bài về cờ cảnh báo, tôi thấy -Wall -Wextra bắt các lỗi cú pháp/cục bộ nhưng bỏ lọt 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 công cụ lúc chạy. Bài này là công cụ đó: AddressSanitizer (-fsanitize=address, gọi tắt 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 một bài đúng chủ đề cả sê-ri.

Sanitizer lúc chạy

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 phát 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ã, và nơi vùng đó được cấp phát cùng 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 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ỉ là 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. Chạy chương trình thường (không ASan): nó in a[10] = 42 và 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ỉ "maybe" thì ASan nói chắc chắn.

Một lần tôi đo hớ: ASan chỉ bắt cái thật sự chạy

Tôi vào bài tự tin: viết a[10] = 42; int r = a[10];, biên dịch -fsanitize=address, và 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 42khô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.

Bài học đo lường 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 biến mất, ASan 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ứ 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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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.

Hệ quả 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.

Hệ quả 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. Con số mang theo: 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. Hai tầng lưới, đúng công cụ cho đúng lớp lỗi, và luôn hỏi "lỗi này có thực thi không".

Thử ba mươi giây

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.