Hình dung bạn ký với một nhà thầu cực kỳ máy móc một bản cam kết: "Tôi cam đoan khu đất này không bao giờ ngập." Nhà thầu tin tuyệt đối. Anh ta lên kế hoạch dựa trên lời hứa đó — và nếu bạn lỡ yêu cầu lắp thêm một cái máy bơm chống ngập, anh ta tháo nó ra, lý luận rất tỉnh: "Anh đã cam kết không ngập rồi, cái bơm là thừa." Cái máy bơm bạn lắp để đề phòng chính cái điều bạn đã hứa sẽ không xảy ra — bị gỡ đúng theo hợp đồng. Hành vi không xác định (undefined behavior — UB) trong C vận hành đúng như bản cam kết đó, và đây là mặt của nó mà tôi đã hiểu sai suốt một thời gian dài.

Ở bài strict aliasing ta gặp một quy tắc mà vi phạm là UB, và compiler tối ưu dựa trên giả định bạn không vi phạm. Bài này lùi lại một bước để nhìn UB như một công cụ của trình biên dịch nói chung. Câu hỏi tôi mang vào: "UB mở khóa tối ưu" nghĩa là gì, và đo được không? Tôi đã đoán sai chỗ cái lợi hiện ra — và cái đo được lại đáng sợ hơn tôi tưởng. Đo trong container gcc:13 (ARM).

UB là lời hứa, không phải tai nạn

Chuẩn C liệt kê một loạt việc là hành vi không xác định: tràn số nguyên có dấu, giải tham chiếu (dereference) con trỏ null, đọc ngoài biên mảng, dùng biến chưa khởi tạo, chia cho không. Cách hiểu phổ biến — và là cách tôi từng hiểu — là "nếu chạm UB thì chương trình crash, hoặc cho ra giá trị rác, nhưng nếu mình cẩn thận thì không sao".

Cách hiểu đó bỏ sót nửa quan trọng. UB không chỉ nói về chuyện gì xảy ra khi bạn vi phạm; nó là một lời hứa theo chiều ngược lại. Chuẩn tuyên bố những việc đó là UB chính là để cho phép compiler giả định chúng không bao giờ xảy ra. Compiler tin lời hứa ấy tuyệt đối, rồi mọi suy luận dựa trên nó đều hợp lệ. Đây mới là chỗ "mở khóa tối ưu": vì được giả định i không tràn, p không null, chỉ số không vượt biên, compiler có thêm sự thật để rút gọn code. Đúng nhà thầu tin bản cam kết rồi bỏ mọi thứ mà lời hứa khiến thành thừa.

Cú vấp: tôi tìm cái lợi ở sai chỗ

Tôi vào bài với một giả thuyết gọn gàng: nếu UB "mở khóa tối ưu", thì code phải chạy nhanh hơn. Ví dụ kinh điển tôi nhắm tới là biến đếm vòng lặp. Tràn số nguyên có dấu là UB, nên với int i, compiler được giả định i không bao giờ quấn vòng — nó có thể mở rộng biến quy nạp lên 64-bit thoải mái. Còn unsigned i thì tràn được định nghĩa là quấn vòng theo 2^32, nên compiler phải giữ đúng ngữ nghĩa đó, dè dặt hơn. Tôi đoán: vòng int sẽ vector hóa mạnh hơn và nhanh hơn vòng unsigned.

Tôi viết hai hàm cộng một mảng 200 triệu số, một dùng int làm chỉ số, một dùng unsigned, biên dịch -O2, đo thời gian tốt nhất qua nhiều lần (đổi một phần tử mảng giữa các lần để chặn compiler nâng kết quả ra ngoài vòng — bài học hoisting từ đo lường vi mô đúng cách):

signed   : 0,246 ns/phần tử
unsigned : 0,245 ns/phần tử

Giống hệt. Với một vòng cộng đơn giản, gcc -O2 vector hóa cả hai y như nhau; compiler thừa sức chứng minh n nằm trong khoảng an toàn nên unsigned cũng không phải trả giá gì. Giả thuyết "signed nhanh hơn" của tôi sai với ca này.

Nhưng chính khi con số thời gian không nhúc nhích, tôi mới nhìn đúng chỗ. Cái "tối ưu" mà UB mở khóa ở đây không nằm ở đồng hồ, mà nằm ở mã — và nó không phải làm code nhanh hơn, mà là làm compiler xóa những nhánh tôi tự tay viết ra. Đó mới là mặt đáng đo, và đáng sợ.

Compiler xóa kiểm tra null của bạn

Xét một hàm rất đời thường — đọc giá trị qua con trỏ, rồi (lỡ) kiểm tra null sau đó:

int deref_then_check(int *p){
  int x = *p;
  if(p == 0) return -1;   // kiểm tra null... nhưng đã muộn
  return x;
}

Dòng int x = *p; đã giải tham chiếu p. Nếu p là null thì việc đó đã là UB rồi. Compiler suy luận: "chương trình đúng thì không bao giờ chạm UB, nên tới được dòng if nghĩa là p chắc chắn khác null — vậy nhánh return -1 không đời nào chạy." Nó xóa luôn. Đây là mã -O2 sinh ra (AArch64):

deref_then_check:
    ldr  w0, [x0]    // đọc *p
    ret              // trả về. Hết. Không có so sánh nào với 0.

Cả câu if(p == 0) biến mất — không một lệnh so sánh. Cùng hàm đó ở -O0 có 2 lệnh cmp/nhánh cho phép kiểm tra. Ở -O2, kiểm tra null bạn viết bị bốc hơi, vì bạn đã "hứa" (bằng cách deref trước) rằng p không null.

Compiler xóa kiểm tra tràn của bạn

Ca thứ hai còn thẳng vào bảo mật hơn. Một cách kiểm tràn thủ công quen thuộc: cộng xong thì xem tổng có nhỏ hơn số ban đầu không (nếu nhỏ hơn tức là đã quấn vòng):

int overflow_check(int a){
  int s = a + 100;
  if(s < a) return 1;   // "đã tràn"
  return 0;
}

Với int, tràn số nguyên có dấu là UB, nên compiler giả định a + 100 không bao giờ tràn. Mà nếu không tràn thì s = a + 100 luôn lớn hơn a, tức s < a luôn sai. Nhánh return 1 không đời nào chạy. Mã -O2:

overflow_check:
    mov  w0, 0     // luôn trả về 0
    ret

Hàm kiểm tràn của bạn giờ là hàm luôn nói "không tràn". Đúng cái nó phải bắt thì nó mù — cái máy bơm chống ngập bị tháo mất. So với cùng code biên dịch thêm cờ -fwrapv (bảo compiler định nghĩa tràn có dấu thành quấn vòng, y như unsigned):

overflow_check:              // với -fwrapv
    mov  w1, 0x7fffff9b       // = INT_MAX - 100
    cmp  w0, w1
    cset w0, gt               // trả 1 khi a > INT_MAX-100
    ret

Với -fwrapv, kiểm tra được giữ lại và biên dịch đúng thành "a có lớn hơn INT_MAX - 100 không" — chính xác điều kiện tràn. Cùng một dòng C, hai kết quả trái ngược, chỉ vì một cờ đổi UB thành hành vi xác định.

Chạy thật với a = INT_MAX cho thấy hậu quả không phải lý thuyết:

-O2            : overflow_check(INT_MAX) = 0   (lọt: nói không tràn)
-O2 -fwrapv    : overflow_check(INT_MAX) = 1   (bắt được)
-O2 -fsanitize=undefined:
    runtime error: signed integer overflow:
    2147483647 + 100 cannot be represented in type 'int'

Bản -O2 mặc định để tràn lọt qua im lặng — không crash, không cảnh báo, chỉ trả 0 sai. Đây từng là một lớp lỗ hổng thật trong phần mềm C thật: một kiểm tra biên if(len + n < len) bị compiler xóa, và cái tràn mà nó lẽ ra chặn trở thành lỗ tràn bộ đệm. Bạn viết đúng ý; compiler, đúng luật, xóa nó đi.

Về đến code hằng ngày

Điều đầu tiên: đừng viết kiểm tra dựa trên UB đã-xảy-ra để phát hiện UB. if(s < a) sau khi s = a + 100 là bẫy — tới lúc kiểm thì tràn đã là UB và compiler được phép cho rằng nó không xảy ra. Muốn phát hiện tràn an toàn, dùng thứ không dựa trên UB: __builtin_add_overflow(a, 100, &s) (compiler biến thành lệnh đọc cờ tràn của CPU, luôn đúng), hoặc kiểm trước khi cộng (if(a > INT_MAX - 100)), hoặc làm phép trong kiểu unsigned nơi quấn vòng được định nghĩa.

Điều thứ hai: UB không phải "crash nếu xui", mà là compiler đang tối ưu dựa trên giả định bạn không chạm nó. Cùng một chương trình chạy đúng ở -O0 có thể sai ở -O2 không phải vì -O2 hỏng, mà vì -O2 khai thác nhiều lời hứa UB hơn — đúng như các mức -O0 đến -O3 mở dần các phép biến đổi. "Chạy được trên máy tôi ở chế độ debug" không chứng minh code không có UB; nó chỉ chứng minh mức tối ưu đó chưa khai thác cái UB đang nằm im.

Điều thứ ba: có công cụ để bắt và để chọn. -fsanitize=undefined (UBSan) chèn kiểm tra lúc chạy và báo đúng dòng, đúng loại UB — bật nó trong test là cách rẻ nhất để lôi UB ra ánh sáng. Nếu bạn cần ngữ nghĩa quấn vòng có dấu (một số codebase mật mã, băm), -fwrapv biến nó thành xác định trên toàn dự án. Mỗi loại UB có một cờ để tắt giả định tương ứng (-fno-strict-aliasing, -fwrapv).

Và đây là chỗ đáng nhớ nhất, vì UB là một trong những điểm C khác biệt sâu sắc nhất với các ngôn ngữ ra sau — hiểu nó là hiểu vì sao chúng được thiết kế thế. Java và Go gần như xóa sổ loại UB này: tràn số nguyên được định nghĩa là quấn vòng (không phải UB), truy cập mảng ngoài biên ném ngoại lệ chứ không phải hành vi bất định, deref null cho NullPointerException/panic rõ ràng — đổi lại chúng chèn kiểm tra biên lúc chạy, một cái giá hiệu năng mà C từ chối trả. Rust đi đường thứ ba tinh tế: an toàn bộ nhớ được bảo đảm bởi hệ kiểu lúc biên dịch (nên không cần kiểm runtime ở code an toàn), còn tràn số nguyên thì panic ở bản debug và quấn vòng ở bản release — một lựa chọn có chủ đích. Mẫu số chung, và là điều đáng mang theo khi đọc mã C: "hành vi không xác định" không phải lỗ hổng của C mà là một giao kèo — C đánh đổi an toàn lấy tốc độ bằng cách bắt bạn giữ lời hứa thay vì compiler kiểm tra; các ngôn ngữ sau dịch cán cân về phía an toàn, và biết mình đang ở phía nào của giao kèo quyết định bạn phải cẩn thận tới đâu.

Muốn tự thấy thì dán đoạn này vào t.c: int chk(int a){ int s = a + 1000000000; if(s < a) return 1; return 0; }. Rồi so hai lần biên dịch: gcc -O2 -S -o - t.c và gcc -O2 -fwrapv -S -o - t.c. Tìm nhãn chk:. Bản đầu bạn sẽ thấy nó rút về mov w0, 0; ret — kiểm tra tràn biến mất. Bản -fwrapv giữ một cmp/cset. Rồi gcc -O2 -fsanitize=undefined -o t t.c với một main gọi chk(2000000000) — UBSan sẽ in đúng dòng tràn. Ba lệnh, và bạn tận mắt thấy compiler xóa cái kiểm tra bạn vừa viết, cùng hai cách giữ nó lại.