Menu

Undefined behavior trong C là gì và vì sao nó nguy hiểm

Undefined behavior là code mà chuẩn C không đặt ra bất kỳ yêu cầu nào, nên chuyện gì cũng có thể xảy ra, kể cả việc trình tối ưu xóa mất các bước kiểm tra của bạn. Trang này giải thích nguyên nhân, vì sao "máy mình chạy được" không chứng minh được gì, và các cờ giúp phát hiện nó.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

"Undefined behavior" nghe như thuật ngữ chỉ "không đoán trước được". Thực ra nó mạnh hơn thế. Khi chuẩn C nói một cấu trúc có undefined behavior (hành vi không xác định), nghĩa là chuẩn không đặt ra bất kỳ yêu cầu nào về việc chương trình sẽ làm gì: không cho giá trị, không cho câu lệnh, không cho cả chương trình.

Đây là phần khiến nhiều người bất ngờ: undefined behavior không chỉ giới hạn ở dòng vi phạm. Trình biên dịch được phép giả định nó không bao giờ xảy ra, và viết lại code xung quanh dựa trên giả định đó. Kết quả có thể là một chương trình mà bước kiểm tra bạn viết rõ ràng lại không hề tồn tại trong tệp nhị phân.

Mô hình hợp đồng

Hãy coi chuẩn C như một hợp đồng giữa bạn và trình biên dịch. Bạn hứa không làm một số việc; đổi lại, trình biên dịch hứa chương trình của bạn có đúng ý nghĩa như những gì nó viết.

Không truy cập ngoài mảng. Không làm tràn số nguyên có dấu. Không đọc giá trị chưa khởi tạo. Không dùng con trỏ sau khi đã free. Không sửa cùng một đối tượng hai lần trong một biểu thức mà không có điểm tuần tự (sequence point) ở giữa.

Vi phạm một điều khoản là hợp đồng mất hiệu lực, cho cả chương trình chứ không chỉ dòng đó. Không có "phương án dự phòng hợp lý" nào và cũng không có yêu cầu phải crash.

Ba thuật ngữ liên quan đáng phân biệt:

  • Undefined behavior: chuyện gì cũng có thể xảy ra. Truy cập ngoài giới hạn, tràn số có dấu, dùng sau khi free.
  • Unspecified behavior: một trong vài kết quả hợp lệ, và trình biên dịch không cần cho bạn biết là kết quả nào. Ví dụ thứ tự tính các đối số của hàm.
  • Implementation-defined behavior: trình biên dịch tự chọn, và phải ghi lại lựa chọn đó trong tài liệu. char có dấu hay không, int lớn bao nhiêu.

Chỉ loại đầu tiên là nguy hiểm theo kiểu "trình tối ưu đã xóa code của mình".

Các nguồn gây ra chính

Tràn số nguyên có dấu

Phép tính không dấu quay vòng, và chuẩn C nói rõ như vậy. Phép tính có dấu thì không: vượt quá phạm vi là không xác định.

Phép kiểm tra if (a > INT_MAX - b) chạy hoàn toàn trong phạm vi hợp lệ, đó là điều khiến nó trở thành một phép kiểm tra tràn số đúng. Viết if (a + b < 0) sẽ thực hiện phép tràn trước rồi mới hỏi về kết quả, và trình biên dịch, vốn được phép giả định tràn số không bao giờ xảy ra, có thể xóa luôn bước kiểm tra.

Truy cập ngoài giới hạn

Đọc hoặc ghi ra ngoài mảng là không xác định, dù chương trình có crash hay không:

int arr[5] = {1, 2, 3, 4, 5};
int x = arr[5];        /* UB: index 5 does not exist */
arr[-1] = 0;           /* UB */
int *p = arr + 10;     /* UB even to compute this pointer */

Chú ý dòng cuối cùng: tạo ra một con trỏ vượt quá cuối mảng hơn một phần tử là không xác định, kể cả khi bạn không bao giờ giải tham chiếu nó. Chuẩn C cho phép arr + 5 (ngay sau cuối mảng, để kết thúc vòng lặp) nhưng không cho phép arr + 6.

Những lần tràn nhỏ mới là nguy hiểm. Chúng thường không gây segfault; chúng âm thầm ghi đè lên một biến bên cạnh, và kết quả sai xuất hiện ở một chỗ chẳng liên quan.

Đọc giá trị chưa khởi tạo

int x;
printf("%d\n", x);     /* UB: reading an indeterminate value */

int *p;
*p = 42;               /* UB: dereferencing an indeterminate pointer */

Rất dễ nghĩ rằng "nó chỉ chứa giá trị rác", nhưng chuẩn C không nói như vậy, và trình biên dịch khai thác sự khác biệt đó. GCC từng kết luận rằng một biến được đọc trước khi gán có thể mang bất kỳ giá trị nào nó muốn, kể cả giá trị khiến một nhánh bị loại bỏ.

Con trỏ treo

int *p = malloc(sizeof *p);
free(p);
*p = 42;               /* UB: use after free */
free(p);               /* UB: double free */

int *q;
{
    int local = 10;
    q = &local;
}
printf("%d\n", *q);    /* UB: the object's lifetime ended */

Hậu quả lúc chạy được trình bày trong trang segmentation fault; điều cần nói ở đây là crash mới là kết cục may mắn.

Sai định dạng trong printf

printf("%d\n", 3.14);        /* UB: %d with a double */
printf("%s\n", 42);          /* UB: %s with an int - usually crashes */
printf("%d %d\n", 1);        /* UB: fewer arguments than specifiers */
long n = 5;
printf("%d\n", n);           /* UB on systems where long is wider than int */

printf là hàm có số đối số thay đổi: nó đọc đối số theo chuỗi định dạng và không thể kiểm tra chúng. Sai lệch khiến nó đọc sai số byte từ sai chỗ. Biên dịch với -Wall và trình biên dịch sẽ kiểm tra chuỗi định dạng giúp bạn: đây là một trong những cảnh báo giá trị nhất trong bộ cờ này.

Sửa một đối tượng hai lần trong một biểu thức

int i = 0;
i = i++ + ++i;             /* UB */
arr[i] = i++;              /* UB */
printf("%d %d\n", i++, i); /* UB */

Những dòng này là không xác định, chứ không chỉ "tùy trình biên dịch". Các câu đố trong sách hỏi i = i++ + ++i cho ra giá trị bao nhiêu không có câu trả lời đúng.

Strict aliasing

Truy cập một đối tượng qua con trỏ thuộc kiểu không tương thích là không xác định, và điều này làm cả lập trình viên giàu kinh nghiệm bất ngờ:

float f = 1.0f;
int *p = (int *)&f;
printf("%d\n", *p);        /* UB: reading a float through an int * */

Trình biên dịch giả định một int * và một float * không bao giờ trỏ vào cùng vùng nhớ, và sắp xếp lại lệnh theo giả định đó. Cách được định nghĩa để diễn giải lại các byte là memcpy (được tối ưu thành đúng những lệnh máy đó) hoặc một union:

char * là ngoại lệ: bạn luôn được phép xem các byte của bất kỳ đối tượng nào qua unsigned char *.

Vì sao "máy mình chạy được" không chứng minh được gì

Undefined behavior thường trông như chạy đúng, và chính điều đó khiến nó nguy hiểm. Chương trình chạy đúng suốt quá trình phát triển và kiểm thử, rồi hỏng khi một thứ hoàn toàn không liên quan thay đổi:

  • Một phiên bản trình biên dịch mới với trình tối ưu thông minh hơn.
  • Chuyển từ -O0 sang -O2 cho bản phát hành.
  • Thêm một hàm không liên quan, làm dịch chuyển bố cục stack khiến một lần tràn giờ rơi vào thứ quan trọng.
  • Một máy khác, một libc khác, một hệ điều hành khác.

Việc trông như chạy được không phải bằng chứng của tính đúng đắn, vì chuẩn C chưa bao giờ hứa gì. Đó là một bug đang ngủ yên, và thứ đánh thức nó thường là bản build phát hành.

Trình tối ưu khai thác nó như thế nào

Đây là ví dụ thường khép lại mọi tranh luận. Một lập trình viên viết bước kiểm tra null:

void process(int *p) {
    int value = *p;              /* dereference */
    if (p == NULL) {             /* then check for null */
        return;
    }
    printf("%d\n", value * 2);
}

Thứ tự bị sai, bước kiểm tra nằm sau phép giải tham chiếu, nhưng chắc chắn bước kiểm tra vẫn chạy chứ?

Không nhất thiết. Trình biên dịch lập luận: *p đã được giải tham chiếu, nên p không thể là NULL (giải tham chiếu NULL là không xác định, nên trong mọi chương trình có hành vi xác định thì nó không phải NULL), nên p == NULL luôn sai, nên toàn bộ thân if là code chết và có thể xóa đi.

Hàm sau khi biên dịch hoàn toàn không có bước kiểm tra null nào. Một trường hợp thật của mẫu này trong nhân Linux đã trở thành CVE-2009-1897, khi GCC xóa đúng một bước kiểm tra như vậy và biến một lỗi sắp xếp thứ tự tưởng như vô hại thành một lỗ hổng có thể bị khai thác.

Ví dụ thứ hai, nhỏ hơn:

/* An overflow check that does not work */
int safe_add(int a, int b) {
    int sum = a + b;
    if (sum < a) {          /* "did it wrap?" */
        return -1;
    }
    return sum;
}

Với kiểu có dấu, trình biên dịch có thể giả định a + b không tràn, khi đó sum < a chỉ có thể xảy ra khi b < 0. Với giả định đó, bước kiểm tra kiểm tra một điều khác với ý tác giả, và với b >= 0 nó có thể bị tối ưu bỏ hoàn toàn. Phiên bản đúng sẽ kiểm tra trước:

Cả hai phép kiểm tra đều nằm trong phạm vi biểu diễn được, nên không bao giờ xảy ra tràn số và trình tối ưu không có gì để giả định mà bỏ đi. (GCC và clang còn cung cấp __builtin_add_overflow, làm việc này chỉ trong một lệnh máy.)

Phát hiện undefined behavior

Trước tiên là cảnh báo tĩnh, chúng miễn phí:

gcc -Wall -Wextra -Wpedantic program.c -o program

Lệnh đó bắt được sai lệch chuỗi định dạng, một số lần đọc biến chưa khởi tạo, các phép so sánh đáng ngờ và code không bao giờ chạy tới.

Tiếp theo là các sanitizer, chúng gắn thêm code kiểm tra vào chương trình và báo lỗi ngay lúc vi phạm xảy ra:

gcc -g -fsanitize=undefined program.c -o program
./program
program.c:8:15: runtime error: signed integer overflow:
2147483647 + 1 cannot be represented in type 'int'

Kết hợp với AddressSanitizer cho phần liên quan đến bộ nhớ:

gcc -g -Wall -Wextra -fsanitize=address,undefined program.c -o program

Cùng nhau, chúng bắt được truy cập ngoài giới hạn, use-after-free, free hai lần, tràn số có dấu, phép dịch bit không hợp lệ, con trỏ lệch căn chỉnh và giải tham chiếu null, mỗi lỗi kèm tên tệp, số dòng và stack trace. Chương trình chậm đi khoảng 2 lần, chẳng đáng kể gì trong lúc phát triển.

valgrind ./program không cần biên dịch lại và bắt được việc đọc biến chưa khởi tạo cùng các lỗi bộ nhớ, nhưng không bắt UB số học. clang --analyze và gcc -fanalyzer tìm ra một phần mà không cần chạy chương trình.

Quy tắc thực tế: chạy test dưới sanitizer trong CI. UB trông như chạy được trên máy bạn chính là thứ mà chúng sinh ra để phơi bày.

Sống chung với nó

Bạn không thể tránh undefined behavior chỉ bằng sự cẩn thận: ai rồi cũng sẽ viết ra nó. Điều hiệu quả là khiến nó lộ diện rõ ràng:

  • Biên dịch với -Wall -Wextra ngay từ ngày đầu, và sửa mọi cảnh báo.
  • Chạy test dưới -fsanitize=address,undefined.
  • Khởi tạo mọi biến ngay khi khai báo, và mọi con trỏ bằng NULL.
  • Kiểm tra chỉ số mảng so với độ dài, và viết vòng lặp với i < n.
  • Kiểm tra tràn số trước phép tính, dùng <limits.h>.
  • Gán con trỏ bằng NULL sau khi free.
  • Dùng kiểu unsigned khi quay vòng là hành vi mong muốn: ở đó nó được định nghĩa.
  • Ưu tiên memcpy thay vì ép kiểu con trỏ khi cần diễn giải lại các byte.

Tốc độ của C đến từ việc trình biên dịch được phép giả định bạn đã giữ đúng hợp đồng. Đó là một sự đánh đổi thực sự, không phải lỗi thiết kế, và các công cụ ở trên trả lại cho bạn gần như toàn bộ sự an toàn với chi phí gần như bằng không trong lúc phát triển.

Về biểu hiện lúc chạy của những quy tắc này, xem trang segmentation fault; về những lỗi lúc biên dịch thường gặp trước đó, xem trang lỗi thường gặp.

Câu hỏi thường gặp

Undefined behavior trong C là gì?

Là code mà chuẩn C không đặt ra bất kỳ yêu cầu nào. Trình biên dịch được tự do tạo ra bất cứ thứ gì: crash, kết quả sai, code trông như chạy đúng, hoặc code mà nhánh vi phạm bị xóa hẳn. Nó không phải "do trình biên dịch tự định nghĩa" hay "ngẫu nhiên": đó là một hợp đồng bạn đã vi phạm, và sau đó không có gì được đảm bảo nữa.

Vì sao C có undefined behavior thay vì định nghĩa mọi thứ?

Vì tốc độ và tính khả chuyển. Bắt buộc kiểm tra giới hạn ở mọi lần truy cập mảng sẽ tốn hiệu năng mà C được thiết kế để không phải trả; định nghĩa tràn số có dấu là quay vòng sẽ buộc thêm lệnh máy trên phần cứng vốn báo lỗi thay vì quay vòng. Để những trường hợp đó không xác định cho phép trình biên dịch giả định chúng không bao giờ xảy ra và tối ưu theo đó.

Tràn số nguyên có dấu có phải undefined behavior trong C không?

Có. INT_MAX + 1 là không xác định, nó không được đảm bảo quay vòng về INT_MIN. Tràn số không dấu thì khác: nó được định nghĩa đầy đủ và quay vòng theo modulo 2^N. Đó là lý do trình biên dịch có thể giả định x + 1 > x luôn đúng với x có dấu, và xóa một bước kiểm tra tràn số viết theo cách đó.

Làm sao phát hiện undefined behavior trong chương trình C?

Biên dịch với -Wall -Wextra để bắt những gì trình biên dịch thấy được khi phân tích tĩnh, rồi chạy test với -fsanitize=address,undefined: công cụ này báo truy cập ngoài giới hạn, use-after-free, tràn số có dấu và nhiều lỗi khác ngay lúc chúng xảy ra, kèm tên tệp và số dòng. valgrind bắt được một tập lỗi tương tự mà không cần biên dịch lại.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu