链接器脚本与重定位

本页目录

链接器的两个相关任务是安排布局和修补引用:脚本决定输入 section 如何进入输出及位于哪里,重定位记录决定某个指令或数据字段应填什么。文件中的位置、运行时地址和加载时复制位置必须分开讨论。

重定位记录携带什么

ELF 重定位至少指定待修补位置、类型与相关符号;RELA 形式还显式保存 addend,REL 则由被修补字段提供隐式加数。具体使用哪种形式取决于目标 ABI,不能把所有平台都写成同一种 RELA。

设 S 为符号地址,A 为加数,P 为重定位字段位置。x86-64 的典型公式包括:

类型主要计算解释
R_X86_64_64S + A写入 64 位绝对值
R_X86_64_PC32S + A - P写入经范围检查的 32 位 PC 相对值
R_X86_64_PLT32L + A - PL 为 PLT 入口;满足条件时可直接绑定目标
R_X86_64_GOTPCRELGOT 条目地址 + A - P得到存放目标地址的条目位置

对一个 5 字节的相对 call,位移字段位于指令起点之后 1 字节,CPU 使用的是下一条指令地址。RELA 中常见 A=-4,正是把 P 调整到这个基准。例如指令在 0x1000、P=0x1001、目标在 0x1100,位移为 0x1100 - 4 - 0x1001 = 0xfb;执行时 0x1005 + 0xfb = 0x1100。完整链接例子见 链接与加载。

PLT32 不意味着最终一定经过 PLT:目标可直接绑定时,链接器可以解析为直接调用。动态链接也不是“所有未定引用都进入 .rela.plt”,数据、GOT、TLS 等会采用相应动态重定位,部分引用则在静态链接阶段已解决。AMD64 ABI

从 .o 中的占位到最终位移

下面实验针对 x86-64 ELF,使用 GNU 风格汇编。在独立目录保存 caller.s:

.text
.globl caller
caller:
    call callee
    ret
.section .note.GNU-stack,"",@progbits

再保存 callee.s:

.text
.globl callee
callee:
    ret
.section .note.GNU-stack,"",@progbits
as --64 caller.s -o caller.o
as --64 callee.s -o callee.o
readelf -rW caller.o
objdump -dr caller.o
ld -m elf_x86_64 -Ttext=0x1000 -e caller caller.o callee.o -o layout.elf
nm -n layout.elf
objdump -d layout.elf

layout.elf 只用于观察布局,不要直接执行:caller 的 ret 不是一个合法进程启动/退出协议。输入 caller.o 的位移字段在 .text 偏移 1,常见记录为 R_X86_64_PLT32 callee - 4。此实验 callee 可直接绑定,按输入顺序紧接 caller 的 6 字节代码,最终地址为 0x1006。

x86-64 call:重定位字段位置与 CPU 下一条地址相差 4 指令起点 0x1000 E8:call 操作码,1 字节 P = 0x1001 4 字节位移字段:01 00 00 00 下一条 0x1005 ret:1 字节 S = 0x1006,A = -4:S + A - P = 1 CPU 从 0x1005 加位移 1 到达 callee;公式中的 P 不是 call 指令起点。

最终 call 位移为 1,对应小端字节 01 00 00 00。读取 .o 时,偏移 1 是相对于该输入 section 的位置;最终链接后才能结合输出布局得到 P=0x1001。不能把输入重定位表的 offset 无条件当成进程虚拟地址。

如果最终目标距离超出对应带符号位移范围,链接器必须按目标机制处理或报错,不能把高位截掉“凑出一个可执行文件”。改变代码模型、布局或选择合适的间接序列才是有语义的修复。

A=-4 到底补偿了哪四个字节?

先用默认目标验证 0xfb,对应字段 fb 00 00 00;再把目标移到指令之前,观察负位移的二补码字节。最后只改变整个代码块基址:S 与 P 同时平移,位移不变。这个不变量比孤立记住一条公式更有助于理解位置无关引用。

正在呈现知识画面
A=-4 到底补偿了哪四个字节?

重定位位置 P 指向4字节位移字段,CPU 的 RIP 相对基准却在字段之后。A=-4 把二者接起来,整体平移不改变相对位移。

AArch64:页地址与页内偏移配合

AArch64 的 ADRP 用页粒度构造地址,随后 ADD 或加载指令处理页内部分。R_AARCH64_ADR_PREL_PG_HI21 对应 ADRP 的页相对计算,不是把 ADR 和 ADRP 混作同一个指令。

R_AARCH64_ADD_ABS_LO12_NC 提供低 12 位;R_AARCH64_LDST64_ABS_LO12_NC 对应 64 位加载或存储的缩放立即数,不能忽略对齐与缩放。R_AARCH64_ADR_GOT_PAGE 只负责 GOT 页相关部分,通常还需配套加载的低位重定位,不是单独一个记录就完成“页地址加加载”。Arm ELF ABI

跨过页边界,ADRP 与 ADD 如何分工?

默认目标是0x1fff,低12位为0xfff;向右移动一个字节到0x2000,低位变为0,ADRP 负责的页基址增加0x1000。这里的“页”是 ADRP 固定的地址运算粒度,即使系统采用其他页大小,也不能把公式里的12位换掉。

正在呈现知识画面
跨过页边界,ADRP 与 ADD 如何分工?

ADRP 计算目标页与指令所在页的差,ADD 补齐目标低12位。跨边界时低位归零、页差增加,最终地址仍连续。

section 与 segment 分工

链接器安排 section,加载器依据 segment 建立映射 a.o 的 .text / .rodata b.o 的 .text / .data 输出 section:按脚本合并与对齐 程序头 PT_LOAD:文件区间、内存区间与权限

输出 section 名称本身不决定运行时页权限;程序头、属性、页对齐及链接器布局共同形成加载视图。检查布局时同时看 readelf -SW 与 readelf -lW,不要只看到 .text、.data 就认为读写执行权限一定正确。

位置计数器 . 表示当前输出位置,ALIGN 调整对齐,通配模式收集输入 section。*(.text) 不会自动包含 .text.foo,启用每函数 section 时通常还需 .text.*。

完整默认脚本还处理启动数组、异常展开、TLS、动态信息等。不要用仅含 .text/.data/.bss 的教学脚本直接替换普通 Linux 程序默认布局。先运行 ld --verbose 查看当前目标的默认脚本,不能假定固定安装路径。GNU ld 脚本

给默认布局增加一个自定义区段

下面的 GNU ld 扩展脚本保留默认布局,只插入应用元信息。保存为 extra.ld:

SECTIONS
{
  .appmeta : ALIGN(8)
  {
    __appmeta_start = .;
    KEEP(*(.appmeta .appmeta.*))
    __appmeta_end = .;
  }
}
INSERT AFTER .rodata;

KEEP 防止匹配 section 被链接垃圾收集删除,但不能保证编译器一定生成原始变量,所以源代码也用 used 属性。保存为 meta.c:

__attribute__((section(".appmeta"), used))
const unsigned int app_version = 7;

int main(void) { return 0; }
gcc -O2 -ffunction-sections -fdata-sections meta.c \
  -Wl,--gc-sections,-T,extra.ld,-Map,meta.map -o meta
readelf -SW meta
readelf -lW meta
readelf -x .appmeta meta

INSERT 使这个脚本增强默认 SECTIONS,而不是普通 -T 替换完整默认脚本的行为。两个导出符号描述地址,不会自动分配 C 变量存储;从 C 使用它们时应按链接器符号约定声明和取址。GNU ld INSERT

这份示例适用于所述 GNU ld 用法;换链接器需核对支持细节和产物。LLD 兼容很多 GNU 脚本行为,但有明确的实现差异。LLD 脚本说明

三道不同关卡:生成变量、提取归档、保留 section

used、归档提取和 KEEP 发生在不同阶段。used 要求编译器保留定义,KEEP 保护已进入链接处理的匹配 section;如果定义藏在静态归档里,那个成员尚未被提取,KEEP 不能替代提取条件。

为此另外准备 registry.c,只包含元信息定义;准备 entry.c 作为应用入口:

/* registry.c */
__attribute__((section(".appmeta"), used))
const unsigned int app_version = 7;
/* entry.c */
int main(void) { return 0; }
gcc -O2 -fdata-sections -c registry.c -o registry.o
ar rcs libregistry.a registry.o
gcc entry.c -L. -lregistry -Wl,--gc-sections,-T,extra.ld -o not-extracted
gcc entry.c -Wl,-u,app_version -L. -lregistry \
  -Wl,--gc-sections,-T,extra.ld -o extracted
nm not-extracted
nm extracted
readelf -x .appmeta extracted

第一份应用没有对 app_version 的未定义引用,归档成员通常不会被提取。第二份用 -u 引入符号需求,从归档提取 registry.o,再由 KEEP 保留元信息区段。大型插件注册表也会遇到这类问题;--whole-archive 可以扩大提取范围,但应及时用 --no-whole-archive 限定范围,避免把无关归档全部拉入。GNU ld 选项

还可以把 extra.ld 复制成 drop.ld,仅移除 KEEP 包装,直接链接 registry.o 与 entry.c 并开启 --gc-sections。此时编译器确实生成了变量、对象也已输入链接,但没有可达引用的 .appmeta 仍可被垃圾收集;这第三种情况不能用 used 修复。

VMA 与 LMA:运行位置不一定等于装载来源

嵌入式固件可能把初始化数据存于 Flash,启动时复制到 RAM。该数据 section 的 VMA 是运行时地址,LMA 是初始内容的装载地址,可通过 AT 或 AT> 指定关系。脚本只描述布局,启动代码仍需执行复制和 .bss 清零。

Linux ELF 用户程序主要按程序头完成映射,不应把裸机 Flash/RAM 复制模型直接套到动态加载器。NOLOAD、SHT_NOBITS、文件大小和内存大小也应按工具与加载模型分别核对。GNU ld:输出 section LMA

用地址账理解初始化复制

假设一段 .data 的运行地址 VMA 为 0x20000000,初始内容在 Flash 的 LMA 为 0x08004000,大小 16 字节。上电后访问变量使用前一个地址;启动代码应把后一个地址开始的 16 字节复制过去。若只把 VMA 改成 RAM,却没有相应初始内容和复制动作,变量不会自动得到编译时初值。

对象镜像中是否需要初始字节启动阶段动作
已初始化 .data需要从装载来源复制到运行位置
零初始化 .bss常用 NOBITS 表示,不保存同等大小的零字节按目标运行模型清零
.text / 只读常量需要就地执行/读取,或按平台需要复制

脚本可以用 LOADADDR、ADDR、SIZEOF 导出这些边界,启动代码再按目标约定使用。对普通 Linux ELF,则应查看 PT_LOAD 的 p_filesz 和 p_memsz 如何定义文件与内存范围;并不是动态加载器普遍执行这张 Flash/RAM 复制表。输出 section 的装载地址

Relaxation:已知最终布局后改写指令序列

链接放松利用最终距离、符号绑定和重定位类型,将某些序列替换成等价且更合适的序列。例如目标能直接绑定时,x86-64 的某些 GOT 访问可以放松成直接地址计算;RISC-V 远调用序列可在距离合适时缩短。

x86 指令是变长编码,并非“固定大小所以没有 relaxation”。能否放松取决于目标 ABI、重定位标记、链接器支持和选项;指令数量减少也不保证整个程序一定更快。

排查顺序

readelf -rW file.o
objdump -dr file.o
nm -u file.o
gcc file.o other.o -Wl,-Map,output.map,--trace -o prog

先确认引用哪个符号、哪个文件提供定义,再检查输入顺序、可见性、版本、归档提取和布局。重定位溢出通常意味着编码范围或代码模型不满足,不应随意截断结果。符号已解析但运行时错位,还需核对 ABI、PIE、加载映射和动态重定位。

选择 GNU ld、LLD 或其他链接器时,应基于目标平台、脚本、LTO 格式、诊断和实际链接资源测量,避免固定“快几倍”的结论。

关键词:linker script, relocation, REL, RELA, GOT, PLT, section, segment, VMA, LMA, KEEP, relaxation, LLD