Suốt sê-ri này ta dùng compiler như một cỗ máy sinh mã: đưa vào C, nhận ra assembly nhanh. Nhưng compiler còn một vai trò thứ hai ít được tận dụng: công cụ tìm lỗi. Nó đọc code kỹ hơn con người, và nếu bạn bật đúng cờ, nó chỉ ra những lỗi mà mắt thường bỏ qua. Câu hỏi thực tế: bật cờ cảnh báo bắt được bao nhiêu lỗi, và "biên dịch không cảnh báo" có nghĩa là code đúng không? Tôi viết một file đầy lỗi thật rồi đo — và câu trả lời có hai cú lật.
Ba mức: mặc định, -Wall, -fanalyzer
Tôi viết một file C gcc:13 với sáu lỗi thật: một biến dùng khi chưa khởi tạo; một so sánh giữa unsigned và int (nguồn của vô số bug ngầm); một con trỏ dùng sau khi đã free; một malloc không bao giờ free (rò rỉ bộ nhớ); một malloc mà không kiểm tra NULL trước khi dùng; và một biến cục bộ tính ra rồi không dùng. Rồi biên dịch ở ba mức cờ.
Mức mặc định (chỉ -O2, không cờ cảnh báo):
số cảnh báo: 0 / 6 lỗi
Không một lời nào. Compiler nuốt cả sáu lỗi trong im lặng. Đây là mặc định mà rất nhiều người vẫn dùng, và nó không bảo vệ bạn khỏi bất cứ điều gì.
Mức -Wall -Wextra:
bắt 4/6: biến chưa khởi tạo, so sánh dấu, dùng-sau-free, biến thừa
sót: rò rỉ bộ nhớ, và dùng malloc không kiểm NULL
Nhảy từ 0 lên 4. -Wall bắt biến chưa khởi tạo và biến thừa; -Wextra thêm cảnh báo so sánh dấu; và — điều làm tôi hơi bất ngờ — gcc 13 bắt luôn cả dùng-sau-free đơn giản (-Wuse-after-free) ngay ở mức này. Nhưng nó sót hai lỗi bộ nhớ nghiêm trọng: rò rỉ và dùng con trỏ có thể NULL.
Mức -fanalyzer (phân tích tĩnh của gcc):
bắt thêm: rò rỉ bộ nhớ (-Wanalyzer-malloc-leak),
null-deref (-Wanalyzer-possible-null-dereference),
và UAF / dùng-chưa-khởi-tạo theo đường đi
-fanalyzer khác hẳn: nó mô phỏng các đường đi thực thi của chương trình, nên bắt được những lỗi phụ thuộc luồng mà cảnh báo cú pháp bỏ qua — chính là rò rỉ và null-deref mà -Wall sót. Đây là mức bảo vệ mạnh nhất gcc cung cấp sẵn.
Cú lật thứ nhất: sạch không bằng đúng
Tôi vào đo với niềm tin phổ biến: "code biên dịch sạch — không cảnh báo — là code đúng", và "bật -Wall là đủ". Đo phá cả hai. Mức mặc định bắt 0/6: một file đầy lỗi bộ nhớ và hành vi không xác định vẫn "biên dịch sạch" như thường. Vậy "không có cảnh báo" ở cấu hình mặc định không nói lên gì cả về tính đúng. Và -Wall -Wextra, dù bắt 4/6, vẫn để lọt hai lỗi bộ nhớ nguy hiểm nhất (rò rỉ, null-deref) — những lỗi mà hành vi không xác định sinh ra và compiler có thể tối ưu theo cách bất ngờ. Chỉ khi bật -fanalyzer, hai lỗi đó mới hiện ra. Bài học nửa đầu rõ ràng: "biên dịch sạch" không bằng "đúng"; bạn phải bật cờ đúng, và với an toàn bộ nhớ thì -Wall chưa đủ — cần phân tích tĩnh.
Cú lật thứ hai: công cụ mạnh vẫn có điều kiện
Nhưng đây là chỗ đo hớ được sửa theo chiều thứ hai, và nó quan trọng không kém. Tôi tưởng "cứ chạy -fanalyzer là bắt hết lỗi bộ nhớ". Không. Khi tôi chạy -fanalyzer cùng với -O2, nó bỏ sót chính cái rò rỉ bộ nhớ mà nó bắt được khi chạy ở -O0. Lý do: -O2 biến đổi code trước khi (hoặc theo cách ảnh hưởng tới) bộ phân tích nhìn vào, nên kết quả của -fanalyzer phụ thuộc vào mức tối ưu. Cùng một lỗi, cùng một cờ -fanalyzer, mà bật thêm -O2 là nó câm.
Đây đúng là bài học của cả sê-ri, lặp lại ở một công cụ khác: mỗi công cụ đo/phân tích có điều kiện của nó — như perf cần PMU phần cứng mà máy ảo không có, -fanalyzer cần mức tối ưu phù hợp để cho kết quả đầy đủ. Ngoài chuyện phụ thuộc -O, phân tích tĩnh còn có thể báo nhầm (false positive — cảnh báo một lỗi không có thật) và chậm trên mã lớn (vì mô phỏng đường đi tốn kém). Nó là công cụ mạnh, không phải bùa hộ mệnh.
Cảnh báo và tối ưu dùng chung một bộ phân tích
Một điểm đáng nhớ: nhiều cảnh báo của compiler sinh ra từ chính công việc tối ưu. Để biết một biến "chưa khởi tạo" hay một nhánh "không bao giờ chạy", compiler phải làm đúng thứ phân tích luồng dữ liệu mà nó dùng để khử mã chết và lan truyền hằng. Đó là lý do -Wmaybe-uninitialized chỉ hiện ra khi bật tối ưu (-O1 trở lên) — không có phân tích luồng thì compiler không "thấy" biến chưa khởi tạo. Nói cách khác, cảnh báo là tác dụng phụ có ích của bộ tối ưu: cùng một phân tích vừa giúp sinh mã nhanh vừa giúp bắt lỗi. Điều này cũng giải thích cú lật thứ hai ở trên — vì cảnh báo và phân tích gắn với pha tối ưu, nên đổi mức -O đổi luôn cái compiler nhìn thấy và báo. Hai vai trò "sinh mã" và "tìm lỗi" của compiler không tách rời; chúng chia nhau cùng một bộ máy phân tích.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: luôn bật -Wall -Wextra, và coi -Werror cho CI. Nhảy từ 0 lên 4 lỗi bắt được chỉ bằng vài cờ là món hời lớn nhất trong nghề. Đừng để mặc định im lặng ru ngủ bạn — nó không bảo vệ gì. Bật -Werror biến cảnh báo thành lỗi build, buộc phải sửa thay vì cuộn qua.
Hệ quả thứ hai: với an toàn bộ nhớ, thêm -fanalyzer (và các sanitizer lúc chạy). Cảnh báo cú pháp bắt lỗi cục bộ; lỗi luồng (rò rỉ, dùng-sau-free qua nhiều hàm, null-deref có điều kiện) cần phân tích đường đi. -fanalyzer cho một tầng, -fsanitize=address,undefined cho một tầng khác lúc chạy — dùng cả hai, và nhớ kiểm ở nhiều mức -O vì kết quả phân tích tĩnh phụ thuộc mức tối ưu.
Hệ quả thứ ba là bài học đo lường bao trùm: đừng đọc "không có cảnh báo" là "đúng"; hỏi công cụ đã được cấu hình để tìm cái gì. Con số mang theo: mặc định gcc bắt 0/6 lỗi (im lặng hoàn toàn); -Wall -Wextra bắt 4/6 (chưa khởi tạo, so sánh dấu, dùng-sau-free, biến thừa) nhưng SÓT rò rỉ và null-deref; chỉ -fanalyzer (mô phỏng đường đi) bắt hai lỗi bộ nhớ đó — NHƯNG -fanalyzer ở -O2 lại sót chính cái rò rỉ nó bắt ở -O0, kết quả phụ thuộc điều kiện. "Biên dịch sạch" là điều kiện cần, không phải đủ; bật cờ đúng là bắt buộc, và ngay cả công cụ mạnh nhất cũng có giới hạn cần biết.
Thử ba mươi giây
Lấy một file C bạn đang có và biên dịch lại với gcc -O2 -Wall -Wextra -c file.c — rất có thể bạn sẽ thấy vài cảnh báo chưa từng để ý, vì bạn (như phần lớn mọi người) vẫn build ở mặc định. Rồi thử gcc -fanalyzer -c file.c (chú ý: bỏ -O2 để analyzer nhìn rõ nhất) — nếu code có đụng malloc/free, bạn có thể thấy những cảnh báo rò rỉ hay null-deref mà -Wall im lặng. Ba mươi giây đó cho bạn thấy compiler biết nhiều về lỗi của bạn hơn bạn tưởng — nó chỉ đợi bạn bật đúng cờ để nói ra.