“编译成功,为什么运行时找不到库?”和“库明明在,为什么还报 undefined symbol?”不是同一个问题。
前者通常停在文件定位,后者已经进入符号与 ABI 的匹配。
如果只不断增加 LD_LIBRARY_PATH,很容易把一个可解释的错误变成依赖启动终端的偶发故障。
这篇把 ldd、readelf、objdump、nm、size 串成一条调查路线。
先确定手里的是什么文件,再确认加载了谁,随后查看符号和指令,最后讨论体积。
入口与其他工具见 Linux 工具学习地图;编译背景可接着读原来的 GCC 笔记。
实验、动画与适用范围
本文面向 GNU/Linux 的 ELF 工具链,不把 macOS 的 Mach-O 或 Windows 的 PE 文件当成 ELF。
同名命令可能来自不同实现,先看 --version,跨架构时尤其要记录编译器与 binutils 的目标支持。
在完整网站源码仓库根目录运行实验入口:
bash docker/linux-tools-lab/run.sh build |
原生编译和工具执行都放在声明的 Docker 环境中,不要求在宿主机补装 GCC。
实验文件位置、结果和架构以 复现说明、验证状态、原始会话 为准。
GIF 是实际命令输出经过筛选和重新排版的教学动画,不是屏幕录像;正文中的工程模板不因此自动成为已通过的实验。
减少动态效果时显示静态图,地址、大小、版本字符串应以自己那次运行为准。
实验使用自建的可信文件:/work/fixture/tool_demo、libdemo.so 与 symbols.o。
共享库提供 C 接口 demo_add;目标文件还提供全局数据、局部数据、C++ 函数 demo::square 及未定义的 puts,便于比较符号类型。
下面每节使用的 controller_node 等则是自己的工程产物占位名,并非要求实验仓库中存在这些文件。
例如在实验生成目录里,可以把同一观察方法用于以下对象:
cd /work/fixture |
先建立一个二进制模型
一次典型 C++ 构建大致经历:源文件编译成 .o,链接器组合目标文件和库,得到可执行文件或共享库,运行时加载器再完成装载与必要的符号解析。
每一阶段都可能出现“找不到”,只是找的对象不同。
| 现场 | 真正要找的东西 | 优先观察 |
|---|---|---|
| 编译器报头文件不存在 | 声明与宏 | 编译命令中的 include 路径 |
| 链接时报 undefined reference | 链接输入里的符号定义 | nm、实际链接命令 |
| 启动时报 cannot open shared object | 运行时依赖文件 | readelf -d、可信程序的 ldd |
| 启动或插件加载报 undefined symbol | 当前加载集合中的可用动态符号 | nm -D、readelf -V |
| 程序运行结果异常 | 指令、数据与执行现场 | objdump、GDB、运行时证据 |
ELF 中还要区分两种组织方式:section 面向链接和文件内容组织;segment 描述程序装载所需的映射。
.text 是节名,PT_LOAD 是程序头类型,不能把两个词互换。
一个装载段可以包含多个节;不是每个节都需要映射进运行中的进程。ELF 程序装载规范
readelf:先验明文件身份
不运行程序也能回答什么
readelf 读取 ELF 元数据,而不是启动目标程序。
第一轮不需要输出所有 DWARF 信息,先回答“格式、架构、装载、依赖”四个问题:
readelf -hW ./controller_node |
-h 看 ELF 头,-l 看程序头,-S 看节表,-d 看动态节;-W 减少长名称折行。
这些输出是文件声明的事实,不代表目标已经成功运行。GNU readelf 手册
如何读关键字段
Class 区分 ELF32 与 ELF64;Data 表示字节序;Machine 表示目标机器类型。
“都是 64 位”不足以说明可互换,x86-64 和 AArch64 仍是不同指令集。
Type: REL 常见于可重定位目标文件;EXEC 是可执行类型;DYN 用于共享对象,也常见于 PIE 可执行文件。
所以看到 DYN 不能马上下结论“这不是程序,只是一个 .so”。
还需要结合入口、解释器、动态标志和构建方式判断。
程序头里,Offset 是文件偏移,VirtAddr 是虚拟地址描述,FileSiz 和 MemSiz 分别描述文件与内存映像的大小。
MemSiz 大于 FileSiz 的部分可以用于零初始化数据;这与把同等长度的零字节全写进磁盘文件不是一回事。
查看 INTERP 项尤其有用:一个确实存在的可执行文件若启动时提示“没有那个文件”,缺失的可能是它指定的动态解释器。
这时反复 chmod +x 不能补回目标解释器,也不能解决架构不匹配。
PIE 地址不能直接当运行时地址
readelf、nm、objdump 显示的是文件中的符号值或链接视角地址。
对于 PIE 和共享库,装载器可能施加 load bias,ASLR 还可能让不同运行的映射位置不同。
因此不能拿上一次 nm 的数字直接当这一次 GDB 的绝对地址。
一般思路是先确认目标映射,再把文件地址与对应装载段对齐。
不能简单取 /proc/PID/maps 任意一行开头作为“库基址”:要核对文件偏移和段的虚拟地址。
工具能按符号定位时优先使用符号,让 GDB 结合实际映射完成定位。ELF 基地址定义
重定位与符号版本
readelf -rW ./controller_node |
重定位表不是“程序出错列表”,而是需要按照目标 ABI 完成地址或数值修正的记录。
条目涉及修改位置、符号索引、重定位类型,以及某些格式里的加数。
REL 把隐式加数放在待修正位置的内容里;RELA 在重定位项里显式携带 addend。
具体计算规则与重定位类型由架构 ABI 决定,不能把 x86-64 的某个公式直接套在所有 AArch64 条目上。ELF 重定位规范
-V 能帮助检查版本需求与定义;“库名相同”不等于满足所需的 GLIBCXX_... 或其他符号版本。
-n 可查看 build ID 等 note;它适合关联构建产物,但不是恶意篡改检查的替代品。
最小练习:比较同一源码的 .o 与最终可执行文件。
记录哪些未定义符号被链接器解决,哪些依赖留到动态装载阶段;不要只比较文件扩展名。
ldd:当前环境到底会找到哪些库
只对可信文件使用
ldd 的问题不是“这个文件文字里提到了谁”,而是“在当前环境与加载器规则下,依赖会解析到哪里”。
它通常借助动态加载器跟踪依赖,不能把它当成对任意不可信程序都安全的纯静态解析器。
尤其不要对网上下载的未知二进制直接运行 ldd。Linux ldd 手册的安全说明
对不可信或来源未确认的文件,优先在隔离环境中静态检查:
readelf -dW ./unknown_program |
其中 NEEDED 反映直接记录的依赖,不会像依赖解析那样自动展示完整递归加载结果。
静态解析器也需要保持更新,不应由此推导出“任何恶意文件都能放心交给有权限的工具”。
读懂解析结果
对自己构建、可以信任的程序:
ldd ./controller_node |
一般行会呈现“依赖名称 → 找到的路径”,或明确的 not found。
路径比“有没有这一行”重要:找到 /usr/local/lib 的旧库与找到当前工作空间的库不是同一结果。
-v 增加版本相关信息;-r 检查数据及函数重定位中缺失的对象或函数,仍只对可信程序使用。
静态链接程序、不是当前加载器可识别的文件、缺失解释器等情形,输出可能不是普通依赖列表。
要回到 readelf -h/-l 建立文件身份,不能把所有“not a dynamic executable”都解释成文件损坏。
搜索路径为什么会随终端变化
glibc 动态加载器会结合对象中记录的路径、环境、缓存与系统目录寻找库。
DT_RPATH 与 DT_RUNPATH 不是同义词:在常见 glibc 规则下,RUNPATH 仅用于该对象直接记录的依赖,不自动替孙依赖提供同样的搜索路径。
安全执行模式还会限制或忽略某些环境变量。glibc 加载器搜索规则
针对一个可信测试程序,可临时观察,不污染以后所有终端:
env LD_LIBRARY_PATH=/opt/robot/debug-lib \ |
第二条会真正执行程序,--help 也不是所有程序都有的无副作用入口。
只对已经了解行为的测试目标使用;输出可能很多,并可能泄露部署路径。
若需要永久修复,优先处理安装布局、包声明和目标的运行路径,不把临时变量不断追加进 .bashrc。
正确保留 $ORIGIN
假设发布目录约定为 bin/controller_node 与 lib/libcontroller.so,可以在自己工程的链接命令中表达相对部署关系:
g++ main.o -L./lib -lcontroller \ |
这是工程模板,前提是 main.o、库和输出目录已经存在。
单引号让 shell 不提前展开 $ORIGIN;运行时由加载器按包含该标记的对象所在目录解释。
它不是当前工作目录,也不是一个应由用户 export 的普通环境变量。
这里显式启用 new dtags,随后仍要检查最终是 RUNPATH 还是别的结果。
CMake、Make、shell 各有一层转义规则,不能把 shell 中正确的引号原样当成每种构建文件的唯一写法。GNU ld 运行路径选项
最小练习:只改变一次性 LD_LIBRARY_PATH,比较 ldd 的解析路径。
回答“现在找到库了”和“现在找到正确版本了”分别需要什么证据。
nm:谁定义了这个符号
从名字追踪到定义
链接错误里很长的 _Z... 往往是 C++ 修饰名。
编译器需要把名称空间、参数类型等信息编码进链接名称,才能区分重载。
先把名字还原成人能读的形式:
nm -C ./controller.o |
-C 负责 demangle,-D 选择动态符号表。
普通符号表里存在一个函数,不代表动态加载器能从这个共享库导出它。GNU nm 手册
字母不是随意的缩写
常见的 U 表示未定义,T 常表示代码区定义,D 常表示已初始化数据,B 常表示零初始化区。
大小写通常关联局部或外部符号,但存在 GNU unique 等例外,不能用“全大写一定导出”替代正式属性。
弱符号也有不同类别:一个可选的未定义弱符号不一定导致和普通强未定义符号相同的故障。
想进一步判断时,使用:
readelf --dyn-syms -W ./libcontroller.so |
观察 Bind、Vis、Ndx:它们分别提供绑定、可见性、定义所在节或特殊状态的信息。
GLOBAL 不等于“任何动态链接对象都可以用”,还要看可见性、是否进入动态表以及加载作用域。
HIDDEN 与 DEFAULT 的外部可见行为不同。ELF 符号绑定与可见性
找到相似名字还不够
如果调用方需要 update(double),而库只提供 update(float),demangle 后看起来“都有 update”,链接器仍不会替你转换接口。
const、引用、名称空间、类 ABI、标准库 ABI 与符号版本也可能造成不匹配。
静态库还涉及归档成员何时被拉入链接。
对采用常见单向扫描规则的 GNU 链接过程,库顺序可能改变解析结果;用真实链接命令检查,而不是只证明 .a 里某个地方有该函数。
循环依赖可以审慎评估 linker group,但不应一上来就把所有库 whole-archive。GNU ld 归档搜索与分组
nm -A -C ./libcontroller.a |
-A 把文件或归档成员身份带进输出,适合追踪“定义究竟来自哪里”。
插件允许保留某些由宿主提供的未定义符号时,不能只因 nm -D 有 U 就断言构建错误。
反过来,也不能因为插件链接成功就认为运行环境一定提供这些符号。
最小练习:在自己源码副本里改变一个函数的参数类型,只重编调用方。
比较修饰名和链接报错;再完整重建双方,验证接口一致才是真正的修复。
objdump:从符号走到机器指令
不要一开始反汇编整个文件
objdump 既能看文件与节信息,也能反汇编。
当疑问是“这段代码是否被内联或优化掉”,看具体函数比倾倒几十万行输出有效:
objdump -f ./controller_node |
-d 反汇编被认为包含指令的节;-D 范围更广,可能把数据按指令解释,不是“更真实”的开关。
-S 尝试把源码与反汇编交错展示,需要合适的调试信息和可找到的源码。GNU objdump 手册
重定位信息让调用位置更可读
未最终链接的目标文件中,某些调用尚未具备最终目标地址:
objdump -dr -C ./controller.o |
-r 展示普通重定位,-R 用于动态重定位。
对于 .o 中看似指向附近的调用,结合重定位项里的符号才能知道链接器将来要补到哪里。
不要把占位数值当成真实函数关系。
输出中地址、机器码字节、助记符和操作数是不同列。
同样一个 C++ 表达式在不同架构上会生成不同指令;x86 常见的显示风格选项不能用来改造 AArch64 指令集。
跨架构反汇编失败时,先 objdump -i 看当前工具支持哪些目标,必要时使用目标前缀工具链。
优化不等于一行对应一条指令
源码中的局部变量可能只存在于寄存器、被常量折叠,或者完全被消除。
两个函数可能因内联、尾调用等因素不再形成直觉中的调用栈。
反汇编能帮助解释优化后的布局,但“看见一个 call”仍不能说明它运行了多少次。
不要用静态指令条数直接推算真实延迟:缓存、分支预测、向量化、访存和调度都可能改变结果。
真正的热点需要运行时采样或测量,执行状态需要 GDB 调试 的现场。
最小练习:对同一份无未定义行为的小函数分别用 -O0 与 -O2 构建。
保留源码、编译命令和两个输出文件,比较调用是否消失,而不是只比较哪份反汇编“看起来短”。
size:体积大,究竟大在哪里
先选统计口径
size 统计目标文件的节大小,不是在启动进程后测 RSS。
GNU 工具支持不同输出格式,比较前要固定格式与进制:
size -B ./controller_node |
-B 使用 Berkeley 风格摘要,常见列是 text/data/bss/dec/hex。
-A 按节展开,-d 显式选择十进制。
不同格式对只读数据的归类可以不同,不能把两个格式同名摘要列直接混着比较。GNU size 手册
文件大小、映像大小、RSS 是三件事
一个大零初始化全局数组通常增加 .bss 表示的内存需求,却不需要在文件里存储等量的零。
加入调试信息可能明显增大磁盘 ELF,却不意味着每个调试节都会映射成运行时常驻内存。
堆和各线程栈的动态使用,也不会完整反映在可执行文件的 size 摘要里。
因此排查“镜像变大”时看文件与包;排查“静态数据变大”时看节;排查“运行越来越占内存”时需要进程内存指标。
后者接着看 资源监控篇,不要用裁剪调试信息去治疗堆泄漏。
把体积回归追到候选符号
size -A -d ./controller_old |
如果主要是代码区增长,检查模板实例、静态链接输入与优化参数。
如果是数据区增长,检查新增表格、数组和全局对象。
nm -S --size-sort 能列出候选符号大小,但并非所有填充、链接器生成内容都能分摊到一个普通符号。
只有同架构、相近编译参数、相同统计口径下,比较才容易解释。
先保存含符号的原文件再研究裁剪策略;直接覆盖唯一一份调试二进制,会损害以后分析 core 的能力。
最小练习:在独立样例里分别增加未初始化全局数组、显式非零初始化数组。
观察 size -A 与文件字节数,再实际运行测内存;解释三个数字为什么不会总按同一比例增长。
联合排障:ROS 插件库明明存在却加载失败
下面以 controller_node、librobot_controller.so 为占位工程,命令中的路径需要替换成自己的安装产物。
先保留失败现场:启动参数、工作空间 source 顺序、容器镜像、二进制和库的校验值。
不要先批量复制库到系统目录。
第一步:究竟运行了哪一个产物
readelf -hW /workspace/install/lib/controller_node |
实际 ROS 包可执行文件通常位于其包安装布局内,上面的路径只是示意。
确认 architecture、解释器、NEEDED、RUNPATH,再对可信产物使用 ldd。
若先发现路径指向旧工作空间,先解决选错产物,而不是继续分析新源码。
第二步:区分缺文件与缺符号
ldd /workspace/install/lib/librobot_controller.so |
缺文件时核对依赖是否部署、搜索路径是否合理。
缺符号时核对完整名称、参数、动态导出、版本需求及真正加载的提供者。
两个包各自编译成功不能证明它们的 ABI 一致,尤其是在混用不同系统镜像的库时。
第三步:修复后用同一入口验收
修复 CMake 依赖、符号导出或安装布局后,重建相应产物。
用原来的 launch、参数文件和环境重新启动,确认插件真正进入目标生命周期,而不只是错误信息暂时消失。
再用一次性环境或加载器诊断比对加载路径。
一份有用的结论应该是:“旧工作空间的 A 库被先加载,它缺少 B 符号;修正安装运行路径后加载了 C,接口与版本匹配,原启动入口通过。”
“加了几个 export 就好了”只能记录现象,还没有说明原因。
学完以后如何继续
进程已经启动但卡住,转到 进程诊断篇。
需要把对应库和日志安全带回分析机,转到 传输与定时任务篇。
本系列的选题入口来自用户提供的 Linux Tools Quick Tutorial 工具参考。
本文按工程问题重新组织,示例与推理为本系列独立编写;命令语义以就近链接的 GNU 手册、ELF 规范和发行版资料为准。