bài về hành vi không xác định, ta thấy trình biên dịch xóa những đoạn kiểm tra chỉ có nghĩa nếu UB xảy ra. Tràn số nguyên là ví dụ kinh điển và nguy hiểm nhất của luật đó, vì nó chia làm hai nửa trái ngược: tràn unsigned được định nghĩa rõ (quấn vòng), còn tràn signed là UB. Bài này đo cả hai trong container, đọc assembly để xem trình biên dịch làm gì với một đoạn kiểm tra tràn — và một lần nữa, phép đo sửa lại điều tôi tưởng mình đã biết.

Tràn số nguyên

Hai nửa trái ngược

Với unsigned, tràn được chuẩn C định nghĩa chính xác: số học chạy theo modulo 2^n, nên UINT_MAX + 1 == 0, quấn vòng về đầu. Đây là hành vi đảm bảo, đoán trước được, giống kim đồng hồ quay hết vòng lại về 0.

Với signed, tràn là hành vi không xác định. Chuẩn không nói INT_MAX + 1 bằng bao nhiêu — nó nói chương trình của bạn không được phép để điều đó xảy ra. Và như mọi UB, hệ quả không phải "kết quả tùy máy" mà nặng hơn: trình biên dịch được phép giả định signed không bao giờ tràn, rồi dùng chính giả định đó để tối ưu. Điều này biến một đoạn kiểm tra tràn ngây thơ thành vô dụng, theo cách rất khó ngờ.

Đo: cùng biểu thức, một bên bị xóa, một bên là kiểm tra thật

Tôi viết đúng một biểu thức phát hiện tràn kiểu ngây thơ — "cộng vào rồi so với chính nó" — cho cả hai kiểu:

int chk_signed(int x){ return x + 100 < x; }         // nếu x+100 nhỏ hơn x thì đã tràn?
int chk_unsigned(unsigned x){ return x + 100 < x; }  // cùng biểu thức, kiểu unsigned

Đọc assembly:

chk_signed  : mov w0, 0                    ; luôn trả về 0 — bị XÓA
chk_unsigned: cmn w0, #101 ; cset w0, hi   ; một phép so sánh THẬT

chk_signed bị gấp thành hằng 0 — trình biên dịch lý luận: "signed không bao giờ tràn, nên x + 100 luôn lớn hơn x, nên x + 100 < x luôn false", và xóa sạch đoạn kiểm tra. chk_unsigned thì giữ nguyên một phép so sánh thật (cmn/cset hi), vì unsigned quấn vòng nên x + 100 < x là điều thật sự xảy ra khi x gần UINT_MAX.

Chạy chương trình cho thấy hậu quả sống động:

chk_signed(INT_MAX-50)   = 0    (báo "không tràn")   nhưng x+100 = -2147483599 (đã tràn!)
chk_unsigned(UINT_MAX-50)= 1    (báo "có tràn" — đúng)

chk_signed trả về 0 — khẳng định "không tràn" — trong khi x + 100 in ra là một số âm to đùng, tức là nó đã tràn ngay trước mắt. Đoạn kiểm tra không chỉ vô dụng, nó nói dối: quả quyết an toàn đúng vào lúc tràn đang xảy ra. chk_unsigned thì báo đúng.

Một lần tôi đo hớ: UB cắn ngay ở -O0, không đợi -O2

Tôi vào bài với một kịch bản định sẵn, dựa trên đúng bài học UB: đoạn kiểm tra tràn signed sẽ chạy đúng ở -O0 (trình biên dịch chưa tối ưu nên còn tính thật) rồi bị xóa ở -O2 — một minh họa hoàn hảo cho "kết quả đổi theo mức -O", chữ ký kinh điển của UB.

Tôi biên dịch cả hai mức và đọc assembly. Bất ngờ: ở -O0, chk_signed cũng đã là mov w0, 0 — bị xóa y hệt -O2. Không hề có sự khác biệt theo -O mà tôi chờ đợi. Lý do: việc gấp x + C < x thành false xảy ra ngay trong giai đoạn phân tích cú pháp / đơn giản hóa biểu thức của front-end, trước cả khi các phase tối ưu (-O) chạy. Kỳ vọng "UB chỉ cắn ở mức tối ưu cao" của tôi sai: một số suy luận dựa trên UB được áp dụng ở mọi mức, kể cả -O0.

Đây là một đo hớ tinh tế và đáng nhớ hơn kịch bản tôi định viết. Bài học kép: thứ nhất, "biên dịch không tối ưu (-O0) cho tôi ngữ nghĩa ngây thơ, an toàn" là một niềm tin sai — UB có thể bị khai thác ngay từ -O0, nên đừng bao giờ dựa vào -O0 để "thoát" UB. Thứ hai, và sâu hơn: viết kiểm tra tràn bằng chính phép tràn là tự đánh bại. Bạn dùng a + b < a để phát hiện a + b tràn — nhưng trình biên dịch dùng đúng cái UB đó (giả định không tràn) để kết luận biểu thức luôn false và xóa lá chắn. Bạn đưa cho nó vũ khí để vô hiệu hóa chính mình.

Để chắc, tôi kiểm hai cách sửa. Với -fwrapv (bảo trình biên dịch coi signed cũng quấn vòng, biến tràn thành hành vi định nghĩa), chk_signed sống lại thành một phép so sánh thật (cmp/cset gt). Và với -fsanitize=undefined, chương trình chạy đến chỗ tràn thì in thẳng: runtime error: signed integer overflow: 2147483597 + 100 cannot be represented in type 'int' — chỉ đúng dòng, đúng phép tính.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: đừng phát hiện tràn signed bằng chính phép tràn. if (a + b < a), if (a * b / b != a) — những mẫu này bị xóa và nói dối. Cách đúng là kiểm trước khi tràn: if (a > INT_MAX - b) (kiểm mà không thực hiện phép cộng nguy hiểm), hoặc dùng các hàm dựng sẵn __builtin_add_overflow / __builtin_mul_overflow (chúng trả về cờ tràn một cách an toàn và không UB), hoặc làm phép tính trong kiểu unsigned rồi kiểm quấn vòng. Đây là lỗi bảo mật thật: rất nhiều lỗ hổng tràn số integer trong cấp phát bộ nhớ đến từ đúng một đoạn kiểm tra bị trình biên dịch xóa.

Hệ quả thứ hai: hiểu ranh giới signed/unsigned khi chọn kiểu. Nếu bạn cần số học quấn vòng đoán trước được — băm, mã hóa, bộ đếm vòng, thao tác bit — hãy dùng unsigned, nơi tràn là hành vi định nghĩa. Nếu dùng int cho những phép có thể tràn (chỉ số mảng lớn, kích thước, phép nhân), hãy ý thức rằng tràn là UB và trình biên dịch sẽ tối ưu như thể nó không xảy ra — kể cả xóa vòng lặp, xóa nhánh, giả định điều kiện. Cờ -fsanitize=undefined là bạn thân trong lúc phát triển: bật nó khi chạy test để phát hiện tràn signed trước khi nó thành lỗ hổng.

Hệ quả thứ ba là bài học đo lường mang theo: UB không phải hiện tượng "chỉ ở mức tối ưu cao"; đừng cho rằng -O0 an toàn. Con số mang theo: cùng biểu thức x + 100 < x bị trình biên dịch xóa thành hằng 0 (kể cả ở -O0) khi x là signed vì tràn signed là UB, nhưng là một phép so sánh thật khi x là unsigned vì unsigned quấn vòng có định nghĩa; nên đừng phát hiện tràn bằng chính phép tràn — kiểm trước, dùng unsigned hoặc _builtin*_overflow, và chạy thử với -fsanitize=undefined. Unsigned quấn vòng thì đoán được; còn với signed, đừng đoán, và tuyệt đối đừng đưa cho trình biên dịch cái UB mà bạn đang cố phát hiện.

Thử ba mươi giây

Viết int f(int x){ return x + 1 > x; } — một biểu thức mà toán học luôn đúng nhưng dấu phẩy động số nguyên signed có thể sai khi x == INT_MAX. Biên dịch gcc -O0 -Sgcc -O2 -S, đọc assembly: cả hai mức đều cho ra mov w0, 1 (luôn trả về 1) — trình biên dịch giả định x + 1 luôn lớn hơn x, xóa mọi khả năng tràn, ngay từ -O0. Giờ đổi int thành unsigned và làm lại: bạn sẽ thấy một phép so sánh thật hiện ra, vì unsigned quấn vòng nên x + 1 > x có thể false. Cùng một dòng mã, hai kiểu, hai thế giới ngữ nghĩa — và bạn vừa thấy vì sao "signed là UB" không phải chuyện lý thuyết.