The 3 key points of this post
- The A extension provides two kinds of atomic instructions:
lr.w/sc.w(Zalrsc), which set a reservation and store conditionally, and nine AMOs (Zaamo), which do read-compute-write in a single instruction. Each instruction'saqandrlbits set its ordering relative to the memory accesses before and after it. - On QEMU, with 2, 4, and 8 harts each incrementing a counter 100,000 times, plain
lw/addi/swkept only 50%, 27%, and 22% of the increments, whileamoadd.w, LR/SC, and spinlocks were exact in all 3 repetitions. With 8 harts, LR/SC saw 0.98sc.wfailures per increment. - A misaligned
amoadd.wraised an mcause 6 exception, andlr.wraised mcause 4. On the other hand, QEMU letsc.wsucceed even when another hart wrote back the same value, a case where the specification requires failure. This is something to watch for when using QEMU as a reference model.
Up to part 7, we were in a world where a single hart (hardware thread) executes instructions one after another. Once two or more harts share the same memory, a new problem appears: another hart can slip in between "read, add 1, write." The A extension adds atomic instructions that solve this problem. In this part I check the meaning and rules of the instructions on QEMU and actually make several harts race. How reservations are tracked in hardware is covered in part 20 (implementing the A extension), and the precise definition of the ordering rules in part 9 (the memory model).
The machine is a Mac M1 Max (10 cores), the QEMU 11.1.3 virt board, and riscv64-elf-gcc 16.2. QEMU uses a separate host thread for each hart (MTTCG), so multiple harts really run at the same time.
1. Why atomic instructions are needed
Suppose two harts increment a shared counter. Written with RV32I alone, it looks like this.
lw t0, 0(a0) # read
addi t0, t0, 1 # add
sw t0, 0(a0) # write
If both harts do lw at the same time while the counter is 5, both read 5 and both write 6. One of the two increments is lost. In practice, with 2 harts each incrementing 100,000 times on QEMU, only 100,686, 100,243, and 100,109 of the expected 200,000 remained (3 runs). Almost half were lost.
There are two directions for a fix. One is to make the read-modify-write a single instruction so it cannot be split on the memory side (AMO). The other is to leave a mark when reading and, when writing, give up and retry if anyone touched the location in between (LR/SC). RISC-V provides both.
LR/SC is split out as the Zalrsc extension (lr.w, sc.w, plus .d on RV64) and the nine AMOs as the Zaamo extension; A is the two combined. The former handles arbitrary read-modify-write and CAS (compare-and-swap), and the latter handles fixed operations such as counters, swaps, bit sets, and locks.
The A extension was split into the two sub-extensions in ratified version 2.1. The specification describes a Zaamo-only configuration as intended for "microcontrollers that lack caches and so find it hard to support LR/SC naturally."
2. Encoding: opcode AMO, funct5, and aq/rl
Every A instruction uses a variant of the R format. The opcode is AMO (0101111), and funct3 gives the size (010 = word, 011 = doubleword). In the funct7 position, the upper 5 bits (funct5) hold the operation and the lower 2 bits hold aq (bit 26) and rl (bit 25). The values below were extracted from machine code generated by GNU as.
| funct5 | Instruction | funct5 | Instruction |
|---|---|---|---|
| 00010 | lr |
01100 | amoand |
| 00011 | sc |
01000 | amoor |
| 00001 | amoswap |
10000 | amomin |
| 00000 | amoadd |
10100 | amomax |
| 00100 | amoxor |
11000 | amominu |
| 11100 | amomaxu |
lr does not use the rs2 field (it must be 0). In assembly, the two bits are turned on with suffixes, as in amoadd.w.aq, amoadd.w.rl, and amoadd.w.aqrl. In the machine code I checked, .aq set only bit 26, .rl only bit 25, and .aqrl both.
3. LR/SC: set a reservation, store conditionally
lr.w rd, (rs1) (load-reserved) reads a word into rd and registers a reservation set containing that address. sc.w rd, rs2, (rs1) (store-conditional) writes rs2 only if the reservation is still valid and the address is within the reservation set. On success it puts 0 in rd; on failure it puts a nonzero value and leaves memory untouched. Whether it succeeds or fails, executing sc clears that hart's reservation.

Only the failure code 1 is currently defined ("unspecified reason"). The specification explains that 1 was chosen "so that a simple implementation can produce it with the mux that already exists for slt/sltu." Portable software should only check whether the value is zero or not.
The heart of the design is when sc must fail and when it may fail. Summarizing the specification's rules:
| Situation | sc |
|---|---|
Address is outside the reservation set of the most recent lr |
Must fail |
| Another hart stored to the reservation set in between | Must fail |
Another device wrote to the bytes accessed by lr in between |
Must fail |
There is another sc between lr and sc |
Must fail |
| The same hart stored in between | May succeed or fail |
| For no reason at all | May fail (within the forward-progress guarantee) |
I ran each case on QEMU with a single hart (sc result, memory).
sc only (no lr) -> 1, memory unchanged
lr; sc -> 0, written
lr; sc; sc (second) -> 1
lr x; sc y (different addr) -> 1
lr; sw 99; sc -> 1, memory 99
lr; sw same value; sc -> 0, written
lr; lw other addr; sc -> 0, written
A store from the same hart is left to the implementation by the specification, so neither of QEMU's two results (fail for a different value, succeed for the same value) violates it. But running the same test with 2 harts revealed a problem. Hart 0 read 5 with lr.w, then hart 1 wrote to the same address, and then hart 0 did sc.w.
Hart 1 wrote Hart 0's sc.w Spec
9 (diff value) 1 (fail) fail
5 (same value) 0 (success) must fail
9 → 5 (ABA) 0 (success) must fail
QEMU emulates reservations not by address but by value comparison. Looking at the QEMU source (gen_sc in target/riscv/tcg/insn_trans/trans_rva.c.inc), sc.w is translated into a compare-and-swap that "stores if the current value equals the value LR read." So when another hart writes back the same value (ABA), it doesn't notice. The very first reason the specification gives for choosing LR/SC over CAS is that "LR/SC monitors every write to the address, so it has no ABA problem." When comparing RTL against QEMU in lockstep (part 33), keep in mind that in this case the RTL's sc failure is the correct answer, yet it will differ from QEMU.
Forward progress. With LR/SC, harts can keep breaking each other's reservations so that no one ever finishes, a livelock. The specification guarantees that someone eventually succeeds only for a "constrained LR/SC loop." The conditions are that the whole loop is at most 16 consecutive instructions, only base I instructions appear between lr and sc (excluding loads/stores, backward branches, jalr, fence, and SYSTEM), and the sc uses the same address and size as the preceding lr. Any LR/SC sequence outside these rules may fail forever on some implementation. Multiplication, division, and floating point are also forbidden. The specification explains this is to simplify the operating system's job of emulating those instructions on implementations that lack the hardware.
4. The nine AMOs: read, compute, and write in one go
An AMO amoop.w rd, rs2, (rs1) reads the original value at memory [rs1] into rd, and writes the result of operating on that value and rs2 to the same address. To other harts, these three steps appear as one indivisible action. I ran all nine with memory 0x80000005 (negative when read as signed) and rs2 = 3. Every AMO's rd was the original value 0x80000005, and the values left in memory were as follows.
| Instruction | Computation | Memory result |
|---|---|---|
amoswap.w |
rs2 | 0x00000003 |
amoadd.w |
m + rs2 | 0x80000008 |
amoand.w |
m & rs2 | 0x00000001 |
amoor.w |
m | rs2 | 0x80000007 |
amoxor.w |
m ^ rs2 | 0x80000006 |
amomax.w |
Signed maximum | 0x00000003 |
amomin.w |
Signed minimum | 0x80000005 |
amomaxu.w |
Unsigned maximum | 0x80000005 |
amominu.w |
Unsigned minimum | 0x00000003 |
Note that max and maxu gave opposite answers. 0x80000005 read as signed is −2,147,483,643, which is less than 3; read as unsigned it is 2,147,483,653, which is greater than 3. I also ran all nine with memory 0x0c and rs2 = −6 (0xfffffffa), compared all 18 against a Python reference model, and all 18 matched.
To discard the result, set rd to x0. The specification states that in this case the implementation may skip fetching the original value. This is what makes "far atomics," where the AMO executes in the memory controller or a shared cache, possible. Conversely, a simple implementation may build AMOs internally the LR/SC way (as long as completion is guaranteed).
Alignment. AMOs and LR/SC require addresses aligned to their size (a multiple of 4 for words). Otherwise, an address-misaligned exception or an access-fault exception occurs. I tested on QEMU with address 0x800006a2 (a multiple of 4 + 2).
amoadd.w (addr+2) -> trap, mcause 6, mtval 0x800006a2
lr.w (addr+2) -> trap, mcause 4, mtval 0x800006a2
lw (addr+2) -> no trap (handled by QEMU)
mcause 6 is "store/AMO address misaligned" and 4 is "load address misaligned." An AMO includes a write, so it uses the store-side cause number, and lr uses the load side. As seen in part 4, an implementation may handle a misaligned access by an ordinary lw, but there is no good way to emulate atomic instructions, so an exception is the rule. As an exception, the specification provides a PMA that relaxes alignment (the misaligned atomicity granule, Privileged spec), but it applies only to AMOs, not to LR/SC.
5. aq/rl: ordering bits that block only one direction
It is not enough for an atomic instruction to be atomic by itself. Suppose you take a lock, modify data in the critical section, and release the lock. The moment another hart sees "the lock is free," the writes from the critical section must also be visible. RISC-V's base memory model (RVWMO) allows accesses to different addresses from the same hart to appear reordered to other harts, so ordering has to be specified separately.
| Bit | Name | Meaning |
|---|---|---|
| aq | Acquire | Later memory accesses are not seen before this instruction |
| rl | Release | Earlier memory accesses are not seen after this instruction |
| aq+rl | Sequentially consistent | No reordering in either direction |

As the figure shows, aq blocks only crossings from below to above, and rl only crossings from above to below. Accesses outside the lock are allowed to move into the critical section. This is why they are cheaper than fence. The specification explains that acquire could also be built with fence r,rw and release with fence rw,w, but that would enforce unnecessary ordering too. The ordering applies only within the same address domain (memory or I/O); ordering across the two domains is specified with fence.
For an LR/SC pair, the basic form puts aq on lr and rl on sc. The specification says not to use lr.rl (without aq) or sc.aq (without rl): they can only be slower without guaranteeing stronger ordering. The specification's spinlock example looks like this, and this post's hands-on uses the same shape.
li t0, 1
again:
lw t1, (a0) # check first whether it's held
bnez t1, again
amoswap.w.aq t1, t0, (a0) # try to acquire
bnez t1, again
# ... critical section ...
amoswap.w.rl x0, x0, (a0) # write 0 to release
Checking first with lw and doing amoswap only when the lock is free is called test-and-test-and-set. Since it does not repeat the AMO (a write) while the lock is held, the cache line bounces between harts less.
6. Hands-on: counter race with 1 to 8 harts
The startup code in common/ assumes a single hart, so I copied it into my own folder (08/smp/) and modified it. At startup, it reads mhartid, sets up a separate 4 KiB stack for each hart, and calls main(hartid). I first confirmed that under QEMU -bios none -smp N, every hart starts from the same entry point.
Using five methods, each hart incremented a shared counter 100,000 times. Between methods, a barrier built with amoadd.w lined up all harts so they started together.
static void inc_amo(void) {
__asm__ volatile("amoadd.w zero, %1, (%0)"
:: "r"(&counter), "r"(1) : "memory");
}
static uint32_t inc_lrsc(void) { /* number of sc failures */
uint32_t t, f = 0;
__asm__ volatile(
"1: lr.w %0, (%2)\n addi %0, %0, 1\n"
" sc.w %0, %0, (%2)\n beqz %0, 2f\n"
" addi %1, %1, 1\n j 1b\n 2:"
: "=&r"(t), "+r"(f) : "r"(&counter) : "memory");
return f;
}
First, with 1 hart, I counted the instructions per iteration with QEMU -icount (including loop control). With no contention, all five methods gave exactly 100,000.
| Method | Core sequence | Instructions per iteration |
|---|---|---|
lw/addi/sw |
3 | 5.0 |
amoadd.w |
1 | 3.0 |
| LR/SC loop | 4 (lr addi sc beqz) |
8.0 |
| AMO spinlock | Acquire 4 + increment 3 + release 1 | 12.0 |
| LR/SC spinlock | Acquire 4 + increment 3 + release 1 | 12.0 |
The 8 instructions of the LR/SC loop include 2 that count failures. For incrementing a single counter, the AMO is the shortest. A lock pays off when the critical section is long and several pieces of data are modified together.
Then I increased the number of harts. Each configuration was run 3 times.

| Harts | Expected | lw/addi/sw result (3 runs) |
4 atomic methods |
|---|---|---|---|
| 2 | 200,000 | 100,686 / 100,243 / 100,109 | All exact |
| 4 | 400,000 | 111,473 / 101,414 / 106,797 | All exact |
| 8 | 800,000 | 203,637 / 156,962 / 163,550 | All exact |
Non-atomic increments lost more as harts increased, and the value differed every time. The four atomic methods (amoadd.w, LR/SC, and the two spinlocks) matched the expected value in all 36 runs (3 configurations × 3 runs × 4 methods). LR/SC sc.w failures were 170,295, 166,215, and 195,313 with 2 harts, and 793,259, 775,984, and 793,356 with 8. That is almost one failure per increment. The failure ratio depends on QEMU's thread scheduling, so it should not be read as real-chip numbers. Still, the structural difference holds: the heavier the contention, the more LR/SC retries, while an AMO finishes in one go without retries. The number of spins while waiting for the lock in the spinlocks was 110 to 160 million in all 3 runs, even with just 2 harts (the rest spin while the one hart holding the lock works).
Providing Zaamo and Zalrsc separately. I compiled the same C atomic operations with three -march settings.
rv32ia rv32i_zaamo rv32i_zalrsc
fetch_add amoadd.w amoadd.w LR/SC loop
CAS LR/SC loop library LR/SC loop
With only Zalrsc, GCC expanded fetch_add into an LR/SC loop. With only Zaamo, there is no instruction to build CAS with, so it left a call to __atomic_compare_exchange_4. On bare metal, this function has to be provided separately for linking to succeed. This means a core that will run code needing CAS (most lock-free data structures) needs Zalrsc.
7. Design checklist: the A extension
| Item | Rule |
|---|---|
| Decode | opcode AMO (0101111), funct3 010/011, 11 funct5 values. lr has rs2 = 0 |
sc result |
0 on success, 1 on failure (the only failure code currently defined). No memory write on failure |
| Reservation release | Executing sc clears that hart's reservation, whether it succeeds or fails |
| Reservation set size | Up to the implementation (a word or more). Common choice: one cache line. The platform must advertise it |
| Store from another hart | If it touches the reservation set, sc must fail. Value comparison alone misses ABA |
| Store from the same hart | sc may succeed or fail (implementation choice) |
| Forward progress | Constrained loops of 16 instructions or fewer must eventually succeed. Don't let reservations be stolen indefinitely |
| Alignment | If misaligned, AMOs and sc raise mcause 6 (or 7), lr raises 4 (or 5). No emulation by default |
Failed sc and PMP |
Memory protection may be checked as for a store |
AMO rd=x0 |
Reading the original value may be skipped (far atomics) |
| aq/rl | aq: later accesses don't move ahead; rl: earlier accesses don't fall behind. Only within the same address domain |
RV64 .w |
Sign-extended into rd, upper 32 bits of rs2 ignored |
| Context switch | The operating system forcibly clears the reservation by doing sc to a dummy address |
What the last row means for hardware: each hart has only one reservation, and sc always clears it, so a circuit to clear it separately in the trap handler is not strictly necessary. Software clears it with sc on a context switch. Where to keep the reservation (a register set inside the core or cache line state) and how to detect stores from other harts (cache coherence invalidation messages) are covered in part 20.
Summary
- A extension = Zalrsc (
lr/sc) + Zaamo (nine AMOs). Every instruction uses opcodeAMO, selects the operation with funct5, and selectsaq/rlwith bits 26 and 25. scwrites only while the reservation is alive and returns 0, or returns 1 on failure. A store from another hart must make it fail even if the value is the same, but QEMU let it succeed after same-value and ABA stores.- 18 AMOs matched the reference model, and misaligned AMO and LR raised mcause 6 and 4 exceptions. In the 8-hart race, plain increments kept only 22% while all four atomic methods were exact.
- aq keeps later accesses from moving ahead, and rl keeps earlier accesses from falling behind. A lock is acquired with
amoswap.w.aqand released withamoswap.w.rl.
Next post: Part 9 tackles head-on the question deferred in this part: "in what order do one hart's accesses appear to other harts?" It covers RISC-V's memory model RVWMO, the pred/succ combinations of fence, the relationship between aq/rl and fence, and Ztso, which provides x86-like strong ordering. I count for myself which outcomes are allowed in litmus tests (MP, SB, LB).
References
-
gen_scin the QEMU sourcetarget/riscv/tcg/insn_trans/trans_rva.c.inc. https://github.com/qemu/qemu -
The RISC-V Instruction Set Manual, Volume I: Unprivileged Architecture, Version 20240411. Chapter 14, "A" Extension v2.1 (14.1 Specifying Ordering, 14.2 Zalrsc, 14.3 Eventual Success of Store-Conditional, 14.4 Zaamo). https://github.com/riscv/riscv-isa-manual
-
The RISC-V Instruction Set Manual, Volume II: Privileged Architecture, Version 20240411. 3.1.15 mcause (exception codes 4 and 6), 3.6 PMA (misaligned atomicity granule)
-
D. Patterson, A. Waterman, The RISC-V Reader: An Open Architecture Atlas, 2017. Chapter 6, RV32A
-
K. Gharachorloo et al., "Memory Consistency and Event Ordering in Scalable Shared-Memory Multiprocessors," ISCA 1990 (release consistency)
-
QEMU multi-threaded TCG (MTTCG) documentation. https://www.qemu.org/docs/master/devel/multi-thread-tcg.html
