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/analyze.py | 293 +++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/analyze.py (limited to 'debug/risc-v-gsp-rm-vm/greeneye/analyze.py') 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" -- cgit v1.2.3