Skip to content
Published at:

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 sltusrai vs srli),而不是 Load 本身。因此 ldu 不存在——也不需要存在。

符号扩展 vs 零扩展:选错指令的 bug

asm
# 内存 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)

同一个内存字节,lblbu 读到寄存器中的值完全不同。这不是 RISC-V 的独特设计——x86 的 movsx/movzx 系列、ARM 的 ldrsb/ldrb 系列都有同样区分。选错指令是汇编中最常见的静默 bug:

asm
# 典型错误:用 lb 读取无符号数组元素
# char buf[256] → 如果用 lb 读,buf[128] 及以上(byte 值 0x80-0xFF)被符号扩展为负数
# 正确的做法是 lbu

Load 的寻址方式

RISC-V 的寻址只有一种模式:基址 + 偏移(base + displacement)。offset 是 12 位有符号立即数,范围 [-2048, 2047]:

asm
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-typeI-typeS-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 中不存在。

数组访问

asm
# 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 的偏移访问:

asm
# 函数序言:分配 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)逐个读取:

asm
# 计算以空字符结尾的字符串长度(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 模式)

内存中变量的增量操作经典的三步走:

asm
# 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 位寄存器中处理 → 存回窄宽度:

asm
# 将数组中的每个字节加 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),需额外判断。

数据块的复制

asm
# 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(最高有效字节)存到最高地址:

asm
# 观察小端序存储
    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 默认小端序,低字节在低地址;非对齐访问允许但可能损失性能