Có một loại tối ưu của trình biên dịch dựa trên một giả định về kiểu con trỏ, và khi bạn vi phạm giả định đó, chương trình cho kết quả khác nhau tùy mức tối ưu — dấu hiệu kinh điển của hành vi không xác định. Nó tên là strict aliasing (bí danh chặt về kiểu). Bài này đo một đoạn type-pun (đọc lại byte của một kiểu dưới dạng kiểu khác) qua ép con trỏ, và tìm hiểu vì sao -O0-O2 cho hai con số khác nhau từ cùng một mã nguồn.

Strict aliasing

Trình dịch giả định con trỏ khác kiểu không trùng

Luật strict aliasing cho phép trình biên dịch giả định rằng hai con trỏ có kiểu không tương thích không trỏ tới cùng một ô nhớ. Một int* và một float*, theo luật, được coi là không bao giờ trùng nhau. Nhờ giả định này, khi biên dịch một hàm ghi qua float* rồi đọc qua int*, trình dịch được phép cho rằng lần ghi float không ảnh hưởng giá trị int — nên nó có thể giữ giá trị int cũ trong thanh ghi, không cần nạp lại từ bộ nhớ. Đây là một tối ưu thật: nó cắt được các lần đọc bộ nhớ thừa. Trong mã số học nặng — xử lý ảnh, tín hiệu, ma trận — nơi con trỏ trỏ vào các mảng khác kiểu, giả định này giúp trình dịch giữ dữ liệu trong thanh ghi thay vì liên tục đọc lại RAM, và đó là một phần đáng kể của tốc độ ở -O2.

Nhưng nếu bạn cố tình làm hai con trỏ khác kiểu trỏ cùng ô — ví dụ để "đọc lại các bit" (type-punning) — thì giả định của trình dịch sai, và bạn rơi vào hành vi không xác định. Tôi dựng đúng tình huống đó: một hàm ghi 1 qua int*, ghi 2.0f qua float* vào cùng địa chỉ, rồi đọc lại qua int*.

Đo: cùng mã nguồn, kết quả đổi theo -O

Hàm nhận một int* và một float*, được gọi với cả hai trỏ vào cùng một biến (viol(&x, (float*)&x)). Chạy ở các mức tối ưu:

-O0  ->  1073741824    (đúng các bit của 2.0f đọc dưới dạng int)
-O2  ->  1             (giá trị int CŨ — khác hẳn!)
-O3  ->  1

Cùng một đoạn mã, ba mức tối ưu, hai kết quả. Ở -O0 (không tối ưu), trình dịch nạp lại *ip sau khi ghi *fp, nên nó đọc đúng các byte mới (bit của 2.0f = 1073741824). Ở -O2, strict aliasing cho phép giả định lần ghi float không đụng tới int, nên nó trả về giá trị 1 đã ghi lúc đầu mà không nạp lại. Đọc assembly xác nhận:

mov  w0, 1        ; giá trị trả về = HẰNG 1
str  w0, [x2]     ; *ip = 1
str  s0, [x1]     ; *fp = 2.0f
ret               ; trả về w0 = 1 (không đọc lại [x2]!)

Trình dịch đặt giá trị trả về là hằng 1 trước khi ghi float, và không hề đọc lại ô nhớ sau đó. Đó chính là tối ưu strict aliasing hiện hình. Và điều đáng nói: gcc đã cảnh báo suốt: warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing].

Một lần tôi đo hớ: ép con trỏ không "chỉ là đọc lại bit"

Tôi vào bài với niềm tin rất phổ biến: *(int*)&f chỉ đơn giản là "đọc lại các bit của f dưới dạng int" — một phép diễn giải lại vô hại, luôn cho cùng kết quả. Đo ra thì không: -O0 cho 1073741824 (đúng bit của 2.0f) nhưng -O2 cho 1 (giá trị int cũ). Cùng mã nguồn, khác kết quả theo mức -O. Đó không phải "đọc lại bit" ổn định — đó là hành vi không xác định, và trình dịch có toàn quyền diễn giải theo cách khiến kết quả phụ thuộc vào mức tối ưu.

Bài học đo lường quan trọng: khi cùng một chương trình cho kết quả khác nhau ở các mức -O, đó gần như chắc chắn là dấu hiệu của UB, không phải "trình dịch có lỗi". Trình dịch không sai — nó đang thực thi một tối ưu hợp lệ dựa trên một giả định mà mã của tôi đã vi phạm. Tôi đã đem trực giác "con trỏ chỉ là địa chỉ, ép kiểu rồi đọc là xong" — đúng ở mức phần cứng — áp lên một tầng ngữ nghĩa nơi trình dịch được phép giả định nhiều hơn thế. Và như mọi khi, công cụ đã nói thật từ đầu: cái cảnh báo -Wstrict-aliasing chính là lời báo trước mà tôi (và nhiều người) hay bỏ qua.

Cách đúng để đọc lại bit của một kiểu dưới dạng kiểu khác là memcpy (hoặc union trong C, hoặc con trỏ char* vốn được miễn trừ khỏi luật). Tôi đo memcpy(&i, &f, sizeof i): nó cho kết quả đúng 1073741824 ở mọi mức -O, và — quan trọng — không hề chậm: gcc gấp memcpy bốn byte thành đúng một lệnh (fmov w0, s0, chuyển bit float sang thanh ghi int), không có lời gọi memcpy thật. Vậy cách đúng vừa an toàn vừa miễn phí; không có lý do gì để ép con trỏ.

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

Hệ quả đầu tiên là type-pun luôn bằng memcpy, đừng bao giờ bằng ép con trỏ. Muốn xem bit của một float như int, một struct như mảng byte, hay ngược lại, dùng memcpy giữa hai biến đúng kiểu. Trình dịch nhận ra đây là type-pun và sinh mã tối ưu (thường một lệnh chuyển thanh ghi), nên bạn được cả đúng đắn lẫn tốc độ. *(T*)&x cho kiểu không tương thích là một cái bẫy UB, dù nó "trông có vẻ chạy" ở -O0.

Hệ quả thứ hai là để ý cảnh báo -Wstrict-aliasing, và bật -Wall. Cảnh báo này chỉ đúng chỗ vi phạm, và bỏ qua nó là chuốc lấy một bug chỉ hiện ra khi bạn tăng mức tối ưu — kiểu bug tệ nhất, vì nó "chạy trên máy dev" (thường build debug -O0) rồi "hỏng trên production" (build release -O2). Nếu bạn thật sự cần tắt luật (ví dụ với mã cũ dựa vào type-pun), -fno-strict-aliasing làm được, nhưng nó tắt một tối ưu hữu ích trên toàn bộ chương trình — nên vá đúng chỗ bằng memcpy tốt hơn.

Hệ quả thứ ba là bài học đo lường nền tảng của cả sê-ri: kết quả phụ thuộc mức tối ưu là chữ ký của hành vi không xác định. Con số mang theo: strict aliasing cho phép -O2 giả định con trỏ khác kiểu không trùng nhau (một tối ưu thật, cắt đọc bộ nhớ thừa); type-pun bằng ép con trỏ vi phạm luật này, cho kết quả khác nhau theo mức -O (1073741824 ở -O0, 1 ở -O2) — đó là UB; hãy type-pun bằng memcpy (đúng ở mọi mức và được gcc gấp thành một lệnh, miễn phí), và đừng bỏ qua cảnh báo -Wstrict-aliasing. Khi thấy chương trình đổi hành vi lúc bạn bật -O2, đừng đổ lỗi cho trình dịch — soi lại mã của mình tìm UB.

Thử ba mươi giây

Viết một hàm int bits(float f){ return *(int*)&f; } và một main in bits(2.0f). Dịch gcc -O0 a.c && ./a.out (thấy 1073741824), rồi gcc -O2 -Wall a.c && ./a.out — bạn sẽ thấy con số khác, kèm cảnh báo -Wstrict-aliasing. Giờ đổi hàm thành int bits(float f){ int i; memcpy(&i,&f,sizeof i); return i; }, dịch lại -O2: kết quả đúng ổn định, không cảnh báo, và gcc -O2 -S cho thấy nó chỉ là một lệnh chuyển thanh ghi. Chỉ vài lần dịch là bạn thấy tận mắt vì sao ép con trỏ là bẫy còn memcpy là đường thẳng.