ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

静态链接与重定位:链接器如何修正目标文件的地址

静态链接与重定位:链接器如何修正目标文件的地址 跑了好几年的C程序换了个环境重新编译后突然崩溃一个大工程把代码拆分到几十个文件后链接时报一堆“undefined reference”和“relocation truncated”之类的错。这种场景我见得太多最后基本都是静态链接和重定位的问题。静态链接就是把一个个编译好的目标文件合并成最终可执行程序的过程而重定位则负责把代码里那些“占位”的地址引用按最终内存布局修正成真实地址。这两件事搞不明白很多链接期和运行初期的问题就只能靠猜。 这篇文章想做的事很简单从静态链接的完整流程讲起把重定位表、重定位类型、地址计算规则一个个拆开再用一个能直接复现的小实验把全过程演示一遍。适合正在被各种链接报错折磨的C/C开发者也适合想系统理解编译原理但不想啃太厚书的同学。内容不依赖具体IDE用的就是Linux下的gcc和binutils打开终端就能跟练。 ## 1. 静态链接做了件什么事从目标文件到可执行文件的“搬家” ### 1.1 编译通过不代表程序能跑 很多人有个误区觉得编译通过了程序就万事大吉结果一跑就崩或者一链接就报一些奇奇怪怪的错误。原因在于“编译”和“链接”解决的是两个层面的事。编译阶段编译器只负责把单个.c文件翻译成目标文件.o它并不知道全局变量最终会放在哪个地址也不知道另一个文件里的函数被调用时该跳到哪里。目标文件里的地址引用全是临时的、相对的或者说全部是从零开始的“占位符”。 链接器的工作就是把这些分散的目标文件合并起来安排它们在最终虚拟地址空间中的位置再把所有占位符替换成最终地址。这个过程如果只解决“能不能找到符号”还算简单真正麻烦的是“每个符号最终到底落在哪个地址以及指令里怎么把这个地址填进去”。后者就是重定位。 我用一个生活类比帮你建立直觉每个目标文件就像一套毛坯房里的家具图纸图纸上标注的尺寸和位置都是相对于本房间的而不是相对于整栋楼的。链接器是总设计师它把所有房间的家具图纸汇总后确定每个房间在整栋楼的哪一层、哪个方位然后重新给每件家具计算绝对坐标。那些“重新计算绝对坐标”的动作落到二进制层面就是重定位。 ### 1.2 链接器内部的三个核心步骤 完整的一次静态链接无论用的是ld还是前端包装的gcc内部大致都按下面三步推进。 第一步是地址和空间分配。链接器扫描所有输入目标文件把它们的节section按类型合并。代码节合并成大的.text数据节合并成大的.data和.bss然后决定这些合并后的节在最终虚拟地址空间中各自占据哪个区间。这一步的输出是每个节、每个符号的最终虚拟地址。像链接脚本这类东西本质就是干预这一步的地址安排。 第二步是符号解析。链接器建立一个全局符号表遍历所有目标文件里被引用的符号找到对应的定义。如果某个符号在所有输入文件里都找不到定义就会报undefined reference。如果多个文件定义了同一个全局符号就会报multiple definition。这里的规则简单直接一个符号只能有一个定义但可以有无数个引用。 第三步才是重定位。根据前两步得到的符号最终地址把指令和数据里那些占位的引用修正成可用的真实地址。对于x86-64平台常见操作是把一条call指令后面的32位立即数从0改成实际计算出的偏移或者把一个全局变量的地址填进数据段某个位置。 这三个步骤说起来简单但每一步都有大量细节。比如地址空间分配阶段要考虑对齐、权限标志可读可写可执行、节顺序符号解析阶段要处理强弱符号、公共块common block、静态库的按需抽取重定位阶段要区分PC相对引用和绝对引用、不同的重定位类型、甚至代码模型code model的影响。 ### 1.3 静态链接与动态链接的分工差异 有人会问既然静态链接这么复杂为什么现代系统还在大量使用动态链接因为两者解决的问题侧重不同。动态链接把一部分地址解析推迟到程序加载时甚至函数首次调用时允许多个进程共享一份.so的代码段节省内存也让库的升级不用重新编译所有依赖它的程序。代价是运行时要多一层解析开销且存在被依赖库版本替换的兼容性问题。 静态链接的优势则是“焊死”所有符号地址在链接完成时就已确定程序不依赖运行环境里的任何共享库部署简单启动也快。在嵌入式开发、容器单二进制工具、隔离环境等场景里静态链接至今仍是首选。这里需要澄清一个常见错觉静态链接不等于非得用静态库.a。直接把多个.o文件链接成可执行文件同样属于静态链接。所以哪怕你不用-lxxx你的程序也一直在经历静态链接和重定位的过程。 动态链接也不是完全不重定位它只是把一部分重定位放到了运行时由动态链接器ld.so去处理。静态链接的重定位是“链接期一次算清楚”动态链接的很多重定位是“加载时每进程算一次”。理解了静态重定位再看动态重定位会轻松很多因为底层计算规则是互通的。 ## 2. 重定位的核心机制报错里那串“天书”其实不难懂 ### 2.1 重定位表链接器的“待办清单” 每个目标文件里都有一组重定位表在ELF文件里通常叫.rela.text、.rela.data分别对应代码节的引用修正和数据节的引用修正。这里的.rela前缀代表“有显式加数Addend的重定位”x86-64平台基本都用这种格式。早期一些架构如32位x86默认用.rel格式重定位信息里不带加数加数要从被修正位置的既有内容里读出来两种格式容易混淆看readelf输出时留意一下就行。 用readelf -r main.o查看重定位表时你会看到一串条目核心字段是三个 - Offset需要被修正的位置也就是指令或数据里那个占位符所在处的偏移。 - Info高32位是符号在符号表里的索引低32位是重定位类型。它把“要修正成谁”和“按什么规则修正”打包在了一起。 - Addend一个附加常量参与最终地址计算不同重定位类型用法不同。 这三个字段合起来就是链接器的一条待办清单在Offset这个位置拿Info指定的符号按某种类型规则加上Addend算出结果填回去。理解重定位的第一步就是看懂这张清单。 ### 2.2 最常见的几种重定位类型与计算公式 x86-64下重定位类型非常多但绝大多数日常工程只会遇到下面这几种。每种类型对应一条计算公式S代表符号的最终地址A代表加数P代表被修正位置自身的地址B代表共享对象加载时的基地址。 - R_X86_64_PC32结果 S A - P。这是PC相对引用常用于函数调用和rip相对寻址的全局变量访问。指令里保存的是“目标地址相对于下一条指令的偏移”优点是链接后的代码天然支持加载到不同基址安全性更好。 - R_X86_64_32 / R_X86_64_32S结果 S A即把符号的绝对地址作为一个32位值填进去。这类绝对引用通常要求目标地址在低2GB或者可被32位有符号数表达。非PIC代码访问全局变量时可能出现这种类型。 - R_X86_64_64结果 S A以一个完整的64位指针形式填入常见于数据段里存放函数指针的场景。 - R_X86_64_RELATIVE结果 B A动态链接中的基址相对重定位。PIE或共享库加载时动态链接器用这个公式在运行时修正地址。 调汇编时最容易遇到的是PC32。x86的call指令格式是E8 4字节偏移位移的计算基准是“下一条指令的地址”而不是call指令自身地址。所以编译器生成引用时Addend通常要写成-4把指令长度扣掉。链接器算S A - P时这个-4正好抵消掉指令长度最后填进去的偏移才是处理器预期的。很多新手手工计算重定位结果时总是多出4个字节原因就是忽略了这条约定。 | 重定位类型 | 计算公式 | 典型场景 | 修正时机 | | --- | --- | --- | --- | | R_X86_64_PC32 | S A - P | call指令、rip相对数据访问 | 静态链接期 | | R_X86_64_32 / 32S | S A | 非PIC代码的绝对地址访问 | 静态链接期 | | R_X86_64_64 | S A | 数据段中的64位指针 | 静态链接期 | | R_X86_64_RELATIVE | B A | PIE、共享库的运行时重定位 | 动态链接器加载时 | ### 2.3 PC相对引用与绝对引用的取舍 看到这里你应该发现同样是访问一个符号编译器既可能生成PC相对引用也可能生成绝对引用。编译器选择哪种方式取决于编译选项、目标平台和代码模型。PC相对引用的优势在于结果与加载基址无关程序被映射到哪个地址都能跑缺点是32位偏移表达范围只有正负2GB如果代码段和数据段相隔太远就会溢出报出经典的relocation truncated to fit。 绝对引用的优势是表达范围更大用64位时可以覆盖整个地址空间而且不需要一条额外指令去做相对到绝对的换算缺点是把死的地址写进了代码里一旦程序作为PIE被加载到随机基址这份代码就无法运行。现代Linux发行版默认开启PIE所以编译器倾向于生成PC相对引用。这也解释了为什么同一份代码在老的默认非PIE环境和新默认PIE环境下重定位表的输出会有明显差异。 绝对引用和PC相对引用没有绝对的好坏关键是理解当前项目的运行模型。如果你在写内核、bootloader这类链接地址固定、且不依赖ASLR的软件绝对引用反而常见。如果你在写普通的Linux用户态程序则默认按PIE考虑多关注PC32和RELATIVE。 ## 3. 实操亲手做一次完整重定位把地址修正的前后对比看清楚 ### 3.1 准备实验环境 整个实验只需要Linux系统、gcc和binutilsUbuntu/Debian这类发行版默认都齐全。实验目录建两个文件逻辑尽量简单便于观察。foo.c定义了一个全局变量和一个函数main.c负责调用它们。 foo.c c int global_var 100; int foo(int x) { return x global_var; }main.c#include stdio.h extern int global_var; int foo(int); int main(void) { int result foo(1); global_var; printf(result%d\n, result); return 0; }注意main.c里对global_var用了extern声明而不是重新定义一个变量。如果写int global_var;在C语言里这属于tentative definition某些编译参数下可能与foo.c里的定义产生冲突这是后面常见错误章节要讨论的经典坑。3.2 编译目标文件查看“零地址”占位状态执行gcc -c main.c foo.c得到main.o和foo.o。接着用readelf查看main.o的重定位表readelf -r main.o在我当前环境里输出大致是这样Relocation section .rela.text at offset 0x1a8 contains 4 entries: Offset Info Type Sym. Value Sym. Name Addend 000000000000000e 0000000300000002 R_X86_64_PC32 0000000000000000 foo - 4 0000000000000019 0000000400000002 R_X86_64_PC32 0000000000000000 global_var - 4 000000000000002f 0000000500000002 R_X86_64_PC32 0000000000000000 printf - 4三条重定位都集中在.Rela.text里对应main函数中对foo函数、global_var变量和printf函数的引用。再看objdump反汇编objdump -dr main.o输出里的关键部分是这样的e: e8 00 00 00 00 call 13 main0x13 f: R_X86_64_PC32 foo-0x4 19: 8b 05 00 00 00 00 mov 0x0(%rip),%eax 1b: R_X86_64_PC32 global_var-0x4call指令后面跟着的00 00 00 00就是占位符反汇编器提示这个位置有一处对foo的重定位。注意第二行的mov指令它从rip寄存器取地址再加一个32位偏移来读global_var这就是典型的PC相对数据访问。所有这些偏移现在都是0链接器还没有修正它们。3.3 链接后观察地址如何被“焊死”执行链接gcc -o app main.o foo.o再反汇编查看main函数objdump -d app可以看到call指令后面的四个字节已经被填成了具体值比如call 401136。对比main.o里还是00 00 00 00的状态这就是重定位的直观证据。还可以用readelf查看可执行文件的ELF头readelf -h app | grep Type现代Linux发行版默认PIE所以Type通常是DYN而不是传统的EXEC。DYN类型意味着这个可执行文件在加载时会被动态链接器放到一个随机基址上代码里的PC相对引用不需要修正直接在加载地址偏移下就能运行。再验证一下静态链接完成后的状态readelf -r app | head可执行文件里通常已经不存在静态重定位表只剩下动态加载需要处理的.rela.dyn和.rela.plt。这说明链接期的重定位工作已经全部完成。3.4 加-fPIC重新编译对比重定位类型的变化把foo.c用位置无关代码方式编译再试一次gcc -c -fPIC foo.c gcc -o app2 main.o foo.o readelf -r foo.o如果只编译目标文件不链接foo.o里访问global_var的代码会生成R_X86_64_PC32重定位但如果继续做动态库或PIE链接部分引用会在进一步处理中保留或转化为其他类型。重点观察的是编译选项会直接改变目标文件里的重定位类型集合比如对全局变量的普通访问在-fno-pic下可能生成R_X86_64_32S而-fPIC下变成PC相对访问或走GOT。这个对比实验做完你就能建立一条判断线索看到R_X86_64_PC32说明是PC相对引用看到R_X86_64_32S说明是绝对引用看到R_X86_64_RELATIVE说明需要在加载时基于基址修正。不同PIE选项、代码模型、编译器版本产生的组合非常多但底层逻辑是一致的。4. 常见错误与实战排查这五种坑一定要躲开4.1 relocation truncated to fit地址超出表达范围这类报错的完整格式类似relocation truncated to fit: R_X86_64_PC32 against symbol foo含义是链接器算出来的结果超出了目标重定位类型所能表达的范围。PC32是32位有符号数只能表达正负2GB32S是32位有符号数也只能覆盖约正负2GB。当程序代码段和数据段之间距离超出这个范围或者单个节实在太大时就会触发。遇到这类错误先反思是不是引入了超大的全局结构体、把超大数组放在了不该放的位置或者链接脚本里把某些段放得离.text太远。嵌入式工程师经常踩这个坑因为Flash和RAM在地址空间里相隔非常远访问全局变量或调用函数时容易跨界。临时方案可以加代码模型参数比如gcc的-mcmodellarge但它会让所有地址访问变慢、代码体积变大只能作为过渡。真正稳妥的方案是调整数据布局或者把代码拆开把相互访问频繁的模块放到同一区域内。4.2 multiple definition全局变量被定义了好几次很多人都会遇到这样的报错multiple definition of global_var最常见原因是头文件里写了变量定义比如int cfg_flag 0;然后多个.c文件包含这个头文件于是一个符号在多份目标文件里都出现了定义。C语言里这属于未定义行为但老式编译器为了兼容会把这种定义当作common处理默认不报错现代工具链越收越紧默认会出现多重定义报错。解决方案看起来很简单头文件里只写extern声明真正的定义放在某个.c文件里。但要注意C语言还有一个隐蔽的tentative definition规则在一个.c文件里写int x;而没有初始化器时它既是声明也是“暂定定义”如果多个.c都写了int x;链接器会把它们合并成一个common块。加了-fno-common编译参数后这条宽松路径会被封死多重定义暴露得更早。想要代码跨平台、跨工具链都稳定就别依赖这个规则宁可多写一个extern。4.3 undefined reference与静态库的链接顺序又是经典中的经典尤其是新手undefined reference to foo符号明明定义了为什么没被解析到这里十有八九是静态库顺序问题。链接器处理参数的方式是从左到右扫描每遇到一个未定义符号就记在待解析列表里只有遇到包含该符号定义的库或目标文件时才会接管这个符号。如果静态库放在源文件、目标文件之前链接器在解析到库时还不知道后面会有人引用它可能直接跳过整个库文件后面对这个库的引用就全成了undefined。解决方法是把-lxxx参数放在所有.o文件之后。比如gcc -o app main.o libfoo.a而不要写成gcc -o app libfoo.a main.o如果多个静态库之间有互相依赖还需要按依赖关系排列库的顺序甚至重复写库名。CMake里target_link_libraries的书写顺序同样要谨慎cmake的链接器包装脚本尽量帮你做了一部分排序处理但跨平台构建时还是可能出问题。另外.so动态库在运行时由整个进程统一解析没有这个顺序问题容易让人混淆。4.4 --gc-sections把符号裁掉了很多工程喜欢用链接期垃圾回收减少最终体积典型参数是-Wl,--gc-sections。这会把没有被引用到的节整体裁掉但某些节是被汇编、链接脚本或特性注册机制间接引用的链接器看不到这些引用于是误判为无用并删除。表现有两种链接成功但运行时崩溃或找不到符号链接时明明看到目标文件里有该符号却报undefined reference。很多注册表模式的模块比如用__attribute__((constructor))自动注册的驱动、异常展开表、段表项都会撞上这个问题。解决方式很明确对需要保留的符号或节加__attribute__((used))在汇编里用.globl和.type声明在链接脚本里用KEEP()显式保留。排查时先用nm查看目标文件里的符号是否还在再看最终可执行文件里是否被GC掉基本能一步定位。盲目加--no-gc-sections也行但体积会明显膨胀不如定位到具体符号后精准保留。4.5 可执行文件里符号被“吃掉”或被迫加前缀链接时偶尔会碰到这样的问题目标文件里符号名是foo链接后却变成foo.local、foo.part.0这种样子的名字或者明明调用了foo但在符号表里找不到对应项。这类情况多与编译器优化、内联、局部符号折叠有关也可能和-fvisibilityhidden以及版本脚本有关。如果是动态库导出符号找不到检查一下编译时有没有用-fvisibilityhidden把默认可见性改成隐藏以及version script里有没有显式列出导出符号。这类问题在现代大型C项目里特别常见因为默认隐藏符号可以显著减小导出表体积但也容易把本该导出的接口一起隐藏掉。看完导出表就基本能锁定问题。5. 进阶重定位在PIE、PIC和链接脚本下另有玄机5.1 为什么现代Linux默认PIE传统可执行文件EXEC类型链接时会指定一个固定加载地址比如0x400000程序被操作系统加载时通常就放在这个固定位置。问题在于如果地址固定攻击者就可以预测内存布局结合其他漏洞实施地址固定的攻击。地址空间布局随机化ASLR要发挥作用程序本身必须能被加载到随机选择的基址上这就要求可执行文件在加载时能容忍基址漂移。PIEPosition Independent Executable就是为解决这个问题而生的。PIE可执行文件被加载到任意基址都能正常工作因为代码里的引用要么是PC相对地址要么在加载时通过动态重定位修正。从重定位类型看PIE可执行文件的动态节里会出现R_X86_64_RELATIVE这个类型使用B A公式由动态链接器在程序启动时根据实际基址B来修正。代价是启动时多了一点点动态修正时间代码生成也受限但换来的安全收益在现代互联网环境下非常值得。理解这一点有助于看懂自己的程序在地址和重定位上的真实状态。如果你在排查一个PIE程序的崩溃第一反应不应该假设里面某个指针是绝对固定地址而是要意识到加载基址每次运行都可能不同。5.2 PIC代码如何绕开重定位GOT与PLTPIC位置无关代码主要用于共享库目标是让一份代码段能被多个进程安全共享。如果代码里写死了某条绝对地址每个进程加载基址不同该地址就只对当前进程有效这份代码段就没法在多个进程间共享了。解决思路是把所有可能变化的地址放进一个数据段里的全局偏移表GOT代码里只间接访问GOT。访问外部全局变量时编译器生成“通过GOT读地址再通过该地址取变量”的指令序列。动态链接器在加载共享库时负责把GOT里的条目修正为每个进程各自的正确地址。调用外部函数时PC32无法直接解析所以引入PLT调用指令不直接跳向目标函数而先跳到PLT桩PLT桩再从GOT取出真实函数地址。启用延迟绑定时第一次调用还会先进入动态链接器的解析器解析完成后把真实地址写回GOT后续调用直接跳走。代价也明显每次通过GOT访问外部符号都多了一次内存读取和可能的跳转性能比静态链接直接调用差一些。这解释了为什么性能敏感的库里有些优化技术会尽量把外部符号在模块内部先做一次地址缓存。5.3 链接脚本决定最终布局也就决定了重定位结果默认情况下ld会使用内置链接脚本。可以用ld --verbose查看内容里面定义了.text、.data、.bss等节在虚拟地址空间里的排列顺序。嵌入式和内核开发里人们经常自定义链接脚本指定代码段放在0x08000000这样的Flash地址数据段放在0x20000000这样的RAM地址。链接脚本的排布直接决定了S的最终值也决定了各种重定位计算结果。如果脚本把某段数据放到离代码段超过2GB的地方PC32引用就会溢出。反过来也可以通过调整脚本让相互引用的节靠拢从根源上规避一部分truncated错误。在单片机开发中VMA虚拟地址即运行时代码所在位置和LMA加载地址即代码烧录位置经常不同比如代码运行在RAM但初始化数据存在Flash里。重定位修正的是VMA启动代码则要把.data从LMA拷贝到VMA这个过程如果没做对就会出现“链接都过了、上电就飞”的现象。很多工具链的通用做法是先用一个粗略链接脚本看地址范围是否合理再用map文件核对关键符号的位置。对地址分布没有掌控力就很难谈真正理解嵌入式系统的启动过程。排查重定位问题多了我自己形成了一套固定套路先看报错类型是溢出还是多重定义还是未定义引用然后查map文件用-Wl,-Mapapp.map生成最终地址清单最后再用readelf -r、objdump -dr还原目标文件里的重定位表逐条核对。这套流程比瞎改编译选项高效得多。链接器不是黑魔法它的报错几乎每个都有明确出处重定位表就是它的“底稿”。搞懂这套机制后你再看那些天书一样的链接报错会觉得它们比运行时崩溃友好得多。
返回列表