Visual walkthrough — RISC-V extensions (M, A, F, D, V, C)
The parent note told you the A extension makes a "compare-and-swap" happen in one hardware instruction, using lr.w and sc.w. But why does that pair of instructions guarantee no update is ever lost? This page builds the whole story from absolute zero — two people, one shared number, and a rule that stops them stepping on each other.
Everything here rests on the base ISA (plain loads and stores) and quietly borrows the tracking machinery of cache coherence. We'll build both as we go.
Step 1 — The shared number and the two workers
WHAT. We have one memory location and two cores. A core is just one independent little machine that runs instructions. Memory is a row of numbered boxes; counter lives in one box.
WHY. Before we can talk about atomic operations we must see the thing they protect: a value that more than one worker touches. If only one worker ever existed, plain load/store would be perfectly safe and this whole extension would be pointless.
PICTURE. The teal box is the shared counter in memory. The two burnt-orange figures are Core A and Core B. Each has a private notepad (a register — a tiny scratch box inside the core). Right now the number is 7.
Step 2 — Watch the race break it (the plain load/store way)
WHAT. Both cores run the naïve sequence with ordinary base-ISA instructions:
Here lw = load word (copy the memory box into a register), addi = add immediate (register + a fixed number), sw = store word (copy the register back into the box). a0 holds the address of counter; the parentheses (a0) mean "the box that address points to."
WHY. We must see the disease before we prescribe the cure. The danger is timing: what if the two workers interleave?
PICTURE. Time runs downward. Both cores read 7 before either writes. Both compute 8. Both store 8. Two increments happened — but the counter only rose by one. One update was silently lost. This is a race condition.
Step 3 — The fix: reserve the address with lr.w
WHAT. We replace the plain load with a special one:
lr.w does everything lw does — copy counter into t0 — plus it whispers to the hardware: "I am watching this address." The core writes that address into a hidden per-core slot called the reservation register.
WHY. Losing an update in Step 2 happened because a write had no memory of whether the value had changed. A reservation is that memory: a little flag that says "this location was clean when I looked; tell me if anyone disturbs it."
PICTURE. Core A executes lr.w. Value 7 flows into its register (orange arrow), and a second plum arrow drops the address into the reservation slot, which lights up VALID.
Step 4 — How the reservation notices trouble (coherence does the watching)
WHAT. While Core A holds a reservation on counter's address, suppose Core B writes to that same cache line. The hardware invalidates Core A's reservation — flips its flag from VALID to INVALID.
WHY. The reservation must break the instant someone else disturbs the value; otherwise it would be a promise nobody keeps. RISC-V does not add new snooping hardware for this — it reuses the cache-coherence protocol that already broadcasts every write. When coherence tells Core A "your copy of this line is now stale," that same signal clears the reservation.
PICTURE. Core B's write sends a coherence "invalidate" signal (teal arrow) to Core A's cache line. The reservation slot flips to INVALID (plum, crossed out). Core A hasn't even run its store yet — it's already been warned.
Step 5 — sc.w: store only if nobody disturbed us
WHAT. The conditional store:
sc.w checks the reservation:
- Reservation still VALID → perform the store, and set
t2 = 0(success). - Reservation INVALID → do nothing to memory, set
t2 = 1(failure).
WHY. This is the whole trick. A store that refuses to run when the world changed underneath it can never overwrite someone else's update. The return code t2 tells our program which happened, so we can react.
PICTURE. Two outcomes side by side. Left: reservation VALID, t1 = 8 lands in memory, t2 = 0. Right: reservation INVALID, memory untouched, t2 = 1.
Step 6 — The retry loop that ties it together
WHAT. The complete lock-free increment:
retry:
lr.w t0, (a0) # read counter, reserve address
addi t1, t0, 1 # t1 = counter + 1
sc.w t2, t1, (a0) # try to store t1
bnez t2, retry # if t2 != 0 (failed), loop
bnez t2, retry = branch if not equal zero: if t2 is 1 (failure), jump back to retry.
WHY. A single attempt can fail — that's guaranteed correct but not guaranteed done. Wrapping it in a loop turns "correct" into "correct and eventually completes": each time we retry, we start fresh from the newest value, so our +1 is always applied to current data, never stale data.
PICTURE. A flow: lr.w → addi → sc.w → branch. Success (t2 = 0) exits; failure (t2 = 1) curls back to lr.w.
Step 7 — Trace two cores through the loop (no update lost)
WHAT. Replay the Step 2 race, but now with lr/sc. Counter starts at 7.
| Time | Core A | Core B | Memory |
|---|---|---|---|
| 1 | lr.w reads 7, reserve |
— | 7 |
| 2 | — | lr.w reads 7, reserve |
7 |
| 3 | — | sc.w 8 → success, t2=0 |
8 |
| 4 | sc.w 8 → fail (reservation broken by B), t2=1 |
— | 8 |
| 5 | loop: lr.w reads 8, reserve |
— | 8 |
| 6 | sc.w 9 → success, t2=0 |
— | 9 |
WHY. Compare with Step 2, which ended at 8 (one lost). Here the counter reaches 9 — both increments landed. Core A's first sc failed on purpose the moment Core B wrote, so A re-read the fresh 8 and correctly produced 9.
PICTURE. The same downward-time diagram as Step 2, but now A's store is stamped BLOCKED and A loops to re-read 8. Both +1s survive.
Step 8 — Edge cases you must never trip over
WHAT & WHY. A correct picture must cover the corners:
PICTURE. A 2×2 grid, one tile per corner case, each showing the reservation flag's fate and the resulting t2.
The one-picture summary
Everything at once: the reservation flag is the hero. lr sets it VALID, any foreign write clears it, and sc obeys it — storing only on VALID, failing (and forcing a retry) on INVALID. That single flag is what makes an increment indivisible even though it's built from separate read and write steps.
Recall Feynman retelling — tell it to a 12-year-old
Two kids share one number on a whiteboard and both want to add 1. If they just read, add, and rewrite, they can both read 7, both write 8, and one kid's turn vanishes. So we give each kid a sticky note: when you read, write down the number's address on your note and stick it up — that's lr, "load and reserve." A watcher (that's the cache-coherence system that already exists to keep everyone's copies matching) tears your note down the instant anyone else changes that number. When you go to write, you only write if your note is still stuck up — that's sc, "store-conditional." If your note is gone, your write is refused and you get a "1" meaning nope, try again; if it's still there you write and get a "0" meaning done. Wrap it in a loop so a refusal just sends you back to re-read the newest number. Now no update is ever lost, and we never needed a heavy lock — we just reused the whisper network the caches were already running.
Recall Quick self-test
What does lr.w do that lw does not? ::: It also records the loaded address in the per-core reservation register and marks it VALID.
What makes a reservation become INVALID? ::: Any other core writing to that cache line (or an interrupt/context switch), signalled via the cache-coherence protocol.
What are the two possible values of sc.w's result register and their meanings? ::: 0 = store succeeded (reservation was valid); 1 = store failed (reservation was invalid, must retry).
In the two-core trace, why does the counter correctly reach 9 instead of 8? ::: Core A's sc fails when Core B writes, so A re-reads the fresh 8 and stores 9 — both increments survive.
Why is a single sc attempt not enough in real code? ::: sc can fail (contention or spurious causes), so we loop and retry until it succeeds, guaranteeing forward progress.