ukasol

Blog Zephyr Series · 07/07

Developing firmware without hardware, with native_sim

It compiles your code for the host, not the target. How native_sim works, what it emulates, and how GDB and AddressSanitizer find bugs before a board exists.

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.