Hãy tưởng tượng ba kiểu kiểm tra an toàn một căn nhà. Người thứ nhất đi lướt một vòng, thấy tường không đổ thì gật đầu "ổn". Người thứ hai cầm bảng kiểm, soi từng vết nứt, ổ cắm lỏng, cầu thang thiếu tay vịn — bắt được kha khá. Người thứ ba là kỹ sư kết cấu: anh ta lần theo đường truyền tải trọng, tính xem cây cột nào sẽ gãy khi có động đất — thấy những thứ hai người kia không thể thấy. Nhưng ngay cả kỹ sư giỏi cũng bó tay nếu bạn đã trát kín tường trước khi anh ta tới. Compiler khi làm công cụ tìm lỗi có đúng ba mức đó, và cái bẫy nằm ở người thứ ba.

Suốt sê-ri này ta dùng compiler như cỗ máy sinh mã: đưa vào C, nhận ra assembly nhanh. Nhưng nó còn một vai trò thứ hai mà ít người tận dụng hết: 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ắt thường bỏ qua. Câu hỏi thực tế là: 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ó thật sự nghĩa là code đúng? Tôi viết một file C gài sẵn lỗi rồi đo trong container gcc:13 — và câu trả lời có tới hai cú lật.

Ba mức: mặc định, -Wall, -fanalyzer

Tôi nhét vào một file C 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 dùng luôn mà không kiểm NULL; và một biến cục bộ tính ra rồi bỏ đó 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 đúng là mặc định mà rất nhiều người vẫn build hằng ngày, và nó không che chắn cho 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 chỉ bằng hai cờ. -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à — chỗ này hơi bất ngờ với tôi — 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ể là 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 chơi một trò 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 để lọt. Đây là mức bảo vệ mạnh nhất gcc cho sẵn, đúng anh kỹ sư lần theo đường tải trọng.

Cú lật thứ nhất: sạch không có nghĩa là đúng

Tôi vào đo với hai niềm tin rất phổ biến, và cả hai đều ngã. Niềm tin thứ nhất: "code biên dịch sạch — không cảnh báo — là code đúng". 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 điều gì về tính đúng. Niềm tin thứ hai: "bật -Wall là đủ". Nhưng -Wall -Wextra, dù bắt 4/6, vẫn để lọt đúng 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 lộ. Nửa đầu bài học rõ ràng: "biên dịch sạch" không bằng "đúng"; bạn phải bật đúng cờ, và với an toàn bộ nhớ thì -Wall chưa đủ — cần tới phân tích tĩnh.

Cú lật thứ hai: công cụ mạnh vẫn có điều kiện của nó

Nhưng đây mới là chỗ tôi phải sửa lại chính mình 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 hề. 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 cờ -fanalyzer, mà bật thêm -O2 là nó câm. Đúng anh kỹ sư giỏi nhất cũng không soi được qua bức tường đã trát kín.

Đây lại chính 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 đều 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 mới 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 rất tốn). 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ộ máy

Một điểm đáng dừng lại: rất 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 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 luôn 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 là đổ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ề đến code hằng ngày

Điều đầu tiên: luôn bật -Wall -Wextra, và bật -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ủ — nó không bảo vệ gì cả. -Werror biến cảnh báo thành lỗi build, buộc phải sửa thay vì cuộn qua.

Điều 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.

Và cái thang ba bậc này không riêng gì C — mọi hệ sinh thái trưởng thành đều có đúng nó, chỉ khác tên. Java: javac -Xlint:all là bậc -Wall, còn Error Prone (Google) hay SpotBugs mô phỏng luồng để bắt null-deref và rò rỉ resource — đúng vai -fanalyzer, và IDE thường gạch chân chúng ngay lúc bạn gõ. Go đóng gói bậc hai vào chuẩn luôn: go vet chạy mặc định trong go test, và staticcheck là tầng phân tích sâu hơn. Ngay cả CSDL cũng có tinh thần này: EXPLAIN cảnh báo bạn một câu query sẽ quét toàn bảng trước khi nó kịp làm chậm production. Mẫu số chung ở khắp nơi: "chạy được / biên dịch được" là điều kiện cần, không phải đủ — luôn có một tầng công cụ sâu hơn đang chờ bạn bật lên, và ngay cả tầng mạnh nhất cũng có giới hạn cần biết.

Muốn tự thấy thì lấy một file C bạn đang có, 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, nhớ 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 thấy compiler biết về lỗi của bạn nhiều hơn bạn tưởng; nó chỉ đợi bạn bật đúng cờ để nói ra.