What simulation can't catch, and why it never tells you

I wrote a CI check that asserted a block of code finished under a cycle budget. It passed. It measured zero cycles, because the counter it reads does not exist on that virtual board.
Most writing about embedded simulation limitations is a feature checklist: this simulator has Ethernet, that one doesn't. That framing is useless, because a missing feature you know about costs you an afternoon. The expensive failures are the ones where the simulator answers the question anyway. It returns a number. The number is fiction. Nothing in your build log says so.
So here is the honest version, organized by failure mode rather than by feature, and built on two real Chiplab runs I can hand you the IDs for. Chiplab runs Renode 1.16.1, and everything below is visible in the transcripts.
Failure mode 1: a register that does not exist
The DWT cycle counter is the standard way to time code on a Cortex-M. You set TRCENA in
DEMCR at 0xE000EDFC, set CYCCNTENA in DWT_CTRL at 0xE0001000, and read DWT_CYCCNT at
0xE0001004.1 Every profiling guide for this family walks you through those three
addresses.
I built a probe that does exactly that, then times 256 flash-resident nop instructions
under four different FLASH_ACR configurations. Here is what came back, unaltered:
Renode, version 1.16.1 (d66b0c2aa3d420408eccecfd1d3bab0fd702a6db)
cpu: Setting initial values: PC = 0x8000189, SP = 0x20030000
...
sysbus: [cpu: 0x8000334] WriteDoubleWord to non existing peripheral at 0xE0001004, value 0x0.
sysbus: [cpu: 0x8000336] ReadDoubleWord from non existing peripheral at 0xE0001000.
sysbus: [cpu: 0x800033E] WriteDoubleWord to non existing peripheral at 0xE0001000, value 0x1.
usart2: [host: 0.22s (+8.49ms)|virt: 0s (+0s)] dwt_ctrl=0x00000000 cyccnt_enabled=no
usart2: [host: 0.22s (+0.79ms)|virt: 0s (+0s)] cyccnt_before=0 cyccnt_after=0 advanced=NO
The writes returned. No fault, no error code. From inside the firmware, enabling the cycle
counter looked like it worked. dwt_ctrl reads back 0x00000000 and every subsequent
measurement is zero.
This is the shape of the whole problem. The simulator knew. It printed non existing peripheral on its own host console. The firmware — the thing under test, the thing your
assertions run inside — learned nothing.

Failure mode 2: a register modeled as storage, not as behavior
Same run, next four lines. The probe writes FLASH_ACR at 0x40023C00 with different
latency settings and times the same 256 nops each time.
flash_cfg=ws0_no_accel acr_written=0x00000000 acr_readback=0x00000000 cycles_for_256_nops=0
flash_cfg=ws5_no_accel acr_written=0x00000005 acr_readback=0x00000005 cycles_for_256_nops=0
Look at the middle column. acr_written=0x00000005 acr_readback=0x00000005. The LATENCY
field reads back exactly what was written. A clock-setup driver that verifies its own
register write — which is good practice, and which most HALs do — is completely satisfied.
There is no timing consequence at all. Flash wait states on this model are a variable you can store a number in. On silicon, five wait states means the core stalls five cycles on every flash fetch that misses the accelerator. Here it means nothing, and it means nothing quietly.
This is worse than an unimplemented register, because an unimplemented register usually reads back zero and a careful driver notices. This one lies convincingly.
Failure mode 3: bits that vanish between the write and the read
The last two configurations turn on the flash accelerator. Prefetch is bit 8 of FLASH_ACR, instruction cache is bit 9, data cache is bit 10.
flash_controller: Unhandled write to offset 0x0. Unhandled bits: [8] when writing value 0x105. Tags: PRFTEN (0x1).
flash_cfg=ws5_prefetch acr_written=0x00000105 acr_readback=0x00000005 cycles_for_256_nops=0
flash_controller: Unhandled write to offset 0x0. Unhandled bits: [9-10] when writing value 0x600. Tags: ICEN (0x1), DCEN (0x1).
flash_cfg=ws0_icache_dcache acr_written=0x00000600 acr_readback=0x00000000 cycles_for_256_nops=0
Write 0x105, read back 0x005. Bit 8 is gone. Write 0x600, read back 0x000. Bits 9
and 10 are gone. Renode names them precisely on its console — Tags: PRFTEN (0x1), ICEN (0x1), DCEN (0x1) — which is genuinely good diagnostics, on a channel the firmware cannot
see.
On revision A of this exact silicon, ST's own errata sheet says the ART Accelerator prefetch queue "is not supported," with workaround "None."2 So a real chip and this simulator can agree that PRFTEN does nothing — for completely unrelated reasons, one of which is documented in a PDF you can read and the other of which is only discoverable by writing a probe. That is the gap. What cycle-accurate actually means goes deeper into why simulator accuracy claims and timing claims are different claims.
Failure mode 4: memory that is not there at all
Different probe, same board. This one writes 0xC0FFEE01 to a list of addresses and reads
each 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=0x20020000 name=just_past_128k wrote=0xc0ffee01 read=0xc0ffee01 ram_here=yes
addr=0x20030000 name=past_192k_claim wrote=0xc0ffee01 read=0xc0ffee01 ram_here=yes
sysbus: [cpu: 0x80003BC] WriteDoubleWord to non existing peripheral at 0x20040000, value 0xC0FFEE01.
addr=0x20040000 name=way_past_256k wrote=0xc0ffee01 read=0x00000000 ram_here=no
sysbus: [cpu: 0x80003BC] WriteDoubleWord to non existing peripheral at 0x10000000, value 0xC0FFEE01.
addr=0x10000000 name=ccm_data_ram wrote=0xc0ffee01 read=0x00000000 ram_here=no
== probe complete, no fault taken ==
ST's RM0090 lists an STM32F405/407 as having "Main internal SRAM1 (112 KB)", "Auxiliary
internal SRAM2 (16 KB)", and a separate 64 KB CCM data RAM that "is not part of the bus
matrix."3 The DS8626 memory map puts that 128 KB block at 0x20000000, marks 0x2002 0000 – 0x3FFF FFFF as Reserved, and puts CCM at 0x10000000.4
The simulated map is inverted relative to the chip:
| Address range | Real STM32F407 (RM0090 / DS8626) | This simulated board | Run B probe result |
|---|---|---|---|
0x10000000 | CCM data RAM, 64 KB | not mapped | read=0x00000000 ram_here=no |
0x20000000 – 0x2001FFFF | SRAM1 + SRAM2, 128 KB | RAM, present | read=0xc0ffee01 ram_here=yes |
0x20020000 – 0x2003FFFF | Reserved | RAM, present | read=0xc0ffee01 ram_here=yes |
0x20040000 and up | Reserved | not mapped | read=0x00000000 ram_here=no, no fault |
A follow-up probe walked the exact edge to be sure it wasn't a lucky guess: RAM answers
correctly through the last word at 0x2003FFFC and goes silent at 0x20040000. That's a
probed boundary, not an inference — the simulated board provides exactly 256 KB contiguous
from 0x20000000.
It invents 128 KB the chip does not have at that address and omits 64 KB the chip does
have somewhere else. And read the last transcript line again: no fault taken. Writes
into unmapped space vanished, reads returned zero, and the CPU never took an exception.
That matches what I found when I planted four deliberate faults and got no
HardFault — the bus model spots the bad access, logs
it, and moves on.

The bug this hid was ours
That 0x20030000 stack pointer is not a synthetic example. It comes from memory.x in
Veecle's own public examples repo, which declares RAM : ORIGIN = 0x20000000, LENGTH = 192K for an STM32F4 Discovery. That is an STM32F42x memory map. On an F407, 192 KB
contiguous at 0x20000000 does not exist.
We shipped that, and the simulator hid it from us. Not maliciously — it hid it by being generous. The board model provides enough contiguous RAM that the wrong linker script boots fine, prints fine, and exits clean.
The same ELF, unchanged, tells three different stories:
| Machine | Result | Why |
|---|---|---|
Chiplab (Renode 1.16.1), stm32f4_discovery | boots, runs, prints, exits clean | its platform provides 256 KB contiguous at 0x20000000 |
QEMU 11.0.3, netduinoplus2 (STM32F405) | instant lockup before main | provides the silicon-correct 128 KB |
| Real STM32F407 | would bus-fault on the same push | 0x20020000+ is reserved per DS8626 |
The simulator that "worked" was the wrong one. QEMU's brutal qemu: fatal: Lockup was the
honest answer, and it is the least friendly tool of the three. And cargo build --release
printed Finished for all of it, because a linker script pointing the stack into reserved
address space links perfectly cleanly.
Embedded simulation limitations that no functional simulator will ever fix
The four modes above are gaps in a model. These are different — they are things a functional instruction-set simulator is structurally not in the business of producing.
| What you want | Why simulation can't give it | What you get instead |
|---|---|---|
| Cycle counts, WCET | Renode advances virtual time from a configured MIPS rate, default 100 MIPS5 | a plausible-looking number derived from a config setting |
| Analog behavior | no ADC nonlinearity, reference drift, or input impedance in the model | ideal converted values |
| EMI, signal integrity | no physical layer exists to be noisy | perfect edges forever |
| Power draw | no current model attached to peripheral state | nothing |
| Real bus peers | sensor models implement the datasheet, not the part | clean ACKs, no clock stretching, no bad units |
| Thermal | no die, no ambient | no derating, no shutdown |
| Silicon errata | the model implements the manual, not the mistakes | correct behavior the chip does not have2 |
That last row is the sharp one and it cuts both ways. A simulator is a model of the documentation. Errata are the list of places the silicon disagrees with its own documentation. By construction, a functional simulator implements the document, so it is closest to the chip exactly where the chip is least surprising. This is a large part of what hardware-in-the-loop rigs exist for: HIL puts the real device in the loop precisely because offline simulation "tests a model of the device, not the device itself, so it misses real timing, firmware and hardware behaviour."6 Which tests belong where is its own argument — simulation vs HIL, and what belongs in CI lays out where I draw the line.
A test that measures nothing is worse than no test
This is the whole point, and it is not really about Renode limitations. It is about what a green check means.
If I had no cycle counter at all, I would know I have no timing coverage, and I would go
find some. Instead I had assert!(cycles < BUDGET) with cycles = 0, which is a green
check that actively tells me timing is fine. It will stay green through every refactor
that triples the real cost. That is a silent test failure: a test that passes for a reason
unrelated to the thing it claims to check.

Four things I now do, all cheap:
- Assert on what the model actually owns: register state, protocol bytes on a bus, control flow, whether the thing booted. Not on quantities the model fabricates.
- Make every measurement prove its instrument first.
advanced=NOshould fail the build beforecycles < BUDGETis ever evaluated. A counter that did not move is an error, not a zero. - Fail the build on the host log. Renode printed
non existing peripheralandUnhandled bitsfor every single problem in this post. In most CI setups that output goes to a file nobody opens. Grep it. Treat those strings as failures unless explicitly allowlisted. - Read the platform file's memory map before you trust your linker script. Both of them are claims. Only one of them is going to be checked at runtime, and on the wrong board neither is.
None of this makes me want to stop using simulation. Two probes, roughly thirty seconds each, found a real bug in our own repo and mapped four distinct classes of thing this board cannot tell me. That is an extremely good trade. But I would not have gone looking if I still believed the green check.
The simulator will never volunteer which of its answers are made up. That part is your job.
Update, 2026-08-04: the linker script is fixed
We fixed it the same day this went out. memory.x now declares LENGTH = 128K, which is
what an STM32F407 actually has at 0x20000000, and the initial stack pointer moved to
0x20020000 — the real top of SRAM.
Rebuilt and re-run, both machines now agree. Chiplab reports
Setting initial values: PC = 0x8000189, SP = 0x20020000 and prints Hello world!. So
does QEMU's netduinoplus2, which refused to reach main at all before the fix.
Every transcript above is from the broken build and stays exactly as it was. None of them is the reason we found it. The zeros, the accepted wait states, the dropped cache bits and the RAM that answered from reserved space all behaved identically before and after — which is the whole post.
- Repo & examples: github.com/veecle/chiplab
- Chiplab: veecle.ai/chiplab
Sources
Footnotes
-
Baldassari, François. "Profiling Firmware on Cortex-M." Interrupt by Memfault. https://interrupt.memfault.com/blog/profiling-firmware-on-cortex-m ↩
-
STMicroelectronics. "STM32F405/407xx and STM32F415/417xx revision A device limitations," errata sheet, Doc ID 022183 Rev 1, section 2.1.1. https://d1.amobbs.com/bbs_upload782111/files_46/ourdev_678847KKGVKC.pdf ↩ ↩2
-
STMicroelectronics. RM0090, "STM32F405/415, STM32F407/417, STM32F427/437 and STM32F429/439 advanced Arm-based 32-bit MCUs" reference manual. https://www.cse.scu.edu/~dlewis/book3/docs/RM0090.pdf ↩
-
STMicroelectronics. STM32F405xx/407xx datasheet (DS8626), memory map. https://hangpersonal.com/wp-content/uploads/2024/10/STM32F407-Memory-Mapping.pdf ↩
-
Renode documentation. "Time framework." https://renode.readthedocs.io/en/latest/advanced/time_framework.html ↩
-
Wirtek. "Hardware-in-the-loop testing for embedded systems." https://www.wirtek.com/blog/hardware-in-the-loop-testing-for-embedded-systems ↩
