Skip to content
Published at:

08. 分支与跳转

分支与跳转赋予 CPU "选择"的能力——根据条件改变执行路径。没有它们,程序就是一条直线,从第一条指令走到最后一条。RISC-V 的分支跳转体系只包含 6 条分支指令 + 2 条跳转指令,却足以表达从 ifswitch 的所有高级控制流。

条件分支指令(B-type)

B-type 格式用于条件分支,opcode = 1100011。6 条分支指令覆盖了所有比较模式:

指令funct3条件说明
beq rs1, rs2, offset000rs1 == rs2Branch if Equal
bne rs1, rs2, offset001rs1 != rs2Branch if Not Equal
blt rs1, rs2, offset100rs1 < rs2(有符号)Branch if Less Than
bge rs1, rs2, offset101rs1 >= rs2(有符号)Branch if Greater or Equal
bltu rs1, rs2, offset110rs1 < rs2(无符号)Branch if Less Than Unsigned
bgeu rs1, rs2, offset111rs1 >= 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

asm
# 长距离条件分支的编译器展开:
# 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 一样,立即数由汇编器从标签距离自动计算。

关键模式

asm
# 函数调用
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 字节对齐。

asm
# 函数返回
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 块)

asm
# 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

asm
# 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 循环

asm
# 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 循环

asm
# 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 循环

asm
# 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 链:

asm
# 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(交换操作数)。多了些心智负担。

优点

  1. 无隐式状态:ALU 指令执行后不改变任何全局状态。指令重排(乱序执行)的自由度更高——不需要维护 FLAGS 的假依赖
  2. 流水线更简单:分支在 Execute 阶段同时完成比较和地址计算,不需要单独的"条件码写回"阶段
  3. 节省状态位:FLAGS 需要 5 条以上额外状态线贯穿整个流水线。RISC-V 彻底消除了这种设计
  4. 密集编码:6 条分支指令覆盖所有比较模式——而 x86 需要 CMP + Jcc 两步,且 Jcc 和 CMP 之间需要保留条件码不被改写的"气泡"

ARM 的更早期版本有"条件执行"(每条指令的 bit 31:28 是条件码,可根据 FLAGS 决定是否执行),ARMv8 放弃了这种做法(代价:每个指令码 4 bits 被条件码占用,浪费编码空间)。

RISC-V 的设计师显然是吸取了这些历史教训——不设条件码,分支自包含,指令编码规整。

FLAGS 缺失的补偿:set-before-branch

需要"先计算比较结果,再后续分支"时,RISC-V 用 slt + bne 显式实现:

asm
# 在不同基本块中使用比较结果
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,多一步心智操作但节省了硬件和编码空间