1.7 链接、加载与库
两个 .o 文件怎样变成一个可执行文件?为什么少写一个 -lm 会在链接时报 undefined reference,而不是在运行时?GOT、PLT、ld.so 各自负责哪一步?
用 readelf/objdump/nm 读目标文件;解释强符号、弱符号与归档的解析顺序;手算一次 PC 相对重定位;用 LD_PRELOAD 和 dlopen 替换或加载库函数。
1.1 把 hello.c 走了一遍「预处理—编译—汇编—链接—加载」。那里只掀开了链接阶段的一角:可执行文件里有一个 puts@plt,真正的 puts 在 libc.so.6 里,运行时才被接上。这一章把这一角完全打开。
链接器面对的不是源代码,而是一组可重定位目标文件和归档库。它要解决两个问题:同一个符号在多个文件里出现时听谁的(符号解析);指令里留下的地址空洞填什么(重定位)。这两个问题决定了你日常遇到的一多半构建错误:multiple definition、undefined reference、cannot find -lxxx、symbol lookup error。
加载则是另一个程序的工作。内核只把 PT_LOAD 段映射进地址空间,动态链接器 ld.so 接着解析依赖、填 GOT、调用构造函数,最后才跳到 _start。把链接与加载分开看,你才能理解「编译通过、链接失败、运行时报找不到符号」分别发生在哪一层。
1.7.1 链接器在流水线中的位置
gcc 是驱动程序,不是编译器。它按后缀和选项调用 cc1、as、collect2,最后由 collect2 调用 ld。打开 verbose 模式能看到真实的命令行:
cc -v hello.c -o hello 2>&1 | sed -n '/collect2/,/^$/p'
输出里会出现 ld -pie --eh-frame-hdr ... -dynamic-linker /lib64/ld-linux-x86-64.so.2 ... -lc。三件事值得注意:
-pie默认开启:现代发行版的 GCC 默认生成位置无关可执行文件(position-independent executable, PIE),类型是ET_DYN而不是ET_EXEC。-dynamic-linker指定解释器:可执行文件里写死一个ld.so路径,内核加载时先映射它。-lc由驱动程序补上:你只写hello.c,GCC 自动加 C 启动文件、libc和动态链接器参数。
静态链接在构建时把被引用目标模块的代码和数据复制进可执行文件,产物不依赖外部库文件;动态链接只记录依赖的共享库名字,把符号绑定推迟到加载时(或首次调用时),由动态链接器完成。两者不是「有没有库」的区别,而是「绑定发生在构建期还是运行期」的区别。
链接器的输入只有两类:可重定位目标文件(.o,类型 ET_REL)和静态库(.a,其实是一组 .o 的归档)。它输出可执行文件(ET_EXEC/ET_DYN)或共享库(ET_DYN)。核心工作可以拆成四步:
| 步骤 | 做什么 | 失败时的典型报错 |
|---|---|---|
| 符号解析 | 每个未定义符号找到唯一强定义 | undefined reference to 'foo' |
| 合并节 | 把所有 .text/.data/.rodata 拼成输出节 | 很少直接报错 |
| 重定位 | 按重定位表改写指令/数据中的地址 | relocation truncated to fit |
| 布局 | 分配虚拟地址、生成程序头、设置入口 | section .text will not fit |
「编译通过」只说明每个 .c 文件内部语法和类型正确;跨文件的符号是否存在、地址是否放得下,是链接器的事。这也是为什么一个函数声明了却从未定义,编译器不会报警——它只看到声明。
1.7.2 ELF:节、段与符号表
ELF(Executable and Linkable Format)用同一套容器装三种东西:可重定位目标文件、可执行文件、共享库。区别写在文件头的 e_type 里。readelf -h 先看这个字段:
readelf -h hello | grep -E 'Type|Machine|Entry|Start of program|Start of section'
Type: DYN 说明它是 PIE 或共享库;Entry point 是内核交棒后执行的第一条指令地址,通常是 _start,不是 main。
两种视图:节与段
链接器按节(section)工作,加载器按段(segment)工作。节是可合并、可重定位的细粒度单元;段是内核映射的粗粒度单元,一段包含一或多个节,按页对齐、按权限合并。
| 节 | 内容 | 典型权限 | 运行时还在吗 |
|---|---|---|---|
.text | 机器指令 | R-X | 在,映射为代码段 |
.rodata | 字符串、常量表、跳转表 | R-- | 在 |
.data | 已初始化全局/静态变量 | RW- | 在 |
.bss | 未初始化全局/静态变量 | RW- | 占内存,不占文件 |
.symtab / .strtab | 链接期符号表与字符串表 | 不加载 | 可被 strip 删除 |
.rela.text | 代码段的重定位表 | 不加载 | 链接后消失 |
.got / .got.plt | 全局偏移表 | RW- | 在,动态链接用 |
.plt | 过程链接桩 | R-X | 在 |
.dynamic / .dynsym / .dynstr | 动态链接元数据 | R-- | 在,运行必需 |
.bss 的 NOBITS 类型值得单独记:它在文件里只占一个表项,记录大小和地址;加载器把它映射到零页,第一次写触发缺页才分配物理页。static char pool[64 << 20]; 不会让二进制膨胀 64 MiB。
ELF 可执行文件(PIE,加载基址随机) ┌─────────────────────────────────────────────────────────────┐ │ ELF header:e_type=DYN, e_entry, e_phoff, e_shoff │ ├─────────────────────────────────────────────────────────────┤ │ Program headers(加载器视图) │ │ PT_INTERP → "/lib64/ld-linux-x86-64.so.2" │ │ PT_LOAD #1 → R-- .interp .note .dynsym .dynstr .rela.dyn │ │ PT_LOAD #2 → R-X .init .plt .text .fini │ │ PT_LOAD #3 → RW- .init_array .dynamic .got .data .bss │ │ PT_GNU_RELRO → 只读化 .dynamic/.got 的一部分 │ │ PT_GNU_STACK → 栈权限(RW,无 X) │ ├─────────────────────────────────────────────────────────────┤ │ .text: call puts@plt │ │ │ │ │ .plt: ┌──▼──────────────┐ .got.plt │ │ │ puts@plt: │ ┌──────────────────┐ │ │ │ jmp *0x2f(%rip)├───►│ GOT[3] = 0x... │ │ │ │ push $0x2 │ ├──────────────────┤ │ │ │ jmp plt0 │ │ GOT[1] link_map │ │ │ └─────────────────┘ │ GOT[2] resolver │ │ │ └──────────────────┘ │ │ 首次调用:GOT[3] 指向 push 序列 → 解析 → 改写为真实 puts │ └─────────────────────────────────────────────────────────────┘
读图抓住两条线:链接器视图(节)和加载器视图(段)。readelf -S 看节,readelf -l 看段;调试链接错误看前者,调试加载/权限问题看后者。图下半部分的 PLT/GOT 是 1.7.6 的主题,先记住「代码段不直接写死库函数地址」。
符号表里有什么
.symtab 的每一项描述一个符号:
readelf -s hello.o | sed -n '1,12p'
典型一行:
Num: Value Size Type Bind Vis Ndx Name
4: 0000000000000000 27 FUNC GLOBAL DEFAULT 1 main
5: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
四个字段决定链接行为:
Type:FUNC、OBJECT、NOTYPE、SECTION、FILE。Bind:LOCAL(static)、GLOBAL、WEAK。Ndx:符号所在节的下标;UND表示未定义,ABS表示绝对值,COM表示 common。Vis:DEFAULT、HIDDEN、PROTECTED。HIDDEN的符号不进入动态符号表,不能被外部库引用。
static 函数不是「运行时私有」,而是链接期局部:它进 .symtab 的 LOCAL 项,不参与跨文件解析。strip 删掉 .symtab 后,nm 看不到局部符号,但 .dynsym 里的动态符号仍在——否则动态链接器无法工作。
1.7.3 符号解析:强、弱与归档顺序
链接器维护三个集合:已定义符号、未定义符号、归档成员。每读一个输入文件就更新集合。规则不复杂,但边界很多。
函数和已初始化的全局变量是强符号(strong symbol);未初始化全局变量在传统 C 里是弱符号(weak symbol,COMMON),用 __attribute__((weak)) 可显式声明弱符号。链接规则:
- 多个强符号同名 → 报
multiple definition; - 一个强符号 + 多个弱符号 → 选强符号;
- 多个弱符号 → 选体积最大的那个(
COMMON的历史行为)。
第 3 条是历史包袱。两个文件都写 int counter;,链接器会把它们合并成一个 common 符号,而不是报错;如果两个文件对它的类型理解不同(一个 int、一个 double),运行时就会读到错误的值。GCC 10 起默认 -fno-common,把未初始化全局变量放进 .bss 并视为强符号,这类静默合并才变成链接错误。
显式弱符号的典型用途是「可覆盖的默认实现」:
/* weak.c:库提供弱默认值,应用可以强定义覆盖 */
#include <stdio.h>
int __attribute__((weak)) plugin_init(void)
{
return -1; /* 没有插件时的默认行为 */
}
int main(void)
{
if (plugin_init() == 0)
puts("plugin: ready");
else
puts("plugin: absent");
return 0;
}
单独编译 weak.c,程序打印 plugin: absent。若再链接一个定义了强符号 plugin_init 的 plugin.o,强定义获胜,打印 plugin: ready。若弱符号只有声明没有定义,链接器把它的地址解析为 0;所以调用前必须判断 if (plugin_init),否则跳到 0 地址。
静态库不是「一个文件」,是归档
libm.a 是 ar 归档,里面是一堆 .o。链接器扫描归档时不会把整个库装进去,只按需抽取成员:
- 从左到右扫描命令行;
- 遇到
.o就加入; - 遇到
.a,反复检查当前未定义符号,抽取能解决它们的成员,直到没有新成员被抽出; - 扫描结束后仍有未定义符号 → 报错。
这解释了两个常见现象:
cc main.o -lfoo -lbar # 如果 foo 依赖 bar,顺序正确
cc main.o -lbar -lfoo # 如果 bar 的成员先被扫描,可能留下未定义
cc main.o -Wl,--start-group -lfoo -lbar -Wl,--end-group # 循环依赖时强制多轮
库的依赖顺序应当「被依赖者放右边」。--start-group/--end-group 让链接器反复扫描组内库,代价是更慢;--whole-archive 则强制装入所有成员,常用于注册表式插件。
- 「声明了就能链接」——声明只告诉编译器签名,定义必须来自某个
.o或库。undefined reference永远是「找不到定义」,不是「头文件没包含」。 - 「
static函数不能被外部调用,所以运行时也没有名字」——它只是不参与跨文件链接;调试信息里仍有名字,nm -a也能看到(未 strip 时)。 - 「多个
int x;一定报错」——在-fcommon的传统模式下会合并;GCC 10 起默认报错。判断依据是编译选项,不是语言标准。 - 「
-lfoo会搜索整个磁盘」——只在-L指定目录、默认库目录和ld.so缓存对应的开发库路径里找libfoo.so/libfoo.a。
1.7.4 重定位:把地址填进指令
编译单个 .c 时,编译器不知道外部符号最终落在哪,也不知道本文件的 .text 会被放到输出文件的什么地址。它留下「占位 + 重定位条目」,由链接器填。
x86-64 用 ELF64_Rela,三个字段的布局是:
#include <stdint.h>
/* ELF64_Rela 的字段布局(名字换成 _view,避免与 <elf.h> 冲突) */
typedef struct {
uint64_t r_offset; /* 需要改写的位置(节内偏移或虚拟地址) */
uint64_t r_info; /* 符号下标 + 重定位类型 */
int64_t r_addend; /* 加数 */
} rela_view;
常见类型:
| 类型 | 计算式 | 用在哪 |
|---|---|---|
R_X86_64_64 | S + A | 64 位绝对地址(指针表、.data 中的函数指针) |
R_X86_64_PC32 | S + A - P | PC 相对寻址(lea、全局变量访问) |
R_X86_64_PLT32 | L + A - P | call/jmp 到 PLT 桩 |
R_X86_64_32S | S + A,检查符号扩展 | 非 PIE 的 32 位绝对地址 |
R_X86_64_GOTPCREL | GOT(S) + A - P | PIC 代码经 GOT 取地址 |
S 是符号最终地址,P 是被改写位置的地址,A 是加数,L 是 PLT 桩地址。看一个真实的重定位:
cc -c -O2 -fno-pic call.c -o call.o
objdump -dr call.o | sed -n '/<main>:/,+8p'
readelf -r call.o
输出里 call 指令的字节是 e8 00 00 00 00,后面跟着 R_X86_64_PLT32 puts-0x4。e8 是 call rel32,五个零是占位;链接器把 rel32 改成 puts@plt - (P + 4)。减 4 是因为 P 指向重定位字段本身,而 CPU 计算目标时用「下一条指令」的地址,即 P + 4。
设 call 指令的 opcode 位于虚拟地址 0x401140,rel32 字段从 0x401141 开始,所以 P = 0x401141。puts@plt 的最终地址 S = 0x401030,条目里的加数 A = -4。则
rel32 = S + A - P = 0x401030 - 4 - 0x401141 = -0x115。
补码写作 0xfffffeeb,指令字节变成 e8 eb fe ff ff。CPU 执行完这条 5 字节指令后 %rip = 0x401145,目标 = 0x401145 - 0x115 = 0x401030,正好是 puts@plt。
若漏掉 A = -4,算出的 rel32 会多 4,落点偏到 PLT 桩中间。读重定位时先看 A,不要自己猜该减 4 还是减 5。
这个例子说明:重定位不是「填一个绝对地址」,而是按类型做一次带符号的算术。R_X86_64_32S 在地址超过 2 GiB 时会报 relocation truncated to fit,因为 32 位字段放不下;PIE 默认用 PC 相对和 GOT,正是为了避免这种绝对地址。
1.7.5 加载:从 execve 到 _start
内核的 execve 最终调用 load_elf_binary(fs/binfmt_elf.c)。它做这些事:
- 读 ELF 头,检查魔数、类(32/64 位)、字节序、机器类型;
- 读程序头,找到
PT_INTERP,把动态链接器映射进来; - 对每个
PT_LOAD段,用mmap建立映射,设置R/W/X;文件尾部不足一页的部分清零; - 对
.bss对应的p_memsz > p_filesz部分,映射匿名零页; - 把
argv、envp、辅助向量(auxv)复制到新栈顶; - 设置
%rip为入口(有解释器时是ld.so的入口),%rsp指向新栈; - 返回用户态。
/proc/<pid>/maps 能直接看到结果:
cat /proc/self/maps | sed -n '1,6p'
# 555555554000-555555555000 r--p 00000000 08:01 1234 /usr/bin/cat
# 555555555000-555555556000 r-xp 00001000 08:01 1234 /usr/bin/cat
# 555555556000-555555557000 r--p 00002000 08:01 1234 /usr/bin/cat
# 555555557000-555555558000 r--p 00002000 08:01 1234 /usr/bin/cat
# 555555558000-555555559000 rw-p 00003000 08:01 1234 /usr/bin/cat
# 7ffff7dc0000-7ffff7de8000 r--p 00000000 08:01 5678 /usr/lib/libc.so.6
同一个文件被映射成多个段,权限分别是 r--、r-x、r--、rw-。最后一段的 rw- 里既有 .data 也有 .bss;.bss 在文件里没有对应偏移,所以 Offset 与前一页相同,但 Size 只由内存大小决定。
PIE 的加载基址每次运行都不同(ASLR),非 PIE 的 ET_EXEC 固定加载到 0x400000。判断一个二进制是不是 PIE:
readelf -h ./prog | grep Type
# Type: DYN → PIE(或共享库)
# Type: EXEC → 固定地址
ldd 显示依赖,但它会调用动态链接器,对不可信二进制不安全。更安全的做法是 objdump -p ./prog | grep NEEDED,只读文件不执行。
1.7.6 动态链接:ld.so、GOT 与 PLT
动态链接的可执行文件不包含 libc 的代码,只包含:
PT_INTERP:解释器路径;.dynamic:依赖库、符号表位置、重定位表位置、初始化函数数组;.dynsym/.dynstr:动态符号与名字;.rela.dyn/.rela.plt:需要动态链接器处理的重定位;.got/.got.plt:运行时填写的地址表。
readelf -d 看 .dynamic:
readelf -d ./prog | grep -E 'NEEDED|SONAME|RPATH|RUNPATH|BIND_NOW|FLAGS'
NEEDED 是直接依赖的 soname;RPATH/RUNPATH 是搜索路径;BIND_NOW 表示立即绑定。
搜索顺序
ld.so 找 libfoo.so 的顺序(简化,Linux 6.x):
- 若存在
DT_RPATH且没有DT_RUNPATH:DT_RPATH目录; - 环境变量
LD_LIBRARY_PATH(安全执行模式下忽略); DT_RUNPATH目录;/etc/ld.so.cache(由ldconfig生成);- 默认目录
/lib、/usr/lib(以及lib64变体)。
注意 DT_RPATH 与 DT_RUNPATH 的差别:RPATH 会作用于依赖的依赖(传递),RUNPATH 只作用于直接依赖。新链接器默认生成 RUNPATH,所以 -Wl,-rpath,$ORIGIN/../lib 对间接依赖不生效,需要给每个库都设置,或用 LD_LIBRARY_PATH。
延迟绑定:第一次调用才解析
PLT/GOT 的协作分两步:
- 首次调用:
puts@plt的第一条指令jmp *GOT[n]跳到的不是puts,而是 PLT 里的push $index; jmp plt0;plt0把link_map和重定位索引传给ld.so的解析器,解析器找到puts的真实地址,改写 GOT[n],再跳过去。 - 后续调用:
jmp *GOT[n]直接到puts,只剩一次间接跳转。
这就是延迟绑定(lazy binding)。它缩短启动时间,但让第一次调用的开销不可预测,也让 GOT 在运行时可写。安全加固通常用 -Wl,-z,now(立即绑定)加 -Wl,-z,relro(GOT 只读),把 GOT 在重定位后设为只读,减少劫持面。
cc -O2 -fPIE -pie -Wl,-z,relro,-z,now prog.c -o prog
readelf -l prog | grep GNU_RELRO
readelf -d prog | grep -E 'BIND_NOW|FLAGS'
运行时加载:dlopen
dlopen 让程序在运行时加载共享库,适合插件、驱动式扩展和按需加载:
/* dlopen.c:加载插件并调用约定符号;cc ... -ldl */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <dlfcn.h>
#include <stdio.h>
#include <string.h>
typedef int (*plugin_fn)(const char *);
int main(int argc, char **argv)
{
if (argc != 3) {
fprintf(stderr, "usage: %s <lib.so> <symbol>\n", argv[0]);
return 2;
}
void *h = dlopen(argv[1], RTLD_NOW | RTLD_LOCAL);
if (h == NULL) {
fprintf(stderr, "dlopen: %s\n", dlerror());
return 1;
}
dlerror(); /* 清除旧错误 */
void *sym = dlsym(h, argv[2]);
const char *err = dlerror();
if (err != NULL) {
fprintf(stderr, "dlsym: %s\n", err);
dlclose(h);
return 1;
}
plugin_fn fn = NULL;
_Static_assert(sizeof(fn) == sizeof(sym), "function pointer size");
memcpy(&fn, &sym, sizeof(fn)); /* 避免函数指针与 void* 的直接转换告警 */
int rc = fn("from main");
printf("plugin returned %d\n", rc);
dlclose(h);
return rc == 0 ? 0 : 1;
}
关键点:RTLD_NOW 在加载时解析全部未定义符号,错误立刻暴露;RTLD_LAZY 推迟到调用。RTLD_LOCAL 让库的符号不进入全局命名空间,避免不同插件互相覆盖;需要共享时用 RTLD_GLOBAL。dlclose 只是减少引用计数,不保证卸载。
dlsym 返回 void *,C 标准不保证它能无损转成函数指针。上面的 memcpy 是保守写法;POSIX 环境直接强转通常也能工作,但打开 -Wpedantic 会告警。
1.7.7 PIC、PIE 与安全加固
位置无关代码(position-independent code, PIC)的核心约束是:代码段里不出现需要链接期确定的绝对地址。这样同一份 .text 可以映射到任意基址,被多个进程共享。
编译器用两种手法实现:
- PC 相对寻址:模块内的函数、静态变量用
lea sym(%rip), %reg,距离在链接时确定。 - GOT 间接:跨模块的全局变量、外部函数地址放在
.got,代码只引用 GOT 表项相对自己的偏移。
-fPIC 生成共享库用的 PIC;-fPIE 生成可执行文件用的 PIE。两者都位置无关,但 PIE 可以利用「本可执行文件不会被其他模块插入同名符号」的假设做更多优化。链接时 -pie 生成 ET_DYN,-no-pie 生成 ET_EXEC。
| 选项组合 | 产物 | 基址 | 典型用途 |
|---|---|---|---|
-fno-pic -no-pie | ET_EXEC | 固定 0x400000 | 老式二进制、内核模块 |
-fPIE -pie | ET_DYN | 随机 | 现代发行版默认 |
-fPIC -shared | ET_DYN | 随机 | 共享库 |
-fPIC -pie | ET_DYN | 随机 | 少见,但合法 |
安全加固不止 PIE:
# 查看一个二进制的加固项(需安装 checksec)
checksec --file=./prog
# RELRO STACK CANARY NX PIE RPATH RUNPATH FORTIFY
- NX:栈和堆不可执行,靠
PT_GNU_STACK标记; - Stack Canary:
-fstack-protector-strong,函数返回前检查哨兵; - RELRO:
-Wl,-z,relro把 GOT 中不需要延迟绑定的部分设为只读;-z now让全部 GOT 只读(full RELRO); - FORTIFY:
-D_FORTIFY_SOURCE=2在编译期检查memcpy/printf等边界。
- 「
LD_LIBRARY_PATH一定优先」——当库带DT_RPATH且没有DT_RUNPATH时,RPATH先于环境变量;setuid 程序还会忽略LD_LIBRARY_PATH。 - 「
ldd只是读文件」——ldd会启动动态链接器,对恶意二进制可能执行代码;只读检查用objdump -p。 - 「
strip后没有符号了」——删的是.symtab;.dynsym必须保留,否则动态链接失败。 - 「替换
.so不需要重新链接」——只要 soname 和 ABI 兼容可以;改了结构体布局、删了导出符号,旧程序会在运行时崩溃或报symbol lookup error。 - 「静态链接的程序完全不依赖系统」——仍依赖内核 ABI、
/etc/nsswitch.conf等运行时配置;glibc 的 NSS、dlopen、locale 在纯静态下行为受限。
1.7.8 库打桩:在链接期和运行期换实现
「打桩」(interposition)指用自己的实现替换库函数,常用于统计、注入故障、兼容层和安全审计。三种时机:
| 时机 | 手段 | 影响范围 | 典型场景 |
|---|---|---|---|
| 编译期 | #define malloc my_malloc、--wrap | 单个构建 | 单元测试替换依赖 |
| 链接期 | -Wl,--wrap=malloc | 单个可执行文件 | 统计某函数的调用 |
| 加载期 | LD_PRELOAD=./hook.so | 该进程及其子进程 | 全局观测、故障注入 |
LD_PRELOAD
LD_PRELOAD 让指定共享库先于其他库被加载,同名符号优先使用它。下面这个库统计 malloc 调用次数:
/* count_malloc.c:cc -shared -fPIC -O2 count_malloc.c -o count_malloc.so -ldl */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <dlfcn.h>
#include <stddef.h>
#include <stdio.h>
#include <unistd.h>
static void *(*real_malloc)(size_t);
static unsigned long long calls;
static void resolve(void)
{
real_malloc = (void *(*)(size_t))dlsym(RTLD_NEXT, "malloc");
if (real_malloc == NULL)
_exit(127); /* 解析失败,直接退出,避免递归 */
}
void *malloc(size_t size)
{
if (real_malloc == NULL)
resolve();
calls++;
return real_malloc(size);
}
__attribute__((destructor)) static void report(void)
{
char buf[64];
int n = snprintf(buf, sizeof(buf), "malloc calls: %llu\n", calls);
if (n > 0)
(void)write(STDERR_FILENO, buf, (size_t)n);
}
编译并在任意程序上试用:
cc -shared -fPIC -O2 count_malloc.c -o count_malloc.so -ldl
LD_PRELOAD=./count_malloc.so /bin/ls >/dev/null
RTLD_NEXT 是关键:它在「当前库之后的搜索顺序」里找 malloc,也就是真正的 libc 实现。若写成 dlsym(RTLD_DEFAULT, "malloc"),会找到自己,造成无限递归。
这个实现有三个真实缺陷,正好说明打桩的边界:calls++ 不是原子操作,多线程会丢计数;dlsym 自身可能调用 malloc,首次解析有递归风险;snprintf 在析构函数里也可能分配内存。生产级的统计库会用线程局部计数、__libc_malloc 或 malloc 的弱别名绕开这些问题。
链接期 –wrap
--wrap=symbol 把对 symbol 的引用改写成 __wrap_symbol,同时提供 __real_symbol 指向原实现:
/* wrap.c:cc -Wl,--wrap=malloc wrap.c */
#include <stdio.h>
#include <stdlib.h>
void *__real_malloc(size_t);
void *__wrap_malloc(size_t size)
{
void *p = __real_malloc(size);
fprintf(stderr, "wrap malloc(%zu) = %p\n", size, p);
return p;
}
int main(void)
{
char *p = malloc(32);
free(p);
return 0;
}
--wrap 只影响当前链接里对该符号的未定义引用,不影响共享库内部已经绑定的调用;它比 LD_PRELOAD 可控,但需要重新链接。
观测工具的分工
| 工具 | 看什么 | 典型命令 |
|---|---|---|
file | 文件类型、架构、是否动态链接 | file ./prog |
readelf | ELF 头、节、段、符号、重定位、动态段 | readelf -S/-l/-s/-r/-d |
objdump | 反汇编、重定位混排、动态符号表 | objdump -dr、objdump -T |
nm | 符号与地址(含未定义) | nm -C --defined-only |
ldd | 依赖解析结果(会执行 loader) | ldd ./prog |
ldconfig -p | 缓存中的库与路径 | ldconfig -p | grep libssl |
LD_DEBUG | 加载/绑定过程的详细日志 | LD_DEBUG=libs,bindings ./prog |
调试「运行时找不到库」时,按这个顺序取证:
readelf -d确认NEEDED的 soname;objdump -p确认RPATH/RUNPATH;LD_DEBUG=libs ./prog看搜索了哪些目录;ldconfig -p确认缓存里有没有;- 最后才怀疑
LD_LIBRARY_PATH或权限。
1.7.9 ELF 文件布局:逐字段读一个目标文件
这一节回答:readelf -h 打印的那些字段在文件里到底位于哪个偏移?节头、符号、重定位项各自是什么结构?把结构体摊开,objdump/readelf 的输出就不再是「工具魔法」。
ELF64 有两种「视图」:链接视图(节 section,给链接器用)与执行视图(段 segment,给加载器用)。目标文件(.o)只有节,可执行文件两者都有。
ELF 头(Elf64_Ehdr,64 字节)
| 字段 | 偏移 | 大小 | 含义 |
|---|---|---|---|
e_ident[16] | 0 | 16 | 7f 45 4c 46、类别(1=32/2=64)、字节序(1=小端)、版本、OSABI |
e_type | 16 | 2 | 1=REL(.o)、2=EXEC、3=DYN(共享库/PIE)、4=CORE |
e_machine | 18 | 2 | 62=x86-64、183=AArch64、243=RISC-V |
e_version | 20 | 4 | 固定 1 |
e_entry | 24 | 8 | 入口地址(可执行文件) |
e_phoff | 32 | 8 | 程序头表偏移 |
e_shoff | 40 | 8 | 节头表偏移 |
e_flags | 48 | 4 | 架构相关标志 |
e_ehsize | 52 | 2 | ELF 头大小(64) |
e_phentsize / e_phnum | 54 | 2/2 | 程序头项大小与数量 |
e_shentsize / e_shnum | 58 | 2/2 | 节头项大小(64)与数量 |
e_shstrndx | 62 | 2 | 节名字符串表所在的节索引 |
一个 x86-64 目标文件的头部十六进制是:
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
00000010 01 00 3e 00 01 00 00 00 00 00 00 00 00 00 00 00
00000020 00 00 00 00 00 00 00 00 28 02 00 00 00 00 00 00
逐段读:7f 45 4c 46 是魔数;02 表示 64 位;01 表示小端;01 是 ELF 版本;01 00 是 ET_REL;3e 00 是 62(x86-64);28 02 … 是 e_shoff = 0x228。这些值与 1.7.12 的程序输出一致。
节头(Elf64_Shdr,64 字节)
| 字段 | 偏移 | 含义 |
|---|---|---|
sh_name | 0 | 节名在 .shstrtab 中的偏移 |
sh_type | 4 | 1=PROGBITS、2=SYMTAB、3=STRTAB、4=RELA、8=NOBITS、9=REL、11=DYNSYM |
sh_flags | 8 | 1=WRITE、2=ALLOC、4=EXEC、0x20=STRINGS、0x400=TLS |
sh_addr | 16 | 运行时的虚拟地址(.o 中为 0) |
sh_offset | 24 | 文件内偏移 |
sh_size | 32 | 字节数 |
sh_link | 40 | 关联节索引(SYMTAB→STRTAB、RELA→SYMTAB) |
sh_info | 44 | 附加信息(重定位节=目标节) |
sh_addralign | 48 | 对齐 |
sh_entsize | 56 | 表项大小(符号 24、Rela 24) |
sh_link 是读符号表的关键:符号的名字不在符号项里,而是 st_name 偏移 + sh_link 指向的字符串表。
符号(Elf64_Sym,24 字节)
| 字段 | 大小 | 含义 |
|---|---|---|
st_name | 4 | 名字在字符串表中的偏移 |
st_info | 1 | 高 4 位绑定(0=LOCAL、1=GLOBAL、2=WEAK),低 4 位类型(1=OBJECT、2=FUNC、3=SECTION、4=FILE、5=COMMON、6=TLS) |
st_other | 1 | 可见性(0=DEFAULT、2=HIDDEN、3=PROTECTED) |
st_shndx | 2 | 所属节索引;0=UNDEF、0xfff1=ABS、0xfff2=COMMON |
st_value | 8 | 节内偏移或地址 |
st_size | 8 | 对象大小 |
重定位(Elf64_Rela,24 字节)
| 字段 | 大小 | 含义 |
|---|---|---|
r_offset | 8 | 要修改的位置(节内偏移或虚拟地址) |
r_info | 8 | 高 32 位符号索引,低 32 位类型 |
r_addend | 8 | 加数(REL 格式没有这一项,加数在指令里) |
程序头(Elf64_Phdr,56 字节)描述段:p_type(1=LOAD、2=DYNAMIC、3=INTERP、6=PHDR、7=TLS、0x6474e551=GNU_STACK、0x6474e552=GNU_RELRO)、p_flags(1=X、2=W、4=R)、p_offset、p_vaddr、p_filesz、p_memsz、p_align。加载器只关心段,链接器主要关心节。
.bss 的类型是 SHT_NOBITS,sh_offset 指向文件里一个位置但没有实际字节;加载器按 p_memsz > p_filesz 的差值分配零页。所以「全局未初始化数组」不会让二进制变大,但会占内存。-fno-common(GCC 10 默认)把 tentative definition 也放进 .bss,而不是 COMMON 符号。
1.7.10 符号解析:强、弱、COMMON 与归档顺序
这一节回答:两个文件定义了同名函数,链接器选谁?静态库里的符号为什么有时「找不到」?答案在符号的绑定类型与链接器的扫描顺序。
强符号与弱符号
| 定义方式 | 绑定 | 冲突时 |
|---|---|---|
int f(void) { … } | 强(GLOBAL) | 多个强定义 → 链接错误 |
int x = 1; | 强(GLOBAL) | 同上 |
__attribute__((weak)) int f(void) { … } | 弱(WEAK) | 强定义存在时用强,否则用弱 |
__attribute__((weak)) int x; | 弱 | 同上 |
int x;(无初始化,-fcommon) | COMMON | 多个 COMMON 取最大大小,合并为一块 |
规则可以概括为三句话:
- 不允许两个强符号同名。
- 强 + 弱 → 选强,弱定义被忽略。
- 弱 + 弱 → 选任意一个(通常是先出现的),不报错。
未解析的弱符号不会导致链接失败,而是取地址 0(或在 GOT 里填 0);这也是「弱符号做可选钩子」的原理:
/* 库代码:若使用者定义了 my_hook 就调用,否则跳过 */
extern void my_hook(void) __attribute__((weak));
if (my_hook) my_hook();
归档(.a)的扫描顺序
静态库是 .o 的归档。链接器从左到右扫描命令行,只把「能解决当前未定义符号」的成员拉进来;已经被拉进来的成员不会再扫描。于是:
$ cc main.o -lfoo -lbar # 顺序有意义
$ cc main.o -lbar -lfoo # 若 foo 依赖 bar,可能失败
循环依赖(foo 依赖 bar,bar 又依赖 foo)用分组解决:
$ cc main.o -Wl,--start-group -lfoo -lbar -Wl,--end-group
其他相关选项:
| 选项 | 作用 |
|---|---|
--whole-archive | 把归档里所有成员都链进来(用于注册表/插件) |
--as-needed | 只保留实际用到的共享库依赖(发行版默认) |
--no-as-needed | 强制保留(某些插件的 DT_NEEDED 需要) |
--gc-sections | 删除未引用的节(配合 -ffunction-sections) |
符号版本与可见性
glibc 用符号版本区分 ABI:memcpy@@GLIBC_2.14 是默认版本,memcpy@GLIBC_2.2.5 是旧版本。readelf -s 能看到 @ 后缀;dlvsym 可以按版本取符号。
可见性控制导出范围:
__attribute__((visibility("hidden"))) void internal(void);
配合 -fvisibility=hidden,共享库只导出显式标记的符号,减少 PLT/GOT 项与符号冲突。C++ 的 -fvisibility-inlines-hidden 同理。
静态链接与 --gc-sections
-static 把 libc 也链进来,产物不依赖 ld.so,但体积大、无法享受库的安全更新。--gc-sections 需要编译时 -ffunction-sections -fdata-sections,把每个函数/变量放进独立节,链接器才能按引用关系删除。嵌入式与 initramfs 常用这套组合。
- 「弱符号就是『可以被覆盖』」——只有强定义能覆盖弱定义;两个弱定义谁赢取决于链接顺序,不可依赖。
- 「
-lfoo放在哪里都一样」——静态库按命令行顺序扫描,被依赖的库要放在依赖者右边。 - 「
extern int x;是定义」——它是声明;int x;在-fcommon下是 COMMON 定义,在-fno-common下是 BSS 强定义。 - 「
static只是文件内可见」——它还让符号变成 LOCAL,不进入动态符号表,链接器可以做更多优化(内联、删除)。
1.7.11 重定位:把地址填进指令
这一节回答:目标文件里的 call 为什么目标地址是 0?链接器改写了哪几个字节?公式里的 S、A、P 分别是什么?
记号:
S:符号的最终地址(或 PLT 项地址L)。A:加数(Rela 的r_addend)。P:被修改字段的地址(r_offset对应的运行时地址)。B:加载基址(PIE/共享库)。
常见类型
| 类型 | 计算 | 用在哪 |
|---|---|---|
R_X86_64_64 | S + A | 64 位绝对地址(指针表、.quad sym) |
R_X86_64_PC32 | S + A - P | RIP 相对数据访问、mov sym(%rip) |
R_X86_64_PLT32 | L + A - P | call func(经 PLT) |
R_X86_64_32 / 32S | S + A(截断 32 位) | 非 PIE 的 32 位绝对地址 |
R_X86_64_GOTPCREL | G + GOT + A - P | mov sym@GOTPCREL(%rip) |
R_X86_64_GLOB_DAT | S | GOT 中的数据符号 |
R_X86_64_JUMP_SLOT | S | GOT 中的函数(延迟绑定) |
R_X86_64_RELATIVE | B + A | PIE 内部的绝对地址 |
R_X86_64_COPY | 复制 S 的内容 | 可执行文件引用共享库的数据对象 |
R_X86_64_TLSGD/TPOFF | TLS 偏移 | __thread 变量 |
手算一次 call
目标文件里 call 的编码是 e8 00 00 00 00,位移字段位于 P = 0x0a3,重定位项为 R_X86_64_PLT32 vsum - 4。链接后:
rel32 = S + A - P = vsum + (-4) - 0xa3 = vsum - 0xa7
0xa7 正是下一条指令的地址(P + 4)。CPU 执行 call 时把 rel32 加到 %rip(此时 %rip 已指向下一条),所以最终跳转地址是 vsum。加数 -4 就是在补偿「P 是字段地址,而 CPU 用下一条指令地址」这个差值。
手算一次 RIP 相对数据访问
movsd .LC0(%rip), %xmm0 的位移字段在 P=0x93,重定位 R_X86_64_PC32 .LC0 - 4:
disp32 = .LC0 - 4 - 0x93 = .LC0 - 0x97
0x97 是下一条指令地址。链接器把 disp32 填进 4 个字节,运行时 %rip + disp32 正好指向常量。
静态链接与动态链接的重定位差异
| 阶段 | 谁处理 | 典型类型 | 何时完成 |
|---|---|---|---|
| 编译/汇编 | 汇编器 | 生成占位符 + 重定位项 | 编译期 |
| 静态链接 | ld/lld | PC32、PLT32、64 | 链接期 |
| 加载时 | ld.so | RELATIVE、GLOB_DAT、COPY | execve 后、main 前 |
| 首次调用 | ld.so(延迟绑定) | JUMP_SLOT | 第一次经过 PLT |
-z now(或 LD_BIND_NOW=1)把所有 JUMP_SLOT 在加载时解析完,牺牲启动时间换运行期确定性;安全加固(RELRO + BIND_NOW)让 GOT 在启动后只读,防止 GOT 覆盖攻击。
Offset Info Type Sym. Value Sym. Name + Addend
0000000000a3 000a00000004 R_X86_64_PLT32 0000000000000000 vsum - 4
Offset=0xa3 是 P;Info 的高 32 位是符号索引 10,低 32 位是类型 4;vsum - 4 是 S + A 中的符号与加数。链接器把 S - 4 - 0xa3 写成 4 字节小端,objdump -d 就能看到解析后的 call 目标。
1.7.12 完整程序:不依赖 libelf 解析 ELF
readelf 很好用,但它把细节藏起来了。这个程序用 pread/fread 直接读文件,按 1.7.9 的结构体布局解析 ELF 头、节头与符号表。它不链接 libelf,也不依赖 <elf.h>,因此在任何平台都能编译——你可以用它读交叉编译出来的目标文件。
/* elfdump.c — 不依赖 libelf,直接按文件布局解析 ELF64
* 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 elfdump.c
* 运行:./elfdump demo.o
*/
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
typedef struct __attribute__((packed)) {
unsigned char e_ident[16];
uint16_t e_type, e_machine;
uint32_t e_version;
uint64_t e_entry, e_phoff, e_shoff;
uint32_t e_flags;
uint16_t e_ehsize, e_phentsize, e_phnum, e_shentsize, e_shnum, e_shstrndx;
} Ehdr;
typedef struct __attribute__((packed)) {
uint32_t sh_name, sh_type;
uint64_t sh_flags, sh_addr, sh_offset, sh_size;
uint32_t sh_link, sh_info;
uint64_t sh_addralign, sh_entsize;
} Shdr;
typedef struct __attribute__((packed)) {
uint32_t st_name;
unsigned char st_info, st_other;
uint16_t st_shndx;
uint64_t st_value, st_size;
} Sym;
static const char *sh_type_name(uint32_t t)
{
switch (t) {
case 1: return "PROGBITS";
case 2: return "SYMTAB";
case 3: return "STRTAB";
case 4: return "RELA";
case 8: return "NOBITS";
case 9: return "REL";
case 11: return "DYNSYM";
default: return "OTHER";
}
}
static const char *sym_type_name(unsigned t)
{
switch (t) {
case 0: return "NOTYPE";
case 1: return "OBJECT";
case 2: return "FUNC";
case 3: return "SECTION";
case 4: return "FILE";
case 5: return "COMMON";
case 6: return "TLS";
default: return "?";
}
}
int main(int argc, char **argv)
{
if (argc != 2) { fprintf(stderr, "usage: %s <elf64>\n", argv[0]); return 2; }
FILE *f = fopen(argv[1], "rb");
if (!f) { perror("fopen"); return 1; }
if (fseek(f, 0, SEEK_END) != 0) { perror("fseek"); return 1; }
long size = ftell(f);
rewind(f);
unsigned char *buf = malloc((size_t)size);
if (!buf) { perror("malloc"); return 1; }
if (fread(buf, 1, (size_t)size, f) != (size_t)size) { perror("fread"); return 1; }
fclose(f);
if (size < (long)sizeof(Ehdr) || memcmp(buf, "\177ELF", 4) != 0) {
fprintf(stderr, "%s: not an ELF file\n", argv[1]);
return 1;
}
const Ehdr *eh = (const Ehdr *)buf;
printf("ELF%d type=%u machine=%u entry=0x%llx shoff=0x%llx shnum=%u shstrndx=%u\n",
buf[4] == 2 ? 64 : 32, eh->e_type, eh->e_machine,
(unsigned long long)eh->e_entry, (unsigned long long)eh->e_shoff,
eh->e_shnum, eh->e_shstrndx);
const Shdr *sh = (const Shdr *)(buf + eh->e_shoff);
const Shdr *shstr = &sh[eh->e_shstrndx];
const char *strtab = (const char *)(buf + shstr->sh_offset);
printf("\n[节头] idx name type addr off size\n");
for (int i = 0; i < eh->e_shnum; i++) {
const char *name = strtab + sh[i].sh_name;
printf(" %2d %-20s %-9s 0x%012llx 0x%06llx %6llu\n", i, name,
sh_type_name(sh[i].sh_type), (unsigned long long)sh[i].sh_addr,
(unsigned long long)sh[i].sh_offset, (unsigned long long)sh[i].sh_size);
}
for (int i = 0; i < eh->e_shnum; i++) {
if (sh[i].sh_type != 2 && sh[i].sh_type != 11) continue; /* SYMTAB / DYNSYM */
const Sym *sym = (const Sym *)(buf + sh[i].sh_offset);
size_t n = sh[i].sh_size / sizeof(Sym);
const char *names = (const char *)(buf + sh[sh[i].sh_link].sh_offset);
printf("\n[符号表 %s] %zu 项(前 20 项)\n", strtab + sh[i].sh_name, n);
for (size_t k = 1; k < n && k <= 20; k++) {
const char *nm = names + sym[k].st_name;
if (!*nm) nm = "(null)";
printf(" %-24s value=0x%08llx size=%5llu bind=%u type=%s shndx=%u\n",
nm, (unsigned long long)sym[k].st_value,
(unsigned long long)sym[k].st_size, sym[k].st_info >> 4,
sym_type_name(sym[k].st_info & 0xf), sym[k].st_shndx);
}
}
free(buf);
return 0;
}
对一个交叉编译的 demo.o(x86-64 ELF)运行:
$ ./elfdump demo.o
ELF64 type=1 machine=62 entry=0x0 shoff=0x228 shnum=11 shstrndx=1
[节头] idx name type addr off size
0 OTHER 0x000000000000 0x000000 0
1 .strtab STRTAB 0x000000000000 0x0001c0 99
2 .text PROGBITS 0x000000000000 0x000040 26
3 .rela.text RELA 0x000000000000 0x000160 48
4 .data PROGBITS 0x000000000000 0x00005c 4
5 .comment PROGBITS 0x000000000000 0x000060 48
6 .note.GNU-stack PROGBITS 0x000000000000 0x000090 0
7 .eh_frame OTHER 0x000000000000 0x000090 64
8 .rela.eh_frame RELA 0x000000000000 0x000190 48
9 .llvm_addrsig OTHER 0x000000000000 0x0001c0 0
10 .symtab SYMTAB 0x000000000000 0x0000d0 144
[符号表 .symtab] 6 项(前 20 项)
- value=0x00000000 size= 0 bind=0 type=FILE shndx=65521
(null) value=0x00000000 size= 0 bind=0 type=SECTION shndx=2
add value=0x00000000 size= 14 bind=1 type=FUNC shndx=2
g value=0x00000000 size= 4 bind=1 type=OBJECT shndx=4
main value=0x00000010 size= 10 bind=1 type=FUNC shndx=2
对照 1.7.9 的表读:
e_shoff=0x228、e_shnum=11:节头表在文件 0x228,共 11 项,每项 64 字节。.text在 0x40,26 字节;add的st_value=0、st_size=14,main在偏移 0x10。目标文件里st_value是节内偏移,不是虚拟地址。.rela.text有 48 字节 = 2 项 × 24 字节,正好对应 1.7.11 的两条重定位(call与 RIP 相对访问)。.symtab的sh_link=1指向.strtab,st_name是那张表的偏移。shndx=65521(0xfff1)是SHN_ABS,65522(0xfff2)是SHN_COMMON;FILE 符号的名字是编译器的输入名(这里是-)。
这个程序只处理小端 ELF64;大端或 ELF32 需要换解析函数。真实工具(readelf、llvm-readobj)还要处理压缩节、SHT_GROUP、.gnu.hash、扩展节号(e_shnum=0 时用 section 0 的 sh_size)等边界。理解主干后,这些都能按同样的「读结构体、按偏移跳转」方式加。
如果给它一个非 ELF 文件,程序会在检查魔数后退出:
$ ./elfdump /bin/ls.dylib
/bin/ls.dylib: not an ELF file
节头表按链接器写入的顺序排列,.text 通常在文件前部,.symtab/.strtab 在后部;节索引只是数组下标,不代表地址顺序。用 readelf -S 时看 Offset 列,不要假设「索引越大越靠后」。
1.7.13 完整程序:LD_PRELOAD 与 --wrap 打桩
链接期打桩有两种典型方式:动态链接期用 LD_PRELOAD 抢先加载一个共享库,静态链接期用 --wrap 把引用改到自己的函数。下面两个程序分别演示。
LD_PRELOAD:统计净分配次数
/* malloc_count.c — LD_PRELOAD 统计净分配次数
* 编译:cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -fPIC -shared \
* malloc_count.c -o libcount.so -ldl
* 运行:LD_PRELOAD=./libcount.so ls
*/
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include <dlfcn.h>
#include <stddef.h>
#include <unistd.h>
static void *(*real_malloc)(size_t);
static void (*real_free)(void *);
static unsigned long live;
static int resolving;
static void write_num(int fd, const char *tag, unsigned long v)
{
char buf[64];
int n = 0;
while (tag[n]) { buf[n] = tag[n]; n++; }
char tmp[24];
int k = 0;
do { tmp[k++] = (char)('0' + v % 10); v /= 10; } while (v);
while (k) buf[n++] = tmp[--k];
buf[n++] = '\n';
ssize_t ignored = write(fd, buf, (size_t)n);
(void)ignored;
}
void *malloc(size_t n)
{
if (!real_malloc && !resolving) {
resolving = 1;
real_malloc = (void *(*)(size_t))dlsym(RTLD_NEXT, "malloc");
resolving = 0;
}
void *p = real_malloc(n);
if (p && !resolving) live++;
return p;
}
void free(void *p)
{
if (!real_free && !resolving) {
resolving = 1;
real_free = (void (*)(void *))dlsym(RTLD_NEXT, "free");
resolving = 0;
}
if (p && !resolving) live--;
real_free(p);
}
__attribute__((destructor)) static void report(void)
{
write_num(2, "malloc_count: live allocations = ", live);
}
编译与运行:
$ cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -fPIC -shared \
malloc_count.c -o libcount.so -ldl
$ LD_PRELOAD=./libcount.so ls > /dev/null
malloc_count: live allocations = 0
$ LD_PRELOAD=./libcount.so cat /etc/hostname > /dev/null
malloc_count: live allocations = 0
「净分配为 0」说明程序在退出前把申请的内存都释放了;若某个库泄漏,这个数会大于 0。真实工具(valgrind --tool=memcheck、heaptrack)用同样的机制跟踪每次分配,只是记录更多信息。
关键点:
dlsym(RTLD_NEXT, "malloc")取「下一个」malloc,也就是 libc 的实现;直接调用malloc会递归到自己。resolving防递归:dlsym内部可能调用malloc,第一次解析时要避免再次进入钩子。__attribute__((destructor))在进程退出前执行,适合输出统计;但不要在里面用printf(可能分配内存),用write。- 只能拦截动态链接的调用。静态链接、直接
syscall(SYS_brk)、或经__libc_malloc内部调用的路径不会被拦截。
--wrap:链接期替换
/* wrap.c — cc -O2 wrap.c -Wl,--wrap=puts -o wrap */
#include <stdio.h>
int __real_puts(const char *s);
int __wrap_puts(const char *s)
{
fputs("[wrap] ", stdout);
return __real_puts(s);
}
int main(void)
{
puts("hello");
return 0;
}
$ cc -O2 wrap.c -Wl,--wrap=puts -o wrap
$ ./wrap
[wrap] hello
--wrap=puts 让链接器把所有未定义引用 puts 改写成 __wrap_puts,同时把 __real_puts 解析为原来的 puts。它只影响链接到该可执行文件的引用;共享库内部的调用、或同文件内被内联的调用不受影响。常用来在测试中替换 malloc、time、open 等外部依赖,而不改源码。
| 手段 | 生效阶段 | 作用范围 | 典型用途 |
|---|---|---|---|
LD_PRELOAD | 加载期 | 整个进程(含依赖库) | 性能分析、故障注入、兼容层 |
--wrap | 链接期 | 当前链接单元 | 单元测试打桩 |
#define/函数指针 | 编译期 | 源码级 | 可测试性设计 |
ptrace/eBPF | 运行期 | 内核/用户态 | 观测、热补丁 |
- 静态链接的程序不吃
LD_PRELOAD——没有动态符号表与ld.so,预加载不会发生。 dlsym本身可能分配内存——钩子里要先设防递归标志,否则栈溢出。--wrap不影响已内联的调用——-O2把puts内联后,链接器看不到引用;测试打桩要用-fno-builtin或-O0。- glibc 2.34+ 把
dlopen/dlsym并入 libc——-ldl仍可加,但不再是必需;老系统需要它。
1.7.14 Rust 与链接:同一套 ELF,不同的名字
Rust 编译产物就是普通 ELF,链接器、readelf、LD_PRELOAD 都照常工作。差异在符号命名与代码生成:
| 主题 | C | Rust |
|---|---|---|
| 符号名 | 与函数名相同 | 默认经过 v0 mangling(_RNv...) |
| 导出 C ABI | 天然 | #[no_mangle] pub extern "C" |
| 结构体布局 | 按 ABI | 默认可重排,FFI 要 #[repr(C)] |
| 静态库 | .a | --crate-type staticlib(含 Rust 运行时) |
| 动态库 | .so | --crate-type cdylib |
| 链接时优化 | -flto | -C lto=fat,跨 crate 内联 |
| 未使用代码 | --gc-sections | 默认按可达性删除,#[used] 保留 |
| 符号可见性 | -fvisibility=hidden | 默认隐藏,#[no_mangle] 才导出 |
把 Rust 代码放进指定节:
#![allow(unused)]
fn main() {
#[used]
#[link_section = ".rodata.mydata"]
static BLOB: [u8; 4] = *b"RUST";
}
#[used] 防止编译器删除,link_section 让链接脚本或 C 代码能按节名找到它。内核模块、eBPF、嵌入式固件常用这招把元数据放进固定节。
Rust 的 panic 默认展开(unwind),跨 extern "C" 边界会 abort;需要 C++ 异常兼容时用 -C panic=abort 或 catch_unwind 在边界内处理。nm -C 能还原 Rust 的 v0 mangling,perf 也支持 demangle;发布库建议只导出 #[no_mangle] 的薄封装,把 mangled 符号藏在 pub(crate) 后面。
1.7.15 实验:读一个真实可执行文件的链接信息
$ cc -O2 -g -o hello hello.c
$ file hello
hello: ELF 64-bit LSB pie executable, x86-64, dynamically linked, ...
$ readelf -h hello | grep -E 'Type|Machine|Entry'
Type: DYN (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x1050
$ readelf -d hello | grep -E 'NEEDED|RPATH|RUNPATH'
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
$ ldd hello
linux-vdso.so.1 (0x00007ffd...)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)
/lib64/ld-linux-x86-64.so.2 (0x00007f...)
Type: DYN 说明它是 PIE:入口是相对地址,加载时由内核选基址(ASLR)。readelf -r 会看到大量 R_X86_64_RELATIVE;-no-pie 编译则变成 EXEC,入口是固定地址,RELATIVE 消失。
符号与重定位:
$ nm -C hello | grep ' T '
0000000000001139 T main
$ nm -D hello | head # 动态符号表
U puts@GLIBC_2.2.5
$ objdump -d --section=.plt hello | head -20
0000000000001020 <puts@plt>:
1020: ff 25 e2 2f 00 00 jmp QWORD PTR [rip+0x2fe2]
1026: 68 00 00 00 00 push 0x0
102b: e9 e0 ff ff ff jmp 1010 <_init+0x10>
PLT 项的三条指令就是 1.7.6 讲的「懒绑定」:第一次 jmp 跳到 GOT 里的 resolver 地址,push 索引,jmp 到公共解析器;解析后 GOT 被改成 puts 的真实地址,后续直接跳转。LD_BIND_NOW=1 ./hello 会在启动时全部解析,LD_DEBUG=bindings 能打印每次绑定。
最后看加载器找了哪些库:
$ LD_DEBUG=libs ./hello 2>&1 | grep 'calling init\|find library'
$ LD_PRELOAD=./libcount.so ./hello
LD_DEBUG 的输出会列出搜索路径、DT_NEEDED、初始化顺序;LD_PRELOAD 则把 libcount.so 插到 libc.so.6 之前。两者都是 ld.so 的行为,不需要重新链接。
1.7.16 十二条链接陷阱
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | undefined reference to 'foo' | 声明有、定义没链进来,或库顺序不对 | 把提供 foo 的库放在引用者右边 |
| 2 | 多个 multiple definition | 两个强符号同名 | 改 static/弱符号/合并定义 |
| 3 | 静态库里的符号找不到 | 归档只按需拉取,且只扫一遍 | --start-group/--whole-archive 或调整顺序 |
| 4 | C++ 调用 C 函数链接失败 | C++ 名字修饰 | 头文件加 extern "C" |
| 5 | LD_PRELOAD 不生效 | 程序静态链接,或调用被内联 | 动态链接;-fno-builtin;改用 --wrap |
| 6 | dlsym(RTLD_NEXT) 返回 NULL | 符号被 -fvisibility=hidden 隐藏,或版本不匹配 | 导出符号;用 dlvsym |
| 7 | PIE 下绝对地址报错 | -fPIE 代码里用了非 PIC 的绝对重定位 | 用 -fPIC 编译,或 -no-pie |
| 8 | 运行时报 cannot open shared object | RUNPATH 未包含库路径 | -Wl,-rpath,'$ORIGIN',或设 LD_LIBRARY_PATH |
| 9 | --wrap 没替换成功 | 调用被内联,或目标是共享库内部调用 | 关闭内联;改在链接单元外调用 |
| 10 | Rust 符号在 nm 里找不到 | 默认 v0 mangling 且可能被 LTO 删除 | #[no_mangle] + #[used],或 nm -C |
| 11 | 升级 glibc 后旧二进制跑不了 | 符号版本要求高于系统 | 在旧系统构建,或静态链接(慎用) |
| 12 | -Wl,--as-needed 导致插件加载失败 | 依赖被判定「未使用」而丢弃 | --no-as-needed 或显式 dlopen |
- 「链接就是拼文件」——还要做符号解析、重定位、节合并、垃圾回收、PLT/GOT 生成;每一步都可能改变语义。
- 「动态库更新一定兼容」——ABI 兼容要求结构体布局、符号版本、调用约定都不变;加字段、改
enum大小都可能破坏。 - 「
-static更稳定」——静态链接绕过了库更新,但也拿不到安全修复,且 glibc 静态链接在 NSS/DNS 上有已知限制。
1.7.17 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出链接器四步 | 能区分符号解析与重定位 | 1.7.1 |
读 readelf -S/-l | 能指出 .text/.data/.bss 与对应段 | 1.7.2 |
解释 multiple definition | 能说出强/弱符号规则 | 1.7.3 |
| 手算 PC 相对重定位 | 会代 S + A - P 并处理 A=-4 | 1.7.4 |
| 解释 PIE 与 ASLR | 能说出 ET_DYN 与随机基址的关系 | 1.7.5 |
| 解释 PLT 首次调用 | 能画出 GOT 从 resolver 到真实地址的变化 | 1.7.6 |
用 LD_PRELOAD 替换函数 | 能写 RTLD_NEXT 并说明递归风险 | 1.7.8 |
Q1 nm hello.o 显示 U printf,但链接 hello.o 时不加 -lc 会报什么?为什么加了 -lc 就能过?
Q2 两个文件都定义 int counter;,GCC 13 默认编译后链接报 multiple definition,而 GCC 9 不报。解释差异,并给出恢复旧行为的选项。
Q3 一个共享库的 DT_RPATH=/opt/lib,同时进程设了 LD_LIBRARY_PATH=/tmp/lib。ld.so 先搜哪个?如果这个程序是 setuid 的呢?
Q4 call puts@plt 第一次执行时,%rip 最终跳到哪?GOT 表项在这前后有什么变化?
答案
A1 报 undefined reference to 'printf'。hello.o 里只有对 printf 的未定义引用和重定位条目,没有实现;-lc 把 libc.so 加入链接,链接器在其中找到定义并完成重定位。C 标准库函数不是编译器凭空生成的。
A2 GCC 10 起默认 -fno-common,未初始化全局变量进入 .bss 并算强符号,两个强定义冲突。GCC 9 及更早默认 -fcommon,两个 counter 被合并为一个 common 符号。用 -fcommon 可恢复旧行为,但正确做法是把定义放进一个 .c,其他文件用 extern 声明。
A3 若该库只有 DT_RPATH 而没有 DT_RUNPATH,先搜 /opt/lib,再搜 LD_LIBRARY_PATH。若是 setuid 程序,进入安全执行模式,LD_LIBRARY_PATH 被忽略,RPATH 仍可能生效(取决于加载器与权限),所以不要把可写目录放进 RPATH。
A4 第一次执行先跳到 PLT 桩的 push index; jmp plt0,由 ld.so 解析出 puts 的真实地址;解析器把该地址写进对应的 GOT 表项,再跳过去。之后同一调用点的 jmp *GOT[n] 直接到 puts,不再进解析器。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| C(CSAPP) | 第 7 章 链接(7.1–7.14) | 1.7.1–1.7.8 | 完整:编译驱动、ELF、符号、重定位、加载、动态链接、PIC、打桩 |
| C(CSAPP) | 第 7 章 7.14 处理目标文件的工具 | 1.7.8 | 改写为取证顺序与工具分工表 |
| K(Kerrisk) | 第 41 章 共享库基础 | 1.7.5–1.7.6 | 精选:soname、搜索路径、ldconfig |
| K(Kerrisk) | 第 42 章 共享库高级特性 | 1.7.6–1.7.8 | 精选:dlopen、符号可见性、LD_PRELOAD |
| G(鸟哥) | 第 21 章 软件安装:源代码与 Tarball | 1.7.1、1.7.8 | 背景:./configure && make 中的链接步骤 |
CSAPP 第 7 章小节清单
| CSAPP 小节 | 页码 | 状态 | 本书落点 |
|---|---|---|---|
| 7.1 编译器驱动程序 | 501 | 合并 | 1.7.1 |
| 7.2 静态链接 | 502 | 已覆盖 | 1.7.1、1.7.3 |
| 7.3 目标文件 | 502 | 已覆盖 | 1.7.2 |
| 7.4 可重定位目标文件 | 503 | 已覆盖 | 1.7.9(Ehdr/Shdr/Sym/Rela 逐字段) |
| 7.5 符号和符号表 | 504 | 已覆盖 | 1.7.2、1.7.9 |
| 7.6 符号解析 | 506 | 已覆盖 | 1.7.3、1.7.10(强/弱/COMMON/归档顺序) |
| 7.7 重定位 | 514 | 已覆盖 | 1.7.4、1.7.11(PC32/PLT32 手算) |
| 7.8 可执行目标文件 | 519 | 合并 | 1.7.5、1.7.15 |
| 7.9 加载可执行目标文件 | 520 | 已覆盖 | 1.7.5 |
| 7.10 动态链接共享库 | 521 | 已覆盖 | 1.7.6 |
| 7.11 从应用程序中加载和链接共享库 | 523 | 合并 | 1.7.6(dlopen/dlsym) |
| 7.12 位置无关代码 | 525 | 已覆盖 | 1.7.7 |
| 7.13 库打桩机制 | 532 | 已覆盖 | 1.7.8、1.7.13(LD_PRELOAD、--wrap) |
| 7.14 处理目标文件的工具 | 532 | 已覆盖 | 1.7.15(readelf/nm/objdump/ldd) |
| 7.15 小结 | 532 | 合并 | 1.7.16 |
下一站:链接好的程序被
execve装入后,从哪里开始执行?一个除零、一次缺页、一个Ctrl-C,在内核里分别走哪条路径?进入 1.8 异常控制流,看 CPU 与内核如何交接控制权。