拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Linux链接机制与排错:符号解析、重定位和动态库搜索

我记得第一次被链接这两个字按在地上摩擦是在一个再普通不过的下午。本地make一路绿灯二进制拷到测试机上回车之后只回了我一句error while loading shared libraries: libxxx.so.1: cannot open shared object file。源码没问题编译没报错唯独链接这一环在换了个环境之后彻底翻脸。后来我把ldd、readelf、LD_DEBUG这几个工具翻来覆去用了很多遍才把这条从源码到进程的链路真正摸清楚。这也是我打算开操作系统这个系列的原因。第 00 篇不聊调度、不聊虚拟内存的页表先把链接这件事讲透——因为它是你写的每一行代码变成能被内核execve真正加载执行的进程之前最后也是最容易被忽略的一步。这篇内容适合几类人刚学完编译原理、但对.o、.a、.so仍然一头雾水的学生被undefined reference反复折磨的初中级开发以及想搞清楚为什么同样的代码换个机器就跑不起来的运维和嵌入式同学。整篇会围绕静态链接、动态链接、符号解析、重定位以及动态链接器搜索路径这几件事展开所有命令都可以在你自己的机器上复现。1. 编译不是一步被压缩掉的那最后一公里1.1 一条 gcc 命令背后其实跑了四趟大多数人写 C 代码的日常是gcc main.c -o main回车完事。但这条命令背后至少跑了四个独立的程序只是驱动器帮你串起来了。拆开来看是这样gcc -E main.c -o main.i # 1. 预处理展开宏、处理 #include gcc -S main.i -o main.s # 2. 编译C 代码 - 汇编 gcc -c main.s -o main.o # 3. 汇编汇编 - 机器码产出可重定位目标文件 gcc main.o -o main # 4. 链接合并目标文件与库产出可执行文件前三步针对单个源文件彼此之间完全不知道对方的存在。main.c里调用了add()编译器只能看到一句extern int add(int, int);的声明它拿不到add的函数体也没必要拿到——它只需要在生成的.o里留一个这里有个符号叫 add地址待定的标记。把这个悬空的标记接上就是第四步链接器干的活。想亲眼看到这一步用gcc -v main.o -o main打开啰嗦模式你会看到它最后实际调用的是collect2而collect2内部又去调了ld。collect2的存在感很低但它其实负责了一件挺重要的事收集构造函数和析构函数也就是__attribute__((constructor))那类东西并生成__init_array/__fini_array相关的辅助代码。换句话说链接不是简单地把文件拼起来中间还有一批由编译器驱动自动注入的小动作。1.2 链接器的两个核心任务符号解析与重定位不管链接器是 GNU ld、gold、lld 还是 mold它要干的核心工作就两件符号解析Symbol Resolution。每个目标文件都有一个符号表里面记录了自己定义了哪些符号函数名、全局变量名、引用了哪些符号。链接器把所有输入文件的符号表汇总为每一个未定义引用找到唯一一个定义。找到多个定义怎么办如果都是强符号直接报重复定义错误如果一强多弱选强的全是弱符号随便挑一个。找不到定义呢undefined reference就来了。重定位Relocation。符号解析只是知道了add 这个函数在内存里的最终地址是 0x401126但.o文件里调用add的那条call指令当时并不知道这个地址它留了一个占位符同时在.rela.text节里留了一条重定位记录意思是把偏移 0x15 处的 4 字节改成 add 的地址减去下一条指令地址。链接器遍历所有重定位表按公式S A - PS 是符号地址A 是加数P 是重定位位置逐条回填。这两个任务听着简单但几乎所有的链接期报错都能归到这两件事上找不到符号、找到太多符号、地址算错了通常表现为段错误。你说它是操作系统的知识也行说它是工具链的知识也行反正绕不开。1.3 一个能看见符号的最小实验光说概念没意思我准备了一个三分钟就能跑完的实验建议你边看边敲一遍/* main.c */ #include stdio.h extern int add(int, int); /* 只声明不定义 */ int global_var 42; /* 已初始化的全局变量 */ int uninit_var; /* 未初始化进 .bss */ int main(void) { printf(%d\n, add(global_var, uninit_var)); return 0; }/* add.c */ int add(int a, int b) { return a b; }分别编译再链接gcc -c main.c -o main.o gcc -c add.c -o add.o nm main.onm的输出大概长这样0000000000000000 T main 0000000000000000 D global_var 0000000000000004 B uninit_var U add U printf这里每个字母都有讲究我给你整理成表字母含义所在节区是否占用最终地址T / t全局 / 局部函数.text是D / d已初始化全局 / 局部变量.data是B / b未初始化全局 / 局部变量.bss是但不占文件体积R / r只读数据.rodata是U未定义引用无由别人决定大写的add和printf都是U意思是我要用但我这儿没有。链接器的工作就是把这两个U填上add从add.o里找printf从 libc 里找。global_var是Duninit_var是B这两个的区别值得单独提一句——.bss节在磁盘上不占空间只在加载时由内核清零映射所以一个int arr[1000000];的全局数组放在文件里不会让二进制变成 4MB这也是为什么大缓冲区推荐放全局而不是局部局部要占栈会爆。2. ELF 目标文件的三种形态与符号表里的门道2.1 可重定位、可执行、共享同一套格式的三种用法ELFExecutable and Linkable Format是 Linux 上的统一容器格式但同一个格式被用来装三种不同阶段的东西可重定位目标文件.o地址还没定包含.rela.text之类的重定位节等着被人合并。可执行目标文件无后缀或任意名地址已经全部定死有一个入口点e_entry可以直接被execve加载。共享目标文件.so可以加载到任意地址通常带位置无关代码既能被链接器当输入用也能被运行时动态加载。它们共享同一个 ELF 头结构readelf -h看到的字段完全一致魔数7f 45 4c 46、类型字段e_typeET_REL/ET_EXEC/ET_DYN、机器架构e_machine、程序入口e_entry、节区头表偏移等等。判断一个文件属于哪种形态最直接的办法就是看e_type。.so和现代的可执行文件PIEPosition Independent Executable都显示为ET_DYN所以别看到ET_DYN就以为是动态库readelf -h配合readelf -d看有没有SONAME更靠谱。一个 ELF 文件从链接视角和加载视角看结构是两套视角链接视角看节区头表Section Header Table.text、.data、.symtab、.rela.text、.debug_info这些是给链接器和调试器用的。加载视角看程序头表Program Header TableLOAD、DYNAMIC、INTERP、GNU_RELRO这些是给内核和动态链接器用的描述了哪些段要映射到内存、以什么权限映射。我踩过的第一个坑就是这里strip掉.symtab之后nm什么都没了但程序照跑不误。原因很简单.symtab只服务于链接和调试运行时用的是.dynsym动态符号表两者是独立的。想减小体积strip可以但别指望strip能治链接错误。2.2 符号的绑定属性LOCAL / GLOBAL / WEAK符号除了有没有定义还有绑定属性这个维度用readelf -s能看到Bind这一列LOCAL只在本目标文件内可见比如static函数。多个文件里有同名static函数互不冲突因为它们在符号表里根本不上交。GLOBAL全局可见参与符号解析的竞争。WEAK弱符号。当强符号和弱符号同名时强符号赢。这套规则在实战里有很实际的应用。标准库里的malloc就是弱符号你完全可以自己定义一个malloc覆盖它这在内存调试、嵌入式内存池、hook 场景里非常常用。同理__attribute__((weak))还常被用来做可选依赖库提供weak版本的空实现应用层如果不提供强符号就用默认的提供了就覆盖。还有一个不太被提起但很实用的点C 语言里int x;和int x 0;在不同作用域下会产生临时定义tentative definition多个.o里都写int x;链接器会合并成一个不报错。但如果你在头文件里写int x;然后被多个.c包含每个.o都会有一个COMMON符号老编译器会合并但-fno-commonGCC 10 起是默认值之后直接变成重复定义报错。这也是为什么头文件里放变量定义现在一定会翻车正确做法是.h里extern int x;.c里int x 0;。2.3 readelf 和 objdump 的实战用法这几个命令我基本上每天都在用列个对照表方便你按需查目的命令关键看点看文件类型与入口readelf -h a.outType、Entry point看节区布局readelf -S a.out.text大小、.bss大小看程序头段readelf -l a.outINTERP指向的动态链接器路径看动态依赖readelf -d a.outNEEDED、RPATH、RUNPATH看符号readelf -s a.o/nm -C a.oBind、Ndx、UND看重定位表readelf -r a.oR_X86_64_PC32等类型反汇编objdump -d -M intel a.out调用指令的实际形式看运行时实际加载的库ldd a.out每一条NEEDED的解析结果ldd这个工具要单独说一句它其实是通过设置环境变量让动态链接器自己跑一遍并打印结果对来源不明的二进制执行ldd理论上有风险。更稳的做法是objdump -p a.out | grep NEEDED看静态信息或者用LD_TRACE_LOADED_OBJECTS1 ./a.out。安全敏感的环境里这点值得注意。3. 静态链接把库焊进二进制3.1 .a 文件本质上只是 .o 的归档动态库和静态库的概念很多人一开始是混的其实静态库简单到有点朴素它就是一个ar归档包里面装了一堆.o。gcc -c add.c -o add.o gcc -c mul.c -o mul.o ar rcs libmath.a add.o mul.o ar t libmath.a # 列出成员add.o mul.oar rcs三个参数分别是 replace、create、generate symbol table。最后那个s很重要它会在归档里生成一个符号索引ranlib的等价物链接器靠这个索引快速判断哪个成员定义了我要找的符号而不是每次线性扫描所有成员。少了索引链接会变慢某些老链接器甚至会报错。ar t能列出成员ar x能解包nm libmath.a会把每个成员的符号都打出来。理解到这一层你就明白静态链接的粒度其实是目标文件级的链接器只把确实被引用到的那些.o提取出来合并没被引用到的成员不会进最终二进制。3.2 链接顺序为什么能决定成败这是静态链接最经典的坑没有之一。GNU ld 默认是从左到右单趟扫描的处理到某个库时只有当它已经看到过未解析的符号才会去这个库里找定义。扫描完一遍就不再回头。意味着下面这种写法大概率失败gcc main.o -lmath -lmisc -o app # 如果 libmisc 依赖 libmath就挂了因为在扫描-lmath的时候main.o需要的符号可能还没暴露出来。正确顺序是调用者在左被调用者在右gcc main.o -lmisc -lmath -o app如果是循环依赖两个库互相引用简单调序解决不了可以用--start-group/--end-groupgcc main.o -Wl,--start-group -lmath -lmisc -Wl,--end-group -o app这对参数会让链接器在这组库之间反复扫描直到所有符号都解析完。代价是链接变慢所以能调顺序就调顺序循环依赖实在绕不开再用。我见过不少项目为了解决这个问题干脆把整个库重复写两遍-lmath -lmisc -lmath能跑但很难看不推荐。3.3 静态链接的代价与适用场景静态链接的好处很直接部署简单没有运行时依赖。一个二进制扔到任何同架构的机器上就能跑不怕目标机缺库、不怕库版本不一样。这也是为什么 Go 默认静态链接、Rust 在musl目标下也是静态链接、很多嵌入式固件和容器基础镜像里的工具都偏爱静态。代价同样明确体积膨胀每个程序都带一份 libc几 MB 起步。可以用-static配合-Os和strip压一压。内存浪费同一份 libc 在 10 个进程里就占 10 份物理内存而动态库通过共享映射只需要一份。升级困难libc 出了安全问题你得重新编译并重新发布所有二进制而不是换一个.so。行为差异getpwnam()、dlopen()这类依赖运行时状态的函数在静态链接下常常有坑glibc 静态链接还会打一堆警告。我的经验是命令行小工具、容器里的 init 程序、跨发行版分发的东西优先考虑静态或者musl静态而系统服务、长期驻留的进程老老实实用动态链接把库共享的好处拿到手。4. 动态链接把见面推迟到运行时4.1 位置无关代码 PIC、GOT 与 PLT动态库最大的难题是它在编译时不知道自己会被加载到哪个地址可执行文件里的绝对地址又必须写死。解决办法就是让代码本身不依赖绝对地址也就是位置无关代码PICPosition Independent Code编译时加-fPIC。PIC 的核心套路有两条。第一条是相对寻址同一个模块内部的函数调用、变量访问都用当前指令地址 偏移的方式PC 相对寻址天然不受加载地址影响不需要任何重定位。第二条是引入跳板跨模块的调用没办法相对寻址于是引入 GOTGlobal Offset Table——一个放在数据段里的表每一项存一个外部符号的绝对地址。代码里只写从 GOT 的第 N 项取地址再跳过去GOT 表本身在加载时由动态链接器填好。和 GOT 配套的是 PLTProcedure Linkage Table它位于代码段每一项是一小段桩代码。这样设计的原因很务实GOT 在数据段可读可写所以每次调用都要从内存读一次地址多了一次间接寻址而 PLT 在代码段是只读的能直接被call指令相对跳转。两者配合既拿到了地址的灵活性又保留了指令的相对跳转效率。4.2 延迟绑定第一次调用为什么慢一点如果进程启动时把所有外部函数的地址全部解析完一个稍微大点的程序可能要解析上千个符号启动会明显变慢。于是有了延迟绑定lazy binding函数第一次被调用时PLT 项跳到的其实不是真正的函数而是一段去查 GOT、发现是空的、调用_dl_runtime_resolve的逻辑。解析完成后_dl_runtime_resolve把真实地址写回 GOT下次再调用就直接跳过去了。这就是为什么很多程序第一遍跑某个功能卡一下后面就顺了。想关掉延迟绑定、启动时一次性解析完可以LD_BIND_NOW1 ./app # 或者链接时指定 gcc -Wl,-z,now main.o -o app-z now在安全加固场景里很常见因为延迟绑定会让 GOT 在运行期保持可写存在被篡改的风险。开启-z now配合-z relro能让 GOT 在解析完成后变成只读这是很多发行版编译时的默认加固选项。代价是启动稍慢但换来的是运行期的确定性。4.3 动态链接器找库的完整搜索顺序回到开头那个cannot open shared object file它本质上就是动态链接器的搜索路径没命中。glibc 的搜索顺序我整理成下面这张表顺序不能记错顺序来源说明1DT_RPATH且没有DT_RUNPATH老式写法已废弃会传递给整个依赖树2LD_LIBRARY_PATH环境变量调试时最方便生产慎用3DT_RUNPATH新式写法只作用于直接依赖不往下传4/etc/ld.so.cache由ldconfig生成是生产环境的主力5/lib、/usr/lib等默认目录兜底几个关键细节DT_RPATH和DT_RUNPATH的区别不是新旧而已。RPATH会沿着依赖链一路传递下去A 依赖 B、B 依赖 C 时C 也能用 A 的 RPATH 来找容易造成意外命中和混乱RUNPATH只在直接依赖这一级生效语义清晰得多。所以现在链接时统一用gcc main.o -L./lib -lfoo -Wl,-rpath,$ORIGIN/../lib -Wl,--enable-new-dtags -o app--enable-new-dtags就是让它生成RUNPATH而不是RPATH。$ORIGIN表示可执行文件自身所在目录这个写法让程序可以带着自己的库一起搬到任何地方是做可重定位分发包的必备技巧。LD_LIBRARY_PATH的坑。它在 setuid 程序里会被动态链接器直接忽略这是安全设计别为此浪费时间排查。它还会影响子进程一个脚本里设了后面所有命令都受影响。生产环境更推荐RUNPATH或者把库交给ldconfig管理。ldconfig才是生产主力。把库放到/usr/local/lib然后在/etc/ld.so.conf.d/下扔一个myapp.conf写上一行路径执行sudo ldconfig缓存就更新了。之后ldconfig -p | grep libfoo能查到记录。注意一个高频坑新增.so但忘了跑ldconfig或者用了符号链接但没建 soname 链接都会导致找不到库。5. 链接报错的三条典型排查链路5.1 undefined reference 的三种成因undefined reference to xxx是最常见的链接错误但找不到符号背后的原因至少有三类得分开处理第一类库根本没链。报的是undefined reference to sqrt但你忘了-lm。数学库在 GCC 里就是需要显式加-lm因为历史上它被单独拆出去了至今没变。第二类链了但顺序不对。前面讲过的单趟扫描问题。判断方法很简单把可疑的库在命令行里往前挪或者用--start-group包起来试试如果好了就是顺序问题。第三类名字被 C 重整mangling了。用 C 调用 C 库时会看到一长串undefined reference to foo(int, char*)这种带参数列表的形式说明 C 编译器把名字改编过了而 C 库导出的是原始的foo。解决办法就是在头文件里加#ifdef __cplusplus extern C { #endif int foo(int, char *); #ifdef __cplusplus } #endif这个extern C包裹几乎是所有 C 库头文件的标准配置自己写库给别人用的时候千万别省。5.2 符号重复定义与 WEAK 的正确用法multiple definition of xxx一般有两种来源一是全局变量直接写在了头文件里被多个.c包含前面说过的COMMON问题二是两个库都定义了同一个符号。对于第二种如果你确实想提供一个可覆盖的默认实现用弱符号__attribute__((weak)) void on_error(const char *msg) { /* 默认什么都不做 */ }应用层只要定义了一个强符号on_error链接器就会用应用层的那个反之用默认的。这是插件式架构里非常常用的模式——库提供一堆弱符号钩子框架不清空用的人按需覆盖。不过要注意弱符号在静态库里的行为和动态库不太一样静态库里如果包含弱符号的.o是因为别的符号被拉进来的弱符号也会一起进来如果没人引用整个.o都可能不被拉进来这时候你的默认实现根本没被链接。这个坑我在做 SDK 的时候踩过一次排查了半天最后还是老老实实把默认实现编译成一个强制保留的目标文件配合--whole-archive才解决。5.3 版本符号冲突version GLIBC_2.xx not found这个报错挺有意思形态是./app: /lib/x86_64-linux-gnu/libc.so.6: version GLIBC_2.34 not found它的意思不是找不到 libc而是找到了 libc但你需要的那个版本标签没有。ELF 支持符号版本化每个符号会带一个版本标签比如memcpyGLIBC_2.14。在高版本系统上编译的程序可能引用了GLIBC_2.34才提供的符号版本拿到低版本发行版上就跑不了。排查用objdump -T ./app | grep GLIBC # 看程序需要哪些版本 strings /lib/x86_64-linux-gnu/libc.so.6 | grep GLIBC_2.3 # 看系统提供了哪些解决办法只有三条路在低版本系统上重新编译升级目标系统的 libc风险很大可能带崩整机别轻易动或者做静态链接把 libc 一起打包进去。这也是为什么很多跨发行版分发的二进制会选择在最老的那个基础镜像里编译——编译环境的 glibc 版本决定了二进制能向下兼容到哪。6. 用 LD_DEBUG 把加载过程开膛看一遍6.1 LD_DEBUG 的几种常用取值前面讲的搜索顺序、延迟绑定说得再细也不如自己看一遍真实日志。glibc 提供了一个非常强大的调试开关LD_DEBUG取值有十几种常用的就这几个取值输出内容libs库的搜索过程、每一次查找和命中bindings每个符号绑定到哪个库的哪个地址symbols符号查找的详细过程reloc重定位处理过程versions版本符号检查all全部打开输出量巨大慎用help列出所有可用取值用法就是环境变量前置LD_DEBUGlibs ./app 21 | head -50 LD_DEBUGbindings ./app 21 | grep printflibs的输出会明确告诉你我为了找 libfoo 去了哪些目录、跳过哪些、最后命中了哪个。我排过的最离谱的一个问题是这样的客户环境里/usr/local/lib下有一份旧版本的libfoo.soRUNPATH指向的目录里是新版本但ld.so.cache的优先级更高结果程序一直加载旧版本新功能的符号全是未定义。用LD_DEBUGlibs两分钟就定位了。没有这个工具光靠ldd只能看到结果看不到过程。6.2 手搓一个最小动态库并观察强烈建议自己走一遍这个流程比看十篇文章都管用# 1. 编一个动态库带上 soname gcc -fPIC -c add.c -o add.o gcc -shared -Wl,-soname,libadd.so.1 -o libadd.so.1.0.0 add.o # 2. 建立符号链接链这一步最容易忘 ln -sf libadd.so.1.0.0 libadd.so.1 ln -sf libadd.so.1 libadd.so # 3. 链接主程序 gcc main.o -L. -ladd -Wl,-rpath,$ORIGIN -Wl,--enable-new-dtags -o app # 4. 检查依赖记录 readelf -d app | grep -E NEEDED|RUNPATH这里有一个细节值得展开-Wl,-soname里的libadd.so.1会被记录进libadd.so.1.0.0的SONAME字段主程序链接时按SONAME记进NEEDED所以运行时找的是libadd.so.1而不是libadd.so.1.0.0。这条链子如果断了比如只留了libadd.so.1.0.0没有libadd.so.1运行时就报找不到库。我刚接触这套东西的时候因为漏了符号链接浪费了整整一个下午现在每做一个库都习惯性地先把三个文件的链接建好。6.3 rpath 和 runpath 到底选哪个我的实际做法是优先RUNPATH用$ORIGIN表达相对路径配合--enable-new-dtags。理由是它的作用域更小、语义更清楚不会莫名其妙地影响下两层的依赖查找而且$ORIGIN让整个目录可以整体搬迁不用重编。什么时候必须用老式的RPATH如果你的库本身又有依赖又希望不管被谁加载都能自动找到同一目录下的依赖RPATH的传递特性反而有用。但这种情况越来越少了多半是历史遗留工程。还有一点如果你的程序需要运行期加载插件dlopen是关键void *h dlopen(./plugins/libview.so, RTLD_NOW | RTLD_LOCAL); if (!h) { fprintf(stderr, %s\n, dlerror()); return; } void (*init)(void) (void (*)(void))dlsym(h, plugin_init); if (init) init(); dlclose(h);RTLD_NOW表示立刻解析所有符号出问题马上暴露比RTLD_LAZY好排查RTLD_LOCAL表示这个库的符号不导出给别的库避免符号互相覆盖。dlopen的路径不含斜杠时也会走那套搜索路径所以插件最好用绝对路径或者显式拼接别依赖运气。这就是注册动态链接在应用层最常见的落地形式插件自己导出一个register函数宿主dlsym拿到就调把插件注册进自己的表里。最后分享一个我现在养成的习惯每次遇到链接相关的报错先不要动代码按这个顺序走一遍——file和readelf -h确认文件类型ldd或objdump -p确认依赖解析结果nm或readelf -s确认符号有没有最后LD_DEBUGlibs看搜索过程。这四步下来九成以上的链接问题都能定位到具体环节比盲改-l参数高效得多。链接这块知识看着偏底层其实它是理解程序如何从源码变成能跑的进程最直接的一环把它吃透之后再去看加载器、进程地址空间、甚至自己动手写一个玩具操作系统的加载流程都会顺很多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门