Skip to content

EPIC-10: latent layout and timing bugs - #10

Merged
diegoparrilla merged 9 commits into
release/v1.1.0from
epic/10-latent-layout-and-timing-bugs
Sep 15, 2026
Merged

diegoparrilla merged 9 commits into
release/v1.1.0from
epic/10-latent-layout-and-timing-bugs

Conversation

@diegoparrilla

Copy link
Copy Markdown
Contributor

Third epic of v1.1.0: find and fix the bugs that only showed up in some builds, and in Release rather than MinSizeRel, so both build types can ship.

MinSizeRel was the symptom. The causes were an alignment-dependent copy of the cartridge image, undefined behaviour in linker symbol declarations, and debug traces that delayed the cartridge enough to miss the ST's boot.

What changes

  • Cartridge image copy works wherever the linker puts it. The image is a uint16_t array streamed out of XIP flash with a word-sized DMA copy that assumed 4-byte alignment. The array is now aligned(4), and a misaligned image falls back to a word copy. An odd word count copies its trailing word, and the 64 KB window is cleared before the copy. Debug builds compare the copy with the image at boot and count non-zero bytes after it.
  • Linker symbols declared as arrays. The cartridge window (__rom_in_ram_start__) and the six flash-region symbols were declared as 4-byte unsigned ints, then read and written as 64 KB and 4 KB regions: undefined behaviour that GCC warned about once the window was cleared. Code size is identical.
  • [G] and [U] launch only after the ST's HELLO. Launching before the ST has booted the cartridge code left it in GEM without the resident blob. The menu now says Reset the Atari ST first: no HELLO since RP boot., and the countdown restarts when HELLO arrives.
  • Debug console at 921,600 baud, buffered. At 115,200 the boot traces delayed the cartridge enough that a power-cycled ST booted into GEM. DEBUG_BUFFERED_CONSOLE in debug.h (default 1) queues DPRINTF text in a 4 KB RAM ring sent by the UART TX interrupt. When the ring is full it waits instead of dropping text, and the exception path writes synchronously. Every library using DPRINTF goes through it, including settings/. Set it to 0 for the blocking console when chasing a hang.
  • USB stdio no longer resets the UART. usbcdc_init re-ran stdio_init_all(), which lost the console bytes in flight.
  • Folder listings return every entry. The streaming listing checked the remaining space before reading an entry without counting the entry's size, so a listing of 100 files returned 15 with truncated: true.
  • App folder defaults to /devops. FOLDER kept the template's /test, which md-devops never uses, so every boot created an empty /test folder. Stored settings with /test are changed to /devops once at boot; the folder is created if missing.

Verified on hardware

A new smoke script checks health before and after each step and fails on any reboot, crash or ROM3 overrun. It covers: cold boot, [G] desktop and file copy, 256 KB upload, download, rename and delete, a listing of 500 files, Runner load, exec and unload with a debug tail, and SELECT.

Build Result
release All steps pass
debug, buffered console at 921,600 11 passed; no panic, HardFault or overrun
release and debug with flash code and constants shifted about 6 KB 11 passed each
release and debug with RAM code and data shifted about 1 KB 11 passed each; no overruns or dropped console text
Power-cycled ST on debug Boots to the DevOps menu
Stored FOLDER=/test Changed to /devops and saved once; other settings kept

A -Wall -Wextra review of application code found no uninitialised-use, array-bounds or aliasing warnings beyond the two declaration fixes.

Found and filed, not fixed here

  • A card without the app folder crash-loops at boot: f_mkdir asks for 32 KB under PICO_MALLOC_PANIC, more than the whole heap (EPIC-11). Before this PR the same happened with /test.
  • commRing is 8 KB-aligned, so .data growing past an 8 KB boundary costs 8 KB of heap (EPIC-12).
  • Debug stack high-water reaches 7,700 bytes in the Runner path (EPIC-12).
  • Trace trimming (per-access shared-variable traces, per-command GEMDRIVE traces, the reset_device() flood on SELECT) is halted; the buffered console removed the stalls.

…10 STORY-02 STORY-03)

The image reaches RAM through the XIP stream, whose address register has
no bits 1:0. target_firmware is a uint16_t array, only 2-byte aligned, so
at an address that is 2 mod 4 every word would be shifted and the ST would
boot without the cartridge. It has been aligned by luck. firmware.py now
emits aligned(4), and COPY_FIRMWARE_TO_RAM falls back to a word copy for
any source that is not 4-byte aligned.

The stream also moves 32-bit words, so an odd word count lost the last
16-bit word. It is now copied separately. The 64 KB window is cleared
before the copy, so nothing from a previous run (a jump from Booster, a
crash reboot) survives past the end of the image. COPY_FIRMWARE_TO_RAM_MEMCPY
took bytes while every other copy macro takes words; it takes words now.

Debug builds check the window against target_firmware after the copy and
count non-zero bytes past the image. target_firmware.h is regenerated by
the m68k build; only the declaration and the header's build date change.
…TORY-01)

__rom_in_ram_start__ was declared as `extern unsigned int`, so to the
compiler it is a 4-byte object and every read or write past those bytes
is undefined behaviour it may optimise on at -O3. Clearing the window
with memset made GCC warn that 65,536 bytes overflow a 4-byte region.
Declare it as an array of unknown size in constants.h and in the two
files that redeclare it. Every use takes its address and casts it, so no
call site changes and the code size is identical.
[G] and [U] make the m68k install GEMDRIVE at the relocation address and
memtop the RP publishes when the ST sends HELLO at cold boot. After the RP
reboots while the ST keeps running (a crash, a SELECT reset, a flash) no
HELLO arrives. Recovery only worked because the previous run's shared
variables survived in RAM; with the window now cleared, the address reads
0 and the ST halts with "Reloc/stack overlap".

The launch commands now refuse until HELLO has landed since the RP booted,
keep the full menu on screen and say "Reset the Atari ST first" on the
bottom strip. When HELLO lands the message clears, and if the refused
launch came from the countdown, the countdown starts again. Keystrokes
the ST sends while it boots are dropped at that point, or the main loop
would halt the restarted countdown before its first tick.

Verified on hardware (release): a launch after an RP reboot is refused
from the countdown and from [U] with the menu intact; after an ST reset
the countdown restarts, runs to 0 and Runner launches without the banner.
DPRINTF blocks on the UART. At the SDK's default 115,200 baud a debug
build printed its flash layout and both settings dumps before copying the
cartridge image, and a power-cycled ST booted into GEM because the
cartridge was not live yet; release, which prints nothing, showed the
menu. At 921,600 the same output is eight times faster and the
power-cycled ST shows the DevOps menu on the debug build too.

The UART clock stays at 48 MHz after the 225 MHz overclock, so the divider
gives 921,413 baud, a 0.02 % error. Only debug builds get the define;
release has no UART console. Set the serial terminal to 921600.
…RY-04)

DPRINTF waited until every byte had left the UART, so each trace stalled
the main loop, the ROM3 command ring and the ST's commands for about 1 ms
at 921,600 baud, and bursts such as GEMDRIVE's per-call traces for much
longer.

DEBUG_BUFFERED_CONSOLE in debug.h, default 1, toggles a buffered console.
DPRINTF formats with fprintf into a funopen stream that queues the text in
a 4 KB ring buffer, and the UART transmit interrupt sends it. When the
ring is full the caller waits for room, or sends bytes itself with
interrupts off, so nothing is lost. Inside an exception the queue and the
new text are sent at once, so crash reports stay complete and in order.
stderr and stdout are redirected to the same queue, so nothing else writes
to the UART behind the interrupt. Set it to 0 for the previous blocking
console, which keeps the last lines before a hang.

Everything lives in debug.h: the shared state and functions are weak
definitions merged by the linker into one copy. debug.h replaces DPRINTF
definitions made earlier, and settings.c includes it, so the settings
library's own fallback DPRINTF no longer bypasses the queue.

Debug builds only: 4 KB less heap and about 500 bytes of flash. Release is
unchanged. Verified on hardware: a power-cycled boot log is complete and in
order, and a forced panic prints its report whole, in order, before the
reboot.
usbcdc_init() called stdio_init_all() a second time in debug builds,
assuming it was idempotent. It is not: stdio_uart_init() runs uart_init(),
which resets the UART and throws away up to 32 bytes still in the console
FIFO, and stdio_usb_init() claims another IRQ and background task on every
call. With the buffered console this showed as exactly 32 bytes missing
from a boot trace.

Start USB stdio only when TinyUSB is not running yet. Release builds, where
main.c does not initialise stdio, behave as before.
__flash_binary_start and the ROM temp, Booster, config, lookup and global
config region symbols were declared as `extern unsigned int`, 4-byte
objects to the compiler. aconfig.c reads the whole 4 KB lookup table
through a pointer taken from _global_lookup_flash_start, which is undefined
behaviour the optimiser may use. Declare them as arrays of unknown size, as
__rom_in_ram_start__ already is. Every use takes the address and casts it,
so no call site changes.
…-05)

The streaming listing checked for 32 bytes of room, enough for the closing
envelope, before reading the next entry. An entry needs about 80 bytes, up
to 350 with a long name, so once a chunk had between 32 and 80 bytes left
the entry just read did not fit: it was dropped and the listing ended with
"truncated": true. Any folder with more entries than fit in one 1 KB chunk,
about 15, came back cut short.

Check for room for the largest possible entry before f_readdir, since an
entry that has been read cannot be put back; the listing then continues in
the next chunk. Found by the EPIC-10 smoke script; verified on hardware with
100 and 500 files: all entries listed, "truncated": false.
…-10 STORY-05)

FOLDER defaulted to the microfirmware template's "/test", which md-devops
never uses, so every boot created an empty /test folder on the card.
FOLDER now defaults to "/devops" and is created at boot if it does not
exist. Settings saved with the old "/test" value are changed to "/devops"
and saved once at boot, then the folder is created if needed.

Verified on hardware: the first boot logged the change and rewrote the
app settings with every other value kept; the next power-on boot read
FOLDER=/devops and mounted the card with no /test folder involved.
@diegoparrilla
diegoparrilla merged commit b9b53cc into release/v1.1.0 Sep 15, 2026
2 checks passed
@diegoparrilla
diegoparrilla deleted the epic/10-latent-layout-and-timing-bugs branch September 15, 2026 21:59
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