summaryrefslogtreecommitdiffstats
path: root/debug/risc-v-gsp-rm-vm/greeneye/analyze.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/analyze.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/analyze.py')
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/analyze.py293
1 files changed, 293 insertions, 0 deletions
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"