Ở ba bài đầu ta đã theo một file qua bốn giai đoạn dịch. Nhưng dự án thật có nhiều file .c, và câu hỏi quan trọng là: chúng được dịch cùng nhau hay riêng rẽ? Câu trả lời — mỗi .c dịch hoàn toàn độc lập thành một .o, rồi mới nối lại lúc liên kết — định hình gần như mọi thứ về cách C tổ chức chương trình, và là gốc của một loại bug âm thầm đặc biệt khó chịu. Bài này đo tính độc lập đó, và dựng một cái bẫy lệch kiểu xuyên file — rồi chính cái bẫy lật lại dạy tôi một bài học về "số đo trùng khớp".

Đơn vị dịch

Mỗi file là một đơn vị dịch riêng

Một đơn vị dịch (translation unit) là một file .c cùng tất cả header nó include, được dịch thành đúng một file đối tượng .o. Điểm cốt lõi: trình biên dịch xử lý mỗi đơn vị dịch một cách hoàn toàn cô lập — khi dịch a.c, nó không biết gì về b.c. Nếu a.c gọi một hàm định nghĩa ở b.c, trình dịch chỉ để lại một ký hiệu ngoài (undefined) trong a.o, phó thác cho bước liên kết nối lại sau. Nó không kiểm tra tính nhất quán giữa các file.

Việc một tên ở phạm vi file có "thò" ra cho file khác thấy hay không được quyết định bởi liên kết (linkage). Tên không static (mặc định) có liên kết ngoài: nó hiện diện như một ký hiệu toàn cục, dùng chung được giữa các đơn vị dịch. Tên có static (ở phạm vi file) có liên kết nội bộ: chỉ nhìn thấy trong file của nó. Tôi đo cả hai.

Đo: static giữ tên trong nhà, không static thì đụng nhau

Hai file, mỗi file có một biến static int secret với giá trị khác nhau (111 và 222) và một hàm đọc nó. Dịch, liên kết, chạy:

read1=111  read2=222

Mỗi file giữ secret riêng của mình, không hề đụng nhau, dù trùng tên. Nhìn vào bảng ký hiệu (nm f1.o) thấy rõ:

d secret     (chữ THƯỜNG 'd' = liên kết nội bộ, file khác không thấy)
T read1      (chữ HOA 'T'   = liên kết ngoài, dùng chung được)

Quy ước của nm rất gọn: chữ hoa là ký hiệu ngoài (toàn cục), chữ thường là nội bộ (cục bộ trong file). static đã hạ secret xuống nội bộ nên hai file cùng tên vẫn sống chung hòa bình.

Ngược lại, khi tôi bỏ static — hai file cùng có int total; với liên kết ngoài — thì lúc liên kết:

multiple definition of `total'

Hai ký hiệu toàn cục trùng tên là lỗi liên kết cứng. Đây là lý do static quan trọng trong C: nó là công cụ để giấu hàm và biến phụ trợ trong một file, tránh đụng tên với phần còn lại của chương trình. Đến đây mọi thứ đúng như sách. Rồi tôi dựng cái bẫy.

Một lần tôi đo hớ: hai số 42 trùng nhau che một cái bug

Vì mỗi đơn vị dịch được dịch cô lập, tôi muốn chứng minh hậu quả: nếu hai file bất đồng về kiểu của một biến dùng chung, không ai bắt được. Tôi cho a.c định nghĩa int shared = 42; (4 byte), còn b.c khai báo extern long shared; (8 byte) rồi đọc nó. Tôi đoán chắc b sẽ đọc ra rác — nó đọc 8 byte từ một ô chỉ có 4 byte — và con số rác đó sẽ chứng minh cái bug. Đo ra:

a đọc (int)  = 42
b đọc (long) = 42

b đọc ra 42 — đúng y hệt! Cái bug tôi định phô bày đã không xuất hiện. Suýt thì tôi kết luận "hóa ra không sao". Nhưng hai số trùng khớp làm tôi cảnh giác thay vì yên tâm. Kiểm bằng nm --print-size a.o: shared đúng là 4 byte. Vậy b thật sự đọc 8 byte từ ô 4 byte — bug có thật — nhưng 4 byte kề bên shared trong bộ nhớ tình cờ bằng 0, nên phần cao của "long" là 0, và giá trị đọc ra vẫn là 42. Cái bug bị che bởi may mắn của layout bộ nhớ.

Đây là bài học đo lường cốt lõi: hai số đo trùng khớp không có nghĩa là đúng — nó chỉ có nghĩa là lỗi chưa lộ ra lần này. Nếu shared được đặt cạnh một byte khác 0 (chỉ cần thêm một biến toàn cục nữa, đổi thứ tự liên kết, hay đổi trình dịch), b sẽ đọc ra một con số sai lệch. Và chốt lại: không một ai trong chuỗi dịch bình thường bắt được lỗi này. Trình biên dịch nhìn a.cb.c mỗi cái một mình, nên không thấy mâu thuẫn. Linker khớp ký hiệu theo tên (shared khớp shared), bỏ qua kiểu hoàn toàn. Chỉ khi tôi bật -flto (tối ưu lúc liên kết, phá vỡ sự cô lập để nhìn xuyên các đơn vị dịch), trình dịch mới cảnh báo: type of 'shared' does not match original declaration. Chính cơ chế "mỗi file dịch một mình" — thứ khiến C biên dịch nhanh và chia nhỏ được — cũng là thứ khiến không ai canh được tính nhất quán chéo giữa các file.

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

Hệ quả đầu tiên là luôn khai báo giao diện chung qua một header. Cái bug lệch kiểu ở trên biến mất hoàn toàn nếu shared được khai báo một lần trong một header mà cả a.c (nơi định nghĩa) và b.c (nơi dùng) đều include — vì khi đó cả hai đơn vị dịch thấy cùng một khai báo, và trình dịch bắt được mâu thuẫn trong từng file. Đây là lý do sâu xa của quy tắc "định nghĩa ở .c, khai báo extern trong .h, và file định nghĩa cũng include header của chính nó": để trình dịch có cơ hội đối chiếu định nghĩa với khai báo trong cùng một đơn vị dịch.

Hệ quả thứ hai là dùng static một cách hào phóng cho mọi thứ không cần lộ ra. Mỗi hàm phụ trợ, mỗi biến toàn cục nội bộ mà bạn không đánh static đều trở thành một ký hiệu toàn cục có thể đụng tên với phần khác của chương trình (hoặc với thư viện), và tệ hơn, có thể bị file khác vô tình extern tới và phụ thuộc vào. Đánh static là thu hẹp bề mặt, giảm đụng độ, và cho trình dịch tối ưu mạnh tay hơn vì nó biết chắc không ai ngoài file này dùng tới.

Hệ quả thứ ba là hiểu vì sao build C nhanh mà cũng nguy hiểm. Biên dịch độc lập cho phép build song song và chỉ dịch lại file đã đổi (nền tảng của make), nhưng cái giá là không có kiểm tra nhất quán toàn cục — sự thống nhất giữa các file hoàn toàn dựa vào việc bạn kỷ luật dùng chung header. Con số mang theo: mỗi .c là một đơn vị dịch dịch cô lập; static cho liên kết nội bộ (giấu trong file), không static cho liên kết ngoài (dùng chung, đụng tên là lỗi); và linker khớp theo TÊN chứ không theo KIỂU — nên một lệch kiểu xuyên file lọt qua cả trình dịch lẫn linker, chỉ -flto mới bắt. Và đừng bao giờ tin hai số đo trùng nhau là bằng chứng của đúng đắn; đôi khi đó chỉ là một cái bug đang may mắn.

Thử ba mươi giây

Tạo a.c với int n = 5; và một hàm trả về nó, rồi b.c với extern short n; (khai báo sai kiểu) và một hàm đọc n. Dịch riêng (gcc -c a.c b.c) rồi liên kết — bạn sẽ thấy không lỗi, không cảnh báo. Chạy thử, có thể vẫn ra đúng (do may mắn layout). Giờ thêm gcc -flto vào cả hai lệnh: lần này trình dịch cảnh báo type mismatch. Chỉ hai lần dịch là bạn thấy tận mắt khoảng mù giữa các đơn vị dịch — và vì sao khai báo chung qua header, chứ không phải extern tay, mới là cách an toàn.