The biggest handicap of embedded development is this: when you do not have the hardware yet, you have to wait.
native_sim solves exactly that problem. I have used it on many projects, and
being able to start on the application before the hardware was ready shortened
development considerably.
This post covers how it actually works, what it can and cannot emulate, and
the two tools that make it worth using even once the boards have arrived: a
real debugger and AddressSanitizer. Everything below was run on Zephyr 4.4.2,
and the example is in
native-sim in firmware-notes.
So how does it work?#
native_sim compiles your code for your host system rather than for the
embedded target. The output is not a .hex or a .bin but an ordinary Linux
executable. Running it needs neither a flashing tool nor a JTAG debugger.
$ west build -b native_sim
$ ./build/zephyr/zephyr.exe
At this point the Zephyr kernel is fully running. Thread scheduling, timers, kernel primitives: all of them are active.
Under the hood, native_sim is a Zephyr board like any other, with its own devicetree and Kconfig, built on Zephyr’s POSIX architecture. Each Zephyr thread is backed by a host pthread, but only one of them is allowed to run at a time, exactly when the Zephyr kernel says so. The scheduling you observe is Zephyr’s, not Linux’s, which is what makes the behaviour match a single-core microcontroller.
Time is simulated too, and this is the part that surprises people. An
application is slowed down to real time by default, so it behaves like it
would on a board. A test build is not: Kconfig turns real time off when
CONFIG_TEST is set, and simulated time then advances as fast as the host can
go. You can choose either way at run time. Here is a small application that
sleeps ten times for one second:
$ ./build/zephyr/zephyr.exe -stop_at=11
uptime: 10100 ms
$ ./build/zephyr/zephyr.exe -no-rt -stop_at=11
uptime: 10100 ms
The first run took 11.0 seconds of wall-clock time. The second took 0.004 seconds: eleven simulated seconds in four milliseconds. That is why a test suite full of timeouts and sleeps finishes almost instantly in CI. (The uptime is 10,100 ms and not 10,000 because each one-second sleep lasts one extra tick: the kernel adds a tick to every relative timeout so that it can never be shorter than requested, and native_sim ticks at 100 Hz. A real board follows the same rule.)
What it emulates#
There are GPIO emulators, I2C emulators and UART simulation too, so you can test even the code that depends on peripherals. The list in native_sim’s devicetree is longer than most people expect:
- GPIO, with an API to drive inputs and read outputs from a test.
- I2C and SPI controllers that device emulators attach to. A sensor emulator answers on the bus the way the real part would, so the real driver runs against it.
- ADC, DMA, RTC, a counter and a random number generator.
- CAN, either as a loopback or connected to a real Linux SocketCAN interface.
- A flash simulator and an EEPROM simulator, so storage code and settings can be tested, including what happens after a “reboot”.
- UARTs on a pseudo-terminal, or on the process’s own stdin and stdout.
- A display and touch input through SDL, in a window on your desktop.
- Bluetooth, through a real controller on the host via Linux’s HCI user channel.
What it does not have is your board’s actual peripherals. There is no STM32 timer or nRF radio in there, and that boundary matters; more on it below.
An important detail#
native_sim is not an ISS (Instruction Set Simulator), and it is not QEMU
either. It does not run your code by emulating an architecture such as ARM. It
compiles directly for x86 and runs on Linux. That is why you can use every
native Linux tool: GDB, Valgrind, AddressSanitizer and the rest.
Those tools are the real reason to keep native_sim in your workflow after the hardware arrives, so here they are on a concrete bug. The example is a moving average over the last four samples:
int32_t avg_add(struct avg *a, int32_t sample)
{
if (a->count == AVG_WINDOW) {
/* The window is full: the oldest sample leaves the sum. */
a->sum -= a->samples[a->next];
} else {
a->count++;
}
a->samples[a->next] = sample;
a->sum += sample;
if (++a->next == AVG_WINDOW) {
a->next = 0;
}
return a->sum / a->count;
}
Now break it the way such code usually breaks: change == to > in the wrap
check, so the index is allowed to reach AVG_WINDOW, one past the end of the
array.
Without AddressSanitizer: a symptom#
The test suite fails, but only with a wrong number:
Assertion failed at WEST_TOPDIR/firmware-notes/native-sim/tests/average/src/main.c:33: average_test_window_slides: (avg_add(&a, 0) not equal to 0)
That tells you the average is wrong. It does not tell you why, and on a real board an out-of-bounds read like this one can just as easily return a plausible value and pass.
With AddressSanitizer: the cause#
Add two lines to the build, CONFIG_ASAN=y and CONFIG_UBSAN=y, and run the
same test:
==196404==ERROR: AddressSanitizer: stack-buffer-overflow on address 0xef2b0038 ...
READ of size 4 at 0xef2b0038 thread T5
#0 0x0804badc in avg_add native-sim/lib/average/average.c:21
#1 0x0804ac5f in average_test_window_slides native-sim/tests/average/src/main.c:31
...
This frame has 1 object(s):
[32, 56) 'a' (line 23) <== Memory access at offset 56 overflows this variable
It names the kind of error, the exact line (a->sum -= a->samples[a->next],
reading past the end), the test that called it, and the variable that was
overrun. No hardware debugger on a microcontroller tells you that much, that
fast. In the example repository the sanitizer build is simply a second Twister
scenario:
tests:
native_sim.average: {}
native_sim.average.asan:
extra_configs:
- CONFIG_ASAN=y
- CONFIG_UBSAN=y
One practical note: Twister writes the sanitizer report to
handler_stderr.log, next to the handler.log you would normally open.
A real debugger#
Because zephyr.exe is a normal Linux process, gdb works on it as it would
on any host program, including conditional breakpoints on your firmware’s own
state. Stop only at the moment the index goes out of range:
$ gdb -q build/zephyr/zephyr.exe
(gdb) break average.c:21 if a->next == 4
(gdb) run
Thread 6 "test_window_sli" hit Breakpoint 1, avg_add (a=0xf7b4c254, sample=0) at native-sim/lib/average/average.c:21
21 a->sum -= a->samples[a->next];
(gdb) print *a
$1 = {sum = 400, next = 4 '\004', count = 4 '\004', samples = {100, 100, 100, 100}}
Even the thread name is the test’s name. There is no probe to attach, no flashing between attempts, and a rebuild takes seconds.
The alternatives#
There are alternatives, by the way. QEMU works by emulating a specific CPU
architecture. Renode models real hardware. Both are useful in different
scenarios, but the speed of native_sim and how easy its tooling is give it a
real advantage during development.
Roughly, each one answers a different question:
| native_sim | QEMU | Renode | Real board | |
|---|---|---|---|---|
| What runs | Your code, compiled for the host | The target binary on an emulated CPU | The target binary on modelled SoC and peripherals | The target binary |
| Speed | Host speed, faster than real time if you want | Slower than native | Slower than native | Real time |
| Debugging | Host GDB, Valgrind, sanitizers | GDB through a stub | GDB through a stub, plus the model’s view of the hardware | JTAG or SWD |
| Best for | Logic, protocols, storage, tests in CI | Architecture-specific code and boot | Multi-node systems, peripheral-level tests | Timing, power, RF, everything real |
They are not rivals. A healthy project uses native_sim for most of its tests, and keeps the others for what only they can show.
Where it stops#
The same design that makes native_sim fast also sets its limits, and the Zephyr documentation is explicit about them.
The CPU is infinitely fast. No simulated time passes while code runs, and interrupts only arrive when the CPU sleeps. A busy-wait loop that waits for an interrupt or another thread to change a variable never ends; it stalls the whole simulation. Code that waits by counting cycles does not work either. Both are worth removing anyway, but on native_sim you have to.
It is not your silicon. Register access, inline assembly and vendor HAL calls do not run, and there is no clock tree, no DMA timing and no interrupt latency to measure. Races between hardware and software cannot be reproduced here.
It is not your compiler. The code is built with the host GCC for x86. Undefined behaviour can show up differently, and an x86 host tolerates unaligned accesses that a Cortex-M0 turns into a fault.
The line is simple: native_sim tests what your code decides, not how the silicon behaves. Keep hardware access behind drivers and APIs, and the part above that line, which is usually most of the firmware, can be developed and tested without a board.
Pro tip#
By default native_sim is built with a 32-bit ABI (ILP32), which mimics the
pointer size of most embedded targets. That matters a lot for catching bugs
that depend on the size of a pointer or a long. If you are on a 64-bit system
and have trouble running 32-bit binaries, you can use the
native_sim/native/64 target. A 32-bit build needs the host’s 32-bit
development libraries (the multilib packages); native_sim/native/64 needs
nothing extra.
Details are in the native_sim documentation.
Combine native_sim with Twister and you can run automated tests in your CI
pipeline without any hardware at all.