feat(xteink): report and dump guest RAM usage
Adds a 'memory' subcommand and matching web tab that show how much of each ESP32-C3 RAM window the guest has written, plus raw region dumps via QMP pmemsave.
This commit is contained in:
+3
-1
@@ -41,11 +41,13 @@ nix run .#xteink-emu -- interactive --firmware firmware.bin --sdcard sdcard.img
|
||||
nix run .#xteink-emu -- wait --timeout 30 --match 'ready'
|
||||
nix run .#xteink-emu -- button bottom-2 --hold-ms 100
|
||||
nix run .#xteink-emu -- screenshot --output screen.png
|
||||
nix run .#xteink-emu -- memory
|
||||
nix run .#xteink-emu -- memory --region iram --dump iram.bin
|
||||
nix run .#xteink-emu -- stop
|
||||
nix run .#xteink-emu -- web --host 127.0.0.1 --port 8080
|
||||
```
|
||||
|
||||
The code lives in `scripts/xteink/` (`emu.py` drives QEMU, `cli.py` is the command line, `web/` is the browser UI); `scripts/xteink-emu.py` is the entry point. `web` serves a page that uploads firmware and an SD image, boots them, drives the buttons, streams the panel and serial log, and browses or edits the SD card through mtools. Card edits stop the emulator first, since QEMU caches the image; use *Apply & Reboot* to bring it back. The screen redraws off the panel's `frame-generation` counter, so it updates on each real e-ink refresh rather than on a timer.
|
||||
The code lives in `scripts/xteink/` (`emu.py` drives QEMU, `cli.py` is the command line, `web/` is the browser UI); `scripts/xteink-emu.py` is the entry point. `web` serves a page that uploads firmware and an SD image, boots them, drives the buttons, streams the panel and serial log, and browses or edits the SD card through mtools. Card edits stop the emulator first, since QEMU caches the image; use *Apply & Reboot* to bring it back. `memory` reports how much of each RAM window the guest has written and dumps a region to a file; the web UI shows the same table under its *Memory* tab. QEMU zeroes RAM at reset, so the number is a high-water mark, not live heap — the firmware's own heap logging is authoritative for that. The screen redraws off the panel's `frame-generation` counter, so it updates on each real e-ink refresh rather than on a timer.
|
||||
|
||||
State defaults to `/tmp/xteink-emu-$UID`; pass `--state-dir` to any subcommand for parallel instances. `interactive` opens the display and maps Left/Right, 1–4, and P to the matching device buttons. Hold P for a long power press and close the window to stop. `wait` advances a persistent log cursor after each match. The emulator exposes the open Wi-Fi network `qemu`; guest traffic uses QEMU's user-mode NAT.
|
||||
|
||||
|
||||
@@ -81,6 +81,20 @@ def screenshot(args):
|
||||
print(emu.screendump(state, args.output, args.settle_ms))
|
||||
|
||||
|
||||
def memory(args):
|
||||
state = state_dir(args)
|
||||
emu.require_running(state)
|
||||
if args.dump:
|
||||
print(emu.dump_memory(state, args.region, args.dump))
|
||||
return
|
||||
for row in emu.memory_usage(state):
|
||||
percent = 100 * row["touched"] / row["size"]
|
||||
print(
|
||||
f"{row['region']:<7}{row['base']:#010x} {row['size'] // 1024:>4} KiB total"
|
||||
f" {row['touched'] // 1024:>4} KiB touched ({percent:.0f}%)"
|
||||
)
|
||||
|
||||
|
||||
def web(args):
|
||||
serve(state_dir(args), args.host, args.port, args.log_bytes)
|
||||
|
||||
@@ -134,6 +148,12 @@ def parser():
|
||||
command.add_argument("--settle-ms", type=int, default=1500, help="wait for the panel to refresh before capturing")
|
||||
command.set_defaults(handler=screenshot)
|
||||
|
||||
command = commands.add_parser("memory", help="RAM high-water marks, or dump a region with --dump")
|
||||
add_state(command)
|
||||
command.add_argument("--region", choices=emu.MEMORY_REGIONS, default="dram")
|
||||
command.add_argument("--dump", help="write the raw region to this file")
|
||||
command.set_defaults(handler=memory)
|
||||
|
||||
command = commands.add_parser("web")
|
||||
add_state(command)
|
||||
command.add_argument("--host", default="127.0.0.1")
|
||||
|
||||
@@ -31,6 +31,12 @@ BUTTONS = {
|
||||
"power": ("/machine/gpio", "input-level[3]", False, True),
|
||||
}
|
||||
VARIANTS = ("x3", "x4")
|
||||
# ESP32-C3 RAM windows. dram and iram alias the same internal SRAM, so their numbers overlap.
|
||||
MEMORY_REGIONS = {
|
||||
"dram": (0x3FC80000, 0x60000),
|
||||
"iram": (0x4037C000, 0x60000 + 16 * 1024),
|
||||
"rtcram": (0x50000000, 0x2000),
|
||||
}
|
||||
|
||||
|
||||
def default_state_dir():
|
||||
@@ -302,6 +308,31 @@ def press(state, name, samples=4, timeout=90, hold_ms=500):
|
||||
await_samples(state, prop, samples, timeout)
|
||||
|
||||
|
||||
def dump_memory(state, region, output):
|
||||
base, size = MEMORY_REGIONS[region]
|
||||
output = Path(output).expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
qmp_command(state, "pmemsave", {"val": base, "size": size, "filename": str(output)}, timeout=30)
|
||||
return output
|
||||
|
||||
|
||||
def memory_usage(state):
|
||||
"""Bytes the guest has written per RAM region.
|
||||
|
||||
QEMU zeroes RAM at reset, so a non-zero byte is one the firmware touched. That makes this a
|
||||
high-water mark rather than live heap usage: freed blocks keep their old contents.
|
||||
"""
|
||||
scratch = state / "memory.bin"
|
||||
rows = []
|
||||
try:
|
||||
for region, (base, size) in MEMORY_REGIONS.items():
|
||||
data = dump_memory(state, region, scratch).read_bytes()
|
||||
rows.append({"region": region, "base": base, "size": size, "touched": len(data) - data.count(0)})
|
||||
finally:
|
||||
scratch.unlink(missing_ok=True)
|
||||
return rows
|
||||
|
||||
|
||||
def screendump(state, output, settle_ms=0):
|
||||
# The e-ink panel needs a full refresh cycle before a capture shows the latest frame.
|
||||
if settle_ms:
|
||||
|
||||
@@ -118,6 +118,20 @@ def serve(state, host, port, log_bytes):
|
||||
except UnicodeDecodeError:
|
||||
return self.reply_json({"binary": True, "size": len(data)})
|
||||
return self.reply_json({"binary": False, "text": text})
|
||||
if route == "/memory":
|
||||
emu.require_running(state)
|
||||
return self.reply_json(emu.memory_usage(state))
|
||||
if route == "/memory/dump":
|
||||
emu.require_running(state)
|
||||
region = query.get("region", ["dram"])[0]
|
||||
if region not in emu.MEMORY_REGIONS:
|
||||
return self.send_error(404)
|
||||
dump = emu.dump_memory(state, region, state / "web-memory.bin")
|
||||
return self.reply(
|
||||
dump.read_bytes(),
|
||||
"application/octet-stream",
|
||||
headers=[("Content-Disposition", f'attachment; filename="{region}.bin"')],
|
||||
)
|
||||
if route == "/log":
|
||||
# Tail only; a long-lived emulator log outgrows what the page can hold.
|
||||
return self.reply((state / "serial.log").read_bytes()[-log_bytes:])
|
||||
|
||||
@@ -120,6 +120,7 @@ img { display: block; width: 100%; height: auto; aspect-ratio: 528 / 792; backgr
|
||||
<div class="tabs" role="tablist">
|
||||
<button type="button" role="tab" aria-selected="true" data-tab="logs">Logs</button>
|
||||
<button type="button" role="tab" aria-selected="false" data-tab="files">Files</button>
|
||||
<button type="button" role="tab" aria-selected="false" data-tab="memory">Memory</button>
|
||||
</div>
|
||||
|
||||
<div id="panel-logs" class="tab-panel">
|
||||
@@ -130,6 +131,14 @@ img { display: block; width: 100%; height: auto; aspect-ratio: 528 / 792; backgr
|
||||
<pre id="log" class="log" role="log" aria-live="polite"></pre>
|
||||
</div>
|
||||
|
||||
<div id="panel-memory" class="tab-panel" hidden>
|
||||
<div class="log-header">
|
||||
<span>RAM written since boot (high-water mark, not live heap)</span>
|
||||
<button id="memory-refresh" type="button" class="file-button">Refresh</button>
|
||||
</div>
|
||||
<table class="file-list"><tbody id="memory-list"></tbody></table>
|
||||
</div>
|
||||
|
||||
<div id="panel-files" class="tab-panel" hidden>
|
||||
<div id="files-browse">
|
||||
<div class="file-location">
|
||||
@@ -290,9 +299,37 @@ for (const tab of document.querySelectorAll("[data-tab]")) {
|
||||
$("panel-" + other.dataset.tab).hidden = other !== tab;
|
||||
}
|
||||
if (tab.dataset.tab === "files") refreshFiles();
|
||||
if (tab.dataset.tab === "memory") refreshMemory();
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshMemory() {
|
||||
const list = $("memory-list");
|
||||
list.innerHTML = "";
|
||||
let rows;
|
||||
try {
|
||||
rows = JSON.parse(await call("memory"));
|
||||
} catch (error) {
|
||||
report(String(error), true);
|
||||
return;
|
||||
}
|
||||
for (const entry of rows) {
|
||||
const row = list.insertRow();
|
||||
const percent = (100 * entry.touched / entry.size).toFixed(0);
|
||||
row.insertCell().textContent = entry.region;
|
||||
row.insertCell().textContent = "0x" + entry.base.toString(16);
|
||||
row.insertCell().textContent =
|
||||
`${Math.round(entry.touched / 1024)} / ${Math.round(entry.size / 1024)} KiB (${percent}%)`;
|
||||
const download = document.createElement("a");
|
||||
download.href = "memory/dump?region=" + entry.region;
|
||||
download.textContent = "dump";
|
||||
download.download = entry.region + ".bin";
|
||||
row.insertCell().append(download);
|
||||
}
|
||||
}
|
||||
|
||||
$("memory-refresh").onclick = refreshMemory;
|
||||
|
||||
let directory = "/";
|
||||
let editing = null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user