Skip to content

EPIC-12: RAM layout and budgets - #13

Merged
diegoparrilla merged 12 commits into
release/v1.1.0from
epic/12-ram-layout
Sep 16, 2026
Merged

diegoparrilla merged 12 commits into
release/v1.1.0from
epic/12-ram-layout

Conversation

@diegoparrilla

Copy link
Copy Markdown
Contributor

Fifth epic of v1.1.0. The core-0 stack had 2 KB reserved at the end of the 4 KB SCRATCH_Y bank while the firmware's real peak was 5,772 bytes, and up to 7,700 during a Runner session: it had been running through the rest of that bank and into core 1's SCRATCH_X, unnoticed, on every build that shipped. Meanwhile 47 KB of code sat in RAM that never needed to be there.

What changes

  • Handlers run from flash again. Every http_server handler, and most of emul.c, gemdrive.c, runner.c, term.c and select.c, was marked __not_in_flash_func. RAM placement only matters for code that runs while flash is unavailable. The five functions of the download data path stay in RAM, because without them a 256 KB download slowed from 0.65 s to 0.72 s, and all of GEMDRIVE stays in RAM, which is Diego's rule for the ST-facing path.
  • The folder picker is allocated while it is open. DirNavigation was 24,774 bytes of permanent .bss for a browser that is open for seconds; a failed allocation now says so on the menu instead of following a NULL.
  • The deepest stack frames are gone. term_loop copied a whole protocol slot (4,184 bytes) and now reads it in place; handleReadBuffCall read into a 4 KB local and copied it to the window, and now reads straight into the window; handleWriteBuffCall swaps the chunk where the parser already put it; pathUp held 256 pointers for a path that cannot have more than half its length in segments; FATFS fsys left emul_start's frame; the nine largest HTTP handlers keep their path buffers in .bss. Deepest static path 5,696 -> 2,976 bytes.
  • The stack lives at the top of main RAM, 16 KB by D-02 (peak x 2 + 1 KB, floor 16 KB), with the heap capped at its bottom, porting Booster's layout. FF_FS_TINY 1 shares one FatFs sector buffer, decided by measurement: the ST copy that interleaves a read and a write took 47 s with per-file buffers and 48 s with the shared one.
  • An overflow faults instead of corrupting. PICO_USE_STACK_GUARDS in both build types puts an MPU region over the lowest 32 bytes of the stack; a forced overflow gives HARDFAULT pc=0x10003af6 sp=0x2002bff8, eight bytes below the stack bottom, and a clean reboot. POST /api/v1/debug/test/stack-overflow triggers it in debug builds.
  • The budget is asserted at link time: a heap floor of 40K, no stack/heap overlap, no stack below D-02's 16K. Every link prints the same size report the CI summary uses -- whose stack row had been reading 2 KB from .stack_dummy and whose heap row used the wrong symbol.

Fixes found while verifying this epic

Measuring GEMDRIVE throughput exercised paths the smoke script never had.

  • 4 MB from the ST took 13-14 minutes and sometimes failed with a TOS error and a truncated file. It now takes 47 s. Four causes: the ST accepted a stale token as an answer (the template fixed this in May, after this project forked); its write timeout was $0000FFFF with 3 retries where md-drives-emulator ships $0006FFFF and 5 for the same protocol, while an SD write here blocks up to 70 ms; the RP slept up to 10 ms before draining the command ring; and the protocol parser treated its 10 ms resync window as a deadline for a whole frame, so any 1 KB payload that did not arrive in a single drain was discarded and the ST resent it after a full timeout. The parser now refreshes its timestamp per sample, which is what "restart after silence" means.
  • A failed write published 0 bytes, which the ST's loop treats as progress: it re-issued the same chunk for ever. It returns EIO now.
  • f_write ran without feeding the watchdog, so a card that stalls for 8 s rebooted the RP mid-copy.
  • [X] never reached Booster. The jump ran, but our interrupts vectored through Booster's table before it installed handlers and the core locked up (PC 0xfffffffe), leaving the ST on this app's farewell screen. Masking and clearing the NVIC first fixes it.
  • term.c no longer traces every command: two lines per 1 KB chunk broke transfers on debug by itself.

Verified on hardware

Check Result
Smoke, release, --manual --entries 500 13 passed, 0 failed, 0 skipped
Smoke, debug, automated 8 passed, 0 failed
30-minute mixed session: 831 HTTP rounds of 128 KB + ST copies throughout 0 errors, 0 reboots, 0 crashes, 0 ROM3 overruns; stack 2,532, heap floor 36,304
8 x 512 KB copied from the ST 47 s, every file byte-exact
Forced stack overflow HardFault at the guard, breadcrumb, clean recovery
Cartridge window after the session Menu renders clean, no stray pixels
[X] Booster ST boots into Booster
EPIC-09 Now
Heap, release 35,508 51,316
Heap, debug 27,284 47,160
Code and data in RAM, release 47,896 33,112
Stack reserved 2,048 16,384
Stack peak measured 5,772-7,700 2,532-3,100

Known and filed, not fixed here

  • A chunk the ST re-sends after a timeout can be written twice: neither project carries an offset or a sequence number (EPIC-15 STORY-05). Not reachable in practice now that frames are not lost, but the hole is real.
  • One Runner exec ended in three bombs on the ST during STORY-04 testing and never reproduced, through a manual run, two smoke runs and the 30-minute session. Recorded in STORY-04 with a bisect starting point.
  • md-drives-emulator forces a MegaSTE down to 8 MHz with its cache off before talking to the cartridge; neither this project nor the template does, which would drop payload words on that machine (noted in STORY-08).

Every http_server handler, and most of emul.c, gemdrive.c, runner.c,
term.c and select.c, was marked __not_in_flash_func, so 47 KB of code
and data sat in RAM and the release heap was 35,472 bytes. RAM placement
only matters for code that runs while flash is unavailable: the settings
write itself, the per-tick command path, and there are no application
interrupt handlers (C-09).

The handlers run from flash now. The five functions of the download data
path (srv_recv_cb, srv_sent_cb, stream_download_drive, stream_send_chunk,
send_buffered) stay in RAM: without them a 256 KB download slowed from
0.65 s to 0.72 s, and keeping them costs 4.8 KB.

Release: code and data in RAM 47,496 -> 20,824 bytes, heap 35,472 ->
60,048 (+24,576). Debug heap 23,132 -> about 55,900 before the download
path was put back. Measured on hardware, medians of three runs of a
256 KB round trip: upload 3.03 s before and 3.04 s after (SD-bound),
download 0.65 s and 0.64 s, listing 43-48 ms both.

flash.sh keys its build folder on the source path too, so building two
checkouts that are both called rp/src no longer reuses one CMake cache.
The stale comment in emul.c that credited the RAM move is corrected.
DirNavigation was 24,774 bytes of permanent BSS for a browser that is
open for a few seconds: 256 names of 96 bytes. Upstream allocates it on
entry and frees it on exit, which is what this does now. A failed
allocation says "Not enough memory to open the folder picker." on the
menu instead of following a NULL, and a read error closes the picker with
a message too.

Release heap 60,048 -> 84,820 bytes, debug 55,900 -> 80,672.

Verified on hardware: opening the picker with [o] took the heap from
71,352 to 46,568 bytes (24,784), the listing appeared, and ESC returned
it to exactly 71,352. With the heap held at 22,184 bytes free the picker
refused with the message on screen and the menu intact.
Pressing [X] left the Atari ST showing this app's farewell screen and
Booster never appeared. The jump itself ran: the debug probe showed
Booster's stack pointer loaded and VTOR pointing at 0x10120100. But the
core was in lockup, PC 0xfffffffe.

The jump writes VTOR and branches without stopping anything of ours, so
from that instruction on, our interrupts (the buffered console's UART
transmit, USB, alarms) vector through Booster's table into handlers it
has not installed yet; the fault handler is not installed either, so the
core locks up. Masking and clearing every NVIC interrupt first fixes it:
after the change the probe reads PC inside Booster's code with its own
RAM vector table installed, and Diego confirmed the ST now boots into
Booster.

The jump sequence itself is unchanged and still matches the template.
… (EPIC-12)

Copying 4 MB from the ST to the drive failed with a TOS error and left
the last file truncated. The ST waits for the RP's answer to each 1 KB
chunk, and an SD write that allocates a cluster and updates the FAT
blocks for tens of milliseconds: 70 ms measured on this card, with the
console instrumented to report writes over 20 ms.

Its budget was nowhere near that. This project had COMMAND_TIMEOUT
$0000FFFF and 3 retries, where md-drives-emulator ships $0006FFFF (about
800 ms) and 5 retries for the same protocol, with a comment saying the
value exists to cover FatFs SD operations. Both values are restored, in
gemdrive.s and runner.s.

Three more faults on the same path:

- The RP slept up to SLEEP_LOOP_MS before draining the command ring, so
  every chunk paid that latency while the ST was already waiting.
  chandler_loop() now runs before the wait as well as after.
- A failed write published 0 bytes written, which the ST's loop treats
  as progress: it would re-issue the same chunk for ever. It now bails
  out with EIO.
- f_write ran without feeding the watchdog, so a card that stalls for
  8 s would reboot the RP mid-copy.

Every GEMDRIVE function also runs from RAM again (Diego's rule). STORY-01
had moved the command callback to flash, where a first execution waits on
the XIP cache while the ST is blocked; only that callback was ever marked
before, so the handlers themselves ran from flash even upstream. Costs
about 16 KB: debug heap 80,672 -> 64,288 bytes, still +41,156 on the
epic's start.

term.c no longer traces every command (two lines per 1 KB chunk broke the
transfer by itself; EPIC-10 STORY-04 left that trimming open), and debug
builds report only writes slower than GEMDRIVE_SLOW_WRITE_US.

Verified on hardware: eight 512 KB files copied from the ST, all exactly
524,288 bytes, on release and on debug.
md-devops forked the microfirmware template before its commit d7ec84e,
"fix(sync): prevent false positive token detection", so its synchronous
wait accepted a bare token match. A stale token from an earlier command
could therefore be taken as the answer to the current one, which
desynchronises the handshake: one chunk appears to succeed early and the
next waits for a token that never arrives.

The template's version requires the seed to have advanced as well, and
is now copied verbatim into both wait loops; the file is identical to the
template again.

Found while chasing TOS write errors on multi-megabyte copies from the
ST. It moved the first failure from the first file to the last, so it was
one of several causes, not the only one.
Writes from the ST ran at 4.6 to 25 KB/s and the total time tracked the
ST's command timeout, although no retry was ever visible: the counters
showed 0 repeated tokens, the RP busy 0.5 ms per command, and a 1 KB
frame apparently taking 45 ms to arrive, spread over 13 main-loop passes.

The parser explains all of it. tprotocol_parse restarts on
PROTOCOL_READ_RESTART_MICROSECONDS (10 ms) of silence, but it refreshed
its timestamp only while hunting for a header, so the window was a
deadline for the whole frame. Samples are timestamped when they are
parsed, in batches drained from the DMA ring, and this app's main loop
sleeps up to SLEEP_LOOP_MS between drains: any 1 KB payload that did not
land in a single drain was thrown away mid-frame. The ST then waited its
full timeout and resent with a fresh token, which is why the time scaled
with the timeout and no repeat was ever counted.

md-drives-emulator has the same parser and the same constant, but its
runtime loop never sleeps, so a frame never spans a gap. The timestamp is
now refreshed on every sample, which is what "restart after silence"
means, and the window is 50 ms to cover this loop's own gap between two
drains.

Measured on hardware, 4 MB copied from the ST with the timeout at
$0006FFFF: 13-14 minutes before, 47 seconds after (87 KB/s), every file
byte-exact, no checksum errors, no repeats. A 1 KB frame now arrives in
0.91 ms across 1.24 passes instead of 45 ms across 13.

chandler.c keeps the counters this was found with (handled, dropped,
repeated, checksum errors, busy/gap/quiet microseconds, passes per
frame). They cost a few microseconds per command and are read over SWD by
symbol, so a release build can be measured.
The deepest static paths were 5,696 bytes through the terminal, 5,120
through GEMDRIVE and 3,784 through the HTTP server, against a 2 KB
reserved stack. Four frames accounted for most of it:

- term_loop copied the published protocol slot into a 4,184-byte local.
  It reads the slot in place now; the double buffer already keeps the
  producer off it and both run on core 0.
- handleReadBuffCall read into a 4 KB local and then copied it into the
  shared window. It reads straight into the window and swaps there.
- handleWriteBuffCall copied the chunk into a 1 KB local to byte-swap it.
  It swaps it where the parser already put it.
- pathUp held 256 pointers (1 KB) for a path that cannot contain more
  than half its length in segments.

FATFS fsys moved out of emul_start's frame into .bss, and the path
buffers of the nine largest HTTP handlers are static: 6.2 KB of RAM for
1,672-byte frames, safe because the server is single-threaded and its
spin-waits pump chandler_loop only, never lwIP.

Deepest static path now: release 2,976 bytes, debug 2,952, MinSizeRel
debug 2,928 -- all under the story's 3 KB. On hardware the measured
high-water fell from 5,772 to 2,564 bytes and the smoke script passes.

measure_builds.sh also lost its way to the repo root when the tools
moved into tools/dev.
FF_FS_TINY 0 gives every open FIL its own 512-byte sector buffer, 6 KB
across the slots this app opens. Measured both ways on hardware: eight
512 KB files copied from the ST, which interleaves a read and a write
through the shared buffer and is the case the setting can hurt, took 47 s
at 0 and 48 s at 1; the 256 KB HTTP round trips stayed inside their
run-to-run spread. Release heap 61,556 -> 67,700 bytes.
The SDK default put core 0's stack at the end of the 4 KB SCRATCH_Y bank
with PICO_STACK_SIZE reserving 2 KB of it. This firmware's measured peak
was 2,564 bytes before this epic's work and up to 7,700 during a Runner
session, so it was already running through the rest of SCRATCH_Y and into
core 1's SCRATCH_X, silently.

The stack now occupies the top of main RAM and the heap is capped at its
bottom, porting Booster's memmap_booster.ld block and its reasoning.
Size follows D-02: peak x 2 + 1 KB, rounded to 4 KB, never below 16 KB;
2,564 x 2 + 1,024 rounds to 8 KB, so the 16 KB floor decides. Set in the
linker script only, never through PICO_STACK_SIZE, which would move core
1's stack as well.

EPIC-09's painting and high-water scan follow the stack: one plain region
between __StackBottom and __StackTop, with no code interleaved as there
was in the scratch banks.

Release heap 67,700 -> 51,316 bytes (35,472 at the start of this epic),
debug 47,164. Measured on hardware: reserved 16,384, peak 2,580 during
the smoke script on release and 2,008 on debug, no overflow, and both
build types pass the smoke script.
With the stack at the top of RAM, an overflow would write down through
the heap and, past that, whatever else it reached. PICO_USE_STACK_GUARDS
puts an MPU region over the lowest 32 bytes of core 0's stack, so an
overflow faults there and reboots with a breadcrumb (D-03).

Enabled in both build types, not just debug: in a shipping build the
alternative is silent corruption that surfaces much later as a mystery.
It costs one of the eight MPU regions, which nothing else here uses, the
lowest 32 bytes of the stack, and a few instructions per core at boot.

EPIC-09's stack painting had to move above the guard: it painted from
__StackBottom and faulted on the guarded bytes at every boot, which put
the RP in a crash loop tight enough that neither picotool nor the probe
could attach -- recovery needed BOOTSEL. Painted stack is now 16,352 of
16,384 bytes.

POST /api/v1/debug/test/stack-overflow (debug builds) recurses until it
hits the guard. Verified on hardware: HARDFAULT with pc inside the
recursion and sp 0x2002bff8, eight bytes below the 0x2002c000 stack
bottom, then a reboot with reason hardfault and a clean recovery. Two
earlier versions of that test were optimised into a loop by GCC and
never grew the stack; the frame has to escape through a volatile pointer
to stop it.

Release: smoke passes, stack peak 1,936 of 16,384, heap unchanged.
Three asserts in memmap_rp.ld, so a change that eats RAM stops at the
build instead of on a desk: the heap may not fall below __MinHeapSize
(40K), the stack may not overlap the heap, and the core-0 stack may not
drop below D-02's 16K floor. 40K is what this epic's measurements
support: 51,316 bytes of heap on release and 47,164 on debug, about 15 KB
ever claimed and 31 KB free at the worst moment of a smoke run.

Every link now prints the same size report the CI job summary uses, so
local and CI builds report identical numbers. Two of its rows were wrong
since the stack moved: it read the stack from .stack_dummy, still 2 KB
after STORY-04 put the real 16 KB stack at the top of RAM, and the heap
from __bss_end__ rather than __end__.

Checked by raising the floor to 52K: the link fails with "heap is below
__MinHeapSize: something new is eating RAM, see EPIC-12 STORY-06".
The developer tools in tools/dev leave .pyc files behind when they import
each other.
@diegoparrilla
diegoparrilla merged commit 48b4e71 into release/v1.1.0 Sep 16, 2026
2 checks passed
@diegoparrilla
diegoparrilla deleted the epic/12-ram-layout branch September 16, 2026 20:30
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