This deep dive lives under the parent topic on RISC-V extensions . The parent told you what the M, A, F and D extensions do. Here we prove you can survive every case those instructions can throw at you — every sign, every zero, every overflow, every rounding trap.
Before we start, one rule for reading this page: whenever a symbol shows up, we say what it means in plain words first.
Definition Words we will reuse
rs1, rs2 ::: "source register 1 / 2" — the input boxes an instruction reads from.
rd ::: "destination register" — the output box the instruction writes to.
Two's complement ::: the way a computer writes negative whole numbers. On n bits, the top bit has weight − 2 n − 1 instead of + 2 n − 1 . So on 32 bits, 0x80000000 means − 2 31 , not + 2 31 .
Hex 0x... ::: base-16 shorthand. Each hex digit is 4 binary bits. 0xF = 1111 = 15.
.w / .s / .d suffix ::: word (32-bit integer), single float (32-bit), double float (64-bit).
Floor ⌊ x ⌋ ::: "the greatest integer that is ≤ x ." So ⌊ 3.9 ⌋ = 3 and ⌊ 2 ⌋ = 2 . We use it to name "just the whole-number part left after a division."
ULP ::: "unit in the last place" — the gap between one representable floating-point number and the very next one. It is the smallest change you can make to a float by flipping its lowest mantissa bit. Rounding error is always measured in ULPs.
Every trap in these four extensions falls into one of these cells. Our examples below are labelled with the cell they cover, so you can check nothing is missing.
#
Cell (case class)
Extension
Why it can bite you
C1
Positive × positive, need both halves
M
32×32 gives 64 bits; forgetting MULH loses the top
C2
Signed × signed, one negative
M
sign bits propagate into the upper half
C3
Both negative (product positive)
M
naive "two negatives" reasoning at the bit level
C4
Mixed signed × unsigned
M
MULHSU — the exam favourite
C5
Divide-by-zero / signed overflow
M
RISC-V does not trap; it returns defined values
C6
Atomic increment, contended
A
LR/SC retry loop
C7
Atomic compare-and-swap
A
building CAS from LR/SC
C8
Exact float add (power-of-two operands)
F
no rounding — the "clean" case
C9
Inexact float add (0.1 + 0.2)
F
rounding error, the classic bug
C10
Single vs double precision drift
F/D
accumulation over many adds
C11
Degenerate float: zero, and − 0
F
two zeros, one value
Prerequisites you may want open: 5.1.07-RISC-V-base-ISA , 5.2.03-IEEE-754-floating-point , 3.3.06-Instruction-encoding , 6.4.02-Cache-coherence-protocols .
Read the map (figure s01). Along the top row are the three families of scenarios: the lavender M box splits a 64-bit product into two halves, the mint A box shows the reservation dance, the butter F/D box shows the align-add-round pipeline. Each top box has an arrow dropping to a coral box of the exact case numbers (C1–C5, C6–C7, C8–C11) we will walk. Use this as your checklist — every coral cell gets its own worked example below.
Intuition The 64-bit box that doesn't fit
Multiply two 32-bit numbers and the answer can be up to 64 bits wide — it does not fit in one 32-bit register. So the hardware makes the full 64-bit product internally, then lets you fetch it in two grabs: MUL hands you the bottom 32 bits, and one of MULH/MULHU/MULHSU hands you the top 32 bits. The variant you pick tells the hardware how to interpret the signs of the inputs.
full 64-bit product rs1 × rs2 = 2 32 ⋅ ( MULH ) + ( MUL )
Read the multiplier picture (figure s02). Two lavender 32-bit inputs rs1 and rs2 feed the mint "multiplier array"; its arrow points down to a butter bar labelled "full 64-bit product". From that bar, two arrows fan out: the left one to the coral MULH box (bits [63:32]) and the right one to the lavender MUL box (bits [31:0]). The line at the top, product = 2^32 * MULH + MUL, is the only equation you need for every M example — the picture just shows where each half physically comes from.
Worked example C1 — Positive × positive, keep both halves
Statement: rs1 = 0x10000 (that's 65536 ), rs2 = 0x10000 (65536 ). Compute MUL and MULH (signed).
Forecast: guess — will the top half be zero or non-zero?
Multiply the true values. 65536 × 65536 = 2 16 ⋅ 2 16 = 2 32 = 4294967296 .
Why this step? Before touching bits, know the real number. 2 32 needs bit 32 set — so it cannot fit in 32 bits, it spills into the upper half.
Write it in 64-bit hex. 2 32 = 0x0000000100000000.
Why this step? Splitting on the hex is now trivial: 8 low hex digits, 8 high hex digits.
MUL = low 32 bits = 0x00000000.
Why this step? MUL returns bits [31:0] (the right-hand MUL box in figure s02); the low 8 hex digits of 0x0000000100000000 are all zero.
MULH = high 32 bits = 0x00000001.
Why this step? This is exactly the value ⌊ ( 2 32 ) / 2 32 ⌋ = 1 predicted by the formula (floor ⌊ ⋅ ⌋ = "greatest whole number ≤ ", from the definitions box).
Verify: 2 32 ⋅ MULH + MUL = 2 32 ⋅ 1 + 0 = 2 32 = 6553 6 2 . ✓ Both operands positive, so signed and unsigned give the same answer here.
Worked example C2 — Signed × signed, one operand negative
Statement: rs1 = 0x80000000 (signed = − 2 31 ), rs2 = 0x00000002 (= + 2 ). Compute MUL and MULH.
Forecast: the true product is negative — what does that do to the top 32 bits?
True value. − 2 31 × 2 = − 2 32 = − 4294967296 .
Why this step? A negative product means, in 64-bit two's complement, the upper bits fill with 1s (sign extension), not 0s.
64-bit two's complement of − 2 32 . Take 2 64 − 2 32 = 0xFFFFFFFF00000000.
Why this step? To write − x on 64 bits you compute 2 64 − x . Here x = 2 32 .
MUL = low half = 0x00000000.
Why this step? The low 8 hex digits of 0xFFFFFFFF00000000 are all zero, and MUL only ever hands back bits [31:0].
MULH (signed) = high half = 0xFFFFFFFF.
Why this step? All the 1s living up top are the sign extension of a negative number — this is why MULH (signed variant) exists.
Verify: interpret 0xFFFFFFFF00000000 as signed 64-bit: high bit set ⇒ negative. Its magnitude = 2 64 − 0xFFFFFFFF00000000 = 2 32 . So value = − 2 32 . ✓
Worked example C3 — Both operands negative (product is positive)
Statement: rs1 = rs2 = 0x80000000 (each = − 2 31 ). Compute MULH (signed) and, as a trap check, MULHU.
Forecast: negative × negative = positive. Will MULH and MULHU agree? Guess before reading.
Signed true value. ( − 2 31 ) × ( − 2 31 ) = 2 62 .
Why this step? Positive result ⇒ top bits are 0s, not 1s.
64-bit hex of 2 62 . 2 62 = 0x4000000000000000.
Why this step? 2 62 sets a single bit — bit 62 — which lands in the upper half, so we know MULH is where the action is.
MULH (signed) = high half = 0x40000000.
Why this step? The high 8 hex digits of 0x4000000000000000 are 40000000; that is bits [63:32], exactly what MULH returns (left box of figure s02). Numerically ⌊ 2 62 / 2 32 ⌋ = 2 30 = 0x40000000.
MULHU (both unsigned) reinterprets each input as + 2 31 : 2 31 × 2 31 = 2 62 again → high half 0x40000000.
Why this step? Here they happen to match numerically, but the meaning differs: MULH says "square of − 2 31 ", MULHU says "square of + 2 31 ". Same bits, different story. This is the parent note's mistake box, made concrete.
Verify: 2 32 ⋅ 0x40000000 = 2 32 ⋅ 2 30 = 2 62 , and the low half is 0x00000000, total = 2 62 . ✓
Worked example C4 — Mixed: signed × unsigned (
MULHSU)
Statement: rs1 = 0xFFFFFFFF treated signed (= − 1 ), rs2 = 0x00000002 treated unsigned (= + 2 ). Compute MULHSU and MUL.
Forecast: product of a small negative and a small positive — top bits all 1s or all 0s?
True value. signed( − 1 ) × unsigned( 2 ) = − 2 .
Why this step? MULHSU is the only variant that says "first is signed, second is unsigned" — needed by big-number libraries multiplying a signed limb by an unsigned limb.
64-bit two's complement of − 2 = 0xFFFFFFFFFFFFFFFE.
Why this step? − 2 on 64 bits is 2 64 − 2 , which is all 1s except the last bit is 0 — giving the ...FE tail.
MUL = low half = 0xFFFFFFFE.
Why this step? MUL returns bits [31:0]; the low 8 hex digits of 0xFFFFFFFFFFFFFFFE are FFFFFFFE.
MULHSU = high half = 0xFFFFFFFF.
Why this step? Negative product ⇒ sign-extended 1s up top, but only because we told the hardware the first operand was signed.
Verify: value of 0xFFFFFFFFFFFFFFFE signed = − ( 2 64 − 0x...FE ) = − 2 . ✓
Worked example C5 — Degenerate: divide by zero and signed overflow
Statement: RISC-V M has no trap on division. What do these return?
(a) DIVU rd, x, 0 (unsigned divide by zero).
(b) REM rd, x, 0 (remainder by zero), for x = 7.
(c) DIV rd, 0x80000000, 0xFFFFFFFF — signed − 2 31 ÷ − 1 (the overflow case).
Forecast: in C, dividing by zero crashes. Does RISC-V crash? Guess.
Divide by zero returns "all ones." The RISC-V spec defines DIV/DIVU by zero as − 1 = 0xFFFFFFFF.
Why this step? Defining it (instead of trapping) keeps hardware simple and lets software check the divisor if it cares.
Remainder by zero returns the dividend. REM x, 0 = x. So REM 7, 0 = 7.
Why this step? Consistency: a = q ⋅ 0 + r forces r = a .
Signed overflow. − 2 31 ÷ − 1 = + 2 31 , which does not fit in signed 32-bit. Spec defines the result as the dividend, 0x80000000 (= − 2 31 ), and REM of that case = 0 .
Why this step? + 2 31 can't be stored, so the ISA picks a defined fallback rather than an exception.
Verify: (a) 0xFFFFFFFF = 2 32 − 1 . (b) = 7 . (c) result 0x80000000, remainder 0 . ✓ (see VERIFY block)
Two acronyms drive this whole section, so let's pin them down before any code:
Definition The two atomic acronyms
LR/SC ::: ==L oad-R eserved / S tore-C onditional==. lr.w loads a word and marks that address as "reserved for me." sc.w stores a word only if my reservation is still intact, and reports back whether it succeeded. Together they let you read, think, and write without any other core sneaking in between.
CAS ::: ==C ompare-A nd-S wap==. One logical operation: "if the memory still holds the value I expect , overwrite it with a new value; otherwise leave it alone." It is the workhorse of lock-free data structures, and we will build it out of LR/SC.
We will also meet two RISC-V pseudo-instructions in the code:
Definition Two pseudo-ops you'll see
bnez rX, label ::: "b ranch if n ot e qual to z ero" — if register rX is non-zero, jump to label; otherwise fall through to the next line. We use it to retry a failed sc.w.
li rX, value ::: "l oad i mmediate" — put a constant value straight into register rX. We use it to set a success/failure return code.
Read the reservation timeline (figure s03). The top coral row is core A's timeline, the bottom lavender row is core B's, running left to right in time. Both start with lr.w -> 5 (each reads the counter value 5 and reserves the address). Core A's sc.w 6 OK fires first; the vertical "invalidate" arrow dropping onto core B's row is the key moment — that store kills core B's reservation. So core B's sc.w FAIL box does nothing, and the mint boxes show core B looping back: lr.w -> 6 (now the fresh value) then sc.w 7 OK. The butter bar at the bottom, counter: 5 -> 6 -> 7, is the moral: no update was lost.
Worked example C6 — Contended atomic increment (LR/SC retry)
Statement: Two cores both run the increment loop on the same counter starting at 5. Core A wins the store; core B's reservation breaks. Trace core B.
Forecast: does core B write 6 (its stale value) or 7? Guess.
Both do lr.w t0, (a0) and read 5. Each marks the address reserved for its own core .
Why this step? lr.w records the address in a per-core reservation register — see 6.4.02-Cache-coherence-protocols for how the cache line tracks writes.
Core A's sc.w succeeds , writes 6. That write invalidates core B's reservation.
Why this step? A store to the reserved line by anyone else is exactly what kills the reservation.
Core B's sc.w fails (returns non-zero). bnez (branch-if-not-zero) jumps back to retry.
Why this step? A failed sc.w means the value core B loaded is now stale; retrying is the only way to avoid silently overwriting core A's 6 and losing an increment.
Core B re-runs lr.w, now reads 6 , computes 7, and sc.w succeeds.
Why this step? Retrying with the fresh value is what makes the final result correct: no update is lost.
Verify: final counter = 5 → 6 → 7 . Two increments, value 7. ✓
Worked example C7 — Compare-and-swap built from LR/SC
Statement: Implement CAS(addr, expected, new) (compare-and-swap): write new only if mem[addr] == expected. Use expected = 10, new = 42, and current memory 10.
Forecast: will the swap happen? And what if memory were 11 instead?
cas:
lr.w t0, (a0) # load current, reserve address
bne t0, a1, fail # if current != expected, give up
sc.w t1, a2, (a0) # try to store new
bnez t1, cas # reservation lost? (t1 != 0) retry
li a0, 0 # li = load immediate: success code 0
ret
fail:
li a0, 1 # failure code 1
ret
lr.w reads current = 10, a1 (expected) = 10. Equal ⇒ do not branch.
Why this step? CAS only proceeds if the value matches the caller's assumption.
sc.w writes 42. Assuming no interfering core, succeeds ⇒ t1 = 0.
Why this step? We reached sc.w only because the value matched and the reservation from lr.w is still live; the store is what actually commits the new value atomically.
Return success (a0 = 0), memory now 42. li a0, 0 (load-immediate) plants the success code.
Why this step? A zero t1 means the store landed with no interference, so the caller must be told the swap really happened — hence the success code 0.
Counterfactual: if memory were 11, step 1's bne fires ⇒ jump to fail, memory unchanged, return 1.
Why this step? This covers the "expected value did not match" branch of the matrix: CAS must report failure and leave memory untouched so the caller can retry with a fresh read.
Verify: matched case → memory 42, code 0. Mismatched case → memory 11, code 1. ✓
Recall the single-precision layout from the parent and 5.2.03-IEEE-754-floating-point :
( − 1 ) s × 1. m × 2 e − 127
where s is the sign bit, m the 23-bit mantissa, e the 8-bit biased exponent.
Read the float pipeline (figure s04). Four boxes flow left to right, each an arrow to the next: lavender unpack (split each number into 1. m × 2 e ), mint align (shift the smaller number's mantissa so both share one exponent), butter add mantissas , coral normalize + pack (put the sum back into 1. m × 2 e form and write the bits 0x3FE00000). The caption underneath, "no bits fall off the shift → no rounding → exact", is why this particular example (C8) is clean: the align step in the mint box loses nothing.
Worked example C8 — Exact float add (both operands powers of two)
Statement: fadd.s f2, f0, f1 with f0 = 1.5, f1 = 0.25. Do it by hand through the hardware steps.
Forecast: will the result be exact (no rounding)? Guess.
Unpack. 1.5 = 1. 1 2 × 2 0 , so e = 127 , m = .100... ; 0.25 = 1. 0 2 × 2 − 2 , so e = 125 .
Why this step? Adders need both numbers on the same exponent scale before adding (the mint "align" box in figure s04 needs unpacked exponents to work on).
Align the smaller (0.25 ) up to exponent 0 : shift its mantissa right by 2 ⇒ 0.0 1 2 × 2 0 .
Why this step? 127 − 125 = 2 places of shift; no bits fall off the end, so no rounding .
Add mantissas: 1.10 0 2 + 0.01 0 2 = 1.11 0 2 = 1.75 .
Why this step? Once both share exponent 2 0 , ordinary binary addition of the mantissas is the real sum — that is the whole point of aligning first.
Normalize + pack: already in [ 1 , 2 ) , so e = 127 , mantissa .110... ⇒ bits 0x3FE00000.
Why this step? 1.75 = 1.1 1 2 × 2 0 , mantissa top three bits 110 → 0x3FE00000.
Verify: decode 0x3FE00000 ⇒ 1.75 . Exact, because 1.5 , 0.25 , and 1.75 are all finite sums of powers of two. ✓
Worked example C9 — Inexact float add (the 0.1 + 0.2 trap)
Statement: Is fadd.s(0.1, 0.2) exactly 0.3? Explain and give the drift.
Forecast: true or false? Guess before reading.
0.1 is not a finite binary fraction: 0. 1 10 = 0.000110011 0011 2 . Rounded to 23 mantissa bits it becomes a value slightly off 0.1 .
Why this step? Only sums of powers of two are exact; 0.1 has a repeating binary tail (parent's mistake box).
Same for 0.2 . Both stored values are already approximations.
Why this step? 0.2 = 2 × 0.1 inherits the same repeating tail, so before we even add, both inputs are already rounded — errors are baked in at load time, not just at add time.
Add the two approximations, round again. The result is a value near 0.3 but not the stored value of 0.3.
Why this step? Two rounding errors plus the final rounding rarely cancel to the exact 0.3 bit pattern.
In single precision: 0.1f + 0.2f stores as a value whose distance from the stored 0.3f is on the order of a single ULP.
Why this step? One ULP is the smallest gap the format can express here; when the true sum lands between two representable floats, rounding snaps it to the nearer one, leaving a residual of roughly one ULP (≈ 3 × 1 0 − 8 near 0.3 in single precision).
Verify: in real IEEE-754 arithmetic (0.1 + 0.2) == 0.3 evaluates to False ; the difference is nonzero (order 1 0 − 17 in double, 1 0 − 8 in single). ✓
Worked example C10 — Single vs double drift over a million adds
Statement: Add 1.0 to an accumulator 1 0 6 times, once as float (single), once as double. Which stays exact?
Forecast: guess which one is still exactly 1000000 at the end.
Single precision has 24 significant bits (2 24 = 16777216 ). Once the running sum exceeds 2 24 , the ULP (gap between neighbouring floats) grows past 1 , so adding 1 can no longer change the value — the increment is lost to rounding .
Why this step? When one ULP is larger than the amount you are adding, "+ 1 " rounds straight back to the same float. Knowing 2 24 is the danger threshold tells us exactly when single precision starts freezing.
Compare the target 1 0 6 against the threshold. 1 0 6 = 1000000 < 16777216 = 2 24 .
Why this step? The accumulator never crosses 2 24 during these 1 0 6 adds, so the ULP stays at or below 1 the whole time — every "+ 1 " is still representable and lands exactly.
Therefore single precision is still exact here: the float sum reaches exactly 1000000.0 .
Why this step? No add ever gets swallowed, so no error accumulates; the trap only appears for larger counts (e.g. summing past 2 24 ).
Double precision has 53 significant bits (2 53 ≈ 9 × 1 0 15 ).
Why this step? Its ULP stays below 1 until the sum is astronomically large, so double is exact here with enormous margin — this is why numerically sensitive code accumulates in double.
Verify: float sum of 1 0 6 ones = 1000000.0 exactly (since 1 0 6 < 2 24 ); double also = 1000000.0 . Both exact at this size — the trap only appears past 2 24 . ✓
Worked example C11 — Degenerate floats:
+ 0 , − 0 , and their sum
Statement: IEEE-754 has two zeros: 0x00000000 (+ 0 ) and 0x80000000 (− 0 ). What is fadd.s(+0, -0)? And does +0 == -0?
Forecast: guess the sign of the sum, and whether the two zeros compare equal.
Both are zero in value , differing only in the sign bit (bit 31).
Why this step? The format lets the sign bit be set even when the magnitude is zero, which is how hardware records the direction from which a result underflowed to zero.
+0 == -0 is true by IEEE comparison rules, even though the bit patterns differ.
Why this step? Numeric comparison asks "same value?", not "same bits?"; both represent 0.0 , so the standard mandates equality. Only a bitwise inspection can tell them apart.
fadd.s(+0, -0) = +0 under the default round-to-nearest mode.
Why this step? The standard fixes this exact tie case to + 0 so the answer is portable across every conforming FPU — you never have to guess which zero comes out.
Verify: value( + 0 ) = value( − 0 ) = 0.0 , equality holds; their sum is 0.0 with a + sign. ✓
Recall Self-test
M — why do we need MULH at all? ::: A 32×32 product is up to 64 bits; MUL gives the low 32, MULH the high 32.
M — what does DIVU x, 0 return on RISC-V? ::: 0xFFFFFFFF (all ones); no trap.
A — what does LR/SC stand for? ::: Load-Reserved / Store-Conditional.
A — why does core B's sc.w fail after core A stores? ::: Core A's write invalidated core B's reservation on that line.
A — what does CAS mean? ::: Compare-And-Swap: overwrite only if memory still holds the expected value.
F — is 1.5 + 0.25 exact? ::: Yes, both and their sum are finite sums of powers of two.
F — is 0.1 + 0.2 == 0.3? ::: No; 0.1 and 0.2 aren't exact in binary, so rounding differs.
F — what is a ULP? ::: The gap between one representable float and the next; the smallest change from flipping the lowest mantissa bit.
F — do +0 and -0 compare equal? ::: Yes, numerically equal; bits differ.
"H igh U ses the same S ign story you tell it." MULH=both signed, MULHU=both unsigned, MULHSU=S igned-then-U nsigned.