diff options
Diffstat (limited to 'debug')
33 files changed, 7280 insertions, 168 deletions
diff --git a/debug/risc-v-gsp-rm-vm/.gitignore b/debug/risc-v-gsp-rm-vm/.gitignore index f400d55..54dd9aa 100644 --- a/debug/risc-v-gsp-rm-vm/.gitignore +++ b/debug/risc-v-gsp-rm-vm/.gitignore @@ -22,3 +22,8 @@ gpgpu-sim/**/*.so # S4 evaluation evidence: generated sessions are never tracked. harness/qemu_line/sessions/ harness/re_demo/sessions/ + +# G3 GMMU/FBIF harness: generated fixtures/reports are never tracked. +harness/gmmu_debug_fixture/ +harness/gmmu_debug_reports/ +harness/gmmu_oracle_reports/ diff --git a/debug/risc-v-gsp-rm-vm/README.md b/debug/risc-v-gsp-rm-vm/README.md index 1868a58..d730660 100644 --- a/debug/risc-v-gsp-rm-vm/README.md +++ b/debug/risc-v-gsp-rm-vm/README.md @@ -116,14 +116,17 @@ SIMULATOR-WORK-BOUNDARY.txt scope contract for the 2026-10-03 simulator wave | [31-TURING-TU104-PARAMETERS](docs/31-TURING-TU104-PARAMETERS.md) | Turing TU104 parameter dossier (public sources) | | [32-GSP-CYCLE-MODEL](docs/32-GSP-CYCLE-MODEL.md) | modeled per-instruction cycle accounting (not silicon timing) | | [33-AMPERE-PARAMETERS](docs/33-AMPERE-PARAMETERS.md) | Ampere parameter dossier (public sources) | -| [34-BLACKWELL-SOURCES](docs/34-BLACKWELL-SOURCES.md) | Blackwell source classification (public/private leads) | +| [34-BLACKWELL-SOURCES](docs/34-BLACKWELL-SOURCES.md) | Blackwell source classification (public sources and open questions) | | [35-GREENEYE-SIMULATOR-DESIGN](docs/35-GREENEYE-SIMULATOR-DESIGN.md) | standalone GSP simulator design; Phase S/G scope and gates | -| [36-GSP-DEVICE-MODEL](docs/36-GSP-DEVICE-MODEL.md) | S1 device model, versioned profiles, boot ladder B0–B2 | +| [36-GSP-DEVICE-MODEL](docs/36-GSP-DEVICE-MODEL.md) | S1 device model, versioned profiles, boot ladder B0–B3 (XMPU + libos services) | | [37-SIM-QEMU-LINE](docs/37-SIM-QEMU-LINE.md) | simulator-vs-QEMU A/B harness and measured line verdict | | [38-GSP-EM-API](docs/38-GSP-EM-API.md) | TLV boundary between the GSP emulator and a system simulator (design) | | [39-RE-WORKFLOW-REVIEW](docs/39-RE-WORKFLOW-REVIEW.md) | reverse-engineering workflow review: time sinks and upgrades | | [40-NEW-VERSION-TRIAGE](docs/40-NEW-VERSION-TRIAGE.md) | one-command new-version triage (`harness/triage.py`, `./risc-v-gsp-rm-debugger.sh triage`) | | [41-STABILITY-AND-TIME-TO-VALUE](docs/41-STABILITY-AND-TIME-TO-VALUE.md) | formal stability/value report: phased improvements, proof, adversarial review, conditions | +| [42-GMMU-FBIF-OBSERVABILITY](docs/42-GMMU-FBIF-OBSERVABILITY.md) | observe-only GMMU/FBIF windows and `greeneye-gmmu-fbif-trace/1` (in development) | +| [43-GMMU-PTE-DECODER](docs/43-GMMU-PTE-DECODER.md) | clean-room TU104 GMMU PTE/PDE decoder (in development) | +| [44-GMMU-ORACLE-AND-WORKFLOW](docs/44-GMMU-ORACLE-AND-WORKFLOW.md) | oracle-vs-simulator comparison and static<->dynamic register join (in development) | | [RISC-V-GSP-RM-DEBUGGER-ROADMAP](RISC-V-GSP-RM-DEBUGGER-ROADMAP.md) | the long-term program (H1–H7) | Scope contract for the current simulator wave: diff --git a/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md b/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md index 50b4428..a5f14ab 100644 --- a/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md +++ b/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md @@ -44,12 +44,12 @@ whole stack scriptable, and QEMU is just the first execution backend. | Plugin loader + example plugin | working | | Native RV64IMAC simulator (M1, `--backend native`) | working; QEMU-parity differential test | | Native exec/access trace (M1.5, R4): `--trace`, analyzer coverage | working; trace blob + determinism test | -| Test suite | full suite green on the 2026-10-03 review host (298 tests incl. live QEMU + native differential; count varies with local prerequisites) | +| Test suite | full suite green on the 2026-10-03 review host (417 tests incl. live QEMU + native differential; count varies with local prerequisites) | | Ghidra bridge | scripts + docs | | Real GSP `.fwimage` probe (first-fault report) | working (first fault + lenient continuation) | -| GSP device model + versioned profiles (S1) | in development: `sim/devices/`, `sim/profiles/`, `sim/gsp_boot.py`; B0–B2 on the real 570.144 image with 0 unknown MMIO (36) | +| GSP device model + versioned profiles (S1) | in development: `sim/devices/`, `sim/profiles/`, `sim/gsp_boot.py`, XMPU model `sim/mpu.py`, libos service model `sim/libos.py`; B0–B3 on the real 570.144 image with 0 unknown MMIO (36) | | Modeled cycle accounting | increment 0: `sim/cycles.py`; modeled defaults, not silicon timing (32) | -| Simulator-vs-QEMU A/B + new-version triage | working: frozen thresholds, verdict `above` on the S1 path per docs/37 §4.3; `harness/triage.py` (37, 40) | +| Simulator-vs-QEMU A/B + new-version triage | working: frozen thresholds, verdict `above` on the S1 path per docs/37 §4.4; `harness/triage.py` (37, 40) | | GPGPU-Sim overlay | Phase 0 scaffold + CUDA-12 build port record; Phase G gated (30) | | TCG full-memory tracing plugin | source + optional build (verified live) | | Automation harness (mutation/portability/version diff) + value demos | working (18/19/21/25) | @@ -63,7 +63,7 @@ The in-development notices were removed on 2026-10-02 after the tool audit end-to-end s01–s10 value demonstrations ([25-VALUE-VALIDATION](25-VALUE-VALIDATION.md)). Open items stay listed as open: docs 28/29 are unadmitted pilots, the device model is partial (S1, -B0–B2; docs/36) and custom-simulator M2+ remains roadmap, and nothing here is +B0–B3; docs/36) and custom-simulator M2+ remains roadmap, and nothing here is a stability guarantee beyond the audited v0.1 surface. ## Quick start @@ -127,6 +127,10 @@ Interactively: | [38-GSP-EM-API](38-GSP-EM-API.md) | GSP-emulator/system-simulator TLV boundary (design) | | [39-RE-WORKFLOW-REVIEW](39-RE-WORKFLOW-REVIEW.md) | reverse-engineering workflow review | | [40-NEW-VERSION-TRIAGE](40-NEW-VERSION-TRIAGE.md) | one-command new-version triage | +| [41-STABILITY-AND-TIME-TO-VALUE](41-STABILITY-AND-TIME-TO-VALUE.md) | formal stability and value report | +| [42-GMMU-FBIF-OBSERVABILITY](42-GMMU-FBIF-OBSERVABILITY.md) | observe-only GMMU/FBIF windows and trace (in development) | +| [43-GMMU-PTE-DECODER](43-GMMU-PTE-DECODER.md) | TU104 GMMU PTE/PDE decoder (in development) | +| [44-GMMU-ORACLE-AND-WORKFLOW](44-GMMU-ORACLE-AND-WORKFLOW.md) | GMMU/FBIF oracle comparison and static<->dynamic join (in development) | | [RISC-V-GSP-RM-DEBUGGER-ROADMAP](../RISC-V-GSP-RM-DEBUGGER-ROADMAP.md) | long-term program H1–H7 | ## Where this fits in the Open-IOV workflow diff --git a/debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md b/debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md index a9a91aa..1a85ec5 100644 --- a/debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md +++ b/debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md @@ -124,9 +124,11 @@ Before trusting a risc-v-gsp-rm-debugger session: ## 8. Known accuracy gaps (living list) 1. Partial TU104 device model (S1, docs/36): the real 570.144 image is now - served past the first unmapped access to B2 (relocation copy, 0 unknown - MMIO) with a recorded boot shim; B3/B4 (CSB poll, message loop) are not - reached, and the model is uncalibrated and partial. + served past the first unmapped access to B3 (libos init complete; the + message-loop task blocks in the port wait, 0 unknown MMIO) with the XMPU + model, a recorded boot shim and the libos service model; B4/B5 + (host-traffic-triggered CSB poll, scripted message) are not reached, and + the model is uncalibrated and partial. 2. No time/concurrency model; multi-core and partition-switch behaviour is approximated. Cycles are modeled defaults, not measured (docs/32). 3. PMU/SEC2/ACR/Booter behaviour is message-level only. diff --git a/debug/risc-v-gsp-rm-vm/docs/35-GREENEYE-SIMULATOR-DESIGN.md b/debug/risc-v-gsp-rm-vm/docs/35-GREENEYE-SIMULATOR-DESIGN.md index 83954d4..d5bf4a4 100644 --- a/debug/risc-v-gsp-rm-vm/docs/35-GREENEYE-SIMULATOR-DESIGN.md +++ b/debug/risc-v-gsp-rm-vm/docs/35-GREENEYE-SIMULATOR-DESIGN.md @@ -129,11 +129,20 @@ reproducible twice identically and have its device requirements listed. |---|---|---| | B0 | flat image loads; first MMIO observed at `0x2000000000000A00` | done (native, lenient) | | B1 | device model serves the whole boot path: 0 unknown MMIO, 0 unhandled in windows; deterministic 2M-instruction run stopping at the boot-halt/report loop (`0x4010928`) | done (S1 device model; sessions `dumps/gsp-boot-b1-run1/2`) | -| B2 | boot stage progress: vector table/TCM/MPU/trap handling exercised with the shim; passes the bootargs consumer and reaches the relocation copy, then stops on the known DMEM-runspace unknown (`fault @ 0x400EA0C`; docs/36 §4) | done (shim) | -| B3 | first CSB/TRANSCOM mailbox poll with correct read-back semantics | target | -| B4 | message loop reached (`appMain`/equivalent); waits on host message deterministically | target | +| B2 | boot stage progress: vector table/TCM/MPU/trap handling exercised with the shim; passes the bootargs consumer and reaches the relocation copy | done (S1 device model + XMPU model + bootstrap-args shim; reaches B3) | +| B3 | libos init completes (TCM registration, pagetable registration, DMA flush, external-interrupt arming) and the message-loop task blocks in the port wait | done (sessions `dumps/gsp-boot-b3-run1/2`: 474 043 insns, stop `ecall` at `0x4018038`, two-run identical) | +| B4 | first CSB/TRANSCOM mailbox poll served with correct read-back semantics (host-traffic-triggered doorbell) | target | | B5 | one scripted host-side RM message served end-to-end with a recorded reply | target (gated by S3 injection) | +**Ladder revision (2026-10-03, B3).** The original B3 wording assumed the +firmware polls CSB/TRANSCOM during a silent boot. The 570.144 image does not: +after arming interrupts and completing its libos services it blocks in the +message-loop port wait, and the CSB/TRANSCOM poll is *triggered by host +traffic*. B3 is therefore anchored on the observed wait (a real, reproducible +milestone) and the CSB poll moved to B4, where a scripted doorbell will +produce it. The pre-revision text is kept in history; this note is the +operative definition. + The ladder is deliberately versioned-data driven: a new firmware version is a new profile + shim record, not a code fork. @@ -211,7 +220,7 @@ flags; every call attributable to a session. | Probe/record/plugins | yes | — | backend peer | yes | | Ghidra symbol/control bridges | yes | watch/heat-map/nav | protocol-compatible | full (S2/S3) | | Injection SDK | demo | RM-core interception H1.3 | demo backend | services vs GSP model (S3) | -| Device model / real boot | no (faults) | H2.2/H2.3/H2.4 planned | ladder B0–B2 (S1, device model + shim) | B4/B5 (S1/S3) | +| Device model / real boot | no (faults) | H2.2/H2.3/H2.4 planned | ladder B0–B3 (S1, device model + XMPU + libos services) | B4/B5 (S1/S3) | | Determinism/replay | wall-clock | H2.4 budgets | instruction-count | event replay (S2) | | MCP | yes (QEMU) | — | native sim tools + backend selection (20) | wider GSP-model tools (S3) | | Tiering | — | — | modeled cycles | calibrated bands (S4) | @@ -277,12 +286,13 @@ The comparison and decision are recorded in the private handoff. | Wave | Date | Result | |---|---|---| -| Recon + master handoff | 2026-10-03 | `handoff-simulator.md`, local sources register | +| Recon + master handoff | 2026-10-03 | master handoff; source register | | W1 GPGPU-Sim build | 2026-10-03 | CUDA 12 port patches; `vectorAdd` functional+perf green | | W1 TU104 config/overlay | 2026-10-03 | config scaffold + sanity 26/26 + admissions draft | | W1 GSP cycle-0 | 2026-10-03 | modeled cycles; real image 200k instr deterministic; 298 tests green (2026-10-03 suite run; count varies with prerequisites) | | W1 dossier | 2026-10-03 | Turing/Ampere/Blackwell parameter docs + cross-generation survey | | W2 Phase S (S1 + S4) | 2026-10-03 | `sim/devices` + `sim/profiles` device model: B0–B2 on the real image (0 unknown MMIO), 2-run determinism; A/B harness discovers the profile and records verdict `above` on the frozen checks (docs/36, docs/37 §4.3, docs/40) | +| W2 Phase S (4c: XMPU + libos) | 2026-10-03 | `sim/mpu.py` + `sim/libos.py` + bootstrap-args shim: B3 on the real image (474 043 instr, message-loop port wait, 0 unknown MMIO, 2-run identical); A/B re-run at `--instructions 2000000` verdict `above` (docs/37 §4.4); 417 tests green | | W2 Phase S | launched | S2 parity, S3 MCP/injection, S4 validation | Backlog (recorded, not started): Ampere/Blackwell models; fabric (PCIe, diff --git a/debug/risc-v-gsp-rm-vm/docs/36-GSP-DEVICE-MODEL.md b/debug/risc-v-gsp-rm-vm/docs/36-GSP-DEVICE-MODEL.md index 9516fd5..2741748 100644 --- a/debug/risc-v-gsp-rm-vm/docs/36-GSP-DEVICE-MODEL.md +++ b/debug/risc-v-gsp-rm-vm/docs/36-GSP-DEVICE-MODEL.md @@ -7,7 +7,7 @@ > `06-ACCURACY.md`. Development-only: nothing here authorises GPU hardware, > kernel modules, mdev, guest access or reboots. > -> **See also:** `37-SIM-QEMU-LINE.md` (the B0–B2 device model on the frozen +> **See also:** `37-SIM-QEMU-LINE.md` (the B0–B3 device model on the frozen > simulator-vs-QEMU checks) and `40-NEW-VERSION-TRIAGE.md` (one-command triage > of a new image through this ladder). @@ -47,13 +47,15 @@ sim/devices/__init__.py registry_from_profile(profile, machine) │ FBIF control/transcfg ├─ priscv.py RISC-V-local block: IRQ mask, trace, error regs ├─ timer.py PTIMER (modeled virtual time; opt-in) - ├─ csb.py CSB/TRANSCOM message window seam (B3 landing) + ├─ csb.py CSB/TRANSCOM message window seam (B4 landing) ├─ fwsec.py WPR2 address pair (FWSEC state unmodeled) ├─ pmu.py PMU stub: observes, never fakes ├─ dma.py aperture → RAM translation seam (FBGPA/SYSGPA) └─ irq.py falcon + RISC-V-local IRQ aggregation ▼ sim/machine.py Machine(devices=..., translator=..., reset_regs=...) + │ + sim/mpu.py: XMPU entry model (fetch/load/store) + │ + sim/libos.py: libos syscall service model ▼ sim/gsp_boot.py bounded real-firmware run + session + rung ``` @@ -80,7 +82,7 @@ noted; every unlisted offset is unhandled and logged. | RISC-V local | `+0x111000`, alias `0x300001000` | `IRQMSET` 0x2ac, `IRQMCLR` 0x2b0, `IRQMASK` 0x2b4 (read), `IRQDEST` 0x2b8, `IRQTYPE` 0x2bc, `TRACECTL` 0x344, `TRACE_RDIDX` 0x348, `TRACE_WTIDX` 0x34c, `PRIV_ERR_STAT` 0x360, `PRIV_ERR_INFO` 0x364 (read), `PRIV_ERR_ADDR` 0x368 (read), `HUB_ERR_STAT` 0x36c | header + observed | | PTIMER | `+0x9000` | `TIME_0` aperture 0x9400 / block `+0x400` (NSEC 31:5), `TIME_1` aperture 0x9410 / block `+0x410` (NSEC 28:0); value = instruction count × `ns_per_insn` (**modeled**, not calibrated) | header + **modeled** time | | WPR2 | `+0x1FA000` | `WPR2_ADDR_LO` 0x1FA824, `WPR2_ADDR_HI` 0x1FA828 (4 KiB alignment; HI points at the last 128 KiB block) | header + firmware use | -| CSB/TRANSCOM | profile-declared | none by default; a profile may declare individual registers with width/value and `rw`/`ro`/`wo`/`w1c`/`set`/`clear` semantics | none yet (B3 landing point) | +| CSB/TRANSCOM | profile-declared | none by default; a profile may declare individual registers with width/value and `rw`/`ro`/`wo`/`w1c`/`set`/`clear` semantics | none yet (B4 landing point) | | PMU | profile-declared | none; the stub records accesses and returns unhandled | none yet | | DMA seam | FBGPA/SYSGPA (profile) | GPU-physical → RAM windows for falcon DMA and aperture loads/stores; unmapped transfers are refused and recorded | address-map header + firmware use | @@ -99,10 +101,15 @@ and records each as an assumption in `session.json.boot_shim.assumptions`: * `NV_RISCV_BOOT_PARAMS` at a RAM address (bootType `NVRM`, size 120, version 1, core-dump buffer, one `WPRM` libos init arg); * a `GspFwWprMeta` (magic/revision/verified and a self-consistent FB layout); -* raw-image mirror at the DMEM aperture base so the app's own relocation copy - is self-consistent (it is not: see §6); -* `a0`–`a5` register arguments (bootargs PA/VA, ELF PA/size, load base, WPR - id). +* a `libos_bootstrap_args_t` — six `u64` fields (IMEM and DMEM + `base`/`size`/`bs`) the real bootloader builds from its TCM configuration + and passes in `a0`; the M-mode TCM configuration is declared in the + profile (offsets 0, 64 KiB windows). This replaces the earlier + raw-image mirror at the DMEM aperture: with the XMPU model (§3.2) the + resident-section copy reads its real backing and no image mirror is needed; +* `a0`–`a5` register arguments (a0 = bootstrap-args pointer; the rest are + declared assumptions the boot path only uses where its own computation is + not self-sufficient). The DEBUGINFO **host-consume** behaviour is also a shim: the firmware writes a boot-stage marker and polls bits 22:20 until they clear, and no self-clearing @@ -110,28 +117,86 @@ path exists in the firmware, so the host side is modelled as consuming the stage marker on read. The device never changes a read value except for this documented shim, and the shim is profile-enabled and session-recorded. +### 3.2 CPU protection unit (XMPU) — core model, `sim/mpu.py` + +The GSP core boots in bare mode and then enables a small protection unit +through M-mode CSRs; the firmware programs entries for its code/data ranges +and, during init, for a scratch window it copies resident sections through. +Evidence kind: **hardware CSR/field header + firmware use + observed access +sequence** (the 570.144 bootstrap writes are visible in any run's device/CSR +trace). Modelled CSRs: + +| CSR | value | semantics | +|---|---|---| +| `MMPUIDX` (0x7C8) | entry index | INDEX field 5:0 (64 entries) | +| `MMPUVA` (0x7C9) | virtual base | BASE 63:12, `VLD` bit 0 | +| `MMPUPA` (0x7CA) | physical base | BASE 63:12 | +| `MMPURNG` (0x7CB) | range (a size) | RANGE 63:12 | +| `MMPUATTR` (0x7CC) | attributes | recorded, not enforced | + +Translation is active only when `mstatus.VM == NVMPU` (`0x1E`; field bits +28:24); lookup is lowest-index-first (the firmware's own bootstrap comment +documents the precedence). An entry maps `addr` to `pa + (addr - va_base)` +when `va_base <= addr < va_base + range`. The model applies to instruction +fetch, loads, stores and atomics; permissions/cache attributes are recorded +but not enforced, and **no match is treated as identity** (more lenient than +silicon) because the simulator keeps local memory flat and the boot path's +mappings are self-consistent. `read_mem`/`write_mem` (debugger surface) +remain raw and untranslated. + +Evidence in the session: `session.json.mpu` carries the enabled flag, entry +table and `mstatus`. The model removes the B2 ceiling: with the correct +bootstrap args and the scratch mapping, the resident-section copy reads the +in-place image and the run continues (no DMEM image mirror, no faked copy). + +### 3.3 libos syscall surface — service model, `sim/libos.py` + +The application is a libos task that requests kernel services with `ecall` +(the syscall number in `t0`). The native machine stops on `ecall` unless a +handler is installed, so the boot runner attaches a deterministic service +model that: + +* records every request (number, name, arguments, pc, instruction count) in + `session.json.libos`; +* acknowledges only no-ops and modelled services — task yield, cache + invalidation, external-interrupt arming, profiler enable/query, the + modeled timer read, DMA bounce-buffer registration (`TCM` region), + inverted-pagetable registration, and DMA copy/flush (an in-RAM copy in the + flat model: the real kernel drives the falcon DMA through TCM, the copy + effect is identical and is not faked — both sides must resolve or the run + stops); +* **never fakes waits**: `processor_suspend`, `task_port_send_recv_wait`, + `task_panic` and every unimplemented syscall stop the run with `R_ECALL` + and are reported in `session.json.libos.stopped`. + +Task switching, interrupt delivery, pagination, partition switch and memory +queries are not modelled (force-stopped, not invented). The B3 result is +exactly this stop: the message-loop task blocks in `task_port_send_recv_wait` +with `armed_mask=0xFF`. + ## 4. Boot ladder results (observed, 2026-10-03) | Rung | Claim | Evidence | |---|---|---| | **B0** | flat image loads; first MMIO is the unmodelled PMC `BOOT_42` read at `0x2000_0000_0000_0A00` | `tests/test_sim_devices.py` (B0 classification) | | **B1** | device model serves the whole boot path: **0 unknown MMIO, 0 unhandled in windows**, deterministic 2M-instruction run stopping at the boot-halt/report loop (`0x4010928`) | `run_boot(use_shim=False)`; sessions `dumps/gsp-boot-b1-run1`, `dumps/gsp-boot-b1-run2`; test `test_no_shim_b1_device_model_serves_boot` | -| **B2** | with the shim, the firmware passes the chip gate, MPU/vector setup, the bootargs consumer (`0x4007A30`) and reaches its **relocation copy** (`0x4007B7C`), then stops at a known unknown (DMEM runspace) | sessions `dumps/gsp-boot-b2-run1`, `dumps/gsp-boot-b2-run2`; test `test_shim_reaches_relocation_b2` | +| **B2** | with the shim, the firmware passes the chip gate, XMPU setup, the bootstrap-args consumer and its **relocation copy** (`0x4007B7C`); the XMPU model resolves the copy's real backing and the run continues into libos init | sessions `dumps/gsp-boot-b2-run1`, `dumps/gsp-boot-b2-run2`; test `test_shim_reaches_relocation_plus_mmu` | +| **B3** | libos init completes (TCM registration `0x5B22000`/`0x1000`, pagetable registration, DMA flush, external-interrupt arming `0xFF`) and the message-loop task blocks in the port wait | sessions `dumps/gsp-boot-b3-run1`, `dumps/gsp-boot-b3-run2`; test `test_shim_reaches_message_loop_b3` | Reproduction (read-only; no hardware): ```bash cd open-iov/debug/risc-v-gsp-rm-vm -python3 -m sim.gsp_boot --instructions 2000000 --record dumps/gsp-boot-b2-run1 -python3 -m unittest tests.test_sim_devices -v +python3 -m sim.gsp_boot --instructions 2000000 --record dumps/gsp-boot-b3-run1 +python3 -m unittest tests.test_sim_devices tests.test_mpu -v ``` Two-run determinism is asserted by `test_two_runs_identical` and was checked -at the recorded B2 sessions: identical instruction count (14 682), stop -reason/PC (`fault @ 0x400EA0C`), device-state SHA-256 -(`3fa264b9…`) and register file. The B1 no-shim run is likewise identical -across runs (2 000 000 instructions, budget stop at `0x4010928`, device-state -SHA-256 `88eb07b7…`). +at the recorded B3 sessions: identical instruction count (474 043), stop +reason/PC (`ecall @ 0x4018038`, syscall +`task_port_send_recv_wait`), device-state SHA-256 (`5b3cc7e4…`) and register +file. The B1 no-shim run is likewise identical across runs (2 000 000 +instructions, budget stop at `0x4010928`, device-state SHA-256 `88eb07b7…`). ## 5. Session evidence @@ -139,21 +204,24 @@ Each `run_boot` invocation writes a `greeneye-session/1` directory with: * `session.json` — firmware SHA-256, profile name, load layout, rung, milestones, stop reason/PC, unknown-MMIO counts, device-state SHA-256, - device-access counts and the full boot-shim assumption list; + device-access counts, the full boot-shim assumption list, the XMPU state + (`mpu`: enabled, entries, mstatus) and the libos service log (`libos`: + calls, counts, TCM/pagetable registrations, the stopping request); * `blobs/device-state.json` — the `greeneye-sim-devices/1` snapshot of every device (registers, IRQ state, DMA windows/counters, shim flags); * `events.jsonl` — the normal session event stream (`gsp_boot` note). ## 6. Unknowns (explicitly not modeled / not verified) -1. **DMEM runspace content.** The app's relocation copy reads the DMEM - aperture (`0x1_0000_0000+`) and writes it to the `0x400E000` runtime - region; the bootloader-provided content is not reconstructed, so the copy - writes zeros and the run stops at `0x400EA0C` (illegal instruction). This - is the B2 ceiling today. -2. **CSB/TRANSCOM register semantics.** No message-window poll has been - observed yet, so no register is modeled. B3 waits for the first observed - poll; the `csb.py` seam and profile register declarations are in place. +1. **CSB/TRANSCOM register semantics.** No message-window poll has been + observed yet, so no register is modeled. The 570.144 firmware blocks in + the message-loop port wait before any CSB access; B4 waits for the first + host-traffic-triggered poll; the `csb.py` seam and profile register + declarations are in place. +2. **Libos services.** Task switching, interrupt delivery, pagination, + partition switch and memory queries are not modelled: the service model + records and force-stops on them (never invents progress). DMA copies are + modelled as in-RAM copies in the flat memory model. 3. **PMU.** No PMU register access observed; stub only. 4. **FWSEC state machine.** Only the WPR2 address pair is modeled; FWSEC execution is not. @@ -161,14 +229,15 @@ Each `run_boot` invocation writes a `greeneye-session/1` directory with: not proven; only the profile's DMA windows decide what an address means. 6. **Interrupt delivery.** Falcon/RISC-V IRQ state is aggregated and serialisable, but mstatus/mie/mip/mtvec trap entry and interrupt injection - are not modelled. + are not modelled (the firmware arms external interrupts with mask `0xFF` + and then waits forever in the flat model). 7. **PTIMER calibration.** Values are modeled from the instruction count with a placeholder `ns_per_insn`; no firmware PTIMER read has been observed and no timing claim is made. -8. **MPU translation.** The firmware programs its MPU CSRs (observed), but - the machine does not enforce translations; the libos private mapping is - served by explicit device aliases and the FBGPA/SYSGPA seam, not by an MPU - model. +8. **XMPU enforcement.** Translation, VLD gating, mstatus enable and + lowest-index precedence are modelled; permissions, cache/coherence + attributes and WPR IDs are recorded but not enforced, and an unmatched + access passes through as identity (lenient by design, listed here). Nothing in this page is a silicon-accuracy claim; `06-ACCURACY.md` fidelity levels apply (L0/L1 modelled, L2 modelled-not-calibrated, L3 partial and diff --git a/debug/risc-v-gsp-rm-vm/docs/37-SIM-QEMU-LINE.md b/debug/risc-v-gsp-rm-vm/docs/37-SIM-QEMU-LINE.md index 42c76cc..06fa943 100644 --- a/debug/risc-v-gsp-rm-vm/docs/37-SIM-QEMU-LINE.md +++ b/debug/risc-v-gsp-rm-vm/docs/37-SIM-QEMU-LINE.md @@ -29,11 +29,12 @@ and analyze/compare, probes/plugins, Ghidra bridges, trace, injection SDK and `Session.call`/`run_until`, plus the documented first fault at firmware vaddr `0x4009014` (4-byte read of `0x2000000000000A00`). -The boot ladder (docs/35 §3.3) is `pre-B0` (image loads, first MMIO observed -*as a fault*) < `B0` (first MMIO observed; lenient) < `B1` (first fault past -the QEMU point, explained by device semantics) < `B2` (boot-stage progress) < -`B3` (first mailbox poll) < `B4` (message loop) < `B5` (one scripted message -served). +The boot ladder (docs/35 §3.3, revised 2026-10-03) is `pre-B0` (image loads, +first MMIO observed *as a fault*) < `B0` (first MMIO observed; lenient) < +`B1` (first fault past the QEMU point, explained by device semantics) < +`B2` (boot-stage progress: relocation copy) < `B3` (libos init complete; +message-loop task blocks in the port wait) < `B4` (first CSB/TRANSCOM poll +served, host-traffic-triggered) < `B5` (one scripted message served). **"Above the QEMU line"** requires *all* of the frozen checks in §3, including the docs/35 §1.1 requirement that the milestone be reached "with @@ -196,6 +197,33 @@ MMIO. Frozen thresholds unchanged. Measured run on the same image `harness/qemu_line/sim-qemu-line.json|.md`). Wall figures are host-load dependent. +### 4.4 Re-measurement after the XMPU/libos wave (2026-10-03, session 4c) + +The native side now runs the real image with the XMPU (MPU) translation model +and the libos service model (docs/36 §3.2/§3.3), so the DMEM-runspace unknown +is gone. Fresh run, frozen thresholds unchanged, same image +(sha256 `2d1bb5c60b208cfd…`), `--instructions 2000000`: + +| side | ran | rung | instructions | cycles (modeled) | stop | wall s | evidence | +|---|---|---|---:|---:|---|---:|---| +| QEMU probe | yes | `pre-B0` | – | – | `fault_load` fw `0x4009014` -> `0x2000000000000A00` | 0.98 | `harness/qemu_line/sessions/qemu-probe` | +| native run 1 | yes | `B3` | 474,043 | 871,904 | `ecall@0x4018038` (`task_port_send_recv_wait`) | 0.65 | `harness/qemu_line/sessions/native-1` | +| native run 2 | yes | `B3` | 474,043 | 871,904 | `ecall@0x4018038` | 0.66 | `harness/qemu_line/sessions/native-2` | + +* all five frozen checks pass: `qemu_first_fault_reproduced`, + `native_above_qemu`, `identical_runs` (device-state sha256 + `5b3cc7e4af933e01…` on both runs), `native_pair_wall_s`, and + `device_model_documented`; **verdict `above`**. +* milestones observed: `bootargs_consumer`, `relocation_copy`; + `unknown MMIO = 0`, `device_unhandled = 0`; `armed_mask=0xFF`. +* What this does **not** claim: B4/B5 are not reached (the message-loop task + blocks before any CSB access without host traffic); the shim values are + declared assumptions; cycles are modeled (docs/32); no timing or + device-environment claim. +* Command: `python3 harness/sim_qemu_line.py --instructions 2000000` + (regenerates `harness/qemu_line/sim-qemu-line.json|.md`; the tracked report + is this run). Wall figures are host-load dependent. + ## 5. Failure signals (when to disable, per U48/U56) * Any frozen check false **with QEMU available and the firmware present**: @@ -227,10 +255,11 @@ report is emitted with `unverified_qemu_absent`. ## 7. Honest limits -* **S1 partial** — the device model reaches B2 (relocation copy) with the - recorded boot shim; B3/B4 (CSB poll, message loop) are not reached and the - DMEM-runspace unknown is the documented ceiling (docs/36 §6). The §4.1 - lenient record predates S1; §4.3 is the post-S1 measurement. +* **S1 partial** — the device model reaches B3 (libos init complete; + message-loop port wait) with the XMPU model, the recorded boot shim and the + libos service model (docs/36); B4/B5 (host-traffic-triggered CSB poll and a + scripted message) are not reached. §4.1/§4.3 are the pre-XMPU records; + §4.4 is the current measurement. * **L2/L3 partial** — cycles are modeled (`rv64imac-modeled/1`, docs/32), not measured; the device model is partial and carries per-register evidence (docs/36). No timing or full device-environment claim is made. diff --git a/debug/risc-v-gsp-rm-vm/docs/39-RE-WORKFLOW-REVIEW.md b/debug/risc-v-gsp-rm-vm/docs/39-RE-WORKFLOW-REVIEW.md index 2b94746..d21957f 100644 --- a/debug/risc-v-gsp-rm-vm/docs/39-RE-WORKFLOW-REVIEW.md +++ b/debug/risc-v-gsp-rm-vm/docs/39-RE-WORKFLOW-REVIEW.md @@ -83,7 +83,7 @@ but unfinished; docs 30–38 and the untracked `sim/devices`/`gpgpu-sim` trees), | 4 | Native RV64IMAC backend (M1) | `sim/decode.py`, `sim/machine.py`, `greeneye/sim_backend.py` | deterministic in-process backend; QEMU parity | tests, demos, new sim work | differential test (`docs/16` §1.1); `sim_diff.py` `PARITY: OK` | verified useful (reference core) | | 5 | Native exec/access trace (M1.5/R4) | `sim/trace.py` | full instruction/access trace as session blob | record/analyze, value demos | `docs/16` §1.1a; trace tests | verified useful | | 6 | Modeled cycle counter | `sim/cycles.py` | table-driven modeled cycles (explicitly not silicon timing) | 21 offline tests | `docs/32`; `results.json` of `test_sim_cycles` | in development (increment 0) | -| 7 | GSP device model + profiles + bounded boot runner | `sim/devices/`, `sim/profiles/`, `sim/gsp_boot.py`, `sim/gsp_profile.py` | real 570.144 image past the QEMU first fault (B0–B2) | S1 workstream | `dumps/gsp-boot-b1-run1/2`, `b2-run1/2`; 2-run determinism asserted (`docs/36` §4) | in development (S1) | +| 7 | GSP device model + profiles + bounded boot runner | `sim/devices/`, `sim/profiles/`, `sim/gsp_boot.py`, `sim/gsp_profile.py`, `sim/mpu.py`, `sim/libos.py` | real 570.144 image past the QEMU first fault (B0–B3) | S1 workstream | `dumps/gsp-boot-b1-run1/2`, `b3-run1/2`; 2-run determinism asserted (`docs/36` §4) | in development (S1) | | 8 | Declarative probe specs | `greeneye/probes.py`, `examples/probes/` | JSON hooks/conditions without scripts | audit smoke; documented pattern | `docs/27` §3 (3 probes fired) | verified useful | | 9 | Plugin loader + example plugin | `greeneye/plugins.py`, `plugins/example_plugin.py` | extension point | audit smoke | `plugins` CLI lists all three | verified useful | | 10 | MCP server | `plugins/mcp_server.py` | agent/CI machine surface (JSON-RPC over stdio/loopback) | no live agent consumer found | 55 tests (`docs/20` §9); tool catalogue 31 (`docs/20` §5) | implemented; **unused in real workflow** (F1 `partially used`) | diff --git a/debug/risc-v-gsp-rm-vm/docs/40-NEW-VERSION-TRIAGE.md b/debug/risc-v-gsp-rm-vm/docs/40-NEW-VERSION-TRIAGE.md index 613e966..aadae37 100644 --- a/debug/risc-v-gsp-rm-vm/docs/40-NEW-VERSION-TRIAGE.md +++ b/debug/risc-v-gsp-rm-vm/docs/40-NEW-VERSION-TRIAGE.md @@ -194,6 +194,14 @@ the QEMU line" next-action line no longer appears. Wall ≈2.7 s (host-load dependent). The pre-S1 trace above is kept as the recorded before/after evidence. +Post-XMPU/libos wave (2026-10-03, session 4c; same command): with the default +budget the `native_boot` step reports rung **B2** and a budget stop at +200,000 instructions (no fault anymore — the former `0x400EA0C` wall is +gone); with `--budget 2000000` it reports rung **B3** (474,043 instructions, +`ecall` port wait, 0 unknown MMIO) and `ab_eval` verdict `above` (measured +wall ≈4.1 s, 434× the frozen 1800 s manual baseline). The numbers in the +example and the post-S1 paragraph above are the pre-wave record. + Baseline variant (same image both sides, an unchanged-version rehearsal with the docs/21 path): diff --git a/debug/risc-v-gsp-rm-vm/docs/41-STABILITY-AND-TIME-TO-VALUE.md b/debug/risc-v-gsp-rm-vm/docs/41-STABILITY-AND-TIME-TO-VALUE.md index 77e1298..57ee33b 100644 --- a/debug/risc-v-gsp-rm-vm/docs/41-STABILITY-AND-TIME-TO-VALUE.md +++ b/debug/risc-v-gsp-rm-vm/docs/41-STABILITY-AND-TIME-TO-VALUE.md @@ -3,15 +3,16 @@ > **Date:** 2026-10-03. **Scope:** the simulator/debugger workstream inside > `debug/risc-v-gsp-rm-vm/` only. **Verdict:** the minimum bar for marking the > *tool and standalone GSP simulator* as **stable (conditional)** is passed: -> 298 tests green twice, deterministic real-firmware runs, an independently +> 417 tests green (298 at the first review; the XMPU/libos wave added the +> rest), deterministic real-firmware runs, an independently > reproduced A/B verdict "above the QEMU line", and reproducible value > measurements. Conditions are listed in §7 — chiefly that the device model -> currently reaches boot rung B2 (not the message loop) and cycle numbers are +> currently reaches boot rung B3 (message-loop port wait; the CSB poll awaits +> host traffic) and cycle numbers are > modelled, not silicon-calibrated. > -> Local notes: `../../../../handoff-simulator.md` (directive ledger, top work), -> `../LOCAL-REVIEW-NOTES.md` (local provenance). Public text describes -> components and behaviour only. +> Companion: `../../../../handoff-simulator.md` (directive ledger, top work). +> Public text describes components and behaviour only. ## 1. What we were doing before (the QEMU baseline) @@ -29,8 +30,8 @@ | Phase | Improvement | Status | |---|---|---| -| P1 | **GSP device model** (`sim/devices/`, `sim/profiles/`) with PMC/FALCON/PRISCV/timer/CSB/FWSEC/PMU/DMA/IRQ windows, versioned profile, boot shims | B2: 14,682 instr, fault `0x400EA0C`, 0 unknown MMIO, two-run identical | -| P2 | **`sim.gsp_boot` boot-ladder runner** with rung classification and serialisable device state | B0–B2 (B3/B4 targets) | +| P1 | **GSP device model** (`sim/devices/`, `sim/profiles/`) with PMC/FALCON/PRISCV/timer/CSB/FWSEC/PMU/DMA/IRQ windows, versioned profile, boot shims, **XMPU model** (`sim/mpu.py`) and **libos service model** (`sim/libos.py`) | B3: 474,043 instr, `ecall` port wait, 0 unknown MMIO, two-run identical | +| P2 | **`sim.gsp_boot` boot-ladder runner** with rung classification and serialisable device state | B0–B3 (B4/B5 targets) | | P3 | **One-command release triage** (`./risc-v-gsp-rm-debugger.sh triage`) orchestrating identity/QEMU probe/native boot/A-B eval/version diff/readiness | 570.144 in ~2.7 s; baseline pair ~11.4 s | | P4 | **QEMU-line A/B evaluation harness** (`harness/sim_qemu_line.py`, frozen checks) | verdict `above`, all threshold checks computed from live runs | | P5 | **Real RE demonstration** (`harness/re_demo.py`) answering a concrete bootstrap question with simulator tools | PASS 5/5, 0.13 s vs 601 s baseline floor | @@ -41,7 +42,7 @@ | Scenario | Before | Now | Evidence | |---|---|---|---| -| What happens in GSP boot before the first wall? | QEMU: fault at `0x4009014`, stop | Device model serves the path; deterministic B2 fault `0x400EA0C`; unknown accesses 0 | `python3 -m sim.gsp_boot`; sessions `dumps/gsp-boot-b2-run1/2` | +| What happens in GSP boot before the first wall? | QEMU: fault at `0x4009014`, stop | Device model serves the path; deterministic B3 port wait (`ecall @ 0x4018038`); unknown accesses 0 | `python3 -m sim.gsp_boot`; sessions `dumps/gsp-boot-b3-run1/2` | | Triage a new firmware image | manual probe + reading, ~1800 s estimated | one command, **2.68 s**, JSON+MD report with hashes | `./risc-v-gsp-rm-debugger.sh triage --firmware … --version 570.144` | | Is the emulator better than QEMU? | no mechanism | validators run both, compute rung/feature deltas; verdict `above` | `python3 harness/sim_qemu_line.py` | | Answer a bootstrap RE question | static reading only | 5/5 recorded answers in 0.13 s | `python3 harness/re_demo.py` | @@ -54,10 +55,10 @@ one-line command in the report/doc that produced it. ## 4. Stability evidence -* `python3 -m unittest discover -s tests -t .` → **298 tests, OK**, twice - (rounded identity across runs); MCP 55, triage 14, device model 34 new - checks included. -* Determinism: B1/B2 device-state SHA identical across runs; triage sessions +* `python3 -m unittest discover -s tests -t .` → **417 tests, OK** (298 at + the first review; the XMPU/libos wave added 13 boot/MPU checks plus WPR2 + and trace-number updates); MCP 55, triage 14, device model 35 included. +* Determinism: B1/B3 device-state SHA identical across runs; triage sessions hashed; A/B harness repeats native runs and compares. * Back-compat: `Session`, `RspClient`, `SimClient` and CLI keep prior behaviour; additions are keyword/default forms; QEMU path untouched @@ -79,15 +80,20 @@ one-line command in the report/doc that produced it. derivation, DMA refusal counting, PMC chip-ID duplication, overlapping window fallback, stale doc counts, `session_launch` documentation, docs/40 measurement labels). One latent item recorded: WPR2 window offsets versus - profile base (firmware never touches it in B0–B2). + profile base — left unserved because the old B2 run never reached the read; + the XMPU/libos wave reached it and the offset defect was fixed (relative and + absolute forms both accepted, docs/36 §3). * Verdict after fixes: conditional stability as stated in §7; no unverified "beats silicon" or "boots RM" statement anywhere. ## 6. Top work for the next session (time-to-value first) -1. **Push the device model to B3/B4** (CSB/TRANSCOM mailbox semantics, - DMEM runspace source, PMU/FWSEC stubs) — this is the gate to serving a - real RM message and the largest remaining time-to-value lever. +1. **Push the device model to B4/B5** (host-traffic-triggered CSB/TRANSCOM + poll semantics, one scripted message served end-to-end) — this is the gate + to serving a real RM message and the largest remaining time-to-value + lever. *Update 2026-10-03, session 4c: the XMPU model, bootstrap-args shim + and libos service model landed; the ladder is now B3 (was B2), with the + former DMEM-runspace unknown resolved.* 2. **Surgical rehost of the live GR engine-context promote/commit wall** (docs/39 top-3): convert the manual, multi-cycle physical campaign into device-free hook/watch/compare sessions. @@ -99,7 +105,8 @@ one-line command in the report/doc that produced it. ## 7. Conditions on the stability claim -1. Device model ceiling is B2; B3/B4 pending profile semantics. +1. Device model ceiling is B3 (libos init complete; message-loop port wait); + B4/B5 (host-traffic-triggered CSB poll, scripted message) pending. 2. Cycle counts are modelled defaults, not calibrated bands. 3. Manual-baseline speedups use labelled estimates, not timed human studies. 4. Cross-version triage was exercised as a self-diff rehearsal (no second @@ -111,8 +118,8 @@ one-line command in the report/doc that produced it. ```bash cd open-iov/debug/risc-v-gsp-rm-vm -python3 -m unittest discover -s tests -t . # 298 tests -python3 -m sim.gsp_boot # B2 +python3 -m unittest discover -s tests -t . # 417 tests +python3 -m sim.gsp_boot # B3 (port wait) python3 -m sim.gsp_boot --no-shim # B1 budget stop python3 harness/sim_qemu_line.py # verdict "above" python3 harness/re_demo.py # PASS 5/5 diff --git a/debug/risc-v-gsp-rm-vm/docs/42-GMMU-FBIF-OBSERVABILITY.md b/debug/risc-v-gsp-rm-vm/docs/42-GMMU-FBIF-OBSERVABILITY.md new file mode 100644 index 0000000..4a2d6ee --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/42-GMMU-FBIF-OBSERVABILITY.md @@ -0,0 +1,203 @@ +# 42 — GMMU/FBIF observability (in development) + +> **Status: in development (2026-10-03, G1 work package).** This page +> documents the observe-only GMMU and FBIF/FBPA register windows added to the +> device model, the `greeneye-gmmu-fbif-trace/1` document and the boot-runner +> flags that produce it. Development-only: nothing here authorises GPU +> hardware, kernel modules, mdev, guest access or reboots, and nothing here is +> a silicon-accuracy claim. +> +> **See also:** `36-GSP-DEVICE-MODEL.md` (the device model this plugs into), +> `06-ACCURACY.md` (fidelity levels), `24-DEVICE-MAP.md` (harvested access +> maps), `35-GREENEYE-SIMULATOR-DESIGN.md` §4.1 (the Phase G inventory this +> observability de-risks). + +## 1. What this is + +The Phase G board model needs the GMMU and the FB interface eventually. Before +modelling either, we need to know **what the firmware actually touches** in +those register windows, with which width and direction, from which PC, and +without pretending to know register behaviour we have not established. + +`sim/devices/observe.py` adds **observer windows**: device-model windows that +record every access and then **decline** it, so execution follows the existing +device-map / lenient / unknown path exactly as it did before the window +existed. They never invent a read value. + +## 2. What is observed + +The TU104 profile (`sim/profiles/tu104-570.144.json`) declares 23 observer +windows, all `observe-only`. Bases are runtime addresses in the GSP RISC-V +PRIV aperture (`0x2000_0000_0000_0000` + BAR0 offset); offsets/names come from +the hardware register/field headers (evidence kind: **header**). The B0–B2 +runs did not touch any of them; the B3 wave reaches the WPR2 read pair +(`0x1FA824/0x1FA828`), which falls inside the `gmmu-wpr-lock` window and is +recorded observe-only (2 accesses, 0 served; the fwsec device answers). + +| Window label | Runtime base | Size | Covers | +|---|---|---|---| +| `gmmu-pri-mmu` | `0x2000000000100C80` | `0x280` | `NV_PFB_PRI_MMU_*` cluster (`NV_PFB_PRI_MMU_CTRL` first) | +| `gmmu-wpr-lock` | `0x20000000001FA7D8` | `0x5C` | MMU per-engine PRIV_LEVEL masks + VPR/WPR/lock address pair | +| `gmmu-hsmmu` | `0x20000000001FAC00` | `0x400` | `NV_PFB_HSMMU_PRI_MMU_*` (secondary MMU block) | +| `fbpa` | `0x20000000009A0000` | `0x4000` | main FBPA block (`NV_PFB_FBPA_*`) | +| `fbpa-partition-0..15` | `0x2000000000900000` + i·`0x4000` | `0x4000` | per-partition FBPA instances | +| `fbpa-mc-0..2` | `0x2000000000980000` + i·`0x4000` | `0x4000` | FBPA MC instances | + +Register names (and, for a few control/status registers, header field names +with bit positions) are declared per window so traces are readable; offsets +inside a window that the profile does not name are still recorded and land +under `unclassified`. `gmmu-pri-mmu` spans the `NV_PFB_PRI_MMU_*` cluster +`0x100C80–0x100EFC`; the few non-MMU PFB registers interleaved in that span +(e.g. the PRI_FB clock/power-gating registers) are recorded as unclassified, +never renamed. + +## 3. Mechanism: record, then decline + +Each `ObservedWindow` is a normal device-model window and is consulted by the +registry like any other. Its `read`/`write` always record: + +```text +addr, offset, size, rw, value-if-known, pc, vt, window, register, served +``` + +and then return `None`/`False` unless the window is declared `storage` **and** +the accessed offset is a declared plain read/write register **and** (for +reads) that register has been written before. Declining means the registry +falls through to the next matching window and the machine's existing path +applies unchanged: + +* lenient mode: the access is logged/counted as before and reads 0; +* fault mode: the same `Fault(cause, tval)` as without the window; +* a modelled window underneath (e.g. the PMC block) is consulted exactly as + before and serves the access if it models it, and the observer records that + it happened. (The `gmmu-wpr-lock` window overlaps the fwsec WPR2 window, so + it exercises this fall-through; it asserts nothing about whether the + underlying WPR2 path serves — that path is unchanged by the observer and + currently serves nothing through the registry, a pre-existing condition.) + +**No-fake-read guarantee.** An observe-only read records `"value": null` and +returns `None`; a storage read before any write is declined the same way; no +reset value is synthesised. The only read value a storage window can return +is one this same run wrote to that register. All profile windows for +GMMU/FBIF are `observe-only`; the `storage` mode exists for surfaces whose +header proves plain read/write behaviour and is exercised by tests. + +`pc` is the faulting instruction address (the registry forwards it only to +devices that opt in). `vt` is the machine instruction counter at the access; +it advances at `Machine.run` boundaries, so accesses inside one bounded run +share the run-start value — a documented granularity, not a timing claim +(fidelity L2 is not modelled). + +**Size limit.** Each window's access log is capped (`max_access_log`, default +65536 entries); accesses beyond the cap are counted, not written. The trace +document reports `dropped_accesses` per window and in the summary and +separates `accesses` (total observed) from `retained_accesses` (present in +the document), so truncation is never silent. + +## 4. Trace document: `greeneye-gmmu-fbif-trace/1` + +`--trace-out <path>` writes a trace file: a path that is an existing +directory (or ends in `/`, which makes that directory) gets the canonical +filename `gmmu-fbif-trace.json` inside; anything else is used as the file +name. Accesses are grouped per window and per declared register; undeclared +offsets go to `unclassified`. Shape: + +```json +{ + "schema": "greeneye-gmmu-fbif-trace/1", + "image_sha256": "…", + "profile": "tu104-570.144.json", + "profile_name": "tu104-570.144", + "symbols": "symbols.json", + "summary": {"windows": 23, "windows_touched": 0, "accesses": 0, + "retained_accesses": 0, "dropped_accesses": 0, + "reads": 0, "writes": 0, "served": 0, "registers_touched": 0}, + "windows": [ + {"label": "gmmu-pri-mmu", "base": "0x2000000000100c80", "size": "0x280", + "mode": "observe-only", "evidence": "…", + "counts": {"r": 0, "w": 0, "served": 0, "declined": 0}, + "retained_accesses": 0, "dropped_accesses": 0, + "registers": [ + {"offset": "0x0", "name": "NV_PFB_PRI_MMU_CTRL", "access": "rw", + "counts": {"r": 0, "w": 1, "served": 0}, + "accesses": [ + {"addr": "0x2000000000100c80", "offset": "0x0", "size": 4, + "rw": "w", "value": "0x1", "pc": "0x4001234", + "pc_symbol": "rm_init+0x20", "vt": 0, + "window": "gmmu-pri-mmu", "register": "NV_PFB_PRI_MMU_CTRL", + "served": false, "decoded": {"VM_PG_SIZE": 1}} + ]} + ], + "unclassified": {"counts": {"r": 0, "w": 0, "served": 0}, + "accesses": []}} + ] +} +``` + +`pc_symbol` is `null` unless `--symbols <greeneye-symbols/1 JSON>` is given; +`decoded` is present only when a value is known and the register declares +fields (bit positions from the header). `value`/`addr`/`offset`/`pc` are hex +strings; `vt` is an integer. The document is deterministic for a fixed image, +profile, flags and stimulus. + +The **count summary is always in the boot report** (and session metadata under +`gmmu_fbif`) even when `--trace-out` is off; the trace blob is added to the +recorded session only when the flag is on. + +## 5. Commands + +```bash +cd open-iov/debug/risc-v-gsp-rm-vm + +# B2 run with the observer summary, trace written to a directory: +python3 -m sim.gsp_boot --instructions 20000 --record dumps/gsp-boot-b2-observe \ + --trace-out dumps/gsp-boot-b2-observe + +# Resolve recorded PCs through a symbol database: +python3 -m sim.gsp_boot --trace-out /tmp/trace.json --symbols symbols.json + +# Ad-hoc window whose base is not established yet (repeatable, no guessing): +python3 -m sim.gsp_boot --observe-window 0x20000000009A0000:0x4000:fbpa \ + --observe-window 0x2000000000100C80:0x280:gmmu +``` + +`--observe-window BASE:SIZE[:LABEL[:MODE]]` is the escape hatch: the trace +accepts a runtime window list, so an unknown surface can be observed before +any profile entry exists. A runtime window is `observe-only` unless +`MODE=storage` is given (storage without declared registers still records and +declines). + +## 6. What is NOT modelled + +* **No GMMU page walk, no PTE/PDE decode, no TLB, no fault handling, no ATS.** + Window accesses are recorded; their semantics are not interpreted. +* **No FBIF transaction semantics, compression, ECC or partition hashing.** + FBPA register offsets/names are labels, not behaviour. +* **No interrupt or timing (L2) claims.** `vt` is the modeled instruction + counter, not cycles. +* **No device-environment (L3) claims.** The windows exist because the + simulator serves them; silicon behaviour is not asserted. +* **Storage is not semantics.** A storage window only echoes values written in + the same run; it is not evidence that the real register stores them. +* **No product or hardware action.** Nothing in this workstream touches + `vgsp`, a GPU, a module, mdev, or a guest. + +## 7. Evidence, limits and tests + +* Evidence kind for every window/offset is the hardware register/field header + (block ranges and register offsets); observed firmware use in B0–B2 was + **zero accesses** (measured at the B2 stop: 14 682 instructions, 0 unknown + MMIO, 1 device access — the PMC chip-ID read). The B3 wave's WPR2 reads are + the first observed accesses (2 reads inside `gmmu-wpr-lock`, observe-only, + 0 served). The mechanism is still tested end-to-end with runtime windows. +* Boot numbers are unchanged by the observer windows (instructions, stop + PC/reason, unknown MMIO, unhandled-in-window, served accesses); the only + session difference is the added device-state entries for the observers. +* Tests: `tests/test_gmmu_fbif_trace.py` — no-semantic-change (lenient and + fault paths match a registry without the window), storage read-back, + field decode, profile expansion, trace schema, access-log-cap reporting, + WPR2-overlap pass-through, B2 invariance, symbol resolution, runtime + windows. +* Public artifacts carry only component register names/offsets and behaviour; + the window/register tables come from the tracked analysis and recorded + sessions. diff --git a/debug/risc-v-gsp-rm-vm/docs/43-GMMU-PTE-DECODER.md b/debug/risc-v-gsp-rm-vm/docs/43-GMMU-PTE-DECODER.md new file mode 100644 index 0000000..ea6d3d4 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/43-GMMU-PTE-DECODER.md @@ -0,0 +1,206 @@ +# 43 — GMMU PTE/PDE decoder for TU104 (in development) + +> **Status: in development (2026-10-03, G2 work package).** This page +> documents `sim/gmmu_pte.py`: a clean-room decoder for captured Turing +> (TU104) GMMU page-table entries. It is decode-only tooling — no hardware, +> no kernel module, no mdev, no guest, no RM 610. Development-only, never +> shipped. +> +> **Rule: decode captured data only; validate against a capture before +> trusting.** Every field position below is a reverse-engineering result; +> none of it is a silicon-accuracy or timing claim (`06-ACCURACY.md` +> fidelity levels apply). + + +## 1. What this is — and is not + +`sim/gmmu_pte.py` turns a captured page-table value or blob into named +fields with exact bit positions. It is **not** a page-table walker: it never +dereferences a decoded address, never reads a target table, and never fills +a gap with a guess. Unsupported encodings and levels raise `E_UNSUPPORTED`; +set bits that no named field covers are reported (`reserved_bits` / +`reserved_mask`) and never dropped. + +The evidence base is the Turing GMMU entry-format definitions (field names, +bit ranges, aperture/KIND numeric IDs) together with the driver's own +page-table encoder/decoder behaviour, which is what proves which of the +several described encodings Turing actually uses (VER2) and how the +page-tree geometry maps to virtual-address bits. + +## 2. Encodings shipped + +| Encoding | Size | Used for | +|---|---|---| +| `ver2` | 8 bytes | VER2 leaf PTE and directory PDE — the encoding the Turing driver's page-tree path programs | +| `ver2_dual` | 16 bytes | VER2 dual PDE0 slot for mixed 64K/4K page tables: BIG half (low 64 bits) + SMALL half (high 64 bits) | + +Known-but-unsupported encodings (explicit `E_UNSUPPORTED`): the older +non-VER2 PTE/PDE encoding and the "NEW"/VER1 encodings also described by the +same register definitions. They are not shipped because the Turing driver's +page-tree path uses VER2; adding them without a capture to validate against +would be unverified surface. See §8. + +## 3. VER2 leaf PTE (8 bytes) + +| Field (our name) | Bits | Meaning | +|---|---|---| +| `valid` | 0 | entry is present | +| `aperture` / `aperture_name` | 2:1 | 0 video, 1 peer, 2 system coherent, 3 system non-coherent | +| `volatile` | 3 | cache/volatility bit: 0 = cached, 1 = volatile (the format's cache-related field) | +| `encrypted` | 4 | encrypted memory | +| `privilege` | 5 | privileged access | +| `read_only` (`writable`) | 6 | write protection | +| `atomic_disable` | 7 | atomics disabled | +| `address_sys` | 53:8 | system-aperture address bits (PA 53:12) | +| `address_vid` | 32:8 | video/peer address bits (PA 36:12) | +| `peer_id` | 35:33 | peer/fabric id (peer aperture) | +| `comptagline` | 55:36 | compression tag; overloaded as high address bits for the documented 47-bit peer case | +| `kind` / `kind_name` | 63:56 | memory kind id (see §6); unknown ids are reported, not guessed | + +## 4. VER2 directory PDE (8 bytes) + +| Field (our name) | Bits | Meaning | +|---|---|---| +| `is_pde_pointer` / `is_leaf` | 0 | 0 = pointer to a lower table, 1 = 2 MB leaf PTE stored at PDE0 | +| `aperture` / `aperture_name` | 2:1 | 0 invalid, 1 video, 2 system coherent, 3 system non-coherent | +| `volatile` | 3 | as PTE | +| `no_ats` | 5 | skip the ATS lookup for the covered range (driver sets it at depth 2) | +| `address_sys` | 53:8 | next-table address bits (system aperture) | +| `address_vid` | 32:8 | next-table address bits (video aperture) | +| `peer_id` | 35:33 | peer/fabric id | + +Note the aperture-id difference: for a leaf PTE aperture 0 is video memory; +for a directory PDE aperture 0 is invalid. + +## 5. VER2 dual PDE0 (16 bytes) + +The BIG half is the low 64 bits, the SMALL half the high 64 bits. Bit 0 +applies to the whole slot. + +| Field (our name) | Bits | Meaning | +|---|---|---| +| `is_pde_pointer` / `is_leaf` | 0 | whole-slot discriminator | +| `aperture_big` | 2:1 | 0 invalid, 1 video, 2 system coherent, 3 system non-coherent | +| `volatile_big` | 3 | as PTE | +| `no_ats` | 5 | ATS skip; lies inside the BIG address field (see §7) | +| `address_big_sys` | 53:4 | BIG target address; stores address >> 8 (PA 57:8) | +| `address_big_vid` | 32:4 | BIG target address, video aperture | +| `peer_id_big` | 35:33 | BIG peer/fabric id | +| `aperture_small` | 66:65 | as `aperture_big` | +| `volatile_small` | 67 | as PTE | +| `address_small_sys` | 117:72 | SMALL target address; stores address >> 12 (PA 57:12) | +| `address_small_vid` | 96:72 | SMALL target address, video aperture | +| `peer_id_small` | 99:97 | SMALL peer/fabric id | + +Bits 54–64, 68–71 and 118–127 are unnamed by the format definitions and are +reported as reserved when set. + +## 6. Page-tree geometry and KIND ids + +Turing's page-tree path inherits the Pascal/Volta geometry and uses the VER2 +entry encoding. Depths are root (0) to leaf (4): + +| Depth | HW level | VA bits | Index bits | Entry size | Role | +|---|---|---|---|---|---| +| 0 | PDE3 | 48:47 | 2 | 8 | pointer PDE | +| 1 | PDE2 | 46:38 | 9 | 8 | pointer PDE | +| 2 | PDE1 | 37:29 | 9 | 8 | pointer PDE (NO_ATS here) | +| 3 | PDE0 | 28:21 | 8 | 16 (dual) / 8 (2 MB leaf) | dual 64K/4K PDE, or a 2 MB leaf PTE | +| 4 | PTE | 20:12 (4 KB) / 20:16 (64 KB) | 9 (4 KB) / 5 (64 KB) | 8 | leaf PTE | + +Supported page sizes: 2 MB (depth 3), 64 KB and 4 KB (depth 4); VA width 49 +bits. A leaf entry alone does not tell 4 KB from 64 KB — the table's depth +and page-size context do, so the decoder reports `page_size` as +`4k_or_64k` when the depth is pinned to 4. + +The PTE `kind` field carries numeric ids (243 named values re-derived from +the format definitions). Common compute values: `0x00 PITCH`, +`0x06 GENERIC_MEMORY`, `0x07 INVALID`, `0x0F SMSKED_MESSAGE`, +`0xFE GENERIC_16BX2`. Unknown ids decode as `kind_name: null`, +`kind_known: false` with the raw value preserved. + +## 7. Address decode and uncertainty + +* System apertures (`system_coherent_memory`, `system_non_coherent_memory`): + the reconstructed `address` is complete (`address_complete: true`). +* Video/peer apertures: only the low address bits are unambiguous + (`address_complete: false`). `comptagline` is a compression tag in the + normal case and is documented as overloaded for 47-bit peer addresses; the + decoder therefore does not claim a complete video address. +* Dual BIG address: the field stores `address >> 8`, so `address` is + reconstructed as `field << 8`. Bit 5 of the slot is named `no_ats` and also + lies inside the BIG address field (address bit 9). The decoder reports the + flag from bit 5 and excludes that bit from the reconstructed BIG address; + the raw `address_big_sys` / `address_big_vid` fields keep the bit visible. + This assumes the target table is 4 KB aligned, which is the normal GPU + allocation granularity — if a capture shows a non-aligned target, treat + that entry as an unexplained case, not as proof of a different format. +* Reserved-bit policy: `reserved_bits` lists every set bit not covered by a + named field; `reserved_mask` is the same set as a mask. Nothing is + silently dropped. +* The VER2 PTE has no separate cache field: `volatile` (bit 3) is the only + cache-related bit the format defines, so no cache encoding is invented. +* `decode_table` is little-endian and never follows pointers; a blob whose + length is not a multiple of `entry_size` raises `E_TRUNCATED`, an empty + blob decodes to `[]`. + +## 8. Supported levels and explicit gaps + +`levels` may pin the page-tree depth (0–4) or a sequence of acceptable +depths. `None` (default) uses the full supported set and lets each entry's +bit-0 discriminator choose between the leaf-PTE and directory-PDE views, +reporting `level: null` plus `level_candidates` instead of guessing a single +depth. Any depth outside 0–4 raises `E_UNSUPPORTED`. + +Explicit gaps (never guessed): + +1. Pre-VER2 and "NEW"/VER1 encodings are not decoded (`E_UNSUPPORTED`). +2. The decoder does not walk tables, resolve virtual addresses, or infer a + 4 KB vs 64 KB leaf page size from the entry alone. +3. No capture has been validated yet: the golden vectors are constructed + field-by-field from the format definitions, not sampled from silicon. + **Validate against a real capture before trusting a decode.** +4. No timing, device-environment, isolation or security claim is made; the + simulator fidelity levels in `06-ACCURACY.md` apply. + +## 9. Usage + +Python: + +```python +from sim.gmmu_pte import decode_pte, decode_table + +pte = decode_pte(0x0600000000123456, pte_kind="ver2") +entries = decode_table(blob, base_pa=0x2000, entry_size=8, levels=4) +dual = decode_table(blob16, base_pa=0x2000, entry_size=16, kind="ver2_dual", + levels=3) +``` + +CLI: + +```bash +# one entry (default role "auto": bit 0 picks leaf vs directory) +python3 -m sim.gmmu_pte --value 0x0600000000123456 --role pte +python3 -m sim.gmmu_pte --value 0x0600000000123456 --json + +# a captured table blob, with the blob's GPU physical base address +python3 -m sim.gmmu_pte --blob table.bin --base-pa 0x2000 +python3 -m sim.gmmu_pte --blob table.bin --base-pa 0x2000 --levels 4 --json + +# a dual PDE0 slot +python3 -m sim.gmmu_pte --blob dual.bin --base-pa 0x2000 \ + --kind ver2_dual --entry-size 16 --levels 3 --json +``` + +Exit code 0 on success; 2 with an `E_*` code on stderr for unsupported +kinds/levels, truncated blobs or bad values. JSON output is sorted-key and +deterministic. + +## 10. Tests + +`tests/test_gmmu_pte.py` builds every vector field-by-field from the tables +above, asserts field round-trips, reserved-bit reporting (including the +unnamed dual bit 64 and the unnamed PDE bits 4, 6, 7, 54–63), the NO_ATS +overlap behaviour, unknown-kind reporting, unsupported kind/level/entry-size +errors, empty and truncated blobs, deterministic output, and CLI success and +error paths. 44 constructed golden entry vectors across 43 tests. diff --git a/debug/risc-v-gsp-rm-vm/docs/44-GMMU-ORACLE-AND-WORKFLOW.md b/debug/risc-v-gsp-rm-vm/docs/44-GMMU-ORACLE-AND-WORKFLOW.md new file mode 100644 index 0000000..affd350 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/44-GMMU-ORACLE-AND-WORKFLOW.md @@ -0,0 +1,254 @@ +# 44 — GMMU/FBIF oracle and workflow (in development) + +> **Status: in development (2026-10-03, G3 work package).** This page +> documents two stdlib-only harness tools: +> +> * `harness/gmmu_oracle.py` — offline comparison of a human oracle capture +> against a simulator GMMU/FBIF trace (per-register agreement, coverage, +> unmatched lists, fidelity labels); +> * `harness/gmmu_debug.py` — static↔dynamic join: a trace plus a symbol +> map becomes a per-function register-write report and a +> `greeneye_hits.tsv` the existing Ghidra annotator consumes. +> +> Development-only: these tools read files and write reports. They never +> touch a GPU, kernel module, mdev, guest or network, and they make no +> cycle/timing claim. Physical work (the oracle capture) is supervised-only +> per RUNBOOK P1/P7 and is never run by an agent. +> +> **See also:** `06-ACCURACY.md` (fidelity levels, M1–M6), +> `05-GHIDRA-BRIDGE.md` (address spaces, hit annotation), +> `17-RM-HARNESS.md` (read-only RM oracle policy), +> `25-VALUE-VALIDATION.md` (measured-value discipline), +> `36-GSP-DEVICE-MODEL.md` (the simulator device model whose traces feed +> these tools). + +## 1. What this is and why + +The device model (`sim/devices/`, docs/36) records every modelled MMIO +access with its PC, width, direction and value. Two questions follow: + +1. **Accuracy** — does the value the model serves agree with what a human + can observe on the host (read-only RM/sysfs readbacks, supervised + captures)? (`gmmu_oracle.py`) +2. **Static analysis** — which firmware function programs which + GMMU/FBIF register, in what order, and where is it in Ghidra? + (`gmmu_debug.py`) + +Both are offline. The oracle file is produced by a human; the tools ingest +it and compare semantically. Nothing here executes a physical command. + +## 2. The accuracy loop (oracle vs simulator) + +``` +human (supervised, read-only) simulator (agent-safe) + rmtool / rmtop / sysfs / debugfs greeneye-gmmu-fbif-trace/1 + -> greeneye-gmmu-oracle/1 (from sim/ devices + trace) + \ / + \ harness/gmmu_oracle.py compare / + \____________________________________/ + | + gmmu-oracle-report.{json,md} + agreement %, coverage %, unmatched lists, + fidelity label per row +``` + +Rules: + +* The oracle is **observed** (human capture). The simulator value is + **modelled** (device model / recorded trace). Every row carries that + distinction; when a value is missing on either side the row's fidelity + label says the value is **unknown** instead of guessing. +* Agreement is computed **per register on the matched intersection** — it + is never a single global "accuracy %". Coverage is reported separately. +* An oracle-only register means the simulator did not touch it under this + stimulus; a simulator-only register means no oracle counterpart exists + yet. Neither is proof of a gap. +* No timing, ordering or silicon claim. Fidelity is L0/L1 (docs/06). + +### 2.1 Oracle schema `greeneye-gmmu-oracle/1` + +An object with a `captures` list (a bare JSON list is accepted and reported +as `unlabeled-list`): + +```json +{ + "format": "greeneye-gmmu-oracle/1", + "captures": [ + {"name": "FBIF_CTL", "window": "falcon", "offset": "0x624", + "value": "0x1", "source": "rm-readback", "captured_at": "2026-10-03"} + ] +} +``` + +`name`, `window`, `offset`, `value`, `source`, `captured_at` are the +documented fields. `offset`/`value`/`addr` accept ints or `0x…` strings. +A different explicit `format` is a clean error. + +### 2.2 Simulator schema `greeneye-gmmu-fbif-trace/1` + +The producer (`sim/devices/observe.py:build_trace`, docs/36) emits a +nested document: accesses are grouped per window and per declared register, +with an `unclassified` bucket for observed offsets the profile does not +name. The `schema` key carries the format tag: + +```json +{ + "schema": "greeneye-gmmu-fbif-trace/1", + "image_sha256": "…", "profile": "tu104-570.144", + "summary": {"windows": 1, "accesses": 2}, + "windows": [ + { + "label": "falcon-observe", "base": "0x300110000", "size": "0x1000", + "mode": "observe-only", "evidence": "header", + "registers": [ + {"offset": "0x624", "name": "FBIF_CTL", "access": "rw", + "counts": {"r": 0, "w": 1, "served": 0}, + "accesses": [ + {"addr": "0x300110624", "offset": "0x624", "size": 4, "rw": "w", + "value": "0x1", "pc": "0x4002040", "vt": 123, + "window": "falcon-observe", "register": "FBIF_CTL", + "served": false, "pc_symbol": "fbif_setup"} + ]} + ], + "unclassified": {"counts": {"r": 1, "w": 0, "served": 0}, + "accesses": [{"addr": "0x300110008", "offset": "0x8", + "rw": "r", "value": null, "pc": "0x4002050"}]} + } + ] +} +``` + +`load_sim_trace` flattens this shape (and accepts a flat `events` list, a +bare list, JSONL, and aliases) so the harness survives producer naming +choices: `kind`/`rw`/`direction`/`type`/`t`; `name`/`register`/`reg`; +`window`/`block`/`device`; `offset`/`reg_offset`; `addr`/`address`/`pa`; +`value`/`data`; `pc`; `function`/`symbol`/`pc_symbol`; `handled`/`served`. +Nested documents are flattened per window/register: per-register access +order is preserved, but global interleaving across registers is not +recoverable from that grouping. The producer's top-level `summary` — +including `retained_accesses` / `dropped_accesses` — is carried into +`inputs.trace.source.summary`, so a capped (truncated) access log stays +visible downstream. A session directory is accepted too: +`session.json` pointers (`gmmu_fbif_trace`, `gmmu_fbif_trace_blob`, +`trace_blob`) or a `blobs/*.json[l]` file carrying the format tag. A raw +`read`/`write` JSONL trace without register names still parses (registers +are then reported by address). + +## 3. Static-analysis debugging loop (`gmmu_debug.py`) + +``` +greeneye-gmmu-fbif-trace/1 + symbols (greeneye-symbols/1 | addr->name map | TSV) + | + v +per-function register-write report gmmu-debug-report.{json,md} + function, register, window, offset, + sequence of written values, + first/last pc, write/read counts + | + v +greeneye_hits.tsv (addr hits symbol annotation) + | + v +analyzeHeadless ... -postScript MarkGreenEyeHits.java <greeneye_hits.tsv> +``` + +`greeneye_hits.tsv` is byte-compatible with +`greeneye/ghidra.py:export_hits` and `ghidra/MarkGreenEyeHits.java`: header +`addr\thits\tsymbol\tannotation`, hex address, one row per function, `hits` += register accesses attributed to the function. Function attribution uses +the symbol DB (containing symbol when sizes exist, the toolchain's +nearest-preceding fallback otherwise); a pc outside every symbol becomes +`sub_<pc>`. An explicit `function` field in the trace wins over resolution. + +## 4. Exact commands + +All commands run from `open-iov/debug/risc-v-gsp-rm-vm`. They are offline +and safe for an agent. + +```bash +# Accuracy loop — compare a human oracle with a simulator trace/session. +python3 harness/gmmu_oracle.py compare \ + --oracle oracle.json --sim dumps/<session-or-trace.json> \ + --out dumps/gmmu-oracle-report + +# Print (do NOT execute) the supervised, read-only capture instructions. +python3 harness/gmmu_oracle.py --print-capture-hints + +# Static↔dynamic join — report + Ghidra-ready hits TSV. +python3 harness/gmmu_debug.py report \ + --trace dumps/<session-or-trace.json> --symbols symbols.json \ + --out dumps/gmmu-debug-report + +# Synthetic end-to-end fixture (no firmware, no hardware). +python3 harness/gmmu_debug.py demo --out harness/gmmu_debug_fixture + +# Tests (synthetic only; no live oracle capture, no hardware). +python3 -m unittest tests.test_gmmu_oracle tests.test_gmmu_debug -v +``` + +`--print-capture-hints` only prints. It names the read-only RM wrapper +commands (`./rmharness/rmtool.sh gpu list/engine list/mem stats`, +`rmtop --once`), sysfs/proc/debugfs readbacks, and the optional +first-touch step, with the hard warning that physical work is +supervised-only and an agent must never run those commands. The safe +simulator observation (`python3 -m sim.gsp_boot … --record …`) is the only +capture an agent may produce. + +Generated fixture/report directories under `harness/gmmu_*` are gitignored. + +## 5. Worked example (synthetic, measured) + +`python3 harness/gmmu_debug.py demo` builds a 13-event synthetic trace with +three named functions plus one unattributed pc, runs the whole loop, and +prints a `RESULT_JSON` line. On 2026-10-03 (this host, CPython 3.12) the +loop measured **≈1 ms**, against a manual static-reading baseline frozen +in `harness/gmmu_debug.py` **before** the first measurement: + +| field | value | +|---|---| +| manual baseline (frozen) | `1200 s` — a **conservative estimate**, not measured on a human | +| threshold fraction (frozen) | `0.05` → `60 s` | +| measured (fresh run required to quote) | ≈0.001 s | +| verdict | pass | + +The baseline and fraction are constants in `harness/gmmu_debug.py`; moving +them requires a new recorded run and a note here. The demo proves the +**shape** of the loop (trace → per-function report → hits TSV → +Ghidra-ready output), never a hardware fact. + +Synthetic oracle comparison check (three oracle items, three trace events): +one agree, one mismatch, one oracle-only, one simulator-only → agreement +50 % on the two-item intersection, coverage 66.67 %. These are fixture +numbers, not an accuracy claim about any firmware. + +## 6. Limits and not-verified list + +* **No live oracle capture was taken.** The tools were exercised only with + synthetic fixtures. A real oracle requires a supervised human session + per §4 and docs/17 §6. +* **No real firmware trace was compared.** The loader is exercised against + the real `sim/devices/observe.py` producer (read-only integration test) + and against synthetic documents; no captured firmware run was compared, + and no live oracle capture was taken. +* **L0/L1 only.** The trace records execution and register accesses, not + time; no L2/L3 claim is made. Absence of a register in a trace is a fact + about that run's stimulus, not proof of dead code. +* **Agreement is per-register on the intersection**, with coverage and both + unmatched lists reported; never a single global percentage. +* **Function attribution depends on the symbol map.** Zero-size symbols use + the toolchain's nearest-preceding fallback; supply sized symbols + (`greeneye-symbols/1`) for strict boundaries. +* **Product changes are not implied.** Any emulation change still needs the + accuracy loop plus physical reproduction per docs/06 §6. +* The demo baseline is an **estimate**; no human was timed. + +## 7. Tests + +`tests/test_gmmu_oracle.py` and `tests/test_gmmu_debug.py` cover: oracle +ingest (object/list/aliases/bad format), comparison (agreement, mismatch, +oracle-only, simulator-only, name fallback, no-value, empty), session +directory ingest, report shape and sequence order, explicit-function +override, unknown-pc handling, hits-TSV compatibility with +`MarkGreenEyeHits.java`, empty traces, the frozen-threshold demo, and the +CLI error paths (exit 2, no traceback). All fixtures are synthetic; no +hardware, module, guest or network is touched. diff --git a/debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py b/debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py new file mode 100644 index 0000000..794bb1f --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py @@ -0,0 +1,924 @@ +#!/usr/bin/env python3 +"""GMMU/FBIF static<->dynamic join (harness/gmmu_debug.py). + +Answers "which firmware function programs which register, in what order" +from a recorded simulator trace, then writes a Ghidra-ready hit file. + +Inputs +------ + +* A G1 trace: ``greeneye-gmmu-fbif-trace/1``. The producer's document is + nested (``windows[].registers[].accesses[]`` plus + ``windows[].unclassified.accesses[]``); a flat ``events`` list and field + aliases are also accepted so the harness survives producer naming choices + (see :func:`load_sim_trace`). Nested documents are flattened per + window/register: per-register order is preserved, global interleaving + across registers is not recoverable from that shape. +* A session directory containing such a trace under ``blobs/`` or named by + ``session.json`` (``gmmu_fbif_trace`` / ``gmmu_fbif_trace_blob``). +* A symbol DB/map: ``greeneye-symbols/1`` JSON, a plain ``{addr: name}`` + JSON map, or a Ghidra ``addr<TAB>name[...]`` TSV. + +Outputs +------- + +* ``gmmu-debug-report.json`` / ``.md``: per-firmware-function register rows + (function, register, window, offset, sequence of written values, + first/last pc, write and read counts). +* ``greeneye_hits.tsv``: ``addr<TAB>hits<TAB>symbol<TAB>annotation``, the + exact shape ``ghidra/MarkGreenEyeHits.java`` consumes. + +The ``demo`` subcommand builds a small synthetic fixture, runs the whole +loop, and compares the measured wall time against thresholds frozen in this +file before the first measurement. The manual baseline is a conservative +estimate, not a measurement on a human. + +Development-only: this module only reads files and writes reports. It never +touches a GPU, module, mdev, guest or network. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +import time +from dataclasses import dataclass, field +from typing import Any + +TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if TOOL_ROOT not in sys.path: + sys.path.insert(0, TOOL_ROOT) + +from greeneye.errors import GreenEyeError # noqa: E402 +from greeneye.ghidra import render_ghidra_command # noqa: E402 +from greeneye.symbols import Symbol, SymbolDB # noqa: E402 + +#: Schema tag of the G1 simulator observation file. +TRACE_FORMAT = "greeneye-gmmu-fbif-trace/1" +#: Schema tag of the report this module emits. +DEBUG_REPORT_FORMAT = "greeneye-gmmu-debug-report/1" +#: Schema tag of the demo result line. +DEMO_RESULT_FORMAT = "greeneye-gmmu-debug-demo/1" +#: Exact header expected by ``ghidra/MarkGreenEyeHits.java``. +HITS_HEADER = "addr\thits\tsymbol\tannotation" +#: Cap on the per-register value sequence kept in the JSON report. +MAX_SEQUENCE_VALUES = 32 + +# FROZEN BEFORE THE FIRST MEASUREMENT (2026-10-03). Do not move silently: +# a threshold change requires a new recorded run and a note in docs/44. +#: Conservative ESTIMATE (not measured on a human) of the static path for +#: the demo's handful of functions: open the Ghidra project, locate each +#: function, read every register-write site by hand against the register +#: header. ~20 minutes is deliberately generous to the manual baseline. +MANUAL_STATIC_BASELINE_SECONDS = 1200 +MANUAL_BASELINE_LABEL = "conservative estimate (not measured on a human)" +#: The tool path must stay at or below this fraction of the estimate. +WORKFLOW_THRESHOLD_FRACTION = 0.05 + + +class GmmuInputError(GreenEyeError): + """A GMMU input file is absent, unreadable or not in a supported schema.""" + + +def sha256_file(path: str) -> str: + digest = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def parse_int(value: Any, default: int | None = None) -> int | None: + """Parse ints, decimal strings and ``0x`` strings; ``None`` on failure.""" + if value is None: + return default + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + text = value.strip() + if not text: + return default + try: + return int(text, 0) + except ValueError: + try: + return int(text, 16) + except ValueError: + return default + return default + + +def _first_str(event: dict, keys: tuple[str, ...]) -> str: + for key in keys: + value = event.get(key) + if isinstance(value, str) and value: + return value + if isinstance(value, int) and not isinstance(value, bool): + return str(value) + return "" + + +def _first_int(event: dict, keys: tuple[str, ...]) -> int | None: + for key in keys: + if key in event: + parsed = parse_int(event.get(key)) + if parsed is not None: + return parsed + return None + + +def _first_bool(event: dict, keys: tuple[str, ...]) -> bool | None: + for key in keys: + value = event.get(key) + if isinstance(value, bool): + return value + return None + + +_WRITE_WORDS = {"w", "write", "wr", "store", "st", "out"} +_READ_WORDS = {"r", "read", "rd", "load", "ld", "in"} +_OTHER_WORDS = {"exec", "summary", "note", "event"} + + +def _normalise_kind(event: dict) -> str: + for key in ("kind", "rw", "direction", "access", "op", "type", "t"): + value = event.get(key) + if isinstance(value, str): + low = value.strip().lower() + if low in _WRITE_WORDS: + return "write" + if low in _READ_WORDS: + return "read" + if low in _OTHER_WORDS: + return "other" + write_flag = event.get("write") + if isinstance(write_flag, bool): + return "write" if write_flag else "read" + return "other" + + +@dataclass +class TraceObservation: + """One register access from a simulator trace.""" + + seq: int + kind: str + name: str = "" + window: str = "" + offset: int | None = None + addr: int | None = None + size: int | None = None + value: int | None = None + pc: int | None = None + function: str = "" + handled: bool | None = None + vt: int | None = None + + def to_json(self) -> dict: + out: dict[str, Any] = {"seq": self.seq, "kind": self.kind} + if self.name: + out["name"] = self.name + if self.window: + out["window"] = self.window + if self.offset is not None: + out["offset"] = self.offset + if self.addr is not None: + out["addr"] = self.addr + if self.size is not None: + out["size"] = self.size + if self.value is not None: + out["value"] = self.value + if self.pc is not None: + out["pc"] = self.pc + if self.function: + out["function"] = self.function + if self.handled is not None: + out["handled"] = self.handled + if self.vt is not None: + out["vt"] = self.vt + return out + + +@dataclass +class SimTrace: + path: str + format: str + sha256: str + observations: list[TraceObservation] = field(default_factory=list) + source: dict = field(default_factory=dict) + + +def _observation_from_event(event: dict, seq: int) -> TraceObservation: + return TraceObservation( + seq=seq, + kind=_normalise_kind(event), + name=_first_str(event, ("name", "register", "reg", "signal")), + window=_first_str(event, ("window", "block", "device", "bar", "region")), + offset=_first_int(event, ("offset", "reg_offset", "index")), + addr=_first_int(event, ("addr", "address", "pa", "guest_addr", "gpa")), + size=_first_int(event, ("size", "width", "bytes")), + value=_first_int(event, ("value", "data", "val")), + pc=_first_int(event, ("pc", "pc_addr")), + function=_first_str(event, ("function", "symbol", "func", "pc_symbol")), + handled=_first_bool(event, ("handled", "served")), + vt=_first_int(event, ("vt", "virtual_time")), + ) + + +_EVENT_KEYS = ("events", "trace", "accesses", "items", "observations") + + +def _flatten_windows(payload: dict) -> list[dict]: + """Flatten the G1 nested document (windows -> registers -> accesses).""" + events: list[dict] = [] + for window in payload.get("windows", []): + if not isinstance(window, dict): + continue + label = window.get("label") or window.get("window") or "" + for register in window.get("registers", []) or []: + if not isinstance(register, dict): + continue + name = register.get("name") + for access in register.get("accesses", []) or []: + if not isinstance(access, dict): + continue + event = dict(access) + if label: + event.setdefault("window", label) + if name: + event.setdefault("register", name) + events.append(event) + unclassified = window.get("unclassified") + if isinstance(unclassified, dict): + for access in unclassified.get("accesses", []) or []: + if not isinstance(access, dict): + continue + event = dict(access) + if label: + event.setdefault("window", label) + events.append(event) + return events + + +def _source_metadata(payload: dict) -> dict: + source: dict = {} + if isinstance(payload.get("source"), dict): + source.update(payload["source"]) + for key in ("image_sha256", "profile", "profile_name", "symbols"): + if payload.get(key) is not None: + source.setdefault(key, payload[key]) + summary = payload.get("summary") + if isinstance(summary, dict): + source.setdefault("summary", summary) + return source + + +def _events_from_payload(payload: Any) -> tuple[str, list | None, dict]: + if isinstance(payload, list): + return "unlabeled-list", payload, {} + if not isinstance(payload, dict): + raise GmmuInputError("trace JSON must be an object or a list of events") + fmt = str(payload.get("format") or payload.get("schema") or "") + source = _source_metadata(payload) + for key in _EVENT_KEYS: + if isinstance(payload.get(key), list): + return fmt, payload[key], source + if isinstance(payload.get("windows"), list): + return fmt, _flatten_windows(payload), source + if fmt == TRACE_FORMAT: + return fmt, [], source + return fmt, None, source + + +def _parse_jsonl(text: str) -> tuple[str, list[dict], dict]: + fmt = "" + events: list[dict] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(item, dict): + continue + if item.get("format") or item.get("schema"): + fmt = str(item.get("format") or item.get("schema")) + if not any(k in item for k in ("addr", "pc", "offset", "name", "register")): + continue + if item.get("t") == "summary": + continue + events.append(item) + return fmt, events, {} + + +def _find_session_trace(session_dir: str) -> str: + session_json = os.path.join(session_dir, "session.json") + explicit: list[str] = [] + if os.path.isfile(session_json): + try: + with open(session_json, "r", encoding="utf-8") as fh: + meta = json.load(fh) + except (OSError, json.JSONDecodeError): + meta = {} + if isinstance(meta, dict): + for key in ("gmmu_fbif_trace", "gmmu_fbif_trace_blob", "trace_blob"): + value = meta.get(key) + if isinstance(value, str) and value: + explicit.append(os.path.join(session_dir, value)) + for candidate in explicit: + if os.path.isfile(candidate): + return candidate + blobs = os.path.join(session_dir, "blobs") + if os.path.isdir(blobs): + for name in sorted(os.listdir(blobs)): + if not name.endswith((".json", ".jsonl")): + continue + candidate = os.path.join(blobs, name) + try: + with open(candidate, "r", encoding="utf-8", errors="replace") as fh: + head = fh.read(1 << 16) + except OSError: + continue + if TRACE_FORMAT in head: + return candidate + for name in sorted(os.listdir(blobs)): + if name.endswith(".jsonl"): + return os.path.join(blobs, name) + raise GmmuInputError( + f"{session_dir}: no {TRACE_FORMAT} trace found (looked for session.json " + "pointers and blobs/*.json[l])" + ) + + +def load_sim_trace(path: str) -> SimTrace: + """Load a G1 trace file, or a session directory containing one. + + Accepted shapes: the G1 nested document (``schema`` + + ``windows[].registers[].accesses[]``), an object with a flat + ``events``/``trace``/``accesses`` list, a bare list, and JSONL. Event + aliases: ``kind``/``rw``/``direction``/``type``/``t``; + ``name``/``register``/``reg``; ``window``/``block``/``device``; + ``offset``/``reg_offset``; ``addr``/``address``/``pa``; + ``value``/``data``; ``pc``; ``function``/``symbol``/``pc_symbol``; + ``handled``/``served``. Unknown fields are ignored; an event with no + recognisable direction is kept as ``other``. + """ + if os.path.isdir(path): + path = _find_session_trace(path) + if not os.path.isfile(path): + raise GmmuInputError(f"trace not found: {path}") + digest = sha256_file(path) + with open(path, "r", encoding="utf-8", errors="replace") as fh: + text = fh.read() + try: + payload = json.loads(text) + except json.JSONDecodeError: + payload = None + if payload is not None: + fmt, events, source = _events_from_payload(payload) + if events is None: + raise GmmuInputError( + f"{path}: no event list (expected {TRACE_FORMAT} with an 'events' key)" + ) + else: + fmt, events, source = _parse_jsonl(text) + observations = [ + _observation_from_event(event, seq) + for seq, event in enumerate(events) + if isinstance(event, dict) + ] + return SimTrace( + path=os.path.abspath(path), + format=fmt or "unlabeled", + sha256=digest, + observations=observations, + source=source, + ) + + +def load_symbols(path: str | None) -> SymbolDB: + """Load a symbol DB (JSON) or a Ghidra-style TSV/map (``addr<TAB>name``).""" + db = SymbolDB() + if not path: + return db + if not os.path.isfile(path): + raise GmmuInputError(f"symbol map not found: {path}") + if path.endswith(".json"): + try: + with open(path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + except (OSError, json.JSONDecodeError) as exc: + raise GmmuInputError(f"cannot read symbol map {path}: {exc}") from exc + if isinstance(payload, dict) and ( + "symbols" in payload or payload.get("format") == "greeneye-symbols/1" + ): + db.load_json(path) + return db + if isinstance(payload, list): + db.load_json(path) + return db + if isinstance(payload, dict): + for key, value in payload.items(): + addr = parse_int(key) + if addr is None: + continue + if isinstance(value, str): + db.add(Symbol(addr=addr, name=value, source="map")) + elif isinstance(value, dict) and value.get("name"): + db.add( + Symbol( + addr=addr, + name=str(value["name"]), + size=parse_int(value.get("size"), 0) or 0, + source="map", + ) + ) + return db + raise GmmuInputError(f"{path}: unsupported symbol JSON shape") + db.load_index_tsv(path) + if len(db) == 0: + db.load_names_tsv(path) + if len(db) == 0: + raise GmmuInputError(f"{path}: no symbols parsed") + return db + + +def _function_identity( + obs: TraceObservation, symbols: SymbolDB | None +) -> tuple[tuple | None, int | None, str]: + """Resolve an observation to ``(group_key, entry_addr, display_name)``. + + An explicit ``function`` field groups by name (all events of one named + function stay together even without a symbol DB); a resolved symbol + groups by its entry address; an unresolved pc becomes ``sub_<pc>``. + """ + if obs.function: + symbol = symbols.get(obs.function) if symbols is not None else None + return ( + ("name", obs.function), + symbol.addr if symbol is not None else None, + obs.function, + ) + if obs.pc is None: + return None, None, "" + symbol = symbols.resolve(obs.pc) if symbols is not None else None + if symbol is not None: + return ("addr", symbol.addr), symbol.addr, symbol.name + return ("sub", obs.pc), obs.pc, f"sub_{obs.pc:#x}" + + +def _register_identity(obs: TraceObservation) -> tuple: + if obs.offset is not None: + return (obs.window, obs.offset, obs.name) + if obs.name: + return (obs.window, None, obs.name) + return (obs.window, obs.addr, "") + + +def _register_label(obs: TraceObservation) -> str: + if obs.name: + return obs.name + if obs.offset is not None: + return f"0x{obs.offset:x}" + if obs.addr is not None: + return f"0x{obs.addr:x}" + return "?" + + +@dataclass +class RegisterRow: + function: str + function_addr: int | None + register: str + window: str + offset: int | None + addr: int | None + write_count: int = 0 + read_count: int = 0 + first_pc: int | None = None + last_pc: int | None = None + first_seq: int | None = None + last_seq: int | None = None + values: list[int] = field(default_factory=list) + values_truncated: bool = False + + def observe(self, obs: TraceObservation) -> None: + if obs.kind == "write": + self.write_count += 1 + if obs.value is not None: + if len(self.values) < MAX_SEQUENCE_VALUES: + self.values.append(obs.value) + else: + self.values_truncated = True + elif obs.kind == "read": + self.read_count += 1 + if obs.pc is not None: + if self.first_pc is None: + self.first_pc = obs.pc + self.last_pc = obs.pc + if self.first_seq is None: + self.first_seq = obs.seq + self.last_seq = obs.seq + + def to_json(self) -> dict: + out: dict[str, Any] = { + "register": self.register, + "write_count": self.write_count, + "read_count": self.read_count, + "first_seq": self.first_seq, + "last_seq": self.last_seq, + } + if self.window: + out["window"] = self.window + if self.offset is not None: + out["offset"] = self.offset + if self.addr is not None: + out["addr"] = self.addr + if self.first_pc is not None: + out["first_pc"] = self.first_pc + if self.last_pc is not None: + out["last_pc"] = self.last_pc + out["values"] = self.values + out["values_truncated"] = self.values_truncated + return out + + +def build_debug_report(trace: SimTrace, symbols: SymbolDB | None) -> dict: + """Group trace observations by firmware function and register.""" + functions: dict[tuple, dict] = {} + order: list[tuple] = [] + unknown_pc = 0 + for obs in trace.observations: + fkey, addr, function = _function_identity(obs, symbols) + if fkey is None: + unknown_pc += 1 + continue + entry = functions.get(fkey) + if entry is None: + entry = { + "function": function, + "addr": addr, + "first_pc": None, + "writes": 0, + "reads": 0, + "registers": {}, + "order": [], + } + functions[fkey] = entry + order.append(fkey) + rkey = _register_identity(obs) + row = entry["registers"].get(rkey) + if row is None: + row = RegisterRow( + function=function, + function_addr=addr, + register=_register_label(obs), + window=obs.window, + offset=obs.offset, + addr=obs.addr, + ) + entry["registers"][rkey] = row + entry["order"].append(rkey) + row.observe(obs) + if obs.pc is not None and entry["first_pc"] is None: + entry["first_pc"] = obs.pc + if obs.kind == "write": + entry["writes"] += 1 + elif obs.kind == "read": + entry["reads"] += 1 + + def sort_key(fkey: tuple): + entry = functions[fkey] + anchor = entry["addr"] if entry["addr"] is not None else entry["first_pc"] + return (anchor is None, anchor or 0, entry["function"]) + + function_rows = [] + for fkey in sorted(order, key=sort_key): + entry = functions[fkey] + register_rows = [entry["registers"][rkey] for rkey in entry["order"]] + register_rows.sort( + key=lambda r: (r.window, r.offset is None, r.offset or 0, r.register) + ) + function_rows.append( + { + "function": entry["function"], + "addr": entry["addr"], + "first_pc": entry["first_pc"], + "writes": entry["writes"], + "reads": entry["reads"], + "registers": [r.to_json() for r in register_rows], + } + ) + + total_registers = sum(len(f["registers"]) for f in function_rows) + return { + "format": DEBUG_REPORT_FORMAT, + "inputs": { + "trace": { + "path": trace.path, + "sha256": trace.sha256, + "format": trace.format, + "events": len(trace.observations), + "source": trace.source, + } + }, + "summary": { + "functions": len(function_rows), + "registers": total_registers, + "writes": sum(f["writes"] for f in function_rows), + "reads": sum(f["reads"] for f in function_rows), + "unknown_pc_events": unknown_pc, + "trace_events": len(trace.observations), + }, + "functions": function_rows, + "limits": [ + "L0/L1 evidence only: the trace records execution and register accesses, not timing.", + "Absence of a write is not proof a register is untouched outside this run's stimulus.", + "Synthetic demo fixture values are invented; real traces must come from the simulator.", + ], + } + + +def write_hits_tsv(report: dict, path: str) -> str: + """Write a ``greeneye_hits.tsv`` compatible with MarkGreenEyeHits.java.""" + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(HITS_HEADER + "\n") + for function in report["functions"]: + addr = function.get("addr") + if addr is None: + addr = function.get("first_pc") + if addr is None: + for register in function["registers"]: + if register.get("first_pc") is not None: + addr = register["first_pc"] + break + if addr is None: + continue + hits = function["writes"] + function["reads"] + annotation = ( + f"gmmu: {function['writes']} write(s), {function['reads']} read(s) " + f"across {len(function['registers'])} register(s)" + ) + fh.write( + f"{addr:#x}\t{hits}\t{function['function']}\t{annotation}\n" + ) + return os.path.abspath(path) + + +def render_debug_markdown(report: dict, hits_path: str, ghidra_command: str) -> str: + summary = report["summary"] + lines = [ + "# GMMU/FBIF per-function register-write report", + "", + f"> Format `{report['format']}`. Development-only static<->dynamic join;", + "> no timing or silicon claim. Fidelity: L0/L1 (docs/06).", + "", + "## Inputs", + "", + f"* trace: `{report['inputs']['trace']['path']}` " + f"({report['inputs']['trace']['events']} events, " + f"format `{report['inputs']['trace']['format']}`, " + f"sha256 `{report['inputs']['trace']['sha256'][:16]}…`)", + "", + "## Summary", + "", + "| functions | registers | writes | reads | unknown-pc events |", + "|---|---|---|---|---|", + f"| {summary['functions']} | {summary['registers']} | {summary['writes']} | " + f"{summary['reads']} | {summary['unknown_pc_events']} |", + "", + "## Per function", + "", + ] + for function in report["functions"]: + addr = f"{function['addr']:#x}" if function["addr"] is not None else "unknown" + lines.append( + f"### `{function['function']}` @ {addr} — " + f"{function['writes']} write(s), {function['reads']} read(s)" + ) + lines.append("") + lines.append("| register | window | offset | writes | reads | first pc | last pc | value sequence |") + lines.append("|---|---|---|---|---|---|---|---|") + for register in function["registers"]: + offset = f"{register['offset']:#x}" if register.get("offset") is not None else "" + first_pc = f"{register['first_pc']:#x}" if register.get("first_pc") is not None else "" + last_pc = f"{register['last_pc']:#x}" if register.get("last_pc") is not None else "" + sequence = ", ".join(f"{v:#x}" for v in register["values"]) + if register.get("values_truncated"): + sequence += ", …" + lines.append( + f"| `{register['register']}` | {register.get('window', '')} | {offset} | " + f"{register['write_count']} | {register['read_count']} | {first_pc} | {last_pc} | " + f"{sequence or ''} |" + ) + lines.append("") + lines += [ + "## Ghidra annotation", + "", + f"Hits file: `{hits_path}`", + "", + "```bash", + ghidra_command, + "```", + "", + "## Limits", + "", + ] + for limit in report["limits"]: + lines.append(f"* {limit}") + lines.append("") + return "\n".join(lines) + + +def run_report(trace_path: str, symbols_path: str | None, out_dir: str) -> dict: + """Run the full trace -> report -> hits-TSV loop into ``out_dir``.""" + trace = load_sim_trace(trace_path) + symbols = load_symbols(symbols_path) + report = build_debug_report(trace, symbols) + os.makedirs(out_dir, exist_ok=True) + report_json = os.path.join(out_dir, "gmmu-debug-report.json") + report_md = os.path.join(out_dir, "gmmu-debug-report.md") + hits_tsv = os.path.join(out_dir, "greeneye_hits.tsv") + with open(report_json, "w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2, sort_keys=True) + fh.write("\n") + write_hits_tsv(report, hits_tsv) + ghidra_command = render_ghidra_command( + "<ghidra-project-dir>", "<project>", "<program>", os.path.abspath(hits_tsv) + ) + with open(report_md, "w", encoding="utf-8") as fh: + fh.write(render_debug_markdown(report, os.path.abspath(hits_tsv), ghidra_command)) + report["artifacts"] = { + "report_json": os.path.abspath(report_json), + "report_md": os.path.abspath(report_md), + "hits_tsv": os.path.abspath(hits_tsv), + "ghidra_command": ghidra_command, + } + return report + + +DEMO_FUNCTIONS = ( + (0x4001000, "gmmu_map_alloc", 0x120), + (0x4002000, "fbif_setup", 0x80), + (0x4003000, "gmmu_pte_write", 0x60), +) + + +def _fixture_events() -> list[dict]: + raw = [ + (0x4002010, "write", "falcon", 0x600, "FBIF_TRANSCFG0", 4, 0x00000000), + (0x4002020, "write", "falcon", 0x604, "FBIF_TRANSCFG1", 4, 0x00000001), + (0x4002030, "write", "falcon", 0x608, "FBIF_TRANSCFG2", 4, 0x00000002), + (0x4002040, "write", "falcon", 0x624, "FBIF_CTL", 4, 0x00000001), + (0x4002050, "read", "falcon", 0x008, "IRQSTAT", 4, 0x00000000), + (0x4001010, "write", "gmmu", 0x0000, "GMMU_PDB_LO", 4, 0x00400000), + (0x4001020, "write", "gmmu", 0x0004, "GMMU_PDB_HI", 4, 0x00000000), + (0x4001030, "write", "gmmu", 0x0010, "GMMU_MODE", 4, 0x00000007), + (0x4001040, "write", "gmmu", 0x0010, "GMMU_MODE", 4, 0x0000000F), + (0x4001050, "read", "gmmu", 0x0018, "GMMU_STATUS", 4, 0x00000000), + (0x4003010, "write", "gmmu", 0x0200, "PTE_DATA", 8, 0x0000000000040001), + (0x4003020, "write", "gmmu", 0x0208, "PTE_DATA", 8, 0x0000000000040001), + (0x4004010, "write", "falcon", 0x110, "DMATRFBASE", 4, 0x00000000), + ] + return [ + { + "seq": seq, + "vt": 100 + seq, + "pc": pc, + "kind": kind, + "window": window, + "offset": offset, + "name": name, + "size": size, + "value": value, + "handled": True, + } + for seq, (pc, kind, window, offset, name, size, value) in enumerate(raw) + ] + + +def build_fixture(out_dir: str) -> dict: + """Write the synthetic demo fixture (symbols + trace); return the paths. + + Symbols carry sizes so a pc past every function stays unattributed + (``sub_<pc>``) instead of being absorbed by the nearest-preceding name. + """ + os.makedirs(out_dir, exist_ok=True) + symbols_path = os.path.join(out_dir, "symbols.json") + symbols_payload = { + "format": "greeneye-symbols/1", + "count": len(DEMO_FUNCTIONS), + "symbols": [ + {"addr": addr, "name": name, "size": size, "source": "fixture"} + for addr, name, size in DEMO_FUNCTIONS + ], + } + with open(symbols_path, "w", encoding="utf-8") as fh: + json.dump(symbols_payload, fh, indent=2, sort_keys=True) + fh.write("\n") + trace_path = os.path.join(out_dir, "trace.json") + payload = { + "format": TRACE_FORMAT, + "source": { + "kind": "synthetic-fixture", + "note": "invented values; proves the join shape only, never a hardware claim", + }, + "events": _fixture_events(), + } + with open(trace_path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + fh.write("\n") + return { + "symbols": os.path.abspath(symbols_path), + "trace": os.path.abspath(trace_path), + "events": len(payload["events"]), + "functions": len(DEMO_FUNCTIONS), + } + + +def run_demo(out_dir: str) -> dict: + """Build the fixture, run the loop, and check the frozen threshold.""" + fixture = build_fixture(out_dir) + start = time.perf_counter() + report = run_report(fixture["trace"], fixture["symbols"], out_dir) + elapsed = time.perf_counter() - start + threshold = MANUAL_STATIC_BASELINE_SECONDS * WORKFLOW_THRESHOLD_FRACTION + result = { + "format": DEMO_RESULT_FORMAT, + "scenario": "trace -> per-function register report -> greeneye_hits.tsv -> Ghidra-ready", + "fixture": fixture, + "summary": report["summary"], + "measured_seconds": round(elapsed, 4), + "manual_baseline_seconds": MANUAL_STATIC_BASELINE_SECONDS, + "manual_baseline_label": MANUAL_BASELINE_LABEL, + "threshold_fraction": WORKFLOW_THRESHOLD_FRACTION, + "threshold_seconds": round(threshold, 4), + "pass": elapsed <= threshold, + "artifacts": report["artifacts"], + "limits": report["limits"], + } + with open(os.path.join(out_dir, "demo-result.json"), "w", encoding="utf-8") as fh: + json.dump(result, fh, indent=2, sort_keys=True) + fh.write("\n") + print("RESULT_JSON: " + json.dumps(result, sort_keys=True)) + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="gmmu_debug.py", + description="GMMU/FBIF trace -> per-function register-write report -> Ghidra hits TSV.", + ) + sub = parser.add_subparsers(dest="command") + report = sub.add_parser("report", help="build the report from a trace and symbol map") + report.add_argument("--trace", required=True, help="trace file or session directory") + report.add_argument("--symbols", default=None, help="symbol DB/map (JSON or TSV)") + report.add_argument("--out", required=True, help="output directory") + demo = sub.add_parser("demo", help="run the synthetic end-to-end fixture") + demo.add_argument( + "--out", + default=os.path.join(TOOL_ROOT, "harness", "gmmu_debug_fixture"), + help="fixture/report directory (gitignored)", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if args.command == "report": + try: + report = run_report(args.trace, args.symbols, args.out) + except GmmuInputError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + print( + json.dumps( + { + "format": report["format"], + "summary": report["summary"], + "artifacts": report["artifacts"], + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + if args.command == "demo": + result = run_demo(args.out) + return 0 if result["pass"] else 1 + parser.print_help() + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/gmmu_oracle.py b/debug/risc-v-gsp-rm-vm/harness/gmmu_oracle.py new file mode 100644 index 0000000..c504550 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/gmmu_oracle.py @@ -0,0 +1,585 @@ +#!/usr/bin/env python3 +"""Host-accuracy oracle comparison for GMMU/FBIF register observations. + +Ingests two files and compares them semantically: + +* an **oracle capture** (schema ``greeneye-gmmu-oracle/1``): a list of + ``{name, window, offset, value, source, captured_at}`` items produced by a + human from read-only RM queries or supervised captures; +* a **simulator observation** (G1's ``greeneye-gmmu-fbif-trace/1``, or a + session directory containing one), ingested by + :func:`harness.gmmu_debug.load_sim_trace`. + +The comparison reports per-register value agreement, coverage (oracle items +with no simulator observation), and simulator accesses with no oracle +counterpart, with an explicit fidelity label per row (observed vs modelled +vs unknown). It emits ``gmmu-oracle-report.json`` and ``.md``. + +``--print-capture-hints`` prints (and only prints) the supervised, read-only +commands a human would run to produce an oracle. Physical work is +supervised-only per RUNBOOK P1/P7; an agent must never run those commands. + +No cycle/timing claim is made anywhere. This tool only reads files and +writes reports; it never touches a GPU, module, mdev, guest or network. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from dataclasses import dataclass, field +from typing import Any + +TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if TOOL_ROOT not in sys.path: + sys.path.insert(0, TOOL_ROOT) + +from harness.gmmu_debug import ( # noqa: E402 + GmmuInputError, + SimTrace, + TraceObservation, + load_sim_trace, + parse_int, + sha256_file, +) + +#: Schema tag of the human-produced oracle capture. +ORACLE_FORMAT = "greeneye-gmmu-oracle/1" +#: Schema tag of the report this module emits. +REPORT_FORMAT = "greeneye-gmmu-oracle-report/1" + +CAPTURE_HINTS = """\ +================================================================================ +GMMU/FBIF ORACLE CAPTURE HINTS — SUPERVISED, READ-ONLY, HUMAN-RUN ONLY +================================================================================ +This command prints instructions; it executes NOTHING. + +HARD RULES (RUNBOOK P1/P7, SIMULATOR-WORK-BOUNDARY.txt): + * Physical work is supervised-only: a named human present, one cycle at a + time. An agent MUST NOT run any command below. + * Read-only means exactly that: no module load/unload, no mdev create or + claim, no bind/unbind, no guest access, no /sys/fs/pstore, no reboot. + * The RM tools used here allocate only an RM client/device/subdevice handle + pair and issue allowlisted read-only controls (docs/17-RM-HARNESS.md §6). + * A simulator observation is not silicon truth; only a supervised capture + is. Product changes still need physical reproduction. + +STEP 1 — read-only RM inventory (record the session, human-run): + cd open-iov/debug/risc-v-gsp-rm-vm + ./rmharness/harness.sh --session dumps/real-rm-before --name gmmu-oracle \\ + -- rmtool gpu list --format=json + ./rmharness/harness.sh --session dumps/real-rm-before --name gmmu-oracle \\ + -- rmtool engine list --format=json + ./rmharness/harness.sh --session dumps/real-rm-before --name gmmu-oracle \\ + -- rmtool mem stats --format=json + ./rmharness/harness.sh --session dumps/real-rm-before --name gmmu-oracle \\ + -- rmtop --once + # allowlisted read-only control values (never a write control): + ./rmharness/rmtool.sh nv2080 method call --help + +STEP 2 — sysfs/proc/debugfs readbacks (read-only `cat`): + cat /proc/driver/nvidia/gpus/*/information + lspci -vv -s <bdf> # config space readback + cat /sys/bus/pci/devices/<bdf>/resource # BAR ranges/sizes + # vgsp debugfs (host module; read-only reads): + ls /sys/kernel/debug/vgsp/ + cat /sys/kernel/debug/vgsp/gsp-phys-* # name the exact file per readback + +STEP 3 — optional supervised first-touch capture: + A simulator observation is produced without hardware (safe, agent-allowed): + python3 -m sim.gsp_boot --instructions 2000000 --record dumps/gmmu-sim + A physical first-touch capture is a supervised human step and must not be + scripted by an agent. + +STEP 4 — write the oracle file (human, from the readbacks above): + { + "format": "greeneye-gmmu-oracle/1", + "captures": [ + {"name": "FBIF_CTL", "window": "falcon", "offset": "0x624", + "value": "0x1", "source": "rm-readback", "captured_at": "<date>"}, + {"name": "BOOT_42", "window": "pmc", "offset": "0xa00", + "value": "0x164", "source": "sysfs-readback", "captured_at": "<date>"} + ] + } + Then compare (safe, offline): + python3 harness/gmmu_oracle.py compare \\ + --oracle oracle.json --sim dumps/gmmu-sim --out dumps/gmmu-oracle-report +================================================================================ +""" + + +@dataclass +class OracleItem: + index: int + name: str + window: str + offset: int | None + addr: int | None + value: int | None + source: str + captured_at: str + raw: dict = field(default_factory=dict) + + +@dataclass +class OracleCapture: + path: str + format: str + sha256: str + items: list[OracleItem] = field(default_factory=list) + + +def load_oracle(path: str) -> OracleCapture: + """Load a ``greeneye-gmmu-oracle/1`` capture (object wrapper or bare list). + + A missing ``format`` is tolerated and reported as ``unlabeled-list`` / + ``unlabeled-object``; a different explicit format is a clean error. + """ + if not os.path.isfile(path): + raise GmmuInputError(f"oracle not found: {path}") + digest = sha256_file(path) + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + payload = json.load(fh) + except (OSError, json.JSONDecodeError) as exc: + raise GmmuInputError(f"cannot read oracle {path}: {exc}") from exc + if isinstance(payload, list): + fmt = "unlabeled-list" + items_payload: list = payload + elif isinstance(payload, dict): + fmt = str(payload.get("format") or payload.get("schema") or "unlabeled-object") + if fmt not in (ORACLE_FORMAT, "unlabeled-object"): + raise GmmuInputError( + f"{path}: format {fmt!r} is not {ORACLE_FORMAT!r}" + ) + found = None + for key in ("captures", "items", "registers", "observations", "oracle"): + candidate = payload.get(key) + if isinstance(candidate, list): + found = candidate + break + if found is None: + raise GmmuInputError(f"{path}: no capture list (expected 'captures')") + items_payload = found + else: + raise GmmuInputError(f"{path}: oracle must be an object or list") + items: list[OracleItem] = [] + for index, raw in enumerate(items_payload): + if not isinstance(raw, dict): + continue + name = str(raw.get("name") or raw.get("register") or "") + window = str(raw.get("window") or raw.get("block") or "") + offset = parse_int(raw.get("offset", raw.get("reg_offset"))) + addr = parse_int(raw.get("addr", raw.get("address"))) + value = parse_int(raw.get("value")) + if not name: + if offset is not None: + name = f"0x{offset:x}" + elif addr is not None: + name = f"0x{addr:x}" + else: + name = f"item_{index}" + items.append( + OracleItem( + index=index, + name=name, + window=window, + offset=offset, + addr=addr, + value=value, + source=str(raw.get("source") or "unlabelled"), + captured_at=str(raw.get("captured_at") or ""), + raw=raw, + ) + ) + return OracleCapture( + path=os.path.abspath(path), format=fmt, sha256=digest, items=items + ) + + +def _obs_keys(obs: TraceObservation) -> list[tuple]: + keys: list[tuple] = [] + if obs.offset is not None: + if obs.window: + keys.append(("window-offset", obs.window, obs.offset)) + keys.append(("offset", obs.offset)) + if obs.name and obs.window: + keys.append(("window-name", obs.window, obs.name)) + if obs.name: + keys.append(("name", obs.name)) + if obs.addr is not None: + keys.append(("addr", obs.addr)) + return keys + + +def _item_keys(item: OracleItem) -> list[tuple]: + keys: list[tuple] = [] + if item.offset is not None: + if item.window: + keys.append(("window-offset", item.window, item.offset)) + keys.append(("offset", item.offset)) + if item.name and item.window: + keys.append(("window-name", item.window, item.name)) + if item.name: + keys.append(("name", item.name)) + if item.addr is not None: + keys.append(("addr", item.addr)) + return keys + + +def _group_observations(observations: list[TraceObservation]) -> tuple[list[dict], dict]: + """Cluster trace observations into registers; return (groups, key index).""" + groups: list[dict] = [] + index: dict[tuple, int] = {} + for obs in observations: + keys = _obs_keys(obs) + gidx = None + for key in keys: + if key in index: + gidx = index[key] + break + if gidx is None: + gidx = len(groups) + groups.append({"observations": []}) + groups[gidx]["observations"].append(obs) + for key in keys: + index.setdefault(key, gidx) + return groups, index + + +def _group_descriptor(group: dict) -> dict: + observations: list[TraceObservation] = group["observations"] + first = observations[0] + last = observations[-1] + writes = [o for o in observations if o.kind == "write"] + reads = [o for o in observations if o.kind == "read"] + value_obs = writes[-1] if writes else (reads[-1] if reads else last) + out: dict[str, Any] = { + "register": first.name or ( + f"0x{first.offset:x}" if first.offset is not None + else (f"0x{first.addr:x}" if first.addr is not None else "?") + ), + "observations": len(observations), + "writes": len(writes), + "reads": len(reads), + } + if first.window: + out["window"] = first.window + if first.offset is not None: + out["offset"] = first.offset + if first.addr is not None: + out["addr"] = first.addr + if value_obs.value is not None: + out["last_value"] = value_obs.value + out["fidelity"] = "modelled (no oracle counterpart)" + else: + out["fidelity"] = "modelled (value unknown; no oracle counterpart)" + pcs = [o.pc for o in observations if o.pc is not None] + if pcs: + out["first_pc"] = pcs[0] + out["last_pc"] = pcs[-1] + return out + + +def compare(oracle: OracleCapture, trace: SimTrace) -> dict: + """Semantic oracle-vs-simulator comparison; no timing claims.""" + groups, index = _group_observations(trace.observations) + matched_groups: set[int] = set() + rows: list[dict] = [] + unmatched_oracle: list[dict] = [] + for item in oracle.items: + gidx = None + matched_by = "" + for key in _item_keys(item): + if key in index: + gidx = index[key] + matched_by = key[0] + break + if gidx is None: + unmatched_oracle.append( + { + "register": item.name, + "window": item.window, + "offset": item.offset, + "oracle_value": item.value, + "source": item.source, + "captured_at": item.captured_at, + "fidelity": "observed (no simulator counterpart)", + } + ) + rows.append( + { + "register": item.name, + "window": item.window, + "offset": item.offset, + "oracle_value": item.value, + "sim_value": None, + "status": "oracle-only", + "matched_by": "", + "fidelity": "observed (no simulator counterpart)", + "source": item.source, + "captured_at": item.captured_at, + } + ) + continue + matched_groups.add(gidx) + group = groups[gidx] + observations: list[TraceObservation] = group["observations"] + writes = [o for o in observations if o.kind == "write"] + reads = [o for o in observations if o.kind == "read"] + value_obs = writes[-1] if writes else (reads[-1] if reads else observations[-1]) + sim_value = value_obs.value + if item.value is None or sim_value is None: + status = "no-value" + fidelity = "observed vs modelled (value unknown)" + elif sim_value == item.value: + status = "agree" + fidelity = "observed vs modelled" + else: + status = "mismatch" + fidelity = "observed vs modelled" + value_ever = any(o.value == item.value for o in observations if o.value is not None) + pcs = [o.pc for o in observations if o.pc is not None] + row = { + "register": item.name, + "window": item.window, + "offset": item.offset, + "oracle_value": item.value, + "sim_value": sim_value, + "status": status, + "matched_by": matched_by, + "fidelity": fidelity, + "source": item.source, + "captured_at": item.captured_at, + "sim_observations": len(observations), + "sim_writes": len(writes), + "sim_reads": len(reads), + "sim_value_ever_agreed": value_ever, + "sim_first_pc": pcs[0] if pcs else None, + "sim_last_pc": pcs[-1] if pcs else None, + } + rows.append(row) + + unmatched_simulator = [ + _group_descriptor(group) + for gidx, group in enumerate(groups) + if gidx not in matched_groups + ] + + agreed = sum(1 for row in rows if row["status"] == "agree") + mismatched = sum(1 for row in rows if row["status"] == "mismatch") + no_value = sum(1 for row in rows if row["status"] == "no-value") + compared = agreed + mismatched + no_value + oracle_items = len(oracle.items) + denominator = agreed + mismatched + agreement_pct = round(100.0 * agreed / denominator, 2) if denominator else None + coverage_pct = round(100.0 * compared / oracle_items, 2) if oracle_items else None + sim_registers = len(groups) + simulator_match_pct = ( + round(100.0 * len(matched_groups) / sim_registers, 2) if sim_registers else None + ) + return { + "format": REPORT_FORMAT, + "inputs": { + "oracle": { + "path": oracle.path, + "format": oracle.format, + "sha256": oracle.sha256, + "items": oracle_items, + }, + "simulator": { + "path": trace.path, + "format": trace.format, + "sha256": trace.sha256, + "observations": len(trace.observations), + "registers": sim_registers, + "source": trace.source, + }, + }, + "summary": { + "oracle_items": oracle_items, + "simulator_registers": sim_registers, + "simulator_observations": len(trace.observations), + "compared": compared, + "agreed": agreed, + "mismatched": mismatched, + "no_value": no_value, + "oracle_only": len(unmatched_oracle), + "simulator_only": len(unmatched_simulator), + "agreement_pct": agreement_pct, + "coverage_pct": coverage_pct, + "simulator_match_pct": simulator_match_pct, + "claim": ( + "per-register value agreement on the oracle/simulator intersection; " + "not a timing, silicon or full-coverage claim" + ), + }, + "rows": rows, + "unmatched_oracle": unmatched_oracle, + "unmatched_simulator": unmatched_simulator, + "fidelity": { + "oracle": "observed (human read-only capture; supervised for physical work)", + "simulator": "modelled (device model / recorded trace)", + "levels": "L0/L1 only; L2 timing and L3 device environment not claimed (docs/06)", + }, + "limits": [ + "No live oracle capture was taken by this tool; it only ingests a file a human produced.", + "Agreement is per-register on the matched intersection, never a global accuracy percentage.", + "An oracle-only register means the simulator did not touch it under this stimulus; it is not proof of a device-model gap.", + "A simulator-only register means no oracle counterpart exists yet; it is not evidence of a wrong value.", + "Values are point observations, not timing or ordering claims.", + ], + } + + +def render_oracle_markdown(report: dict) -> str: + summary = report["summary"] + lines = [ + "# GMMU/FBIF oracle vs simulator report", + "", + f"> Format `{report['format']}`. Fidelity: {report['fidelity']['levels']}.", + "> No cycle/timing claim. Underclaim every number (docs/06, docs/44).", + "", + "## Inputs", + "", + f"* oracle: `{report['inputs']['oracle']['path']}` " + f"(format `{report['inputs']['oracle']['format']}`, " + f"{report['inputs']['oracle']['items']} items, " + f"sha256 `{report['inputs']['oracle']['sha256'][:16]}…`)", + f"* simulator: `{report['inputs']['simulator']['path']}` " + f"(format `{report['inputs']['simulator']['format']}`, " + f"{report['inputs']['simulator']['registers']} registers / " + f"{report['inputs']['simulator']['observations']} observations, " + f"sha256 `{report['inputs']['simulator']['sha256'][:16]}…`)", + "", + "## Summary", + "", + "| oracle items | compared | agreed | mismatched | no-value | oracle-only | sim-only | agreement % | coverage % |", + "|---|---|---|---|---|---|---|---|---|", + f"| {summary['oracle_items']} | {summary['compared']} | {summary['agreed']} | " + f"{summary['mismatched']} | {summary['no_value']} | {summary['oracle_only']} | " + f"{summary['simulator_only']} | " + f"{summary['agreement_pct'] if summary['agreement_pct'] is not None else 'n/a'} | " + f"{summary['coverage_pct'] if summary['coverage_pct'] is not None else 'n/a'} |", + "", + f"* {summary['claim']}", + "", + "## Per-register rows", + "", + "| register | window | offset | oracle | simulator | status | fidelity | matched by |", + "|---|---|---|---|---|---|---|---|", + ] + for row in report["rows"]: + offset = f"{row['offset']:#x}" if row.get("offset") is not None else "" + oracle_value = f"{row['oracle_value']:#x}" if row.get("oracle_value") is not None else "n/a" + sim_value = f"{row['sim_value']:#x}" if row.get("sim_value") is not None else "n/a" + lines.append( + f"| `{row['register']}` | {row.get('window', '')} | {offset} | {oracle_value} | " + f"{sim_value} | {row['status']} | {row['fidelity']} | {row.get('matched_by', '')} |" + ) + lines += ["", "## Oracle items with no simulator observation", ""] + if report["unmatched_oracle"]: + for item in report["unmatched_oracle"]: + offset = f"{item['offset']:#x}" if item.get("offset") is not None else "?" + value = f"{item['oracle_value']:#x}" if item.get("oracle_value") is not None else "n/a" + lines.append( + f"* `{item['register']}` ({item.get('window', '')} @ {offset}) = {value} " + f"[{item['source']}]" + ) + else: + lines.append("* none") + lines += ["", "## Simulator registers with no oracle counterpart", ""] + if report["unmatched_simulator"]: + for item in report["unmatched_simulator"]: + offset = f"{item['offset']:#x}" if item.get("offset") is not None else "?" + value = f"{item['last_value']:#x}" if item.get("last_value") is not None else "n/a" + lines.append( + f"* `{item['register']}` ({item.get('window', '')} @ {offset}) last={value} " + f"[{item['writes']}w/{item['reads']}r]" + ) + else: + lines.append("* none") + lines += ["", "## Limits", ""] + for limit in report["limits"]: + lines.append(f"* {limit}") + lines.append("") + return "\n".join(lines) + + +def run_compare(oracle_path: str, sim_path: str, out_dir: str) -> dict: + """Compare an oracle capture with a simulator trace; write JSON + MD.""" + oracle = load_oracle(oracle_path) + trace = load_sim_trace(sim_path) + report = compare(oracle, trace) + os.makedirs(out_dir, exist_ok=True) + report_json = os.path.join(out_dir, "gmmu-oracle-report.json") + report_md = os.path.join(out_dir, "gmmu-oracle-report.md") + with open(report_json, "w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2, sort_keys=True) + fh.write("\n") + with open(report_md, "w", encoding="utf-8") as fh: + fh.write(render_oracle_markdown(report)) + report["artifacts"] = { + "report_json": os.path.abspath(report_json), + "report_md": os.path.abspath(report_md), + } + return report + + +def print_capture_hints(out=None) -> None: + """Print the supervised, read-only capture instructions; execute nothing.""" + print(CAPTURE_HINTS, file=out or sys.stdout) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="gmmu_oracle.py", + description=( + "Compare a human oracle capture with a simulator GMMU/FBIF trace. " + "Offline only; never executes physical commands." + ), + ) + parser.add_argument( + "--print-capture-hints", + action="store_true", + help="print the supervised read-only commands a human would run (executes nothing)", + ) + sub = parser.add_subparsers(dest="command") + compare_cmd = sub.add_parser("compare", help="compare oracle vs simulator observation") + compare_cmd.add_argument("--oracle", required=True, help="greeneye-gmmu-oracle/1 file") + compare_cmd.add_argument("--sim", required=True, help="trace file or session directory") + compare_cmd.add_argument("--out", required=True, help="output directory") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if args.print_capture_hints: + print_capture_hints() + return 0 + if args.command == "compare": + try: + report = run_compare(args.oracle, args.sim, args.out) + except GmmuInputError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + print( + json.dumps( + { + "format": report["format"], + "summary": report["summary"], + "artifacts": report["artifacts"], + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + parser.print_help() + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.json b/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.json index 4df3593..f5ccc4e 100644 --- a/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.json +++ b/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.json @@ -6,7 +6,7 @@ "detail": { "analyze_chars": 444, "analyze_session": "harness/qemu_line/sessions/native-1", - "compare_chars": 526, + "compare_chars": 394, "compare_headers": [ "Breakpoint profile delta (A vs B)", "Watchpoint delta", @@ -43,9 +43,9 @@ "dir": "harness/qemu_line/sessions/features-inject", "expected": 48814, "hashes": { - "events.jsonl": "08ab0874cfad71e0e4565b569d9b0ea5251bf158eaab3cdf17798182e4c66a95", - "session.json": "f34b677be0b0b453cdade4487118e059c3dfb5a1010d4e6c2fc8b37a867fb25e", - "summary.md": "59a1aa4f2b616d5266d565c4df4b0257d11aab95b09cec80f380e9aca8bfc22f" + "events.jsonl": "ce9c81fb9663813c5eefbacfb94af69dc4050ef563fd62e302869942ae207be1", + "session.json": "bb203d2e551f906cc24500c4b3f2d8263e1218267ce6aa809d99dd9c964caae2", + "summary.md": "a6a087528c23227628faa0e3ad3a646b66bf06c06489d26fda4e82091a7bb2ac" }, "ping_result": 48814, "slot": 6 @@ -79,9 +79,9 @@ "watch_hit": 1 }, "hashes": { - "events.jsonl": "a6328cb00287bdf678d9b62d496929fc59190a08af3910b059c840c6bc98462e", - "session.json": "2372eea627f31d11f114019759d8d6dc665683bbc88e834f158f56b16fa5c0d4", - "summary.md": "6dfca9e61a4f23374b90dbef995d86771130fcf4c2b61dc40aac6d379d4b8706" + "events.jsonl": "4ed32100308c465d999c78b71a232ab096e3cfcbb82561f46c5046eb34a0f462", + "session.json": "6c8c45b9b4be70b934c3a43b4bd00df4a1e875baafe7c8988b13d5a18dbc4813", + "summary.md": "9e7af234005461bd8b4395498fa3b58a3da5e589184c51e34b399f8cba37d50b" } }, "status": "pass" @@ -99,8 +99,8 @@ "dir": "harness/qemu_line/sessions/features-trace", "events": 4001, "hashes": { - "events.jsonl": "36b80ed88205daa8e1bd58536eb274ed4849c3d4b1a05daa0f2c44382c7b764a", - "session.json": "a09ec38202c291a5b7a3719434ea785725e291cba2dc877d94cd0caf7fcd1a4e", + "events.jsonl": "649ddd072446b2184427b6ea18a7e6bad6c118a0aa63c77bfff591cf9165114d", + "session.json": "c6abc0bbae7539b0c5d6685e700a6105c55d64821f42451b0f8ea386e0568020", "summary.md": "10686e9f247862f4b0f2c68a2353821f4039befdd76846f8daefa72ae79f4b44" }, "max_events": 4000 @@ -129,7 +129,7 @@ "native": { "backend": "native", "compare": { - "chars": 526, + "chars": 394, "headers": [ "Breakpoint profile delta (A vs B)", "Watchpoint delta", @@ -139,70 +139,70 @@ }, "device_model_used": true, "identical": true, - "instructions_budget": 200000, - "pair_wall_s": 0.3093, + "instructions_budget": 2000000, + "pair_wall_s": 1.2722, "profile": "sim/profiles/tu104-570.144.json", "ran": true, "reason": "ok", "repeat": 2, - "rung": "B2", + "rung": "B3", "rung_basis": "observed S1 boot-ladder rung from sim.gsp_boot (milestones=['bootargs_consumer', 'relocation_copy'], mmio_unknown=0, device_unhandled=0)", "runs": [ { "cycle_model": "rv64imac-modeled/1", - "cycles": 27294, - "device_accesses": 1, - "device_state_sha256": "3fa264b9f81081e8455a9cd26c0c2a721e3ac272e1079105a0eb191b1497f83d", + "cycles": 871904, + "device_accesses": 15, + "device_state_sha256": "5b3cc7e4af933e01057cc6ab7d858ecbb11f76f2d10f3f8963a7fc974e239323", "device_unhandled": 0, "first_unknown_mmio": null, - "instructions": 14682, + "instructions": 474043, "milestones": [ "bootargs_consumer", "relocation_copy" ], "mmio_unknown": 0, - "rung": "B2", + "rung": "B3", "rung_basis": "observed S1 boot-ladder rung from sim.gsp_boot (milestones=['bootargs_consumer', 'relocation_copy'], mmio_unknown=0, device_unhandled=0)", "session": "harness/qemu_line/sessions/native-1", "session_hashes": { - "events.jsonl": "f1f336af84b750b0c8bf8f8e9a7a3952161eb884418f5580f08cb3e26a070713", - "session.json": "c756f8078812aa1ed03628e097ee517320d7de3664089f1133861c96130d5b5b", - "summary.md": "c0aa5d5b2cb306fda703a7144af455c36fe72b9d1e2c73ddcdab905d36cdc8a7" + "events.jsonl": "0501ee6c127c8acfeb06c6b4c36aecacd791e872e05bf341883df22054d34315", + "session.json": "811b076cc5d4555062ce17474f302e64d6cb29e7af9e5c05cb7d7f67825bd315", + "summary.md": "9ad8c39a95078b9aca1cf1ca0b83a1f7ce22b0dd33e679aaa5f8eb5068a97b75" }, "stop": { - "pc": 67168780, - "pc_fw": 67168780, - "reason": "fault" + "pc": 67207224, + "pc_fw": 67207224, + "reason": "ecall" }, - "wall_s": 0.1561 + "wall_s": 0.6548 }, { "cycle_model": "rv64imac-modeled/1", - "cycles": 27294, - "device_accesses": 1, - "device_state_sha256": "3fa264b9f81081e8455a9cd26c0c2a721e3ac272e1079105a0eb191b1497f83d", + "cycles": 871904, + "device_accesses": 15, + "device_state_sha256": "5b3cc7e4af933e01057cc6ab7d858ecbb11f76f2d10f3f8963a7fc974e239323", "device_unhandled": 0, "first_unknown_mmio": null, - "instructions": 14682, + "instructions": 474043, "milestones": [ "bootargs_consumer", "relocation_copy" ], "mmio_unknown": 0, - "rung": "B2", + "rung": "B3", "rung_basis": "observed S1 boot-ladder rung from sim.gsp_boot (milestones=['bootargs_consumer', 'relocation_copy'], mmio_unknown=0, device_unhandled=0)", "session": "harness/qemu_line/sessions/native-2", "session_hashes": { - "events.jsonl": "d0d15816dbde8c85c30ba2576f97c9d5e3f470e6c38d9b4762d266a612cafe12", - "session.json": "8c7f0ea958a930edb814690bc0b233be01fcab3f747332abff39989e66642c2e", - "summary.md": "d2d6154ec22f4ee0d942543a32c29699ccb8031188df955c415d836462d04fd3" + "events.jsonl": "77b6fb63a1b0be8d2f5eef9e786795849c1ad74acab96cc8cfb7a37790bd804a", + "session.json": "626646f2bc8f8200a339d2f93a23297eaaf39160fb41de9a97867de7ad6dc131", + "summary.md": "1ad09477c1ba2611db4a6ff9e47a38441395fe3b198b407b659a8a8e4b28ab95" }, "stop": { - "pc": 67168780, - "pc_fw": 67168780, - "reason": "fault" + "pc": 67207224, + "pc_fw": 67207224, + "reason": "ecall" }, - "wall_s": 0.1524 + "wall_s": 0.6167 } ] }, @@ -235,12 +235,12 @@ "rung": "pre-B0", "session": "harness/qemu_line/sessions/qemu-probe", "session_hashes": { - "events.jsonl": "cdba69c63e306f4c3de220b171e2ea7c1de4bd4abf18cdba1fb51bc681d5d172", - "session.json": "7e3f0f8b24c0403d114e59f68ed1b923e44abbfbf19f4865bd566a1b631c64ac", + "events.jsonl": "0f59d5d26b51be185e1a38b4540dee06303f8a2c0661519d9e1a32d2e6b10062", + "session.json": "f3d1eaf6dab15c79b0f4c4019f431a8032dc2b014d3acb17f63407326eca5e26", "summary.md": "d141fa06d3e2d0603f3b99ad7603758e1fc14627aed74c6fe8a6ad22c56eb14b" }, "stopped_pc": 0, - "wall_s": 0.9519 + "wall_s": 0.9578 }, "rerun_when_s1_lands": [ "python3 harness/sim_qemu_line.py", diff --git a/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.md b/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.md index 4e546b3..550f32a 100644 --- a/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.md +++ b/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.md @@ -11,9 +11,9 @@ | side | ran | rung | instructions | cycles (modeled) | stop | wall s | evidence | |---|---|---|---:|---:|---|---:|---| -| QEMU probe | True | pre-B0 | - | - | fault | 0.9519 | `harness/qemu_line/sessions/qemu-probe` | -| native run 1 | True | B2 | 14682 | 27294 | fault@0x400ea0c | 0.1561 | `harness/qemu_line/sessions/native-1` | -| native run 2 | True | B2 | 14682 | 27294 | fault@0x400ea0c | 0.1524 | `harness/qemu_line/sessions/native-2` | +| QEMU probe | True | pre-B0 | - | - | fault | 0.9578 | `harness/qemu_line/sessions/qemu-probe` | +| native run 1 | True | B3 | 474043 | 871904 | ecall@0x4018038 | 0.6548 | `harness/qemu_line/sessions/native-1` | +| native run 2 | True | B3 | 474043 | 871904 | ecall@0x4018038 | 0.6167 | `harness/qemu_line/sessions/native-2` | ### QEMU first fault @@ -43,38 +43,38 @@ Thresholds (frozen 2026-10-03): native >= QEMU + 1 rung(s); 2 identical runs; pa | session | file | sha256 | |---|---|---| -| qemu-probe | events.jsonl | `cdba69c63e306f4c3de220b171e2ea7c1de4bd4abf18cdba1fb51bc681d5d172` | -| qemu-probe | session.json | `7e3f0f8b24c0403d114e59f68ed1b923e44abbfbf19f4865bd566a1b631c64ac` | +| qemu-probe | events.jsonl | `0f59d5d26b51be185e1a38b4540dee06303f8a2c0661519d9e1a32d2e6b10062` | +| qemu-probe | session.json | `f3d1eaf6dab15c79b0f4c4019f431a8032dc2b014d3acb17f63407326eca5e26` | | qemu-probe | summary.md | `d141fa06d3e2d0603f3b99ad7603758e1fc14627aed74c6fe8a6ad22c56eb14b` | -| native-1 | events.jsonl | `f1f336af84b750b0c8bf8f8e9a7a3952161eb884418f5580f08cb3e26a070713` | -| native-1 | session.json | `c756f8078812aa1ed03628e097ee517320d7de3664089f1133861c96130d5b5b` | -| native-1 | summary.md | `c0aa5d5b2cb306fda703a7144af455c36fe72b9d1e2c73ddcdab905d36cdc8a7` | -| native-2 | events.jsonl | `d0d15816dbde8c85c30ba2576f97c9d5e3f470e6c38d9b4762d266a612cafe12` | -| native-2 | session.json | `8c7f0ea958a930edb814690bc0b233be01fcab3f747332abff39989e66642c2e` | -| native-2 | summary.md | `d2d6154ec22f4ee0d942543a32c29699ccb8031188df955c415d836462d04fd3` | -| sessions | events.jsonl | `a6328cb00287bdf678d9b62d496929fc59190a08af3910b059c840c6bc98462e` | -| sessions | session.json | `2372eea627f31d11f114019759d8d6dc665683bbc88e834f158f56b16fa5c0d4` | -| sessions | summary.md | `6dfca9e61a4f23374b90dbef995d86771130fcf4c2b61dc40aac6d379d4b8706` | -| trace | events.jsonl | `36b80ed88205daa8e1bd58536eb274ed4849c3d4b1a05daa0f2c44382c7b764a` | -| trace | session.json | `a09ec38202c291a5b7a3719434ea785725e291cba2dc877d94cd0caf7fcd1a4e` | +| native-1 | events.jsonl | `0501ee6c127c8acfeb06c6b4c36aecacd791e872e05bf341883df22054d34315` | +| native-1 | session.json | `811b076cc5d4555062ce17474f302e64d6cb29e7af9e5c05cb7d7f67825bd315` | +| native-1 | summary.md | `9ad8c39a95078b9aca1cf1ca0b83a1f7ce22b0dd33e679aaa5f8eb5068a97b75` | +| native-2 | events.jsonl | `77b6fb63a1b0be8d2f5eef9e786795849c1ad74acab96cc8cfb7a37790bd804a` | +| native-2 | session.json | `626646f2bc8f8200a339d2f93a23297eaaf39160fb41de9a97867de7ad6dc131` | +| native-2 | summary.md | `1ad09477c1ba2611db4a6ff9e47a38441395fe3b198b407b659a8a8e4b28ab95` | +| sessions | events.jsonl | `4ed32100308c465d999c78b71a232ab096e3cfcbb82561f46c5046eb34a0f462` | +| sessions | session.json | `6c8c45b9b4be70b934c3a43b4bd00df4a1e875baafe7c8988b13d5a18dbc4813` | +| sessions | summary.md | `9e7af234005461bd8b4395498fa3b58a3da5e589184c51e34b399f8cba37d50b` | +| trace | events.jsonl | `649ddd072446b2184427b6ea18a7e6bad6c118a0aa63c77bfff591cf9165114d` | +| trace | session.json | `c6abc0bbae7539b0c5d6685e700a6105c55d64821f42451b0f8ea386e0568020` | | trace | summary.md | `10686e9f247862f4b0f2c68a2353821f4039befdd76846f8daefa72ae79f4b44` | -| injection | events.jsonl | `08ab0874cfad71e0e4565b569d9b0ea5251bf158eaab3cdf17798182e4c66a95` | -| injection | session.json | `f34b677be0b0b453cdade4487118e059c3dfb5a1010d4e6c2fc8b37a867fb25e` | -| injection | summary.md | `59a1aa4f2b616d5266d565c4df4b0257d11aab95b09cec80f380e9aca8bfc22f` | +| injection | events.jsonl | `ce9c81fb9663813c5eefbacfb94af69dc4050ef563fd62e302869942ae207be1` | +| injection | session.json | `bb203d2e551f906cc24500c4b3f2d8263e1218267ce6aa809d99dd9c964caae2` | +| injection | summary.md | `a6a087528c23227628faa0e3ad3a646b66bf06c06489d26fda4e82091a7bb2ac` | ## Native feature matrix (demo firmware) | feature | status | detail | |---|---|---| -| analyze_compare | pass | {'analyze_session': 'harness/qemu_line/sessions/native-1', 'analyze_chars': 444, 'compare_pair': ['harness/qemu_line/sessions/native-1', 'harness/qemu_line/sessions/native-2'], 'compare_chars': 526, 'compare_headers': ['Breakpoint profile delta (A vs B)', 'Watchpoint delta', 'Written pages delta']} | +| analyze_compare | pass | {'analyze_session': 'harness/qemu_line/sessions/native-1', 'analyze_chars': 444, 'compare_pair': ['harness/qemu_line/sessions/native-1', 'harness/qemu_line/sessions/native-2'], 'compare_chars': 394, 'compare_headers': ['Breakpoint profile delta (A vs B)', 'Watchpoint delta', 'Written pages delta']} | | breakpoints | pass | {'run_until_hit': 2147483912, 'attached_then_removed': True, 'kind': 'sw'} | | hooks | pass | {'rpc_dispatch_hits': 1, 'cmds': [1, 2]} | -| injection | pass | {'dir': 'harness/qemu_line/sessions/features-inject', 'blob': 'sdk/build/injected_demo.elf', 'slot': 6, 'ping_result': 48814, 'expected': 48814, 'hashes': {'session.json': 'f34b677be0b0b453cdade4487118e059c3dfb5a1010d4e6c2fc8b37a867fb25e', 'events.jsonl': '08ab0874cfad71e0e4565b569d9b0ea5251bf158eaab3cdf17798182e4c66a95', 'summary.md': '59a1aa4f2b616d5266d565c4df4b0257d11aab95b09cec80f380e9aca8bfc22f'}} | +| injection | pass | {'dir': 'harness/qemu_line/sessions/features-inject', 'blob': 'sdk/build/injected_demo.elf', 'slot': 6, 'ping_result': 48814, 'expected': 48814, 'hashes': {'session.json': 'bb203d2e551f906cc24500c4b3f2d8263e1218267ce6aa809d99dd9c964caae2', 'events.jsonl': 'ce9c81fb9663813c5eefbacfb94af69dc4050ef563fd62e302869942ae207be1', 'summary.md': 'a6a087528c23227628faa0e3ad3a646b66bf06c06489d26fda4e82091a7bb2ac'}} | | mcp | not_run | native MCP surface is S3's deliverable (plugins/mcp_server.py); S4 must not race that work. Re-run after S3 lands (docs/37). | | memory | pass | {'ctrl': 2147485760, 'magic': 1196642353} | -| sessions | pass | {'dir': 'harness/qemu_line/sessions/features', 'event_counts': {'bp_hit': 2, 'hook': 1, 'image_loaded': 1, 'mem_read': 2, 'mem_write': 4, 'session_end': 1, 'session_start': 1, 'stop': 2, 'watch': 1, 'watch_hit': 1}, 'hashes': {'session.json': '2372eea627f31d11f114019759d8d6dc665683bbc88e834f158f56b16fa5c0d4', 'events.jsonl': 'a6328cb00287bdf678d9b62d496929fc59190a08af3910b059c840c6bc98462e', 'summary.md': '6dfca9e61a4f23374b90dbef995d86771130fcf4c2b61dc40aac6d379d4b8706'}} | +| sessions | pass | {'dir': 'harness/qemu_line/sessions/features', 'event_counts': {'bp_hit': 2, 'hook': 1, 'image_loaded': 1, 'mem_read': 2, 'mem_write': 4, 'session_end': 1, 'session_start': 1, 'stop': 2, 'watch': 1, 'watch_hit': 1}, 'hashes': {'session.json': '6c8c45b9b4be70b934c3a43b4bd00df4a1e875baafe7c8988b13d5a18dbc4813', 'events.jsonl': '4ed32100308c465d999c78b71a232ab096e3cfcbb82561f46c5046eb34a0f462', 'summary.md': '9e7af234005461bd8b4395498fa3b58a3da5e589184c51e34b399f8cba37d50b'}} | | struct | pass | {'schema': 'gsp_ctrl.schema.json', 'fields': 14} | -| trace | pass | {'dir': 'harness/qemu_line/sessions/features-trace', 'blob': 'harness/qemu_line/sessions/features-trace/blobs/trace.jsonl', 'events': 4001, 'max_events': 4000, 'hashes': {'session.json': 'a09ec38202c291a5b7a3719434ea785725e291cba2dc877d94cd0caf7fcd1a4e', 'events.jsonl': '36b80ed88205daa8e1bd58536eb274ed4849c3d4b1a05daa0f2c44382c7b764a', 'summary.md': '10686e9f247862f4b0f2c68a2353821f4039befdd76846f8daefa72ae79f4b44'}} | +| trace | pass | {'dir': 'harness/qemu_line/sessions/features-trace', 'blob': 'harness/qemu_line/sessions/features-trace/blobs/trace.jsonl', 'events': 4001, 'max_events': 4000, 'hashes': {'session.json': 'c6abc0bbae7539b0c5d6685e700a6105c55d64821f42451b0f8ea386e0568020', 'events.jsonl': '649ddd072446b2184427b6ea18a7e6bad6c118a0aa63c77bfff591cf9165114d', 'summary.md': '10686e9f247862f4b0f2c68a2353821f4039befdd76846f8daefa72ae79f4b44'}} | | watch | pass | {'result_field_stores': 1, 'pcs': [2147484066]} | ## S1 integration diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/__init__.py b/debug/risc-v-gsp-rm-vm/sim/devices/__init__.py index caf22b3..6fdce2e 100644 --- a/debug/risc-v-gsp-rm-vm/sim/devices/__init__.py +++ b/debug/risc-v-gsp-rm-vm/sim/devices/__init__.py @@ -26,6 +26,14 @@ from .dma import DmaTranslator from .falcon import FalconBlock from .fwsec import Wpr2Block from .irq import IrqRouter +from .observe import ( + TRACE_SCHEMA, + ObservedWindow, + build_trace, + collect_observations, + parse_window_spec, + windows_from_section, +) from .pmc import PmcBlock from .pmu import PmuStub from .priscv import PriscvBlock @@ -33,12 +41,16 @@ from .timer import PtimerBlock PROFILE_SCHEMA = "greeneye-sim-profile/1" +#: Profile device sections that declare observe-only/log-only windows. +OBSERVE_SECTIONS = ("gmmu", "fbif") + __all__ = [ "Device", "DeviceRegistry", "Window", "SNAPSHOT_SCHEMA", "PROFILE_SCHEMA", + "TRACE_SCHEMA", "PmcBlock", "FalconBlock", "PriscvBlock", @@ -48,8 +60,13 @@ __all__ = [ "PmuStub", "DmaTranslator", "IrqRouter", + "ObservedWindow", "load_profile", "registry_from_profile", + "windows_from_section", + "parse_window_spec", + "collect_observations", + "build_trace", ] @@ -153,6 +170,15 @@ def registry_from_profile(profile: dict, *, machine=None, ) elif name == "pmu": dev = PmuStub() + elif name in OBSERVE_SECTIONS: + for win in windows_from_section(section): + reg.add(ObservedWindow( + base=win["base"], size=win["size"], label=win["label"], + mode=win["mode"], registers=win["registers"], + evidence=section.get("evidence", ""), + clock=(machine.insn_count if machine is not None else None), + )) + continue else: raise ValueError(f"unknown device section {name!r}") for base, size in _windows(section): diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/base.py b/debug/risc-v-gsp-rm-vm/sim/devices/base.py index 877109c..7254334 100644 --- a/debug/risc-v-gsp-rm-vm/sim/devices/base.py +++ b/debug/risc-v-gsp-rm-vm/sim/devices/base.py @@ -44,6 +44,11 @@ class Device: name = "device" + #: Opt-in hook: when True the registry forwards the faulting PC to + #: ``read``/``write`` as ``pc=`` (observer windows record it; every other + #: device keeps the original two-/three-argument call unchanged). + wants_pc = False + def __init__(self, base: int = 0, size: int = 0): self.base = base self.size = size @@ -184,7 +189,10 @@ class DeviceRegistry: matched = False for win in self._candidates(addr, size): matched = True - value = win.device.read(addr - win.base, size) + if win.device.wants_pc: + value = win.device.read(addr - win.base, size, pc=pc) + else: + value = win.device.read(addr - win.base, size) if value is not None: self._record(addr, size, False, value, True, pc) return True, value @@ -202,7 +210,11 @@ class DeviceRegistry: matched = False for win in self._candidates(addr, size): matched = True - if win.device.write(addr - win.base, size, value): + if win.device.wants_pc: + handled = win.device.write(addr - win.base, size, value, pc=pc) + else: + handled = win.device.write(addr - win.base, size, value) + if handled: self._record(addr, size, True, value, True, pc) return True self.unhandled += 1 diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py b/debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py index 8dcf633..b278ec8 100644 --- a/debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py +++ b/debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py @@ -25,6 +25,17 @@ WPR2_ADDR_HI = 0x1FA828 WPR2_ALIGNMENT = 12 WPR2_BLOCK = 0x20000 +#: Block-relative offsets (a profile window that starts at ``+0x1FA000`` +#: reaches the pair at ``0x824``/``0x828``; the device accepts both forms). +WPR2_ADDR_LO_REL = WPR2_ADDR_LO & 0xFFF +WPR2_ADDR_HI_REL = WPR2_ADDR_HI & 0xFFF +_KEYS = { + WPR2_ADDR_LO: WPR2_ADDR_LO, + WPR2_ADDR_HI: WPR2_ADDR_HI, + WPR2_ADDR_LO_REL: WPR2_ADDR_LO, + WPR2_ADDR_HI_REL: WPR2_ADDR_HI, +} + class Wpr2Block(Device): """WPR2 address pair as seen by the firmware (host/booter-written).""" @@ -60,13 +71,15 @@ class Wpr2Block(Device): self.regs: dict[int, int] = {} def read(self, offset: int, size: int) -> int | None: - if size == 4 and offset in self.regs: - return self.regs[offset] + key = _KEYS.get(offset) + if size == 4 and key is not None and key in self.regs: + return self.regs[key] return None def write(self, offset: int, size: int, value: int) -> bool: - if size == 4 and offset in (WPR2_ADDR_LO, WPR2_ADDR_HI): - self.regs[offset] = value & 0xFFFFFFFF + key = _KEYS.get(offset) + if size == 4 and key is not None: + self.regs[key] = value & 0xFFFFFFFF return True return False diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/observe.py b/debug/risc-v-gsp-rm-vm/sim/devices/observe.py new file mode 100644 index 0000000..d43cfe6 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/devices/observe.py @@ -0,0 +1,372 @@ +"""Observer-only MMIO windows: record accesses, change no semantics. + +Evidence rule (see ``sim/devices/base.py`` and ``docs/36``): this module +carries **no modelled register behaviour** beyond optional read-back of +values written to registers the header proves plain read/write. Every access +is recorded -- address, window-relative offset, size, direction, +value-if-known, PC, virtual time, window label and register name -- and the +observer then **declines** the access, so the machine's existing +device-map/lenient/unknown path serves it exactly as before. A read value is +never invented: an observe-only read records ``value: null`` and returns +``None``; a storage read before any write is declined the same way. + +Modes +===== + +``observe-only`` (default) + Read and write are recorded and declined. Nothing changes. + +``storage`` + A declared register whose header access type is plain ``rw`` (or ``wo``) + stores the last written value; a later read of a register that has been + written returns that value. Undeclared offsets, ``ro`` registers and + never-written storage registers are still declined. + +Register specs come from the profile (or a runtime window list):: + + "0x24": {"name": "NV_PFB_PRI_MMU_TLB_CTRL", + "width": 4, "access": "rw", + "fields": {"L2_SIZE": [1, 0], ...}} + +``fields`` is optional and only used to decode known values in the trace +(``hi``/``lo`` bit positions, header field names); no field semantics are +implied. + +The access record is the unit of evidence. It lives in +:attr:`ObservedWindow.accesses` for the live run and is exported through the +``greeneye-gmmu-fbif-trace/1`` document built by :func:`build_trace`; +:meth:`ObservedWindow.state` deliberately carries only counters and storage +values so a device snapshot stays small. + +Nothing here is a silicon claim and nothing here authorises hardware: these +windows are simulator-only observability for the GMMU/FBIF configuration +surface (``docs/42-GMMU-FBIF-OBSERVABILITY.md``). +""" +from __future__ import annotations + +from .base import Device + +#: Accepted observer modes. +MODES = ("observe-only", "storage") +#: Accepted per-register access kinds (header access type, plain meaning). +ACCESS_KINDS = ("rw", "ro", "wo") +#: Schema tag for the GMMU/FBIF trace document. +TRACE_SCHEMA = "greeneye-gmmu-fbif-trace/1" + +DEFAULT_MAX_ACCESS_LOG = 65536 + + +def _int(value, default=None): + if value is None: + return default + if isinstance(value, str): + return int(value, 0) + return int(value) + + +def _normalise_spec(offset, spec) -> dict: + """Accept ``"NAME"`` or ``{name, width, access, fields}`` register specs.""" + if isinstance(spec, str): + spec = {"name": spec} + if not isinstance(spec, dict) or not spec.get("name"): + raise ValueError(f"observer register {offset:#x}: spec needs a name") + access = spec.get("access", "rw") + if access not in ACCESS_KINDS: + raise ValueError(f"observer register {offset:#x}: unknown access {access!r}") + width = int(spec.get("width", 4)) + if width not in (1, 2, 4, 8): + raise ValueError(f"observer register {offset:#x}: unsupported width {width}") + fields = {} + for name, span in (spec.get("fields") or {}).items(): + if isinstance(span, str): + hi, lo = span.split(":") + hi, lo = int(hi, 0), int(lo, 0) + else: + hi, lo = int(span[0]), int(span[1]) + if not 0 <= lo <= hi <= 8 * width - 1: + raise ValueError( + f"observer register {offset:#x}: bad field {name} {hi}:{lo}") + fields[str(name)] = (hi, lo) + return {"name": str(spec["name"]), "width": width, + "access": access, "fields": fields} + + +class ObservedWindow(Device): + """One observe-only (or storage) MMIO window; a :class:`~sim.devices.base.Device`. + + ``clock`` is an optional zero-argument callable returning virtual time + (``Machine.insn_count`` follows the existing ``PtimerBlock`` pattern). + The counter advances at :meth:`sim.machine.Machine.run` boundaries, so all + accesses inside one bounded run share the run-start value; that granularity + is documented, not hidden. + """ + + name = "observe" + #: Opt-in: the registry forwards ``pc=`` to devices that set this. + wants_pc = True + + def __init__(self, *, base: int = 0, size: int = 0x1000, label: str | None = None, + mode: str = "observe-only", registers=None, clock=None, + evidence: str = "", max_access_log: int = DEFAULT_MAX_ACCESS_LOG): + super().__init__(base=base, size=size) + self.label = str(label or "observe") + if mode not in MODES: + raise ValueError(f"observer window {self.label}: unknown mode {mode!r}") + self.mode = mode + self.evidence = str(evidence) + self.clock = clock + self.registers: dict[int, dict] = {} + for off, spec in (registers or {}).items(): + self.registers[_int(off)] = _normalise_spec(_int(off), spec) + self.max_access_log = max(0, int(max_access_log)) + self.accesses: list[dict] = [] + self.dropped = 0 + self.counts = {"r": 0, "w": 0, "served": 0, "declined": 0} + self.written: dict[int, int] = {} + + # -- recording --------------------------------------------------------- + + def _record(self, offset: int, size: int, rw: str, value, served: bool, + pc: int) -> None: + spec = self.registers.get(offset) + entry = { + "addr": self.base + offset, + "offset": offset, + "size": size, + "rw": rw, + "value": value, + "pc": pc, + "vt": self.clock() if self.clock is not None else None, + "window": self.label, + "register": spec["name"] if spec is not None else None, + "served": bool(served), + } + if value is not None and spec is not None and spec["fields"]: + entry["decoded"] = { + name: (value >> lo) & ((1 << (hi - lo + 1)) - 1) + for name, (hi, lo) in spec["fields"].items() + } + self.counts[rw] += 1 + self.counts["served" if served else "declined"] += 1 + if len(self.accesses) < self.max_access_log: + self.accesses.append(entry) + else: + self.dropped += 1 + + # -- guest-visible semantics ------------------------------------------ + + def read(self, offset: int, size: int, pc: int = 0) -> int | None: + spec = self.registers.get(offset) + value = None + served = False + if (self.mode == "storage" and spec is not None + and spec["width"] == size and spec["access"] == "rw" + and offset in self.written): + value = self.written[offset] + served = True + self._record(offset, size, "r", value, served, pc) + return value + + def write(self, offset: int, size: int, value: int, pc: int = 0) -> bool: + spec = self.registers.get(offset) + served = False + if (self.mode == "storage" and spec is not None + and spec["width"] == size and spec["access"] in ("rw", "wo")): + mask = (1 << (8 * size)) - 1 + self.written[offset] = value & mask + served = True + self._record(offset, size, "w", value & ((1 << (8 * size)) - 1), served, pc) + return served + + def peek(self, offset: int, size: int) -> int | None: + spec = self.registers.get(offset) + if (self.mode == "storage" and spec is not None + and spec["width"] == size and spec["access"] == "rw" + and offset in self.written): + return self.written[offset] + return None + + # -- state ------------------------------------------------------------- + + def state(self) -> dict: + return { + "label": self.label, + "mode": self.mode, + "counts": dict(self.counts), + "written": {str(k): v for k, v in sorted(self.written.items())}, + "dropped_accesses": self.dropped, + } + + def load_state(self, data: dict) -> None: + counts = data.get("counts", {}) + self.counts = {k: int(counts.get(k, 0)) for k in ("r", "w", "served", "declined")} + self.written = {int(k): int(v) for k, v in data.get("written", {}).items()} + self.dropped = int(data.get("dropped_accesses", 0)) + + def reset(self) -> None: + self.accesses.clear() + self.dropped = 0 + self.counts = {"r": 0, "w": 0, "served": 0, "declined": 0} + self.written.clear() + + def __repr__(self) -> str: # pragma: no cover - cosmetic + return (f"ObservedWindow({self.label} @ {self.base:#x}+{self.size:#x}, " + f"{self.mode}, {len(self.registers)} registers)") + + +def windows_from_section(section: dict) -> list[dict]: + """Expand a profile observer section into per-window declarations. + + A window entry may carry ``count`` and ``stride`` (header-proven register + instances, e.g. the 16 FBPA partitions); labels get an ``-<i>`` suffix + when more than one instance is declared. Window-level ``mode`` and + ``registers`` override the section-level values. + """ + section_regs = section.get("registers") or {} + section_mode = section.get("mode", "observe-only") + section_label = section.get("label", "observe") + out: list[dict] = [] + for win in section.get("windows", ()): + base = _int(win["base"]) + size = _int(win.get("size", 0x1000)) + count = int(win.get("count", 1)) + stride = _int(win.get("stride", size)) + label = str(win.get("label", section_label)) + mode = win.get("mode", section_mode) + regs = win.get("registers", section_regs) + if count < 1: + raise ValueError(f"observer window {label}: count must be >= 1") + for i in range(count): + out.append({ + "base": base + i * stride, + "size": size, + "label": label if count == 1 else f"{label}-{i}", + "mode": mode, + "registers": regs, + }) + return out + + +def parse_window_spec(text: str) -> dict: + """Parse ``BASE:SIZE[:LABEL[:MODE]]`` for a runtime observer window. + + Bases/sizes accept ``0x`` forms. This is the escape hatch for windows + whose base cannot be established from evidence yet (docs/42); it never + invents a value, it only records accesses. + """ + parts = str(text).split(":") + if len(parts) < 2: + raise ValueError(f"observer window spec {text!r}: expected BASE:SIZE[:LABEL[:MODE]]") + base, size = _int(parts[0]), _int(parts[1]) + if base < 0 or size <= 0: + raise ValueError(f"observer window spec {text!r}: bad base/size") + label = parts[2] if len(parts) > 2 and parts[2] else "runtime-observe" + mode = parts[3] if len(parts) > 3 and parts[3] else "observe-only" + if mode not in MODES: + raise ValueError(f"observer window spec {text!r}: unknown mode {mode!r}") + return {"base": base, "size": size, "label": label, "mode": mode, "registers": {}} + + +def collect_observations(registry) -> list[ObservedWindow]: + """All observer windows in a device registry, in registration order.""" + return [d for d in getattr(registry, "devices", ()) if isinstance(d, ObservedWindow)] + + +def _counts(entries) -> dict: + out = {"r": 0, "w": 0, "served": 0} + for entry in entries: + out[entry["rw"]] += 1 + if entry["served"]: + out["served"] += 1 + return out + + +def _decorate(entry: dict, resolve) -> dict: + out = dict(entry) + out["addr"] = hex(entry["addr"]) + out["offset"] = hex(entry["offset"]) + if entry["value"] is not None: + out["value"] = hex(entry["value"]) + if entry["pc"] is not None: + out["pc"] = hex(entry["pc"]) + out["pc_symbol"] = None + if resolve is not None and entry["pc"] is not None: + out["pc_symbol"] = resolve(entry["pc"]) + return out + + +def build_trace(registry, *, image_sha256: str | None = None, + profile: str | None = None, profile_name: str | None = None, + symbols_path: str | None = None, resolve=None) -> dict: + """Build the ``greeneye-gmmu-fbif-trace/1`` document. + + Accesses are grouped per window and per declared register; offsets inside + an observed window that the profile does not name go to ``unclassified`` + (recorded, never guessed). ``resolve`` is an optional ``pc -> str`` + callable (``SymbolDB``-backed in :mod:`sim.gsp_boot`). + + Truncation is never silent: a window's access log is capped at + ``ObservedWindow.max_access_log`` entries (default 65536); + ``dropped_accesses`` reports the accesses beyond the cap in each window + and in the summary, and the summary separates ``accesses`` (total + observed) from ``retained_accesses`` (present in this document). + """ + windows = [] + total = {"windows": 0, "windows_touched": 0, "accesses": 0, + "retained_accesses": 0, "dropped_accesses": 0, + "reads": 0, "writes": 0, "served": 0, "registers_touched": 0} + for dev in collect_observations(registry): + total["windows"] += 1 + win = { + "label": dev.label, + "base": hex(dev.base), + "size": hex(dev.size), + "mode": dev.mode, + "evidence": dev.evidence, + "counts": dict(dev.counts), + "dropped_accesses": dev.dropped, + "retained_accesses": len(dev.accesses), + "registers": [], + "unclassified": {"counts": {"r": 0, "w": 0, "served": 0}, + "accesses": []}, + } + grouped: dict[int, list] = {} + unclassified: list = [] + for entry in dev.accesses: + if entry["offset"] in dev.registers: + grouped.setdefault(entry["offset"], []).append(entry) + else: + unclassified.append(_decorate(entry, resolve)) + for offset in sorted(grouped): + spec = dev.registers[offset] + entries = [_decorate(e, resolve) for e in grouped[offset]] + win["registers"].append({ + "offset": hex(offset), + "name": spec["name"], + "access": spec["access"], + "counts": _counts(entries), + "accesses": entries, + }) + win["unclassified"]["accesses"] = unclassified + win["unclassified"]["counts"] = _counts(unclassified) + observed = dev.counts["r"] + dev.counts["w"] + total["dropped_accesses"] += dev.dropped + if observed: + total["windows_touched"] += 1 + total["accesses"] += observed + total["retained_accesses"] += len(dev.accesses) + total["reads"] += dev.counts["r"] + total["writes"] += dev.counts["w"] + total["served"] += dev.counts["served"] + total["registers_touched"] += len(grouped) + windows.append(win) + doc = { + "schema": TRACE_SCHEMA, + "image_sha256": image_sha256, + "profile": profile, + "profile_name": profile_name, + "symbols": symbols_path, + "summary": total, + "windows": windows, + } + return doc diff --git a/debug/risc-v-gsp-rm-vm/sim/gmmu_pte.py b/debug/risc-v-gsp-rm-vm/sim/gmmu_pte.py new file mode 100644 index 0000000..24381eb --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/gmmu_pte.py @@ -0,0 +1,988 @@ +"""Clean-room decoder for the Turing (TU104) GMMU page-table entry formats. + +This module decodes *captured* GMMU page-table data. It is a decoder, not a +page-table walker: it never dereferences a decoded address and it never fills +a gap with a guess. Every named field below has an exact bit position taken +from the Turing GMMU format definitions; the page-tree geometry (depth -> VA +bits, index width, entry size, page sizes) is taken from the driver's own +page-tree HAL for Turing, which uses the VER2 entry encoding. + +The write-up, the field tables and the uncertainty notes live in +``docs/43-GMMU-PTE-DECODER.md``. + +Formats shipped (evidence: the Turing GMMU register-field definitions and the +driver's page-table encoder behaviour): + +* ``ver2`` -- the 8-byte VER2 leaf PTE and directory PDE, the encoding the + Turing driver programs (``NV_MMU_VER2_PTE`` / ``NV_MMU_VER2_PDE``). +* ``ver2_dual`` -- the 16-byte dual PDE0 slot used for mixed 64K/4K page + tables (``NV_MMU_VER2_DUAL_PDE``): a BIG half (64K) and a SMALL half (4K) + in one 16-byte entry. The BIG address field spans bits 53:4 and stores + the address shifted right by 8; the format also names bit 5 NO_ATS. + Depth-3 page-table allocations are 4 KB aligned, so address bit 9 (the + overlapping bit) is zero in a valid pointer; the decoder reports + ``no_ats`` from bit 5 and excludes that bit from the BIG address. + +Known unsupported encodings (explicit ``E_UNSUPPORTED``, never guessed): the +older non-VER2 PTE/PDE encoding and the "NEW"/VER1 encodings that are also +described by the same register definitions but are not used by the Turing +driver's page-tree HAL. See ``docs/43-GMMU-PTE-DECODER.md`` section 6. + +Reserved-bit policy: any set bit that no named field of the decoded role +covers is reported in ``reserved_bits`` / ``reserved_mask`` -- never silently +dropped and never given an invented meaning. + +Rule: decode captured data only; validate against a capture before trusting. +""" +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + +E_UNSUPPORTED = "E_UNSUPPORTED" +E_TRUNCATED = "E_TRUNCATED" +E_BAD_VALUE = "E_BAD_VALUE" + + +class GmmuDecodeError(ValueError): + """A decode request that the format evidence cannot answer.""" + + def __init__(self, code: str, message: str): + super().__init__(f"{code}: {message}") + self.code = code + self.message = message + + +# --------------------------------------------------------------------------- +# Encoding families +# --------------------------------------------------------------------------- + +KIND_VER2 = "ver2" +KIND_VER2_DUAL = "ver2_dual" + +#: Entry-encoding families this decoder ships. +SUPPORTED_KINDS = (KIND_VER2, KIND_VER2_DUAL) + +#: Page-tree depths the evidence supports, root (0) -> leaf (4). +SUPPORTED_LEVELS = (0, 1, 2, 3, 4) + +#: Depth -> hardware level, VA bits, index width and entry size. +#: +#: Evidence: the driver's page-tree HAL for Turing inherits the Pascal/Volta +#: geometry; the comments in that HAL state the mapping below. Depths 0-2 +#: are pointer directories, depth 3 is the PDE0 slot (a dual 64K/4K entry, +#: or a 2 MB leaf PTE), depth 4 is the 4K/64K leaf page table. +LEVELS = ( + {"depth": 0, "hw": "pde3", "va_bits": (48, 47), "index_bits": 2, + "entry_size": 8, "role": "pde"}, + {"depth": 1, "hw": "pde2", "va_bits": (46, 38), "index_bits": 9, + "entry_size": 8, "role": "pde"}, + {"depth": 2, "hw": "pde1", "va_bits": (37, 29), "index_bits": 9, + "entry_size": 8, "role": "pde"}, + {"depth": 3, "hw": "pde0", "va_bits": (28, 21), "index_bits": 8, + "entry_size": 16, "role": "pde_or_2m_pte"}, + {"depth": 4, "hw": "pte", "va_bits": (20, 12), "index_bits": 9, + "entry_size": 8, "role": "pte"}, +) + +#: Page sizes the format evidence supports, and the depth their entries live at. +PAGE_SIZES = ("4k", "64k", "2m") +PAGE_TABLE_DEPTH = {"2m": 3, "4k": 4, "64k": 4} + +#: PTE aperture field (bits 2:1). +APERTURE_NAMES_PTE = { + 0: "video_memory", + 1: "peer_memory", + 2: "system_coherent_memory", + 3: "system_non_coherent_memory", +} + +#: PDE aperture field (bits 2:1). Note aperture 0 is INVALID here, while for +#: a leaf PTE aperture 0 is video memory -- the two entry roles differ. +APERTURE_NAMES_PDE = { + 0: "invalid", + 1: "video_memory", + 2: "system_coherent_memory", + 3: "system_non_coherent_memory", +} + +#: PTE KIND field (bits 63:56), numeric IDs from the Turing GMMU format +#: definitions. Values not listed are reported as unknown, never guessed. +PTE_KIND_NAMES = { + 0x00: 'PITCH', + 0x01: 'Z16', + 0x02: 'S8', + 0x03: 'S8Z24', + 0x04: 'ZF32_X24S8', + 0x05: 'Z24S8', + 0x06: 'GENERIC_MEMORY', + 0x07: 'INVALID', + 0x08: 'GENERIC_MEMORY_COMPRESSIBLE', + 0x09: 'GENERIC_MEMORY_COMPRESSIBLE_DISABLE_PLC', + 0x0A: 'S8_COMPRESSIBLE_DISABLE_PLC', + 0x0B: 'Z16_COMPRESSIBLE_DISABLE_PLC', + 0x0C: 'S8Z24_COMPRESSIBLE_DISABLE_PLC', + 0x0D: 'ZF32_X24S8_COMPRESSIBLE_DISABLE_PLC', + 0x0E: 'Z24S8_COMPRESSIBLE_DISABLE_PLC', + 0x0F: 'SMSKED_MESSAGE', + 0x10: 'Z16_MS2_2Z', + 0x11: 'Z16_MS2_2C', + 0x12: 'S8Z24_1Z', + 0x13: 'S8Z24_MS2_1Z', + 0x14: 'S8Z24_MS4_1Z', + 0x15: 'S8Z24_MS8_1Z', + 0x16: 'S8Z24_MS16_1Z', + 0x17: 'S8Z24_2CZ', + 0x18: 'S8Z24_MS2_2CZ', + 0x19: 'S8Z24_MS4_2CZ', + 0x1A: 'S8Z24_MS8_2CZ', + 0x1B: 'S8Z24_MS16_2CZ', + 0x1C: 'S8Z24_2CS', + 0x1D: 'S8Z24_MS2_2CS', + 0x1E: 'S8Z24_MS4_2CS', + 0x1F: 'S8Z24_MS8_2CS', + 0x20: 'S8Z24_MS16_2CS', + 0x21: 'S8Z24_4CSZV', + 0x22: 'S8Z24_MS2_4CSZV', + 0x23: 'S8Z24_MS4_4CSZV', + 0x24: 'S8Z24_MS8_4CSZV', + 0x25: 'S8Z24_MS16_4CSZV', + 0x26: 'V8Z24_MS4_VC12', + 0x27: 'V8Z24_MS4_VC4', + 0x28: 'V8Z24_MS8_VC8', + 0x29: 'V8Z24_MS8_VC24', + 0x2A: 'Z16_2C', + 0x2B: 'S8_2S', + 0x2C: 'C32_MS4_4CBRA', + 0x2D: 'C64_MS4_4CBRA', + 0x2E: 'V8Z24_MS4_VC12_1ZV', + 0x2F: 'V8Z24_MS4_VC4_1ZV', + 0x30: 'V8Z24_MS8_VC8_1ZV', + 0x31: 'V8Z24_MS8_VC24_1ZV', + 0x32: 'V8Z24_MS4_VC12_2CS', + 0x33: 'V8Z24_MS4_VC4_2CS', + 0x34: 'V8Z24_MS8_VC8_2CS', + 0x35: 'V8Z24_MS8_VC24_2CS', + 0x36: 'Z16_2CZ', + 0x37: 'Z16_MS2_2CZ', + 0x38: 'Z16_MS4_2CZ', + 0x39: 'Z16_MS8_2CZ', + 0x3A: 'V8Z24_MS4_VC12_2CZV', + 0x3B: 'V8Z24_MS4_VC4_2CZV', + 0x3C: 'V8Z24_MS8_VC8_2CZV', + 0x3D: 'V8Z24_MS8_VC24_2CZV', + 0x3E: 'V8Z24_MS4_VC12_2ZV', + 0x3F: 'V8Z24_MS4_VC4_2ZV', + 0x40: 'V8Z24_MS8_VC8_2ZV', + 0x41: 'V8Z24_MS8_VC24_2ZV', + 0x42: 'V8Z24_MS4_VC12_4CSZV', + 0x43: 'V8Z24_MS4_VC4_4CSZV', + 0x44: 'V8Z24_MS8_VC8_4CSZV', + 0x45: 'V8Z24_MS8_VC24_4CSZV', + 0x46: 'Z16_MS8_2C', + 0x47: 'Z24S8_1Z', + 0x48: 'Z24S8_MS2_1Z', + 0x49: 'Z24S8_MS4_1Z', + 0x4A: 'Z24S8_MS8_1Z', + 0x4B: 'Z24S8_MS16_1Z', + 0x4C: 'Z24S8_2CS', + 0x4D: 'Z24S8_MS2_2CS', + 0x4E: 'Z24S8_MS4_2CS', + 0x4F: 'Z24S8_MS8_2CS', + 0x50: 'Z24S8_MS16_2CS', + 0x51: 'Z24S8_2CZ', + 0x52: 'Z24S8_MS2_2CZ', + 0x53: 'Z24S8_MS4_2CZ', + 0x54: 'Z24S8_MS8_2CZ', + 0x55: 'Z24S8_MS16_2CZ', + 0x56: 'Z24S8_4CSZV', + 0x57: 'Z24S8_MS2_4CSZV', + 0x58: 'Z24S8_MS4_4CSZV', + 0x59: 'Z24S8_MS8_4CSZV', + 0x5A: 'Z24S8_MS16_4CSZV', + 0x5B: 'Z24V8_MS4_VC12', + 0x5C: 'Z24V8_MS4_VC4', + 0x5D: 'Z24V8_MS8_VC8', + 0x5E: 'Z24V8_MS8_VC24', + 0x5F: 'Z16_MS16_2CZ', + 0x60: 'Z16_MS4_2Z', + 0x61: 'Z16_MS8_2Z', + 0x62: 'Z16_MS16_2Z', + 0x63: 'Z24V8_MS4_VC12_1ZV', + 0x64: 'Z24V8_MS4_VC4_1ZV', + 0x65: 'Z24V8_MS8_VC8_1ZV', + 0x66: 'Z24V8_MS8_VC24_1ZV', + 0x67: 'Z24V8_MS4_VC12_2CS', + 0x68: 'Z24V8_MS4_VC4_2CS', + 0x69: 'Z24V8_MS8_VC8_2CS', + 0x6A: 'Z24V8_MS8_VC24_2CS', + 0x6B: 'Z16_2Z', + 0x6C: 'Z16_MS16_2C', + 0x6F: 'Z24V8_MS4_VC12_2CZV', + 0x70: 'Z24V8_MS4_VC4_2CZV', + 0x71: 'Z24V8_MS8_VC8_2CZV', + 0x72: 'Z24V8_MS8_VC24_2CZV', + 0x73: 'Z24V8_MS4_VC12_2ZV', + 0x74: 'Z24V8_MS4_VC4_2ZV', + 0x75: 'Z24V8_MS8_VC8_2ZV', + 0x76: 'Z24V8_MS8_VC24_2ZV', + 0x77: 'Z24V8_MS4_VC12_4CSZV', + 0x78: 'Z24V8_MS4_VC4_4CSZV', + 0x79: 'Z24V8_MS8_VC8_4CSZV', + 0x7A: 'Z24V8_MS8_VC24_4CSZV', + 0x7B: 'ZF32', + 0x7C: 'ZF32_1Z', + 0x7D: 'ZF32_MS2_1Z', + 0x7E: 'ZF32_MS4_1Z', + 0x7F: 'ZF32_MS8_1Z', + 0x80: 'ZF32_MS16_1Z', + 0x81: 'ZF32_2CS', + 0x82: 'ZF32_MS2_2CS', + 0x83: 'ZF32_MS4_2CS', + 0x84: 'ZF32_MS8_2CS', + 0x85: 'ZF32_MS16_2CS', + 0x86: 'ZF32_2CZ', + 0x87: 'ZF32_MS2_2CZ', + 0x88: 'ZF32_MS4_2CZ', + 0x89: 'ZF32_MS8_2CZ', + 0x8A: 'ZF32_MS16_2CZ', + 0x8B: 'X8Z24_X16V8S8_MS4_VC12', + 0x8C: 'X8Z24_X16V8S8_MS4_VC4', + 0x8D: 'X8Z24_X16V8S8_MS8_VC8', + 0x8E: 'X8Z24_X16V8S8_MS8_VC24', + 0x8F: 'X8Z24_X16V8S8_MS4_VC12_1CS', + 0x90: 'X8Z24_X16V8S8_MS4_VC4_1CS', + 0x91: 'X8Z24_X16V8S8_MS8_VC8_1CS', + 0x92: 'X8Z24_X16V8S8_MS8_VC24_1CS', + 0x97: 'X8Z24_X16V8S8_MS4_VC12_1ZV', + 0x98: 'X8Z24_X16V8S8_MS4_VC4_1ZV', + 0x99: 'X8Z24_X16V8S8_MS8_VC8_1ZV', + 0x9A: 'X8Z24_X16V8S8_MS8_VC24_1ZV', + 0x9B: 'X8Z24_X16V8S8_MS4_VC12_1CZV', + 0x9C: 'X8Z24_X16V8S8_MS4_VC4_1CZV', + 0x9D: 'X8Z24_X16V8S8_MS8_VC8_1CZV', + 0x9E: 'X8Z24_X16V8S8_MS8_VC24_1CZV', + 0x9F: 'X8Z24_X16V8S8_MS4_VC12_2CS', + 0xA0: 'X8Z24_X16V8S8_MS4_VC4_2CS', + 0xA1: 'X8Z24_X16V8S8_MS8_VC8_2CS', + 0xA2: 'X8Z24_X16V8S8_MS8_VC24_2CS', + 0xA3: 'X8Z24_X16V8S8_MS4_VC12_2CSZV', + 0xA4: 'X8Z24_X16V8S8_MS4_VC4_2CSZV', + 0xA5: 'X8Z24_X16V8S8_MS8_VC8_2CSZV', + 0xA6: 'X8Z24_X16V8S8_MS8_VC24_2CSZV', + 0xA7: 'ZF32_X16V8S8_MS4_VC12', + 0xA8: 'ZF32_X16V8S8_MS4_VC4', + 0xA9: 'ZF32_X16V8S8_MS8_VC8', + 0xAA: 'ZF32_X16V8S8_MS8_VC24', + 0xAB: 'ZF32_X16V8S8_MS4_VC12_1CS', + 0xAC: 'ZF32_X16V8S8_MS4_VC4_1CS', + 0xAD: 'ZF32_X16V8S8_MS8_VC8_1CS', + 0xAE: 'ZF32_X16V8S8_MS8_VC24_1CS', + 0xB3: 'ZF32_X16V8S8_MS4_VC12_1ZV', + 0xB4: 'ZF32_X16V8S8_MS4_VC4_1ZV', + 0xB5: 'ZF32_X16V8S8_MS8_VC8_1ZV', + 0xB6: 'ZF32_X16V8S8_MS8_VC24_1ZV', + 0xB7: 'ZF32_X16V8S8_MS4_VC12_1CZV', + 0xB8: 'ZF32_X16V8S8_MS4_VC4_1CZV', + 0xB9: 'ZF32_X16V8S8_MS8_VC8_1CZV', + 0xBA: 'ZF32_X16V8S8_MS8_VC24_1CZV', + 0xBB: 'ZF32_X16V8S8_MS4_VC12_2CS', + 0xBC: 'ZF32_X16V8S8_MS4_VC4_2CS', + 0xBD: 'ZF32_X16V8S8_MS8_VC8_2CS', + 0xBE: 'ZF32_X16V8S8_MS8_VC24_2CS', + 0xBF: 'ZF32_X16V8S8_MS4_VC12_2CSZV', + 0xC0: 'ZF32_X16V8S8_MS4_VC4_2CSZV', + 0xC1: 'ZF32_X16V8S8_MS8_VC8_2CSZV', + 0xC2: 'ZF32_X16V8S8_MS8_VC24_2CSZV', + 0xC3: 'Z16_MS4_2C', + 0xC4: 'ZF32_X24S8_1CS', + 0xC5: 'ZF32_X24S8_MS2_1CS', + 0xC6: 'ZF32_X24S8_MS4_1CS', + 0xC7: 'ZF32_X24S8_MS8_1CS', + 0xC8: 'ZF32_X24S8_MS16_1CS', + 0xCB: 'SMHOST_MESSAGE', + 0xCC: 'C32_MS2_4CBRA', + 0xCD: 'C64_MS2_4CBRA', + 0xCE: 'ZF32_X24S8_2CSZV', + 0xCF: 'ZF32_X24S8_MS2_2CSZV', + 0xD0: 'ZF32_X24S8_MS4_2CSZV', + 0xD1: 'ZF32_X24S8_MS8_2CSZV', + 0xD2: 'ZF32_X24S8_MS16_2CSZV', + 0xD3: 'ZF32_X24S8_2CS', + 0xD4: 'ZF32_X24S8_MS2_2CS', + 0xD5: 'ZF32_X24S8_MS4_2CS', + 0xD6: 'ZF32_X24S8_MS8_2CS', + 0xD7: 'ZF32_X24S8_MS16_2CS', + 0xD8: 'C32_2C', + 0xD9: 'C32_2CBR', + 0xDA: 'C32_2CBA', + 0xDB: 'C32_2CRA', + 0xDC: 'C32_2BRA', + 0xDD: 'C32_MS2_2C', + 0xDE: 'C32_MS2_2CBR', + 0xDF: 'C32_MS4_2C', + 0xE0: 'C32_MS4_2CBR', + 0xE1: 'C32_MS4_2CBA', + 0xE2: 'C32_MS4_2CRA', + 0xE3: 'C32_MS4_2BRA', + 0xE4: 'C32_MS8_MS16_2C', + 0xE5: 'C32_MS8_MS16_2CRA', + 0xE6: 'C64_2C', + 0xE7: 'C64_2CBR', + 0xE8: 'C64_2CBA', + 0xE9: 'C64_2CRA', + 0xEA: 'C64_2BRA', + 0xEB: 'C64_MS2_2C', + 0xEC: 'C64_MS2_2CBR', + 0xED: 'C64_MS4_2C', + 0xEE: 'C64_MS4_2CBR', + 0xEF: 'C64_MS4_2CBA', + 0xF0: 'C64_MS4_2CRA', + 0xF1: 'C64_MS4_2BRA', + 0xF2: 'C64_MS8_MS16_2C', + 0xF3: 'C64_MS8_MS16_2CRA', + 0xF4: 'C128_2C', + 0xF5: 'C128_2CR', + 0xF6: 'C128_MS2_2C', + 0xF7: 'C128_MS2_2CR', + 0xF8: 'C128_MS4_2C', + 0xF9: 'C128_MS4_2CR', + 0xFA: 'C128_MS8_MS16_2C', + 0xFB: 'C128_MS8_MS16_2CR', + 0xFC: 'X8C24', + 0xFD: 'PITCH_NO_SWIZZLE', + 0xFE: 'GENERIC_16BX2', +} + +# --------------------------------------------------------------------------- +# Field positions (lo, hi), inclusive. A field is only listed when the +# format definitions prove its position; there are no inferred fields. +# --------------------------------------------------------------------------- + +_VER2_PTE_FIELDS = ( + ("valid", 0, 0), + ("aperture", 1, 2), + ("volatile", 3, 3), + ("encrypted", 4, 4), + ("privilege", 5, 5), + ("read_only", 6, 6), + ("atomic_disable", 7, 7), + ("address_sys", 8, 53), + ("address_vid", 8, 32), + ("peer_id", 33, 35), + ("comptagline", 36, 55), + ("kind", 56, 63), +) + +_VER2_PDE_FIELDS = ( + ("is_pde_pointer", 0, 0), + ("aperture", 1, 2), + ("volatile", 3, 3), + ("no_ats", 5, 5), + ("address_sys", 8, 53), + ("address_vid", 8, 32), + ("peer_id", 33, 35), +) + +# The dual PDE is a 16-byte (128-bit) entry: the BIG half is the low 64 bits, +# the SMALL half is the high 64 bits. Bit 0 (IS_PDE) applies to the whole +# entry and is stored in the low bits. +_VER2_DUAL_PDE_FIELDS = ( + ("is_pde_pointer", 0, 0), + ("aperture_big", 1, 2), + ("volatile_big", 3, 3), + ("no_ats", 5, 5), + ("address_big_sys", 4, 53), + ("address_big_vid", 4, 32), + ("peer_id_big", 33, 35), + ("aperture_small", 65, 66), + ("volatile_small", 67, 67), + ("address_small_sys", 72, 117), + ("address_small_vid", 72, 96), + ("peer_id_small", 97, 99), +) + +# Address field shifts from the format definitions. +_PTE_ADDRESS_SHIFT = 12 +_PDE_ADDRESS_SHIFT = 12 +_DUAL_ADDRESS_SHIFT = 12 +_DUAL_ADDRESS_BIG_SHIFT = 8 + + +def _field(value: int, lo: int, hi: int) -> int: + return (value >> lo) & ((1 << (hi - lo + 1)) - 1) + + +def _named_mask(fields) -> int: + mask = 0 + for _name, lo, hi in fields: + mask |= ((1 << (hi - lo + 1)) - 1) << lo + return mask + + +def _reserved(value: int, fields, width: int) -> tuple[list[int], int]: + """Set bits not covered by any named field: (bit list, mask).""" + mask = _named_mask(fields) + leftover = value & ~mask + bits = [b for b in range(width) if (leftover >> b) & 1] + return bits, leftover + + +def _check_value(value: int, width: int, what: str) -> None: + if not isinstance(value, int) or isinstance(value, bool): + raise GmmuDecodeError(E_BAD_VALUE, f"{what} must be an int") + if value < 0 or value >= (1 << width): + raise GmmuDecodeError( + E_BAD_VALUE, f"{what} {value:#x} does not fit in {width} bits") + + +def _check_kind(kind: str, allowed, what: str) -> None: + if kind not in allowed: + raise GmmuDecodeError( + E_UNSUPPORTED, + f"{what} {kind!r} is not supported (supported: {', '.join(allowed)})") + + +def _check_level(level, allowed, what: str) -> None: + if level is None: + return + if not isinstance(level, int) or isinstance(level, bool): + raise GmmuDecodeError(E_BAD_VALUE, f"{what} level must be an int or None") + if level not in allowed: + raise GmmuDecodeError( + E_UNSUPPORTED, + f"{what} level {level} is not supported " + f"(supported: {', '.join(str(x) for x in allowed)})") + + +def _page_size_for_level(level) -> str | None: + if level == 3: + return "2m" + if level == 4: + return "4k_or_64k" + return None + + +# --------------------------------------------------------------------------- +# Leaf PTE (VER2, 8 bytes) +# --------------------------------------------------------------------------- + +def decode_pte(value: int, *, pte_kind: str = KIND_VER2, + level: int | None = None) -> dict: + """Decode one 8-byte VER2 leaf PTE. + + ``pte_kind`` selects the entry encoding; only ``ver2`` is a leaf encoding. + ``level``, when given, pins the page-tree depth (3 = 2 MB leaf, 4 = + 4 KB/64 KB leaf) and is otherwise reported as undetermined. + """ + _check_kind(pte_kind, SUPPORTED_KINDS, "pte_kind") + if pte_kind != KIND_VER2: + raise GmmuDecodeError( + E_UNSUPPORTED, f"{pte_kind} is a directory entry, not a leaf PTE") + _check_value(value, 64, "PTE value") + _check_level(level, (3, 4), "PTE") + + valid = bool(_field(value, 0, 0)) + aperture = _field(value, 1, 2) + aperture_name = APERTURE_NAMES_PTE[aperture] + address_sys = _field(value, 8, 53) + address_vid = _field(value, 8, 32) + peer_id = _field(value, 33, 35) + comptagline = _field(value, 36, 55) + kind = _field(value, 56, 63) + + if aperture_name == "system_coherent_memory" or \ + aperture_name == "system_non_coherent_memory": + address = address_sys << _PTE_ADDRESS_SHIFT + address_complete = True + elif aperture_name in ("video_memory", "peer_memory"): + # Bits 36:12 of the physical address are in address_vid. The high + # address bits are overloaded into comptagline only for the + # documented 47-bit peer case; otherwise comptagline is a + # compression tag, so a full address is not claimed here. + address = address_vid << _PTE_ADDRESS_SHIFT + address_complete = False + else: # pragma: no cover - all 4 aperture values are named + address = None + address_complete = False + + reserved_bits, reserved_mask = _reserved(value, _VER2_PTE_FIELDS, 64) + return { + "role": "pte", + "level": level, + "level_candidates": [3, 4] if level is None else [level], + "page_size": _page_size_for_level(level), + "valid": valid, + "aperture": aperture, + "aperture_name": aperture_name, + "volatile": bool(_field(value, 3, 3)), + "encrypted": bool(_field(value, 4, 4)), + "privilege": bool(_field(value, 5, 5)), + "read_only": bool(_field(value, 6, 6)), + "writable": not bool(_field(value, 6, 6)), + "atomic_disable": bool(_field(value, 7, 7)), + "address": address, + "address_complete": address_complete, + "address_sys": address_sys, + "address_vid": address_vid, + "peer_id": peer_id, + "comptagline": comptagline, + "kind": kind, + "kind_name": PTE_KIND_NAMES.get(kind), + "kind_known": kind in PTE_KIND_NAMES, + "reserved_bits": reserved_bits, + "reserved_mask": reserved_mask, + } + + +# --------------------------------------------------------------------------- +# Directory PDE (VER2, 8 bytes) +# --------------------------------------------------------------------------- + +def decode_pde(value: int, *, pde_kind: str = KIND_VER2, + level: int | None = None) -> dict: + """Decode one 8-byte VER2 directory entry. + + Bit 0 is the leaf/pointer discriminator: the driver's encoder writes 0 + for a pointer to a lower table and 1 for a 2 MB leaf PTE. A value whose + bit 0 is 1 is a leaf, not a directory entry, and is rejected here; use + :func:`decode_pte` (or :func:`decode_entry` with ``role='auto'``). + """ + _check_kind(pde_kind, SUPPORTED_KINDS, "pde_kind") + if pde_kind != KIND_VER2: + raise GmmuDecodeError( + E_UNSUPPORTED, f"{pde_kind} is a dual entry, not a single PDE") + _check_value(value, 64, "PDE value") + _check_level(level, (0, 1, 2, 3), "PDE") + + if _field(value, 0, 0): + raise GmmuDecodeError( + E_UNSUPPORTED, + "PDE bit 0 is set: this is a leaf (2 MB) PTE, not a directory entry") + + aperture = _field(value, 1, 2) + aperture_name = APERTURE_NAMES_PDE[aperture] + address_sys = _field(value, 8, 53) + address_vid = _field(value, 8, 32) + peer_id = _field(value, 33, 35) + + if aperture_name in ("system_coherent_memory", "system_non_coherent_memory"): + address = address_sys << _PDE_ADDRESS_SHIFT + address_complete = True + elif aperture_name in ("video_memory", "peer_memory"): + address = address_vid << _PDE_ADDRESS_SHIFT + address_complete = False + else: # aperture 0 = invalid + address = None + address_complete = False + + reserved_bits, reserved_mask = _reserved(value, _VER2_PDE_FIELDS, 64) + return { + "role": "pde", + "level": level, + "level_candidates": [0, 1, 2, 3] if level is None else [level], + "is_pde_pointer": True, + "is_leaf": False, + "aperture": aperture, + "aperture_name": aperture_name, + "volatile": bool(_field(value, 3, 3)), + "no_ats": bool(_field(value, 5, 5)), + "address": address, + "address_complete": address_complete, + "address_sys": address_sys, + "address_vid": address_vid, + "peer_id": peer_id, + "reserved_bits": reserved_bits, + "reserved_mask": reserved_mask, + } + + +# --------------------------------------------------------------------------- +# Dual PDE0 slot (VER2, 16 bytes) +# --------------------------------------------------------------------------- + +def decode_dual_pde(value: int, *, pde_kind: str = KIND_VER2_DUAL, + level: int | None = None) -> dict: + """Decode one 16-byte VER2 dual PDE0 slot (BIG + SMALL halves). + + ``value`` is the 128-bit little-endian slot value: the BIG half is the + low 64 bits, the SMALL half the high 64 bits. Bit 0 belongs to the whole + slot. The BIG half carries 64K-page-table state, the SMALL half 4K. + """ + _check_kind(pde_kind, SUPPORTED_KINDS, "pde_kind") + if pde_kind != KIND_VER2_DUAL: + raise GmmuDecodeError( + E_UNSUPPORTED, f"{pde_kind} is a single entry, not a dual PDE") + _check_value(value, 128, "dual PDE value") + _check_level(level, (3,), "dual PDE") + + aperture_big = _field(value, 1, 2) + aperture_small = _field(value, 65, 66) + name_big = APERTURE_NAMES_PDE[aperture_big] + name_small = APERTURE_NAMES_PDE[aperture_small] + + address_big_sys = _field(value, 4, 53) + address_big_vid = _field(value, 4, 32) + peer_id_big = _field(value, 33, 35) + address_small_sys = _field(value, 72, 117) + address_small_vid = _field(value, 72, 96) + peer_id_small = _field(value, 97, 99) + + # Bit 5 of the entry is named NO_ATS and also lies inside the BIG + # address field (bits 53:4), i.e. at field bit 1 (entry bit 5). A valid + # depth-3 allocation is 4 KB aligned, so address bit 9 (the overlapping + # bit) is zero; exclude it from the reconstructed address and report it + # as the flag the definitions name. + _NO_ATS_FIELD_MASK = 1 << (5 - 4) + if name_big in ("system_coherent_memory", "system_non_coherent_memory"): + address_big = (address_big_sys & ~_NO_ATS_FIELD_MASK) << _DUAL_ADDRESS_BIG_SHIFT + complete_big = True + elif name_big in ("video_memory", "peer_memory"): + address_big = (address_big_vid & ~_NO_ATS_FIELD_MASK) << _DUAL_ADDRESS_BIG_SHIFT + complete_big = False + else: + address_big = None + complete_big = False + + if name_small in ("system_coherent_memory", "system_non_coherent_memory"): + address_small = address_small_sys << _DUAL_ADDRESS_SHIFT + complete_small = True + elif name_small in ("video_memory", "peer_memory"): + address_small = address_small_vid << _DUAL_ADDRESS_SHIFT + complete_small = False + else: + address_small = None + complete_small = False + + reserved_bits, reserved_mask = _reserved(value, _VER2_DUAL_PDE_FIELDS, 128) + return { + "role": "dual_pde", + "level": level, + "level_candidates": [3] if level is None else [level], + "is_pde_pointer": not bool(_field(value, 0, 0)), + "is_leaf": bool(_field(value, 0, 0)), + "no_ats": bool(_field(value, 5, 5)), + "big": { + "aperture": aperture_big, + "aperture_name": name_big, + "volatile": bool(_field(value, 3, 3)), + "address": address_big, + "address_complete": complete_big, + "address_sys": address_big_sys, + "address_vid": address_big_vid, + "peer_id": peer_id_big, + }, + "small": { + "aperture": aperture_small, + "aperture_name": name_small, + "volatile": bool(_field(value, 67, 67)), + "address": address_small, + "address_complete": complete_small, + "address_sys": address_small_sys, + "address_vid": address_small_vid, + "peer_id": peer_id_small, + }, + "reserved_bits": reserved_bits, + "reserved_mask": reserved_mask, + } + + +# --------------------------------------------------------------------------- +# Role dispatch +# --------------------------------------------------------------------------- + +def decode_entry(value: int, *, kind: str = KIND_VER2, role: str = "auto", + level: int | None = None) -> dict: + """Decode one entry with an explicit or inferred role. + + ``role`` is one of ``auto``, ``pte``, ``pde``, ``dual_pde``. ``auto`` + uses the format's own bit-0 discriminator for 8-byte VER2 entries + (bit 0 set = leaf PTE, clear = directory PDE); the inferred role is + reported with ``role_inferred=True`` and the level stays undetermined + unless the caller pins it. + """ + if role == "auto": + if kind == KIND_VER2_DUAL: + decoded = decode_dual_pde(value, pde_kind=kind, level=level) + decoded["role_inferred"] = level is None + return decoded + _check_value(value, 64, "entry value") + if _field(value, 0, 0): + decoded = decode_pte(value, pte_kind=kind, level=level) + else: + decoded = decode_pde(value, pde_kind=kind, level=level) + decoded["role_inferred"] = level is None + return decoded + if role == "pte": + return decode_pte(value, pte_kind=kind, level=level) + if role == "pde": + return decode_pde(value, pde_kind=kind, level=level) + if role == "dual_pde": + return decode_dual_pde(value, pde_kind=kind, level=level) + raise GmmuDecodeError( + E_UNSUPPORTED, + f"role {role!r} is not supported (auto, pte, pde, dual_pde)") + + +# --------------------------------------------------------------------------- +# Table decode +# --------------------------------------------------------------------------- + +def _normalize_levels(levels) -> tuple[tuple[int, ...], int | None]: + """Return (allowed levels, pinned level or None) from ``levels``.""" + if levels is None: + return tuple(SUPPORTED_LEVELS), None + requested: tuple[int, ...] + if isinstance(levels, int) and not isinstance(levels, bool): + requested = (levels,) + else: + try: + requested = tuple(int(x) for x in levels) + except (TypeError, ValueError): + raise GmmuDecodeError( + E_BAD_VALUE, "levels must be an int, a sequence of ints, or None") + if not requested: + raise GmmuDecodeError(E_BAD_VALUE, "levels must not be empty") + for level in requested: + if level not in SUPPORTED_LEVELS: + raise GmmuDecodeError( + E_UNSUPPORTED, + f"level {level} is not supported " + f"(supported: {', '.join(str(x) for x in SUPPORTED_LEVELS)})") + pinned = requested[0] if len(set(requested)) == 1 else None + return tuple(sorted(set(requested))), pinned + + +def _infer_role(bit0: int, allowed: tuple[int, ...]) -> str: + plausible_leaf = [lv for lv in allowed if lv in (3, 4)] + plausible_pde = [lv for lv in allowed if lv in (0, 1, 2, 3)] + if bit0: + if not plausible_leaf: + raise GmmuDecodeError( + E_UNSUPPORTED, + f"entry bit 0 is set (leaf) but levels {allowed} have no leaf level") + return "pte" + if not plausible_pde: + raise GmmuDecodeError( + E_UNSUPPORTED, + f"entry bit 0 is clear (directory) but levels {allowed} have no directory level") + return "pde" + + +def _candidates_for(role: str, allowed: tuple[int, ...]) -> list[int]: + if role == "pte": + return [lv for lv in allowed if lv in (3, 4)] + if role == "pde": + return [lv for lv in allowed if lv in (0, 1, 2, 3)] + return list(allowed) + + +def decode_table(blob: bytes, *, base_pa: int, entry_size: int = 8, + levels=None, kind: str = KIND_VER2) -> list[dict]: + """Decode a captured page-table blob into a list of entry dicts. + + ``blob`` is read little-endian, ``entry_size`` bytes per entry. + ``base_pa`` is the GPU physical address of the blob's first byte and is + reported per entry as ``entry_pa`` (it is not dereferenced). + + ``kind`` selects the entry encoding: ``ver2`` (8-byte PTE/PDE) or + ``ver2_dual`` (16-byte dual PDE0). ``entry_size`` must match the kind. + + ``levels`` optionally pins the page-tree depth of this blob (0-4) or a + sequence of acceptable depths; ``None`` (the default) uses the full + supported set and lets each entry's bit-0 discriminator choose between + the leaf-PTE and directory-PDE views, reporting the level as + undetermined (``level=None`` plus ``level_candidates``) rather than + guessing one. Any depth outside the supported set raises + ``E_UNSUPPORTED``. + + An empty blob decodes to ``[]``; a blob whose length is not a multiple + of ``entry_size`` raises ``E_TRUNCATED`` (trailing bytes are never + silently dropped). + """ + _check_kind(kind, SUPPORTED_KINDS, "kind") + expected = 8 if kind == KIND_VER2 else 16 + if entry_size != expected: + raise GmmuDecodeError( + E_UNSUPPORTED, + f"kind {kind!r} uses {expected}-byte entries, got entry_size={entry_size}") + if not isinstance(base_pa, int) or isinstance(base_pa, bool) or base_pa < 0: + raise GmmuDecodeError(E_BAD_VALUE, "base_pa must be a non-negative int") + if not isinstance(blob, (bytes, bytearray, memoryview)): + raise GmmuDecodeError(E_BAD_VALUE, "blob must be bytes-like") + blob = bytes(blob) + allowed, pinned = _normalize_levels(levels) + + if len(blob) == 0: + return [] + if len(blob) % entry_size != 0: + raise GmmuDecodeError( + E_TRUNCATED, + f"blob length {len(blob)} is not a multiple of entry_size={entry_size}") + + entries = [] + for index in range(len(blob) // entry_size): + offset = index * entry_size + value = int.from_bytes(blob[offset:offset + entry_size], "little") + + if kind == KIND_VER2_DUAL: + if pinned is not None and pinned != 3: + raise GmmuDecodeError( + E_UNSUPPORTED, + f"dual PDE entries live at level 3, got levels={levels}") + decoded = decode_dual_pde(value, level=3 if pinned == 3 else None) + level_reported = 3 if pinned == 3 else None + decoded["role_inferred"] = False + else: + bit0 = value & 1 + if pinned is None: + role = _infer_role(bit0, allowed) + decoded = decode_entry(value, kind=kind, role=role, level=None) + level_reported = None + else: + level = pinned + if level == 4: + role = "pte" + elif level in (0, 1, 2): + role = "pde" + else: # level 3 + role = "pte" if bit0 else "pde" + decoded = decode_entry(value, kind=kind, role=role, level=level) + level_reported = level + decoded["role_inferred"] = level_reported is None + if level_reported is None: + decoded["level_candidates"] = _candidates_for( + decoded["role"], allowed) + + entry: dict[str, Any] = { + "index": index, + "entry_pa": base_pa + offset, + "value": value, + } + entry.update(decoded) + entry["encoding"] = kind + entries.append(entry) + return entries + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def _parse_int(text: str) -> int: + try: + return int(text, 0) + except ValueError: + raise argparse.ArgumentTypeError(f"not an integer: {text!r}") + + +def _parse_levels(text: str): + if text is None: + return None + try: + return [int(part, 0) for part in text.split(",") if part != ""] + except ValueError: + raise argparse.ArgumentTypeError(f"not a level list: {text!r}") + + +def _render_entry(entry: dict) -> str: + role = entry["role"] + head = (f"index={entry['index']} pa={entry['entry_pa']:#x} " + f"role={role} level={entry['level']} value={entry['value']:#x}") + if role == "pte": + fields = (f"valid={entry['valid']} aperture={entry['aperture_name']} " + f"volatile={entry['volatile']} encrypted={entry['encrypted']} " + f"privilege={entry['privilege']} read_only={entry['read_only']} " + f"atomic_disable={entry['atomic_disable']} " + f"address={entry['address'] if entry['address'] is None else hex(entry['address'])} " + f"address_complete={entry['address_complete']} " + f"peer_id={entry['peer_id']} comptagline={entry['comptagline']:#x} " + f"kind={entry['kind']:#x} kind_name={entry['kind_name']}") + elif role == "pde": + fields = (f"aperture={entry['aperture_name']} volatile={entry['volatile']} " + f"no_ats={entry['no_ats']} " + f"address={entry['address'] if entry['address'] is None else hex(entry['address'])} " + f"address_complete={entry['address_complete']}") + else: + big, small = entry["big"], entry["small"] + fields = (f"big=({big['aperture_name']}, " + f"{big['address'] if big['address'] is None else hex(big['address'])}) " + f"small=({small['aperture_name']}, " + f"{small['address'] if small['address'] is None else hex(small['address'])}) " + f"no_ats={entry['no_ats']}") + tail = (f"reserved_bits={entry['reserved_bits']} " + f"reserved_mask={entry['reserved_mask']:#x}") + return f"{head} {fields} {tail}" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="python3 -m sim.gmmu_pte", + description=("Decode captured TU104 GMMU page-table entries (read-only, " + "stdlib-only; decode captured data only and validate " + "against a capture before trusting).")) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--value", type=_parse_int, + help="single entry value, e.g. --value 0x...") + source.add_argument("--blob", help="file containing a captured table blob") + parser.add_argument("--base-pa", type=_parse_int, default=0, + help="GPU physical address of the blob's first byte") + parser.add_argument("--kind", default=KIND_VER2, choices=SUPPORTED_KINDS, + help="entry encoding family (default: ver2)") + parser.add_argument("--entry-size", type=int, default=8, + help="bytes per entry (default: 8; ver2_dual uses 16)") + parser.add_argument("--levels", type=_parse_levels, default=None, + help="page-tree depth or comma-separated depths (0-4)") + parser.add_argument("--role", default="auto", + choices=("auto", "pte", "pde", "dual_pde"), + help="entry role for --value (default: auto)") + parser.add_argument("--json", action="store_true", + help="emit JSON (sorted keys; deterministic)") + args = parser.parse_args(argv) + + try: + if args.value is not None: + level = None + if args.levels is not None: + if len(args.levels) != 1: + raise GmmuDecodeError( + E_BAD_VALUE, "--value accepts exactly one level") + level = args.levels[0] + entry = decode_entry(args.value, kind=args.kind, role=args.role, + level=level) + entry.setdefault("index", 0) + entry.setdefault("entry_pa", args.base_pa) + entry["value"] = args.value + entry["encoding"] = args.kind + entries = [entry] + else: + with open(args.blob, "rb") as handle: + blob = handle.read() + entries = decode_table(blob, base_pa=args.base_pa, + entry_size=args.entry_size, + levels=args.levels, kind=args.kind) + except OSError as exc: + print(f"gmmu_pte: cannot read {args.blob}: {exc}", file=sys.stderr) + return 2 + except GmmuDecodeError as exc: + print(f"gmmu_pte: {exc}", file=sys.stderr) + return 2 + + if args.json: + print(json.dumps(entries, indent=2, sort_keys=True)) + else: + for entry in entries: + print(_render_entry(entry)) + print(f"# {len(entries)} entr{'y' if len(entries) == 1 else 'ies'} " + f"(kind={args.kind}, entry_size={args.entry_size})") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/debug/risc-v-gsp-rm-vm/sim/gsp_boot.py b/debug/risc-v-gsp-rm-vm/sim/gsp_boot.py index c586ab3..a6f2545 100644 --- a/debug/risc-v-gsp-rm-vm/sim/gsp_boot.py +++ b/debug/risc-v-gsp-rm-vm/sim/gsp_boot.py @@ -22,6 +22,16 @@ The boot shim supplies reset-time state that the real SEC2 booter/host would provide (bootargs and the register arguments); every shim value is an explicit, documented assumption in the profile and the session metadata. The device model itself never fakes a register read. + +GMMU/FBIF observability (``--trace-out``, default off): the profile's +``gmmu``/``fbif`` sections declare observe-only register windows +(:mod:`sim.devices.observe`); every access in them is recorded (never served, +never faked) and, when requested, exported as a +``greeneye-gmmu-fbif-trace/1`` document. ``--symbols`` optionally resolves +recorded PCs through a ``greeneye-symbols/1`` JSON database, and +``--observe-window BASE:SIZE[:LABEL[:MODE]]`` adds a runtime window list for +surfaces whose base is not established yet. The count summary is always part +of the boot report; see ``docs/42-GMMU-FBIF-OBSERVABILITY.md``. """ from __future__ import annotations @@ -35,7 +45,14 @@ from dataclasses import dataclass, field from greeneye.device_map import firmware_sha256 from greeneye.elf import load_elf from greeneye.errors import GreenEyeError +from greeneye.symbols import SymbolDB from sim.devices import load_profile, registry_from_profile +from sim.libos import LibosSyscalls, PROCESSOR_SUSPEND, TASK_PORT_SEND_RECV_WAIT +from sim.devices.observe import ( + ObservedWindow, + build_trace, + parse_window_spec, +) from sim.machine import ( R_BP, R_BUDGET, @@ -89,6 +106,10 @@ LIBOS_MEMORY_REGION_LOC_SYSMEM = 1 WPRM_ID8 = 0x5750524D # 'WPRM' +#: ``libos_bootstrap_args_t``: IMEM then DMEM ``base/size/bs`` u64 fields. +BOOTSTRAP_ARGS_SIZE = 6 * 8 + + def _int(value, default=None): if value is None: return default @@ -131,6 +152,25 @@ def build_bootargs(machine, *, bootargs_pa: int, core_dump_pa: int, # initArgs[1..2] stay zero (id8 == 0 terminates the array). +def build_bootstrap_args(machine, *, pa: int, imem_base_pa: int, imem_size: int, + imem_bs_size: int, dmem_base_pa: int, dmem_size: int, + dmem_bs_size: int) -> None: + """Write the ``libos_bootstrap_args_t`` the bootloader passes in ``a0``. + + Assumption (profile-recorded): the real libos bootloader builds this + structure (IMEM/DMEM base+size+bs) from its TCM configuration and calls + the firmware entry with a pointer to it; we reconstruct the documented + layout. Values are declared in the profile, never derived from silicon. + """ + machine.write_mem(pa, bytes(BOOTSTRAP_ARGS_SIZE)) + _write_u64(machine, pa + 0x00, imem_base_pa) + _write_u64(machine, pa + 0x08, imem_size) + _write_u64(machine, pa + 0x10, imem_bs_size) + _write_u64(machine, pa + 0x18, dmem_base_pa) + _write_u64(machine, pa + 0x20, dmem_size) + _write_u64(machine, pa + 0x28, dmem_bs_size) + + def build_wpr_meta(machine, *, wpr_meta_pa: int, elf_pa: int, elf_size: int, wpr_start: int, wpr_end: int, boot_bin_offset: int, gsp_fw_offset: int, @@ -206,7 +246,10 @@ class GspBootResult: device_state_sha256: str | None = None registers: dict = field(default_factory=dict) shim: dict = field(default_factory=dict) + libos: dict = field(default_factory=dict) milestones: list = field(default_factory=list) + trace_summary: dict | None = None + trace_path: str | None = None device_map: object | None = field(default=None, repr=False) def to_dict(self) -> dict: @@ -232,7 +275,12 @@ class GspBootResult: "device_state_sha256": self.device_state_sha256, "registers": self.registers, "boot_shim": self.shim, + "libos": self.libos, "milestones": list(self.milestones), + "gmmu_fbif_trace": { + "path": self.trace_path, + "summary": self.trace_summary, + }, } @@ -263,8 +311,18 @@ def run_boot( name: str = "gsp-boot", use_shim: bool = True, shim_overrides: dict | None = None, + trace_out: str | None = None, + symbols_path: str | None = None, + observe_windows: list[str] | None = None, ) -> GspBootResult: - """Run one bounded boot attempt and record a session.""" + """Run one bounded boot attempt and record a session. + + ``trace_out`` (default off) writes the ``greeneye-gmmu-fbif-trace/1`` + observer document to that path (a directory gets ``gmmu-fbif-trace.json`` + inside); ``symbols_path`` resolves recorded PCs through a + ``greeneye-symbols/1`` JSON database; ``observe_windows`` adds runtime + observer windows (``BASE:SIZE[:LABEL[:MODE]]``, repeatable). + """ if instructions <= 0 or instructions > MAX_ATTEMPT_INSTRUCTIONS: raise GreenEyeError( f"instructions must be in 1..{MAX_ATTEMPT_INSTRUCTIONS}, got {instructions}") @@ -274,6 +332,15 @@ def run_boot( return GspBootResult(ran=False, reason=f"profile absent: {profile_path}", image=image) + resolve_pc = None + if symbols_path: + db = SymbolDB() + db.load_json(symbols_path) + + def resolve_pc(pc, _db=db): + sym = _db.resolve(pc) + return sym.label(pc) if sym is not None else None + profile = load_profile(profile_path) image_obj = load_elf(image) if not image_obj.is_riscv: @@ -299,6 +366,16 @@ def run_boot( machine = session.target.machine machine.devices = registry_from_profile(profile, machine=machine) machine.translator = getattr(machine.devices, "dma", None) + libos = LibosSyscalls() + machine.syscall_handler = libos.handle + for spec in observe_windows or (): + win = parse_window_spec(spec) + machine.devices.add(ObservedWindow( + base=win["base"], size=win["size"], label=win["label"], + mode=win["mode"], registers=win["registers"], + evidence="runtime --observe-window", + clock=machine.insn_count, + )) shim_meta = {"enabled": bool(use_shim), "assumptions": []} if use_shim: @@ -309,6 +386,7 @@ def run_boot( _apply_boot_shim(machine, shim, image_obj, shim_meta) milestones_hit: list[str] = [] + trace_path: str | None = None try: reason, pc, a, b = _run_with_milestones(machine, instructions, milestones_hit) model = machine.cycle_model @@ -320,8 +398,19 @@ def run_boot( state_sha = hashlib.sha256(state_json.encode()).hexdigest() if state else None registers = {f"x{i}": machine.x[i] for i in range(32) if i and machine.x[i]} registers["pc"] = pc - rung = _rung_from_run(machine, shim_meta, milestones_hit) + rung = _rung_from_run(machine, shim_meta, milestones_hit, libos) shim_meta["rung"] = rung + trace_doc = build_trace( + reg, + image_sha256=sha, + profile=os.path.basename(profile_path), + profile_name=profile.get("name"), + symbols_path=symbols_path, + resolve=resolve_pc, + ) if reg is not None else None + trace_summary = trace_doc["summary"] if trace_doc is not None else None + if trace_out and trace_doc is not None: + trace_path = _write_trace_file(trace_out, trace_doc) meta = { "kind": "gsp-boot", "profile": os.path.basename(profile_path), @@ -341,7 +430,13 @@ def run_boot( "device_accesses": len(getattr(reg, "accesses", ())), "device_state_sha256": state_sha, "boot_shim": shim_meta, + "mpu": machine.mpu.state(), + "libos": libos.state(), } + if trace_summary is not None: + meta["gmmu_fbif"] = trace_summary + if trace_path is not None: + meta["gmmu_fbif_trace"] = trace_path if getattr(session.recorder, "dir", None) is not None: if state is not None: rel = session.recorder.blob( @@ -349,6 +444,12 @@ def run_boot( json.dumps(state, indent=1, sort_keys=True, default=str).encode(), ) meta["device_state_blob"] = rel + if trace_doc is not None and trace_out: + rel = session.recorder.blob( + "gmmu-fbif-trace.json", + json.dumps(trace_doc, indent=1, sort_keys=True).encode(), + ) + meta["gmmu_fbif_trace_blob"] = rel session.recorder.meta.update(meta) session.note( "gsp_boot", @@ -383,7 +484,10 @@ def run_boot( device_state_sha256=state_sha, registers=registers, shim=shim_meta, + libos=libos.state(), milestones=list(milestones_hit), + trace_summary=trace_summary, + trace_path=trace_path, ) @@ -408,8 +512,8 @@ def _run_with_milestones(machine, budget: int, hit: list[str]): return R_BUDGET, machine.pc, 0, 0 -def _rung_from_run(machine, shim_meta: dict, milestones_hit) -> str: - """Observed rung from milestones and the unknown-MMIO surface. +def _rung_from_run(machine, shim_meta: dict, milestones_hit, libos=None) -> str: + """Observed rung from milestones, libos services and the unknown-MMIO surface. B0: the first unmodelled access is the PMC ``BOOT_42`` chip-ID read. B1: the device model served the whole run (no unknown MMIO) and the boot @@ -417,10 +521,17 @@ def _rung_from_run(machine, shim_meta: dict, milestones_hit) -> str: B2: the boot shim was consumed and the firmware reached its relocation stage (milestone ``relocation_copy``) -- boot-stage progress beyond the bootargs consumer. + B3: the libos init path completed its services (TCM/pagetable + registration, DMA flush) and the message-loop task blocks in the + port wait -- the CSB/TRANSCOM poll itself is host-traffic-triggered + and remains owed to the message-injection rung (docs/35 §3.3 rev). """ first_unknown = machine.mmio_log[0] if machine.mmio_log else None if first_unknown is not None and first_unknown["addr"] == 0x2000000000000A00: return "B0" + stopped = getattr(libos, "stopped", None) if libos is not None else None + if stopped and stopped.get("num") in (TASK_PORT_SEND_RECV_WAIT, PROCESSOR_SUSPEND): + return "B3" if "relocation_copy" in milestones_hit: return "B2" return "B1" @@ -431,6 +542,8 @@ def _rung_note(rung: str) -> str: "B0": "first MMIO is the unmodelled PMC BOOT_42 chip-ID read", "B1": "device model serves the boot path; bootargs consumer reached", "B2": "boot-stage progress past the bootargs consumer (relocation)", + "B3": "init complete; message-loop task blocks in the port wait " + "(CSB poll awaits host traffic)", }.get(rung, rung) @@ -472,6 +585,24 @@ def _apply_boot_shim(machine, shim: dict, image_obj, shim_meta: dict) -> None: shim_meta["assumptions"].append( "GspFwWprMeta reconstructed from the documented layout (header); " "real value is booter-provided") + bootstrap = shim.get("bootstrap_args") + if bootstrap: + args_pa = _int(bootstrap.get("pa", 0x81000)) + build_bootstrap_args( + machine, + pa=args_pa, + imem_base_pa=_int(bootstrap.get("imem_base_pa", 0x0)), + imem_size=_int(bootstrap.get("imem_size", 0x10000)), + imem_bs_size=_int(bootstrap.get("imem_bs_size", 0x0)), + dmem_base_pa=_int(bootstrap.get("dmem_base_pa", 0x100000000)), + dmem_size=_int(bootstrap.get("dmem_size", 0x10000)), + dmem_bs_size=_int(bootstrap.get("dmem_bs_size", 0x0)), + ) + shim_meta["assumptions"].append( + "libos_bootstrap_args_t (IMEM/DMEM base+size+bs) reconstructed at " + "0x%x from the documented layout; real value is bootloader-built " + "and passed in a0" % args_pa) + shim_meta["bootstrap_args_pa"] = args_pa shim_meta["applied"] = True shim_meta["registers"] = {k: registers[k] for k in sorted(registers)} image_at = shim.get("image_at") @@ -481,13 +612,27 @@ def _apply_boot_shim(machine, shim: dict, image_obj, shim_meta: dict) -> None: blob = fh.read() machine.write_mem(addr, blob) shim_meta["assumptions"].append( - "raw firmware image mirrored at 0x%x (DMEM) so the app's own " - "self-relocation copy is self-consistent; real value is " - "booter-DMA-provided" % addr) + "raw firmware image mirrored at 0x%x (DMEM); legacy fallback for " + "profiles without a bootstrap-args record (no longer used by the " + "tu104-570.144 profile: the MMU model resolves the copy source)" + % addr) shim_meta["image_at"] = addr shim_meta["image_bytes"] = len(blob) +def _write_trace_file(path: str, doc: dict) -> str: + """Write the observer trace document; a directory gets the canonical name.""" + target = path + if os.path.isdir(path) or path.endswith(os.sep): + target = os.path.join(path, "gmmu-fbif-trace.json") + parent = os.path.dirname(os.path.abspath(target)) + os.makedirs(parent, exist_ok=True) + with open(target, "w", encoding="utf-8") as fh: + json.dump(doc, fh, indent=1, sort_keys=True) + fh.write("\n") + return target + + def _print_result(result: GspBootResult, out=sys.stdout) -> None: print(f"image: {result.image}", file=out) print(f"profile: {result.profile_name} ({result.profile})", file=out) @@ -504,6 +649,14 @@ def _print_result(result: GspBootResult, out=sys.stdout) -> None: first = result.first_unknown_mmio print(f"first unknown MMIO: pc={first['pc']:#x} addr={first['addr']:#x} " f"size={first['size']} rw={first['rw']}", file=out) + if result.trace_summary is not None: + summary = result.trace_summary + print(f"GMMU/FBIF observers: {summary['windows']} windows, " + f"{summary['accesses']} accesses ({summary['reads']} r / " + f"{summary['writes']} w, {summary['served']} served, " + f"{summary['windows_touched']} windows touched)", file=out) + if result.trace_path is not None: + print(f"GMMU/FBIF trace: {result.trace_path}", file=out) print("note: device semantics carry their evidence in docs/36; " "boot-shim values are declared assumptions", file=out) @@ -520,6 +673,15 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--name", default="gsp-boot") parser.add_argument("--no-shim", action="store_true", help="run without the boot shim (pure device model)") + parser.add_argument("--trace-out", default=None, + help="write the greeneye-gmmu-fbif-trace/1 observer " + "document to this file (or directory); default off") + parser.add_argument("--symbols", default=None, + help="greeneye-symbols/1 JSON database for PC resolution") + parser.add_argument("--observe-window", action="append", default=None, + metavar="BASE:SIZE[:LABEL[:MODE]]", + help="extra runtime observer window (repeatable); " + "observe-only unless MODE=storage") parser.add_argument("--json", action="store_true") args = parser.parse_args(argv) try: @@ -530,6 +692,9 @@ def main(argv: list[str] | None = None) -> int: record=args.record, name=args.name, use_shim=not args.no_shim, + trace_out=args.trace_out, + symbols_path=args.symbols, + observe_windows=args.observe_window, ) except GreenEyeError as exc: print(f"[gsp-boot] error: {exc}", file=sys.stderr) diff --git a/debug/risc-v-gsp-rm-vm/sim/libos.py b/debug/risc-v-gsp-rm-vm/sim/libos.py new file mode 100644 index 0000000..e8280fd --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/libos.py @@ -0,0 +1,217 @@ +"""Minimal, honest libos syscall surface for the native GSP boot. + +Evidence kind: **firmware use + RTOS syscall table.** The GSP application is +a libos task that requests kernel services with ``ecall`` (the syscall number +travels in ``t0``); the native ``Machine`` stops on ``ecall`` by default, so +booting the real image past kernel init needs a deterministic service model. + +Rules: + +* every requested syscall is recorded (number, name, arguments, pc, + instruction count) so the session shows exactly what the firmware asked for; +* only *no-ops and modelled* services are acknowledged: task yield, cache + invalidation, external-interrupt arming, profiler enable/query, and the + timer read (returned from the modeled instruction count, labelled + modeled); +* **waits are never faked**: ``PROCESSOR_SUSPEND``, + ``TASK_PORT_SEND_RECV_WAIT``, ``TASK_PANIC`` and every unimplemented + syscall return *not handled*, which stops the run with ``R_ECALL`` so the + caller can classify the stop (message-loop wait / panic / unsupported + service) instead of inventing progress; +* task switching, DMA, memory queries, pagetable registration, partition + switch and interrupt delivery are not modelled. + +No timing, scheduling or security claim is made; this is development +tooling for the boot ladder (docs/35 §3.3). +""" +from __future__ import annotations + +# Syscall numbers (RTOS ``libos_syscalls.h``). +TASK_PORT_SEND_RECV_WAIT = 1 +TASK_YIELD = 2 +PROCESSOR_SUSPEND = 3 +DCACHE_INVALIDATE = 4 +MEMORY_QUERY = 5 +TASK_STATE_QUERY = 6 +SHUTTLE_RESET = 7 +TASK_DMA_REGISTER_TCM = 8 +TASK_DMA_COPY = 9 +TASK_DMA_FLUSH = 10 +TASK_MEMORY_READ = 11 +INIT_REGISTER_PAGETABLES = 12 +PROFILER_ENABLE = 13 +TASK_EXIT = 14 +TASK_PANIC = 15 +INTERRUPT_ARM_EXTERNAL = 16 +TIMER_GET_NS = 17 +DCACHE_INVALIDATE_ALL = 18 +PROFILER_GET_COUNTER_MASK = 19 +PARTITION_SWITCH = 20 +LIMIT_POW2 = 32 + +NAMES = { + TASK_PORT_SEND_RECV_WAIT: "task_port_send_recv_wait", + TASK_YIELD: "task_yield", + PROCESSOR_SUSPEND: "processor_suspend", + DCACHE_INVALIDATE: "dcache_invalidate", + MEMORY_QUERY: "memory_query", + TASK_STATE_QUERY: "task_state_query", + SHUTTLE_RESET: "shuttle_reset", + TASK_DMA_REGISTER_TCM: "task_dma_register_tcm", + TASK_DMA_COPY: "task_dma_copy", + TASK_DMA_FLUSH: "task_dma_flush", + TASK_MEMORY_READ: "task_memory_read", + INIT_REGISTER_PAGETABLES: "init_register_pagetables", + PROFILER_ENABLE: "profiler_enable", + TASK_EXIT: "task_exit", + TASK_PANIC: "task_panic", + INTERRUPT_ARM_EXTERNAL: "interrupt_arm_external", + TIMER_GET_NS: "timer_get_ns", + DCACHE_INVALIDATE_ALL: "dcache_invalidate_all", + PROFILER_GET_COUNTER_MASK: "profiler_get_counter_mask", + PARTITION_SWITCH: "partition_switch", + LIMIT_POW2: "limit_pow2", +} + +#: Services acknowledged as no-ops (result 0); everything else stops the run. +_NOOP_SYSCALLS = frozenset({ + TASK_YIELD, DCACHE_INVALIDATE, DCACHE_INVALIDATE_ALL, PROFILER_ENABLE, +}) + + +class LibosSyscalls: + """Deterministic libos service model: record, acknowledge, or stop.""" + + name = "libos-syscalls" + + def __init__(self) -> None: + self.calls: list[dict] = [] + self.counts: dict[int, int] = {} + self.armed_mask = 0 + #: Last registered TCM bounce buffer (``task_dma_register_tcm``). + self.tcm: dict | None = None + #: Last inverted-pagetable registration (``init_register_pagetables``). + self.pagetables: dict | None = None + self.dma_copies = 0 + self.dma_bytes = 0 + #: (number, a0, a1, pc, insns) of the last request. + self.last: tuple | None = None + #: The request that stopped the run (not handled), if any. + self.stopped: dict | None = None + + def handle(self, machine, pc: int = 0): + """``Machine`` ecall hook: ``(handled, result)``. + + Unhandled requests leave ``machine.run`` stopping with ``R_ECALL``; + the number is still recorded here and in ``stopped``. + """ + num = machine.x[5] + a0, a1 = machine.x[10], machine.x[11] + entry = { + "num": num, + "name": NAMES.get(num, f"unknown_{num}"), + "a0": a0, + "a1": a1, + "pc": pc, + "insns": machine.insns, + } + self.calls.append(entry) + self.counts[num] = self.counts.get(num, 0) + 1 + self.last = (num, a0, a1, pc, machine.insns) + if num == INTERRUPT_ARM_EXTERNAL: + self.armed_mask |= a0 + return True, 0 + if num == TIMER_GET_NS: + # Modeled time: instruction count * 1 ns (docs/36: modeled, not + # calibrated; no timing claim). + return True, machine.insns + if num == TASK_DMA_REGISTER_TCM: + # The kernel validates the VA against its pagetable and stores it + # as the DMA bounce buffer; without a paging model the region is + # recorded and the observed result (LIBOS_OK) returned. + self.tcm = {"va": a0, "size": a1} + return True, 0 + if num == TASK_DMA_COPY: + return self._dma_copy(machine, a0, a1, machine.x[12], pc) + if num == INIT_REGISTER_PAGETABLES: + # The kernel builds its inverted page table from this registration + # and returns LIBOS_OK. The simulator does not model the + # inverted table (DMA resolution goes through the flat RAM + MPU + # + aperture seam); the registration is recorded as evidence. + self.pagetables = {"base": a0, "entries": a1, "probe_count": machine.x[12]} + return True, 0 + if num == TASK_DMA_FLUSH: + return True, 0 + if num in _NOOP_SYSCALLS: + return True, 0 + if num == PROFILER_GET_COUNTER_MASK: + return True, 0 + self.stopped = entry + return False, None + + # -- DMA service model ------------------------------------------------- + + def _resolve(self, machine, va: int): + """VA -> simulator RAM offset through the MPU and aperture seam.""" + if machine.mpu.active: + translated = machine.mpu.translate(va) + if translated is not None: + va = translated + off = va - machine.ram_base + if 0 <= off < machine.ram_size: + return off + translator = machine.translator + if translator is not None: + sim = translator.translate(va) + if sim is not None: + off = sim - machine.ram_base + if 0 <= off < machine.ram_size: + return off + return None + + def _dma_copy(self, machine, dst_va: int, src_va: int, size: int, pc: int = 0): + """Modeled DMA copy: equivalent to a RAM memcpy in the flat model. + + The real kernel drives the falcon DMA engine through TCM; the + simulator keeps all regions in one RAM, so the copy effect is the + same. Both sides must resolve or the run stops (never a faked copy). + """ + if size == 0: + return True, 0 + dst = self._resolve(machine, dst_va) + src = self._resolve(machine, src_va) + if dst is None or src is None: + self.stopped = {"num": TASK_DMA_COPY, "name": "task_dma_copy", + "a0": dst_va, "a1": src_va, "size": size, + "reason": "unresolved", "pc": pc} + return False, None + ram = machine.ram + if (0 <= dst <= machine.ram_size - size + and 0 <= src <= machine.ram_size - size): + ram[dst:dst + size] = bytes(ram[src:src + size]) + else: + # Straddling or out-of-range: byte-wise through the bounds. + for i in range(size): + if dst + i >= machine.ram_size or src + i >= machine.ram_size: + self.stopped = {"num": TASK_DMA_COPY, "name": "task_dma_copy", + "a0": dst_va, "a1": src_va, "size": size, + "reason": "out-of-range", "pc": pc} + return False, None + ram[dst + i] = ram[src + i] + machine.invalidate() + self.dma_copies += 1 + self.dma_bytes += size + return True, 0 + + def state(self) -> dict: + return { + "calls": len(self.calls), + "counts": {str(k): v for k, v in sorted(self.counts.items())}, + "armed_mask": self.armed_mask, + "tcm": self.tcm, + "pagetables": self.pagetables, + "dma_copies": self.dma_copies, + "dma_bytes": self.dma_bytes, + "last": list(self.last) if self.last else None, + "stopped": self.stopped, + } diff --git a/debug/risc-v-gsp-rm-vm/sim/machine.py b/debug/risc-v-gsp-rm-vm/sim/machine.py index 9908753..7634ce1 100644 --- a/debug/risc-v-gsp-rm-vm/sim/machine.py +++ b/debug/risc-v-gsp-rm-vm/sim/machine.py @@ -19,6 +19,7 @@ import mmap import struct from .cycles import DEFAULT_MODEL +from .mpu import MSTATUS, WATCHED_CSRS, RiscvMpu from .decode import ( A_ADD, A_AND, @@ -84,6 +85,9 @@ R_ECALL = 6 #: Accepted ``unknown_mmio`` policies for accesses outside RAM/UART/the map. UNKNOWN_MMIO_MODES = ("fault", "lenient") +#: CSRs whose writes update the MMU model (M-mode protection unit + mstatus). +_MPU_CSRS = frozenset(WATCHED_CSRS) | {MSTATUS} + UART_BASE = 0x10000000 UART_SIZE = 0x100 @@ -232,6 +236,10 @@ class Machine: self.device_map = device_map self.devices = devices self.translator = translator # aperture -> RAM seam (sim.devices.dma) + self.mpu = RiscvMpu() # M-mode protection unit (sim.mpu) + #: Optional guest-ECALL service (``handle(machine) -> (handled, ret)``); + #: when absent (or unhandled) ``run`` stops with ``R_ECALL`` as before. + self.syscall_handler = None self.unknown_mmio = unknown_mmio self.learn_mmio = bool(learn_mmio) self.bias = bias # PC relocation for learned-map firmware addresses @@ -506,6 +514,10 @@ class Machine: a = b = 0 traced = trace is not None active = False + # M-mode protection unit (sim.mpu): translation is consulted only when + # an enabled entry exists; no match is treated as identity. + mpu = self.mpu + mpu_on = mpu.active # Modeled cycle accounting (sim.cycles): pure table lookups, no timing. cycle_base = self._cycle_base cycle_op = self._cycle_op @@ -520,7 +532,12 @@ class Machine: a = pc break - off = pc - base + fetch_pc = pc + if mpu_on: + translated = mpu.translate(pc) + if translated is not None: + fetch_pc = translated + off = fetch_pc - base if off < 0 or off + 4 > limit: reason = R_FAULT a = 1 @@ -558,6 +575,12 @@ class Machine: b = hit break o = ea - base + mem_ea = ea + if mpu_on: + translated = mpu.translate(ea) + if translated is not None: + mem_ea = translated + o = mem_ea - base if 0 <= o <= limit - size: if active: trace.mem(insn_pc, ea, size, False, self.insns + n) @@ -587,7 +610,7 @@ class Machine: pc = nxt else: try: - v = self._mmio_load(ea, size, pc) + v = self._mmio_load(mem_ea, size, pc) except Fault as f: reason = R_FAULT a = f.cause @@ -613,6 +636,12 @@ class Machine: b = hit break o = ea - base + mem_ea = ea + if mpu_on: + translated = mpu.translate(ea) + if translated is not None: + mem_ea = translated + o = mem_ea - base value = x[r2] if 0 <= o <= limit - size: if active: @@ -627,17 +656,20 @@ class Machine: ram[o] = value & 0xFF # Self-modifying code: drop any cached decode overlapping # the written range (firmware patches code at runtime). - p = ea & ~1 - end = ea + size - while p < end: - cache.pop(p, None) - p += 2 + # Both the virtual address and its translated backing are + # dropped so a fetch through either mapping re-decodes. + for target in ((ea, mem_ea) if mem_ea != ea else (ea,)): + p = target & ~1 + end = target + size + while p < end: + cache.pop(p, None) + p += 2 if counting: cycle_sum += cycle_mem pc = nxt else: try: - self._mmio_store(ea, size, value, pc) + self._mmio_store(mem_ea, size, value, pc) except Fault as f: reason = R_FAULT a = f.cause @@ -864,6 +896,9 @@ class Machine: a = 2 b = csr break + if csr in _MPU_CSRS: + mpu.on_csr_write(csr, self.csrs.get(csr, 0)) + mpu_on = mpu.active pc = nxt elif kind == K_AMO: @@ -938,9 +973,20 @@ class Machine: elif kind == K_ECALL: if single_step: pc = nxt + elif self.syscall_handler is not None: + handled, result = self.syscall_handler(self, pc) + if not handled: + reason = R_ECALL + a = pc + b = x[5] # syscall number (t0), for diagnostics + break + if result is not None: + x[10] = result & MASK64 + pc = nxt else: reason = R_ECALL a = pc + b = x[5] break elif kind == K_MRET: @@ -975,15 +1021,25 @@ class Machine: # -- guest memory helpers (AMO) ---------------------------------------- def _guest_load(self, addr: int, width: int, pc: int = 0) -> int: - off = addr - self.ram_base + mem = addr + if self.mpu.active: + translated = self.mpu.translate(addr) + if translated is not None: + mem = translated + off = mem - self.ram_base if 0 <= off <= self.ram_size - width: if width == 8: return self._u64.unpack_from(self.ram, off)[0] return self._u32.unpack_from(self.ram, off)[0] - return self._mmio_load(addr, width, pc) + return self._mmio_load(mem, width, pc) def _guest_store(self, addr: int, width: int, value: int, pc: int = 0) -> None: - off = addr - self.ram_base + mem = addr + if self.mpu.active: + translated = self.mpu.translate(addr) + if translated is not None: + mem = translated + off = mem - self.ram_base if 0 <= off <= self.ram_size - width: if width == 8: self._u64p(self.ram, off, value) @@ -995,7 +1051,7 @@ class Machine: self._cache.pop(p, None) p += 2 return - self._mmio_store(addr, width, value, pc) + self._mmio_store(mem, width, value, pc) #: Task-facing alias: the native simulator machine is :class:`Machine`. diff --git a/debug/risc-v-gsp-rm-vm/sim/mpu.py b/debug/risc-v-gsp-rm-vm/sim/mpu.py new file mode 100644 index 0000000..3f24358 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/mpu.py @@ -0,0 +1,154 @@ +"""RISC-V MMU (NVMPU) CSR model for the GSP core. + +Evidence kind: **hardware CSR/field header + firmware use + observed access +sequence.** + +The GSP RISC-V core boots in *bare* mode and then enables a small protection +unit through CSRs; the firmware maps resident code/data into TCM and uses a +scratch window to copy it there. Without this model the flat-RAM simulator +serves those accesses at their virtual addresses and the relocated copy reads +the wrong backing (the B2 ceiling in ``docs/36-GSP-DEVICE-MODEL.md``). + +Modelled CSRs (M-mode values from the hardware CSR header; the firmware writes +exactly these - observed at the 570.144 bootstrap ``0x4007000``..``0x4007C00``): + +============================================ ====== ========================= +CSR value semantics +============================================ ====== ========================= +``MMPUIDX`` (0x7C8) entry INDEX field 5:0 +``MMPUVA`` (0x7C9) VA BASE 63:12, VLD bit 0 +``MMPUPA`` (0x7CA) PA BASE 63:12 +``MMPURNG`` (0x7CB) range RANGE 63:12 (a size) +``MMPUATTR`` (0x7CC) attr recorded, not enforced +``mstatus.VM`` (0x300) mode 0x1E = NVMPU (enabled) +============================================ ====== ========================= + +Semantics modelled: + +* an entry is *valid* when ``MMPUVA.VLD`` is set; writes to ``MMPUVA`` with + VLD clear disable the selected entry (the firmware's clear sequence); +* translation requires ``mstatus.VM == NVMPU`` (``0x1E``, field bits 28:24); + before the enable write the CSR entries exist but no access is translated; +* lookup is **lowest-index-first** (the firmware's own bootstrap comment: + "the MPU gives precedence to lower mappings"); a matching entry maps + ``addr`` to ``pa + (addr - va_base)`` when + ``va_base <= addr < va_base + range``; +* **no match is not a fault here**: the machine treats "no matching entry" as + identity. This is deliberately more lenient than silicon (which would + fault under the kernel's management) because the simulator keeps local + memory flat and the boot path's identity mappings are self-consistent; the + choice is recorded in the session and in ``docs/36`` §6. + +Permissions and cache/coherence attributes are recorded for evidence but not +enforced; no timing or side-channel claim is made. This module has no device +or host dependency and is deterministic. +""" +from __future__ import annotations + +#: CSR numbers (M-mode protection unit; hardware CSR header values). +MMPUIDX = 0x7C8 +MMPUVA = 0x7C9 +MMPUPA = 0x7CA +MMPURNG = 0x7CB +MMPUATTR = 0x7CC + +#: CSRs this model consumes when written by the guest. +WATCHED_CSRS = (MMPUIDX, MMPUVA, MMPUPA, MMPURNG, MMPUATTR) + +MSTATUS = 0x300 +MSTATUS_VM_SHIFT = 24 +MSTATUS_VM_MASK = 0x1F << MSTATUS_VM_SHIFT +#: mstatus.VM value that enables the protection unit (header addendum). +MSTATUS_VM_NVMPU = 0x1E + +#: Field masks (header: BASE/RANGE fields start at bit 12, 4 KiB granularity). +_PAGE_MASK = ~0xFFF & ((1 << 64) - 1) +_INDEX_MASK = 0x3F +_MAX_ENTRIES = 64 + + +class RiscvMpu: + """MMPU entry table + mstatus enable state (deterministic, serialisable).""" + + def __init__(self) -> None: + self.index = 0 + self.mstatus = 0 + self.entries: dict[int, dict] = {} + #: Ascending [(va_base, pa, range)] of valid entries; kept in sync on + #: every CSR write so translation is a short loop. + self._active: list[tuple[int, int, int]] = [] + self.enabled = False + + # -- guest CSR writes -------------------------------------------------- + + def on_csr_write(self, csr: int, value: int) -> None: + """Apply one guest CSR write (``value`` already masked to 64 bits).""" + if csr == MMPUIDX: + self.index = value & _INDEX_MASK + elif csr == MMPUVA: + entry = self.entries.setdefault(self.index, self._blank()) + entry["va"] = value & _PAGE_MASK + entry["valid"] = bool(value & 1) + self._refresh() + elif csr == MMPUPA: + entry = self.entries.setdefault(self.index, self._blank()) + entry["pa"] = value & _PAGE_MASK + elif csr == MMPURNG: + entry = self.entries.setdefault(self.index, self._blank()) + entry["range"] = value & _PAGE_MASK + elif csr == MMPUATTR: + entry = self.entries.setdefault(self.index, self._blank()) + entry["attr"] = value + elif csr == MSTATUS: + self.mstatus = value & ((1 << 64) - 1) + self.enabled = ((self.mstatus & MSTATUS_VM_MASK) >> MSTATUS_VM_SHIFT + == MSTATUS_VM_NVMPU) + + @staticmethod + def _blank() -> dict: + return {"va": 0, "pa": 0, "range": 0, "attr": 0, "valid": False} + + def _refresh(self) -> None: + self._active = [ + (e["va"], e["pa"], e["range"]) + for _, e in sorted(self.entries.items()) + if e.get("valid") and e.get("range") + ] + + # -- translation ------------------------------------------------------- + + @property + def active(self) -> bool: + """True when a matching entry could translate (fast-path switch).""" + return self.enabled and bool(self._active) + + def translate(self, addr: int) -> int | None: + """Virtual -> physical for ``addr``, or ``None`` when unmatched.""" + if not self.enabled: + return None + for va, pa, rng in self._active: + off = addr - va + if 0 <= off < rng: + return pa + off + return None + + # -- evidence / snapshot ---------------------------------------------- + + def state(self) -> dict: + return { + "enabled": self.enabled, + "mstatus": self.mstatus, + "index": self.index, + "entries": [ + {"index": i, **{k: e[k] for k in ("va", "pa", "range", "attr", "valid")}} + for i, e in sorted(self.entries.items()) + if e.get("valid") or e.get("pa") or e.get("range") or e.get("attr") + ], + } + + def reset(self) -> None: + self.index = 0 + self.mstatus = 0 + self.entries.clear() + self._active = [] + self.enabled = False diff --git a/debug/risc-v-gsp-rm-vm/sim/profiles/tu104-570.144.json b/debug/risc-v-gsp-rm-vm/sim/profiles/tu104-570.144.json index 919dffe..91f4ed5 100644 --- a/debug/risc-v-gsp-rm-vm/sim/profiles/tu104-570.144.json +++ b/debug/risc-v-gsp-rm-vm/sim/profiles/tu104-570.144.json @@ -69,6 +69,808 @@ "evidence": "PMU stub: no PMU register has been observed; every access stays unhandled and logged.", "windows": [] }, + "gmmu": { + "evidence": "GMMU/PRI-MMU register surface: NV_PFB_PRI_MMU_* offsets inside the PFB block (block range 0x00100000-0x00100FFF), the WPR/VPR/lock cluster (0x1FA7D8-0x1FA830) and the HSMMU block (0x001FAC00-0x001FAFFF) from the hardware register/field headers. Observe-only: no register semantics are modelled and no read value is invented; the B0-B2 runs did not touch these windows; the B3 wave's WPR2 read pair (0x1FA824/0x1FA828) falls in the wpr-lock window and is recorded observe-only (0 served).", + "mode": "observe-only", + "windows": [ + {"base": "0x2000000000100C80", "size": "0x280", "label": "gmmu-pri-mmu", "registers": {"0x0": {"name": "NV_PFB_PRI_MMU_CTRL", "fields": {"VM_PG_SIZE": [0, 0], "VOL_FAULT": [1, 1], "COMP_FAULT": [2, 2], "MISS_GRAN": [4, 3], "CACHE_MODE": [6, 5], "PDE_CACHE_MODE": [8, 7], "L2_SEND_MODE": [9, 9], "WORK_CREATION_DISABLE": [10, 10], "USE_PDB_BIG_PAGE_SIZE": [11, 11], "USE_FULL_COMP_TAG_LINE": [12, 12], "PRI_FIFO_ERROR_CLEAR": [13, 13], "PRI_FIFO_ERROR": [14, 14], "PRI_FIFO_EMPTY": [15, 15], "PRI_FIFO_SPACE": [23, 16], "ATOMIC_CAPABILITY_MODE": [25, 24], "STRONG_BIT_ATOMIC_CYA": [26, 26], "STRONG_BIT_OVERRIDE_ATOMIC_DISABLE": [26, 26], "ATOMIC_CAPABILITY_SYS_NCOH_MODE": [27, 27], "MMU_APERTURE": [29, 28], "MMU_VOL": [30, 30], "MMU_DISABLE": [31, 31]}}, "0x4": {"name": "NV_PFB_PRI_MMU_PM"}, "0x8": {"name": "NV_PFB_PRI_MMU_PM_ADDR_LOW"}, "0xc": {"name": "NV_PFB_PRI_MMU_PM_ADDR_HIGH"}, "0x10": {"name": "NV_PFB_PRI_MMU_PM_UNIT_MASK"}, "0x14": {"name": "NV_PFB_PRI_MMU_PM_REQ_MASK"}, "0x18": {"name": "NV_PFB_PRI_MMU_CG"}, "0x1c": {"name": "NV_PFB_PRI_MMU_CG1"}, "0x20": {"name": "NV_PFB_PRI_MMU_PG"}, "0x24": {"name": "NV_PFB_PRI_MMU_TLB_CTRL", "fields": {"L2_SIZE": [1, 0], "L2_FA_SIZE": [3, 2], "L2_RB_SIZE": [5, 4], "HUB_SIZE": [7, 6], "HUB_FA_SIZE": [9, 8], "HUB_RB_SIZE": [11, 10], "FILL_SIZE_PTE": [13, 12], "FILL_SIZE_PDE": [15, 14], "FILL_SIZE_RB": [17, 16], "L2_SET_INDEX_BIG_PG": [19, 18], "HUB_SET_INDEX_BIG_PG": [21, 20], "L2_SET_INDEX_SMALL_PG": [23, 22], "HUB_SET_INDEX_SMALL_PG": [25, 24], "HUB_LOCKED_LIMIT": [28, 26], "ATSD_PROP_FLUSH": [26, 26], "INVAL_PROP_FLUSH": [27, 27], "BIND_PROP_FLUSH": [28, 28], "HUB_RSRVD_VM": [31, 29]}}, "0x28": {"name": "NV_PFB_PRI_MMU_RSRVD"}, "0x2c": {"name": "NV_PFB_PRI_MMU_BIND_IMB"}, "0x30": {"name": "NV_PFB_PRI_MMU_BIND"}, "0x34": {"name": "NV_PFB_PRI_MMU_INVALIDATE_VADDR"}, "0x38": {"name": "NV_PFB_PRI_MMU_INVALIDATE_PDB"}, "0x3c": {"name": "NV_PFB_PRI_MMU_INVALIDATE", "fields": {"VADDR_BITS": [31, 4], "UPPER_VADDR_BITS": [19, 0], "PDB_APERTURE": [1, 1], "PDB_ADDR": [31, 4], "UPPER_PDB_ADDR": [19, 0], "PASID_VAL": [19, 0], "SIZE_VAL": [5, 0], "SIZE_GFID": [31, 26], "ALL_VA": [0, 0], "ALL_PDB": [1, 1], "HUBTLB_ONLY": [2, 2], "REPLAY": [5, 3], "SYS_MEMBAR": [6, 6], "ACK": [8, 7], "CANCEL_CLIENT_ID": [14, 9], "CANCEL_GPC_ID": [19, 15], "CANCEL_CLIENT_TYPE": [20, 20], "USE_PASID": [21, 21], "USE_SIZE": [22, 22], "PROP_FLUSH": [23, 23], "CACHE_LEVEL": [26, 24], "TRIGGER": [31, 31], "VADDR_VIRT_BITS": [31, 4], "UPPER_VADDR_VIRT_BITS": [19, 0], "PDB_VIRT_APERTURE": [1, 1], "PDB_VIRT_ADDR": [31, 4], "UPPER_PDB_VIRT_ADDR": [19, 0], "PASID_VIRT_VAL": [19, 0], "SIZE_VIRT_VAL": [5, 0], "SIZE_VIRT_GFID": [31, 26], "VIRT_ALL_VA": [0, 0], "VIRT_ALL_PDB": [1, 1], "VIRT_HUBTLB_ONLY": [2, 2], "VIRT_REPLAY": [5, 3], "VIRT_SYS_MEMBAR": [6, 6], "VIRT_ACK": [8, 7], "VIRT_CANCEL_CLIENT_ID": [14, 9], "VIRT_CANCEL_GPC_ID": [19, 15], "VIRT_CANCEL_CLIENT_TYPE": [20, 20], "VIRT_USE_PASID": [21, 21], "VIRT_USE_SIZE": [22, 22], "VIRT_PROP_FLUSH": [23, 23], "VIRT_CACHE_LEVEL": [26, 24], "VIRT_TRIGGER": [31, 31]}}, "0x40": {"name": "NV_PFB_PRI_MMU_PREFETCH"}, "0x44": {"name": "NV_PFB_PRI_MMU_DEBUG_CTRL"}, "0x48": {"name": "NV_PFB_PRI_MMU_DEBUG_WR"}, "0x4c": {"name": "NV_PFB_PRI_MMU_DEBUG_RD"}, "0x50": {"name": "NV_PFB_PRI_MMU_VPR_INFO"}, "0x54": {"name": "NV_PFB_PRI_MMU_WPR_INFO"}, "0x58": {"name": "NV_PFB_PRI_MMU_VPR_WPR_WRITE"}, "0x5c": {"name": "NV_PFB_PRI_MMU_PRIV_LEVEL_MASK", "fields": {"READ_PROTECTION": [3, 0], "READ_PROTECTION_LEVEL0": [0, 0], "READ_PROTECTION_LEVEL1": [1, 1], "READ_PROTECTION_LEVEL2": [2, 2], "READ_PROTECTION_LEVEL3": [3, 3], "WRITE_PROTECTION": [7, 4], "WRITE_PROTECTION_LEVEL0": [4, 4], "WRITE_PROTECTION_LEVEL1": [5, 5], "WRITE_PROTECTION_LEVEL2": [6, 6], "WRITE_PROTECTION_LEVEL3": [7, 7], "READ_VIOLATION": [8, 8], "WRITE_VIOLATION": [9, 9], "SOURCE_READ_CONTROL": [10, 10], "SOURCE_WRITE_CONTROL": [11, 11], "SOURCE_ENABLE": [31, 12]}}, "0x60": {"name": "NV_PFB_PRI_MMU_LOCAL_MEMORY_RANGE"}, "0x64": {"name": "NV_PFB_PRI_MMU_PHYS_SECURE"}, "0x68": {"name": "NV_PFB_PRI_MMU_INVALIDATE_UPPER_VADDR"}, "0x6c": {"name": "NV_PFB_PRI_MMU_INVALIDATE_UPPER_PDB"}, "0x70": {"name": "NV_PFB_PRI_MMU_PM_UPPER_ADDR_LOW"}, "0x74": {"name": "NV_PFB_PRI_MMU_PM_UPPER_ADDR_HIGH"}, "0x78": {"name": "NV_PFB_PRI_MMU_PAGE_FAULT_CTRL", "fields": {"PRF_FILTER": [1, 0], "VIRT_PRF_FILTER": [1, 0]}}, "0x7c": {"name": "NV_PFB_PRI_MMU_PG1"}, "0x180": {"name": "NV_PFB_PRI_MMU_ATS_ADDR_RANGE_LOCAL"}, "0x1cc": {"name": "NV_PFB_PRI_MMU_FAULT_ADDR_LO"}, "0x1d0": {"name": "NV_PFB_PRI_MMU_FAULT_ADDR_HI"}, "0x1d4": {"name": "NV_PFB_PRI_MMU_FAULT_INST_LO"}, "0x1d8": {"name": "NV_PFB_PRI_MMU_FAULT_INST_HI"}, "0x1dc": {"name": "NV_PFB_PRI_MMU_FAULT_INFO"}, "0x1e0": {"name": "NV_PFB_PRI_MMU_FAULT_STATUS", "fields": {"DROPPED_BAR1_PHYS": [0, 0], "DROPPED_BAR1_VIRT": [1, 1], "DROPPED_BAR2_PHYS": [2, 2], "DROPPED_BAR2_VIRT": [3, 3], "DROPPED_IFB_PHYS": [4, 4], "DROPPED_IFB_VIRT": [5, 5], "DROPPED_OTHER_PHYS": [6, 6], "DROPPED_OTHER_VIRT": [7, 7], "REPLAYABLE": [8, 8], "NON_REPLAYABLE": [9, 9], "REPLAYABLE_ERROR": [10, 10], "NON_REPLAYABLE_ERROR": [11, 11], "REPLAYABLE_OVERFLOW": [12, 12], "NON_REPLAYABLE_OVERFLOW": [13, 13], "REPLAYABLE_GETPTR_CORRUPTED": [14, 14], "NON_REPLAYABLE_GETPTR_CORRUPTED": [15, 15], "BUSY_TYPE": [29, 28], "BUSY": [30, 30], "VALID": [31, 31], "VIRT_DROPPED_BAR1_PHYS": [0, 0], "VIRT_DROPPED_BAR1_VIRT": [1, 1], "VIRT_DROPPED_BAR2_PHYS": [2, 2], "VIRT_DROPPED_BAR2_VIRT": [3, 3], "VIRT_DROPPED_IFB_PHYS": [4, 4], "VIRT_DROPPED_IFB_VIRT": [5, 5], "VIRT_DROPPED_OTHER_PHYS": [6, 6], "VIRT_DROPPED_OTHER_VIRT": [7, 7], "VIRT_REPLAYABLE": [8, 8], "VIRT_NON_REPLAYABLE": [9, 9], "VIRT_REPLAYABLE_ERROR": [10, 10], "VIRT_NON_REPLAYABLE_ERROR": [11, 11], "VIRT_REPLAYABLE_OVERFLOW": [12, 12], "VIRT_NON_REPLAYABLE_OVERFLOW": [13, 13], "VIRT_REPLAYABLE_GETPTR_CORRUPTED": [14, 14], "VIRT_NON_REPLAYABLE_GETPTR_CORRUPTED": [15, 15], "VIRT_BUSY_TYPE": [29, 28], "VIRT_BUSY": [30, 30], "VIRT_VALID": [31, 31]}}, "0x1e4": {"name": "NV_PFB_PRI_MMU_INVALIDATE_PASID"}, "0x1e8": {"name": "NV_PFB_PRI_MMU_INVALIDATE_SIZE"}, "0x1ec": {"name": "NV_PFB_PRI_MMU_L2TLB_ECC_CONTROL"}, "0x1f0": {"name": "NV_PFB_PRI_MMU_L2TLB_ECC_STATUS"}, "0x1f4": {"name": "NV_PFB_PRI_MMU_L2TLB_ECC_CORRECTED_ERR_COUNT"}, "0x1f8": {"name": "NV_PFB_PRI_MMU_L2TLB_ECC_UNCORRECTED_ERR_COUNT"}, "0x1fc": {"name": "NV_PFB_PRI_MMU_L2TLB_ECC_ADDRESS"}, "0x200": {"name": "NV_PFB_PRI_MMU_HUBTLB_ECC_CONTROL"}, "0x204": {"name": "NV_PFB_PRI_MMU_HUBTLB_ECC_STATUS"}, "0x208": {"name": "NV_PFB_PRI_MMU_HUBTLB_ECC_CORRECTED_ERR_COUNT"}, "0x20c": {"name": "NV_PFB_PRI_MMU_HUBTLB_ECC_UNCORRECTED_ERR_COUNT"}, "0x210": {"name": "NV_PFB_PRI_MMU_HUBTLB_ECC_ADDRESS"}, "0x214": {"name": "NV_PFB_PRI_MMU_FILLUNIT_ECC_CONTROL"}, "0x218": {"name": "NV_PFB_PRI_MMU_FILLUNIT_ECC_STATUS"}, "0x21c": {"name": "NV_PFB_PRI_MMU_FILLUNIT_ECC_CORRECTED_ERR_COUNT"}, "0x220": {"name": "NV_PFB_PRI_MMU_FILLUNIT_ECC_UNCORRECTED_ERR_COUNT"}, "0x224": {"name": "NV_PFB_PRI_MMU_FILLUNIT_ECC_ADDRESS"}, "0x228": {"name": "NV_PFB_PRI_MMU_HUBMMU_GLOBAL_ESR"}, "0x22c": {"name": "NV_PFB_PRI_MMU_ECC_PRIV_LEVEL_MASK"}, "0x234": {"name": "NV_PFB_PRI_MMU_CBC_ADJ"}, "0x238": {"name": "NV_PFB_PRI_MMU_CBC_ADJ_PRIV_LEVEL_MASK"}, "0x23c": {"name": "NV_PFB_PRI_MMU_CBC_PRIV_LEVEL_MASK"}, "0x240": {"name": "NV_PFB_PRI_MMU_NUM_ACTIVE_LTCS"}, "0x244": {"name": "NV_PFB_PRI_MMU_CBC_BASE"}, "0x248": {"name": "NV_PFB_PRI_MMU_CBC_TOP"}, "0x24c": {"name": "NV_PFB_PRI_MMU_CBC_MAX"}, "0x250": {"name": "NV_PFB_PRI_MMU_HYPERVISOR_CTL"}, "0x254": {"name": "NV_PFB_PRI_MMU_PEER_SUPPORT"}, "0x258": {"name": "NV_PFB_PRI_MMU_BIND_EXT"}, "0x25c": {"name": "NV_PFB_PRI_MMU_INT_VECTOR_ECC_ERROR"}, "0x260": {"name": "NV_PFB_PRI_MMU_INT_VECTOR_INFO_FAULT"}, "0x27c": {"name": "NV_PFB_PRI_MMU_PHYS_SECURE_PRIV_LEVEL_MASK"}}}, + {"base": "0x20000000001FA7D8", "size": "0x5C", "label": "gmmu-wpr-lock", "registers": {"0x0": {"name": "NV_PFB_PRI_MMU_PMU_PRIV_LEVEL_MASK"}, "0x4": {"name": "NV_PFB_PRI_MMU_SEC_PRIV_LEVEL_MASK"}, "0x8": {"name": "NV_PFB_PRI_MMU_NVDEC_PRIV_LEVEL_MASK"}, "0xc": {"name": "NV_PFB_PRI_MMU_NVENC_PRIV_LEVEL_MASK"}, "0x10": {"name": "NV_PFB_PRI_MMU_NVJPG_PRIV_LEVEL_MASK"}, "0x14": {"name": "NV_PFB_PRI_MMU_GSP_PRIV_LEVEL_MASK"}, "0x18": {"name": "NV_PFB_PRI_MMU_FECS_PRIV_LEVEL_MASK"}, "0x1c": {"name": "NV_PFB_PRI_MMU_GPCCS_PRIV_LEVEL_MASK"}, "0x20": {"name": "NV_PFB_PRI_MMU_FBFALCON_PRIV_LEVEL_MASK"}, "0x28": {"name": "NV_PFB_PRI_MMU_VPR_MODE", "fields": {"VPR_PROTECTED_MODE": [4, 4], "MEMORY_LOCKED": [5, 5], "VPR_NON_COMPRESSIBLE": [6, 6], "SYSWR_WHEN_MEMORY_LOCKED": [7, 7], "MEMORY_LOCK_FUSE": [8, 8], "WPR_COMPRESSIBLE": [9, 9], "SECURE_LOCK": [12, 12], "WPR_WITH_SECURE_LOCK": [13, 13], "FORCE_SPA_EQUAL_GPA": [20, 20], "VPR_FAULT_REPORT": [29, 29], "VPR_LOCK_HWPM": [30, 30], "VPR_LOCK_MISC": [28, 28]}}, "0x2c": {"name": "NV_PFB_PRI_MMU_VPR_ADDR_LO"}, "0x30": {"name": "NV_PFB_PRI_MMU_VPR_ADDR_HI"}, "0x34": {"name": "NV_PFB_PRI_MMU_VPR_CYA_LO"}, "0x38": {"name": "NV_PFB_PRI_MMU_VPR_CYA_HI"}, "0x3c": {"name": "NV_PFB_PRI_MMU_WPR_ALLOW_READ", "fields": {"ALL": [11, 4], "WPR1": [7, 4], "WPR1_SECURE0": [4, 4], "WPR1_SECURE1": [5, 5], "WPR1_SECURE2": [6, 6], "WPR1_SECURE3": [7, 7], "WPR2": [11, 8], "WPR2_SECURE0": [8, 8], "WPR2_SECURE1": [9, 9], "WPR2_SECURE2": [10, 10], "WPR2_SECURE3": [11, 11], "WPR_OK": [27, 27], "MIS_MATCH": [31, 28], "MIS_MATCH0": [28, 28], "MIS_MATCH1": [29, 29], "MIS_MATCH2": [30, 30], "MIS_MATCH3": [31, 31]}}, "0x40": {"name": "NV_PFB_PRI_MMU_WPR_ALLOW_WRITE", "fields": {"ALL": [11, 4], "WPR1": [7, 4], "WPR1_SECURE0": [4, 4], "WPR1_SECURE1": [5, 5], "WPR1_SECURE2": [6, 6], "WPR1_SECURE3": [7, 7], "WPR2": [11, 8], "WPR2_SECURE0": [8, 8], "WPR2_SECURE1": [9, 9], "WPR2_SECURE2": [10, 10], "WPR2_SECURE3": [11, 11], "WPR_ENABLE_FUSE": [27, 27], "MIS_MATCH": [31, 28], "MIS_MATCH0": [28, 28], "MIS_MATCH1": [29, 29], "MIS_MATCH2": [30, 30], "MIS_MATCH3": [31, 31]}}, "0x44": {"name": "NV_PFB_PRI_MMU_WPR1_ADDR_LO"}, "0x48": {"name": "NV_PFB_PRI_MMU_WPR1_ADDR_HI"}, "0x4c": {"name": "NV_PFB_PRI_MMU_WPR2_ADDR_LO"}, "0x50": {"name": "NV_PFB_PRI_MMU_WPR2_ADDR_HI"}, "0x54": {"name": "NV_PFB_PRI_MMU_LOCK_ADDR_LO"}, "0x58": {"name": "NV_PFB_PRI_MMU_LOCK_ADDR_HI"}}}, + {"base": "0x20000000001FAC00", "size": "0x400", "label": "gmmu-hsmmu", "registers": {"0x80": {"name": "NV_PFB_HSMMU_PRI_MMU_CTRL", "fields": {"VM_PG_SIZE": [0, 0], "VOL_FAULT": [1, 1], "COMP_FAULT": [2, 2], "MISS_GRAN": [4, 3], "CACHE_MODE": [6, 5], "L2_SEND_MODE": [9, 9], "USE_FULL_COMP_TAG_LINE": [12, 12], "ATOMIC_CAPABILITY_MODE": [25, 24], "STRONG_BIT_ATOMIC_CYA": [26, 26], "STRONG_BIT_OVERRIDE_ATOMIC_DISABLE": [26, 26]}}, "0x84": {"name": "NV_PFB_HSMMU_PRI_MMU_PM"}, "0x88": {"name": "NV_PFB_HSMMU_PRI_MMU_PM_ADDR_LOW"}, "0x8c": {"name": "NV_PFB_HSMMU_PRI_MMU_PM_UPPER_ADDR_LOW"}, "0x90": {"name": "NV_PFB_HSMMU_PRI_MMU_PM_ADDR_HIGH"}, "0x94": {"name": "NV_PFB_HSMMU_PRI_MMU_PM_UPPER_ADDR_HIGH"}, "0x98": {"name": "NV_PFB_HSMMU_PRI_MMU_PM_UNIT_MASK"}, "0x9c": {"name": "NV_PFB_HSMMU_PRI_MMU_PM_REQ_MASK"}, "0xa0": {"name": "NV_PFB_HSMMU_PRI_MMU_TLB_CTRL", "fields": {"UTLB_SIZE": [1, 0], "UTLB_RB_SIZE": [3, 2], "UTLB_FAST_PATH_DISABLE": [4, 4], "L1TLB_SIZE": [17, 16], "L1TLB_FA_SIZE": [19, 18], "L1TLB_RB_SIZE": [21, 20], "L1TLB_SET_INDEX_BIG_PG": [23, 22], "L1TLB_SET_INDEX_SMALL_PG": [25, 24], "L1TLB_FAST_PATH_DISABLE": [26, 26], "SECURE_LOCK": [30, 30], "FAST_BIG_PG_FAULT_DISABLE": [31, 31]}}, "0xa4": {"name": "NV_PFB_HSMMU_PRI_MMU_PREFETCH"}, "0xb0": {"name": "NV_PFB_HSMMU_PRI_MMU_CG"}, "0xb4": {"name": "NV_PFB_HSMMU_PRI_MMU_CG1"}, "0xb8": {"name": "NV_PFB_HSMMU_PRI_MMU_PG"}, "0xc0": {"name": "NV_PFB_HSMMU_PRI_MMU_PG1"}, "0xc4": {"name": "NV_PFB_HSMMU_PRI_MMU_DEBUG_CTRL"}, "0xc8": {"name": "NV_PFB_HSMMU_PRI_MMU_DEBUG_WR"}, "0xcc": {"name": "NV_PFB_HSMMU_PRI_MMU_DEBUG_RD"}}} + ], + "note": "gmmu-pri-mmu spans the NV_PFB_PRI_MMU_* cluster 0x100C80-0x100EFC (interleaved PFB PRI_FB/NISO offsets inside the span stay unclassified); gmmu-wpr-lock overlaps the fwsec WPR2 window at 0x1FA824/0x1FA828 and just declines, so the access follows exactly the same underlying path as a run without the observer (observed by tests, not an assertion that the WPR2 path serves)." + }, + "fbif": { + "evidence": "FBIF/FBPA register surface: NV_PFB_FBPA block (0x009A0000-0x009A3FFF), the 16 per-partition FBPA instances (0x00900000 + i*0x4000) and the 3 MC instances (0x00980000 + i*0x4000) from the hardware register/field headers. Observe-only: FB access-path, compression and ECC semantics are not modelled; no read value is invented.", + "mode": "observe-only", + "registers": { + "0x0": { + "name": "NV_PFB_FBPA_PRI_FBPA_CG" + }, + "0x4": { + "name": "NV_PFB_FBPA_PRI_FBPA_CG1" + }, + "0x8": { + "name": "NV_PFB_FBPA_PRI_FBPA_PG" + }, + "0xc": { + "name": "NV_PFB_FBPA_PRI_FBPA_PG1" + }, + "0x50": { + "name": "NV_PFB_FBPA_SPARE" + }, + "0x54": { + "name": "NV_PFB_FBPA_FALCON_MONITOR" + }, + "0x60": { + "name": "NV_PFB_FBPA_CFG_PWRD" + }, + "0x64": { + "name": "NV_PFB_FBPA_FALCON_CTRL_VER" + }, + "0x80": { + "name": "NV_PFB_FBPA_DEBUG_0" + }, + "0x84": { + "name": "NV_PFB_FBPA_DEBUG_1" + }, + "0x90": { + "name": "NV_PFB_FBPA_TESTCMD", + "fields": { + "CKE": [ + 0, + 0 + ], + "CS0": [ + 1, + 1 + ], + "CS1": [ + 2, + 2 + ], + "RAS": [ + 3, + 3 + ], + "CAS": [ + 4, + 4 + ], + "WE": [ + 5, + 5 + ], + "RES": [ + 6, + 6 + ], + "BANK_4": [ + 7, + 7 + ], + "BANK": [ + 11, + 8 + ], + "ADR": [ + 25, + 12 + ], + "ADR_UPPER": [ + 28, + 26 + ], + "GO": [ + 30, + 30 + ], + "HOLD": [ + 31, + 31 + ], + "EXT_ACT": [ + 0, + 0 + ], + "EXT_VLD_DRAM0": [ + 1, + 1 + ], + "EXT_VLD_DRAM1": [ + 2, + 2 + ], + "EXT_ADR_DRAM1": [ + 10, + 3 + ], + "EXT_RW_CMD": [ + 16, + 16 + ], + "EXT_MRS_CMD": [ + 17, + 17 + ], + "EXT_DR_CMD": [ + 18, + 18 + ], + "EXT_4CK_CMD": [ + 19, + 19 + ], + "EXT_LAM_CMD": [ + 20, + 20 + ], + "EXT_1_ADR": [ + 23, + 0 + ], + "EXT_1_CSB0": [ + 27, + 27 + ], + "EXT_1_CSB1": [ + 28, + 28 + ], + "EXT_1_USE_ADR": [ + 29, + 29 + ] + } + }, + "0x94": { + "name": "NV_PFB_FBPA_TESTCMD_EXT" + }, + "0xa0": { + "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP0BYTE0" + }, + "0xa4": { + "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP0BYTE1" + }, + "0xa8": { + "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP0BYTE2" + }, + "0xac": { + "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP0BYTE3" + }, + "0xb0": { + "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP1BYTE0" + }, + "0xb4": { + "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP1BYTE1" + }, + "0xb8": { + "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP1BYTE2" + }, + "0xbc": { + "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP1BYTE3" + }, + "0xc0": { + "name": "NV_PFB_FBPA_SWIZZLE_DBI" + }, + "0xc4": { + "name": "NV_PFB_FBPA_SWIZZLE_BYTE" + }, + "0xc8": { + "name": "NV_PFB_FBPA_SWIZZLE_ECC" + }, + "0xcc": { + "name": "NV_PFB_FBPA_SPARE2" + }, + "0xd0": { + "name": "NV_PFB_FBPA_REPLAY_RETRY_SUBP0" + }, + "0xd4": { + "name": "NV_PFB_FBPA_REPLAY_RETRY_SUBP1" + }, + "0xd8": { + "name": "NV_PFB_FBPA_REPLAY_CYCLES_SUBP0" + }, + "0xdc": { + "name": "NV_PFB_FBPA_REPLAY_CYCLES_SUBP1" + }, + "0x100": { + "name": "NV_PFB_FBPA_PM" + }, + "0x148": { + "name": "NV_PFB_FBPA_HISEC_PRIV_LEVEL_MASK", + "fields": { + "READ_PROTECTION": [ + 3, + 0 + ], + "READ_PROTECTION_LEVEL0": [ + 0, + 0 + ], + "READ_PROTECTION_LEVEL1": [ + 1, + 1 + ], + "READ_PROTECTION_LEVEL2": [ + 2, + 2 + ], + "READ_PROTECTION_LEVEL3": [ + 3, + 3 + ], + "WRITE_PROTECTION": [ + 7, + 4 + ], + "WRITE_PROTECTION_LEVEL0": [ + 4, + 4 + ], + "WRITE_PROTECTION_LEVEL1": [ + 5, + 5 + ], + "WRITE_PROTECTION_LEVEL2": [ + 6, + 6 + ], + "WRITE_PROTECTION_LEVEL3": [ + 7, + 7 + ], + "READ_VIOLATION": [ + 8, + 8 + ], + "WRITE_VIOLATION": [ + 9, + 9 + ], + "SOURCE_READ_CONTROL": [ + 10, + 10 + ], + "SOURCE_WRITE_CONTROL": [ + 11, + 11 + ], + "SOURCE_ENABLE": [ + 31, + 12 + ] + } + }, + "0x150": { + "name": "NV_PFB_FBPA_ASRCTRL_NONISO" + }, + "0x154": { + "name": "NV_PFB_FBPA_TESTCMD_EXT_1" + }, + "0x158": { + "name": "NV_PFB_FBPA_PTR_RESET" + }, + "0x15c": { + "name": "NV_PFB_FBPA_CONFIG12" + }, + "0x160": { + "name": "NV_PFB_FBPA_MISC_CTRL", + "fields": { + "INTERVAL_TIMER_PERIOD": [ + 2, + 0 + ], + "INTERVAL_TIMER_EN": [ + 4, + 4 + ] + } + }, + "0x164": { + "name": "NV_PFB_FBPA_NUM_ACTIVE_FBPS" + }, + "0x168": { + "name": "NV_PFB_FBPA_MEM_PRIV_LEVEL_MASK", + "fields": { + "READ_PROTECTION": [ + 3, + 0 + ], + "READ_PROTECTION_LEVEL0": [ + 0, + 0 + ], + "READ_PROTECTION_LEVEL1": [ + 1, + 1 + ], + "READ_PROTECTION_LEVEL2": [ + 2, + 2 + ], + "READ_PROTECTION_LEVEL3": [ + 3, + 3 + ], + "WRITE_PROTECTION": [ + 7, + 4 + ], + "WRITE_PROTECTION_LEVEL0": [ + 4, + 4 + ], + "WRITE_PROTECTION_LEVEL1": [ + 5, + 5 + ], + "WRITE_PROTECTION_LEVEL2": [ + 6, + 6 + ], + "WRITE_PROTECTION_LEVEL3": [ + 7, + 7 + ], + "READ_VIOLATION": [ + 8, + 8 + ], + "WRITE_VIOLATION": [ + 9, + 9 + ], + "SOURCE_READ_CONTROL": [ + 10, + 10 + ], + "SOURCE_WRITE_CONTROL": [ + 11, + 11 + ], + "SOURCE_ENABLE": [ + 31, + 12 + ] + } + }, + "0x16c": { + "name": "NV_PFB_FBPA_NUM_ACTIVE_LTCS" + }, + "0x170": { + "name": "NV_PFB_FBPA_ECC_PRIV_LEVEL_MASK", + "fields": { + "READ_PROTECTION": [ + 3, + 0 + ], + "READ_PROTECTION_LEVEL0": [ + 0, + 0 + ], + "READ_PROTECTION_LEVEL1": [ + 1, + 1 + ], + "READ_PROTECTION_LEVEL2": [ + 2, + 2 + ], + "READ_PROTECTION_LEVEL3": [ + 3, + 3 + ], + "WRITE_PROTECTION": [ + 7, + 4 + ], + "WRITE_PROTECTION_LEVEL0": [ + 4, + 4 + ], + "WRITE_PROTECTION_LEVEL1": [ + 5, + 5 + ], + "WRITE_PROTECTION_LEVEL2": [ + 6, + 6 + ], + "WRITE_PROTECTION_LEVEL3": [ + 7, + 7 + ], + "READ_VIOLATION": [ + 8, + 8 + ], + "WRITE_VIOLATION": [ + 9, + 9 + ], + "SOURCE_READ_CONTROL": [ + 10, + 10 + ], + "SOURCE_WRITE_CONTROL": [ + 11, + 11 + ], + "SOURCE_ENABLE": [ + 31, + 12 + ] + } + }, + "0x174": { + "name": "NV_PFB_FBPA_WPRE0" + }, + "0x178": { + "name": "NV_PFB_FBPA_WPRE1" + }, + "0x17c": { + "name": "NV_PFB_FBPA_WPRE2" + }, + "0x1d8": { + "name": "NV_PFB_FBPA_PM_CAPTURE_CTL" + }, + "0x200": { + "name": "NV_PFB_FBPA_CFG0", + "fields": { + "BL": [ + 1, + 0 + ], + "EXTBANK": [ + 2, + 2 + ], + "X8": [ + 4, + 4 + ], + "CA_RETURN_TO_ZERO": [ + 5, + 5 + ], + "HBM_CMD_PARITY_OVERRIDE": [ + 6, + 6 + ], + "EDC_RATE": [ + 7, + 7 + ], + "GAPLESS_WTR": [ + 8, + 8 + ], + "DRAM_ACPD": [ + 11, + 11 + ], + "AUTO_ATR": [ + 12, + 12 + ], + "DATA_SCRAMBLE": [ + 13, + 13 + ], + "AUTO_SYNC": [ + 14, + 14 + ], + "LOW_POWER": [ + 15, + 15 + ], + "ABI": [ + 16, + 16 + ], + "BURST_LENGTH": [ + 17, + 17 + ], + "DM_ON_ADR": [ + 18, + 18 + ], + "TIMING_UNIT": [ + 19, + 19 + ], + "CMD_PARITY": [ + 20, + 20 + ], + "AUTO_ODT": [ + 21, + 21 + ], + "CLAMSHELL": [ + 22, + 22 + ], + "EDC_EARLY_VALUE": [ + 23, + 23 + ], + "BG_8": [ + 24, + 24 + ], + "AUTO_PRE": [ + 25, + 25 + ], + "SAME_QUSE_TWO_RANKS": [ + 26, + 26 + ], + "RDEDC": [ + 27, + 27 + ], + "WREDC": [ + 28, + 28 + ], + "REPLAY": [ + 29, + 29 + ], + "MS01_ECC": [ + 30, + 30 + ], + "DRAM_ACK": [ + 31, + 31 + ] + } + }, + "0x204": { + "name": "NV_PFB_FBPA_CFG1", + "fields": { + "SUBP": [ + 1, + 0 + ], + "AP": [ + 6, + 4 + ], + "COL": [ + 15, + 12 + ], + "ROWA": [ + 19, + 16 + ], + "ROWB": [ + 23, + 20 + ], + "BANK": [ + 25, + 24 + ], + "BG_8_OVR": [ + 26, + 26 + ], + "NUM_BANKS_PER_BG": [ + 28, + 27 + ], + "NUM_BANKS_PER_ROW_BG": [ + 30, + 29 + ] + } + }, + "0x20c": { + "name": "NV_PFB_FBPA_CSTATUS" + }, + "0x210": { + "name": "NV_PFB_FBPA_REFCTRL", + "fields": { + "PUT": [ + 6, + 0 + ], + "GET": [ + 14, + 8 + ], + "RESET": [ + 16, + 16 + ], + "VALID": [ + 31, + 31 + ] + } + }, + "0x214": { + "name": "NV_PFB_FBPA_ZQ" + }, + "0x218": { + "name": "NV_PFB_FBPA_PIN" + }, + "0x21c": { + "name": "NV_PFB_FBPA_PAD" + }, + "0x220": { + "name": "NV_PFB_FBPA_TIMING0" + }, + "0x224": { + "name": "NV_PFB_FBPA_TIMING1" + }, + "0x228": { + "name": "NV_PFB_FBPA_TIMING2" + }, + "0x22c": { + "name": "NV_PFB_FBPA_TIMING3" + }, + "0x230": { + "name": "NV_PFB_FBPA_TIMING4" + }, + "0x234": { + "name": "NV_PFB_FBPA_TIMING5" + }, + "0x238": { + "name": "NV_PFB_FBPA_TIMING6" + }, + "0x23c": { + "name": "NV_PFB_FBPA_TIMING7" + }, + "0x240": { + "name": "NV_PFB_FBPA_TIMING8" + }, + "0x244": { + "name": "NV_PFB_FBPA_TIMING9" + }, + "0x248": { + "name": "NV_PFB_FBPA_TIMING10" + }, + "0x24c": { + "name": "NV_PFB_FBPA_TIMING11" + }, + "0x250": { + "name": "NV_PFB_FBPA_TIMING12" + }, + "0x254": { + "name": "NV_PFB_FBPA_TIMING13" + }, + "0x258": { + "name": "NV_PFB_FBPA_TIMING14" + }, + "0x25c": { + "name": "NV_PFB_FBPA_TIMING15" + }, + "0x260": { + "name": "NV_PFB_FBPA_TIMING16" + }, + "0x264": { + "name": "NV_PFB_FBPA_TIMING17" + }, + "0x268": { + "name": "NV_PFB_FBPA_TIMING18" + }, + "0x26c": { + "name": "NV_PFB_FBPA_TIMING19" + }, + "0x280": { + "name": "NV_PFB_FBPA_CG1" + }, + "0x284": { + "name": "NV_PFB_FBPA_CG2" + }, + "0x288": { + "name": "NV_PFB_FBPA_TIMING20_GEN" + }, + "0x28c": { + "name": "NV_PFB_FBPA_TIMING20" + }, + "0x290": { + "name": "NV_PFB_FBPA_CONFIG0" + }, + "0x294": { + "name": "NV_PFB_FBPA_CONFIG1" + }, + "0x298": { + "name": "NV_PFB_FBPA_CONFIG2" + }, + "0x29c": { + "name": "NV_PFB_FBPA_CONFIG3" + }, + "0x2a0": { + "name": "NV_PFB_FBPA_CONFIG4" + }, + "0x2a4": { + "name": "NV_PFB_FBPA_CONFIG5" + }, + "0x2a8": { + "name": "NV_PFB_FBPA_CONFIG6" + }, + "0x2ac": { + "name": "NV_PFB_FBPA_CONFIG7" + }, + "0x2b0": { + "name": "NV_PFB_FBPA_TIMING0_GEN" + }, + "0x2b4": { + "name": "NV_PFB_FBPA_TIMING1_GEN" + }, + "0x2b8": { + "name": "NV_PFB_FBPA_TIMING2_GEN" + }, + "0x2bc": { + "name": "NV_PFB_FBPA_TIMING3_GEN" + }, + "0x2c0": { + "name": "NV_PFB_FBPA_TIMING4_GEN" + }, + "0x2c4": { + "name": "NV_PFB_FBPA_TIMING5_GEN" + }, + "0x2c8": { + "name": "NV_PFB_FBPA_TIMING6_GEN" + }, + "0x2cc": { + "name": "NV_PFB_FBPA_CONFIG8" + }, + "0x2d0": { + "name": "NV_PFB_FBPA_TIMING7_GEN" + }, + "0x2d4": { + "name": "NV_PFB_FBPA_TIMING8_GEN" + }, + "0x2d8": { + "name": "NV_PFB_FBPA_TIMING9_GEN" + }, + "0x2dc": { + "name": "NV_PFB_FBPA_TIMING15_GEN" + }, + "0x2e0": { + "name": "NV_PFB_FBPA_TIMING16_GEN" + }, + "0x2e4": { + "name": "NV_PFB_FBPA_TIMING17_GEN" + }, + "0x2e8": { + "name": "NV_PFB_FBPA_CONFIG9" + }, + "0x2ec": { + "name": "NV_PFB_FBPA_TIMING18_GEN" + }, + "0x2f0": { + "name": "NV_PFB_FBPA_TIMING19_GEN" + }, + "0x2f4": { + "name": "NV_PFB_FBPA_CONFIG10" + }, + "0x2f8": { + "name": "NV_PFB_FBPA_ASR_WAKEUP" + }, + "0x2fc": { + "name": "NV_PFB_FBPA_DRAM_ASR" + }, + "0x300": { + "name": "NV_PFB_FBPA_GENERIC_MRS" + } + }, + "windows": [ + {"base": "0x20000000009A0000", "size": "0x4000", "label": "fbpa"}, + {"base": "0x2000000000900000", "size": "0x4000", "label": "fbpa-partition", "count": 16, "stride": "0x4000"}, + {"base": "0x2000000000980000", "size": "0x4000", "label": "fbpa-mc", "count": 3, "stride": "0x4000"} + ] + }, "dma": { "evidence": "GSP RISC-V address map: SYSGPA/FBGPA apertures. In this flat model both apertures alias simulator RAM (documented assumption) so booter-style DMA and firmware aperture reads resolve; unmapped transfers are refused and recorded.", "windows": [ @@ -80,7 +882,7 @@ "boot_shim": { "note": "Reset-time state the real SEC2 booter/host provides. Every value here is an assumption recorded in the session; the device model never fakes a register read.", "registers": { - "a0": "0x80000", + "a0": "0x81000", "a1": "0x80000", "a2": "0x4000000", "a3": "0x1b34ea0", @@ -92,7 +894,16 @@ "core_dump_pa": "0x40000", "core_dump_size": "0x10000", "wpr_meta_pa": "0x90000", - "image_at": "0x100000000", + "bootstrap_args": { + "pa": "0x81000", + "imem_base_pa": "0x0", + "imem_size": "0x10000", + "imem_bs_size": "0x0", + "dmem_base_pa": "0x100000000", + "dmem_size": "0x10000", + "dmem_bs_size": "0x0", + "evidence": "libos bootstrap-args layout (IMEM/DMEM base+size+bs, passed in a0 by the bootloader); the M-mode boot path (mstatus.VM=NVMPU, observed) uses the non-sep-kern TCM configuration (offsets 0, 64 KiB). Values are declared assumptions, not silicon reads." + }, "wpr": { "elf_pa": "0x4000000", "elf_size": "0x1b34ea0", diff --git a/debug/risc-v-gsp-rm-vm/tests/test_gmmu_debug.py b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_debug.py new file mode 100644 index 0000000..d9046be --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_debug.py @@ -0,0 +1,484 @@ +"""Tests for the GMMU/FBIF static<->dynamic join (harness/gmmu_debug.py, docs/44). + +All fixtures are synthetic: no firmware, no simulator run, no QEMU, no +hardware. The end-to-end demo test builds its own fixture in a temporary +directory and checks the frozen threshold result. + +Run:: + + python3 -m unittest tests.test_gmmu_debug -v +""" +from __future__ import annotations + +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOL_ROOT = os.path.abspath(os.path.join(HERE, "..")) +if TOOL_ROOT not in sys.path: + sys.path.insert(0, TOOL_ROOT) + +from harness.gmmu_debug import ( # noqa: E402 + DEBUG_REPORT_FORMAT, + HITS_HEADER, + MANUAL_STATIC_BASELINE_SECONDS, + TRACE_FORMAT, + WORKFLOW_THRESHOLD_FRACTION, + GmmuInputError, + build_debug_report, + load_sim_trace, + load_symbols, + main, + run_demo, + run_report, + write_hits_tsv, +) + + +def _write_json(path: str, payload) -> str: + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh) + return path + + +def _symbols_json(path: str) -> str: + return _write_json( + path, + { + "format": "greeneye-symbols/1", + "symbols": [ + {"addr": 0x4001000, "name": "gmmu_map_alloc", "size": 0x120}, + {"addr": 0x4002000, "name": "fbif_setup", "size": 0x80}, + ], + }, + ) + + +def _events() -> list[dict]: + return [ + {"seq": 0, "pc": "0x4002040", "kind": "write", "window": "falcon", + "offset": "0x624", "name": "FBIF_CTL", "value": "0x1"}, + {"seq": 1, "pc": "0x4002050", "kind": "read", "window": "falcon", + "offset": "0x8", "name": "IRQSTAT", "value": "0x0"}, + {"seq": 2, "pc": "0x4001010", "kind": "write", "window": "gmmu", + "offset": "0x0", "name": "GMMU_PDB_LO", "value": "0x400000"}, + {"seq": 3, "pc": "0x4001020", "kind": "write", "window": "gmmu", + "offset": "0x0", "name": "GMMU_PDB_LO", "value": "0x800000"}, + {"seq": 4, "pc": "0x4009000", "kind": "write", "window": "gmmu", + "offset": "0x10", "name": "GMMU_MODE", "value": "0x7"}, + ] + + +def _g1_document() -> dict: + """The nested shape emitted by sim/devices/observe.py:build_trace.""" + return { + "schema": TRACE_FORMAT, + "image_sha256": "ab" * 32, + "profile": "tu104-570.144", + "profile_name": "fixture", + "symbols": "symbols.json", + "summary": {"windows": 1, "accesses": 2, "retained_accesses": 2, + "dropped_accesses": 0}, + "windows": [ + { + "label": "falcon-observe", + "base": "0x300110000", + "size": "0x1000", + "mode": "observe-only", + "evidence": "header", + "counts": {"r": 1, "w": 1, "served": 0, "declined": 2}, + "registers": [ + { + "offset": "0x624", + "name": "FBIF_CTL", + "access": "rw", + "counts": {"r": 0, "w": 1, "served": 0}, + "accesses": [ + {"addr": "0x300110624", "offset": "0x624", "size": 4, + "rw": "w", "value": "0x1", "pc": "0x4002040", "vt": 123, + "window": "falcon-observe", "register": "FBIF_CTL", + "served": False, "pc_symbol": "fbif_setup"}, + ], + } + ], + "unclassified": { + "counts": {"r": 1, "w": 0, "served": 0}, + "accesses": [ + {"addr": "0x300110008", "offset": "0x8", "size": 4, + "rw": "r", "value": None, "pc": "0x4002050", "vt": 123, + "window": "falcon-observe", "register": None, + "served": False, "pc_symbol": "fbif_setup"}, + ], + }, + } + ], + } + + +def _run_main(argv: list[str]) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + rc = main(argv) + return rc, out.getvalue(), err.getvalue() + + +class TraceIngestTests(unittest.TestCase): + def test_trace_tagged_and_aliases(self): + with tempfile.TemporaryDirectory() as tmp: + path = _write_json( + os.path.join(tmp, "t.json"), + { + "format": TRACE_FORMAT, + "events": [ + {"pc": 0x4002040, "rw": "w", "block": "falcon", + "reg_offset": 0x624, "register": "FBIF_CTL", "data": 1}, + ], + }, + ) + trace = load_sim_trace(path) + self.assertEqual(trace.format, TRACE_FORMAT) + obs = trace.observations[0] + self.assertEqual((obs.kind, obs.window, obs.offset, obs.name, obs.value), + ("write", "falcon", 0x624, "FBIF_CTL", 1)) + + def test_jsonl_fallback(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "t.jsonl") + with open(path, "w", encoding="utf-8") as fh: + fh.write(json.dumps({"format": TRACE_FORMAT}) + "\n") + fh.write(json.dumps({"t": "write", "addr": "0x624", "size": 4, "pc": "0x4002040"}) + "\n") + fh.write(json.dumps({"t": "summary", "insns": 1}) + "\n") + trace = load_sim_trace(path) + self.assertEqual(len(trace.observations), 1) + self.assertEqual(trace.observations[0].addr, 0x624) + + def test_empty_event_list_is_legal(self): + with tempfile.TemporaryDirectory() as tmp: + path = _write_json(os.path.join(tmp, "t.json"), {"format": TRACE_FORMAT, "events": []}) + trace = load_sim_trace(path) + self.assertEqual(trace.observations, []) + + def test_untagged_object_without_events_is_clean_error(self): + with tempfile.TemporaryDirectory() as tmp: + path = _write_json(os.path.join(tmp, "t.json"), {"hello": "world"}) + with self.assertRaises(GmmuInputError): + load_sim_trace(path) + + def test_missing_trace_is_clean_error(self): + with self.assertRaises(GmmuInputError): + load_sim_trace("/does/not/exist.json") + + def test_session_directory_ingest(self): + with tempfile.TemporaryDirectory() as tmp: + blobs = os.path.join(tmp, "blobs") + os.makedirs(blobs) + _write_json(os.path.join(tmp, "session.json"), {"format": "greeneye-session/1"}) + _write_json( + os.path.join(blobs, "gmmu-fbif-trace.json"), + {"format": TRACE_FORMAT, "events": _events()}, + ) + trace = load_sim_trace(tmp) + self.assertEqual(len(trace.observations), len(_events())) + + def test_g1_nested_document_flattens(self): + with tempfile.TemporaryDirectory() as tmp: + path = _write_json(os.path.join(tmp, "t.json"), _g1_document()) + trace = load_sim_trace(path) + self.assertEqual(trace.format, TRACE_FORMAT) + self.assertEqual(len(trace.observations), 2) + self.assertEqual(trace.source["profile"], "tu104-570.144") + self.assertEqual(trace.source["summary"]["dropped_accesses"], 0) + self.assertEqual(trace.source["summary"]["retained_accesses"], 2) + write_obs = trace.observations[0] + self.assertEqual(write_obs.kind, "write") + self.assertEqual(write_obs.name, "FBIF_CTL") + self.assertEqual(write_obs.window, "falcon-observe") + self.assertEqual(write_obs.offset, 0x624) + self.assertEqual(write_obs.addr, 0x300110624) + self.assertEqual(write_obs.pc, 0x4002040) + self.assertEqual(write_obs.vt, 123) + self.assertIs(write_obs.handled, False) + self.assertEqual(write_obs.function, "fbif_setup") + read_obs = trace.observations[1] + self.assertEqual(read_obs.kind, "read") + self.assertEqual(read_obs.offset, 0x8) + self.assertIsNone(read_obs.value) + + def test_g1_nested_document_report(self): + with tempfile.TemporaryDirectory() as tmp: + path = _write_json(os.path.join(tmp, "t.json"), _g1_document()) + report = build_debug_report(load_sim_trace(path), None) + self.assertEqual(report["summary"]["functions"], 1) + function = report["functions"][0] + self.assertEqual(function["function"], "fbif_setup") + registers = {r["register"]: r for r in function["registers"]} + self.assertEqual(registers["FBIF_CTL"]["write_count"], 1) + self.assertEqual(registers["0x8"]["read_count"], 1) + + +class ObserveProducerIntegrationTests(unittest.TestCase): + """Ingest a document produced by the real sim/devices/observe.py module. + + The producer is in-tree, so the import is unconditional: if it breaks, + the G1<->G3 interface has regressed and this test must fail, not skip. + """ + + def test_ingest_real_build_trace_document(self): + from sim.devices.observe import ObservedWindow, build_trace + window = ObservedWindow( + base=0x300110000, + size=0x1000, + label="integration", + registers={"0x624": {"name": "FBIF_CTL", "width": 4, "access": "rw"}}, + ) + self.assertFalse(window.write(0x624, 4, 1, pc=0x4002040)) + self.assertIsNone(window.read(0x8, 4, pc=0x4002050)) + registry = type("Registry", (), {"devices": [window]})() + document = build_trace( + registry, image_sha256="cd" * 32, profile="tu104-570.144" + ) + self.assertEqual(document.get("schema"), TRACE_FORMAT) + with tempfile.TemporaryDirectory() as tmp: + path = _write_json(os.path.join(tmp, "t.json"), document) + trace = load_sim_trace(path) + self.assertEqual(trace.format, TRACE_FORMAT) + self.assertEqual(len(trace.observations), 2) + names = {obs.name for obs in trace.observations} + self.assertIn("FBIF_CTL", names) + write_obs = next(obs for obs in trace.observations if obs.kind == "write") + self.assertEqual(write_obs.offset, 0x624) + self.assertEqual(write_obs.value, 1) + + +class SymbolLoadingTests(unittest.TestCase): + def test_symbols_json_db(self): + with tempfile.TemporaryDirectory() as tmp: + db = load_symbols(_symbols_json(os.path.join(tmp, "s.json"))) + self.assertEqual(db.resolve(0x4002040).name, "fbif_setup") + + def test_symbols_plain_map_json(self): + with tempfile.TemporaryDirectory() as tmp: + path = _write_json(os.path.join(tmp, "s.json"), {"0x4001000": "gmmu_map_alloc"}) + db = load_symbols(path) + self.assertEqual(db.get("gmmu_map_alloc").addr, 0x4001000) + + def test_symbols_tsv(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "s.tsv") + with open(path, "w", encoding="utf-8") as fh: + fh.write("0x4001000\tgmmu_map_alloc\n0x4002000\tfbif_setup\n") + db = load_symbols(path) + self.assertEqual(db.resolve(0x4002000).name, "fbif_setup") + + def test_missing_symbols_is_clean_error(self): + with self.assertRaises(GmmuInputError): + load_symbols("/does/not/exist.tsv") + + +class ReportTests(unittest.TestCase): + def _report(self, events=None, symbols=None): + with tempfile.TemporaryDirectory() as tmp: + trace_path = _write_json( + os.path.join(tmp, "t.json"), + {"format": TRACE_FORMAT, "events": events if events is not None else _events()}, + ) + trace = load_sim_trace(trace_path) + return build_debug_report(trace, symbols) + + def test_report_shape_and_resolution(self): + with tempfile.TemporaryDirectory() as tmp: + symbols = load_symbols(_symbols_json(os.path.join(tmp, "s.json"))) + report = self._report(symbols=symbols) + self.assertEqual(report["format"], DEBUG_REPORT_FORMAT) + summary = report["summary"] + self.assertEqual(summary["functions"], 3) + self.assertEqual(summary["writes"], 4) + self.assertEqual(summary["reads"], 1) + names = [f["function"] for f in report["functions"]] + self.assertEqual(names, ["gmmu_map_alloc", "fbif_setup", "sub_0x4009000"]) + fbif = report["functions"][1] + self.assertEqual(fbif["addr"], 0x4002000) + registers = {r["register"]: r for r in fbif["registers"]} + self.assertEqual(registers["FBIF_CTL"]["write_count"], 1) + self.assertEqual(registers["FBIF_CTL"]["first_pc"], 0x4002040) + self.assertEqual(registers["FBIF_CTL"]["last_pc"], 0x4002040) + self.assertEqual(registers["IRQSTAT"]["read_count"], 1) + unknown = report["functions"][2] + self.assertEqual(unknown["function"], "sub_0x4009000") + self.assertEqual(unknown["addr"], 0x4009000) + + def test_sequence_order_and_last_pc(self): + with tempfile.TemporaryDirectory() as tmp: + symbols = load_symbols(_symbols_json(os.path.join(tmp, "s.json"))) + report = self._report(symbols=symbols) + alloc = report["functions"][0] + pdb = next(r for r in alloc["registers"] if r["register"] == "GMMU_PDB_LO") + self.assertEqual(pdb["write_count"], 2) + self.assertEqual(pdb["values"], [0x400000, 0x800000]) + self.assertEqual(pdb["first_pc"], 0x4001010) + self.assertEqual(pdb["last_pc"], 0x4001020) + + def test_explicit_function_field_wins(self): + events = [ + {"seq": 0, "pc": "0x4009000", "kind": "write", "window": "gmmu", + "offset": "0x10", "name": "GMMU_MODE", "value": "0x7", + "function": "fbif_setup"}, + ] + with tempfile.TemporaryDirectory() as tmp: + symbols = load_symbols(_symbols_json(os.path.join(tmp, "s.json"))) + report = self._report(events=events, symbols=symbols) + self.assertEqual(report["functions"][0]["function"], "fbif_setup") + self.assertEqual(report["functions"][0]["addr"], 0x4002000) + + def test_no_symbols_gives_sub_labels(self): + report = self._report(symbols=None) + self.assertTrue(all(f["function"].startswith("sub_") for f in report["functions"])) + + def test_events_without_pc_are_counted_not_attributed(self): + events = [{"seq": 0, "kind": "write", "window": "gmmu", "offset": "0x10", + "name": "GMMU_MODE", "value": "0x7"}] + report = self._report(events=events, symbols=None) + self.assertEqual(report["summary"]["unknown_pc_events"], 1) + self.assertEqual(report["summary"]["functions"], 0) + + def test_empty_trace(self): + report = self._report(events=[], symbols=None) + self.assertEqual(report["summary"]["functions"], 0) + self.assertEqual(report["summary"]["registers"], 0) + self.assertEqual(report["functions"], []) + + +class HitsTsvTests(unittest.TestCase): + def test_hits_tsv_is_markgreeneye_compatible(self): + with tempfile.TemporaryDirectory() as tmp: + symbols = load_symbols(_symbols_json(os.path.join(tmp, "s.json"))) + trace = load_sim_trace( + _write_json( + os.path.join(tmp, "t.json"), + {"format": TRACE_FORMAT, "events": _events()}, + ) + ) + report = build_debug_report(trace, symbols) + hits_path = write_hits_tsv(report, os.path.join(tmp, "greeneye_hits.tsv")) + with open(hits_path, "r", encoding="utf-8") as fh: + lines = [line.rstrip("\n") for line in fh] + self.assertEqual(lines[0], HITS_HEADER) + self.assertTrue(lines[0].startswith("addr")) + rows = [line.split("\t") for line in lines[1:]] + self.assertEqual(len(rows), 3) + for cols in rows: + self.assertGreaterEqual(len(cols), 2) + int(cols[0].replace("0x", ""), 16) + self.assertTrue(cols[1].isdigit()) + first = rows[0] + self.assertEqual(first[0], "0x4001000") + self.assertEqual(first[2], "gmmu_map_alloc") + self.assertIn("write", first[3]) + addrs = {int(cols[0], 16) for cols in rows} + self.assertIn(0x4009000, addrs) + + def test_hits_tsv_empty_report(self): + with tempfile.TemporaryDirectory() as tmp: + report = {"format": DEBUG_REPORT_FORMAT, "functions": []} + hits_path = write_hits_tsv(report, os.path.join(tmp, "greeneye_hits.tsv")) + with open(hits_path, "r", encoding="utf-8") as fh: + self.assertEqual(fh.read().strip(), HITS_HEADER) + + +class ArtifactTests(unittest.TestCase): + def test_run_report_writes_artifacts(self): + with tempfile.TemporaryDirectory() as tmp: + symbols_path = _symbols_json(os.path.join(tmp, "s.json")) + trace_path = _write_json( + os.path.join(tmp, "t.json"), + {"format": TRACE_FORMAT, "events": _events()}, + ) + out = os.path.join(tmp, "out") + report = run_report(trace_path, symbols_path, out) + for key in ("report_json", "report_md", "hits_tsv", "ghidra_command"): + self.assertIn(key, report["artifacts"]) + self.assertTrue(os.path.isfile(report["artifacts"]["report_json"])) + self.assertTrue(os.path.isfile(report["artifacts"]["report_md"])) + self.assertTrue(os.path.isfile(report["artifacts"]["hits_tsv"])) + self.assertIn("MarkGreenEyeHits.java", report["artifacts"]["ghidra_command"]) + with open(report["artifacts"]["report_json"], "r", encoding="utf-8") as fh: + loaded = json.load(fh) + self.assertEqual(loaded["summary"]["functions"], 3) + with open(report["artifacts"]["report_md"], "r", encoding="utf-8") as fh: + md = fh.read() + self.assertIn("Per function", md) + self.assertIn("Ghidra annotation", md) + + +class DemoTests(unittest.TestCase): + def test_demo_end_to_end_and_frozen_threshold(self): + with tempfile.TemporaryDirectory() as tmp: + out = io.StringIO() + with contextlib.redirect_stdout(out): + result = run_demo(tmp) + self.assertIn("RESULT_JSON:", out.getvalue()) + self.assertTrue(result["pass"]) + self.assertLessEqual(result["measured_seconds"], result["threshold_seconds"]) + self.assertEqual( + result["threshold_seconds"], + round(MANUAL_STATIC_BASELINE_SECONDS * WORKFLOW_THRESHOLD_FRACTION, 4), + ) + self.assertIn("estimate", result["manual_baseline_label"]) + self.assertEqual(result["summary"]["functions"], 4) + self.assertEqual(result["summary"]["writes"], 11) + self.assertEqual(result["summary"]["reads"], 2) + for key in ("report_json", "report_md", "hits_tsv"): + self.assertTrue(os.path.isfile(result["artifacts"][key])) + self.assertTrue(os.path.isfile(os.path.join(tmp, "demo-result.json"))) + + +class CliTests(unittest.TestCase): + def test_report_cli(self): + with tempfile.TemporaryDirectory() as tmp: + symbols_path = _symbols_json(os.path.join(tmp, "s.json")) + trace_path = _write_json( + os.path.join(tmp, "t.json"), + {"format": TRACE_FORMAT, "events": _events()}, + ) + out = os.path.join(tmp, "out") + rc, stdout, _stderr = _run_main( + ["report", "--trace", trace_path, "--symbols", symbols_path, "--out", out] + ) + self.assertEqual(rc, 0) + payload = json.loads(stdout[stdout.index("{"):stdout.rindex("}") + 1]) + self.assertEqual(payload["summary"]["functions"], 3) + self.assertTrue(os.path.isfile(os.path.join(out, "greeneye_hits.tsv"))) + + def test_report_cli_missing_trace_exits_2(self): + with tempfile.TemporaryDirectory() as tmp: + rc, _stdout, stderr = _run_main( + ["report", "--trace", "/nope.json", "--out", os.path.join(tmp, "out")] + ) + self.assertEqual(rc, 2) + self.assertIn("trace not found", stderr) + + def test_empty_trace_cli_writes_report(self): + with tempfile.TemporaryDirectory() as tmp: + trace_path = _write_json( + os.path.join(tmp, "t.json"), {"format": TRACE_FORMAT, "events": []} + ) + out = os.path.join(tmp, "out") + rc, stdout, _stderr = _run_main(["report", "--trace", trace_path, "--out", out]) + self.assertEqual(rc, 0) + payload = json.loads(stdout[stdout.index("{"):stdout.rindex("}") + 1]) + self.assertEqual(payload["summary"]["functions"], 0) + self.assertTrue(os.path.isfile(os.path.join(out, "gmmu-debug-report.md"))) + + def test_no_command_prints_help(self): + rc, stdout, _stderr = _run_main([]) + self.assertEqual(rc, 2) + self.assertIn("usage", stdout.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_gmmu_fbif_trace.py b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_fbif_trace.py new file mode 100644 index 0000000..7b43b64 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_fbif_trace.py @@ -0,0 +1,381 @@ +"""GMMU/FBIF observer tests: no-fake-read, no semantic change, trace schema. + +Every assertion is tied to the evidence documented in +``sim/devices/observe.py`` and ``docs/42-GMMU-FBIF-OBSERVABILITY.md``. The +real-firmware runs are bounded (<= 20k instructions) and assert observed +numbers only: adding observer windows must not change instructions, stop +PC/reason, unknown MMIO, unhandled-in-window counts or served accesses. +""" +import json +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sim.devices import ( # noqa: E402 + ObservedWindow, + PmcBlock, + TRACE_SCHEMA, + build_trace, + collect_observations, + load_profile, + parse_window_spec, + registry_from_profile, + windows_from_section, +) +from sim.devices.base import DeviceRegistry # noqa: E402 +from sim.machine import Machine # noqa: E402 + +TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEFAULT_IMAGE = os.path.normpath( + os.path.join(TOOL_ROOT, "..", "..", "firmware", "57X-GSP", "570.144", + "gsp-570.144.fwimage.bin")) +DEFAULT_PROFILE = os.path.join(TOOL_ROOT, "sim", "profiles", "tu104-570.144.json") + +PMC_BASE = 0x2000000000000000 +PMC_BOOT_42 = PMC_BASE + 0xA00 + + +class ObserveWindowUnitTests(unittest.TestCase): + def test_observe_only_declines_and_records(self): + reg = DeviceRegistry() + dev = ObservedWindow(base=0x1000, size=0x100, label="unit", + mode="observe-only", + registers={0x10: "REG_A"}) + reg.add(dev) + value = reg.read(0x1010, 4, pc=0x4000) + self.assertEqual(value, (False, 0)) + self.assertEqual(reg.unhandled, 1) + self.assertEqual(dev.counts["r"], 1) + self.assertEqual(dev.counts["declined"], 1) + entry = dev.accesses[0] + self.assertEqual(entry["addr"], 0x1010) + self.assertEqual(entry["offset"], 0x10) + self.assertEqual(entry["size"], 4) + self.assertEqual(entry["rw"], "r") + self.assertIsNone(entry["value"]) # never invent a read value + self.assertFalse(entry["served"]) + self.assertEqual(entry["pc"], 0x4000) + self.assertEqual(entry["register"], "REG_A") + self.assertEqual(entry["window"], "unit") + self.assertIsNone(entry["vt"]) # no clock supplied + self.assertFalse(reg.write(0x1010, 4, 0xAB, pc=0x4004)) + self.assertEqual(dev.accesses[1]["value"], 0xAB) + self.assertFalse(dev.accesses[1]["served"]) + + def test_storage_reads_back_written_values_only(self): + dev = ObservedWindow(base=0x2000, size=0x100, label="st", mode="storage", + registers={ + 0x10: {"name": "SPARE", "access": "rw"}, + 0x14: {"name": "RO", "access": "ro"}, + }) + reg = DeviceRegistry() + reg.add(dev) + # Never-written storage register: declined, no invented reset value. + self.assertEqual(reg.read(0x2010, 4), (False, 0)) + self.assertTrue(reg.write(0x2010, 4, 0xCAFE)) + self.assertEqual(reg.read(0x2010, 4), (True, 0xCAFE)) + # Read-only register: writes declined, reads have no value source. + self.assertFalse(reg.write(0x2014, 4, 1)) + self.assertEqual(reg.read(0x2014, 4), (False, 0)) + # Undeclared offset inside the window stays declined. + self.assertEqual(reg.read(0x2018, 4), (False, 0)) + # Wrong width is not served. + self.assertEqual(reg.read(0x2010, 8), (False, 0)) + self.assertEqual(dev.written[0x10], 0xCAFE) + self.assertEqual(dev.counts["served"], 2) # one write + one read-back + + def test_storage_field_decode_in_trace(self): + dev = ObservedWindow(base=0x3000, size=0x100, label="fields", + mode="storage", + registers={0x10: {"name": "CFG", + "fields": {"LOW": [3, 0], + "HIGH": [7, 4]}}}) + reg = DeviceRegistry() + reg.add(dev) + reg.write(0x3010, 4, 0x0053, pc=0x100) + reg.read(0x3010, 4, pc=0x104) + doc = build_trace(reg, image_sha256="0" * 64, profile="p.json", + profile_name="p") + win = doc["windows"][0] + self.assertEqual(win["label"], "fields") + self.assertEqual(win["mode"], "storage") + reg_entry = win["registers"][0] + self.assertEqual(reg_entry["name"], "CFG") + self.assertEqual(reg_entry["counts"], {"r": 1, "w": 1, "served": 2}) + write, read = reg_entry["accesses"] + self.assertEqual(write["value"], "0x53") + self.assertEqual(write["decoded"], {"LOW": 3, "HIGH": 5}) + self.assertEqual(read["value"], "0x53") + + def test_windows_from_section_expands_count_and_stride(self): + section = { + "mode": "observe-only", + "registers": {"0x0": "REG0"}, + "windows": [ + {"base": "0x1000", "size": "0x100", "label": "single"}, + {"base": "0x2000", "size": "0x100", "label": "many", + "count": 3, "stride": "0x1000"}, + {"base": "0x3000", "size": "0x100", "label": "override", + "mode": "storage"}, + ], + } + windows = windows_from_section(section) + self.assertEqual([w["label"] for w in windows], + ["single", "many-0", "many-1", "many-2", "override"]) + self.assertEqual([w["base"] for w in windows], + [0x1000, 0x2000, 0x3000, 0x4000, 0x3000]) + self.assertEqual(windows[1]["registers"], {"0x0": "REG0"}) + self.assertEqual(windows[-1]["mode"], "storage") + + def test_parse_window_spec(self): + win = parse_window_spec("0x2000000000100000:0x1000:adhoc") + self.assertEqual((win["base"], win["size"], win["label"], win["mode"]), + (0x2000000000100000, 0x1000, "adhoc", "observe-only")) + win = parse_window_spec("0x1000:0x100:st:storage") + self.assertEqual(win["mode"], "storage") + with self.assertRaises(ValueError): + parse_window_spec("0x1000") + with self.assertRaises(ValueError): + parse_window_spec("0x1000:0x100:bad:wrong-mode") + + def test_state_round_trip(self): + dev = ObservedWindow(base=0x4000, size=0x100, label="rt", mode="storage", + registers={0x10: "SPARE"}) + dev.write(0x10, 4, 0x1234) + snap = dev.state() + other = ObservedWindow(base=0x4000, size=0x100, label="rt", mode="storage", + registers={0x10: "SPARE"}) + other.load_state(snap) + self.assertEqual(other.state(), dev.state()) + self.assertEqual(other.written[0x10], 0x1234) + + def test_access_log_cap_is_reported_not_silent(self): + dev = ObservedWindow(base=0x6000, size=0x100, label="cap", + max_access_log=2, registers={0x10: "REG_A"}) + for value in (1, 2, 3, 4): + dev.write(0x10, 4, value) + doc = build_trace(type("Registry", (), {"devices": [dev]})()) + self.assertEqual(dev.counts["w"], 4) + win = doc["windows"][0] + self.assertEqual(win["counts"]["w"], 4) + self.assertEqual(win["retained_accesses"], 2) + self.assertEqual(win["dropped_accesses"], 2) + self.assertEqual(win["registers"][0]["counts"]["w"], 2) + summary = doc["summary"] + self.assertEqual(summary["accesses"], 4) + self.assertEqual(summary["retained_accesses"], 2) + self.assertEqual(summary["dropped_accesses"], 2) + + +class NoSemanticChangeTests(unittest.TestCase): + def test_observe_only_matches_no_window_lenient(self): + reg_a = DeviceRegistry() + reg_a.add(ObservedWindow(base=0x50000000, size=0x1000, label="obs")) + reg_b = DeviceRegistry() + a = Machine(memory_mb=1, ram_base=0, devices=reg_a, unknown_mmio="lenient") + b = Machine(memory_mb=1, ram_base=0, devices=reg_b, unknown_mmio="lenient") + self.assertEqual(a._mmio_load(0x50000010, 4, pc=0x40), + b._mmio_load(0x50000010, 4, pc=0x40)) + self.assertEqual(a.mmio_unknown, b.mmio_unknown) + self.assertEqual(a.mmio_log, b.mmio_log) + a._mmio_store(0x50000010, 4, 7, pc=0x44) + b._mmio_store(0x50000010, 4, 7, pc=0x44) + self.assertEqual(a.mmio_unknown, b.mmio_unknown) + self.assertEqual(a.mmio_log, b.mmio_log) + + def test_observe_only_matches_no_window_strict(self): + from sim.machine import Fault + + reg_a = DeviceRegistry() + reg_a.add(ObservedWindow(base=0x50000000, size=0x1000, label="obs")) + a = Machine(memory_mb=1, ram_base=0, devices=reg_a, unknown_mmio="fault") + b = Machine(memory_mb=1, ram_base=0, devices=DeviceRegistry(), + unknown_mmio="fault") + with self.assertRaises(Fault) as ca: + a._mmio_load(0x50000010, 4, pc=0x40) + with self.assertRaises(Fault) as cb: + b._mmio_load(0x50000010, 4, pc=0x40) + self.assertEqual(ca.exception.cause, cb.exception.cause) + self.assertEqual(ca.exception.tval, cb.exception.tval) + + def test_observer_does_not_shadow_a_modelled_window(self): + reg = DeviceRegistry() + pmc = PmcBlock(chip_id=0x164, base=PMC_BASE, size=0x1000) + reg.add(pmc) + obs = ObservedWindow(base=PMC_BOOT_42, size=0x10, label="overlay") + reg.add(obs) + machine = Machine(memory_mb=1, ram_base=0, devices=reg, + unknown_mmio="lenient") + value = machine._mmio_load(PMC_BOOT_42, 4, pc=0x400) + self.assertEqual(value, pmc.read(0xA00, 4)) + self.assertEqual(reg.unhandled, 0) + self.assertEqual(len(obs.accesses), 1) + self.assertFalse(obs.accesses[0]["served"]) + self.assertIsNone(obs.accesses[0]["value"]) + + def test_gmmu_wpr_overlap_leaves_underlying_path_identical(self): + # gmmu-wpr-lock (profile) overlaps the fwsec WPR2 window; the + # observer must record and decline, leaving whatever the underlying + # path does byte-for-byte identical to a run without the observer. + profile = load_profile(DEFAULT_PROFILE) + addr = 0x20000000001FA824 + + def probe(p): + machine = Machine(memory_mb=16, ram_base=0, unknown_mmio="lenient") + reg = registry_from_profile(p, machine=machine) + machine.devices = reg + value = machine._mmio_load(addr, 4, pc=0x40) + observations = collect_observations(reg) + return (machine.mmio_unknown, reg.unhandled, value, + {d.label: d.counts for d in observations}) + + with_observers = probe(profile) + stripped = json.loads(json.dumps(profile)) + stripped["devices"].pop("gmmu", None) + stripped["devices"].pop("fbif", None) + without_observers = probe(stripped) + self.assertEqual(with_observers[:3], without_observers[:3]) + self.assertEqual(with_observers[3]["gmmu-wpr-lock"]["declined"], 1) + self.assertEqual(without_observers[3], {}) + + +class ProfileAndBootTests(unittest.TestCase): + def test_profile_declares_gmmu_fbif_observe_windows(self): + profile = load_profile(DEFAULT_PROFILE) + self.assertIn("gmmu", profile["devices"]) + self.assertIn("fbif", profile["devices"]) + machine = Machine(memory_mb=16, ram_base=0, unknown_mmio="lenient") + reg = registry_from_profile(profile, machine=machine) + observers = collect_observations(reg) + self.assertEqual(len(observers), 23) + by_label = {d.label: d for d in observers} + self.assertEqual(by_label["gmmu-pri-mmu"].base, 0x2000000000100C80) + self.assertEqual(by_label["gmmu-pri-mmu"].size, 0x280) + self.assertEqual(by_label["gmmu-wpr-lock"].base, 0x20000000001FA7D8) + self.assertEqual(by_label["gmmu-hsmmu"].base, 0x20000000001FAC00) + self.assertEqual(by_label["fbpa"].base, 0x20000000009A0000) + self.assertEqual(by_label["fbpa-partition-15"].base, + 0x2000000000900000 + 15 * 0x4000) + self.assertEqual(by_label["fbpa-mc-2"].base, + 0x2000000000980000 + 2 * 0x4000) + for dev in observers: + self.assertEqual(dev.mode, "observe-only") + self.assertEqual(by_label["gmmu-pri-mmu"].registers[0x0]["name"], + "NV_PFB_PRI_MMU_CTRL") + self.assertEqual(by_label["fbpa"].registers[0x50]["name"], + "NV_PFB_FBPA_SPARE") + + def test_b2_numbers_unchanged_by_observer_windows(self): + from sim.gsp_boot import run_boot + + r = run_boot(image=DEFAULT_IMAGE, profile_path=DEFAULT_PROFILE, + instructions=20_000, record=False, use_shim=True) + self.assertEqual(r.rung, "B2") + self.assertEqual(r.instructions, 20_000) + self.assertEqual(r.stop_reason, "budget") + self.assertEqual(r.mmio_unknown, 0) + self.assertEqual(r.device_unhandled, 0) + self.assertEqual(r.device_accesses, 15) + # The run reaches the WPR2 reads inside the gmmu-wpr-lock observer + # window (offsets 0x4C/0x50); the observer records them without + # serving, and the fwsec device still answers. + self.assertIsNotNone(r.trace_summary) + self.assertEqual(r.trace_summary["windows"], 23) + self.assertEqual(r.trace_summary["accesses"], 2) + self.assertEqual(r.trace_summary["served"], 0) + + # Same run with the observer sections removed: identical execution. + with open(DEFAULT_PROFILE, "r", encoding="utf-8") as fh: + profile = json.load(fh) + del profile["devices"]["gmmu"] + del profile["devices"]["fbif"] + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "no-observe.json") + with open(path, "w", encoding="utf-8") as fh: + json.dump(profile, fh) + base = run_boot(image=DEFAULT_IMAGE, profile_path=path, + instructions=20_000, record=False, use_shim=True) + self.assertEqual(base.instructions, r.instructions) + self.assertEqual(base.stop_reason, r.stop_reason) + self.assertEqual(base.stop_pc, r.stop_pc) + self.assertEqual(base.mmio_unknown, r.mmio_unknown) + self.assertEqual(base.device_unhandled, r.device_unhandled) + self.assertEqual(base.device_accesses, r.device_accesses) + self.assertEqual(base.registers, r.registers) + + def test_boot_trace_schema_and_determinism(self): + from sim.gsp_boot import run_boot + + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "trace-dir") + os.makedirs(path) + r = run_boot(image=DEFAULT_IMAGE, profile_path=DEFAULT_PROFILE, + instructions=20_000, record=False, use_shim=True, + trace_out=path) + self.assertEqual(r.trace_path, + os.path.join(path, "gmmu-fbif-trace.json")) + with open(r.trace_path, "r", encoding="utf-8") as fh: + doc = json.load(fh) + self.assertEqual(doc["schema"], TRACE_SCHEMA) + self.assertEqual(doc["image_sha256"], r.image_sha256) + self.assertEqual(set(doc["summary"]), + {"windows", "windows_touched", "accesses", + "retained_accesses", "dropped_accesses", + "reads", "writes", "served", "registers_touched"}) + self.assertEqual(doc["summary"]["accesses"], 2) + self.assertEqual(doc["summary"]["retained_accesses"], 2) + self.assertEqual(doc["summary"]["dropped_accesses"], 0) + labels = [w["label"] for w in doc["windows"]] + self.assertIn("gmmu-pri-mmu", labels) + self.assertIn("fbpa", labels) + for win in doc["windows"]: + self.assertEqual(set(win), + {"label", "base", "size", "mode", "evidence", + "counts", "dropped_accesses", + "retained_accesses", "registers", + "unclassified"}) + self.assertEqual(win["mode"], "observe-only") + self.assertEqual(win["dropped_accesses"], 0) + self.assertTrue(win["base"].startswith("0x")) + r2 = run_boot(image=DEFAULT_IMAGE, profile_path=DEFAULT_PROFILE, + instructions=20_000, record=False, use_shim=True) + self.assertEqual(r.device_state_sha256, r2.device_state_sha256) + + def test_runtime_window_records_without_faking_and_resolves_symbols(self): + from sim.gsp_boot import run_boot + + symbols = {"format": "greeneye-symbols/1", "count": 1, + "symbols": [{"addr": "0x4009014", "name": "boot_probe", + "size": 64}]} + with tempfile.TemporaryDirectory() as tmp: + sym_path = os.path.join(tmp, "symbols.json") + trace_path = os.path.join(tmp, "trace.json") + with open(sym_path, "w", encoding="utf-8") as fh: + json.dump(symbols, fh) + r = run_boot( + image=DEFAULT_IMAGE, profile_path=DEFAULT_PROFILE, + instructions=20_000, record=False, use_shim=True, + observe_windows=["0x%x:0x10:pmc-probe" % PMC_BOOT_42], + symbols_path=sym_path, trace_out=trace_path) + # Two gmmu-wpr-lock reads (the WPR2 pair) plus the two BOOT_42 + # reads inside the runtime PMC probe window. + self.assertEqual(r.trace_summary["accesses"], 4) + self.assertEqual(r.trace_summary["windows"], 24) + with open(trace_path, "r", encoding="utf-8") as fh: + doc = json.load(fh) + self.assertEqual(doc["symbols"], sym_path) + win = [w for w in doc["windows"] if w["label"] == "pmc-probe"][0] + entry = win["unclassified"]["accesses"][0] + self.assertEqual(entry["addr"], "0x%x" % PMC_BOOT_42) + self.assertEqual(entry["rw"], "r") + self.assertIsNone(entry["value"]) # observe-only: no fake read + self.assertFalse(entry["served"]) + self.assertEqual(entry["pc_symbol"], "boot_probe") + self.assertIsInstance(entry["vt"], int) + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_gmmu_oracle.py b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_oracle.py new file mode 100644 index 0000000..9211c81 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_oracle.py @@ -0,0 +1,397 @@ +"""Tests for the GMMU/FBIF oracle comparison (harness/gmmu_oracle.py, docs/44). + +All fixtures are synthetic and written to temporary directories: no oracle +capture, no hardware, no simulator run, no QEMU, no network. The comparison +is offline file-in/file-out. Tests that would need real inputs are written +to assert the clean error path instead. + +Run:: + + python3 -m unittest tests.test_gmmu_oracle -v +""" +from __future__ import annotations + +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOL_ROOT = os.path.abspath(os.path.join(HERE, "..")) +if TOOL_ROOT not in sys.path: + sys.path.insert(0, TOOL_ROOT) + +from harness.gmmu_debug import GmmuInputError, TRACE_FORMAT # noqa: E402 +from harness.gmmu_oracle import ( # noqa: E402 + ORACLE_FORMAT, + REPORT_FORMAT, + compare, + load_oracle, + load_sim_trace, + main, + print_capture_hints, + run_compare, +) + + +def _write_json(path: str, payload) -> str: + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh) + return path + + +def _oracle_items() -> list[dict]: + return [ + { + "name": "FBIF_CTL", + "window": "falcon", + "offset": "0x624", + "value": "0x1", + "source": "rm-readback", + "captured_at": "2026-10-03", + }, + { + "name": "GMMU_PDB_LO", + "window": "gmmu", + "offset": "0x0", + "value": "0x400000", + "source": "rm-readback", + "captured_at": "2026-10-03", + }, + ] + + +def _sim_events() -> list[dict]: + return [ + { + "seq": 0, + "pc": "0x4002040", + "kind": "write", + "window": "falcon", + "offset": "0x624", + "name": "FBIF_CTL", + "size": 4, + "value": "0x1", + }, + { + "seq": 1, + "pc": "0x4001010", + "kind": "write", + "window": "gmmu", + "offset": "0x0", + "name": "GMMU_PDB_LO", + "size": 4, + "value": "0x400000", + }, + ] + + +def _run_main(argv: list[str]) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + rc = main(argv) + return rc, out.getvalue(), err.getvalue() + + +class IngestTests(unittest.TestCase): + def test_oracle_object_and_bare_list(self): + with tempfile.TemporaryDirectory() as tmp: + tagged = _write_json( + os.path.join(tmp, "o1.json"), + {"format": ORACLE_FORMAT, "captures": _oracle_items()}, + ) + bare = _write_json(os.path.join(tmp, "o2.json"), _oracle_items()) + self.assertEqual(load_oracle(tagged).format, ORACLE_FORMAT) + self.assertEqual(load_oracle(bare).format, "unlabeled-list") + self.assertEqual(len(load_oracle(tagged).items), 2) + self.assertEqual(load_oracle(tagged).items[0].value, 1) + self.assertEqual(load_oracle(tagged).items[1].offset, 0) + + def test_oracle_wrong_format_is_clean_error(self): + with tempfile.TemporaryDirectory() as tmp: + path = _write_json( + os.path.join(tmp, "o.json"), {"format": "something-else", "captures": []} + ) + with self.assertRaises(GmmuInputError): + load_oracle(path) + + def test_oracle_missing_file_is_clean_error(self): + with self.assertRaises(GmmuInputError): + load_oracle("/does/not/exist.json") + + def test_trace_aliases(self): + with tempfile.TemporaryDirectory() as tmp: + path = _write_json( + os.path.join(tmp, "t.json"), + { + "format": TRACE_FORMAT, + "events": [ + { + "pc": 0x4002040, + "rw": "w", + "block": "falcon", + "reg_offset": 0x624, + "register": "FBIF_CTL", + "value": 1, + } + ], + }, + ) + trace = load_sim_trace(path) + obs = trace.observations[0] + self.assertEqual(obs.kind, "write") + self.assertEqual(obs.window, "falcon") + self.assertEqual(obs.offset, 0x624) + self.assertEqual(obs.name, "FBIF_CTL") + self.assertEqual(obs.value, 1) + + def test_trace_missing_file_is_clean_error(self): + with self.assertRaises(GmmuInputError): + load_sim_trace("/does/not/exist.json") + + +class ComparisonTests(unittest.TestCase): + def _compare(self, items, events): + with tempfile.TemporaryDirectory() as tmp: + oracle_path = _write_json( + os.path.join(tmp, "oracle.json"), + {"format": ORACLE_FORMAT, "captures": items}, + ) + trace_path = _write_json( + os.path.join(tmp, "trace.json"), {"format": TRACE_FORMAT, "events": events} + ) + return compare(load_oracle(oracle_path), load_sim_trace(trace_path)) + + def test_full_agreement(self): + report = self._compare(_oracle_items(), _sim_events()) + summary = report["summary"] + self.assertEqual(summary["oracle_items"], 2) + self.assertEqual(summary["compared"], 2) + self.assertEqual(summary["agreed"], 2) + self.assertEqual(summary["mismatched"], 0) + self.assertEqual(summary["oracle_only"], 0) + self.assertEqual(summary["simulator_only"], 0) + self.assertEqual(summary["agreement_pct"], 100.0) + self.assertEqual(summary["coverage_pct"], 100.0) + self.assertTrue(all(row["status"] == "agree" for row in report["rows"])) + self.assertTrue(all(row["fidelity"] == "observed vs modelled" for row in report["rows"])) + + def test_mismatch_is_reported(self): + events = _sim_events() + events[0]["value"] = "0x2" + report = self._compare(_oracle_items(), events) + summary = report["summary"] + self.assertEqual(summary["mismatched"], 1) + self.assertEqual(summary["agreed"], 1) + self.assertEqual(summary["agreement_pct"], 50.0) + mismatch = next(row for row in report["rows"] if row["status"] == "mismatch") + self.assertEqual(mismatch["oracle_value"], 1) + self.assertEqual(mismatch["sim_value"], 2) + + def test_missing_simulator_observation(self): + report = self._compare(_oracle_items(), _sim_events()[:1]) + summary = report["summary"] + self.assertEqual(summary["oracle_only"], 1) + self.assertEqual(summary["coverage_pct"], 50.0) + self.assertEqual(len(report["unmatched_oracle"]), 1) + self.assertEqual(report["unmatched_oracle"][0]["register"], "GMMU_PDB_LO") + oracle_only = next(row for row in report["rows"] if row["status"] == "oracle-only") + self.assertIn("observed", oracle_only["fidelity"]) + + def test_missing_oracle_counterpart(self): + extra = { + "seq": 9, + "pc": "0x4003010", + "kind": "write", + "window": "gmmu", + "offset": "0x200", + "name": "PTE_DATA", + "value": "0x1", + } + report = self._compare(_oracle_items(), _sim_events() + [extra]) + self.assertEqual(report["summary"]["simulator_only"], 1) + self.assertEqual(len(report["unmatched_simulator"]), 1) + self.assertEqual(report["unmatched_simulator"][0]["register"], "PTE_DATA") + self.assertEqual(report["summary"]["simulator_match_pct"], 66.67) + + def test_name_fallback_when_oracle_has_no_offset(self): + items = [{"name": "IRQSTAT", "window": "falcon", "value": "0x0", "source": "rm"}] + events = [ + { + "seq": 0, + "pc": 0x4002050, + "kind": "read", + "window": "falcon", + "offset": "0x8", + "name": "IRQSTAT", + "value": "0x0", + } + ] + report = self._compare(items, events) + self.assertEqual(report["summary"]["agreed"], 1) + self.assertEqual(report["rows"][0]["matched_by"], "window-name") + + def test_no_value_is_not_agreement(self): + items = [{"name": "FBIF_CTL", "window": "falcon", "offset": "0x624"}] + report = self._compare(items, _sim_events()[:1]) + self.assertEqual(report["summary"]["no_value"], 1) + self.assertEqual(report["summary"]["agreement_pct"], None) + + def test_g1_nested_document_compares(self): + document = { + "schema": TRACE_FORMAT, + "image_sha256": "ab" * 32, + "profile": "tu104-570.144", + "windows": [ + { + "label": "falcon-observe", + "registers": [ + { + "offset": "0x624", + "name": "FBIF_CTL", + "access": "rw", + "accesses": [ + {"addr": "0x300110624", "offset": "0x624", + "size": 4, "rw": "w", "value": "0x1", + "pc": "0x4002040", "window": "falcon-observe", + "register": "FBIF_CTL", "served": False, + "pc_symbol": None}, + ], + } + ], + } + ], + } + with tempfile.TemporaryDirectory() as tmp: + oracle_path = _write_json( + os.path.join(tmp, "oracle.json"), + { + "format": ORACLE_FORMAT, + "captures": [ + {"name": "FBIF_CTL", "window": "falcon-observe", + "offset": "0x624", "value": "0x1", "source": "rm"} + ], + }, + ) + trace_path = _write_json(os.path.join(tmp, "trace.json"), document) + report = compare(load_oracle(oracle_path), load_sim_trace(trace_path)) + self.assertEqual(report["summary"]["oracle_items"], 1) + self.assertEqual(report["summary"]["agreed"], 1) + self.assertEqual(report["inputs"]["simulator"]["format"], TRACE_FORMAT) + + def test_empty_inputs(self): + report = self._compare([], []) + summary = report["summary"] + self.assertEqual(summary["oracle_items"], 0) + self.assertEqual(summary["compared"], 0) + self.assertEqual(summary["agreement_pct"], None) + self.assertEqual(summary["coverage_pct"], None) + self.assertEqual(report["rows"], []) + self.assertEqual(report["unmatched_simulator"], []) + + +class SessionIngestTests(unittest.TestCase): + def test_session_directory_with_blobs_trace(self): + with tempfile.TemporaryDirectory() as tmp: + session = os.path.join(tmp, "session") + blobs = os.path.join(session, "blobs") + os.makedirs(blobs) + _write_json( + os.path.join(session, "session.json"), + {"format": "greeneye-session/1", "meta": {}}, + ) + _write_json( + os.path.join(blobs, "gmmu-fbif-trace.json"), + {"format": TRACE_FORMAT, "events": _sim_events()}, + ) + trace = load_sim_trace(session) + self.assertEqual(len(trace.observations), 2) + self.assertEqual(trace.format, TRACE_FORMAT) + + def test_session_without_trace_is_clean_error(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(GmmuInputError): + load_sim_trace(tmp) + + +class ReportArtifactTests(unittest.TestCase): + def test_run_compare_writes_json_and_markdown(self): + with tempfile.TemporaryDirectory() as tmp: + oracle_path = _write_json( + os.path.join(tmp, "oracle.json"), + {"format": ORACLE_FORMAT, "captures": _oracle_items()}, + ) + trace_path = _write_json( + os.path.join(tmp, "trace.json"), + {"format": TRACE_FORMAT, "events": _sim_events()}, + ) + out = os.path.join(tmp, "out") + report = run_compare(oracle_path, trace_path, out) + self.assertEqual(report["format"], REPORT_FORMAT) + self.assertTrue(os.path.isfile(report["artifacts"]["report_json"])) + self.assertTrue(os.path.isfile(report["artifacts"]["report_md"])) + with open(report["artifacts"]["report_json"], "r", encoding="utf-8") as fh: + loaded = json.load(fh) + self.assertEqual(loaded["summary"]["agreed"], 2) + with open(report["artifacts"]["report_md"], "r", encoding="utf-8") as fh: + md = fh.read() + self.assertIn("oracle vs simulator report", md) + self.assertIn("No cycle/timing claim", md) + self.assertIn("Oracle items with no simulator observation", md) + + +class CliTests(unittest.TestCase): + def test_print_capture_hints_executes_nothing(self): + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + print_capture_hints(out) + text = out.getvalue() + self.assertIn("SUPERVISED, READ-ONLY, HUMAN-RUN ONLY", text) + self.assertIn("executes NOTHING", text) + self.assertIn("RUNBOOK P1/P7", text) + self.assertEqual(err.getvalue(), "") + + def test_print_capture_hints_cli(self): + rc, stdout, _stderr = _run_main(["--print-capture-hints"]) + self.assertEqual(rc, 0) + self.assertIn("rmharness/harness.sh", stdout) + self.assertIn("gmmu_oracle.py compare", stdout) + + def test_compare_cli_writes_report(self): + with tempfile.TemporaryDirectory() as tmp: + oracle_path = _write_json( + os.path.join(tmp, "oracle.json"), + {"format": ORACLE_FORMAT, "captures": _oracle_items()}, + ) + trace_path = _write_json( + os.path.join(tmp, "trace.json"), + {"format": TRACE_FORMAT, "events": _sim_events()}, + ) + out = os.path.join(tmp, "out") + rc, stdout, _stderr = _run_main( + ["compare", "--oracle", oracle_path, "--sim", trace_path, "--out", out] + ) + self.assertEqual(rc, 0) + payload = json.loads(stdout[stdout.index("{"):stdout.rindex("}") + 1]) + self.assertEqual(payload["summary"]["agreed"], 2) + self.assertTrue(os.path.isfile(os.path.join(out, "gmmu-oracle-report.json"))) + + def test_compare_missing_inputs_exits_2(self): + with tempfile.TemporaryDirectory() as tmp: + rc, _stdout, stderr = _run_main( + ["compare", "--oracle", "/nope.json", "--sim", "/nope.json", "--out", tmp] + ) + self.assertEqual(rc, 2) + self.assertIn("oracle not found", stderr) + + def test_no_command_prints_help(self): + rc, stdout, _stderr = _run_main([]) + self.assertEqual(rc, 2) + self.assertIn("usage", stdout.lower()) + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_gmmu_pte.py b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_pte.py new file mode 100644 index 0000000..2460f1f --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_pte.py @@ -0,0 +1,534 @@ +"""Golden-vector tests for the clean-room TU104 GMMU PTE/PDE decoder. + +Every vector is constructed field-by-field from the bit positions documented +in ``sim/gmmu_pte.py`` (see ``docs/43-GMMU-PTE-DECODER.md``), then decoded and asserted field-by-field. +The tests also pin the explicit failure behaviour: reserved bits are +reported, unsupported encodings/levels error instead of guessing, empty and +truncated blobs are handled deterministically, and decode output is +repeatable. +""" +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from sim.gmmu_pte import ( # noqa: E402 + APERTURE_NAMES_PDE, + APERTURE_NAMES_PTE, + E_BAD_VALUE, + E_TRUNCATED, + E_UNSUPPORTED, + GmmuDecodeError, + KIND_VER2, + KIND_VER2_DUAL, + LEVELS, + PTE_KIND_NAMES, + SUPPORTED_LEVELS, + decode_dual_pde, + decode_entry, + decode_pde, + decode_pte, + decode_table, + main, +) + + +def make_pte(*, valid=1, aperture=2, volatile=1, encrypted=0, privilege=0, + read_only=0, atomic_disable=1, address=0, comptagline=0, + peer_id=0, kind=0x06): + value = 0 + value |= (valid & 1) << 0 + value |= (aperture & 3) << 1 + value |= (volatile & 1) << 3 + value |= (encrypted & 1) << 4 + value |= (privilege & 1) << 5 + value |= (read_only & 1) << 6 + value |= (atomic_disable & 1) << 7 + if aperture in (2, 3): + value |= ((address >> 12) & ((1 << 46) - 1)) << 8 + else: + value |= ((address >> 12) & ((1 << 25) - 1)) << 8 + value |= (peer_id & 7) << 33 + value |= (comptagline & ((1 << 20) - 1)) << 36 + value |= (kind & 0xFF) << 56 + return value + + +def make_pde(*, aperture=1, volatile=1, no_ats=0, address=0, peer_id=0): + value = 0 + value |= (aperture & 3) << 1 + value |= (volatile & 1) << 3 + value |= (no_ats & 1) << 5 + value |= ((address >> 12) & ((1 << 46) - 1)) << 8 + value |= (peer_id & 7) << 33 + return value + + +def make_dual(*, aperture_big=1, volatile_big=1, aperture_small=1, + volatile_small=1, address_big=0, address_small=0, + peer_big=0, peer_small=0, no_ats=0): + value = 0 + value |= (aperture_big & 3) << 1 + value |= (volatile_big & 1) << 3 + value |= ((address_big >> 8) & ((1 << 50) - 1)) << 4 + value |= (peer_big & 7) << 33 + value |= (aperture_small & 3) << 65 + value |= (volatile_small & 1) << 67 + value |= ((address_small >> 12) & ((1 << 46) - 1)) << 72 + value |= (peer_small & 7) << 97 + value |= (no_ats & 1) << 5 + return value + + +class GoldenVectorTests(unittest.TestCase): + """Field-by-field constructed vectors must round-trip exactly.""" + + def test_pte_system_coherent_round_trip(self): + pa = 0x123456789000 + value = make_pte(valid=1, aperture=2, volatile=1, encrypted=0, + privilege=1, read_only=1, atomic_disable=0, + address=pa, kind=0x06) + d = decode_pte(value) + self.assertEqual(d["role"], "pte") + self.assertTrue(d["valid"]) + self.assertEqual(d["aperture"], 2) + self.assertEqual(d["aperture_name"], "system_coherent_memory") + self.assertTrue(d["volatile"]) + self.assertFalse(d["encrypted"]) + self.assertTrue(d["privilege"]) + self.assertTrue(d["read_only"]) + self.assertFalse(d["writable"]) + self.assertFalse(d["atomic_disable"]) + self.assertEqual(d["address"], pa) + self.assertTrue(d["address_complete"]) + self.assertEqual(d["kind"], 0x06) + self.assertEqual(d["kind_name"], "GENERIC_MEMORY") + self.assertTrue(d["kind_known"]) + + def test_pte_system_non_coherent_round_trip(self): + pa = 0x2AAAAA0000 + value = make_pte(aperture=3, address=pa, read_only=0, + atomic_disable=1, kind=0x00) + d = decode_pte(value) + self.assertEqual(d["aperture_name"], "system_non_coherent_memory") + self.assertEqual(d["address"], pa) + self.assertTrue(d["writable"]) + self.assertTrue(d["atomic_disable"]) + self.assertEqual(d["kind_name"], "PITCH") + + def test_pte_video_aperture_round_trip(self): + pa_low = 0x0ABCDEF000 + value = make_pte(aperture=0, address=pa_low, comptagline=0x12345, + kind=0x06) + d = decode_pte(value) + self.assertEqual(d["aperture_name"], "video_memory") + self.assertEqual(d["address"], pa_low) + self.assertFalse(d["address_complete"]) + self.assertEqual(d["comptagline"], 0x12345) + self.assertEqual(d["peer_id"], 0) + + def test_pte_peer_aperture_round_trip(self): + pa_low = 0x0100000000 + value = make_pte(aperture=1, address=pa_low, peer_id=5, kind=0x06) + d = decode_pte(value) + self.assertEqual(d["aperture_name"], "peer_memory") + self.assertEqual(d["address"], pa_low) + self.assertFalse(d["address_complete"]) + self.assertEqual(d["peer_id"], 5) + + def test_pte_aperture_names_are_the_proven_four(self): + self.assertEqual( + APERTURE_NAMES_PTE, + {0: "video_memory", 1: "peer_memory", + 2: "system_coherent_memory", 3: "system_non_coherent_memory"}) + for aperture in range(4): + d = decode_pte(make_pte(aperture=aperture)) + self.assertEqual(d["aperture_name"], APERTURE_NAMES_PTE[aperture]) + + def test_pde_video_pointer_round_trip(self): + pa = 0x00000123456000 + value = make_pde(aperture=1, volatile=1, no_ats=0, address=pa) + d = decode_pde(value) + self.assertEqual(d["role"], "pde") + self.assertTrue(d["is_pde_pointer"]) + self.assertFalse(d["is_leaf"]) + self.assertEqual(d["aperture_name"], "video_memory") + self.assertTrue(d["volatile"]) + self.assertFalse(d["no_ats"]) + self.assertEqual(d["address"], pa) + self.assertFalse(d["address_complete"]) + + def test_pde_system_pointer_round_trip(self): + pa = 0x1FFFF0000000 + value = make_pde(aperture=2, volatile=0, no_ats=1, address=pa) + d = decode_pde(value) + self.assertEqual(d["aperture_name"], "system_coherent_memory") + self.assertFalse(d["volatile"]) + self.assertTrue(d["no_ats"]) + self.assertEqual(d["address"], pa) + self.assertTrue(d["address_complete"]) + + def test_pde_invalid_aperture_has_no_address(self): + d = decode_pde(make_pde(aperture=0, volatile=0)) + self.assertEqual(d["aperture_name"], "invalid") + self.assertIsNone(d["address"]) + self.assertFalse(d["address_complete"]) + + def test_pde_leaf_bit_is_rejected(self): + with self.assertRaises(GmmuDecodeError) as ctx: + decode_pde(1) + self.assertEqual(ctx.exception.code, E_UNSUPPORTED) + + def test_dual_pde_halves_round_trip(self): + addr_big = 0x0000001234560000 + addr_small = 0x0000000FEDCBA000 + value = make_dual(aperture_big=1, volatile_big=1, aperture_small=1, + volatile_small=1, address_big=addr_big, + address_small=addr_small) + d = decode_dual_pde(value) + self.assertEqual(d["role"], "dual_pde") + self.assertTrue(d["is_pde_pointer"]) + self.assertFalse(d["is_leaf"]) + self.assertFalse(d["no_ats"]) + self.assertEqual(d["big"]["aperture_name"], "video_memory") + self.assertTrue(d["big"]["volatile"]) + self.assertEqual(d["big"]["address"], addr_big) + self.assertFalse(d["big"]["address_complete"]) + self.assertEqual(d["small"]["aperture_name"], "video_memory") + self.assertTrue(d["small"]["volatile"]) + self.assertEqual(d["small"]["address"], addr_small) + self.assertFalse(d["small"]["address_complete"]) + + def test_dual_pde_no_ats_bit_does_not_perturb_big_address(self): + # The format names bit 5 NO_ATS inside the BIG address field; valid + # depth-3 allocations are 4 KB aligned, so address bit 9 is zero and + # the flag must decode without changing the reconstructed address. + addr_big = 0x0000001234560000 + d = decode_dual_pde(make_dual(address_big=addr_big, no_ats=1)) + self.assertTrue(d["no_ats"]) + self.assertEqual(d["big"]["address"], addr_big) + self.assertEqual(d["big"]["address_vid"] & (1 << 1), 1 << 1) + + def test_dual_pde_system_halves_are_complete(self): + value = make_dual(aperture_big=2, aperture_small=3, + address_big=0x1000000000, + address_small=0x2000000000) + d = decode_dual_pde(value) + self.assertEqual(d["big"]["aperture_name"], "system_coherent_memory") + self.assertTrue(d["big"]["address_complete"]) + self.assertEqual(d["big"]["address"], 0x1000000000) + self.assertEqual(d["small"]["aperture_name"], + "system_non_coherent_memory") + self.assertTrue(d["small"]["address_complete"]) + self.assertEqual(d["small"]["address"], 0x2000000000) + + def test_dual_pde_peer_ids_round_trip(self): + value = make_dual(aperture_big=1, aperture_small=1, peer_big=3, + peer_small=6) + d = decode_dual_pde(value) + self.assertEqual(d["big"]["peer_id"], 3) + self.assertEqual(d["small"]["peer_id"], 6) + + def test_pde_aperture_names_are_the_proven_four(self): + self.assertEqual( + APERTURE_NAMES_PDE, + {0: "invalid", 1: "video_memory", + 2: "system_coherent_memory", 3: "system_non_coherent_memory"}) + for aperture in range(4): + d = decode_pde(make_pde(aperture=aperture)) + self.assertEqual(d["aperture_name"], APERTURE_NAMES_PDE[aperture]) + + def test_known_kind_names_spot_values(self): + self.assertEqual(PTE_KIND_NAMES[0x06], "GENERIC_MEMORY") + self.assertEqual(PTE_KIND_NAMES[0x00], "PITCH") + self.assertEqual(PTE_KIND_NAMES[0x07], "INVALID") + self.assertEqual(PTE_KIND_NAMES[0x0F], "SMSKED_MESSAGE") + self.assertEqual(PTE_KIND_NAMES[0xFE], "GENERIC_16BX2") + self.assertEqual(len(PTE_KIND_NAMES), 243) + + def test_unknown_kind_field_is_reported_not_guessed(self): + value = make_pte(kind=0xFF) + d = decode_pte(value) + self.assertEqual(d["kind"], 0xFF) + self.assertIsNone(d["kind_name"]) + self.assertFalse(d["kind_known"]) + + def test_level_metadata(self): + self.assertEqual([lv["depth"] for lv in LEVELS], + list(SUPPORTED_LEVELS)) + self.assertEqual(decode_pte(make_pte(), level=3)["page_size"], "2m") + self.assertEqual(decode_pte(make_pte(), level=4)["page_size"], + "4k_or_64k") + self.assertEqual(decode_pte(make_pte())["level_candidates"], [3, 4]) + + +class ReservedBitTests(unittest.TestCase): + """Set bits with no named field must be reported, never dropped.""" + + def test_pde_all_bits_set_reports_exact_reserved_set(self): + d = decode_pde(((1 << 64) - 1) & ~1) # bit 0 clear = directory entry + self.assertEqual(d["reserved_bits"], [4, 6, 7] + list(range(54, 64))) + self.assertEqual(d["reserved_mask"], + sum(1 << b for b in d["reserved_bits"])) + + def test_pde_named_bits_are_not_reported(self): + d = decode_pde(make_pde(aperture=2, volatile=1, no_ats=1, + address=0x12345000)) + self.assertEqual(d["reserved_bits"], []) + + def test_dual_pde_all_bits_set_reports_exact_reserved_set(self): + d = decode_dual_pde((1 << 128) - 1) + expected = list(range(54, 65)) + list(range(68, 72)) + \ + list(range(118, 128)) + self.assertEqual(d["reserved_bits"], expected) + self.assertEqual(d["reserved_mask"], + sum(1 << b for b in expected)) + + def test_dual_pde_unnamed_small_half_bit_64_is_reported(self): + d = decode_dual_pde(1 << 64) + self.assertEqual(d["reserved_bits"], [64]) + + def test_pte_has_no_reserved_bits(self): + d = decode_pte((1 << 64) - 1) + self.assertEqual(d["reserved_bits"], []) + self.assertEqual(d["reserved_mask"], 0) + + +class ErrorBehaviourTests(unittest.TestCase): + """Unsupported requests error; they never guess.""" + + def test_unknown_encoding_kind_errors(self): + with self.assertRaises(GmmuDecodeError) as ctx: + decode_pte(0, pte_kind="legacy") + self.assertEqual(ctx.exception.code, E_UNSUPPORTED) + with self.assertRaises(GmmuDecodeError) as ctx: + decode_pde(0, pde_kind="ver1") + self.assertEqual(ctx.exception.code, E_UNSUPPORTED) + with self.assertRaises(GmmuDecodeError) as ctx: + decode_table(b"", base_pa=0, kind="nope") + self.assertEqual(ctx.exception.code, E_UNSUPPORTED) + + def test_wrong_role_for_encoding_errors(self): + with self.assertRaises(GmmuDecodeError) as ctx: + decode_pte(0, pte_kind=KIND_VER2_DUAL) + self.assertEqual(ctx.exception.code, E_UNSUPPORTED) + with self.assertRaises(GmmuDecodeError) as ctx: + decode_dual_pde(0, pde_kind=KIND_VER2) + self.assertEqual(ctx.exception.code, E_UNSUPPORTED) + + def test_unsupported_levels_error(self): + value = make_pde().to_bytes(8, "little") + for bad in (5, 99, -1, (0, 7)): + with self.assertRaises(GmmuDecodeError) as ctx: + decode_table(value, base_pa=0, levels=bad) + self.assertEqual(ctx.exception.code, E_UNSUPPORTED) + with self.assertRaises(GmmuDecodeError) as ctx: + decode_pte(0, level=0) + self.assertEqual(ctx.exception.code, E_UNSUPPORTED) + + def test_entry_size_must_match_kind(self): + with self.assertRaises(GmmuDecodeError) as ctx: + decode_table(b"\x00" * 8, base_pa=0, kind=KIND_VER2_DUAL) + self.assertEqual(ctx.exception.code, E_UNSUPPORTED) + with self.assertRaises(GmmuDecodeError) as ctx: + decode_table(b"\x00" * 16, base_pa=0, entry_size=16) + self.assertEqual(ctx.exception.code, E_UNSUPPORTED) + + def test_bad_values_error(self): + with self.assertRaises(GmmuDecodeError) as ctx: + decode_pte(1 << 64) + self.assertEqual(ctx.exception.code, E_BAD_VALUE) + with self.assertRaises(GmmuDecodeError) as ctx: + decode_dual_pde(-1) + self.assertEqual(ctx.exception.code, E_BAD_VALUE) + with self.assertRaises(GmmuDecodeError) as ctx: + decode_table(b"", base_pa=-1) + self.assertEqual(ctx.exception.code, E_BAD_VALUE) + + +class TableDecodeTests(unittest.TestCase): + def test_empty_blob_is_empty_list(self): + self.assertEqual(decode_table(b"", base_pa=0x1000), []) + self.assertEqual(decode_table(b"", base_pa=0x1000, kind=KIND_VER2_DUAL, + entry_size=16), []) + + def test_short_blob_errors(self): + for blob in (b"\x01", b"\x01\x02\x03", b"\x00" * 4, b"\x00" * 12): + with self.assertRaises(GmmuDecodeError) as ctx: + decode_table(blob, base_pa=0) + self.assertEqual(ctx.exception.code, E_TRUNCATED) + with self.assertRaises(GmmuDecodeError) as ctx: + decode_table(b"\x00" * 24, base_pa=0, kind=KIND_VER2_DUAL, + entry_size=16) + self.assertEqual(ctx.exception.code, E_TRUNCATED) + + def test_two_entry_table_round_trip_and_addresses(self): + pte = make_pte(aperture=2, address=0x40000000) + pde = make_pde(aperture=1, address=0x50000000) + blob = pte.to_bytes(8, "little") + pde.to_bytes(8, "little") + entries = decode_table(blob, base_pa=0x1000) + self.assertEqual(len(entries), 2) + self.assertEqual(entries[0]["index"], 0) + self.assertEqual(entries[0]["entry_pa"], 0x1000) + self.assertEqual(entries[0]["role"], "pte") + self.assertEqual(entries[0]["address"], 0x40000000) + self.assertEqual(entries[1]["index"], 1) + self.assertEqual(entries[1]["entry_pa"], 0x1008) + self.assertEqual(entries[1]["role"], "pde") + self.assertEqual(entries[1]["address"], 0x50000000) + + def test_auto_role_uses_bit_zero_and_reports_inference(self): + entries = decode_table(make_pte().to_bytes(8, "little"), + base_pa=0) + self.assertEqual(entries[0]["role"], "pte") + self.assertTrue(entries[0]["role_inferred"]) + self.assertIsNone(entries[0]["level"]) + self.assertEqual(entries[0]["level_candidates"], [3, 4]) + entries = decode_table(make_pde().to_bytes(8, "little"), base_pa=0) + self.assertEqual(entries[0]["role"], "pde") + self.assertTrue(entries[0]["role_inferred"]) + self.assertEqual(entries[0]["level_candidates"], [0, 1, 2, 3]) + + def test_pinned_level_selects_role(self): + pte = make_pte() + pde = make_pde() + leaf = decode_table(pte.to_bytes(8, "little"), base_pa=0, levels=3) + self.assertEqual((leaf[0]["role"], leaf[0]["level"]), ("pte", 3)) + self.assertEqual(leaf[0]["page_size"], "2m") + ptr = decode_table(pde.to_bytes(8, "little"), base_pa=0, levels=3) + self.assertEqual((ptr[0]["role"], ptr[0]["level"]), ("pde", 3)) + upper = decode_table(pde.to_bytes(8, "little"), base_pa=0, levels=1) + self.assertEqual((upper[0]["role"], upper[0]["level"]), ("pde", 1)) + page = decode_table(pte.to_bytes(8, "little"), base_pa=0, levels=4) + self.assertEqual((page[0]["role"], page[0]["level"]), ("pte", 4)) + + def test_level_sequence_narrows_candidates(self): + entries = decode_table(make_pte().to_bytes(8, "little"), base_pa=0, + levels=(3, 4)) + self.assertEqual(entries[0]["level_candidates"], [3, 4]) + entries = decode_table(make_pde().to_bytes(8, "little"), base_pa=0, + levels=(3, 4)) + self.assertEqual(entries[0]["level_candidates"], [3]) + + def test_dual_table_entry(self): + value = make_dual(address_big=0x1000, address_small=0x2000) + entries = decode_table(value.to_bytes(16, "little"), base_pa=0x2000, + kind=KIND_VER2_DUAL, entry_size=16) + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["role"], "dual_pde") + self.assertEqual(entries[0]["entry_pa"], 0x2000) + self.assertEqual(entries[0]["encoding"], KIND_VER2_DUAL) + self.assertFalse(entries[0]["role_inferred"]) + self.assertEqual(entries[0]["big"]["address"], 0x1000) + self.assertEqual(entries[0]["small"]["address"], 0x2000) + + def test_decode_entry_explicit_roles(self): + pte = decode_entry(make_pte(), role="pte") + self.assertEqual(pte["role"], "pte") + pde = decode_entry(make_pde(), role="pde") + self.assertEqual(pde["role"], "pde") + dual = decode_entry(make_dual(), kind=KIND_VER2_DUAL, role="dual_pde") + self.assertEqual(dual["role"], "dual_pde") + with self.assertRaises(GmmuDecodeError) as ctx: + decode_entry(0, role="bogus") + self.assertEqual(ctx.exception.code, E_UNSUPPORTED) + + def test_output_is_deterministic(self): + blob = (make_pte(aperture=2, address=0x1000).to_bytes(8, "little") + + make_pde(aperture=1, address=0x2000).to_bytes(8, "little") + + make_dual(address_big=0x3000, + address_small=0x4000).to_bytes(16, "little")) + first = decode_table(blob, base_pa=0x1000) + second = decode_table(blob, base_pa=0x1000) + self.assertEqual(first, second) + self.assertEqual(json.dumps(first, sort_keys=True), + json.dumps(second, sort_keys=True)) + + def test_blob_accepts_bytearray_and_memoryview(self): + pte = make_pte(aperture=2, address=0x1000).to_bytes(8, "little") + for blob in (bytearray(pte), memoryview(pte)): + entries = decode_table(blob, base_pa=0) + self.assertEqual(entries[0]["address"], 0x1000) + + +class CliTests(unittest.TestCase): + def test_value_json(self): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + rc = main(["--value", hex(make_pte(aperture=2, address=0x4000)), + "--role", "pte", "--json"]) + self.assertEqual(rc, 0) + data = json.loads(buf.getvalue()) + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["role"], "pte") + self.assertEqual(data[0]["address"], 0x4000) + + def test_value_level_is_honoured(self): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + rc = main(["--value", hex(make_pte()), "--role", "pte", + "--levels", "3", "--json"]) + self.assertEqual(rc, 0) + data = json.loads(buf.getvalue()) + self.assertEqual(data[0]["level"], 3) + self.assertEqual(data[0]["page_size"], "2m") + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = main(["--value", hex(make_pte()), "--role", "pte", + "--levels", "3,4"]) + self.assertEqual(rc, 2) + self.assertIn(E_BAD_VALUE, err.getvalue()) + + def test_blob_json(self): + blob = (make_pte(aperture=2, address=0x1000).to_bytes(8, "little") + + make_pde(aperture=1, address=0x2000).to_bytes(8, "little")) + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "table.bin") + with open(path, "wb") as handle: + handle.write(blob) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + rc = main(["--blob", path, "--base-pa", "0x1000", "--json"]) + self.assertEqual(rc, 0) + data = json.loads(buf.getvalue()) + self.assertEqual([e["role"] for e in data], ["pte", "pde"]) + self.assertEqual(data[1]["entry_pa"], 0x1008) + + def test_text_output_is_deterministic(self): + value = hex(make_pte(aperture=2, address=0x4000)) + outputs = [] + for _ in range(2): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + rc = main(["--value", value, "--role", "pte"]) + self.assertEqual(rc, 0) + outputs.append(buf.getvalue()) + self.assertEqual(outputs[0], outputs[1]) + self.assertIn("role=pte", outputs[0]) + + def test_truncated_blob_exits_two(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "short.bin") + with open(path, "wb") as handle: + handle.write(b"\x01\x02\x03") + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = main(["--blob", path, "--base-pa", "0x1000"]) + self.assertEqual(rc, 2) + self.assertIn(E_TRUNCATED, err.getvalue()) + + def test_unsupported_level_exits_two(self): + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = main(["--value", "0x1", "--role", "pde", "--levels", "9"]) + self.assertEqual(rc, 2) + self.assertIn(E_UNSUPPORTED, err.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_mpu.py b/debug/risc-v-gsp-rm-vm/tests/test_mpu.py new file mode 100644 index 0000000..73ac81f --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_mpu.py @@ -0,0 +1,177 @@ +"""MMU (NVMPU) model tests: CSR semantics, translation, machine integration. + +The model is exercised directly and through the machine's guest paths; the +real-firmware relocation-copy success is covered by +``tests/test_sim_devices.py`` (boot ladder). All fixtures are synthetic; no +hardware, module, guest or network is touched. +""" +from __future__ import annotations + +import unittest + +from sim.machine import Machine, R_EBREAK +from sim.mpu import ( + MSTATUS_VM_NVMPU, + MMPUATTR, + MMPUIDX, + MMPUPA, + MMPURNG, + MMPUVA, + RiscvMpu, +) + +KB = 1024 + + +def enable(mpu: RiscvMpu) -> None: + mpu.on_csr_write(0x300, MSTATUS_VM_NVMPU << 24) + + +def entry(mpu: RiscvMpu, index: int, va: int, pa: int, rng: int, attr: int = 0x600) -> None: + mpu.on_csr_write(MMPUIDX, index) + mpu.on_csr_write(MMPUPA, pa) + mpu.on_csr_write(MMPURNG, rng) + mpu.on_csr_write(MMPUATTR, attr) + mpu.on_csr_write(MMPUVA, va | 1) # VLD + + +class RiscvMpuTests(unittest.TestCase): + def test_disabled_does_not_translate(self): + mpu = RiscvMpu() + entry(mpu, 0, 0x80000000, 0x1000, 0x1000) + self.assertFalse(mpu.enabled) + self.assertIsNone(mpu.translate(0x80000000)) + + def test_enable_is_the_nvmpu_vm_mode(self): + mpu = RiscvMpu() + entry(mpu, 0, 0x80000000, 0x1000, 0x1000) + mpu.on_csr_write(0x300, 0x1E000000) + self.assertTrue(mpu.enabled) + # Bare mode disables translation again. + mpu.on_csr_write(0x300, 0) + self.assertFalse(mpu.enabled) + self.assertIsNone(mpu.translate(0x80000000)) + + def test_translate_range_and_offset(self): + mpu = RiscvMpu() + entry(mpu, 2, 0x400E000, 0x1000, 0x3000) + enable(mpu) + self.assertEqual(mpu.translate(0x400E000), 0x1000) + self.assertEqual(mpu.translate(0x400E800), 0x1800) + self.assertEqual(mpu.translate(0x4010FFF), 0x3FFF) + self.assertIsNone(mpu.translate(0x400DFFF)) + self.assertIsNone(mpu.translate(0x4011000)) + + def test_lowest_index_precedence(self): + mpu = RiscvMpu() + entry(mpu, 3, 0x1000, 0x9000, 0x1000) + entry(mpu, 0, 0x1000, 0x5000, 0x1000) + entry(mpu, 1, 0x1000, 0x7000, 0x1000) + enable(mpu) + self.assertEqual(mpu.translate(0x1000), 0x5000) + + def test_vld_clear_disables_entry(self): + mpu = RiscvMpu() + entry(mpu, 0, 0x80000000, 0x1000, 0x1000) + enable(mpu) + self.assertIsNotNone(mpu.translate(0x80000000)) + mpu.on_csr_write(MMPUIDX, 0) + mpu.on_csr_write(MMPUVA, 0) # VLD clear + self.assertIsNone(mpu.translate(0x80000000)) + + def test_index_field_is_six_bits(self): + mpu = RiscvMpu() + mpu.on_csr_write(MMPUIDX, 0x140) # 0x40 | 0x00 -> index 0 + entry(mpu, 0, 0x1000, 0x2000, 0x1000) + enable(mpu) + self.assertEqual(mpu.translate(0x1000), 0x2000) + + def test_state_and_reset(self): + mpu = RiscvMpu() + entry(mpu, 0, 0x80000000, 0x1000, 0x1000) + enable(mpu) + state = mpu.state() + self.assertTrue(state["enabled"]) + self.assertEqual(state["entries"][0]["index"], 0) + self.assertEqual(state["entries"][0]["pa"], 0x1000) + mpu.reset() + self.assertFalse(mpu.enabled) + self.assertIsNone(mpu.translate(0x80000000)) + + +class MachineMpuTests(unittest.TestCase): + def test_guest_load_store_through_mapping(self): + m = Machine(memory_mb=1, ram_base=0, unknown_mmio="fault") + m.write_mem(0x1000, b"\x11\x22\x33\x44\x55\x66\x77\x88") + entry(m.mpu, 0, 0x80000000, 0x1000, 0x1000) + enable(m.mpu) + self.assertEqual(m._guest_load(0x80000000, 8), + int.from_bytes(b"\x11\x22\x33\x44\x55\x66\x77\x88", "little")) + m._guest_store(0x80000004, 8, 0xAABBCCDD) + self.assertEqual(m.read_mem(0x1004, 4), b"\xdd\xcc\xbb\xaa") + # The debugger surface stays raw/strict: the mapped VA has no backing. + with self.assertRaises(Exception): + m.read_mem(0x80000000, 4) + + def test_instruction_fetch_is_translated(self): + m = Machine(memory_mb=1, ram_base=0, unknown_mmio="fault") + program = bytes.fromhex("13057000" "73001000") # addi a0,zero,7; ebreak + m.write_mem(0x2000, program) + entry(m.mpu, 0, 0x80002000, 0x2000, 0x1000) + enable(m.mpu) + m.pc = 0x80002000 # virtual entry; the backing lives at PA 0x2000 + reason, pc, _, _ = m.run(10) + self.assertEqual(reason, R_EBREAK) + self.assertEqual(m.x[10], 7) + self.assertEqual(pc, 0x80002004) + + def test_unmapped_fetch_still_faults(self): + m = Machine(memory_mb=1, ram_base=0, unknown_mmio="fault") + entry(m.mpu, 0, 0x80002000, 0x2000, 0x1000) + enable(m.mpu) + m.pc = 0x90000000 # no entry covers it, no backing RAM + reason, _, _, _ = m.run(1) + self.assertNotEqual(reason, R_EBREAK) + + +class Wpr2RelativeOffsetTests(unittest.TestCase): + def test_window_relative_offsets_read_the_same_pair(self): + from sim.devices.fwsec import Wpr2Block + + dev = Wpr2Block() + dev.set_range(0x2000000, 0x6000000) + self.assertEqual(dev.read(0x824, 4), dev.read(0x1FA824, 4)) + self.assertEqual(dev.read(0x828, 4), dev.read(0x1FA828, 4)) + self.assertIsNone(dev.read(0x820, 4)) + + +class BootShimArgsTests(unittest.TestCase): + def test_bootstrap_args_written_and_image_mirror_absent(self): + from sim.gsp_boot import _apply_boot_shim, DEFAULT_IMAGE, DEFAULT_PROFILE + from greeneye.elf import load_elf + from sim.devices import load_profile + + profile = load_profile(DEFAULT_PROFILE) + m = Machine(memory_mb=16, ram_base=0, unknown_mmio="lenient") + shim = dict(profile.get("boot_shim", {})) + shim["_image_path"] = DEFAULT_IMAGE + meta = {"enabled": True, "assumptions": []} + _apply_boot_shim(m, shim, load_elf(DEFAULT_IMAGE), meta) + args = profile["boot_shim"]["bootstrap_args"] + pa = int(args["pa"], 0) + import struct + + fields = struct.unpack("<6Q", m.read_mem(pa, 48)) + self.assertEqual(fields[0], int(args["imem_base_pa"], 0)) + self.assertEqual(fields[1], int(args["imem_size"], 0)) + self.assertEqual(fields[2], int(args["imem_bs_size"], 0)) + self.assertEqual(fields[3], int(args["dmem_base_pa"], 0)) + self.assertEqual(fields[4], int(args["dmem_size"], 0)) + self.assertEqual(fields[5], int(args["dmem_bs_size"], 0)) + # The DMEM image mirror hack is gone from this profile. + self.assertNotIn("image_at", shim) + self.assertNotIn("image_at", meta) + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_sim_devices.py b/debug/risc-v-gsp-rm-vm/tests/test_sim_devices.py index 8063ee9..13912b5 100644 --- a/debug/risc-v-gsp-rm-vm/tests/test_sim_devices.py +++ b/debug/risc-v-gsp-rm-vm/tests/test_sim_devices.py @@ -428,18 +428,34 @@ class BootLadderTests(unittest.TestCase): self.assertEqual(r.mmio_unknown, 0) self.assertEqual(r.device_unhandled, 0) - def test_shim_reaches_relocation_b2(self): + def test_shim_reaches_message_loop_b3(self): + from sim.gsp_boot import run_boot + r = run_boot(image=DEFAULT_IMAGE, profile_path=DEFAULT_PROFILE, + instructions=2_000_000, record=False, use_shim=True) + self.assertTrue(r.ran) + self.assertEqual(r.rung, "B3") + self.assertIn("relocation_copy", r.milestones) + self.assertEqual(r.mmio_unknown, 0) + self.assertEqual(r.device_unhandled, 0) + # The MMU model resolves the resident-section copy, the libos service + # path runs (DMA/pagetable registration) and the message-loop task + # blocks in the port wait -- deterministic, not faked. + self.assertEqual(r.stop_reason, "ecall") + self.assertEqual(r.stop_pc, 0x4018038) + stopped = r.libos.get("stopped") or {} + self.assertEqual(stopped.get("num"), 1) # task_port_send_recv_wait + + def test_shim_reaches_relocation_plus_mmu(self): + """The relocation copy reads its real backing through the MPU.""" from sim.gsp_boot import run_boot r = run_boot(image=DEFAULT_IMAGE, profile_path=DEFAULT_PROFILE, instructions=20_000, record=False, use_shim=True) self.assertTrue(r.ran) - self.assertEqual(r.rung, "B2") self.assertIn("relocation_copy", r.milestones) self.assertEqual(r.mmio_unknown, 0) - # Known unknown: the DMEM runspace content the app relocates from is - # not reconstructed; the run stops there rather than faking it. - self.assertEqual(r.stop_reason, "fault") - self.assertEqual(r.stop_pc, 0x400EA0C) + # The run no longer stops at the old B2 fault; with a 20k budget it + # is mid-init and deterministic. + self.assertNotEqual(r.stop_pc, 0x400EA0C) def test_two_runs_identical(self): from sim.gsp_boot import run_boot |
