基于RISC-V的编译链接和装载16之链接器松弛
基于RISC-V的编译链接和装载16之链接器松弛
链接器松弛(Linker Relaxation)主要用来在链接阶段减少不必要的指令。
在编译阶段,编译器无法预知函数或变量的最终内存地址,因此会生成保守、通用的指令序列;
在链接阶段,所有目标文件和符号的最终地址都已确定,链接器可以在此基础上将这些指令序列简化或优化,达到减小代码体积、提升性能的目的。
1 RISC-V的链接松弛
RISC-V采用定长指令设计,在编译时,当需要访问远距离变量或调用远距离函数时,编译器不知道访问距离有多远,所以保守的使用多条指令组合;等到链接时所有目标文件和符号的最终地址都已确定,如果要访问的符号地址或要调用函数正好满足一定条件时,链接器可以在此基础上将这些指令序列简化或优化,这样可以减少代码体积,提高性能。
对于RISC-V来说,链接松弛主要(常见)涉及两个场景:
- 函数调用松弛
- 符号地址访问松弛(注意:gp松弛时符号地址访问松弛的一种常见场景,文中以gp松弛举例)
满足一定条件时,即可进行松弛优化:
| 特性 | 函数调用松弛 | gp松弛(符号地址访问松弛其中的一种情况) |
|---|---|---|
| 优化对象 | 函数调用指令本身 (call, tail) |
访问全局变量的指令 (lui + 加载/存储) |
| 核心依据 | PC 相对偏移:当前指令到目标函数地址的距离 | gp 相对偏移:目标变量相对于 __global_pointer$ 的位置 |
| 关键寄存器 | 程序计数器 PC,用于计算距离 |
全局指针寄存器 gp (x3),用作基址 |
| 典型指令转换 | auipc + jalr (8字节) → jal (4字节) |
lui + load/store (8字节) → load/store with gp (4字节) |
| 判断条件 | 函数在 ±1 MiB (非压缩指令) 或 ±2 KiB (压缩指令) 范围内 | 变量在 gp 指针 ±2 KiB 范围内 |
2 RISC-V链接松弛举例
2.1 函数调用松弛
在RISC-V中,要调用一个距离较远的函数,编译器会生成 auipc 和 jalr 两条指令,两者配合可以实现±2GB范围内的跳转。但如果链接时发现目标函数就在附近(通常在±1MB范围内),这两条指令就可以被一条 jal 指令替代
举例:
函数调用松弛优化:
# 调用 foo() 函数
# 松弛前:两条指令,8字节
auipc ra,0x0 # 将 foo 与当前PC的高20位差值加载到 ra,0x0仅为占位,链接时将被替换为实际的高位偏移
jalr 26(ra) # 加上低12位偏移,跳转到 foo
# ================================================
# 调用 foo() 函数
# 松弛后:一条指令,4字节
jal ra,1001e <foo> # 直接跳转到 foo
2.2 gp松弛(符号地址访问松弛其中的一种情况)
gp松弛利用 gp(全局指针)寄存器来实现符号地址访问松弛,当全局变量在 gp 指针 ±2 KiB 范围内时,可以将lui与lw两条指令优化为一条lw a0, <offset>(gp)指令。
举例:
访问一个全局变量:
# 访问一个全局变量
# 松弛前(两条指令,8字节)
lui a5,0x10 # 加载 symbol 绝对地址的高20位
lw a5,176(a5) # 加载低12位((经过 %lo 计算)偏移,得到 shared 的值到 a5
# ================================================
# 松弛后(一条指令,4字节)
lw a5, <offset>(gp) # 直接从 gp 寄存器相对偏移加载
3 链接器松弛的步骤
step1:编译器配合生成R_RISCV_RELAX重定位
链接器松弛需要编译器与链接器协同工作,编译时,编译器会为每个可以被松弛的指令序列打上标记——生成R_RISCV_RELAX重定位,见第7讲的3.2节。
step2: 链接器的松弛执行
链接器扫描所有带R_RISCV_RELAX标记的重定位,根据最终确定的地址偏移,决定是否执行松弛以及采用哪种松弛方式。
step3: 重定位组
松弛操作必须保证原子性。例如,LUI+LOAD两条指令构成一个重定位组(Relocation Group),必须同时被松弛或同时保留。
step4: 反复迭代
链接器松弛没有固定的轮数,而是会根据代码修改情况,在检测到仍有优化机会时,反复进行多轮处理,直到结果收敛(即无法再进一步优化)。
松弛的核心是缩短指令长度,当一段代码被缩短后,后续指令的地址和符号的相对偏移都会发生变化。这就像多米诺骨牌一样,可能让之前因为“距离太远”而无法优化的调用指令,在新的地址布局下又获得了优化机会。因此,链接器需要反复迭代来挖掘所有可能的优化空间。
4 启用链接器松弛
链接松弛通常不需要特别配置,因为它一般默认就是开启的,
4.1 启用/禁用松弛
在 RISC-V 工具链中,与链接松弛相关的选项实际上分为两个层面:
| 层面 | 作用对象 | 启用松弛 | 关闭松弛 | 说明 |
|---|---|---|---|---|
| 汇编器 | 汇编器 (GAS) | -mrelax (默认) |
-mno-relax |
控制汇编器是否生成用于链接松弛的重定位信息(如 R_RISCV_RELAX)。 |
| 链接器 | 链接器 (LD/LLD) | 默认开启 | --no-relax |
控制链接器是否执行松弛优化。 |
注意:-mrelax 和 -mno-relax 是传递给汇编器的选项,用于控制汇编阶段的行为。--no-relax 是链接器的选项,用于控制链接阶段的行为。它们的关系是:-mrelax 是 --no-relax 生效的前提,如果汇编时没有生成 R_RISCV_RELAX 标记(即使用了 -mno-relax),那么链接器即使默认开启松弛,也没有可优化的对象。
# 编译时禁用松弛
$ riscv64-unknown-elf-gcc -mno-relax -c main.c -o main.o
# 链接时禁用松弛
$ riscv64-unknown-elf-ld --no-relax main.o -o test.elf
4.2 gp寄存器的特别配置
gp 松弛需要与gp寄存器进行比较判断是否在 gp 指针 ±2 KiB 范围内,然后决定是否要松弛。gp寄存器不会自动设置,必须在 _start 启动代码中显式初始化,且该初始化代码本身必须禁用松弛。如果不加 .option norelax,链接器可能会将 auipc+addi 视作 la 伪指令,并将其松弛为 mv gp, gp,导致 gp 初始化失败,这样会引发数据访问异常。
step1: 在链接脚本中设置_global_pointer$
__global_pointer$ 通常被设置为 .sdata 段的起始地址加上 0x800:
__global_pointer$ = _sdata + 0x800
__global_pointer$ 被设置为 _sdata + 0x800,是因为 .sdata 和 .sbss 在内存中连续排列,gp 的有效范围是 [gp-2048, gp+2047],因此这个设置能让 gp 覆盖从 _sdata 到 _sbss 末尾的 4KB 区域(前提是 .sdata + .sbss 的总大小不超过 4KB)。
step2: 在初始化中设置gp寄存器的值为_global_pointer$
.option push
.option norelax # 关键:禁用此段落的松弛
1:
auipc gp, %pcrel_hi(__global_pointer$)
addi gp, gp, %pcrel_lo(1b)
.option pop
step3: 怎么控制哪些变量能启动gp松弛呢?
可以使用 -msmall-data-limit=N 选项来设定一个字节阈值(例如 -msmall-data-limit=8)。
- 编译器会自动将大小小于或等于
N字节的全局变量和静态变量放入小数据区(.sdata或.sbss) - 只有被放入这些特定段(Section)的变量,链接器才会基于
gp寄存器进行寻址优化 - 如果将其设置为
0,则会完全关闭此优化,所有变量都不会使用gp相对寻址
如果你希望某个特定的变量(无论其大小是否超过阈值)强制参与 gp 优化,可以在 C 代码中使用 __attribute__ 将其显式放入小数据段:
__attribute__((section(".sdata"))) int global_var;
这会告诉编译器将该变量直接放入 .sdata 段,从而使其成为 gp 优化的候选目标。
参考:

浙公网安备 33010602011771号