Có một lỗi liên kết làm rối trí người mới: gcc main.o -lfoo dịch ngon lành, nhưng gcc -lfoo main.o — cùng những file đó, chỉ đảo chỗ — báo "undefined reference to foo". Cả hai lệnh đều thư viện trên dòng lệnh, sao một cái được một cái không? Đây là chuyện thứ tự liên kết thư viện, và như bài đơn vị dịchbảng ký hiệu đã chuẩn bị, nó bắt nguồn từ cách trình liên kết xử lý ký hiệu. Bài này đo cái lỗi đó, và tôi vấp đúng chỗ mình tưởng đã hiểu.

Thứ tự liên kết thư viện

Trình liên kết đi một lượt, trái sang phải

Trình liên kết xử lý các file và thư viện trên dòng lệnh theo một lượt, từ trái sang phải, giữ một danh sách các ký hiệu chưa giải (undefined). Khi gặp một file đối tượng (.o), nó thêm các ký hiệu file đó cung cấp và ghi nhận các ký hiệu file đó cần. Khi gặp một thư viện tĩnh (.a — thực chất là một kho chứa nhiều .o), nó chỉ rút ra những thành viên giải quyết các ký hiệu đang chưa giải tại thời điểm đó. Đây là điểm mấu chốt: một ký hiệu trở thành "chưa giải" sau khi thư viện đã được xử lý sẽ không kéo được thành viên từ thư viện đó nữa — vì trình liên kết đã đi qua, không quay lại. Cách làm một-lượt này có lý do lịch sử: nó cho phép trình liên kết chạy nhanh và tốn ít bộ nhớ trên những máy xưa, chỉ cần giữ danh sách ký hiệu chưa giải thay vì nạp toàn bộ mọi thư viện lên rồi giải chéo. Cái giá là sự nhạy cảm thứ tự mà ta gặp hôm nay.

Hệ quả: thư viện tĩnh phải đứng sau file dùng nó. Tôi dựng một libmymath.a chứa hàm square, và một main.o gọi square, rồi thử hai thứ tự:

gcc main.o libmymath.a   ->  OK
gcc libmymath.a main.o   ->  undefined reference to `square'

Cùng hai file, đảo chỗ, một cái dịch được một cái không. Khi .a đứng trước main.o, lúc trình liên kết xử lý .a thì chưa có ký hiệu nào chưa giải (nó chưa thấy main.o), nên nó rút 0 thành viên; rồi main.o mới khai square là chưa giải, nhưng .a đã qua rồi → không ai cung cấp square → lỗi. Khi main.o đứng trước, square đang chưa giải lúc gặp .a, nên thành viên chứa square được rút → xong. Và chú ý một điều tinh tế: trình liên kết chỉ rút đúng những thành viên .o cần thiết trong kho, không phải cả kho — nên một libc.a khổng lồ đứng đúng chỗ cũng chỉ góp vào file thực thi các hàm bạn thật sự gọi, đúng như đã thấy khi so kích thước liên kết tĩnh. Hiểu tới đây, tôi đã đoán về thư viện động — và đoán sai.

Một lần tôi đo hớ: chỉ thư viện tĩnh mới kén thứ tự

Tôi tưởng luật "thư viện đặt sau file dùng nó" áp cho mọi loại thư viện, và với cờ --as-needed (mặc định trên nhiều hệ thống), thư viện động (.so) cũng phải nhạy cảm thứ tự — vì --as-needed bỏ qua một .so không có ký hiệu nào đang cần lúc gặp nó. Để kiểm, tôi thử thư viện động với cả hai thứ tự:

gcc main.o -lmymath   ->  OK
gcc -lmymath main.o   ->  OK   (chạy được, trả về 49)

Cả hai thứ tự đều liên kết được và chạy đúng — kể cả --as-needed mặc định. Kiểm lại bằng objdump -p, bản "thư viện trước" vẫn ghi đủ NEEDED libmymath.so và chạy trả về 49 (square(7)). Nghĩa là trình liên kết đã nhìn thấy main.o cần square, tra ra libmymath.so cung cấp nó, và ghi phụ thuộc — bất kể .so đứng trước hay sau trên dòng lệnh. Vậy chỉ thư viện tĩnh kén thứ tự; thư viện động xuôi ngược đều được. Đoán của tôi sai.

Lý do nằm ở sự khác biệt cơ bản đã đo: thư viện tĩnh rút thành viên theo nhu cầu (chỉ lấy cái giải quyết ký hiệu đang chưa giải, một lượt), nên vị trí quyết định có rút được hay không. Còn thư viện động không rút thành viên — trình liên kết chỉ ghi một mục phụ thuộc NEEDED vào file thực thi (như bài trình liên kết động đã thấy), để trình liên kết động lúc chạy lo phần còn lại. Vì không có chuyện "rút hay không rút", .so đỡ kén thứ tự hơn hẳn. Bài học đo lường: một quy tắc "an toàn thì cứ theo" (đặt thư viện sau) có thể áp dụng cho một trường hợp mà cơ chế thật của nó không đòi hỏi — luật thứ tự sinh ra từ cách .a rút thành viên, nên nó là luật của .a, không phải của mọi thư viện. Tôi đã tổng quát hóa một hành vi vượt quá cái nguyên nhân tạo ra nó.

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

Hệ quả đầu tiên là quy tắc thực dụng: luôn đặt thư viện sau các file/thư viện dùng nó. gcc main.o -lfoo -lbar (thư viện ở cuối) gần như luôn đúng; gcc -lfoo main.o là công thức gây "undefined reference" bí ẩn với thư viện tĩnh. Đây là quy tắc an toàn cho cả hai loại (dù .so không đòi hỏi), nên cứ theo — nhưng biết vì sao giúp bạn không hoảng khi thấy lỗi.

Hệ quả thứ hai là phụ thuộc vòng giữa các thư viện tĩnh cần xử lý riêng. Nếu libA.a cần ký hiệu trong libB.alibB.a lại cần ký hiệu trong libA.a, không thứ tự tuyến tính nào thỏa mãn cả hai trong một lượt — bạn phải liệt kê một thư viện hai lần (-lA -lB -lA), hoặc bọc chúng trong -Wl,--start-group -lA -lB -Wl,--end-group để trình liên kết lặp lại tới khi hết ký hiệu chưa giải. Đây là một tình huống chỉ xảy ra với thư viện tĩnh, đúng vì cơ chế rút thành viên một lượt. Trình liên kết hiện đại thường đủ thông minh để tự lặp lại khi cần, nhưng biết mẹo --start-group vẫn cứu bạn khi gặp một chuỗi thư viện cũ phụ thuộc lẫn nhau.

Hệ quả thứ ba là hiểu "undefined reference" đúng ngữ cảnh. Thấy lỗi này thư viện đã có trên dòng lệnh, nghi phạm số một là thứ tự: -lfoo đặt trước file dùng foo, với thư viện tĩnh. Con số mang theo: trình liên kết đi một lượt trái-phải, thư viện tĩnh .a chỉ rút thành viên cho ký hiệu ĐANG chưa giải — nên .a phải đứng sau file dùng nó, đảo chỗ là undefined reference; thư viện động .so không rút thành viên mà chỉ ghi phụ thuộc NEEDED nên xuôi ngược đều được. Quy tắc "thư viện đứng sau" là luật của kho tĩnh, và biết gốc rễ của nó giúp bạn gỡ đúng khi liên kết trục trặc — thay vì xáo trộn dòng lệnh một cách mê tín.

Thử ba mươi giây

Tạo foo.c với một hàm int foo(void){return 1;}, đóng thành thư viện tĩnh: gcc -c foo.c && ar rcs libfoo.a foo.o. Viết main.c gọi foo(), biên dịch gcc -c main.c. Giờ thử hai thứ tự: gcc main.o -L. -lfoo (chạy ngon) và gcc -L. -lfoo main.o (báo "undefined reference to foo"). Cùng hai file, chỉ đảo chỗ. Đó là toàn bộ luật thứ tự liên kết — và nếu bạn đóng libfoo thành .so (gcc -shared -fPIC -o libfoo.so foo.c) thì cả hai thứ tự đều chạy, cho bạn thấy tận mắt vì sao chỉ kho tĩnh mới kén.