make program builds an ELF that no UF2 tool will take

tock/tock#4770 open when this was written reported 2026-04-01 by potto216

The error names an address in RAM, which makes it look like a limitation of elf2uf2-rs. It is not. The kernel ELF is fine; the two objcopy steps that splice an application into it produce a second ELF whose program headers claim 5,376 bytes of file content for memory that has none, and both UF2 converters are right to refuse it.

Bug
Splicing an application into the kernel gives the (NOLOAD) .stack segment file content for SRAM that has none, so make program fails on every RP2 board.
Fix
One objcopy -R .stack per splice rule — five rules, four Makefiles, +13 −0. Proposed as tock/tock#5156.
Status
confirmed on hardware The reporter ran the fix on an RP2040 and program-openocd was A/B'd on an RP2350 — Section 8. Two boards of four and program-probe are still file-level only.

1What happens

Building any application into the Pico kernel fails at the last step:

$ APP=.../spi_controller_write_read.tbf make program
arm-none-eabi-objcopy --set-section-flags .apps=LOAD,ALLOC ... raspberry_pi_pico-app.elf
arm-none-eabi-objcopy: ...: section .relocate lma 0x20000000 adjusted to 0x20001500
arm-none-eabi-objcopy --update-section .apps=... raspberry_pi_pico-app.elf
elf2uf2-rs ... raspberry_pi_pico-app.uf2
Error: "ELF contains memory contents for uninitialized memory at 20000000"
make: *** [Makefile:48: program] Error 1

I reproduced that string exactly, with the reporter's own tool, on a kernel built from 2a7a40450. picotool refuses the same file for the same reason, exits 253 and leaves a zero-byte UF2 behind, so this is not one converter being fussy:

$ elf2uf2-rs raspberry_pi_pico-app.elf out.uf2
Error: "ELF contains memory contents for uninitialized memory at 20000000"

$ picotool uf2 convert raspberry_pi_pico-app.elf out.uf2
ERROR: ELF contains memory contents for uninitialized memory at 0x20000000

The suggested cause in the issue is that elf2uf2-rs does not deal with RAM. That reading is worth retiring early, because it sends you looking at the converter. The same converter takes the same kernel without complaint one step earlier — the control at the bottom of figure 4 — and what it is objecting to is a real defect in the file it was handed.

2What the two objcopy steps do to the program headers

The rule runs objcopy twice: once to give .apps contents, once to fill it with the .tbf. Both are needed — --update-section on its own fails with section has no contents, because .apps is declared (NOLOAD) in the linker script. Giving it contents is the intended change. Everything else in the table below is collateral.

The seven LOAD segments, before and after the objcopy

The kernel as linked

SectionsAddressRegion MemSizFileSizFile bytes
.text0x10000000flash0x001000x00100
.text0x10000100flash0x174a00x174a0
.ARM.exidx .storage0x100175a0flash0x00a600x00a60
.stack0x20000000SRAM0x015000x00000
.apps0x10040000flash0x000040x00000
.relocate .sram0x20001500SRAM0x02b8c0x00000
.attributes0x10018000flash0x0002c0x0002c

Both SRAM segments carry no file bytes, which is what a flash image requires: nothing in the file claims to be the contents of memory that has none.

Figure 1. Program headers of raspberry_pi_pico.elf and of the raspberry_pi_pico-app.elf built from it, read with arm-none-eabi-readelf -lW. The bar draws FileSiz against that segment's own MemSiz, so a hatched bar is a segment that occupies memory and takes up no room in the file. Kernel built from 2a7a40450; the two .text sizes move with the build, the .stack row does not.

3The section never changed, and the flags are not the cause

This is the part that rules out the converter, and it is worth stating plainly because it is counter-intuitive: .stack is still NOBITS afterwards. Its section header is untouched. Only the program header changed, and UF2 converters read segments, not sections — correctly, since segments are what a loader uses.

Section headers, the same two files

SectionType, as linkedType, after objcopyAddressSize
.stackNOBITSNOBITS20000000001500
.relocatePROGBITSREL20001500000000
.appsNOBITSPROGBITS10040000001000
.sramNOBITSNOBITS20001500002b8c
Figure 2. readelf -SW on both files. .apps going NOBITSPROGBITS is the requested change. .stack does not move, which is why nothing that reads the section table can see this bug. .relocate is reported as REL afterwards, which is odd and still unexplained. The row matters for another reason: that empty section is what moves the .stack program header, which is the next thing below.

The obvious reading is that giving .apps contents is what forces BFD to lay the segments out again, and that is what this page said until 2026-09-10. It is wrong. An objcopy with no arguments at all, on the kernel with nothing spliced into it, produces the same program header — and deleting one empty section stops it:

Where the FileSiz comes from. Three copies, with no app spliced into any.
VARIANT                            STACK-FILE/MEM
-------                            --------------
as linked, no objcopy at all       0x00000/0x01500
objcopy, no arguments at all       0x01500/0x01500
objcopy -R .relocate               0x00000/0x01500
llvm-objcopy, no arguments         0x00000/0x01500

  .relocate's reconstructed load address, and where the section says it is:
    .stack     size 00001500  vma 20000000  lma 20000000
    .relocate  size 00000000  vma 20001500  lma 20000000

The trigger is .relocate — the empty one. ELF section headers carry no load address, so BFD reconstructs one as it reads the file, and it does so from file offsets rather than addresses. The linker gives .stack, .relocate, .apps, .sram and .attributes a single shared offset, so zero-length .relocate matches the .stack segment first and comes back holding .stack's load address: the lma 20000000 above, on a section that lives at 0x20001500 and belongs in flash. .attributes sits at that same offset and escapes, because a segment whose p_filesz is 0 cannot cover its 0x2c bytes. Being zero-length is what lets .relocate fit anywhere.

objcopy then maps sections into segments by load address, so .relocate lands inside the .stack segment; its address is pushed out to that segment's end, which is the warning Section 1 already showed happening; and because it is PROGBITS the segment has to be given file bytes reaching it. That distance is .stack's size. In every file produced this way, .relocate's file offset minus .stack's is 0x1500 — and so is the p_filesz.

Two things follow that the flags reading had backwards. The section flags are innocent: program is simply the only rule that runs objcopy at all, which is why make flash, handing the linker's own ELF straight to the converter, has never failed. And so is the linker — it wrote p_filesz 0, and llvm-objcopy, which copies program headers instead of rebuilding them, hands that number back unchanged. The defect enters in the round trip through BFD, and nowhere before it.

The reach is wider than the RP2 boards, and the emptiness predicts which ones. Of the 25 Arm board ELFs I had built, 24 pick up the same bogus p_filesz from a bare objcopy; the only one that does not, nrf52840dk-test-kernel, is the only one whose .relocate is not empty. Six boards outside the RP2 family run this same splice rule. Two of them I have builds for and both carry it — teensy40 and weact_f401ccu6, at 0x2000 each, because the number is always .stack's own size. opentitan/earlgrey-cw310 should be clean, its .relocate being 0x1c rather than empty, but that is a prediction and not a measurement: it is RISC-V, and the Arm objcopy here will not read it. imxrt1050-evkb, psc3m5_evk and cy8cproto_62_4343_w I have not built at all. None of the six hands its result to a UF2 converter, which is why none of them has noticed.

Where that comes from, in binutils' own source

Everything above is behaviour, read off files. This is the code that produces it, from binutils-gdb at HEAD on sourceware — bfd/elf.c and include/elf/internal.h. It is not diffed against the 2.47 that produced the numbers, so: the behaviour is measured, the source is current master, and the two agree.

Reading the file gives the section a load address it never had. _bfd_elf_make_section_from_shdr walks the program headers for every allocated section. One that carries contents takes the second branch, and that branch works in file offsets:

  if ((newsect->flags & SEC_LOAD) == 0)
    newsect->lma = (phdr->p_paddr
                    + hdr->sh_addr - phdr->p_vaddr) / opb;
  else
    /* We used to use the same adjustment for SEC_LOAD
       sections, but that doesn't work if the segment
       is packed with code from multiple VMAs.
       Instead we calculate the section LMA based on
       the segment LMA.  It is assumed that the
       segment will contain sections with contiguous
       LMAs, even if the VMAs are not.  */
    newsect->lma = (phdr->p_paddr
                    + hdr->sh_offset - phdr->p_offset) / opb;

.relocate is PROGBITS and allocated, so SEC_LOAD is set and the second form runs. Its sh_offset and the .stack segment's p_offset are the same number, the subtraction is zero, and it comes back holding that segment's p_paddr unchanged — 0x20000000, the number objdump prints above. The comment there is worth reading: the file-offset form is deliberate, and it is the right answer for a segment packed with code from several VMAs. It is only wrong for a section with no size.

Why .relocate and not .attributes. A section is only offered a segment if ELF_SECTION_IN_SEGMENT accepts it, and for anything that is not SHT_NOBITS that means fitting inside the segment's file span:

/* Any section besides one of type SHT_NOBITS must have file
   offsets within the segment.  */
&& ((sec_hdr)->sh_type == SHT_NOBITS
    || ((bfd_vma) (sec_hdr)->sh_offset >= (segment)->p_offset
        && (!(strict)
            || ((sec_hdr)->sh_offset - (segment)->p_offset
                <= (segment)->p_filesz - 1))
        && (((sec_hdr)->sh_offset - (segment)->p_offset
             + ELF_SECTION_SIZE(sec_hdr, segment))
            <= (segment)->p_filesz)))

The bold clause decides it. The .stack segment has p_filesz 0, so the only section that can fit inside its file span is one of size zero. .relocate is size zero and passes; .attributes, sharing the same sh_offset, is 0x2c bytes and fails. That is the entire difference between the section this breaks and the one sitting next to it in the file.

The tie-break, which names the problem out loud. The loop assigns a load address for every segment a section matches, and stops at the first one that passes this:

  /* With contiguous segments, we can't tell from file
     offsets whether a section with zero size should
     be placed at the end of one segment or the
     beginning of the next.  Decide based on vaddr.  */
  if (hdr->sh_addr >= phdr->p_vaddr
      && (hdr->sh_addr + hdr->sh_size
          <= phdr->p_vaddr + phdr->p_memsz))
    break;

For .relocate: 0x20001500 ≥ 0x20000000, and 0x20001500 + 0 ≤ 0x20000000 + 0x1500. Both hold — the second is an inclusive bound, and the section has no size to push it over. So the loop breaks on the .stack segment and never reaches the segment that begins at 0x20001500, which is the one the linker put it in.

The same header defines a stricter form of that test, and its documentation describes this file exactly: ELF_SECTION_IN_SEGMENT_STRICT, under which “a zero size section won't match at the end of a segment, unless the segment is also zero size.” The reader calls the non-strict one.

The write side is then mechanical, and Section 1 already showed its output: rewrite_elf_program_header groups sections into segments by load address, so .relocate is placed in the .stack segment; assign_file_positions_for_load_sections finds its load address below the segment's running end, pushes it up to that end, and prints section .relocate lma 0x20000000 adjusted to 0x20001500; and a segment holding a section with contents 0x1500 bytes in is given a file span that reaches it.

4Every candidate fix, measured

Six variants, all run against the same kernel by reproduce.sh, which is the script that produced these numbers rather than a description of one. Pick a row.

What each candidate actually does

The rule as it is today

.stack FileSiz
0x01500
elf2uf2-rs
refused
app in the UF2
_estack symbol
kept

No UF2 is produced at all, so make program stops with a non-zero status. It does not silently flash a kernel with no app.

Figure 3. Each variant runs the Makefile's two steps and then the candidate fix. Every value is read back off the resulting file: the segment from readelf -lW, the app by decoding the UF2's block headers, the symbol from readelf -sW.

5The workaround in the issue has a trap in it

The issue's fix removes three sections: -R .stack -R .relocate -R .bss. It works, and I would not recommend copying it.

-R .stack on its own is sufficient, and it is the only one of the three that addresses the segment the converter named.

6Converting is not the same as landing

One thing worth checking before trusting any fix to this: whether the application bytes are in the resulting UF2 at all. The failure mode above is loud, but a nearby one is silent.

Two UF2s from the same kernel

InputBytesBlocksBlocks carrying the app
the kernel alone, no objcopy204800400none — correctly, there is no app in it
after the fix, with a 4096-byte app212992416400–415, at 0x10040000–0x10040f00

The application is sixteen blocks appended to four hundred: 4,096 bytes, the whole of it, at exactly the address _sapps names. Decoded out of the UF2 block headers, not inferred from an exit status.

Figure 4. The first row is also the control for section 1: the same converter takes the same kernel happily one step earlier, so the converter is not the thing that is broken.

The reason to check this rather than trust an exit code: llvm-objcopy is not a drop-in here. It updates the section but leaves the program header alone. The UF2 then converts with no error at all, and contains no application — a silent version of this bug, which costs more to find than the loud one. I hit that while working around this on a Pico 2 and it is the reason figure 3 has an "app in the UF2" column instead of a pass mark.

7The change

One line per rule, and there are five of them — every place an RP2 board splices an application into its kernel:

 $(KERNEL_WITH_APP): $(KERNEL)
 ifeq ($(APP),)
     $(error Please define the APP variable with the TBF file to flash an application)
 endif
     arm-none-eabi-objcopy --set-section-flags .apps=LOAD,ALLOC $(KERNEL) $@
     arm-none-eabi-objcopy --update-section .apps=$(APP) $@
+    arm-none-eabi-objcopy -R .stack $@

The section is removed only from <platform>-app.elf, the artifact that exists to be converted. The kernel ELF is a different file and keeps its symbols.

The same make target on all four RP2 boards that can splice an app, with the line and without it

BoardTargetConverter Without the lineWith it
raspberry_pi_picoprogramelf2uf2-rs rc=2, no UF2 rc=0, app at 0x10040000
raspberry_pi_pico_2programpicotool rc=2, empty UF2 rc=0, app at 0x10040000
pico_explorer_baseprogramelf2uf2-rs rc=2, no UF2 rc=0, app at 0x10040000
nano_rp2040_connectflash-appelf2uf2-rs rc=2, no UF2 rc=0, app at 0x10040000

All four fail today with the string from the issue, and all four take the application afterwards — 16 blocks, 4,096 bytes, at the address _sapps names, decoded from the UF2 block headers in every case.

Figure 5. Not the objcopy steps in isolation: this runs each board's real make target with a 4,096-byte stand-in .tbf, with the change applied and with it stashed. The fifth rule, raspberry_pi_pico_2's program-openocd, is changed the same way and is not in the table — it hands the ELF to openocd rather than a converter, so there is no UF2 to decode. It has since been run on a board, and it fails differently: Section 8.

The four boards carry five rules between them, because raspberry_pi_pico_2 splices twice: once for program and once for program-openocd. raspberry_pi_pico's program-probe shares the one rule with program, so it is fixed by the same line.

8Confirmed on hardware, on two chips

Everything above is measured on files, and a UF2 that converts and holds the application at the right address is not yet a board that boots it. Two runs on real silicon have since closed that gap. Different chips, different people, different flashing routes — and the same output.

confirmed The reporter, on an RP2040

that change worked at least for the RP2040

potto216, who filed the issue, put the line into raspberry_pi_pico's program rule, converted with elf2uf2-rs on his own machine and his own binutils, and flashed the result over BOOTSEL. Not one leg of that path is the one measured above.

RP2040 Revision 2 ASIC
tock$ Initialization complete. Enter main loop
Hello world!

tock$ list
 PID    ShortID    Name                Quanta  Syscalls  Restarts  Grants  State
 0      Unique     console                  0        10         0   0/ 9   Terminated

The second run is the fifth rule, program-openocd, on an RP2350 over SWD — the one this page could not reach when it was written. It is broken today too, and it breaks in a third way that neither converter shows.

The board's own program-openocd rule, with the line and without it

Rule as it ismakeopenocd The board afterwards
Without the linerc=2 exits 1 at verify_image halted, console silent
With itrc=0 programs and verifies clean resets itself, application runs

openocd does not refuse the ELF the way a UF2 converter does. program writes the flash segments and skips the SRAM oneWarn : no flash bank found for address 0x20000000 — so .stack is never written. verify_image then checks every segment, that one included, against SRAM nothing wrote, and gives up: Error: checksum mismatch, then 128 diffs beginning diff 0 address 0x20000000. Was 0x88 instead of 0x00. It exits before reaching the reset, which is why the board is left halted instead of running.

Figure 6. The board's real make program-openocd target, APP=console.tbf, on a Pico 2 W through a Debug Probe. The Makefile is unmodified; only OPENOCD= is pointed at a shim that ships the ELF to the machine holding the probe and runs the real openocd there. The application region was erased between the two runs, so the second row's result is its own.

The flash was correct the whole time. Read back over SWD after the failing run, 0x10040000 is byte-identical to the .tbf that went in — a power cycle would have booted it. This rule fails at the verify, not at the write, and the board looking dead afterwards is the part a user would report.

Initialization complete. Enter main loop
Hello world!
tock$ list
 PID    ShortID    Name                Quanta  Syscalls  Restarts  Grants  State
 0      Unique     console                  0        10         0   0/ 4   Terminated

Same application, same ten syscalls, same clean exit, on two chips reached two different ways. The Grants column differs because the two boards expose different numbers of drivers.

9Reproducing it

No board and no application are needed. Nothing about the failure depends on what is spliced in, so any 4,096 bytes stand in for a .tbf. Save reproduce.sh at the root of a Tock checkout, build a kernel, and hand it the ELF:

$ (cd boards/raspberry_pi_pico && make)
$ sh reproduce.sh target/thumbv6m-none-eabi/release/raspberry_pi_pico.elf

VARIANT                        STACK-FILE/MEM  UF2      _ESTACK  APP
-------                        --------------  ---      -------  ---
Makefile as it stands          0x01500/0x01500 REFUSED  kept     -
-R .stack                      removed         ok       lost     416 blocks, app in 16 at 0x10040000-0x10040f00
-R .stack -R .relocate -R .bss removed         ok       lost     416 blocks, app in 16 at 0x10040000-0x10040f00
set .stack=alloc,noload        0x01500/0x01500 REFUSED  kept     -
set .stack=noload              removed         REFUSED  kept     -
set .apps=alloc,load,contents  0x01500/0x01500 REFUSED  -        -

The control: the same kernel with no objcopy at all.
  converts, 400 blocks, app in 0 at nowhere

Where the FileSiz comes from. Three copies, with no app spliced into any.
VARIANT                            STACK-FILE/MEM
-------                            --------------
as linked, no objcopy at all       0x00000/0x01500
objcopy, no arguments at all       0x01500/0x01500
objcopy -R .relocate               0x00000/0x01500
llvm-objcopy, no arguments         0x00000/0x01500

  .relocate's reconstructed load address, and where the section says it is:
    .stack     size 00001500  vma 20000000  lma 20000000
    .relocate  size 00000000  vma 20001500  lma 20000000

  the warning the bare copy prints, and -R .relocate does not:
    arm-none-eabi-objcopy: m-plain.elf: section .relocate lma 0x20000000 adjusted to 0x20001500

reproduce.sh is the script that printed that. It needs arm-none-eabi-objcopy, arm-none-eabi-readelf, arm-none-eabi-objdump, elf2uf2-rs and python3. The last row of the second table needs llvm-objcopy as well, and says so when it is missing; the run above set LLVM_OBJCOPY to the copy inside a rustup toolchain.

10What this does not establish