【DeepSeek】RISC-V 跳转指令与重定位机制详解
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 # 返回
分析:
- 调用点 PC:
0x1040 - 目标地址:
0x1100 - 偏移量:
0x1100 - 0x1040 = 0xC0(十进制 192 字节) - 判断:192 字节远远小于 1MB。
- 结果:编译器直接生成一条
jal ra, add_numbers指令。这条指令被编码时,会将 192 这个偏移量压缩存入指令的 20 位立即数域中。
2. 远距离跳转 (超出限制)
假设我们在编写一个巨型程序,或者调用了一个动态链接库中的函数,目标地址距离当前指令非常远,超过了 1MB。
场景:
- 调用点 PC:
0x10000 - 目标地址:
0x200000(距离 2MB) - 偏移量:
0x200000 - 0x10000 = 0x1F0000(约 2MB)
此时,偏移量超过了 20 位能表示的最大范围,JAL 指令无法直接跳过去。
编译器的解决方案:AUIPC + JALR
编译器会将这一条跳转指令展开成两条指令的组合:
AUIPC(Add Upper Immediate to PC):把 PC 的高位加上一个立即数,算出目标地址的“基地址”。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 模式下,JAL 和 AUIPC+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_JAL或R_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宏重定位类型。这实际上是由两个微操作重定位组成的:R_RISCV_PCREL_HI20(针对AUIPC)R_RISCV_PCREL_LO12_I(针对JALR)
-
计算过程 (关键点):
这种组合允许生成一个 32位(或64位)的相对偏移量,从而可以跳转到任意距离的目标。指令序列:
auipc ra, %pcrel_hi(func) # 重定位类型 A jalr ra, %pcrel_lo(func)(ra) # 重定位类型 B链接器/加载器的处理逻辑:
- 计算偏移:
Offset = Symbol_Address - Current_PC。 - 分解偏移:
- 取
Offset的高 20 位(加上可能的舍入进位),填入AUIPC的立即数域。 - 取
Offset的低 12 位,填入JALR的立即数域。
- 取
- 运行时执行:
AUIPC将 PC 加上高 20 位偏移,结果存入ra。此时ra指向目标地址附近(误差在 4KB 内)。JALR将ra加上低 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 编译中:
JAL是“短程相对跳转”。重定位简单,只填一个偏移量。只能用于模块内部且距离较近的调用。AUIPC+JALR是“远程相对跳转”。重定位复杂(涉及 HI20/LO12 分离),但它是实现 PIC 动态链接和跨模块调用的基石。它可以跳转到内存中任意位置的函数,只要计算出的偏移量在 32位/64位范围内即可。
更多推荐


所有评论(0)