summaryrefslogtreecommitdiffstats
path: root/debug/risc-v-gsp-rm-vm/greeneye/structs.py
diff options
context:
space:
mode:
authorArthur Hanson Rasmusson <arthur@vgpu.io>2026-10-02 08:36:35 -0400
committerArthur Hanson Rasmusson <arthur@vgpu.io>2026-10-02 08:36:35 -0400
commitba58dcdc5ad6ca021c723d279f33e3f14e1184df (patch)
tree47e43a2dad9a2d4bb40da46243654bff8b1302de /debug/risc-v-gsp-rm-vm/greeneye/structs.py
parentfe485080e159c983242d699f4a584659df159818 (diff)
downloadopen-iov-ba58dcdc5ad6ca021c723d279f33e3f14e1184df.tar.gz
open-iov-ba58dcdc5ad6ca021c723d279f33e3f14e1184df.zip
debug: add green-eye GSP/uproc RISC-V VM runtime analysis tooling
Adds open-iov/debug/risc-v-gsp-rm-vm: a GDB-free instrumentation stack that boots uproc/GSP firmware in qemu-system-riscv64 and provides Frida-like hooks, breakpoints, watchpoints, memory dumps, structure decoding, snapshot diffs, recorded sessions, declarative probes, a plugin API and a Ghidra symbol/hit bridge. Verified: 25 tests green (including a live QEMU smoke test); the demo uproc boots with correct hook/leave/watch/schema/snapshot/fault evidence; the optional TCG trace plugin builds and recorded 15M trace lines in 3 s; the TU104 570.144 fwimage probe reports its first exception (load access fault at firmware vaddr 0x4009014 reading 0x2000000000000A00). Documentation: lean tool docs under debug/risc-v-gsp-rm-vm/docs/00-10, a new docs/05-operations/03-risc-v-gsp-rm-vm.md debugging document, and '# TOOL IN DEVELOPMENT - NOT READY YET!' markers in docs/README.md, the onboarding tooling table and the debugging workflows doc. Vendors qemu-plugin.h from QEMU v8.2.2 (GPL-2.0-or-later), with its license header intact, so the optional TCG plugin builds without a QEMU source tree.
Diffstat (limited to 'debug/risc-v-gsp-rm-vm/greeneye/structs.py')
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/structs.py197
1 files changed, 197 insertions, 0 deletions
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/structs.py b/debug/risc-v-gsp-rm-vm/greeneye/structs.py
new file mode 100644
index 0000000..3081bc9
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/structs.py
@@ -0,0 +1,197 @@
+"""Declarative structure decoding for firmware memory.
+
+A schema is plain data (JSON-friendly), so the same description can come from
+the repo, a Ghidra export, or a plugin:
+
+ {
+ "name": "gsp_ctrl",
+ "fields": [
+ {"name": "magic", "offset": 0, "fmt": "u32"},
+ {"name": "state", "offset": 8, "fmt": "u32", "enum": {"0": "IDLE", "1": "RUN"}},
+ {"name": "buf", "offset": 64, "fmt": "bytes", "size": 256},
+ {"name": "name", "offset": 64, "fmt": "cstr", "size": 32}
+ ]
+ }
+
+Supported formats: u8/u16/u32/u64, s8/s16/s32/s64, f32/f64, bool, bytes,
+cstr, ptr (u64 by default; override with ``size``).
+"""
+from __future__ import annotations
+
+import struct as _struct
+from dataclasses import dataclass, field as _field
+
+from .errors import StructError
+
+_INT_FORMATS = {
+ "u8": ("B", 1),
+ "u16": ("H", 2),
+ "u32": ("I", 4),
+ "u64": ("Q", 8),
+ "s8": ("b", 1),
+ "s16": ("h", 2),
+ "s32": ("i", 4),
+ "s64": ("q", 8),
+ "bool": ("B", 1),
+}
+_FLOAT_FORMATS = {"f32": ("f", 4), "f64": ("d", 8)}
+
+
+@dataclass
+class Field:
+ name: str
+ offset: int
+ fmt: str
+ size: int = 0
+ enum: dict | None = None
+ note: str | None = None
+ count: int = 1
+
+ @property
+ def nbytes(self) -> int:
+ if self.fmt in _INT_FORMATS:
+ return _INT_FORMATS[self.fmt][1]
+ if self.fmt in _FLOAT_FORMATS:
+ return _FLOAT_FORMATS[self.fmt][1]
+ if self.fmt in ("bytes", "cstr"):
+ return self.size
+ if self.fmt == "ptr":
+ return self.size or 8
+ if self.fmt == "u64x":
+ return 8
+ raise StructError(f"field {self.name!r}: unknown format {self.fmt!r}")
+
+
+@dataclass
+class Schema:
+ name: str
+ fields: list[Field] = _field(default_factory=list)
+ size: int = 0
+
+ def __post_init__(self) -> None:
+ computed = max((f.offset + f.nbytes for f in self.fields), default=0)
+ if not self.size:
+ self.size = computed
+ elif self.size < computed:
+ raise StructError(
+ f"schema {self.name!r}: declared size {self.size:#x} < fields end {computed:#x}"
+ )
+ self.validate()
+
+ def validate(self) -> None:
+ seen: set[str] = set()
+ for field in self.fields:
+ if field.name in seen:
+ raise StructError(f"schema {self.name!r}: duplicate field {field.name!r}")
+ seen.add(field.name)
+ if field.offset < 0:
+ raise StructError(f"schema {self.name!r}: negative offset for {field.name!r}")
+ if field.nbytes <= 0:
+ raise StructError(f"schema {self.name!r}: zero-size field {field.name!r}")
+
+ def decode(self, data: bytes, base: int = 0) -> dict:
+ if len(data) < self.size:
+ raise StructError(
+ f"schema {self.name!r}: need {self.size} bytes, got {len(data)}"
+ )
+ out: dict = {}
+ for field in self.fields:
+ out[field.name] = _decode_field(field, data, base)
+ return out
+
+
+def _decode_field(field: Field, data: bytes, base: int):
+ start = field.offset
+ if field.fmt in _INT_FORMATS:
+ code, width = _INT_FORMATS[field.fmt]
+ value = _struct.unpack_from("<" + code, data, start)[0]
+ if field.fmt == "bool":
+ value = bool(value)
+ return value
+ if field.fmt in _FLOAT_FORMATS:
+ code, _width = _FLOAT_FORMATS[field.fmt]
+ return _struct.unpack_from("<" + code, data, start)[0]
+ if field.fmt == "ptr":
+ return int.from_bytes(data[start : start + field.nbytes], "little")
+ if field.fmt == "bytes":
+ return bytes(data[start : start + field.size])
+ if field.fmt == "cstr":
+ raw = data[start : start + field.size]
+ return raw.split(b"\x00", 1)[0].decode("utf-8", "replace")
+ raise StructError(f"unknown format {field.fmt!r}")
+
+
+def decode(schema: Schema, data: bytes, base: int = 0) -> dict:
+ return schema.decode(data, base=base)
+
+
+def schema_from_json(payload: dict) -> Schema:
+ if not isinstance(payload, dict) or "fields" not in payload:
+ raise StructError("schema JSON must be an object with a 'fields' list")
+ fields = []
+ for item in payload["fields"]:
+ try:
+ fields.append(
+ Field(
+ name=item["name"],
+ offset=int(item["offset"]),
+ fmt=item.get("fmt", "bytes"),
+ size=int(item.get("size", 0)),
+ enum=item.get("enum"),
+ note=item.get("note"),
+ count=int(item.get("count", 1)),
+ )
+ )
+ except KeyError as exc:
+ raise StructError(f"schema field missing key: {exc}") from exc
+ return Schema(name=payload.get("name", "schema"), fields=fields, size=int(payload.get("size", 0)))
+
+
+def schema_to_json(schema: Schema) -> dict:
+ return {
+ "name": schema.name,
+ "size": schema.size,
+ "fields": [
+ {
+ k: v
+ for k, v in {
+ "name": f.name,
+ "offset": f.offset,
+ "fmt": f.fmt,
+ "size": f.size or None,
+ "enum": f.enum,
+ "note": f.note,
+ }.items()
+ if v is not None
+ }
+ for f in schema.fields
+ ],
+ }
+
+
+def render(schema: Schema, values: dict, indent: str = " ") -> str:
+ lines = [f"{schema.name} ({{base}}+{schema.size:#x} bytes)"]
+ width = max((len(f.name) for f in schema.fields), default=0)
+ for field in schema.fields:
+ value = values.get(field.name)
+ lines.append(f"{indent}{field.name:<{width}} @+0x{field.offset:03x} = {_format_value(field, value)}")
+ if field.note:
+ lines.append(f"{indent}{'':<{width}} ; {field.note}")
+ return "\n".join(lines)
+
+
+def _format_value(field: Field, value) -> str:
+ if isinstance(value, bool):
+ return str(value)
+ if isinstance(value, float):
+ return f"{value:g}"
+ if isinstance(value, int):
+ text = f"{value:#x}" if not field.enum else f"{value} ({field.enum.get(str(value), '?')})"
+ if field.enum and field.fmt not in ("bool",):
+ text = f"{value:#x} ({field.enum.get(str(value), field.enum.get(value, '?'))})"
+ return text
+ if isinstance(value, (bytes, bytearray)):
+ head = bytes(value[:16]).hex(" ")
+ suffix = " ..." if len(value) > 16 else ""
+ return f"[{len(value)}B] {head}{suffix}"
+ return repr(value)