Ở bài liên kết tĩnh và động ta thấy chương trình động khởi động nhanh bất ngờ. Lý do nằm ở cách trình liên kết động (ld.so) làm việc — cụ thể là ở một mẹo tên lazy binding. Bài này mở nắp cơ chế đó: hàm thư viện được gọi qua hai bảng PLT và GOT ra sao, và khi nào địa chỉ thật được điền vào. Tôi vào bài với một giả định về "khi nào" đó, và đo ra nó sai.
Gọi hàm thư viện qua PLT và GOT
Khi chương trình động của bạn gọi printf, nó không nhảy thẳng tới printf trong libc — vì địa chỉ của printf chưa biết lúc biên dịch (nó phụ thuộc vào nơi libc.so được nạp vào lúc chạy). Thay vào đó, trình biên dịch gọi qua một PLT (Procedure Linkage Table) — một đoạn stub nhỏ — và stub này nhảy gián tiếp qua một ô trong GOT (Global Offset Table) — bảng chứa địa chỉ thật. Đọc PLT của printf bằng objdump:
printf@plt:
adrp x16, ... ; trỏ tới bảng GOT
ldr x17, [x16, #..] ; NẠP địa chỉ printf từ ô GOT
br x17 ; nhảy tới địa chỉ đó
Stub nạp địa chỉ từ một ô GOT rồi nhảy tới. Mỗi hàm ngoài có một ô GOT riêng, đánh dấu bằng một quan hệ tái định vị kiểu R_AARCH64_JUMP_SLOT — chỗ mà ld.so sẽ điền địa chỉ thật vào. Sự gián tiếp qua bảng này chính là thứ cho phép cùng một libc.so được nạp vào địa chỉ khác nhau trong mỗi tiến trình (nền tảng của ASLR — ngẫu nhiên hóa địa chỉ): mã chương trình không nhúng địa chỉ tuyệt đối của printf, mà chỉ nhúng một cú nhảy qua ô GOT, còn ô GOT thì được điền theo nơi thư viện thực sự nằm lần này. Câu hỏi mấu chốt: ld.so điền các ô GOT đó lúc nào? Tôi tưởng mình biết.
Một lần tôi đo hớ: ld.so không phân giải mọi thứ lúc khởi động
Ở bài trước, tôi ngầm cho rằng ld.so phân giải mọi ký hiệu ngay khi nạp chương trình — điền hết các ô GOT trước khi main chạy. Để kiểm, tôi viết một chương trình gọi fprintf (có gọi thật) và liên kết cả sqrt từ thư viện toán (-lm) nhưng không bao giờ gọi nó, rồi dùng LD_DEBUG=bindings để xem ld.so phân giải (bind) những hàm nào:
fprintf (CÓ gọi): bound = 1 (được phân giải)
sqrt (KHÔNG gọi): bound = 0 (KHÔNG được phân giải)
sqrt — dù đã được liên kết vào chương trình — không hề được phân giải, vì tôi không gọi nó. Chương trình vẫn chứa thông tin để phân giải sqrt (một mục JUMP_SLOT trong GOT chờ sẵn), nhưng ld.so chỉ đơn giản không đụng tới nó cho tới khi có ai gọi. Đây là lazy binding (liên kết lười): mặc định, ld.so không điền ô GOT của một hàm lúc nạp; nó hoãn tới lần gọi đầu tiên của hàm đó. Lần đầu gọi printf, ô GOT còn trỏ về một resolver; resolver tìm địa chỉ thật của printf, vá ô GOT, rồi từ đó các lần gọi sau nhảy thẳng. Hàm không bao giờ gọi thì ô GOT của nó không bao giờ được vá.
Để chắc đây là lazy chứ không phải ngẫu nhiên, tôi bật LD_BIND_NOW=1 (ép eager binding — phân giải hết lúc nạp):
sqrt (không gọi): bound = 1 (giờ được phân giải, dù không gọi)
số relocation lúc nạp: 87 (lazy) -> 119 (BIND_NOW)
Với eager, sqrt được phân giải dù không gọi, và ld.so xử lý nhiều quan hệ tái định vị hơn hẳn lúc nạp (87 lên 119). Bài học đo lường: giả định "phân giải hết lúc khởi động" của tôi sai — lazy binding hoãn phân giải tới lần gọi đầu, và bỏ qua hoàn toàn hàm không gọi. Và đây chính là lời giải cho câu đố ở bài trước: chương trình động khởi động rẻ vì phần lớn công phân giải bị hoãn, thậm chí không bao giờ làm với các hàm không dùng tới. Chi phí liên kết động không dồn vào lúc khởi động như tôi tưởng; nó được rải mỏng ra và phần lớn bị né.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là hiểu vì sao lần gọi đầu một hàm thư viện hơi đắt hơn các lần sau. Với lazy binding, lần đầu gọi printf phải chạy qua resolver (tìm ký hiệu, vá GOT); các lần sau chỉ là một cú nhảy gián tiếp qua GOT. Chênh lệch này thường không đáng kể, nhưng với một chương trình khởi chạy rồi thoát ngay (như một lệnh CLI), tổng chi phí phân giải lần-đầu của mọi hàm nó dùng là một phần của thời gian khởi động cảm nhận được. Đó cũng là lý do các công cụ như prelink (xưa) hay LD_BIND_NOW tồn tại: dịch chuyển hoặc gộp chi phí đó. Với một dịch vụ chạy lâu, chi phí lần-đầu này chỉ trả một lần rồi thôi, nên hầu như vô hình; nó chỉ đáng kể với những tiến trình sống rất ngắn.
Hệ quả thứ hai là đánh đổi giữa lazy và eager là tốc độ đổi lấy an toàn. Lazy nhanh nạp, nhưng ô GOT phải ghi được (để resolver vá lúc chạy) — mà một ô GOT ghi được là mục tiêu tấn công: kẻ xấu ghi đè địa chỉ trong GOT để chuyển hướng một lời gọi hàm sang mã của chúng (GOT overwrite). Eager binding (LD_BIND_NOW, cờ liên kết -z now) phân giải hết lúc nạp rồi khóa toàn bộ GOT thành chỉ đọc (full RELRO) — không còn ô nào ghi được để tấn công. Đây là lý do nhiều bản build cứng bảo mật bật -z now mặc định, chấp nhận nạp chậm hơn một chút để đóng cả một họ khai thác. Trên nhiều bản phân phối Linux hiện đại, -z now (full RELRO) là mặc định của trình liên kết, nên thực ra lazy binding thuần túy ngày càng hiếm gặp — một ví dụ nữa cho thấy hành vi thật phụ thuộc vào cấu hình toolchain, phải đo trên chính binary của mình.
Hệ quả thứ ba là LD_DEBUG là cửa sổ nhìn vào trình liên kết động. Khi gặp lỗi "symbol not found" lúc chạy, hay muốn biết chương trình thật sự nạp thư viện nào, phân giải hàm nào, LD_DEBUG=libs,bindings,statistics cho bạn thấy từng bước ld.so làm. Con số mang theo: hàm thư viện được gọi qua PLT→GOT; ld.so mặc định dùng lazy binding — chỉ phân giải một hàm khi nó được gọi lần đầu và bỏ qua hàm không gọi, nên khởi động động rẻ; LD_BIND_NOW/-z now ép phân giải hết lúc nạp để khóa GOT chỉ đọc, đổi tốc độ nạp lấy an toàn. Cái "khi nào phân giải" mà tôi tưởng là "lúc khởi động" hóa ra là "lúc gọi lần đầu, nếu có gọi" — một khác biệt giải thích cả hiệu năng lẫn bảo mật của liên kết động.
Thử ba mươi giây
Biên dịch một chương trình gọi printf (gcc -O0 hi.c -o hi), rồi chạy objdump -d hi | grep plt để thấy các lời gọi đi qua <printf@plt>. Chạy objdump -R hi | grep JUMP_SLOT để thấy các ô GOT chờ ld.so điền. Cuối cùng, chạy LD_DEBUG=bindings ./hi 2>&1 | grep printf để thấy ld.so phân giải printf — và để ý nó xảy ra khi chương trình chạy, không phải trước đó. Thử thêm LD_BIND_NOW=1 LD_DEBUG=statistics ./hi để thấy số relocation lúc nạp tăng lên khi ép eager. Chỉ vài lệnh là bạn thấy trọn cơ chế PLT/GOT và lazy binding vận hành.