Skip to content

EPIC-17: faster hardware iteration - #11

Merged
diegoparrilla merged 10 commits into
release/v1.1.0from
epic/17-faster-hardware-iteration
Sep 15, 2026
Merged

diegoparrilla merged 10 commits into
release/v1.1.0from
epic/17-faster-hardware-iteration

Conversation

@diegoparrilla

Copy link
Copy Markdown
Contributor

Tooling epic, run before EPIC-11 (D-10): cut the round trips between the developer at the Atari ST and the agent at the Mac. The tools reach the RP only through picotool, the Debug Probe (SWD) and the console UART, never through the firmware's own HTTP server (D-11), so they work with any microfirmware built from this template and on a hung RP.

Host tools, in tools/dev/

  • console.py captures the debug console (921,600 baud) to a timestamped log and shows it live. since-boot, tail, grep and wait read that log, so a boot log no longer has to be pasted by hand. It finds the probe by its USB name and reopens it after an unplug.
  • flash.sh <release|debug> builds out of tree and incrementally (35 s cold, 4 s warm; rp/build and the submodules untouched), flashes with picotool or the probe, and then checks over SWD that the RP booted the ELF, that its flash matches byte for byte and that it carries the new build ID. About 18 s from command to a confirmed boot.
  • swd.py wraps OpenOCD: running, verify, build-id, read, program, resume, screen (the setup menu as a PNG), text (the menu as text), shared (the shared variables by name), select, key, app, inject, crash and postmortem. Memory is read while the CPU runs, so a hung RP can be inspected too.
  • smoke.py, measure_builds.sh, stackdepth.py move in from a gitignored folder. The smoke script now stops the countdown, presses SELECT and checks the menu text and the firmware's own state itself; only the cold boot, the [G] desktop file copy and switching the ST to [U] still need a person.

Nothing hard-codes an address: offsets come from chandler.h and the ELF's symbols (C-04).

Firmware changes

  • Build ID in both build types. rp/src/build_id.cmake regenerates build_id.h and build_id.c on every build (not at configure time, so an incremental build is never stale) with <sha7>, or <sha7>-dirty.<diff7> when the tree has uncommitted changes. The same tree gives the same ID and a byte-identical binary. It is reported on the console at boot, in GET /api/v1/system/health next to a new debug flag, and by sidecart.py health.
  • Debug-only mailbox (rp/src/include/devhooks.h, self-contained like debug.h): host tools write a request over SWD, devhooks_poll() in the main loop runs it. A protocol request goes through the new chandler_injectProtocol() as if the ST had sent it, so an injected keystroke takes the firmware's normal path; an app request goes to a handler the app registers (emul.c: countdown stop and restart). release contains none of it.
  • Crash PC no longer lost. The once-a-second stall timer wrote watchdog_hw->scratch[1] = 0 whenever the watchdog had been fed, and that is where the panic and HardFault handlers store the crash PC in the 100 ms before the reboot. Crashes whose reboot window overlapped a tick reported pc=0x00000000. The timer now only replaces its own values there. This is an EPIC-09 bug the new tools found.
  • SELECT needs no firmware support: swd.py select forces the pin's input high through IO_BANK0 GPIOn_CTRL.INOVER. A long press needs --force, because it erases the global settings.

release grew 64 bytes of code and 32 bytes of RAM against release/v1.1.0, all of it the build ID and the two new health fields. It exposes no debug-only route or symbol.

Verified on hardware

Check Result
Smoke on debug (--manual) 13 passed, 0 failed; console clean: no panic, HardFault, ROM3 overrun or dropped bytes
Smoke on release (--manual) 12 passed; the one failure was the script using the mailbox a release build lacks, fixed
Menu PNG against the ST Matched before HELLO, after an ST reset (Phystop, Screenmem, countdown line) and after a forced panic (Recovered: panic @10005194)
Shared variables after HELLO reloc and memtop 0xF4000, drive C, first file descriptor 64, hook vector $70
Countdown stop and restart, key injection Menu bottom line changed as expected; console logged the injected keystroke
SELECT short press over SWD released after 304 ms, Short press detected, next boot reset
Post-mortem of a forced hang Backtrace health_runPendingTest health.c:415 ← health_tick ← emul_start, RP resumed
Crash report after panic and HardFault health.c:404 and health.c:409; five panics in a row all kept the PC after the fix
Flash and verify debug with picotool, release through the probe; a wrong ELF fails verify

Known limitations

  • Halting the RP pauses its timer and watchdog, so a hang halted in its first seconds reboots as reboot rather than hang; the backtrace is the better evidence.
  • Keys and app commands need a debug build; on release those steps fall back to prompts.
  • The tools see only what the RP draws (the setup menu), not the ST's GEM screen, and cannot reset or power-cycle the ST.

tools/dev/console.py watch reads the Debug Probe's UART at 921,600
baud, timestamps each line, appends it to tools/dev/logs/console.log and
shows it in the terminal. It finds the probe by its USB name, and waits
for it and reopens it after an unplug. since-boot, tail, grep and wait
read the log, so a boot log no longer has to be pasted by hand.

Standard library only. Verified on hardware: a probe reset produced a
complete boot log, and wait matched the GEMDRIVE ready line; unplugging
and replugging the probe resumed the capture.
…-17 STORY-02)

Every build now carries the git commit it came from: <sha7>, or
<sha7>-dirty.<diff7> with uncommitted changes, where <diff7> hashes the
diff. rp/src/build_id.cmake regenerates build_id.h on every build and
rewrites it only when the ID changes, so incremental builds never report
a stale ID and only health.c and http_server.c recompile. The same tree
gives the same ID and a byte-identical binary.

The ID is printed on the console at boot and returned by
GET /api/v1/system/health with a new "debug" flag; sidecart.py health
prints both. The health reply buffer grows by 32 bytes for them.

tools/dev/flash.sh builds out of tree and incrementally, keeps the ELF by
build ID, flashes with picotool or through the Debug Probe, and waits
until the health report shows the new ID and build type, printing the
console since boot if it does not.

Verified on hardware: debug with picotool and with the probe, release
with the probe, and the timeout path; about 18 s from command to a
confirmed boot for an unchanged build.
…C-17 STORY-02)

The developer tools must work with any microfirmware from the template
and with a hung RP, so they reach it only through picotool, the Debug
Probe and the console UART.

tools/dev/swd.py wraps OpenOCD and reads memory while the CPU runs:
running waits until VTOR holds the ELF's RAM vector table, verify
compares the whole flash with the ELF, build-id reads the new
release_build_id string from flash, read dumps a range, and program
flashes through the probe. Runs that fail on a momentary debug-port drop
are retried.

build_id.cmake now also generates build_id.c with release_build_id,
kept through --gc-sections. flash.sh checks running, verify and build-id
over SWD instead of polling GET /api/v1/system/health, which keeps
reporting the build ID for API users.

Verified on hardware: debug with picotool and release with the probe,
each running, verified byte for byte and carrying its build ID; a release
ELF against a running debug build fails verify.
swd.py screen reads the 320x200 framebuffer at the top of the cartridge
window while the RP runs, or while it is halted, and writes it as a PNG,
so the setup menu can be checked without looking at the ST. swd.py
shared prints the command sentinel, the random token and the shared
variables, named after the *_SVAR_* indexes in rp/src/include.

Neither hard-codes an address: the window comes from the ELF's
__rom_in_ram_start__ and the offsets from chandler.h, whose #defines are
evaluated by a small parser. Without --elf the tools pick the cached ELF
whose build ID the RP carries.

swd.py running now also requires core 0 not to be halted, and swd.py
resume releases both cores through DHCSR: a halted core 1 pauses the
RP2040's timer and watchdog and leaves core 0 asleep, and OpenOCD's own
resume fails in a new OpenOCD run.

Verified on hardware: the PNG matched the ST's screen before HELLO, after
an ST reset (Phystop, Screenmem and the countdown line), and after a
forced panic (Recovered: panic @10005194, the PC on the console). After
HELLO the shared variables showed reloc and memtop 0xF4000, drive C and
hook vector $70.
…Y-04)

swd.py select presses the SELECT button without firmware code: it forces
the pin's input high through the RP2040's GPIO input override for the
hold time. A long press needs --force, because md-devops then erases the
global settings.

For what the firmware must do itself, devhooks.h adds a debug-only
mailbox in RAM, self-contained like debug.h. Tools write a request over
SWD and devhooks_poll(), called from the main loop, runs it and
acknowledges it. A protocol request is queued through the new
chandler_injectProtocol() as if the ST had sent it, so swd.py key and
swd.py inject use the firmware's normal command path. An app request
goes to a handler the app registers; emul.c handles countdown stop and
restart, named by DEVHOOKS_APP_* defines that swd.py app reads. Release
builds contain none of it.

Verified on hardware with the menu: countdown stop and restart and a
key 'z' each showed the expected bottom line in swd.py screen, the
console logged the injected keystroke, and a SELECT short press was
detected after 304 ms and rebooted the RP with reason reset.
swd.py crash reads the watchdog reason and scratch registers, and the
breadcrumb the firmware decoded at boot when the ELF has it, and resolves
the crash PC and LR to source lines with addr2line. The RP keeps running.

swd.py postmortem halts the RP and prints both cores' backtraces, the
registers, the watchdog registers and the interesting variables through
GDB, then releases both cores. --leave-halted keeps it stopped.

It found a real bug in the crash breadcrumb: the once-a-second stall
timer cleared watchdog scratch[1] whenever the watchdog had been fed,
and that is where the panic and HardFault handlers store the crash PC
just before the reboot. A crash whose reboot delay overlapped a tick
lost its PC. The timer now only replaces its own values there, never a
crash PC. Five test panics in a row now all report the PC.

Verified on hardware: panic resolves to health.c:404 and HardFault to
health.c:409 with the faulting LR, and a postmortem during a forced hang
showed health.c:415 in health_tick from emul_start, before the watchdog
rebooted.
…EPIC-17 STORY-06)

smoke.py, measure_builds.sh and stackdepth.py move from the gitignored
docs/epics/tools into tools/dev, so EPIC-16 and anyone else can run them.

smoke.py now uses the Debug Probe: it stops the countdown through the
debug mailbox, presses SELECT through the pin override, and checks the
menu text and the firmware's own haltCountdown flag instead of asking.
Only the cold boot, the [G] desktop file copy and switching the ST to [U]
still need a person (--manual), and --no-swd turns the probe off. The
JSON report now carries the build ID, the build type, the ELF used and
the console log since the last boot.

swd.py text prints the terminal's character buffer, which is the menu as
text; the bottom status line is drawn straight to the framebuffer, so
swd.py screen stays the way to read it.

Verified on hardware: a probe-driven run with no prompts passed 8 steps
and skipped the 3 that need a person or the Runner, in 33 s.
Two faults from moving smoke.py into tools/dev, both found in the first
full manual run:

The default --hellodbg path still went three directories up, which was
right under docs/epics/tools, so the Runner step crashed with a
FileNotFoundError. It now goes two up, and an unreadable test program
fails the step instead of the run.

The countdown step read the RP's screen as soon as the tester confirmed
the cold boot and reported "menu text NOT found". After a reset the RP
shows its boot screen for about 11 s before the menu, and the countdown
starts with the menu; the step now waits for the menu text first. The
SELECT step waits the same way.
flash.sh read the generated build_id.h unconditionally, so building an
older commit or another microfirmware with --src failed. The ID is now
optional: such a build reports no-build-id and is still verified against
its ELF over SWD.

Found while building release/v1.1.0 before this epic to compare sizes.
The countdown step used the debug mailbox whenever the probe was
available, so a release run failed it with "has no devhooksMailbox"
instead of falling back to the tester, as it does with --no-swd. It now
checks for the mailbox symbol first; without it the step asks, and the
probe still confirms the menu text. Without --manual it skips, saying
the build has no mailbox.

Found in the release smoke run of 39f69dc, where it was the only failure.
@diegoparrilla
diegoparrilla merged commit 9a2c0dc into release/v1.1.0 Sep 15, 2026
2 checks passed
@diegoparrilla
diegoparrilla deleted the epic/17-faster-hardware-iteration branch September 15, 2026 23:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant