基于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 优化的候选目标。

参考:

  1. riscv linker relaxations 是什么 - zephyr~ - 博客园
posted @ 2026-10-24 16:41  sureZ_ok  阅读(11)  评论(0)    收藏  举报