Firmware CI ends at the linker

MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 1024K
RAM : ORIGIN = 0x20000000, LENGTH = 192K
}
That is memory.x from the STM32F4 Discovery example in Veecle's own public examples
repo, and our firmware CI never noticed that the RAM line is wrong. An STM32F407 has 128
KB of contiguous SRAM at 0x20000000 — 112 KB of SRAM1 plus 16 KB of SRAM2 — and
everything from 0x20020000 up is marked Reserved in the datasheet memory map.1 2
The extra 64 KB the chip really does have sits at 0x10000000, on a different bus.
cargo build --release printed Finished and said nothing.
We shipped that. It built clean everywhere, because the pipeline around it compiled, linked, and stopped — where most embedded CI pipelines stop.
The linker did exactly its job
cortex-m-rt places the call stack at the end of the RAM region unless you say
otherwise.3 LENGTH = 192K at origin 0x20000000 therefore puts the vector table's
first word — the initial stack pointer — at 0x20030000. That is documented behavior, not
a bug, and the crate is explicit that it "expects the user, or some other crate, to
provide the memory layout of the target device."3
The linker is equally explicit about its own contract. GNU ld's manual says the MEMORY
command "describes the location and size of blocks of memory in the target," and that the
linker "will set section addresses based on the memory regions, and will warn about
regions that become too full."4
Warn about regions that become too full. The linker checks that your sections fit inside the numbers you handed it. It has no opinion about whether those numbers describe a real chip. Declare 192 KB where 128 KB exists and it's perfectly happy — you gave it more room, not less.
So: the compiler checks types, the linker checks that sections fit the memory map it was given, and nothing in the toolchain checks that the memory map matches the silicon. No stage can. The only thing that catches this is executing the binary against something that models the chip's address space — precisely the stage most firmware CI does not have.
Where each stage actually stops
A normal embedded CI pipeline, stage by stage, and what each one can prove. This table is the whole post.
| Stage | What it actually checks | What it structurally cannot catch | Hardware? |
|---|---|---|---|
| Compile | Types, syntax, API signatures, borrow rules | Whether any address is real | No |
| Static analysis | Known-bad patterns, undefined behavior, some data flow | A memory map that lies; it isn't a pattern | No |
| Unit tests on host | Pure logic: parsers, state machines, encoders | Everything target-specific — this runs on x86 | No |
| Link | That every section fits the regions declared | Whether those regions exist on the chip | No |
| Flash | That the programmer could write the image | Whether it survives reset | Yes |
| Execute on target | Boot, stack, clocks, peripherals, does it run | Only what the model is honest about | Yes, or simulated |
| Integration / HIL | Real timing, electrical behavior, sensors | Little. It's just slow, scarce, expensive | Yes |
Four of the seven stages need no hardware at all, and all four are blind to a wrong memory map. The first stage that can see it is the first stage that needs a chip, real or modeled. That's the whole shape of the problem, and why "firmware build vs run" isn't pedantry.

Why firmware CI stops there, and why it isn't laziness
I want to be fair here, because the smug version of this post is also the wrong one.
A 2025 literature review of embedded DevOps synthesized 20 academic and industrial sources on CI/CD in firmware development. Across nearly all the empirical studies it reviewed, "hardware access was the single largest blocker" to continuous delivery. It also reports interviews across CPS organizations describing CI failures from hardware/software integration delays, flakiness in HIL setups, and hardware lockouts.5
That is not a survey saying "N percent of pipelines never run the binary." No such number exists that I'd trust and I'm not inventing one. What the literature supports is narrower: the hardware stage is the stage that breaks, and teams route around it.
Semaphore's guide for embedded teams describes the same split from the vendor side. Fast feedback first — unit tests, static analysis, build validation, simulation or emulation via QEMU or Renode. Hardware-in-the-loop only after those pass, on USB-connected device farms with network-controlled power switches and Raspberry Pis as device controllers.6 Sensible advice. Also a shopping list.
The running cost isn't theoretical either. An engineer maintaining a self-hosted HIL CI runner for an nRF52 published the real bill: roughly $100 of Pi and dev board, then the part nobody budgets for — OS patching, SD cards replaced every year or two under CI write load, and "USB connection instability — the board occasionally drops off and requires a physical reconnect."7 Somebody has to walk over and reseat a cable. That is the real reason your firmware continuous integration ends at the linker. Not laziness. Physical maintenance nobody owns.
Simulation moves the execute stage back inside reach of CI without pretending it replaces the lab — I've argued that layering in what belongs in CI versus what belongs on a bench and HIL won't scale to the AI era. It stops garbage from reaching the lab.
What happens the moment you run it
Same ELF, md5 90438763f9670f576bf33996d7ff6bd9. QEMU has no STM32F4 Discovery machine,
so this is netduinoplus2, QEMU's STM32F405 board:
$ qemu-system-arm -machine netduinoplus2 -cpu cortex-m4 \
-kernel hello-stm32f4-discovery -nographic -serial mon:stdio
qemu: fatal: Lockup: can't escalate 3 to HardFault (current priority -1)
R00=00000000 R01=00000000 R02=00000000 R03=00000000
R12=00000000 R13=2002ffe0 R14=fffffff9 R15=0800359c
XPSR=41000003 -Z-- T handler
R13=2002ffe0 is the stack pointer, above the 128 KB of SRAM QEMU's F405 model provides,
because the linker script put it at 0x20030000. R15=0800359c is the HardFault
handler's first instruction; R14=fffffff9 says the CPU is already in handler mode. The
first push hit unmapped memory, faulted, entered HardFault, whose own first instruction is
push {r7, lr}, which faulted again. QEMU's own diagnosis is Lockup: can't escalate 3 to HardFault (current priority -1) — a fault inside HardFault at priority -1 is lockup by
definition. Dead before main.
Ugly, unhelpful, no diagnosis, completely correct. Milliseconds of execution found what four stages of static checking could not.

Executing it is necessary. It is not sufficient.
Here's the part that should stop you feeling good.
The same ELF runs fine on a simulated STM32F4 Discovery board. Not "mostly fine" — it
boots, prints, exits clean. I ran a probe on Chiplab that writes 0xC0FFEE01 to a list of
addresses and reads each one back:
== ram boundary probe: linker claims 192k at 0x20000000 ==
initial_sp_from_vector_table=0x20030000
addr=0x20000100 name=sram1_start+0x100 wrote=0xc0ffee01 read=0xc0ffee01 ram_here=yes
addr=0x2001fffc name=sram2_last_word_128k wrote=0xc0ffee01 read=0xc0ffee01 ram_here=yes
addr=0x20020000 name=just_past_128k wrote=0xc0ffee01 read=0xc0ffee01 ram_here=yes
addr=0x20028000 name=sram3_region_f42x wrote=0xc0ffee01 read=0xc0ffee01 ram_here=yes
addr=0x20030000 name=past_192k_claim wrote=0xc0ffee01 read=0xc0ffee01 ram_here=yes
...
addr=0x10000000 name=ccm_data_ram wrote=0xc0ffee01 read=0x00000000 ram_here=no
== probe complete, no fault taken ==
The board model hands out RAM at 0x20020000 and 0x20030000, which on an F407 is
reserved address space. It does not map 0x10000000, where the F407's 64 KB of CCM data
RAM actually lives.1 2 The memory map is inverted relative to the chip in both
directions.
| Address range | Real STM32F407 (RM0090 / DS8626) | This simulated board |
|---|---|---|
| 0x20000000 – 0x2001FFFF | SRAM1 + SRAM2, 128 KB | RAM, present |
| 0x20020000 – 0x2003FFFF | Reserved | RAM, present |
| 0x20040000 and up | Reserved | not mapped |
| 0x10000000 | CCM data RAM, 64 KB | not mapped |
Scoreboard on one binary: the toolchain said nothing, the honest simulator killed it instantly, the generous one ran it to completion, and a real F407 would bus-fault on that same first push. The simulator that "worked" is the one that was wrong.
Execution catches strictly more than linking. It catches exactly as much as the model is honest about and not one byte more — the same boundary a silent runtime failure that compiles clean sits on, seen from the other side, and worth reading next to what simulation quietly misses.

So what do I actually do about it?
Run the binary in CI, on the cheapest thing that will run it, then read that thing's
memory map against the datasheet once. Not per commit — once per board, when you adopt it.
The MEMORY block in your linker script and the address table in the chip's datasheet are
two documents that should agree, and nobody in your pipeline is comparing them.
We found ours because a second simulator disagreed with the first one. That's a stupid way to find a bug and I'll take it.
Update, 2026-08-04: fixed, and the build looked identical
memory.x now says LENGTH = 128K. The stack pointer starts at 0x20020000, inside RAM
the chip actually has. The comment above it explains why nobody should raise it back to
192K.
Both machines now run it: Chiplab prints Hello world! with SP = 0x20020000, and QEMU's
netduinoplus2 prints Hello world! instead of locking up before main.
Here is the part that belongs in this post specifically. cargo build --release said
Finished `release` profile [optimized] target(s) before the fix and after it. Same
command, same exit code, same silence. The build was never going to be the thing that
noticed, and adding a stage that compiles harder would not have helped. Running it did.
Sources
Footnotes
-
STMicroelectronics, "RM0090: STM32F405/415, STM32F407/417, STM32F427/437 and STM32F429/439 advanced Arm-based 32-bit MCUs," Rev 19. https://www.cse.scu.edu/~dlewis/book3/docs/RM0090.pdf ↩ ↩2
-
STMicroelectronics, STM32F405xx/STM32F407xx datasheet (DS8626), memory map extract. https://hangpersonal.com/wp-content/uploads/2024/10/STM32F407-Memory-Mapping.pdf ↩ ↩2
-
cortex-m-rtcrate documentation, "Requirements →memory.x" and "_stack_start/_stack_end." https://docs.rs/cortex-m-rt ↩ ↩2 -
GNU Binutils, "3.7 MEMORY Command," GNU linker (
ld) manual. https://sourceware.org/binutils/docs/ld/MEMORY.html ↩ -
"Embedded DevOps: A Survey on the Application of DevOps Practices in Embedded Software and Firmware Development," arXiv:2507.00421, 2025. https://arxiv.org/html/2507.00421v1 ↩
-
Miloravac, Pete. "What CI/CD strategies work for embedded or IoT projects that require hardware testing?" Semaphore, April 2026. https://semaphore.io/blog/what-ci-cd-strategies-work-for-embedded-or-iot-projects-that-require-hardware-testing ↩
-
"Firmware HIL CI Pipeline." Reverse to Build devlog series #5. https://reversetobuild.com/devlogs/firmware-hil-ci-pipeline ↩
