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 条硬指令。
为什么需要伪指令?
- 提升可读性:
mv t0, t1比addi t0, t1, 0更清晰地表达"移动"意图 - 隐藏复杂度:
la t0, symbol一条指令背后的展开可能涉及 2-8 条硬指令——取决于符号的地址范围 - 可移植性:同一个
li t0, 0x123456789ABC在编译器看来自动选择最优序列,程序员不关心底层是 2 条还是 6 条指令 - 减少错误:手工构造 32 位/64 位立即数需要处理符号扩展修正(下文详述),
li伪指令自动处理所有 corner case
汇编器的角色:汇编器维护伪指令 → 硬指令展开表。展开是确定性的——给定伪指令和上下文,展开结果永远相同。你可以在汇编列表输出中看到展开结果(gcc -S 或 objdump)。
核心伪指令展开
下表给出 RISC-V 标准伪指令及对应展开。一条伪指令展开为一条硬指令的占大多数——这些本质上只是寄存器的重新解释。
移动与加载类
| 伪指令 | 等价硬指令 | 说明 |
|---|---|---|
mv rd, rs | addi rd, rs, 0 | 寄存器复制 |
li rd, imm | 视立即数大小自动选择(见下文) | 加载任意 64 位立即数 |
la rd, symbol | auipc + addi(或更长链) | 加载符号地址 |
lla rd, symbol | auipc + addi(局部符号) | 加载局部符号地址 |
mv 展开为 addi rd, rs, 0 而非 add rd, rs, x0(两者等价)。汇编器偏爱 I-type 变体——I-type addi 和 R-type add 的延迟相同,但 I-type 编码多一个选择。事实上两者完全等效。
跳转类
| 伪指令 | 等价硬指令 | 说明 |
|---|---|---|
j label | jal x0, label | 无条件跳转 |
jal label | jal x1, label | 跳转并链接 ra(等同于 call) |
jr rs | jalr x0, rs, 0 | 间接跳转 |
jalr rs | jalr x1, rs, 0 | 间接调用 |
ret | jalr x0, ra, 0 | 函数返回 |
call symbol | auipc ra, %pcrel_hi(sym); jalr ra, %pcrel_lo(ra)(sym) | 函数调用 |
tail symbol | auipc t1, %pcrel_hi(sym); jalr x0, %pcrel_lo(t1)(sym) | 尾调用优化 |
ret 是 jalr x0, ra, 0 的语法糖——"跳到 ra 存的地址,不记返回地址"。这也是为什么 ra(x1)叫返回地址寄存器:jal 把返回地址写入 ra,ret 从 ra 跳回。
运算类
| 伪指令 | 等价硬指令 | 说明 |
|---|---|---|
nop | addi x0, x0, 0 | 空操作 |
not rd, rs | xori rd, rs, -1 | 按位取反 |
neg rd, rs | sub rd, x0, rs | 取负(0 - rs) |
negw rd, rs | subw rd, x0, rs | 取负(32 位,符号扩展) |
sext.w rd, rs | addiw rd, rs, 0 | 将 rs 的低 32 位符号扩展至 64 位 |
seqz rd, rs | sltiu rd, rs, 1 | 等于零判断:rd = (rs == 0) |
not 是 xori rd, rs, -1 的直接体现——-1 的 12 位编码是 0xFFF,符号扩展后是 64 位全 1,异或全 1 = 取反。nop 展开为 addi x0, x0, 0——写 x0 丢弃结果,读 x0 给零,这确实是真正的"什么都不做"。
分支类
| 伪指令 | 等价硬指令 | 说明 |
|---|---|---|
beqz rs, label | beq rs, x0, label | 等于零分支 |
bnez rs, label | bne rs, x0, label | 不等于零分支 |
blez rs, label | bge x0, rs, label | 小于等于零分支(有符号) |
bgez rs, label | bge rs, x0, label | 大于等于零分支 |
bltz rs, label | blt rs, x0, label | 小于零分支 |
bgtz rs, label | blt x0, rs, label | 大于零分支 |
bgt rs1, rs2, label | blt rs2, rs1, label | 大于(交换操作数) |
ble rs1, rs2, label | bge rs2, rs1, label | 小于等于(交换操作数) |
bgtu rs1, rs2, label | bltu rs2, rs1, label | 无符号大于 |
bleu rs1, rs2, label | bgeu rs2, rs1, label | 无符号小于等于 |
分支伪指令是 RISC-V 伪指令体系中最有生产力提升的部分。硬指令只有 6 条(beq/bne/blt/bge/bltu/bgeu),但加上 x0 和操作数交换,能拼出所有常见的比较-分支模式。伪指令给这些组合命名,无需每次都心算操作数交换。
伪指令组合示例
# 完整的 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 中立即数最宽的格式。
lui t0, 0x12345 # t0 = 0x12345 << 12 = 0x12345_000
# 注意:0x12345 是 20 位(5 个 hex 位),正好填满 U-type 立即数构造 32 位值:LUI + ADDI
# 目标: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:
# 目标: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 + 移位 + 合并):
# 目标: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, imm | 1 |
| 12 位无符号 [0, 4095] | ori rd, x0, imm(某些汇编器选用) | 1 |
| 32 位以内 | lui + addi | 2 |
| 32 位,lo12 需修正 | lui(hi20+1) + addi(负lo12修正) | 2 |
| 64 位 | lui+addi+slli+lui+addi+or 或类似序列 | 6 |
| 位模式特殊(如全 1) | addi rd, x0, -1 | 1 |
条件伪指令
前面的分支伪指令表中已列出完整列表。这里补充关键的两个:
# 无符号的大于/小于等于
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_zerobeqz/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)
auipc t0, 0 # t0 = PC(读取自己的 PC 值!)auipc t0, 0 是获取当前 PC 值的唯一方法——RISC-V 不设"读 PC"指令。结合 jalr 或 addi,auipc 可以访问 ±2GB 范围内的任何代码或数据地址。
la 伪指令的展开
la rd, symbol 根据符号的范围自动选择展开:
# 近符号(±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 伪指令的展开
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 看不见它们。但调试时需要能"看穿"伪指令:
# 反汇编时显示伪指令(默认)
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 范围的地址和跳转