AdaCore: Build Software that Matters
I Stock 1226985345
Jun 16, 2026

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-stdout

Step 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-stdout

Each 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.gpr

Step 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/main

For 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.srctrace

Step 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.srctrace

Step 6: Generate the merged report

gnatcov coverage --level=stmt+decision \
    -P prj.gpr \
    --checkpoint=native.ckpt \
    --checkpoint=cross.ckpt \
    --annotate=html \
    --output-dir=report

The 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 True outcome for both decisions (the error conditions).
  • The cross checkpoint supplies the False outcome for both decisions, and the only path to return 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

Jose Ruiz
Embedded Product Manager, AdaCore

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.

Blog_

Latest Blog Posts