Skip to content
Chiplab logo
Chiplab logo
Back to Now

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.

Terminal transcript showing a DWT cycle counter read returning zero on a simulated STM32F4

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 rangeReal STM32F407 (RM0090 / DS8626)This simulated boardRun B probe result
0x10000000CCM data RAM, 64 KBnot mappedread=0x00000000 ram_here=no
0x200000000x2001FFFFSRAM1 + SRAM2, 128 KBRAM, presentread=0xc0ffee01 ram_here=yes
0x200200000x2003FFFFReservedRAM, presentread=0xc0ffee01 ram_here=yes
0x20040000 and upReservednot mappedread=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.

Diagram comparing the real STM32F407 memory map with the simulated board's inverted map

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:

MachineResultWhy
Chiplab (Renode 1.16.1), stm32f4_discoveryboots, runs, prints, exits cleanits platform provides 256 KB contiguous at 0x20000000
QEMU 11.0.3, netduinoplus2 (STM32F405)instant lockup before mainprovides the silicon-correct 128 KB
Real STM32F407would bus-fault on the same push0x20020000+ 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 wantWhy simulation can't give itWhat you get instead
Cycle counts, WCETRenode advances virtual time from a configured MIPS rate, default 100 MIPS5a plausible-looking number derived from a config setting
Analog behaviorno ADC nonlinearity, reference drift, or input impedance in the modelideal converted values
EMI, signal integrityno physical layer exists to be noisyperfect edges forever
Power drawno current model attached to peripheral statenothing
Real bus peerssensor models implement the datasheet, not the partclean ACKs, no clock stretching, no bad units
Thermalno die, no ambientno derating, no shutdown
Silicon erratathe model implements the manual, not the mistakescorrect 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.

Diagram showing a CI assertion passing because a simulated DWT cycle counter returned zero

Four things I now do, all cheap:

  1. 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.
  2. Make every measurement prove its instrument first. advanced=NO should fail the build before cycles < BUDGET is ever evaluated. A counter that did not move is an error, not a zero.
  3. Fail the build on the host log. Renode printed non existing peripheral and Unhandled bits for 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.
  4. 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.

Sources

Footnotes

  1. Baldassari, François. "Profiling Firmware on Cortex-M." Interrupt by Memfault. https://interrupt.memfault.com/blog/profiling-firmware-on-cortex-m

  2. 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

  3. 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

  4. STMicroelectronics. STM32F405xx/407xx datasheet (DS8626), memory map. https://hangpersonal.com/wp-content/uploads/2024/10/STM32F407-Memory-Mapping.pdf

  5. Renode documentation. "Time framework." https://renode.readthedocs.io/en/latest/advanced/time_framework.html

  6. Wirtek. "Hardware-in-the-loop testing for embedded systems." https://www.wirtek.com/blog/hardware-in-the-loop-testing-for-embedded-systems