STM32F4 flash stub
A small RAM monitor that installs, checks and backs up the internal flash of the STM32F405 radios behind the vendor bootloader, one image for all of them. A running RadioFoundry loads it
through the debug link (debuglink.md 3.18) and jumps to it with no power cycle. The host then sends
the radio's board record (section 5.2), and only then can the stub write. It does for the STM32 radios what the HR_C7000 stub does for those radios (stub/README.md).
| Part | Where |
|---|---|
| RadioFoundry side: staging buffer, quiesce, copy, jump | platform/stm32f4/stub_entry.c, hal_system_t.stub_region / enter_stub / stub_run_addr, board_rf_safe in each board's board.c |
| The stub | stub/stm32f4/ (make -C stub/stm32f4 writes build/stub-stm32f4/f4stub.bin) |
| Its protocol engine (portable, shared with other stubs, host tested) | stub/common/stubeng.c (section 13), tests/host/f4stubTest.c |
| The STM32F4 target's facts and board record check (portable) | stub/stm32f4/f4stub.h, f4stub_board.c |
| Host tool | stub/f4stub.py, called by stub/stubtool.py; tests/host/f4stub_wire_check.py runs it against the real engine |
| The board profiles | each board's board.toml [flash.stub] with the SPI flash pins of its pins.h (PIN_FLASH_SCK, _MISO, _MOSI, _CS), its crystal and image address from board.mk; tools/boards.py derives the profile and checks it (board-registry.md), stub/f4stub.py BOARDS reads it |
| Switch | CONFIG_STUB_ENTRY, on by default on every radio, with or without the debug link (stub-entry.md) |
Status (2026-10-22): built, host tested and run on an HR_C6000 board (section 11, with protocol 2). Protocol 4 (the board
record) is host tested only (tested = false in each board's [flash.stub]).
| Contents | |
|---|---|
| 1, 2 | Why a stub, feasibility and options |
| 3, 4 | Entry from RadioFoundry, USB in the stub |
| 5 | Protocol and the board record |
| 6, 7, 8, 9 | Safety rules, update order and recovery, the codec sector, backups |
| 10, 11, 12 | Size, first hardware test, open points |
| 13 | The shared engine |
1. Why
The bootloader route costs three things:
| Cost | With the stub |
|---|---|
| The user holds PTT + side key 1 while switching on | not needed for an update: RadioFoundry enters the stub on request |
| The image goes XOR-keyed (the key comes from the bootloader sectors or the stock firmware file the user has) | not needed: the stub writes plain bytes |
| The bootloader reads no flash, so a host can't back up or verify | the stub reads all of internal flash, CRC-checks it and compares it with a file |
2. Feasibility
| Question | Answer |
|---|---|
| Can code in SRAM erase and program the internal flash? | Yes. The flash interface takes sector erase and word program from any bus master once unlocked (the FLASH_KEYR key pair). Code fetched from SRAM keeps running while the flash is busy; only a flash access stalls. The stub runs entirely from SRAM, so it services USB during a 2 s erase |
| Does read protection level 1 stop it? | No. Level 1 locks flash out only while a debugger is connected, or when the chip booted from SRAM or system memory (the BOOT pins). After a normal boot from user flash, reads, erases and programs from user code are allowed, wherever that code runs. RadioFoundry enters the stub from a normal boot. Bench check: section 11, step 3 |
| Sector size | 16 KB (0-3), 64 KB (4), 128 KB (5-11). Erase is per sector, so an update rewrites whole sectors. The stub CRCs 4 KB blocks so the host can tell which sectors changed |
| Erase and program time | 128 KB: about 1 s, at most 2 s; 16 KB: at most 0.5 s; one word: 16 us, at most 100 us (data sheet, x32 parallelism, 2.7 to 3.6 V). An update erases only the sectors the image covers (at most sectors 3-9), about 1 s per 128 KB sector |
| USB | The stub carries its own polled CDC-ACM device on the OTG_FS core (section 4). It adopts the device RadioFoundry already enumerated when it can |
| RAM | RadioFoundry's 128 KB SRAM and 64 KB CCM are nearly full (86 % and 97 % on the HR_C6000 build). RadioFoundry keeps an 8 KB staging buffer for the image (switch on only). After the jump the stub owns all of SRAM |
2.1 Options compared
| Option | For | Against | Verdict |
|---|---|---|---|
| A. A separate stub with its own minimal USB device | Independent of RadioFoundry's state once entered; reads and writes every application sector, sector 3 included; the same model as the HR_C7000 stub, so the host tools share framing and flow | Needs its own USB code (about 2 KB); entry reaches the stub only while RadioFoundry runs | Chosen |
| B. The stub keeps using RadioFoundry's USB stack (TinyUSB) | No USB code in the stub | TinyUSB runs from flash, and the stub erases flash. Its state lives in SRAM the stub overwrites | Not possible as such. Option A takes the useful part: it adopts the configured device (address and endpoints) without re-enumerating |
| C. RadioFoundry writes flash itself (in-app updater, RAM-resident routine) | No second program to load | RadioFoundry runs from the sectors an update rewrites (3-9), so it can't erase them while it runs. It would have to stage the image elsewhere (the external SPI flash has room) and copy it with a RAM routine that has no USB. That copier is the stub without the host link, with less checking (no verify after the copy) | Rejected. A later "stage over the network, copy at reboot" path could reuse the stub's engine |
3. Entry
- The host sends STUB INFO, BEGIN, DATA and ENTER (debuglink.md 3.18). INFO reports the run address
0x20000000 and the 8 KB span. The data goes into RadioFoundry's staging buffer (
stub_region), which lies anywhere in RadioFoundry's.bss. - ENTER is refused with busy (3) while the radio transmits. Otherwise the reply goes out, the banner "PC Loader
Connected" is drawn with the backlight at full (the generic entry code in
core/stubentry/stub_entry.c, so a dimmed or switched-off backlight does not hide it), andenter_stubruns in the host link task:
| Step | Why |
|---|---|
Note whether USB is in serial mode (usb_device_mode) |
the handoff flag; in network mode the host bound CDC-NCM to the same endpoint numbers |
board_rf_safe (crash-log.md 8): transmitter off (the board's PA enables, band select, supply and APC DAC) |
nothing transmits while the stub runs. The backlight (already at full for the banner), power latch and USB pins stay |
stm32_stub_image_ok (CONFIG_WATCHDOG, also checked at ENTER): with the IWDG running, the image header (3.1) must say the stub feeds the IWDG, or RadioFoundry refuses it |
a stub that doesn't feed the IWDG would be reset part way through a write |
stm32_watchdog_stretch (CONFIG_WATCHDOG): the IWDG to its longest timeout (prescaler 256, reload 4095: about 32 s, at least 22 s), then a feed |
the IWDG can't be stopped; the stub feeds it from here on (watchdog.md 3.1) |
| Interrupts masked, SysTick off, every NVIC enable and pending bit cleared | the stub polls; RadioFoundry's handlers live in flash it may erase |
| DMA1 and DMA2 reset | circular I2S, SPI and ADC streams write into SRAM, where the stub is about to run |
| Copy the staging buffer to 0x20000000 (8 KB, a forward word copy) | the stub is linked there. The copy runs from flash and its stack is the task stack in CCM, so it overwrites nothing it uses. The buffer lies at or above the target, so a forward copy is safe even if they overlap |
| CONTROL = 0, r0 = flags, branch to the entry | thread mode on the main stack. The stub sets SP to the top of SRAM first |
Clocks stay as RadioFoundry set them (72 MHz, USB 48 MHz on every STM32F4 radio; the reduced clock is never active while USB is configured). GPIO levels stay, so the power latch holds. The option bytes select the software
watchdog, so the IWDG runs only when RadioFoundry started it (CONFIG_WATCHDOG). Once started only a reset stops it, so the
stub feeds it: first thing in stub_main, in stub_ms(), which every wait loop reads (the main loop, the flash and
SPI busy waits, the USB waits, the delays), and from the protocol engine at every request, before each erase or
program and once per 4 KB block of SUMS and READ (stubeng_port_t.feed). A feed does nothing while the IWDG isn't
running. The image header (3.1) says so; RadioFoundry with its IWDG running refuses a stub whose header doesn't
(watchdog.md 3.1).
Handoff flags in r0:
| Bit | Meaning |
|---|---|
| 0 | USB is RadioFoundry's configured CDC-ACM serial port: adopt it |
3.1 Image header
16 bytes at offset 0x10 of the image (0x20000010 once copied), right after the 12-byte entry code; the linker script
puts section .stub_header there and fails the build if the entry code grows past it. Little endian. One definition,
stub/stm32f4/f4stub_header.h, shared by the stub and RadioFoundry (platform/stm32f4/stub_entry.c); f4stub.py and the web
flasher (f4stub.js) read the same layout, and their self-tests check their numbers against that file.
| Offset | Size | Field | Value |
|---|---|---|---|
| 0x10 | 4 | magic | 0x48533446 (bytes F4SH) |
| 0x14 | 2 | layout | 1; a later layout only appends fields |
| 0x16 | 2 | size | 16 |
| 0x18 | 4 | stub version | 4 (F4STUB_VERSION, also in HELLO) |
| 0x1C | 4 | flags | bit 0: feeds the IWDG (F4STUB_FLAG_FEEDS_IWDG) |
Versions: 1 the first stub, 2 the SPI flash, 3 the header and the IWDG feeds (F4STUB_VERSION_FEEDS_IWDG), 4 the
shared engine with CONFIG and the board record (section 5). Stubs
before version 3 have no header: those bytes are code. While its IWDG runs, RadioFoundry enters a stub only when all three hold:
the magic, a version of at least 3 and the feed flag (f4stub_image_ok). Otherwise ENTER is refused with reason 1
(debuglink.md 3.18). With the IWDG not running any stub is entered. stubtool.py --rfoundry-enter and the
web flasher print the version from the header before they send the image.
4. USB in the stub
A CDC-ACM device on the OTG_FS core in slave mode (no DMA, no interrupt line): bulk OUT 0x01 and IN 0x81 (64 bytes), interrupt IN 0x82 (8 bytes), VID 0x1FC9, PID 0x0094, the serial number RadioFoundry uses (the 96-bit UID in hex, lowest byte first), product "RadioFoundry flash stub".
| Start | When | What the host sees |
|---|---|---|
| Adopt | flag bit 0 set, the device has an address, and endpoints 1 IN and OUT are active | nothing: the same port keeps working. An IN transfer TinyUSB left open is cut off and the FIFOs flushed |
| Enumerate again | otherwise | the port disappears for about 0.1 s and comes back with the same IDs and serial number (on macOS the same /dev/cu.usbmodem… name) |
After a fresh enumeration Linux may give the port the next number (/dev/ttyACM1) while it still holds the old name.
stubtool.py waits up to 20 s for the stub. It tries the port name it was given for the first 10 s (a /dev/serial/by-path/…
name stays valid across this). After that it also tries a port with the stub's USB ID, taken only from the same USB
location as the port RadioFoundry was on, or, if the location is unknown, the one port with RadioFoundry's serial number or USB ID. Two
candidates are never guessed between: it stops and says so.
The stub handles a bus reset at any time, so a host that resets the port gets a fresh enumeration. FIFO RAM in cold start: RX 128 words, EP0 TX 16, EP1 TX 64, EP2 TX 16. A transfer that ends on a full 64-byte packet gets a zero-length packet after it. HELLO's flags byte says which start happened.
None of this is a board fact. OTG_FS on PA11 / PA12 is the STM32F405's only full speed port, and RadioFoundry has set its pins up before the jump. Every STM32F4 radio runs it with VBUS sensing off and gives RadioFoundry's serial number from the UID, so the stub carries no USB settings per radio.
Web Serial note: after a re-enumeration the page has to reopen the port. Chrome keeps the permission for the same
USB IDs and serial number, so navigator.serial.getPorts() returns it without a new prompt.
5. Protocol
The HR_C7000 stub's framing, unchanged, so the host tools share it:
| Direction | Bytes |
|---|---|
| request | 02 op, length u16, sum u8, 03, payload |
| reply | 02 status, length u16, sum u8, 03, payload |
All numbers are big endian (as in the HR_C7000 stub; the debug link is little endian). The sum is the low byte of the
payload's byte sum. USB has its own CRC per packet; every flash block also carries a CRC-32. A request that stops
arriving for 200 ms is dropped. The largest payload is 4104 bytes (STUBENG_MAX_PAYLOAD).
| Op | Name | Request | Reply |
|---|---|---|---|
01 |
HELLO | none | F4STUB, version u8 (4), max payload u16, flash KB u16, RDP byte u8, flags u8 (bit 0: USB adopted, bit 7: configured), application base u32 (from the board record; 0 before one), SPI flash size u32 (0: none configured or no chip answered), its JEDEC ID (3 bytes), board id u8 (0 before a record), record length u8, the record (the echo, section 5.2). Versions 2 and 3 stopped after the JEDEC ID and always reported 0x0800C000; version 1 stopped after the application base |
02 |
PEEK | addr u32, n u16 (1 to max) | n bytes. Allowed: flash, 0x1FFF0000-0x1FFF7A2F (system memory, OTP, UID, flash size), option bytes 0x1FFFC000-0x1FFFC00F, SRAM, CCM. Peripherals are refused |
07 |
SUMS | addr u32 (4 KB aligned), count u16 (1 to 820), memory u8 (optional: 0 internal flash, the default; 1 SPI flash, addresses from 0) | count x (CRC-32 u32, blank u8) |
09 |
READ | addr u32 (4 KB aligned), count u8 (1 to 255), compress u8, memory u8 (optional, as SUMS) | count frames, one per 4 KB block: CRC-32 u32, method u8 (0 raw, 2 blank), length u16, data. Compression is not offered; a compress request is answered raw |
0D |
RESET | magic u32 | OK, then a system reset once the reply has left. It first writes RadioFoundry's restart-cause mark (F4STUB_SOFT_MARK_ADDR, crash-log.md 1.3), so RadioFoundry reports a software restart |
10 |
ERASE | sector u8, magic u32 | OK once erased |
11 |
PROGRAM | addr u32 (word aligned), 4 to 4096 bytes (whole words) | OK once programmed and read back |
12 |
XERASE | addr u32 (a 4 KB SPI flash sector), magic u32 | OK once erased |
13 |
XPROGRAM | addr u32, 1 to 4096 bytes inside one 4 KB SPI flash sector | OK once programmed and read back |
14 |
CONFIG | the board record (24 bytes, section 5.2) | OK once checked and applied (SPI bus set up, chip woken). A refused record answers 0D and leaves the stub unconfigured, even if an earlier record was in force |
The magic is 0x46345342 ("F4SB").
| Status | Meaning |
|---|---|
05 |
OK |
0A |
bad request sum: send again |
0B |
unknown op |
0D |
bad argument: length, alignment or range |
0E |
payload longer than the maximum |
0F |
refused by a safety rule (section 6) |
10 |
flash error: the controller flagged one, the read-back differs, or the SPI flash stayed busy |
11 |
not configured: ERASE, PROGRAM, XERASE and XPROGRAM before a board record was accepted |
5.1 Shared with the HR_C7000 stub, and what differs
| Item | Same | Different |
|---|---|---|
| Framing, status OK / BADSUM / BADOP / TOOLONG | yes | |
| HELLO | the 6-byte magic, version, max payload at the same offsets | magic F4STUB instead of C7STUB; bytes 9-16 hold flash size, RDP, flags and the application base |
| PEEK | layout | refuses peripherals |
| SUMS, READ | the reply records, 4 KB units | the request is a flash address, not an SPI controller, chip select and offset |
| Writes | the HR_C7000 stub passes SPI commands through (write enable, sector erase, page program); this one has ERASE and PROGRAM for the internal flash, with the rules in section 6 | |
| Baud rate, LED, CALL | not present (USB) | |
| The SPI flash | a memory byte on SUMS and READ, and XERASE / XPROGRAM, instead of the HR_C7000 stub's SPI pass-through. The stub drives the chip itself | |
| Entry | the debug link STUB command and its magic C7SB |
the platform copies the image to its run address; INFO reports the run address |
| Board configuration | a CONFIG after HELLO, echoed by the next HELLO; no writes before it | op 14 (the HR_C7000's 0D is RESET here), and the record holds the SPI flash wiring and the bootloader's end instead of clocks and power pins |
| Transport | USB CDC-ACM instead of a UART at 115200 |
5.2 The board record
The stub image is the same for every radio; what differs goes in a 24-byte record the host sends with CONFIG (layout
in stub/stm32f4/f4stub.h, the values from the board registry, read into stub/f4stub.py BOARDS). The host picks the profile from the running RadioFoundry's
VERSION board= (or --board), sends it, reads HELLO again and checks that the echo is byte for byte what it sent and
that the application base is 0x0800C000. Only then does it write.
| Bytes | Field | Checked by the stub |
|---|---|---|
| 0 | board id ([flash.stub] id, unique among the STM32F4 boards) |
not 0 |
| 1 | layout, 1 | equal |
| 2 | SPI flash bus: 0 none, 1-3 SPI1-SPI3 | at most 3 |
| 3 | SPI mode, CPOL << 1 | CPHA | at most 3 |
| 4-7 | crystal (HSE) Hz | 4 to 26 MHz |
| 8-11 | application base: the end of the bootloader, the commit word | a sector start, not sector 0, at or above 0x08004000 (the engine) |
| 12-15 | fastest SPI clock, Hz | at least 100 kHz |
| 16-18 | SCK, MISO, MOSI pins: port << 4 | pin (port A = 0 to I = 8) | ports A-I |
| 19 | their alternate function | at most 15 |
| 20 | flash chip select pin | ports A-I, none of the other pins |
| 21-22 | other chip selects on the bus, held high (0xFF: none) | ports A-I or 0xFF |
| 23 | 0 | equal |
Where each field comes from:
| Field | Source |
|---|---|
| board id | [flash.stub] id |
| bus, mode, fastest clock | [flash.stub] spi = { bus, mode, hz } |
| SCK, MISO, MOSI, chip select | pins.h PIN_FLASH_SCK, PIN_FLASH_MISO, PIN_FLASH_MOSI, PIN_FLASH_CS |
| alternate function | the bus: platform/stm32f4/platform.toml board_stub.spi_af (AF5 for SPI1 and SPI2, AF6 for SPI3) |
| held high | [flash.stub] idle_cs: pins.h names, at most two |
| crystal | board.mk HSE_VALUE |
| application base | board.mk APP_BASE |
tools/boards.py get <board> flash.stub prints a board's values; the radio's hardware page says where they come
from. The crystal only sets the stub's own timing (time-outs, the 200 ms idle rule): the stub reads the clock tree RadioFoundry
left and needs the HSE to turn it into Hz. Until a record arrives it assumes 8 MHz.
The record matters most on a radio whose flash is not on the SPI1 wiring a protocol 2 stub had built in
(stub/f4stub.py V2_WIRING): there those pins may reach another chip, and a stub with that wiring built in would
send flash commands to it. A stub before protocol 4 serves only a board whose record matches V2_WIRING.
tools/boards.py check holds each profile to the board's sources: every pin it names must be in pins.h in the
GPIOx, GPIO_PIN_n form, the bus must have an alternate function, and the crystal must be set.
Why the host sends the record
The other way considered: RadioFoundry passes a pointer to a board record in a register at the jump, as it passes the USB flag.
| Host CONFIG (chosen) | Record from RadioFoundry at the hand-over | |
|---|---|---|
| Entry routes | every one: RadioFoundry's hand-over, --attach to a running stub, any later route that has no RadioFoundry behind it (a bootloader or ROM loader, as the K22F radios may need) |
only RadioFoundry's hand-over |
| Where the facts live | one place on the host, next to the flash maps the host already keeps per radio (BOARDS), checked against the board sources by a host test |
in every board's firmware; a fix needs a new RadioFoundry build in the radio first |
| Radios in the field | a RadioFoundry build already running with CONFIG_STUB_ENTRY serves a new stub unchanged |
each needs a RadioFoundry update before the new stub can serve it |
| Lifetime | the record is in the stub's own RAM | RadioFoundry's RAM and flash are what the stub overwrites and erases; the record would have to be copied before the first write |
| Mistakes | the host checks the echo before any write; a wrong or missing record means no writes, not a wrong write | the stub would trust whatever the register held |
The HR_C7000 stub made the same choice.
stubtool.Radio holds the shared parts (frames, retries, the debug link loader, the READ record decoder) and
f4stub.F4Radio subclasses it. The web flasher splits the same way: f4stub.js extends stub.js, and the flasher
updates and backs up a radio running RadioFoundry with stub entry through it (website.md). It sends the
records config_record() makes, carried in the site's data/flash-boards.json, and builds none itself.
6. Safety rules
The stub enforces these, so a host bug can't break them:
| Rule | Effect |
|---|---|
Nothing is erased or programmed before a board record is accepted (status 11) |
a host that skipped CONFIG, or sent a record the stub refused, writes nothing |
| Nothing below the record's application base is erased or programmed; no record can put that base in sector 0, below 0x08004000 or off a sector start | sectors 0-2 (0x08000000-0x0800BFFF), the bootloader, always survive on every radio, whose APP_BASE is 0x0800C000 |
| Nothing writes FLASH_OPTCR or its key | RDP and write protection never change |
| The interlock: ERASE of any sector but the one holding the application base (sector 3), and PROGRAM of anything outside it, need the word at the application base (0x0800C000) to read 0xFFFFFFFF | while any application sector is half written, the application looks invalid to the bootloader (section 7) |
| PROGRAM only goes to words that read erased; nothing is written if one doesn't | no silent AND with old data |
| Every programmed word is read back | a weak cell shows up as status 10 |
| ERASE and RESET carry the magic | a stray frame can't erase or reset |
| Caches and prefetch are off | reads after an erase or program see the flash, not a stale cache line |
| XERASE carries the magic; XPROGRAM only goes to bytes that read erased, inside one 4 KB sector, and every byte is read back | the same rules as the internal flash. The SPI flash holds no code, so there is no interlock: what goes there (a backup put back, a codeplug) is the host's choice |
6.1 The SPI flash
The wiring comes from the board record (section 5.2). At CONFIG the stub drives the chip select and the held-high pins high as outputs, puts SCK, MISO and MOSI on their alternate function, and sets the SPI block up from scratch (master, the record's mode, DMA requests off) at the slowest prescaler at or under the record's rate, from the APB clock of that block (APB2 for SPI1, APB1 for SPI2 and SPI3). It wakes the chip (0xAB), reads its JEDEC ID (0x9F), takes the size from the ID's capacity byte, up to 16 MB (3 address bytes), and waits while the chip reports busy, in case RadioFoundry handed over during an erase. Reads use 0x03, erases 0x20 (4 KB), programs 0x02 in pages, each behind write enable (0x06) with the status register (0x05) polled. A chip that doesn't answer gives size 0, and every SPI request is then refused.
On an HR_C6000 board on the bench (2026-10-16): JEDEC ef4018, the whole 16 MB read in 59 s (about 280 KB/s) and verified, and a
pattern written to a blank sector, read back and erased again, after which the whole chip matched the first read.
7. Update order and recovery
The bootloader stays in DFU mode when the application's initial stack pointer at 0x0800C000 fails (SP & 0x2FFE0000) == 0x20000000; an erased word does. The host uses that as the commit flag:
- HELLO; the board from the radio's VERSION reply before the entry.
- SUMS over every sector that holds firmware-owned flash; plan the new content (section 8). Stop if nothing changed.
- Save any sector that also holds bytes the firmware doesn't own (an HR_C5000 board's sector 6) to a file outside git.
- ERASE sector 3. From here on the bootloader stays in DFU at every power-on.
- ERASE and PROGRAM every other changed sector (4 KB pieces, blank pieces skipped).
- PROGRAM sector 3 from 0x0800C004.
- SUMS over everything written, with the first word expected erased.
- PROGRAM the first word (the commit). SUMS of its block.
- RESET, or leave it to a power cycle.
| Interrupted at | Next power-on | Recovery |
|---|---|---|
| before step 4 | RadioFoundry as before | none needed |
| steps 4 to 8 | the bootloader in DFU, with no keys held | rfoundry flash (it needs the key: from the stock firmware file, since the radio can't run RadioFoundry to read it from the bootloader) |
| after step 8 | the new RadioFoundry | none needed |
The bootloader (sectors 0-2) is never touched, so the bootloader route always stays.
HR_C5000 boards: whether the bootloader checks the stack pointer the same way is unverified. If it does not, an interrupted update makes the radio jump to an erased vector. The power-on key combination still reaches DFU, so recovery is the same with the keys held.
8. The codec and the other bytes in a sector
The host knows which flash the firmware owns (f4stub.BOARDS, from the board registry: the linker script's application region):
| Radio | Owned | Sectors touched by an update | Codec |
|---|---|---|---|
| HR_C6000 boards | 0x0800C000-0x080BFFFF (sectors 3-9) | those of 3-9 the image covers | sectors 10 and 11: written only from an image merged with the codec, otherwise never erased, so the installed codec stays (rfoundry's keep mode) |
| HR_C5000 boards | 0x0800C000-0x0807FFFF (sectors 3-7) | those of 3-7 the image covers | sectors 8-11: written only from an image merged with the codec, otherwise never erased |
Inside an owned range, bytes past the image end become 0xFF, so an older, longer build leaves nothing behind. An image
merged with the codec (rfoundry_<board>_codec.bin) runs to the end of flash; the tool splits it into the application and
the codec sectors and writes both whole (codec-placement.md, "STM32 radios"). Any other image
that reaches outside the owned range (the wrong board) is refused before anything is erased.
No radio shares a sector between the firmware and the codec. The rule for one that would stays in the code: a sector that is rewritten and holds bytes the firmware doesn't own is read back and written again with those bytes unchanged.
The stub only runs on a radio that already runs RadioFoundry. A first install, on a radio still on its original firmware, goes through the bootloader with rfoundry's merge mode.
9. Backups and read protection
| Case | What the stub gives |
|---|---|
A radio running RadioFoundry with CONFIG_STUB_ENTRY |
a full 1 MB internal flash image (bootloader, RadioFoundry, codec) and the whole external SPI flash (16 MB: codeplug, calibration), every block CRC checked, with no button press. The SPI flash can be written back the same way |
| A radio on its original firmware | nothing yet. It can't run our code without the bootloader, and the bootloader reads no flash. A first install still goes through the bootloader, blind |
| After the first install | everything above. The bootloader and the codec that came with the radio can be saved before any later update |
So the stub buys updates and post-install backups. It does not buy a pre-install backup of a radio on its original firmware. A dump holds the proprietary bootloader and codec: the tool refuses to write one inside a git work tree, and dumps are never committed.
10. Size and cost
| Item | Size |
|---|---|
| Stub image | 7,968 bytes (limit 8 KB, RadioFoundry's staging buffer, asserted by f4stub.ld) |
| Stub RAM after the jump | 28.6 KB of SRAM (image, engine buffers, 8 KB receive ring), then the stack: at least 4 KB is left for it (asserted by f4stub.ld); the deepest call chain needs under 1 KB (-fstack-usage) |
RadioFoundry with CONFIG_STUB_ENTRY=y, HR_C6000 board |
the STUB state machine, the entry and the banner text, all in sectors 3-9 (720 KB); +8 KB of SRAM (the staging buffer): RAM 95.4 % used, the default build |
RadioFoundry with CONFIG_STUB_ENTRY=y, HR_C5000 board |
the same code, in an application area of 464 KB (the default image is 320,236 bytes), RAM 78.8 %. The switch fits with no other switch off |
| RadioFoundry with the switch off | no change: the same image, byte for byte in size |
The switch is on by default. The command hands the whole SoC to whatever image the host sends, guarded by the magic, the length and the CRC-32; a signed or hash-pinned stub would close that (section 12).
11. First hardware test (HR_C6000 board)
Nothing below transmits. Build, then flash once through the bootloader:
make BOARD=<id> CONFIG_STUB_ENTRY=y -j8
make -C stub/stm32f4
python3 tools/release.py --board <id> --version v0.0.0-stub --out build/dist
(radio in the bootloader: PTT + side key 1 at power-on)
cd tools/rfoundry && python3 -m rfoundry flash ../../build/dist/rfoundry-<id>-v0.0.0-stub.bin --yes (key or --stock as usual)
Then, with RadioFoundry running (PORT is its USB serial port, /dev/cu.usbmodem… on macOS):
| Step | Command | Pass |
|---|---|---|
| 1 | python3 tools/rfoundry_debug.py version |
board= the radio's name, git= the build just flashed |
| 2 | python3 stub/stubtool.py PORT --rfoundry-enter --peek 0x08000000:64 |
"STM32F4 stub v4: 1024 KB flash, RDP byte 0x55, not configured yet, USB adopted from RadioFoundry" (or "enumerated again"), "stub configured for |
| 3 | the same output | the bytes are the bootloader's vector table (not all 00 or FF): CPU reads from SRAM work at RDP 1 |
| 4 | python3 stub/stubtool.py PORT --attach --board <id> --progmem ~/flash.bin |
1 MB, sha256 printed |
| 5 | python3 stub/stubtool.py PORT --attach --board <id> --verify-progmem ~/flash.bin |
identical |
| 6 | python3 stub/stubtool.py PORT --attach --board <id> --write-app build/<id>/rfoundry_<id>.bin |
"0 sector(s) written", every sector unchanged (the build already in the radio) |
| 7 | rebuild with a visible change (another RFOUNDRY_VERSION=), then step 6 again |
sectors 3 and the changed ones written, verified |
| 8 | python3 stub/stubtool.py PORT --attach --board <id> --boot reset |
RadioFoundry starts; rfoundry_debug.py version shows the new git= |
| 9 | python3 stub/stubtool.py PORT --rfoundry-enter --write-app ... with yet another build, USB unplugged during the write, then a power cycle |
the radio comes up in the bootloader with no keys held; rfoundry restores it |
| 10 | rfoundry_debug.py radio usb network, then python3 stub/stubtool.py udp:RADIO_IP --rfoundry-enter --peek 0x08000000:64 |
the hand-over goes over UDP; the stub comes up as a serial port (--stub-port, default the one 1FC9:0094 port) and answers |
| 11 | python3 stub/stubtool.py PORT --rfoundry-enter --q128 ~/spi.bin, then --attach --verify-q128 ~/spi.bin |
"SPI flash 16384 KB (JEDEC …)" in the "configured" line; 16 MB read, identical |
| 12 | write a pattern to a blank 4 KB sector with --write-q128 pat.bin --q128-range ADDR:ADDR+0x1000, read it back, write 0xFF back the same way, then --verify-q128 ~/spi.bin |
1 sector written each time; the pattern reads back; the whole chip matches the first read; RadioFoundry starts with its codeplug |
Steps 4 and 5 write the dump outside the repository. Step 9 needs rfoundry and the stock firmware file at hand. The HIL suite can gain a test for steps 2 and 3 (opt-in, reads only).
Results on an HR_C6000 board (2026-10-16, RDP level 1): steps 1 to 8 and 10 to 12 pass. The stub reads the bootloader's vector table from SRAM (so code running from RAM reads flash at RDP 1), a 1 MB read takes 2 s and matches the image flashed through the bootloader, an unchanged image writes 0 sectors, a changed one writes 5 sectors in 8 s, and the codec sectors stay (the radio still finds its codec). Step 9 is still to do.
12. Open points
- Step 9 of section 11 (an interrupted write and the recovery) on hardware.
- Protocol 4 (the board record) on hardware: section 11 again on an HR_C6000 board.
- HR_C5000 boards: the bootloader's stack pointer check, and the stub on a real radio.
- rfoundry: use the stub when the radio runs a
CONFIG_STUB_ENTRYbuild, with the bootloader as the fallback. - Release use: pin the stub image (the radio could refuse a stub whose SHA-256 is not built in), then turn the switch on by default.
13. The shared engine
The protocol engine (stub/common/stubeng.h, stubeng.c) knows no chip. It holds the framing, every op, the safety
rules of section 6, CRC-32 SUMS and block READ, and the board record's copy for the HELLO echo. A target supplies a
stubeng_port_t and its glue: the STM32F4 target is stub/stm32f4 (USB, flash controller, SPI, entry); a Kinetis K22F target would be another directory with the same shape. The wire protocol (section 5) is the
same for every target; only the HELLO name, the op magic and the board record's layout are the target's.
stubeng_port_t field |
What the target gives | STM32F4 |
|---|---|---|
name, version, magic |
HELLO's first 6 bytes, the protocol version, the u32 ERASE / XERASE / RESET carry | F4STUB, 4, 0x46345342 |
flash (stubeng_flash_t) |
internal flash: base, size (sectors that don't fit wholly don't exist), runs (the sector map as runs of equal sectors), prog_unit (PROGRAM's alignment and length unit: 4 or 8 bytes), min_app_base (no record opens anything below it) |
0x08000000, the flash size register, 4 x 16 KB + 64 KB + 7 x 128 KB, 4, 0x08004000 |
peek, npeek |
address ranges PEEK may read besides the internal flash; never peripherals | system memory and OTP, option bytes, SRAM, CCM |
security, flags |
HELLO bytes 11 and 12 (the target's bits below 0x80; the engine adds bit 7, configured) | RDP byte; USB adopted |
write |
send one reply frame | USB bulk IN |
read |
copy memory the engine has checked | memcpy |
erase, program |
erase one sector by index; program words at an address the engine has checked erased (aligned to prog_unit) |
the flash controller, x32 |
reset |
after the RESET reply | NVIC_SystemReset |
xread, xerase, xprogram |
the external SPI flash, or NULL: read, erase one 4 KB sector, program inside one sector | f4stub_spi.c |
configure |
check and apply the board record (1 to 32 bytes, first byte the nonzero board id); fill stubeng_config_t: app_base, and the SPI flash's xsize and xid |
f4_board_parse, then the clock and f4spi_init |
The engine checks the application base configure returns: a sector start, not sector 0, at or above
min_app_base. The commit word is at that base, and the commit sector (the interlock's) is the sector holding it. The
engine keeps 2 x 4 KB frame buffers and a 4 KB word buffer (stubeng_t, about 12.3 KB of RAM) and needs no heap. It
calls nothing but the port, memcpy, memmove, memset and memcmp.
A new target needs: the port, a receive loop that feeds bytes with stubeng_feed and calls stubeng_idle after
200 ms of silence, its board record layout and check (host tested, as f4stub_board.c), its host-side profiles, and
the host tests: tests/host/f4stubTest.c already runs the engine with a uniform 2 KB sector map and 8-byte program
units.
Sources: STM32F405/407 reference manual (embedded flash interface: unlock, sector erase, programming parallelism, read protection levels; OTG_FS device programming model), STM32F405 data sheet (flash erase and program times), USB 2.0 chapter 9, USB CDC 1.2 and PSTN 1.2.