Execution traces and coverage¶
This page covers the emulator trace model (Unicorn). The same
asmtest_trace_tshape is filled by two native backends — in-process DynamoRIO and hardware trace (Intel PT / ARM CoreSight) — described in Native runtime tracing. The trace substrate, coverage helpers, and the Capstone annotation layer are backend-neutral and shared.Diagram: Trace and coverage backends
The emulator tier can record what a routine executed while it ran. A traced call captures two related views:
an instruction trace: every instruction address, in execution order
basic-block coverage: the distinct basic-block entry offsets reached
Both views use byte offsets from the routine entry. Offset 0 is the first byte
of the code passed to emu_call_traced or a guest-specific traced call.
An asmtest_trace_t records one region — offsets from a single base. That
invariant is unchanged even under call descent,
the ptrace tracer’s mode for following call-outs: the flat trace remains the single-region
view (frame 0, the root region), byte-identical whatever the descent level. When descent
steps into a callee, that callee’s own instructions are recorded in a separate nested
frame on the descent handle (offsets relative to that frame’s base), never mixed into the
flat trace. So “one asmtest_trace_t = one region’s offsets” still holds; descent adds
frames beside it, not into it.
Tracing is useful when a return value is not enough. You can check that tests reach every path you care about, compare a narrow input against a broader “universe” of blocks, inspect a loop’s dynamic behavior, or feed coverage into the emulator fuzzing helpers.
Recording a trace¶
Tracing is opt-in. In C, you provide an emu_trace_t and any buffers you want to
fill. Either buffer can be omitted by leaving its pointer NULL.
uint64_t insns[256];
uint64_t blocks[64];
emu_trace_t tr = {0};
tr.insns = insns;
tr.insns_cap = 256;
tr.blocks = blocks;
tr.blocks_cap = 64;
emu_result_t r;
long args[] = {7};
ASSERT_TRUE(emu_call_traced(E, (void *)classify, 64, args, 1, 0, &r, &tr));
ASSERT_NO_FAULT(&r);
The same pattern works for the cross-architecture guests:
emu_arm64_call_traced(e, code, code_len, args, nargs, max_insns, &r, &tr);
emu_riscv_call_traced(e, code, code_len, args, nargs, max_insns, &r, &tr);
emu_arm_call_traced(e, code, code_len, args, nargs, max_insns, &r, &tr);
emu_call_win64_traced(e, fn, code_len, args, nargs, max_insns, &r, &tr);
The dynamic-language FFI surface uses an opaque trace handle instead of exposing the C struct layout:
emu_trace_t *tr = asmtest_emu_trace_new(256, 64);
asmtest_emu_call6_traced(e, fn, a0, a1, a2, a3, a4, a5, nargs, 0, out, tr);
unsigned long long n = asmtest_emu_trace_blocks_len(tr);
int hit_entry = asmtest_emu_trace_covered(tr, 0);
asmtest_emu_trace_free(tr);
What the fields mean¶
Field |
Meaning |
|---|---|
|
Ordered instruction offsets, one entry per executed instruction until |
|
Entries written to |
|
Total executed instructions, including entries dropped after the buffer fills |
|
Distinct basic-block entry offsets |
|
Distinct block offsets recorded |
|
Dynamic block entries, so loop re-entry increments this |
|
At least one requested buffer filled and dropped later entries |
blocks is de-duplicated. If a loop enters the same block 100 times,
blocks_len grows once and blocks_total grows 100 times.
insns is ordered and not de-duplicated. It is the right view for “what happened
step by step”; blocks is the right view for “which regions did this input
reach?”.
Accumulating coverage¶
Tracing appends to an existing emu_trace_t. That means you can run many inputs
through one trace to form a coverage union:
uint64_t blocks[64];
emu_trace_t all = {0};
all.blocks = blocks;
all.blocks_cap = 64;
long inputs[] = {-5, 0, 7};
for (int i = 0; i < 3; i++) {
emu_result_t r;
long args[] = {inputs[i]};
emu_call_traced(E, (void *)classify, 64, args, 1, 0, &r, &all);
}
ASSERT_BLOCKS_AT_LEAST(&all, 3);
ASSERT_BLOCK_COVERED(&all, 0);
This is the usual way to answer “did this input set reach the expected blocks?”. Use one fresh trace for a single input, and another accumulated trace as the comparison universe.
size_t missed = emu_coverage_uncovered(&covered, &universe, stdout);
ASSERT_EQ(missed, 0);
emu_trace_report(&tr, stdout) prints the distinct block count, dynamic block
entries, instruction count, and covered offsets. emu_trace_lcov(&tr, name, out)
emits an lcov-style record where block offsets stand in for line numbers.
Source-line coverage¶
The emulator records machine-code offsets, not source locations. To report source-line coverage, provide a normalized line map:
static const emu_line_entry_t rows[] = {
{0, 10},
{4, 11},
{8, 12},
};
emu_line_map_t map = {rows, 3};
emu_trace_source_report(&covered, &map, stdout);
emu_trace_lcov_source(&covered, &map, "classify.s", out);
Each row starts at a byte offset and names the source line that owns the range until the next row. A line counts as covered when a covered basic block begins in that line’s range.
The framework deliberately consumes this table rather than parsing DWARF itself.
You can build the map from an assembler listing, objdump, readelf, or any
other tool that can normalize debug information into (offset, line) rows.
Disassembly¶
Trace offsets become easier to read when Capstone is available. The disassembly helpers annotate recorded offsets with instruction text:
emu_trace_disasm(&tr, EMU_ARCH_X86_64, code, code_len, stdout);
emu_trace_report_disasm(&tr, EMU_ARCH_X86_64, code, code_len, stdout);
emu_coverage_uncovered_disasm(&covered, &universe,
EMU_ARCH_X86_64, code, code_len, stdout);
Without Capstone, these helpers fall back to the same offset-only reports. See Disassembly for build flags and examples.
Limits¶
Trace coverage is basic-block coverage, not edge coverage. It can prove a block entry was reached; it does not directly prove that every edge between blocks was taken.
Source-line coverage is only as precise as the line map you provide. Several blocks can map to one line, and one source statement can generate multiple blocks.
Traces are per emulator handle and per call sequence. They are not thread-safe shared collectors. Use a separate trace for each concurrent worker or protect the handle externally.
The emulator records guest execution, so self-modifying code, indirect branches, and faults are observed as the virtual CPU sees them. For native hardware timing or cycle counts, use the benchmark tier instead.