Skip to content
Published at:

09. 伪指令体系

第 5-8 章学到的 RISC-V 指令是"硬指令"——CPU 译码器真正识别的机器码。但在日常汇编编程中,几乎没人直接写 addi t0, x0, 42 来加载一个常数——我们通常写 li t0, 42。后者就是伪指令(pseudo-instruction):汇编器在输出机器码前,将其透明翻译为一条或多条硬指令。理解伪指令的展开规则,是贯通"写的代码"和"执行的代码"之间最后一道桥梁。

伪指令的概念

伪指令不是 CPU 的指令。CPU 芯片上只有第 5-8 章介绍的硬指令(及 M/A/F/D 等扩展)。伪指令是汇编器提供的高级助记符,每条伪指令在汇编阶段被替换为 1-6 条硬指令。

为什么需要伪指令?

  1. 提升可读性mv t0, t1addi t0, t1, 0 更清晰地表达"移动"意图
  2. 隐藏复杂度la t0, symbol 一条指令背后的展开可能涉及 2-8 条硬指令——取决于符号的地址范围
  3. 可移植性:同一个 li t0, 0x123456789ABC 在编译器看来自动选择最优序列,程序员不关心底层是 2 条还是 6 条指令
  4. 减少错误:手工构造 32 位/64 位立即数需要处理符号扩展修正(下文详述),li 伪指令自动处理所有 corner case

汇编器的角色:汇编器维护伪指令 → 硬指令展开表。展开是确定性的——给定伪指令和上下文,展开结果永远相同。你可以在汇编列表输出中看到展开结果(gcc -S 或 objdump)。

核心伪指令展开

下表给出 RISC-V 标准伪指令及对应展开。一条伪指令展开为一条硬指令的占大多数——这些本质上只是寄存器的重新解释。

移动与加载类

伪指令等价硬指令说明
mv rd, rsaddi rd, rs, 0寄存器复制
li rd, imm视立即数大小自动选择(见下文)加载任意 64 位立即数
la rd, symbolauipc + addi(或更长链)加载符号地址
lla rd, symbolauipc + addi(局部符号)加载局部符号地址

mv 展开为 addi rd, rs, 0 而非 add rd, rs, x0(两者等价)。汇编器偏爱 I-type 变体——I-type addi 和 R-type add 的延迟相同,但 I-type 编码多一个选择。事实上两者完全等效。

跳转类

伪指令等价硬指令说明
j labeljal x0, label无条件跳转
jal labeljal x1, label跳转并链接 ra(等同于 call)
jr rsjalr x0, rs, 0间接跳转
jalr rsjalr x1, rs, 0间接调用
retjalr x0, ra, 0函数返回
call symbolauipc ra, %pcrel_hi(sym); jalr ra, %pcrel_lo(ra)(sym)函数调用
tail symbolauipc t1, %pcrel_hi(sym); jalr x0, %pcrel_lo(t1)(sym)尾调用优化

retjalr x0, ra, 0 的语法糖——"跳到 ra 存的地址,不记返回地址"。这也是为什么 ra(x1)叫返回地址寄存器:jal 把返回地址写入 ra,ret 从 ra 跳回。

运算类

伪指令等价硬指令说明
nopaddi x0, x0, 0空操作
not rd, rsxori rd, rs, -1按位取反
neg rd, rssub rd, x0, rs取负(0 - rs)
negw rd, rssubw rd, x0, rs取负(32 位,符号扩展)
sext.w rd, rsaddiw rd, rs, 0将 rs 的低 32 位符号扩展至 64 位
seqz rd, rssltiu rd, rs, 1等于零判断:rd = (rs == 0)

notxori rd, rs, -1 的直接体现——-1 的 12 位编码是 0xFFF,符号扩展后是 64 位全 1,异或全 1 = 取反。nop 展开为 addi x0, x0, 0——写 x0 丢弃结果,读 x0 给零,这确实是真正的"什么都不做"。

分支类

伪指令等价硬指令说明
beqz rs, labelbeq rs, x0, label等于零分支
bnez rs, labelbne rs, x0, label不等于零分支
blez rs, labelbge x0, rs, label小于等于零分支(有符号)
bgez rs, labelbge rs, x0, label大于等于零分支
bltz rs, labelblt rs, x0, label小于零分支
bgtz rs, labelblt x0, rs, label大于零分支
bgt rs1, rs2, labelblt rs2, rs1, label大于(交换操作数)
ble rs1, rs2, labelbge rs2, rs1, label小于等于(交换操作数)
bgtu rs1, rs2, labelbltu rs2, rs1, label无符号大于
bleu rs1, rs2, labelbgeu rs2, rs1, label无符号小于等于

分支伪指令是 RISC-V 伪指令体系中最有生产力提升的部分。硬指令只有 6 条(beq/bne/blt/bge/bltu/bgeu),但加上 x0 和操作数交换,能拼出所有常见的比较-分支模式。伪指令给这些组合命名,无需每次都心算操作数交换。

伪指令组合示例

asm
# 完整的 if-else 使用伪指令的写法
    li   t0, 10
    li   t1, 20
    blt  t0, t1, less       # blt 本身就是硬指令
    j    not_less            # j 是伪指令 → jal x0, not_less
less:
    mv   a0, t1             # mv 是伪指令 → addi a0, t1, 0
    ret                      # ret 是伪指令 → jalr x0, ra, 0
not_less:
    neg  a0, t0             # neg 是伪指令 → sub a0, x0, t0
    ret

每条伪指令在汇编列表中可以用 -M no-aliases 选项(GNU objdump)展示真实展开。

LUI 与构造任意立即数

立即数的位宽限制是 RISC-V 汇编中最常见的"为什么不能这样写"的根源。I-type 只有 12 位立即数,但程序需要 32 位和 64 位常量。lui(Load Upper Immediate)是构造大立即数的核心。

LUI 指令(U-type)

lui rd, imm20:rd = imm20 << 12,低 12 位清零。opcode = 0110111

U-type: ┌──────────────────────────┬───────┬─────────┐
        │       imm[31:12]         │  rd   │ opcode  │
        └───────────20─────────────┴───5───┴────7────┘

U-type 牺牲了 rs1、rs2、funct3 字段,换来了 20 位立即数。这是 RISC-V 中立即数最宽的格式。

asm
lui t0, 0x12345        # t0 = 0x12345 << 12 = 0x12345_000
# 注意:0x12345 是 20 位(5 个 hex 位),正好填满 U-type 立即数

构造 32 位值:LUI + ADDI

asm
# 目标:t0 = 0x12345_678
lui  t0, 0x12345       # t0 = 0x12345_000
addi t0, t0, 0x678     # t0 = 0x12345_000 + 0x678 = 0x12345_678

看似简单直接——但这里有一个微妙的陷阱。

lo12 符号扩展修正

当 lo12(低 12 位立即数)的最高位(bit 11)为 1 时,addi 的符号扩展会将 lo12 解释为负数。lui 设置的 hi20 被符号扩展"吃掉" 1:

asm
# 目标:t0 = 0x12345_ABC
# 错误展开:
lui  t0, 0x12345       # t0 = 0x12345_000
addi t0, t0, 0xABC     # 0xABC 符号位 = 1 → 符号扩展为 0xFFFF_FFFF_FFFF_FABC
                        # t0 = 0x12345_000 + (-0x544) = 0x12344_ABC  ← 错了!

# 正确展开(汇编器自动处理):
lui  t0, 0x12346       # hi20 预先 +1!(0x12345 + 1 = 0x12346)
addi t0, t0, 0xABC     # t0 = 0x12346_000 + (-0x544) = 0x12345_ABC  ← 正确!

汇编器的 li 伪指令自动处理这种修正。手工写 lui+addi 构造立即数时,程序员必须自己判断是否需要 hi20+1 修正——否则产生难以调试的数值错误。这正是 li 存在的重要原因:把"如何构造 32/64 位立即数"的所有 corner case 交给汇编器。

修正判定:当 lo12 的 bit 11 == 1 时,hi20 需要 +1。等价于:如果低 12 位在 [0x800, 0xFFF] 范围内,hi20 加 1。

构造 64 位值

64 位立即数的构造最多需要 6 条指令(2 次 lui+addi + 移位 + 合并):

asm
# 目标:t0 = 0x12345678_9ABCDEF0
# li 伪指令自动展开,以下是手动展开示例:
lui  t0, 0x12345       # t0[63:32] 高半部分 hi20
addi t0, t0, 0x678     # 可能触发 +1 修正
slli t0, t0, 32        # 左移到高 32 位
lui  t1, 0x9ABCD       # t1[31:0] 低半部分 hi20
addi t1, t1, 0xEF0     # 同样可能触发修正
or   t0, t0, t1        # 合并高低半部分

实际上汇编器的 li 展开会更智能——根据立即数中 1 的密度选择 lui+addi 的 2-6 指令序列、或者用 addi+移位 组合、或者加载立即数池(literal pool)。程序员不需要关心细节——li 保证"最正确"的展开。

汇编器的 li 展开策略总结

立即数范围展开指令数
[-2048, 2047]addi rd, x0, imm1
12 位无符号 [0, 4095]ori rd, x0, imm(某些汇编器选用)1
32 位以内lui + addi2
32 位,lo12 需修正lui(hi20+1) + addi(负lo12修正)2
64 位lui+addi+slli+lui+addi+or 或类似序列6
位模式特殊(如全 1)addi rd, x0, -11

条件伪指令

前面的分支伪指令表中已列出完整列表。这里补充关键的两个:

asm
# 无符号的大于/小于等于
bgtu a0, a1, label    # 展开为 bltu a1, a0, label
bleu a0, a1, label    # 展开为 bgeu a1, a0, label

# 与零比较的便捷形式
beqz a0, is_zero       # 展开为 beq a0, x0, is_zero
bnez a0, not_zero      # 展开为 bne a0, x0, not_zero

beqz/bnez 可能是被使用频率最高的伪指令——与零比较是 C 语言中 if (ptr)if (count)while (*p) 的直接翻译。

AUIPC:位置无关代码的基石

auipc rd, imm20(Add Upper Immediate to PC)是 U-type 的另一条指令,opcode = 0010111。它是 RISC-V 实现位置无关代码(PIC)的核心。

语义:rd = PC + (imm20 << 12)

asm
auipc t0, 0            # t0 = PC(读取自己的 PC 值!)

auipc t0, 0 是获取当前 PC 值的唯一方法——RISC-V 不设"读 PC"指令。结合 jalraddiauipc 可以访问 ±2GB 范围内的任何代码或数据地址。

la 伪指令的展开

la rd, symbol 根据符号的范围自动选择展开:

asm
# 近符号(±2GB 内)
la t0, near_var
# → auipc t0, %pcrel_hi(near_var)
#   addi  t0, t0, %pcrel_lo(near_var)

# 远符号(超出 ±2GB,需要 GOT 间接寻址)
la t0, far_var
# → auipc t0, %got_pcrel_hi(far_var)
#   ld    t0, %got_pcrel_lo(t0)(far_var)  # 通过 GOT 间接加载

%pcrel_hi(sym)%pcrel_lo(sym) 是汇编器的重定位操作数(relocation operand)——它们不是立即数,而是"此处填符号相对 PC 的高/低部分"的占位符,链接器在最终链接时填入具体值。

call 伪指令的展开

asm
call my_func
# → auipc ra, %pcrel_hi(my_func)
#   jalr ra, %pcrel_lo(ra)(my_func)

注意 jalr 的偏移使用的是 ra(当前指令的 PC 相关值)的相对偏移——这和 lo12 修正同理。链接器会处理所有重定位计算。

位置无关代码的实际意义

动态共享库(.so)和 PIE(Position-Independent Executable)可执行文件依赖 AUIPC 实现运行时重定位。当加载器将 .so 加载到任意基地址时,库中的 la/call 通过 AUIPC 自动算出正确的运行时地址——无需修改代码段(代码段在进程间共享节省物理内存)。这是现代 OS 安全机制(ASLR——地址空间布局随机化)和内存效率的基础。

伪指令的透明性与调试

伪指令对执行是透明的——CPU 看不见它们。但调试时需要能"看穿"伪指令:

bash
# 反汇编时显示伪指令(默认)
riscv64-unknown-elf-objdump -d program.elf

# 反汇编时只显示硬指令(去掉伪指令别名)
riscv64-unknown-elf-objdump -d -M no-aliases program.elf

使用 -M no-aliases 可以看到硬件真正执行的指令序列——这对于排查汇编器 bug 或优化指令计数很有价值。

本章要点

  • 伪指令不是 CPU 指令,汇编器将其透明展开为 1-6 条硬指令——mv/li/la/call/ret/j/nop 是最常用的核心伪指令
  • LUI(U-type)加载 20 位立即数到高位,与 ADDI 组合构造 32 位常数——lo12 符号扩展修正(hi20+1)由汇编器自动处理
  • li 伪指令根据立即数大小自动选择 1-6 条最优指令序列,程序员无需关心 hi20 修正细节
  • 分支伪指令(beqz/bnez/bgt/ble 等)通过 x0 和操作数交换补全硬指令 6 条分支的语义空白
  • AUIPC 是 PIC/PIE 的基石——la/call 伪指令底层使用 auipc+jalr 实现 ±2GB 范围的地址和跳转