Skip to content

Linking

Linker (ld) nhận một danh sách object file và library, trả về một executable hoặc shared library. Bài này đi qua ba việc nó làm, các quy tắc khi gặp symbol trùng/thiếu, và hai cách đưa library vào chương trình. Nên đọc bài 2 trước.

Ba việc của linker

  1. Symbol resolution: ghép mỗi symbol U (undefined) với đúng một định nghĩa.
  2. Section merging và gán địa chỉ: gộp các .text của từng .o thành một .text, tương tự .data, .bss, rồi gán địa chỉ cho từng thứ.
  3. Relocation: duyệt các relocation entry và điền địa chỉ thật vào chỗ đã đánh dấu.
hello.o: .text .data .rodata          ┌─────────── executable ───────────┐
math.o : .text .data         ──ld──▶  │ .text (hello.o + math.o + crt*)  │
libc   : (qua dynamic link)           │ .rodata  .data  .bss             │
crt1.o : _start                       └──────────────────────────────────┘

Kết quả của relocation: lệnh call square trong hello.o có 4 byte operand 00 00 00 00; trong executable nó đã là 39 00 00 00.

$ objdump -d hello | grep "call.*square"
    1156:   e8 39 00 00 00          call   1194 <square>

Với S = 0x1194 (địa chỉ square), A = -4 (addend) và P = 0x1157 (vị trí operand): S + A - P = 0x39.

Symbol resolution

Strong và weak

Mỗi symbol global là strong hoặc weak. Hàm và biến global thông thường là strong; symbol đánh dấu __attribute__((weak)) là weak. (Với GCC cũ trước 10, biến global chưa khởi tạo cũng là weak/common; từ GCC 10 mặc định -fno-common nên chúng là strong.) Quy tắc:

  1. Hai strong trùng tên: lỗi multiple definition.
  2. Một strong và nhiều weak: chọn strong.
  3. Chỉ có weak: chọn một trong số đó.

Rule 1 trong thực tế (hai file cùng định nghĩa int counter, có khởi tạo):

$ nm a.o b.o
a.o: 0000000000000000 D counter
b.o: 0000000000000000 D counter
$ gcc a.o b.o -o ab
/usr/bin/ld: b.o:(.data+0x0): multiple definition of `counter'; a.o:(.data+0x0): first defined here
collect2: error: ld returned 1 exit status

Rule 2 là cách nhiều hệ thống embedded cung cấp "hook" mặc định, mà người dùng có thể ghi đè bằng cách định nghĩa hàm cùng tên:

w.c
#include <stdio.h>
__attribute__((weak)) void hook(void) { puts("default hook"); }
int main(void) { hook(); return 0; }
$ gcc w.c -o w1 && ./w1
default hook
$ gcc w.c h2.c -o w2 && ./w2      # h2.c định nghĩa hook() bình thường (strong)
custom hook

Pattern này xuất hiện nhiều trong Linux kernel và bootloader (ví dụ các hàm __weak trong U-Boot, handler mặc định trong vector table của MCU).

static và extern

  • static ở file scope đổi symbol từ GLOBAL thành LOCAL: linker không thấy, nên hai file có thể cùng có static int hidden mà không xung đột (d hidden ở output nm trên).
  • Khai báo extern int counter; không tạo symbol, chỉ báo "có biến này ở file khác". Định nghĩa thật (không có extern) chỉ được xuất hiện một lần trong toàn chương trình.

Xem thêm cách tổ chức header/source ở bài 5.

Library

Static library .a

.a là archive: một tập hợp các .o do ar đóng gói.

$ ar rcs libf.a f1.o f2.o
$ ar t libf.a
f1.o
f2.o
$ gcc mm.c -L. -lf -o mm -Wl,-Map=mm.map

mm.c chỉ gọi f1(). Linker không chép cả libf.a vào chương trình, chỉ lấy member nào cần để thoả symbol đang U. Có thể kiểm chứng trong map file:

$ grep -E "libf.a\(" mm.map | head -1
./libf.a(f1.o)                /tmp/ccckPedu.o (f1)

f1.o được kéo vào vì mm.o cần f1; f2.o không xuất hiện. Đơn vị nhỏ nhất là một .o, vì vậy khi viết library nên tách mỗi hàm/nhóm hàm liên quan thành một file .c, để chương trình không phải mang theo code không dùng.

Thứ tự -l quan trọng

Linker xử lý các file từ trái sang phải. Khi gặp một archive, nó chỉ lấy những member giải quyết symbol đang U tại thời điểm đó. Vì vậy:

$ gcc -lm m.c -o m1
m.c:(.text+0x27): undefined reference to `sqrt'
$ gcc m.c -lm -o m2        # đúng

Quy tắc an toàn: đặt -l... sau các file nguồn/object. Nếu hai library phụ thuộc vòng nhau, có thể lặp lại hoặc dùng -Wl,--start-group -la -lb -Wl,--end-group.

Shared library .so

Linker không chép code vào executable, chỉ ghi lại tên library cần (DT_NEEDED). Việc nạp xảy ra lúc chạy.

Build một shared library:

$ gcc -fPIC -shared -Wl,-soname,libf.so.1 f1.c f2.c -o libf.so.1.0.0
$ ln -sf libf.so.1.0.0 libf.so.1      # soname: dùng lúc chạy
$ ln -sf libf.so.1 libf.so            # linker name: dùng lúc link (-lf)
  • -fPIC (Position-Independent Code): sinh code không phụ thuộc địa chỉ nạp, để nhiều process cùng chia sẻ một bản .so trong memory ở các địa chỉ khác nhau.
  • soname (libf.so.1) là tên mà executable ghi lại. Nó đại diện cho ABI: sửa lỗi bên trong giữ nguyên soname (libf.so.1.0.1), đổi ABI thì tăng soname (libf.so.2).

Chuỗi ba tên cho thấy ba vai trò khác nhau:

Tên Vai trò Ai dùng
libf.so.1.0.0 file thật
libf.so.1 soname dynamic loader, lúc chạy
libf.so linker name ld, qua -lf lúc build

Link và chạy:

$ gcc mm.c -Llib -lf -o ms
$ readelf -d ms | grep -E "NEEDED|SONAME"
 0x0000000000000001 (NEEDED)             Shared library: [libf.so.1]
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
$ ./ms
./ms: error while loading shared libraries: libf.so.1: cannot open shared object file: No such file or directory

-L chỉ có tác dụng lúc link. Lúc chạy, loader tìm theo thứ tự: RPATH/RUNPATH trong executable, LD_LIBRARY_PATH, cache /etc/ld.so.cache (cập nhật bằng ldconfig), rồi thư mục mặc định /lib, /usr/lib. Có ba cách để ./ms chạy được:

$ LD_LIBRARY_PATH=./lib ./ms                              # (1) tạm thời, để thử
$ gcc mm.c -Llib -lf -Wl,-rpath,'$ORIGIN/lib' -o ms2      # (2) ghi đường dẫn vào executable
$ sudo cp libf.so.1* /usr/local/lib && sudo ldconfig      # (3) cài vào hệ thống
$ ldd ms2 | grep libf
    libf.so.1 => /.../lib/libf.so.1 (0x00007fed8bb02000)
$ readelf -d ms2 | grep -i runpath
 0x000000000000001d (RUNPATH)            Library runpath: [$ORIGIN/lib]

$ORIGIN là thư mục chứa executable, nên có thể đặt binary và lib/ cạnh nhau rồi copy đi đâu cũng chạy được, rất tiện khi triển khai lên target. Nhớ đặt trong dấu nháy đơn để shell không expand.

Static vs shared

Static (libx.a) Shared (libx.so)
Bản chất linker chép các .o cần thiết vào executable linker ghi lại tên; nạp lúc chạy
Kích thước executable lớn nhỏ
Memory mỗi chương trình một bản nhiều process chia sẻ phần code
Phụ thuộc lúc chạy không cần đúng .so trên target
Cập nhật library build lại chương trình thay .so
Option -static (toàn bộ) hoặc -l:libx.a mặc định

Với hello trên ARM, cùng source:

$ ls -l h_arm h_arm_s | awk '{print $5, $9}'
7876 h_arm          # dynamic
423704 h_arm_s      # static (-static)

Executable static chạy được mà không cần libc.so trên target, thuận tiện cho initramfs tối giản hoặc khi không kiểm soát được môi trường chạy; đổi lại kích thước lớn và phải build lại khi libc có bản vá bảo mật. Với glibc, một số tính năng (NSS, dlopen, locale) hoạt động kém khi link static; musl thường được chọn nếu cần static thật sự.

Linker script

Mặc định ld dùng một linker script nội bộ để quyết định section nào đặt ở đâu. Xem bằng ld --verbose. Với chương trình user space ta hiếm khi cần sửa. Trong bare-metal/bootloader/kernel, linker script là công cụ chính để đặt code vào đúng vùng nhớ của chip (Flash, SRAM, ...), ví dụ arch/arm/kernel/vmlinux.lds.S trong kernel hay u-boot.lds trong U-Boot. Dạng tối giản:

SECTIONS
{
    . = 0x08000000;           /* địa chỉ bắt đầu (Flash) */
    .text   : { *(.text*) }
    .rodata : { *(.rodata*) }
    . = 0x20000000;           /* SRAM */
    .data   : { *(.data*) }
    .bss    : { *(.bss*) *(COMMON) }
}

Tóm tắt

  • Linker làm ba việc: resolve symbol, merge section + gán địa chỉ, relocation.
  • Symbol strong/weak: hai strong trùng tên là lỗi; weak cho phép ghi đè mặc định.
  • .a là archive các .o, linker chỉ lấy member cần; thứ tự -l quan trọng.
  • .so được nạp lúc chạy; -L cho lúc link, -rpath/LD_LIBRARY_PATH/ldconfig cho lúc chạy.
  • Linker script quyết định layout section, quan trọng với bare-metal và bootloader.

Tiếp theo: Từ executable đến process.

References