
From Host to Target: Consolidating Code Coverage Across Execution Environments
Introduction
When developing software for an embedded system, tests are rarely executed only on the target. Some projects run all tests on target boards, but mixed testing environments are typically easier to use.
Fast unit tests can be run on the development host where you have easier debugging support, a richer runtime, and a quick edit-compile-run loop. Additionally, it is far easier to verify how the software responds to unexpected situations, like a temperature sensor giving a wrong measurement.
Integration tests run on the actual target, where conditions are constrained, and the test cycle is slower.
Both kinds of tests matter. Unit tests give you confidence that your algorithms handle every edge case. Integration tests prove that the whole stack works correctly on the hardware you will ship.
The question is: how do you get a unified code coverage picture from two separate execution environments? This post walks through a concrete example, step by step.
Example: A Temperature Sensor Library
We are developing a temperature-monitoring system. The business logic lives in the Temperature package:
-- src/common/temperature.ads
package Temperature is
type Celsius is range -273 .. 1000;
type Status is (Too_Cold, Normal, Too_Hot);
Cold_Threshold : constant Celsius := 0;
Hot_Threshold : constant Celsius := 85;
function Classify (Reading : Celsius) return Status;
-- Returns Too_Cold / Normal / Too_Hot based on thresholds.
end Temperature;-- src/common/temperature.adb
package body Temperature is
function Classify (Reading : Celsius) return Status is
begin
if Reading < Cold_Threshold then
return Too_Cold;
elsif Reading > Hot_Threshold then
return Too_Hot;
else
return Normal;
end if;
end Classify;
end Temperature;We can then have a Platform package that provides the Hardware Abstraction Layer (HAL), which abstracts away the hardware I/O:
-- src/common/platform.ads
with Temperature;
package Platform is
function Get_Temperature return Temperature.Celsius;
procedure Display (S : String);
end Platform;Then you can have a control loop reading and displaying the temperature:
-- src/common/main.adb
with Temperature; use Temperature;
with Platform;
procedure Main is
begin
loop
declare
Raw : constant Celsius := Platform.Get_Temperature;
S : constant Status := Classify (Raw);
begin
case S is
when Too_Cold => Platform.Display ("COLD");
when Normal => Platform.Display ("OK");
when Too_Hot => Platform.Display ("HOT");
end case;
end;
end loop;
end Main;What changes between the host build and the target build is only the Platform body.
The GNAT project file
A single prj.gpr controls both builds through an external variable BUILD:
-- prj.gpr
project Prj is
type Target_Type is ("Native", "Cross");
Target_Build : Target_Type := external ("BUILD", "Cross");
case Target_Build is
when "Cross" =>
for Target use "aarch64-elf";
for Runtime ("Ada") use "light-zynqmp";
when "Native" =>
null;
end case;
for Source_Dirs use
("src/common", "src/embedded", "src/native");
case Target_Build is
when "Native" => for Object_Dir use "obj/native";
when "Cross" => for Object_Dir use "obj/cross";
end case;
for Main use ("main.adb");
package Naming is
case Target_Build is
when "Native" =>
for Implementation ("Platform") use
"platform-native.adb";
when "Cross" =>
for Implementation ("Platform") use
"platform-cross.adb";
end case;
end Naming;
end Prj;Passing -XBUILD=Native to GPRbuild or GNATcoverage selects the host variant; -XBUILD=Cross selects the embedded variant.
Two Kinds of Tests, Two Kinds of Platform
The two test environments are designed around complementary goals:
- Unit tests on the host deliberately inject fault conditions, readings outside the normal operating range, that are difficult or impossible to produce on real hardware without a dedicated fault-injection rig.
- Integration tests on the target use representative readings from the actual hardware in normal operation to verify that the full stack behaves correctly under real-world conditions.
Neither test set alone is sufficient. Together, they achieve complete coverage.
Unit tests on the host (fault conditions)
The host Platform body provides only out-of-range readings, targeting the fault-handling paths of Classify:
-- src/native/platform-native.adb
with Ada.Text_IO;
with Temperature; use Temperature;
package body Platform is
-- Unit tests focus on fault conditions: readings outside the
-- operating range.
-- -5 → Classify: Too_Cold (Reading < Cold_Threshold = 0)
-- 90 → Classify: Too_Hot (Reading > Hot_Threshold = 85)
Readings : constant array (0 .. 1) of Celsius := (-5, 90);
Index : Natural := 1;
function Get_Temperature return Temperature.Celsius is
begin
Index := (Index + 1) mod 2;
return Readings (Index);
end Get_Temperature;
procedure Display (S : String) is
begin
Ada.Text_IO.Put_Line (S);
end Display;
end Platform;Running this build produces:
COLD
HOT
COLD
...return Normal is never reached, by design.
Integration tests on the target (normal operation)
The target Platform body uses readings in the normal operating range, verifying that the system behaves correctly under real-world conditions.
Running on the target should produce OK on the hardware display, and return Too_Cold and return Too_Hot should never be reached, by design. Separate tests should be provided to ensure that the hardware display correctly reproduces the COLD and HOT status.
The GNATcoverage Workflow
Step 1: Install the instrumentation runtimes
GNATcoverage injects coverage probes into your source code and links a small runtime library (gnatcov_rts) to collect the results. Both runtimes are installed into the same cov_rt/ prefix; --install-name gives each one a distinct project name so they coexist without conflict.
# Native runtime: dumps a binary trace file when main() returns
gnatcov setup \
--prefix=cov_rt \
--install-name=gnatcov_rts_native \
--dump-trigger=main-end \
--dump-channel=bin-file
# Cross runtime: base64-encodes the trace on stdout (works over any serial link)
gnatcov setup \
--prefix=cov_rt \
--target=aarch64-elf \
--RTS=light-zynqmp \
--install-name=gnatcov_rts_cross \
--dump-trigger=main-end \
--dump-channel=base64-stdoutStep 2: Instrument the sources
gnatcov instrument -P prj.gpr -XBUILD=Native \
--runtime-project=cov_rt/share/gpr/gnatcov_rts_native.gpr \
--level=stmt+decision \
--dump-trigger=main-end --dump-channel=bin-file \
--dump-filename-simple
gnatcov instrument -P prj.gpr -XBUILD=Cross \
--runtime-project=cov_rt/share/gpr/gnatcov_rts_cross.gpr \
--level=stmt+decision \
--dump-trigger=main-end --dump-channel=base64-stdoutEach run writes its instrumented sources to the project's object directories: obj/native/prj-gnatcov-instr/ and obj/cross/prj-gnatcov-instr/, respectively. Because the two builds already declare separate object directories in prj.gpr, the outputs never collide, and the two passes can run independently.
Step 3: Build
gprbuild -P prj.gpr -XBUILD=Native \
--src-subdirs=gnatcov-instr \
--implicit-with=cov_rt/share/gpr/gnatcov_rts_native.gpr
gprbuild -P prj.gpr -XBUILD=Cross \
--src-subdirs=gnatcov-instr \
--implicit-with=cov_rt/share/gpr/gnatcov_rts_cross.gprStep 4: Run and collect traces
The native executable deposits a .srctrace file in the current directory, so we cd into the target folder before running it:
mkdir -p traces/native
cd traces/native && ../../obj/native/mainFor the cross build, we run the emulator and extract the base64-encoded trace from its output:
aarch64-elf-gnatemu obj/cross/main > traces/cross/output.txt
gnatcov extract-base64-trace traces/cross/output.txt traces/cross/main.srctraceStep 5: Save checkpoints
A checkpoint is a compact snapshot of coverage results that can be merged later.
gnatcov coverage -P prj.gpr -XBUILD=Native --level=stmt+decision \
--save-checkpoint=native.ckpt \
traces/native/main.srctrace
gnatcov coverage -P prj.gpr -XBUILD=Cross --level=stmt+decision \
--save-checkpoint=cross.ckpt \
traces/cross/main.srctraceStep 6: Generate the merged report
gnatcov coverage --level=stmt+decision \
-P prj.gpr \
--checkpoint=native.ckpt \
--checkpoint=cross.ckpt \
--annotate=html \
--output-dir=reportThe Coverage Results
(+ = covered, - = never executed, ! = executed but decision not fully covered)
From the native run alone
The host unit tests cycle through out-of-range readings (-5, 90, -5).
The Normal branch of Classify is never reached:
temperature.adb (stmt+decision, native run only):
function Classify (Reading : Celsius) return Status is
begin
5 + if Reading < Cold_Threshold then
6 + return Too_Cold;
7 ! elsif Reading > Hot_Threshold then
8 + return Too_Hot;
else
10 - return Normal;
end if;
end Classify;From the cross run alone
The integration tests produce only normal readings (45, 48, 52).
The fault-handling paths in Classify are never triggered:
temperature.adb (stmt+decision, cross run only):
function Classify (Reading : Celsius) return Status is
begin
5 ! if Reading < Cold_Threshold then
6 - return Too_Cold;
7 ! elsif Reading > Hot_Threshold then
8 - return Too_Hot;
else
10 + return Normal;
end if;
end Classify;After merging both checkpoints
Each run contributes exactly what the other lacks:
- The native checkpoint supplies the
Trueoutcome for both decisions (the error conditions). - The cross checkpoint supplies the
Falseoutcome for both decisions, and the only path toreturn Normal(the nominal condition).
temperature.adb (stmt+decision, merged):
function Classify (Reading : Celsius) return Status is
begin
5 + if Reading < Cold_Threshold then
6 + return Too_Cold;
7 + elsif Reading > Hot_Threshold then
8 + return Too_Hot;
else
10 + return Normal;
end if;
end Classify;100% statement and decision coverage.
Conclusions
The example illustrates a common pattern in embedded development.
Unit tests on the host excel at exhaustive testing of algorithmic logic: you control the inputs, generating boundary cases and fault scenarios is easy, and the edit-compile-run cycle is fast enough for test-driven development. Integration tests on the target are indispensable for validating that software and hardware work correctly together, but they cannot cover every failure mode without a dedicated fault-injection rig, which is impractical or impossible on many platforms.
GNATcoverage makes consolidation across these two configurations straightforward, even though they differ in almost every dimension: processor architecture (x86_64 vs aarch64), runtime library, trace collection mechanism (binary file written at exit vs base64 stream on stdout), and the bodies of code actually compiled. The instrumentation runtime for each configuration is set up independently with options that match its environment; the resulting .srctrace files are converted into per-configuration checkpoints; and a single final invocation merges them into one report. The checkpoint format operates at the source level, so architectural differences between the two builds are invisible to the merge step. A shared GPR project file with a single scenario variable is all that is needed to keep both configurations under one roof and to drive the entire workflow.
Author
Jose Ruiz

Dr. Jose Ruiz is a Product Manager at AdaCore with 25 years of experience in embedded safety-critical real-time systems, having authored/coauthored over 40 papers in that area. He received his Ph.D. degree for his work in the field of real-time and multimedia systems, including scheduling policies and resource management in real-time operating systems.
He is an expert in certification of high-integrity system in aeronautics, space and railway domains, and he has been involved in the certification/qualification of run-time libraries and automatic code generators from modeling languages.
Throughout his career he has worked on the definition of language profiles for embedded systems, and the design and implementation of the run-time support required for executing on bare-metal targets.





