07. Load 与 Store 指令
Load 和 Store 是 RISC-V 仅有的两条"触摸内存"的桥梁。ALU 操作的对象必须是寄存器——数据必须先 ld 到寄存器、计算、再 sd 回内存。这是 Load-Store 架构的核心纪律:从 RISC-V 汇编程序的视角看,内存是一个可读可写的字节数组,只有 Load 和 Store 能访问它。
Load 指令族
Load 指令族使用 I-type 格式,opcode = 0000011。funct3 决定数据宽度和扩展方式。
RV64I Load 指令完整列表:
| 指令 | funct3 | 功能 | 符号/零扩展 |
|---|---|---|---|
lb rd, offset(rs1) | 000 | 读 1 字节 (byte) | 符号扩展至 64 位 |
lh rd, offset(rs1) | 001 | 读 2 字节 (halfword) | 符号扩展至 64 位 |
lw rd, offset(rs1) | 010 | 读 4 字节 (word) | 符号扩展至 64 位 |
ld rd, offset(rs1) | 011 | 读 8 字节 (doubleword) | 无扩展(已是 64 位满宽度) |
lbu rd, offset(rs1) | 100 | 读 1 字节,无符号 | 零扩展至 64 位 |
lhu rd, offset(rs1) | 101 | 读 2 字节,无符号 | 零扩展至 64 位 |
lwu rd, offset(rs1) | 110 | 读 4 字节,无符号 | 零扩展至 64 位 |
命名约定:l = load,后缀字母表示宽度:b=byte(8b), h=halfword(16b), w=word(32b), d=doubleword(64b)。中间有 u 表示 unsigned(零扩展),无 u 表示 signed(符号扩展)。
为什么没有"无符号 ld"
ld 读 8 字节(64 位)到 64 位寄存器——位宽恰好匹配,不存在扩展问题。任何 64 位位模式在寄存器中如何解释(有符号/无符号)取决于后续使用它的指令(slt vs sltu,srai vs srli),而不是 Load 本身。因此 ldu 不存在——也不需要存在。
符号扩展 vs 零扩展:选错指令的 bug
# 内存 0x1000 处有一个字节 0xFE
li t0, 0x1000
lb t1, 0(t0) # t1 = 0xFFFF_FFFF_FFFF_FFFE(符号扩展,值 = -2)
lbu t2, 0(t0) # t2 = 0x0000_0000_0000_00FE(零扩展,值 = 254)同一个内存字节,lb 和 lbu 读到寄存器中的值完全不同。这不是 RISC-V 的独特设计——x86 的 movsx/movzx 系列、ARM 的 ldrsb/ldrb 系列都有同样区分。选错指令是汇编中最常见的静默 bug:
# 典型错误:用 lb 读取无符号数组元素
# char buf[256] → 如果用 lb 读,buf[128] 及以上(byte 值 0x80-0xFF)被符号扩展为负数
# 正确的做法是 lbuLoad 的寻址方式
RISC-V 的寻址只有一种模式:基址 + 偏移(base + displacement)。offset 是 12 位有符号立即数,范围 [-2048, 2047]:
ld t0, 0(t1) # t0 = mem[t1 + 0](偏移为 0)
ld t0, 8(t1) # t0 = mem[t1 + 8](向前偏移 8 字节)
ld t0, -8(t1) # t0 = mem[t1 - 8](向后偏移 8 字节)0(t1) 中偏移为 0 的模式非常常见——当基址寄存器已经指向目标地址时,偏移就是 0。
为什么只有一种寻址模式? x86 有基址+索引*比例+偏移的复杂寻址(如 mov rax, [rbx + rcx*8 + 0x100]),一条指令完成地址计算。RISC-V 选择分离:先手动计算地址到寄存器,再 ld。代价是多一条指令,收益是:译码器简单、地址计算延迟可预测、无超标量调度冲突。RISC 哲学再次体现——用简单指令的组合换取可预测的执行。
Store 指令族
Store 指令使用独立的 S-type 格式,opcode = 0100011。
RV64I Store 指令完整列表:
| 指令 | funct3 | 功能 |
|---|---|---|
sb rs2, offset(rs1) | 000 | 写低 1 字节 (byte) |
sh rs2, offset(rs1) | 001 | 写低 2 字节 (halfword) |
sw rs2, offset(rs1) | 010 | 写低 4 字节 (word) |
sd rs2, offset(rs1) | 011 | 写 8 字节 (doubleword) |
Store 指令没有符号/无符号之分。原因很简单:存到内存的是寄存器中原始的位模式——存什么 pattern 就是什么 pattern。"有符号"或"无符号"是 CPU 算出来的,存回内存时只有位模式这一个事实。sb 把 rs2 的低 8 位写入内存,至于这 8 位代表 -128 还是 255,是后续 Load 该关心的事。
S-type 格式的立即数拆分
S-type 与 I-type 最大的不同在于立即数字段的排列方式:
S-type: ┌───────┬───────┬───────┬─────┬───────┬─────────┐
│imm[11:5]│ rs2 │ rs1 │funct3│imm[4:0]│ opcode │
└───7────┴───5───┴───5───┴──3───┴───5────┴────7────┘
I-type: ┌────────────────────┬───────┬─────┬───────┬─────────┐
│ imm[11:0] │ rs1 │funct3│ rd │ opcode │
└─────────12─────────┴───5───┴──3───┴───5───┴────7────┘立即数被拆成 imm[11:5](高 7 位,放在 bit 31:25)和 imm[4:0](低 5 位,放在 bit 11:7)两部分。为什么不用整齐的 12 位连续排列?观察关键字段的位置:
| 字段 | R-type | I-type | S-type |
|---|---|---|---|
| opcode | [6:0] | [6:0] | [6:0] |
| rd | [11:7] | [11:7] | —(无 rd) |
| funct3 | [14:12] | [14:12] | [14:12] |
| rs1 | [19:15] | [19:15] | [19:15] |
| rs2 | [24:20] | —(立即数) | [24:20] |
在三种格式中,opcode、rs1、rs2、funct3 的位置完全相同。译码器在确定具体指令之前就可以并行读出这些字段,立即开始寄存器文件的读取。S-type 将立即数拆开填到"冗余"的位置(R-type 的 rd 位 + funct7 位),以保持字段对齐——这不是设计缺陷,而是硬件译码器的刻意优化。
寻址模式
RISC-V 只有一种寻址模式:基址 + 偏移。这是 Load-Store 架构中最简洁的选择——x86 那种"一条指令做太多事"的风格在 RISC 中不存在。
数组访问
# int64_t array[100] 的遍历
# a0 = 数组基地址, a1 = 元素数量
li t0, 0 # i = 0(循环索引)
mv t1, a0 # t1 = &array[0]
loop:
bge t0, a1, done # if i >= n → 结束
ld t2, 0(t1) # t2 = array[i]——当前元素值
# ... 对 t2 做运算 ...
addi t1, t1, 8 # 指针前进 8 字节(sizeof(int64_t))
addi t0, t0, 1 # i++
j loop
done:这里演示了指针算术和索引计数的双重迭代。指针 t1 每次 +8(元素大小),同时 t0 计数循环次数。两种方式等价,选择取决于上下文——指针方式避免乘法;索引+乘法方式当数组不是连续访问时更灵活。
栈帧访问
栈在 RISC-V ABI 中由 sp(x2)指向。局部变量通常通过 sp 的偏移访问:
# 函数序言:分配 32 字节栈帧
addi sp, sp, -32 # 栈向下增长 32 字节
# 访问栈上的局部变量
sd s0, 24(sp) # 保存 s0 到栈偏移 24 处
ld s0, 24(sp) # 从栈偏移 24 处恢复 s0
# 存储局部变量
sd t0, 16(sp) # 局部 var1 在 sp+16
sd t1, 8(sp) # 局部 var2 在 sp+8
# 函数尾声:回收栈帧
addi sp, sp, 32 # 栈指针恢复sd s0, 24(sp) 表示:取寄存器 s0 的 64 位值,写入内存地址 sp + 24。栈帧中的每个变量获得一个固定的 sp + offset 地址——汇编器不帮你记忆变量名字,你需要自行管理偏移量的分配。
遍历字符串(lb 的典型场景)
字符串在 C/Rust 中是 char 数组(每字符 1 字节),遍历时需要 lbu(无符号 byte load)逐个读取:
# 计算以空字符结尾的字符串长度(strlen)
# a0 = 字符串首地址,返回值在 t0
mv t0, x0 # len = 0
mv t1, a0 # t1 = 当前指针
strlen_loop:
lbu t2, 0(t1) # t2 = *t1(读一个字节,零扩展)
beqz t2, strlen_done # 遇到 '\0'(值为 0)→ 结束
addi t1, t1, 1 # 指针++
addi t0, t0, 1 # len++
j strlen_loop
strlen_done:
# t0 = 字符串长度使用 lbu 而非 lb 的原因:char 在 C 中何时有符号/无符号由实现决定。如果字符串包含高位为 1 的字节(如 UTF-8 编码的中文字符),lb 会将其符号扩展为负数,导致比较 beqz 前的扩展行为。虽然空字符 0x00 不受符号扩展影响(扩展后还是 0),但为确保正确处理无符号字符值,lbu 是正确的选择。
Load 与 Store 的协同模式
读 → 改 → 写(RMW 模式)
内存中变量的增量操作经典的三步走:
# counter++(内存中的 64 位计数器)
# t0 = 计数器内存地址
ld t1, 0(t0) # 1. 从内存加载
addi t1, t1, 1 # 2. 寄存器中增量
sd t1, 0(t0) # 3. 存回内存这是非原子操作——两条指令之间有中断/线程切换的风险。RISC-V A 扩展(原子操作)提供 amoadd.d 等指令来原子化 RMW。但在单线程或无竞争的场景下,load-add-store 就是内存变量的标准操作。
不同宽度的 Load/Store 组合
读取窄数据 → 在 64 位寄存器中处理 → 存回窄宽度:
# 将数组中的每个字节加 1(模拟 toupper 风格操作)
# a0 = 数组基地址, a1 = 长度
li t2, 0 # i = 0
byte_loop:
bge t2, a1, byte_done
add t3, a0, t2 # t3 = &array[i]
lbu t4, 0(t3) # t4 = array[i](零扩展至 64 位)
addi t4, t4, 1 # t4 = t4 + 1(运算在 64 位寄存器中完成)
sb t4, 0(t3) # 只存回低 8 位
addi t2, t2, 1
j byte_loop
byte_done:lbu 将字节值零扩展至 64 位,加法在 64 位 ALU 中进行,sb 只将低 8 位写入内存。超过 255 的值用 sb 存储时高位被截断——如果需要饱和加法(>255 时钳位到 255),需额外判断。
数据块的复制
# memcpy(dest, src, n)
# a0 = 目标地址, a1 = 源地址, a2 = 字节数
mv t0, a0 # 保存 dest 原始值(用于返回)
# 先按 8 字节块复制
srli t1, a2, 3 # t1 = n / 8(8 字节块数)
beqz t1, copy_tail # 不足 8 字节直接跳尾部
copy_8:
ld t2, 0(a1) # 读 8 字节
sd t2, 0(a0) # 写 8 字节
addi a0, a0, 8
addi a1, a1, 8
addi t1, t1, -1
bnez t1, copy_8
copy_tail:
# 处理剩余不足 8 字节的部分(逐字节复制)
andi t1, a2, 0x7 # t1 = n % 8
beqz t1, copy_done
copy_byte:
lbu t2, 0(a1)
sb t2, 0(a0)
addi a0, a0, 1
addi a1, a1, 1
addi t1, t1, -1
bnez t1, copy_byte
copy_done:
mv a0, t0 # 返回 dest 原始指针实际 memcpy 实现会进一步使用更大的块、对齐检查、甚至向量指令。但上述代码展示了 Load/Store 的核心模式:按不同宽度分段处理以最大化吞吐量。
端序与 Load/Store 的实际表现
RISC-V 默认小端序。sd 将 64 位寄存器的字节 0(最低有效字节)存到最低地址,字节 7(最高有效字节)存到最高地址:
# 观察小端序存储
li t0, 0x1000 # 基地址
li t1, 0x0102030405060708 # 测试值
sd t1, 0(t0) # 存入内存
# 逐字节读回验证端序
lb t2, 0(t0) # t2 = 0x08(LSB 在最低地址)
lb t3, 1(t0) # t3 = 0x07
lb t4, 2(t0) # t4 = 0x06
lb t5, 7(t0) # t5 = 0x01(MSB 在最高地址)lb 有符号扩展,读回的值可能是负数——如果字节最高位为 1。用 lbu 可以得到 0-255 的无符号 val。
跨端序系统通信:如果你的 RISC-V 代码需要与大端序设备或网络协议交互,必须在 Load 后/Store 前做字节序转换。Linux 内核提供了 cpu_to_be64/be64_to_cpu 等宏,其底层就是字节交换指令(RV32 的 Zbb 扩展有 rev8 字节反转指令,RV64 也有对等变体)。
对齐要求
RISC-V 规范对 Load/Store 指令的地址对齐有要求。理论上:
ld/sd需要 8 字节对齐(地址 mod 8 == 0)lw/sw需要 4 字节对齐lh/sh需要 2 字节对齐lb/sb无对齐要求(始终合法)
但是,RISC-V 规范允许实现支持硬件非对齐访问。Linux-capable RISC-V 处理器通常支持非对齐的 ld/sd——但会付出性能代价(可能需要两次内存访问 + 拼接)。对于汇编程序员,除非确实需要处理非对齐数据(如解析网络包),否则始终保持数据自然对齐是最佳实践。
本章要点
- Load 指令族(opcode=0000011)按宽度分为 ld/lw/lh/lb,带
u后缀的做零扩展、不带u的做符号扩展——选错扩展方式是常见 bug - Store 指令族(opcode=0100011)无符号/无符号之分——存储的是寄存器中原始位模式
- RISC-V 只有基址+偏移一种寻址模式,简洁统一;数组、栈帧、字符串遍历均基于此
- S-type 的立即数拆分为 imm[11:5] 和 imm[4:0] 是为了保持 rs1/rs2/funct3 字段在 R/I/S 三种格式中位置一致,简化译码器硬件
- RISC-V 默认小端序,低字节在低地址;非对齐访问允许但可能损失性能