《ELF 二进制与动态链接:从符号到加载》

深入 ELF 二进制格式:ELF header、sections 与 program headers、符号表与重定位、PLT/GOT 懒绑定、ld.so 动态链接流程、LD_PRELOAD 与 LD_LIBRARY_PATH 注入、静态与动态链接对比、readelf/objdump/nm 分析,以及 undefined symbol 等常见链接问题的排查实战。

引言

./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

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 的既定规则):

  1. 编译期 DT_RPATH(已废弃,不建议用)
  2. 环境变量 LD_LIBRARY_PATH
  3. 编译期 DT_RUNPATH
  4. /etc/ld.so.cache(ldconfig 生成)
  5. 默认路径 /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>
所有 sectionreadelf -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
编译带 runpathgcc -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/ — 启动性能与链接参数调优

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「linux」更多文章

  1. 《进程调度与 CPU:CFS、优先级与 cgroup CPU 控制》
  2. 《Linux 网络虚拟化:namespace、veth、网桥与虚拟交换机》
  3. 《eBPF 与可观测性:bpftrace、追踪与动态插桩》