The question these tests answer is one-directional:
is what the real hardware did an execution our model ALLOWS?
so a test only has to EXHIBIT one model execution matching what QEMU produced.
That is a computation, not a proof: no WP, no invariants, no Iris. A test is
a vm_cast_no_checkd equation between a projection of the model's reached
state and a literal captured from QEMU. Nothing here is restricted to what
the xv6 driver's proofs assume -- a test may program the queue illegally, and
the only question is whether the model has a run that matches.
The suite has one set of test cases (tests/*.S). A case declares, in
its own vtest: directive, which platforms it is meaningful on.
Executing a case on a platform produces a test run, so a case yields
zero or more runs (QEMU, the VisionFive 2 board, the CVA6 RTL) -- and every
run, whichever platform it came from, is
the same kind of object and is judged by the same theorem.
/* vtest: platforms=qemu builder=multi budget=4000 tick=1
proj=fields:24,48 */
Every knob defaults, so most cases declare nothing:
| default | what it says | |
|---|---|---|
platforms= |
qemu,jh7110,cva6 |
which platforms this case is MEANINGFUL on. A case is narrowed when the question cannot be ASKED on the other platform -- not when it merely fails there. A failure is a finding and belongs in the table. |
builder= |
single |
how the model side is run. multi = races two harts, needs a VConc schedule; sched = needs an explicit VSched item list (a serial byte ARRIVING is a schedule choice); icache = the case stores over its OWN code, so its outcomes are the FETCH VIEW's choice and the model side is one run per fetch schedule of a hand-written <Case>Sched.v over VIcache.v, which threads the hart's instruction view. VRun has a builder only for single, so the others produce NO run module and the table says no builder -- which is honest, where running them through the single-hart builder would compute an outcome in which the second hart never ran. |
budget= |
2000 |
steps the model is given |
tick= |
0 |
step the CLOCK-TICKING branch of the boundary's exists tick : bool |
selfmod= |
0 |
the program writes its OWN TEXT. A board repeat reloads the image only when it changes (board.py), so without this a repeat of a self-modifying program observes the previous run's patched code -- measured on core_icache: repeats 2 and 3 warmed up on the already-patched probe and looked like a coherent fetch. QEMU starts afresh every run. |
proj= |
whole |
what is compared. fields:o1,o2,… for a case some of whose fields legitimately differ between two runs of the SAME machine -- counters, a raw mtime, an image-dependent mtvec, the hart id. |
uarts= |
1 |
HOW MANY 16550s THE MACHINE HAS. QEMU virt instantiates its second ns16550a -- abi.h's UART1, 0x1000a000, PLIC source 12 -- only when a SECOND -serial backend is attached, so a case that touches the second window has to ask for it. With uarts=1 the command line is byte-for-byte what it always was and the second node is absent from the device tree, which is why no existing capture moved. |
serial_in= / serial1_in= |
empty | the bytes the host types, at port 0 and at port 1. Either one turns THAT port's backend into a socket the runner pushes into; the other stays an output file. On the model side these are VSched.SUartRx i b items, TAGGED WITH THE PORT, and the run's uart_input is the list of (port, byte) pairs the theorem pins the trace against. |
There is no runner-side skip list. A profile that excluded an area would be hiding a fact about a case inside a fact about a machine, and the two had already drifted apart.
vtest-rocq/VRun.v. A run is a TEST_RUN
module; the theorem is stated once and parametrically:
Definition run_passes observed outcome : Prop :=
match outcome with
| MNoStep => True (* a pass *)
| MDone exhibited => forall o, o ∈ observed -> o ∈ exhibited
| MUnknown | MBudget => False
end.
Module Type TEST_PASSES (R : TEST_RUN).
Axiom passes : run_passes R.observed R.outcome.
End TEST_PASSES.A run passes when the model exhibits every outcome the platform observed (if the platform showed several, because the hardware itself has more than one legal execution, the model must have each), or when the model is STUCK -- which is a pass too, because a state the model cannot step from is a state no proof over the model can reach. It costs REACH, not soundness.
Stuck means MNoStep, not VStuck. exec also declines on
Interface.Choose, where the relation DOES have transitions and only the
interpreter will not pick one, and "our proofs can never run into this case"
is false there. VExecStuck.exec_r_no_step is what makes MNoStep
mean it; EChoice becomes MUnknown, which is a gap, not a pass.
outcome is COMPUTED by the SingleHart functor, not asserted by the
generated module, so a run cannot claim a model execution it does not have.
Every proof is then the same two tactics:
(* QEMU/CoreSmokePass.v *)
Module CoreSmokePass <: TEST_PASSES CoreSmoke.
Lemma passes : run_passes CoreSmoke.observed CoreSmoke.outcome.
Proof. apply run_passes_b_sound. vm_cast_no_check (eq_refl true). Qed.
End CoreSmokePass.make vtest-table print it
make vtest-passes ATTEMPT every run's proof (against `_CoqProject.all`,
so a red one can still be tried), then rewrite the
green `_CoqProject` from what produced a `.vo`
make vtest-runs rebuild the run modules from the checked-in captures
Everything in it is read off the tree -- the case's directive, whether a run
module exists, whether a TEST_PASSES instantiation compiles -- so it cannot
drift: the table reads the .vo, never _CoqProject membership -- membership
is only an ASSERTION that a proof holds, and a table that read it would be
reporting its own bookkeeping back.
A run with no passing proof is a FINDING, not a broken build. It is
absent from _CoqProject, so CI stays green while the table's row for it says
no proof; the findings table below says what the divergence is.
abi.h THE ABI: regions, result layout, done handshake (read first)
vtest.S prologue/epilogue every test image uses
tests/<area>_<name>.S one test program (a `vtest:` directive in it
carries the repeat count and backend configs)
vtest.py builds the image, runs QEMU, captures, generates Rocq,
and prints THE RUN TABLE (`--check` is CI's verdict)
board.py the same, against a real board over JTAG
../../vtest-rocq/ the model side: harness, then per (case, platform)
<Case><Platform>Run.v what was run, what came back
<Case><Platform>Pass.v its VRun.TEST_PASSES instance
A test is <area>_<name>, and the Rocq side is the same in CamelCase:
tests/disk_rw.S -> vtest-rocq/DiskRw.v + the generated DiskRwGen.v,
whose definitions are disk_rw_text, disk_rw_qemu_result, ... Areas:
| area | what it is about |
|---|---|
core |
the CPU and ordinary memory: instruction semantics, the loaded image, the declared regions. The plumbing test lives here. |
disk |
the virtio-mmio block device (VirtioModel.v) |
uart |
the 16550 (DevModel.v's UART half) |
plic |
the interrupt controller ITSELF -- arbitration, priorities, thresholds, several sources |
pt |
Sv39 translation: the walk, the TLB, page faults, and the A/D write-back. S-mode, start_pt. |
conc |
shared memory between harts. Uses vtest-rocq/VConc.v and smp= rather than the single-hart harness. |
The area is what the test is ABOUT, not everything it touches.
disk_intr drives the PLIC and reads mip, but it exists to check the
DISK's interrupt path, so it is a disk test. A plic_ test would be one
whose subject is the controller -- two sources competing, a threshold that
masks one, a claim while another is pending.
make vtest regenerate from QEMU (needs qemu-system-riscv64 + the
riscv64 toolchain), then check the model against it
make vtest-check check the model against the CHECKED-IN captures only
(no QEMU, no toolchain), stopping at the first red test
make vtest-check-ci the same captures, but compile EVERY test (-k) and
report the per-test result -- this is what CI runs,
on every push
vtest-check is deliberately outside make proofs: a red test must not break
the proof build. It needs only the ~20-file dependency cone of RiscvExec +
DevModel, not all of iris/.
.github/workflows/ci.yml runs vtest-check-ci after the proof build --
against the CHECKED-IN captures, so CI never runs QEMU and never looks for
new hardware behaviours; it re-checks that the executions already recorded
from real hardware are still executions the model ADMITS.
Every test must pass, and a red one fails the job. There is no list of expected reds and no need for one: a known divergence is pinned on BOTH sides and proved unequal (see Recording a divergence), so it is green today and goes red exactly when the model moves -- the findings are theorems about the disagreement, not failures.
What the step adds over a plain vtest-check is the REPORT: everything is
compiled (-k) before the verdict, so one red run does not hide the rest, and
vtest.py table --format md puts THE RUN TABLE in the run's step summary.
There is no second reporter: a run is listed in _CoqProject exactly when its
proof holds, so the green set and the table's "pass" column are the same fact,
read from the same .vo. --check fails the step when a listed file has
none. vtest-check-ci deletes the .vo first, because a failed recompile
leaves the previous one in place and a red run would otherwise be reported
green off the last build.
vtest.py gen --hart 1 core_hart core_regs_gpr ...
Every capture in this suite was of a program on hart 0 until 2026-08-29,
because QEMU boots hart 0 and -smp 1 is the rule. --hart N builds the
same source with PRIMARY_HART=N, runs it under -smp N+1, and writes
<Name>Hart<N>Gen.v with its own <name>_hartN_ definitions and a
<name>_hartN_primary_hart. The model side runs it with
VTest.start_hart, which is a legal schedule for exactly the reason a
hart-0-only schedule is.
IT IS A DIFFERENT IMAGE, not a different schedule, and that is the one
thing to keep in mind. vtest.S's prologue branches primary-vs-AP on the
hart id and biases every hart's stack slot by PRIMARY_HART, so the primary
is slot 0 whichever hart it is; under #if PRIMARY_HART != 0 that costs one
extra instruction, which is why ra moves by 4 between the two captures.
A hart-0 capture is byte-identical to what it always was.
WHY IT IS WORTH DOING. ColdBoot.cold_regs takes the hart id as an
argument, and the boot chain does exactly two things with it: it stores it,
so csrr mhartid reads it, and it copies it into a0. Those two claims are
what make csrr mhartid the right way for a program to tell harts apart --
VConc.g0_of's header rests on them, and so does the whole multi-hart
harness -- and no hart-0 test in the tree can check either of them, since
on hart 0 both values are 0 and a model that ignored the argument entirely
would pass.
What the hart-1 captures established, all of it new:
core_hart |
QEMU reports mhartid = 1, and the model started on hart 1 reproduces it. a0 = mhartid on both machines, checked by the program itself rather than by reading two fields. |
core_regs_mcsr |
of the whole M-mode CSR dump, exactly one word differs from the hart-0 capture, and it is mhartid. Stated by rebuilding the hart-1 capture out of the hart-0 one with that word replaced, so it excludes both a model that leaks the id elsewhere and one that ignores it. |
core_regs_gpr |
29 of the 31 GPRs are identical. The two that move are a0 (the id) and ra (the longer prologue). sp, t0 and t2 do NOT move, which is the check that the slot bias is right -- had it been wrong, sp would be a page off. |
core_regs_scsr, core_regs_pmp, core_regs_hpm, core_regs_fcsr, core_smoke |
byte-identical result regions on hart 1, model and machine. The hart id does not leak into the S-mode CSR file, the PMP file, the HPM file or the fp control file. |
core_regs_ctr has no hart-1 capture on purpose: its fields are the cycle
and instruction counters, which differ between two runs on the SAME hart, so
a cross-hart comparison would say nothing. core_regs_fpr has none because
it traps by design and never publishes a result.
By default a fault ends a run: mtvec is 0, the pc goes there, the fetch at
an undeclared address faults again, and the machine trap-loops forever (see
the rules below). That is fine when the trap is the ANSWER -- CoreRegsFpr.v
pins the pc at two step counts and reads the story out of mcause/mepc/
mtval. It is useless when a trap is something to STEP OVER, for two
reasons:
- The loop destroys the evidence. The second trap overwrites
mcause,mepcandmtvalwith its own. Measured on the board:core_regs_mcsrreportedmcause=1 mepc=0 mtval=0, which says nothing; catching the FIRST trap with a hardware breakpoint at 0 gavemcause=2 mepc=0x8000008c mtval=0x30a022f3, i.e.csrr menvcfg, which says everything. - One trap ends the run, so a program can only ever report its first failure. A probe that wants to ask forty questions gets one answer.
trap.S is an M-mode handler that records the trap and resumes
past the faulting instruction. A test #includes it after its body and
points mtvec at _vtest_trap. It is not in vtest.S, because putting it
there would add bytes to all 60 images to serve the two tests that want it.
Its contract is in its header and a test using it must obey all four points
-- in particular t0 and t1 are clobbered by any trap and it advances
mepc by 4, so such a test needs .option norvc of its own.
Reading a CSR over JTAG is not the same question as executing csrr.
All twenty of core_regs_mcsr's CSRs read fine through OpenOCD on the U74 --
menvcfg included, answering 0 -- while the hart refuses to execute a read
of it. The debug module can report a value for a register the machine will
not let an instruction touch, so only a csrr in the image measures what a
kernel would see. This is why the probe below exists at all.
core_csrprobe (both machines) and core_csrwide (QEMU only, marked
machines=qemu in its vtest: directive) split the question in two,
because the model cannot be asked the second half -- see finding 32.
| model | QEMU virt | VisionFive 2 U74 | |
|---|---|---|---|
| 32 standard M/S CSRs and counters | ok | ok | ok |
menvcfg, mconfigptr, senvcfg (priv 1.12) |
ok | ok | trap |
time |
ok | ok | trap |
mseccfg |
no transition | trap | trap |
mstateen0, sstateen0, scountovf, mcyclecfg, minstretcfg, ssp |
unreached | trap | trap |
| an all-zeros instruction word (the control) | trap | trap | trap |
The control is load-bearing. Without it, a run in which the handler was
never reached is indistinguishable from one in which nothing trapped -- every
entry would be 0 either way. It is also the only place the MODEL's trap
machinery runs in core_csrprobe, since the model refuses none of the 36,
so it is what establishes that the model takes the trap, runs the handler and
returns through mret correctly.
The machine has two 16550s (DevModel.uart_id), and every observation
channel is indexed by port: serial_of and a run's o_uart are one wire
per port in enum uart_id order, and the wire claim is TOTAL -- it says
what BOTH wires hold, so a byte the model puts on the wrong port is a
violation and not something nobody looked at. A one-port case therefore
still pins port 1's wire as empty, and o_serial1 is emitted for every run.
The second port exists on QEMU virt only with a second -serial
backend, so a uart1_ case declares platforms=qemu uarts=2. THE MODEL
ALWAYS HAS BOTH -- uarts= is a fact about the machine QEMU builds, not
about DevModel -- so a one-port case runs against a model whose second
port simply never moves, and the only thing that would diverge is a
uarts=1 program that touched 0x1000a000: the model would answer it and
the machine would fault. Nothing does. It is not
on the board profile at all (finding 30: that machine's UART is a different
chip), and platforms=qemu there is the usual statement that the question
cannot be ASKED on the other machine, not that it fails.
uart1_both is the case only a two-port machine can ask: it transmits two
different strings byte-by-byte alternating between the ports, gives the two
ports different scratch bytes and different MCRs, enables the FIFOs on one
and reads the other's ISR, and enables the THRE interrupt in one IER and
reads both ISRs. Every other uart case would pass against a model with
ONE uart_state aliased at both windows. uart1_irq is the routing:
port 1's line reaches the PLIC on source 12, is not delivered while only
source 10 is enabled, and the claim hands back 12.
- The RESULT region (
abi.h). QEMU side: read out of guest physical memory over QMPxp. Model side: read out ofgmem. The whole 4 KB is compared, untouched zeros included -- nothing is trimmed, so a difference cannot hide in the tail. - The disk image. QEMU side: byte-diff the raw
-drivefile after the process exits, reported per 512-byte sector. Model side:disk_readoverv_disk. This is the channel that sees what a write request actually did.
The serial console is deliberately NOT a channel: printing a result costs ~10 instructions per character, and the model executes every one of them. an early hex-printing version was 1600 instructions / 23 s; the same observations through memory are ~40 instructions / 2 s.
They come from the ABI and from what the model is:
- Only the regions in
abi.hare mapped. Anything else is a STUCK model. - No dependence on
a1/a2. a0 (hartid) agrees, but the model writes a1 to a hardcoded0x1000that is not the DTB address (finding 18) and never writes a2 at all. - No LR/SC:
exectreats the reservation outcomes as stuck. -smp 1. The model has 8 harts, but a schedule that only ever steps hart 0 is a legal execution, so this costs nothing.- A bad FETCH is a TRAP LOOP, not
VStuck.mtvecis 0 at power-on, so an illegal instruction sends the pc to 0, where the fetch at an undeclared address raises an access fault that traps back to 0 -- forever. The status isVBudget, and the way to tell it from a small budget is to pin the pc at two different step counts (CoreRegsFpr.vdoes).VStuckis for DATA accesses the model refuses. - The clock never ticks. The harness always steps
riscv_step false, somcycle/cycle/timeare frozen whileminstretDOES advance -- which is the control showing the freeze is about the clock and not about counters in general. Consequence: no vtest image can observe elapsed time, so this suite cannot reach the timer-interrupt path at all, andmip.MTIPcan only ever appear as power-on garbage. - QEMU's
-kernelboot contract passes a0 = hartid, a1 = the DTB pointer, AND a2 =0x1028(virt's reset ROM leaves a pointer to thefw_dynamicinfo struct there). The model's chain writes a0 and a1 and nothing else; a2 is admitted as a witness. See finding 18 for a1. - Do not write FCR bit 0 in a test that receives. Flipping FIFO-enable
flushes the receive path -- on BOTH machines now,
uart_writeoffset 2 implements it -- and the host's first byte is already in QEMU's holding register before the guest's first instruction runs, so an FCR FIFO-enable at the top of the program silently eats byte 1 (measured: reads0x42first, 8 runs of 8). The model cannot reproduce the RACE, only the flush, which is why this is a rule for the test rather than a finding. Relatedly, with the FIFOs off QEMU delivers one character at a time and re-offers the next only after RHR is read, so any "LSR bit 0 immediately after a read" field is a race against the host rather than a fact about the device; order plus a spin before each read establishes the same property deterministically. - A
pt_program must pinmenvcfg.ADUEexplicitly, beforesatp. Power-on ADUE DIFFERS (finding 20): clear in the model, set on the machine. So a page table written the natural way (V|R|W|X, no A/D) needs an A/D update on its first access, which FAULTS in the model and SUCCEEDS on QEMU -- and that makes an unrelated translation test diverge in a way that looks like a walk bug. Set or clear bit 61 deliberately in everypt_program; Svadu is the natural default, being whatstart()gives xv6. QEMU does accept the CLEAR (verified -- ADUE is WARL, so this was not a given), so the Svade arm is testable on hardware too. - Use
lla, neverla, to materialise an address.laassembles to a GOT load; the GOT is outside.textand the image is built withobjcopy -j .text, so it is not in the image at all. The model then reads an undeclared address and goes STUCK -- which looks exactly like a genuine "the model does not decode this" finding. Whenever a test reportsVStuck, checkstuck_pcagainstriscv64-linux-gnu-objdump -d tools/vtest/build/<name>.elfand confirm the instruction it names is the access you meant to test. - The QEMU command line is fixed by
vtest.py, and-global virtio-mmio.force-legacy=falseis part of it: without that flag QEMU presents a LEGACY virtio-mmio device (Version reads 1, not 2) and every register test disagrees for the wrong reason.
- The virt PLIC is built with
num-priorities = 7and silently ignores any priority or threshold write above 7. A probe using threshold 9 looks exactly like "the threshold does not mask". Keep every value <= 7. - QEMU's
sifive_plic_writedoes not recompute the context notification on an ENABLE write, so it lags by one register access.plic_mask.Sre-writes the threshold with its existing value right after the enable write to force the recompute; that write is a no-op on the model side. - QEMU defaults to LEGACY virtio-mmio (Version reads 1);
-global virtio-mmio.force-legacy=falseis in the fixed command line. - A QEMU process that outlives its test holds the disk image's write lock, and
the next run dies with "Failed to get write lock"; the runner quits it in a
finally.
A test compares field by field, so a known divergence does not have to make the build red. The convention is to pin the value on BOTH sides:
Lemma t2_rw_model_diverging : ... = t2_model_diverging. (* [8; 0; 512; 512] *)
Lemma t2_rw_qemu_diverging : ... = t2_qemu_diverging. (* [1024; 25684; 1; 513] *)
Lemma t2_rw_really_diverges : t2_model_diverging <> t2_qemu_diverging.with a comment classifying it. This is green today and the model-side equation goes RED the day someone changes the model there -- which is exactly when the test file should be revisited, and is the property a comment alone would not give.
Sixteen of these have been FIXED rather than recorded -- the whole UART register file, its interrupt-status semantics and its bus decode; the PLIC's threshold, its M contexts and its source count; and the virtio disk's register decode, its queue sizes and its used-ring reporting. They are in Findings fixed below, with what the fix was and which test now passes because of it; they are kept because the point of this table is the register of what differential testing FOUND, not only of what is still open.
Classified as incompleteness (the model is stricter than the hardware, so some real driver has no model execution -- cannot make a proof wrong, but limits what can be verified) or defect (the model produces a value the hardware never produces, so a proof that depends on it is about a device that does not exist).
| # | what | model | QEMU | kind | found by |
|---|---|---|---|---|---|
| 2 | offered / negotiated features | FLUSH|CONFIG_WCE, negotiates 0 |
0x30006e54, negotiates 0x6454 |
incompleteness | disk_rw |
| 17 | a descriptor chain that is not exactly THREE descriptors | the device STALLS (virtio_stalled); only DevStepDiskWild covers it |
served normally -- 512 bytes written, status 0 | incompleteness in practice | disk_chain |
| 18 | a1 at entry -- the device-tree pointer |
0x1000, a HARDCODED constant in rv64d.v's init_boot_requirements |
the real DTB address (0x87e00000), which moves with -m and the image |
defect (boot contract) | core_regs_gpr |
| 19 | misa bit 7 (H), and mideleg as its consequence |
H absent; mideleg 0 |
H present; mideleg 0x1444 (VSSIP/VSTIP/VSEIP/SGEIP, hardwired when H is implemented) |
incompleteness | core_regs_mcsr |
| 20 | menvcfg bit 61 (ADUE) at POWER-ON |
0 (Svade), so an access needing A/D FAULTS |
0x2000... (Svadu), so hardware writes the bit back |
incompleteness, and a FALSE board assumption | core_regs_mcsr, pt_ad |
| 26 | the model's TLB is DIRECT-MAPPED (64 entries, tlb_hash = the low 6 bits of the VPN) |
at a colliding VPN the entry is evicted by the very next fetch, so a PTE rewritten WITHOUT sfence.vma is re-walked and the NEW mapping is used |
keeps the stale entry | unsound direction, but see below | pt_tlb |
| 21 | misa advertises F and D but the model has NO F/D instructions |
fsd takes an illegal-instruction trap (mcause 2, mtval = the encoding) |
executes | incompleteness + internal inconsistency | core_regs_fpr |
| 22 | CSRs the model implements that QEMU's default virt CPU REFUSES: mseccfg, mstateen0, sstateen0, scountovf, mcyclecfg, minstretcfg, ssp |
implemented, read successfully | illegal instruction | model is WIDER -- needs a ruling, see below | core_regs_mcsr |
| 25 | sc.w does not evaluate |
vm_compute does not return (110 s+), so a test containing one cannot be COMPILED |
executes | out of scope: LR/SC is not supported | conc_amo |
Seven more, from running the suite on a StarFive VisionFive 2 over JTAG --
though one of them, 27, is withdrawn; see below.
README-hw.md is where they are written up, what a board
run claims (it is narrower than a QEMU run), and how the rig works; this
table stays the authority for the numbers.
Two of the first three questions asked of a board produced findings that QEMU testing structurally could not have found: every QEMU test runs on hart 0, and the harness's clock never ticks.
| # | what | model | the board | kind | found by |
|---|---|---|---|---|---|
WITHDRAWN -- IT WAS MY REASONING, NOT THE MODEL. RiscvLang.mnode_step's instruction-boundary rule is exists tick : bool, so an execution with a moving clock is one the model ALLOWS; VTest.run_until steps riscv_step false and only ever exhibited one branch. ClintTime.v §4 runs the other one and the model advances mtime and mcycle exactly as both machines do |
withdrawn | clint_time |
|||
| 28 | the CLINT is not indexed by hart | only hart 0's registers exist -- clint_load/clint_store compare the offset with eq_vec against MSIP_BASE = 0 and MTIMECMP_BASE = 0x4000, and every other hart's access takes a load access fault |
every hart has its own MSIP at CLINT+4*hartid, with exactly the semantics the model gets right for hart 0 |
UNSOUNDNESS, and live in xv6's start() |
clint_msip |
| 29 | misa, all three different |
model 0x…14112D (A C D F I M S U) |
QEMU adds H (0x…1411AD, finding 19); the board adds B and X and has no H (0x…94112F) |
incompleteness both ways | core_csrprobe |
| 30 | the UART is a different chip on this board | a byte-strided 16550 in an 8-byte window (DevModel.uart_size = 8) |
Synopsys DW-APB v3.14a, reg-shift 2 -- LSR is at 0x14, and the whole register file lies outside the model's window |
board difference, not a model defect | the probe |
| 31 | CSRs the U74 refuses that the model and QEMU both implement: menvcfg, mconfigptr, senvcfg (privileged spec 1.12 additions the core predates) and time (SiFive leaves rdtime to firmware to emulate, which is what OpenSBI does on this board) |
implemented, read successfully | implemented | illegal instruction | model is WIDER than the board |
| 32 | the model has no transition for csrr mseccfg -- and this is now a THEOREM, not a reading of VStuck. vtest-rocq/VExecStuck.v splits exec's failure into ENoStep / EChoice and proves exec_r_no_step; stuck_why answers Some ENoStep here, so by that theorem the RELATION has no step. Finding 22 records these seven as "implemented, read successfully"; the model does not read mseccfg at all |
no transition (proved) | illegal instruction | illegal instruction | the model is NARROWER than both machines |
| 33 | the machine's identity: mvendorid, marchid, mimpid |
0, 0, 0 -- an anonymous machine |
QEMU also 0, 0, 0; the board answers 0x489 (SiFive's JEDEC id), 0x7, 0x4210427 |
incompleteness | core_csrprobe |
| 34 | the I-cache is not coherent with the data side. core_icache executes a one-instruction probe, overwrites its word with an ordinary store (no fence.i), executes it three more times, then once more after a fence.i |
BOTH executions (claude-notes/design/icache.md): a fetch reads at any view at or above the hart's INSTRUCTION view, which only fence.i raises. VIcache.v exhibits them as the two fetch schedules of CoreIcacheSched.v: IFresh (1 2 2 2 2) and IStale (1 1 1 1 2) |
QEMU: the NEW instruction at once, every run (TCG invalidates its translation of a written page) -- 1 2 2 2 2. The U74: the OLD instruction on every fetch until the fence.i, 5 runs of 5 -- 1 1 1 1 2 |
the model is RIGHT to be non-coherent, and an xv6 exec without its fence.i would run stale text on this board |
core_icache |
| 35 | the board does not hand the payload a power-on register file, and the model's reset assumes it does. Two register files on the VisionFive 2 hold state at _vtest_body that no reset produced: the fp control file (fcsr = 0x44, i.e. frm = 2 and fflags = OF -- firmware executed floating point) and the low PMP entries (pmpcfg0 = 0x0F, entry 0 TOR with RWX, and pmpaddr0..7 -- OpenSBI programs them before it enters the payload). QEMU is started with -bios none, so both files are zero there, which is what the model's reset_pmp and cold fcsr also produce |
0 in both files | QEMU 0 in both; the board as above | NOT an unsoundness -- core_regs_pmp's header already says reset_pmp establishes its values over ARBITRARY garbage and that pmpcfg is not a board obligation. The model's reset is more SPECIFIC than the machine a payload actually lands on, so these words can never be an agree proof on a board |
core_regs_fcsr, core_regs_pmp_fw |
| 36 | the U74 implements two programmable HPM counters, and they are running. mhpmcounter3/4 (and their unprivileged aliases hpmcounter3/4) answer large, boot-varying values on the board; counters 5..18 read 0 there as everywhere, and all sixteen mhpmevent selectors agree |
all sixteen counters read 0 | QEMU's default rv64 CPU also 0; the board's first two are live (measured, one boot: 0x64_8490_0744 and 0x19_0201_0210) |
incompleteness, and unfixable by construction -- a free-running counter cannot agree between two machines that execute a different number of cycles | core_regs_hpm_live |
Finding 29 was previously attributed to core_regs_mcsr, and that was
wrong: that test does not complete on the board at all (it reads for
values, so the U74's refusal of menvcfg ends its run seven registers in).
The board's misa was known only from a JTAG read, and a JTAG read is not
this question -- see "Treating a trap as DATA" above. core_csrprobe
establishes both that misa is csrr-readable on the U74 and what it
answers, so the row is now a measurement.
One row where the board vindicates the MODEL against QEMU, and it is the
first: mideleg. QEMU has the hypervisor extension, so VSSIP/VSTIP/VSEIP/
SGEIP are hardwired into it (0x1444, finding 19's consequence). The board
has no H and reads 0 -- which is what the model reads. Every other row in
this table has the model narrower or wider than the hardware; here QEMU is
the outlier.
Finding 31 has a live consequence. README's rules require a pt_
program to pin menvcfg.ADUE explicitly before satp (finding 20). On this
board it cannot -- the CSR does not exist -- so any port of the pt_ area
to this hardware has to deal with that first.
Finding 32 stands, and getting it onto a proper footing produced the one
piece of infrastructure this suite was missing. It was first written as
"the model has NO TRANSITION", concluded from VStuck -- which does not
establish that, because exec bails on Choose (the Sail monad's
nondeterminism) exactly as it bails where the relation is genuinely stuck,
and exec_run_det only runs the Some direction. That was the same error
as the withdrawn finding 27, one level down.
So the gap was closed rather than the claim weakened.
vtest-rocq/VExecStuck.v defines exec_r
-- exec with its failure clause split into ENoStep and EChoice, proved
to agree with exec everywhere -- and then
exec_r_no_step : exec_r m s = inr ENoStep -> forall x s', ~ run m s x s'which is the converse the tree did not have. VTest.stuck_why reports
which kind a run hit and stuck_why_no_step lifts it to the whole run.
Measured: csrr mseccfg is Some ENoStep -- real stuckness, not a
declined choice -- so the original claim was right and is now checkable.
CoreCsrwide.v carries it as core_csrwide_model_really_stuck.
Finding 25 (sc.w does not evaluate) has NOT been given this treatment
and is the obvious next candidate: it is an exec limit stated as one, but
nobody has asked whether the relation steps there.
FINDING 27 IS WITHDRAWN, and the mistake is worth more than the finding
was. I ran the default runner, saw a frozen clock, and reported a
property of the MODEL. But RiscvLang.mnode_step's instruction-boundary
rule is
| Interface.Ret _ => exists tick : bool, m' = riscv_step tick /\ ...-- the language quantifies EXISTENTIALLY over the tick at every boundary
(the sound weakening of the model loop's deterministic every-
plat_insns_per_tick tick; plat_insns_per_tick is 2). An execution in
which mtime and mcycle advance is one the model ALLOWS. VTest.run_until
steps riscv_step false because that is a convenient DEFAULT for tests whose
subject is not time, not because the other branch is unavailable, and
VTest.run_until_tick takes it: ClintTime.v §4 exhibits a run where the
model advances the clock and reports exactly what both machines reported.
AN ABSENT WITNESS WAS READ AS AN ABSENT EXECUTION, in a suite whose entire question is whether the model ALLOWS what the hardware did. That is the one-directional framing inverted, and it is available anywhere else the language quantifies existentially and a runner resolves the choice: before reporting "the model cannot", check whether the harness ever ASKED it to.
What survives is a fact about the default and not about the model: every
other test here is exhibited on the non-ticking branch, so none of them
observes elapsed time. A test that wants to reason about elapsed time asks
for run_until_tick, as clint_time now does.
Finding 28 is the one only a board could find. ClintMsip.v carries
both halves, and either alone would be misread: on QEMU's hart 0 the model's
MSIP semantics are RIGHT end to end -- including raising and dropping
mip.MSIP, which a "stored but inert" model would fail exactly as the
UART's MCR did in finding 6 -- and on the board's hart 2 the same program
completes on the machine while the model takes a load access fault at
0x8000006c with mtval = 0x2000008 and trap-loops forever. It is the
same shape as findings 11 and 12, where the PLIC was indexed by hart instead
of by CONTEXT and half the register file was simply absent.
tools/vtest/cva6.py asks the same question of the RTL of the OpenHW
CVA6 core (cv64a6_imafdc_sv39, pinned in tools/vtest/cva6/build.py),
inside CVA6's own corev_apu testharness, simulated by Verilator. It is the
first platform that is a hardware design rather than an emulator or a
chip, and it exists because of the effort in
claude-notes/projects/hw-refinement.md: xv6 proved on a specific core.
The runner's docstring says what a run claims; in short:
| QEMU | VisionFive 2 | CVA6 RTL | |
|---|---|---|---|
| the image | the image | rebuilt with board -Ds |
QEMU's, byte for byte |
| start state | reset | what firmware left, patched over JTAG | a real reset through CVA6's boot ROM (a0 = hartid, a1 = its DTB, s0 = 0x80000000) |
| determinism | racy cases vary | varies | deterministic, one run |
| harts | as many as asked | 4 U74s | one -- conc_* say platforms=qemu,jh7110 |
| UART | 16550, byte stride | DW-APB, reg-shift 2 | PULP apb_uart, reg-shift 2, PLIC source 1 -- no uart_ case yet |
| PLIC | 96 sources | 96 | 30 sources |
| disk | virtio | none | none |
cva6 is in the default platforms=, so a case runs there unless it says
otherwise. The simulator is built on the build VM (tools/vtest/cva6/:
build.py takes CVA6's own Verilator file list and swaps in
vtest_tb.cpp, which backdoor-loads DRAM, polls DONE and decodes the
16550's SOUT pin; Verilator v5.008 with CVA6's patch, as CVA6's CI pins).
A run is a fraction of a second.
First sweep, 2026-09-27: 30 cases runnable, 30 captured, 19 pass (18
agree, 1 stuck -- core_csrwide, as on QEMU). Eighteen of the thirty
result regions are byte-identical to QEMU's. Of the eleven red, five are
the value dumps that are red on every platform (clint_raw, core_dtb,
core_csrvals, core_regs_fpr, pt_tlb_set0); the other six are these:
| # | what | model | CVA6 | kind | found by |
|---|---|---|---|---|---|
| 37 | no Svadu: menvcfg.ADUE is hardwired 0 |
ADUE is writable, so after the write the walker updates A/D and the access succeeds | the write is dropped (WARL), the access page-faults (0xbad1), the PTE is untouched |
model NARROWER -- no execution in which ADUE ignores the write; also the gap between this core and xv6's start() |
pt_adu, pt_ident |
| 38 | CSRs CVA6 refuses: time (no hardware rdtime; left to firmware as on the U74) and tselect (no trigger module in this config) |
implemented | illegal instruction | model WIDER, as finding 31 | core_csrprobe (and why core_regs_ctr is not run here) |
| 39 | no Sstc: stimecmp is an illegal instruction |
implemented | illegal instruction | model WIDER -- and xv6's timer uses it | core_regs_scsr (not run here: it never publishes) |
| 40 | reset and identity values: mtvec = 0x10040 (the boot ROM's park loop), mvendorid = 0x602 (OpenHW), marchid = 3 (CVA6) |
0, 0, 0 | as stated | incompleteness, as finding 33 | core_regs_mcsr |
| 41 | the boot ROM leaves s0 = 0x80000000 | s0 = 0 | the jump register | boot-contract difference, like QEMU's a2 | core_regs_gpr |
| 42 | the PLIC has 30 sources: source 32's priority register does not exist (write dropped, reads 0) | 96 sources, reads back 4 | 0 | platform parameter | plic_prio0 |
Where CVA6 sides with the MODEL against QEMU: pt_ad. The case leaves
menvcfg alone; the model's power-on ADUE is 0, CVA6's is 0 (it has no
Svadu at all), QEMU's is 1 (finding 20). So CVA6 takes the page fault the
model takes, and the run passes. And core_icache is stale on CVA6
exactly as on the U74 (finding 34) -- ipol_cva6=stale -- where QEMU is
coherent: the model's non-coherent fetch view is right about this core too.
These are findings, not deleted rows. Each was a real difference between the model and the machine and each is now a passing test rather than a pinned divergence, but the register of what differential testing FOUND is the point of this file, so they keep their numbers, their values and their original classification. All five were in the 16550 and they went together: half of them are one modelling shortcut seen from different sides.
| # | what | the model USED to say | the machine -- and the model now | kind it was | pinned by |
|---|---|---|---|---|---|
| 6 | UART MCR (4), MSR (6), SCRATCH (7) | 0 / 0 / 0, and writes discarded |
3 / 0xb0 / 0x5a: five bits of MCR including LOOPBACK, the port's modem inputs (MCR's outputs under LOOP), and a byte of storage |
incompleteness | uart_regs, uart_loop |
| 7 | UART ISR bits 7:6 (FIFOs-enabled) | hardcoded set: 0xc1 at reset |
0x01 until FCR bit 0 enables the FIFOs, 0xc1 after |
incompleteness | uart_regs, uart_irq_tx, uart_irq_rx |
| 8 | UART THRE interrupt: LEVEL or LATCH | a level -- the second ISR read still 0xc2 |
second read 0xc1: the read cleared it. The latch u_thri arms when the transmitter falls idle, when an FCR write clears the tx FIFO, or when IER bit 1 is written while it is already idle |
incompleteness | uart_regs, uart_irq_tx |
| 9 | UART access WIDTH | width 1 only -- a 4-byte read was STUCK | 0x00000008: the bus NARROWS to the one byte register the address names (zero-extended out, low byte in), at 2, 4 and 8 bytes, and does NOT gather registers into a word |
incompleteness | uart_width |
| 23 | RHR read on an EMPTY receive FIFO | 0 |
the LAST byte received: the holding register is cleared neither by a read nor by an FCR clear, only the DR FLAG is -- and with the FIFOs ENABLED the machine answers 0, which is the FIFO's output stage |
incompleteness | uart_rx, uart_dlab |
| 24 | THE MEMORY MODEL was sequentially consistent | one shared gmem, a store global the instant it retired -- store buffering's (0,0) unreachable by any schedule |
(0,0) in 4.6% of runs. The machine is now Ztso (RiscvLang.mnode_step, TsoMemPa): a plain store appends to the era's write log without moving the author's view, a plain load reads latest-visible at a view of its choosing, and VConc's CCpuStale exhibits (0,0) -- see below |
unsoundness | conc_sb |
| 10 | PLIC claim ignores the context THRESHOLD | the masked source's id, and its pending bit cleared | 0, and the source STAYS pending. The threshold now lives in plic_cand, which plic_eip and plic_best both read |
unsoundness (and a defect) | plic_thresh |
| 11 | PLIC M-context registers (enable 0x2000, threshold 0x200000, claim 0x200004) | not decoded -- STUCK at the first M access | all three serviced: the PLIC is indexed by CONTEXT (plic_nctx, plic_mctx/plic_sctx), both halves of every hart's pair, and each context drives its own pin |
incompleteness | plic_mctx |
| 12 | PLIC source 0's priority register, and plic_nsrc = 32 against the board's 96 |
STUCK at both bounds | offset 0 is read-only zero (source 0 does not exist); 96 sources, so the enable and pending bitmaps are three words each | incompleteness | plic_prio0 |
| 1 | QueueNumMax, and QueueNum writes |
8, and only {1,2,4,8} accepted | 1024, and any power of two up to it. An illegal size is still refused, which is deliberate | incompleteness | disk_rw, disk_ident_qnum |
| 3 | DeviceFeaturesSel (0x14), DriverFeaturesSel (0x24) |
not decoded -- STUCK, so no feature bit above 31 could be read OR acked | both are real registers; word 1 carries VIRTIO_F_VERSION_1, the ack lands in the word the selector names, and a full 1.x negotiation reaches Status 11 |
incompleteness | disk_ident_featsel, disk_ident_drvfsel |
| 4 | used.ring[i].len |
vr_len -- the data descriptor's length -- in every direction |
the device-WRITABLE part of the chain: 1 for a write, 513 for a read, and 513 for an unrecognised type on a read-shaped chain. The discriminator is the descriptor's WRITE flag, not the request type | defect | disk_rw, disk_order, disk_err |
| 13 | virtio CONFIG SPACE: capacity (0x100), ConfigGeneration (0x0fc) | not decoded -- STUCK | the capacity is a field of the device state (v_cap), set by whoever attaches the image and kept across a reset; generation 0 |
incompleteness | disk_ident_cap, disk_ident_confgen |
| 14 | virtio QueueReset (0x0c0) and the SHM registers (0x0ac..) |
not decoded -- STUCK | QueueReset reads 0 (this device does not offer RING_RESET); every SHM region reads all-ones, which is how the transport says a region does not exist |
incompleteness | disk_ident_qreset, disk_ident_shmsel |
| 15 | sub-word access anywhere in the virtio window | not decoded -- STUCK | the transport is 32-bit: a 1- or 2-byte read reaches no register and answers 0, and a narrow write is dropped | incompleteness | disk_ident_rd1/rd2/wr1 |
| 16 | a per-queue write with QueueSel /= 0, and QueueNotify /= 0 |
REFUSED -- STUCK | both accepted and ignored. What kept the queue geometry legal was never the refusal but virtio_live's own conditions |
incompleteness | disk_ident_qsel, disk_ident_notify |
| 5 | the completion ORDER of two in-flight requests | publication order ONLY -- virtio_req_step read the chain at v_seen and handed back v_seen + 1, so no schedule could reorder |
EITHER order, and the model now has both: the device POPS available-ring entries in order (virtio_pop_step, v_inflight = the popped heads) and completes ANY in-flight head (virtio_complete_step takes the head), VSched picks which, and DiskOrder.v exhibits the two executions against the two captures. The Iris driver proofs are ported to it (design/virtio-driver.md) |
UNSOUNDNESS, RESOLVED | disk_order |
Finding 10 was the serious one of the PLIC three, and the only PLIC
finding that was not mere incompleteness: the threshold appeared in plic_eip
alone, so the model's NOTIFICATION and its CLAIM disagreed -- a source could be
invisible to a context and still be what that context's claim register handed
back, with its pending bit cleared on the way out. The fix is not a special
case in the claim but a move: the threshold went into plic_cand, the one
predicate that says what a context can see, and plic_eip became
existsb (plic_cand p c) while plic_best folds the same thing. The two
cannot disagree now because they are the same question asked twice ("is there
one?" versus "which one?"), which PlicThresh.v states off the model as well
as off the program. It also subsumed the old 0 <? p_prio guard -- a
priority-0 source cannot exceed a threshold of 0 -- so the model got smaller.
Findings 11 and 12 were one change: the PLIC stopped being indexed by hart.
It has CONTEXTS, the board wires two to each hart (M then S), and every
per-context register family -- enable bitmap, threshold, claim/complete -- is
now decoded for all of them, with dev_meip beside dev_seip and a second
wire arm in RiscvLang.plic_step so that an M context's notification reaches
its pin rather than being computed and dropped. Decoding the registers while
leaving the pin unmodelled would have been the same half-fix as a
stored-but-inert control bit. The source count went with it: 96 sources means
three enable words per context and three pending words, which is what makes
source 32's priority register (plic_prio0 +20) a register at all.
Finding 4 is the one that had to be got right rather than merely made.
Two rules both explain "1 for a write, 513 for a read": key off the request
TYPE, or key off the data descriptor's WRITE flag. disk_err is what
separates them -- an unrecognised type and a FLUSH, both published through a
read-shaped chain, where the device writes no data at all. The spec's wording
("bytes WRITTEN into the device-writable part") says 1; the hardware says 513,
because what a real device reports is the writable SEGMENT and not the
transfer. Keying off the type would have passed disk_rw and produced 1
there -- a value the machine does not produce, which is finding 4 itself moved
from the read path to the error paths. So vreq_used_len reads vr_wr, the
flag the parser takes off the descriptor.
Findings 3, 13, 14, 15 and 16 were all the same shape: an access the
hardware answers and the model had no transition for, so a driver that made it
had no model execution. Nothing about them was subtle once each register's
meaning was pinned; what they cost was reach, and the two that had real
drivers behind them are 15 (a narrow read of a status byte) and 16 (a driver
that touches a queue this device does not have). Finding 13 needed one piece
of modelling rather than decode: v_disk is a total function, so the medium
has no edge of its own and the CAPACITY had to become a field of the device
state, set by the machine that attaches the image.
Finding 6 was the load-bearing one of the UART five, and not because of the values. MCR
bit 4 is LOOPBACK, so a readable MCR that ignored bit 4 would have been worse
than the register that read as zero: a driver's self-test would have put its
byte on the WIRE, which is a thing the hardware never does -- turning an
incompleteness into a defect. Making the register real meant making the mode
real. uart_tx_pop has two arms, and uart_state carries u_wire (what
left on SOUT -- the console-observable trace, and what VTest.serial_of
compares) beside u_out (what the TRANSMITTER finished with, loopback
included), so that the transmitter-token argument in WpUart.v, stated on
u_out ++ u_tx, is untouched by where the byte went. uart_loop checks the
whole of it against the machine, serial channel included: the loopbacked byte
comes back on RHR and the host sees only the byte sent after LOOP was cleared.
What these fixes deliberately did NOT change, each for the same reason -- the safe direction is the model producing MORE interrupts and accepting MORE input than the machine, never fewer:
- the RECEIVE interrupt is a level on the hardware too, so
uart_rx_intis what it always was (uart_irq_rxis the evidence); - the ISR's receive condition triggers at ONE byte rather than at FCR's trigger level. Modelling the trigger level without the character TIMEOUT interrupt would err toward FEWER interrupts, and one byte is the level xv6 and every test here select anyway (FCR bits 7:6 clear);
- the model's rx FIFO is 16 deep in both FIFO modes, where the hardware has a
one-byte holding register with the FIFOs off. The model accepts more host
input than the machine offers, and
uart_rx_pushREFUSES when full, which is the flow control QEMU's front end applies rather than an overrun.
Finding 4 is the one worth acting on. The spec defines the used element's
len as the number of bytes written into the DEVICE-WRITABLE part of the
chain -- the status byte alone for a write request, the data buffer plus the
status byte for a read. VirtioModel.virtio_used_elem_writes writes the data
descriptor's length regardless of direction, which is a value no real device
produces. Nothing in the tree has noticed because xv6's driver ignores the
field. The fix needs the request TYPE at that point, so it is local; what
makes it a decision rather than a drive-by edit is that VirtioModel.v's
reverse-dependency closure is 1286 files.
Finding 5 was the serious one, and it is RESOLVED. disk_order
publishes two write requests in one batch -- eight sectors at 100 and one
sector at 5 -- and records which descriptor id lands in which used-ring slot.
QEMU produces BOTH orders:
-drive options |
in order | reordered |
|---|---|---|
cache=writeback (the default) |
19/25 | 6/25 |
cache=writeback,aio=threads |
20/25 | 5/25 |
cache=none,aio=threads |
3/25 | 22/25 |
cache=none,aio=native |
0/25 | 25/25 |
The model used to have no execution for the second: virtio_req_step read
the chain at position v_seen and handed back v_seen + 1, so the served
order WAS the publication order. That was the other direction from findings
1--3 -- the hardware having a behaviour the model had no transition for, so a
theorem proved against the model did not cover the machine -- and it was
live in xv6: virtio_disk_intr reads each used element's ID and wakes
disk.info[id] precisely because completions need not come back in order.
What the model does now (VirtioModel.v section 5b): the device POPS
available-ring entries strictly in order (virtio_pop_step; v_seen is the
pop index and v_inflight the set of popped, uncompleted descriptor HEADS)
and serves any in-flight head one bus transaction at a time
(virtio_fetch_step / virtio_capture_step / virtio_write_step /
virtio_complete_step take the head). VSched's eager schedules differ only
in which in-flight head they pick (lowest_head / highest_head), and
DiskOrder.v proves the model admits both captured orders
(disk_order_admits_inorder, disk_order_admits_reordered,
disk_order_model_has_both) plus the general facts model_pops_in_order
and model_completes_any_inflight_head. The Iris side followed: the keyed
protocol serves any position (vp_srv, vp_lo, vp_uix), and the driver
proofs (virtio_disk_rw, virtio_disk_intr) are stated over a
per-descriptor receipt keyed by head -- claude-notes/design/virtio-driver.md
and claude-notes/completed/virtio-finding5-driver-port.md.
plic_cand is pending && enabled && (0 < priority) and never mentions the
threshold; the threshold appears in plic_eip alone. So the model's
notification and its own claim disagree with each other: a source masked by
the threshold raises no notification, yet plic_claim hands it back and
clears its pending bit. QEMU returns 0 and leaves the source pending, so
lowering the threshold later re-exposes it.
Like finding 5, this is not merely a wrong value: plic_read's claim arm is a
pure function of plic_state, so no schedule can produce QEMU's answer.
It is not live in xv6 -- plicinithart writes threshold 0 -- but
PlicPlan.v deliberately leaves the per-hart threshold completely free, so
the device invariant admits exactly the states where the two disagree.
The fix is three tokens and makes the two halves agree by construction: move
the threshold conjunct from plic_eip into plic_cand (where it subsumes
0 < priority), leaving plic_eip := existsb (plic_cand p h) plic_srcs.
plic_level shows the model re-forwarding a still-asserted level source after
plic_complete, where QEMU does not: QEMU's PLIC takes pending off the RISING
edge only. A level gateway that re-forwards is the spec-faithful behaviour --
it is why a driver acknowledges the DEVICE before completing at the PLIC -- so
no model change is wanted. The model does have a matching execution (SLatch
is never forced), but VSched.settle is eager and takes every enabled arm, so
the harness forced a forward the RELATION never required.
Now reproduced. settle1_gated takes a latch flag (every existing
caller passes true, so nothing else moves), and vtest-rocq/QEMU/PlicLevelRun.v
is HAND-WRITTEN with a device schedule -- the first user of the generator's
hand-written escape hatch. The schedule is a CREDIT, not a step count: what
the execution is is "the gateway forwards ONCE", which is what an
edge-triggered gateway does with a level that rises once and is stated without
reference to the program. settle1_credit offers each round without the
gateway first and spends a credit only when nothing else is enabled, so it
counts FORWARDS rather than settle rounds. The run reproduces QEMU's result
with zero differing bytes.
chain_at requires exactly three descriptors, so a 4-descriptor
scatter-gather chain leaves the device virtio_stalled and it never
completes -- run_status reads VBudget, and disk_chain confirms
virtio_stalled = true after 700 instructions, so it is a real stall and not
a small budget.
But this is incompleteness in practice, not unsoundness, and the
difference is the whole point of the "model UB as anything, never as nothing"
rule: DevStepDiskWild is enabled exactly when virtio_stalled holds, so
the model's transition relation genuinely does contain QEMU's behaviour --
the device may write anything anywhere, which includes writing what QEMU
wrote. What the model does not contain is anything a driver PROOF could use.
DiskChain.v states both halves off the model (model_refuses_longer_chains,
model_stalled_leaves_only_wild) rather than off the test.
Exhibiting it would need a hand-written schedule interleaving ~550 SCpu
steps around one SDiskWild carrying ~530 (address, byte) pairs built
mechanically from the capture; DiskChain.v section 5 records what that takes.
disk_err runs the UNSUPP and FLUSH paths, where the model reports used.len
= 512 and QEMU reports 513 -- but the spec says 1 on both, since neither
path writes the data buffer. So on these paths QEMU is not spec-conforming
either, and finding 4's fix must be stated against the SPEC (bytes actually
written into the device-writable part of the chain), not against QEMU's value.
conc_sb is the store-buffering litmus test -- hart 0: X=1; a=Y alongside
hart 1: Y=1; b=X -- and QEMU produces (a,b) = (0,0), which no
sequentially consistent machine can. Measured 55 times in 1200 runs (4.6%)
by the agent, and independently re-confirmed at 4 in 200. It is not a
truncated run: in the (0,0) captures X and Y are both 1, so both stores
landed and both loads still returned 0.
QEMU is RIGHT. RVWMO permits a store to be reordered past a later load, the
litmus program has no fence between them, and QEMU on an x86 host exposes the
host's TSO. It was the MODEL that was wrong: RiscvLang stepped every hart
over one byte map, so a store was global the instant it retired, and the
old VConc recorded that off the model (model_hart_sees_the_one_memory,
model_store_is_immediately_global) and enumerated all six interleavings of
the two two-instruction sequences, none of which gives (0,0).
FIXED BY THE TSO CUTOVER. The language is now a store-buffering
machine (RiscvLang.mnode_step over TsoMemPa): the era has a global
append-only write log, every hart a monotone FLOOR into it, a plain store
appends without moving the author's floor, and a plain load reads
latest-visible at any view between the floor and the top -- below the view,
or the hart's own message (which is store forwarding). A W->R fence drains.
Since the load-load relaxation (claude-notes/completed/relaxed-rr.md) a plain
load moves no floor either -- it raises a read WATERMARK and the per-byte
COHERENCE floors, and a fence with an R->R edge acquires (floor to the
watermark). The harness follows: VTso.texec is exec with those arms,
its read policy is a schedule choice, and VConc.CCpuStale runs a hart
reading at the lowest view the model admits. ConcSbSched.sb_00 -- align ++ [CCpuStale hart0 2; CCpuStale hart1 2] -- is the (0,0) execution: no
load moved either hart's floor and conc_sb.S has no fence before the litmus
load, so each hart's floor is still 0, before either litmus store, and each
load reads below the other hart's store. (The two arms it uses were once
stated off the model in VModelFacts; those lemmas went with the
mnode_step arity change in 6e4483a3a, and the arms themselves are the
record.) With sw; fence; lw the same schedule reads at the top and (0,0)
is gone, so xv6's __sync_synchronize() in acquire/release is now
OBSERVABLE.
The model reproduces all four observed outcomes, whole result region each
(QEMU/ConcSbPass). The fresh policy (CCpu) is provably the old harness
(VTso.texec_fresh_exec), so every other multi-hart run computes what it
computed before.
pt_tlb rewrites a level-0 leaf without sfence.vma and reads again, at two
VAs chosen to land in different TLB sets. At set 7 both machines keep the
stale entry -- that is the control. At set 0 QEMU keeps it and the model
has already re-walked, 20 runs of 20.
The cause is exact: tlb is a vec (option TLB_Entry) 64 and tlb_hash is
literally the low num_tlb_entries_exp = 6 bits of the VPN, so the TLB is
DIRECT-MAPPED -- and every fetch in a vtest image is at VPN 0x80000 with
every result store at 0x80100, both = 0 (mod 64), so a set-0 entry is
evicted by the next instruction.
Unlike findings 5, 10 and 24 the model is not missing a FREEDOM -- set 7 shows
it can keep a stale entry. It has a smaller, less associative TLB, and both
are legal implementations. The risk is directional rather than structural: a
theorem over this model can conclude "the access after a PTE write uses the
new mapping" where the real machine uses the old one. Only software that
omits sfence.vma is exposed, and nothing in xv6 is -- but the model hands
such a program the favourable answer instead of refusing it.
A fix is not more capacity: any deterministic finite TLB collides somewhere.
It would have to make eviction NONDETERMINISTIC -- the DevStepDiskWild
over-approximation shape applied to translate. Recorded, not proposed.
pt_adu runs two write-backs under vm_compute at ordinary cost -- no hang,
no VStuck, no internal_error. The sc.w non-termination of finding 25
does not reach this path, and the reason is structural: execute_STORECON
goes through the opaque platform axioms match_reservation/
cancel_reservation, which exec cannot step, whereas
write_pte_conditional is mem_write_value_priv ... con = true -- an
ordinary Interface.MemWrite outcome. exec answers it inl None and
write_ram maps that to true.
Consequence worth knowing: the conditional write ALWAYS succeeds under
exec, so the Ok false -> internal_error arm of update_and_write_pte is
unreachable by this interpreter. Not a defect -- the limit of what a test
in this suite can probe.
Every other row is the model being NARROWER than the hardware. Finding 22 is the reverse: the model implements CSRs that QEMU's default rv64 virt CPU answers with an illegal-instruction trap. Under this suite's premise -- QEMU-virt is the reference hardware -- the hardware's TRAP then has no model execution, which is the unsound direction.
But it may not be a bug at all: it may just mean the model is configured for a
different machine than -cpu rv64 gives us (sail-config-rv64d.json decides
which extensions exist). The same axis produces finding 19 in the opposite
direction -- QEMU has H and the model does not.
This needs a decision that is not the suite's to make: WHICH machine is the
model claiming to be? If it is QEMU's virt board, findings 19 and 22 are
both real gaps and the config should be reconciled. If it is a machine with a
different extension set, the QEMU command line should be pinned to match
(e.g. -cpu rv64,h=false,smstateen=on) and both rows become configuration
notes rather than findings. Until that is settled they are recorded, not
classified.
model-xv6iris/sail-modules.txt includes FD_core -- the registers, fcsr,
and the CSR plumbing -- but no F/D INSTRUCTION modules, so the decoder has no
floating-point instructions at all while misa bits 3 and 5 promise them.
fsd ft0,8(s11) takes an illegal-instruction trap with mstatus.FS correctly
set to Initial first, so FS is not the cause. The fix is a one-liner either
way: add the modules, or set F/D supported: false in the config so misa
stops promising them.
QEMU's FIRST execution of a basic block costs thousands of guest instructions'
worth of wall time in tb_gen_code, so a plain rendezvous does not make two
harts overlap -- whichever is still in the translator loses before it starts.
Measured: a 16-round lost-update loop came out at 32 in 60 runs of 60, and
2048 rounds each was still 0 lost in 25 runs.
The fix, used by all three racing tests: a two-pass structure. Both harts
run the whole hot path once on PRIVATE words, leaving every block in QEMU's
shared TB cache, then rendezvous, then race. With that, two rounds each is
enough. conc_sb additionally gives the harts delay loops of different
lengths to trim hart 0's systematic head start, which raised (0,0) from 1.3%
to 4.6% of runs.
Only store->load reordering appears on an x86-64 host, which is what TSO under MTTCG can expose. Message-passing and load-buffering tests would be guaranteed single-outcome here and are worth adding only if this suite is ever run on an aarch64 host.
Because QEMU has more than one legal execution here, disk_order is captured as
a SET. A vtest: directive in the .S names the repeat count and the
backend configurations to union over:
/* vtest: repeat=20 drives=cache=writeback;cache=none,aio=threads */
No single configuration reliably shows both orders, so a test about
nondeterminism names the ones that between them exhibit its executions;
without that, make vtest-gen is itself flaky. The generated file then
carries <name>_qemu_results : list (list Z) alongside the single
<name>_qemu_result.
Positive results are worth recording too -- each is a way the model could have been wrong and is not.
- The request protocol (
disk_rw): both status bytes, both used-ring ids, the used index, the interrupt status, and the 512 bytes DMA'd in each direction. - The disk image (
disk_rw,disk_order,disk_intr): QEMU has flushed a completed write to the backing file by the time its process exits, so the raw-file diff is a sound channel -- and the model'sv_diskagrees with it byte for byte, including on a multi-sector (8-sector) request. - THE TWO PORTS ARE TWO DEVICES (
uart1_both,uart1_tx,uart1_regs,uart1_rx,uart1_irq): the second window has its own register file (scratch, MCR, IER, FCR/ISR and the THRE latch all answer per port), its own transmit and receive FIFOs, its own wire -- two interleaved strings came back on the two backends with nothing crossed -- and its own PLIC source.uart1_irqpins the routing in both directions: with only source 10 enabled port 1's still-asserted level is NOT delivered (SEIP stays clear, the claim returns 0, the source stays pending), and enabling source 12 delivers the same request and the claim returns 12. - The UART transmit path end to end (
uart_tx,uart_dlab,uart_regs):uart_writeat offset 0 ->uart_tx_pop->u_wireis exactly the byte sequence the host received, in order, nothing lost or duplicated;uart_lsris0x60with the FIFO empty at every point either machine can be asked, including one instruction after a THR store, and LSR reads are pure. - The whole UART register file, read and written (
uart_regs,uart_width,uart_loop): all eight offsets, at all four access widths, in both directions -- see Findings fixed, since every one of those rows is now a confirmation. The two worth naming on their own are LOOPBACK (uart_loop: the transmitted byte comes back on RHR, the modem outputs come back on the modem inputs pin for pin, and the host sees nothing of it -- the serial channel is what proves the last part) and the ISR's LATCH (uart_regs,uart_irq_tx: two reads in a row differ, and the latch is armed by the IER write itself, so a transmitter that is already idle when the interrupt is enabled still interrupts). - The DLAB divisor-latch aliasing (
uart_dlab), which is what the wholeun_dlabghost indesign/device.mdexists for: LCR bit 7's polarity, offset 0 aliasing to DLL and offset 1 to DLM, IER unaffected by a DLM store, the aliasing not sticky, and a byte written to offset 0 with DLAB set NOT reaching the wire. - PLIC arbitration, both clauses (
plic_arb+plic_tie): with the disk at priority 3 and the UART at 7 the claim returns the HIGHER id 10 over the pending, enabled id 1 -- ids are scanned in order, so only the priority comparison explains it -- and the same program with equal priorities reverses the order to 1-then-10. The pair pins both clauses ofplic_better; either alone is passed by a half-wrong implementation, and QEMU breaks ties the same way. - The rest of the controller (
plic_thresh,plic_mask,plic_level): the threshold gates the NOTIFICATION strictly and in both directions (equal is masked, and the pin follows down and back up); the enable bit is not the gateway (a disabled source still latches, is not claimable, and becomes claimable when the bit is set later with no new device edge); a claim clears that source's pending bit and no other and marks it claimed, and neither source re-latches while claimed;plic_completetouchesp_claimedonly; claiming with nothing pending returns 0 and disturbs nothing; the pending word carries two bits (0x402), whichplic_pending_word's fold had never been exercised past one; and pending is sticky across the source going quiet. - The two-device interrupt fabric: the UART raises PLIC source 10 with no
serial input at all (IER bit 1, empty FIFO), and
dev_irq_level's two-source dispatch agrees with the machine. - The UART raises BOTH interrupts, all the way into
mip(uart_irq_tx,uart_irq_rx): IER bit clear -> nothing pending; bit set -> the line goes high -> the gateway latches source 10 -> the wire setsmipbit 9; the ISR names the cause (2 = THRE, 4 = rx available); the S-context claim hands back 10; the condition removed and completed -> pending and SEIP clear. Identical on both machines at every step of both chains. This is the first test to raise source 10 at all, so it is the first evidence that the UART is wired to the controller AND to the right source. It also pins the GATEWAY against the ISR read: on both machines the read drops the UART's line and source 10 stays pending with SEIP still set, because the gateway holds a forwarded request until it is claimed --plic_latchdoes the same. - The virtio RESET command (
disk_ident), never exercised before: Status -> 0,QueueReady-> 0 without the driver clearing it, ISR -> 0, and the offered features untouched.virtio_resetkeeping onlyv_diskis what the hardware does. - The error paths (
disk_err): an unrecognised request type is an ANSWER, not a silence -- status 2 (UNSUPP) and a used-ring slot, with zero data movement on both channels and the disk unchanged; and aFLUSHfrom a driver that DECLINEDVIRTIO_BLK_F_FLUSHstill completes OK on QEMU, so the model's unconditional recognition of type 4 is faithful. The ISR is a bit and not a count: still 1 after three completions. - Per-queue READS at a queue that does not exist (
disk_ident): both answer 0. A driver may LOOK at queue 1; it may not TOUCH it. - The whole S-mode CSR file, the whole PMP file, and the whole HPM file
(
core_regs_scsr,core_regs_pmp,core_regs_hpm) agree byte for byte over the entire result region. None is vacuous:sstatusis derived through the model'slower_mstatuswindow onto the board-writtenmstatus;satp = 0is what makes "translation is off untilstart()turns it on" true OF THE MACHINE; andArchReset.vsays pmpcfg is explicitly NOT a board obligation -- the spec's ownreset_pmpestablishespmp_all_offover arbitrary garbage -- so those sixteen zero entries are produced by the model's reset CODE, and this is the check that the code's answer is the machine's answer. The HPM test also exercises a permission path: an M-mode read of the UNPRIVILEGED alias withmcounteren = 0must succeed, becausemcounterengates S and U and not M, and the model does not wrongly refuse. mstatus = 0xA00000000(core_regs_mcsr):ArchReset.board_regs' power-on obligation for mstatus -- SXL = UXL = 2 and everything else clear, the partreset_sysdoes NOT establish -- is a true statement about this board.mipandtselectare WITNESSED, not findings (core_regs_mcsr): the model admits QEMU's values from a legal power-on file.tselectis the instructive one --read_CSR 0x7A0returnsnot_vec tselect, the architecture's "no trigger selectable" stub, so the default's all-ones readback is justinit_regstate's zero complemented; preset the register to all-ones and the read gives QEMU's 0. This is exactly the case a raw diff would have mis-reported as a divergence.- The UART receive datapath (
uart_rx), every observation exact: bytes come out IN ORDER (u_rxreally is a FIFO -- a LIFO or a one-deep register gives a different answer here); RHR pops EXACTLY one byte per read, established without a race by reading three of a four-byte queue and finding the fourth still there; LSR bit 0 tracks the FIFO across the whole sequence and is not sticky in either direction; and FCR bit 1 clears the receive FIFO -- a liveuart_writearm no test had touched on the rx side, and whatuartinitwrites at boot -- while leaving the receive HOLDING register alone, which is what a read with DR clear hands back. - Instruction-granularity interleaving (
conc_lost): a lost-update race produces exactly three outcomes (4, 2 and 3 over two rounds) and the model has a schedule for each, floor included. It also pins that the UNSCHEDULED round-robin run is the maximally-colliding one, which is the concrete reason a race test must NAME its interleaving rather than let the harness pick. - Sub-word store granularity under concurrent access (
conc_byte): two harts holding a loaded byte while the other stores an adjacent byte of the same word, three rounds, nothing lost -- the same shape that DOES lose an update inconc_lostwhen the byte is shared. amoadd.w(conc_amo) computes the right read-modify-write and both machines answer 8. The atomicity claim is deliberately weak and the file says so:CCpuis instruction-granular, so no schedule here could split an AMO in the first place.- S-mode with paging on, end to end (
pt_ident): the PMP TOR grant that S-mode cannot run without, thesatpwrite andsfence.vma,mretto Supervisor, a level-2 leaf taking 30 offset bits, and FETCH through translation as well as load and store. - The A/D write-back arm against real hardware (
pt_adu), for the first time -- both the A case and the D case, checked by reading the PTE word back out of the page-table page:0x07 -> 0x47and0x47 -> 0xC7, upper 54 bits identical, i.e. the write-back rewrote exactly the flag bits to the same word the hardware wrote. And the Svade arm (pt_ade): the model refuses exactly where the hardware refuses, and writes nothing. - The fault matrix (
pt_fault): five refusals and one grant, withscause,sepcANDstvalidentical in all five.stvalcarries the faulting VIRTUAL address including the sub-page byte offset -- a model reporting the page-aligned VA, the PA, or the PTE address would fail here -- and the instruction-fetch case attributessepcto the unmapped VA itself, not to thejalr.sstatus.SUMgates the identical U-page load in both directions on both machines. - The walk really descends (
pt_levels): a three-level walk to a 4 KB leaf, and a level-1 megapage whose 21-bit offset is checked by a store 1 MB in landing where it should. - Two checks a translation model could quietly have omitted and did not:
the reserved leaf encoding R=0/W=1 (
pt_resv) and the misaligned superpage (pt_super). Each has a control mapping built from the same PPN that works, so the fault is attributable to the CHECK and not to the table. medelegbits 12/13/15: the M-mode backstop never fired in any of the nine images, andsfence.vmaflushes totally on both machines.- The whole interrupt path (
disk_intr): the device raising its line, the PLIC gateway latching source 1, the wire driving hart 0'ssig_seip(visible inmip), the S-context claim returning the source and clearing pending, the device acknowledgement dropping the line, and the PLIC complete clearing the claim. All twelve observations identical. In particular the model's deliberate PROPAGATION DELAY -- the gateway latch and the wire are separate steps, not side effects of the completing MMIO write -- is faithful: the test spins on the pending bit rather than reading it once, so it would still pass if the delay were longer and would fail if the model had quietly made the pin synchronous.