The following bug was root-caused and confirmed by Fable 5. The section below was written by Claude Fable 5; it looks correct to me.
The basic issue is that divw (and friends) on rv64 are specified to be a division of only the lower 32-bits, but the Haskell semantics uses the entire 64 bits.
src/riscv/Spec/ExecuteM64.v
implements the RV64M W-form divisions by
dividing the full 64-bit register values and sign-extending only the
result. The ISA requires both operands to be truncated to their lower 32
bits before the division. The model therefore disagrees with hardware
(and qemu) whenever an operand of divw/divuw/remw/remuw has live
bits above bit 31 — for divuw/remuw even on canonical sign-extended
values.
Affected: current master (checked 2026-07-16) and the released opam
package coq-riscv.0.0.6. The bug appears to originate in the Haskell
source these files are generated from:
mit-plv/riscv-semantics's
src/Spec/ExecuteM64.hs
has the same structure, so a fix may belong in both places.
What the ISA says
RISC-V unprivileged spec, "M" extension, RV64M:
DIVW and DIVUW are RV64 instructions that divide the lower 32 bits of
rs1 by the lower 32 bits of rs2, treating them as signed and
unsigned integers respectively, placing the 32-bit quotient in rd,
sign-extended to 64 bits. REMW and REMUW are RV64 instructions that
provide the corresponding signed and unsigned remainder operations
respectively.
What ExecuteM64.v does
| Spec.Decode.Divuw rd rs1 rs2 =>
Bind (Spec.Machine.getRegister rs1) (fun x =>
Bind (Spec.Machine.getRegister rs2) (fun y =>
let q :=
if reg_eqb y (ZToReg 0) : bool then Utility.Utility.maxUnsigned else
Utility.Utility.divu x y in (* <-- x, y are the full 64-bit registers *)
Spec.Machine.setRegister rd (Utility.Utility.s32 q)))
and analogously for Divw (div x y), Remw (rem x y), Remuw
(remu x y). With the standard MachineWidth_XLEN instance, divu is
word.divu on 64-bit words and s32 is word.sextend 32, so the only
32-bit truncation happens after the divide. The guards are wrong in the
same way:
Divuw/Remuw's zero-divisor test is y == 0 on the full 64-bit y;
it should test y[31:0] == 0.
Divw/Remw's overflow test is x == minSigned && y == -1 against the
64-bit minSigned (-2^63); it should be x[31:0] == -2^31 && y[31:0] == -1.
Concrete divergences (model vs. qemu-riscv64)
| instruction |
rs1 |
rs2 |
ExecuteM64 |
hardware / qemu |
divuw |
2^32 |
2 |
s32(2^32 / 2) = 0xFFFFFFFF80000000 |
lower32(rs1) = 0, 0/2 = 0 |
divuw |
10 |
2^32 |
10 / 2^32 = 0 |
lower32(rs2) = 0 → div-by-zero = 0xFFFFFFFFFFFFFFFF |
divuw |
0xFFFFFFFF80000000 |
2 |
s32(0x7FFFFFFFC0000000) = 0xFFFFFFFFC0000000 |
0x80000000 / 2 = 0x40000000 |
remuw |
2^32 |
3 |
s32(2^32 mod 3) = 1 |
0 mod 3 = 0 |
divw |
0x500000064 |
0x0ABCDEF000000003 |
s32(0x500000064 / 0xABC…003) = 0 |
100 / 3 = 33 |
The third row is worth emphasizing: 0xFFFFFFFF80000000 is the
canonical sign-extended form of INT32_MIN, i.e. exactly the value an
RV64 register holds after any W-form instruction — so this is not just a
non-canonical-input corner case.
mulw is unaffected (the low 32 bits of a product depend only on the low
32 bits of the operands), which is probably why the bug went unnoticed.
srlw/sraw are fine too (they explicitly take regToShamt5/u32).
qemu reproduction
#include <stdio.h>
#include <stdint.h>
int main(void) {
uint64_t x = 1ULL << 32, y = 2, q;
asm("divuw %0, %1, %2" : "=r"(q) : "r"(x), "r"(y));
printf("divuw(2^32, 2) = 0x%016lx\n", q); /* prints 0x0000000000000000 */
return 0;
}
$ riscv64-linux-gnu-gcc -static t.c -o t && qemu-riscv64 ./t
divuw(2^32, 2) = 0x0000000000000000
Coq reproduction
Require Import Coq.ZArith.ZArith.
Require Import riscv.Utility.Utility.
Require Import riscv.Utility.MkMachineWidth.
Require Import riscv.Utility.Words64Naive.
Require Import coqutil.Word.Interface coqutil.Word.Naive.
Local Open Scope Z_scope.
(* ExecuteM64's Divuw data path: s32 (if y = 0 then maxUnsigned else divu x y) *)
Definition model_divuw (x y : Z) : Z :=
let x' := word.of_Z (word := word64) x in
let y' := word.of_Z (word := word64) y in
word.unsigned (s32 (if reg_eqb y' (ZToReg 0) then maxUnsigned else divu x' y')).
Eval vm_compute in model_divuw (2^32) 2.
(* = 18446744071562067968 (0xFFFFFFFF80000000); hardware: 0 *)
Eval vm_compute in model_divuw 10 (2^32).
(* = 0; hardware: 18446744073709551615 (division by zero: lower32(rs2) = 0) *)
Impact
Any proof built on this spec can certify a binary whose divw-family
behavior differs from real hardware, and conversely a correct binary can
be unprovable against the model. The divergence is silent: it only
manifests when an operand's bits 63:32 matter, which compilers routinely
produce (e.g. a 64-bit induction variable divided with a W-form
instruction after the compiler proves the value fits in 32 bits — the
register may still hold history above bit 31 on paths the model and
hardware then disagree about).
Suggested fix
Truncate both operands first and test the guards on the truncated values
(sketch; u32 x := ZToReg (bitSlice (regToZ_unsigned x) 0 32) or
equivalent, s32 as today):
| Spec.Decode.Divuw rd rs1 rs2 => ...
let x' := u32 x in let y' := u32 y in
let q := if reg_eqb y' (ZToReg 0) then maxUnsigned else divu x' y' in
setRegister rd (s32 q)
| Spec.Decode.Divw rd rs1 rs2 => ...
let x' := s32 x in let y' := s32 y in
let q := if andb (reg_eqb x' (s32 (ZToReg (2^31)))) (reg_eqb y' (negate (ZToReg 1)))
then x'
else if reg_eqb y' (ZToReg 0) then negate (ZToReg 1)
else div x' y' in
setRegister rd (s32 q)
(and analogously for Remw/Remuw). With sign-extended (s32) operands
the 64-bit signed div/rem coincides with 32-bit division, and with
zero-extended (u32) operands the 64-bit divu/remu coincides with
32-bit unsigned division, so the final s32 on the result then matches
the ISA. If the fix should instead go through the hs-to-coq pipeline, the
corresponding change lands in riscv-semantics's ExecuteM64.hs.
Would riscv-tests have caught this?
(this section was human written)
The upstream tests in riscv-tests do cover this issue in the rv64um suite, even in the pinned commit, but riscv-coq doesn't use that particular suite (it builds only rv64mi rv64si rv64ui rv64uf). That said, even upstream the coverage for this particular issue is poor: divw/remw/remuw need new
vectors with live bits above bit 31 to get proper coverage.
The following bug was root-caused and confirmed by Fable 5. The section below was written by Claude Fable 5; it looks correct to me.
The basic issue is that divw (and friends) on rv64 are specified to be a division of only the lower 32-bits, but the Haskell semantics uses the entire 64 bits.
src/riscv/Spec/ExecuteM64.vimplements the RV64M W-form divisions by
dividing the full 64-bit register values and sign-extending only the
result. The ISA requires both operands to be truncated to their lower 32
bits before the division. The model therefore disagrees with hardware
(and qemu) whenever an operand of
divw/divuw/remw/remuwhas livebits above bit 31 — for
divuw/remuweven on canonical sign-extendedvalues.
Affected: current
master(checked 2026-07-16) and the released opampackage
coq-riscv.0.0.6. The bug appears to originate in the Haskellsource these files are generated from:
mit-plv/riscv-semantics's
src/Spec/ExecuteM64.hshas the same structure, so a fix may belong in both places.
What the ISA says
RISC-V unprivileged spec, "M" extension, RV64M:
What ExecuteM64.v does
| Spec.Decode.Divuw rd rs1 rs2 => Bind (Spec.Machine.getRegister rs1) (fun x => Bind (Spec.Machine.getRegister rs2) (fun y => let q := if reg_eqb y (ZToReg 0) : bool then Utility.Utility.maxUnsigned else Utility.Utility.divu x y in (* <-- x, y are the full 64-bit registers *) Spec.Machine.setRegister rd (Utility.Utility.s32 q)))and analogously for
Divw(div x y),Remw(rem x y),Remuw(
remu x y). With the standardMachineWidth_XLENinstance,divuisword.divuon 64-bit words ands32isword.sextend 32, so the only32-bit truncation happens after the divide. The guards are wrong in the
same way:
Divuw/Remuw's zero-divisor test isy == 0on the full 64-bity;it should test
y[31:0] == 0.Divw/Remw's overflow test isx == minSigned && y == -1against the64-bit
minSigned(-2^63); it should bex[31:0] == -2^31 && y[31:0] == -1.Concrete divergences (model vs. qemu-riscv64)
divuw2^322s32(2^32 / 2)=0xFFFFFFFF80000000lower32(rs1) = 0,0/2=0divuw102^3210 / 2^32=0lower32(rs2) = 0→ div-by-zero =0xFFFFFFFFFFFFFFFFdivuw0xFFFFFFFF800000002s32(0x7FFFFFFFC0000000)=0xFFFFFFFFC00000000x80000000 / 2=0x40000000remuw2^323s32(2^32 mod 3)=10 mod 3=0divw0x5000000640x0ABCDEF000000003s32(0x500000064 / 0xABC…003)=0100 / 3=33The third row is worth emphasizing:
0xFFFFFFFF80000000is thecanonical sign-extended form of
INT32_MIN, i.e. exactly the value anRV64 register holds after any W-form instruction — so this is not just a
non-canonical-input corner case.
mulwis unaffected (the low 32 bits of a product depend only on the low32 bits of the operands), which is probably why the bug went unnoticed.
srlw/sraware fine too (they explicitly takeregToShamt5/u32).qemu reproduction
Coq reproduction
Impact
Any proof built on this spec can certify a binary whose
divw-familybehavior differs from real hardware, and conversely a correct binary can
be unprovable against the model. The divergence is silent: it only
manifests when an operand's bits 63:32 matter, which compilers routinely
produce (e.g. a 64-bit induction variable divided with a W-form
instruction after the compiler proves the value fits in 32 bits — the
register may still hold history above bit 31 on paths the model and
hardware then disagree about).
Suggested fix
Truncate both operands first and test the guards on the truncated values
(sketch;
u32 x := ZToReg (bitSlice (regToZ_unsigned x) 0 32)orequivalent,
s32as today):| Spec.Decode.Divuw rd rs1 rs2 => ... let x' := u32 x in let y' := u32 y in let q := if reg_eqb y' (ZToReg 0) then maxUnsigned else divu x' y' in setRegister rd (s32 q) | Spec.Decode.Divw rd rs1 rs2 => ... let x' := s32 x in let y' := s32 y in let q := if andb (reg_eqb x' (s32 (ZToReg (2^31)))) (reg_eqb y' (negate (ZToReg 1))) then x' else if reg_eqb y' (ZToReg 0) then negate (ZToReg 1) else div x' y' in setRegister rd (s32 q)(and analogously for
Remw/Remuw). With sign-extended (s32) operandsthe 64-bit signed
div/remcoincides with 32-bit division, and withzero-extended (
u32) operands the 64-bitdivu/remucoincides with32-bit unsigned division, so the final
s32on the result then matchesthe ISA. If the fix should instead go through the hs-to-coq pipeline, the
corresponding change lands in
riscv-semantics'sExecuteM64.hs.Would riscv-tests have caught this?
(this section was human written)
The upstream tests in riscv-tests do cover this issue in the rv64um suite, even in the pinned commit, but riscv-coq doesn't use that particular suite (it builds only
rv64mi rv64si rv64ui rv64uf). That said, even upstream the coverage for this particular issue is poor:divw/remw/remuwneed newvectors with live bits above bit 31 to get proper coverage.