From ba58dcdc5ad6ca021c723d279f33e3f14e1184df Mon Sep 17 00:00:00 2001 From: Arthur Hanson Rasmusson Date: Fri, 2 Oct 2026 08:36:35 -0400 Subject: 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. --- debug/risc-v-gsp-rm-vm/greeneye/cli.py | 688 +++++++++++++++++++++++++++++++++ 1 file changed, 688 insertions(+) create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/cli.py (limited to 'debug/risc-v-gsp-rm-vm/greeneye/cli.py') diff --git a/debug/risc-v-gsp-rm-vm/greeneye/cli.py b/debug/risc-v-gsp-rm-vm/greeneye/cli.py new file mode 100644 index 0000000..5bfcee2 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/cli.py @@ -0,0 +1,688 @@ +"""green-eye command line interface. + +Commands: + info environment/tooling check + demo boot the bundled uproc demo and run a canned instrumentation pass + run run a Python instrumentation script against a target + attach interactive REPL against a QEMU gdbstub + probe run an arbitrary firmware (default: the real GSP fwimage) for N + instructions and report the first divergence + record boot a firmware and record a memory-activity session + analyze render a recorded session to a symbol-annotated report + compare A/B two recorded sessions at the symbolic level + symbols build/inspect a green-eye symbol database from Ghidra exports + plugins list discoverable plugins +""" +from __future__ import annotations + +import argparse +import cmd +import os +import sys +import tempfile +import time +from pathlib import Path + +import greeneye as ge +from . import analyze as _analyze +from . import ghidra as _ghidra +from .elf import load_elf +from .errors import GreenEyeError +from .plugins import PluginManager +from .structs import schema_from_json +from .symbols import SymbolDB +from .target import QemuTarget + +TOOL_ROOT = Path(__file__).resolve().parent.parent +DUMPS = TOOL_ROOT / "dumps" +DEMO_ELF = TOOL_ROOT / "firmware" / "uproc_demo" / "build" / "uproc_demo.elf" +DEMO_SCHEMA = TOOL_ROOT / "firmware" / "uproc_demo" / "gsp_ctrl.schema.json" +DEFAULT_FWIMAGE = TOOL_ROOT.parents[1] / "firmware" / "57X-GSP" / "570.144" / "gsp-570.144.fwimage.bin" + +RPC_HANDLERS = ["rpc_ping", "rpc_echo", "rpc_fill", "rpc_sum", "rpc_state", "rpc_fault"] + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + + +def _ensure_dumps_env() -> None: + os.environ.setdefault("GREENEYE_DUMPS", str(DUMPS)) + + +def _build_demo(quiet: bool = False) -> Path: + import subprocess + + fw_dir = TOOL_ROOT / "firmware" / "uproc_demo" + result = subprocess.run( + ["make", "-C", str(fw_dir)], + capture_output=quiet, + text=True, + ) + if result.returncode != 0: + output = (result.stdout or "") + (result.stderr or "") + raise GreenEyeError(f"demo firmware build failed:\n{output}") + if not DEMO_ELF.exists(): + raise GreenEyeError(f"demo build produced no {DEMO_ELF}") + return DEMO_ELF + + +def _load_schema(path: Path): + import json + + with open(path, "r", encoding="utf-8") as fh: + return schema_from_json(json.load(fh)) + + +def _start_session( + *, + kernel: str | None, + port: int | None, + record, + name: str, + plugins: list[str] | None = None, +): + if port: + db = SymbolDB() + if kernel: + db.load_elf(kernel) + session = ge.attach(port=port, record=record, symbols=db, name=name) + else: + session = ge.launch(kernel=kernel, record=record, name=name) + manager = PluginManager(session) + manager.load_all(plugins) + return session, manager + + +def _print_session_dir(session) -> None: + directory = getattr(session.recorder, "dir", None) + if directory: + print(f"[green-eye] session: {directory}") + + +# --------------------------------------------------------------------------- +# commands +# --------------------------------------------------------------------------- + + +def cmd_info(args) -> int: + from .target import DEFAULT_QEMU + + print(f"green-eye {ge.__version__}") + print(f"tool root: {TOOL_ROOT}") + qemu = os.environ.get("GREENEYE_QEMU", DEFAULT_QEMU) + import shutil + + resolved = shutil.which(qemu) if os.path.sep not in qemu else (qemu if os.path.exists(qemu) else None) + print(f"qemu: {resolved or 'NOT FOUND (install qemu-system-misc)'}") + if resolved: + import subprocess + + version = subprocess.run([resolved, "--version"], capture_output=True, text=True).stdout.strip() + print(f"qemu version: {version}") + for label, path in [ + ("demo firmware", DEMO_ELF), + ("demo schema", DEMO_SCHEMA), + ("default fwimage", DEFAULT_FWIMAGE), + ]: + print(f"{label}: {path} ({'present' if Path(path).exists() else 'missing'})") + cuda_names = Path(TOOL_ROOT).parents[1] / "userspace" / "cuda" / "570.144" / "annotations" / "names.tsv" + print(f"CUDA Ghidra names.tsv: {cuda_names} ({'present' if cuda_names.exists() else 'missing'})") + plugins_dir = TOOL_ROOT / "plugins" + plugins = sorted(p.name for p in plugins_dir.glob("*.py") if not p.name.startswith("_")) + print(f"bundled plugins: {', '.join(plugins) or 'none'}") + return 0 + + +def cmd_demo(args) -> int: + _ensure_dumps_env() + elf = args.kernel or (_build_demo() if not args.no_build else DEMO_ELF) + if not Path(elf).exists(): + raise GreenEyeError(f"demo firmware not built: {elf}") + schema = _load_schema(DEMO_SCHEMA) + record = True if args.record is None else args.record + session, _manager = _start_session(kernel=str(elf), port=None, record=record, name="demo") + hits: list[str] = [] + try: + ctrl = session.symbols.lookup("gsp_ctrl") + for name in RPC_HANDLERS: + sym = session.symbols.get(name) + if sym is not None: + session.hook( + sym.addr, + on_enter=lambda hit: hits.append(f"enter {hit.label()} a0={hit.arg(0):#x}"), + on_leave=lambda hit: hits.append(f"leave {hit.label()} -> {hit.retval:#x}"), + ) + session.watch(ctrl + 16, size=8, kind="w", listener="rpc_count") + print(f"[demo] ctrl={ctrl:#x} schema={schema.name} size={schema.size:#x}") + print("[demo] running the firmware self-test ...") + session.run(duration=args.duration) + banner = session.target.read_uart() if session.target else "" + print("[demo] uart:", banner.strip().splitlines()[0] if banner.strip() else "") + before = session.snapshot(ctrl, schema.size, "ctrl-after-selftest") + + print("[demo] host stimulus: PING") + session.memory.write_u64(ctrl + 32, 1) + session.memory.write_u64(ctrl + 40, 0x1234) + session.run(duration=0.5) + after = session.snapshot(ctrl, schema.size, "ctrl-after-ping") + + values = schema.decode(after, base=ctrl) + print("[demo] control block after stimulus:") + from .structs import render + + print(render(schema, values)) + diffs = ge.Memory.diff(before, after, base=ctrl) + if diffs: + print("[demo] snapshot diff (self-test -> ping):") + print(ge.Memory.render_diff(diffs, session.symbols, max_lines=16)) + + print("[demo] host stimulus: FAULT (ebreak) to demonstrate trap capture") + session.memory.write_u64(ctrl + 32, 6) + session.run(duration=1.0, max_stops=1) + pc = session.pc + sym = session.symbols.resolve(pc) + print(f"[demo] stopped at {pc:#x} {sym.name if sym else ''} (ebreak)") + session.step() # step past the trap + print("[demo] stepped past the trap; hits:") + for line in hits[:24]: + print(" ", line) + print(f"[demo] total hook lines: {len(hits)}") + _print_session_dir(session) + finally: + session.close() + return 0 + + +def cmd_run(args) -> int: + _ensure_dumps_env() + script = Path(args.script) + if not script.exists(): + raise GreenEyeError(f"script not found: {script}") + kernel = args.kernel + if not kernel and not args.port: + kernel = str(_build_demo()) + session, manager = _start_session( + kernel=kernel, port=args.port, record=args.record or True, name=script.stem, plugins=args.plugin + ) + namespace = { + "ge": ge, + "session": session, + "s": session, + "memory": session.memory, + "symbols": session.symbols, + "recorder": session.recorder, + "greeneye": ge, + } + try: + source = script.read_text(encoding="utf-8") + exec(compile(source, str(script), "exec"), namespace) + return 0 + except SystemExit as exc: + return int(exc.code or 0) + finally: + session.close() + + +# -- interactive REPL ------------------------------------------------------- + + +class GreenEyeRepl(cmd.Cmd): + intro = "green-eye interactive instrumentation. Type 'help' for commands." + prompt = "greeneye> " + + def __init__(self, session, manager): + super().__init__() + self.session = session + self.manager = manager + self._listener_hooks = [] + + # utility + def _print_hit(self, hit): + print(" " + str(hit)) + + def _say(self, text): + print(text) + + # commands + def do_regs(self, arg): + """regs -- dump all registers.""" + regs = self.session.read_regs() + for name, value in regs.items(): + print(f" {name:<5} = {value:#018x}") + + def do_reg(self, arg): + """reg [value] -- read or write one register.""" + parts = arg.split() + if not parts: + return self._say("usage: reg [value]") + if len(parts) == 1: + print(f" {parts[0]} = {self.session.client.read_reg(parts[0]):#x}") + else: + self.session.client.write_reg(parts[0], int(parts[1], 0)) + self._say("ok") + + def do_x(self, arg): + """x [len] -- hexdump memory (annotated).""" + parts = arg.split() + if not parts: + return self._say("usage: x [len]") + addr = self.session.resolve(parts[0]) + length = int(parts[1], 0) if len(parts) > 1 else 64 + print(self.session.memory.hexdump(addr, length)) + + def do_mem(self, arg): + """mem -- raw memory read.""" + addr, length = arg.split() + print(self.session.memory.read(self.session.resolve(addr), int(length, 0)).hex()) + + def do_w(self, arg): + """w -- write memory.""" + addr, data = arg.split() + self.session.memory.write(self.session.resolve(addr), bytes.fromhex(data)) + self._say("ok") + + def do_wstr(self, arg): + """wstr -- write a string plus NUL.""" + addr, _, text = arg.partition(" ") + self.session.memory.write(self.session.resolve(addr), text.encode() + b"\x00") + self._say("ok") + + def do_bp(self, arg): + """bp [sw|hw] -- set a breakpoint that prints on hit.""" + parts = arg.split() + if not parts: + return self._say("usage: bp [sw|hw]") + kind = parts[1] if len(parts) > 1 else "sw" + handle = self.session.hook(parts[0], on_enter=self._print_hit, kind=kind, listener="repl") + self._listener_hooks.append(handle) + self._say(f"breakpoint @ {handle.addr:#x} ({handle.label})") + + def do_hook(self, arg): + """hook -- alias for bp with enter/leave printing.""" + parts = arg.split() + if not parts: + return self._say("usage: hook ") + handle = self.session.hook( + parts[0], on_enter=self._print_hit, on_leave=self._print_hit, listener="repl" + ) + self._listener_hooks.append(handle) + self._say(f"hooked {handle.label}") + + def do_watch(self, arg): + """watch [size] [w|r|a] -- set a watchpoint.""" + parts = arg.split() + if not parts: + return self._say("usage: watch [size] [w|r|a]") + size = int(parts[1], 0) if len(parts) > 1 else 8 + kind = parts[2] if len(parts) > 2 else "w" + wp = self.session.watch(parts[0], size=size, kind=kind, callback=self._print_hit, listener="repl") + self._say(f"watch {kind}@{wp.addr:#x} size={size}") + + def do_s(self, arg): + """s [n] -- step n instructions (default 1).""" + count = int(arg) if arg.strip() else 1 + for hit in self.session.step(count): + self._print_hit(hit) + + def do_c(self, arg): + """c [seconds] -- continue and service events for N seconds (default 1).""" + seconds = float(arg) if arg.strip() else 1.0 + stops = self.session.run(duration=seconds) + self._say(f"resumed for {seconds}s, serviced {stops} stops") + + def do_events(self, arg): + """events [n] -- show the last n recorded events (default 20).""" + from .record import load_events + + directory = getattr(self.session.recorder, "dir", None) + if not directory: + return self._say("this session is not recording") + events = load_events(directory) + for event in events[-int(arg or 20) :]: + print(" " + str(event)) + + def do_snap(self, arg): + """snap