RISC-V 跳转指令与重定位机制详解

第一部分:JAL 指令的限制与原理

1. 为什么 JAL 有 ±1 MiB 的限制?

这完全是由指令的二进制编码格式决定的。

RISC-V 的指令长度固定为 32位(4字节)。在这 32 位里,必须存放:

  • 操作码:告诉 CPU 这是一条 JAL 指令。
  • 目标寄存器:存放返回地址,通常是 x1 (即 ra),占用 5 位。
  • 立即数:也就是跳转的偏移量。

数学计算:
32位总长度 - 7位操作码 - 5位目标寄存器 = 20位 留给立即数。
RISC-V 的 JAL 指令将这 20 位立即数进行了符号扩展,并且默认末尾补 0(因为指令地址至少是 4 字节对齐的,或者 RVC 扩展下 2 字节对齐,最低位不需要存)。

所以,它能表示的范围是:
±220 Bytes=±1,048,576 Bytes≈±1 MiB \pm 2^{20} \text{ Bytes} = \pm 1,048,576 \text{ Bytes} \approx \pm 1 \text{ MiB} ±220 Bytes=±1,048,576 Bytes±1 MiB

结论JAL 只能跳转到当前 PC 指针前后 1MB 的范围内。对于一般的函数调用(同一个文件或模块内),这个距离通常足够了。


第二部分:跳转实例分析 (近距离 vs 远距离)

1. 近距离跳转 (标准 JAL)

假设我们在编写一段 C 语言代码,调用了一个很近的函数 add_numbers

C 代码:

int add_numbers(int a, int b) {
    return a + b;
}

int main() {
    return add_numbers(1, 2);
}

对应的 RISC-V 汇编(简化版):

# 假设内存布局如下:
# 0x1000: main 函数开始
# ...
# 0x1040: main 函数中调用 add_numbers 的位置
# ...
# 0x1100: add_numbers 函数开始

main:
    # ... 一些设置代码 ...
    
    # 调用指令就在这里
    # 0x1040: jal ra, add_numbers  
    
    # ... 后续代码 ...

add_numbers:
    add a0, a0, a1  # 执行加法
    ret             # 返回

分析:

  • 调用点 PC0x1040
  • 目标地址0x1100
  • 偏移量0x1100 - 0x1040 = 0xC0 (十进制 192 字节)
  • 判断:192 字节远远小于 1MB。
  • 结果:编译器直接生成一条 jal ra, add_numbers 指令。这条指令被编码时,会将 192 这个偏移量压缩存入指令的 20 位立即数域中。

2. 远距离跳转 (超出限制)

假设我们在编写一个巨型程序,或者调用了一个动态链接库中的函数,目标地址距离当前指令非常远,超过了 1MB。

场景:

  • 调用点 PC0x10000
  • 目标地址0x200000 (距离 2MB)
  • 偏移量0x200000 - 0x10000 = 0x1F0000 (约 2MB)

此时,偏移量超过了 20 位能表示的最大范围,JAL 指令无法直接跳过去

编译器的解决方案:AUIPC + JALR

编译器会将这一条跳转指令展开成两条指令的组合:

  1. AUIPC (Add Upper Immediate to PC):把 PC 的高位加上一个立即数,算出目标地址的“基地址”。
  2. JALR (Jump and Link Register):基于这个基地址,加上低位偏移,完成最终跳转。

汇编代码变化:

原本想写:

jal ra, far_away_func  # 错误!偏移量溢出,汇编器会报错或自动转换

实际生成的代码:

# 伪指令 call far_away_func 会被展开为:

# 1. 计算目标地址的高 20 位并存入 ra
#    AUIPC 指令逻辑: ra = PC + (imm << 12)
auipc ra, 0x1F0      # 这里是示意,具体数值由链接器计算

# 2. 跳转到 ra + offset (低位偏移)
#    JALR 指令逻辑: tmp = ra + offset; ra = PC+4; PC = tmp
jalr  ra, ra, 0x0    # 这里的 0x0 也是示意,实际是低 12 位偏移

原理详解:

  • AUIPC 读取当前 PC 值,加上一个 20 位的立即数(左移 12 位),结果存入寄存器。这实际上是在构建一个“接近目标地址”的中间值。
  • JALR 接收 AUIPC 的结果作为基地址,再加上一个 12 位的立即数偏移。
  • 组合效果:[PC + 高20位偏移] + 低12位偏移 = 任意 32 位(或 64 位)地址。
  • 代价:原本 1 条指令能完成的跳转,现在需要 2 条指令。这就是为了指令集简洁而付出的性能代价(但在 RISC-V 中,这种远距离跳转相对较少,所以整体影响小)。

3. 总结对比

场景 指令使用 机制
近距离调用 (±1MB 内) jal ra, func 单条指令,将 20 位偏移量编码在指令内部。速度快,密度高。
远距离调用 (超过 ±1MB) auipc ra, %hi(func)
jalr ra, ra, %lo(func)
双指令组合。AUIPC 设置高位地址,JALR 完成最终跳转。可覆盖整个地址空间。
伪指令写法 call func 汇编器会自动判断距离。如果近,生成 jal;如果远,生成 auipc + jalr

第三部分:PIC 编译下的重定位区别

当开启 PIC (Position Independent Code,位置无关代码) 编译时,代码可能会被加载到内存的任意位置,因此所有的跳转地址计算都必须是基于当前 PC 的相对偏移,而不能使用绝对地址。

在 PIC 模式下,JALAUIPC+JALR 这两种跳转方式在重定位类型计算逻辑以及适用场景上有显著区别。

1. 核心区别总结

特性 JAL (近距离跳转) AUIPC + JALR (远距离跳转)
重定位类型 R_RISCV_JAL (或 R_RISCV_CALL 的简化版) R_RISCV_CALL (标准函数调用)
地址计算公式 Target = PC + Offset (单一偏移) Target = PC + Hi20_Offset + Lo12_Offset (拼接偏移)
重定位修补时机 链接时 主要确定偏移量。如果溢出则报错或转换。 链接时 将目标地址分解为 High/Low 两部分填入指令。
PIC 适用性 仅适用于 模块内部 跳转。 既适用于模块内部,也适用于 模块外部 跳转。
指令序列 1 条指令。 2 条指令 (AUIPC + JALR)。

2. 详细解析

A. JAL 的重定位 (近距离)

在 PIC 模式下,JAL 是最简单的相对跳转。

  • 重定位类型:通常使用 R_RISCV_JALR_RISCV_BRANCH 类型的变体。
  • 计算过程
    链接器会计算:Offset = Symbol_Address - Current_PC
    如果 Offset±1MiB\pm 1\text{MiB}±1MiB 范围内,链接器直接将这个偏移量编码进 JAL 指令的立即数域。
  • PIC 下的限制
    在 PIC 中,JAL 只能用于调用同一个共享库或可执行文件内部的函数。
    • 原因:如果是调用外部共享库的函数(如 printf),两个库之间的加载距离在运行时是不确定的,且通常非常远(远超 1MB),JAL 的 20 位偏移量根本无法覆盖这个距离。
  • 重定位条目示例
    如果汇编器生成了 jal func,且 func 是本地符号,目标文件中会生成一个重定位条目,告诉链接器:“把 func 相对于这条指令的偏移填进去”。
B. AUIPC + JALR 的重定位 (远距离/标准调用)

这是 RISC-V PIC 编译中最标准的函数调用方式,伪指令 call 通常就会展开成这个序列。

  • 重定位类型:使用 R_RISCV_CALL 宏重定位类型。这实际上是由两个微操作重定位组成的:

    1. R_RISCV_PCREL_HI20 (针对 AUIPC)
    2. R_RISCV_PCREL_LO12_I (针对 JALR)
  • 计算过程 (关键点)
    这种组合允许生成一个 32位(或64位)的相对偏移量,从而可以跳转到任意距离的目标。

    指令序列:

    auipc ra, %pcrel_hi(func)   # 重定位类型 A
    jalr  ra, %pcrel_lo(func)(ra) # 重定位类型 B
    

    链接器/加载器的处理逻辑:

    1. 计算偏移Offset = Symbol_Address - Current_PC
    2. 分解偏移
      • Offset 的高 20 位(加上可能的舍入进位),填入 AUIPC 的立即数域。
      • Offset 的低 12 位,填入 JALR 的立即数域。
    3. 运行时执行
      • AUIPC 将 PC 加上高 20 位偏移,结果存入 ra。此时 ra 指向目标地址附近(误差在 4KB 内)。
      • JALRra 加上低 12 位偏移,精确跳转到目标地址。
  • PIC 下的优势
    这种方式完全基于 PC 相对寻址,不依赖任何绝对地址,因此代码段可以随意移动。
    当调用外部函数(如动态库中的函数)时,链接器通常会将其指向 PLT (Procedure Linkage Table) 入口。PLT 代码本身也是由 AUIPC + JALR 构成的,用于在运行时解析目标函数的实际地址。

3. 实例说明

假设我们在 PIC 代码中调用一个函数 bar

场景 A:bar 是本地函数,且距离很近
编译器为了优化代码大小和速度,会生成:

jal ra, bar
  • 重定位:链接器计算 bar - PC,填入指令。
  • 结果:1 条指令完成,纯 PIC。

场景 B:bar 是外部函数,或者距离极远
编译器必须生成:

# 伪指令: call bar
auipc ra, %pcrel_hi(bar)
jalr  ra, %pcrel_lo(bar)(ra)
  • 重定位
    • 目标文件中包含 R_RISCV_CALL 重定位条目。
    • 链接器发现 bar 是外部符号,将其指向 PLT 表的 bar@plt
    • 链接器计算 PLT_Entry_Address - Current_PC,将这个巨大的偏移量拆分为 High/Low 两部分。
    • High 部分修补 AUIPC
    • Low 部分修补 JALR
  • 结果:2 条指令完成,依然纯 PIC,且能跨越任意距离。

4. 总结

在 PIC 编译中:

  1. JAL 是“短程相对跳转”。重定位简单,只填一个偏移量。只能用于模块内部且距离较近的调用。
  2. AUIPC + JALR 是“远程相对跳转”。重定位复杂(涉及 HI20/LO12 分离),但它是实现 PIC 动态链接和跨模块调用的基石。它可以跳转到内存中任意位置的函数,只要计算出的偏移量在 32位/64位范围内即可。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐