A flashing route with no ELF to get wrong

tock/tock#5160 open when this was written the route, run 2026-09-09

#5156 approved, superseded by this the four-line fix this replaces — written up at /findings/4770/

#4770 the defect both address make program builds an ELF that no UF2 tool will take

The fix in #5156 is one objcopy line in four Makefiles. Two maintainers approved it, and one of them asked for something else in the same breath — that these boards stop splicing an application into the kernel ELF and use tockloader local-board instead. That route was run end to end on a Pico 2 W. It works, and it does not fix the defect so much as remove the conditions for it.

Asked
A reviewer approved #5156 and said he would rather these boards moved to tockloader local-board; the answer on the thread was that it would be looked into for a later pull request. This page is that look. Both halves of that exchange are quoted below, and under them the second ask that came once this change was open.
Route
Build the kernel binary, let tockloader keep a flash image file, install applications into it as .tab archives, and hand the flat image to picotool, openocd or probe-rs. No ELF is spliced, so nothing recomputes a program header. make never takes an application: make init records the board, tockloader install --local-board adds the application, and make flash writes the image — the shape esp32-c3-devkitM-1 already uses.
Status
runs on silicon A kernel and an application assembled this way were put onto a Pico 2 W every way the rules offer — over SWD, through the bootloader and with probe-rs — and the process is live in the kernel's own process list — Section 3. Four costs come with it, and one of them is a tool swap on the four RP2040 boards — Section 5. The change itself is #5160 — five boards, two commits.

the ask On the pull request that fixes it the short way

I would rather see this move to use `tockloader local-board`, but if this improves things then fine

Quoted with its backticks because that is how it was written. The approval and the alternative arrived in the same sentence, so nothing here is a dispute — it is the second half being taken seriously.

the answer Given on the thread the same day

I can look into that for a future pr. This fixes the immediate breakage.

What follows is that look, run rather than reasoned about.

the second ask On this pull request, once it was open

If we're going to switch this, let's go with somewhat of an established workflow:
- `make init` configures the local-board for tockloader
- `make install` flashes the local image file to the board

We don't need all of the comments in the makefiles.

Also, why change from elf2uf2-rs?

Taken as written. That workflow is esp32-c3-devkitM-1's, and adopting it removes the APP variable rather than redefining it — which retires the cost a reviewer would have stopped on. The elf2uf2-rs question is answered in Section 5: it reads only ELF, and what this route produces is a flat image.

1What the route is

The rule in the tree today makes a copy of the kernel ELF, gives its .apps section file content, and writes one application's TBF into it. The local-board route never opens an ELF. tockloader keeps a file that stands in for the board's flash; the kernel binary is written at the base of it and applications are installed into it at the application region, exactly as they would be onto a real board.

Four commands, and what the file contains afterwards

StepCommandResult
declare tockloader local-board set raspberry_pi_pico_2 --binary-path <image> --arch cortex-m33 --app-address 0x10040000 --flash-address 0x10000000 Records the board once — this is all make init does. The addresses are the board's own layout.ld.
kernel tockloader flash --local-board --address 0x10000000 raspberry_pi_pico_2.bin 262,144 bytes, byte-for-byte the .bin the ordinary build already produces.
application tockloader install --local-board console.tab Reports the application at 0x10040080 inside a 1,024-byte region beginning at 0x10040000.
load openocd … program <image> 0x10000000 verify or picotool uf2 convert <image> -t bin -o 0x10000000 make flash-openocd and make flash. Both take a flat binary; neither is given an ELF.

The resulting image is 263,169 bytes: the kernel binary unchanged — compared byte for byte, not assumed — then the 1,024-byte TBF at offset 0x40000, then a single terminator byte. The kernel binary is already exactly 0x40000 long on both chips, so the application lands where the linker script puts the application region with no padding arithmetic anywhere.

Figure 1. Run against tock/tock e2ec78ff5 with tockloader 1.18.1. Flash begins at 0x10000000 on every RP2 board, and the application region is at 0x10040000 on four of the five.

A fifth board is carried along without being edited. raspberry_pi_pico_w's Makefile is one line — include ../raspberry_pi_pico/Makefile — so it inherits whatever that file says. Splicing never had to care: objcopy wrote into the .apps section at whatever address the board's own ELF put it. Naming the address in the Makefile does have to care, and that board's layout.ld gives its kernel 576 K rather than 256 K, so its prog region begins at 0x10090000. Configured with 0x10040000 it would install applications on top of its own kernel. It sets APP_ADDRESS for itself, and make init there prints the higher address.

2Why the defect cannot occur here

#4770 is not really about the stack. It is about what objcopy does when a section acquires file content: it lays the program headers out again, and on the way through, the (NOLOAD) stack segment in SRAM comes back claiming file content it does not have. The section stays NOBITS the whole time, which is why nothing that reads the section table can see it.

That sequence needs an ELF to happen to. On this route the only objcopy in the chain is the kernel's own ELF-to-binary step, which the build already performs and which nothing here changes. There is no second copy, no section given contents, and no program header to recompute.

The three failure modes recorded on the #4770 page were checked against this route rather than assumed away. Two of them belong to the UF2 converters and cannot arise, because what they are handed is not an ELF. The third belonged to openocd, which used to write flash correctly, skip the SRAM segment with no flash bank found, and then fail its own verify_image against memory it had declined to write. The programming run in Section 3 emitted no such warning and verified clean, which is the observation rather than the argument.

3On silicon

The image was programmed onto a Pico 2 W over a Debug Probe, the board reset, and the console read. Two applications were used in turn: console, which prints a line and exits, and blink, which loops — so that the second run leaves a live process for the kernel to be asked about.

Programmed, booted, and asked

StageWhat came backReading
programming ** Programming Finished **
** Verified OK **
flat image accepted
boot Initialization complete. Enter main loop
Hello world!
the application ran
through the bootloader The same image as a UF2 — 549,376 bytes, 1,073 blocks — copied onto the mounted RP2350 volume. The board left BOOTSEL by itself and booted. drag-and-drop works
through probe-rs probe-rs download --binary-format bin --base-address, then probe-rs reset. Finished in 17.69 s. same flag form as the rule
process list PID 0  Unique  blink  Quanta 0  Syscalls 141  Restarts 0  Grants 1/4  Yielded the kernel enumerates it

The third row is the one that matters. A line of output could come from anywhere; a process the kernel has admitted to its own table and parked in Yielded was loaded and started from that image. The system-call count is whatever it had reached when the question was asked — the application loops — so it says the process is running, not that the number reproduces. The Hello world! line above it is from the first run, whose application prints once and terminates.

Figure 2. Pico 2 W on a Debug Probe, 2026-09-09, kernel built from e2ec78ff5. All three ways of putting the image on the board were run — SWD, the bootloader, and probe-rs. SWD and the bootloader were then run again after the rework, under the target names the change actually shipsmake flash-openocd and make flash — and both behaved as they had. The process list is the board's own process console answering list over the UART. Transcripts are quoted, not summarised.

And two applications at once, which the route it replaces cannot do at all. --update-section .apps= takes one file. Two were installed into a 274,433-byte image — leak_claim at 0x10040080 and leak_observe at 0x10041080 — and the kernel listed both, loaded over SWD and again through the bootloader. One tockloader install --local-board carries both, its signature being [tab ...]: run with the two archives it reports a layout of leak_claim 4,096 bytes at 0x10040000 and leak_observe 8,192 bytes at 0x10041000, and the file it leaves is 274,433 bytes. That was read off the rule until 2026-09-09, when the call was made directly and the number came back the same. The two have to want different addresses: these are built for adjacent slots, and two applications compiled for the same one are refused with Could not meet fixed address requirements. That requirement comes from an optional TBF header, not from this route — process_binary.rs:231 only checks a fixed address when the application declares one.

And the empty case, which the rework created and nothing had tested. Collapsing program into flash means flash converts the image rather than the ELF, so with no application installed the board is handed a dense 262,144 bytes where the ELF was sparse. Run against an erased application region it boots and the process console answers list with its header row and nothing under it — which is two observations, not one: the kernel came up from the dense image, and an erased region really is empty rather than quietly holding something the loader would find.

The byte does the work, not the erase it triggers. That distinction looked unresolvable: the route writes the terminator through a programming path that erases the sector first, so the two always arrive together. They come apart on the bench, because NOR programming only clears bits and the terminator is 0x00 — nothing needs erasing to write it. With a ghost standing on the board, flash fillb 0x10041000 0x00 1 wrote that one byte and nothing else: a dump either side of it differs at exactly one position, the TBF version field going 02 to 00, with the header's size, checksum and name still intact. On the next reset the kernel listed one process where it had listed two. One byte, no erase, ghost gone.

The route does more than dodge the ELF defect — it closes a hazard the current one has. That took two measurements, and the first is about the kernel rather than about either route.

The loader stops at the first invalid header. It walks TBF headers from the start of the application region, and when one does not parse it gives up rather than scanning on. That was checked instead of assumed: a valid application placed one sector further along, with erased flash between, is never reached and never appears in the process list. So there is exactly one address at which an old application can come back — the slot immediately after the new image's last one.

tockloader writes a single zero byte at that slot. Splicing writes nothing there. That is the entire difference between the two routes on this point, and it is worth exactly one byte.

Two images one byte apart, flashed over the same board state

ImageLengthProcess list after reset
ends exactly on the old header 266,240 bytes blink, then ghostold application comes back
the same, plus tockloader's terminator 266,241 bytes blink alone — slot invalidated

Both arms start from an identical board state, written with an erase beforehand: one application in a 4 kB slot and a second, valid one beginning on the next sector boundary. The images differ by one byte and nothing else. Without it the kernel loads an application that is not in the image it was given. openocd reports the mechanism itself — the byte falls inside the old application's sector, which forces that sector to be erased. On this flash the byte and the erase it triggers cannot be told apart; together they make the slot unreadable.

Figure 3. Same board, 2026-09-09. Both applications are toolchain products — leak_claim and, from a second-slot build, leak_observe. The two images are tockloader's own output and that same file with its last byte removed, so the only difference between the arms is the byte under test.

The ordinary case, for completeness. The A/B above is contrived on purpose — it puts an application exactly where the next one would start, because that is the only place a stale header can survive. The everyday version was run too: an 8,192-byte application flashed, then a 1,024-byte one over it with no erase in between. Only the second appears in the process list. That is the terminator doing its job without anyone arranging for it to matter.

What this retires. An earlier version of this page recommended padding the image across the whole application region. That rested on a wrong model of the loader — that it might scan past a gap — and it is unnecessary: the terminator already covers the only slot that can matter. Flashing a kernel on its own does leave applications in place, but that is Tock's ordinary behaviour and not a fault to be fixed here.

4The other half, byte for byte

The bootloader route was run — Section 3 — but a board booting is not the same as knowing what was written to it. So the UF2 was also taken apart and compared against the bytes that had just run over SWD.

The UF2 disassembled and compared

PropertyValueWhy it is checked
blocks1,029 Every one carries the two UF2 magic words and the trailing one, or the reassembly would have stopped.
family0xe48bff59 RP2350 Arm-S. The converter inferred it; a wrong family is refused by the bootloader and looks like nothing happening.
payload263,424 bytes Reassembled from the block headers, not from the file order.
against the imageidentical The first 263,169 bytes equal the image programmed in Section 3; the remainder is zero padding to a block boundary.

This is what carries the silicon result across to the program target: the UF2 is not like the image that booted, it is that image plus padding. The same conversion with --family rp2040 produces family 0xe48bff56 and places the TBF at the same address — but see Section 7 for what that does and does not show.

Figure 4. Decoded with a short script that walks the 512-byte block headers and rebuilds a flat address space, rather than trusting the converter's exit status. The whole reason #4770 went unnoticed is that a tool exited zero.

5What it would cost

The four lines in #5156 buy a working make program and change nothing else. This route is better shaped, and it is not free.

The heaviest cost is the converter swap on the four RP2040 boards, so it was measured rather than estimated. It looks like a question about a chip — and there is no RP2040 to ask, nor an emulator: mainline QEMU has no such machine, and Tock's own emulated boards are RISC-V and x86. But it is really a question about two programs, and both are here.

Both converters, one kernel, compared address by address

Fromelf2uf2-rspicotool
the kernel ELF 400 blocks, 102,400 bytes 400 blocks, 102,400 bytes
family 0xe48bff56 0xe48bff56
bytes they disagree on 0
the flash image cannot — reads ELF only 1,033 blocks, TBF at the application address

Not one byte differs across the 102,400 addresses both write from the same kernel. Converting the flash image, picotool writes a further 159,744 bytes that elf2uf2-rs never had the chance to — every one of them zero, filling unused flash between the end of the kernel and the end of the kernel region, and stopping before the application address. So the question "does an RP2040 boot from this?" reduces to "does it boot today?", which is known. What it costs is size, and more than the earlier shape did: make flash now converts the image rather than the ELF, so on this board its UF2 goes from 204,800 to 524,288 bytes with no application installed at all. The 400 blocks both files write are byte-identical and the 624 added ones are entirely zero — the image is dense where the ELF was sparse.

Figure 5. Built from raspberry_pi_pico at e2ec78ff5 and compared by reassembling both files from their block headers into address maps. An emulator would have proved that the emulator models the chip; this proves the two files agree.

Four costs, each one measured rather than anticipated

CostWhat was observedWeight
Four boards would change converter Three Makefiles between them, since raspberry_pi_pico_w includes one of the others. elf2uf2-rs reads only ELF. Wrapping the flat image back into one — the technique the ESP32-C3 board uses — produces a file with no program headers at all, and the converter rejects it with Unrecognized ABI 97. picotool takes the binary directly, and produces the same kernel bytes — Figure 5. measured, not blind
The kernel UF2 gets bigger One flash target now covers both cases, so it converts the image. With no application installed that is 524,288 bytes against 204,800 on the Pico and 147,456 on the Pico 2 — every added block zero, and every block they share identical. Installing an application adds five blocks, not a second file. user-visible
The board setting is machine-wide One board at a time, in a single file under the user's application-support directory. make init for one board replaces the setting for another, and make flash depends on a step run earlier. already upstream
tockloader becomes required For a route that needs none of it today. It is what installs an application now — there is no APP variable to redefine, because there is no longer one at all. Without it make flash stops at write-kernel: tockloader: command not found, Error 127, exit 2 — loudly, which is worth saying because one recipe already in the tree swallows the same 127 and exits 0. normal for Tock

The first two are the ones a reviewer would stop on. The one that used to head this list is gone: an earlier shape of this change kept APP and made it mean a .tab rather than a .tbf, and adopting the established workflow deletes the variable instead of redefining it. The Pico 2 carries no converter swap either — it already uses picotool — which is why that board is the one worth moving first.

Figure 6. The third row is not hypothetical — esp32-c3-devkitM-1 does exactly this today, and this change now follows it. That is what makes the exposure acceptable, not what makes it harmless: an earlier shape ran local-board set on every build so no rule could inherit another board's addresses, and a separate make init gives that up.

6Choosing between this and #5156

Both fix #4770 on the same five boards — four Makefiles in #5156's case, because raspberry_pi_pico_w includes another board's. They are not alternatives in the sense of one being right — they are different sizes of change with different evidence behind them, and the order they land in costs nothing either way.

What each one is, and what each one has behind it

#5156#5160, this one
size One objcopy line per rule. Four Makefiles, thirteen added lines. Nine files, +159 −126. Five Makefiles and four READMEs; program, program-openocd, program-probe and flash-app all disappear.
what a user notices Nothing. make init once per board, then tockloader install --local-board instead of APP=; elf2uf2-rs is no longer needed and tockloader is.
the ghost application Not addressed. The splice still writes nothing at the slot after the last application. Fixed — Figure 3.
RP2040 evidence hardware the reporter ran it on his own board none file-level only; no RP2040 here and no emulator
review standing Approved by two maintainers. Opened 2026-09-09, and reworked the same day to the workflow the reviewer asked for.

The asymmetry that matters is the fourth row. This page's weakest point is exactly where #5156 is strongest. Merging #5156 first costs this pull request nothing — it deletes those lines anyway — and it keeps a fix with hardware behind it on the four boards nobody here can test.

Figure 7. Neither reading is a criticism of the other change. #5156 is the smaller ask with the better evidence; this is the larger one that removes the conditions rather than patching them.

#5158 went first, and the collision cost one line. That pull request was two commits: one renamed a make target in two RP2 READMEs, and one said in the ADC syscall document that samples are left-justified. It merged on 2026-09-09, and this change was rebased onto it the same day. Both conflicts were the same shape — #5158's corrected make program against the tockloader install lines that replace the whole block — and both resolved by keeping the replacement. The ADC commit is untouched. This is what the queue's dependency arrows are for: the hold was recorded before either landed, and the resolution was a rebase rather than a surprise.

7What this does not establish

This list was longer, and every entry that came off it came off by going back to the board rather than by arguing about it: a UF2 written through the bootloader, two applications installed at once, an A/B rebuilt from real applications instead of a patched copy, and — after the rework introduced it — a kernel-only image, which the collapse of program into flash made a new and untested case. What follows is what nothing here could close.