Suốt sê-ri này, một cơ chế cứ trở đi trở lại phá phép đo của tôi: trình biên dịch xóa mã mà nó cho là "không làm gì". Nó có tên — loại bỏ mã chết (dead code elimination). Bài này soi thẳng vào nó: xóa những gì, theo tiêu chí nào, và một trường hợp mà việc "xóa mã vô dụng" biến thành một lỗ hổng bảo mật thật sự — điều tôi chỉ tin khi đọc assembly.

Loại bỏ mã chết

Trình dịch giữ kết quả, không giữ mã bạn viết

Nguyên tắc nền của mọi tối ưu — và của loại bỏ mã chết nói riêng — là: trình biên dịch chỉ phải bảo toàn tác dụng quan sát được của chương trình (thứ xuất ra màn hình, ghi ra file, gửi qua mạng, đọc từ volatile). Bất cứ đoạn mã nào không ảnh hưởng tới tác dụng quan sát được đều là "mã chết" và có thể bị xóa. Trình dịch giữ kết quả, không giữ mã bạn gõ.

Mã chết có nhiều dạng, và tôi đo từng dạng ở -O2 bằng cách đọc assembly:

  • Không thể tới: lệnh sau return, hoặc trong if(0). Tôi viết return a*2; g(a); — đọc assembly, g(a) không xuất hiện (0 lời gọi). Tương tự if(0) g(a) cũng biến mất — trình dịch tính được điều kiện luôn sai lúc dịch nên cắt cả nhánh.
  • Ghi chết (dead store): int x=1; x=2; return x;. Đọc assembly hàm này ở -O2 chỉ còn đúng hai lệnh: mov w0, 2; ret. Phép ghi x=1 bị xóa vì không ai đọc x trước khi nó bị đè; và x=2 cũng được gộp thẳng vào giá trị trả về.
  • Tính vô dụng: một phép tính mà kết quả không bao giờ được dùng (đúng cái đã làm hỏng benchmark ở bài các mức -O).

Ba dạng này đều vô hại — xóa chúng không đổi hành vi chương trình, chỉ làm mã nhỏ và nhanh hơn. Nhưng tiêu chí "không quan sát được" có một mặt tối, và tôi vấp đúng nó.

Một lần tôi đo hớ: memset xóa mật khẩu bị xóa mất

Tôi viết một hàm xử lý mật khẩu, và như mọi tài liệu bảo mật khuyên, xóa sạch buffer trước khi hàm kết thúc để bí mật không lởn vởn trong bộ nhớ:

void xu_ly(void){
    char pw[16];
    dung_mat_khau(pw);            // điền và dùng mật khẩu
    memset(pw, 0, sizeof pw);     // xóa sạch — cho an toàn
}

Tôi đinh ninh memset đó chạy. Nhưng đọc assembly ở -O2, toàn bộ thân hàm chỉ là:

	bl	dung_mat_khau
	ret

Không có memset. Không có một lệnh ghi 0 nào. Phép xóa mật khẩu đã biến mất hoàn toàn. Lý do đúng theo tiêu chí mã chết: sau memset, biến cục bộ pw không bao giờ được đọc lại (hàm kết thúc ngay), và pw không thoát ra ngoài. Nên với trình biên dịch, việc ghi số 0 vào pw là một ghi chết — không ai quan sát được kết quả của nó — và nó bị loại đi. Hậu quả: mật khẩu vẫn nằm nguyên vẹn trên stack sau khi hàm trả về, chờ được đọc trộm bởi một lỗi khác. Ý định bảo mật của tôi bị tối ưu hóa xóa sổ.

Bài học đo lường: đây đúng là cơ chế từng làm hỏng benchmark của tôi (xóa phép tính vô dụng), chỉ khác là lần này nó xóa mã bảo mật — và hậu quả không phải một số đo sai, mà một lỗ hổng thật. Điểm chung: trình dịch giữ kết quả quan sát được, không giữ ý định của tôi. Việc "xóa 16 byte rồi vứt đi" trông vô dụng với máy, dù với tôi nó là cả mục đích. Và một lần nữa, chỉ đọc assembly mới lộ ra — nếu tin vào mã nguồn, tôi đã đóng gói một lỗ hổng mà vẫn nghĩ mình đã vá.

Cách sửa là dùng một công cụ mà trình dịch không được phép loại: explicit_bzero(pw, sizeof pw) (hoặc memset_s, hoặc một rào cản volatile). Đo lại: hàm dùng explicit_bzero-O2 vẫn giữ nguyên lời gọi bl explicit_bzero — phép xóa sống sót. Các hàm này tồn tại chính vì memset thường bị loại bỏ mã chết — chúng đặt một rào cản mà bộ tối ưu buộc phải tôn trọng, coi phép ghi 0 như một tác dụng quan sát được không thể bỏ qua.

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

Hệ quả đầu tiên, quan trọng nhất: để xóa dữ liệu nhạy cảm, đừng dùng memset trần. Mật khẩu, khóa mã hóa, token — mọi thứ bạn muốn xóa khỏi bộ nhớ vì lý do an ninh — phải xóa bằng explicit_bzero, memset_s (C11 Annex K), hoặc thư viện chuyên dụng (sodium_memzero...). Một memset cuối hàm gần như chắc chắn bị -O2 loại bỏ. Đây là một lớp lỗi CVE có thật, không phải chuyện lý thuyết. OpenSSL, GnuPG và nhân Linux đều từng vá đúng loại lỗi này: một phép xóa khóa hay mật khẩu bị bộ tối ưu loại đi, để lại dữ liệu nhạy cảm nằm chờ trong bộ nhớ hoặc trong bản dump.

Hệ quả thứ hai là hiểu rằng "mã tôi viết sẽ chạy" là một giả định, không phải bảo đảm. Nếu tác dụng của một đoạn mã vô hình với trình biên dịch — ghi vào biến không ai đọc, tính toán không dùng, đọc bộ nhớ rồi vứt kết quả — nó có thể bị xóa. Muốn ép một hành động xảy ra thật dù trình dịch không thấy lý do, bạn cần nói rõ: volatile (buộc mỗi truy cập phải thật), một hàm rào cản mà trình dịch không nhìn thấu, hay một tác dụng phụ quan sát được. Đây cũng là gốc của mọi microbenchmark tử tế — phải có "sink" để phần đo không bị loại.

Hệ quả thứ ba là mặt tích cực: loại bỏ mã chết là bạn, không phải thù, trong 99% trường hợp. Nó dọn sạch các nhánh gỡ lỗi bị tắt bằng cờ biên dịch, các kiểm tra dư thừa sau khi hằng số được truyền vào, mã sinh ra từ macro và template không dùng tới — làm nhị phân nhỏ và nhanh hơn mà không đổi hành vi. Vấn đề chỉ nảy sinh ở đúng cái ranh giới: khi ý định của bạn (xóa bí mật, đo thời gian) không trùng với tác dụng quan sát được mà trình dịch bảo toàn. Con số mang theo: trình dịch xóa mọi mã không có tác dụng quan sát được — lệnh sau return, ghi chết, tính vô dụng, và cả memset xóa mật khẩu nếu buffer không đọc lại; nó giữ kết quả chứ không giữ ý định, nên hãy dùng explicit_bzero để xóa bí mật, volatile/sink để giữ phép đo, và đọc assembly khi nghi ngờ. Cùng một tối ưu vừa dọn rác cho bạn vừa có thể xóa mất điều bạn cần nhất — biết ranh giới đó là biết khi nào phải nói rõ với trình biên dịch.

Thử ba mươi giây

Viết một hàm có char buf[16];, gọi một hàm ngoài với buf (để nó được "dùng"), rồi memset(buf, 0, sizeof buf); ở cuối. Dịch gcc -O2 -S file.c -o file.s và tìm trong thân hàm: bạn sẽ không thấy lệnh nào ghi 0 vào bufmemset đã bị xóa. Giờ đổi thành explicit_bzero(buf, sizeof buf);, dịch lại, mở .s: lần này xuất hiện bl explicit_bzero. Chỉ hai lần đọc assembly là bạn thấy tận mắt vì sao "xóa mật khẩu bằng memset" là một cái bẫy — và vì sao các hàm xóa an toàn tồn tại.