Ở bài về cờ cảnh báo, tôi thấy -Wall -Wextra bắt gọn các lỗi cú pháp và lỗi cục bộ, nhưng bó tay với lỗi luồng dữ liệu như dùng biến chưa khởi tạo — và kết rằng những lỗi đó cần một công cụ lúc chạy. Bài này chính là công cụ đó: AddressSanitizer (-fsanitize=address, quen gọi là ASan), một tầng lưới thứ hai theo dõi từng truy cập bộ nhớ khi chương trình chạy. Tôi đo nó trong container — và cái cách nó suýt không bắt được lỗi của tôi lại dạy đúng một bài xuyên suốt cả sê-ri.

ASan hoạt động thế nào

-fsanitize=address bảo trình biên dịch chèn mã đo đạc quanh mọi truy cập bộ nhớ, và cấp thêm một vùng gọi là shadow memory để đánh dấu byte nào hợp lệ, byte nào không. Quanh mỗi khối malloc, nó đặt các vùng đỏ (redzone) đầu độc; hễ chương trình đọc hay ghi vào một byte đầu độc — ngoài mảng, sau khi free, ngoài khung ngăn xếp — ASan dừng ngay và in một báo cáo chính xác: loại lỗi, địa chỉ, kích thước truy cập (WRITE of size 4), đúng dòng mã, cùng nơi vùng đó được cấp phát và nơi nó bị giải phóng. Đây chính là tầng bắt được các lỗi luồng dữ liệu mà cảnh báo tĩnh đành chịu thua.

Đo: lỗi mà cả -Wall lẫn "chạy thử" đều bỏ qua

Tôi viết một lỗi ghi ngoài mảng kinh điển: cấp malloc(10 * sizeof(int)) rồi truy cập a[10] — chỉ số hợp lệ chỉ từ 0 tới 9.

Biên dịch với -Wall -Wextra: 0 cảnh báo. Đúng như bài trước, phân tích tĩnh không thấy gì. Chạy chương trình thường (không ASan): nó in a[10] = 42 rồi thoát bình thường, exit 0, không sập. Byte ngay sau mảng tình cờ nằm trong vùng heap hợp lệ, nên ghi/đọc vào đó không gây lỗi phần cứng. Nói cách khác: lỗi có thật, nhưng "chạy thử thấy ổn" — một cái bẫy chết người, vì "chạy được" bị nhầm thành "đúng".

Biên dịch với -fsanitize=address và chạy, ASan dừng ngay và báo:

ERROR: AddressSanitizer: heap-buffer-overflow on address 0x604...038
WRITE of size 4 at 0x604...038 thread T0
    #0 ... in main oob3.c:8

Đúng dòng, đúng loại (heap-buffer-overflow), đúng thao tác (WRITE 4 byte), và exit 1. Với lỗi dùng sau khi giải phóng, ASan cũng bắt gọn, kèm cả "freed by thread T0 here" và "previously allocated here" — chỉ thẳng nơi bạn free và nơi bạn cấp phát. Cái mà -Wall chỉ dám nói "maybe" thì ASan nói chắc chắn.

Cú im lặng suýt làm tôi kết luận sai

Tôi vào bài đầy tự tin: viết a[10] = 42; int r = a[10];, biên dịch -fsanitize=address, và ngồi chờ ASan hét lên. Chạy: im lặng. In ra bình thường, exit 0. ASan không bắt gì cả. Tôi kiểm lại — libasan liên kết đúng, và với một lỗi dùng sau khi giải phóng thì ASan bắt ngon lành. Vậy sao nó bỏ qua cái ghi ngoài mảng rành rành này?

Đọc kỹ mới ra: tôi biên dịch ở -O1, và trình biên dịch đã gấp cặp "ghi rồi đọc" ngoài vùng đó thành một hằng số. a[10] = 42; r = a[10] — trình biên dịch suy ra r chắc chắn bằng 42, nên nó chỉ in 42 mà không thực sự thực hiện phép ghi/đọc bộ nhớ nào. Không có truy cập thật xảy ra lúc chạy, nên ASan — vốn chỉ đo các truy cập thật — chẳng có gì để bắt. Để lỗi thật sự thực thi, tôi phải làm chỉ số idx thành volatile và biên dịch -O0; lúc đó phép ghi ngoài vùng mới chạy thật, và ASan lập tức báo heap-buffer-overflow đúng dòng.

Đây là bài học kép, cả hai đều là sợi chỉ xuyên suốt sê-ri. Thứ nhất: ASan bắt cái thật sự chạy, không bắt cái mà optimizer đã chứng minh là bỏ đi được. Nó là công cụ động, không phải phân tích tĩnh — nếu một truy cập lỗi bị tối ưu hóa xóa mất, ASan im lặng, và im lặng đó hoàn toàn đúng theo nghĩa của nó (không có lỗi runtime nào xảy ra thật). Thứ hai, và thực tế hơn: mức -O là một biến ẩn khi test bằng sanitizer. Một lỗi có thể biến mất ở -O cao (bị gấp đi) rồi hiện lại ở -O0 — nên khi dựng test cho sanitizer, phải đảm bảo phép truy cập lỗi thật sự được thực thi, thường bằng cách chạy trên dữ liệu thật, dùng volatile, hoặc biên dịch ở mức tối ưu thấp. Tôi suýt kết luận "ASan không bắt được lỗi này" — trong khi thật ra chính optimizer đã xóa lỗi trước khi ASan kịp thấy.

Về đến code hằng ngày

Điều đầu tiên: dùng lưới hai tầng — cảnh báo tĩnh ở khâu biên dịch, sanitizer ở khâu chạy test. -Wall -Wextra (và -Werror) chặn lỗi cú pháp/cục bộ khi build; -fsanitize=address (cùng -fsanitize=undefined cho hành vi không xác định, -fsanitize=thread cho đua tranh luồng) chặn lỗi bộ nhớ/luồng khi chạy bộ test. Hai tầng bắt hai lớp lỗi khác nhau; thiếu tầng nào là để một lớp lỗi lọt hết. Bật ASan trong CI cho suite test là một trong những khoản đầu tư chất lượng đáng giá nhất — nó biến những lỗi bộ nhớ vốn "thỉnh thoảng sập, không tái hiện được" thành báo cáo chính xác, tái hiện được.

Điều thứ hai: "chạy không sập" không phải bằng chứng đúng. Lỗi ghi ngoài mảng của tôi chạy trơn, in kết quả hợp lý, exit 0 — và vẫn là một lỗi hỏng bộ nhớ có thể thành lỗ hổng bảo mật hoặc sập ngẫu nhiên trên một bố cục heap khác. Đừng tin một lần chạy thành công; hãy chạy dưới sanitizer để biết chương trình thật sự không chạm vào bộ nhớ nó không được phép. Rất nhiều bug "không tái hiện được" chỉ đơn giản là chưa ai chạy chúng dưới ASan.

Điều thứ ba là cái giá phải nhớ: sanitizer là công cụ lúc test, không phải để ship hay để benchmark. Tôi đo ASan làm chương trình chậm khoảng 2 lần (27 xuống 57 mili giây cho cùng vòng lặp) và tốn thêm bộ nhớ đáng kể (shadow memory ~3 lần) — bình thường với một công cụ instrument mọi truy cập. Đừng bật ASan trong bản phát hành, và tuyệt đối đừng đo hiệu năng dưới nó: con số bạn thấy là tốc độ của ASan, không phải của mã bạn. Gói lại bằng số: ASan bắt lỗi ghi/đọc ngoài mảng và dùng-sau-giải-phóng mà -Wall bỏ lọt và "chạy thử" giấu đi, chỉ thẳng dòng và offset — nhưng chỉ bắt truy cập thật sự chạy (ở -O cao lỗi có thể bị gấp mất), và tốn ~2x tốc độ nên chỉ dùng khi test.

Cái tinh thần "lưới nhiều tầng, mỗi tầng bắt một lớp lỗi" này không riêng gì C. Java có phiên bản của cả hai tầng: compiler và IDE cảnh báo tĩnh, còn lúc chạy thì -ea bật assertion, và những công cụ như jqwik hay các bộ property-test đóng vai công cụ động soi ra lỗi mà mắt thường bỏ qua — thậm chí -Xcheck:jni bắt lỗi vùng nhớ khi gọi native, đúng họ hàng với ASan. Go đi thẳng vào tận nhân: go test -race là một ThreadSanitizer thu nhỏ dựng sẵn, chỉ ra data race mà "chạy thử thấy ổn" luôn giấu; go test -fuzz thì ném dữ liệu thật vào để lỗi thật sự thực thi — đúng cái bài học "-O gấp lỗi đi" nhắc: lỗi chỉ lộ khi nó được chạy. Ngay cả cơ sở dữ liệu cũng có tầng động của riêng nó: một ràng buộc CHECK hay FOREIGN KEY là lưới bắt lúc ghi cái mà kiểu cột (tầng tĩnh) không bắt nổi. Mẫu số chung ở mọi tầng: đừng tin một lần chạy trơn, và hãy có một công cụ chạy thật soi vào chỗ mắt người và phân tích tĩnh đều mù.

Muốn tự thấy thì viết một lỗi bộ nhớ đơn giản — int*a=malloc(4*sizeof(int)); a[4]=1; printf("%d\n", a[4]); — và biên dịch hai cách: gcc -O0 -fsanitize=address rồi chạy, và gcc -O2 (không sanitizer) rồi chạy. Bản thường rất có thể in ra bình thường, exit 0, như thể không có gì sai; bản ASan sẽ dừng ngay với heap-buffer-overflow, chỉ đúng dòng a[4]. Nếu bản ASan cũng im lặng, thử đổi chỉ số 4 thành một biến volatile hoặc lấy từ argc — bạn vừa gặp đúng cái bẫy của bài này: ở mức tối ưu cao, phép truy cập lỗi có thể bị gấp đi trước khi ASan kịp thấy, nên phải đảm bảo nó thật sự chạy.