Ở bài strict aliasing ta gặp một trường hợp cụ thể của hành vi không xác định (undefined behavior, UB) và thấy nó khiến kết quả đổi theo mức tối ưu. Bài này nhìn UB ở tầng tổng quát, qua một hệ quả gây sốc: trình biên dịch có thể xóa cả một đoạn kiểm tra bạn viết ra để bắt bug, vì nó suy luận rằng cái bug đó "không được phép xảy ra". Tôi dựng đúng cái bẫy đó và đo — và hiểu vì sao UB là một trong những khái niệm nguy hiểm nhất trong C.
Trình dịch giả định UB không bao giờ xảy ra
Hành vi không xác định là những việc mà chuẩn C tuyên bố "không được phép xảy ra" và không quy định điều gì nếu chúng xảy ra: tràn số nguyên có dấu, giải tham chiếu con trỏ null, đọc biến chưa khởi tạo, truy cập ngoài mảng, chia cho 0... Điểm cốt lõi — và là nguồn của mọi bất ngờ — là trình biên dịch được phép giả định UB không bao giờ xảy ra, và tối ưu dựa trên giả định đó. Nếu một đoạn mã chỉ có nghĩa khi UB xảy ra, trình dịch kết luận đoạn đó là mã chết và xóa nó.
Nghe trừu tượng, nên tôi dựng một ví dụ cụ thể: một hàm kiểm tra tràn số nguyên theo kiểu quen thuộc — cộng rồi so sánh xem kết quả có nhỏ hơn ban đầu không.
Đo: đoạn kiểm tra tràn bị xóa
Hàm check cộng x + 100 và nếu kết quả nhỏ hơn x thì báo "đã tràn" (return -1):
int check(int x){
if (x + 100 < x) return -1; // ý định: bắt tràn
return x + 100;
}
Gọi với x = INT_MAX - 50 (nên x + 100 chắc chắn tràn), biên dịch -O2:
-O2 mặc định (tràn = UB): check trả về -2147483599 (giá trị tràn, KHÔNG bắt được)
-O2 -fwrapv (tràn có nghĩa): check trả về -1 (bắt được tràn)
Cùng một hàm, cùng -O2, hai kết quả — khác nhau chỉ bởi một cờ. Ở chế độ mặc định, đoạn kiểm tra tràn bị xóa sạch: đọc assembly thấy hàm chỉ còn add w0, w0, 100; ret — không có một lệnh so sánh nào cho nhánh if. Trình dịch đã lý luận: tràn số nguyên có dấu là UB, tức "không bao giờ xảy ra"; mà x + 100 < x chỉ có thể đúng khi có tràn; vậy điều kiện đó luôn sai; vậy nhánh return -1 là mã chết → loại bỏ. Cái check tôi viết ra để bắt tràn lại bị chính trình dịch xóa đi, nên tràn lọt qua và trả về giá trị rác.
Bật -fwrapv (định nghĩa tràn số nguyên có dấu là quấn vòng, biến nó từ UB thành hành vi xác định) thì assembly có lại lệnh cmp w0, w1, đoạn kiểm tra chạy thật và trả về -1. Điều này chứng minh thủ phạm chính xác là giả định UB, không phải một tối ưu nào khác.
Một lần tôi đo hớ: viết check bắt bug, check bị compile đi mất
Tôi vào bài với niềm tin thẳng thắn: if(x + 100 < x) là một cách hợp lệ để phát hiện tràn — nếu cộng làm số nhỏ đi thì rõ ràng đã tràn. Đo ra ở -O2 đoạn check đó biến mất hoàn toàn, và tràn lọt qua không ai bắt. Cú sốc không phải "trình dịch có lỗi" — trình dịch đúng theo chuẩn. Nó tối ưu dựa trên một lời hứa: "chương trình của bạn không có UB". Nhưng cái check của tôi lại tự nó gây UB (tính x + 100 khi biết sẽ tràn) rồi cố quan sát hậu quả — mà theo lời hứa đó, hậu quả "không tồn tại". Nên trình dịch có mọi quyền xóa nó.
Bài học đo lường, mở rộng từ bài strict aliasing thành một quy luật chung: kết quả đổi theo mức -O (hay theo cờ như -fwrapv) là chữ ký chắc chắn của UB, không phải lỗi trình dịch. Và một bài học lập trình quan trọng hơn: không bao giờ kiểm tra một điều kiện bằng cách gây ra UB rồi quan sát kết quả — vì trình dịch được phép giả định kết quả đó không xảy ra. Muốn bắt tràn, phải kiểm trước khi tràn: if (x > INT_MAX - 100) (so sánh không gây tràn), chứ không phải if (x + 100 < x) (đã tràn rồi mới so). Tôi đã đem trực giác phần cứng ("cộng thì quấn vòng, so sánh thì thấy") áp lên một tầng ngữ nghĩa nơi "tràn" đơn giản là không được phép tồn tại.
Cái đáng sợ của UB là nó im lặng: -Wstrict-overflow không phải lúc nào cũng cảnh báo, và code "chạy đúng" ở -O0 (bản debug) rồi hỏng ở -O2 (bản release). Công cụ để bắt UB lúc chạy là UBSan (-fsanitize=undefined): biên dịch với cờ đó, chương trình in ngay runtime error: signed integer overflow: 2147483597 + 100 cannot be represented in type 'int' tại đúng dòng vi phạm.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là UB không phải "kết quả lạ", mà là giấy phép để trình dịch làm bất cứ gì — kể cả xóa mã, đảo thứ tự, hay giả định điều kiện. Một ví dụ khét tiếng ngoài đời: kernel Linux từng có lỗ hổng bảo mật vì một if (!ptr) đặt sau một lần giải tham chiếu ptr bị trình dịch xóa (giải tham chiếu nghĩa là ptr không thể null, nên check thừa) — biến một kiểm tra an toàn thành một lỗ hổng. UB không chỉ cho số sai; nó có thể mở toang cửa bảo mật.
Hệ quả thứ hai là viết mã tránh UB, và kiểm tra điều kiện trước khi vi phạm. Kiểm tràn bằng so sánh không gây tràn (x > INT_MAX - n), kiểm null trước khi giải tham chiếu, dùng kiểu không dấu khi cần hành vi quấn vòng (tràn unsigned được định nghĩa, không phải UB), hay các hàm cộng có kiểm tra tràn (__builtin_add_overflow). Đừng dựa vào việc quan sát hậu quả của UB — hậu quả đó không được trình dịch bảo đảm tồn tại.
Hệ quả thứ ba là dùng các công cụ bắt UB: bật -Wall -Wextra, chạy test với -fsanitize=undefined,address, và đừng bỏ qua cảnh báo. Con số mang theo: trình dịch được giả định UB không bao giờ xảy ra, nên mã chỉ có nghĩa khi có UB (như đoạn kiểm tra if(x+100<x)) bị coi là mã chết và XÓA ở -O2 — khiến bug lọt qua; kết quả đổi theo -O hay -fwrapv là chữ ký của UB; hãy kiểm tra điều kiện TRƯỚC khi vi phạm và dùng UBSan để bắt. UB là chỗ mà trực giác "máy tính làm cái tôi bảo" gãy: trình dịch không thực thi mã của bạn, nó thực thi một chương trình tương đương dưới giả định bạn không phạm luật — và phạm luật thì mọi bảo đảm biến mất.
Thử ba mươi giây
Viết int f(int x){ return (x+100 < x); } và in f(INT_MAX-50). Dịch gcc -O0 a.c && ./a.out rồi gcc -O2 a.c && ./a.out: ở -O0 bạn có thể thấy 1 (hoặc 0 tùy gcc gấp hằng), nhưng chạy gcc -O2 -S a.c và xem hàm — bạn sẽ thấy nó trả về hằng 0, đoạn so sánh biến mất. Giờ thêm -fwrapv và xem lại .s: lệnh cmp xuất hiện trở lại. Cuối cùng, gcc -fsanitize=undefined a.c && ./a.out in ra lỗi tràn tại đúng dòng. Chỉ vài lần dịch là bạn thấy tận mắt trình dịch xóa một đoạn kiểm tra dựa trên UB — và vì sao phải kiểm trước, không kiểm sau.