Ở 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 tôi 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.
Cú 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ó.
Cái tôi rút ra, 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ề đến code hằng ngày
Điều đầ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.
Điều 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.
Và điều cuối 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. Gói lại bằng số: 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.
Cái ý "ngôn ngữ có những vùng không định nghĩa mà hệ thống được phép làm bất cứ gì" này là chỗ C và các ngôn ngữ cao cấp rẽ hướng dứt khoát nhất. Java gần như xóa sổ UB: tràn số nguyên được định nghĩa (quấn vòng), truy cập ngoài mảng ném ArrayIndexOutOfBoundsException, null ném NullPointerException — không có "trình dịch xóa check của bạn", vì mọi thao tác nguy hiểm đều có ngữ nghĩa xác định. Đó là một trong những lý do Java an toàn hơn cho code đời thường, đổi lại là mất chút tốc độ. Go cũng định nghĩa gần hết và panic thay vì UB. Nhưng Rust là câu trả lời thú vị nhất: nó có UB (trong khối unsafe), nhưng dồn toàn bộ vào những vùng bạn phải khai unsafe tường minh, còn code an toàn thì trình biên dịch chứng minh không có UB — đúng tinh thần "kiểm trước khi vi phạm" nâng lên thành luật ngôn ngữ. Cơ sở dữ liệu có phiên bản riêng: hành vi khi tràn kiểu, chia cho 0, hay so sánh NULL được đặc tả rõ trong chuẩn SQL (dù mỗi engine một khác), nên không có chuyện planner "xóa" một điều kiện WHERE vì cho rằng nó không xảy ra. Mẫu số chung: sức mạnh và tốc độ của C đến kèm những vùng "không được phép phạm" mà trình dịch khai thác triệt để — các ngôn ngữ sau đổi một ít tốc độ lấy việc bịt các vùng đó lại, và biết ranh giới ấy ở đâu là biết ngôn ngữ của mình tha thứ hay trừng phạt lỗi của bạn.
Muốn tự thấy thì 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.