Observability: what a control-flow trace can show, and where source-level coverage breaks
A validated control-flow trace reconstructs machine execution for a declared interval; source coverage asks whether those events map to source obligations.
For an observation interval whose trace-validity checks pass, control-flow trace can provide the machine-level program-counter sequence required by the analysis. This statement is limited to the declared trace source, protocol, configuration and interval; detected overflow, discontinuity or unsupported events invalidate or qualify the affected interval. Everything in this article follows from what that sentence includes, and from what it leaves out.
This page is about one information basis: a passive control-flow trace of the otherwise unmodified, optimized production binary. Compiler-integrated coverage that instruments the program before the optimizer runs - Clang’s source-based coverage, for instance - works from a different basis; how the two relate is discussed below.
In brief
Within a validated interval, control-flow trace records the machine events used for control-flow reconstruction; source attribution remains a separate step that needs the mapping from object code back to source obligations. Optimization can merge, clone or remove exactly the structures that mapping relies on - the machine events stay observable while attribution degrades. What follows walks that chain with object-code examples; the summary concept has its own page: source attribution.
The trust chain, stated once
A source-level result depends on a chain: source revision → compiler and flags → generated object code → linked executable → debug and mapping information → loaded image → trace observation → reconstructed addresses → source attribution. A mismatch or ambiguity at any link limits the source-level claim even when the object-code observation is valid.
Before mapping, the workflow verifies that the program image and debug information correspond to the executable used in the run. The check uses build identifiers or cryptographic hashes rather than filenames alone.
What the protocol transmits
The trace unit does not report every instruction. For a declared configuration and a valid interval, it emits compressed branch-outcome and address information - together with synchronization points - from which a decoder that holds the program image reconstructs the executed control-flow sequence, inferring the sequential instructions in between. “Discontinuity” alone would be too narrow a model: a conditional branch that is not taken produces no program-counter discontinuity, yet its outcome is still encoded, because the decoder needs it for the reconstruction.
The following examples use different encoding models and configuration vocabularies; they are not interchangeable wire formats. The rows give the logical information carried, not a complete encoding description:
| Protocol | Conditional branch | Indirect branch | Resynchronization |
|---|---|---|---|
| Arm® CoreSight™ ETMv3 | P-header elements carrying taken/not-taken outcomes | branch address packets | A-Sync byte alignment plus I-Sync instruction-address and state packets |
| Arm® CoreSight™ ETMv4 | atom elements; interpretation requires the reconstructed instruction context - do not equate E/N mechanically with taken/not-taken | address packets | trace-synchronization and address packets |
| Intel® PT | TNT packets carrying sequences of taken/not-taken outcomes | TIP packets | PSB packet-stream boundaries with their synchronization context |
| Nexus (IEEE-ISTO 5001) | branch-history field carrying multiple outcomes | unique address field | sync message |
| RISC-V® E-Trace | branch maps carrying multiple branch outcomes; optional modes change which outcomes are transmitted explicitly | address, differentially encoded | synchronization packets |
| Infineon® MCDS | documentation restricted, available under NDA |
One compactly coded outcome per conditional branch, an address when the target cannot be inferred - that is the minimum control-flow idea. Actual streams can also carry further trace information - context, exceptions, events, timestamps, filtering state, data trace and synchronization - according to architecture and configuration.
The MCDS row is deliberately empty. Its message-level specification is restricted and reaches tool vendors under NDA, so the wire format does not belong on a public page, and this article makes no claim about what it carries beyond what Infineon has published itself. The observability argument that follows is made for control-flow-only trace configurations in general; whether and how it applies to a specific MCDS configuration has to be established against Infineon’s documentation for that configuration.
Branches are observable, conditions are not
This is the statement the rest of the article rests on:
Within a valid, continuous and correctly configured instruction-trace interval, the decoder can reconstruct the control-flow distinctions represented by that architecture and configuration. Optional data and context channels are separate claims.
What a decoder can establish from the stream, with certainty:
- that a branch instruction executed;
- which way it went;
- the target of an indirect branch;
- the order in which all of this happened.
What a control-flow-only stream does not carry:
- the value of any register, flag or memory location;
- whether a conditional non-branch instruction selected its result - the trace shows that the instruction executed, not what it decided;
- which source-level condition contributed to a branch outcome;
- anything that did not become a branch in the object code.
Nothing here is a shortcoming of a particular protocol. It is the design point: the control-flow protocols above deliberately do not carry a complete value history of registers and memory - continuous general data trace is far more bandwidth- and storage-intensive than compressed branch outcomes, which is exactly why the stream is compressed the way it is.
The same condition, twice
The two figures below are the whole argument in one picture each. Same source line, same metric, one optimization level apart - and the difference is not in the trace, it is in what reached the object code.
In the first case each condition became a branch, both branches are in the stream, and the per-evaluation observations that MC/DC needs are recoverable. In the second the compiler combined both conditions into one operation: the decision is still observable, the conditions are not. Nothing was lost in transmission. The branch that would have carried the information was never emitted. MC/DC itself is a property of a set of executions - each condition additionally needs an independence pair across the test set; what the object-code structure decides is whether the per-evaluation inputs exist at all.
One exception worth knowing. The ETMv4 architecture defines an optional conditional-instruction tracing capability that can signal conditionally executed non-branch instructions, which narrows the gap for that one class. It is an implementation option rather than a guarantee: whether a given part implements it is discoverable from the trace unit’s identification registers and its documentation, and support must be established for the specific processor, ETM version and trace configuration before evidence relies on it.
Source attribution
Source attribution is the step between machine observation and source-level evidence.
It asks whether an observed instruction, branch outcome or path can be assigned to the correct source-level coverage obligation without ambiguity.
This is not the same as trace completeness. An instruction trace may contain every required machine-level event while optimization has removed the one-to-one relationship needed to determine which source condition produced that event.
A useful report should therefore distinguish three properties:
- Trace completeness: whether the relevant machine execution was observed.
- Source attribution: whether the observation can be assigned to the correct source obligation.
- Coverage achieved: whether the attributable obligation was exercised as required.
Treating these properties as one percentage can hide important limitations in the resulting evidence.
See source attribution measured on an optimized SQLite workload
The three questions a metric asks
Now put the metrics next to that capability.
| Metric | The question it asks | Can the protocol answer it? |
|---|---|---|
| Object instruction | did this machine instruction execute? | yes, within a valid reconstructed interval |
| Source statement | did this source-level statement execute? | only through a validated object-to-source attribution |
| Branch / decision | did both outcomes of this decision occur? | yes, provided the decision is a branch and that branch is validly attributed to the source decision |
| Condition | did each condition take both values? | only if each condition evaluation has a distinct, attributable, trace-observable representation - its own branch, a supported conditional-execution event, or a verified supplemental marker |
| MC/DC | did each condition independently affect the outcome? | only if the condition vector and decision outcome of each evaluation are recoverable and groupable per execution instance - and the test set contains an independence pair for each condition |
The rows mix four layers on purpose, and the mix is the point: what the protocol reports directly, what the decoder reconstructs from it, what a validated static mapping attributes, and what the coverage evaluator may conclude from grouped observations. The last two rows are answered only when the source-level decision structure is present in the object code - because that structure is the only place the information can come from.
That is the observability problem in one sentence: the relevant decisions have to be representable in the control flow. Whether they are is decided by the compiler, not by the tool.
How mapping results are classified
Mapping results are classified as direct, many-to-one, one-to-many, compiler-generated, inlined, optimized away, ambiguous, or unmapped. The classes can overlap - an inlined region can at the same time be one-to-many, and compiler-generated instructions can sit inside a mapped source range - so a usable report rests on a documented classification precedence and a documented treatment of every class in the coverage denominator. The reporting policy is that every object-code block is assigned to a class and surfaces in the report rather than being silently dropped, and that source coverage is computed only according to the documented treatment of those classes.
Example 1: the computed decision
if ((a == 0) && (b == 0))
act();
At -O0 the compiler emits the short-circuit structure literally: compare
a, branch; compare b, branch. Two conditions, two branches.
The trace stream then carries two atoms. Each one is attributable to a source condition, and the pair of outcomes is the condition vector of that evaluation
- the per-evaluation input MC/DC needs.
At -O3 the compiler is free to evaluate the whole decision arithmetically
- combine both comparisons into one operation and emit a single conditional branch on the result.
Now the stream carries one atom. The decoder sees that the decision was taken or not taken. It cannot see which condition made it so, because the information never entered the control flow: the conditional branch is visible in the trace, the arguments of the arithmetic operation are not.
Read that against the table above and the verdict is not a matter of tool quality. Branch coverage is still answerable. Condition coverage and MC/DC are not, because the branch they would need does not exist.
Example 2: the conditional move
b = (a == 0) ? 42 : b;
At -O0, again the literal structure: a compare and a conditional branch
around the assignment.
At -O3, the compiler emits a conditional selection instead of a branch:
on x86-64 a cmovne conditional move, on AArch64 a csel conditional-select
instruction; A32/T32 code can reach the same effect through predicated
execution or an IT block, whose trace observability depends on the ETM
architecture and configuration. There is no branch at all.
This case is subtler than the first, and worse. Many common instruction-trace configurations report that the instruction executed; they do not report the predicate outcome - whether the destination received the new value or kept its old one. So the stream does not merely lack the condition - it contains a positive signal that can be misread as coverage. The instruction ran. What it selected is not in the trace.
The optional ETMv4 conditional-instruction tracing capability mentioned above narrows this class of loss where a part implements it; whether a given part does has to be established from its identification registers and documentation before evidence relies on it. Where it is absent, the mitigation below is the reliable route.
The documented mitigation is a build constraint: forbid conditional non-branch
instructions. The available controls are compiler- and target-specific aids
rather than a general guarantee - coding style helps, but avoiding the ternary
operator does not prevent a compiler from if-converting an ordinary
if/else, and flags such as GCC’s -fno-if-conversion and
-fno-if-conversion2 address specific passes while back-end instruction
selection can still produce branchless forms. What generalizes is the
verification: prove the absence of conditional non-branch instructions in the
generated object code.
Note what just happened, though. The coverage requirement has started to dictate the code generation of the build you ship.
Two further mapping hazards
Context. After optimizing transformations, the mapping from a source
condition to a specific conditional branch can simply be gone - the branch
exists, its provenance does not. The blunt mitigation recorded in our own
coverage notes - if the compiler does not optimize the code (-O0), the
described effect does not occur - is an observation scoped to the compilers
and source patterns it was recorded for, not a general rule: -O0 disables
most discretionary optimization passes, not every transformation; mandatory
lowering, instruction selection and macro expansion remain. The preprocessor
makes provenance worse: common compilers attribute every token of a multi-line
macro expansion to the invocation location, in which case column information
cannot separate the expanded tokens - DWARF 5 defines dedicated
macro-information structures, but line tables that point at the invocation
site are what tools frequently encounter.
Direction. The compiler chooses whether a source-level true becomes a
jump taken or not taken - JZ or JNZ is its decision. This is not an
information loss but a polarity-mapping task: the source-to-object polarity
must be established before a missing branch outcome may be reported as a
missing source outcome. Once object-level coverage is complete the distinction
stops mattering for the verdict; it still costs the engineer time when hunting
a missing test case.
Tree and diamond
One distinction is not the optimizer’s doing at all - and it is easy to state incorrectly, so it deserves care.
In a tree-shaped decision, every path to an outcome is unique, so the condition vector of an evaluation follows from the branch outcomes alone - object-level branch observations are sufficient to reconstruct which condition combinations occurred. In a diamond-shaped one - the reconvergence that short-circuit evaluation produces naturally - two different condition vectors can reach the same successor block, so from the merge point on the current control-flow node alone no longer identifies them.
A complete, ordered trace interval still contains the branch outcomes that were emitted before the merge point; reconvergence changes the current node, it does not erase already-emitted history from the stream. What breaks is the shortcut: an analysis that keeps only aggregate edge or branch counts, or that does not delimit which observations belong to one evaluation of the decision, cannot tell the reconverging vectors apart. Distinguishing them requires per-instance condition histories - the ordered observations of one execution instance, grouped per decision. The supplementation mechanism described below provides exactly that grouping; without such a grouping model, no optimization setting and no amount of debug information turns aggregate branch counts back into per-instance vectors.
In real code diamond-shaped decisions are a minority and not a negligible one.
Optimization does not make source mapping uniformly impossible. It makes the relationship non-uniform: source statements can be reordered, merged, duplicated by inlining or represented without a distinct address range. The report preserves those distinctions rather than converting every executed address into a simple line hit.
Compiler-integrated coverage under optimization
Everything above concerns a passive control-flow trace of the otherwise unmodified, optimized binary. Compiler-integrated coverage works from a different basis - and the two mechanisms in common use differ from each other as well.
Clang’s source-based coverage generates its source-to-counter mapping from AST and preprocessor information before the optimizer runs, and the inserted counter and MC/DC-bitmap updates are exactly the state-writing instrumentation this page sets aside; its documentation states that optimizations do not affect report quality because the optimizer cannot remove the instrumentation. GCC’s condition coverage instead derives condition outcomes from control-flow-graph edges, and GCC’s documentation warns that optimization merges source constructs.
In our reproduction of example 1, GCC 15.2 with -fcondition-coverage reports
four condition outcomes at -O0 and two at -O1, -O2, -O3 and -Os -
the fusion propagates into the compiler’s own MC/DC evidence - while Clang
22.1 with -fcoverage-mcdc preserves both conditions as separately
instrumented tests at -O3. The result is the same whether the two conditions
are written inline or first hoisted into named Boolean variables. Neither
resulting coverage report exposes the optimization-induced fusion as a
distinct diagnostic. In the tested GCC case, the report reflects the fused
CFG; the reduction from four to two condition outcomes becomes apparent when
optimization levels are compared or the generated CFG or object code is
inspected. Clang’s source-based MC/DC instrumentation preserves the two
source conditions in the instrumented build, so the fusion does not arise
there.
GCC 15.2.0, -fcondition-coverage, both source forms | condition outcomes reported |
|---|---|
-O0 | 4, all covered by the three vectors |
-O1, -O2, -O3, -Os | 2, all covered by the three vectors |
Both mechanisms are also sensitive to source representation. Semantically equivalent rewrites - a bitwise operator, a hand-folded comparison, a conditional expression - change or eliminate the multi-condition decision that the respective coverage mechanism recognizes. In the tested cases, neither tool reports the original two-condition MC/DC obligation; the report reflects the rewritten source or control-flow graph rather than marking that original obligation as uncovered.
Every case can be reproduced on Compiler Explorer. Each link preloads one
source form with the same three-vector driver and three compiler panes: GCC
15.2 at -O3; GCC 15.2 at -O3 --coverage -fcondition-coverage; Clang 22.1
at -O3 -fprofile-instr-generate -fcoverage-mapping -fcoverage-mcdc. The
runtime coverage outputs quoted above come from local runs with GCC 15.2.0
and Clang 22.1.4.
| Source form | Compiler Explorer |
|---|---|
conditions inline, if ((a == 0) && (b == 0)) | godbolt.org/z/ajo4jds3P |
| conditions hoisted into named Booleans | godbolt.org/z/cPqdYGsv6 |
bitwise & | godbolt.org/z/WefWzGGhn |
hand-folded (a | b) == 0 | godbolt.org/z/EK7fWT8zW |
| conditional operator | godbolt.org/z/YP6svEdjv |
&& hoisted into an initializer | godbolt.org/z/PhWT453x6 |
Three common responses
Every tool that derives source-level coverage from a control-flow trace plus debug information sits inside the boundary above. This is not a claim about implementation quality: it is an upper bound on the information available in the binary, and it therefore applies to every tool of the class - ours included.
Three responses are common. Each gives something up. They are response classes, not an exhaustive taxonomy - selective data trace, source transformation, binary rewriting and compiler-metadata approaches exist, and whether any of them satisfies a given assurance case is a separate argument.
| Response | What it buys | What it costs |
|---|---|---|
| Lower the optimization level | the structure survives, the MC/DC inputs become recoverable | the measured build is no longer the shipped build - the representativeness argument returns, and it is the expensive one |
| Constrain the code generation (compiler flags, coding style) | removes the conditional non-branch class | coverage requirements now shape the production build; verification per build required |
| Add targeted instrumentation | full MC/DC | counters in the shipped code: timing, footprint, optimizer interaction - the probe effect the trace approach existed to avoid |
Two of the three responses are not tool-independent: one lives in the compiler, the other in the coverage tool, so the constraint and the evidence about it come from the same place - a dependence a reviewer will want addressed. Each response also pays with part of what made trace-based observation attractive: response 2 with verification per build, response 3 with the probe effect, and response 1 with the shipped binary.
Do not infer through an absent distinction. If the generated object control flow does not distinguish two source outcomes and no verified supplemental observation exists, the source-level obligation remains unresolved.
How CEDARtools.Anchor closes it
The gap is now precisely located, which makes the fix statable in one sentence:
If the missing information is not in the control flow, put it there - as control flow, and without writing anything.
At the points a coverage metric needs to tell apart, the source is supplemented with control-flow blocks that are
- functionally neutral - they do not change what the program computes;
- preserved through optimization - established at build time and verified rather than assumed, per compiler, version and optimization setting;
- free of memory operations on analysis state - the application maintains no coverage counters and no software trace buffer.
The third property separates counterless control-flow supplementation from state-writing instrumentation. Supplementing the source for observability is instrumentation in the broad sense - the program representation is modified for measurement. What this variant does not do is write coverage state from the application: conventional counter- or tag-based instrumentation performs loads and stores to analysis state, and those accesses can touch caches, timing and the memory budget depending on configuration. An anchor produces no such access; it still executes as part of the program, and its instruction-level effect is measured separately.
And the first two are what make it work against the observability boundary of this article: the anchor is a control-flow event, which is exactly what the trace protocol transmits within a valid observation scope.
The mechanism is applied before compilation: the supplemented source, together with its header or macro definitions, is compiled and linked into the machine code used for analysis. That is the causal step a downstream trace-path device cannot supply by itself - the optimization-resistant source distinctions exist in the binary because they were placed and protected before and during compilation, not retrofitted afterwards. The build produces the supplemented machine code together with a static observation mapping: a static correspondence between source-level points and their binary representations, used to correlate runtime trace events with source constructs. That mapping metadata can draw on debugging data, symbol-table associations, or both - and where debugging data is used, it is subject to the same chain of trust as every other mapping input, including what the debug information actually says under optimization.
The evaluation happens outside the target: the measurement device in the trace path reconstructs the control flow, determines which supplemented points each execution instance passed, and evaluates the metric from those grouped observations. For a multi-condition decision, that grouping is exactly the per-instance condition history the reconvergence section above requires, and the independence-pair check runs across the test set, off the target. See the product it ships in and how the output reaches an existing verification tool.
What that changes, against the responses above:
- the optimization level stays where the product needs it;
- the measured artifact can remain the shipped artifact: the published application states that the impact can be small enough to justify leaving the modified code in the release - artifact identity then means releasing the supplemented image that was measured, under the trust chain stated at the top of this page, or making a separate representativeness argument;
- the metric that was unanswerable at
-O3becomes answerable, including the condition-level detail that MC/DC actually requires.
Probe effect, stated precisely. This is probe-effect-minimal, not probe-effect-free, and we will not write that it is: the supplemented machine-code structure exists, it occupies code space, and it executes - code size, control-flow and layout changes, cache effects and localized optimization barriers are possible effects, and the timing effect is measured for the named target, compiler, placement and configuration. What the supplementation does not do is read or write application-resident analysis state - which is where much of the cost of conventional instrumentation typically lives.
Status. The mechanism is the subject of a published international patent application. In the PCT international phase, international search and a written opinion - and optionally an international preliminary examination - take place; grant is a matter for the national and regional offices after entry into the national and regional phases. Publication details, priority date and our granted patent families are on the patents page.
We are describing the mechanism publicly on purpose. It is disclosed in a published application, and a method that can be inspected can be argued about with an assessor.
The full description is on the CEDARtools.Anchor page.
If you are evaluating tools
The questions that separate the claims quickly - optimization level of the measurement, treatment of branchless conditionals, reporting of unattributable code, and the chain of trust behind the source-level verdict - have their own page: the five questions worth asking every vendor. That page also names the three artifacts worth requesting with the answers.
What this page establishes
- trace completeness, source attribution and coverage achieved are three different questions;
- the mapping classes - one-to-one, one-to-many, many-to-one, no source - and the reporting policy under which results are classified rather than silently dropped;
- concrete object-code examples of where the source distinction is lost;
- why reconverging decisions need per-instance condition histories rather than aggregate branch counts;
- how Anchor* - supplementation before compilation, a static observation mapping, and off-target evaluation - supplies the cases the passive route cannot attribute.
Sources
The statements above about what a protocol transmits, what a debug format promises and what a standard requires are based on the primary documents:
- Embedded Trace Macrocell Architecture Specification, ETMv1.0 to ETMv3.5 (Arm IHI 0014Q);
- Embedded Trace Macrocell Architecture Specification, ETMv4.0 to ETMv4.6 (Arm IHI 0064);
- Intel 64 and IA-32 Architectures Software Developer’s Manual, volume 3C, chapter on Intel Processor Trace;
- IEEE-ISTO 5001-2012, the Nexus 5001 Forum standard - the Forum dissolved in 2020, the text is mirrored by the RISC-V Nexus trace task group;
- RISC-V Processor Trace specification (E-Trace);
- DWARF Debugging Information Format Version 5;
- GCC, “Options That Control Optimization” - the entries for
-fif-conversion,-fif-conversion2and-O0referenced in the mitigation discussion; - Clang 22.1.0, “Source-based Code Coverage”, section “Impact of llvm optimizations on coverage reports” - the mapping is generated before the optimizer runs, and the optimizer does not remove the instrumentation;
- GCC, “Using gcov with GCC Optimization” - the optimizer can combine simple code lines, and gcov cannot then report separate counts;
- J. Kvalsvik, Modified Condition/Decision Coverage in the GNU Compiler Collection, arXiv:2501.02133 - GCC’s condition coverage maps conditions to control-flow-graph edges;
- RTCA DO-178C / EUROCAE ED-12C, cited by section (licensed publication).
The compiler-coverage comparison in the section above is our own measurement (GCC 15.2.0 and Clang 22.1.4, local runs; object code on Compiler Explorer with GCC 15.2 and Clang 22.1.0); the six links reproduce every case.
Infineon’s MCDS is covered by a non-disclosure agreement; this article therefore names only what Infineon has published itself.
Apply this to your target
Send the exact processor, board, trace path, compiler and metric you need. We will review feasibility at configuration level rather than answering from the core-family name alone.
Start a target feasibility review
Related: MC/DC worked through · what instrumentation costs
Assembly examples in the figures are shortened schematics. Every example on this page can be reproduced on Compiler Explorer with preloaded source, compilers and flags: example 1 · example 2 · compiler-integrated coverage, six source forms.