Files
qemu-xteink/scripts/xteink/emu.py
T
evan 6e0e65218a 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.
2026-07-27 12:39:55 -04:00

344 lines
12 KiB
Python
Executable File

"""Core emulator control shared by the CLI and the web UI."""
import json
import os
import re
import signal
import socket
import subprocess
import tempfile
import time
from pathlib import Path
FLASH_SIZE = 16 * 1024 * 1024
APP_OFFSET = 0x10000
PARTITIONS_OFFSET = 0x8000
RELEASED_ADC = 4095
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
DEFAULT_PATHS = {
"XTEINK_QEMU": PROJECT_ROOT / "dist/qemu-native/bin/qemu-system-riscv32",
"XTEINK_BIOS": PROJECT_ROOT / "dist/qemu-native/share/qemu",
"XTEINK_BOOTLOADER": PROJECT_ROOT / "scripts/assets/esp32c3-bootloader.bin",
"XTEINK_PARTITIONS": PROJECT_ROOT / "scripts/assets/xteink-partitions.bin",
}
BUTTONS = {
"left": ("/machine/adc", "adci[2]", 2242, RELEASED_ADC),
"right": ("/machine/adc", "adci[2]", 5, RELEASED_ADC),
"bottom-1": ("/machine/adc", "adci[1]", 3512, RELEASED_ADC),
"bottom-2": ("/machine/adc", "adci[1]", 2694, RELEASED_ADC),
"bottom-3": ("/machine/adc", "adci[1]", 1493, RELEASED_ADC),
"bottom-4": ("/machine/adc", "adci[1]", 5, RELEASED_ADC),
"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():
return Path(tempfile.gettempdir()) / f"xteink-emu-{os.getuid()}"
def require_env(name):
path = Path(os.environ.get(name, DEFAULT_PATHS[name]))
if not path.exists():
raise RuntimeError(f"{path} does not exist; run 'make xteink'")
return path
def pid_from(state):
try:
return int((state / "pid").read_text().strip())
except (FileNotFoundError, ValueError):
return None
def process_alive(pid):
if pid is None:
return False
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
def is_running(state):
return process_alive(pid_from(state))
def require_running(state):
pid = pid_from(state)
if not process_alive(pid):
raise RuntimeError(f"xteink-emu is not running in {state}")
return pid
def qmp_command(state, execute, arguments=None, timeout=5):
with socket.socket(socket.AF_UNIX) as sock:
sock.settimeout(timeout)
sock.connect(str(state / "qmp.sock"))
stream = sock.makefile("rwb")
json.loads(stream.readline())
def send(command):
stream.write(json.dumps(command).encode() + b"\n")
stream.flush()
while True:
response = json.loads(stream.readline())
if "event" in response:
continue
if "error" in response:
raise RuntimeError(response["error"]["desc"])
return response.get("return")
send({"execute": "qmp_capabilities"})
command = {"execute": execute}
if arguments is not None:
command["arguments"] = arguments
return send(command)
def build_flash(firmware, output):
app = firmware.read_bytes()
if len(app) == FLASH_SIZE:
output.write_bytes(app)
return
if not app or app[0] != 0xE9:
raise RuntimeError(f"{firmware} is not an ESP32 app image")
if APP_OFFSET + len(app) > FLASH_SIZE:
raise RuntimeError(f"{firmware} does not fit in a 16 MiB flash image")
flash = bytearray(b"\xff" * FLASH_SIZE)
bootloader = require_env("XTEINK_BOOTLOADER").read_bytes()
partitions = require_env("XTEINK_PARTITIONS").read_bytes()
flash[: len(bootloader)] = bootloader
flash[PARTITIONS_OFFSET : PARTITIONS_OFFSET + len(partitions)] = partitions
flash[APP_OFFSET : APP_OFFSET + len(app)] = app
output.write_bytes(flash)
def hold_power(state, hold_ms):
path, prop, pressed, released = BUTTONS["power"]
qmp_command(state, "qom-set", {"path": path, "property": prop, "value": pressed})
try:
time.sleep(hold_ms / 1000)
finally:
qmp_command(state, "qom-set", {"path": path, "property": prop, "value": released})
def launch(state, firmware, sdcard, variant="x3", power_hold_ms=2000, interactive=False):
"""Boot QEMU in `state`, returning the process once its QMP socket answers."""
old_pid = pid_from(state)
if process_alive(old_pid):
raise RuntimeError(f"xteink-emu is already running as PID {old_pid} in {state}")
if variant not in VARIANTS:
raise RuntimeError(f"unknown variant: {variant}")
firmware = Path(firmware).expanduser().resolve()
sdcard = Path(sdcard).expanduser().resolve()
if not firmware.is_file():
raise RuntimeError(f"firmware not found: {firmware}")
if not sdcard.is_file():
raise RuntimeError(f"SD card image not found: {sdcard}")
state.mkdir(parents=True, exist_ok=True, mode=0o700)
for name in ("pid", "qmp.sock", "serial.log", "qemu.log", "wait.offset"):
(state / name).unlink(missing_ok=True)
(state / "serial.log").touch()
(state / "wait.offset").write_text("0\n")
flash = state / "flash.bin"
build_flash(firmware, flash)
efuse = bytearray(1024)
efuse[0x18:0x1E] = bytes.fromhex("563412005452")
efuse[0x26] = 0x0C
(state / "efuse.bin").write_bytes(efuse)
command = [
str(require_env("XTEINK_QEMU")),
"-machine", f"xteink,variant={variant}",
"-accel", "tcg",
"-L", str(require_env("XTEINK_BIOS")),
"-display", "gtk" if interactive else "none",
"-serial", "stdio",
"-monitor", "none",
"-qmp", f"unix:{state / 'qmp.sock'},server=on,wait=off",
"-nic", "user,model=esp32c3_wifi,mac=52:54:00:12:34:56,net=192.168.4.0/24",
"-drive", f"file={flash},if=mtd,format=raw",
"-drive", f"file={state / 'efuse.bin'},if=none,format=raw,id=efuse",
"-global", "driver=nvram.esp32c3.efuse,property=drive,value=efuse",
"-drive", f"file={sdcard},if=sd,format=raw",
]
if interactive:
process = subprocess.Popen(command)
else:
with (state / "serial.log").open("ab") as serial_log:
process = subprocess.Popen(
command,
stdin=subprocess.DEVNULL,
stdout=serial_log,
stderr=subprocess.STDOUT,
start_new_session=True,
)
(state / "pid").write_text(f"{process.pid}\n")
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if process.poll() is not None:
message = (state / "serial.log").read_text(errors="replace")
raise RuntimeError(f"QEMU exited during startup\n{message}")
try:
qmp_command(state, "query-status")
if power_hold_ms:
hold_power(state, power_hold_ms)
return process
except (FileNotFoundError, ConnectionRefusedError, socket.timeout):
time.sleep(0.05)
process.terminate()
raise RuntimeError("QEMU did not open its QMP socket")
def shutdown(state):
pid = require_running(state)
try:
qmp_command(state, "quit")
except (ConnectionError, FileNotFoundError):
os.kill(pid, signal.SIGTERM)
deadline = time.monotonic() + 5
while process_alive(pid) and time.monotonic() < deadline:
time.sleep(0.05)
if process_alive(pid):
os.kill(pid, signal.SIGKILL)
(state / "pid").unlink(missing_ok=True)
(state / "qmp.sock").unlink(missing_ok=True)
return pid
def wait_for_log(state, match, timeout):
"""Scan the serial log from the persistent cursor and return the first matching line."""
require_running(state)
offset_path = state / "wait.offset"
try:
pattern = re.compile(match)
except re.error as error:
raise RuntimeError(f"invalid regex: {error}") from error
try:
offset = int(offset_path.read_text().strip())
except (FileNotFoundError, ValueError):
offset = 0
deadline = time.monotonic() + timeout
with (state / "serial.log").open("r", errors="replace") as log:
log.seek(offset)
while True:
line = log.readline()
if line:
if pattern.search(line):
offset_path.write_text(f"{log.tell()}\n")
return line
continue
if time.monotonic() >= deadline:
raise RuntimeError(f"timed out after {timeout:g}s waiting for /{match}/")
if not is_running(state):
raise RuntimeError("QEMU exited while waiting for serial output")
time.sleep(0.05)
def frame_generation(state):
"""Panel refresh counter; changes only when the e-ink display actually redraws."""
return qmp_command(state, "qom-get", {"path": "/machine", "property": "frame-generation"})
def await_frame(state, since, timeout):
"""Block until the panel redraws, returning the new generation or None on timeout."""
deadline = time.monotonic() + timeout
while True:
current = frame_generation(state)
if current != since:
return current
if time.monotonic() >= deadline:
return None
time.sleep(0.05)
def adc_samples(state, prop):
"""Number of times the guest has read this ADC channel."""
channel = prop[prop.index("[") + 1 : prop.index("]")]
return qmp_command(state, "qom-get", {"path": "/machine/adc", "property": f"adcsamples[{channel}]"})
def await_samples(state, prop, count, timeout):
"""Block until the guest reads the channel again.
Holding a button for a fixed wall-clock time is unreliable: the firmware only polls the
resistor ladder between e-ink refreshes, which can block for seconds.
"""
start = adc_samples(state, prop)
deadline = time.monotonic() + timeout
while adc_samples(state, prop) - start < count:
if time.monotonic() >= deadline:
raise RuntimeError(f"timed out after {timeout:g}s waiting for the guest to sample {prop}")
time.sleep(0.01)
def set_button(state, name, down):
path, prop, pressed, released = BUTTONS[name]
qmp_command(state, "qom-set", {"path": path, "property": prop, "value": pressed if down else released})
def press(state, name, samples=4, timeout=90, hold_ms=500):
path, prop, _, _ = BUTTONS[name]
adc = not path.endswith("gpio")
set_button(state, name, down=True)
try:
if adc:
await_samples(state, prop, samples, timeout)
else:
time.sleep(hold_ms / 1000)
finally:
set_button(state, name, down=False)
if adc:
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:
time.sleep(settle_ms / 1000)
output = Path(output).expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
qmp_command(state, "screendump", {"filename": str(output), "format": "png"}, timeout=30)
return output