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

Giải tham chiếu

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

  • Đọc và ghi giá trị qua con trỏ
  • Phân biệt dấu sao khi khai báo với dấu sao khi giải tham chiếu
  • Nhận ra bốn tình huống giải tham chiếu không hợp lệ
  • Dùng công cụ để bắt lỗi giải tham chiếu

Nếu toán tử lấy địa chỉ là hành động ghi số nhà ra giấy, thì giải tham chiếu là hành động đi tới ngôi nhà đó. Đây là toán tử nguy hiểm nhất trong C, vì nó là chỗ mọi lỗi con trỏ cuối cùng phát nổ.

#Cú pháp và ý nghĩa

Giải tham chiếu
Toán tử * một ngôi đặt trước một con trỏ, cho ra đối tượng mà con trỏ đó đang trỏ tới. Kết quả dùng được cả để đọc lẫn để ghi.
co-ban.c
#include <stdio.h>

int main(void)
{
    int  x = 42;
    int *p = &x;

    printf("%d\n", *p);      /* đọc: in ra 42 */

    *p = 100;                 /* ghi: x giờ bằng 100 */
    printf("%d\n", x);       /* 100 */

    *p += 5;                  /* đọc rồi ghi: x giờ bằng 105 */
    printf("%d\n", x);       /* 105 */

    return 0;
}

Điểm mấu chốt: *pxcùng một vùng nhớ. Không phải hai bản sao đồng bộ với nhau, mà là đúng một chỗ nhìn qua hai cái tên. Mọi thao tác qua *p đều là thao tác trên x.

ViếtNghĩa làKiểu
pđịa chỉ mà con trỏ đang giữint *
*pgiá trị tại địa chỉ đóint
&(*p)quay lại chính pint *
*(&x)quay lại chính xint

#Hai vai trò của dấu sao

Dấu * xuất hiện ở hai chỗ hoàn toàn khác nhau, và nhầm lẫn giữa chúng là nguyên nhân số một khiến người mới thấy con trỏ khó.

Ở đâuNghĩa làVí dụ
Trong khai báoBiến này là con trỏint *p;
Trong biểu thứcĐi tới chỗ con trỏ trỏ tới*p = 42;
Giữa hai toán hạngPhép nhâna * b
int *p = &x;      /* KHAI BÁO: p là con trỏ. Không có phép giải tham chiếu nào. */

int y;

*p = 42;          /* BIỂU THỨC: ghi vào chỗ p trỏ tới */

y = *p * 2;       /* dấu sao đầu là giải tham chiếu, dấu sao sau là nhân */

#Đọc và ghi qua con trỏ

doc-ghi.c
int  a[5] = { 10, 20, 30, 40, 50 };
int *p = a;

/* Đọc */
int x = *p;              /* 10 */
int y = *(p + 2);        /* 30 */
int z = p[2];            /* 30, cùng nghĩa với dòng trên */

/* Ghi */
*p = 99;                 /* a[0] thành 99 */
*(p + 1) = 88;           /* a[1] thành 88 */
p[2] = 77;               /* a[2] thành 77 */

/* Đọc rồi ghi */
(*p)++;                  /* a[0] thành 100 */
*p += 10;                /* a[0] thành 110 */

Mẫu duyệt bằng con trỏ

/* Cách kinh điển, gặp khắp nơi trong mã C */
for (int *p = a; p < a + n; ++p)
    printf("%d ", *p);

/* Với chuỗi thì điều kiện là byte kết thúc */
for (const char *s = chuoi; *s != '\0'; ++s)
    putchar(*s);

/* Cách viết cô đọng của thư viện chuẩn */
while ((*d++ = *s++) != '\0')
    ;

Dòng cuối là thân của strcpy ở Bài 11.5. Giờ bạn đọc được nó: giải tham chiếu cả hai con trỏ, gán, tăng cả hai, rồi so kết quả gán với byte kết thúc.

#Bốn cách giải tham chiếu sai

Trường hợpVí dụTriệu chứng
Con trỏ rỗngint *p = NULL; *p = 1;Sập ngay, thông báo rõ ràng
Con trỏ hoangint *p; *p = 1;Có thể sập, có thể phá dữ liệu chỗ khác
Con trỏ treofree(p); *p = 1;Thường không sập ngay, hỏng dữ liệu
Ra ngoài biênint a[3]; int *p = a + 5; *p = 1;Ghi đè biến khác
bon-loi.c
#include <stdlib.h>

int main(void)
{
    /* 1. Con trỏ rỗng */
    int *a = NULL;
    /* *a = 1; */

    /* 2. Con trỏ hoang */
    int *b;
    /* *b = 1; */

    /* 3. Con trỏ treo */
    int *c = malloc(sizeof *c);
    free(c);
    /* *c = 1; */

    /* 4. Ra ngoài biên */
    int  arr[3] = { 0 };
    int *d = arr + 5;
    /* *d = 1; */

    return 0;
}
terminal
# Bỏ chú thích từng dòng rồi chạy để thấy bốn thông báo khác nhau
gcc -std=c17 -g -fsanitize=address bon-loi.c -o t && ./t
1) SEGV on unknown address 0x000000000000
2) SEGV, dia chi ngau nhien
3) heap-use-after-free
4) stack-buffer-overflow

#Độ ưu tiên với toán tử khác

Biểu thứcNghĩa làVì sao
*p + 1(*p) + 1Toán tử một ngôi ưu tiên cao hơn phép cộng
*(p + 1)phần tử kế tiếpNgoặc đổi thứ tự
*p++*(p++)Hậu tố ưu tiên cao hơn dấu sao
(*p)++tăng giá trịNgoặc đổi thứ tự
*p.x*(p.x)Chấm ưu tiên cao hơn, thường là lỗi
(*p).xtrường x của struct p trỏ tớiViết gọn là p->x
*p[0]*(p[0])Ngoặc vuông ưu tiên cao hơn
Dựa vào độ ưu tiên
int y = *p + 1;
int z = *p++;
int w = *ps.x;        /* lỗi biên dịch nếu ps là con trỏ tới struct */
Dùng ngoặc cho rõ ý
int y = (*p) + 1;
int z = *(p++);
int w = ps->x;        /* dạng viết tắt của (*ps).x */

Bảng độ ưu tiên rút gọn

MứcToán tửKết hợp
Cao nhất() [] . -> ++ hậu tốTrái sang phải
* & ! ~ ++ tiền tố (kiểu)Phải sang trái
* / %Trái sang phải
Thấp hơn+ -Trái sang phải

Bài 4.6 đã lập bảng độ ưu tiên đầy đủ. Điều cần nhớ ở đây là toán tử giải tham chiếu nằm ở mức thứ hai, thấp hơn ngoặc vuông, dấu chấm và hậu tố. Đó là nguồn của gần như mọi bất ngờ trong bảng trên.

Tự làm thử

  1. Viết chương trình đổi giá trị một biến qua con trỏ, in giá trị trước và sau bằng cả tên biến lẫn qua con trỏ.
  2. Chạy thử đủ bốn dạng (*p)++, *p++, *++p, ++*p và điền vào bảng ba cột như trong bài.
  3. Duyệt một mảng bằng cả chỉ số lẫn con trỏ, xác nhận kết quả giống nhau.
  4. Bỏ chú thích từng dòng trong chương trình bốn lỗi, chạy dưới -fsanitize=address và chép lại bốn thông báo khác nhau.
  5. Viết struct hai trường, truy cập trường qua cả (*p).x p->x, rồi thử *p.x và đọc thông báo lỗi.
  6. Tự cài strcpy bằng dòng while ((*d++ = *s++)) và giải thích từng phần của biểu thức đó.

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

  • *p và biến mà p trỏ tới là cùng một vùng nhớ, không phải hai bản sao.
  • Dấu sao trong khai báo và dấu sao trong biểu thức là hai chuyện khác nhau. Trong int *p = &x; không có phép giải tham chiếu nào.
  • (*p)++ tăng giá trị, *p++ tăng con trỏ. Luôn dùng ngoặc khi trộn hai toán tử.
  • Bốn cách giải tham chiếu sai là con trỏ rỗng, hoang, treo và ra ngoài biên. Chỉ loại đầu kiểm tra được bằng if.
  • Dùng p->x thay cho (*p).x, vì *p.x được hiểu là *(p.x) và gần như luôn là lỗi.