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/__init__.py | 115 +++++ debug/risc-v-gsp-rm-vm/greeneye/__main__.py | 3 + debug/risc-v-gsp-rm-vm/greeneye/analyze.py | 293 ++++++++++++ debug/risc-v-gsp-rm-vm/greeneye/cli.py | 688 ++++++++++++++++++++++++++++ debug/risc-v-gsp-rm-vm/greeneye/elf.py | 322 +++++++++++++ debug/risc-v-gsp-rm-vm/greeneye/errors.py | 30 ++ debug/risc-v-gsp-rm-vm/greeneye/ghidra.py | 80 ++++ debug/risc-v-gsp-rm-vm/greeneye/memory.py | 190 ++++++++ debug/risc-v-gsp-rm-vm/greeneye/plugins.py | 121 +++++ debug/risc-v-gsp-rm-vm/greeneye/probes.py | 166 +++++++ debug/risc-v-gsp-rm-vm/greeneye/record.py | 156 +++++++ debug/risc-v-gsp-rm-vm/greeneye/rsp.py | 583 +++++++++++++++++++++++ debug/risc-v-gsp-rm-vm/greeneye/session.py | 562 +++++++++++++++++++++++ debug/risc-v-gsp-rm-vm/greeneye/structs.py | 197 ++++++++ debug/risc-v-gsp-rm-vm/greeneye/symbols.py | 313 +++++++++++++ debug/risc-v-gsp-rm-vm/greeneye/target.py | 223 +++++++++ 16 files changed, 4042 insertions(+) create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/__init__.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/__main__.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/analyze.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/cli.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/elf.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/errors.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/ghidra.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/memory.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/plugins.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/probes.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/record.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/rsp.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/session.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/structs.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/symbols.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/target.py (limited to 'debug/risc-v-gsp-rm-vm/greeneye') diff --git a/debug/risc-v-gsp-rm-vm/greeneye/__init__.py b/debug/risc-v-gsp-rm-vm/greeneye/__init__.py new file mode 100644 index 0000000..8b1c1e5 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/__init__.py @@ -0,0 +1,115 @@ +"""green-eye: Frida-like runtime instrumentation for GSP/uproc firmware in a +RISC-V QEMU VM. + +green-eye talks to QEMU's gdbstub directly (no GDB dependency) and layers a +scriptable instrumentation API on top: breakpoints, hooks, watchpoints, +memory inspection, structure decoding, session recording and post-run analysis. + +Public API (stable-ish for v0.1): + + import greeneye as ge + s = ge.launch(kernel="firmware/uproc_demo/build/uproc_demo.elf") + fn = s.symbols.lookup("rpc_sum") + h = s.hook(fn, on_enter=lambda hit: print(hit)) + s.run(duration=2.0) + s.close() + +Everything is development tooling; see docs/00-OVERVIEW.md. +""" +from __future__ import annotations + +__version__ = "0.1.0" + +from .errors import ( # noqa: F401 + GreenEyeError, + RspError, + RspTimeout, + RspProtocolError, + SymbolNotFound, + TargetError, + StructError, +) +from .rsp import RspClient, StopReply, Register # noqa: F401 +from .elf import FirmwareImage, Segment, ElfSymbol, load_elf # noqa: F401 +from .symbols import Symbol, SymbolDB # noqa: F401 +from .memory import Memory # noqa: F401 +from .structs import Field, Schema, decode, schema_from_json # noqa: F401 +from .record import Recorder, NullRecorder, load_events # noqa: F401 +from .session import Session, Hit, HookHandle, Breakpoint, Watchpoint # noqa: F401 +from .target import QemuTarget # noqa: F401 + +__all__ = [ + "attach", + "launch", + "Session", + "Hit", + "HookHandle", + "Breakpoint", + "Watchpoint", + "QemuTarget", + "RspClient", + "SymbolDB", + "Symbol", + "Memory", + "Recorder", +] + + +def attach( + host: str = "127.0.0.1", + port: int = 4444, + *, + timeout: float = 10.0, + record: str | None = None, + symbols: SymbolDB | None = None, + name: str = "attach", +) -> Session: + """Attach to an already-running QEMU gdbstub and return a Session.""" + client = RspClient(host, port, timeout=timeout) + client.connect() + client.sync() + return Session(client=client, symbols=symbols, record=record, name=name) + + +def launch( + *, + kernel: str | None = None, + machine: str = "virt", + memory_mb: int = 256, + gdb_port: int = 0, + bios: str = "none", + extra_args: list[str] | None = None, + timeout: float = 10.0, + record: str | None = None, + symbols: SymbolDB | None = None, + name: str = "session", + load_symbols: bool = True, + keep_alive: bool = False, +) -> Session: + """Launch QEMU (paused), attach, and return a ready Session. + + The target starts paused at its reset vector; use Session.load_image() to + load an ELF (which also sets PC), or QemuTarget.run_kernel() style helper + launch with kernel= for the fast `-kernel` path. + """ + target = QemuTarget( + machine=machine, + memory_mb=memory_mb, + gdb_port=gdb_port, + bios=bios, + extra_args=extra_args, + kernel=kernel, + ) + if keep_alive: + target.keep_alive = True + target.start(paused=True) + client = RspClient(target.host, target.port, timeout=timeout) + client.connect() + client.sync() + + symdb = symbols or SymbolDB() + sess = Session(client=client, target=target, symbols=symdb, record=record, name=name) + if kernel: + image = load_elf(kernel) + sess.load_image(image, load_symbols=load_symbols) + return sess diff --git a/debug/risc-v-gsp-rm-vm/greeneye/__main__.py b/debug/risc-v-gsp-rm-vm/greeneye/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/greeneye/analyze.py b/debug/risc-v-gsp-rm-vm/greeneye/analyze.py new file mode 100644 index 0000000..07c24eb --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/analyze.py @@ -0,0 +1,293 @@ +"""Post-run analysis: turn a session directory into a symbol-annotated report. + +The report is the "complete map" half of the deliverable: hits by function, +watch hits, memory writes, coverage and snapshot inventory, ready for +offline comparison between runs (bare-metal vs VFIO, old firmware vs new). +""" +from __future__ import annotations + +import os +import re +from collections import Counter, defaultdict + +from .record import load_events, load_meta + +_ACCESS_RE = re.compile( + r"Invalid (read|write) at addr (0x[0-9a-fA-F]+), size (\d+), region '([^']*)', reason: (\w+)" +) +_INTERRUPT_RE = re.compile( + r"riscv_cpu_do_interrupt: hart:(\d+), async:(\d+), cause:([0-9a-fA-F]+), " + r"epc:(0x[0-9a-fA-F]+), tval:(0x[0-9a-fA-F]+), desc=([\w_]+)" +) + + +def parse_qemu_faults(log_text: str) -> list[dict]: + """Parse QEMU ``-d guest_errors,unimp,int`` logs into fault records. + + Each record pairs the most recent invalid access (if any) with the + exception it raised, which is exactly the "first divergence" report a + firmware probe needs. + """ + faults: list[dict] = [] + pending_access: dict | None = None + for line in log_text.splitlines(): + access = _ACCESS_RE.search(line) + if access: + pending_access = { + "rw": access.group(1), + "access_addr": int(access.group(2), 16), + "size": int(access.group(3)), + "region": access.group(4), + "reason": access.group(5), + } + continue + interrupt = _INTERRUPT_RE.search(line) + if interrupt: + faults.append( + { + "hart": int(interrupt.group(1)), + "async": int(interrupt.group(2)), + "cause": int(interrupt.group(3), 16), + "epc": int(interrupt.group(4), 16), + "tval": int(interrupt.group(5), 16), + "desc": interrupt.group(6), + "access": pending_access, + } + ) + pending_access = None + return faults + + +_RISCV_CAUSES = { + 0: "instruction address misaligned", + 1: "instruction access fault", + 2: "illegal instruction", + 3: "breakpoint", + 4: "load address misaligned", + 5: "load access fault", + 6: "store address misaligned", + 7: "store access fault", +} + + +def describe_fault(fault: dict, bias: int = 0) -> str: + epc = fault["epc"] + fw_pc = epc - bias + cause = fault["cause"] + desc = _RISCV_CAUSES.get(cause & 0xFF, fault.get("desc", "exception")) + lines = [ + f"first exception: {fault.get('desc', '?')} (cause {cause:#x}: {desc})", + f" epc: {epc:#x}" + (f" (firmware vaddr {fw_pc:#x})" if bias else ""), + f" tval: {fault.get('tval', 0):#x}", + ] + access = fault.get("access") + if access: + lines.insert( + 1, + f" access: {access['rw']} at {access['access_addr']:#x} " + f"({access['size']} bytes, reason={access['reason']})", + ) + return "\n".join(lines) + + +def render_summary(session_dir: str, symbols=None, max_rows: int = 40) -> str: + meta = load_meta(session_dir) + events = load_events(session_dir) + return render_summary_from(meta, events, symbols=symbols, max_rows=max_rows) + + +def render_summary_from(meta: dict, events: list[dict], symbols=None, max_rows: int = 40) -> str: + lines: list[str] = [] + lines.append(f"# green-eye session summary: {meta.get('name', '?')}") + lines.append("") + lines.append(f"* format: `{meta.get('format', '?')}` green-eye {meta.get('greeneye', '?')}") + lines.append(f"* directory: `{meta.get('dir', '.')}`") + lines.append(f"* events: {len(events)}") + counts = Counter(event.get("type", "?") for event in events) + lines.append("* event counts: " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items()))) + counts_meta = meta.get("counts") + if counts_meta: + lines.append("* recorder counts: " + ", ".join(f"{k}={v}" for k, v in sorted(counts_meta.items()))) + lines.append("") + + def annotate(addr): + if not isinstance(addr, int): + return "" + if symbols is not None: + return symbols.annotate(addr) + return f"{addr:#x}" + + # Breakpoint hits + bp_hits = [e for e in events if e.get("type") == "bp_hit"] + if bp_hits: + by_site: Counter = Counter() + by_symbol: Counter = Counter() + for event in bp_hits: + key = event.get("symbol") or f"{event.get('pc', 0):#x}" + by_site[(event.get("pc"), key)] += 1 + by_symbol[key] += 1 + lines.append("## Breakpoint hits") + lines.append("") + lines.append("| hits | pc | symbol |") + lines.append("|---:|---|---|") + for (pc, key), count in by_site.most_common(max_rows): + lines.append(f"| {count} | `{pc:#x}` | `{key}` |") + lines.append("") + + # Watchpoint hits + watch_hits = [e for e in events if e.get("type") == "watch_hit"] + if watch_hits: + by_addr: Counter = Counter() + for event in watch_hits: + by_addr[(event.get("addr"), event.get("kind"))] += 1 + lines.append("## Watchpoint hits") + lines.append("") + lines.append("| hits | address | kind | symbol |") + lines.append("|---:|---|---|---|") + for (addr, kind), count in by_addr.most_common(max_rows): + sym = symbols.resolve(addr) if symbols else None + lines.append(f"| {count} | `{addr:#x}` | {kind} | `{sym.name if sym else ''}` |") + lines.append("") + + # Memory writes + writes = [e for e in events if e.get("type") == "mem_write"] + if writes: + regions = Counter() + for event in writes: + regions[(event.get("addr", 0) >> 12) << 12] += 1 + lines.append("## Memory writes") + lines.append("") + lines.append(f"* writes: {len(writes)}, distinct 4 KiB pages: {len(regions)}") + lines.append("") + lines.append("| page | writes | symbol |") + lines.append("|---|---:|---|") + for page, count in regions.most_common(max_rows): + sym = symbols.resolve(page) if symbols else None + lines.append(f"| `{page:#x}` | {count} | `{sym.name if sym else ''}` |") + lines.append("") + + # Coverage + steps = [e for e in events if e.get("type") == "step"] + if steps: + pcs = {(e.get("pc")) for e in steps} + lines.append("## Stepped coverage") + lines.append("") + lines.append(f"* steps: {len(steps)}, unique PCs: {len(pcs)}") + by_sym = Counter() + for pc in pcs: + sym = symbols.resolve(pc) if symbols else None + by_sym[sym.name if sym else f"{pc:#x}"] += 1 + lines.append("") + lines.append("| PCs | symbol |") + lines.append("|---:|---|") + for name, count in by_sym.most_common(max_rows): + lines.append(f"| {count} | `{name}` |") + lines.append("") + + # Snapshots + snapshots = meta.get("snapshots") or [e for e in events if e.get("type") == "snapshot_saved"] + if snapshots: + lines.append("## Snapshots") + lines.append("") + lines.append("| label | address | size | path | sha256 |") + lines.append("|---|---|---:|---|---|") + for snap in snapshots[:max_rows]: + lines.append( + f"| {snap.get('label')} | `{snap.get('addr', 0):#x}` | {snap.get('size', 0)} | " + f"`{snap.get('path')}` | `{(snap.get('sha256') or '')[:16]}` |" + ) + lines.append("") + + # Timeline excerpt + notable = [e for e in events if e.get("type") in ("bp_hit", "leave", "watch_hit", "mem_write", "note", "exit")] + if notable: + lines.append("## Timeline (first hits)") + lines.append("") + for event in notable[:max_rows]: + t = event.get("t", 0) + kind = event.get("type") + if kind in ("bp_hit", "leave"): + site = event.get("symbol") or f"{event.get('pc', 0):#x}" + extra = "" + regs = event.get("regs") or {} + if "a0" in regs: + extra = f" a0={regs['a0']:#x}" + if kind == "leave" and "retval" in event: + extra += f" -> {event['retval']:#x}" + lines.append(f"* `{t:8.3f}s` {kind}: {site}{extra}") + elif kind == "watch_hit": + lines.append(f"* `{t:8.3f}s` watch: {annotate(event.get('addr'))} ({event.get('kind')})") + elif kind == "mem_write": + lines.append( + f"* `{t:8.3f}s` write: {annotate(event.get('addr'))} " + f"{event.get('size')}B {event.get('data', '')[:32]}" + ) + elif kind == "note": + lines.append(f"* `{t:8.3f}s` note: {event.get('text')}") + elif kind == "exit": + lines.append(f"* `{t:8.3f}s` exit status={event.get('status')}") + lines.append("") + + # Symbol-annotation table for automation + lines.append("## Annotated sites") + lines.append("") + sites = Counter() + for event in events: + for key in ("pc", "addr"): + value = event.get(key) + if isinstance(value, int) and value and event.get("type") in ("bp_hit", "watch_hit", "mem_write"): + sites[value] += 1 + lines.append("| address | count | annotation |") + lines.append("|---|---:|---|") + for addr, count in sites.most_common(max_rows): + lines.append(f"| `{addr:#x}` | {count} | `{annotate(addr)}` |") + lines.append("") + return "\n".join(lines) + "\n" + + +def compare_sessions(a_dir: str, b_dir: str, symbols=None, max_rows: int = 40) -> str: + """Diff two recorded sessions at the symbolic event level (A/B tooling).""" + a_meta, b_meta = load_meta(a_dir), load_meta(b_dir) + a_events, b_events = load_events(a_dir), load_events(b_dir) + lines = [f"# green-eye session comparison", "", f"* A: {a_meta.get('name')} ({a_dir})", f"* B: {b_meta.get('name')} ({b_dir})", ""] + + def profile(events): + bp = Counter() + watch = Counter() + writes = Counter() + for event in events: + if event.get("type") == "bp_hit": + bp[event.get("symbol") or f"{event.get('pc', 0):#x}"] += 1 + elif event.get("type") == "watch_hit": + watch[(event.get("addr"), event.get("kind"))] += 1 + elif event.get("type") == "mem_write": + writes[(event.get("addr", 0) >> 12) << 12] += 1 + return bp, watch, writes + + a_bp, a_watch, a_writes = profile(a_events) + b_bp, b_watch, b_writes = profile(b_events) + + lines.append("## Breakpoint profile delta (A vs B)") + lines.append("") + lines.append("| symbol | A hits | B hits | delta |") + lines.append("|---|---:|---:|---:|") + for name in sorted(set(a_bp) | set(b_bp), key=lambda k: -(a_bp.get(k, 0) + b_bp.get(k, 0)))[:max_rows]: + lines.append(f"| `{name}` | {a_bp.get(name, 0)} | {b_bp.get(name, 0)} | {b_bp.get(name, 0) - a_bp.get(name, 0):+d} |") + lines.append("") + + lines.append("## Watchpoint delta") + lines.append("") + lines.append("| address | kind | A | B |") + lines.append("|---|---|---:|---:|") + for key in sorted(set(a_watch) | set(b_watch), key=lambda k: -(a_watch.get(k, 0) + b_watch.get(k, 0)))[:max_rows]: + lines.append(f"| `{key[0]:#x}` | {key[1]} | {a_watch.get(key, 0)} | {b_watch.get(key, 0)} |") + lines.append("") + + lines.append("## Written pages delta") + lines.append("") + lines.append("| page | A writes | B writes |") + lines.append("|---|---:|---:|") + for page in sorted(set(a_writes) | set(b_writes), key=lambda k: -(a_writes.get(k, 0) + b_writes.get(k, 0)))[:max_rows]: + lines.append(f"| `{page:#x}` | {a_writes.get(page, 0)} | {b_writes.get(page, 0)} |") + lines.append("") + return "\n".join(lines) + "\n" 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