diff options
Diffstat (limited to 'debug/risc-v-gsp-rm-vm/sim')
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/__init__.py | 14 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/cycles.py | 247 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/devices/__init__.py | 163 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/devices/base.py | 263 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/devices/csb.py | 89 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/devices/dma.py | 117 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/devices/falcon.py | 228 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py | 80 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/devices/irq.py | 58 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/devices/pmc.py | 90 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/devices/pmu.py | 46 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/devices/priscv.py | 113 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/devices/timer.py | 86 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/gsp_boot.py | 548 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/gsp_profile.py | 317 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/machine.py | 185 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/sim/profiles/tu104-570.144.json | 106 |
17 files changed, 2746 insertions, 4 deletions
diff --git a/debug/risc-v-gsp-rm-vm/sim/__init__.py b/debug/risc-v-gsp-rm-vm/sim/__init__.py index ad20893..c083bb7 100644 --- a/debug/risc-v-gsp-rm-vm/sim/__init__.py +++ b/debug/risc-v-gsp-rm-vm/sim/__init__.py @@ -5,8 +5,17 @@ RV64IMAC from a 256 MiB RAM region with an NS16550A UART; the debugger-facing adapter lives in ``greeneye.sim_backend``. A machine can be given a ``greeneye.device_map.DeviceMap`` and ``unknown_mmio="lenient"`` to serve unmodelled MMIO accesses from harvested first-touch data instead of faulting. +:mod:`sim.cycles` adds a modeled, table-driven cycle counter alongside the +instruction count (not a timing claim; see ``docs/32-GSP-CYCLE-MODEL.md``). """ from .decode import decode16, decode32 # noqa: F401 +from .cycles import ( # noqa: F401 + CYCLE_FIDELITY, + CYCLE_MODEL_NAME, + DEFAULT_MODEL, + NOTE, + CycleModel, +) from .machine import ( # noqa: F401 R_BP, R_BUDGET, @@ -32,6 +41,11 @@ __all__ = [ "decode32", "Trace", "make_trace", + "CycleModel", + "DEFAULT_MODEL", + "CYCLE_MODEL_NAME", + "CYCLE_FIDELITY", + "NOTE", "R_BUDGET", "R_BP", "R_WATCH", diff --git a/debug/risc-v-gsp-rm-vm/sim/cycles.py b/debug/risc-v-gsp-rm-vm/sim/cycles.py new file mode 100644 index 0000000..b841c6c --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/cycles.py @@ -0,0 +1,247 @@ +"""Modeled RV64IMAC instruction-class cycle accounting for the native core. + +**Every number in this module is a modeled default - NOT a measurement.** +The tables encode deliberately simple, public assumptions about an in-order +single-issue RISC-V core: one cycle for plain ALU work, a multi-cycle cost for +the M extension, a fixed modeled latency added when a memory access completes, +and a larger one for MMIO. They are *not* derived from the GSP core, not from +silicon, and not from vendor data. + +The simulator's fidelity statement is unchanged (docs/06-ACCURACY.md): +instruction semantics (L0) and memory behaviour (L1) are modelled; L2 +(time/concurrency) is **not**. A cycle count from this module is a +deterministic accounting artifact under an explicit model - never a timing, +latency, throughput or silicon claim. + +The model is deterministic by construction: cost depends only on the decoded +instruction class (``sim.decode`` kind/sub) and whether a completed access was +RAM or MMIO. No host clock, no entropy, no caches, no pipeline hazards. +""" +from __future__ import annotations + +from .decode import ( + A_DIV, + A_DIVU, + A_MUL, + A_MULH, + A_MULHSU, + A_MULHU, + A_REM, + A_REMU, + K_AMO, + K_AUIPC, + K_BRANCH, + K_CSR, + K_EBREAK, + K_ECALL, + K_FENCE, + K_ILLEGAL, + K_JAL, + K_JALR, + K_LOAD, + K_LUI, + K_MRET, + K_OP, + K_OP32, + K_OPI, + K_OPI32, + K_STORE, +) + +#: Model identifier written into session metadata and reports. +CYCLE_MODEL_NAME = "rv64imac-modeled/1" + +#: Fidelity label that must travel with every cycle number from this module. +CYCLE_FIDELITY = "L2-not-modelled; cycles are modeled defaults, not measured" + +#: One-line provenance/label, repeated wherever the model is described. +NOTE = ( + "modeled defaults sourced from public in-order RISC-V pipeline assumptions; " + "not measured, not a silicon timing claim" +) + +# Instruction classes used for reporting and per-class reasoning. +CLASS_ALU = "alu" +CLASS_BRANCH = "branch" +CLASS_JUMP = "jump" +CLASS_MUL = "mul" +CLASS_DIV = "div" +CLASS_LOAD = "load" +CLASS_STORE = "store" +CLASS_AMO = "amo" +CLASS_CSR = "csr" +CLASS_TRAP = "trap" +CLASS_FENCE = "fence" +CLASS_ILLEGAL = "illegal" + +_CLASS_BY_KIND = { + K_LUI: CLASS_ALU, + K_AUIPC: CLASS_ALU, + K_JAL: CLASS_JUMP, + K_JALR: CLASS_JUMP, + K_BRANCH: CLASS_BRANCH, + K_LOAD: CLASS_LOAD, + K_STORE: CLASS_STORE, + K_OPI: CLASS_ALU, + K_OPI32: CLASS_ALU, + K_FENCE: CLASS_FENCE, + K_CSR: CLASS_CSR, + K_ECALL: CLASS_TRAP, + K_EBREAK: CLASS_TRAP, + K_MRET: CLASS_TRAP, + K_AMO: CLASS_AMO, + K_ILLEGAL: CLASS_ILLEGAL, +} + +#: Sentinel in ``base_costs``: the cost lives in ``op_costs`` (M extension). +_SENTINEL = -1 + +_M_EXT_KINDS = (K_OP, K_OP32) + + +class CycleModel: + """Table-driven, deterministic instruction-class cycle costs. + + All keyword values are **modeled defaults** (see the module docstring); + they are parameters so a future calibration can replace them without + touching the execution loop. ``memory_latency``/``mmio_latency`` are the + extra cycles added when a load/store/AMO access completes (RAM vs + modelled-MMIO respectively); they are *not* cache models. + """ + + name = CYCLE_MODEL_NAME + + def __init__( + self, + *, + alu: int = 1, # plain integer ALU / shift / lui / auipc / op-imm + branch: int = 1, # conditional branch (taken penalty deliberately not modelled) + jump: int = 1, # jal / jalr + mul: int = 3, # mul / mulh / mulhsu / mulhu (conservative) + div: int = 20, # div / divu / rem / remu (conservative, data-independent) + load: int = 1, # load base; + memory_latency/mmio_latency on completion + store: int = 1, # store base; + memory_latency/mmio_latency on completion + amo: int = 2, # amoswap/lr/sc base; + one memory latency on completion + csr: int = 2, # CSR read/modify/write + trap: int = 3, # ecall / ebreak / mret + fence: int = 1, # fence / fence.i (no ordering model; nominal) + illegal: int = 1, # decode failure (execution stops immediately) + memory_latency: int = 3, # extra cycles for a completed RAM access + mmio_latency: int = 12, # extra cycles for a completed unmodelled-MMIO access + ): + for label, value in ( + ("alu", alu), + ("branch", branch), + ("jump", jump), + ("mul", mul), + ("div", div), + ("load", load), + ("store", store), + ("amo", amo), + ("csr", csr), + ("trap", trap), + ("fence", fence), + ("illegal", illegal), + ("memory_latency", memory_latency), + ("mmio_latency", mmio_latency), + ): + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError(f"cycle model: {label} must be a non-negative int, got {value!r}") + if alu < 1 or branch < 1 or jump < 1: + raise ValueError("cycle model: alu/branch/jump must be >= 1 for a clean per-instruction model") + self.alu = alu + self.branch = branch + self.jump = jump + self.mul = mul + self.div = div + self.load = load + self.store = store + self.amo = amo + self.csr = csr + self.trap = trap + self.fence = fence + self.illegal = illegal + self.memory_latency = memory_latency + self.mmio_latency = mmio_latency + + base = [0] * 18 + base[K_LUI] = alu + base[K_AUIPC] = alu + base[K_JAL] = jump + base[K_JALR] = jump + base[K_BRANCH] = branch + base[K_LOAD] = load + base[K_STORE] = store + base[K_OPI] = alu + base[K_OP] = _SENTINEL + base[K_OPI32] = alu + base[K_OP32] = _SENTINEL + base[K_FENCE] = fence + base[K_CSR] = csr + base[K_ECALL] = trap + base[K_EBREAK] = trap + base[K_MRET] = trap + base[K_AMO] = amo + base[K_ILLEGAL] = illegal + + op = [alu] * 18 + for sub in (A_MUL, A_MULH, A_MULHSU, A_MULHU): + op[sub] = mul + for sub in (A_DIV, A_DIVU, A_REM, A_REMU): + op[sub] = div + # Immutable fast-path tables consumed by Machine.run's hot loop. + self.base_costs = tuple(base) + self.op_costs = tuple(op) + + # -- queries ----------------------------------------------------------- + + def instruction_cost(self, kind: int, sub: int = 0) -> int: + """Modeled cycles for one decoded instruction (memory latency excluded).""" + cost = self.base_costs[kind] + if cost == _SENTINEL: + return self.op_costs[sub] + return cost + + def memory_cost(self, is_mmio: bool = False) -> int: + """Modeled extra cycles when a load/store/AMO access completes.""" + return self.mmio_latency if is_mmio else self.memory_latency + + @staticmethod + def classify(kind: int, sub: int = 0) -> str: + """Human-readable class for a decoded instruction (reporting only).""" + if kind in _M_EXT_KINDS: + if sub >= A_DIV: + return CLASS_DIV + if sub >= A_MUL: + return CLASS_MUL + return CLASS_ALU + return _CLASS_BY_KIND.get(kind, "other") + + def describe(self) -> dict: + """The full table, for session metadata and reports.""" + return { + "name": self.name, + "note": NOTE, + "fidelity": CYCLE_FIDELITY, + "classes": { + CLASS_ALU: self.alu, + CLASS_BRANCH: self.branch, + CLASS_JUMP: self.jump, + CLASS_MUL: self.mul, + CLASS_DIV: self.div, + CLASS_LOAD: self.load, + CLASS_STORE: self.store, + CLASS_AMO: self.amo, + CLASS_CSR: self.csr, + CLASS_TRAP: self.trap, + CLASS_FENCE: self.fence, + CLASS_ILLEGAL: self.illegal, + }, + "memory_latency": self.memory_latency, + "mmio_latency": self.mmio_latency, + } + + +#: Shared default model. Machine() uses it unless told otherwise; it is +#: immutable in practice, so one instance is safe for every target. +DEFAULT_MODEL = CycleModel() diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/__init__.py b/debug/risc-v-gsp-rm-vm/sim/devices/__init__.py new file mode 100644 index 0000000..caf22b3 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/devices/__init__.py @@ -0,0 +1,163 @@ +"""GSP device model: registry + per-block modules. + +Public surface: + +* :class:`~sim.devices.base.DeviceRegistry` -- address-range lookup, access + log and serialisable state. +* :func:`registry_from_profile` -- build a registry from a versioned + ``sim/profiles/*.json`` document. +* per-block devices: :mod:`sim.devices.pmc`, :mod:`sim.devices.falcon`, + :mod:`sim.devices.priscv`, :mod:`sim.devices.timer`, :mod:`sim.devices.csb`, + :mod:`sim.devices.fwsec`, :mod:`sim.devices.pmu`, plus the + :mod:`sim.devices.dma` translation seam and :mod:`sim.devices.irq` router. + +Evidence and limits for every modelled register are documented in the module +docstrings and in ``docs/36-GSP-DEVICE-MODEL.md``; unmodelled offsets always +stay unhandled so the machine's unknown-MMIO log remains the source of truth +for what is still missing. +""" +from __future__ import annotations + +import json + +from .base import Device, DeviceRegistry, SNAPSHOT_SCHEMA, Window +from .csb import CsbWindow +from .dma import DmaTranslator +from .falcon import FalconBlock +from .fwsec import Wpr2Block +from .irq import IrqRouter +from .pmc import PmcBlock +from .pmu import PmuStub +from .priscv import PriscvBlock +from .timer import PtimerBlock + +PROFILE_SCHEMA = "greeneye-sim-profile/1" + +__all__ = [ + "Device", + "DeviceRegistry", + "Window", + "SNAPSHOT_SCHEMA", + "PROFILE_SCHEMA", + "PmcBlock", + "FalconBlock", + "PriscvBlock", + "PtimerBlock", + "CsbWindow", + "Wpr2Block", + "PmuStub", + "DmaTranslator", + "IrqRouter", + "load_profile", + "registry_from_profile", +] + + +def load_profile(path) -> dict: + """Load and minimally validate a ``greeneye-sim-profile/1`` document.""" + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + if data.get("schema") != PROFILE_SCHEMA: + raise ValueError(f"{path}: expected schema {PROFILE_SCHEMA!r}") + if "devices" not in data or not isinstance(data["devices"], dict): + raise ValueError(f"{path}: missing 'devices' object") + return data + + +def _int(value, default=None): + """Profile numbers may be JSON integers or ``0x``/decimal strings.""" + if value is None: + return default + if isinstance(value, str): + return int(value, 0) + return int(value) + + +def _windows(section: dict, default_size: int = 0x1000): + out = [] + for win in section.get("windows", ()): + out.append((_int(win["base"]), _int(win.get("size", default_size)))) + return out + + +def registry_from_profile(profile: dict, *, machine=None, + max_access_log: int = 100_000) -> DeviceRegistry: + """Build a :class:`DeviceRegistry` from a profile document. + + ``machine`` (optional) supplies RAM to the DMA translation seam; without + it DMA transfers are still modelled structurally but refuse to move data + (recorded, never faked). + """ + if profile.get("schema") != PROFILE_SCHEMA: + raise ValueError(f"expected schema {PROFILE_SCHEMA!r}") + sections = profile.get("devices", {}) + reg = DeviceRegistry(max_access_log=max_access_log) + + dma_section = sections.get("dma", {}) + dma_windows = [ + {"pa": _int(w["pa"]), "sim": _int(w["sim"]), "size": _int(w["size"])} + for w in dma_section.get("windows", ()) + ] + if machine is not None: + dma = DmaTranslator( + ram=machine.ram, + ram_base=machine.ram_base, + ram_size=machine.ram_size, + windows=dma_windows, + ) + else: + dma = DmaTranslator(ram=bytearray(), ram_base=0, ram_size=0, + windows=()) + + falcon = None + priscv = None + + for name, section in sections.items(): + if not isinstance(section, dict): + continue + if name == "dma": + continue + if name == "pmc": + dev = PmcBlock( + chip_id=_int(section.get("chip_id", 0x164)), + major=_int(section.get("major", 0)), + minor=_int(section.get("minor", 0)), + minor_ext=_int(section.get("minor_ext", 0)), + boot_42=(_int(section["boot_42"]) if "boot_42" in section else None), + ) + elif name == "falcon": + shims = section.get("shims", {}) + dev = FalconBlock( + dma=dma, + debuginfo_host_consume=bool(shims.get("debuginfo_host_consume", False)), + debuginfo_stage_mask=_int(section.get("debuginfo_stage_mask", 0x700000)), + debuginfo_stage_shift=_int(section.get("debuginfo_stage_shift", 20)), + ) + falcon = dev + elif name == "priscv": + dev = PriscvBlock() + priscv = dev + elif name == "timer": + if not section.get("enabled", True): + continue + dev = PtimerBlock( + ns_per_insn=float(section.get("ns_per_insn", 1.0)), + clock=(machine.insn_count if machine is not None else None), + ) + elif name == "csb": + dev = CsbWindow(registers=section.get("registers", {})) + elif name == "fwsec": + dev = Wpr2Block( + wpr2_start=(_int(section["wpr2_start"]) if "wpr2_start" in section else None), + wpr2_end=(_int(section["wpr2_end"]) if "wpr2_end" in section else None), + ) + elif name == "pmu": + dev = PmuStub() + else: + raise ValueError(f"unknown device section {name!r}") + for base, size in _windows(section): + reg.add(dev, base, size) + + reg.add_service(IrqRouter(falcon=falcon, priscv=priscv)) + reg.dma = dma + return reg diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/base.py b/debug/risc-v-gsp-rm-vm/sim/devices/base.py new file mode 100644 index 0000000..877109c --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/devices/base.py @@ -0,0 +1,263 @@ +"""Device-model core: address-range registry and device base classes. + +The registry decodes a guest MMIO address to at most one modelled device +window (``base <= addr < base + size``) and asks that device for read/write +semantics. Modeled windows take precedence over the lenient device-map / +unknown fallback in :class:`sim.machine.Machine`; accesses inside a known +window that the device does not model are *not* silently zeroed here -- they +return ``unhandled`` so the machine still logs them as unknowns. + +Every modelled access is recorded in :attr:`DeviceRegistry.accesses` with the +PC, width, direction and value so a session can explain exactly what the +device model served. All state is exposed as JSON-serialisable data +(:meth:`Device.state` / :meth:`DeviceRegistry.snapshot`) so snapshot support +can be layered on later without changing this API. + +Evidence rule (SIMULATOR-WORK-BOUNDARY / docs/35): a register is only given +semantics when it is backed by at least one of + +* a hardware register/field header (offset, width, access, reset/field docs); +* a firmware/source use (the code that reads or writes it); +* an observed access pattern (address, width, direction, PC, value sequence). + +Each device class documents the evidence kind for every register it models. +Anything else stays ``unhandled`` and is reported by the machine's unknown +log; we never invent a read-back value. +""" +from __future__ import annotations + +import json + +#: Schema tag for :meth:`DeviceRegistry.snapshot`. +SNAPSHOT_SCHEMA = "greeneye-sim-devices/1" + + +class Device: + """One modelled register block at one or more address windows. + + Subclasses implement :meth:`read`/:meth:`write` (guest side, side effects + allowed) and :meth:`peek` (debugger side, no side effects). Returning + ``None`` from ``read``/``peek`` or ``False`` from ``write`` means "not + modelled"; the registry then reports the access as unhandled so it is + still logged by the caller. + """ + + name = "device" + + def __init__(self, base: int = 0, size: int = 0): + self.base = base + self.size = size + + # -- guest-visible semantics ------------------------------------------ + + def read(self, offset: int, size: int) -> int | None: + return None + + def write(self, offset: int, size: int, value: int) -> bool: + return False + + # -- debugger-visible semantics (no side effects) ---------------------- + + def peek(self, offset: int, size: int) -> int | None: + return self.read(offset, size) + + # -- serialisable state ------------------------------------------------ + + def state(self) -> dict: + return {} + + def load_state(self, data: dict) -> None: + pass + + def reset(self) -> None: + self.load_state({}) + + # -- helpers ----------------------------------------------------------- + + @staticmethod + def _mask(size: int) -> int: + return (1 << (8 * size)) - 1 + + def __repr__(self) -> str: # pragma: no cover - cosmetic + return f"{type(self).__name__}({self.name} @ {self.base:#x}+{self.size:#x})" + + +class Window: + """One (base, size) -> :class:`Device` mapping.""" + + __slots__ = ("base", "size", "device") + + def __init__(self, base: int, size: int, device: Device): + self.base = base + self.size = size + self.device = device + + def contains(self, addr: int, size: int) -> bool: + return self.base <= addr and addr + size <= self.base + self.size + + def to_dict(self) -> dict: + return { + "base": self.base, + "size": self.size, + "device": self.device.name, + } + + +class DeviceRegistry: + """Ordered set of device windows plus an access log. + + Lookup is last-registered-first so a narrow window may override a wider + one without hiding it: when the last matching window does not implement the + access (``read`` returns ``None`` / ``write`` returns ``False``), the + registry continues to the next matching window (safe fallback). If every + matching window refuses, the access is recorded once as unhandled. + Accesses are matched by exact containment (``addr + size`` inside the + window) so a straddling access is never half-served. + """ + + def __init__(self, *, max_access_log: int = 100_000): + self.windows: list[Window] = [] + self.devices: list[Device] = [] + self.services: list[object] = [] + self.dma = None # optional DmaTranslator aperture seam + self.accesses: list[dict] = [] + self.dropped_accesses = 0 + self.unhandled = 0 + self.max_access_log = max(0, int(max_access_log)) + + def add_service(self, service) -> object: + """Attach a non-window service (e.g. an IRQ router) for state export.""" + self.services.append(service) + return service + + # -- construction ------------------------------------------------------ + + def add(self, device: Device, base: int | None = None, size: int | None = None) -> Device: + """Register ``device`` for one window (defaults: device.base/size).""" + b = device.base if base is None else base + s = device.size if size is None else size + if s <= 0: + raise ValueError(f"device window size must be positive, got {s}") + if device not in self.devices: + self.devices.append(device) + self.windows.append(Window(b, s, device)) + return device + + def device_by_name(self, name: str) -> Device | None: + for dev in self.devices: + if dev.name == name: + return dev + return None + + # -- lookup ------------------------------------------------------------ + + def _candidates(self, addr: int, size: int): + """Matching windows, last-registered-first (fallback order).""" + for win in reversed(self.windows): + if win.contains(addr, size): + yield win + + def _record(self, addr: int, size: int, write: bool, value: int | None, + handled: bool, pc: int) -> None: + entry = { + "addr": addr, + "size": size, + "rw": "w" if write else "r", + "handled": bool(handled), + "pc": pc, + } + if write and value is not None: + entry["value"] = value & ((1 << (8 * size)) - 1) + if handled and not write: + entry["value"] = value + if len(self.accesses) < self.max_access_log: + self.accesses.append(entry) + else: + self.dropped_accesses += 1 + + def read(self, addr: int, size: int, pc: int = 0) -> tuple[bool, int]: + """Guest read: ``(handled, value)``. Unhandled accesses are logged. + + The last matching window is asked first; a window that does not model + the offset defers to the next matching window. + """ + matched = False + for win in self._candidates(addr, size): + matched = True + value = win.device.read(addr - win.base, size) + if value is not None: + self._record(addr, size, False, value, True, pc) + return True, value + self.unhandled += 1 + if matched: + self._record(addr, size, False, None, False, pc) + return False, 0 + + def write(self, addr: int, size: int, value: int, pc: int = 0) -> bool: + """Guest write: True when handled. Unhandled accesses are logged. + + The last matching window is asked first; a window that does not model + the offset defers to the next matching window. + """ + matched = False + for win in self._candidates(addr, size): + matched = True + if win.device.write(addr - win.base, size, value): + self._record(addr, size, True, value, True, pc) + return True + self.unhandled += 1 + if matched: + self._record(addr, size, True, value, False, pc) + return False + + def peek(self, addr: int, size: int) -> bytes | None: + """Debugger read of device space: bytes, or None when unmapped.""" + for win in self._candidates(addr, size): + value = win.device.peek(addr - win.base, size) + if value is not None: + return int(value).to_bytes(size, "little") + return None + + # -- state ------------------------------------------------------------- + + def state(self) -> dict: + return { + "schema": SNAPSHOT_SCHEMA, + "devices": [{"name": d.name, "base": d.base, "size": d.size, + "state": d.state()} for d in self.devices], + "services": [{"name": getattr(s, "name", type(s).__name__), + "state": s.state()} for s in self.services], + "counters": { + "accesses": len(self.accesses), + "dropped_accesses": self.dropped_accesses, + "unhandled": self.unhandled, + }, + } + + def load_state(self, data: dict) -> None: + if data.get("schema") != SNAPSHOT_SCHEMA: + raise ValueError(f"not a {SNAPSHOT_SCHEMA} snapshot") + for entry in data.get("devices", []): + dev = self.device_by_name(entry.get("name", "")) + if dev is not None: + dev.load_state(entry.get("state", {})) + counters = data.get("counters", {}) + self.dropped_accesses = int(counters.get("dropped_accesses", 0)) + self.unhandled = int(counters.get("unhandled", 0)) + + def snapshot_json(self) -> str: + return json.dumps(self.state(), sort_keys=True) + + def reset(self) -> None: + self.accesses.clear() + self.dropped_accesses = 0 + self.unhandled = 0 + for dev in self.devices: + dev.reset() + for service in self.services: + reset = getattr(service, "reset", None) + if callable(reset): + reset() + + def describe(self) -> list[dict]: + return [w.to_dict() for w in self.windows] diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/csb.py b/debug/risc-v-gsp-rm-vm/sim/devices/csb.py new file mode 100644 index 0000000..818a263 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/devices/csb.py @@ -0,0 +1,89 @@ +"""CSB / TRANSCOM message-window seam (B3 landing point). + +Evidence kind: **hardware register/field header + observed access pattern** +(once the firmware reaches the message loop). At the time of writing the +570.144 boot has not been observed polling the GSP message-queue CSB window, +so this module deliberately models **no register semantics by default**: a +window is only served when a profile declares it *and* lists the individual +registers with their widths, reset values and semantics (``rw``, ``w1c``, +``set``, ``clear``). Undeclared offsets stay unhandled and are logged. + +The CSB/TRANSCOM window carries the host<->GSP message queues and doorbells +(head/tail pointers, interrupt set/clear); B3 requires the first such poll to +return the documented read-back, which will be added here together with the +observed access evidence and a test. +""" +from __future__ import annotations + +from .base import Device + +#: Per-register semantics accepted by a profile. +SEMANTICS = ("rw", "ro", "wo", "w1c", "set", "clear") + + +class CsbWindow(Device): + """Profile-declared CSB/mailbox register window (empty by default).""" + + name = "csb" + + def __init__(self, *, base: int = 0, size: int = 0x1000, registers=None): + super().__init__(base=base, size=size) + # registers: {offset: {"width": 4, "value": int, "semantics": "rw"}} + self.registers: dict[int, dict] = {} + for off, spec in (registers or {}).items(): + self.declare(int(off), spec) + self.reset() + + def declare(self, offset: int, spec: dict) -> None: + semantics = spec.get("semantics", "rw") + if semantics not in SEMANTICS: + raise ValueError(f"unknown CSB semantics {semantics!r}") + width = int(spec.get("width", 4)) + if width not in (1, 2, 4, 8): + raise ValueError(f"unsupported CSB register width {width}") + self.registers[offset] = { + "width": width, + "reset": int(spec.get("value", 0)), + "semantics": semantics, + } + + def reset(self) -> None: + self.values = {off: spec["reset"] for off, spec in self.registers.items()} + + def _spec(self, offset: int, size: int) -> dict | None: + spec = self.registers.get(offset) + if spec is None or spec["width"] != size: + return None + return spec + + def read(self, offset: int, size: int) -> int | None: + spec = self._spec(offset, size) + if spec is None or spec["semantics"] == "wo": + return None + return self.values.get(offset, 0) + + def write(self, offset: int, size: int, value: int) -> bool: + spec = self._spec(offset, size) + if spec is None or spec["semantics"] == "ro": + return False + mask = (1 << (8 * size)) - 1 + value &= mask + sem = spec["semantics"] + if sem == "w1c": + self.values[offset] = self.values.get(offset, 0) & ~value & mask + elif sem == "set": + self.values[offset] = (self.values.get(offset, 0) | value) & mask + elif sem == "clear": + self.values[offset] = (self.values.get(offset, 0) & ~value) & mask + else: + self.values[offset] = value + return True + + def state(self) -> dict: + return {"values": {str(k): v for k, v in sorted(self.values.items())}, + "declared": sorted(self.registers)} + + def load_state(self, data: dict) -> None: + values = {int(k): int(v) for k, v in data.get("values", {}).items()} + self.values = {off: values.get(off, spec["reset"]) + for off, spec in self.registers.items()} diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/dma.py b/debug/risc-v-gsp-rm-vm/sim/devices/dma.py new file mode 100644 index 0000000..45d03e5 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/devices/dma.py @@ -0,0 +1,117 @@ +"""DMA / address-aperture translation seam. + +Evidence kind: **hardware address-map header + firmware use + observed +access pattern (through the falcon DMA registers).** + +The GSP RISC-V address map (Turing) defines apertures the firmware and its +DMA engine address in GPU physical space: + +* ``IMEM`` ``0x0_0000_0000`` (4 GiB window) -- local instruction memory +* ``DMEM`` ``0x1_0000_0000`` (4 GiB window) -- local data memory +* ``EMEM`` ``0x2_0000_0000`` (4 GiB window) -- external memory window +* ``FBGPA`` ``0x6180_0000_0000_0000`` -- framebuffer aperture +* ``SYSGPA````0x8180_0000_0000_0000`` -- system-memory aperture +* ``PRIV`` ``0x2000_0000_0000_0000`` -- register aperture + +The simulator keeps local memory in one flat RAM region; framebuffer and +system memory only exist as *profile-declared windows* that map a GPU +physical aperture onto a simulator RAM address. A window not declared in the +profile is not modelled: the transfer is refused and recorded (never +silently zero-filled). This class is the single place where that mapping +lives, so a future IOMMU model can replace it without touching devices. +""" +from __future__ import annotations + + +class DmaTranslator: + """Translate GPU physical addresses to simulator RAM through windows.""" + + def __init__(self, *, ram, ram_base: int, ram_size: int, windows=None): + self.ram = ram + self.ram_base = ram_base + self.ram_size = ram_size + self.windows: list[tuple[int, int, int]] = [] # (pa_base, sim_base, size) + self.transfers = 0 + self.refused = 0 + self.events: list[dict] = [] + for win in windows or (): + self.add_window(int(win["pa"]), int(win["sim"]), int(win["size"])) + + def add_window(self, pa_base: int, sim_base: int, size: int) -> None: + if size <= 0: + raise ValueError("DMA window size must be positive") + self.windows.append((pa_base, sim_base, size)) + + def translate(self, pa: int) -> int | None: + """GPU physical address -> simulator address, or None if unmodelled.""" + for pa_base, sim_base, size in reversed(self.windows): + if pa_base <= pa < pa_base + size: + return sim_base + (pa - pa_base) + return None + + def read(self, pa: int, size: int) -> bytes | None: + sim = self.translate(pa) + if sim is None: + self._refuse(pa, size, "r") + return None + return self._read_sim(sim, size) + + def write(self, pa: int, data: bytes) -> bool: + sim = self.translate(pa) + if sim is None: + self._refuse(pa, len(data), "w") + return False + self._write_sim(sim, data) + return True + + def _in_ram(self, addr: int, size: int) -> bool: + off = addr - self.ram_base + return 0 <= off and off + size <= self.ram_size + + def _read_sim(self, sim: int, size: int) -> bytes | None: + if not self._in_ram(sim, size): + self._refuse(sim, size, "r-sim") + return None + off = sim - self.ram_base + return bytes(self.ram[off:off + size]) + + def _write_sim(self, sim: int, data: bytes) -> bool: + if not self._in_ram(sim, len(data)): + self._refuse(sim, len(data), "w-sim") + return False + off = sim - self.ram_base + self.ram[off:off + len(data)] = data + return True + + def copy(self, src_pa: int, dst_pa: int, size: int) -> bool: + """Copy ``size`` bytes between GPU physical addresses. + + A transfer is counted only when both the source read and the + destination write complete; a refused destination write leaves the + transfer in :attr:`refused` and not in :attr:`transfers`. + """ + data = self.read(src_pa, size) + if data is None: + return False + ok = self.write(dst_pa, data) + if ok: + self.transfers += 1 + return ok + + def _refuse(self, pa: int, size: int, kind: str) -> None: + self.refused += 1 + if len(self.events) < 4096: + self.events.append({"pa": pa, "size": size, "kind": kind}) + + def state(self) -> dict: + return { + "windows": [{"pa": p, "sim": s, "size": n} for p, s, n in self.windows], + "transfers": self.transfers, + "refused": self.refused, + "events": list(self.events), + } + + def reset(self) -> None: + self.transfers = 0 + self.refused = 0 + self.events.clear() diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/falcon.py b/debug/risc-v-gsp-rm-vm/sim/devices/falcon.py new file mode 100644 index 0000000..8841906 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/devices/falcon.py @@ -0,0 +1,228 @@ +"""GSP falcon register block (boot polling + handshake + DMA). + +Evidence kind: **hardware register/field header + firmware use + observed +access pattern.** + +The block is reached through the PRIV aperture (``PRIV + 0x110000``) and, in +the libos kernel's private mapping, at virtual base ``0x3_0000_0000`` (the +firmware accesses both forms during boot). Modelled registers, all with +header offsets relative to the block: + +====================================== ====== ===== ========================= +register offset access semantics +====================================== ====== ===== ========================= +``FALCON_IRQSSET`` 0x000 W ``irq_status |= value`` +``FALCON_IRQSCLR`` 0x004 W ``irq_status &= ~value`` +``FALCON_IRQSTAT`` 0x008 R ``irq_status`` +``FALCON_MAILBOX0`` 0x040 RW storage +``FALCON_MAILBOX1`` 0x044 RW storage +``FALCON_OS`` 0x080 RW storage +``FALCON_DEBUGINFO`` 0x094 RW storage (+ host shim) +``FALCON_CPUCTL`` 0x100 RW storage +``FALCON_BOOTVEC`` 0x104 RW storage +``FALCON_DMACTL`` 0x10c RW storage +``FALCON_DMATRFBASE`` 0x110 RW storage +``FALCON_DMATRFMOFFS`` 0x114 RW storage +``FALCON_DMATRFCMD`` 0x118 RW transfer on write +``FALCON_DMATRFFBOFFS`` 0x11c RW storage +``FALCON_DMATRFBASE1`` 0x128 RW storage +``FBIF_TRANSCFG(0..7)`` 0x600 RW storage +``FBIF_CTL`` 0x624 RW storage +====================================== ====== ===== ========================= + +Observed firmware use (570.144): the boot code writes MAILBOX0 (progress +code), MAILBOX1, DEBUGINFO (boot-stage marker ``0x11DEAD``) and IRQSSET +``HALT`` (bit 4), then polls DEBUGINFO bits ``22:20`` until they clear; it +reads back DMACTL/DMATRFCMD/DMATRFBASE/DMATRFBASE1/DMATRFMOFFS/ +DMATRFFBOFFS and IRQSTAT. The DMA transfer semantics (size encoding, IMEM/ +WRITE direction, ``((base1<<32|base)<<8)+fboffs`` addressing) follow the +register header fields and are exercised only through the translation seam +(:mod:`sim.devices.dma`); an unmapped transfer is refused and recorded, never +zero-filled. + +The DEBUGINFO "host consume" behaviour is a **boot shim**, not a device +claim: the firmware's own write-then-poll loop has no self-clearing path, and +the documented debug channel is write-then-wait-for-zero (consumer is the +host side). It is enabled per profile and recorded as an assumption. +""" +from __future__ import annotations + +from .base import Device + +# Register offsets (header values, relative to the falcon block base). +IRQSSET = 0x000 +IRQSCLR = 0x004 +IRQSTAT = 0x008 +MAILBOX0 = 0x040 +MAILBOX1 = 0x044 +OS = 0x080 +DEBUGINFO = 0x094 +CPUCTL = 0x100 +BOOTVEC = 0x104 +DMACTL = 0x10C +DMATRFBASE = 0x110 +DMATRFMOFFS = 0x114 +DMATRFCMD = 0x118 +DMATRFFBOFFS = 0x11C +DMATRFBASE1 = 0x128 +FBIF_TRANSCFG0 = 0x600 +FBIF_TRANSCFG_STRIDE = 4 +FBIF_TRANSCFG_COUNT = 8 +FBIF_CTL = 0x624 + +# DMATRFCMD fields (header). +CMD_FULL = 1 << 0 +CMD_IDLE = 1 << 1 +CMD_IMEM = 1 << 4 +CMD_WRITE = 1 << 5 +CMD_SIZE_SHIFT = 8 +CMD_SIZE_MASK = 0x7 +CMD_CTXDMA_SHIFT = 12 +CMD_CTXDMA_MASK = 0x7 +CMD_ERROR = 1 << 20 + +#: IRQSSET/IRQSCLR bit 4 is HALT in the header's field list; the firmware's +#: boot handshake sets it after writing MAILBOX0/DEBUGINFO. +IRQ_HALT = 1 << 4 + +# IMEM/DMEM apertures of the GSP RISC-V address map (header constants). +AMAP_IMEM_START = 0x0000000000000000 +AMAP_DMEM_START = 0x0000000100000000 + +_RW_REGISTERS = { + MAILBOX0, MAILBOX1, OS, DEBUGINFO, CPUCTL, BOOTVEC, DMACTL, DMATRFBASE, + DMATRFMOFFS, DMATRFCMD, DMATRFFBOFFS, DMATRFBASE1, FBIF_CTL, +} + + +class FalconBlock(Device): + """Falcon/GSP register block with boot-handshake and DMA semantics.""" + + name = "falcon" + + def __init__(self, *, base: int = 0, size: int = 0x1000, dma=None, + debuginfo_host_consume: bool = False, + debuginfo_stage_mask: int = 0x700000, + debuginfo_stage_shift: int = 20): + super().__init__(base=base, size=size) + self.dma = dma + self.debuginfo_host_consume = bool(debuginfo_host_consume) + self.debuginfo_stage_mask = debuginfo_stage_mask + self.debuginfo_stage_shift = debuginfo_stage_shift + self.reset() + + def reset(self) -> None: + self.irq_status = 0 + self.regs: dict[int, int] = {off: 0 for off in _RW_REGISTERS} + self.regs[DMATRFCMD] = CMD_IDLE + self.transcfg = [0] * FBIF_TRANSCFG_COUNT + self.dma_transfers = 0 + self.dma_refused = 0 + self.debuginfo_consumed = 0 + + # -- access ------------------------------------------------------------ + + def read(self, offset: int, size: int) -> int | None: + if size != 4: + return None + if offset == IRQSTAT: + return self.irq_status + if offset == DEBUGINFO: + value = self.regs.get(DEBUGINFO, 0) + if self.debuginfo_host_consume: + stage = (value & self.debuginfo_stage_mask) >> self.debuginfo_stage_shift + if stage: + # Boot shim: the host consumes the stage marker (documented + # write-then-wait-for-zero debug channel); the rest of the + # register is preserved. + self.regs[DEBUGINFO] = value & ~self.debuginfo_stage_mask + self.debuginfo_consumed += 1 + return value + if offset in self.regs: + return self.regs[offset] + if FBIF_TRANSCFG0 <= offset < FBIF_TRANSCFG0 + 4 * FBIF_TRANSCFG_COUNT: + return self.transcfg[(offset - FBIF_TRANSCFG0) // 4] + return None + + def write(self, offset: int, size: int, value: int) -> bool: + if size != 4: + return False + value &= 0xFFFFFFFF + if offset == IRQSSET: + self.irq_status = (self.irq_status | value) & 0xFFFFFFFF + return True + if offset == IRQSCLR: + self.irq_status = (self.irq_status & ~value) & 0xFFFFFFFF + return True + if offset == IRQSTAT: + return False # read-only + if offset == DMATRFCMD: + self.regs[DMATRFCMD] = value + self._dma_transfer(value) + return True + if offset in self.regs: + self.regs[offset] = value + return True + if FBIF_TRANSCFG0 <= offset < FBIF_TRANSCFG0 + 4 * FBIF_TRANSCFG_COUNT: + self.transcfg[(offset - FBIF_TRANSCFG0) // 4] = value + return True + return False + + def peek(self, offset: int, size: int) -> int | None: + # No shim side effects for debugger reads. + if size != 4: + return None + if offset == IRQSTAT: + return self.irq_status + if offset in self.regs: + return self.regs[offset] + if FBIF_TRANSCFG0 <= offset < FBIF_TRANSCFG0 + 4 * FBIF_TRANSCFG_COUNT: + return self.transcfg[(offset - FBIF_TRANSCFG0) // 4] + return None + + # -- DMA --------------------------------------------------------------- + + def _dma_transfer(self, cmd: int) -> None: + """Execute one falcon DMA transfer (header field semantics).""" + if self.dma is None: + self.dma_refused += 1 + return + size = 1 << (((cmd >> CMD_SIZE_SHIFT) & CMD_SIZE_MASK) + 2) + tcm = AMAP_IMEM_START if (cmd & CMD_IMEM) else AMAP_DMEM_START + base = ((self.regs.get(DMATRFBASE1, 0) & 0x1FF) << 32) | self.regs.get(DMATRFBASE, 0) + fb = (base << 8) + self.regs.get(DMATRFFBOFFS, 0) + tcm_off = self.regs.get(DMATRFMOFFS, 0) & 0xFFFF + if cmd & CMD_WRITE: + ok = self.dma.copy(tcm + tcm_off, fb, size) + else: + ok = self.dma.copy(fb, tcm + tcm_off, size) + if ok: + self.dma_transfers += 1 + self.regs[DMATRFCMD] = (cmd | CMD_IDLE) & ~CMD_ERROR + else: + self.dma_refused += 1 + self.regs[DMATRFCMD] = (cmd & ~CMD_IDLE) | CMD_ERROR + + # -- state ------------------------------------------------------------- + + def state(self) -> dict: + return { + "irq_status": self.irq_status, + "regs": {str(k): v for k, v in sorted(self.regs.items())}, + "transcfg": list(self.transcfg), + "dma_transfers": self.dma_transfers, + "dma_refused": self.dma_refused, + "debuginfo_consumed": self.debuginfo_consumed, + "debuginfo_host_consume": self.debuginfo_host_consume, + } + + def load_state(self, data: dict) -> None: + self.irq_status = int(data.get("irq_status", 0)) + regs = {int(k): int(v) for k, v in data.get("regs", {}).items()} + self.regs = {off: regs.get(off, 0) for off in _RW_REGISTERS} + self.transcfg = [int(v) for v in data.get("transcfg", [0] * FBIF_TRANSCFG_COUNT)] + self.dma_transfers = int(data.get("dma_transfers", 0)) + self.dma_refused = int(data.get("dma_refused", 0)) + self.debuginfo_consumed = int(data.get("debuginfo_consumed", 0)) + self.debuginfo_host_consume = bool( + data.get("debuginfo_host_consume", self.debuginfo_host_consume)) diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py b/debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py new file mode 100644 index 0000000..8dcf633 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py @@ -0,0 +1,80 @@ +"""WPR2 / FWSEC state registers. + +Evidence kind: **hardware register/field header + firmware use** (the boot +flow locates the firmware write-protected region from these registers on +pre-Hopper parts; the host/booter writes them, the firmware reads them). + +Modelled registers (offsets in the FB/PFB register aperture): + +* ``NV_PFB_PRI_MMU_WPR2_ADDR_LO`` ``0x1FA824``: WPR2 start, bits ``31:4`` + shifted by ``NV_PFB_PRI_MMU_WPR2_ADDR_LO_ALIGNMENT = 12`` (4 KiB). +* ``NV_PFB_PRI_MMU_WPR2_ADDR_HI`` ``0x1FA828``: points at the start of the + last 128 KiB block of the WPR, same alignment; the end is + ``(value << 12) + 0x20000``. + +A profile that wants the firmware to see a WPR declares ``wpr2_start`` / +``wpr2_end``; without a declaration the window is unhandled and logged. +FWSEC-specific state is not modelled (no proven register use yet). +""" +from __future__ import annotations + +from .base import Device + +WPR2_ADDR_LO = 0x1FA824 +WPR2_ADDR_HI = 0x1FA828 +WPR2_ALIGNMENT = 12 +WPR2_BLOCK = 0x20000 + + +class Wpr2Block(Device): + """WPR2 address pair as seen by the firmware (host/booter-written).""" + + name = "fwsec" + + def __init__(self, *, base: int = 0, size: int = 0x1000, + wpr2_start: int | None = None, wpr2_end: int | None = None): + super().__init__(base=base, size=size) + self.reset() + if wpr2_start is not None and wpr2_end is not None: + self.set_range(wpr2_start, wpr2_end) + + def set_range(self, start: int, end: int) -> None: + if end <= start: + raise ValueError("WPR2 end must be greater than start") + self.regs[WPR2_ADDR_LO] = (start >> WPR2_ALIGNMENT) & 0xFFFFFFFF + self.regs[WPR2_ADDR_HI] = ((end - WPR2_BLOCK) >> WPR2_ALIGNMENT) & 0xFFFFFFFF + + @property + def start(self) -> int | None: + """WPR2 start derived from ADDR_LO (``None`` when unset).""" + lo = self.regs.get(WPR2_ADDR_LO) + return None if lo is None else lo << WPR2_ALIGNMENT + + @property + def end(self) -> int | None: + """WPR2 end derived from ADDR_HI (``None`` when unset).""" + hi = self.regs.get(WPR2_ADDR_HI) + return None if hi is None else (hi << WPR2_ALIGNMENT) + WPR2_BLOCK + + def reset(self) -> None: + 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] + 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 + return True + return False + + def state(self) -> dict: + return {"regs": {str(k): v for k, v in sorted(self.regs.items())}, + "start": self.start, "end": self.end} + + def load_state(self, data: dict) -> None: + # start/end are derived from regs; serialised copies are ignored so a + # restored block can never report a stale range. + self.regs = {int(k): int(v) for k, v in data.get("regs", {}).items()} diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/irq.py b/debug/risc-v-gsp-rm-vm/sim/devices/irq.py new file mode 100644 index 0000000..7ce63fb --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/devices/irq.py @@ -0,0 +1,58 @@ +"""IRQ routing seam. + +Evidence kind: **hardware register/field header + observed access pattern.** + +The GSP boot handshake sets the falcon ``IRQSSET`` HALT bit (bit 4) after +writing MAILBOX0/DEBUGINFO, and the boot code reads ``FALCON_IRQSTAT``; the +libos kernel owns the RISC-V-local ``RISCV_IRQMSET``/``IRQMCLR``/``IRQMASK`` +group. This module aggregates those two sources behind one object so a +future core-interrupt model can consume a single ``pending()`` surface. + +It does **not** deliver interrupts to the machine's hart: mstatus/mie/mip/ +mtvec trap entry is not modelled (documented unknown). It only makes the +routing state explicit and serialisable. +""" +from __future__ import annotations + +# Falcon IRQSSET/IRQSCLR bit positions used by the boot handshake (header +# field list; only the ones with proven firmware use are named). +FALCON_IRQ_GPTMR = 1 << 0 +FALCON_IRQ_WDTMR = 1 << 1 +FALCON_IRQ_MTHD = 1 << 2 +FALCON_IRQ_CTXSW = 1 << 3 +FALCON_IRQ_HALT = 1 << 4 +FALCON_IRQ_EXTERR = 1 << 5 +FALCON_IRQ_SWGEN0 = 1 << 6 +FALCON_IRQ_SWGEN1 = 1 << 7 +FALCON_IRQ_DMA = 1 << 16 +FALCON_IRQ_SHA = 1 << 17 + + +class IrqRouter: + """Aggregate falcon + RISC-V-local interrupt state.""" + + name = "irq" + + def __init__(self, *, falcon=None, priscv=None): + self.falcon = falcon + self.priscv = priscv + + def falcon_status(self) -> int: + return self.falcon.irq_status if self.falcon is not None else 0 + + def riscv_mask(self) -> int: + return self.priscv.irq_mask if self.priscv is not None else 0 + + def pending(self) -> dict: + """Raw pending state; delivery is deliberately not modelled.""" + return { + "falcon_irq_status": self.falcon_status(), + "falcon_halt": bool(self.falcon_status() & FALCON_IRQ_HALT), + "riscv_irq_mask": self.riscv_mask(), + } + + def state(self) -> dict: + return self.pending() + + def reset(self) -> None: + pass diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/pmc.py b/debug/risc-v-gsp-rm-vm/sim/devices/pmc.py new file mode 100644 index 0000000..1ccf1cd --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/devices/pmc.py @@ -0,0 +1,90 @@ +"""PMC / master register block (first-touch device). + +Evidence kind: **hardware register/field header + firmware use + observed +access pattern.** + +* ``NV_PMC_BOOT_42`` lives at offset ``0xA00`` of the master (PMC) block and is + read by the GSP boot code through the PRIV window at + ``0x2000_0000_0000_0A00`` (observed 4-byte read, first MMIO of the real + 570.144 image; the QEMU baseline faults there). +* The firmware extracts bits ``28:20`` (``NV_PMC_BOOT_42_CHIP_ID``) and + compares against the TU10x IDs ``0x162..0x168``; only a match continues the + boot (observed branch chain at firmware vaddr ``0x4009020``). +* ``NV_PMC_BOOT_42`` field layout: ARCHITECTURE ``28:24`` (TU100 = ``0x16``), + IMPLEMENTATION ``23:20``, MAJOR_REVISION ``19:16``, MINOR_REVISION + ``15:12``, MINOR_EXTENDED_REVISION ``11:8``, CHIP_ID ``28:20``. + +Only ``0xA00`` is modelled; every other offset in the window stays unhandled +and is logged by the machine. +""" +from __future__ import annotations + +from .base import Device + +BOOT_42_OFFSET = 0xA00 +CHIP_ID_SHIFT = 20 +CHIP_ID_MASK = 0x1FF +ARCHITECTURE_MASK = 0x1F +IMPLEMENTATION_MASK = 0xF +MAJOR_MASK = 0xF +MINOR_MASK = 0xF +MINOR_EXT_MASK = 0xF + +#: Chip IDs seen in the firmware's comparison chain (header field values). +#: TU105 (0x165) is kept because it appears in the observed chain, but its +#: product mapping is unverified. The former ``TU107 = 0x167`` entry was a +#: transcription error (0x167 is TU117) and has been removed. +TU102 = 0x162 +TU104 = 0x164 +TU105 = 0x165 # unverified product mapping (observed in the chain only) +TU106 = 0x166 +TU116 = 0x168 +TU117 = 0x167 + + +def boot_42_value(chip_id: int, *, major: int = 0, minor: int = 0, + minor_ext: int = 0) -> int: + """Assemble an ``NV_PMC_BOOT_42`` value from its documented fields. + + ARCHITECTURE and IMPLEMENTATION are derived from ``chip_id`` (they are the + upper/lower nibbles of the same bits ``28:20``), matching the header's + ``CHIP_ID_TU104 = 0x164`` / ``ARCHITECTURE_TU100 = 0x16`` pair. + """ + chip_id &= CHIP_ID_MASK + arch = (chip_id >> 4) & ARCHITECTURE_MASK + impl = chip_id & IMPLEMENTATION_MASK + value = (chip_id << CHIP_ID_SHIFT) + value |= (arch << 24) | (impl << 20) + value |= (major & MAJOR_MASK) << 16 + value |= (minor & MINOR_MASK) << 12 + value |= (minor_ext & MINOR_EXT_MASK) << 8 + return value + + +class PmcBlock(Device): + """Master/PMC block: ``NV_PMC_BOOT_42`` chip identification.""" + + name = "pmc" + + def __init__(self, *, chip_id: int = TU104, major: int = 0, minor: int = 0, + minor_ext: int = 0, boot_42: int | None = None, + base: int = 0, size: int = 0x1000): + super().__init__(base=base, size=size) + self.chip_id = chip_id + self.boot_42 = boot_42 if boot_42 is not None else boot_42_value( + chip_id, major=major, minor=minor, minor_ext=minor_ext) + + def read(self, offset: int, size: int) -> int | None: + if offset == BOOT_42_OFFSET and size == 4: + return self.boot_42 + return None + + def peek(self, offset: int, size: int) -> int | None: + return self.read(offset, size) + + def write(self, offset: int, size: int, value: int) -> bool: + # NV_PMC_BOOT_42 is read-only (R--4R). + return False + + def state(self) -> dict: + return {"chip_id": self.chip_id, "boot_42": self.boot_42} diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/pmu.py b/debug/risc-v-gsp-rm-vm/sim/devices/pmu.py new file mode 100644 index 0000000..b241c7e --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/devices/pmu.py @@ -0,0 +1,46 @@ +"""PMU stub. + +Evidence kind: **none yet** (no PMU register has been observed in the +570.144 boot runs, and no PMU register semantics are needed to reach the +rungs achieved so far). The class exists so a profile can attach a PMU +window without inventing behaviour: every access stays unhandled and is +reported by the machine's unknown log, and the stub records the accesses it +saw for later identification. + +When PMU accesses are observed, each register is added here with its header +field evidence and a test, exactly like the falcon block. +""" +from __future__ import annotations + +from .base import Device + + +class PmuStub(Device): + """A PMU window with no proven semantics: observe, never fake.""" + + name = "pmu" + + def __init__(self, *, base: int = 0, size: int = 0x1000): + super().__init__(base=base, size=size) + self.reset() + + def reset(self) -> None: + self.seen: dict[str, int] = {} + + def read(self, offset: int, size: int) -> int | None: + self._note(offset, size, "r") + return None + + def write(self, offset: int, size: int, value: int) -> bool: + self._note(offset, size, "w") + return False + + def peek(self, offset: int, size: int) -> int | None: + return None + + def _note(self, offset: int, size: int, rw: str) -> None: + key = f"{rw}:{offset:#x}:{size}" + self.seen[key] = self.seen.get(key, 0) + 1 + + def state(self) -> dict: + return {"seen": dict(sorted(self.seen.items()))} diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/priscv.py b/debug/risc-v-gsp-rm-vm/sim/devices/priscv.py new file mode 100644 index 0000000..62a8fea --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/devices/priscv.py @@ -0,0 +1,113 @@ +"""RISC-V local (PRISCV) register block. + +Evidence kind: **hardware register/field header + observed access pattern.** + +Reached through the PRIV aperture at ``PRIV + 0x111000`` and through the libos +kernel's private mapping at ``0x3_0000_1000`` (the firmware reads the trace +and error-status registers there during boot). Modelled registers: + +================================ ====== ===== ============================== +register offset access semantics +================================ ====== ===== ============================== +``RISCV_IRQMSET`` 0x2ac W ``irq_mask |= value`` +``RISCV_IRQMCLR`` 0x2b0 W ``irq_mask &= ~value`` +``RISCV_IRQMASK`` 0x2b4 R ``irq_mask`` +``RISCV_IRQDEST`` 0x2b8 RW storage +``RISCV_IRQTYPE`` 0x2bc RW storage +``RISCV_TRACECTL`` 0x344 RW storage +``RISCV_TRACE_RDIDX`` 0x348 RW storage +``RISCV_TRACE_WTIDX`` 0x34c RW storage +``RISCV_PRIV_ERR_STAT`` 0x360 RW storage +``RISCV_HUB_ERR_STAT`` 0x36c RW storage +================================ ====== ===== ============================== + +The IRQ mask semantics (set/clear/read) follow the header access types and the +observed boot reads; core interrupt *delivery* (mtvec/mip) is not part of this +device and remains unmodelled (documented unknown). +""" +from __future__ import annotations + +from .base import Device + +IRQMSET = 0x2AC +IRQMCLR = 0x2B0 +IRQMASK = 0x2B4 +IRQDEST = 0x2B8 +IRQTYPE = 0x2BC +TRACECTL = 0x344 +TRACE_RDIDX = 0x348 +TRACE_WTIDX = 0x34C +PRIV_ERR_STAT = 0x360 +PRIV_ERR_INFO = 0x364 +PRIV_ERR_ADDR = 0x368 +HUB_ERR_STAT = 0x36C + +_RW = {IRQDEST, IRQTYPE, TRACECTL, TRACE_RDIDX, TRACE_WTIDX, + PRIV_ERR_STAT, HUB_ERR_STAT} +#: Read-only error capture registers (header R-I4R); the boot panic path +#: reads them after a trap (observed reads at PC 0x40106BC). +_RO = {PRIV_ERR_INFO, PRIV_ERR_ADDR} + + +class PriscvBlock(Device): + """RISC-V-local control/trace/error registers.""" + + name = "priscv" + + def __init__(self, *, base: int = 0, size: int = 0x1000): + super().__init__(base=base, size=size) + self.reset() + + def reset(self) -> None: + self.irq_mask = 0 + self.regs: dict[int, int] = {off: 0 for off in _RW} + self.error: dict[int, int] = {off: 0 for off in _RO} + + def read(self, offset: int, size: int) -> int | None: + if size != 4: + return None + if offset == IRQMASK: + return self.irq_mask + if offset in self.regs: + return self.regs[offset] + if offset in self.error: + return self.error[offset] + return None + + def write(self, offset: int, size: int, value: int) -> bool: + if size != 4: + return False + value &= 0xFFFFFFFF + if offset == IRQMSET: + self.irq_mask = (self.irq_mask | value) & 0xFFFFFFFF + return True + if offset == IRQMCLR: + self.irq_mask = (self.irq_mask & ~value) & 0xFFFFFFFF + return True + if offset == IRQMASK: + return False # read-only + if offset in self.regs: + self.regs[offset] = value + return True + return False + + def peek(self, offset: int, size: int) -> int | None: + if size != 4: + return None + if offset == IRQMASK: + return self.irq_mask + if offset in self.error: + return self.error[offset] + return self.regs.get(offset) + + def state(self) -> dict: + return {"irq_mask": self.irq_mask, + "regs": {str(k): v for k, v in sorted(self.regs.items())}, + "error": {str(k): v for k, v in sorted(self.error.items())}} + + def load_state(self, data: dict) -> None: + self.irq_mask = int(data.get("irq_mask", 0)) + regs = {int(k): int(v) for k, v in data.get("regs", {}).items()} + self.regs = {off: regs.get(off, 0) for off in _RW} + err = {int(k): int(v) for k, v in data.get("error", {}).items()} + self.error = {off: err.get(off, 0) for off in _RO} diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/timer.py b/debug/risc-v-gsp-rm-vm/sim/devices/timer.py new file mode 100644 index 0000000..c57ee06 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/devices/timer.py @@ -0,0 +1,86 @@ +"""PTIMER block (modeled virtual time; not a silicon timing claim). + +Evidence kind: **hardware register/field header** (offsets and field layout) +plus a *modeled* time source. The firmware has not yet been observed reading +this block in the 570.144 boot runs (the observed time-like reads were on +falcon MAILBOX1); the device is therefore opt-in: it is registered only when +a profile declares a ``timer`` section, and a section with +``"enabled": false`` is skipped. Its values are explicitly labelled +*modeled*. + +Header facts used: + +* ``NV_PTIMER`` block base ``0x9000`` in the register aperture. +* ``NV_PTIMER_TIME_0`` at aperture offset ``0x9400``: nanoseconds bits + ``31:5``. +* ``NV_PTIMER_TIME_1`` at aperture offset ``0x9410``: nanoseconds bits + ``28:0``. +* Reads of TIME_0 latch TIME_1 (paired read), as on the hardware register + pair; the simulator derives nanoseconds from the machine's instruction + count times the profile's ``ns_per_insn`` (default 1.0, i.e. 1 ns/insn -- + a placeholder, not calibrated). + +Register offsets below are **relative to the block window base** the profile +declares (``PRIV + 0x9000``), i.e. aperture offset minus ``PTIMER_BASE``: +``0x9400 - 0x9000 = 0x400``. Using the aperture-absolute numbers here would +place the registers outside the declared window and serve nothing. +""" +from __future__ import annotations + +from .base import Device + +PTIMER_BASE = 0x9000 +#: Block-relative offsets of NV_PTIMER_TIME_0/1 (aperture 0x9400/0x9410). +TIME_0 = 0x400 +TIME_1 = 0x410 + + +class PtimerBlock(Device): + """Two-register nanosecond timer with a modeled tick source.""" + + name = "timer" + + def __init__(self, *, base: int = 0, size: int = 0x1000, + ns_per_insn: float = 1.0, clock=None): + super().__init__(base=base, size=size) + self.ns_per_insn = float(ns_per_insn) + self.clock = clock # callable returning virtual instruction count + self._latch = 0 + self.reads = 0 + + def _now_ns(self) -> int: + insns = self.clock() if self.clock is not None else 0 + return int(insns * self.ns_per_insn) & 0xFFFFFFFFFFFFFFFF + + def read(self, offset: int, size: int) -> int | None: + if size != 4: + return None + if offset == TIME_0: + self._latch = self._now_ns() + self.reads += 1 + return (self._latch >> 5) & 0xFFFFFFFF # NSEC field 31:5 + if offset == TIME_1: + self.reads += 1 + return self._latch & 0x1FFFFFFF # NSEC field 28:0 + return None + + def peek(self, offset: int, size: int) -> int | None: + if size != 4: + return None + if offset == TIME_0: + return (self._now_ns() >> 5) & 0xFFFFFFFF + if offset == TIME_1: + return self._now_ns() & 0x1FFFFFFF + return None + + def write(self, offset: int, size: int, value: int) -> bool: + return False + + def state(self) -> dict: + return {"ns_per_insn": self.ns_per_insn, "latch": self._latch, + "reads": self.reads} + + def load_state(self, data: dict) -> None: + self.ns_per_insn = float(data.get("ns_per_insn", self.ns_per_insn)) + self._latch = int(data.get("latch", 0)) + self.reads = int(data.get("reads", 0)) diff --git a/debug/risc-v-gsp-rm-vm/sim/gsp_boot.py b/debug/risc-v-gsp-rm-vm/sim/gsp_boot.py new file mode 100644 index 0000000..c586ab3 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/gsp_boot.py @@ -0,0 +1,548 @@ +"""Real-firmware GSP boot runner (device model + versioned profile + shim). + +Loads ``gsp-570.144.fwimage.bin`` at its **link addresses** (RAM base 0), builds +the :mod:`sim.devices` registry from a ``greeneye-sim-profile/1`` document, runs +bounded (<= 2M instructions per attempt), and records a normal +``greeneye-session/1`` directory carrying the device state, unknown-MMIO +summary and the boot-shim assumptions. + +Boot ladder rungs (docs/35-GREENEYE-SIMULATOR-DESIGN.md §3.3) reported by +:class:`GspBootResult`: + +* ``B0`` -- flat image loads, first MMIO observed at ``0x2000_0000_0000_0A00``. +* ``B1`` -- first fault past the QEMU point, explained by the device model + (the QEMU fault is the PMC ``BOOT_42`` chip-ID read; modelled by + :class:`sim.devices.pmc.PmcBlock`). +* ``B2`` -- boot-stage progress: vector/MPU/TCM setup and bootargs consumption + exercised with the profile. +* ``B3`` -- first CSB/TRANSCOM message-window poll served with read-back. +* ``B4`` -- message loop reached (waits on a host message). + +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. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +from dataclasses import dataclass, field + +from greeneye.device_map import firmware_sha256 +from greeneye.elf import load_elf +from greeneye.errors import GreenEyeError +from sim.devices import load_profile, registry_from_profile +from sim.machine import ( + R_BP, + R_BUDGET, + R_EBREAK, + R_ECALL, + R_FAULT, + R_STEP, + R_WATCH, +) + +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") + +#: Hard cap for one attempt (task rule: <= 2M instructions per attempt). +MAX_ATTEMPT_INSTRUCTIONS = 2_000_000 +#: Default bounded budget for a boot attempt. +DEFAULT_INSTRUCTIONS = 2_000_000 + +STOP_REASONS = { + R_BUDGET: "budget", + R_BP: "breakpoint", + R_WATCH: "watch", + R_EBREAK: "ebreak", + R_FAULT: "fault", + R_STEP: "step", + R_ECALL: "ecall", +} + +# GSP RISC-V address-map aperture starts (header constants). +SYSGPA_START = 0x8180000000000000 +FBGPA_START = 0x6180000000000000 +PRIV_START = 0x2000000000000000 + +# GspFwWprMeta field constants (header). +GSP_FW_WPR_META_MAGIC = 0xdc3aae21371a60b3 +GSP_FW_WPR_META_REVISION = 1 +GSP_FW_WPR_META_VERIFIED = 0xa0a0a0a0a0a0a0a0 +WPR_META_SIZE = 256 + +# NV_RISCV_BOOTLDR_PARAMS / NV_RISCV_BOOT_PARAMS layout (header). +BOOTLDR_BOOT_TYPE_RM = 0x4E56524D # 'NVRM' +BOOTLDR_VERSION = 1 +BOOTARGS_DEFAULT_SIZE = 0x78 # NV_RISCV_BOOT_PARAMS (120 bytes) +LIBOS_MEMORY_REGION_CONTIGUOUS = 1 +LIBOS_MEMORY_REGION_LOC_FB = 2 +LIBOS_MEMORY_REGION_LOC_SYSMEM = 1 +WPRM_ID8 = 0x5750524D # 'WPRM' + + +def _int(value, default=None): + if value is None: + return default + if isinstance(value, str): + return int(value, 0) + return int(value) + + +def _write_u32(machine, addr: int, value: int) -> None: + machine.write_mem(addr, (value & 0xFFFFFFFF).to_bytes(4, "little")) + + +def _write_u64(machine, addr: int, value: int) -> None: + machine.write_mem(addr, (value & 0xFFFFFFFFFFFFFFFF).to_bytes(8, "little")) + + +def build_bootargs(machine, *, bootargs_pa: int, core_dump_pa: int, + core_dump_size: int, wpr_meta_pa: int, + wpr_meta_size: int = WPR_META_SIZE, + bootargs_size: int = BOOTARGS_DEFAULT_SIZE) -> None: + """Write ``NV_RISCV_BOOT_PARAMS`` and its ``WPRM`` init arg. + + Assumption (profile-recorded): the real SEC2 booter/host fills these; we + reconstruct the documented structure and one ``WPRM`` libos init argument + pointing at the ``GspFwWprMeta`` written by :func:`build_wpr_meta`. + """ + _write_u32(machine, bootargs_pa + 0x00, BOOTLDR_BOOT_TYPE_RM) + machine.write_mem(bootargs_pa + 0x04, (bootargs_size & 0xFFFF).to_bytes(2, "little")) + machine.write_mem(bootargs_pa + 0x06, bytes([BOOTLDR_VERSION])) + machine.write_mem(bootargs_pa + 0x07, b"\x00") + _write_u64(machine, bootargs_pa + 0x08, core_dump_pa) + _write_u32(machine, bootargs_pa + 0x10, core_dump_size) + # libos.initArgs[0]: WPRM in FB, contiguous. + base = bootargs_pa + 0x18 + _write_u64(machine, base + 0x00, WPRM_ID8) + _write_u64(machine, base + 0x08, wpr_meta_pa) + _write_u64(machine, base + 0x10, wpr_meta_size) + machine.write_mem(base + 0x18, bytes([LIBOS_MEMORY_REGION_CONTIGUOUS, + LIBOS_MEMORY_REGION_LOC_FB])) + # initArgs[1..2] stay zero (id8 == 0 terminates the array). + + +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, + fb_size: int) -> None: + """Write a ``GspFwWprMeta`` (header layout) into RAM. + + Assumption (profile-recorded): the booter/CPU-RM produces this structure; + we place it with a self-consistent layout that satisfies the documented + validation (magic/revision/verified, monotonic FB ranges). Values are + declared in the profile, never derived from silicon. + """ + machine.write_mem(wpr_meta_pa, bytes(WPR_META_SIZE)) + _write_u64(machine, wpr_meta_pa + 0x00, GSP_FW_WPR_META_MAGIC) + _write_u64(machine, wpr_meta_pa + 0x08, GSP_FW_WPR_META_REVISION) + _write_u64(machine, wpr_meta_pa + 0x10, elf_pa) + _write_u64(machine, wpr_meta_pa + 0x18, elf_size) + _write_u64(machine, wpr_meta_pa + 0x20, 0) # sysmemAddrOfBootloader + _write_u64(machine, wpr_meta_pa + 0x28, 0) # sizeOfBootloader + _write_u64(machine, wpr_meta_pa + 0x30, 0) # bootloaderCodeOffset + _write_u64(machine, wpr_meta_pa + 0x38, 0) # bootloaderDataOffset + _write_u64(machine, wpr_meta_pa + 0x40, 0) # bootloaderManifestOffset + _write_u64(machine, wpr_meta_pa + 0x48, 0) # sysmemAddrOfSignature + _write_u64(machine, wpr_meta_pa + 0x50, 0) # sizeOfSignature + _write_u64(machine, wpr_meta_pa + 0x58, wpr_start) # gspFwRsvdStart + _write_u64(machine, wpr_meta_pa + 0x60, wpr_start) # nonWprHeapOffset + _write_u64(machine, wpr_meta_pa + 0x68, 0x10000) # nonWprHeapSize + _write_u64(machine, wpr_meta_pa + 0x70, wpr_start) # gspFwWprStart + _write_u64(machine, wpr_meta_pa + 0x78, wpr_start) # gspFwHeapOffset + _write_u64(machine, wpr_meta_pa + 0x80, wpr_end - wpr_start) # heap size + _write_u64(machine, wpr_meta_pa + 0x88, gsp_fw_offset) # gspFwOffset + _write_u64(machine, wpr_meta_pa + 0x90, boot_bin_offset) # bootBinOffset + _write_u64(machine, wpr_meta_pa + 0x98, 0) # frtsOffset + _write_u64(machine, wpr_meta_pa + 0xA0, 0) # frtsSize + _write_u64(machine, wpr_meta_pa + 0xA8, wpr_end) # gspFwWprEnd + _write_u64(machine, wpr_meta_pa + 0xB0, fb_size) # fbSize + _write_u64(machine, wpr_meta_pa + 0xB8, wpr_end) # vgaWorkspaceOffset + _write_u64(machine, wpr_meta_pa + 0xC0, 0x100000) # vgaWorkspaceSize + _write_u64(machine, wpr_meta_pa + 0xC8, 0) # bootCount + _write_u64(machine, wpr_meta_pa + 0xD0, 0) # partitionRpcAddr + _write_u64(machine, wpr_meta_pa + 0xF8, GSP_FW_WPR_META_VERIFIED) + + +#: Observed boot milestones (firmware vaddrs) used to classify the rung. +#: Evidence: disassembly of the 570.144 image (bootargs consumer reads +#: NV_RISCV_BOOT_PARAMS; the relocation loop copies from the DMEM aperture +#: into the 0x400E000 runtime region). +MILESTONES = ( + ("bootargs_consumer", 0x4007A30), + ("relocation_copy", 0x4007B7C), +) + + +@dataclass +class GspBootResult: + ran: bool + reason: str + image: str + profile: str | None = None + profile_name: str | None = None + image_sha256: str | None = None + session_dir: str | None = None + instructions: int = 0 + cycles: int = 0 + cycle_model: str | None = None + stop_reason: str | None = None + stop_pc: int | None = None + stop_pc_fw: int | None = None + rung: str = "B0" + first_unknown_mmio: dict | None = None + mmio_unknown: int = 0 + device_unhandled: int = 0 + device_accesses: int = 0 + device_state_sha256: str | None = None + registers: dict = field(default_factory=dict) + shim: dict = field(default_factory=dict) + milestones: list = field(default_factory=list) + device_map: object | None = field(default=None, repr=False) + + def to_dict(self) -> dict: + return { + "schema": "greeneye-gsp-boot/1", + "ran": self.ran, + "reason": self.reason, + "image": self.image, + "image_sha256": self.image_sha256, + "profile": self.profile, + "profile_name": self.profile_name, + "session": self.session_dir, + "rung": self.rung, + "instructions_executed": self.instructions, + "cycles": self.cycles, + "cycle_model": self.cycle_model, + "stop": {"reason": self.stop_reason, "pc": self.stop_pc, + "pc_fw": self.stop_pc_fw}, + "first_unknown_mmio": self.first_unknown_mmio, + "mmio_unknown": self.mmio_unknown, + "device_unhandled": self.device_unhandled, + "device_accesses": self.device_accesses, + "device_state_sha256": self.device_state_sha256, + "registers": self.registers, + "boot_shim": self.shim, + "milestones": list(self.milestones), + } + + +def _fw_pc(pc: int) -> int: + return pc + + +def classify_rung(machine, *, shim: dict) -> str: + """Best rung supported by the observed run (never inferred, only observed).""" + if machine.mmio_unknown and machine.mmio_log: + first = machine.mmio_log[0] + if first["addr"] == 0x2000000000000A00: + return "B0" + if shim.get("applied"): + # The firmware consumed the boot shim and advanced past the first + # unknown; B2 is claimed only when the run went past the chip gate and + # exercised the MPU/bootargs path (the runner checks the PC). + return shim.get("rung", "B1") + return "B1" + + +def run_boot( + *, + image: str = DEFAULT_IMAGE, + profile_path: str = DEFAULT_PROFILE, + instructions: int = DEFAULT_INSTRUCTIONS, + record=None, + name: str = "gsp-boot", + use_shim: bool = True, + shim_overrides: dict | None = None, +) -> GspBootResult: + """Run one bounded boot attempt and record a session.""" + if instructions <= 0 or instructions > MAX_ATTEMPT_INSTRUCTIONS: + raise GreenEyeError( + f"instructions must be in 1..{MAX_ATTEMPT_INSTRUCTIONS}, got {instructions}") + if not os.path.isfile(image): + return GspBootResult(ran=False, reason=f"image absent: {image}", image=image) + if not os.path.isfile(profile_path): + return GspBootResult(ran=False, reason=f"profile absent: {profile_path}", + image=image) + + profile = load_profile(profile_path) + image_obj = load_elf(image) + if not image_obj.is_riscv: + raise GreenEyeError(f"{image}: not a RISC-V ELF image") + sha = firmware_sha256(image) + + load = profile.get("load", {}) + ram_base = _int(load.get("ram_base", 0)) + memory_mb = _int(load.get("memory_mb", 12288)) + + from greeneye.sim_backend import launch_native # deferred + + session = launch_native( + kernel=image, + memory_mb=memory_mb, + ram_base=ram_base, + record=True if record is None else record, + name=name, + device_map=None, + unknown_mmio="lenient", + bias=0, + ) + machine = session.target.machine + machine.devices = registry_from_profile(profile, machine=machine) + machine.translator = getattr(machine.devices, "dma", None) + + shim_meta = {"enabled": bool(use_shim), "assumptions": []} + if use_shim: + shim = dict(profile.get("boot_shim", {})) + if shim_overrides: + shim.update(shim_overrides) + shim["_image_path"] = image + _apply_boot_shim(machine, shim, image_obj, shim_meta) + + milestones_hit: list[str] = [] + try: + reason, pc, a, b = _run_with_milestones(machine, instructions, milestones_hit) + model = machine.cycle_model + stop_reason = STOP_REASONS.get(reason, str(reason)) + first = machine.mmio_log[0] if machine.mmio_log else None + reg = machine.devices + state = reg.state() if reg is not None else None + state_json = json.dumps(state, sort_keys=True, default=str) if state else "" + 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) + shim_meta["rung"] = rung + meta = { + "kind": "gsp-boot", + "profile": os.path.basename(profile_path), + "profile_name": profile.get("name"), + "firmware_sha256": sha, + "ram_base": ram_base, + "instructions_executed": machine.insns, + "cycles": machine.cycles, + "cycle_model": model.name if model is not None else None, + "stop_reason": stop_reason, + "stop_pc": pc, + "rung": rung, + "milestones": list(milestones_hit), + "mmio_unknown": machine.mmio_unknown, + "first_unknown_mmio": first, + "device_unhandled": getattr(reg, "unhandled", 0), + "device_accesses": len(getattr(reg, "accesses", ())), + "device_state_sha256": state_sha, + "boot_shim": shim_meta, + } + if getattr(session.recorder, "dir", None) is not None: + if state is not None: + rel = session.recorder.blob( + "device-state.json", + json.dumps(state, indent=1, sort_keys=True, default=str).encode(), + ) + meta["device_state_blob"] = rel + session.recorder.meta.update(meta) + session.note( + "gsp_boot", + rung=rung, + instructions=machine.insns, + stop_reason=stop_reason, + stop_pc=pc, + ) + session_dir = getattr(session.recorder, "dir", None) + finally: + session.close() + + return GspBootResult( + ran=True, + reason="ok", + image=image, + profile=profile_path, + profile_name=profile.get("name"), + image_sha256=sha, + session_dir=session_dir, + instructions=machine.insns, + cycles=machine.cycles, + cycle_model=model.name if model is not None else None, + stop_reason=stop_reason, + stop_pc=pc, + stop_pc_fw=_fw_pc(pc), + rung=rung, + first_unknown_mmio=first, + mmio_unknown=machine.mmio_unknown, + device_unhandled=getattr(reg, "unhandled", 0), + device_accesses=len(getattr(reg, "accesses", ())), + device_state_sha256=state_sha, + registers=registers, + shim=shim_meta, + milestones=list(milestones_hit), + ) + + +def _run_with_milestones(machine, budget: int, hit: list[str]): + """Run the budget, stopping at each milestone once to record it. + + Milestone hits are evidence of boot-stage progress (they are firmware + vaddrs reached), not inferred from timing or heuristics. + """ + remaining = budget + while remaining > 0: + pending = {addr for name, addr in MILESTONES if name not in hit} + reason, pc, a, b = machine.run(remaining, breakpoints=pending) + remaining = budget - machine.insns + if reason == R_BP: + for name, addr in MILESTONES: + if addr == pc and name not in hit: + hit.append(name) + break + continue + return reason, pc, a, b + 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. + + 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 + chip gate was passed; the run may stop at the bootargs panic/halt. + B2: the boot shim was consumed and the firmware reached its relocation + stage (milestone ``relocation_copy``) -- boot-stage progress beyond + the bootargs consumer. + """ + 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" + if "relocation_copy" in milestones_hit: + return "B2" + return "B1" + + +def _rung_note(rung: str) -> str: + return { + "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)", + }.get(rung, rung) + + +def _apply_boot_shim(machine, shim: dict, image_obj, shim_meta: dict) -> None: + """Write profile-declared reset state (documented assumption).""" + registers = {k: (int(v, 0) if isinstance(v, str) else int(v)) + for k, v in shim.get("registers", {}).items()} + machine.apply_reset_regs(registers) + bootargs_pa = shim.get("bootargs_pa") + if bootargs_pa is not None: + bootargs_pa = int(bootargs_pa, 0) if isinstance(bootargs_pa, str) else int(bootargs_pa) + core_dump_pa = _int(shim.get("core_dump_pa", 0x40000)) + core_dump_size = _int(shim.get("core_dump_size", 0x10000)) + build_bootargs( + machine, + bootargs_pa=bootargs_pa, + core_dump_pa=core_dump_pa, + core_dump_size=core_dump_size, + wpr_meta_pa=_int(shim.get("wpr_meta_pa", 0x90000)), + wpr_meta_size=_int(shim.get("wpr_meta_size", WPR_META_SIZE)), + bootargs_size=_int(shim.get("bootargs_size", BOOTARGS_DEFAULT_SIZE)), + ) + shim_meta["assumptions"].append( + "NV_RISCV_BOOT_PARAMS reconstructed at 0x%x from the documented " + "layout (header); real value is host/booter-provided" % bootargs_pa) + if shim.get("wpr_meta_pa") is not None and shim.get("wpr"): + wpr = shim["wpr"] + build_wpr_meta( + machine, + wpr_meta_pa=_int(shim["wpr_meta_pa"]), + elf_pa=_int(wpr.get("elf_pa", image_obj.entry)), + elf_size=_int(wpr.get("elf_size", image_obj.max_vaddr - 0x4000000)), + wpr_start=_int(wpr.get("wpr_start", 0x2000000)), + wpr_end=_int(wpr.get("wpr_end", 0x6000000)), + boot_bin_offset=_int(wpr.get("boot_bin_offset", 0x2000000)), + gsp_fw_offset=_int(wpr.get("gsp_fw_offset", 0x4000000)), + fb_size=_int(wpr.get("fb_size", 0x200000000)), + ) + shim_meta["assumptions"].append( + "GspFwWprMeta reconstructed from the documented layout (header); " + "real value is booter-provided") + shim_meta["applied"] = True + shim_meta["registers"] = {k: registers[k] for k in sorted(registers)} + image_at = shim.get("image_at") + if image_at is not None: + addr = _int(image_at) + with open(shim["_image_path"], "rb") as fh: + 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) + shim_meta["image_at"] = addr + shim_meta["image_bytes"] = len(blob) + + +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) + print(f"session: {result.session_dir}", file=out) + print(f"rung: {result.rung}", file=out) + print(f"instructions: {result.instructions}", file=out) + print(f"cycles (modeled): {result.cycles}", file=out) + print(f"stop: {result.stop_reason} pc={result.stop_pc:#x}" + if result.stop_pc is not None else f"stop: {result.stop_reason}", file=out) + print(f"unknown MMIO accesses: {result.mmio_unknown}", file=out) + print(f"device accesses: {result.device_accesses} " + f"(unhandled in windows: {result.device_unhandled})", file=out) + if result.first_unknown_mmio: + 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) + print("note: device semantics carry their evidence in docs/36; " + "boot-shim values are declared assumptions", file=out) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="python3 -m sim.gsp_boot", + description=("Bounded real-firmware GSP boot attempt with the device model " + "and versioned profile (read-only image; no hardware).")) + parser.add_argument("--image", default=DEFAULT_IMAGE) + parser.add_argument("--profile", default=DEFAULT_PROFILE) + parser.add_argument("--instructions", type=int, default=DEFAULT_INSTRUCTIONS) + parser.add_argument("--record", help="session directory (default: auto)") + 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("--json", action="store_true") + args = parser.parse_args(argv) + try: + result = run_boot( + image=args.image, + profile_path=args.profile, + instructions=args.instructions, + record=args.record, + name=args.name, + use_shim=not args.no_shim, + ) + except GreenEyeError as exc: + print(f"[gsp-boot] error: {exc}", file=sys.stderr) + return 1 + if not result.ran: + print(f"[gsp-boot] skipped: {result.reason}", file=sys.stderr) + return 2 + if args.json: + print(json.dumps(result.to_dict(), indent=1, sort_keys=True)) + else: + _print_result(result) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/debug/risc-v-gsp-rm-vm/sim/gsp_profile.py b/debug/risc-v-gsp-rm-vm/sim/gsp_profile.py new file mode 100644 index 0000000..6d87389 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/gsp_profile.py @@ -0,0 +1,317 @@ +"""GSP cycle-0 profile: bounded, deterministic real-firmware run with cycles. + +Loads the TU104 570.144 GSP flat image (read-only), maps it the same way the +device-map/probe path does (``bias=0x80000000``, ``greeneye.elf`` PT_LOAD plan, +lenient MMIO from a :class:`greeneye.device_map.DeviceMap`), executes a bounded +instruction budget, and writes a normal ``greeneye-session/1`` directory whose +metadata carries the modeled instruction/cycle totals and the stop reason. + +**Fidelity:** this is a bounded lenient first-touch profile, not a boot. The +cycle figures come from :mod:`sim.cycles` - modeled defaults, not silicon +timings (the simulator's L2 timing is *not* modelled; docs/06-ACCURACY.md, +docs/32-GSP-CYCLE-MODEL.md). The run is deterministic: two identical +invocations execute the same instructions and report the same cycle total. + +CLI:: + + python3 -m sim.gsp_profile --instructions 200000 + python3 -m sim.gsp_profile --record dumps/gsp-cycle0 --json + +The image is loaded read-only; nothing is written back to it, and no hardware, +GPU, module, guest or network is touched. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from dataclasses import dataclass, field as _field + +from greeneye.device_map import DeviceMap, firmware_sha256 +from greeneye.elf import load_elf +from greeneye.errors import GreenEyeError +from sim.machine import ( + R_BP, + R_BUDGET, + R_EBREAK, + R_ECALL, + R_FAULT, + R_STEP, + R_WATCH, +) + +#: Tool root (the directory containing ``sim/`` and ``greeneye/``). +TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +#: The TU104 570.144 GSP firmware, as laid out in the workspace tree. +DEFAULT_IMAGE = os.path.normpath( + os.path.join( + TOOL_ROOT, "..", "..", "firmware", "57X-GSP", "570.144", "gsp-570.144.fwimage.bin" + ) +) + +#: GPU physical addresses are relocated into simulator RAM at this bias +#: (same convention as the probe/device-map path). +DEFAULT_BIAS = 0x80000000 + +#: Small default budget: enough to pass the documented first-touch fault. +DEFAULT_INSTRUCTIONS = 200_000 + +#: Machine stop reasons -> stable session labels. +STOP_REASONS = { + R_BUDGET: "budget", + R_BP: "breakpoint", + R_WATCH: "watch", + R_EBREAK: "ebreak", + R_FAULT: "fault", + R_STEP: "step", + R_ECALL: "ecall", +} + + +@dataclass +class GspProfileResult: + """Outcome of one :func:`run_profile` invocation. + + ``ran`` is False (with ``reason`` explaining why) when the image is absent + and the caller should skip cleanly; ``device_map`` is the in-memory map + used/learned during the run (never serialised by :meth:`to_dict`). + """ + + ran: bool + reason: str + image: str + image_sha256: str | None = None + session_dir: str | None = None + instructions: int = 0 + cycles: int = 0 + cycle_model: str | None = None + stop_reason: str | None = None + stop_pc: int | None = None + stop_pc_fw: int | None = None + first_unknown_mmio: dict | None = None + mmio_unknown: int = 0 + device_map: DeviceMap | None = _field(default=None, repr=False) + + def to_dict(self) -> dict: + data = { + "schema": "greeneye-gsp-cycle-profile/1", + "ran": self.ran, + "reason": self.reason, + "image": self.image, + "image_sha256": self.image_sha256, + "session": self.session_dir, + "instructions_executed": self.instructions, + "cycles": self.cycles, + "cycle_model": self.cycle_model, + "fidelity": "modeled cycles; L2 (timing) not modelled (docs/06)", + "stop": {"reason": self.stop_reason, "pc": self.stop_pc, "pc_fw": self.stop_pc_fw}, + "first_unknown_mmio": self.first_unknown_mmio, + "mmio_unknown": self.mmio_unknown, + } + if self.device_map is not None: + data["device_map_entries"] = len(self.device_map) + return data + + +def _fw_pc(pc: int, bias: int) -> int: + if not bias or pc == 0 or pc < bias: + return pc + return pc - bias + + +def _memory_mb(image, bias: int, ram_base: int = DEFAULT_BIAS) -> int: + """RAM size that covers the relocated image, as the probe path computes it.""" + end = image.max_vaddr + bias + need = end - ram_base + (1 << 20) + return max(256, (need + (1 << 20) - 1) // (1 << 20)) + + +def run_profile( + *, + image: str = DEFAULT_IMAGE, + instructions: int = DEFAULT_INSTRUCTIONS, + bias: int = DEFAULT_BIAS, + record=None, + device_map=None, + learn_mmio: bool = False, + name: str = "gsp-cycle-profile", + cycle_model=None, +) -> GspProfileResult: + """Run the bounded cycle profile; skip cleanly when the image is absent. + + ``record`` is a session directory (or True for an auto-named one under + ``dumps/``); the default ``None`` still records, so a session is always + written. ``device_map`` may be a :class:`~greeneye.device_map.DeviceMap`, + a path to one, or None for an empty lenient map. + """ + if instructions <= 0: + raise GreenEyeError(f"instructions must be positive, got {instructions}") + if not os.path.isfile(image): + return GspProfileResult(ran=False, reason=f"image absent: {image}", image=image) + + image_obj = load_elf(image) + if not image_obj.is_riscv: + raise GreenEyeError(f"{image}: not a RISC-V ELF image") + sha = firmware_sha256(image) + + if isinstance(device_map, (str, os.PathLike)): + dm = DeviceMap.load(os.fspath(device_map)) + elif device_map is None: + dm = DeviceMap( + meta={ + "source": "gsp-profile", + "firmware": os.path.basename(image), + "firmware_sha256": sha, + "bias": bias, + } + ) + else: + dm = device_map + + from greeneye.sim_backend import launch_native # deferred: keeps sim/ standalone + + session = launch_native( + kernel=image, + memory_mb=_memory_mb(image_obj, bias), + record=True if record is None else record, + name=name, + device_map=dm, + unknown_mmio="lenient", + learn_mmio=learn_mmio, + bias=bias, + cycle_model=cycle_model, + ) + machine = session.target.machine + try: + reason, pc, _a, _b = machine.run(instructions) + first = machine.mmio_log[0] if machine.mmio_log else None + model = machine.cycle_model + stop_reason = STOP_REASONS.get(reason, str(reason)) + stop_pc_fw = _fw_pc(pc, bias) + meta = { + "kind": "gsp-cycle-profile", + "firmware_sha256": sha, + "bias": bias, + "instructions_executed": machine.insns, + "cycles": machine.cycles, + "cycle_model": model.name if model is not None else None, + "cycle_fidelity": "modeled cycles, not measured (docs/32-GSP-CYCLE-MODEL.md)", + "stop_reason": stop_reason, + "stop_pc": pc, + "stop_pc_fw": stop_pc_fw, + "mmio_unknown": machine.mmio_unknown, + "first_unknown_mmio": first, + } + if model is not None: + meta["cycle_model_table"] = model.describe() + if getattr(session.recorder, "dir", None) is not None: + session.recorder.meta.update(meta) + session.note( + "gsp_cycle_profile", + instructions=machine.insns, + cycles=machine.cycles, + stop_reason=stop_reason, + stop_pc_fw=stop_pc_fw, + ) + session_dir = getattr(session.recorder, "dir", None) + finally: + session.close() + + return GspProfileResult( + ran=True, + reason="ok", + image=image, + image_sha256=sha, + session_dir=session_dir, + instructions=machine.insns, + cycles=machine.cycles, + cycle_model=model.name if model is not None else None, + stop_reason=stop_reason, + stop_pc=pc, + stop_pc_fw=stop_pc_fw, + first_unknown_mmio=first, + mmio_unknown=machine.mmio_unknown, + device_map=dm, + ) + + +def _print_result(result: GspProfileResult, out=sys.stdout) -> None: + print(f"image: {result.image}", file=out) + print(f"sha256: {result.image_sha256}", file=out) + print(f"session: {result.session_dir}", file=out) + print(f"instructions: {result.instructions}", file=out) + print(f"cycles (modeled): {result.cycles}", file=out) + print(f"cycle model: {result.cycle_model}", file=out) + if result.instructions: + print(f"cycles/instruction: {result.cycles / result.instructions:.3f}", file=out) + print( + f"stop: {result.stop_reason} pc={result.stop_pc:#x} (fw {result.stop_pc_fw:#x})", + file=out, + ) + print(f"unknown MMIO accesses: {result.mmio_unknown}", file=out) + if result.first_unknown_mmio: + first = result.first_unknown_mmio + print( + "first unknown MMIO: " + f"pc={first['pc']:#x} addr={first['addr']:#x} " + f"size={first['size']} rw={first['rw']}", + file=out, + ) + print( + "note: cycles are modeled (sim/cycles.py defaults); no silicon timing claim", + file=out, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="python3 -m sim.gsp_profile", + description=( + "Bounded deterministic GSP cycle-0 profile (modeled cycles; no silicon claim). " + "Loads the real firmware read-only, runs under lenient MMIO and records a session." + ), + ) + parser.add_argument("--image", default=DEFAULT_IMAGE, help="flat GSP firmware image") + parser.add_argument( + "--instructions", type=int, default=DEFAULT_INSTRUCTIONS, help="instruction budget" + ) + parser.add_argument("--bias", type=lambda s: int(s, 0), default=DEFAULT_BIAS) + parser.add_argument("--record", help="session directory (default: auto under dumps/)") + parser.add_argument("--device-map", help="existing greeneye-device-map/1 JSON to serve MMIO") + parser.add_argument("--learn", action="store_true", help="learn observed MMIO into the map") + parser.add_argument("--learn-out", help="save the learned/used map here") + parser.add_argument("--name", default="gsp-cycle-profile") + parser.add_argument("--json", action="store_true", help="emit the result as JSON") + args = parser.parse_args(argv) + + try: + result = run_profile( + image=args.image, + instructions=args.instructions, + bias=args.bias, + record=args.record, + device_map=args.device_map, + learn_mmio=args.learn, + name=args.name, + ) + except GreenEyeError as exc: + print(f"[gsp-profile] error: {exc}", file=sys.stderr) + return 1 + + if not result.ran: + print(f"[gsp-profile] skipped: {result.reason}", file=sys.stderr) + return 2 + if args.learn_out and result.device_map is not None: + result.device_map.save(args.learn_out) + print(f"[gsp-profile] device map ({len(result.device_map)} entries) -> {args.learn_out}") + if args.json: + print(json.dumps(result.to_dict(), indent=1, sort_keys=True)) + else: + _print_result(result) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/debug/risc-v-gsp-rm-vm/sim/machine.py b/debug/risc-v-gsp-rm-vm/sim/machine.py index 8d77dd8..9908753 100644 --- a/debug/risc-v-gsp-rm-vm/sim/machine.py +++ b/debug/risc-v-gsp-rm-vm/sim/machine.py @@ -5,12 +5,20 @@ The execution loop is a monolithic dispatch over the cached decode tuples from image and the instruction budget handed to ``run``: no host clock, no threads, no entropy. RAM is an anonymous ``mmap`` (lazily paged, so a 256 MiB region is cheap); all other addresses resolve to the UART or fault. + +Alongside the instruction count (virtual time, ``insns``) the machine keeps a +``cycles`` counter advanced by :mod:`sim.cycles`, a table-driven **modeled** +instruction-class cost. It changes no execution behaviour: with the default +model enabled, ``insns``, stops and register/memory state are exactly as +before; ``cycle_model=False`` leaves ``cycles`` at 0. See +``docs/32-GSP-CYCLE-MODEL.md``. """ from __future__ import annotations import mmap import struct +from .cycles import DEFAULT_MODEL from .decode import ( A_ADD, A_AND, @@ -22,7 +30,6 @@ from .decode import ( A_MULHU, A_OR, A_REM, - A_REMU, A_SLL, A_SLT, A_SLTU, @@ -38,8 +45,6 @@ from .decode import ( I_SLLIW, I_SLTI, I_SLTIU, - I_SRAI, - I_SRAIW, I_SRLI, I_SRLIW, I_XORI, @@ -50,7 +55,6 @@ from .decode import ( K_EBREAK, K_ECALL, K_FENCE, - K_ILLEGAL, K_JAL, K_JALR, K_LOAD, @@ -182,6 +186,12 @@ class Machine: ``learn_mmio=True``, writes the observation back into the map. Nothing is logged or counted when lenient mode is off, and debugger ``read_mem``/ ``write_mem`` stay strict. + + ``cycle_model`` selects the modeled cycle accounting (:mod:`sim.cycles`): + ``None`` (default) uses :data:`sim.cycles.DEFAULT_MODEL`, a + :class:`~sim.cycles.CycleModel` instance uses that table, and ``False`` + disables accounting (``cycles`` stays 0). Accounting never changes + execution. """ def __init__( @@ -192,14 +202,36 @@ class Machine: unknown_mmio: str = "fault", learn_mmio: bool = False, bias: int = 0, + cycle_model=None, + devices=None, + reset_regs=None, + translator=None, ): if unknown_mmio not in UNKNOWN_MMIO_MODES: raise ValueError(f"unknown_mmio must be one of {UNKNOWN_MMIO_MODES}, got {unknown_mmio!r}") + if cycle_model is False: + cycle_model = None + elif cycle_model is None: + cycle_model = DEFAULT_MODEL + self.cycle_model = cycle_model + self.cycles = 0 + if cycle_model is None: + self._cycle_base = None + self._cycle_op = None + self._cycle_mem = 0 + self._cycle_io = 0 + else: + self._cycle_base = cycle_model.base_costs + self._cycle_op = cycle_model.op_costs + self._cycle_mem = cycle_model.memory_latency + self._cycle_io = cycle_model.mmio_latency self.ram_base = ram_base self.ram_size = memory_mb * 1024 * 1024 self.ram = mmap.mmap(-1, self.ram_size) self.uart = Uart16550() self.device_map = device_map + self.devices = devices + self.translator = translator # aperture -> RAM seam (sim.devices.dma) self.unknown_mmio = unknown_mmio self.learn_mmio = bool(learn_mmio) self.bias = bias # PC relocation for learned-map firmware addresses @@ -219,6 +251,34 @@ class Machine: self._u16p = self._u16.pack_into self._u32p = self._u32.pack_into self._u64p = self._u64.pack_into + if reset_regs: + self.apply_reset_regs(reset_regs) + + # -- reset state (boot shim support) ----------------------------------- + + def apply_reset_regs(self, regs: dict) -> None: + """Set initial register values (boot shim: a0-a5 bootargs, etc.). + + Accepts integer indices (0-31) or register names (``"a0"``..``"a7"``, + ``"ra"``, ``"sp"``, ``"tp"``, ``"s0"``.., ``"t0"``..). x0 is ignored. + """ + names = {"zero": 0, "ra": 1, "sp": 2, "gp": 3, "tp": 4, + "t0": 5, "t1": 6, "t2": 7, + "s0": 8, "fp": 8, "s1": 9, + "a0": 10, "a1": 11, "a2": 12, "a3": 13, "a4": 14, "a5": 15, + "a6": 16, "a7": 17, + "s2": 18, "s3": 19, "s4": 20, "s5": 21, "s6": 22, "s7": 23, + "s8": 24, "s9": 25, "s10": 26, "s11": 27, + "t3": 28, "t4": 29, "t5": 30, "t6": 31} + for key, value in regs.items(): + index = key if isinstance(key, int) else names.get(str(key)) + if index is None: + raise ValueError(f"unknown register name {key!r}") + self.set_reg(int(index), int(value)) + + def insn_count(self) -> int: + """Virtual time (instruction count) for modeled clock devices.""" + return self.insns # -- image loading ----------------------------------------------------- @@ -262,6 +322,21 @@ class Machine: out.append(self.uart.read(addr - UART_BASE)) addr += 1 size -= 1 + elif self.translator is not None and self.translator.translate(addr) is not None: + sim = self.translator.translate(addr) + off = sim - self.ram_base + if off < 0 or off + size > self.ram_size: + raise Fault(5, addr) + out += self.ram[off : off + size] + addr += size + size = 0 + elif self.devices is not None: + peeked = self.devices.peek(addr, size) + if peeked is None: + raise Fault(5, addr) + out += peeked + addr += size + size = 0 else: raise Fault(5, addr) return bytes(out) @@ -280,6 +355,17 @@ class Machine: elif UART_BASE <= here < UART_BASE + UART_SIZE: self.uart.write(here - UART_BASE, data[offset]) offset += 1 + elif self.translator is not None and self.translator.translate(here) is not None: + sim = self.translator.translate(here) + off = sim - self.ram_base + if off < 0 or off + remaining > self.ram_size: + raise Fault(7, here) + self.ram[off : off + remaining] = data[offset:] + offset = len(data) + elif self.devices is not None and self.devices.write( + here, remaining, int.from_bytes(data[offset:], "little") + ): + offset = len(data) else: raise Fault(7, here) @@ -308,9 +394,57 @@ class Machine: self.device_map = DeviceMap(meta={"source": "learn"}) self.device_map.record(addr, size, rw, value, pc=pc, bias=self.bias, source="trace") + def _aperture_load(self, addr: int, size: int) -> int | None: + """Serve a load from a translated memory aperture (FBGPA/SYSGPA).""" + if self.translator is None: + return None + sim = self.translator.translate(addr) + if sim is None: + return None + off = sim - self.ram_base + if off < 0 or off + size > self.ram_size: + return None + if size == 8: + return self._u64.unpack_from(self.ram, off)[0] + if size == 4: + return self._u32.unpack_from(self.ram, off)[0] + if size == 2: + return self._u16.unpack_from(self.ram, off)[0] + if size == 1: + return self.ram[off] + return None + + def _aperture_store(self, addr: int, size: int, value: int) -> bool: + """Serve a store into a translated memory aperture (FBGPA/SYSGPA).""" + if self.translator is None: + return False + sim = self.translator.translate(addr) + if sim is None: + return False + off = sim - self.ram_base + if off < 0 or off + size > self.ram_size: + return False + if size == 8: + self._u64p(self.ram, off, value & 0xFFFFFFFFFFFFFFFF) + elif size == 4: + self._u32p(self.ram, off, value & 0xFFFFFFFF) + elif size == 2: + self._u16p(self.ram, off, value & 0xFFFF) + else: + self.ram[off] = value & 0xFF + self.invalidate() + return True + def _mmio_load(self, addr: int, size: int, pc: int = 0) -> int: if UART_BASE <= addr < UART_BASE + UART_SIZE: return self.uart.read(addr - UART_BASE) + value = self._aperture_load(addr, size) + if value is not None: + return value + if self.devices is not None: + handled, value = self.devices.read(addr, size, pc=pc) + if handled: + return value handled, value = self._mapped_mmio(addr, size, False) if handled: return value @@ -323,6 +457,10 @@ class Machine: if UART_BASE <= addr < UART_BASE + UART_SIZE: self.uart.write(addr - UART_BASE, value) return + if self._aperture_store(addr, size, value): + return + if self.devices is not None and self.devices.write(addr, size, value, pc=pc): + return handled, _ = self._mapped_mmio(addr, size, True) if handled: if self.learn_mmio and self.device_map is not None: @@ -368,6 +506,13 @@ class Machine: a = b = 0 traced = trace is not None active = False + # Modeled cycle accounting (sim.cycles): pure table lookups, no timing. + cycle_base = self._cycle_base + cycle_op = self._cycle_op + cycle_mem = self._cycle_mem + cycle_io = self._cycle_io + counting = cycle_base is not None + cycle_sum = 0 while n < budget: if has_bps and pc in breakpoints: @@ -394,6 +539,9 @@ class Machine: cache[pc] = d kind, sub, rd, r1, r2, imm, nxt = d n += 1 + if counting: + kc = cycle_base[kind] + cycle_sum += cycle_op[sub] if kc < 0 else kc if traced: insn_pc = pc active = trace.begin(insn_pc, self.insns + n) @@ -434,6 +582,8 @@ class Machine: else: if rd: x[rd] = i16(ram, o)[0] + if counting: + cycle_sum += cycle_mem pc = nxt else: try: @@ -447,6 +597,8 @@ class Machine: x[rd] = v if active: trace.mem(insn_pc, ea, size, False, self.insns + n) + if counting: + cycle_sum += cycle_io pc = nxt continue @@ -473,6 +625,15 @@ class Machine: u16p(ram, o, value & 0xFFFF) else: 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 + if counting: + cycle_sum += cycle_mem pc = nxt else: try: @@ -484,6 +645,8 @@ class Machine: break if active: trace.mem(insn_pc, ea, size, True, self.insns + n) + if counting: + cycle_sum += cycle_io pc = nxt continue @@ -755,6 +918,8 @@ class Machine: trace.mem(insn_pc, ea, width, True, self.insns + n) if rd: x[rd] = (_sext32(old) & MASK64) if width == 4 else old + if counting: + cycle_sum += cycle_mem if 0 <= ea - base <= limit - width else cycle_io pc = nxt except Fault as f: reason = R_FAULT @@ -796,10 +961,17 @@ class Machine: self.pc = pc self.insns += n + self.cycles += cycle_sum if single_step and reason == R_BUDGET: reason = R_STEP return (reason, pc, a, b) + def device_state(self) -> dict | None: + """Serialisable state of the attached device registry (or None).""" + if self.devices is None or not hasattr(self.devices, "state"): + return None + return self.devices.state() + # -- guest memory helpers (AMO) ---------------------------------------- def _guest_load(self, addr: int, width: int, pc: int = 0) -> int: @@ -817,6 +989,11 @@ class Machine: self._u64p(self.ram, off, value) else: self._u32p(self.ram, off, value & 0xFFFFFFFF) + p = addr & ~1 + end = addr + width + while p < end: + self._cache.pop(p, None) + p += 2 return self._mmio_store(addr, width, value, pc) 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 new file mode 100644 index 0000000..919dffe --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/profiles/tu104-570.144.json @@ -0,0 +1,106 @@ +{ + "schema": "greeneye-sim-profile/1", + "name": "tu104-570.144", + "description": "Turing TU104 GSP 570.144: device model windows, load layout and boot-shim assumptions. New firmware version = new profile, not a code fork.", + "firmware": { + "file": "firmware/57X-GSP/570.144/gsp-570.144.fwimage.bin", + "sha256": "2d1bb5c60b208cfd115f015b17dd04a6fece05fd2c0dcba3625d9d48cce3ceea", + "entry": "0x4009000", + "link_base": "0x4000000", + "max_vaddr": "0x5bb9000", + "size": 28528288 + }, + "load": { + "ram_base": "0x0", + "memory_mb": 12288, + "note": "firmware executes at its link addresses; PRIV/PMC is at 0x2000000000000000, local memory covers IMEM/DMEM/EMEM (0x0..0x300000000)." + }, + "devices": { + "pmc": { + "chip_id": "0x164", + "major": 0, + "minor": 0, + "minor_ext": 0, + "evidence": "NV_PMC_BOOT_42 at PMC+0xA00; CHIP_ID bits 28:20 compared against TU10x IDs by the boot code; TU104 = 0x164 (header field).", + "windows": [ + {"base": "0x2000000000000000", "size": "0x1000"} + ] + }, + "falcon": { + "shims": { + "debuginfo_host_consume": true + }, + "debuginfo_stage_mask": "0x700000", + "debuginfo_stage_shift": 20, + "evidence": "Falcon register header offsets; boot writes MAILBOX0/MAILBOX1/DEBUGINFO/IRQSSET(HALT) and polls DEBUGINFO bits 22:20; DMA field layout and transfer semantics from the register header. DEBUGINFO host-consume is a boot shim, not a device claim.", + "windows": [ + {"base": "0x2000000000110000", "size": "0x1000"}, + {"base": "0x300000000", "size": "0x1000"} + ] + }, + "priscv": { + "evidence": "RISC-V-local register header; boot reads TRACECTL/TRACE_RDIDX/TRACE_WTIDX/PRIV_ERR_STAT/IRQMASK through the libos private mapping at 0x300001000.", + "windows": [ + {"base": "0x2000000000111000", "size": "0x1000"}, + {"base": "0x300001000", "size": "0x1000"} + ] + }, + "timer": { + "ns_per_insn": 1.0, + "evidence": "PTIMER register header (NV_PTIMER_TIME_0 0x9400, TIME_1 0x9410). Values are MODELED from the instruction count; the firmware has not been observed reading this block in the 570.144 boot runs.", + "windows": [ + {"base": "0x2000000000009000", "size": "0x1000"} + ] + }, + "fwsec": { + "wpr2_start": "0x2000000", + "wpr2_end": "0x6000000", + "evidence": "PFB WPR2 ADDR_LO/HI register header (0x1FA824/0x1FA828, 4KiB alignment, HI points at the last 128KiB block); values are boot-shim supplied (host/booter-written on real hardware).", + "windows": [ + {"base": "0x20000000001FA000", "size": "0x1000"} + ] + }, + "csb": { + "evidence": "CSB/TRANSCOM message-window seam. No register semantics are modelled until a message-window poll is observed (B3); a profile may declare individual registers with width/value/semantics.", + "registers": {}, + "windows": [] + }, + "pmu": { + "evidence": "PMU stub: no PMU register has been observed; every access stays unhandled and logged.", + "windows": [] + }, + "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": [ + {"pa": "0x8180000000000000", "sim": "0x0", "size": "0x8000000"}, + {"pa": "0x6180000000000000", "sim": "0x0", "size": "0x8000000"} + ] + } + }, + "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", + "a1": "0x80000", + "a2": "0x4000000", + "a3": "0x1b34ea0", + "a4": "0x4000000", + "a5": "0x0" + }, + "bootargs_pa": "0x80000", + "bootargs_size": 120, + "core_dump_pa": "0x40000", + "core_dump_size": "0x10000", + "wpr_meta_pa": "0x90000", + "image_at": "0x100000000", + "wpr": { + "elf_pa": "0x4000000", + "elf_size": "0x1b34ea0", + "wpr_start": "0x2000000", + "wpr_end": "0x6000000", + "boot_bin_offset": "0x3000000", + "gsp_fw_offset": "0x4000000", + "fb_size": "0x200000000" + } + } +} |
