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

Con trỏ

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

  • Khai báo và khởi tạo con trỏ đúng cách
  • Phân biệt con trỏ rỗng, con trỏ hoang và con trỏ treo
  • Giải thích vì sao mọi con trỏ đều rộng như nhau nhưng kiểu vẫn quan trọng
  • Luôn khởi tạo con trỏ ngay khi khai báo

Con trỏ là một biến bình thường. Nó có địa chỉ, có kích thước, có giá trị. Điểm khác biệt duy nhất là giá trị của nó tình cờ là địa chỉ của một vùng nhớ khác. Hiểu con trỏ như một biến bình thường sẽ gỡ được phần lớn khó khăn.

#Khai báo con trỏ

int    *p;      /* p là con trỏ tới int */
double *q;      /* q là con trỏ tới double */
char   *s;      /* s là con trỏ tới char */
Con trỏ
Một biến có giá trị là địa chỉ của một đối tượng khác. Kiểu của nó ghi rõ đối tượng đó thuộc kiểu gì.

Khởi tạo

khoi-tao.c
int n = 42;

int *p1 = &n;        /* trỏ tới một biến có sẵn */
int *p2 = NULL;      /* con trỏ rỗng, chưa trỏ đi đâu */
int *p3;             /* NGUY HIỂM: chứa rác, không biết trỏ đi đâu */

int *p4 = malloc(sizeof *p4);   /* trỏ tới vùng vừa cấp, Chương 14 */

int a[5];
int *p5 = a;         /* trỏ tới phần tử đầu của mảng, do phân rã */
int *p6 = &a[2];     /* trỏ tới phần tử thứ ba */

Mọi con trỏ đều rộng như nhau

kich-thuoc.c
#include <stdio.h>

int main(void)
{
    printf("%zu %zu %zu %zu\n",
           sizeof(char *), sizeof(int *),
           sizeof(double *), sizeof(void *));

    return 0;
}
terminal
./kich-thuoc
8 8 8 8      # trên máy 64 bit
gcc -m32 kich-thuoc.c -o k32 && ./k32
4 4 4 4      # trên máy 32 bit

Con trỏ chứa một địa chỉ, và mọi địa chỉ đều rộng như nhau. Vậy nếu kích thước giống nhau thì kiểu của con trỏ để làm gì. Đó là câu hỏi của mục sau.

#Sơ đồ và ba biểu thức

Biến p là một ô nhớ riêng, có địa chỉ riêng, và giá trị của nó là địa chỉ của x.
Biểu thứcĐọc làKiểuGiá trị trong sơ đồ
xgiá trị của xint42
&xđịa chỉ của xint *0x7ffd3a10
pgiá trị của pint *0x7ffd3a10
&pđịa chỉ của pint **0x7ffd3a18
*pgiá trị tại chỗ p trỏint42
ba-bieu-thuc.c
#include <stdio.h>

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

    printf("x   = %d\n", x);
    printf("&x  = %p\n", (void *)&x);
    printf("p   = %p\n", (void *)p);
    printf("&p  = %p\n", (void *)&p);
    printf("*p  = %d\n", *p);

    printf("p == &x  : %d\n", p == &x);      /* 1 */
    printf("*p == x  : %d\n", *p == x);      /* 1 */

    return 0;
}

#Vì sao kiểu con trỏ quan trọng

Mọi con trỏ đều rộng 8 byte, nhưng kiểu vẫn quyết định ba chuyện, và cả ba đều quan trọng.

Kiểu quyết địnhVí dụ với int * và char *
Đọc bao nhiêu byte khi giải tham chiếuint * đọc 4 byte, char * đọc 1 byte
Bước nhảy của số học con trỏp + 1 với int * nhảy 4 byte, với char * nhảy 1 byte
Diễn giải các byte đó thành giá trị gìCùng bốn byte, int * thấy số nguyên, float * thấy số thực
cung-dia-chi.c
#include <stdio.h>

int main(void)
{
    int   n = 1000000;
    int  *pi = &n;
    char *pc = (char *)&n;      /* cùng địa chỉ, kiểu khác */

    printf("pi == pc : %d\n", (void *)pi == (void *)pc);   /* 1, cùng địa chỉ */

    printf("*pi = %d\n", *pi);          /* 1000000, đọc 4 byte */
    printf("*pc = %d\n", *pc);          /* 64, chỉ đọc 1 byte đầu */

    printf("pi + 1 cach pi %td byte\n",
           (char *)(pi + 1) - (char *)pi);      /* 4 */
    printf("pc + 1 cach pc %td byte\n",
           (pc + 1) - pc);                      /* 1 */

    return 0;
}
terminal
./cung-dia-chi
pi == pc : 1
*pi = 1000000
*pc = 64
pi + 1 cach pi 4 byte
pc + 1 cach pc 1 byte

#Ba loại con trỏ nguy hiểm

LoạiNghĩa làPhát hiện thế nào
Con trỏ rỗngBằng NULL, cố tình không trỏ đi đâuKiểm tra được bằng if, giải tham chiếu thì sập ngay
Con trỏ hoangChưa khởi tạo, chứa rácKhông kiểm tra được, đây là loại tệ nhất
Con trỏ treoTừng hợp lệ, nhưng vùng nhớ đã bị thu hồiKhông kiểm tra được, cần công cụ dò lỗi

Con trỏ rỗng

int *p = NULL;

if (p != NULL)
    printf("%d\n", *p);      /* an toàn: chỉ dùng khi khác NULL */

printf("%d\n", *p);          /* sập ngay, có thông báo rõ ràng */

Đây là loại an toàn nhất trong ba loại, vì nó kiểm tra được và vì giải tham chiếu nó gần như luôn làm chương trình chết ngay tại chỗ gây lỗi. Hệ điều hành cố tình không ánh xạ trang nhớ ở địa chỉ 0 đúng để tạo ra hành vi này.

Con trỏ hoang

void f(void)
{
    int *p;         /* chứa rác từ lời gọi hàm trước */

    *p = 42;        /* ghi vào một địa chỉ ngẫu nhiên */
}

Con trỏ treo

treo.c
/* Cách 1: trả về địa chỉ biến cục bộ */
int *sai(void)
{
    int x = 42;

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

/* Cách 2: dùng sau khi giải phóng */
int *p = malloc(sizeof *p);

free(p);
*p = 42;            /* p vẫn giữ địa chỉ cũ nhưng vùng đó đã bị thu hồi */

/* Cách 3: con trỏ tới phần tử của mảng đã ra khỏi phạm vi */
int *q;

{
    int a[5];

    q = a;
}

*q = 1;             /* a không còn tồn tại */
terminal
gcc -std=c17 -Wall -g -fsanitize=address treo.c -o t && ./t
ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
WRITE of size 4
    #0 in main treo.c:13

0x602000000010 is located 0 bytes inside of 4-byte region
freed by thread T0 here:
    #0 in free
    #1 in main treo.c:12

#Bốn quy tắc bắt buộc

Bốn cách viết nguy hiểm
int *p;                    /* 1. không khởi tạo */

*p = 42;                   /* 2. dùng mà không kiểm tra */

int *q = malloc(100);
*q = 1;                    /* 3. không kiểm tra kết quả malloc */

free(q);
printf("%d\n", *q);        /* 4. dùng sau khi giải phóng */
Bốn quy tắc thay thế
int *p = NULL;             /* 1. luôn khởi tạo */

if (p != NULL) *p = 42;    /* 2. luôn kiểm tra trước khi giải tham chiếu */

int *q = malloc(100);

if (q == NULL) return -1;  /* 3. luôn kiểm tra kết quả cấp phát */

*q = 1;

free(q);
q = NULL;                  /* 4. đặt NULL ngay sau khi giải phóng */

Tự làm thử

  1. Khai báo một biến và một con trỏ tới nó, in đủ năm biểu thức trong bảng, đối chiếu với sơ đồ.
  2. In sizeof của con trỏ tới bốn kiểu khác nhau, xác nhận chúng bằng nhau.
  3. Trỏ cả int *char * vào cùng một biến, in giá trị qua cả hai và giải thích chênh lệch.
  4. Viết ba chương trình tạo ra ba loại con trỏ nguy hiểm, chạy dưới -fsanitize=address và so sánh thông báo.
  5. Khai báo int *a, b; rồi thử gán b = &n. Đọc thông báo lỗi và giải thích.
  6. Viết hàm trả về địa chỉ biến cục bộ, gọi nó rồi gọi một hàm khác, in giá trị và quan sát nó bị đè.

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ỏ là biến bình thường, chỉ khác ở chỗ giá trị của nó là một địa chỉ.
  • Dấu sao trong khai báo gắn với biến chứ không gắn với kiểu, nên int *a, b; chỉ có a là con trỏ.
  • Mọi con trỏ đều rộng như nhau, nhưng kiểu quyết định đọc mấy byte, nhảy mấy byte và diễn giải thế nào.
  • Ba loại nguy hiểm là con trỏ rỗng, con trỏ hoang và con trỏ treo. Con trỏ rỗng là loại an toàn nhất vì kiểm tra được.
  • Bốn quy tắc: luôn khởi tạo, luôn kiểm tra trước khi giải tham chiếu, luôn kiểm tra kết quả cấp phát, và đặt NULL ngay sau khi giải phóng.