diff options
| author | Arthur Hanson Rasmusson <arthur@vgpu.io> | 2026-10-02 08:36:35 -0400 |
|---|---|---|
| committer | Arthur Hanson Rasmusson <arthur@vgpu.io> | 2026-10-02 08:36:35 -0400 |
| commit | ba58dcdc5ad6ca021c723d279f33e3f14e1184df (patch) | |
| tree | 47e43a2dad9a2d4bb40da46243654bff8b1302de | |
| parent | fe485080e159c983242d699f4a584659df159818 (diff) | |
| download | open-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.
60 files changed, 7886 insertions, 4 deletions
diff --git a/debug/risc-v-gsp-rm-vm/.gitignore b/debug/risc-v-gsp-rm-vm/.gitignore new file mode 100644 index 0000000..3a97916 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/.gitignore @@ -0,0 +1,8 @@ +# Recorded sessions and built artifacts are never tracked. +dumps/ +firmware/uproc_demo/build/ +qemu/*.so +qemu/*.o +__pycache__/ +*.pyc +.run/ diff --git a/debug/risc-v-gsp-rm-vm/Makefile b/debug/risc-v-gsp-rm-vm/Makefile new file mode 100644 index 0000000..a5103f5 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/Makefile @@ -0,0 +1,31 @@ +# green-eye convenience targets. The real work lives in the package. +PYTHON ?= python3 + +.PHONY: all demo test demo-run plugin probe-real clean info + +all: demo + +demo: + $(MAKE) -C firmware/uproc_demo + +test: demo + $(PYTHON) -m unittest discover -s tests -t . + +demo-run: demo + ./greeneye.sh demo --no-build + +info: + ./greeneye.sh info + +# Optional TCG plugin (needs the QEMU plugin headers; see qemu/README.md). +plugin: + $(MAKE) -C qemu + +# Run the real GSP fwimage for a bounded window and report first divergence. +probe-real: + ./greeneye.sh probe + +clean: + $(MAKE) -C firmware/uproc_demo clean + rm -rf dumps/* qemu/*.so qemu/*.o + find . -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null || true diff --git a/debug/risc-v-gsp-rm-vm/README.md b/debug/risc-v-gsp-rm-vm/README.md new file mode 100644 index 0000000..66f53fa --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/README.md @@ -0,0 +1,77 @@ +# green-eye — GSP runtime analysis in a RISC-V QEMU VM + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** +> Experimental tooling for simulating and inspecting the GSP firmware's +> runtime memory, for `gsp-rm-vfio` accuracy measurements and GSP/uproc +> reverse engineering. It is a development-only, out-of-tree tool: never a +> dependency of the shipped driver and never present in a guest. + +green-eye boots GSP/uproc firmware in `qemu-system-riscv64`, attaches over +QEMU's gdbstub with a GDB-free Python client, and exposes Frida-like +instrumentation: hooks, breakpoints, watchpoints, memory dumping, structure +decoding, snapshot diffing, session recording, plugins and a Ghidra bridge. + +Start with [`docs/00-OVERVIEW.md`](docs/00-OVERVIEW.md). + +## Quick start + +```bash +cd open-iov/debug/risc-v-gsp-rm-vm + +make demo # build the bundled RV64 uproc demo (clang + lld) +./greeneye.sh demo # boot it, hook handlers, watch counters, record + analyze +./greeneye.sh info # environment / tooling check +``` + +Interactively instrument a session: + +```bash +./greeneye.sh attach --kernel firmware/uproc_demo/build/uproc_demo.elf +greeneye> bp rpc_sum +greeneye> watch gsp_ctrl+0x10 8 w +greeneye> x 0x80000840 64 +greeneye> c 2 +``` + +Or launch a target separately and attach to it by port: + +```bash +./greeneye.sh launch --kernel <elf> # prints the gdbstub port, keeps QEMU alive +./greeneye.sh attach --port <port> --kernel <elf> +``` + +Requirements: QEMU (`qemu-system-misc` on Ubuntu gives +`qemu-system-riscv64`), Python 3.10+, clang + lld for the demo firmware. +Everything else is Python stdlib. + +## Layout + +``` +greeneye/ Python package: RSP client, session, memory, symbols, + structures, recording, analysis, probes, plugins, Ghidra bridge +firmware/uproc_demo/RV64 bare-metal demo "uproc" (mailbox RPC, state machine, trap) +qemu/ launch helper + optional TCG plugin for full memory tracing +ghidra/ Ghidra scripts (export symbols, annotate hit addresses) +plugins/ example plugin + authoring guide +examples/ Python instrumentation scripts and declarative probe specs +tests/ unit tests + QEMU integration smoke test +docs/ full documentation set (design, scripting, plugins, accuracy, + portability playbooks, roadmap, GSP firmware notes) +dumps/ recorded sessions (gitignored) +``` + +## Documentation map + +| Doc | Content | +|---|---| +| [00-OVERVIEW](docs/00-OVERVIEW.md) | purpose, status, quickstart | +| [01-DESIGN](docs/01-DESIGN.md) | architecture and data model | +| [02-USAGE](docs/02-USAGE.md) | CLI and REPL recipes | +| [03-SCRIPTING](docs/03-SCRIPTING.md) | Python API + probe specs | +| [04-PLUGINS](docs/04-PLUGINS.md) | writing green-eye plugins | +| [05-GHIDRA-BRIDGE](docs/05-GHIDRA-BRIDGE.md) | static/dynamic address join | +| [06-ACCURACY](docs/06-ACCURACY.md) | simulation fidelity and measurement | +| [07-PORTABILITY-PLAYBOOKS](docs/07-PORTABILITY-PLAYBOOKS.md) | new GSP/device/arch runs | +| [08-ROADMAP](docs/08-ROADMAP.md) | MCP, custom simulator, device model | +| [09-GSP-FIRMWARE-NOTES](docs/09-GSP-FIRMWARE-NOTES.md) | uproc/falcon/NV-RISCV facts | +| [10-RELATED-WORK](docs/10-RELATED-WORK.md) | Frida/GDB/QEMU-plugin comparison | diff --git a/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md b/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md new file mode 100644 index 0000000..a9f4251 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md @@ -0,0 +1,98 @@ +# 00 — green-eye overview + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** +> Experimental tooling for simulating and inspecting the GSP firmware's +> runtime memory, for `gsp-rm-vfio` accuracy measurements and GSP/uproc +> reverse engineering. Interfaces and file formats may change without notice. +> It is development-only: never shipped, never a dependency of the emulation, +> never present in a guest. + +## What problem it solves + +Open-IOV emulates the GSP interface and (in the physical backend) boots the +real GSP firmware. Both efforts need the same missing capability: to *watch +the firmware do things* — which handler runs, what it reads and writes, what +it returns, how its state machine evolves — without a physical GPU, without +WPR2/hazard exposure, and repeatably. + +green-eye runs uproc/GSP firmware in `qemu-system-riscv64` and gives the +developer Frida-like instrumentation over its memory and execution: + +* breakpoints and enter/leave **hooks** (RV64 ABI aware), +* **watchpoints** on firmware variables, +* **memory** read/write/hexdump/search/scan, +* **structure decoding** from schemas, +* **snapshot + diff** of memory regions, +* **sessions**: a recorded, replayable dump of runtime memory activity, +* **post-run analysis** with symbol annotation, +* **plugins** for new tools, harnesses and virtualization updates, +* a **Ghidra bridge** joining static addresses to runtime hits, +* A/B tooling for **bare-metal vs vfio-mdev** result comparison. + +It is deliberately not a GDB wrapper: a pure-Python RSP client makes the +whole stack scriptable, and QEMU is just the first execution backend. + +## Status (v0.1, 2026-10-02) + +| Capability | State | +|---|---| +| RV64 QEMU boot + gdbstub transport (no GDB) | working, live-verified | +| Hooks / breakpoints / watchpoints / step / continue | working, live-verified | +| Memory tools + struct decode + snapshot diff | working, live-verified | +| Session recording + `analyze` report | working, live-verified | +| Declarative probe specs | working, live-verified | +| Plugin loader + example plugin | working | +| Test suite | 22 tests green (live QEMU smoke included) | +| Ghidra bridge | scripts + docs | +| Real GSP `.fwimage` probe (first-fault report) | working (honest: it does not boot without a TU104 model — see 06) | +| TCG full-memory tracing plugin | source + optional build | +| MCP server / custom simulator / full device model | roadmap only (08) | + +## Quick start + +```bash +cd open-iov/debug/risc-v-gsp-rm-vm +make demo # build the bundled RV64 uproc demo +./greeneye.sh demo # canned instrumentation pass, records a session +./greeneye.sh info # environment check +./greeneye.sh probe # run the real GSP fwimage and report first divergence +``` + +Interactively: + +```bash +./greeneye.sh demo --no-build --duration 0 # launches; prints the gdbstub port +./greeneye.sh attach --port <port> --kernel firmware/uproc_demo/build/uproc_demo.elf +``` + +## Document map + +| Doc | Read it for | +|---|---| +| [01-DESIGN](01-DESIGN.md) | how the pieces fit, transport, data model, limits | +| [02-USAGE](02-USAGE.md) | CLI and REPL recipes | +| [03-SCRIPTING](03-SCRIPTING.md) | writing programs against the API | +| [04-PLUGINS](04-PLUGINS.md) | extending green-eye | +| [05-GHIDRA-BRIDGE](05-GHIDRA-BRIDGE.md) | static/dynamic address join | +| [06-ACCURACY](06-ACCURACY.md) | what the simulation does and does not prove | +| [07-PORTABILITY-PLAYBOOKS](07-PORTABILITY-PLAYBOOKS.md) | new GSP / device / architecture | +| [08-ROADMAP](08-ROADMAP.md) | MCP, custom simulator, device model | +| [09-GSP-FIRMWARE-NOTES](09-GSP-FIRMWARE-NOTES.md) | uproc/falcon/NV-RISCV facts used here | +| [10-RELATED-WORK](10-RELATED-WORK.md) | Frida/GDB/QEMU-plugin comparison | + +## Where this fits in the Open-IOV workflow + +green-eye is a **development and research** tool, alongside the Ghidra corpus +and the guest diagnostics. It does not replace either: + +* **Ghidra** answers *what is the code*; green-eye answers *what did it do*. +* **Guest diagnostics** (`tools/guest-diag`) answer *what did the guest + driver do*; green-eye answers *what did the firmware server do*. +* **The physical backend** (PLAN B) answers *what does real silicon do*; + green-eye provides a safe place to repeat, mutate and single-step the same + firmware when the hardware path is hazardous or unavailable. + +The intended accuracy loop is: run the same workload against the real +GSP-RM (hardware or mdev), record its RPC/state behaviour, then run the +firmware in green-eye, record the same events, and compare with `compare` +(see 06 and 07). diff --git a/debug/risc-v-gsp-rm-vm/docs/01-DESIGN.md b/debug/risc-v-gsp-rm-vm/docs/01-DESIGN.md new file mode 100644 index 0000000..c5de2da --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/01-DESIGN.md @@ -0,0 +1,179 @@ +# 01 — green-eye design + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status. + +## 1. Why this shape + +Two facts drive the design: + +1. **Frida does not run here.** Frida upstream ships no RISC-V target + (verified against the 17.20 release assets: x86/x86_64, arm/arm64, mips, + macOS/Windows/QNX/Prospero — no riscv64), and its agent model requires a + hosted process. The GSP image is bare-metal RV64IMAC with its own libos. + So the instrumentation layer is built from QEMU's gdbstub and QEMU TCG + plugins instead (see 10 for the full comparison). +2. **The project needs scripting, not a debugger.** The recurring workflow is + "run firmware, capture a symbol-annotated map of behaviour, diff it against + another run". A pure-Python RSP client makes that a library call; GDB + would make it a subprocess and a parsing problem. + +Design goals, in order: direct time-to-value, scriptability, extensibility, +robustness, and honest fidelity reporting. + +## 2. Component map + +``` + +-----------------------------+ + | qemu-system-riscv64 (virt) | + | -bios none -kernel <elf> | + | gdbstub <-- TCP | + +--------------+--------------+ + | RSP + greeneye/rsp.py pure-Python RSP client + | | + | registers, memory, Z0-Z4, continue/step/interrupt, qRcmd, qXfer + v + greeneye/target.py QEMU process lifecycle, loader segments, UART/qemu logs + greeneye/elf.py ELF64/32 parse: segments, symbols, entry + greeneye/symbols.py symbol DB (ELF + Ghidra TSV + JSON) + address translation + greeneye/session.py hooks, breakpoints, watchpoints, stop dispatch, run loop + greeneye/memory.py read/write/hexdump/search/snapshot/diff + greeneye/structs.py declarative structure schemas + render + greeneye/probes.py declarative probe specs + conditions + greeneye/record.py session dir: events.jsonl + snapshots + session.json + greeneye/analyze.py summary/compare reports (symbol annotated) + greeneye/plugins.py plugin discovery + API + greeneye/ghidra.py Ghidra export/import bridge + greeneye/cli.py info/demo/run/attach/probe/record/analyze/compare/symbols +``` + +## 3. Transport: the RSP client + +`rsp.py` implements the gdbstub subset QEMU needs: + +* packet framing `$<data>#<checksum>`, ack/no-ack, retransmit on bad checksum; +* `QStartNoAckMode` (QEMU acknowledges with an **empty packet**, then + switches once the client acks — a bring-up bug worth remembering); +* `qSupported`, including the stub's `PacketSize` (memory payloads are chunked + so hex expansion never exceeds it); +* `qXfer:features:read:target.xml`, recursively inlining `xi:include` register + files; RV64 register layout with a 33×8-byte fallback; +* `g`/`p`/`P`, `m`/`M`/`X`, `Z0-Z4`, `c`/`s`, interrupt `0x03`, `qRcmd`; +* stop-reply parsing (`T`/`S`/`W`/`X`, `watch:`/`rwatch:`/`awatch:`, + `thread:`). + +The client is deliberately blocking and simple; async/concurrency belongs in +the plugin layer, not the transport. + +## 4. Session semantics (the Frida-like core) + +* **Breakpoint kinds**: `sw` (default; stepped over transparently) and `hw`. +* **Hooks**: `hook(target, on_enter, on_leave)`. `on_leave` is implemented by + reading `ra` on entry and arming a *return breakpoint*; nested calls at the + same return address are queued (LIFO). Tail calls and asynchronous + interrupts are not tracked — documented limitation. +* **Watchpoints**: `Z2/Z3/Z4` (write/read/access). QEMU stops *at* the + accessing instruction, so green-eye temporarily clears the watch, steps one + instruction, and re-arms it. Without this the target livelocks on the same + store. +* **Stop dispatch**: every stop reads the register file, resolves the PC to a + symbol, records an event, invokes matching hooks/watchpoints, and resumes. +* **Run loop**: `run(duration=…, max_stops=…)` services events until the + wall-clock budget, stop budget, or target exit; the deadline path interrupts + **once**, dispatches that stop, and returns (never interrupt twice). +* **Faults**: `ebreak`/traps surface as plain stops; `step()` walks past them. + The demo uses this to demonstrate trap capture. + +## 5. Address spaces and the symbol layer + +Three address spaces must never be confused (the CUDA docs learned this the +hard way): + +| Space | Meaning | Example | +|---|---|---| +| file vaddr | offset in the binary file (`libcuda+0x…`) | `0x353ef0` | +| program addr | what Ghidra shows (file vaddr + image base) | `0x453ef0` (base `0x100000`) | +| runtime addr | link address + relocation bias | `0x80000048` | + +`SymbolDB` stores runtime addresses. Loaders take a `bias`: + +```python +db.load_elf(image, bias=relocation) # demo firmware: bias 0 +db.load_names_tsv("names.tsv", bias=0x100000) # libcuda: file vaddr -> runtime +db.load_index_tsv("decompiled_index.tsv") # program+file addr columns +db.load_callgraph("callgraph.tsv") # caller/callee extras +``` + +`resolve()` returns the containing symbol (or nearest preceding within a +delta), and `annotate()` renders `addr name+0xoff`. + +## 6. Recording format (`greeneye-session/1`) + +A session directory is the durable artifact: + +``` +dumps/<stamp>-<name>/ + session.json # format, name, created, counts, snapshot inventory, meta + events.jsonl # one JSON object per event, monotonic t + fields + snapshots/*.bin.z # zlib-compressed region captures (sha256 recorded) + blobs/* # plugin/probe dumps + summary.md # rendered analysis (analyze/render_summary) +``` + +Event types: `session_start`, `image_loaded`, `hook`, `watch`, `bp_hit`, +`leave`, `watch_hit`, `stop`, `step`, `mem_read`, `mem_write`, `snapshot`, +`snapshot_saved`, `probe_log`, `probe_dump`, `plugin*`, `note`, `exit`, +`session_end`. The format is append-only JSON lines so partial sessions stay +readable, and `compare` can profile two sessions symbolically. + +This is the "complete map and recorded dump of runtime memory activity": the +event stream is the map, the snapshot/blob files are the dump. + +## 7. Structures + +Schemas are JSON data (`structs.schema_from_json`) with integer, float, +`bytes`, `cstr` and `ptr` fields plus optional `enum` names and notes. The +demo control block schema lives at +`firmware/uproc_demo/gsp_ctrl.schema.json`. Schemas are intentionally +declarative so they can come from a Ghidra type export or a plugin later. + +## 8. Execution backends + +* `QemuTarget` (`virt`, RAM, NS16550A UART) is the default and the only + supported backend today. +* The **kernel fast path** passes `-kernel <elf>`; QEMU loads segments and + sets PC. +* The **probe path** passes each PT_LOAD segment as a raw loader device and + green-eye sets PC through the gdbstub — this lets a firmware linked for GPU + physical addresses be relocated into RAM for observation. +* A **custom simulator** can be added as a session backend later (roadmap, + not v0.1) because `Session` only assumes the RSP-like operations. + +## 9. Ghidra bridge + +* Out: `greeneye symbols export --names … --index … --exports … --callgraph …` + builds `greeneye-symbols.json` from the project's existing TSV exports. +* In: `greeneye.ghidra.export_hits(session_dir, out_tsv)` writes + `addr<TAB>hits<TAB>symbol`, and `MarkGreenEyeHits.java` annotates those + functions in a Ghidra project (plate comments + bookmarks). +* Mapping a hit to source: symbols carrying `bucket`/`line` extras resolve to + the decompiled corpus via `greeneye.ghidra.corpus_location()`. + +## 10. Plugins + +`plugins.py` discovers modules and calls `register(ge)` with a `GreenEyeAPI` +(`session`, `memory`, `symbols`, `recorder`, `hook`, `watch`, `on_event`, +`add_command`, `log`). Errors are recorded as `plugin_error` and never break a +session. MCP is a planned plugin, not an implementation (08). + +## 11. Known limits (be honest) + +1. QEMU `virt` models **no TU104**: GPU MMIO, falcon windows, WPR2, PMU, + timers and FB DMA do not exist. The real GSP image will fault early; the + `probe` command reports exactly where. Not a boot environment (06). +2. The transport is blocking; no replay of async interrupts mid-run. +3. Return hooks miss tail calls and interrupt-driven returns. +4. `qRcmd` monitor commands are host-tooling only and not portable. +5. Recorded events describe *observed* stops; instructions executed between + stops are invisible unless the TCG plugin is enabled or a step trace is + taken. diff --git a/debug/risc-v-gsp-rm-vm/docs/02-USAGE.md b/debug/risc-v-gsp-rm-vm/docs/02-USAGE.md new file mode 100644 index 0000000..53cf19f --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/02-USAGE.md @@ -0,0 +1,168 @@ +# 02 — green-eye usage + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status. + +All commands are run from `open-iov/debug/risc-v-gsp-rm-vm/`. + +## 0. Requirements + +* `qemu-system-riscv64` (Ubuntu: `apt install qemu-system-misc`); +* Python 3.10+ (stdlib only); +* for the demo firmware: `clang` and `lld` (`apt install lld-18` provides + `/usr/lib/llvm-18/bin/ld.lld`). + +Check everything: + +```bash +./greeneye.sh info +make test # builds the demo then runs unit + QEMU smoke tests +``` + +## 1. The canned demo (fastest way to see it work) + +```bash +make demo +./greeneye.sh demo +``` + +It boots `firmware/uproc_demo/build/uproc_demo.elf`, installs hooks on all +`rpc_*` handlers and a watchpoint on `rpc_count`, runs the firmware self-test, +injects a `PING` from the host, decodes and diffs the control block, then +injects `FAULT` to capture the `ebreak` trap. A session is recorded under +`dumps/` and a `summary.md` is written. + +Options: `--no-build`, `--duration SECONDS`, `--kernel PATH`, `--record PATH`. + +## 2. Interactive REPL + +```bash +./greeneye.sh attach --kernel firmware/uproc_demo/build/uproc_demo.elf +``` + +`attach` launches the target itself when `--port` is omitted. To share a +target between terminals, launch it separately and attach by port: + +```bash +./greeneye.sh launch --kernel <elf> # prints: gdbstub: 127.0.0.1:<port> +./greeneye.sh attach --port <port> --kernel <elf> +``` + +REPL commands: + +| Command | Meaning | +|---|---| +| `regs` / `reg <name> [value]` | read/write registers | +| `x <addr> [len]` | annotated hexdump | +| `mem <addr> <len>` | raw bytes (hex) | +| `w <addr> <hex>` / `wstr <addr> <text>` | write memory | +| `bp <addr\|symbol> [sw\|hw]` | breakpoint that prints on hit | +| `hook <addr\|symbol>` | enter/leave hook with printing | +| `watch <addr\|symbol> [size] [w\|r\|a]` | watchpoint | +| `s [n]` / `c [seconds]` | step n; continue for N seconds | +| `snap <label> <addr> <len>` | snapshot a region into the session | +| `sym <addr\|name>` | symbol resolution | +| `events [n]` | tail the recorded event stream | +| `note <text>` | annotate the session | +| `quit` | close and exit | + +Plugin-provided commands appear automatically (e.g. `gehits` from the example +plugin when loaded with `--plugin plugins/example_plugin.py`). + +## 3. Scripted sessions + +```bash +./greeneye.sh run examples/scripts/trace_rpc.py +./greeneye.sh run examples/scripts/dump_diff.py +./greeneye.sh run my_script.py --kernel <elf> --plugin plugins/example_plugin.py +``` + +The script runs with `ge`, `s`/`session`, `memory`, `symbols`, `recorder` in +scope and is recorded by default. See [03-SCRIPTING](03-SCRIPTING.md). + +## 4. Recording, analysis, comparison + +```bash +./greeneye.sh record --duration 2.0 --watch gsp_ctrl+0x10:8 --snapshot gsp_ctrl:392 +./greeneye.sh analyze dumps/20261002-082721-demo +./greeneye.sh analyze dumps/<a> --symbols symbols.json > a.md +./greeneye.sh compare dumps/<run-a> dumps/<run-b> --symbols +``` + +`analyze` prints a markdown report: event counts, breakpoint hits by symbol, +watchpoint hits, written pages, stepped coverage, snapshot inventory and a +timeline. `compare` diffs two sessions symbolically (hit-count deltas, watch +deltas, written-page deltas) — the A/B primitive for bare-metal vs +vfio-mdev and old-firmware vs new-firmware runs. + +## 5. Probing an arbitrary firmware image + +```bash +./greeneye.sh probe # the project's TU104 570.144 fwimage +./greeneye.sh probe path/to/fw.elf --duration 0.5 +./greeneye.sh probe --instructions 2000 --no-relocate +``` + +Segments are flattened into a raw kernel loaded at `0x80000000`; because QEMU +starts raw kernels at the load base, a tiny PC-relative trampoline at offset 0 +jumps to the image's real (relocated) entry. The run is bounded and the report +shows the **first exception** (faulting instruction, access address/size, +cause) plus the QEMU log tail. For the real GSP image expect an early +load-access fault on an unmodelled GPU register — that is the honest result +(see [06-ACCURACY](06-ACCURACY.md)). + +## 6. Symbol databases + +```bash +./greeneye.sh symbols export \ + --names ../../userspace/cuda/570.144/annotations/names.tsv \ + --index <run>/analysis/decompiled/decompiled_index.tsv \ + --callgraph <run>/analysis/elf-index/callgraph.tsv \ + --bias 0x100000 -o symbols-570.json +./greeneye.sh symbols resolve --db symbols-570.json 0x453ef0 +``` + +`--bias` normalises file vaddrs to runtime addresses (`0x100000` is the +libcuda Ghidra image base; use 0 for firmware linked at its load address). + +## 7. Plugins + +```bash +./greeneye.sh plugins +./greeneye.sh run examples/scripts/trace_rpc.py --plugin plugins/example_plugin.py +GREENEYE_PLUGINS=/path/to/plugins ./greeneye.sh demo +``` + +See [04-PLUGINS](04-PLUGINS.md). + +## 8. Make targets + +| Target | Action | +|---|---| +| `make demo` | build the demo firmware | +| `make test` | build + unit tests + live QEMU smoke | +| `make demo-run` | `greeneye demo --no-build` | +| `make probe-real` | run the TU104 fwimage probe | +| `make plugin` | build the optional TCG plugin | +| `make clean` | remove builds, dumps and `__pycache__` | + +## 9. Troubleshooting + +* **`qemu-system-riscv64 not found`** — install `qemu-system-misc` or set + `GREENEYE_QEMU`. +* **`gdbstub port not ready`** — QEMU exited early; the error includes its + stderr. Check the ELF actually loads (`./greeneye.sh info`). +* **A run seems stuck** — sessions never `pkill -f greeneye` (it matches the + invoking shell). Use `pkill -9 -f '[q]emu-system-riscv64'` to clean up + leftover emulators. +* **Target stops immediately at `0x1000`** — the firmware was not passed to + QEMU; check the `-kernel` path (known historical bug, fixed). +* **`Permission denied` on `/dev/kvm`** — not needed here; QEMU `virt` may + fall back to TCG. For speed, ensure the user can use KVM. + +## 10. Safety + +green-eye touches no GPU, no mdev, no kernel module and no host driver. It +runs unprivileged QEMU instances on loopback gdbstub ports. The only host +side effects are temporary directories under `/tmp` and `dumps/` under the +tool tree. It is safe to run alongside the CUDA/PLAN-B work, but keep an eye +on RAM/CPU if large snapshots are recorded. diff --git a/debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md b/debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md new file mode 100644 index 0000000..c677c8d --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md @@ -0,0 +1,219 @@ +# 03 — green-eye scripting + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status. + +green-eye scripts are ordinary Python programs run with +`./greeneye.sh run <script.py>` (or `python3 -m greeneye run …`). The +names `ge`, `session`/`s`, `memory`, `symbols`, `recorder` are preloaded. + +## 1. Attach or launch + +```python +import greeneye as ge + +# Launch the bundled demo (QEMU -kernel fast path), recording by default. +s = ge.launch(kernel="firmware/uproc_demo/build/uproc_demo.elf", record=True, name="my-run") + +# Or attach to an already-running gdbstub (e.g. started by `greeneye launch`). +# s = ge.attach(port=4444, record="dumps/attach-run") + +try: + ... +finally: + s.close() # clears breakpoints/watchpoints, kills owned QEMU +``` + +`launch(..., keep_alive=True)` leaves QEMU running after `close()`. + +## 2. Symbols + +```python +sym = s.symbols.get("rpc_sum") # Symbol | None +addr = s.symbols.lookup("rpc_sum") # int, raises if missing +s.symbols.annotate(0x80000048) # "0x80000048 rpc_ping" +s.symbols.resolve(0x80000050) # Symbol (containing or nearest) +s.resolve("rpc_sum") # int | int passthrough | "0x..." prefix +``` + +Symbols come from ELF (demo), Ghidra `names.tsv`/`decompiled_index.tsv` +(`symbols export`), or native JSON. + +## 3. Hooks and breakpoints + +```python +events = [] + +def on_sum_enter(hit): + events.append(("enter", hit.arg(0), hit.arg(1))) # a0, a1 + +def on_sum_leave(hit): + events.append(("leave", hit.retval)) # a0 at return + +handle = s.hook("rpc_sum", on_enter=on_sum_enter, on_leave=on_sum_leave) +handle.hits, handle.label, handle.addr +handle.detach() + +bp = s.breakpoint(0x800000fe, callback=lambda hit: print(hit), temporary=True, kind="sw") +s.remove_breakpoint(bp) +``` + +`Hit` carries `kind`, `addr`, `regs` (full register dict), `symbol`, +`retval` (leave hits), `watch`, `stop`, `session`, plus helpers +`hit.arg(n)` and `hit.label()`. + +A hook's `on_leave` is realised as a return breakpoint; the callback's +`hit.addr`/`hit.symbol` are the hooked function (not the return site), and +`hit.retval` is `a0`. Tail calls and interrupt returns are not tracked. + +## 4. Watchpoints + +```python +def on_write(hit): + print(f"{hit.label()} written; pc={hit.pc:#x}") + +wp = s.watch("gsp_ctrl+0x10", size=8, kind="w", callback=on_write) # w|r|a +wp.hits +s.unwatch(wp) +``` + +The session transparently steps over the triggering store (required with +QEMU; see 01 §4). + +## 5. Memory + +```python +s.memory.read(0x80000840, 64) +s.memory.write_u64(0x80000860, 1) # mailbox = PING +s.memory.hexdump(0x80000840, 128) # annotated +s.memory.search_string(0x80000000, 0x10000, "GREEN") +s.memory.search_u32(0x80000000, 0x10000, 0x47535031) +s.memory.read_cstring(0x800007d0) +s.memory.snapshot(0x80000840, 392, name="ctrl") +``` + +`Memory.read`/`write` record `mem_read`/`mem_write` events; snapshots are +saved and hashed by the recorder. + +## 6. Structures + +```python +import json +from greeneye.structs import schema_from_json, render, decode + +schema = schema_from_json(json.load(open("firmware/uproc_demo/gsp_ctrl.schema.json"))) +values = decode(schema, s.memory.read(ctrl, schema.size), base=ctrl) +print(render(schema, values)) +``` + +A schema is JSON: `{"name":…, "fields":[{"name","offset","fmt","size?","enum?","note?"}]}` +with `fmt` in `u8..u64`, `s8..s64`, `f32/f64`, `bool`, `bytes`, `cstr`, +`ptr`. + +## 7. Snapshots and diffs + +```python +from greeneye import Memory + +before = s.snapshot(ctrl, schema.size, "before") +s.memory.write_u64(ctrl + 32, 1) +s.run(duration=0.4) +after = s.snapshot(ctrl, schema.size, "after") +print(Memory.render_diff(Memory.diff(before, after, base=ctrl), s.symbols)) +``` + +## 8. Running + +```python +s.run(duration=1.0) # service events for one second +s.run(max_stops=20) # or until 20 stops +hits = s.step(10) # single-step and dispatch hooks +s.stop() # interrupt and dispatch +``` + +`run` returns the number of stops serviced. Every stop is recorded. + +## 9. Declarative probes + +For simple instrumentation, ship a JSON spec instead of Python: + +```json +{ + "probes": [ + {"name": "sum", "symbol": "rpc_sum", "when": "a1 > 0", + "on_enter": {"log": ["a0", "a1"], + "dump": [{"reg": "a0", "len": 32, "label": "sum-input"}], + "note": "sum called"}, + "on_leave": {"log": ["retval"]}} + ] +} +``` + +```python +from greeneye.probes import load_spec, install +install(s, load_spec("examples/probes/demo_probes.json")) +s.run(duration=1.0) +``` + +Conditions accept register names (`a0..a7`, `pc`, `ra`, `sp`, `x0..x31`, +`retval`), integer/hex literals and `and/or/not` with comparisons; arbitrary +code is rejected. Actions: `log`, `dump` (save a blob), `snapshot`, `note`. + +## 10. Event stream and plugins + +```python +s.on_event("watch_hit", lambda hit: print("wrote", hit.label())) +s.note("starting phase 2") + +# In a plugin module: +def register(ge): + ge.add_command("mycmd", lambda session, args: 0) +``` + +See [04-PLUGINS](04-PLUGINS.md). + +## 11. Recording and post-analysis from Python + +```python +s.recorder.dir # session directory (or None) +s.recorder.event("phase", name="p2") +s.recorder.close() # writes session.json + summary.md + +from greeneye.analyze import render_summary, compare_sessions +print(render_summary("dumps/run-a", symbols=s.symbols)) +print(compare_sessions("dumps/run-a", "dumps/run-b", symbols=s.symbols)) +``` + +## 12. A complete worked script + +```python +"""Drive the demo, capture a PING and a FAULT, and summarise.""" +import greeneye as ge + +s = ge.launch(kernel="firmware/uproc_demo/build/uproc_demo.elf", record=True, name="worked") +ctrl = s.symbols.lookup("gsp_ctrl") +hits = [] +try: + for name in ("rpc_ping", "rpc_sum", "rpc_fault"): + if s.symbols.get(name): + s.hook(name, on_enter=lambda h: hits.append(h.label())) + s.watch(ctrl + 0x10, size=8, kind="w") + s.run(duration=0.5) # self-test + s.memory.write_u64(ctrl + 0x20, 1) # PING + s.memory.write_u64(ctrl + 0x28, 0x1234) + s.run(duration=0.5) + s.memory.write_u64(ctrl + 0x20, 6) # FAULT -> ebreak + s.run(duration=1.0, max_stops=1) + print("pc:", hex(s.pc), s.symbols.annotate(s.pc)) + s.step() # past the trap + print("hits:", hits) +finally: + print("recording:", getattr(s.recorder, "dir", None)) + s.close() +``` + +## 13. Errors and timeouts + +All green-eye errors derive from `greeneye.GreenEyeError`: +`RspTimeout` (transport), `RspProtocolError`, `TargetError`, +`SymbolNotFound`, `StructError`. `RspClient.timeout` (default 10 s) bounds a +single packet read; `run`'s budget bounds the whole pass. diff --git a/debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md b/debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md new file mode 100644 index 0000000..fa0fe0c --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md @@ -0,0 +1,123 @@ +# 04 — writing green-eye plugins + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status. + +A plugin is a Python module exposing `register(ge)`. It is loaded before the +session runs (CLI `--plugin PATH`, `GREENEYE_PLUGINS`, or the bundled +`plugins/` directory) and can add instrumentation, CLI commands and harness +logic without touching green-eye itself. + +## 1. The API object + +```python +def register(ge): + # live objects + ge.session # Session + ge.memory # Memory + ge.symbols # SymbolDB + ge.recorder # Recorder | NullRecorder + + # instrumentation + ge.hook(target, on_enter=None, on_leave=None, kind="sw") + ge.watch(target, size=8, kind="w", callback=None) + ge.on_event(kind, callback) # kind in {"*", "bp_hit", "watch_hit", ...} + ge.resolve(target) # symbol/addr -> runtime address + + # CLI + logging + ge.add_command("name", fn, help="…") # fn(session, args: list[str]) -> int + ge.log("message", any="field") # recorded as a "plugin" event + ge.commands # dict of registered commands +``` + +Everything a plugin records lands in the session's `events.jsonl`, so plugin +findings are replayable and comparable like any other event. + +## 2. Lifecycle and safety + +* `register()` runs once, immediately after the session is attached and + symbols are loaded; the target is still stopped, so hooks are in place + before the first instruction. +* A plugin that raises is recorded as `plugin_error` and skipped; it never + aborts the session or the other plugins. +* Session close detaches all hooks/watchpoints, including plugin-installed + ones. +* Plugins must be stdlib-only unless the dependency is documented; they are + development tooling and never shipped. + +## 3. The four plugin classes + +**(a) Runtime RE tools** — add analysis that green-eye does not have: + +```python +def register(ge): + def on_state(hit): + ge.recorder.event("state_machine", result=hit.retval, pc=hit.pc) + ge.hook("rpc_state", on_leave=on_state) +``` + +Useful additions: allocation graphs, ring-buffer watermarks, page-table walks, +coverage collectors built from `on_event("stop")` plus the TCG trace, entropy +or checksum probes over structures. + +**(b) Harnesses** — drive the firmware and assert: + +```python +def register(ge): + def check(session, args): + ctrl = session.symbols.lookup("gsp_ctrl") + before = session.memory.read_u64(ctrl + 0x10) + session.memory.write_u64(ctrl + 0x20, 1) # PING + session.run(duration=0.3) + after = session.memory.read_u64(ctrl + 0x10) + assert after == before + 1, "PING did not dispatch" + print("PING ok") + return 0 + ge.add_command("check-ping", check) +``` + +**(c) Virtualization-environment updates** — alter how the target is built or +driven: extra QEMU args, a rehosting shim for uproc sources, a device-model +stub, a replay layer that feeds recorded events back into a run. Keep such +changes behind plugin-level flags and document them in the session metadata +(`s.recorder.meta`). + +**(d) AI/harness integration** — export sessions/reports to external tools, +generate probe specs, score runs. **MCP servers are planned as a plugin** +(`docs/08-ROADMAP.md`), not part of v0.1; do not add MCP dependencies to the +core package before that rung is scheduled. + +## 4. Example plugin + +`plugins/example_plugin.py` demonstrates (a), (b) and logging: + +```python +def register(ge): + ge.log("example plugin loaded") + def on_state(hit): + ge.recorder.event("plugin_state_transition", result=hit.retval) + if ge.symbols.get("rpc_state"): + ge.hook("rpc_state", on_leave=on_state) + ge.add_command("gehits", lambda session, args: print("…and 0"), help="…") +``` + +Run it with: + +```bash +./greeneye.sh run examples/scripts/trace_rpc.py --plugin plugins/example_plugin.py +``` + +## 5. Testing a plugin + +1. Load it against the demo firmware (`./greeneye.sh demo --plugin …`) — the + session must remain green and the plugin's events must appear in + `events.jsonl`. +2. Unit-test pure logic outside green-eye; keep hooks small. +3. Never let a plugin assume a particular firmware: gate on + `ge.symbols.get(name)` and log a skip instead of failing. + +## 6. Distribution + +Plugins can live anywhere. For in-tree plugins that document the workflow, +put them under `plugins/` in this tool directory; for local or +target-specific tooling, use `GREENEYE_PLUGINS` and keep paths out of tracked +docs (project rule). diff --git a/debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md b/debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md new file mode 100644 index 0000000..857102e --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md @@ -0,0 +1,105 @@ +# 05 — Ghidra bridge + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status. + +Ghidra answers *what is the code*; green-eye answers *what did it do*. The +bridge joins them in both directions: Ghidra symbols into the runtime session, +runtime hit addresses back into the Ghidra project. + +## 1. Address conventions (read this first) + +| Space | Meaning | Transformation | +|---|---|---| +| file vaddr | offset in the binary (`libcuda+0x…`) | base | +| Ghidra program addr | what the decompiler shows | file vaddr + image base (`0x100000` for libcuda) | +| runtime addr | live process / emulator address | file vaddr + load bias | + +* Firmware linked at its load address: bias 0 (the demo, and a + relocated-to-RAM GSP image when using the probe bias). +* CUDA `libcuda` in the host oracle: runtime base from `/proc/<pid>/maps`; + `--bias` normalises it if you want runtime addresses in the DB. +* The project's `decompiled_index.tsv`/bucket TSVs carry **both** program and + file addresses; green-eye parses both and stores the program address in + `symbol.extras["program_addr"]`. + +## 2. Build a symbol DB from Ghidra exports + +```bash +./greeneye.sh symbols export \ + --names ../../userspace/cuda/570.144/annotations/names.tsv \ + --index ../../userspace/cuda/570.144/run/analysis/decompiled/decompiled_index.tsv \ + --exports ../../userspace/cuda/570.144/run/analysis/elf-index/exports.tsv \ + --callgraph ../../userspace/cuda/570.144/run/analysis/elf-index/callgraph.tsv \ + --bias 0 -o symbols-cuda.json +``` + +For the GSP firmware project (`tools/ghidra/gsp-fwimage-570.144/`), first run +the existing export scripts (`ExportFunctionIndex.java`, +`DecompileFunctions.java`, …) to produce TSVs, then: + +```bash +./greeneye.sh symbols export --index <export.tsv> --bias 0 -o symbols-gsp.json +``` + +`load_index_tsv` is column-aware: it accepts +`<program>\t<file>\t<size>\t<name>\t<bucket>\t<line>\t<status>`, the 4-column +exports form, or a plain `<addr>\t<name>` two-column form. + +## 3. Runtime addresses in the session + +```python +db = SymbolDB() +db.load_json("symbols-cuda.json") +db.load_names_tsv("names.tsv", bias=runtime_base - file_base) +s.symbols = db # or pass symbols= to attach()/launch() +``` + +`resolve()` then annotates every hit with the Ghidra name, and +`greeneye.ghidra.corpus_location(symbol, corpus_root)` returns +`<bucket>:<line>` when the index carried that information — the pointer from a +live hit to the decompiled body. + +## 4. Annotate hits back into Ghidra + +```bash +./greeneye.sh analyze dumps/<run> --symbols symbols-gsp.json --out run.md +python3 - <<'PY' +from greeneye.ghidra import export_hits +from greeneye.symbols import SymbolDB +db = SymbolDB(); db.load_json("symbols-gsp.json") +print(export_hits("dumps/<run>", "greeneye_hits.tsv", symbols=db)) +PY +``` + +`greeneye_hits.tsv` is `addr<TAB>hits<TAB>symbol<TAB>annotation`. Then run the +bundled Java script in Ghidra headless: + +```bash +$GHIDRA/support/analyzeHeadless <proj_dir> <project> -process <program> -noanalysis \ + -scriptPath debug/risc-v-gsp-rm-vm/ghidra \ + -postScript MarkGreenEyeHits.java /abs/path/greeneye_hits.tsv +``` + +`ExportForGreenEye.java` is the reverse helper: it walks the current program +and writes a green-eye-compatible `addr<TAB>name<TAB>size` TSV. + +## 5. Breakpoints from Ghidra + +Two practical routes today: + +1. `greeneye decompose/decompile` → note the file vaddr → script + `s.hook("0x…")` (green-eye translates to runtime via the bias you loaded). +2. Use `symbols export` from the Ghidra index, then `s.hook("<name>")` by + name — the most robust route across recompilations. + +A future Ghidra-side green-eye console (roadmap item) can call +`greeneye` through its CLI to set breakpoints directly from the tool. + +## 6. Limits + +* Wrapper-generated functions are re-laid-out between driver patch versions; + transfer names, never raw wrapper addresses (the CUDA playbook's lesson). +* The decompiler does not recover struct types; structure schemas for + green-eye decoding must be written or exported separately. +* `decompiled_index.tsv` buckets are large and gitignored; keep symbol JSONs + small (name/size/subsystem only) if they must be tracked. diff --git a/debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md b/debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md new file mode 100644 index 0000000..1a1ae82 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md @@ -0,0 +1,136 @@ +# 06 — Accuracy: what the green-eye test bed does and does not prove + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status. +> This document is deliberately conservative. Treat every claim without a +> recorded A/B as unverified. + +## 1. Purpose + +green-eye exists to measure how accurately Open-IOV's GSP emulation +(`gsp-rm-vfio` and the in-kernel emulation) reproduces the behaviour of the +real GSP firmware, and to make the firmware itself analysable. That makes +accuracy a first-class deliverable, not an assumption. This document defines +the fidelity levels, the measurement contract, and the rules for honest +conclusions. + +## 2. Fidelity levels + +| Level | Property | green-eye today | +|---|---|---| +| **L0 — instruction semantics** | RV64IMAC executes as on the device core | high (QEMU TCG, same ISA family) | +| **L1 — memory behaviour** | reads/writes/ordering as firmware expects | high for RAM-resident code, *conditional* on mapping the same physical layout | +| **L2 — time/concurrency** | cycle counts, DMA latency, interrupt timing | **not modelled** (do not claim) | +| **L3 — device environment** | falcon windows, WPR2, CSB/TRANSCOM, PMU, timers, FB DMA, IRQ routing | **absent** (roadmap) | + +The practical reading: green-eye is an **execution and memory** microscope, +not a timing or silicon model. Conclusions of the form "the firmware writes X +before Y" are in scope; "the firmware takes N µs" is not. + +## 3. The real boot chain vs a bare QEMU VM + +The physical path the project already proved is: + +``` +FWSEC (falcon) -> WPR2 carve -> Booter Load (SEC2) -> RISC-V start + -> bootloader/appMain -> GSP-RM init -> RPC server +``` + +The GSP application's own entry supports this model: `_start` receives +bootargs from the bootloader in `a0`–`a5` (bootargs PA/VA, ELF PA/size, load +base PA, write-protected-region id), sets `stvec`, sets its stack, and calls +`appMain` (see `uproc/gsp/src/kernel/start.S`). It expects falcon/CSB register +windows, a TCM/MPU layout and a PMU-provided environment that `qemu virt` +does not have. + +**Therefore:** loading the raw `.fwimage` into `qemu virt` cannot boot it. +The `greeneye probe` command is designed for exactly this: it loads the +segments, runs bounded, and reports the first divergence +(`guest_errors,unimp,int` log + stopped PC + symbol). That report is a *memory +and control-flow map* of what the firmware tries to touch first — useful data, +but not a boot. + +**Measured first-touch (2026-10-02, TU104 570.144 image).** The firmware +enters at relocated `0x84009000`, executes six instructions, and faults at +firmware vaddr `0x4009014` on a 4-byte read of `0x2000000000000A00` +(load access fault), after which it falls into an `mtvec=0` fetch loop. That +address is the first entry for the R1 device model's register map. + +## 4. Re-hosting the uproc components (the higher-fidelity path) + +The GSP firmware contains the uproc components (`gsp`, `os/libos`, +`nvriscv`, `sec2*`, `soe*`, `pmu_sw`, `conf_compute`, …). Re-hosting them +means compiling a *subset* against a QEMU-virt platform shim: + +| Area | Can be faithful | Cannot be faithful | +|---|---|---| +| task/RPC logic, parsers, state machines | yes — this is ordinary code | — | +| memory layout of app-owned structures | yes, if schemas/addresses are pinned | TCM/MPU protections | +| libos scheduler semantics | approximated (round-robin, timers) | real interrupt latencies, partition switching | +| register/CSB access, falcon windows | stubbed with recorded behaviour | true device semantics | +| PMU/SEC2/ACR/Booter interactions | modelled as messages | boot ROM, secure state machines | + +Re-host fidelity must be stated per module in the session metadata, and every +"this is what the firmware does" claim should be cross-checked against the +physical RPC traces (PLAN B) before it is used to change the emulation. + +## 5. Measurement contract for `gsp-rm-vfio` accuracy + +Define these metrics once and use them everywhere: + +| # | Metric | How it is measured | +|---|---|---| +| M1 | **Inventory coverage** | set of controls/RPCs observed in a real run vs the emulation's handled set (and green-eye's observed set) | +| M2 | **Reply agreement** | field-by-field diff of replies (real vs emulated); canonical-size and consumed-field checks included | +| M3 | **Ordering agreement** | sequence alignment of the control/event stream (edit distance / LCS) | +| M4 | **State agreement** | state-machine transitions observed in the firmware vs the emulation's internal state model | +| M5 | **Error-path agreement** | injected faults produce the same status/notifier path | +| M6 | **Memory side-effects** | read/write map agreement against the emulation's shadow state; snapshot hashes for fixed points | +| M7 | **Timing** | *not claimed*; only order-of-events budgets (e.g. "reply before the 4 s poll") | + +Reference capture: +* real hardware/backend: the project's existing RPC logs (module trace, + `gsp-phys-log-dump`, status-queue drains) and the static-config oracle; +* green-eye: `events.jsonl` + snapshots, symbol-annotated. + +Comparison: `greeneye compare dumps/real-<run> dumps/greeneye-<run> +--symbols`, then a short table of M1–M6 with counts. Never publish a single +"accuracy %" number without the metric breakdown. + +## 6. Adversarial interoperability use + +green-eye is also a safe place to be adversarial: corrupt a reply, flip a +capability bit, truncate a control payload, or inject a fault at a chosen +firmware PC, then observe whether the emulation and the firmware agree on the +failure path. The rules: + +1. The simulator's reaction is *evidence about the firmware*, not about + silicon. Confirm any conclusion against a physical cycle before it changes + the product. +2. Keep the injected fault and the observed path in the session recording. +3. Do not use simulator timing as a security proof (side-channel claims need + physical measurement). + +## 7. Validating the tool itself + +Before trusting a green-eye session: + +* the demo firmware self-test must pass (`greeneye demo`, `make test`); +* the session must contain the expected `hook`/`watch` registrations and a + coherent `bp_hit` sequence (the analyzer highlights empty sessions); +* the UART banner and control-block magic are cheap liveness checks; +* golden sessions (checked-in expectations) for the demo detect regressions + in the toolchain itself. + +## 8. Known accuracy gaps (living list) + +1. No TU104 device model: no boot beyond the first unmapped access. +2. No time/concurrency model; multi-core and partition-switch behaviour is + approximated. +3. PMU/SEC2/ACR/Booter behaviour is message-level only. +4. Re-hosted uproc code is compiled for `qemu virt`, so compiler/layout + differences with the shipped image must be accounted for when comparing + control flow. +5. Interrupt delivery and MSI semantics differ; error-path M5 is the weakest + metric until the device model exists (08). + +Each gap maps to a roadmap item; do not silently ignore them in reports. diff --git a/debug/risc-v-gsp-rm-vm/docs/07-PORTABILITY-PLAYBOOKS.md b/debug/risc-v-gsp-rm-vm/docs/07-PORTABILITY-PLAYBOOKS.md new file mode 100644 index 0000000..5426bf1 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/07-PORTABILITY-PLAYBOOKS.md @@ -0,0 +1,126 @@ +# 07 — Portability playbooks: new GSP, new device, new architecture + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status. + +These playbooks cover the three churn axes the project expects: a new GSP +firmware version, the same firmware on a new device, and a new GPU +architecture (e.g. GB10 / DGX Spark / Surface-class). They reuse the +project's existing version discipline and add green-eye's runtime layer. + +## 0. Common setup + +1. Acquire the firmware for the target (driver runfile or `linux-firmware`): + extract the GSP boot image (`.fwimage`) and record its hash. For TU10x, + `firmware/57X-GSP/<ver>/gsp-<ver>.fwimage.bin` is the extracted form. +2. Lay down a versioned directory; never overwrite a working version. +3. Build a symbol DB for the new image: + * import the fwimage into the existing Ghidra project tooling + (`tools/ghidra/analyze_fwimage.py`, `DecompileFunctions.java`), + * `./greeneye.sh symbols export --index <export.tsv> -o symbols-<ver>.json`. +4. Record a baseline session of the previous version (see P1.4) so the + comparison has an anchor. + +## P1. New GSP firmware version (same family) + +**Goal:** know what changed in the firmware's runtime behaviour before the +emulation is adjusted. + +1. **Static triage** — the project's existing flow: decompile the new + fwimage, diff the RPC dispatcher, subcommands, message layouts and version + strings against the previous version (`cudatool.py delta`-style discipline + for firmware; `diff_corpus.py` for the corpus). +2. **Probe the new image** — `./greeneye.sh probe <new.fwimage>`: record the + first divergence and the first MMIO/read/write addresses. Compare with the + previous version's probe; changed first-touch addresses are early + structure-change signals. +3. **Re-host / demo run** — if uproc sources for the family are available, + build the rehosted subset against the QEMU-virt shim and run the same + scripted stimulus used for the previous version. +4. **Baseline + candidate sessions** — for each version capture: + * `greeneye record --kernel <rehosted elf> --duration N --snapshot <regions>` + or the scripted harness (`run`), + * the real-backend reference traces for the same workload. +5. **Compare** — `./greeneye.sh compare dumps/<old> dumps/<new> --symbols`: + hit-count deltas per function, watchpoint deltas, written-page deltas. + Investigate every function whose call-count changed before touching the + emulation. +6. **Metrics** — fill in M1–M6 from `06-ACCURACY.md` for the new version + against the real backend. Attach the table to the version-bump PR. +7. **Acceptance** — no rung regression in the existing guest ladder, the + control inventory diff is explained, and every new/changed control has a + version-keyed handler or an explicit "not used" note. + +## P2. Same GSP version, new device + +**Goal:** prove device-profile portability (the project direction is +"configuration + verification, not new code"). + +1. Collect the device profile: PCI IDs, BAR layout/sizes, FB geometry, + engine inventory, interrupt topology, VBIOS/ROM specifics. +2. Verify the firmware pairing: the image family (TU10x/GH100/LS10 …), FMC + partition manifests, and the version string; a mismatched pairing presents + as an emulation bug (the project learned this with 570.144 vs 570.211). +3. `./greeneye.sh probe <device fwimage>` on the new device image; compare + first-divergence maps with the reference device. Differences here usually + point at profile data, not logic. +4. Re-run the P1.4/1.5 loop with the device profile applied; record probe + + compare evidence in the portability matrix. +5. Validate on the physical device with the supervised harness before + claiming support (`docs/06-portability/hardware-software-matrix.md` + discipline: tested and documented, not "should work"). + +## P3. New architecture (GB10 / DGX Spark / Surface-class) + +**Goal:** survive a new silicon generation (likely a newer NV-RISCV core, +newer PMU/SEC2 images and possibly RV64GC rather than RV64IMAC). + +1. **Confirm the ISA and image shape** — `file`/`readelf` the new fwimage: + ISA string, segments, entry, compressed/float extensions. Update the + rehost build flags (`-march=…`) accordingly; note that QEMU supports + RV64GC even if green-eye's demo uses IMAC. +2. **Check the platform's boot variant** — newer platforms use FMC/manifest + partition models (`uproc/*_riscv/fmc`, `safety/PMU-*`, `conf_compute` + Init→ACR→RM flow). Record which partition model the image uses; the + rehost shim needs a matching entry/return convention. +3. **PMU/SOE deltas** — diff the PMU/SOE headers and firmware + (`pmu_sw`, `uproc/soe*`) because the GSP environment provisions identity + and clock state through them. Any change here invalidates prior + environment assumptions. +4. **Run the P1 loop** with the new architecture profile; expect the probe to + show *different* first-touch addresses (new register windows). Update the + device-model scope in `08-ROADMAP.md` with what the probe revealed. +5. **Host portability** — green-eye itself is Python and QEMU; on aarch64 + hosts install `qemu-system-misc` there and the same commands run. Keep + host-specific paths out of session metadata. + +## P4. A/B: bare metal vs vfio-mdev (the core accuracy ritual) + +**Goal:** quantify how closely the virtual function's emulation tracks the +real firmware for a chosen workload. + +1. **Reference capture (real):** + * hardware path: the supervised physical backend cycle, capturing RPC + records/log dumps and the static-config oracle; + * or the vfio-mdev path with the same workload. + Save the reference event stream in a session-compatible form (or the + vendor logs plus a conversion note). +2. **green-eye capture:** run the same workload's firmware-side stimulus in + the rehosted simulator; record hooks, watchpoints, snapshots. +3. **Compare:** `greeneye compare` for hit/watch/write profiles; for RPC + payloads use M2 field agreement and M3 ordering (a small script or a + plugin can emit per-control events). +4. **Report:** metric table (M1–M6), changed-function list, and an explicit + statement of L2/L3 limits from `06-ACCURACY.md`. +5. **Escalation:** a divergence that changes emulation behaviour must be + reproduced on the physical backend before the fix lands; simulator-only + divergence that cannot be reproduced is recorded as a fidelity gap. + +## Evidence checklist (attach to any portability task) + +* firmware hash + version string + pairing notes; +* `probe` output (first divergence, first-touch map); +* symbol DB hash and the Ghidra export provenance; +* session directories for baseline and candidate (or their sha256 list); +* `compare` report; +* M1–M6 table with the reference-capture method; +* unexplained diffs list (empty is a claim, not a default). diff --git a/debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md b/debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md new file mode 100644 index 0000000..c8b4638 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md @@ -0,0 +1,116 @@ +# 08 — green-eye roadmap + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status. +> Items here are **not** v0.1. Do not start them ad hoc; each has a gate. + +## R0. Product principles + +* green-eye stays a **development tool**: no shipped dependencies, no guest + presence, no kernel-module coupling. +* Fidelity improvements must be *measurable* (metrics table in 06) and must + not slow the fast path (demo + probe) below usefulness. +* Every new backend must satisfy the same `Session` contract so scripts, + probes, plugins and recordings survive. + +## R1. Platform device model (raise L3) + +**Why:** a bare `qemu virt` cannot boot the real fwimage; the probe shows the +first unmapped access and stops. + +**Scope:** model the minimum GSP environment: +falcon windows (FALCON/CSB/TRANSCOM registers), WPR2/FWSEC state registers +with read-back semantics, PTIMER, PMU/SEC2 mailbox stubs, FB DMA/IOMMU +address translation for the firmware's DMA descriptors, and interrupt +routing. QEMU `qemu-system-riscv64` with a custom device (`-device`) is the +preferred vehicle; TCG plugins provide the observation points. + +**Gate:** R1 starts only when a portability task (P2/P3) or an accuracy +measurement (P1.6) requires booting real firmware in-simulator. Until then, +use the probe + rehosted uproc paths. + +**Exit criteria:** `greeneye probe <fwimage>` reaches a stable address and +reports "waiting for host message" rather than a fault; the first host↔GSP +RPC records are captured in a session; M3 ordering can be measured against +the physical backend. + +## R2. Custom simulator (explicitly requested; not first version) + +**Why:** QEMU is an excellent execution engine but a poor *workflow* engine +for this project: no in-process API, RSP serialisation for every access, +limited snapshotting, and a device model designed for whole boards rather +than firmware servers. A custom simulator can be deterministic, replayable +and scriptable at native speed. + +**Shape (to build later, not now):** +* an RV64IMAC interpreter/JIT with a **pluggable memory map** (RAM, MMIO + hooks, DMA windows) and a **deterministic scheduler** (no realtime claim); +* native `Session` integration — replace the RSP transport with a + `Backend` interface, keep hooks/watchpoints/memory/recording identical; +* first-class **snapshot/restore** of full machine state (time-travel + debugging, branch-flip experiments); +* an **event bus** feeding the existing `events.jsonl` schema; +* a **stub DSL** for device registers (read/write scripts with state), so a + new GPU generation is configuration, matching the project's portability + direction. + +**Candidates to evaluate at that time:** extending QEMU TCG with a thin +in-process backdoor, `libriscv`, `rv8`, or a from-scratch interpreter. +Realtime is explicitly *not* a requirement; determinism and introspection are. + +**Gate:** R1 must have stabilised the device semantics (otherwise the custom +simulator inherits an unvalidated model). Estimated multi-week work; schedule +only after the guest-bridge work (PLAN B) no longer depends on it. + +## R3. MCP server plugin + +**Why:** expose green-eye sessions to AI harnesses and other agents under the +plugin model the user requested. + +**Shape:** a plugin (`plugins/mcp_server.py` when built) that wraps the +session/analysis API as MCP tools: `list_sessions`, `open_session`, +`symbols_resolve`, `set_breakpoint`, `run`, `memory_read`, `memory_hexdump`, +`snapshot`, `compare_sessions`, `export_hits`. Transport local stdio first; +no network exposure by default. The plugin depends on an MCP library only at +plugin load time; the core stays stdlib-only. + +**Gate:** v0.1 must be stable (acceptance list in 00) and the session format +frozen. Explicitly deferred by the owner; do not add MCP code before then. + +## R4. Full-memory trace (TCG plugin integration) + +The optional `qemu/greeneye_trace.c` plugin records instruction execution and +memory accesses. Roadmap: build it against the vendored QEMU headers, stream +its JSONL into the session format as `exec`/`access` events, and teach +`analyze` to render coverage/heatmaps and written-page timelines. This is the +"complete map" upgrade from stop-based observation to full access tracing. + +**Gate:** available once the QEMU plugin headers are vendored (or the build +environment provides them). Does not block v0.1 — watchpoint recording plus +step traces already cover the common cases. + +## R5. Fuzzing and mutation harness + +Use AFL++ QEMU mode (or a custom mutator) against the rehosted firmware's +RPC input surface, with green-eye recording crash/trap paths and symbol +annotation. Requires R1/R2 for stable input plumbing. Output: a corpus of +malformed controls and the firmware's error paths, feeding the emulation's +fail-closed rules (project security hygiene). + +## R6. Multi-version orchestration + +Scripts that, given a set of firmware images and device profiles, run the +probe + rehost + compare pipeline for the whole matrix and emit the M1–M6 +tables. This automates `07-PORTABILITY-PLAYBOOKS` P1–P3. + +## R7. Ghidra two-way console + +A Ghidra script/plugin that talks to a live green-eye session (via the CLI or +a local socket) to toggle breakpoints/watchpoints from the decompiler and to +highlight hit addresses. Until then, the hit-TSV + `MarkGreenEyeHits.java` +flow is the supported path. + +## Not on the green-eye roadmap + +* Graphics/display emulation, Windows guest internals, multi-tenant policy — + these belong to the main project roadmap, not to this analysis tool. +* Replacing Ghidra: green-eye is the dynamic complement, never a decompiler. diff --git a/debug/risc-v-gsp-rm-vm/docs/09-GSP-FIRMWARE-NOTES.md b/debug/risc-v-gsp-rm-vm/docs/09-GSP-FIRMWARE-NOTES.md new file mode 100644 index 0000000..216bf93 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/09-GSP-FIRMWARE-NOTES.md @@ -0,0 +1,104 @@ +# 09 — GSP/uproc firmware notes used by green-eye + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status. +> Exact local-source provenance is recorded in the local task record; this +> tracked document intentionally keeps source locations generic and cites +> public material. + +## 1. The microcontrollers around the GPU + +From the open-iov wiki **GPU Firmware** page and the linked **NVIDIA RISC-V +Story** presentation (the project mirrors the wiki locally; the PDF is a +public RISC-V summit deck): + +* **Falcon (FAst Logic CONtroller)** is NVIDIA's proprietary control + processor, used across >15 hardware engines for over a decade. It was + designed for flexibility, long memory latency, low area and security. + Limits that forced a successor: small addressing range, modest performance + (~0.67 DMIPS/MHz, ~1.4 CoreMark/MHz), no data cache, no rich OS support. +* **NV-RISCV** is NVIDIA's own implementation of a RISC-V core chosen as the + next-generation falcon: more performance (>2× falcon claimed), wider + addressing, TCM, vectored interrupts, security features (TZ-style), with + area targets under 0.1 mm² at 16FF. +* The GPU hosts several of these cores: the **GSP** (GPU System Processor) + running a rich firmware stack, plus security/boot engines (**SEC2**, ACR, + SOE) and the **PMU** microcontroller. The RISC-V Story deck documents the + falcon-to-RV transition and the DMA/mailbox lines between engines. + +## 2. GSP application structure + +The GSP firmware is an ELF64 RV64IMAC image (the TU104 570.144 `.fwimage` used +by the probe has entry `0x4009000`, 33 program headers, stripped). Its +application layer is a multi-task RTOS program: + +* `_start` (hand-written assembly) sets the trap vector to an early panic + handler, initialises the stack, pushes two zero words to help debugger + backtracing, and calls `appMain`. The **bootloader passes arguments in + a0–a5**: bootargs PA and VA, ELF PA and size, load base PA, and the + write-protected region id. A re-host harness must reproduce this ABI. +* `init.c` creates the tasks and starts the RTOS; the included driver set + covers MPU, ODP (on-demand paging), memory, and the RISC-V system library. +* The boot configuration selects `NVRISCV_PARTITION_SWITCH` variants, i.e. + images can run under a separation-kernel partition model. + +## 3. RTOS and libos layers + +Two OS layers appear in the vendor snapshots: + +* **SafeRTOS/NVRTOS** — the classic resident RTOS: priority scheduling, + timers, events/signals, IPC and DMA support; ~38 KiB TCM-resident kernel; + missing paging, task reset, capability security. Used by the GSP test + firmware partition model. +* **LIBOS** (exokernel design, early draft) — designed to be TCM-resident + (projected 1–6 KiB), capability-based security, round-robin scheduler, + DMEM on-demand paging, module configuration so unused features are not + paid for. This is the direction the GSP stack evolves toward. + +**Implication for the simulator:** the scheduler is not the interesting part +for RPC accuracy; the task/dispatcher semantics are. Green-eye's demo mirrors +that shape (mailbox → dispatcher → handler → counters), and a rehost shim +should stub the scheduler while keeping task entry points and message +handling real. + +## 4. Boot and partition models + +* **MP-SK/FMC** (separation kernel): images are built as partitions. One + observed GSP test image has two partitions: a SafeRTOS first-stage + bootloader partition (clobbered after hand-off via partition switch), and a + high-sec test partition that reads performance counters, writes them to + shared memory and switches back. +* **Confidential-compute skeleton**: six partition stubs — RM Proxy, RM + (mutually exclusive with RM Proxy), SPDM, ACR, Init and Attestation — with + the boot flow **Init → ACR → RM**. The vendor tooling side-loads images to + a live GSP with a debug command (`nvwatch …`) for bring-up. + +These models matter for portability: a new architecture may change the +partition layout or boot order, and green-eye's probe is the quickest way to +see the new first-touch sequence before a device model exists. + +## 5. Memory, DMA and registers + +* The firmware assumes **TCM** and an MPU (protection regions are generated + at link time); `qemu virt` has neither, so the probe loads segments into + RAM and faults at the first register access. +* The engines exchange data over **CSB/TRANSCOM windows and mailboxes** with + DMA into host/FB memory; the physical backend work in Open-IOV already + models the message-queue side (status/command queues, doorbells) — that is + the layer green-eye can emulate first when R1 lands. +* The GSP kernel's start path receives its memory permissions as boot args + from the bootloader rather than discovering them, which is why a + re-host should pass the same structure rather than re-derive it. + +## 6. How green-eye models this today (and honestly, does not) + +| Firmware aspect | green-eye v0.1 | +|---|---| +| Task/dispatcher/state-machine logic | modelled faithfully by the demo and by rehosted code | +| RPC handler input/output behaviour | observable and recordable; schemas decode structures | +| RTOS scheduling, interrupts, partition switch | approximated / absent | +| Falcon/CSB/TCM/PMU registers | absent (probe reports first touch; R1 roadmap) | +| Boot ROM chain (FWSEC/booter/SEC2) | out of scope for the VM; the physical backend owns it | + +Use the notes above when designing new rehost shims or a device model: the +goal is to keep everything *above* the register/mailbox boundary real, and to +model that boundary explicitly rather than pretend it away. diff --git a/debug/risc-v-gsp-rm-vm/docs/10-RELATED-WORK.md b/debug/risc-v-gsp-rm-vm/docs/10-RELATED-WORK.md new file mode 100644 index 0000000..a2a45db --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/10-RELATED-WORK.md @@ -0,0 +1,76 @@ +# 10 — related work: why not Frida, and what each tool is for + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status. + +## 1. Frida + +**Verdict for the firmware VM: not applicable.** Upstream Frida ships no +RISC-V target (checked against the 17.20 release assets: x86/x86_64, +arm/arm64, mips, macOS/Windows/QNX/Prospero — no `linux-riscv64`), and +frida-agent requires a hosted OS process. The GSP image is bare-metal RV64 +with its own libos; there is nothing to inject into, and even a hypothetical +RISC-V port would need an OS layer the firmware does not have. + +**Where the Frida *idea* lives in green-eye:** the scripting model. `Session` +offers `hook`/`watch`/`memory`/`events` with Python callbacks, which is the +part of Frida that matters for this workflow. The difference is transport: +RSP to QEMU instead of an injected agent. + +**Where Frida is applicable in the project:** host- or guest-userland x86_64 +work (e.g. the CUDA userspace layer) if a dynamic tracer is ever wanted there. +That is outside this tool's scope; the existing ptrace tracer covers the +guest today. + +## 2. GDB / gdbstub + +QEMU's gdbstub is the transport both tools share. GDB itself is still useful +for *debugging green-eye* (stub behaviour, protocol traces), and `gdb +-multiarch` can inspect the same target interactively. It is not the +instrumentation layer here because: + +* every operation becomes a subprocess/parse problem instead of an API call; +* recording, symbol annotation, structure decoding and session diffing would + have to be rebuilt around GDB/MI anyway; +* the project already uses GDB Python scripts for the CUDA corpus; green-eye + is the equivalent for firmware, with a stronger data model. + +## 3. QEMU TCG plugins + +The correct complement, not a competitor: plugins execute inside QEMU and can +observe **every** instruction and memory access, which gdbstub breakpoints +cannot. green-eye's `qemu/greeneye_trace.c` emits a JSONL access trace for +exactly this; integrating it into the session format is R4 on the roadmap. +Rule of thumb: use green-eye hooks for semantics ("handler X ran with these +args") and TCG plugins for coverage/heatmaps ("every write to this window"). + +## 4. Unicorn / emulator libraries + +Unicorn (and similar CPU emulators) can execute RV64 code faster to set up +than QEMU and embed cleanly in Python, but they model no board at all and +have weaker debug infrastructure. They are candidates for the **custom +simulator** (R2) once the required device semantics are known — the same +reason the roadmap defers that work. + +## 5. `rr` / record-replay + +`rr` does not support RISC-V guests and records at the host-process level. +QEMU's own record/replay (`-icount`) can provide deterministic reruns later; +green-eye's session recording occupies a different niche (semantic events, +not full replay) and can coexist. + +## 6. Comparison table + +| Tool | Layer | RISC-V firmware | green-eye relationship | +|---|---|---|---| +| Frida | userland process injection | no | conceptual model only | +| GDB + gdbstub | execution control | yes (transport) | debugging green-eye; same stub | +| QEMU TCG plugin | full instruction/memory trace | yes | complement (R4) | +| green-eye | semantic instrumentation + recording + analysis | yes | this tool | +| Unicorn/rv8/etc. | CPU emulation library | yes | R2 candidate | +| Ghidra | static decompilation | yes | symbol/hit bridge (05) | + +## 7. Reading list + +* `docs/01-DESIGN.md` — where each layer sits. +* `docs/05-GHIDRA-BRIDGE.md` — the static/dynamic join. +* `docs/08-ROADMAP.md` — R1 device model, R2 custom simulator, R3 MCP. diff --git a/debug/risc-v-gsp-rm-vm/examples/probes/demo_probes.json b/debug/risc-v-gsp-rm-vm/examples/probes/demo_probes.json new file mode 100644 index 0000000..db623f1 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/examples/probes/demo_probes.json @@ -0,0 +1,25 @@ +{ + "probes": [ + { + "name": "sum-nonzero", + "symbol": "rpc_sum", + "when": "a1 > 0", + "on_enter": { + "log": ["a0", "a1"], + "dump": [{"reg": "a0", "len": 32, "label": "sum-input"}], + "note": "rpc_sum with a non-zero length" + }, + "on_leave": {"log": ["retval"]} + }, + { + "name": "state-machine", + "symbol": "rpc_state", + "on_leave": {"log": ["retval"], "note": "state transition"} + }, + { + "name": "trap", + "symbol": "rpc_fault", + "on_enter": {"note": "rpc_fault entered; the ebreak trap follows"} + } + ] +} diff --git a/debug/risc-v-gsp-rm-vm/examples/scripts/dump_diff.py b/debug/risc-v-gsp-rm-vm/examples/scripts/dump_diff.py new file mode 100644 index 0000000..329ffb1 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/examples/scripts/dump_diff.py @@ -0,0 +1,33 @@ +"""Snapshot-diff the uproc control block across a host-injected PING. + +Run from the tool root: + + ./greeneye.sh run examples/scripts/dump_diff.py + +Demonstrates: symbol -> structure schema decoding, snapshot + diff, host +stimulus via memory writes, and symbol-annotated output. +""" +import json +from pathlib import Path + +from greeneye import Memory +from greeneye.structs import render, schema_from_json + +TOOL_ROOT = Path(__file__).resolve().parents[2] +schema = schema_from_json(json.loads((TOOL_ROOT / "firmware/uproc_demo/gsp_ctrl.schema.json").read_text())) +ctrl = symbols.lookup("gsp_ctrl") + +# Let the firmware self-test settle. +s.run(duration=0.4) +before = s.snapshot(ctrl, schema.size, "ctrl-before-ping") + +# Host stimulus: PING with argument 0x1234. +memory.write_u64(ctrl + 32, 1) # mailbox +memory.write_u64(ctrl + 40, 0x1234) +s.run(duration=0.4) +after = s.snapshot(ctrl, schema.size, "ctrl-after-ping") + +print("decoded control block:") +print(render(schema, schema.decode(after, base=ctrl))) +print("\nchanged bytes:") +print(Memory.render_diff(Memory.diff(before, after, base=ctrl), symbols)) diff --git a/debug/risc-v-gsp-rm-vm/examples/scripts/trace_rpc.py b/debug/risc-v-gsp-rm-vm/examples/scripts/trace_rpc.py new file mode 100644 index 0000000..12b912b --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/examples/scripts/trace_rpc.py @@ -0,0 +1,29 @@ +"""Trace every uproc RPC handler with enter/leave hooks. + +Run from the tool root: + + ./greeneye.sh run examples/scripts/trace_rpc.py + +The script namespace provides ``s``/``session``, ``memory``, ``symbols``, +``recorder`` and ``ge`` (the greeneye package). +""" +NAMES = ["rpc_ping", "rpc_echo", "rpc_fill", "rpc_sum", "rpc_state", "rpc_fault"] +events = [] + +for name in NAMES: + sym = symbols.get(name) + if sym is None: + print(f"symbol {name} not found") + continue + s.hook( + sym.addr, + on_enter=lambda hit: events.append(("enter", hit.label(), hit.arg(0))), + on_leave=lambda hit: events.append(("leave", hit.label(), hit.retval)), + ) + +s.run(duration=1.0) + +print(f"{len(events)} hook events:") +for kind, label, value in events: + print(f" {kind:5} {label:14} {value:#018x}") +recorder.event("trace_complete", events=len(events)) diff --git a/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/Makefile b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/Makefile new file mode 100644 index 0000000..f406a52 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/Makefile @@ -0,0 +1,34 @@ +# green-eye uproc demo firmware (RISC-V 64, bare metal) +# +# Uses clang + lld (no riscv64 GCC required): +# CC=clang, LD=ld.lld (absolute path to llvm-18 lld by default). +# Override with: make CC=... LLD=... + +# `?=` does not override make's built-in CC=cc, so choose clang explicitly; +# a command-line `make CC=...` still wins. +CC := clang +LLD ?= $(shell command -v ld.lld 2>/dev/null || echo /usr/lib/llvm-18/bin/ld.lld) +BUILD := build +TARGET := $(BUILD)/uproc_demo.elf + +CFLAGS := --target=riscv64-unknown-elf -march=rv64imac -mabi=lp64 \ + -mcmodel=medany -mno-relax -ffreestanding -nostdlib -fno-builtin \ + -fno-stack-protector -O2 -Wall -Wextra -I. +LDFLAGS := --ld-path=$(LLD) -nostdlib -static -Wl,-T,linker.ld -Wl,--no-relax +SRCS := start.S main.c + +.PHONY: all clean dump + +all: $(TARGET) + +$(TARGET): $(SRCS) linker.ld ge_uart.h + @mkdir -p $(BUILD) + $(CC) $(CFLAGS) $(SRCS) $(LDFLAGS) -o $@ + @echo "built $@" + +dump: $(TARGET) + @llvm-objdump-18 -d --no-show-raw-insn $(TARGET) | head -80 2>/dev/null || \ + objdump -d $(TARGET) | head -80 + +clean: + rm -rf $(BUILD) diff --git a/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/ge_uart.h b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/ge_uart.h new file mode 100644 index 0000000..4bd2f53 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/ge_uart.h @@ -0,0 +1,38 @@ +/* Minimal NS16550A UART helpers for the QEMU virt machine. */ +#ifndef GE_UART_H +#define GE_UART_H + +typedef unsigned char u8; +typedef unsigned int u32; +typedef unsigned long u64; +typedef long i64; + +#define UART0_BASE 0x10000000UL +#define UART_THR (UART0_BASE + 0x00) +#define UART_LSR (UART0_BASE + 0x05) + +static inline void uart_putc(char c) +{ + volatile u8 *lsr = (volatile u8 *)UART_LSR; + while ((*lsr & 0x20) == 0) { + /* wait for THR empty */ + } + *(volatile u8 *)UART_THR = (u8)c; +} + +static inline void uart_puts(const char *s) +{ + while (*s) { + uart_putc(*s++); + } +} + +static inline void uart_hex(u64 value, int digits) +{ + static const char hex[] = "0123456789abcdef"; + for (int i = (digits - 1) * 4; i >= 0; i -= 4) { + uart_putc(hex[(value >> i) & 0xf]); + } +} + +#endif /* GE_UART_H */ diff --git a/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/gsp_ctrl.schema.json b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/gsp_ctrl.schema.json new file mode 100644 index 0000000..ca18f81 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/gsp_ctrl.schema.json @@ -0,0 +1,19 @@ +{ + "name": "gsp_ctrl", + "size": 392, + "fields": [ + {"name": "magic", "offset": 0, "fmt": "u32", "note": "GSP-like control block magic 0x47535031"}, + {"name": "version", "offset": 4, "fmt": "u32"}, + {"name": "state", "offset": 8, "fmt": "u32", "enum": {"0": "S0", "1": "S1", "2": "S2", "3": "S3"}}, + {"name": "heartbeat", "offset": 12, "fmt": "u32"}, + {"name": "rpc_count", "offset": 16, "fmt": "u64"}, + {"name": "last_rpc", "offset": 24, "fmt": "u64"}, + {"name": "mailbox", "offset": 32, "fmt": "u64", "enum": {"0": "idle", "1": "PING", "2": "ECHO", "3": "FILL", "4": "SUM", "5": "STATE", "6": "FAULT"}}, + {"name": "arg0", "offset": 40, "fmt": "u64"}, + {"name": "arg1", "offset": 48, "fmt": "u64"}, + {"name": "result", "offset": 56, "fmt": "u64"}, + {"name": "buf", "offset": 64, "fmt": "bytes", "size": 256}, + {"name": "handler_count", "offset": 320, "fmt": "u64"}, + {"name": "handlers", "offset": 328, "fmt": "u64", "count": 8} + ] +} diff --git a/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/linker.ld b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/linker.ld new file mode 100644 index 0000000..7ad7af8 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/linker.ld @@ -0,0 +1,54 @@ +/* green-eye uproc demo firmware - RISC-V 64 bare-metal test target. + * + * Mimics the shape of a GSP-side micro-service: a global control block with a + * magic, a handler table and counters; a mailbox-driven RPC dispatcher; a + * multi-stage state machine handler; and an intentional trap handler so + * green-eye can demonstrate breakpoint/hook/watchpoint/trap workflows. + * + * Build: make -C firmware/uproc_demo + */ +OUTPUT_ARCH(riscv) +ENTRY(_start) + +MEMORY +{ + RAM (rwxa) : ORIGIN = 0x80000000, LENGTH = 32M +} + +SECTIONS +{ + . = 0x80000000; + + .text : ALIGN(4) { + KEEP(*(.text.start)) + *(.text .text.*) + } > RAM + + .rodata : ALIGN(8) { + *(.rodata .rodata.*) + } > RAM + + .data : ALIGN(8) { + *(.data .data.*) + } > RAM + + /* The control block gets its own section so its address is predictable. */ + .gsp_data : ALIGN(64) { + KEEP(*(.gsp_data)) + } > RAM + + .bss : ALIGN(8) { + *(.bss .bss.*) + *(COMMON) + } > RAM + + . = ALIGN(16); + . += 0x4000; /* 16 KiB stack */ + _stack_top = .; + + /DISCARD/ : { + *(.comment) + *(.note .note.*) + *(.riscv.attributes) + } +} diff --git a/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/main.c b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/main.c new file mode 100644 index 0000000..d23b00c --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/main.c @@ -0,0 +1,222 @@ +/* green-eye uproc demo firmware. + * + * A small, deterministic "GSP-like" service: + * - gsp_ctrl global control block (magic 0x47535031) in .gsp_data; + * - mailbox-driven RPC dispatcher with a handler table; + * - handlers: PING, ECHO, FILL, SUM, STATE (4-stage machine), FAULT (ebreak); + * - a self-test sequence at boot so a freshly attached debugger sees events; + * - a free-running heartbeat so memory activity is always present. + * + * No libc: memcpy/memset are provided for the compiler. + */ +#include "ge_uart.h" + +#define GSP_MAGIC 0x47535031u + +typedef struct { + u32 magic; /* +0x00 */ + u32 version; /* +0x04 */ + u32 state; /* +0x08 */ + u32 heartbeat; /* +0x0c */ + u64 rpc_count; /* +0x10 */ + u64 last_rpc; /* +0x18 */ + u64 mailbox; /* +0x20 */ + u64 arg0; /* +0x28 */ + u64 arg1; /* +0x30 */ + u64 result; /* +0x38 */ + char buf[256]; /* +0x40 */ + u64 handler_count; /* +0x140 */ + u64 handlers[8]; /* +0x148 */ +} gsp_ctrl_t; + +volatile gsp_ctrl_t gsp_ctrl __attribute__((section(".gsp_data"), aligned(64))); + +/* ---- compiler support (freestanding) ---- */ + +void *memcpy(void *dst, const void *src, u64 n) +{ + u8 *d = (u8 *)dst; + const u8 *s = (const u8 *)src; + while (n--) { + *d++ = *s++; + } + return dst; +} + +void *memset(void *dst, int value, u64 n) +{ + u8 *d = (u8 *)dst; + while (n--) { + *d++ = (u8)value; + } + return dst; +} + +static u64 strlen_local(const char *s) +{ + u64 n = 0; + while (s[n]) { + n++; + } + return n; +} + +/* ---- RPC handlers (kept as symbols for green-eye hooks) ---- */ + +__attribute__((noinline)) u64 rpc_ping(u64 a0, u64 a1) +{ + (void)a1; + return a0 ^ 0xbeefu; +} + +__attribute__((noinline)) u64 rpc_echo(u64 a0, u64 a1) +{ + (void)a1; + const char *src = (const char *)a0; + u64 n = strlen_local(src); + if (n > sizeof(gsp_ctrl.buf) - 1) { + n = sizeof(gsp_ctrl.buf) - 1; + } + memcpy((void *)gsp_ctrl.buf, src, n); + gsp_ctrl.buf[n] = '\0'; + return n; +} + +__attribute__((noinline)) u64 rpc_fill(u64 a0, u64 a1) +{ + u64 n = a1 > sizeof(gsp_ctrl.buf) ? sizeof(gsp_ctrl.buf) : a1; + memset((void *)gsp_ctrl.buf, (int)(a0 & 0xff), n); + return n; +} + +__attribute__((noinline)) u64 rpc_sum(u64 a0, u64 a1) +{ + const u8 *p = (const u8 *)a0; + u64 total = 0; + for (u64 i = 0; i < a1; i++) { + total += p[i]; + } + return total; +} + +__attribute__((noinline)) u64 rpc_state(u64 a0, u64 a1) +{ + (void)a0; + (void)a1; + u64 stage = gsp_ctrl.state & 3u; + u64 out = 0; + switch (stage) { + case 0: + out = 0x10; + break; + case 1: + out = 0x20; + break; + case 2: + out = 0x30; + break; + default: + out = 0x40; + break; + } + gsp_ctrl.state = (u32)((stage + 1) & 3u); + return out; +} + +__attribute__((noinline)) u64 rpc_fault(u64 a0, u64 a1) +{ + (void)a0; + (void)a1; + __asm__ volatile("ebreak"); /* debugger trap: green-eye stops here */ + return 0xdead; +} + +/* ---- dispatcher ---- */ + +typedef u64 (*rpc_fn)(u64, u64); + +void rpc_dispatch(u64 cmd, u64 a0, u64 a1) +{ + u64 result; + switch (cmd) { + case 1: + result = rpc_ping(a0, a1); + break; + case 2: + result = rpc_echo(a0, a1); + break; + case 3: + result = rpc_fill(a0, a1); + break; + case 4: + result = rpc_sum(a0, a1); + break; + case 5: + result = rpc_state(a0, a1); + break; + case 6: + result = rpc_fault(a0, a1); + break; + default: + result = ~cmd; + break; + } + gsp_ctrl.result = result; + gsp_ctrl.last_rpc = cmd; + gsp_ctrl.rpc_count = gsp_ctrl.rpc_count + 1; /* after the handler */ +} + +static void install_handlers(void) +{ + gsp_ctrl.handlers[0] = (u64)(unsigned long)rpc_ping; + gsp_ctrl.handlers[1] = (u64)(unsigned long)rpc_echo; + gsp_ctrl.handlers[2] = (u64)(unsigned long)rpc_fill; + gsp_ctrl.handlers[3] = (u64)(unsigned long)rpc_sum; + gsp_ctrl.handlers[4] = (u64)(unsigned long)rpc_state; + gsp_ctrl.handlers[5] = (u64)(unsigned long)rpc_fault; + gsp_ctrl.handler_count = 6; +} + +int main(void) +{ + volatile gsp_ctrl_t *ctl = &gsp_ctrl; + ctl->magic = GSP_MAGIC; + ctl->version = 1; + ctl->state = 0; + ctl->heartbeat = 0; + ctl->rpc_count = 0; + ctl->last_rpc = 0; + ctl->mailbox = 0; + ctl->result = 0; + ctl->buf[0] = '\0'; + install_handlers(); + + uart_puts("GREEN-EYE UPROC DEMO v1\n"); + uart_puts("MAGIC "); + uart_hex(ctl->magic, 8); + uart_puts(" CTRL@"); + uart_hex((u64)(unsigned long)ctl, 8); + uart_puts("\n"); + + /* Deterministic self-test so freshly attached instrumentation sees events. */ + rpc_dispatch(1, 0x1111, 0); + rpc_dispatch(2, (u64)(unsigned long)"hello-green-eye", 0); + rpc_dispatch(3, 0x5a, 32); + rpc_dispatch(4, (u64)(unsigned long)ctl->buf, 16); + rpc_dispatch(5, 0, 0); + rpc_dispatch(5, 0, 0); + rpc_dispatch(5, 0, 0); + uart_puts("SELFTEST DONE\n"); + + for (;;) { + ctl->heartbeat = ctl->heartbeat + 1; + u64 cmd = ctl->mailbox; + if (cmd != 0) { + ctl->mailbox = 0; + rpc_dispatch(cmd, ctl->arg0, ctl->arg1); + } + for (volatile int i = 0; i < 2000; i++) { + __asm__ volatile(""); + } + } +} diff --git a/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/start.S b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/start.S new file mode 100644 index 0000000..1341461 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/start.S @@ -0,0 +1,10 @@ +/* green-eye uproc demo firmware - reset/entry. */ + .section .text.start, "ax", @progbits + .global _start + .type _start, @function +_start: + la sp, _stack_top + call main +1: wfi + j 1b + .size _start, . - _start diff --git a/debug/risc-v-gsp-rm-vm/ghidra/ExportForGreenEye.java b/debug/risc-v-gsp-rm-vm/ghidra/ExportForGreenEye.java new file mode 100644 index 0000000..29cc1cd --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/ghidra/ExportForGreenEye.java @@ -0,0 +1,46 @@ +// Export functions as a green-eye-compatible symbol TSV. +// +// Usage (Ghidra headless): +// analyzeHeadless <proj> <name> -process <program> -noanalysis \ +// -scriptPath debug/risc-v-gsp-rm-vm/ghidra \ +// -postScript ExportForGreenEye.java /abs/path/ghidra-symbols.tsv +// +// Columns: addr (program address) <TAB> name <TAB> size. +// green-eye's `symbols export --index` accepts this form directly. +// +//@category OpenIOV +//@menupath Tools.OpenIOV.Export for green-eye + +import java.io.File; +import java.io.PrintWriter; + +import ghidra.app.script.GhidraScript; +import ghidra.program.model.listing.Function; +import ghidra.program.model.listing.FunctionIterator; + +public class ExportForGreenEye extends GhidraScript { + + @Override + public void run() throws Exception { + String[] args = getScriptArgs(); + if (args.length < 1) { + println("usage: ExportForGreenEye.java <out.tsv>"); + return; + } + File out = new File(args[0]); + int count = 0; + try (PrintWriter pw = new PrintWriter(out, "UTF-8")) { + pw.println("addr\tname\tsize"); + FunctionIterator functions = + currentProgram.getFunctionManager().getFunctions(true); + while (functions.hasNext()) { + Function function = functions.next(); + long addr = function.getEntryPoint().getOffset(); + long size = function.getBody().getNumAddresses(); + pw.printf("0x%08x\t%s\t%d%n", addr, function.getName(), size); + count++; + } + } + println("green-eye: exported " + count + " functions to " + out.getAbsolutePath()); + } +} diff --git a/debug/risc-v-gsp-rm-vm/ghidra/MarkGreenEyeHits.java b/debug/risc-v-gsp-rm-vm/ghidra/MarkGreenEyeHits.java new file mode 100644 index 0000000..c99c3e1 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/ghidra/MarkGreenEyeHits.java @@ -0,0 +1,80 @@ +// Annotate green-eye runtime hits in the current Ghidra program. +// +// Input: the TSV produced by greeneye.ghidra.export_hits(): +// addr <TAB> hits <TAB> symbol <TAB> annotation +// +// Usage (Ghidra headless): +// analyzeHeadless <proj> <name> -process <program> -noanalysis \ +// -scriptPath debug/risc-v-gsp-rm-vm/ghidra \ +// -postScript MarkGreenEyeHits.java /abs/path/greeneye_hits.tsv +// +// For each address it adds a plate comment on the containing function +// ("green-eye: N hits") and a bookmark at the exact address. +// +//@category OpenIOV +//@menupath Tools.OpenIOV.Mark green-eye hits + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; + +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Function; + +public class MarkGreenEyeHits extends GhidraScript { + + @Override + public void run() throws Exception { + String[] args = getScriptArgs(); + if (args.length < 1) { + println("usage: MarkGreenEyeHits.java <greeneye_hits.tsv>"); + return; + } + File in = new File(args[0]); + int marked = 0; + int missed = 0; + try (BufferedReader reader = new BufferedReader(new FileReader(in))) { + String line; + boolean header = true; + while ((line = reader.readLine()) != null) { + if (header) { + header = false; + if (line.startsWith("addr")) { + continue; + } + } + if (line.isBlank()) { + continue; + } + String[] cols = line.split("\t"); + if (cols.length < 2) { + continue; + } + long addrValue; + try { + addrValue = Long.parseUnsignedLong(cols[0].replace("0x", ""), 16); + } catch (NumberFormatException e) { + continue; + } + String hits = cols[1]; + String symbol = cols.length > 2 ? cols[2] : ""; + Address address = toAddr(addrValue); + Function function = getFunctionContaining(address); + if (function != null) { + String note = "green-eye: " + hits + " hit(s)" + + (symbol.isEmpty() ? "" : " (" + symbol + ")"); + String existing = function.getComment(); + function.setComment(existing == null ? note : existing + "\n" + note); + marked++; + } else { + missed++; + } + createBookmark(address, "Note", "green-eye", + hits + " runtime hit(s)" + (symbol.isEmpty() ? "" : " " + symbol)); + } + } + println("green-eye: annotated " + marked + " functions (" + missed + + " addresses outside any function) from " + in.getAbsolutePath()); + } +} diff --git a/debug/risc-v-gsp-rm-vm/ghidra/README.md b/debug/risc-v-gsp-rm-vm/ghidra/README.md new file mode 100644 index 0000000..e0bb2c0 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/ghidra/README.md @@ -0,0 +1,39 @@ +# Ghidra bridge scripts + +Two Java scripts run inside Ghidra (GUI or headless): + +| Script | Direction | Purpose | +|---|---|---| +| `ExportForGreenEye.java` | Ghidra → green-eye | write `addr<TAB>name<TAB>size` for every function | +| `MarkGreenEyeHits.java` | green-eye → Ghidra | annotate functions with runtime hit counts + bookmarks | + +Typical loop: + +```bash +# 1. Export the decompiler's view of the firmware. +$GHIDRA/support/analyzeHeadless <proj_dir> <project> -process <program> -noanalysis \ + -scriptPath debug/risc-v-gsp-rm-vm/ghidra \ + -postScript ExportForGreenEye.java /tmp/ghidra-symbols.tsv + +# 2. Build a green-eye symbol DB from it. +./greeneye.sh symbols export --index /tmp/ghidra-symbols.tsv -o symbols.json + +# 3. Run a session, then export its hits. +python3 - <<'PY' +from greeneye.ghidra import export_hits +from greeneye.symbols import SymbolDB +db = SymbolDB(); db.load_json("symbols.json") +print(export_hits("dumps/<run>", "/tmp/greeneye_hits.tsv", symbols=db)) +PY + +# 4. Annotate the project. +$GHIDRA/support/analyzeHeadless <proj_dir> <project> -process <program> -noanalysis \ + -scriptPath debug/risc-v-gsp-rm-vm/ghidra \ + -postScript MarkGreenEyeHits.java /tmp/greeneye_hits.tsv +``` + +Address convention: the exported `addr` is the **program address** (what +Ghidra shows). `greeneye symbols export --index` understands the 3-column +form and keeps file/program addresses separate when present (4/7-column +project indexes). Pass `--bias` only when the exported TSV does not already +carry runtime addresses. diff --git a/debug/risc-v-gsp-rm-vm/greeneye.sh b/debug/risc-v-gsp-rm-vm/greeneye.sh new file mode 100755 index 0000000..f1c5a82 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# green-eye launcher: run the package from this directory without installation. +# TOOL IN DEVELOPMENT - NOT READY YET! +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONPATH="$HERE${PYTHONPATH:+:$PYTHONPATH}" +export GREENEYE_DUMPS="${GREENEYE_DUMPS:-$HERE/dumps}" +exec python3 -m 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 "<empty>") + 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 '<unknown>'} (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 <name> [value] -- read or write one register.""" + parts = arg.split() + if not parts: + return self._say("usage: reg <name> [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 <addr> [len] -- hexdump memory (annotated).""" + parts = arg.split() + if not parts: + return self._say("usage: x <addr> [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 <addr> <len> -- 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 <addr> <hexbytes> -- 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 <addr> <text> -- 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 <addr|symbol> [sw|hw] -- set a breakpoint that prints on hit.""" + parts = arg.split() + if not parts: + return self._say("usage: bp <addr|symbol> [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 <addr|symbol> -- alias for bp with enter/leave printing.""" + parts = arg.split() + if not parts: + return self._say("usage: hook <addr|symbol>") + 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 <addr|symbol> [size] [w|r|a] -- set a watchpoint.""" + parts = arg.split() + if not parts: + return self._say("usage: watch <addr|symbol> [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 <label> <addr> <len> -- snapshot a memory region into the session.""" + parts = arg.split() + if len(parts) < 3: + return self._say("usage: snap <label> <addr> <len>") + data = self.session.memory.snapshot(self.session.resolve(parts[1]), int(parts[2], 0), name=parts[0]) + self._say(f"snapshot {parts[0]}: {len(data)} bytes") + + def do_sym(self, arg): + """sym <addr|name> -- resolve a symbol or an address.""" + target = arg.strip() + if not target: + return self._say("usage: sym <addr|name>") + try: + print(" " + self.session.symbols.annotate(self.session.resolve(target))) + except GreenEyeError as exc: + self._say(f"error: {exc}") + + def do_note(self, arg): + """note <text> -- add a note to the session recording.""" + self.session.note(arg) + self._say("noted") + + def do_quit(self, arg): + """quit -- close the session and exit.""" + return True + + do_EOF = do_quit + + def onecmd(self, line): + name = line.split()[0] if line.split() else "" + if name in self.manager.api.commands: + fn, _help = self.manager.api.commands[name] + try: + fn(self.session, line.split()[1:]) + except Exception as exc: + self._say(f"plugin error: {exc}") + return False + if name and name not in self.get_names() and not hasattr(self, f"do_{name}"): + return super().onecmd(line) + return super().onecmd(line) + + def get_names(self): + names = super().get_names() + names += [f"do_{cmd}" for cmd in self.manager.api.commands] + return names + + def default(self, line): + self._say(f"unknown command: {line.split()[0] if line.split() else line!r}") + + +def cmd_attach(args) -> int: + _ensure_dumps_env() + kernel = args.kernel + if kernel: + kernel = str(Path(kernel)) + elif not args.port: + kernel = str(_build_demo()) + session, manager = _start_session( + kernel=kernel, port=args.port, record=args.record, name="repl", plugins=args.plugin + ) + try: + GreenEyeRepl(session, manager).cmdloop() + finally: + session.close() + return 0 + + +def cmd_launch(args) -> int: + """Start a target, print the gdbstub endpoint, and keep QEMU alive.""" + _ensure_dumps_env() + kernel = args.kernel or str(_build_demo()) + if not Path(kernel).exists(): + raise GreenEyeError(f"kernel not found: {kernel}") + session = ge.launch(kernel=str(kernel), record=args.record, name="launch", keep_alive=True) + port = session.target.port + print(f"[green-eye] target running: {kernel}") + print(f"[green-eye] gdbstub: 127.0.0.1:{port}") + print(f"[green-eye] attach: ./greeneye.sh attach --port {port} --kernel {kernel}") + print("[green-eye] Ctrl-C stops waiting; QEMU keeps running for attach.") + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print("[green-eye] detached (QEMU still running)") + return 0 + + +def cmd_record(args) -> int: + _ensure_dumps_env() + elf = args.kernel or str(_build_demo()) + session, _manager = _start_session(kernel=str(elf), port=None, record=args.record or True, name=args.name) + try: + for spec in args.watch or []: + addr_or_sym, size = (spec.split(":", 1) + ["8"])[:2] + session.watch(addr_or_sym, size=int(size, 0)) + for spec in args.snapshot or []: + addr_or_sym, size = spec.split(":", 1) + session.snapshot(session.resolve(addr_or_sym), int(size, 0), name=addr_or_sym) + session.run(duration=args.duration) + print(f"[record] serviced run for {args.duration}s") + _print_session_dir(session) + finally: + session.close() + return 0 + + +def cmd_probe(args) -> int: + _ensure_dumps_env() + elf = Path(args.elf) + if not elf.exists(): + raise GreenEyeError(f"firmware not found: {elf}") + image = load_elf(str(elf)) + print(f"[probe] {image.summary()}") + relocate = not args.no_relocate + bias = (0x80000000 - image.min_vaddr) if relocate else 0 + kernel_path = str(elf) + if relocate: + from .elf import write_flat_kernel + + out = Path(tempfile.mkdtemp(prefix="greeneye-probe-")) / (elf.name + ".flat.bin") + kernel_path, entry_addr = write_flat_kernel(str(elf), str(out), load_addr=0x80000000) + size = Path(kernel_path).stat().st_size + print( + f"[probe] flat kernel {kernel_path} ({size} bytes); " + f"load 0x80000000 entry {entry_addr:#x} (original {image.entry:#x})" + ) + target = QemuTarget( + kernel=kernel_path, + dlog="guest_errors,unimp,int", + memory_mb=max(256, (image.max_vaddr - image.min_vaddr) // (1024 * 1024) + 64), + ) + target.start(paused=True) + from .rsp import RspClient + + client = RspClient(target.host, target.port) + client.connect() + client.sync() + symbols = SymbolDB() + if args.symbols: + symbols.load_json(args.symbols) + session = ge.Session( + client=client, + target=target, + symbols=symbols, + record=args.record or True, + name=f"probe-{elf.stem}", + meta={"firmware": str(elf), "entry": image.entry, "bias": bias, "kernel": kernel_path}, + ) + session.load_image(image, bias=bias) + try: + if args.instructions: + print(f"[probe] stepping {args.instructions} instructions ...") + session.step(args.instructions) + else: + print(f"[probe] running for {args.duration}s ...") + session.run(duration=args.duration) + pc = session.pc + sym = session.symbols.resolve(pc) + print(f"[probe] stopped at {pc:#x} {sym.name if sym else '<unknown>'}") + from .analyze import describe_fault, parse_qemu_faults + + qlog = target.read_qemu_log() + faults = parse_qemu_faults(qlog) + if faults: + print("[probe] " + describe_fault(faults[0], bias=bias)) + print(f"[probe] total exceptions: {len(faults)}") + else: + print("[probe] no exception recorded (execution stayed in mapped memory)") + tail = qlog.strip().splitlines() + if tail: + print("[probe] qemu log tail:") + for line in tail[-6:]: + print(" ", line) + _print_session_dir(session) + finally: + session.close() + return 0 + + +def cmd_analyze(args) -> int: + symbols = None + if args.symbols: + symbols = SymbolDB() + symbols.load_json(args.symbols) + report = _analyze.render_summary(args.session, symbols=symbols) + if args.out: + Path(args.out).write_text(report, encoding="utf-8") + print(f"wrote {args.out}") + else: + print(report) + return 0 + + +def cmd_compare(args) -> int: + symbols = SymbolDB() if args.symbols else None + report = _analyze.compare_sessions(args.a, args.b, symbols=symbols) + print(report) + return 0 + + +def cmd_symbols(args) -> int: + if args.symbols_command == "export": + db = _ghidra.build_symbol_db( + names_tsv=args.names, + index_tsv=args.index, + exports_tsv=args.exports, + callgraph_tsv=args.callgraph, + elf=args.elf, + bias=args.bias, + ) + db.save_json(args.out, meta={"bias": args.bias}) + print(f"wrote {args.out}: {len(db)} symbols") + return 0 + if args.symbols_command == "resolve": + db = SymbolDB() + if args.db: + db.load_json(args.db) + print(db.annotate(int(args.addr, 0))) + return 0 + return 1 + + +def cmd_plugins(args) -> int: + plugins_dir = TOOL_ROOT / "plugins" + print("bundled plugins:") + for path in sorted(plugins_dir.glob("*.py")): + if path.name.startswith("_"): + continue + print(f" {path.name}") + env = os.environ.get("GREENEYE_PLUGINS") + if env: + print(f"GREENEYE_PLUGINS: {env}") + return 0 + + +# --------------------------------------------------------------------------- +# parser +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="greeneye", description=__doc__.splitlines()[0]) + parser.add_argument("--version", action="version", version=f"green-eye {ge.__version__}") + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("info", help="environment/tooling check").set_defaults(fn=cmd_info) + + demo = sub.add_parser("demo", help="boot the demo uproc and instrument it") + demo.add_argument("--kernel", help="firmware ELF (default: built demo)") + demo.add_argument("--no-build", action="store_true", help="do not rebuild the demo") + demo.add_argument("--record", nargs="?", const=True, default=None, help="record to a path (or default dumps/)") + demo.add_argument("--duration", type=float, default=1.0) + demo.set_defaults(fn=cmd_demo) + + run = sub.add_parser("run", help="run a Python instrumentation script") + run.add_argument("script") + run.add_argument("--kernel") + run.add_argument("--port", type=int) + run.add_argument("--record", nargs="?", const=True, default=None) + run.add_argument("--plugin", action="append", default=[]) + run.set_defaults(fn=cmd_run) + + attach = sub.add_parser("attach", help="interactive REPL over a gdbstub (launches if no --port)") + attach.add_argument("--port", type=int, help="attach to an existing gdbstub instead of launching") + attach.add_argument("--kernel", help="firmware ELF (default: built demo)") + attach.add_argument("--record", nargs="?", const=True, default=None) + attach.add_argument("--plugin", action="append", default=[]) + attach.set_defaults(fn=cmd_attach) + + launch = sub.add_parser("launch", help="start a target and keep it alive for attach") + launch.add_argument("--kernel", help="firmware ELF (default: built demo)") + launch.add_argument("--record", nargs="?", const=True, default=None) + launch.set_defaults(fn=cmd_launch) + + probe = sub.add_parser("probe", help="run a firmware image and report first divergence") + probe.add_argument("elf", nargs="?", default=str(DEFAULT_FWIMAGE)) + probe.add_argument("--no-relocate", action="store_true") + probe.add_argument("--instructions", type=int, default=0) + probe.add_argument("--duration", type=float, default=1.0) + probe.add_argument("--symbols", help="green-eye symbol DB JSON to annotate the stop") + probe.add_argument("--record", nargs="?", const=True, default=None) + probe.set_defaults(fn=cmd_probe) + + record = sub.add_parser("record", help="boot and record a memory-activity session") + record.add_argument("--kernel") + record.add_argument("--name", default="record") + record.add_argument("--duration", type=float, default=2.0) + record.add_argument("--watch", action="append", help="addr|symbol[:size]") + record.add_argument("--snapshot", action="append", help="addr|symbol:len") + record.add_argument("--record", nargs="?", const=True, default=None) + record.set_defaults(fn=cmd_record) + + analyze = sub.add_parser("analyze", help="render a recorded session report") + analyze.add_argument("session") + analyze.add_argument("--symbols") + analyze.add_argument("--out") + analyze.set_defaults(fn=cmd_analyze) + + compare = sub.add_parser("compare", help="A/B two recorded sessions") + compare.add_argument("a") + compare.add_argument("b") + compare.add_argument("--symbols", action="store_true") + compare.set_defaults(fn=cmd_compare) + + symbols = sub.add_parser("symbols", help="build/inspect a symbol database") + symbols_sub = symbols.add_subparsers(dest="symbols_command", required=True) + exp = symbols_sub.add_parser("export") + exp.add_argument("--names") + exp.add_argument("--index") + exp.add_argument("--exports") + exp.add_argument("--callgraph") + exp.add_argument("--elf") + exp.add_argument("--bias", type=lambda v: int(v, 0), default=0) + exp.add_argument("-o", "--out", required=True) + exp.set_defaults(fn=cmd_symbols) + res = symbols_sub.add_parser("resolve") + res.add_argument("addr") + res.add_argument("--db") + res.set_defaults(fn=cmd_symbols) + + plugins = sub.add_parser("plugins", help="list discoverable plugins") + plugins.set_defaults(fn=cmd_plugins) + + return parser + + +def main(argv: list[str] | None = None) -> int: + _ensure_dumps_env() + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.fn(args) + except GreenEyeError as exc: + print(f"green-eye error: {exc}", file=sys.stderr) + return 2 + except KeyboardInterrupt: + print("interrupted", file=sys.stderr) + return 130 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/greeneye/elf.py b/debug/risc-v-gsp-rm-vm/greeneye/elf.py new file mode 100644 index 0000000..abfccd9 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/elf.py @@ -0,0 +1,322 @@ +"""Minimal ELF64/ELF32 reader (stdlib only). + +Needed for three things: + * symbol resolution for the demo uproc firmware (breakpoints by name), + * program-header extraction so the real GSP ``.fwimage`` can be placed into + QEMU RAM (and relocated when a probe needs it), + * entry-point detection for the first-fault firmware probe. + +This is not a full ELF library; it parses headers, PT_LOAD segments and +``.symtab`` symbols, which is all green-eye needs. +""" +from __future__ import annotations + +import struct +from dataclasses import dataclass, field as _field + +from .errors import GreenEyeError + +EM_RISCV = 243 +PT_LOAD = 1 +SHT_SYMTAB = 2 +SHT_STRTAB = 3 +STT_FUNC = 2 +STT_OBJECT = 1 +STT_NOTYPE = 0 + + +class ElfError(GreenEyeError): + """Malformed or unsupported ELF file.""" + + +@dataclass +class Segment: + p_type: int + p_flags: int + p_offset: int + p_vaddr: int + p_paddr: int + p_filesz: int + p_memsz: int + p_align: int + + @property + def is_load(self) -> bool: + return self.p_type == PT_LOAD + + @property + def writable(self) -> bool: + return bool(self.p_flags & 0x2) + + @property + def executable(self) -> bool: + return bool(self.p_flags & 0x1) + + +@dataclass +class ElfSymbol: + name: str + value: int + size: int + bind: int + type: int + + @property + def is_function(self) -> bool: + return self.type == STT_FUNC + + @property + def is_object(self) -> bool: + return self.type == STT_OBJECT + + +@dataclass +class FirmwareImage: + path: str + is64: bool + e_machine: int + entry: int + segments: list[Segment] = _field(default_factory=list) + symbols: list[ElfSymbol] = _field(default_factory=list) + _blob: bytes | None = None + + @property + def is_riscv(self) -> bool: + return self.e_machine == EM_RISCV + + @property + def load_segments(self) -> list[Segment]: + return [s for s in self.segments if s.is_load] + + @property + def min_vaddr(self) -> int: + loads = self.load_segments + return min((s.p_vaddr for s in loads), default=0) + + @property + def max_vaddr(self) -> int: + loads = self.load_segments + return max((s.p_vaddr + s.p_memsz for s in loads), default=0) + + def read(self) -> bytes: + if self._blob is None: + with open(self.path, "rb") as fh: + self._blob = fh.read() + return self._blob + + def segment_bytes(self, seg: Segment) -> bytes: + return self.read()[seg.p_offset : seg.p_offset + seg.p_filesz] + + def flat_load_plan(self, bias: int = 0) -> list[tuple[int, bytes]]: + """Return ``(load_addr, data)`` pairs for every PT_LOAD segment. + + ``bias`` is added to each segment's virtual address (use it to place a + firmware linked for GPU physical addresses into QEMU RAM). + """ + plan: list[tuple[int, bytes]] = [] + for seg in self.load_segments: + data = bytearray(self.segment_bytes(seg)) + if len(data) < seg.p_memsz: + data += b"\x00" * (seg.p_memsz - len(data)) + plan.append((seg.p_vaddr + bias, bytes(data))) + return plan + + def relocated_entry(self, bias: int = 0) -> int: + return self.entry + bias + + def summary(self) -> str: + return ( + f"{self.path}: ELF{'64' if self.is64 else '32'} " + f"machine={self.e_machine} entry={self.entry:#x} " + f"segments={len(self.segments)} loads={len(self.load_segments)} " + f"symbols={len(self.symbols)} " + f"vaddr=[{self.min_vaddr:#x},{self.max_vaddr:#x})" + ) + + +def load_elf(path: str) -> FirmwareImage: + with open(path, "rb") as fh: + data = fh.read() + return parse_elf(data, path=path) + + +def parse_elf(data: bytes, path: str = "<memory>") -> FirmwareImage: + if len(data) < 52 or data[:4] != b"\x7fELF": + raise ElfError(f"{path}: not an ELF file") + ei_class = data[4] + if ei_class == 2: + is64 = True + hdr_fmt = "<HHIQQQIHHHHHH" + elif ei_class == 1: + is64 = False + hdr_fmt = "<HHIIIIIHHHHHH" + else: + raise ElfError(f"{path}: unknown EI_CLASS {ei_class}") + hdr_size = struct.calcsize(hdr_fmt) + fields = struct.unpack_from(hdr_fmt, data, 16) + ( + _e_type, + e_machine, + _e_version, + e_entry, + e_phoff, + e_shoff, + _e_flags, + _e_ehsize, + e_phentsize, + e_phnum, + e_shentsize, + e_shnum, + e_shstrndx, + ) = fields + + image = FirmwareImage(path=path, is64=is64, e_machine=e_machine, entry=e_entry) + + if is64: + ph_fmt = "<IIQQQQQQ" + sh_fmt = "<IIQQQQIIQQ" + sym_fmt = "<IBBHQQ" + else: + ph_fmt = "<IIIIIIII" + sh_fmt = "<IIIIIIIIII" + sym_fmt = "<IIIBBH" + + ph_size = struct.calcsize(ph_fmt) + for i in range(e_phnum): + base = e_phoff + i * e_phentsize + if base + ph_size > len(data): + break + p = struct.unpack_from(ph_fmt, data, base) + image.segments.append(Segment(*p[:8])) + + # Sections -> symbols + sh_size = struct.calcsize(sh_fmt) + sections: list[tuple[int, int, int, int]] = [] # type, offset, size, link + for i in range(e_shnum): + base = e_shoff + i * e_shentsize + if base + sh_size > len(data): + break + sh = struct.unpack_from(sh_fmt, data, base) + sh_type, sh_offset, sh_size_val, sh_link = sh[1], sh[4], sh[5], sh[6] + sections.append((sh_type, sh_offset, sh_size_val, sh_link)) + + for sh_type, sh_offset, sh_size_val, sh_link in sections: + if sh_type != SHT_SYMTAB: + continue + strtab_off, strtab_size = 0, 0 + if 0 <= sh_link < len(sections): + _, strtab_off, strtab_size, _ = sections[sh_link] + strtab = data[strtab_off : strtab_off + strtab_size] + count = sh_size_val // struct.calcsize(sym_fmt) + for i in range(count): + base = sh_offset + i * struct.calcsize(sym_fmt) + if base + struct.calcsize(sym_fmt) > len(data): + break + st = struct.unpack_from(sym_fmt, data, base) + if is64: + st_name, st_info, _st_other, _st_shndx, st_value, st_size_val = st + else: + st_name, st_value, st_size_val, st_info, _st_other, _st_shndx = st + if st_name == 0 or st_value == 0: + continue + name = _read_cstr(strtab, st_name) + if not name: + continue + image.symbols.append( + ElfSymbol( + name=name, + value=st_value, + size=st_size_val, + bind=(st_info >> 4) & 0xF, + type=st_info & 0xF, + ) + ) + return image + + +def _read_cstr(blob: bytes, offset: int) -> str: + if offset >= len(blob): + return "" + end = blob.find(b"\x00", offset) + if end == -1: + end = len(blob) + return blob[offset:end].decode("utf-8", "replace") + + +def write_relocated_elf(path: str, out_path: str, bias: int) -> str: + """Write a copy of an ELF64 whose PT_LOADs and entry are shifted by bias. + + ``p_paddr`` is forced to ``p_vaddr + bias`` so the layout matches the + virtual addresses. Useful for formats/tools that honour e_entry. + """ + with open(path, "rb") as fh: + data = bytearray(fh.read()) + if len(data) < 64 or data[:4] != b"\x7fELF" or data[4] != 2: + raise ElfError(f"{path}: relocation requires a 64-bit ELF") + e_entry = struct.unpack_from("<Q", data, 24)[0] + e_phoff = struct.unpack_from("<Q", data, 32)[0] + e_phentsize = struct.unpack_from("<H", data, 54)[0] + e_phnum = struct.unpack_from("<H", data, 56)[0] + struct.pack_into("<Q", data, 24, e_entry + bias) + for i in range(e_phnum): + base = e_phoff + i * e_phentsize + if base + 56 > len(data): + break + p_type = struct.unpack_from("<I", data, base)[0] + if p_type != PT_LOAD: + continue + p_vaddr = struct.unpack_from("<Q", data, base + 16)[0] + struct.pack_into("<Q", data, base + 16, p_vaddr + bias) + struct.pack_into("<Q", data, base + 24, p_vaddr + bias) + with open(out_path, "wb") as fh: + fh.write(data) + return out_path + + +def _trampoline(entry_offset: int) -> bytes: + """RV64 code at offset 0 that jumps to ``PC + entry_offset``. + + QEMU's RISC-V ``-kernel`` raw/ELF path starts the CPU at the RAM base for + images whose e_entry it does not honour; this 8-byte stub (auipc t0, hi; + jalr x0, t0, lo) makes the firmware entry reachable from the base without + patching the image itself. + """ + if not -0x80000000 <= entry_offset < 0x80000000: + raise ElfError("entry offset out of trampoline range") + hi20 = ((entry_offset + 0x800) >> 12) & 0xFFFFF + lo12 = entry_offset & 0xFFF + auipc = (hi20 << 12) | (5 << 7) | 0x17 # auipc t0, hi20 + jalr = (lo12 << 20) | (5 << 15) | 0x67 # jalr x0, t0, lo12 + return struct.pack("<II", auipc, jalr) + + +def write_flat_kernel( + path: str, + out_path: str, + *, + load_addr: int = 0x80000000, + trampoline: bool = True, +) -> tuple[str, int]: + """Flatten an ELF's PT_LOADs into a raw image QEMU can boot as `-kernel`. + + Returns ``(out_path, entry_addr)`` where ``entry_addr = load_addr + + (e_entry - min_vaddr)``. When the entry is not at offset 0 a tiny + PC-relative trampoline is written at offset 0 (QEMU starts at the load + base for raw kernels). ``load_addr`` is not encoded anywhere except in + the returned entry address: all code is position-independent relative to + the image base, exactly as it executes on the device. + """ + image = load_elf(path) + span = image.max_vaddr - image.min_vaddr + flat = bytearray(span) + for seg in image.load_segments: + start = seg.p_vaddr - image.min_vaddr + end = start + seg.p_filesz + flat[start:end] = image.segment_bytes(seg) + entry_offset = image.entry - image.min_vaddr + if trampoline and entry_offset != 0: + stub = _trampoline(entry_offset) + flat[0 : len(stub)] = stub + with open(out_path, "wb") as fh: + fh.write(flat) + return out_path, load_addr + entry_offset diff --git a/debug/risc-v-gsp-rm-vm/greeneye/errors.py b/debug/risc-v-gsp-rm-vm/greeneye/errors.py new file mode 100644 index 0000000..fab5874 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/errors.py @@ -0,0 +1,30 @@ +"""green-eye error types. Keep them few and meaningful.""" +from __future__ import annotations + + +class GreenEyeError(Exception): + """Base class for all green-eye errors.""" + + +class RspError(GreenEyeError): + """Base class for gdbstub RSP transport errors.""" + + +class RspTimeout(RspError): + """The stub did not answer within the timeout.""" + + +class RspProtocolError(RspError): + """The stub answered with a malformed packet or an error reply.""" + + +class TargetError(GreenEyeError): + """QEMU target lifecycle/configuration error.""" + + +class SymbolNotFound(GreenEyeError): + """A requested symbol is not in the symbol database.""" + + +class StructError(GreenEyeError): + """Structure schema/decode error.""" diff --git a/debug/risc-v-gsp-rm-vm/greeneye/ghidra.py b/debug/risc-v-gsp-rm-vm/greeneye/ghidra.py new file mode 100644 index 0000000..6986d74 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/ghidra.py @@ -0,0 +1,80 @@ +"""Ghidra bridge: symbols in, hit annotations out. + +Two flows: + +*out of Ghidra*: export function names/addresses/sizes to a green-eye JSON +symbol database (from ``names.tsv``, ``decompiled_index.tsv``, exports or ELF); +*hits*: turn a recorded session into ``greeneye_hits.tsv`` +(``addr<TAB>hits<TAB>symbol``) that ``MarkGreenEyeHits.java`` can annotate in +the Ghidra project. + +This is the static/dynamic join: Ghidra owns the address space and +decompilation, green-eye owns what actually executed. +""" +from __future__ import annotations + +import os +from collections import Counter + +from .record import load_events +from .symbols import SymbolDB + + +def build_symbol_db( + *, + names_tsv: str | None = None, + index_tsv: str | None = None, + exports_tsv: str | None = None, + callgraph_tsv: str | None = None, + elf: str | None = None, + bias: int = 0, +) -> SymbolDB: + db = SymbolDB() + if elf: + db.load_elf(elf, bias=0) # ELF symbol values are already runtime addresses + if names_tsv: + db.load_names_tsv(names_tsv, bias=bias) + if index_tsv: + db.load_index_tsv(index_tsv, bias=bias) + if exports_tsv: + db.load_index_tsv(exports_tsv, bias=bias) + if callgraph_tsv: + db.load_callgraph(callgraph_tsv) + return db + + +def export_hits(session_dir: str, out_tsv: str, symbols: SymbolDB | None = None) -> str: + events = load_events(session_dir) + hits: Counter = Counter() + for event in events: + if event.get("type") == "bp_hit": + hits[event.get("pc")] += 1 + elif event.get("type") == "watch_hit": + hits[event.get("addr")] += 1 + with open(out_tsv, "w", encoding="utf-8") as fh: + fh.write("addr\thits\tsymbol\tannotation\n") + for addr, count in sorted(hits.items(), key=lambda kv: -kv[1]): + if not isinstance(addr, int): + continue + sym = symbols.resolve(addr) if symbols else None + name = sym.name if sym else "" + annotation = symbols.annotate(addr) if symbols else f"{addr:#x}" + fh.write(f"{addr:#x}\t{count}\t{name}\t{annotation}\n") + return os.path.abspath(out_tsv) + + +def corpus_location(symbol, corpus_root: str) -> str | None: + """Return ``<bucket>:<line>`` for a symbol with Ghidra index extras.""" + bucket = symbol.extras.get("bucket") + line = symbol.extras.get("line") + if not bucket: + return None + return os.path.join(corpus_root, bucket) + (f":{line}" if line else "") + + +def render_ghidra_command(project_dir: str, project_name: str, program: str, hits_tsv: str) -> str: + return ( + "analyzeHeadless {proj} {name} -process {program} -noanalysis " + "-scriptPath <open-iov>/debug/risc-v-gsp-rm-vm/ghidra " + "-postScript MarkGreenEyeHits.java {hits}" + ).format(proj=project_dir, name=project_name, program=program, hits=hits_tsv) diff --git a/debug/risc-v-gsp-rm-vm/greeneye/memory.py b/debug/risc-v-gsp-rm-vm/greeneye/memory.py new file mode 100644 index 0000000..23d9ca0 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/memory.py @@ -0,0 +1,190 @@ +"""Memory inspection helpers: read/write, hexdump, search, snapshots, diffs.""" +from __future__ import annotations + +from dataclasses import dataclass + +from .errors import GreenEyeError + + +@dataclass +class Diff: + addr: int + old: bytes + new: bytes + + @property + def size(self) -> int: + return len(self.old) + + +class Memory: + """A convenience facade over an RSP client (and optional symbol DB).""" + + def __init__(self, client, symbols=None, recorder=None): + self.client = client + self.symbols = symbols + self.recorder = recorder + + # -- basic access ------------------------------------------------------ + + def read(self, addr: int, size: int) -> bytes: + data = self.client.read_mem(addr, size) + if self.recorder is not None: + self.recorder.event("mem_read", addr=addr, size=size) + return data + + def write(self, addr: int, data: bytes | int | str) -> None: + if isinstance(data, int): + data = bytes([data & 0xFF]) + elif isinstance(data, str): + data = data.encode("utf-8") + self.client.write_mem(addr, data) + if self.recorder is not None: + self.recorder.event("mem_write", addr=addr, size=len(data), data=data.hex()) + + def read_u8(self, addr: int) -> int: + return self.read(addr, 1)[0] + + def read_u16(self, addr: int) -> int: + return int.from_bytes(self.read(addr, 2), "little") + + def read_u32(self, addr: int) -> int: + return int.from_bytes(self.read(addr, 4), "little") + + def read_u64(self, addr: int) -> int: + return int.from_bytes(self.read(addr, 8), "little") + + def write_u32(self, addr: int, value: int) -> None: + self.write(addr, value.to_bytes(4, "little")) + + def write_u64(self, addr: int, value: int) -> None: + self.write(addr, value.to_bytes(8, "little")) + + def read_cstring(self, addr: int, max_len: int = 256) -> str: + return self.read(addr, max_len).split(b"\x00", 1)[0].decode("utf-8", "replace") + + def read_ptr(self, addr: int, width: int = 8) -> int: + return int.from_bytes(self.read(addr, width), "little") + + # -- display ----------------------------------------------------------- + + def hexdump(self, addr: int, size: int, width: int = 16, annotate: bool = True) -> str: + data = self.read(addr, size) + lines: list[str] = [] + if annotate and self.symbols is not None: + sym = self.symbols.resolve(addr) + if sym is not None: + lines.append(f"; {sym.label(addr)} (source={sym.source or '?'})") + for offset in range(0, len(data), width): + chunk = data[offset : offset + width] + hex_part = " ".join(f"{b:02x}" for b in chunk) + ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) + lines.append(f"{addr + offset:08x} {hex_part:<{width * 3 - 1}} |{ascii_part}|") + return "\n".join(lines) + + def hexdump_annotated( + self, + addr: int, + size: int, + struct_schema=None, + width: int = 16, + ) -> str: + """Hexdump plus a decoded structure overlay when a schema is given.""" + lines = [self.hexdump(addr, size, width=width, annotate=True)] + if struct_schema is not None: + from .structs import decode, render + + values = decode(struct_schema, self.read(addr, struct_schema.size), base=addr) + lines.append("") + lines.append(render(struct_schema, values)) + return "\n".join(lines) + + # -- search ------------------------------------------------------------ + + def search( + self, + addr: int, + size: int, + pattern: bytes | int, + *, + mask: bytes | None = None, + max_hits: int = 64, + chunk: int = 0x10000, + ) -> list[int]: + if isinstance(pattern, int): + pattern = bytes([pattern & 0xFF]) + if not pattern: + raise GreenEyeError("empty search pattern") + if mask is not None and len(mask) != len(pattern): + raise GreenEyeError("mask length must match pattern length") + hits: list[int] = [] + offset = 0 + overlap = len(pattern) - 1 + carry = b"" + while offset < size and len(hits) < max_hits: + want = min(chunk, size - offset) + blob = carry + self.read(addr + offset - len(carry), want + len(carry)) if carry else self.read(addr + offset, want) + base = addr + offset - len(carry) + for index in range(0, len(blob) - len(pattern) + 1): + candidate = blob[index : index + len(pattern)] + if mask is None: + if candidate == pattern: + hits.append(base + index) + else: + if all((candidate[i] & mask[i]) == (pattern[i] & mask[i]) for i in range(len(pattern))): + hits.append(base + index) + if len(hits) >= max_hits: + break + carry = blob[-overlap:] if overlap else b"" + offset += want + return hits + + def search_u32(self, addr: int, size: int, value: int, **kw) -> list[int]: + return self.search(addr, size, value.to_bytes(4, "little"), **kw) + + def search_string(self, addr: int, size: int, text: str, **kw) -> list[int]: + return self.search(addr, size, text.encode("utf-8"), **kw) + + # -- capture / diff ---------------------------------------------------- + + def snapshot(self, addr: int, size: int, name: str = "region") -> bytes: + data = self.client.read_mem(addr, size) + if self.recorder is not None: + path = self.recorder.snapshot(name, addr, data) + self.recorder.event("snapshot", label=name, addr=addr, size=size, path=path) + return data + + @staticmethod + def diff(old: bytes, new: bytes, base: int = 0) -> list[Diff]: + length = min(len(old), len(new)) + changed = [i for i in range(length) if old[i] != new[i]] + if not changed: + return [] + # coalesce adjacent bytes into runs + runs: list[Diff] = [] + start = changed[0] + prev = changed[0] + for index in changed[1:]: + if index == prev + 1: + prev = index + continue + runs.append(Diff(base + start, old[start : prev + 1], new[start : prev + 1])) + start = prev = index + runs.append(Diff(base + start, old[start : prev + 1], new[start : prev + 1])) + return runs + + @staticmethod + def render_diff(diffs: list[Diff], symbols=None, max_lines: int = 64) -> str: + lines: list[str] = [] + for diff in diffs[:max_lines]: + old = " ".join(f"{b:02x}" for b in diff.old[:8]) + new = " ".join(f"{b:02x}" for b in diff.new[:8]) + label = "" + if symbols is not None: + sym = symbols.resolve(diff.addr) + if sym is not None: + label = f" ; {sym.label(diff.addr)}" + lines.append(f"{diff.addr:08x} {old:<24} -> {new:<24}{label}") + if len(diffs) > max_lines: + lines.append(f"... {len(diffs) - max_lines} more changed runs") + return "\n".join(lines) diff --git a/debug/risc-v-gsp-rm-vm/greeneye/plugins.py b/debug/risc-v-gsp-rm-vm/greeneye/plugins.py new file mode 100644 index 0000000..af4545f --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/plugins.py @@ -0,0 +1,121 @@ +"""green-eye plugin system. + +A plugin is a normal Python module that exposes:: + + def register(ge): + ge.log("my plugin loaded") + ge.add_command("mycmd", run_mycmd, help="…") + ge.hook("rpc_sum", on_enter=lambda hit: ge.log(f"sum({hit.arg(0)})")) + +Plugins can extend green-eye with: + +* new CLI commands (including an MCP server later - roadmap); +* runtime RE tools (extra watchpoint strategies, structure probes, coverage); +* virtualisation-environment updates (e.g. record/replay harnesses); +* AI-harness integration and reporting. + +Discovery order: + +1. explicit ``--plugin PATH`` arguments; +2. colon-separated paths in ``GREENEYE_PLUGINS``; +3. ``<tool>/plugins/*.py`` (the bundled examples). +""" +from __future__ import annotations + +import importlib.util +import os +import sys +from typing import Callable + + +class GreenEyeAPI: + """Handle passed to ``register(ge)``.""" + + def __init__(self, session, cli=None): + self.session = session + self.cli = cli + self.commands: dict[str, tuple[Callable, str]] = {} + self.loaded: list[str] = [] + + # -- convenience -------------------------------------------------------- + + @property + def memory(self): + return self.session.memory + + @property + def symbols(self): + return self.session.symbols + + @property + def recorder(self): + return self.session.recorder + + def log(self, message: str, **fields) -> None: + self.session.recorder.event("plugin", message=message, **fields) + + def hook(self, target, on_enter=None, on_leave=None, **kw): + return self.session.hook(target, on_enter=on_enter, on_leave=on_leave, **kw) + + def watch(self, target, size: int = 8, kind: str = "w", callback=None, **kw): + return self.session.watch(target, size=size, kind=kind, callback=callback, **kw) + + def on_event(self, kind: str, callback) -> None: + self.session.on_event(kind, callback) + + def resolve(self, target): + return self.session.resolve(target) + + def add_command(self, name: str, fn: Callable, help: str = "") -> None: + self.commands[name] = (fn, help) + + def run_command(self, name: str, args: list[str]) -> int: + fn, _help = self.commands[name] + return int(fn(self.session, args) or 0) + + +class PluginManager: + def __init__(self, session, cli=None): + self.session = session + self.api = GreenEyeAPI(session, cli=cli) + self.loaded: list[str] = [] + + def default_paths(self) -> list[str]: + here = os.path.dirname(os.path.abspath(__file__)) + bundled = os.path.join(os.path.dirname(here), "plugins") + paths = [] + env = os.environ.get("GREENEYE_PLUGINS", "") + paths += [p for p in env.split(os.pathsep) if p] + if os.path.isdir(bundled): + paths.append(bundled) + return paths + + def load_all(self, explicit: list[str] | None = None) -> list[str]: + for path in (explicit or []) + self.default_paths(): + if os.path.isdir(path): + for entry in sorted(os.listdir(path)): + if entry.endswith(".py") and not entry.startswith("_"): + self._load_file(os.path.join(path, entry)) + elif path.endswith(".py") and os.path.isfile(path): + self._load_file(path) + return self.loaded + + def _load_file(self, path: str) -> None: + name = os.path.splitext(os.path.basename(path))[0] + mod_name = f"greeneye_plugin_{name}" + try: + spec = importlib.util.spec_from_file_location(mod_name, path) + if spec is None or spec.loader is None: + return + module = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = module + spec.loader.exec_module(module) + register = getattr(module, "register", None) + if register is None: + self.session.recorder.event("plugin_error", plugin=name, error="no register(ge)") + return + register(self.api) + self.loaded.append(name) + self.session.recorder.event("plugin_loaded", plugin=name) + except Exception as exc: # plugins must never break the session + self.session.recorder.event("plugin_error", plugin=name, error=repr(exc)) diff --git a/debug/risc-v-gsp-rm-vm/greeneye/probes.py b/debug/risc-v-gsp-rm-vm/greeneye/probes.py new file mode 100644 index 0000000..5837bab --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/probes.py @@ -0,0 +1,166 @@ +"""Declarative probe specifications (the Frida-script-like layer). + +A probe spec is JSON so it can be generated by another tool (or an AI +harness) and replayed unchanged: + + { + "probes": [ + { + "name": "sum-entry", + "symbol": "rpc_sum", + "when": "a1 > 0", + "on_enter": { + "log": ["a0", "a1", "a2"], + "dump": [{"reg": "a0", "len": 32, "label": "sum-arg"}], + "note": "sum called" + }, + "on_leave": {"log": ["retval"]} + } + ] + } + +Conditions are evaluated over a register dictionary (``a0``..``a7``, ``pc``, +``ra``, or raw ``x10`` names). The evaluator is deliberately tiny: it only +accepts comparisons/boolean arithmetic over those names and integer +literals, never arbitrary Python. +""" +from __future__ import annotations + +import json +import re + +from .errors import GreenEyeError + +_ALLOWED_CHARS = re.compile(r"^[\s0-9a-fA-FxX<>=!()+\-*/%&|^~]*$") +_NAME_RE = re.compile(r"\b[A-Za-z_][A-Za-z0-9_]*\b") +_ALLOWED_NAMES = re.compile( + r"^(retval|pc|ra|sp|gp|tp|fp|and|or|not|True|False|None" + r"|[a-z][0-9]?|x[0-9]{1,2}|[stga][0-9]{1,2})$" +) + + +def load_spec(path: str) -> dict: + with open(path, "r", encoding="utf-8") as fh: + spec = json.load(fh) + if "probes" not in spec or not isinstance(spec["probes"], list): + raise GreenEyeError("probe spec must be an object with a 'probes' list") + return spec + + +def compile_condition(expr: str | None): + if not expr: + return None + for name in _NAME_RE.findall(expr): + if not _ALLOWED_NAMES.match(name): + raise GreenEyeError(f"probe condition references disallowed name {name!r}") + if not _ALLOWED_CHARS.match(_NAME_RE.sub("0", expr)): + raise GreenEyeError(f"probe condition contains disallowed characters: {expr!r}") + code = compile(expr, "<probe-when>", "eval") + + def check(regs: dict) -> bool: + scope = { + **{k: v for k, v in regs.items() if isinstance(v, int)}, + "retval": regs.get("a0", 0), + } + try: + return bool(eval(code, {"__builtins__": {}}, scope)) # noqa: S307 - whitelisted above + except Exception: + return False + + return check + + +def install(session, spec: dict) -> list: + """Install every probe in the spec; returns the HookHandles.""" + handles = [] + for entry in spec["probes"]: + target = entry.get("symbol") or entry.get("addr") + if target is None: + raise GreenEyeError(f"probe {entry.get('name', '?')}: needs 'symbol' or 'addr'") + when = compile_condition(entry.get("when")) + name = entry.get("name", str(target)) + on_enter_actions = entry.get("on_enter") + on_leave_actions = entry.get("on_leave") + + def make_enter(name=name, when=when, actions=on_enter_actions): + def callback(hit): + if when is not None and not when(hit.regs): + return + _run_actions(hit, name, actions) + + return callback + + def make_leave(name=name, actions=on_leave_actions): + def callback(hit): + _run_actions(hit, name, actions, is_leave=True) + + return callback + + handle = session.hook( + target, + on_enter=make_enter() if on_enter_actions else None, + on_leave=make_leave() if on_leave_actions else None, + listener=f"probe:{name}", + ) + session.note(f"probe installed: {name}", target=str(target)) + handles.append(handle) + return handles + + +def _run_actions(hit, probe_name: str, actions: dict | None, is_leave: bool = False) -> None: + session = hit.session + if actions is None: + return + values = {} + for expr in actions.get("log", []): + values[expr] = _read_value(hit, expr) + if values: + session.recorder.event( + "probe_log", + probe=probe_name, + phase="leave" if is_leave else "enter", + addr=hit.addr, + symbol=hit.symbol.name if hit.symbol else None, + values=values, + ) + for dump in actions.get("dump", []): + addr = _read_value(hit, dump.get("reg", "a0")) + if isinstance(addr, str): + continue + length = int(dump.get("len", 64)) + label = dump.get("label", f"{probe_name}-dump") + try: + data = session.memory.read(addr & 0xFFFFFFFFFFFFFFFF, length) + except Exception as exc: + session.recorder.event("probe_dump_error", probe=probe_name, addr=addr, error=str(exc)) + continue + path = session.recorder.blob(f"{label}-{addr:#x}.bin", data) + session.recorder.event( + "probe_dump", + probe=probe_name, + name=label, + addr=addr, + size=len(data), + path=path, + preview=data[:32].hex(), + ) + for snapshot in actions.get("snapshot", []): + addr = _read_value(hit, snapshot.get("reg", "a0")) + if isinstance(addr, str): + continue + session.snapshot(addr & 0xFFFFFFFFFFFFFFFF, int(snapshot.get("len", 4096)), name=snapshot.get("label", probe_name)) + if actions.get("note"): + session.note(actions["note"], probe=probe_name, addr=hit.addr) + + +def _read_value(hit, expr: str): + if expr == "retval": + return hit.retval if hit.retval is not None else hit.regs.get("a0", 0) + if expr in hit.regs: + return hit.regs[expr] + if expr.startswith("0x"): + return int(expr, 16) + try: + return int(expr, 0) + except ValueError: + return f"<unknown:{expr}>" diff --git a/debug/risc-v-gsp-rm-vm/greeneye/record.py b/debug/risc-v-gsp-rm-vm/greeneye/record.py new file mode 100644 index 0000000..bf70e58 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/record.py @@ -0,0 +1,156 @@ +"""Session recording: events.jsonl + snapshots + summary. + +A recorded session is the durable artifact green-eye produces: a complete, +symbol-annotatable map of what the firmware did (hook hits, watch hits, +memory writes, snapshots, coverage steps) that can be replayed offline, +diffed between runs (bare-metal vs VFIO-mdev, old GSP vs new GSP) and +annotated back into Ghidra. +""" +from __future__ import annotations + +import json +import os +import time +import zlib + +FORMAT = "greeneye-session/1" + + +class NullRecorder: + dir: str | None = None + + def event(self, event_type: str, **fields) -> None: + pass + + def snapshot(self, label: str, addr: int, data: bytes) -> None: + pass + + def blob(self, name: str, data: bytes) -> None: + pass + + def close(self) -> None: + pass + + +class Recorder: + def __init__(self, root: str, *, name: str = "session", meta: dict | None = None): + self.dir = root + os.makedirs(self.dir, exist_ok=True) + self.name = name + self.meta = dict(meta or {}) + self.meta.update({"format": FORMAT, "name": name, "created": time.time(), "greeneye": _version()}) + self._events_path = os.path.join(self.dir, "events.jsonl") + self._fh = open(self._events_path, "w", encoding="utf-8") + self._t0 = time.monotonic() + self._counts: dict[str, int] = {} + self._snapshots: list[dict] = [] + self._closed = False + + @classmethod + def new(cls, *, base: str | None = None, name: str = "session", meta: dict | None = None) -> "Recorder": + base = base or os.environ.get("GREENEYE_DUMPS") or os.path.join(os.getcwd(), "dumps") + stamp = time.strftime("%Y%m%d-%H%M%S") + return cls(os.path.join(base, f"{stamp}-{name}"), name=name, meta=meta) + + # -- writing ----------------------------------------------------------- + + def event(self, event_type: str, **fields) -> None: + if self._closed: + return + record = {"t": round(time.monotonic() - self._t0, 6), "type": event_type} + for key, value in fields.items(): + record[key] = _jsonable(value) + self._fh.write(json.dumps(record, sort_keys=True) + "\n") + self._counts[event_type] = self._counts.get(event_type, 0) + 1 + + def snapshot(self, label: str, addr: int, data: bytes) -> str: + rel = os.path.join("snapshots", f"{label}-{addr:#x}-{len(data)}B.bin") + path = os.path.join(self.dir, rel) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as fh: + fh.write(zlib.compress(data)) + info = {"label": label, "addr": addr, "size": len(data), "path": rel, "sha256": _sha(data)} + self._snapshots.append(info) + self.event("snapshot_saved", **info) + return rel + + def blob(self, name: str, data: bytes) -> str: + rel = os.path.join("blobs", name) + path = os.path.join(self.dir, rel) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as fh: + fh.write(data) + self.event("blob_saved", path=rel, size=len(data)) + return rel + + # -- closing ----------------------------------------------------------- + + def close(self, write_summary: bool = True) -> None: + if self._closed: + return + self.event("session_end") + self._fh.close() + self._closed = True + self.meta["counts"] = self._counts + self.meta["snapshots"] = self._snapshots + with open(os.path.join(self.dir, "session.json"), "w", encoding="utf-8") as fh: + json.dump(self.meta, fh, indent=1, sort_keys=True) + if write_summary: + try: + from .analyze import render_summary + + report = render_summary(self.dir) + with open(os.path.join(self.dir, "summary.md"), "w", encoding="utf-8") as fh: + fh.write(report) + except Exception as exc: # pragma: no cover - never break close + with open(os.path.join(self.dir, "summary-error.txt"), "w", encoding="utf-8") as fh: + fh.write(f"summary failed: {exc}\n") + + def __enter__(self) -> "Recorder": + return self + + def __exit__(self, *exc) -> None: + self.close() + + +def load_events(session_dir: str) -> list[dict]: + path = session_dir + if os.path.isdir(session_dir): + path = os.path.join(session_dir, "events.jsonl") + events: list[dict] = [] + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if line: + events.append(json.loads(line)) + return events + + +def load_meta(session_dir: str) -> dict: + with open(os.path.join(session_dir, "session.json"), "r", encoding="utf-8") as fh: + return json.load(fh) + + +def _jsonable(value): + if isinstance(value, bytes): + return value.hex() + if isinstance(value, (set, tuple)): + return list(value) + if isinstance(value, dict): + return {str(k): _jsonable(v) for k, v in value.items()} + return value + + +def _sha(data: bytes) -> str: + import hashlib + + return hashlib.sha256(data).hexdigest() + + +def _version() -> str: + try: + from . import __version__ + + return __version__ + except Exception: # pragma: no cover + return "unknown" diff --git a/debug/risc-v-gsp-rm-vm/greeneye/rsp.py b/debug/risc-v-gsp-rm-vm/greeneye/rsp.py new file mode 100644 index 0000000..0263c63 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/rsp.py @@ -0,0 +1,583 @@ +"""Pure-Python GDB Remote Serial Protocol (RSP) client for QEMU's gdbstub. + +green-eye deliberately avoids GDB: the whole instrumentation stack is a Python +library, so sessions, probes and plugins are scriptable end to end. This +module implements the RSP subset QEMU needs: packet framing/acks, no-ack mode, +registers (with layout read from ``target.xml``), memory, software/hardware +breakpoints, watchpoints, continue/step/interrupt and monitor commands. + +Tested against QEMU 8.2 (``qemu-system-riscv64``). +""" +from __future__ import annotations + +import socket +import time +from dataclasses import dataclass, field as _field + +from .errors import RspProtocolError, RspTimeout + +# --------------------------------------------------------------------------- +# Packet framing +# --------------------------------------------------------------------------- + + +def checksum(data: bytes) -> int: + """RSP 8-bit checksum: sum of bytes mod 256.""" + return sum(data) & 0xFF + + +def encode_packet(data: bytes) -> bytes: + """Frame a payload as ``$<data>#<checksum>``.""" + return b"$" + data + b"#" + f"{checksum(data):02x}".encode("ascii") + + +def _hex_value(payload: str) -> int: + try: + return int(payload, 16) + except ValueError as exc: # pragma: no cover - defensive + raise RspProtocolError(f"malformed hex value: {payload!r}") from exc + + +# --------------------------------------------------------------------------- +# Stop replies +# --------------------------------------------------------------------------- + + +@dataclass +class StopReply: + """A parsed stop reply (``T``/``S``/``W``/``X`` packet).""" + + raw: str + signal: int | None = None + thread: str | None = None + regs: dict[int, int] = _field(default_factory=dict) + watch_addr: int | None = None + watch_kind: str | None = None + exit_status: int | None = None + + @property + def is_exit(self) -> bool: + return self.raw[:1] in ("W", "X") + + @property + def is_stop(self) -> bool: + return self.raw[:1] in ("T", "S") + + def __str__(self) -> str: # pragma: no cover - cosmetic + if self.is_exit: + return f"exit(status={self.exit_status})" + bits = [f"signal={self.signal}"] + if self.watch_addr is not None: + bits.append(f"{self.watch_kind}@{self.watch_addr:#x}") + if self.thread: + bits.append(f"thread={self.thread}") + return "stop(" + ", ".join(bits) + ")" + + +def parse_stop_reply(raw: str) -> StopReply: + """Parse a stop packet body (without the leading channel).""" + reply = StopReply(raw=raw) + if not raw: + return reply + head = raw[0] + if head in ("T", "S"): + if len(raw) >= 3: + try: + reply.signal = int(raw[1:3], 16) + except ValueError: + pass + rest = raw[3:] + for item in rest.split(";"): + if not item or ":" not in item: + continue + key, value = item.split(":", 1) + if key == "thread": + reply.thread = value + elif key in ("watch", "rwatch", "awatch"): + reply.watch_kind = key + reply.watch_addr = _hex_value(value) + else: + try: + reply.regs[int(key, 16)] = _hex_value(value) + except ValueError: + continue + elif head in ("W", "X"): + if len(raw) >= 3: + try: + reply.exit_status = int(raw[1:3], 16) + except ValueError: + pass + return reply + + +# --------------------------------------------------------------------------- +# Register layout +# --------------------------------------------------------------------------- + + +@dataclass +class Register: + number: int + name: str + bitsize: int + offset: int = 0 + + @property + def nbytes(self) -> int: + return (self.bitsize + 7) // 8 + + +#: Fallback layout for RV64 when target.xml is unavailable: x0..x31 + pc. +RV64_FALLBACK_REGS = [ + Register(n, name, 64, n * 8) + for n, name in enumerate( + ["x0", "ra", "sp", "gp", "tp", "t0", "t1", "t2"] + + [f"s{i}" for i in range(0, 2)] + + [f"a{i}" for i in range(0, 8)] + + [f"s{i}" for i in range(2, 12)] + + [f"t{i}" for i in range(3, 7)] + + ["pc"] + ) +] + + +# --------------------------------------------------------------------------- +# Client +# --------------------------------------------------------------------------- + + +class RspClient: + """A blocking RSP client over TCP.""" + + def __init__(self, host: str = "127.0.0.1", port: int = 4444, timeout: float = 10.0): + self.host = host + self.port = port + self.timeout = timeout + self.sock: socket.socket | None = None + self.no_ack = False + self.packet_size = 0x1000 + self.registers: list[Register] = [] + self._by_name: dict[str, Register] = {} + self._by_number: dict[int, Register] = {} + self.features: str = "" + + # -- lifecycle --------------------------------------------------------- + + def connect(self, retries: int = 1, retry_delay: float = 0.1) -> None: + last: Exception | None = None + for _ in range(max(1, retries)): + try: + self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout) + self.sock.settimeout(self.timeout) + return + except OSError as exc: + last = exc + time.sleep(retry_delay) + raise RspTimeout(f"cannot connect to gdbstub {self.host}:{self.port}: {last}") + + def close(self) -> None: + if self.sock is not None: + try: + self.sock.close() + except OSError: + pass + self.sock = None + + @property + def connected(self) -> bool: + return self.sock is not None + + def __enter__(self) -> "RspClient": + if not self.connected: + self.connect() + return self + + def __exit__(self, *exc) -> None: + self.close() + + # -- low level I/O ----------------------------------------------------- + + def _send_raw(self, data: bytes) -> None: + if self.sock is None: + raise RspTimeout("not connected") + self.sock.sendall(data) + + def _read_exact(self, count: int, deadline: float) -> bytes: + if self.sock is None: + raise RspTimeout("not connected") + chunks = bytearray() + while len(chunks) < count: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RspTimeout(f"timeout waiting for {count} bytes") + self.sock.settimeout(remaining) + try: + part = self.sock.recv(count - len(chunks)) + except socket.timeout as exc: + raise RspTimeout(f"timeout waiting for {count} bytes") from exc + if not part: + raise RspProtocolError("gdbstub closed the connection") + chunks += part + return bytes(chunks) + + def _send_packet(self, payload: bytes) -> None: + packet = encode_packet(payload) + self._send_raw(packet) + if self.no_ack: + return + deadline = time.monotonic() + self.timeout + while True: + ack = self._read_exact(1, deadline) + if ack == b"+": + return + if ack == b"-": + self._send_raw(packet) + continue + # Some stubs answer with a stop packet instead of an ack when a + # run-control command stops immediately; push it back for the + # packet reader by buffering is complex, so treat as protocol + # error only if it is not a start-of-packet marker. + if ack == b"$": + raise RspProtocolError("unexpected packet while waiting for ack") + + def _read_packet(self, timeout: float | None = None) -> str: + eff = self.timeout if timeout is None else timeout + deadline = time.monotonic() + eff + # Skip stale acks and interrupt notifications until a packet starts. + while True: + byte = self._read_exact(1, deadline) + if byte == b"$": + break + if byte == b"+": + continue + if byte == b"\x03": + return "S02" # async interrupt notification + body = bytearray() + while True: + byte = self._read_exact(1, deadline) + if byte == b"#": + break + body += byte + got = self._read_exact(2, deadline) + try: + expected = int(got.decode("ascii"), 16) + except ValueError as exc: + raise RspProtocolError(f"malformed checksum bytes {got!r}") from exc + if checksum(bytes(body)) != expected: + if not self.no_ack: + self._send_raw(b"-") + raise RspProtocolError(f"bad checksum for {bytes(body)!r}") + if not self.no_ack: + self._send_raw(b"+") + return bytes(body).decode("latin1") + + def command(self, payload: str | bytes, timeout: float | None = None) -> str: + """Send one packet and return the reply payload.""" + data = payload.encode("ascii") if isinstance(payload, str) else payload + self._send_packet(data) + return self._read_packet(timeout) + + # -- handshake --------------------------------------------------------- + + def sync(self) -> None: + """Negotiate features and register layout; consume the initial stop.""" + # QEMU acknowledges QStartNoAckMode with an EMPTY packet ($#00), then + # switches to no-ack mode once the client acks that reply. + try: + self._send_packet(b"QStartNoAckMode") + reply = self._read_packet() + if reply in ("", "OK"): + self.no_ack = True + except RspProtocolError: + pass # older stub: stay in ack mode + + self.packet_size = 0x1000 + try: + self.features = self.command("qSupported:multiprocess+;swbreak+;hwbreak+;qXfer:features:read+") + for token in self.features.split(";"): + if token.startswith("PacketSize="): + try: + self.packet_size = int(token.split("=", 1)[1], 16) + except ValueError: + pass + except RspProtocolError: + self.features = "" + + # Consume the initial stop reply (target is paused). + try: + self.command("?") + except RspProtocolError: + pass + # Select CPU 0 for register access in system mode. + for selection in ("Hg0", "Hc-1"): + try: + self.command(selection) + except RspProtocolError: + pass + self._load_registers() + + def _load_registers(self) -> None: + self.registers = [] + try: + xml = self._read_target_xml() + except RspProtocolError: + xml = "" + if xml: + self.registers = _parse_target_xml(xml) + if not self.registers: + self.registers = list(RV64_FALLBACK_REGS) + self._by_name = {r.name: r for r in self.registers} + self._by_number = {r.number: r for r in self.registers} + + def _read_target_xml(self, depth: int = 0) -> str: + """Fetch target.xml and recursively inline its xi:include files.""" + return self._collect_annex("target.xml", depth) + + def _collect_annex(self, annex: str, depth: int) -> str: + import re + + try: + xml = self._read_xfer("features", annex) + except RspProtocolError: + return "" + if depth >= 4: + return xml + parts = [xml] + for href in re.findall(r'xi:include[^>]*href="([^"]+)"', xml): + if href.startswith(("http:", "https:", "file:")): + continue + child = self._collect_annex(href, depth + 1) + if child: + parts.append(child) + return "\n".join(parts) + + def _read_xfer(self, kind: str, annex: str) -> str: + data = bytearray() + offset = 0 + while True: + reply = self.command(f"qXfer:{kind}:read:{annex}:{offset:x},fff") + if reply.startswith("E"): + raise RspProtocolError(f"qXfer {kind}:{annex} failed: {reply}") + if not reply or reply[0] not in ("l", "m"): + raise RspProtocolError(f"qXfer {kind}:{annex} malformed: {reply[:16]!r}") + chunk = reply[1:].encode("latin1") + data += chunk + offset += len(chunk) + if reply[0] == "l": + break + return data.decode("latin1", "replace") + + # -- registers --------------------------------------------------------- + + def read_registers(self) -> dict[str, int]: + payload = self.command("g") + raw = _unhex(payload) + out: dict[str, int] = {} + for reg in self.registers: + start = reg.offset + chunk = raw[start : start + reg.nbytes] + if len(chunk) < reg.nbytes: + break + out[reg.name] = int.from_bytes(chunk, "little", signed=False) + return out + + def read_reg(self, name_or_number: str | int) -> int: + reg = self._reg(name_or_number) + reply = self.command(f"p{reg.number:x}") + if reply.startswith("E"): + raise RspProtocolError(f"read register {reg.name} failed: {reply}") + return int.from_bytes(_unhex(reply), "little", signed=False) + + def write_reg(self, name_or_number: str | int, value: int) -> None: + reg = self._reg(name_or_number) + payload = value.to_bytes(reg.nbytes, "little", signed=False).hex() + reply = self.command(f"P{reg.number:x}={payload}") + if reply != "OK": + raise RspProtocolError(f"write register {reg.name} failed: {reply}") + + def _reg(self, name_or_number: str | int) -> Register: + if isinstance(name_or_number, int): + if name_or_number in self._by_number: + return self._by_number[name_or_number] + # allow ABI-numbered convenience: 0..31 map to x0..x31 + if name_or_number < len(self.registers): + return self.registers[name_or_number] + raise RspProtocolError(f"unknown register number {name_or_number}") + if name_or_number in self._by_name: + return self._by_name[name_or_number] + aliases = {"pc": "pc", "a0": "a0", "return": "ra"} + name_or_number = aliases.get(name_or_number, name_or_number) + if name_or_number in self._by_name: + return self._by_name[name_or_number] + raise RspProtocolError(f"unknown register {name_or_number!r}") + + @property + def pc(self) -> int: + return self.read_reg("pc") + + # -- memory ------------------------------------------------------------ + + def _mem_chunk(self) -> int: + # Each data byte costs two hex chars; leave room for the header. + return max(64, (self.packet_size - 128) // 2) + + def read_mem(self, addr: int, size: int, chunk: int = 0) -> bytes: + if chunk <= 0: + chunk = self._mem_chunk() + out = bytearray() + while len(out) < size: + want = min(chunk, size - len(out)) + here = addr + len(out) + reply = self.command(f"m{here:x},{want:x}") + if reply.startswith("E"): + raise RspProtocolError(f"memory read {here:#x}+{want:#x} failed: {reply}") + data = _unhex(reply) + if not data: + raise RspProtocolError(f"short memory read at {here:#x}") + out += data + return bytes(out) + + def write_mem(self, addr: int, data: bytes, chunk: int = 0) -> None: + if chunk <= 0: + chunk = self._mem_chunk() + view = memoryview(data) + offset = 0 + while offset < len(view): + part = bytes(view[offset : offset + chunk]) + here = addr + offset + reply = self.command(f"M{here:x},{len(part):x}:{part.hex()}") + if reply != "OK": + raise RspProtocolError(f"memory write {here:#x} failed: {reply}") + offset += len(part) + + def read_u32(self, addr: int) -> int: + return int.from_bytes(self.read_mem(addr, 4), "little") + + def read_u64(self, addr: int) -> int: + return int.from_bytes(self.read_mem(addr, 8), "little") + + def write_u32(self, addr: int, value: int) -> None: + self.write_mem(addr, value.to_bytes(4, "little")) + + def write_u64(self, addr: int, value: int) -> None: + self.write_mem(addr, value.to_bytes(8, "little")) + + def read_cstring(self, addr: int, max_len: int = 256) -> str: + raw = self.read_mem(addr, max_len) + return raw.split(b"\x00", 1)[0].decode("utf-8", "replace") + + # -- breakpoints / watchpoints ---------------------------------------- + + def set_sw_break(self, addr: int, kind: int = 4) -> None: + reply = self.command(f"Z0,{addr:x},{kind:x}") + if reply != "OK": + raise RspProtocolError(f"set software breakpoint {addr:#x} failed: {reply}") + + def clear_sw_break(self, addr: int, kind: int = 4) -> None: + reply = self.command(f"z0,{addr:x},{kind:x}") + if reply != "OK": + raise RspProtocolError(f"clear software breakpoint {addr:#x} failed: {reply}") + + def set_hw_break(self, addr: int, kind: int = 4) -> None: + reply = self.command(f"Z1,{addr:x},{kind:x}") + if reply != "OK": + raise RspProtocolError(f"set hardware breakpoint {addr:#x} failed: {reply}") + + def clear_hw_break(self, addr: int, kind: int = 4) -> None: + reply = self.command(f"z1,{addr:x},{kind:x}") + if reply != "OK": + raise RspProtocolError(f"clear hardware breakpoint {addr:#x} failed: {reply}") + + _WATCH_KINDS = {"w": 2, "r": 3, "a": 4} + + def set_watch(self, addr: int, size: int = 8, kind: str = "w") -> None: + code = self._WATCH_KINDS[kind] + reply = self.command(f"Z{code},{addr:x},{size:x}") + if reply != "OK": + raise RspProtocolError(f"set watchpoint {kind}@{addr:#x} failed: {reply}") + + def clear_watch(self, addr: int, size: int = 8, kind: str = "w") -> None: + code = self._WATCH_KINDS[kind] + reply = self.command(f"z{code},{addr:x},{size:x}") + if reply != "OK": + raise RspProtocolError(f"clear watchpoint {kind}@{addr:#x} failed: {reply}") + + # -- run control ------------------------------------------------------- + + def continue_(self, timeout: float | None = None) -> StopReply: + self._send_packet(b"c") + raw = self._read_packet(timeout) + return parse_stop_reply(raw) + + def step(self, timeout: float | None = None) -> StopReply: + self._send_packet(b"s") + raw = self._read_packet(timeout) + return parse_stop_reply(raw) + + def interrupt(self, timeout: float | None = None) -> StopReply: + self._send_raw(b"\x03") + raw = self._read_packet(timeout) + return parse_stop_reply(raw) + + # -- monitor ----------------------------------------------------------- + + def monitor(self, cmd: str) -> str: + payload = ("qRcmd," + cmd.encode("ascii").hex()).encode("ascii") + self._send_packet(payload) + out = bytearray() + while True: + reply = self._read_packet() + if reply == "OK": + break + if reply.startswith("E"): + raise RspProtocolError(f"monitor {cmd!r} failed: {reply}") + if reply.startswith("O"): + out += bytes.fromhex(reply[1:]) + else: # pragma: no cover - defensive + raise RspProtocolError(f"unexpected monitor reply: {reply[:32]!r}") + return out.decode("utf-8", "replace") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _unhex(payload: str) -> bytes: + if len(payload) % 2: + payload = payload[:-1] # tolerate odd trailing nibble + try: + return bytes.fromhex(payload) + except ValueError as exc: + raise RspProtocolError(f"malformed hex payload: {payload[:32]!r}") from exc + + +_RE_REG = None + + +def _parse_target_xml(xml: str) -> list[Register]: + import re + + global _RE_REG + if _RE_REG is None: + _RE_REG = re.compile(r"<reg\b[^>]*?/?>", re.IGNORECASE) + attrs_re = re.compile(r'(\w+)\s*=\s*"([^"]*)"') + regs: list[Register] = [] + next_number = 0 + offset = 0 + for match in _RE_REG.finditer(xml): + attrs = {k: v for k, v in attrs_re.findall(match.group(0))} + name = attrs.get("name") + if not name: + continue + bitsize = int(attrs.get("bitsize", "64")) + number = int(attrs["regnum"], 0) if "regnum" in attrs else next_number + if "offset" in attrs: + try: + offset = int(attrs["offset"], 0) + except ValueError: + pass + regs.append(Register(number=number, name=name, bitsize=bitsize, offset=offset)) + offset += (bitsize + 7) // 8 + next_number = number + 1 + return regs diff --git a/debug/risc-v-gsp-rm-vm/greeneye/session.py b/debug/risc-v-gsp-rm-vm/greeneye/session.py new file mode 100644 index 0000000..7ad6444 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/session.py @@ -0,0 +1,562 @@ +"""green-eye instrumentation session: hooks, breakpoints, watchpoints, events. + +This is the Frida-like core. It is deliberately transport-agnostic; the RSP +client is the default backend, and a Session can also be constructed around a +plugin-supplied backend later. +""" +from __future__ import annotations + +import time +from dataclasses import dataclass, field as _field +from typing import Callable + +from .errors import GreenEyeError, RspTimeout, SymbolNotFound +from .memory import Memory +from .record import NullRecorder, Recorder +from .rsp import RspClient, StopReply +from .symbols import Symbol, SymbolDB + + +@dataclass +class Hit: + """Everything known about one stop event.""" + + kind: str # breakpoint | leave | watchpoint | step | fault | exit | stop + addr: int + regs: dict = _field(default_factory=dict) + symbol: Symbol | None = None + retval: int | None = None + watch: "Watchpoint | None" = None + stop: StopReply | None = None + session: "Session | None" = None + data: dict = _field(default_factory=dict) + + @property + def pc(self) -> int: + return self.regs.get("pc", self.addr) + + def arg(self, index: int) -> int: + """RISC-V ABI argument a0..a7 -> x10..x17.""" + return self.regs.get(f"a{index}", 0) + + def label(self) -> str: + if self.symbol is not None: + return self.symbol.label(self.addr) + return f"{self.addr:#x}" + + def __str__(self) -> str: # pragma: no cover - cosmetic + base = f"[{self.kind}] {self.label()}" + if self.kind in ("breakpoint", "leave"): + base += f" a0={self.arg(0):#x} a1={self.arg(1):#x}" + if self.kind == "leave" and self.retval is not None: + base += f" -> {self.retval:#x}" + if self.kind == "watchpoint" and self.watch is not None: + base += f" {self.watch.kind}@{self.watch.addr:#x}" + return base + + +@dataclass +class Breakpoint: + addr: int + kind: str = "sw" # sw | hw + temporary: bool = False + purpose: str = "user" # user | leave + on_enter: Callable[[Hit], None] | None = None + on_leave: Callable[[Hit], None] | None = None + symbol: Symbol | None = None + attached: bool = True + hits: int = 0 + listener: str | None = None + + @property + def label(self) -> str: + return self.symbol.label(self.addr) if self.symbol else f"{self.addr:#x}" + + +@dataclass +class Watchpoint: + addr: int + size: int = 8 + kind: str = "w" # w | r | a + callback: Callable[[Hit], None] | None = None + symbol: Symbol | None = None + hits: int = 0 + listener: str | None = None + + @property + def label(self) -> str: + return self.symbol.label(self.addr) if self.symbol else f"{self.addr:#x}" + + +class HookHandle: + def __init__(self, session: "Session", breakpoint: Breakpoint): + self._session = session + self._bp = breakpoint + + @property + def addr(self) -> int: + return self._bp.addr + + @property + def hits(self) -> int: + return self._bp.hits + + @property + def label(self) -> str: + return self._bp.label + + def detach(self) -> None: + self._session.remove_breakpoint(self._bp) + + +class Session: + def __init__( + self, + *, + client: RspClient, + target=None, + symbols: SymbolDB | None = None, + record: str | bool | None = None, + name: str = "session", + meta: dict | None = None, + ): + self.client = client + self.target = target + self.symbols = symbols or SymbolDB() + self.name = name + self.image = None + self._owns_target = target is not None + if isinstance(record, str): + self.recorder = Recorder(record, name=name, meta=meta or {}) + elif record: + self.recorder = Recorder.new(name=name, meta=meta or {}) + else: + self.recorder = NullRecorder() + self.memory = Memory(client, symbols=self.symbols, recorder=self.recorder) + self._bps: dict[int, Breakpoint] = {} + self._bp_seq = 0 + self._bps_by_addr: dict[int, list[Breakpoint]] = {} + self._watches: dict[tuple[int, int, str], Watchpoint] = {} + self._watches_by_addr: dict[int, Watchpoint] = {} + self._leave_pending: dict[int, list[Breakpoint]] = {} + self._stopped = True + self._event_hooks: list[tuple[str, Callable[[Hit], None]]] = [] + self.recorder.event("session_start", name=name) + + # -- convenience proxies ---------------------------------------------- + + def load_image( + self, + image, + *, + write_segments: bool = False, + set_pc: bool = False, + bias: int = 0, + load_symbols: bool = True, + source: str = "elf", + ): + """Register a firmware image with the session. + + ``write_segments`` copies every PT_LOAD through the RSP client (for + targets started without their loader devices); ``set_pc`` points the + CPU at the (relocated) entry. With QEMU's ``-kernel`` fast path both + are already handled by QEMU, so the default only loads symbols. + """ + self.image = image + if load_symbols: + self.symbols.load_elf(image, bias=bias, source=source) + if write_segments: + for addr, data in image.flat_load_plan(bias=bias): + self.client.write_mem(addr, data) + if set_pc: + self.client.write_reg("pc", image.relocated_entry(bias=bias)) + self.recorder.event( + "image_loaded", + path=getattr(image, "path", None), + entry=image.entry, + bias=bias, + write_segments=write_segments, + ) + return image + + @property + def pc(self) -> int: + return self.client.pc + + def read_regs(self) -> dict[str, int]: + return self.client.read_registers() + + def snapshot(self, addr: int, size: int, name: str = "region") -> bytes: + return self.memory.snapshot(addr, size, name=name) + + def note(self, text: str, **fields) -> None: + self.recorder.event("note", text=text, **fields) + + # -- symbol helpers ---------------------------------------------------- + + def resolve(self, target: int | str) -> int: + if isinstance(target, int): + return target + if isinstance(target, str): + if target.lower().startswith("0x"): + return int(target, 16) + sym = self.symbols.get(target) + if sym is None: + raise SymbolNotFound(f"symbol {target!r} not found in symbol DB") + return sym.addr + raise GreenEyeError(f"cannot resolve target {target!r}") + + def symbol_at(self, addr: int) -> Symbol | None: + return self.symbols.resolve(addr) + + # -- breakpoints / hooks ---------------------------------------------- + + def hook( + self, + target: int | str, + on_enter: Callable[[Hit], None] | None = None, + on_leave: Callable[[Hit], None] | None = None, + *, + kind: str = "sw", + temporary: bool = False, + symbol: Symbol | None = None, + listener: str | None = None, + ) -> HookHandle: + addr = self.resolve(target) + sym = symbol or self.symbols.resolve(addr) + bp = Breakpoint( + addr=addr, + kind=kind, + temporary=temporary, + on_enter=on_enter, + on_leave=on_leave, + symbol=sym, + listener=listener, + ) + self.add_breakpoint(bp) + self.recorder.event( + "hook", + addr=addr, + symbol=sym.name if sym else None, + kind=kind, + on_leave=bool(on_leave), + listener=listener, + ) + return HookHandle(self, bp) + + def breakpoint(self, target: int | str, callback: Callable[[Hit], None] | None = None, **kw) -> Breakpoint: + addr = self.resolve(target) + bp = Breakpoint(addr=addr, on_enter=callback, symbol=self.symbols.resolve(addr), **kw) + self.add_breakpoint(bp) + return bp + + def add_breakpoint(self, bp: Breakpoint) -> None: + self._bp_seq += 1 + self._bps[self._bp_seq] = bp + self._bps_by_addr.setdefault(bp.addr, []).append(bp) + if bp.kind == "hw": + self.client.set_hw_break(bp.addr) + else: + self.client.set_sw_break(bp.addr) + + def remove_breakpoint(self, bp: Breakpoint) -> None: + for key, candidate in list(self._bps.items()): + if candidate is bp: + del self._bps[key] + lst = self._bps_by_addr.get(bp.addr, []) + if bp in lst: + lst.remove(bp) + if not lst: + self._bps_by_addr.pop(bp.addr, None) + self._leave_pending.pop(bp.addr, None) + try: + if bp.kind == "hw": + self.client.clear_hw_break(bp.addr) + else: + self.client.clear_sw_break(bp.addr) + except Exception: + pass + bp.attached = False + + def on_event(self, kind: str, callback: Callable[[Hit], None]) -> None: + self._event_hooks.append((kind, callback)) + + # -- watchpoints ------------------------------------------------------- + + def watch( + self, + target: int | str, + size: int = 8, + kind: str = "w", + callback: Callable[[Hit], None] | None = None, + listener: str | None = None, + ) -> Watchpoint: + addr = self.resolve(target) + wp = Watchpoint( + addr=addr, + size=size, + kind=kind, + callback=callback, + symbol=self.symbols.resolve(addr), + listener=listener, + ) + key = (addr, size, kind) + if key in self._watches: + raise GreenEyeError(f"watchpoint {kind}@{addr:#x}/{size} already exists") + self._watches[key] = wp + self._watches_by_addr[addr] = wp + self.client.set_watch(addr, size=size, kind=kind) + self.recorder.event( + "watch", + addr=addr, + size=size, + kind=kind, + symbol=wp.symbol.name if wp.symbol else None, + listener=listener, + ) + return wp + + def unwatch(self, wp: Watchpoint) -> None: + key = (wp.addr, wp.size, wp.kind) + self._watches.pop(key, None) + self._watches_by_addr.pop(wp.addr, None) + try: + self.client.clear_watch(wp.addr, size=wp.size, kind=wp.kind) + except Exception: + pass + + # -- execution --------------------------------------------------------- + + def step(self, count: int = 1) -> list[Hit]: + hits: list[Hit] = [] + for _ in range(count): + stop = self.client.step() + hit = self._dispatch_stop(stop, from_step=True) + if hit is not None: + hits.append(hit) + if stop.is_exit: + break + return hits + + def run( + self, + duration: float | None = None, + max_stops: int | None = None, + timeout: float | None = None, + ) -> int: + """Resume and service events until the deadline/budget/exit. + + ``duration`` (or ``timeout`` when duration is None) is a wall-clock + budget: when it expires the target is interrupted once, the resulting + stop is dispatched, and the call returns. Returns the number of stops + serviced. + """ + limit = duration if duration is not None else timeout + deadline = time.monotonic() + limit if limit is not None else None + stops = 0 + while True: + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + break # budget spent while stopped: leave the target stopped + remaining = max(0.001, remaining) + else: + remaining = None + interrupted = False + try: + stop = self.client.continue_(timeout=remaining) + except RspTimeout: + if deadline is None: + raise + stop = self.client.interrupt() + interrupted = True + stops += 1 + self._dispatch_stop(stop) + if stop.is_exit: + break + if max_stops is not None and stops >= max_stops: + break + if interrupted or (deadline is not None and time.monotonic() >= deadline): + break + return stops + + def stop(self) -> Hit | None: + stop = self.client.interrupt() + return self._dispatch_stop(stop) + + # -- stop dispatch ----------------------------------------------------- + + def _dispatch_stop(self, stop: StopReply, from_step: bool = False) -> Hit | None: + if stop.is_exit: + hit = Hit(kind="exit", addr=0, stop=stop, session=self) + self.recorder.event("exit", status=stop.exit_status) + self._fire_event_hooks(hit) + return hit + + regs = self.client.read_registers() + pc = regs.get("pc", 0) + sym = self.symbols.resolve(pc) + + if stop.watch_addr is not None: + wp = self._watches_by_addr.get(stop.watch_addr) + hit = Hit( + kind="watchpoint", + addr=stop.watch_addr, + regs=regs, + symbol=self.symbols.resolve(stop.watch_addr), + watch=wp, + stop=stop, + session=self, + ) + if wp is not None: + wp.hits += 1 + if wp.callback is not None: + wp.callback(hit) + self.recorder.event( + "watch_hit", + addr=stop.watch_addr, + kind=wp.kind if wp else stop.watch_kind, + size=wp.size if wp else None, + symbol=hit.symbol.name if hit.symbol else None, + pc=pc, + regs=_json_regs(regs), + ) + self._fire_event_hooks(hit) + # QEMU stops *at* the instruction that performed the watched + # access. Step it once with the watchpoint temporarily cleared so + # resuming does not re-trigger on the same store forever. + try: + if wp is not None: + self.client.clear_watch(wp.addr, size=wp.size, kind=wp.kind) + self.client.step() + except RspTimeout: + pass + finally: + if wp is not None: + try: + self.client.set_watch(wp.addr, size=wp.size, kind=wp.kind) + except Exception: + pass + return hit + + hit = Hit( + kind="step" if from_step else "stop", + addr=pc, + regs=regs, + symbol=sym, + stop=stop, + session=self, + ) + + bps = list(self._bps_by_addr.get(pc, [])) + leave_queue = self._leave_pending.get(pc, []) + if not bps and not leave_queue: + self.recorder.event( + "stop", + pc=pc, + symbol=sym.name if sym else None, + signal=stop.signal, + reason="step" if from_step else "halt", + ) + self._fire_event_hooks(hit) + return hit + + hit.kind = "breakpoint" + self.recorder.event( + "bp_hit", + pc=pc, + addr=pc, + symbol=sym.name if sym else None, + signal=stop.signal, + regs=_json_regs(regs), + ) + for bp in bps: + bp.hits += 1 + if bp.on_enter is not None: + bp.on_enter(hit) + if bp.on_leave is not None and not bp.temporary: + self._arm_leave(regs.get("ra", 0), bp) + if bp.temporary: + self.remove_breakpoint(bp) + + # Return hooks: pop one pending hook for this return address. + if leave_queue: + bp = leave_queue.pop() + if not leave_queue: + self._leave_pending.pop(pc, None) + for candidate in list(self._bps_by_addr.get(pc, [])): + if candidate.purpose == "leave": + self.remove_breakpoint(candidate) + hit.kind = "leave" + hit.retval = regs.get("a0", 0) + if bp.symbol is not None: + hit.symbol = bp.symbol + hit.addr = bp.addr + if bp.on_leave is not None: + bp.on_leave(hit) + + # Software breakpoints need a single-step over them before resuming. + self._step_over(pc) + self._fire_event_hooks(hit) + return hit + + def _arm_leave(self, retaddr: int, bp: Breakpoint) -> None: + if not retaddr: + return + queue = self._leave_pending.get(retaddr) + if queue is None: + queue = [] + self._leave_pending[retaddr] = queue + temp = Breakpoint( + addr=retaddr, + kind="sw", + temporary=True, + purpose="leave", + symbol=self.symbols.resolve(retaddr), + ) + self.add_breakpoint(temp) + queue.append(bp) + + def _step_over(self, pc: int) -> None: + sw = [bp for bp in self._bps_by_addr.get(pc, []) if bp.kind == "sw"] + if not sw: + return + for bp in sw: + self.client.clear_sw_break(pc) + self.client.step() + for bp in sw: + if bp.attached: + self.client.set_sw_break(pc) + + def _fire_event_hooks(self, hit: Hit) -> None: + for kind, callback in self._event_hooks: + if kind in (hit.kind, "*"): + callback(hit) + + # -- lifecycle --------------------------------------------------------- + + def close(self, kill_target: bool | None = None) -> None: + try: + for bp in list(self._bps.values()): + self.remove_breakpoint(bp) + for wp in list(self._watches.values()): + self.unwatch(wp) + except Exception: + pass + self.client.close() + if kill_target is None: + kill_target = self._owns_target + if kill_target and self.target is not None and not getattr(self.target, "keep_alive", False): + self.target.kill() + self.recorder.close() + self._owns_target = False + + def __enter__(self) -> "Session": + return self + + def __exit__(self, *exc) -> None: + self.close() + + +def _json_regs(regs: dict[str, int]) -> dict[str, int]: + return {name: value for name, value in regs.items() if isinstance(value, int)} 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) diff --git a/debug/risc-v-gsp-rm-vm/greeneye/symbols.py b/debug/risc-v-gsp-rm-vm/greeneye/symbols.py new file mode 100644 index 0000000..7b2e9dd --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/symbols.py @@ -0,0 +1,313 @@ +"""Symbol resolution and address translation for green-eye. + +Inputs supported (all optional, merged into one database): + +* ELF ``.symtab`` symbols (demo firmware); +* the project's Ghidra exports: + - ``names.tsv`` ``<file_vaddr>\t<name>[\t<source>]`` (comments with #), + - ``decompiled_index.tsv`` / bucket TSVs + ``<program_addr>\t<file_addr>\t<size>\t<name>\t<bucket>\t<line>\t<status>``, + - ``exports.tsv`` ``addr\tfile_addr\tsize\tname``, +* green-eye native JSON (``greeneye-symbols.json``). + +Address conventions (matching the project docs): + * Ghidra program address = file vaddr + image base (``0x100000`` for libcuda); + * runtime address = file vaddr + runtime load bias; + * for firmware images linked at their load address the bias is 0. + +``SymbolDB`` stores runtime addresses by default; each loader takes a ``bias`` +so a relocated firmware or a different image base can be normalised. +""" +from __future__ import annotations + +import bisect +import json +import os +from dataclasses import dataclass, field as _field + +from .errors import SymbolNotFound + + +@dataclass +class Symbol: + addr: int + name: str + size: int = 0 + source: str = "" + subsystem: str | None = None + extras: dict = _field(default_factory=dict) + + @property + def end(self) -> int: + return self.addr + self.size if self.size else self.addr + + def contains(self, addr: int) -> bool: + if self.size: + return self.addr <= addr < self.addr + self.size + return addr == self.addr + + def label(self, addr: int | None = None) -> str: + if addr is None or addr == self.addr or not self.size: + return self.name + return f"{self.name}+{addr - self.addr:#x}" + + def to_json(self) -> dict: + out = {"addr": self.addr, "name": self.name} + if self.size: + out["size"] = self.size + if self.source: + out["source"] = self.source + if self.subsystem: + out["subsystem"] = self.subsystem + if self.extras: + out.update(self.extras) + return out + + +class SymbolDB: + def __init__(self) -> None: + self._symbols: list[Symbol] = [] + self._sorted = False + self._by_name: dict[str, Symbol] = {} + self._addr_index: list[int] = [] + + # -- loading ----------------------------------------------------------- + + def add(self, symbol: Symbol) -> None: + self._symbols.append(symbol) + self._sorted = False + existing = self._by_name.get(symbol.name) + if existing is None or (symbol.source and not existing.source): + self._by_name[symbol.name] = symbol + + def load_elf(self, image, bias: int = 0, source: str = "elf", only_code: bool = True) -> int: + """Load ``.symtab`` symbols from a FirmwareImage/ELF path.""" + from .elf import ElfSymbol, load_elf + + if isinstance(image, str): + image = load_elf(image) + count = 0 + for sym in image.symbols: # type: ignore[union-attr] + if only_code and not (sym.is_function or sym.is_object): + continue + self.add( + Symbol( + addr=sym.value + bias, + name=sym.name, + size=sym.size, + source=source, + extras={"type": "func" if sym.is_function else "object"}, + ) + ) + count += 1 + return count + + def load_names_tsv(self, path: str, bias: int = 0) -> int: + """Load the project's Ghidra ``names.tsv`` (file vaddrs).""" + count = 0 + with open(path, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + line = line.rstrip("\n") + if not line or line.startswith("#"): + continue + cols = line.split("\t") + if len(cols) < 2: + continue + try: + addr = int(cols[0], 16) + except ValueError: + continue + self.add( + Symbol(addr=addr + bias, name=cols[1], source=cols[2] if len(cols) > 2 else "ghidra-names") + ) + count += 1 + return count + + def load_index_tsv(self, path: str, bias: int = 0) -> int: + """Load ``decompiled_index.tsv``/bucket TSVs/exports.tsv heuristically.""" + count = 0 + with open(path, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + line = line.rstrip("\n") + if not line or line.startswith("#"): + continue + cols = line.split("\t") + if cols[0] in ("addr", "caller"): + continue + if len(cols) >= 7: + program, file_addr, size, name, bucket, lineno, status = cols[:7] + addr = _parse_hex(file_addr) + extras = {"program_addr": _parse_hex(program), "bucket": bucket, "line": lineno, "status": status} + elif len(cols) >= 4: + program, file_addr, size, name = cols[:4] + addr = _parse_hex(file_addr) + extras = {"program_addr": _parse_hex(program)} + elif len(cols) >= 2: + addr = _parse_hex(cols[0]) + name = cols[1] + size = "0" + extras = {} + else: + continue + if addr is None or not name: + continue + try: + size_val = int(size, 0) + except ValueError: + size_val = 0 + self.add( + Symbol(addr=addr + bias, name=name, size=size_val, source="ghidra-index", extras=extras) + ) + count += 1 + return count + + def load_callgraph(self, path: str) -> int: + """Attach caller/callee lists from ``callgraph.tsv`` (program addrs).""" + by_program: dict[int, Symbol] = {} + for sym in self._symbols: + prog = sym.extras.get("program_addr") + if prog is not None: + by_program[int(prog)] = sym + if not by_program: + return 0 + for sym in self._symbols: + sym.extras.setdefault("callers", []) + sym.extras.setdefault("callees", []) + count = 0 + with open(path, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + line = line.rstrip("\n") + if not line or line.startswith("#") or line.startswith("caller"): + continue + cols = line.split("\t") + if len(cols) < 2: + continue + caller, callee = _parse_hex(cols[0]), _parse_hex(cols[1]) + if caller is None or callee is None: + continue + if caller in by_program: + by_program[caller].extras["callees"].append(callee) + count += 1 + if callee in by_program: + by_program[callee].extras["callers"].append(caller) + return count + + def load_json(self, path: str) -> int: + with open(path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + symbols = payload.get("symbols", payload if isinstance(payload, list) else []) + count = 0 + for item in symbols: + try: + addr = int(item["addr"], 0) if isinstance(item["addr"], str) else int(item["addr"]) + except (KeyError, TypeError, ValueError): + continue + extras = {k: v for k, v in item.items() if k not in ("addr", "name", "size", "source", "subsystem")} + self.add( + Symbol( + addr=addr, + name=item.get("name", f"sub_{addr:x}"), + size=int(item.get("size", 0)), + source=item.get("source", "json"), + subsystem=item.get("subsystem"), + extras=extras, + ) + ) + count += 1 + return count + + def save_json(self, path: str, meta: dict | None = None) -> None: + self._ensure_sorted() + payload = { + "format": "greeneye-symbols/1", + "count": len(self._symbols), + "meta": meta or {}, + "symbols": [s.to_json() for s in self._symbols], + } + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=1, sort_keys=True) + + # -- queries ----------------------------------------------------------- + + def _ensure_sorted(self) -> None: + if self._sorted: + return + self._symbols.sort(key=lambda s: (s.addr, -s.size)) + self._addr_index = [s.addr for s in self._symbols] + self._sorted = True + + def __len__(self) -> int: + return len(self._symbols) + + def __iter__(self): + return iter(self._symbols) + + def get(self, name: str) -> Symbol | None: + return self._by_name.get(name) + + def lookup(self, name: str) -> int: + sym = self._by_name.get(name) + if sym is None: + raise SymbolNotFound(f"symbol {name!r} not found") + return sym.addr + + def resolve(self, addr: int, max_delta: int = 0x10000) -> Symbol | None: + """Resolve an address to a containing or nearest preceding symbol.""" + self._ensure_sorted() + if not self._symbols: + return None + pos = bisect.bisect_right(self._addr_index, addr) - 1 + if pos < 0: + return None + candidate = self._symbols[pos] + if candidate.contains(addr): + return candidate + if candidate.size and addr < candidate.addr + candidate.size: + return candidate + # nearest-within-delta fallback for zero-size symbols (Ghidra names) + if not candidate.size and 0 <= addr - candidate.addr <= max_delta: + return candidate + return None + + def annotate(self, addr: int, max_delta: int = 0x10000) -> str: + sym = self.resolve(addr, max_delta=max_delta) + if sym is None: + return f"{addr:#x} <unknown>" + return f"{addr:#x} {sym.label(addr)}" + + def nearest(self, addr: int, max_delta: int = 0x10000) -> Symbol | None: + return self.resolve(addr, max_delta=max_delta) + + def names(self) -> list[str]: + return sorted(self._by_name) + + def by_subsystem(self, subsystem: str) -> list[Symbol]: + return [s for s in self._symbols if s.subsystem == subsystem] + + +def _parse_hex(value: str) -> int | None: + value = value.strip() + if not value: + return None + try: + return int(value, 16) + except ValueError: + try: + return int(value, 0) + except ValueError: + return None + + +def translation_for( + *, + file_vaddr: int, + program_addr: int | None = None, + runtime_addr: int | None = None, +) -> dict: + """Explain the three address spaces for a debug session (docs aid).""" + out = {"file_vaddr": file_vaddr} + if program_addr is not None: + out["image_base"] = program_addr - file_vaddr + if runtime_addr is not None: + out["load_bias"] = runtime_addr - file_vaddr + return out diff --git a/debug/risc-v-gsp-rm-vm/greeneye/target.py b/debug/risc-v-gsp-rm-vm/greeneye/target.py new file mode 100644 index 0000000..efcb1d1 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/target.py @@ -0,0 +1,223 @@ +"""QEMU target lifecycle for green-eye. + +Owns the ``qemu-system-riscv64`` process, the gdbstub endpoint, the serial +capture and the QEMU trace log. Both firmware paths are supported: + +* ``kernel=`` fast path - QEMU loads an ELF linked for RAM and sets the PC; +* ``segments=`` probe path - each PT_LOAD segment is placed with a loader + device (raw), so a firmware linked for GPU physical addresses can be + relocated into RAM and observed. +""" +from __future__ import annotations + +import os +import shutil +import signal +import socket +import subprocess +import tempfile +import time + +from .errors import TargetError + +DEFAULT_QEMU = "qemu-system-riscv64" + + +def find_qemu() -> str: + env = os.environ.get("GREENEYE_QEMU") + if env: + return env + found = shutil.which(DEFAULT_QEMU) + if not found: + raise TargetError( + f"{DEFAULT_QEMU} not found. Install it (Ubuntu: qemu-system-misc) " + "or set GREENEYE_QEMU to the binary." + ) + return found + + +def free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +class QemuTarget: + def __init__( + self, + *, + machine: str = "virt", + memory_mb: int = 256, + gdb_port: int = 0, + bios: str = "none", + extra_args: list[str] | None = None, + qemu: str | None = None, + kernel: str | None = None, + segments: list[tuple[int, bytes]] | None = None, + dlog: str | None = None, + uart_log: str | None = None, + qemu_log: str | None = None, + workdir: str | None = None, + keep_alive: bool = False, + ): + self.qemu = qemu or find_qemu() + self.machine = machine + self.memory_mb = memory_mb + self.host = "127.0.0.1" + self.port = gdb_port or free_port() + self.bios = bios + self.extra_args = list(extra_args or []) + self.kernel = os.path.abspath(kernel) if kernel else None + self.segments = list(segments or []) + self.dlog = dlog + self.workdir = workdir or tempfile.mkdtemp(prefix="greeneye-qemu-") + os.makedirs(self.workdir, exist_ok=True) + self.uart_log = uart_log or os.path.join(self.workdir, "uart.log") + self.qemu_log = qemu_log or os.path.join(self.workdir, "qemu.log") + self.keep_alive = keep_alive + self.process: subprocess.Popen | None = None + self._segment_files: list[str] = [] + self._cmd: list[str] = [] + + # -- lifecycle --------------------------------------------------------- + + def _build_command(self, paused: bool) -> list[str]: + cmd = [ + self.qemu, + "-machine", + self.machine, + "-m", + f"{self.memory_mb}M", + "-display", + "none", + "-monitor", + "none", + "-serial", + f"file:{self.uart_log}", + "-gdb", + f"tcp:{self.host}:{self.port}", + ] + if self.bios: + cmd += ["-bios", self.bios] + if paused: + cmd += ["-S"] + if self.kernel: + cmd += ["-kernel", self.kernel] + for index, (addr, blob) in enumerate(self.segments): + seg_path = os.path.join(self.workdir, f"segment-{index:02d}-{addr:#x}.bin") + with open(seg_path, "wb") as fh: + fh.write(blob) + self._segment_files.append(seg_path) + cmd += ["-device", f"loader,file={seg_path},addr={addr:#x},force-raw=on"] + if self.dlog: + cmd += ["-d", self.dlog, "-D", self.qemu_log] + cmd += self.extra_args + return cmd + + def start(self, paused: bool = True, timeout: float = 15.0) -> "QemuTarget": + self._cmd = self._build_command(paused) + try: + self.process = subprocess.Popen( + self._cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + cwd=self.workdir, + start_new_session=True, + ) + except OSError as exc: + raise TargetError(f"cannot start QEMU: {exc}") from exc + try: + self.wait_for_port(timeout=timeout) + except TargetError: + self.kill() + raise + return self + + def wait_for_port(self, timeout: float = 15.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if self.process is not None and self.process.poll() is not None: + stderr = b"" + if self.process.stderr is not None: + try: + stderr = self.process.stderr.read() or b"" + except OSError: + pass + raise TargetError( + f"QEMU exited early rc={self.process.returncode}: " + f"{stderr.decode('utf-8', 'replace')[-2000:]}" + ) + try: + with socket.create_connection((self.host, self.port), timeout=0.25): + return + except OSError: + time.sleep(0.05) + raise TargetError(f"gdbstub port {self.port} not ready after {timeout}s") + + def alive(self) -> bool: + return self.process is not None and self.process.poll() is None + + def kill(self, timeout: float = 5.0) -> None: + if self.process is None: + return + if self.process.poll() is None: + try: + os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) + except (ProcessLookupError, PermissionError): + try: + self.process.terminate() + except ProcessLookupError: + pass + try: + self.process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(self.process.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + try: + self.process.kill() + except ProcessLookupError: + pass + self.process.wait(timeout=timeout) + if self.process.stderr is not None: + try: + self.process.stderr.close() + except OSError: + pass + self.process = None + + # -- observation ------------------------------------------------------- + + def read_uart(self) -> str: + try: + with open(self.uart_log, "rb") as fh: + return fh.read().decode("utf-8", "replace") + except FileNotFoundError: + return "" + + def wait_for_uart(self, marker: str, timeout: float = 5.0, interval: float = 0.05) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if marker in self.read_uart(): + return True + time.sleep(interval) + return False + + def read_qemu_log(self) -> str: + try: + with open(self.qemu_log, "rb") as fh: + return fh.read().decode("utf-8", "replace") + except FileNotFoundError: + return "" + + @property + def command_line(self) -> list[str]: + return list(self._cmd) + + def shell_command(self) -> str: + import shlex + + return " ".join(shlex.quote(part) for part in self._cmd) + + def __repr__(self) -> str: # pragma: no cover - cosmetic + return f"QemuTarget(port={self.port}, alive={self.alive()})" diff --git a/debug/risc-v-gsp-rm-vm/plugins/README.md b/debug/risc-v-gsp-rm-vm/plugins/README.md new file mode 100644 index 0000000..aa9bc72 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/plugins/README.md @@ -0,0 +1,34 @@ +# green-eye plugins + +A plugin is a Python module exposing `register(ge)`: + +```python +def register(ge): + ge.log("my plugin loaded") + ge.add_command("mycmd", mycmd, help="does a thing") + ge.hook("rpc_sum", on_enter=lambda hit: ge.log(f"sum a0={hit.arg(0):#x}")) +``` + +`ge` provides: +* `session`, `memory`, `symbols`, `recorder` — the live objects; +* `hook(target, on_enter=, on_leave=)` / `watch(target, size=, kind=, callback=)`; +* `on_event(kind, callback)` — react to every stop of a kind; +* `add_command(name, fn, help=)` — new REPL commands (`fn(session, args) -> int`); +* `log(message, **fields)` — recorded plugin notes. + +Discovery: `--plugin PATH` (file or directory), then `GREENEYE_PLUGINS` +(colon-separated), then the bundled `plugins/` directory. A plugin that raises +is recorded as `plugin_error` and never breaks the session. + +Types of plugins that fit green-eye: +1. **Runtime RE tools** — new watchpoint strategies, coverage collectors, + structure probes, heap/allocator graphs. +2. **Harnesses** — stimulus drivers that poke mailboxes/rings and assert on + recorded events. +3. **Virtualization-environment updates** — replay/record wrappers, QEMU + argument tweaks, device-model shims. +4. **AI/harness integration** — export sessions to analysis services; + **MCP servers are planned as a plugin** (see `docs/08-ROADMAP.md`), not + part of v0.1. + +The example plugin is `example_plugin.py`. diff --git a/debug/risc-v-gsp-rm-vm/plugins/example_plugin.py b/debug/risc-v-gsp-rm-vm/plugins/example_plugin.py new file mode 100644 index 0000000..0bd382e --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/plugins/example_plugin.py @@ -0,0 +1,45 @@ +"""Example green-eye plugin. + +Load it explicitly with ``--plugin plugins/example_plugin.py`` (the bundled +``plugins/`` directory is discovered automatically), or set +``GREENEYE_PLUGINS=/path/to/plugins``. + +It demonstrates the three plugin extension points: + * a CLI command (``gehits``), + * runtime instrumentation (a hook on ``rpc_state`` logging transitions), + * session notes / event stream contributions. +""" +from __future__ import annotations + + +def register(ge): + ge.log("example plugin loaded") + + state_seen: list[int] = [] + + def on_state(hit): + state_seen.append(hit.retval or 0) + ge.recorder.event( + "plugin_state_transition", + plugin="example", + step=len(state_seen), + result=hit.retval, + a0=hit.arg(0), + ) + + sym = ge.symbols.get("rpc_state") + if sym is not None: + ge.hook("rpc_state", on_leave=on_state) + ge.log("hooked rpc_state for transition logging") + else: + ge.log("rpc_state not in symbol DB; hook skipped") + + def cmd_gehits(session, args): + """gehits -- print plugin-observed state transitions.""" + print(f" observed {len(state_seen)} rpc_state transitions: {state_seen}") + directory = getattr(session.recorder, "dir", None) + if directory: + print(f" session: {directory}") + return 0 + + ge.add_command("gehits", cmd_gehits, help="print plugin-observed rpc_state transitions") diff --git a/debug/risc-v-gsp-rm-vm/qemu/Makefile b/debug/risc-v-gsp-rm-vm/qemu/Makefile new file mode 100644 index 0000000..8537cbb --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/qemu/Makefile @@ -0,0 +1,16 @@ +# Optional QEMU TCG plugin: full instruction/memory activity tracing. +# Requires the QEMU plugin API header (vendored as qemu-plugin.h, GPL-2.0+). + +CC ?= gcc +HEADER := qemu-plugin.h +TARGET := greeneye-trace.so + +CFLAGS ?= -O2 -fPIC -Wall -Wextra -fvisibility=hidden -I. + +all: $(TARGET) + +$(TARGET): greeneye_trace.c $(HEADER) + $(CC) $(CFLAGS) -shared -o $@ greeneye_trace.c + +clean: + rm -f $(TARGET) *.o diff --git a/debug/risc-v-gsp-rm-vm/qemu/README.md b/debug/risc-v-gsp-rm-vm/qemu/README.md new file mode 100644 index 0000000..a6be332 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/qemu/README.md @@ -0,0 +1,28 @@ +# green-eye QEMU helpers + +`greeneye_trace.c` is an optional QEMU TCG plugin that records **every** +instruction (opt-in) and every memory access as JSONL: + +```bash +make -C qemu # builds qemu/greeneye-trace.so +qemu-system-riscv64 -machine virt -m 256M -display none -serial file:/tmp/uart.log \ + -bios none -kernel <elf> -S -gdb tcp:127.0.0.1:4444 \ + -plugin qemu/greeneye-trace.so,path=/tmp/ge-trace.jsonl,start=0x80000000,end=0x80010000,exec=off +``` + +This observes more than green-eye's gdbstub layer can: memory accesses +between stops, instruction coverage, exact access sizes. Use it when you need +a *complete* memory-activity map; use green-eye hooks/watchpoints when you +need semantics (arguments, returns, structure decoding). + +The plugin header `qemu-plugin.h` is vendored from QEMU v8.2.2 +(`include/qemu/qemu-plugin.h`, GPL-2.0-or-later) so the plugin builds on +systems where the QEMU source tree is not installed. If you prefer your +distro's header, point `CFLAGS` at it instead. + +`launch.sh` starts a target through the package and keeps it alive for +`greeneye attach`: + +```bash +qemu/launch.sh --kernel firmware/uproc_demo/build/uproc_demo.elf +``` diff --git a/debug/risc-v-gsp-rm-vm/qemu/greeneye_trace.c b/debug/risc-v-gsp-rm-vm/qemu/greeneye_trace.c new file mode 100644 index 0000000..a20748a --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/qemu/greeneye_trace.c @@ -0,0 +1,128 @@ +/* + * green-eye TCG trace plugin - full instruction/memory activity recording. + * + * This is the "complete map" upgrade from stop-based observation: QEMU calls + * the plugin for every translated instruction and every memory access. Output + * is JSONL (one object per line), ready to be folded into a green-eye session: + * + * {"t":"exec","vcpu":0,"pc":"0x80000194"} + * {"t":"write","vcpu":0,"addr":"0x80000850","size":8} + * {"t":"read","vcpu":0,"addr":"0x10000005","size":1} + * {"t":"summary","insns":12345,"mem":678} + * + * Arguments (all optional): + * path=FILE output file (default greeneye-trace.jsonl) + * exec=on|off record instruction execution (default off; heavy) + * start=0xADDR only instrument PCs >= start + * end=0xADDR only instrument PCs <= end + * + * Build: make -C qemu (needs qemu-plugin.h, vendored here) + * Run: qemu-system-riscv64 ... \ + * -plugin qemu/greeneye-trace.so,path=/tmp/ge-trace.jsonl,exec=on + * + * License: GPL-2.0-or-later (matches QEMU's plugin API header). + */ +#include <inttypes.h> +#include <stdbool.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include <qemu-plugin.h> + +QEMU_PLUGIN_EXPORT int qemu_plugin_version = QEMU_PLUGIN_VERSION; + +static FILE *trace_fp; +static bool trace_exec; +static uint64_t start_pc; +static uint64_t end_pc; +static uint64_t insn_count; +static uint64_t mem_count; + +static void vcpu_insn_exec(unsigned int vcpu_index, void *userdata) +{ + uint64_t pc = (uint64_t)(uintptr_t)userdata; + insn_count++; + fprintf(trace_fp, "{\"t\":\"exec\",\"vcpu\":%u,\"pc\":\"0x%" PRIx64 "\"}\n", + vcpu_index, pc); +} + +static void vcpu_mem(unsigned int vcpu_index, qemu_plugin_meminfo_t info, + uint64_t vaddr, void *userdata) +{ + bool is_store = qemu_plugin_mem_is_store(info); + unsigned int shift = qemu_plugin_mem_size_shift(info); + (void)userdata; + mem_count++; + fprintf(trace_fp, + "{\"t\":\"%s\",\"vcpu\":%u,\"addr\":\"0x%" PRIx64 "\",\"size\":%u}\n", + is_store ? "write" : "read", vcpu_index, vaddr, 1u << shift); +} + +static void vcpu_tb_trans(qemu_plugin_id_t id, struct qemu_plugin_tb *tb) +{ + size_t n = qemu_plugin_tb_n_insns(tb); + (void)id; + for (size_t i = 0; i < n; i++) { + struct qemu_plugin_insn *insn = qemu_plugin_tb_get_insn(tb, i); + uint64_t pc = qemu_plugin_insn_vaddr(insn); + if (start_pc && pc < start_pc) { + continue; + } + if (end_pc && pc > end_pc) { + continue; + } + if (trace_exec) { + qemu_plugin_register_vcpu_insn_exec_cb( + insn, vcpu_insn_exec, QEMU_PLUGIN_CB_NO_REGS, + (void *)(uintptr_t)pc); + } + qemu_plugin_register_vcpu_mem_cb(insn, vcpu_mem, QEMU_PLUGIN_CB_NO_REGS, + QEMU_PLUGIN_MEM_RW, NULL); + } +} + +static void plugin_exit(qemu_plugin_id_t id, void *userdata) +{ + (void)id; + (void)userdata; + if (trace_fp) { + fprintf(trace_fp, + "{\"t\":\"summary\",\"insns\":%" PRIu64 ",\"mem\":%" PRIu64 "}\n", + insn_count, mem_count); + fclose(trace_fp); + trace_fp = NULL; + } + qemu_plugin_outs("green-eye trace complete\n"); +} + +QEMU_PLUGIN_EXPORT int qemu_plugin_install(qemu_plugin_id_t id, + const qemu_info_t *info, int argc, + char **argv) +{ + const char *path = "greeneye-trace.jsonl"; + (void)info; + + for (int i = 0; i < argc; i++) { + if (strncmp(argv[i], "path=", 5) == 0) { + path = argv[i] + 5; + } else if (strncmp(argv[i], "exec=", 5) == 0) { + trace_exec = strcmp(argv[i] + 5, "on") == 0 + || strcmp(argv[i] + 5, "true") == 0; + } else if (strncmp(argv[i], "start=", 6) == 0) { + start_pc = strtoull(argv[i] + 6, NULL, 0); + } else if (strncmp(argv[i], "end=", 4) == 0) { + end_pc = strtoull(argv[i] + 4, NULL, 0); + } + } + + trace_fp = fopen(path, "w"); + if (!trace_fp) { + qemu_plugin_outs("green-eye: cannot open trace output\n"); + return -1; + } + qemu_plugin_register_vcpu_tb_trans_cb(id, vcpu_tb_trans); + qemu_plugin_register_atexit_cb(id, plugin_exit, NULL); + qemu_plugin_outs("green-eye trace plugin armed\n"); + return 0; +} diff --git a/debug/risc-v-gsp-rm-vm/qemu/launch.sh b/debug/risc-v-gsp-rm-vm/qemu/launch.sh new file mode 100755 index 0000000..54460db --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/qemu/launch.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Start a green-eye target and keep it alive for `greeneye attach`. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$HERE/../greeneye.sh" launch "$@" diff --git a/debug/risc-v-gsp-rm-vm/qemu/qemu-plugin.h b/debug/risc-v-gsp-rm-vm/qemu/qemu-plugin.h new file mode 100644 index 0000000..4daab6e --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/qemu/qemu-plugin.h @@ -0,0 +1,711 @@ +/* + * Copyright (C) 2017, Emilio G. Cota <cota@braap.org> + * Copyright (C) 2019, Linaro + * + * License: GNU GPL, version 2 or later. + * See the COPYING file in the top-level directory. + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#ifndef QEMU_QEMU_PLUGIN_H +#define QEMU_QEMU_PLUGIN_H + +#include <inttypes.h> +#include <stdbool.h> +#include <stddef.h> + +/* + * For best performance, build the plugin with -fvisibility=hidden so that + * QEMU_PLUGIN_LOCAL is implicit. Then, just mark qemu_plugin_install with + * QEMU_PLUGIN_EXPORT. For more info, see + * https://gcc.gnu.org/wiki/Visibility + */ +#if defined _WIN32 || defined __CYGWIN__ + #ifdef CONFIG_PLUGIN + #define QEMU_PLUGIN_EXPORT __declspec(dllimport) + #define QEMU_PLUGIN_API __declspec(dllexport) + #else + #define QEMU_PLUGIN_EXPORT __declspec(dllexport) + #define QEMU_PLUGIN_API __declspec(dllimport) + #endif + #define QEMU_PLUGIN_LOCAL +#else + #define QEMU_PLUGIN_EXPORT __attribute__((visibility("default"))) + #define QEMU_PLUGIN_LOCAL __attribute__((visibility("hidden"))) + #define QEMU_PLUGIN_API +#endif + +/** + * typedef qemu_plugin_id_t - Unique plugin ID + */ +typedef uint64_t qemu_plugin_id_t; + +/* + * Versioning plugins: + * + * The plugin API will pass a minimum and current API version that + * QEMU currently supports. The minimum API will be incremented if an + * API needs to be deprecated. + * + * The plugins export the API they were built against by exposing the + * symbol qemu_plugin_version which can be checked. + */ + +extern QEMU_PLUGIN_EXPORT int qemu_plugin_version; + +#define QEMU_PLUGIN_VERSION 1 + +/** + * struct qemu_info_t - system information for plugins + * + * This structure provides for some limited information about the + * system to allow the plugin to make decisions on how to proceed. For + * example it might only be suitable for running on some guest + * architectures or when under full system emulation. + */ +typedef struct qemu_info_t { + /** @target_name: string describing architecture */ + const char *target_name; + /** @version: minimum and current plugin API level */ + struct { + int min; + int cur; + } version; + /** @system_emulation: is this a full system emulation? */ + bool system_emulation; + union { + /** @system: information relevant to system emulation */ + struct { + /** @system.smp_vcpus: initial number of vCPUs */ + int smp_vcpus; + /** @system.max_vcpus: maximum possible number of vCPUs */ + int max_vcpus; + } system; + }; +} qemu_info_t; + +/** + * qemu_plugin_install() - Install a plugin + * @id: this plugin's opaque ID + * @info: a block describing some details about the guest + * @argc: number of arguments + * @argv: array of arguments (@argc elements) + * + * All plugins must export this symbol which is called when the plugin + * is first loaded. Calling qemu_plugin_uninstall() from this function + * is a bug. + * + * Note: @info is only live during the call. Copy any information we + * want to keep. @argv remains valid throughout the lifetime of the + * loaded plugin. + * + * Return: 0 on successful loading, !0 for an error. + */ +QEMU_PLUGIN_EXPORT int qemu_plugin_install(qemu_plugin_id_t id, + const qemu_info_t *info, + int argc, char **argv); + +/** + * typedef qemu_plugin_simple_cb_t - simple callback + * @id: the unique qemu_plugin_id_t + * + * This callback passes no information aside from the unique @id. + */ +typedef void (*qemu_plugin_simple_cb_t)(qemu_plugin_id_t id); + +/** + * typedef qemu_plugin_udata_cb_t - callback with user data + * @id: the unique qemu_plugin_id_t + * @userdata: a pointer to some user data supplied when the callback + * was registered. + */ +typedef void (*qemu_plugin_udata_cb_t)(qemu_plugin_id_t id, void *userdata); + +/** + * typedef qemu_plugin_vcpu_simple_cb_t - vcpu callback + * @id: the unique qemu_plugin_id_t + * @vcpu_index: the current vcpu context + */ +typedef void (*qemu_plugin_vcpu_simple_cb_t)(qemu_plugin_id_t id, + unsigned int vcpu_index); + +/** + * typedef qemu_plugin_vcpu_udata_cb_t - vcpu callback + * @vcpu_index: the current vcpu context + * @userdata: a pointer to some user data supplied when the callback + * was registered. + */ +typedef void (*qemu_plugin_vcpu_udata_cb_t)(unsigned int vcpu_index, + void *userdata); + +/** + * qemu_plugin_uninstall() - Uninstall a plugin + * @id: this plugin's opaque ID + * @cb: callback to be called once the plugin has been removed + * + * Do NOT assume that the plugin has been uninstalled once this function + * returns. Plugins are uninstalled asynchronously, and therefore the given + * plugin receives callbacks until @cb is called. + * + * Note: Calling this function from qemu_plugin_install() is a bug. + */ +QEMU_PLUGIN_API +void qemu_plugin_uninstall(qemu_plugin_id_t id, qemu_plugin_simple_cb_t cb); + +/** + * qemu_plugin_reset() - Reset a plugin + * @id: this plugin's opaque ID + * @cb: callback to be called once the plugin has been reset + * + * Unregisters all callbacks for the plugin given by @id. + * + * Do NOT assume that the plugin has been reset once this function returns. + * Plugins are reset asynchronously, and therefore the given plugin receives + * callbacks until @cb is called. + */ +QEMU_PLUGIN_API +void qemu_plugin_reset(qemu_plugin_id_t id, qemu_plugin_simple_cb_t cb); + +/** + * qemu_plugin_register_vcpu_init_cb() - register a vCPU initialization callback + * @id: plugin ID + * @cb: callback function + * + * The @cb function is called every time a vCPU is initialized. + * + * See also: qemu_plugin_register_vcpu_exit_cb() + */ +QEMU_PLUGIN_API +void qemu_plugin_register_vcpu_init_cb(qemu_plugin_id_t id, + qemu_plugin_vcpu_simple_cb_t cb); + +/** + * qemu_plugin_register_vcpu_exit_cb() - register a vCPU exit callback + * @id: plugin ID + * @cb: callback function + * + * The @cb function is called every time a vCPU exits. + * + * See also: qemu_plugin_register_vcpu_init_cb() + */ +QEMU_PLUGIN_API +void qemu_plugin_register_vcpu_exit_cb(qemu_plugin_id_t id, + qemu_plugin_vcpu_simple_cb_t cb); + +/** + * qemu_plugin_register_vcpu_idle_cb() - register a vCPU idle callback + * @id: plugin ID + * @cb: callback function + * + * The @cb function is called every time a vCPU idles. + */ +QEMU_PLUGIN_API +void qemu_plugin_register_vcpu_idle_cb(qemu_plugin_id_t id, + qemu_plugin_vcpu_simple_cb_t cb); + +/** + * qemu_plugin_register_vcpu_resume_cb() - register a vCPU resume callback + * @id: plugin ID + * @cb: callback function + * + * The @cb function is called every time a vCPU resumes execution. + */ +QEMU_PLUGIN_API +void qemu_plugin_register_vcpu_resume_cb(qemu_plugin_id_t id, + qemu_plugin_vcpu_simple_cb_t cb); + +/** struct qemu_plugin_tb - Opaque handle for a translation block */ +struct qemu_plugin_tb; +/** struct qemu_plugin_insn - Opaque handle for a translated instruction */ +struct qemu_plugin_insn; + +/** + * enum qemu_plugin_cb_flags - type of callback + * + * @QEMU_PLUGIN_CB_NO_REGS: callback does not access the CPU's regs + * @QEMU_PLUGIN_CB_R_REGS: callback reads the CPU's regs + * @QEMU_PLUGIN_CB_RW_REGS: callback reads and writes the CPU's regs + * + * Note: currently unused, plugins cannot read or change system + * register state. + */ +enum qemu_plugin_cb_flags { + QEMU_PLUGIN_CB_NO_REGS, + QEMU_PLUGIN_CB_R_REGS, + QEMU_PLUGIN_CB_RW_REGS, +}; + +enum qemu_plugin_mem_rw { + QEMU_PLUGIN_MEM_R = 1, + QEMU_PLUGIN_MEM_W, + QEMU_PLUGIN_MEM_RW, +}; + +/** + * typedef qemu_plugin_vcpu_tb_trans_cb_t - translation callback + * @id: unique plugin id + * @tb: opaque handle used for querying and instrumenting a block. + */ +typedef void (*qemu_plugin_vcpu_tb_trans_cb_t)(qemu_plugin_id_t id, + struct qemu_plugin_tb *tb); + +/** + * qemu_plugin_register_vcpu_tb_trans_cb() - register a translate cb + * @id: plugin ID + * @cb: callback function + * + * The @cb function is called every time a translation occurs. The @cb + * function is passed an opaque qemu_plugin_type which it can query + * for additional information including the list of translated + * instructions. At this point the plugin can register further + * callbacks to be triggered when the block or individual instruction + * executes. + */ +QEMU_PLUGIN_API +void qemu_plugin_register_vcpu_tb_trans_cb(qemu_plugin_id_t id, + qemu_plugin_vcpu_tb_trans_cb_t cb); + +/** + * qemu_plugin_register_vcpu_tb_exec_cb() - register execution callback + * @tb: the opaque qemu_plugin_tb handle for the translation + * @cb: callback function + * @flags: does the plugin read or write the CPU's registers? + * @userdata: any plugin data to pass to the @cb? + * + * The @cb function is called every time a translated unit executes. + */ +QEMU_PLUGIN_API +void qemu_plugin_register_vcpu_tb_exec_cb(struct qemu_plugin_tb *tb, + qemu_plugin_vcpu_udata_cb_t cb, + enum qemu_plugin_cb_flags flags, + void *userdata); + +/** + * enum qemu_plugin_op - describes an inline op + * + * @QEMU_PLUGIN_INLINE_ADD_U64: add an immediate value uint64_t + * + * Note: currently only a single inline op is supported. + */ + +enum qemu_plugin_op { + QEMU_PLUGIN_INLINE_ADD_U64, +}; + +/** + * qemu_plugin_register_vcpu_tb_exec_inline() - execution inline op + * @tb: the opaque qemu_plugin_tb handle for the translation + * @op: the type of qemu_plugin_op (e.g. ADD_U64) + * @ptr: the target memory location for the op + * @imm: the op data (e.g. 1) + * + * Insert an inline op to every time a translated unit executes. + * Useful if you just want to increment a single counter somewhere in + * memory. + * + * Note: ops are not atomic so in multi-threaded/multi-smp situations + * you will get inexact results. + */ +QEMU_PLUGIN_API +void qemu_plugin_register_vcpu_tb_exec_inline(struct qemu_plugin_tb *tb, + enum qemu_plugin_op op, + void *ptr, uint64_t imm); + +/** + * qemu_plugin_register_vcpu_insn_exec_cb() - register insn execution cb + * @insn: the opaque qemu_plugin_insn handle for an instruction + * @cb: callback function + * @flags: does the plugin read or write the CPU's registers? + * @userdata: any plugin data to pass to the @cb? + * + * The @cb function is called every time an instruction is executed + */ +QEMU_PLUGIN_API +void qemu_plugin_register_vcpu_insn_exec_cb(struct qemu_plugin_insn *insn, + qemu_plugin_vcpu_udata_cb_t cb, + enum qemu_plugin_cb_flags flags, + void *userdata); + +/** + * qemu_plugin_register_vcpu_insn_exec_inline() - insn execution inline op + * @insn: the opaque qemu_plugin_insn handle for an instruction + * @op: the type of qemu_plugin_op (e.g. ADD_U64) + * @ptr: the target memory location for the op + * @imm: the op data (e.g. 1) + * + * Insert an inline op to every time an instruction executes. Useful + * if you just want to increment a single counter somewhere in memory. + */ +QEMU_PLUGIN_API +void qemu_plugin_register_vcpu_insn_exec_inline(struct qemu_plugin_insn *insn, + enum qemu_plugin_op op, + void *ptr, uint64_t imm); + +/** + * qemu_plugin_tb_n_insns() - query helper for number of insns in TB + * @tb: opaque handle to TB passed to callback + * + * Returns: number of instructions in this block + */ +QEMU_PLUGIN_API +size_t qemu_plugin_tb_n_insns(const struct qemu_plugin_tb *tb); + +/** + * qemu_plugin_tb_vaddr() - query helper for vaddr of TB start + * @tb: opaque handle to TB passed to callback + * + * Returns: virtual address of block start + */ +QEMU_PLUGIN_API +uint64_t qemu_plugin_tb_vaddr(const struct qemu_plugin_tb *tb); + +/** + * qemu_plugin_tb_get_insn() - retrieve handle for instruction + * @tb: opaque handle to TB passed to callback + * @idx: instruction number, 0 indexed + * + * The returned handle can be used in follow up helper queries as well + * as when instrumenting an instruction. It is only valid for the + * lifetime of the callback. + * + * Returns: opaque handle to instruction + */ +QEMU_PLUGIN_API +struct qemu_plugin_insn * +qemu_plugin_tb_get_insn(const struct qemu_plugin_tb *tb, size_t idx); + +/** + * qemu_plugin_insn_data() - return ptr to instruction data + * @insn: opaque instruction handle from qemu_plugin_tb_get_insn() + * + * Note: data is only valid for duration of callback. See + * qemu_plugin_insn_size() to calculate size of stream. + * + * Returns: pointer to a stream of bytes containing the value of this + * instructions opcode. + */ +QEMU_PLUGIN_API +const void *qemu_plugin_insn_data(const struct qemu_plugin_insn *insn); + +/** + * qemu_plugin_insn_size() - return size of instruction + * @insn: opaque instruction handle from qemu_plugin_tb_get_insn() + * + * Returns: size of instruction in bytes + */ +QEMU_PLUGIN_API +size_t qemu_plugin_insn_size(const struct qemu_plugin_insn *insn); + +/** + * qemu_plugin_insn_vaddr() - return vaddr of instruction + * @insn: opaque instruction handle from qemu_plugin_tb_get_insn() + * + * Returns: virtual address of instruction + */ +QEMU_PLUGIN_API +uint64_t qemu_plugin_insn_vaddr(const struct qemu_plugin_insn *insn); + +/** + * qemu_plugin_insn_haddr() - return hardware addr of instruction + * @insn: opaque instruction handle from qemu_plugin_tb_get_insn() + * + * Returns: hardware (physical) target address of instruction + */ +QEMU_PLUGIN_API +void *qemu_plugin_insn_haddr(const struct qemu_plugin_insn *insn); + +/** + * typedef qemu_plugin_meminfo_t - opaque memory transaction handle + * + * This can be further queried using the qemu_plugin_mem_* query + * functions. + */ +typedef uint32_t qemu_plugin_meminfo_t; +/** struct qemu_plugin_hwaddr - opaque hw address handle */ +struct qemu_plugin_hwaddr; + +/** + * qemu_plugin_mem_size_shift() - get size of access + * @info: opaque memory transaction handle + * + * Returns: size of access in ^2 (0=byte, 1=16bit, 2=32bit etc...) + */ +QEMU_PLUGIN_API +unsigned int qemu_plugin_mem_size_shift(qemu_plugin_meminfo_t info); +/** + * qemu_plugin_mem_is_sign_extended() - was the access sign extended + * @info: opaque memory transaction handle + * + * Returns: true if it was, otherwise false + */ +QEMU_PLUGIN_API +bool qemu_plugin_mem_is_sign_extended(qemu_plugin_meminfo_t info); +/** + * qemu_plugin_mem_is_big_endian() - was the access big endian + * @info: opaque memory transaction handle + * + * Returns: true if it was, otherwise false + */ +QEMU_PLUGIN_API +bool qemu_plugin_mem_is_big_endian(qemu_plugin_meminfo_t info); +/** + * qemu_plugin_mem_is_store() - was the access a store + * @info: opaque memory transaction handle + * + * Returns: true if it was, otherwise false + */ +QEMU_PLUGIN_API +bool qemu_plugin_mem_is_store(qemu_plugin_meminfo_t info); + +/** + * qemu_plugin_get_hwaddr() - return handle for memory operation + * @info: opaque memory info structure + * @vaddr: the virtual address of the memory operation + * + * For system emulation returns a qemu_plugin_hwaddr handle to query + * details about the actual physical address backing the virtual + * address. For linux-user guests it just returns NULL. + * + * This handle is *only* valid for the duration of the callback. Any + * information about the handle should be recovered before the + * callback returns. + */ +QEMU_PLUGIN_API +struct qemu_plugin_hwaddr *qemu_plugin_get_hwaddr(qemu_plugin_meminfo_t info, + uint64_t vaddr); + +/* + * The following additional queries can be run on the hwaddr structure to + * return information about it - namely whether it is for an IO access and the + * physical address associated with the access. + */ + +/** + * qemu_plugin_hwaddr_is_io() - query whether memory operation is IO + * @haddr: address handle from qemu_plugin_get_hwaddr() + * + * Returns true if the handle's memory operation is to memory-mapped IO, or + * false if it is to RAM + */ +QEMU_PLUGIN_API +bool qemu_plugin_hwaddr_is_io(const struct qemu_plugin_hwaddr *haddr); + +/** + * qemu_plugin_hwaddr_phys_addr() - query physical address for memory operation + * @haddr: address handle from qemu_plugin_get_hwaddr() + * + * Returns the physical address associated with the memory operation + * + * Note that the returned physical address may not be unique if you are dealing + * with multiple address spaces. + */ +QEMU_PLUGIN_API +uint64_t qemu_plugin_hwaddr_phys_addr(const struct qemu_plugin_hwaddr *haddr); + +/* + * Returns a string representing the device. The string is valid for + * the lifetime of the plugin. + */ +QEMU_PLUGIN_API +const char *qemu_plugin_hwaddr_device_name(const struct qemu_plugin_hwaddr *h); + +/** + * typedef qemu_plugin_vcpu_mem_cb_t - memory callback function type + * @vcpu_index: the executing vCPU + * @info: an opaque handle for further queries about the memory + * @vaddr: the virtual address of the transaction + * @userdata: any user data attached to the callback + */ +typedef void (*qemu_plugin_vcpu_mem_cb_t) (unsigned int vcpu_index, + qemu_plugin_meminfo_t info, + uint64_t vaddr, + void *userdata); + +/** + * qemu_plugin_register_vcpu_mem_cb() - register memory access callback + * @insn: handle for instruction to instrument + * @cb: callback of type qemu_plugin_vcpu_mem_cb_t + * @flags: (currently unused) callback flags + * @rw: monitor reads, writes or both + * @userdata: opaque pointer for userdata + * + * This registers a full callback for every memory access generated by + * an instruction. If the instruction doesn't access memory no + * callback will be made. + * + * The callback reports the vCPU the access took place on, the virtual + * address of the access and a handle for further queries. The user + * can attach some userdata to the callback for additional purposes. + * + * Other execution threads will continue to execute during the + * callback so the plugin is responsible for ensuring it doesn't get + * confused by making appropriate use of locking if required. + */ +QEMU_PLUGIN_API +void qemu_plugin_register_vcpu_mem_cb(struct qemu_plugin_insn *insn, + qemu_plugin_vcpu_mem_cb_t cb, + enum qemu_plugin_cb_flags flags, + enum qemu_plugin_mem_rw rw, + void *userdata); + +/** + * qemu_plugin_register_vcpu_mem_inline() - register an inline op to any memory access + * @insn: handle for instruction to instrument + * @rw: apply to reads, writes or both + * @op: the op, of type qemu_plugin_op + * @ptr: pointer memory for the op + * @imm: immediate data for @op + * + * This registers a inline op every memory access generated by the + * instruction. This provides for a lightweight but not thread-safe + * way of counting the number of operations done. + */ +QEMU_PLUGIN_API +void qemu_plugin_register_vcpu_mem_inline(struct qemu_plugin_insn *insn, + enum qemu_plugin_mem_rw rw, + enum qemu_plugin_op op, void *ptr, + uint64_t imm); + + + +typedef void +(*qemu_plugin_vcpu_syscall_cb_t)(qemu_plugin_id_t id, unsigned int vcpu_index, + int64_t num, uint64_t a1, uint64_t a2, + uint64_t a3, uint64_t a4, uint64_t a5, + uint64_t a6, uint64_t a7, uint64_t a8); + +QEMU_PLUGIN_API +void qemu_plugin_register_vcpu_syscall_cb(qemu_plugin_id_t id, + qemu_plugin_vcpu_syscall_cb_t cb); + +typedef void +(*qemu_plugin_vcpu_syscall_ret_cb_t)(qemu_plugin_id_t id, unsigned int vcpu_idx, + int64_t num, int64_t ret); + +QEMU_PLUGIN_API +void +qemu_plugin_register_vcpu_syscall_ret_cb(qemu_plugin_id_t id, + qemu_plugin_vcpu_syscall_ret_cb_t cb); + + +/** + * qemu_plugin_insn_disas() - return disassembly string for instruction + * @insn: instruction reference + * + * Returns an allocated string containing the disassembly + */ + +QEMU_PLUGIN_API +char *qemu_plugin_insn_disas(const struct qemu_plugin_insn *insn); + +/** + * qemu_plugin_insn_symbol() - best effort symbol lookup + * @insn: instruction reference + * + * Return a static string referring to the symbol. This is dependent + * on the binary QEMU is running having provided a symbol table. + */ +QEMU_PLUGIN_API +const char *qemu_plugin_insn_symbol(const struct qemu_plugin_insn *insn); + +/** + * qemu_plugin_vcpu_for_each() - iterate over the existing vCPU + * @id: plugin ID + * @cb: callback function + * + * The @cb function is called once for each existing vCPU. + * + * See also: qemu_plugin_register_vcpu_init_cb() + */ +QEMU_PLUGIN_API +void qemu_plugin_vcpu_for_each(qemu_plugin_id_t id, + qemu_plugin_vcpu_simple_cb_t cb); + +QEMU_PLUGIN_API +void qemu_plugin_register_flush_cb(qemu_plugin_id_t id, + qemu_plugin_simple_cb_t cb); + +/** + * qemu_plugin_register_atexit_cb() - register exit callback + * @id: plugin ID + * @cb: callback + * @userdata: user data for callback + * + * The @cb function is called once execution has finished. Plugins + * should be able to free all their resources at this point much like + * after a reset/uninstall callback is called. + * + * In user-mode it is possible a few un-instrumented instructions from + * child threads may run before the host kernel reaps the threads. + */ +QEMU_PLUGIN_API +void qemu_plugin_register_atexit_cb(qemu_plugin_id_t id, + qemu_plugin_udata_cb_t cb, void *userdata); + +/* returns -1 in user-mode */ +int qemu_plugin_n_vcpus(void); + +/* returns -1 in user-mode */ +int qemu_plugin_n_max_vcpus(void); + +/** + * qemu_plugin_outs() - output string via QEMU's logging system + * @string: a string + */ +QEMU_PLUGIN_API +void qemu_plugin_outs(const char *string); + +/** + * qemu_plugin_bool_parse() - parses a boolean argument in the form of + * "<argname>=[on|yes|true|off|no|false]" + * + * @name: argument name, the part before the equals sign + * @val: argument value, what's after the equals sign + * @ret: output return value + * + * returns true if the combination @name=@val parses correctly to a boolean + * argument, and false otherwise + */ +QEMU_PLUGIN_API +bool qemu_plugin_bool_parse(const char *name, const char *val, bool *ret); + +/** + * qemu_plugin_path_to_binary() - path to binary file being executed + * + * Return a string representing the path to the binary. For user-mode + * this is the main executable. For system emulation we currently + * return NULL. The user should g_free() the string once no longer + * needed. + */ +QEMU_PLUGIN_API +const char *qemu_plugin_path_to_binary(void); + +/** + * qemu_plugin_start_code() - returns start of text segment + * + * Returns the nominal start address of the main text segment in + * user-mode. Currently returns 0 for system emulation. + */ +QEMU_PLUGIN_API +uint64_t qemu_plugin_start_code(void); + +/** + * qemu_plugin_end_code() - returns end of text segment + * + * Returns the nominal end address of the main text segment in + * user-mode. Currently returns 0 for system emulation. + */ +QEMU_PLUGIN_API +uint64_t qemu_plugin_end_code(void); + +/** + * qemu_plugin_entry_code() - returns start address for module + * + * Returns the nominal entry address of the main text segment in + * user-mode. Currently returns 0 for system emulation. + */ +QEMU_PLUGIN_API +uint64_t qemu_plugin_entry_code(void); + +#endif /* QEMU_QEMU_PLUGIN_H */ diff --git a/debug/risc-v-gsp-rm-vm/tests/__init__.py b/debug/risc-v-gsp-rm-vm/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/__init__.py diff --git a/debug/risc-v-gsp-rm-vm/tests/test_core.py b/debug/risc-v-gsp-rm-vm/tests/test_core.py new file mode 100644 index 0000000..ab27451 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_core.py @@ -0,0 +1,131 @@ +"""Unit tests for ELF parsing, symbol resolution and structure decoding.""" +from __future__ import annotations + +import json +import os +import tempfile +import unittest + +from greeneye.elf import EM_RISCV, parse_elf, load_elf +from greeneye.structs import Field, Schema, decode, render, schema_from_json +from greeneye.symbols import Symbol, SymbolDB + +DEMO_ELF = os.path.join(os.path.dirname(__file__), "..", "firmware", "uproc_demo", "build", "uproc_demo.elf") + + +class ElfTests(unittest.TestCase): + def test_parse_demo_elf(self): + if not os.path.exists(DEMO_ELF): + self.skipTest("demo firmware not built") + image = load_elf(DEMO_ELF) + self.assertTrue(image.is_riscv) + self.assertEqual(image.e_machine, EM_RISCV) + self.assertEqual(image.entry, 0x80000000) + self.assertGreaterEqual(len(image.load_segments), 1) + names = {s.name for s in image.symbols} + self.assertIn("main", names) + self.assertIn("rpc_ping", names) + self.assertIn("gsp_ctrl", names) + ctrl = next(s for s in image.symbols if s.name == "gsp_ctrl") + self.assertEqual(ctrl.value, 0x80000840) + self.assertEqual(ctrl.size, 0x188) + plan = image.flat_load_plan() + self.assertTrue(plan and plan[0][0] == 0x80000000) + + def test_not_elf(self): + with self.assertRaises(Exception): + parse_elf(b"not an elf") + + +class SymbolTests(unittest.TestCase): + def setUp(self): + self.db = SymbolDB() + self.db.add(Symbol(addr=0x1000, name="alpha", size=0x100, source="test")) + self.db.add(Symbol(addr=0x1200, name="beta", size=0x20, source="test")) + + def test_resolve(self): + self.assertEqual(self.db.resolve(0x1010).name, "alpha") + self.assertEqual(self.db.resolve(0x1210).name, "beta") + self.assertIsNone(self.db.resolve(0x5000)) + self.assertEqual(self.db.annotate(0x1010), "0x1010 alpha+0x10") + self.assertEqual(self.db.lookup("beta"), 0x1200) + + def test_names_tsv(self): + with tempfile.NamedTemporaryFile("w", suffix=".tsv", delete=False) as fh: + fh.write("# comment\n0x00279000\t_DT_INIT\tghidra\n0x00279020\tFUN_x\n") + path = fh.name + try: + count = self.db.load_names_tsv(path, bias=0x100000) + self.assertEqual(count, 2) + self.assertEqual(self.db.lookup("_DT_INIT"), 0x279000 + 0x100000) + finally: + os.unlink(path) + + def test_index_tsv(self): + with tempfile.NamedTemporaryFile("w", suffix=".tsv", delete=False) as fh: + fh.write("addr\tfile_addr\tsize\tname\n") + fh.write("0x00279000\t0x00179000\t24\t_DT_INIT\n") + path = fh.name + try: + count = self.db.load_index_tsv(path, bias=0) + self.assertEqual(count, 1) + sym = self.db.get("_DT_INIT") + self.assertEqual(sym.addr, 0x179000) + self.assertEqual(sym.size, 24) + self.assertEqual(sym.extras["program_addr"], 0x279000) + finally: + os.unlink(path) + + def test_json_roundtrip(self): + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh: + path = fh.name + try: + self.db.save_json(path, meta={"unit": "test"}) + other = SymbolDB() + self.assertEqual(other.load_json(path), 2) + self.assertEqual(other.lookup("alpha"), 0x1000) + payload = json.load(open(path)) + self.assertEqual(payload["format"], "greeneye-symbols/1") + finally: + os.unlink(path) + + +class StructTests(unittest.TestCase): + SCHEMA = { + "name": "demo", + "fields": [ + {"name": "magic", "offset": 0, "fmt": "u32"}, + {"name": "state", "offset": 4, "fmt": "u32", "enum": {"0": "IDLE", "1": "RUN"}}, + {"name": "count", "offset": 8, "fmt": "u64"}, + {"name": "name", "offset": 16, "fmt": "cstr", "size": 16}, + {"name": "buf", "offset": 32, "fmt": "bytes", "size": 8}, + ], + } + + def test_decode_render(self): + schema = schema_from_json(self.SCHEMA) + self.assertEqual(schema.size, 40) + data = bytearray(40) + data[0:4] = (0x47535031).to_bytes(4, "little") + data[4:8] = (1).to_bytes(4, "little") + data[8:16] = (42).to_bytes(8, "little") + data[16:22] = b"green\x00" + data[32:40] = bytes(range(8)) + values = decode(schema, bytes(data), base=0x80000000) + self.assertEqual(values["magic"], 0x47535031) + self.assertEqual(values["state"], 1) + self.assertEqual(values["count"], 42) + self.assertEqual(values["name"], "green") + self.assertEqual(values["buf"], bytes(range(8))) + text = render(schema, values) + self.assertIn("magic", text) + self.assertIn("0x47535031", text) + self.assertIn("green", text) + + def test_invalid_schema(self): + with self.assertRaises(Exception): + Field(name="x", offset=0, fmt="nope").nbytes + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_memory_record.py b/debug/risc-v-gsp-rm-vm/tests/test_memory_record.py new file mode 100644 index 0000000..24b360d --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_memory_record.py @@ -0,0 +1,101 @@ +"""Unit tests for memory helpers and session recording.""" +from __future__ import annotations + +import os +import shutil +import tempfile +import unittest + +from greeneye.memory import Memory +from greeneye.record import NullRecorder, Recorder, load_events, load_meta +from greeneye.symbols import Symbol, SymbolDB + + +class FakeClient: + def __init__(self, size=0x1000): + self.data = bytearray(size) + for i in range(size): + self.data[i] = i & 0xFF + + def read_mem(self, addr, size, chunk=0): + return bytes(self.data[addr : addr + size]) + + def write_mem(self, addr, data, chunk=0): + self.data[addr : addr + len(data)] = data + + +class MemoryTests(unittest.TestCase): + def setUp(self): + self.mem = Memory(FakeClient()) + + def test_read_write_hexdump(self): + self.assertEqual(self.mem.read(0x10, 4), bytes(range(0x10, 0x14))) + self.mem.write(0x20, b"AB") + self.assertEqual(self.mem.read(0x20, 2), b"AB") + text = self.mem.hexdump(0, 16) + self.assertIn("00000000", text) + self.assertIn("00 01 02", text) + self.assertIn("|", text) + + def test_search(self): + self.assertEqual(self.mem.search(0, 0x40, b"\x10\x11"), [0x10]) + self.assertEqual(self.mem.search_string(0, 0x40, "\x20\x21"), [0x20]) + masked = self.mem.search(0, 0x100, b"\xa0", mask=b"\xf0") + self.assertEqual(masked[0], 0xA0) + self.mem.write(0x30, b"GREEN") + self.assertEqual(self.mem.search_string(0, 0x100, "GREEN"), [0x30]) + + def test_snapshot_and_diff(self): + before = self.mem.snapshot(0, 32, "before") + self.mem.write(0x10, b"\xff") + after = self.mem.snapshot(0, 32, "after") + diffs = Memory.diff(before, after, base=0) + self.assertEqual(len(diffs), 1) + self.assertEqual(diffs[0].addr, 0x10) + self.assertEqual(diffs[0].new, b"\xff") + text = Memory.render_diff(diffs) + self.assertIn("00000010", text) + + def test_hexdump_annotated(self): + db = SymbolDB() + db.add(Symbol(addr=0, name="block", size=0x100, source="test")) + mem = Memory(FakeClient(), symbols=db) + text = mem.hexdump(0, 16, annotate=True) + self.assertIn("block", text) + + +class RecorderTests(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp(prefix="greeneye-test-") + self.recorder = Recorder(self.dir, name="unit", meta={"note": "test"}) + self.recorder.event("alpha", value=1) + self.recorder.event("alpha", value=2) + self.recorder.snapshot("regs", 0x80000000, b"\x00" * 16) + + def tearDown(self): + shutil.rmtree(self.dir, ignore_errors=True) + + def test_events_and_meta(self): + self.recorder.close() + events = load_events(self.dir) + kinds = [e["type"] for e in events] + self.assertEqual(kinds.count("alpha"), 2) + self.assertIn("snapshot_saved", kinds) + self.assertIn("session_end", kinds) + meta = load_meta(self.dir) + self.assertEqual(meta["name"], "unit") + self.assertEqual(meta["note"], "test") + self.assertTrue(os.path.exists(os.path.join(self.dir, "summary.md"))) + summary = open(os.path.join(self.dir, "summary.md")).read() + self.assertIn("unit", summary) + + def test_null_recorder(self): + null = NullRecorder() + null.event("x") + null.snapshot("y", 0, b"") + null.close() + self.assertIsNone(null.dir) + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_probe.py b/debug/risc-v-gsp-rm-vm/tests/test_probe.py new file mode 100644 index 0000000..f97a4bf --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_probe.py @@ -0,0 +1,65 @@ +"""Unit tests for the ELF trampoline/flat-kernel path and QEMU fault parsing.""" +from __future__ import annotations + +import os +import struct +import tempfile +import unittest + +from greeneye.analyze import describe_fault, parse_qemu_faults +from greeneye.elf import _trampoline, load_elf, write_flat_kernel + +DEMO_ELF = os.path.join(os.path.dirname(__file__), "..", "firmware", "uproc_demo", "build", "uproc_demo.elf") + +SAMPLE_LOG = """ +Invalid read at addr 0x2000000000000a00, size 4, region '(null)', reason: rejected +riscv_cpu_do_interrupt: hart:0, async:0, cause:0000000000000005, epc:0x0000000084009014, tval:0x2000000000000a00, desc=fault_load +Invalid read at addr 0x0, size 2, region '(null)', reason: rejected +riscv_cpu_do_interrupt: hart:0, async:0, cause:0000000000000001, epc:0x0000000000000000, tval:0x0000000000000000, desc=fault_fetch +""" + + +class TrampolineTests(unittest.TestCase): + def test_encoding(self): + stub = _trampoline(0x4009000) + auipc, jalr = struct.unpack("<II", stub) + self.assertEqual(auipc & 0x7F, 0x17) # AUIPC + self.assertEqual((auipc >> 7) & 0x1F, 5) # t0 + self.assertEqual((auipc >> 12) & 0xFFFFF, 0x4009) + self.assertEqual(jalr & 0x7F, 0x67) # JALR + self.assertEqual((jalr >> 15) & 0x1F, 5) + self.assertEqual(jalr >> 20, 0) + + def test_flat_demo(self): + if not os.path.exists(DEMO_ELF): + self.skipTest("demo firmware not built") + with tempfile.TemporaryDirectory() as tmp: + out = os.path.join(tmp, "flat.bin") + path, entry = write_flat_kernel(DEMO_ELF, out, load_addr=0x80000000) + self.assertEqual(entry, 0x80000000) + image = load_elf(DEMO_ELF) + self.assertEqual(os.path.getsize(path), image.max_vaddr - image.min_vaddr) + with open(path, "rb") as fh: + # entry at offset 0: the real first instruction must be present + first = fh.read(4) + self.assertNotEqual(first, b"\x00\x00\x00\x00") + + +class FaultParseTests(unittest.TestCase): + def test_parse_and_describe(self): + faults = parse_qemu_faults(SAMPLE_LOG) + self.assertEqual(len(faults), 2) + first = faults[0] + self.assertEqual(first["cause"], 5) + self.assertEqual(first["epc"], 0x84009014) + self.assertEqual(first["access"]["access_addr"], 0x2000000000000A00) + self.assertEqual(first["access"]["size"], 4) + text = describe_fault(first, bias=0x80000000) + self.assertIn("0x4009014", text) + self.assertIn("load access fault", text) + self.assertIn("0x2000000000000a00", text) + self.assertEqual(faults[1]["desc"], "fault_fetch") + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_rsp.py b/debug/risc-v-gsp-rm-vm/tests/test_rsp.py new file mode 100644 index 0000000..70b9f45 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_rsp.py @@ -0,0 +1,175 @@ +"""Unit tests for the RSP client using an in-process fake stub.""" +from __future__ import annotations + +import socket +import threading +import unittest + +from greeneye.rsp import ( + RV64_FALLBACK_REGS, + RspClient, + checksum, + encode_packet, + parse_stop_reply, + _parse_target_xml, +) + +TARGET_XML = """<?xml version="1.0"?> +<target><architecture>riscv:rv64</architecture> +<feature name="org.gnu.gdb.riscv.cpu"> +<reg name="x0" bitsize="64" regnum="0"/> +<reg name="ra" bitsize="64" regnum="1"/> +<reg name="pc" bitsize="64" regnum="32"/> +</feature></target>""" + + +class FakeStub(threading.Thread): + """Minimal RSP server for unit tests.""" + + def __init__(self, sock: socket.socket): + super().__init__(daemon=True) + self.sock = sock + self.no_ack = False + self.stopped = True + self.regs = bytearray(33 * 8) + self.regs[8:16] = (0xAA).to_bytes(8, "little") # ra (x1) + self.regs[16:24] = (0x80000194).to_bytes(8, "little") # pc (3-reg test layout) + self.memory = bytearray(0x10000) + for i in range(len(self.memory)): + self.memory[i] = i & 0xFF + self.bps: set[int] = set() + + def run(self): + buf = b"" + while True: + try: + chunk = self.sock.recv(4096) + except OSError: + return + if not chunk: + return + buf += chunk + while b"#" in buf and b"$" in buf: + start = buf.index(b"$") + end = buf.index(b"#", start) + if len(buf) < end + 3: + break + payload = buf[start + 1 : end] + buf = buf[end + 3 :] + self.handle(payload) + + def send(self, text: str): + data = encode_packet(text.encode()) + self.sock.sendall(data) + + def handle(self, payload: bytes): + cmd = payload.decode("latin1") + if not self.no_ack: + self.sock.sendall(b"+") + if cmd.startswith("qSupported"): + self.send("PacketSize=1000;qXfer:features:read+;multiprocess+") + elif cmd == "QStartNoAckMode": + self.send("") + self.no_ack = True + elif cmd == "?": + self.send("T05thread:p01.01;") + elif cmd in ("Hg0", "Hc-1"): + self.send("OK") + elif cmd.startswith("qXfer:features:read:target.xml"): + self.send("l" + TARGET_XML) + elif cmd.startswith("qXfer"): + self.send("E01") + elif cmd == "g": + self.send(bytes(self.regs).hex()) + elif cmd.startswith("p"): + regno = int(cmd[1:], 16) + if regno == 32: + self.send(self.regs[16:24].hex()) + else: + self.send(self.regs[regno * 8 : regno * 8 + 8].hex()) + elif cmd.startswith("m"): + addr_s, len_s = cmd[1:].split(",") + addr, length = int(addr_s, 16), int(len_s, 16) + self.send(bytes(self.memory[addr : addr + length]).hex()) + elif cmd.startswith("M"): + head, data = cmd[1:].split(":", 1) + addr_s, len_s = head.split(",") + addr, length = int(addr_s, 16), int(len_s, 16) + blob = bytes.fromhex(data) + self.memory[addr : addr + length] = blob + self.send("OK") + elif cmd.startswith("Z0"): + self.bps.add(int(cmd.split(",")[1], 16)) + self.send("OK") + elif cmd.startswith("z0"): + self.bps.discard(int(cmd.split(",")[1], 16)) + self.send("OK") + elif cmd.startswith("Z2") or cmd.startswith("z2"): + self.send("OK") + elif cmd in ("c", "s"): + self.stopped = True + self.send("T05thread:p01.01;") + else: + self.send("") + + +class RspTests(unittest.TestCase): + def setUp(self): + self.client_sock, self.server_sock = socket.socketpair() + self.stub = FakeStub(self.server_sock) + self.stub.start() + self.client = RspClient(timeout=2.0) + self.client.sock = self.client_sock + self.client.sync() + + def tearDown(self): + self.client.close() + self.server_sock.close() + + def test_checksum_and_encode(self): + self.assertEqual(checksum(b"OK"), (ord("O") + ord("K")) & 0xFF) + self.assertEqual(encode_packet(b"OK"), b"$OK#9a") + + def test_parse_stop_reply(self): + stop = parse_stop_reply("T05watch:0000000080001000;thread:p01.01;") + self.assertEqual(stop.signal, 5) + self.assertEqual(stop.watch_addr, 0x80001000) + self.assertEqual(stop.watch_kind, "watch") + self.assertEqual(stop.thread, "p01.01") + self.assertTrue(stop.is_stop) + exit_stop = parse_stop_reply("W00") + self.assertTrue(exit_stop.is_exit) + + def test_target_xml_parse(self): + regs = _parse_target_xml(TARGET_XML) + self.assertEqual([r.name for r in regs], ["x0", "ra", "pc"]) + self.assertEqual([r.number for r in regs], [0, 1, 32]) + by_name = {r.name: r for r in regs} + self.assertEqual(by_name["ra"].offset, 8) + self.assertEqual(by_name["pc"].offset, 16) + self.assertEqual(len(RV64_FALLBACK_REGS), 33) + self.assertEqual(RV64_FALLBACK_REGS[32].name, "pc") + + def test_registers_and_memory(self): + regs = self.client.read_registers() + self.assertEqual(regs["ra"], 0xAA) + self.assertEqual(regs["pc"], 0x80000194) + self.assertEqual(self.client.read_reg("pc"), 0x80000194) + data = self.client.read_mem(0x10, 8) + self.assertEqual(data, bytes(range(0x10, 0x18))) + self.client.write_mem(0x20, b"\xde\xad\xbe\xef") + self.assertEqual(self.client.read_mem(0x20, 4), b"\xde\xad\xbe\xef") + self.assertEqual(self.client.read_u32(0x20), 0xEFBEADDE) + + def test_breakpoints_watchpoints_continue(self): + self.client.set_sw_break(0x80000048) + self.client.clear_sw_break(0x80000048) + self.client.set_watch(0x80000850, size=8, kind="w") + stop = self.client.continue_() + self.assertEqual(stop.signal, 5) + stop = self.client.step() + self.assertEqual(stop.signal, 5) + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_smoke_qemu.py b/debug/risc-v-gsp-rm-vm/tests/test_smoke_qemu.py new file mode 100644 index 0000000..c0354d2 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_smoke_qemu.py @@ -0,0 +1,78 @@ +"""Live integration smoke test: boots the demo uproc in QEMU. + +Skipped cleanly when qemu-system-riscv64 or the demo firmware is absent. +""" +from __future__ import annotations + +import os +import shutil +import unittest + +import greeneye as ge +from greeneye.errors import GreenEyeError +from greeneye.probes import compile_condition, install + +HERE = os.path.dirname(os.path.abspath(__file__)) +DEMO_ELF = os.path.join(HERE, "..", "firmware", "uproc_demo", "build", "uproc_demo.elf") + + +def qemu_available() -> bool: + return bool(os.environ.get("GREENEYE_QEMU") or shutil.which("qemu-system-riscv64")) + + +@unittest.skipUnless(qemu_available(), "qemu-system-riscv64 not installed") +@unittest.skipUnless(os.path.exists(DEMO_ELF), "demo firmware not built (make demo)") +class QemuSmokeTests(unittest.TestCase): + def test_boot_hook_watch_snapshot_analyze(self): + session = ge.launch(kernel=DEMO_ELF, record=True, name="unittest-smoke") + hits = [] + try: + ctrl = session.symbols.lookup("gsp_ctrl") + session.hook("rpc_sum", on_enter=lambda hit: hits.append(hit)) + session.watch(ctrl + 16, size=8, kind="w") + session.run(duration=0.6) + self.assertTrue(session.target.wait_for_uart("GREEN-EYE UPROC DEMO", timeout=1.0)) + self.assertGreaterEqual(len(hits), 1, "rpc_sum hook never fired") + after = session.snapshot(ctrl, 0x188, "ctrl") + self.assertEqual(int.from_bytes(after[0:4], "little"), 0x47535031) + self.assertGreaterEqual(int.from_bytes(after[16:24], "little"), 7) + finally: + directory = getattr(session.recorder, "dir", None) + session.close() + if directory: + from greeneye.analyze import render_summary + + report = render_summary(directory) + self.assertIn("rpc_sum", report) + + def test_probe_spec_install(self): + session = ge.launch(kernel=DEMO_ELF, name="unittest-probes") + try: + spec = { + "probes": [ + { + "name": "sum", + "symbol": "rpc_sum", + "when": "a1 > 0", + "on_enter": {"log": ["a0", "a1"], "note": "sum called"}, + } + ] + } + handles = install(session, spec) + self.assertEqual(len(handles), 1) + session.run(duration=0.6) + self.assertEqual(handles[0].hits, 1) # self-test calls rpc_sum once + finally: + session.close() + + def test_condition_guard(self): + self.assertIsNone(compile_condition(None)) + check = compile_condition("a0 == 0x2a and a1 < 4") + self.assertTrue(check({"a0": 0x2A, "a1": 2})) + self.assertFalse(check({"a0": 0x2B, "a1": 2})) + with self.assertRaises(GreenEyeError): + compile_condition("__import__('os').system('true')") + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/05-operations/02-debugging-and-maintenance-workflows.md b/docs/05-operations/02-debugging-and-maintenance-workflows.md index 801cda6..8d711c6 100644 --- a/docs/05-operations/02-debugging-and-maintenance-workflows.md +++ b/docs/05-operations/02-debugging-and-maintenance-workflows.md @@ -192,6 +192,21 @@ Watch the serial log afterwards for backtraces. A truly hard-wedged vCPU (spinning on a faulting MMIO instruction) may print nothing — use the gdbstub instead. +### 3.9 Firmware runtime analysis — green-eye (experimental) + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** + +For firmware-side questions (what does the GSP/uproc server do at runtime, +what does it read/write, how do its state machines evolve), the experimental +green-eye tooling runs uproc/GSP firmware in a `qemu-system-riscv64` VM with +hooks, watchpoints, memory dumps and session recording, without a physical +GPU, WPR2 risk or a reboot. See +[`03-risc-v-gsp-rm-vm.md`](03-risc-v-gsp-rm-vm.md) for purpose, status and +limits; tooling and full documentation live in +[`../../debug/risc-v-gsp-rm-vm/`](../../debug/risc-v-gsp-rm-vm/README.md). +It is development-only: never shipped, never in a guest, and not a +substitute for the physical backend when silicon truth is required. + ## 4. Workflows for new inputs ### 4.1 New firmware / driver version (upgrade runbook) diff --git a/docs/05-operations/03-risc-v-gsp-rm-vm.md b/docs/05-operations/03-risc-v-gsp-rm-vm.md new file mode 100644 index 0000000..8d3cf7c --- /dev/null +++ b/docs/05-operations/03-risc-v-gsp-rm-vm.md @@ -0,0 +1,98 @@ +# GSP in a RISC-V VM — green-eye (experimental) + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** +> This is experimental developer tooling. It is not part of the shipped +> emulation, never runs in a guest, and its interfaces, file formats and +> documented workflows may change without notice. Do not depend on it in +> production paths or cite it as a supported feature. When it reaches a +> minimum stable level, the flags will be removed from this document and from +> the developer-tools list. + +## 1. What it is + +**green-eye** runs GSP/uproc firmware in a `qemu-system-riscv64` VM and makes +its runtime behaviour inspectable with Frida-like primitives: + +* breakpoints and enter/leave hooks (RV64 ABI aware), +* watchpoints on firmware variables, +* memory read/write/hexdump/search, structure decoding, snapshot diffing, +* recorded sessions: a replayable event stream plus memory dumps + (`events.jsonl`, snapshots, `summary.md`), +* declarative probe specs, a plugin API and a Ghidra symbol/hit bridge. + +Its stated purpose is **simulating and inspecting the GSP firmware's runtime +memory for `gsp-rm-vfio` accuracy measurements**: capture how the firmware +behaves, compare that with the emulation and with the physical backend, and +survive GSP version/device/architecture churn with repeatable evidence. + +The tooling lives in +[`../../debug/risc-v-gsp-rm-vm/`](../../debug/risc-v-gsp-rm-vm/README.md); +its full documentation set (design, usage, scripting, plugins, Ghidra bridge, +accuracy, portability playbooks, roadmap) is under +`debug/risc-v-gsp-rm-vm/docs/`. + +## 2. What it is not + +* **Not a GSP boot environment.** QEMU `virt` has no falcon windows, WPR2, + CSB/TRANSCOM, PMU, timers or FB DMA. `greeneye probe` shows exactly where + the real image first diverges (for the TU104 570.144 image: a load-access + fault at firmware vaddr `0x4009014` reading `0x2000000000000A00`); it does + not boot it. A device model is a roadmap item, not a claim. +* **Not a timing model.** Do not infer latency or concurrency behaviour from + it; its in-scope conclusions are execution flow and memory side effects. +* **Not a replacement for Ghidra or the guest diagnostics.** Ghidra answers + *what is the code*; green-eye answers *what did it do*. The guest driver + remains the oracle for guest-side behaviour. + +## 3. Where it fits + +| Layer | Tool | Question answered | +|---|---|---| +| Static | Ghidra corpus (`userspace/cuda`, `tools/ghidra`) | what is the code? | +| Guest driver | `tools/guest-diag/`, ptrace tracer | what did the driver request? | +| Physical backend | supervised physical cycles, module logs | what did real silicon/the real GSP-RM do? | +| **Firmware runtime (this)** | **green-eye** | **what does the firmware server do, repeatably and safely?** | + +The intended measurement loop is: capture the real backend's RPC behaviour, +run the same stimulus in green-eye, and compare the recorded sessions +(`greeneye compare`) with the accuracy metrics defined in the tool's +`docs/06-ACCURACY.md`. Portability runs (new GSP, new device, GB10-class +architecture) are described in `docs/07-PORTABILITY-PLAYBOOKS.md`. + +## 4. Quick start + +From the repository root: + +```bash +cd debug/risc-v-gsp-rm-vm +./greeneye.sh info # environment check (QEMU, toolchain) +make demo # build the bundled RV64 uproc demo +./greeneye.sh demo # canned instrumentation pass; records a session +./greeneye.sh analyze dumps/<session> +./greeneye.sh probe # real GSP image: report the first divergence +``` + +Requirements: `qemu-system-riscv64` (Ubuntu `qemu-system-misc`), Python 3.10+ +(stdlib only), `clang` + `lld` for the demo firmware. The tool touches no GPU, +no mdev, no kernel module and no host driver. + +## 5. Status and limits (v0.1) + +Working and live-verified: QEMU boot and gdbstub transport without GDB, +hooks/breakpoints/watchpoints, memory tools, structure decoding, session +recording and analysis, declarative probes, plugins, the Ghidra bridge, and +the real-firmware first-fault probe. The TCG full-memory trace plugin builds +and records. Not implemented: device model (R1), custom simulator (R2), MCP +plugin (R3) — all roadmap. + +## 6. When the development flag can be removed + +The marker comes off this document and the developer-tools list when: + +1. the v0.1 acceptance list in `debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md` + passes on a clean checkout; +2. the usage recipes in `docs/02-USAGE.md` are verified end-to-end; +3. a second person or agent can run the quickstart unaided; +4. the session format and CLI are declared stable. + +Until then, treat everything here as in development. diff --git a/docs/07-developers/onboarding-and-standards.md b/docs/07-developers/onboarding-and-standards.md index 241e03f..e62ec28 100644 --- a/docs/07-developers/onboarding-and-standards.md +++ b/docs/07-developers/onboarding-and-standards.md @@ -158,6 +158,7 @@ The following rules each come from a real bug and are enforced in review: | `tools/firmware_analyzer.py` + Ghidra scripts | firmware analysis pipeline | | module debug parameters | guest-memory scanning/poking, VRAM inspection | | QEMU gdbstub workflow | guest virtual-memory inspection/writing | +| `debug/risc-v-gsp-rm-vm/` (green-eye) | **# TOOL IN DEVELOPMENT - NOT READY YET!** — RISC-V GSP/uproc VM runtime inspection, session recording and `gsp-rm-vfio` accuracy measurement (see [`../05-operations/03-risc-v-gsp-rm-vm.md`](../05-operations/03-risc-v-gsp-rm-vm.md)) | | `wiki/` documents | protocol and firmware deep dives | ## 5. Landing a change diff --git a/docs/README.md b/docs/README.md index c74c59a..61aa529 100644 --- a/docs/README.md +++ b/docs/README.md @@ -169,10 +169,11 @@ Read in order if you are new. Jump directly if you know what you need. | 17 | [Practical challenges and adversarial interoperability](04-implementation/02-practical-challenges-and-adversarial-interop.md) | developers, stakeholders | 20 min | | 18 | [Performance, correctness, security](05-operations/01-performance-correctness-security.md) | developers, auditors | 25 min | | 19 | [Debugging and maintenance workflows](05-operations/02-debugging-and-maintenance-workflows.md) | developers, maintainers | 25 min | -| 20 | [Hardware and software matrix](06-portability/hardware-software-matrix.md) | developers, integrators | 20 min | -| 21 | [Developer onboarding and standards](07-developers/onboarding-and-standards.md) | contributors | 20 min | -| 22 | [HAAEEP and collaboration](08-ecosystem/haaeep-and-collaboration.md) | stakeholders, partners | 20 min | -| 23 | [Glossary and FAQ](09-reference/glossary-and-faq.md) | everyone | reference | +| 20 | [GSP in a RISC-V VM (green-eye, experimental)](05-operations/03-risc-v-gsp-rm-vm.md) — **# TOOL IN DEVELOPMENT - NOT READY YET!** | developers, analysts | 15 min | +| 21 | [Hardware and software matrix](06-portability/hardware-software-matrix.md) | developers, integrators | 20 min | +| 22 | [Developer onboarding and standards](07-developers/onboarding-and-standards.md) | contributors | 20 min | +| 23 | [HAAEEP and collaboration](08-ecosystem/haaeep-and-collaboration.md) | stakeholders, partners | 20 min | +| 24 | [Glossary and FAQ](09-reference/glossary-and-faq.md) | everyone | reference | Row 15 is a **deferred future goal**: host NVIDIA-driver acceleration over a virtual function. The feasibility verdict and a first design sketch are @@ -190,6 +191,11 @@ The pre-reboot flat documents have been merged into this tree and removed * debugging: gdbstub/NMI workflows, the EPT-livelock case study and the failure taxonomy → [`05-operations/02-debugging-and-maintenance-workflows.md`](05-operations/02-debugging-and-maintenance-workflows.md) +* firmware runtime analysis (experimental): running GSP/uproc firmware in a + RISC-V QEMU VM with hooks, watchpoints, memory dumps and `gsp-rm-vfio` + accuracy measurement — **# TOOL IN DEVELOPMENT - NOT READY YET!** → + [`05-operations/03-risc-v-gsp-rm-vm.md`](05-operations/03-risc-v-gsp-rm-vm.md) + (tooling in [`../debug/risc-v-gsp-rm-vm/`](../debug/risc-v-gsp-rm-vm/README.md)) * performance: the microbenchmark list and the A/B methodology → [`05-operations/01-performance-correctness-security.md`](05-operations/01-performance-correctness-security.md) * version upgrades: the firmware/driver upgrade runbook (with the corrected |
