Skip to content

A hardware development loop in tools/dev - #5

Merged
diegoparrilla merged 9 commits into
release/v1.1.0from
epic/09-hardware-dev-loop
Sep 29, 2026
Merged

diegoparrilla merged 9 commits into
release/v1.1.0from
epic/09-hardware-dev-loop

Conversation

@diegoparrilla

Copy link
Copy Markdown
Contributor

A hardware development loop in tools/dev/: build, flash, verify, watch the console, read the running RP and drive the app from the host, through a Raspberry Pi Debug Probe (SWD and the debug UART), without touching the ST. Ported from md-microfirmware-template's tools/dev/, cut to what this firmware has and extended with what it needs.

The tools

  • flash.sh <debug|release> builds out of tree in tools/dev/builds/<type>, with the settings of the pico_w-<type> preset. It flashes with picotool, or with the probe when picotool can't see the RP. Then it checks over SWD that the RP runs the ELF, that its flash matches, and that the build ID is the new one. It warns when the m68k sources are newer than target_firmware.h.
  • console.py captures the 921,600-baud debug console to tools/dev/logs/console.log and reads it back (since-boot, grep, wait).
  • swd.py
    • running, verify, build-id, program, reset (through the watchdog), resume, read.
    • counters: frames published, blits acknowledged, publishes that timed out, IKBD bytes lost and ROM3 ring overruns, with rates under --watch.
    • heap, crash, postmortem, select.
    • shared: the cartridge window's shared block.
    • fb: the framebuffer as the ST shows it, as a PNG. It waits for a complete frame: a watchpoint on the frame counter stops core 0 for the dump, while core 1 and the ST keep running. --pair grabs two consecutive frames.
    • key and app (debug builds): write the devhooks mailbox.
  • tools_harness.py checks every tool against the device; 38 of 38 pass. measure_builds.sh and stackdepth.py measure flash, RAM, heap and worst-case stack per build.
  • tools/dev/README.md documents them all, including the three rules:
    • reset through the watchdog, never OpenOCD's reset: core 1 is the chunky-to-planar worker from the start;
    • flash with both cores halted and PIO and DMA stopped;
    • a halt stops the RP's code, not the cartridge bus.

Firmware

  • devhooks.h, a debug-only mailbox, polled from the main loop:
    • keys enter where the ST's own IKBD bytes do;
    • app commands go to a handler the app sets. The demo dispatcher takes demo N, menu, overlay 0|1 and slow_frame MS (every frame late on purpose).
    • In release builds the calls compile to nothing: the release image is byte-identical apart from its build ID and date.
  • fbAckTimeouts counts the publishes that gave up waiting for the ST's blit acknowledgement. It should stay at 0 while the ST runs the app.

Found on the way

postmortem locked core 0 up on resume. For GDB's memory map, OpenOCD probes the flash size on connect with a routine in its work area, and core 0 came back with its MSP still pointing into that area. The fix is to run GDB without a memory map; four postmortems in a row then left the RP running.

Verified on hardware

On a Mega ST (TOS 1.04) with the debug build, a script toured the app from the host after an ST reset:

  • each demo launched with key, a frame grabbed from each, back with esc;
  • a postmortem, then a --pair grab.

What the counters showed:

  • The ST acknowledged about 50 blits per second throughout, including after the postmortem and the grabs.
  • No publish timed out while it ran.
  • No IKBD bytes were lost and the ring never overran.
  • The grabs showed the right screens, and the ST's own screen followed the tour.

flash.sh debug and flash.sh release were verified on the RP.

fb_publish() waits for the ST's blit acknowledgement before it overwrites
the cartridge framebuffer, and gives up after 60 ms. fbAckTimeouts counts
those: expected until the ST runs userfw, never while it does, where each
one is a frame that could have torn. A plain global, readable over SWD.
md-microfirmware-template's swd.py, cut to what this firmware has: running,
verify, build-id, read, program, reset, resume, heap, select, crash and
postmortem, all through the Debug Probe and OpenOCD, without the firmware's
help. The command channel's tools (ring, inject, key, app, screen, text,
shared) are not ported: this firmware has no command channel, terminal or
mono framebuffer.

counters reads this firmware's own: the ST's hellos, frames published,
blits acknowledged, publishes that timed out, IKBD bytes lost and ROM3
ring overruns. --watch prints what changed, with the frame and blit
rates (50 a second while the ST runs the app).

OpenOCD's work area moves to the lower 2 KB of SCRATCH_X, not all 4 KB:
that is core 1's stack, and core 1 always runs here (the c2p worker);
it uses about 100 bytes at the top.
console.py is md-microfirmware-template's: it captures the Debug Probe's
UART at 921,600 baud into tools/dev/logs/console.log with timestamps, and
reads it back (tail, since-boot, grep, wait).

flash.sh <release|debug> builds incrementally in tools/dev/builds/<type>
with the settings of the pico_w-<type> preset (never touching rp/build-*
or the submodules), keeps each ELF under its build ID, flashes with
picotool or the Debug Probe, and checks over SWD that the RP runs the
ELF, that its flash matches and that the build ID is the new one. It
warns when target/atarist/ changed after target_firmware.h was made:
the m68k image needs target/atarist/build.sh first.
After a postmortem core 0 locked up at once. Its MSP still held the stack
of the routine OpenOCD runs in its work area to probe the flash size when
GDB connects and asks for a memory map (0x20040400), while its SP was
right. Without the memory map there is no probe: GDB reads the stacks and
variables all the same, and the RP runs on after the resume (four
postmortems in a row on a release build, one on a debug build, frames
published before and after each).
fb OUT.png reads the cartridge framebuffer, undoes the reversed 48-byte
chunks the MOVEM blit needs, decodes the ST's low-resolution planes with
the published palette and writes an RGB PNG: what the ST shows. A dump
takes longer than a frame, so core 0 is stopped by a watchpoint on the
frame counter, which fb_publish() writes last: at each hit a frame is
complete, and the next cannot start until the dump is done. --pair grabs
two consecutive frames the same way, --raw also writes the planar bytes,
--now reads the window without waiting. Core 1 and the ST keep running;
core 0 stops for the dump (about 0.1 s). The session leaves core 1 out
(USE_CORE 0: rp2040.cfg makes the cores an SMP pair, and a watchpoint on
one then needs the other halted), and a second run always clears core
0's watchpoints and releases it, so a grab that found no frame cannot
leave the core to halt later.

shared prints the command sentinel (as the ST's move.l reads it), the
frame counter's low word, the shared-variable slots (named by any
*_SVAR_* define), the palette, the head of the audio buffer and the boot
status with its message. Offsets come from cart_shared.h.
devhooks.h, as md-microfirmware-template's: a struct found by its symbol,
written over SWD, polled from the main loop and acknowledged; in release
builds the calls compile to nothing and the mailbox does not exist (the
release image is byte-identical but for its build ID and date).

Two kinds of request. A key: IKBD bytes fed in where the ST's own
arrive (ikbd_consume_rom3_sample), so the demux, the ESC handling and the
app see a real keyboard. An app command, for the handler the app sets:
the demo dispatcher takes demo N, menu, overlay 0|1 (the DRAW/C2P
readout) and slow_frame MS, which stalls every frame on purpose.

emul.c and hello_text's emul.c poll the mailbox; emul.c registers the
dispatcher's handler.
key types on the ST's keyboard in a debug build: each key (a scancode or
a name: esc, return, up, down, 1-0, a-z...) is pressed and released, or
only pressed or released. app sends a DEVHOOKS_APP_<NAME> command with
optional words. With them a script walks the menu into each demo and
back, and fb grabs what each shows.
measure_builds.sh and stackdepth.py are md-microfirmware-template's:
five out-of-tree builds (MinSizeRel and Release, each with and without
DEBUG_MODE, and CMake Debug) with -fcallgraph-info=su, their sections
and the heap's room; then the worst-case static stack below a function.

tools_harness.py runs every tool against a debug build on the RP and
prints PASS or FAIL per check: the build ID, the console, counters, heap,
shared, key into a demo and back, app demo, fb and fb --pair,
slow_frame (the publish rate drops and comes back), crash, postmortem
(the app runs on after it), a SELECT press and a reset (core 1 alive
after it). --build also checks both builds' flags and symbols. 38 of 38
pass; the ST need not run the app.
tools/dev/README.md describes the hardware setup, the firmware support
the tools rely on, the three rules (reset through the watchdog, flash
with the cores halted and PIO and DMA stopped, a halt stops the RP's code
but not the bus) and every command. CLAUDE.md's Tests section and
AGENTS.md point to it, and the changelog gets a "for developers" entry
for v1.1.0.
@diegoparrilla
diegoparrilla merged commit 88bee44 into release/v1.1.0 Sep 29, 2026
2 checks passed
@diegoparrilla
diegoparrilla deleted the epic/09-hardware-dev-loop branch September 29, 2026 20:31
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