Embedded (LLEXT) Products¶
Embedded RTOS targets (Zephyr) have no filesystem that otto can scp or
sftp from, so the standard .gcda-over-SSH path does not apply. Instead,
otto uses a separate embedded fetcher that pulls coverage data over the
console. This page covers both halves: how the console pipeline works, and —
the larger part — how to set up a product so it can emit coverage at all.
How it works¶
A coverage-instrumented LLEXT extension built against NASA’s
embedded-gcov library dumps its
counters as an ASCII hexdump over the serial console when the cov_dump
function is called (via llext call_fn <extension> cov_dump →
__gcov_exit). Otto captures that output, decodes the hexdump blocks back to
binary .gcda files, and stages them under the same per-host directory
structure used by the remote fetcher:
<staging_root>/
<host_id>/
*.gcda
This means the downstream merge and report pipeline (lcov --capture, path
mapping, HTML render) is reused without modification — the embedded and Unix
code paths converge at the same .gcda file tree, and otto cov get produces
a capture.json for an embedded board exactly as it does for a Unix one.
otto cov clean does not reach embedded boards — see
Coverage Tiers on the main page.
Setting up a product¶
The design rule that shapes everything below: the coverage runtime ships
inside your extension, not in the base image. Base images stay
product-agnostic; an instrumented extension is self-contained — it carries
its product code, the embedded-gcov runtime, and the entry points otto
calls. The complete working example in the otto repo is
tests/repo3/product/ (build script, CMake, and extension source), driven
live by tests/e2e/cov/test_embedded_coverage_e2e.py.
One extension, one translation unit¶
On Cortex-M targets Zephyr’s LLEXT type is ELF_OBJECT: an extension is a
single relocatable object, which in practice means a single translation
unit. The pattern that follows from this is #include-composition — the
product sources and the embedded-gcov runtime sources are included into one
.c file:
#include <stdint.h>
#include <zephyr/llext/symbol.h>
#include <zephyr/sys/printk.h>
#include "gcov_public.c" /* embedded-gcov runtime … */
#include "gcov_gcc.c"
#include "gcov_printf.c"
/* … your product code … */
Vendoring embedded-gcov, and the modern-GCC patch¶
Vendor embedded-gcov (a git submodule works well) — and note that upstream was written for GCC ≤ 11. Zephyr SDK toolchains from 0.16.x onward ship GCC 12+, and the stock runtime miscompiles against them in two ways:
GCC 12 added a
checksumfield tostruct gcov_info(betweenstampandfilename). Without it the runtime readsfilenamefour bytes early — a bus fault the moment it touches the name.GCC 12 changed gcov record length fields from 32-bit words to bytes (
GCOV_TAG_FUNCTION_LENGTH3 → 12,GCOV_TAG_COUNTER_LENGTH(N)N*2→N*2*4). Without itgcovreports “record size mismatch” and 0% coverage despite a valid dump.
The otto repo ships a patch covering both (plus routing the runtime’s
serial output through printk) that supports GCC 12 through 14:
tests/repo3/third_party/patches/embedded-gcov-zephyr-gcc12plus.patch.
Apply it to your vendored copy:
git -C third_party/embedded-gcov apply embedded-gcov-zephyr-gcc12plus.patch
Build the extension and run the report’s cross-gcov with the same GCC
(i.e. the same Zephyr SDK) — the runtime hard-codes gcc’s internal structs,
so mixing compiler versions across those steps reintroduces exactly the
failures the patch fixes.
Entry points: cov_init and cov_dump¶
GCC registers each instrumented TU by emitting an .init_array constructor
that calls __gcov_init — but Zephyr 3.7’s LLEXT loader never runs an
extension’s constructors, and the constructor is a local symbol that
llext call_fn cannot reach by name. The fix (possible because the
extension is a single TU) is an exported wrapper that calls the constructor
through an assembler alias:
/* Verify the generated ctor name with: nm <ext>.llext | grep _sub_I */
extern void gcov_ctor(void) __asm__("_sub_I_00100_0");
void cov_init(void) { gcov_ctor(); } /* registers this TU */
void cov_dump(void) { __gcov_exit(); } /* hexdump over the console */
LL_EXTENSION_SYMBOL(cov_init);
LL_EXTENSION_SYMBOL(cov_dump);
The runtime lifecycle over the console is then:
llext load_hex → call_fn <ext> cov_init → exercise the product →
call_fn <ext> cov_dump (otto issues this one itself — see configuration
below).
Build: instrument only the extension¶
With Zephyr’s add_llext_target(), coverage flags attach to the extension’s
library target — the (throwaway) host app stays uninstrumented:
add_llext_target(my_product_cov
OUTPUT ${ZEPHYR_BINARY_DIR}/my_product_cov.llext
SOURCES ${PROJECT_SOURCE_DIR}/src/my_product_cov.c)
target_include_directories(my_product_cov_llext_lib PRIVATE
${PROJECT_SOURCE_DIR}/../third_party/embedded-gcov/code)
target_compile_options(my_product_cov_llext_lib PRIVATE
-fprofile-arcs -ftest-coverage -O0)
add_dependencies(app my_product_cov)
(-O0 keeps the line table predictable; without the add_dependencies a
plain west build never produces the .llext.)
Post-build: strip the constructor sections¶
The instrumented object carries the .init_array entry for the gcov ctor,
and Zephyr 3.7’s loader refuses any extension containing one
(-ENOEXEC on .rel.init_array). Strip it after the build — the ctor
function stays in .text, where cov_init’s alias reaches it:
arm-zephyr-eabi-objcopy \
--remove-section='.init_array*' --remove-section='.fini_array*' \
--remove-section='.rel.init_array*' --remove-section='.rel.fini_array*' \
--strip-debug \
my_product_cov.llext my_product_cov.stripped.llext
The .gcno stamp guard¶
Every compilation stamps a random 32-bit value into both the .gcno notes
file and the gcov_info struct baked into the object. The report step
refuses a .gcda whose stamp doesn’t match the .gcno (“stamp mismatch
with notes file”) — and the classic way to hit that is a build graph that
recompiles the source (fresh .gcno, fresh stamp) but re-ships a stale
.llext. The failure surfaces minutes later, at report time, as a confusing
0%.
Guard against it in the build: after producing the stripped extension,
check that the .gcno’s stamp actually appears in the shipped binary. The
stamp sits at byte offset 8 of the .gcno; in the extension it follows the
4-byte gcov format-version marker (the .gcno’s bytes 4–8) inside each
gcov_info:
import glob, struct, sys, pathlib
build = pathlib.Path(sys.argv[1])
gcno = pathlib.Path(glob.glob(str(build / "**" / "*.gcno"), recursive=True)[0]).read_bytes()
ver, stamp = gcno[4:8], struct.unpack_from("<I", gcno, 8)[0]
blob = (build / "zephyr" / "my_product_cov.stripped.llext").read_bytes()
stamps = [struct.unpack_from("<I", blob, i + 8)[0]
for i in range(len(blob) - 12) if blob[i:i + 4] == ver]
if stamp not in stamps:
sys.exit(f"stamp guard: .gcno {stamp:#x} not in extension {list(map(hex, stamps))} "
"— the shipped .llext is stale relative to the notes file")
tests/repo3/product/build.sh runs exactly this check on every build; copy
it. It converts a silent wrong-coverage report into an immediate,
actionable build failure.
The same guard exists for Unix targets, where the ship step is a deploy
rather than llext load_hex — see the
GCC page (note the stamp offset inside
gcov_info differs with pointer width: +8 here on 32-bit Cortex-M, +16
on 64-bit Unix hosts).
Embedded coverage configuration¶
Declare the extension name in .otto/settings.toml under [coverage.embedded]:
[coverage.embedded]
extension = "my_product_cov"
When extension is set, otto issues llext call_fn my_product_cov cov_dump on
every embedded host in the lab that matches the optional [coverage].hosts
selector. Non-embedded hosts (Unix, Docker) are skipped automatically.
The dump_command timeout is generous (120 s) because the hexdump is emitted
one printk character at a time and can take several seconds for large binaries.
Toolchain for embedded coverage¶
Embedded hosts that need a cross-gcov binary for the report step can declare
a toolchain block in lab.json pointing to the cross toolchain’s gcov:
{
"element": "board_cov",
"toolchain": {
"sysroot": "/home/vagrant/zephyr-sdk-0.16.8/arm-zephyr-eabi",
"gcov": "bin/arm-zephyr-eabi-gcov",
"lcov": "/usr/bin/lcov"
}
}
Note that lcov is a host-side Perl orchestrator and is not part of the
cross toolchain — point it at the host’s lcov binary (e.g. /usr/bin/lcov),
not a path under the sysroot.
Operational notes¶
The base image needs headroom, not coverage code. The loader base must size its shell command buffer for the extension’s
load_hexline — the hex encoding is 2× the stripped extension size, plus command overhead — andCONFIG_LLEXT_SHELL_MAX_SIZE/CONFIG_LLEXT_HEAP_SIZEto match.Pace bulk console writes. A multi-KB
load_hexline written in one burst overruns the shell’s UART RX ring; otto’s transports chunk writes (e.g. 64 bytes / 15 ms) for exactly this reason.One console session. The Zephyr shell serves a single session; coordinate anything else driving the same console.
See Embedded Hosts for embedded host setup and Lab Configuration
for the full lab.json schema.