08. 分支与跳转
分支与跳转赋予 CPU "选择"的能力——根据条件改变执行路径。没有它们,程序就是一条直线,从第一条指令走到最后一条。RISC-V 的分支跳转体系只包含 6 条分支指令 + 2 条跳转指令,却足以表达从 if 到 switch 的所有高级控制流。
条件分支指令(B-type)
B-type 格式用于条件分支,opcode = 1100011。6 条分支指令覆盖了所有比较模式:
| 指令 | funct3 | 条件 | 说明 |
|---|---|---|---|
beq rs1, rs2, offset | 000 | rs1 == rs2 | Branch if Equal |
bne rs1, rs2, offset | 001 | rs1 != rs2 | Branch if Not Equal |
blt rs1, rs2, offset | 100 | rs1 < rs2(有符号) | Branch if Less Than |
bge rs1, rs2, offset | 101 | rs1 >= rs2(有符号) | Branch if Greater or Equal |
bltu rs1, rs2, offset | 110 | rs1 < rs2(无符号) | Branch if Less Than Unsigned |
bgeu rs1, rs2, offset | 111 | rs1 >= rs2(无符号) | Branch if Greater or Equal Unsigned |
B-type 指令格式
B-type: ┌───────────┬───────┬───────┬─────┬──────────┬─────────┐
│imm[12|10:5]│ rs2 │ rs1 │funct3│imm[4:1|11]│ opcode │
└─────7──────┴───5───┴───5───┴──3───┴────5─────┴────7────┘B-type 的立即数编码是六种格式中最不直观的。立即数被拆散到 bit 31(imm[12])、bit 30:25(imm[10:5])、bit 11:8(imm[4:1])、bit 7(imm[11])。且 bit 0 被省略——因为 RISC-V 指令总是 2 字节对齐(C 扩展的存在使得对齐粒度是 2 字节而非 4 字节),目标地址的最低位始终为 0,无需编码。
立即数的重组(硬件做的事):
imm = {imm[12], imm[10:5], imm[4:1], imm[11], 0}相当于 13 位偏移量(bit 12:1)左移 1 位。实际跳转范围:PC ± 4KB。
为什么分支范围只有 ±4KB?
4KB 听起来很小,但绝大多数分支目标都在几十条指令之内(if/else 两端、循环体、局部跳转)。长距离跳转使用 jal(±1MB)或 jalr(任意 64 位地址)。将分支范围压缩到 12 位立即数能节省 B-type 指令的 bits,保持指令定长 4 字节。如果编译器遇到了超出 4KB 的条件分支,会自动将 beq 翻转后接 jal:
# 长距离条件分支的编译器展开:
# if (a0 == a1) goto far_label; → 超出 ±4KB
bne a0, a1, skip # 翻转条件:不等则跳过 jal
jal x0, far_label # 相等则无条件跳转(±1MB 范围)
skip:分支与 PC 相对寻址
B-type 的分支目标是 PC 相对的:target = PC + sext(imm)。由于立即数的 LSB(bit 0)被省略,编码中的偏移量在拼接后自动左移 1 位。在汇编时,汇编器自动计算 label - PC 的差值,写入立即数位域。程序员只需写标签名字,不需要手动算偏移。
无条件跳转
JAL(J-type)
jal rd, offset(Jump and Link):将 PC+4 写入 rd(返回地址),然后跳转到 PC + sext(offset)。opcode = 1101111。
J-type 格式:
J-type: ┌──────────────────────────┬───────┬─────────┐
│ imm[20|10:1|11|19:12] │ rd │ opcode │
└───────────20─────────────┴───5───┴────7────┘20 位立即数(同样省略 bit 0)×2 = 21 位偏移量,跳转范围 ±1MB。和 B-type 一样,立即数由汇编器从标签距离自动计算。
关键模式:
# 函数调用
jal ra, func_label # ra = PC+4(返回地址), PC = func_label
# 等效伪指令: call func_label
# 无条件跳转(不保存返回地址)
jal x0, label # x0 = 丢弃返回地址 → 纯跳转
# 等效伪指令: j label
# 调用者代码模式
jal ra, my_func # 调用 my_func
back: # ra 指向这里
# 函数返回后继续执行...JALR(I-type,但独立 opcode)
jalr rd, rs1, offset(Jump and Link Register):rd = PC+4,PC = (rs1 + sext(offset)) & ~1。opcode = 1100111,funct3 = 000。
JALR 使用 I-type 格式(12 位立即数 + rs1),但拥有独立的 opcode(不与立即数算术指令共享 0010011)。目标地址的最低位置零(& ~1)确保 2 字节对齐。
# 函数返回
jalr x0, ra, 0 # x0 = 丢弃返回地址, PC = ra → 等价于 ret 伪指令
# 间接调用(函数指针)
jalr ra, t0, 0 # 调用 t0 指向的函数(虚函数表的核心)
# 远跳转:任意 64 位地址
auipc t0, hi20 # t0 = PC + 高位立即数
jalr x0, t0, lo12 # PC = t0 + lo12 → 跳转到绝对地址jalr 的返回地址写入 rd = x0 表示"跳转但不记返回地址"——等价于无条件跳转。写入 ra 则是标准的函数调用。
控制流翻译
RISC-V 的分支跳转体系只有 8 条指令。一切高级控制流——if-else、for、while、do-while、switch——最终都翻译为分支 + 跳转的组合。理解这些翻译模式就能从反汇编中还原控制流结构。
if-then(无 else 块)
# C: if (a >= 0) a = a + 1;
bgez a0, then_body # a0 >= 0 则进入 then 块(伪指令 bgez ≈ bge x, x0, label)
j after_if # 条件不成立,跳过 then 块
then_body:
addi a0, a0, 1
after_if:关键:对条件取反来决定"跳过"。if (cond) 翻译为:若条件不成立则跳过 then 块;若成立则执行 then 块后继续。
if-then-else
# C: if (a < b) max = b; else max = a;
blt a0, a1, then_case # a0 < a1 → then
# else 分支(隐含:a0 >= a1)
mv t0, a0 # max = a
j end_if
then_case:
mv t0, a1 # max = b
end_if:编译器常见的优化:当 then/else 都很短时,直接用条件传送(基于 slt 的掩码技巧)替代分支,避免分支预测失败。
while 循环
# C: while (i < n) { sum += a[i]; i++; }
mv t0, x0 # i = 0
mv t1, x0 # sum = 0
while_cond:
bge t0, a1, while_done # i >= n → 退出
slli t2, t0, 3 # t2 = i * 8
add t2, a0, t2 # t2 = &a[i]
ld t3, 0(t2) # t3 = a[i]
add t1, t1, t3 # sum += a[i]
addi t0, t0, 1 # i++
j while_cond
while_done:
# t1 = sum模式:前置条件检查 + 条件不满足跳末尾 + body + 无条件跳回条件检查。
for 循环
# C: for (i = 0; i < n; i++) sum += a[i];
mv t0, x0 # i = 0(init)
beqz a1, for_done # n = 0 的空循环快速退出
mv t1, x0 # sum = 0
for_cond:
bge t0, a1, for_done # 条件检查(前置)
slli t2, t0, 3
add t2, a0, t2
ld t3, 0(t2)
add t1, t1, t3 # body
addi t0, t0, 1 # inc(后置)
j for_cond
for_done:for 循环和 while 循环在汇编层没有区别——都是 init → 条件检查 → body → 递增 → 跳回条件检查。编译器对 for 和 while 生成相同的汇编代码。
do-while 循环
# C: do { x = f(x); } while (x != 0);
mv t0, a0
do_body:
mv a0, t0
jal ra, f # x = f(x)
mv t0, a0
bnez t0, do_body # x != 0 → 继续do-while 是唯一不需要前置条件检查的循环——进入时无条件执行 body,退出条件在末尾判断。在汇编层通常比 while 少一条分支指令。
switch / case:跳转表
switch 语句在 case 密集且连续时,编译器用跳转表替换 if-else 链:
# C: switch (x) { case 0: ...; case 1: ...; case 2: ...; default: ...; }
# 假设 x 在 t0
li t1, 2 # case 最大值
bltu t1, t0, default_case # x > 2 → default
la t3, jumptable
slli t2, t0, 2 # t2 = x * 4(每个跳转表项 4 字节)
add t2, t3, t2 # t2 = &jumptable[x]
lw t2, 0(t2) # 加载跳转偏移
add t2, t3, t2 # t2 = jumptable_base + offset
jalr x0, t2, 0 # PC = t2
default_case:
# 处理默认情况
.section .rodata
jumptable:
.word case0 - jumptable
.word case1 - jumptable
.word case2 - jumptable跳转表的本质是 PC 相对偏移数组:用 jalr 的基址+偏移能力,将 switch 表达式的值作为数组索引,在跳转表(.word 数组)中查找偏移量后跳转。时间复杂度 O(1)——而 if-else 链在最坏情况下需要检查每个 case。
无 Flags 寄存器:RISC-V 的分支哲学
这是 RISC-V 与 x86/ARM 最根本的设计分歧之一。值得深入理解其代价和收益。
x86 的 FLAGS 方式
CMP rax, rbx # 比较:设置 FLAGS(ZF, CF, SF, OF)
Jxx label # 分支:根据 FLAGS 各位决定是否跳转两步操作:先比较(设置隐式状态),再分支(读取隐式状态)。FLAGS 寄存器是隐藏的全局变量——所有 ALU 指令都可能修改 FLAGS,指令重排时必须"小心"不破坏 FLAGS 依赖链。
RISC-V 的融合方式
blt a0, a1, label # 一条指令:比较并分支RISC-V 把比较和分支合并为一条指令。没有中间状态,没有隐式 FLAGS 寄存器,每条分支指令同时完成比较和跳转。代价:
缺点:RISC-V 缺少纯"大于"和"大于等于"的分支。if (a > b) 必须写成 blt b, a, label(交换操作数)。多了些心智负担。
优点:
- 无隐式状态:ALU 指令执行后不改变任何全局状态。指令重排(乱序执行)的自由度更高——不需要维护 FLAGS 的假依赖
- 流水线更简单:分支在 Execute 阶段同时完成比较和地址计算,不需要单独的"条件码写回"阶段
- 节省状态位:FLAGS 需要 5 条以上额外状态线贯穿整个流水线。RISC-V 彻底消除了这种设计
- 密集编码:6 条分支指令覆盖所有比较模式——而 x86 需要 CMP + Jcc 两步,且 Jcc 和 CMP 之间需要保留条件码不被改写的"气泡"
ARM 的更早期版本有"条件执行"(每条指令的 bit 31:28 是条件码,可根据 FLAGS 决定是否执行),ARMv8 放弃了这种做法(代价:每个指令码 4 bits 被条件码占用,浪费编码空间)。
RISC-V 的设计师显然是吸取了这些历史教训——不设条件码,分支自包含,指令编码规整。
FLAGS 缺失的补偿:set-before-branch
需要"先计算比较结果,再后续分支"时,RISC-V 用 slt + bne 显式实现:
# 在不同基本块中使用比较结果
slt t0, a0, a1 # t0 = (a0 < a1)
# ... 其他代码(不破坏 t0)...
bnez t0, label # 根据之前保存的比较结果跳转这条指令序列有 FLAGS 范式的等价功能性——但 t0 是显式的通用寄存器,编译器可以自由调度它。
本章要点
- B-type 的 6 条分支指令(beq/bne/blt/bge/bltu/bgeu)+ PC 相对寻址 ±4KB 覆盖所有条件转移
- JAL(J-type, ±1MB)和 JALR(I-type, 任意地址)提供无条件和间接跳转——函数调用、返回、跳转表的基础
- 所有高级控制流(if/else/while/for/switch)可翻译为 branch + jump 的组合——do-while 是唯一不需要前置检查的循环
- RISC-V 无 FLAGS 寄存器——比较与分支融合为一条指令,消除隐式状态、简化流水线、提高指令重排自由度
- 无"大于"/"大于等于"分支指令——交换操作数用 blt/bge,多一步心智操作但节省了硬件和编码空间