Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 14.1118 phút đọc

Con trỏ treo

Sau bài này bạn sẽ làm được

  • Nhận ra ba nguồn gốc của con trỏ treo
  • Hiểu vì sao chương trình vẫn chạy đúng một thời gian
  • Dùng công cụ để bắt truy cập sau khi giải phóng
  • Thiết kế để một vùng nhớ chỉ có một chủ sở hữu

Rò rỉ bộ nhớ là mất con trỏ mà còn vùng nhớ. Con trỏ treo là ngược lại: còn con trỏ mà mất vùng nhớ. Trong hai loại, con trỏ treo nguy hiểm hơn hẳn, vì rò rỉ chỉ làm chậm còn con trỏ treo làm sai.

#Con trỏ treo là gì

Con trỏ treo
Con trỏ từng hợp lệ nhưng vùng nhớ nó trỏ tới đã bị thu hồi. Bản thân con trỏ không đổi, nó vẫn giữ nguyên địa chỉ cũ, chỉ có điều địa chỉ đó không còn thuộc về bạn.
treo.c
int *p = malloc(sizeof *p);

if (p == NULL) return 1;

*p = 42;

free(p);        /* vùng nhớ đã trả lại, nhưng p VẪN giữ địa chỉ cũ */

printf("%d\n", *p);   /* hành vi không xác định */
Rò rỉ bộ nhớCon trỏ treo
Còn con trỏKhông
Vùng nhớ còn thuộc về bạnKhông
Triệu chứngBộ nhớ tăng dầnDữ liệu sai, hoặc sập
Phát hiện tự độngDễ, công cụ báo rõKhó hơn, cần trình dò lỗi
Mức nguy hiểmLàm chậmLàm sai, và là lỗ hổng bảo mật

#Ba nguồn gốc

Nguồn 1: dùng sau khi giải phóng

char *s = malloc(64);

free(s);

strcpy(s, "abc");      /* ghi vào vùng đã trả lại */

/* Trường hợp tinh vi hơn: một con trỏ khác cùng trỏ vào đó */
char *a = malloc(64);
char *b = a;

free(a);
a = NULL;              /* a an toàn rồi */

b[0] = 'x';            /* nhưng b vẫn treo */

Nguồn 2: trả về địa chỉ biến cục bộ

cuc-bo.c
int *sai(void)
{
    int x = 42;

    return &x;      /* x chết khi hàm kết thúc */
}

/* Cũng vậy với mảng cục bộ */
char *ten_sai(void)
{
    char bo_dem[64];

    snprintf(bo_dem, sizeof bo_dem, "xin chao");

    return bo_dem;   /* bo_dem phân rã thành con trỏ, và nó chết ngay */
}
terminal
gcc -std=c17 -Wall cuc-bo.c -o t
cuc-bo.c:5:12: warning: function returns address of local variable [-Wreturn-local-addr]
cuc-bo.c:14:12: warning: function returns address of local variable [-Wreturn-local-addr]

Nguồn 3: con trỏ sống lâu hơn vùng nhớ nó trỏ tới

pham-vi.c
int *p;

{
    int a[5] = { 1, 2, 3, 4, 5 };

    p = a;
}                       /* a ra khỏi phạm vi, p thành con trỏ treo */

printf("%d\n", p[0]);

/* Biến thể trong vòng lặp */
int *q;

for (int i = 0; i < 3; ++i) {
    int tam = i;

    q = &tam;           /* tam mới mỗi lượt và chết mỗi lượt */
}

printf("%d\n", *q);

Cùng vấn đề với các tài nguyên khác

FILE *f = fopen("a.txt", "r");

fclose(f);

fgets(bo_dem, sizeof bo_dem, f);      /* f treo, cùng loại lỗi */

/* Và với chỉ số của cấu trúc dữ liệu */
size_t i = tim_vi_tri(ds, "abc");

ds_xoa(ds, 0);                        /* mọi phần tử dịch lên một chỗ */

dung(ds->phan_tu[i]);                 /* i giờ trỏ sai phần tử */

Trường hợp cuối cho thấy vấn đề rộng hơn con trỏ: bất kỳ tham chiếu nào tới một đối tượng đều có thể hết hạn khi đối tượng đó bị hủy hoặc bị di chuyển. Chỉ số an toàn hơn con trỏ với mảng động, nhưng không an toàn với danh sách bị xóa phần tử ở giữa.

#Vì sao nó khó phát hiện

van-chay.c
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int *p = malloc(sizeof *p);

    if (p == NULL) return 1;

    *p = 42;

    free(p);

    printf("%d\n", *p);      /* thường vẫn in ra 42 */

    int *q = malloc(sizeof *q);   /* rất có thể nhận lại đúng khối đó */

    *q = 99;

    printf("%d\n", *p);      /* giờ in 99, vì p và q cùng trỏ một chỗ */

    free(q);

    return 0;
}
terminal
./van-chay
42
99

#Phát hiện bằng công cụ

terminal
gcc -std=c17 -g -fsanitize=address van-chay.c -o t && ./t
==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
READ of size 4 at 0x602000000010 thread T0
    #0 0x109205 in main van-chay.c:14

0x602000000010 is located 0 bytes inside of 4-byte region [0x602000000010,0x602000000014)
freed by thread T0 here:
    #0 0x7f2a in free
    #1 0x1091f0 in main van-chay.c:12

previously allocated by thread T0 here:
    #0 0x7f2a in malloc
    #1 0x109195 in main van-chay.c:6

Cách trình dò lỗi làm được điều đó

Kỹ thuậtChi tiết
Khu cách lyKhối vừa giải phóng không được cấp lại ngay, mà giữ trong hàng đợi một thời gian
Vùng đỏChèn vùng đệm quanh mỗi khối, mọi truy cập vào đó đều bị bắt
Bản đồ bóngMột byte bóng cho mỗi tám byte thật, ghi lại byte nào hợp lệ
Lưu vết ngăn xếpGhi lại nơi cấp phát và nơi giải phóng của từng khối
terminal
# Tăng thời gian giữ trong khu cách ly để bắt được lỗi xa hơn
ASAN_OPTIONS=quarantine_size_mb=256 ./t
# Valgrind cũng bắt được, không cần biên dịch lại
valgrind ./van-chay
==5678== Invalid read of size 4
==5678==    at 0x109205: main (van-chay.c:14)
==5678==  Address 0x4a9d040 is 0 bytes inside a block of size 4 free'd
==5678==    at 0x484B27F: free
==5678==    by 0x1091F0: main (van-chay.c:12)
# Trình phân tích tĩnh tìm được mà không cần chạy
gcc -fanalyzer -std=c17 van-chay.c -o t
van-chay.c:14:20: warning: use after 'free' of 'p' [CWE-416] [-Wanalyzer-use-after-free]

#Bốn cách phòng tránh

Cách 1: đặt NULL ngay sau khi giải phóng

free(p);
p = NULL;

/* Hoặc gói lại để không bao giờ quên */
#define GIAI_PHONG(p)  do { free(p); (p) = NULL; } while (0)

Cách này biến con trỏ treo thành con trỏ rỗng, tức là biến một lỗi im lặng thành một lỗi ồn ào. Nó không giải quyết được trường hợp có nhiều con trỏ, nhưng vẫn đáng làm vì rẻ.

Cách 2: một vùng nhớ chỉ có một chủ

Nhiều nơi cùng giữ con trỏ, không rõ ai giải phóng
typedef struct { char *ten; } A;
typedef struct { char *ten; } B;

char *s = malloc(64);

A a = { s };
B b = { s };      /* hai struct cùng trỏ vào s */

free(a.ten);      /* b.ten giờ treo, và không ai biết */
Một chủ sở hữu rõ ràng
/* A sở hữu chuỗi, B chỉ mượn để đọc. Ghi rõ trong khai báo. */
typedef struct {
    char *ten;              /* SỞ HỮU, phải free trong a_huy */
} A;

typedef struct {
    const char *ten;        /* MƯỢN, không được free, chỉ hợp lệ khi A còn sống */
} B;

void a_huy(A *a)
{
    free(a->ten);
    a->ten = NULL;
}

Cách 3: dùng chỉ số thay cho con trỏ

chi-so.c
typedef struct {
    int   *du_lieu;
    size_t n, suc_chua;
} MangDong;

/* SAI: con trỏ hết hạn sau mỗi lần nới rộng */
int *p = &m.du_lieu[5];

md_them(&m, 42);

*p = 1;                    /* có thể treo */

/* ĐÚNG: chỉ số luôn hợp lệ */
size_t i = 5;

md_them(&m, 42);

m.du_lieu[i] = 1;          /* luôn đúng */

Cách 4: đếm tham chiếu khi buộc phải chia sẻ

dem-tham-chieu.c
#include <stdlib.h>
#include <string.h>

typedef struct {
    size_t so_chu;      /* số nơi đang giữ con trỏ này */
    char   du_lieu[];   /* mảng thành viên linh hoạt */
} Chuoi;

Chuoi *chuoi_tao(const char *s)
{
    size_t n = strlen(s) + 1;
    Chuoi *c = malloc(sizeof *c + n);

    if (c == NULL) return NULL;

    c->so_chu = 1;
    memcpy(c->du_lieu, s, n);

    return c;
}

/* Nhận thêm một tham chiếu */
Chuoi *chuoi_giu(Chuoi *c)
{
    if (c != NULL) c->so_chu++;

    return c;
}

/* Trả lại tham chiếu. Chỉ giải phóng khi không còn ai giữ. */
void chuoi_tha(Chuoi *c)
{
    if (c == NULL) return;

    if (--c->so_chu == 0)
        free(c);
}
dung.c
Chuoi *a = chuoi_tao("xin chao");
Chuoi *b = chuoi_giu(a);         /* so_chu thành 2 */

chuoi_tha(a);                    /* so_chu thành 1, chưa giải phóng */
a = NULL;

printf("%s\n", b->du_lieu);     /* vẫn hợp lệ */

chuoi_tha(b);                    /* so_chu thành 0, giải phóng thật */
b = NULL;

Tự làm thử

  1. Viết chương trình đọc qua con trỏ ngay sau free, chạy thường và chạy với -fsanitize=address.
  2. Tái hiện đủ ba giai đoạn của con trỏ treo trong một chương trình, ghi lại kết quả từng giai đoạn.
  3. Viết hàm trả về địa chỉ mảng cục bộ, đọc cảnh báo của trình biên dịch rồi sửa bằng ba cách khác nhau.
  4. Lưu con trỏ vào phần tử của mảng động, nới rộng mảng, rồi ghi qua con trỏ đó. Chạy nhiều lần để thấy nó lúc đúng lúc sai.
  5. Cài cấu trúc đếm tham chiếu trong bài, kiểm chứng bằng valgrind rằng không rò rỉ và không dùng sau khi giải phóng.
  6. Tạo tham chiếu vòng giữa hai đối tượng đếm tham chiếu, xác nhận cả hai đều rò rỉ.
  7. Rà một chương trình cũ, đánh dấu mọi con trỏ là sở hữu hoặc mượn bằng const, xem có chỗ nào không rõ ràng không.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Con trỏ treo là còn con trỏ mà mất vùng nhớ, ngược với rò rỉ là còn vùng nhớ mà mất con trỏ.
  • Ba nguồn gốc: dùng sau khi giải phóng, trả về địa chỉ biến cục bộ, và con trỏ sống lâu hơn vùng nó trỏ tới.
  • Nó khó phát hiện vì giai đoạn đầu chương trình vẫn chạy đúng, và lỗi lộ ra ở nơi hoàn toàn khác nơi gây ra.
  • -fsanitize=address cho bạn cả ba mốc: nơi truy cập sai, nơi giải phóng và nơi cấp phát.
  • Cách phòng tốt nhất là thiết kế để mỗi vùng nhớ có đúng một chủ sở hữu, và chỉ dùng đếm tham chiếu khi thật sự cần chia sẻ.