Sanitizers in Embedded C++ Belong in Your Host-Side Tests

Last updated:

A memory bug that leaves your program running on your laptop can still quietly corrupt state on the device, and the device is the worst possible place to find it. On a workstation you have a debugger, a readable address space, and a stack trace the moment something goes wrong. On the target you often have none of that: a stray write shows up weeks later as an occasional watchdog reset, a garbled log line deep in a soak test, or a field return nobody can reproduce. The same defect that costs seconds to locate on the host costs days once it reaches hardware, and that bill lands at the worst possible time — after the code has shipped.

Sanitizers close most of that gap before it opens. A sanitizer, a compiler feature that instruments your build with runtime checks for memory and undefined-behaviour bugs, catches that whole class of defect the instant it happens rather than letting it smear across your program’s state. The catch is that sanitizers are a host-side technique: they do not run on target hardware, and trying to force them onto a device is the wrong instinct. Their home is your host-side unit-test and software-in-the-loop builds, the ones that already run on a workstation without a board attached, where a near-undiagnosable field bug becomes a test that fails in seconds with a file, a line, and an address.

This post sits in the unit-testing tier of a larger argument I’ve made elsewhere: that embedded, built the right way, is a better place for an AI coding agent to work than web development is (see Why Embedded Is a Better Place for AI Agents Than Web Development). It assumes you already have a host-side test build for those sanitizers to plug into; if you don’t, that is the reproducible-build foundation everything else waits on, and I cover it in Reproducible Embedded Builds, or Becoming Your Agent’s Synchronization Point. What follows is what each sanitizer actually catches, where to wire them in, and, just as important for not fooling yourself, what they cannot see.

What is a sanitizer?

A sanitizer is a compiler feature that instruments your program at build time with extra runtime checks, so a memory or undefined-behaviour bug stops the program with a precise diagnostic the moment it occurs instead of corrupting state silently and surfacing somewhere unrelated later. The family that ships with Clang and GCC is five tools: AddressSanitizer for memory errors like out-of-bounds accesses and use-after-free, UndefinedBehaviorSanitizer for undefined behaviour such as signed overflow or a misaligned load, ThreadSanitizer for data races, MemorySanitizer for reads of uninitialised memory, and LeakSanitizer for memory leaks. You enable one with a compiler flag, run the tests you already have, and it reports the first violation with a stack trace and the exact address involved. The whole family is free, production-grade tooling that has shipped with both major compilers for years, which means adopting it costs a build configuration to set up, not a purchase order to sign.

Why these bugs are so expensive to find on the target

The reason to catch these on the host is that the target strips away almost everything that makes them findable. A memory error on a workstation aborts at the offending instruction, with symbols and a stack trace. The same error on a microcontroller may do nothing visible for a while and then express itself as an occasional reset, a wrong sensor reading, or a lock-up that only happens once the board is warm, because no silicon is doing that runtime checking for you. Undefined behaviour is worse on the target rather than better: the optimising cross-compiler is entitled to assume it never happens, so a signed overflow or a type-punned read that looked harmless in a host debug build can compile into firmware that misbehaves only when optimised. The loop for chasing any of this is slow by construction: flash, reproduce, maybe attach a hardware probe, guess, repeat, all on a board that resets the very state you were trying to inspect. Put the two situations side by side and the case makes itself: a sanitizer names the file and line on the host in seconds, where the same bug on the device is a multi-day investigation carried out with far worse tools.

What each sanitizer catches, and which ones earn their place

“Sanitizers” is a family, not a single switch, and turning all of them on at once both wastes runtime and runs into tools that refuse to share a process, so it pays to know what each one buys you in embedded C++ specifically. AddressSanitizer (ASan) is the highest-value of the five: it catches heap, stack, and global buffer overflows, use-after-free, and use-after-return, which is the bulk of what actually corrupts state in a C++ program. UndefinedBehaviorSanitizer (UBSan) is the cheap companion you run alongside it, flagging signed integer overflow, shifts wider than the type, and misaligned or null pointer accesses, all of which hide easily in the bit manipulation and fixed-width arithmetic embedded code is full of. ThreadSanitizer (TSan) finds data races, which starts to matter once your host harness runs the code under real threads standing in for real-time operating system (RTOS) tasks; it needs its own build, because it cannot share a process with ASan. MemorySanitizer (MSan) catches reads of uninitialised memory, and it is the fiddliest of the five because it needs every piece of code in the process, the standard library included, to be instrumented before it stops reporting false positives, so reach for it when uninitialised reads are the specific thing you’re hunting. LeakSanitizer (LSan) reports leaks and usually rides along inside ASan at no extra cost; on a long-running software-in-the-loop harness it turns a slow leak that would take days to matter on a device into a plain failure at test exit. The practical default is ASan and UBSan together on your unit tests, and in budget terms that pair is the one that pays for itself immediately; the other three are targeted instruments you add when a specific class of failure justifies the extra setup.

Where sanitizers belong: the host-side test build

None of this ships to the device, and that is a design point rather than a limitation. Sanitizers roughly double or triple runtime and memory use and they change the binary’s layout, so they have no business in production firmware; and they lean on host runtime support the target doesn’t have, so they wouldn’t cross-compile onto it even if you wanted them there. That leaves exactly one right place for them: a separate host build configuration, the one your unit tests and software-in-the-loop harness already use, kept orthogonal to the firmware build that targets the real chip.

Swimlane diagram contrasting a host build path, where sanitizer-instrumented unit tests and a CI gate catch bugs, against a target build path that compiles firmware for the real chip without sanitizers, separated by a boundary line marking that sanitizers never cross into firmware

Concretely, in a CMake project that means an opt-in flag that adds the instrumentation to the host test target and nothing else:

# Host-side test build only — never the firmware/target build.
option(ENABLE_SANITIZERS "Instrument host tests with ASan + UBSan" OFF)
if(ENABLE_SANITIZERS)
  add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer -g)
  add_link_options(-fsanitize=address,undefined)
endif()

The flag defaults off so an ordinary build is untouched, and you turn it on for a dedicated test run rather than baking it into every compile. One detail decides whether this actually protects you: by default some sanitizer reports print a warning and let the program keep running, which means a test can trip a real bug and still pass green. Make the violation fatal instead, so a sanitizer finding fails the build the way a failed assertion would, with halt_on_error=1 in ASAN_OPTIONS/UBSAN_OPTIONS or by compiling with -fno-sanitize-recover. Then run that sanitized configuration as its own entry in CI (the automated build server that rebuilds every change from clean), right next to the ordinary build, so every change is checked the day it lands instead of the day it reaches a board. Wired in this way the standing cost is a single extra CI job and a build flag, and what you get back is an entire class of memory and undefined-behaviour defects failing the pipeline on the host before it can ever reach hardware.

What sanitizers don’t do, and why you still need the boundary

The failure mode to guard against here is trusting a green sanitizer run more than it has earned. Sanitizers only see the code paths your host tests actually execute, so their reach is bounded by your test coverage: an un-exercised branch with a buffer overflow in it is as invisible to ASan as it is to a test that never calls it. They also run host code, built by the host compiler for the host application binary interface (ABI), so a defect that lives in target-specific territory (a volatile hardware-register access, an interrupt handler, an alignment assumption that only bites on the real microcontroller, or an outright miscompilation by the cross-compiler) is outside what they can observe at all. And they are not a substitute for hardware-in-the-loop testing at the boundary; they shrink how much you have to push through that slow, expensive boundary, they do not remove the boundary itself. The alternative I’d reject if someone offered it as a replacement is running the whole firmware image under an instruction-set simulator with its own checks: heavier to stand up, far slower per run, and still not the real silicon — so it earns its place for specific target-level questions rather than as the everyday memory-safety net. Sanitizers on host tests catch the great majority of memory and undefined-behaviour bugs for a fraction of that cost, which is why they belong first in the order, ahead of the slower target testing that still has to come after them.

Where this leaves you

Adding a sanitized host-test configuration is bounded, one-time work: a build flag, a CI entry, and the discipline to make a finding fail the run. Leaving it out doesn’t remove the bugs; it just moves the moment you meet them from a two-second host test to a multi-day investigation on a device that fights you the whole way, long after the code shipped. For a team pointing an AI coding agent at embedded work the asymmetry is sharper still, because a sanitizer failure is a precise, machine-readable signal an agent can act on unsupervised, while a silent memory corruption on the target is exactly the kind of failure an agent cannot diagnose and hands straight back to you. Put the sanitizers where the tests already run, on the host, and you catch that class of bug at the cheapest possible moment; leave them out, and you’ve agreed to meet the same bugs later, on the target, where they cost the most.

Frequently asked questions

What is a sanitizer in C++?

A sanitizer is a compiler feature (in Clang and GCC) that instruments your program at build time with runtime checks, so a memory or undefined-behaviour bug stops the program with a precise diagnostic the moment it occurs instead of corrupting state and surfacing later. The main ones are AddressSanitizer (memory errors), UndefinedBehaviorSanitizer (undefined behaviour), ThreadSanitizer (data races), MemorySanitizer (uninitialised reads), and LeakSanitizer (leaks). You enable one with a compiler flag, run your existing tests, and it reports the first violation with a stack trace and the offending address.

Can you run AddressSanitizer on embedded target hardware?

No, and you shouldn't try. Sanitizers are a host-side technique: they depend on host runtime support and roughly double or triple memory and runtime, so they don't cross-compile onto a microcontroller and have no place in shipped firmware. They belong in the host-side unit-test and software-in-the-loop builds that run on a workstation without a board, where they catch memory and undefined-behaviour bugs before those bugs ever reach a device.

Which sanitizers matter most for embedded C++?

Start with AddressSanitizer and UndefinedBehaviorSanitizer together on your unit tests: ASan catches the buffer overflows and use-after-free that cause most state corruption, and UBSan is a cheap companion for the signed-overflow and misaligned-access bugs common in bit-level embedded code. Add ThreadSanitizer if your host harness runs real threads standing in for RTOS tasks, MemorySanitizer when uninitialised reads are the specific suspect, and LeakSanitizer (usually bundled with ASan) for long-running harnesses.

Do sanitizers replace hardware-in-the-loop testing?

No. Sanitizers only see the code paths your host tests execute, and they run host code built for the host ABI, so they can't observe target-specific defects like a volatile register access, an interrupt handler, or a cross-compiler miscompilation. They reduce how much you have to push through slow hardware-in-the-loop testing by catching memory and undefined-behaviour bugs earlier and cheaper on the host; they don't remove the need to test on real silicon at the boundary.

Where do sanitizers go in an embedded build?

In a separate host build configuration used by your unit tests and software-in-the-loop harness, never in the firmware that targets the real chip. In CMake that's typically an opt-in flag adding -fsanitize=address,undefined to the host test target, run as its own CI entry alongside the normal build, with findings made fatal (halt_on_error=1 or -fno-sanitize-recover) so a violation fails the pipeline instead of printing and passing.