第一次把 nm 认真当回事,是因为一个undefined reference to 'xxx_init'的链接错误直接卡了我半天。项目里静态库有七八个,谁提供这个符号、链接行顺序对不对、是不是被--gc-sections裁掉了,全靠猜。后来在链接命令后面加了一句nm -C --defined-only libxxx.a | grep xxx_init,十秒钟就锁定了问题:那个库根本没被写进链接行。从那以后,nm 就成了我排查编译链接问题的第一把刀。它不解决任何问题,但它会把“有什么、缺什么、谁提供”三件事摊开摆在你面前。
nm 是 binutils 工具集里一个非常老、非常朴素的命令,做的事情只有一件:读取目标文件、静态库、动态库或可执行文件里的符号表,把每个符号的名字、地址、大小、类型列出来。就这么简单,但在三类场景里价值极高——链接期找不到符号、运行期符号解析失败,以及分析一个二进制的内部构成(哪些函数被保留下来了、哪个变量占了多大空间、这个库到底导出了哪些接口)。不管你是刚学 C 语言、第一次接触 Makefile 的同学,还是天天跟交叉编译工具链打交道的嵌入式工程师,只要你在和 .o、.a、.so 打交道,nm 都值得花半小时彻底吃透。
1. 先搞清楚 nm 到底在“看”什么
1.1 目标文件里的符号表是一张什么表
编译一个 C 文件,编译器做的事情可以粗暴地理解为两步:先把源码翻译成机器指令,再把这些指令、数据、字符串按照类型分门别类塞进不同的“段”里。代码进.text,已初始化的全局变量和静态变量进.data,未初始化的进.bss,字符串常量和 const 数据进.rodata,需要重定位的位置记录在.rela.text之类的段里。而这一切的“目录页”,就是符号表.symtab。
符号表里每一条记录对应的结构体长这样:st_name(名字在字符串表里的偏移)、st_value(地址或段内偏移)、st_size(占用大小)、st_info(绑定属性 + 类型)、st_shndx(属于哪个段)。nm 干的事,就是把这堆二进制字段翻译成人类能读的一行文本:地址、类型字母、符号名。你可以把它当成图书管理员,你报一个书名(符号名),它告诉你这本书在哪一排书架(哪个段)、有多厚(多大)、是馆藏的还是别处借来的(已定义还是未定义)。
理解这一点很关键:nm 不是在“反汇编”,也不是在“分析逻辑”,它读的是元数据。所以它速度快得离谱,几 MB 的 .a 文件瞬间出结果;但也意味着它看不懂代码在干什么,只能告诉你名字层面的信息。抱着“它是符号字典,不是调试器”的预期去用它,你就不会失望。
1.2 .symtab、.dynsym、调试信息是三份不同的清单
新手最容易晕的地方在这儿:同一个 .so 文件,为什么nm libfoo.so说 “no symbols”,而nm -D libfoo.so却能列出一堆东西?
原因是 ELF 文件里其实有两份独立的符号表。一份叫.symtab,是编译和链接阶段用的完整符号表,包含所有全局符号、局部符号、静态函数、文件内变量,信息最全。另一份叫.dynsym,只包含动态链接运行时真正需要的那部分符号——也就是这个库导出的对外接口,以及它依赖的外部符号。.dynsym是运行时动态链接器要用的,必须保留;.symtab只在开发调试阶段有用,发布时通常会被strip删掉。
所以判断一个库该怎么查,顺序是先看file libfoo.so或readelf -S libfoo.so | grep symtab:
| 场景 | 现象 | 该用哪个命令 |
|---|---|---|
| 未 strip 的库或可执行文件 | 有.symtab | nm全量可看 |
| 已 strip 的库 | 只剩.dynsym | nm -D看导出符号 |
已 strip 且用-fvisibility=hidden编译 | .dynsym里只剩极少数符号 | nm -D能看到的就这些,别怀疑命令坏了 |
| 调试信息 | .debug_info等段,与符号表无关 | 用objdump --dwarf或调试器 |
还有一份容易混进来的东西是调试信息。有人以为nm -a能看到变量在源码里的行号,其实nm -a只是把调试器符号也列出来,真正的行号映射需要-l,而且前提是编译时带了-g且没被 strip。这三个东西——完整符号表、动态符号表、调试信息——是完全解耦的,配置可以任意组合,这也是排查问题时要先确认“我手上这个文件到底处于哪种状态”的原因。
2. 输出里那些大小写字母,到底在说什么
2.1 符号类型码速查表
nm 输出每行的第二个字段是一个字母,这个字母是判断符号性质的核心。大写表示全局(外部可见),小写表示局部(仅本文件可见)。下面这张表建议直接收藏,排查问题时对着看:
| 字母 | 含义 | 典型场景 |
|---|---|---|
| T / t | 位于代码段,已定义的函数 | 大写是全局函数,小写是static函数 |
| D / d | 位于已初始化数据段 | 有初值的全局变量或静态变量 |
| B / b | 位于未初始化数据段(BSS) | 无初值的全局变量或静态变量 |
| R / r | 位于只读数据段 | 字符串常量、const 数组、跳转表 |
| U | 未定义,需要外部提供 | 调用了别的 .o 或库里的函数 |
| W / w | 弱符号,未强定义时可被覆盖 | __attribute__((weak))标注过的符号 |
| V / v | 弱对象符号 | 弱定义的变量 |
| C | common 符号 | 老式未初始化全局变量写法 |
| A | 绝对地址符号 | 汇编里.set出来的常量,或链接脚本定义的值 |
| N | 调试符号 | 与-a一起出现 |
| I | 间接引用 | 某些架构上的 PLT 相关引用 |
| i | 间接函数(ifunc) | 运行时才决定实现,常见于高性能库 |
| G / g | 小对象数据段 | 部分架构的 small data 优化 |
| S / s | 小对象 BSS | 同上 |
| ? | 未知类型 | 通常是文件格式没识别对 |
这里有几个我踩过坑的细节值得单独拎出来。第一,U是最值钱的字母,所有的“未定义引用”问题都从它身上找线索,一个 .o 里出现一堆U完全正常,关键是链接时能不能被填上。第二,T和t的差别决定了函数能不能被外部调用——你写了个辅助函数觉得没人用,结果它莫名其妙被外部引用了,大概率是你忘了加static,它在符号表里是大写T。第三,B和C的区别直接关联到 GCC 10 之后那个著名的“multiple definition”报错,后面会专门讲。
2.2 拿一段真实输出逐列拆开看
光看表容易记不住,拿一段实际输出走一遍就通了。假设有个静态库,里面有demo_math.o和demo_main.o两个成员:
$ nm -C -S -n libdemo.a demo_math.o: 0000000000000000 0000000000000024 T demo_add 0000000000000024 0000000000000018 T demo_sub 0000000000000000 0000000000000008 r demo_table 0000000000000000 0000000000000004 d seed.0 0000000000000004 0000000000000004 b counter U demo_log U printf demo_main.o: 0000000000000000 0000000000000082 T demo_main 0000000000000004 0000000000000004 b state U demo_add U demo_sub逐列解释。不带-S时只有三列:地址、类型、名字;加了-S就多一列 16 进制的大小。加了-n会按地址排序,方便看段内布局。demo_add那行读作:在代码段,起始偏移 0x0,长度 0x24 字节,是个全局函数。
这里有个几乎所有人第一次看都会困惑的点:为什么r demo_table、d seed.0、b counter的地址都是 0 或者很小的数字,还互相重复?因为对目标文件来说,符号的st_value是相对于所属段的偏移,不是最终虚拟地址。demo_table是.rodata段的第 0 个字节,seed.0是.data段的第 0 个字节,两者当然都是 0。等链接器把它们拼成一个可执行文件后,地址才会被重定位成真实的运行时地址。所以拿nm看.o文件时,地址那一列基本没有参考价值,看名字和类型就行;只有看已经链接完成的可执行文件或 .so 时,地址和大小才有意义。
另一个细节是seed.0后面那个.0后缀。这是编译器给函数内的 static 变量自动生成的唯一名字,.0、.1这样的后缀越多,说明同名静态变量出现得越频繁。这类名字在排查内存占用时特别有用,后面按大小排序的时候你会经常看到它们。
3. 参数组合:把 nm 从“能看”变成“能查”
3.1 七个必须练到条件反射的开关
nm 的参数不多,但有几个属于“不用就白用”的级别。我按使用频率排个序:
-C(等价--demangle):把 C++ 修饰过的名字还原成人能读的形式,_ZN5Demo3addEi变成Demo::add(int)。这是 C++ 项目的默认开关,没有它你看到的是一堆乱码。-u(等价--undefined-only):只列未定义符号。排查缺符号时,这一条能把输出从几百行缩到几行。--defined-only:只列已定义符号。想确认“这个库到底提供了什么接口”时用它。-A(等价-o、--print-file-name):每行前面带上文件名。批量扫多个 .o 或 .a 时必加,否则你根本不知道这行来自哪个文件。-S(等价--print-size):打印符号大小。分析二进制体积时必加。--size-sort:按符号大小排序。配合-S和-r就能直接揪出体积大户。-D(等价--dynamic):看动态符号表。所有 strip 过的库都得靠它。
这几个参数单独用都平平无奇,组合起来才真正好用。我日常最常用的一个组合是nm -A -C -S --size-sort -r -t d libxxx.a | head -30,意思是:带上文件名、还原 C++ 名字、显示大小、按大小降序、用十进制显示,只看前 30 条。一条命令就能看出这个库最占空间的是哪几个函数,对嵌入式项目做裁剪时非常管用。
还有一个不太常用但关键时刻救命的:-l(等价--line-numbers)。它会给每个符号标注源码文件和行号:
$ nm -l -C a.out | grep demo 0000000000001139 T demo_init /home/user/demo/main.c:12前提是编译时带了-g且没 strip。当你在一个几万行的项目里发现某个没人认识的符号被定义了出来,-l能直接把你带到那行代码,比全文搜索靠谱得多。
3.2 C++ 项目里,-C 和 -l 为什么要一起用
C++ 的名字修饰(mangling)机制把命名空间、类名、参数类型、const 限定符全部编码进符号名里。一个void Demo::add(int, double) const编译后可能变成_ZNK4Demo3addEid,完全没法肉眼识别。-C负责把它翻译回来,但这里有个坑:如果符号表已经被 strip,-C就无能为力了,因为它翻译的是字符串本身,不依赖调试信息。
另一个坑是-l和-C同时用的时候,行号标注会被 C++ 名字里的一些内容干扰,偶尔出现 0 行或者乱跳的情况。这时候可以把两个命令拆开跑:nm -C先看名字,nm -l --no-demangle再看行号,用符号原名去对应。虽然麻烦一点,但结果更可靠。
顺便说一句--no-demangle。这个参数的名字很反直觉,意思是“不要还原”。什么时候会用到它?当你需要拿符号名去 grep 二进制或者跟readelf的输出做交叉验证时,因为readelf -s默认显示的是修饰后的原名,nm 默认显示的是还原后的名字,两边对不上。这时候给 nm 加--no-demangle,两边就能对齐了。
3.3 输出格式切换与脚本友好
nm 支持三种输出格式,默认是 BSD(地址、类型、名字三列),另外还有 sysv 和 posix:
nm -f sysv a.out # Name / Value / Class / Type / Size / Line / Section 七列详细表 nm -f posix a.out # 名字在最前,等价于 -P nm -f bsd a.out # 默认格式sysv 格式信息最全,带段编号,适合人读;posix 格式是“名字在前”,最适合用 awk 切字段。举个实际例子,想找出所有大于 1KB 的已定义函数:
nm -S --defined-only -f posix libdemo.a \ | awk '$2 ~ /^[Tt]$/ && strtonum("0x" $4) > 1024 {print $4, $1}' \ | sort -r注意 posix 格式下,名字里如果带空格会被截断,这是 awk 处理这类输出时的一个已知问题。更稳的写法是用 BSD 格式,因为它前面固定有地址和大小两列,名字总在最后一列:
nm -S --defined-only libdemo.a \ | awk 'NF >= 4 && strtonum("0x" $2) > 1024 {print $2, $4}' \ | sort -rNF >= 4这个判断是必要的,因为U类型的行没有地址,字段数只有两个,不排除掉会报错。这类小细节在写 CI 脚本时特别重要,不然一个符号就让你整个流水线崩掉。
3.4 看归档文件时,别漏了 -s
静态库.a本质是个 ar 归档,里面装着一堆.o。直接nm libdemo.a,nm 会自动遍历每个成员并打印成员名作为分组标题。想知道归档索引里有哪些符号(也就是链接器查找时用的那张表),加-s:
nm -s libdemo.a Archive index: demo_add in demo_math.o demo_sub in demo_math.o这个索引就是链接器决定“要不要把某个成员拉进来”的依据。当你遇到“符号明明在库里,链接器就是找不到”的诡异情况时,看一眼nm -s的输出是不是缺了那个符号,能快速排除归档索引损坏的可能。索引损坏一般是 ar 工具版本不一致或者交叉编译时用了不匹配的 ar 导致的,重新ar rcs一遍通常能修好。
4. 实战流程:四类典型问题的完整定位路径
4.1 undefined reference 的三步定位法
链接错误是 nm 最高频的使用场景。我的固定流程分三步,几乎能覆盖八成的情况。
第一步,确认是哪个目标文件缺符号。用nm -C -u扫一遍所有 .o:
nm -C -u build/*.o | grep -B1 ' U ' | head -50输出里每一行U xxx就是一个待填的坑。注意要加-A或者按文件循环扫,否则不知道是哪个文件。
第二步,在所有候选库里找谁能填。用grep配合--defined-only:
for lib in build/lib*.a; do echo "== $lib" nm -C --defined-only "$lib" 2>/dev/null | grep -w 'demo_init' done如果所有库都搜不到,那就是真的没实现,或者实现所在的文件没被编译进来。如果某个库搜到了,进入第三步。
第三步,检查链接顺序。GNU 链接器是从左到右单向扫描的,遇到静态库时,只会把“当前尚未解析的符号”所需要的成员拉进来。这就导致一个反直觉的规则:调用方必须写在被调用方前面。gcc main.o -lfoo能行,gcc -lfoo main.o就不行。库之间存在循环依赖时,用-Wl,--start-group ... -Wl,--end-group把一组库包起来,让链接器反复扫描直到收敛。
还有一个更隐蔽的坑:有些构建系统默认开了--as-needed,链接器发现某个库的符号在当前位置还没被用到,就直接不把它记进DT_NEEDED。结果二进制能编出来,但运行时换了环境就报找不到符号。排查方法是readelf -d a.out | grep NEEDED,看该有的库在不在。临时可以加-Wl,--no-as-needed验证。
4.2 静态库顺序、重复定义与 common 符号
重复定义报multiple definition of 'xxx'时,第一反应应该是定位到底哪几个文件都定义了它:
nm -A --defined-only build/*.o | grep -w ' T demo_config'输出直接告诉你所有定义者。常见成因有三种:头文件里写了变量定义(应该用extern声明)、两个 .c 文件里有同名非 static 全局变量、以及-fno-common带来的行为变化。
第三种值得单独讲。老版本编译器对未初始化的全局变量采用 common 块模型,两个 .o 里都写int counter;不会报错,链接器会合并成一个,符号表里显示为C。较新的编译器默认改成-fno-common,同样的代码会变成两个B符号,直接报重复定义。所以你会看到一种诡异现象:同一份代码在老环境编译正常,换新环境就炸。用nm一看,老环境是C counter,新环境是B counter,原因就明了了。修法是把其中一个改成extern声明,或者在定义处加__attribute__((common))临时兼容,但后者只是权宜之计。
静态库还有一个容易忽略的点:一个.a里如果两个成员互相引用,而链接器扫描到第一个成员时还没看到第二个,就可能漏拉。这类问题用-Wl,--start-group最省事,代价是链接变慢,所以在大型项目里要权衡使用范围。
4.3 判断符号到底有没有导出到动态符号表
写了个库给别人用,对方说找不到接口。别急着怀疑他,先自己验证:
nm -D --defined-only libdemo.so | grep demo_init有输出说明导出成功,没输出则要往下查。常见原因按概率排序:编译时开了-fvisibility=hidden,所有没显式标注的符号默认不导出;源码里加了static;链接脚本里的 version script 把符号过滤掉了;或者整个库被 strip 之后又用nm而不是nm -D去查。
-fvisibility=hidden这个坑值得展开。它能让 .so 的导出符号数量从几千降到几十,加载速度明显变快,是很多团队的标准优化项。但它要求你在每个对外接口上加__attribute__((visibility("default"))),漏一个就少一个。所以每次调整可见性配置后,我习惯做一次快照对比:
nm -D --defined-only libdemo.so | awk '{print $3}' | sort > after.txt diff before.txt after.txtdiff输出里所有<开头的行就是“原本导出、现在没了”的符号,一个一个核对过去,比靠人眼审查头文件可靠得多。这个习惯帮我拦下过好几次上线前才发现接口丢失的事故。
同样地,判断一个库是不是被 strip 过,不需要猜:
file libdemo.so readelf -S libdemo.so | grep -c '\.symtab'symtab计数为 0 就是 strip 过了。这时候任何依赖完整符号表的操作(比如-l行号、-a调试符号)都会失效,只能退回到-D看动态符号。
4.4 弱符号、版本化符号与 LTO 的特殊情况
弱符号在 nm 里显示为大写W(弱函数)或V(弱对象)。它的语义是:如果链接时存在同名强符号,弱定义被丢弃;否则用弱定义。这套机制常被用来做“可覆盖的默认实现”,比如一个库提供 default 版本的日志回调,业务方想替换就自己定义一个同名的强符号。排查“我明明定义了回调,为什么还是走了默认实现”时,nm一看两边都是W就明白了——没有强符号压过它,链接器随机挑了一个。
带版本信息的符号出现在动态符号表里,长这样:demo_init@@LIBDEMO_1.2或demo_init@LIBDEMO_1.0。双@表示默认版本,单@表示非默认版本。这类符号在用-D查看时经常让 grep 失手,因为名字后面多了个尾缀。稳妥的写法是grep -w 'demo_init'或者用grep 'demo_init@',别用grep '^demo_init$'。
LTO(链接时优化)开启后,.o文件里装的其实是中间表示而不是最终机器码,符号表可能非常稀疏,nm看到的结果会让人怀疑人生。这时候如果工具链支持,nm会自动加载对应的插件把符号解出来;不支持的话,你会看到大量的空或者?。遇到这种情况别硬刚,改用gcc -flto -ffat-lto-objects生成带常规目标码的双份文件,或者直接对最终链接产物做分析。LTO 下的符号分析本来就是个公认的麻烦事,把范围缩小到最终二进制是最省力的策略。
5. 踩过的坑和一张能直接用的速查表
5.1 常见问题速查
| 现象 | 大概率原因 | 处理动作 |
|---|---|---|
nm: xxx: no symbols | 文件被 strip,或本身不是 ELF | 换nm -D,或file确认格式 |
nm: xxx.o: File format not recognized | 目标文件是别的架构或损坏 | file xxx.o看架构,确认工具链匹配 |
| 明明有定义却报 undefined | 链接顺序错、--as-needed丢弃、库没写进链接行 | 调整顺序,加-Wl,--no-as-needed验证 |
multiple definition | 头文件定义变量、-fno-common变化 | nm -A定位所有定义者 |
导出接口在nm里看不到 | 开了 hidden visibility 或 version script | nm -D复查,补 visibility 属性 |
| C++ 符号全是乱码 | 没加-C | 加-C,必要时配c++filt |
-l不显示行号 | 没带-g或已 strip | 重新以调试模式编译,或放弃行号 |
| 符号大小全是 0 | 目标文件未链接,或格式不支持 size | 对最终二进制分析,或换readelf -s |
| 同一符号出现多次 | 静态库多个成员含同名局部符号 | 加-A看归属,局部符号加static避免污染 |
| nm 输出几千行没法看 | 没加过滤 | 组合-u、--defined-only、grep |
5.2 几条只有踩过才知道的经验
第一条,nm和objdump -t、readelf -s不是替代关系,而是粗细粒度不同。nm 适合“快速看名字找线索”,readelf -s适合“要全字段精确核对”,objdump -t介于两者之间且能看到段信息。我一般的顺序是:先用 nm 定位到可疑符号,再用readelf -sW把它那条记录的Bind、Vis、Ndx三个字段看全。因为 nm 的类型字母是聚合过的,readelf才能告诉你这个符号到底是GLOBAL DEFAULT还是WEAK HIDDEN,而后者恰恰是很多链接问题的真正原因。
第二条,查符号时永远先确认你查的是哪个文件。交叉编译项目里,x86的 nm 去读 ARM 的目标文件,有的版本会直接报格式不识别,有的版本会输出一堆看不懂的东西。养成习惯:分析前先file一下,确认架构,再用对应工具链前缀的 nm,比如arm-none-linux-gnueabihf-nm。这个问题我在多人协作的项目里见过不止一次,新人拿着主机的 nm 去查交叉编译产物,查了半小时以为库有问题,实际是工具用错了。
第三条,nm对超大二进制的输出最好重定向到文件再处理。像静态链接的可执行文件或者某些语言编译出来的产物,符号动辄几十万个,直接管道给grep有时会因为缓冲区问题卡住或者输出错乱。我现在的固定写法是nm -C -S --size-sort -r big.bin > /tmp/syms.txt,然后用less或grep慢慢翻,顺便还能保留现场做前后对比。
第四条,判断“这个符号是不是真的被裁掉了”,不要只看 nm,还要看链接器的回收日志。开-Wl,--gc-sections之后,没被引用的段会被删除,符号自然也就没了。想确认是主动裁的还是根本没编进来,加-Wl,--print-gc-sections重新链接一次,链接器会明确打印 “removing unused section”,比反过来猜快得多。
第五条,写脚本解析 nm 输出时,记住带-A的格式是文件名:地址 类型 名字,文件名里可能包含冒号(路径里有 Windows 盘符或者奇怪的目录名),所以切分时要从右往左数,不要从左往右数。这个细节坑过我不止一次,尤其是在处理自建的构建目录结构时。
5.3 一个可以直接抄走的小工具
最后留一个我平时用的小脚本,作用是对比两个版本的 .so 导出接口差异,发布前跑一下能避免不少“接口悄悄没了”的事故:
#!/bin/bash # usage: ./api_diff.sh old.so new.so old=$(mktemp); new=$(mktemp) nm -D --defined-only "$1" | awk '{print $NF}' | sort -u > "$old" nm -D --defined-only "$2" | awk '{print $NF}' | sort -u > "$new" echo "--- 消失的接口" comm -23 "$old" "$new" echo "--- 新增的接口" comm -13 "$old" "$new" rm -f "$old" "$new"comm -23取只在第一个文件里出现的行,comm -13取只在第二个文件里出现的行,前提是两边都排序过。这个脚本跑完,“消失的接口”那一段必须为空,否则就是破坏性变更。我把它挂在打包流程的最后一步,几秒钟的事,但拦住过好几次误删导出属性的提交。
我个人在实际操作中的体会是,nm 这类工具的价值不在于功能多强,而在于它把“黑盒”打开了一条缝。编译链接问题之所以难查,很大程度上是因为过程不可见——源码到二进制之间隔着一层不透明的翻译,而符号表就是这层翻译留下的痕迹。你不需要理解链接器的全部算法,只要能读懂这张符号清单,大部分“找不到符号”“定义了却不生效”“库明明是新的却加载了旧的”这类问题,都能在几分钟内缩小到具体文件和具体符号上。真正花时间的从来不是敲命令,而是想清楚该查哪个文件的哪张表——这一步想明白了,剩下的就是复制粘贴。