Loader Runtime Design
This document records the contract a loader runs under, the rules that follow
from it, and the reasons for each, so that the next loader starts from what is
known rather than rediscovering it. The contract and rules were established on
AT25XE_PresTagv3 (STM32L432, AT25XE SPI NOR) and validated on hardware; the
STM32U375 SPI-NAND loader, GD5F2GM7RE_IMUTagNandv2, follows the same rules
with the differences in
STM32U375 and SPI NAND. The orientation and the
add-a-loader checklist are in the loaders README; the case for
loaders at all is
decision 0022.
The design decisions behind the first loader are
decision 0016.
The contract
STM32CubeProgrammer's external-loader interface is not formally specified. What
follows comes from ST's reference loaders and from -vb 3 traces of this
loader on a PresTag.
The descriptor must be at address 0. CubeProgrammer finds StorageInfo by
symbol, but only accepts it in its own load segment at address 0, 200 bytes
long (sizeof(struct StorageInfo)). ST's SPI-NOR loaders
(MX25L4006E_STM32WL5M-SUBG, MX25R3235F_STM32WBA25E-NUCLEO, shipped in
CubeProgrammer's ExternalLoader/ directory) are laid out this way, as is the
Info region of ST's IAR link maps. The first build placed StorageInfo in
SRAM with the rest of the image. CubeProgrammer then accepted -el without a
word, did not load the loader, and served reads of 0x90000000 directly over
SWD -- zeros on an STM32L432, reported as Data read successfully. Nothing in
the output distinguishes this from a working loader except the -vb 3 trace.
How an entry point is called, from the trace:
| Step | Detail |
|---|---|
| Download | The loadable SRAM segment is written at its link address, 0x20000004. The descriptor segment is not downloaded. |
| Return trap | 0xBE00 (BKPT) is written at 0x20000000, and LR is set to 0x20000001. An entry point returns into a breakpoint, and the programmer sees the core halt. |
| Stack | MSP is set just past the image: 0x2000090C for a 0x50C-byte image. About 1 KB. |
| Buffer | The transfer buffer follows the stack (0x20000940). |
| Arguments | R0-R2 as in the C signature, every other register zeroed. Result in R0: 1 success, 0 failure. |
| Sequence | Init is called before each operation, not once per session. |
The entry points are Init, Read, Write, SectorErase(start, end) and
MassErase(parallelism). SectorErase's end address is treated as an address
within the last sector, as ST's loaders do. Verify and CheckSum are
optional and not implemented; CubeProgrammer verifies with Read.
Addresses are fictional. The flash is on plain SPI and is not memory
mapped. The loader declares a base of 0x90000000, the conventional
external-memory address, and subtracts it.
Runtime rules
Each rule exists because the obvious alternative fails.
No startup code. There is no crt0, no vector table and no reset handler:
the programmer jumps straight to an entry point. make.mk takes only the
include paths from ChibiOS's startup makefile. Init clears .bss itself,
between linker symbols, and .data is simply downloaded.
Interrupts are disabled on entry and left disabled. On a hot attach the tag's interrupts are still armed, and one taken during an entry point would run tag firmware from internal flash, since VTOR still points there. ST's loaders disable interrupts in the same way.
No kernel, and no OSAL sleeps. The os-less OSAL's osalThreadSleep waits on
a virtual timer advanced by the SysTick interrupt, so with interrupts off it
never returns; its osalSysPolledDelayX is an empty stub. Moving a driver from
chThdSleep* to the OSAL does not make it loader-ready. Delays come from the DWT
cycle counter (loader_delay.c), which needs neither.
Every wait is bounded. The clock switch, each SPI byte and each flash status poll has an iteration or time limit, and a timeout returns failure. A loader that hangs holds the programmer until it gives up, with nothing to say why.
The clock is set by hand, and ChibiOS's clock setup is never called. The
loader switches SYSCLK to HSI16 (16 MHz), which puts SCK at 8 MHz. That switch
is at most four register writes (loader_clock.c), touching only RCC_CR,
RCC_CFGR and FLASH_ACR. ChibiOS's stm32_clock_init() is ruled out
because it:
- resets every peripheral;
- opens backup-domain write access and leaves it open;
- calls
bd_reset(), which resets the whole backup domain -- the RTC and the backup registers holdingpState-- whenever the configured RTC clock source differs from the tag's. With LSE left off, as it must be here, it always differs.
halInit() is not called either. mcuconf.h sets STM32_NO_INIT TRUE as a
guard, and describes HSI16 only so that compile-time constants such as
STM32_SYSCLK are true; loader_clock.c refuses to compile if they are not.
The loader may be entered with the part in any clock state: MSI at 4 MHz after a
reset, or the tag's 2 MHz MSI in voltage range 2 on a hot attach. HSI16 is within
range 2's limit, so the voltage range is left alone. Flash latency is only ever
raised, never lowered.
Only PAL is used from the HAL. halconf.h enables PAL alone, and
make.mk compiles one ChibiOS source, hal_pal_lld.c, for palSetLineMode().
The board description comes from the committed board.h; board.c is not
built, because its __early_init and full pin setup belong to the application.
No floating point. The programmer does not enable the FPU before calling an
entry point, so the image is built with USE_FPU=no.
Entry points are stateless. Every entry point re-establishes clock, delay,
board, SPI and flash wake from scratch, and trusts nothing left by an earlier
call. The trace shows Init called before every operation anyway. The cost
is a 2 ms flash wake per call.
Touch only what reaching the flash requires. The disassembly of both images
references RCC, FLASH, DWT/CoreDebug, GPIOA and the SPI peripheral, and nothing
else. There is no PWR access and no write to RCC_BDCR, and the RTC and backup
registers cannot be written without the PWR_CR1_DBP bit, which is never set.
loaderBoardInit() enables only the ports the flash lines use.
The part driver
loader_flash.h is the whole interface: probe, size, sector size, read, and,
in the RW image only, erase-sector and program.
Why not the firmware driver. The firmware's at25xe.c sits on the tag's
bus lifecycle -- power and sleep policy, idlePowerMode, semaphores,
stopMilliseconds, kernel sleeps -- which reaches ch.h through
storage_spi.h, core_sync.h and phase_probe.h. More importantly, its wake
hook clears block protection with a Write Status Register when the protect bits
are set. That mutates the part, and the bits are themselves evidence. A
loader-specific driver avoids both problems in about 150 lines.
What is shared. Opcodes, register bits, JEDEC identity and timing budgets
live in tags/common/storage/inc/at25xe_commands.h, which has no dependencies
and is included by both drivers. The budgets encode measured behaviour -- the
sector-erase budget was raised after a 4% failure rate -- so keeping one copy
means a budget raised for the firmware is raised for the loader too. Moving the
constants there left all seven AT25XE firmware targets (PresTag, PresTagRaw,
UIUCTag, CompassTagAT25, CompassTagAT25Breakout, BitTagNG, BitPresTag)
byte-identical in their .list disassembly.
Probe does not write. It sends Resume from Power-Down (ABh), waits
AT25XE_WAKE_DELAY_MS, and reads the JEDEC ID. On the AT25XE321D, ABh is the
only way out of Ultra-Deep Power-Down, which is where the tag leaves the part:
deasserting chip select is not enough. The 2 ms wait covers the datasheet's
worst-case tRUDPD of 1200 us.
Success is a read-back, never the busy bit. A block-protected array silently ignores Program and Erase: BSY never rises, and a poll sees immediate "success". So:
- Erase works in 4 kB sectors. A sector that already reads blank is skipped.
Otherwise the driver clears Status Register 1 protection if it is set and
confirms it cleared, erases within the firmware's measured budget, and
requires the sector to read back as all
0xFF. - Program splits at 256-byte page boundaries, programs each page within the page-program budget, then reads the whole range back and compares it. NOR programming only clears bits, so programming over unerased data fails the comparison instead of leaving a silent mix.
Blank-checks and comparisons stream over SPI without a buffer, so they cost no SRAM against the programmer's transfer buffer.
Why 4 kB sectors and not block or chip erase. The 4 kB sector erase is the command whose budget the firmware has measured. The datasheet gives a 64 kB block erase of up to 2250 ms, which is no faster per byte, and a chip erase of 65-75 s typical with no maximum, which is not a budget at all. Skipping blank sectors is the real speed-up, and on a partly filled tag it is a large one.
Rescue erase
Erasing external flash is a rescue operation, always done together with erasing
internal flash. The two must not be left disagreeing. PresTag's own erase
(dirtyExternalSectors() in families/PresTag/src/datalog.c) erases only the
sectors that its internal page headers cover:
| State | Effect | Recovery |
|---|---|---|
| Internal index kept, external blank | Download returns blank pages. Visible. | tag-reset erases what the index covers, and the tag is consistent again. |
| Internal blank, external still written | The tag's next erase covers zero sectors. New pages are programmed over old data, and the result is corrupted without any error. | A full external erase. |
So the order is fixed: erase external flash and blank-check it, then erase
internal flash and reflash, then tag-reset. If the procedure is interrupted,
the tag is left in the harmless state. A plain full-chip internal erase
(-e all, or the GUI) with no external erase produces the harmful state.
Performance
These figures were recorded with the first loader, AT25XE_PresTagv3, when
this document was written (commit 326afc32); no results log holds them and
they have not been re-measured since.
At SCK = 8 MHz through an ST-LINK-protocol base: 64 KB reads in 0.68 s, and 4 MB in about 70 s (roughly 60 KB/s). Writing 65 KB takes 2.1 s over blank sectors and 4.6 s when 17 sectors need a real erase first. The per-call re-initialisation, including the 2 ms wake, is a measurable share of read time at CubeProgrammer's chunk size. A driver that controls its own chunking could initialise once per session.
STM32U375 and SPI NAND
GD5F2GM7RE_IMUTagNandv2 reads the IMUTagNandBmp581's GigaDevice GD5F2GM7RE
(256 MiB SPI NAND: 2048 data plus 128 spare bytes per page, 64 pages per block,
2048 blocks). Everything in the runtime rules above applies; what differs is
below. Why each choice was made is in the
decision record.
Build. common/make-u375.mk mirrors make.mk with the U3 startup and
platform makefiles, cfg/stm32u3/ (PAL-only halconf.h, mcuconf.h with
STM32_NO_INIT), STM32U375-loader.ld (StorageInfo at 0, the image in SRAM1
from 0x20000004), MCU = cortex-m33 and USE_FPU = no. The first 64 bytes of
SRAM1 are the firmware's monitor mailbox, which the loader overwrites while the
core is halted.
Clock. loader_clock_u3.c pins MSIS at MSIRC1/2 = 12 MHz through
RCC_ICSCR1 (MSIRGSEL = 1): a value the part may already be running at, within
voltage range 2 at the reset's 1 wait state. PWR, the booster, FLASH_ACR and
the backup domain are not touched.
SPI. loader_spi_u3.c is a bounded polled master for the U3's SPI_TXDR
style peripheral, following tags/common/core/src/spi_bus_polled.inc.
Power and the shared bus. The NAND is powered from FLASH_PWR (PA8), which
the attach's reset leaves undriven. The loader drives it high, waits, and sends
ABh (release from deep power-down) before anything else; without that the
NAND runs briefly on residual charge and then drops off the bus mid-read. The
LSM6DSV16X shares SCK, MISO and MOSI and is always powered, so its chip select
(PB1) is driven high before any SPI traffic. No other sensor pin is touched.
See boards/IMUTagNandv2/standby-pins.md.
The part is read, not changed. common/src/gd5f_loader.c never sends
Reset (FFh) and never writes the block-lock register A0h; it records A0h,
B0h, C0h and F0h as found. Its one write is B0h ECC_EN, through gd5fSetEcc(),
which changes that bit alone, holds the reserved bits low, and refuses if
OTP_EN or OTP_PRT was found set (OTP_PRT is non-volatile). Serve() restores
B0h as found before it returns. There is no program or erase code in the
image, and it is built read-only only. Opcodes and timing are shared with the
firmware driver through tags/common/storage/inc/gd5f_commands.h.
Page reads. loaderFlashReadPage() returns 2176 bytes, raw or through ECC,
with C0h and F0h read after the page. Every page read is followed by a
status read; an all-ones reply means the part was not driving MISO, and the page
is retried rather than returned.
Paged service. include/loader_service.h version 2 adds
LOADER_CMD_READ_PAGE and widens detail[] to carry the page status and the
registers as found. All loaders are version 2, and the host accepts 1 or 2.
Choosing which pages to read is the host's job: tag-xflash nand and
tag-capture read page 0 of each block raw, skip the block if that page is
blank, and otherwise read every page of the block raw and through ECC; see
SWD Capture and Recovery Library.
On the bench, a scan of the whole part with one used block took 74 s.
The Serve() entry point
Serve(buffer, size) is the host library's entry point, beside the ST set.
It initialises clock, board, SPI and the part once. It records the JEDEC ID
and status register as found, then loops on the loaderService command block
(include/loader_service.h) until the host sends EXIT. Erase and program
commands are answered LOADER_STATUS_READ_ONLY in an image built without
LOADER_ALLOW_WRITE. Because the service block is in .bss, the image now has
a non-empty .bss, so ld would warn about an RWX segment; make.mk passes
--no-warn-rwx-segments. The protocol and the host side are in
SWD Capture and Recovery Library.
Driving a loader: CubeProgrammer and the host library
CubeProgrammer can drive a loader, but it is a poor driver for this work: its
loader contract is undocumented, it rejects a malformed loader silently, its
sector-number erase is ambiguous between internal and external memory, it knows
nothing of the capture-first and external-before-internal ordering, and it lets
the firmware boot as it leaves, which changes the next boot's recorded
resetCause (investigation).
The host library in tagcore (host/libraries/tagcore/recovery/, used by
tag-xflash and tag-capture) drives the same images through Serve() and
controls the exit; see
SWD Capture and Recovery Library.
Open issues
Open issues are in the loaders worklist.