bài strict aliasing ta gặp một quy tắc mà vi phạm là hành vi không xác định (undefined behavior — 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, không riêng aliasing. 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.

UB mở khóa tối ưu

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.

Nghe trừu tượng, nên tôi vào container gcc:13 (ARM AArch64) đo nó thành mã máy cụ thể.

Đo hớ: 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ù. 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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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.

Hệ quả 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.

Hệ quả 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. Và như strict aliasing đã cho thấy, mỗi loại UB có một cờ để tắt giả định tương ứng (-fno-strict-aliasing, -fno-strict-overflow, -fwrapv).

Con số mang theo: UB là lời hứa compiler tin bạn giữ, không phải tai nạn ngẫu nhiên — ở gcc -O2, if(p==NULL) sau khi deref và if(a+100<a) kiểm tràn đều bị xóa sạch (hàm kiểm tràn còn đúng một lệnh mov w0,0), để tràn INT_MAX lọt qua im lặng; -fwrapv giữ lại kiểm tra, UBSan bắt tại chỗ, còn cái "tối ưu tốc độ" tôi đi tìm (signed nhanh hơn unsigned) thì không hề có — 0,246 với 0,245 ns, giống hệt. UB mở khóa tối ưu ở , không phải ở đồng hồ; và cái mã nó mở khóa có thể là chính nhánh an toàn của bạn.

Thử ba mươi giây

Dán đoạn này vào một file 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.cgcc -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. Đổi thêm gcc -O2 -fsanitize=undefined -o t t.c && ./t với một main gọi chk(2000000000) — UBSan sẽ in ra đú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.