引言
./app 按下回车,内核解析 ELF 头、加载段、找到 ld.so 解释器,由它把几十个动态库的符号一一解析——程序才真正跑起来。理解 ELF 结构、符号与重定位、动态链接流程,是排查「undefined symbol」「找不到 lib」「版本不匹配」这类经典问题的基础。本文从 ELF 的 header/sections/program headers 讲起,拆解 PLT/GOT 与 ld.so 的装载流程,对比静态与动态链接,用 readelf/objdump/nm 实战分析二进制,并演示 LD_PRELOAD 注入这个既强大又危险的技巧。
前置:/linux-shell-scripting/(编译与脚本调用工具链)。进程视角的 /proc 与动态库加载见 /linux-process-management/。
目录
- 1. ELF 文件结构:header、sections、program headers
- 2. 符号表与重定位
- 3. 动态链接流程:解释器与装载
- 4. LD_PRELOAD 与 LD_LIBRARY_PATH
- 5. 静态链接与动态链接对比
- 6. readelf、objdump、nm 分析实战
- 7. dlopen 与符号解析:rpath 与 runpath
- 8. 常见链接问题排查
- 9. 实战:LD_PRELOAD 做沙盒与调试
- 10. 速查表
- 延伸阅读
1. ELF 文件结构:header、sections、program headers
ELF 是 Linux 可执行文件与动态库的统一格式——一张「header 说明文件长什么样,sections 存内容,program headers 说明运行时怎么装载」的三层结构:
ELF Header 魔数、架构、入口点、段表位置
Section Headers 编译期视图:.text .data .symtab .rela.* 等
Program Headers 运行期视图:哪些节映射进内存、何种权限
readelf -h /bin/ls # ELF header:架构、入口、类型
readelf -S /bin/ls # 所有 section
readelf -l /bin/ls # program headers(运行时段映射)
| section | 内容 |
|---|---|
| .text | 机器码(只读可执行) |
| .data | 已初始化全局变量 |
| .bss | 未初始化全局变量(不占文件,只占内存) |
| .rodata | 只读常量(字符串字面量) |
| .dynsym / .dynstr | 动态符号表与字符串 |
| .rela.dyn / .rela.plt | 重定位表 |
| .got / .plt | 全局偏移表与过程链接表 |
心智:sections 是编译视角(给链接器/调试器看),segments(program headers)是加载视角(给内核/ld.so 看)。一个 segment 常包含多个 sections,权限按「读/写/执行」组合。
2. 符号表与重定位
符号是链接的最小单位——函数名、全局变量名都登记在符号表里,重定位记录「这些符号在文件里到底要填到哪」:
nm -D /bin/ls # 动态符号表(只含被导出/引用的符号)
readelf -s /bin/ls # 完整符号表
readelf -r /bin/ls # 重定位条目
符号有两种身份:
| 类型 | 含义 |
|---|---|
| 定义符号(T/D/B) | 本文件提供实现(函数/变量) |
| 未定义符号(U) | 本文件引用、需要别处提供 |
| 弱符号(W) | 有更好定义就用别的,没有就用本地兜底 |
# 谁定义了 printf、谁还缺 printf
nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep ' T printf'
readelf -s /bin/ls | grep 'U printf'
记忆:链接失败最常见就是「一个 U 符号没人提供」。
undefined symbol报错时,先nm -D看是哪个库该提供、再ldd看它到底有没有被加载。
3. 动态链接流程:解释器与装载
内核只做第一棒:读 ELF header、把可加载段映射进内存,然后把控制权交给 PT_INTERP 指定的 ld.so:
readelf -l /bin/ls | grep -i interp # 解释器路径
ldd /bin/ls # 依赖的库及其解析路径
ld.so 要做三件事:读依赖清单(DT_NEEDED)、按搜索顺序找到库、逐符号重定位——函数调用通常走 PLT/GOT 懒绑定(第一次调用才解析,之后直接查表)。
第一次调用 foo():
call foo@PLT → PLT 跳到 GOT 中 foo 的槽 → 槽初始指向解析器
→ 解析器找到 foo 真实地址 → 写回 GOT → 跳转执行
后续调用 foo():
call foo@PLT → GOT 已填真实地址 → 直接执行
# 打开 ld.so 的调试输出,看它如何找库
LD_DEBUG=libs ./app 2>&1 | head
# 全程符号解析过程
LD_DEBUG=bindings ./app 2>&1 | head
记忆:懒绑定让程序启动更快,但第一次调用有解析开销。
LD_BIND_NOW=1或-z now链接可关闭懒绑定(牺牲启动速度换确定性),安全加固常这么做。
4. LD_PRELOAD 与 LD_LIBRARY_PATH
LD_PRELOAD 让你「抢先加载一个库并覆盖符号」——这是注入与拦截的标准手段:
# 让某程序优先使用自定义库中的 malloc
LD_PRELOAD=/opt/mylib.so ./app
# 临时加库搜索路径
LD_LIBRARY_PATH=/opt/mylib:$LD_LIBRARY_PATH ./app
动态库搜索顺序(ld.so 的既定规则):
- 编译期 DT_RPATH(已废弃,不建议用)
- 环境变量 LD_LIBRARY_PATH
- 编译期 DT_RUNPATH
- /etc/ld.so.cache(ldconfig 生成)
- 默认路径 /lib、/usr/lib
# 系统缓存库位置
ldconfig -p | grep libfoo
铁律:LD_PRELOAD 和 LD_LIBRARY_PATH 在「特权程序/AT_SECURE」下会被忽略——setuid 程序由内核强制进入安全执行模式,防止用环境变量劫持系统工具。注入失败时先查程序是否 setuid。
5. 静态链接与动态链接对比
静态链接把所有代码打进一个文件,动态链接只留引用——这是「部署简单」与「节省空间/共享更新」之间的取舍:
| 对比 | 静态链接 | 动态链接 |
|---|---|---|
| 文件大小 | 大(含全部依赖) | 小(只有引用) |
| 启动速度 | 快(无解析) | 慢(ld.so 解析) |
| 部署 | 拷走就能跑 | 目标机需有对应库 |
| 升级库 | 要重新链接 | 换库文件即生效 |
| 内存占用 | 每进程一份代码 | 共享页,多进程省内存 |
# 静态链接编译
gcc -static -o app_static app.c
# 动态链接编译(默认)
gcc -o app_dynamic app.c
file app_static app_dynamic # statically linked / dynamically linked
心法:静态链接适合「放到未知环境的一次性工具」,动态链接适合服务器常态部署。注意 glibc 静态编译会引入 NSS/网络解析等坑,容器场景更常用「动态 + 精简 base 镜像」方案。
6. readelf、objdump、nm 分析实战
三个分析工具的分工:readelf 看结构、objdump 看反汇编、nm 看符号:
readelf -h /bin/ls # 头部
readelf -d /bin/ls # 动态段(DT_NEEDED/DT_RPATH/RUNPATH)
readelf -W -l /bin/ls # 段映射(-W 不截断宽输出)
objdump -d /bin/ls | head # 反汇编 .text
objdump -T /bin/ls | head # 动态符号(类似 nm -D)
nm -C /bin/ls | grep main # C++ 符号还原(demangle)
# 实际排障:这个可执行文件需要哪些库
readelf -d ./app | grep NEEDED
# 某个符号在不在库里
nm -D /path/to/libfoo.so | grep myfunc
记忆:三件套速记 =
readelf问「结构上缺不缺」、nm问「符号有没有」、objdump问「代码长啥样」。链接类报错 90% 用前两个就能定位。
7. dlopen 与符号解析:rpath 与 runpath
运行时加载库(插件系统、按需加载)由 dlopen/dlsym 完成——它和启动时链接走同一条符号解析路径:
#include <dlfcn.h>
void *h = dlopen("./plugin.so", RTLD_NOW);
int (*fn)(void) = dlsym(h, "plugin_init");
fn();
dlclose(h);
# 编译时链接 dl 库
gcc -o app app.c -ldl
编译期的两个「提前找库」字段:
| 字段 | 优先级 | 说明 |
|---|---|---|
| DT_RPATH | 最高 | 传统、只在 ELF 里、子进程不继承 |
| DT_RUNPATH | 第三 | 现代推荐,优先级低于 LD_LIBRARY_PATH |
# 编译时指定 runpath
gcc -Wl,-rpath,/opt/mylib -o app app.c
# 查看编译结果
readelf -d app | grep -E 'RPATH|RUNPATH'
心法:新代码用 RUNPATH 别用 RPATH——RUNPATH 让 LD_LIBRARY_PATH 仍可覆盖,便于部署时临时替换库;RPATH 优先级过高,会绕过你的环境变量。
8. 常见链接问题排查
链接类报错有套路可循——先看报错类型,再选对应工具:
| 报错 | 含义 | 排查 |
|---|---|---|
undefined symbol: foo | 引用没人提供 | nm -D 找谁该提供、ldd 看加载没 |
cannot open shared object file | 库没找到 | ldd 看解析路径、加 LD_LIBRARY_PATH |
version GLIBCXX_3.4.20 not found | 库版本过旧 | strings libstdc++.so.6 | grep GLIBCXX |
relocation truncated to fit | 地址空间布局问题 | 检查大数组/静态库编译选项 |
# 万能第一步:看依赖能否全部解析
ldd ./app
# 库版本符号检查
objdump -T /usr/lib/.../libstdc++.so.6 | grep GLIBCXX_3.4.20
# 逐步跟踪 ld.so 查找过程
LD_DEBUG=libs,versions ./app 2>&1 | tail -20
铁律:
undefined symbol先分清「谁引用、谁该提供、提供方加载了没」三件事。版本类报错本质是「编译机库新、运行机库旧」,部署时锁定与编译环境一致的运行时即可。
9. 实战:LD_PRELOAD 做沙盒与调试
用 LD_PRELOAD 覆盖关键函数,可以实现「白名单沙盒」或「零侵入调试」:
// sandbox.c:拦截 open,只允许白名单路径
#define _GNU_SOURCE
#include <dlfcn.h>
#include <fcntl.h>
#include <string.h>
int open(const char *path, int flags, ...) {
if (strstr(path, "/allowed/") == NULL) { errno = EACCES; return -1; }
int (*real_open)(const char *, int, ...) = dlsym(RTLD_NEXT, "open");
return real_open(path, flags);
}
gcc -shared -fPIC -o sandbox.so sandbox.c -ldl
LD_PRELOAD=./sandbox.so ./app # app 只能读 /allowed/ 下的文件
调试场景:统计某函数的调用次数或耗时,同样覆盖一层即可:
# timing.c 里覆盖 malloc,记录每次调用
# 编译后:LD_PRELOAD=./timing.so ./app
记忆:LD_PRELOAD 沙盒是「用户态软限制」,不能当安全边界——程序可绕过、setuid 程序会忽略。它的正确身份是调试与兼容补丁:修 bug 不用改原程序,非常适合排查「第三方闭源程序调了什么库」。
10. 速查表
| 需求 | 命令 |
|---|---|
| ELF 头 | readelf -h <file> |
| 所有 section | readelf -S <file> |
| 段映射 | readelf -l <file> |
| 依赖库清单 | readelf -d <file> | grep NEEDED |
| 一键看依赖 | ldd <file> |
| 动态符号 | nm -D <file> |
| 反汇编 | objdump -d <file> |
| C++ 符号还原 | nm -C <file> |
| 注入自定义库 | LD_PRELOAD=/path/lib.so ./app |
| 加库搜索路径 | LD_LIBRARY_PATH=/path ./app |
| 库缓存查询 | ldconfig -p | grep libfoo |
| 编译带 runpath | gcc -Wl,-rpath,/path -o app app.c |
一句话记忆:ELF 三层结构 = header + sections(编译视角)+ program headers(加载视角);动态链接靠 ld.so 解析 PLT/GOT;查链接问题用「ldd 看加载、nm 看符号、readelf 看结构」三件套;LD_PRELOAD 是调试利器但非安全边界。
延伸阅读
- /linux-shell-scripting/ — 编译工具链与脚本调用
- /linux-process-management/ — 进程与动态库映射(/proc/pid/maps)
- /linux-systemd-services/ — 服务加载环境与库路径配置
- /linux-security-hardening/ — 安全加固与 LD_PRELOAD 攻击面
- /linux-performance-tuning/ — 启动性能与链接参数调优
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。