15. A 扩展:原子操作指令
单核时代,load → add → store 是流畅的。多核时代,两个 hart 同时执行同一条"读-改-写"逻辑,结果可能是一场灾难——两个 hart 都读到 0,都加 1,都写回 1;最终计数应该是 2,实际却是 1。A 扩展(Atomic Instructions)为此而生:提供硬件保证的不可分割操作,使多核并发编程成为可能。
原子操作的意义
问题:丢失的更新
# 两个 hart 同时执行这段代码,各调度一次:
# 初始值:counter = 0
# Hart A # Hart B
ld t0, 0(s0) # t0 = 0 ld t0, 0(s0) # t0 = 0
addi t0, t0, 1 # t0 = 1 addi t0, t0, 1 # t0 = 1
sd t0, 0(s0) # counter = 1 sd t0, 0(s0) # counter = 1 (!)
# 最终 counter = 1,应该是 2这就是 read-modify-write 竞态(RMW race)。任何共享变量的读-改-写如果不被保护,在多核环境中都是不确定行为。保护方式有两种路线:
- 锁机制:临界区加锁,保证同一时刻只有一个 hart 进入——LR/SC 指令族
- 无锁机制:将读-改-写本身原子化——AMO 指令族
A 扩展同时提供这两种路线的硬件原语。它的设计范围包括:LR(Load-Reserved)、SC(Store-Conditional)、AMO(Atomic Memory Operation)、以及配套的 FENCE 内存屏障。
LR/SC:Load-Reserved / Store-Conditional
基本原理
LR 和 SC 是一对协作指令,共同实现"如果自上次 LR 以来地址未被修改,则写入成功"的语义:
- LR:从地址加载值到寄存器,同时在该地址上设置一个硬件"保留标记"(reservation)
- SC:检查保留标记是否仍然有效。如果有效则将新值写入地址,并将 rd 设为 0(成功);否则不执行写入,rd 设为非 0(失败)
保留标记在以下条件中被清除:另一 hart 向保留地址写入、本 hart 的任何 store、或发生异常/中断。
lr.w rd, (rs1) # rd = MEM[rs1]; 对地址 rs1 设置保留标记
lr.d rd, (rs1) # 64 位版本
sc.w rd, rs2, (rs1) # if (保留标记有效) { MEM[rs1] = rs2; rd = 0; }
# else { rd = 非零; }
sc.d rd, rs2, (rs1) # 64 位版本LR 和 SC 的 opcode 均为 0101111(AMO 操作码)。LR 的 funct5=00010,SC 的 funct5=00011;funct3 指示宽度:010 为 .w(32 位),011 为 .d(64 位)。
自旋锁:完整实现
自旋锁是 LR/SC 的最经典应用——一个 hart 通过原子操作获取锁,其他 hart 忙等。
# ---- 获取自旋锁 ----
# a0 = 锁变量的地址
spin_lock:
lr.w t0, (a0) # 原子读锁值,设保留
bnez t0, spin_lock # 若锁已被持有(t0!=0),忙等重试
li t1, 1
sc.w t1, t1, (a0) # 尝试原子写 1 到锁地址
bnez t1, spin_lock # SC 失败?重试(保留标记被清除)
# 锁获取成功,进入临界区
# ---- 释放自旋锁 ----
# a0 = 锁变量的地址
spin_unlock:
sw x0, (a0) # 直接写 0 释放锁(不需要 SC——释放者是唯一的)
ret几点关键观察:
lr.w t0, (a0)后的bnez t0, spin_lock:如果锁已被持有,不尝试 SC——这样避免不必要的写流量- SC 后的
bnez t1, spin_lock:即使 LR 读到 0、看起来锁自由,SC 仍可能失败——因为有另一个 hart 在我们的 LR 和 SC 之间也执行了写操作,清除了我们的保留标记 - 释放用
sw x0, (a0)而非 SC:只有锁持有者释放锁——这是已知的单写者,不需要原子条件保护
Compare-and-Swap(CAS)实现
自旋锁是可串行化保护的一种方式,但无锁数据结构(如无锁栈、无锁队列)需要 CAS——比较并交换:
# CAS: 如果 MEM[a0] == a1,则 MEM[a0] = a2;返回旧值
# 参数: a0=地址, a1=期望值, a2=新值
# 返回: a0=0 成功, a0=1 失败
cas:
lr.w t0, (a0) # t0 = MEM[a0](旧值),设保留
bne t0, a1, cas_fail # 旧值 ≠ 期望值 → 失败
sc.w t0, a2, (a0) # 尝试写入 a2
bnez t0, cas # SC 失败 → 重试
li a0, 0 # 成功返回 0
ret
cas_fail:
li a0, 1 # 失败返回 1
ret这个 CAS 实现在 4 条指令内完成(不含 ret)——它的紧凑性使得编译器愿意将 CAS 内联到调用点,避免了函数调用开销。
关键边界:LR/SC 对之间的限制
RISC-V 规范对 LR 和 SC 之间的指令有严格限制:
- 只能执行基础整数指令(不能有 load/store、不能有跳转、不能有系统指令)
- 最大指令数由实现在
mcontextCSR 中约束(通常 16 条以内) - 违反上述限制会导致 SC 保证失败
规范这样写是为了避免硬件实现负担——LR/SC 间的指令越多,跟踪保留标记一致性的难度呈指数增长。实际使用中,LR 和 SC 之间通常只有 2-4 条指令(比较+分支),远在上述限制内。
AMO 指令族
AMO(Atomic Memory Operation)将"读-修改-写"封装为单条指令——硬件保证其原子性和(通常的)内存顺序。
指令总览
| 指令 | funct5 | 语义 |
|---|---|---|
amoswap.w/d | 00001 | 原子交换:MEM = rs2,rd = 旧值 |
amoadd.w/d | 00000 | 原子加:MEM += rs2,rd = 旧值 |
amoxor.w/d | 00100 | 原子异或:MEM ^= rs2,rd = 旧值 |
amoand.w/d | 01100 | 原子与:MEM &= rs2,rd = 旧值 |
amoor.w/d | 01000 | 原子或:MEM |= rs2,rd = 旧值 |
amomin.w/d | 10000 | 有符号最小值:MEM = min(MEM, rs2),rd = 旧值 |
amomax.w/d | 10100 | 有符号最大值:MEM = max(MEM, rs2),rd = 旧值 |
amominu.w/d | 11000 | 无符号最小值 |
amomaxu.w/d | 11100 | 无符号最大值 |
所有 AMO 指令格式统一:amo<op>.<width> rd, rs2, (rs1)——从 (rs1) 读取旧值到 rd,将操作(旧值 op rs2)的结果写回 (rs1)。opcode 均为 0101111,funct3 区分宽度(010=.w, 011=.d)。
无锁计数器
计数器是 AMO 的最简单也是最高频应用:
# 原子递增计数器
# a0 = 计数器地址
atomic_inc:
amoadd.w t0, a1, (a0) # t0 = 旧值, MEM[a0] += 1
ret # 调用者可选检查 t0(旧值)对比 LR/SC 版本需要 5-6 条指令,amoadd 一条指令完成——不仅代码更短,而且 pipeline 不会因 LR/SC 的重试循环而造成不可预测的延迟。
无锁累加器(多生产者)
# 多个 hart 向共享累加器添加数据
# a0 = 累加器地址, a1 = 要增加的值
atomic_accumulate:
amoadd.d x0, a1, (a0) # 原子加 a1 到 MEM[a0],丢弃旧值(写入 x0)
ret # 无返回值,纯副作用注意这里 rd = x0——我们只关心加法的副作用(更新累加器的值),对旧值不感兴趣。向 x0 写这是一种高效的值丢弃方式。
无锁标志位设置
# 原子设置标志位
# a0 = 标志地址, a1 = 要设置的位掩码
atomic_set_flags:
amoor.w x0, a1, (a0) # 原子 OR 掩码到标志字,丢弃旧值
ret
# 原子清除标志位
atomic_clear_flags:
not t0, a1 # t0 = ~a1(清除掩码)
amoor.w x0, t0, (a0) # 等等——OR 不能清除位,应使用 AMOAND
ret等等——上例中清除标志的正确做法是 amoand,而非 amoor:
atomic_clear_flags:
not t0, a1 # t0 = ~a1
amoand.w x0, t0, (a0) # 原子 AND (~mask),清除指定位
ret这个例子的要点是:选择 AMO 操作类型时需仔细匹配所需的修改语义。
内存一致性模型
RVWMO 概述
RISC-V 采用 RVWMO(RISC-V Weak Memory Ordering)——一个宽松内存一致性模型。其核心规则:
- 同 hart 内的 load→load、store→store、load→store、store→load 地址依赖链保持顺序(同一 hart 看起来总是顺序执行的)
- 不同 hart 之间的 load 和 store 可见顺序不保证一致——除非显式指定顺序约束
- AMO 和 LR/SC 固有 acquire 和/或 release 语义
FENCE 指令
fence 是汇编程序员强制执行内存顺序的工具:
fence iorw, iorw # 最严格的 fence:所有前序访存完成后,后续访存才开始
fence r, w # 仅保证前序读完成后后续写才开始
fence w, r # 仅保证前序写完成后后续读才开始
fence rw, rw # 前后所有读写操作顺序化
fence # 默认等价于 fence iorw, iorw前面的四字符表示前序操作集,后面的四字符表示后序操作集。每四个字符依次代表:i(输入设备)、o(输出设备)、r(读)、w(写)。
# 自旋锁释放中常见模式
# 临界区操作...
fence rw, w # 保证临界区内的所有读写在锁释放前完成
sw x0, 0(s0) # 释放锁.aq / .rl 后缀:更细腻的顺序控制
AMO 和 LR/SC 指令支持 .aq(acquire)和 .rl(release)后缀,提供比 fence 更精确的顺序约束:
lr.w.aq t0, (a0) # LR with acquire: 后续访存不会重排到此 LR 之前
sc.w.rl t0, t1, (a0) # SC with release: 前序访存不会重排到此 SC 之后
amoswap.w.aqrl t0, t1, (a0) # 同时 acquire + release(顺序一致性语义)不带后缀的 AMO/LR/SC 只保证原子性,不保证对其他访存的顺序约束。.aq/.rl 让程序员在需要时精确地"拧紧"顺序,而非对所有原子操作默认为最严格的顺序一致性——这是 RISC-V 在性能与可编程性之间的精妙平衡。
FENCE.I:指令同步
fence.i # 指令 fence:保证之前的 store 对后续取指可见当程序修改了自己的代码(JIT 编译、动态代码生成),必须在修改后、跳转到新代码前执行 fence.i。这在用户态汇编程序中少见,但对理解底层指令与数据的一致性至关重要。
完整自旋锁(含顺序约束)
# ---- 获取锁(带 acquire 语义)----
# a0 = 锁地址
acquire_lock:
lr.w.aq t0, (a0) # acquire: 临界区访存不会重排到此 LR 之前
bnez t0, acquire_lock # 锁已被持,自旋
li t1, 1
sc.w t1, t1, (a0) # 尝试获取
bnez t1, acquire_lock # 失败,重试
# 进入临界区
# ---- 释放锁(带 release 语义)----
# a0 = 锁地址
release_lock:
fence rw, w # 先 fence: 临界区内所有读写完成
sw x0, (a0) # 再释放锁
ret这里的 .aq 后缀保证了关键的不变量:进入临界区后的任何访存不会被重排到 lr.w 之前——这意味着当 hart 看到锁已被我持有时,它一定也已经看到了锁持有者之前对临界区数据的修改。
本章要点
- A 扩展提供 LR/SC(自旋锁、CAS)+ AMO(无锁计数、无锁数据结构)两类原子操作原语
- LR 加载值并设保留标记,SC 仅在保留标记有效时写入——RLR(retry loop)是实现自旋锁的标准模式
- AMO 将读-修改-写原子化为单条指令(amoswap/amoadd/amoxor/amoand/amoor/amomin/amomax),opcode=
0101111,funct5 区分 9 种操作 - RVWMO 是宽松内存一致性模型;
.aq/.rl后缀提供精确的 acquire/release 顺序控制,fence指令是兜底的显式屏障 - LR 与 SC 之间的指令受严格限制(无 load/store/跳转,通常 <16 条指令)——保持这对指令间的序列短小是正确性的基本前提