SvmTrace is an AMD64 instruction tracer built around AMD SVM. An NPT execute fault
starts capture; the VM-exit handler sets Trap Flag, intercepts exit 0x41, verifies
DR6.BS, records the resulting RIP, RFLAGS, CR3, and 16 GPRs, rearms Trap Flag, and
resumes the guest. The trace encoder stores these post-instruction states as compact,
replayable anchors and deltas.
- Why SvmTrace is different
- What one instruction step means
- Quick start
- Concrete synthetic walkthrough
- C++ API
- Architecture
- Capture lifecycle
- Trace format
- SvmTrace and branch-only tracing
- Build and test
- Driver workflow
- Validation
- Project status
- Provenance
Most trace visualizations start with edges: a branch was taken, a call reached a destination, or execution returned. SvmTrace starts with CPU state at consecutive instruction boundaries.
flowchart LR
subgraph Branch[Branch-oriented view]
B1[Source address] --> B2[Branch event]
B2 --> B3[Destination address]
end
subgraph SVM[SvmTrace view]
S1[Execute gate NPF] --> S2[Set guest TF]
S2 --> S3[Execute one instruction]
S3 --> S4[Intercept #DB VM exit]
S4 --> S5[Capture RIP, RFLAGS, CR3, GPRs]
S5 --> S6[Set TF and resume]
S6 --> S3
end
| Question | Branch-only event stream | Current SvmTrace record model |
|---|---|---|
| Where did control go? | Source and destination around control-flow events | RIP at every captured instruction boundary |
| What did an instruction change? | Requires reconstruction from other data | Consecutive records expose GPR and selected RFLAGS changes |
| Which address space was active? | Depends on the producer | CR3 is part of the reconstructed state |
| Can straight-line code be followed? | No event is required between branches | Each intercepted single-step debug exit can produce a record |
| How is state stored? | Producer-specific packets | Full anchors plus compact deltas in per-CPU v5 streams |
The live capture path stamps each accepted raw slot with:
- the instruction-boundary RIP;
- RFLAGS and CR3;
- RAX, RBX, RCX, RDX, RSI, RDI, RBP, RSP, and R8 through R15;
- a global sequence value and TSC for the raw state;
- a session-start marker when the first post-gate debug exit arrives.
The v5 wire format and TraceReader also model 16 YMM registers and an
xstate_bv field. The current VM-exit hot path deliberately zeroes those fields,
so live captures from this source revision provide GPR-rich state but not live YMM
values. This distinction is visible directly in driver/svmtrace_driver.c.
The debug exception occurs after the processor executes an instruction. The captured RIP therefore points to the next instruction, while the registers contain the state produced by the instruction that just completed.
sequenceDiagram
participant Guest as Target thread
participant CPU as AMD CPU
participant VMM as SvmTrace VM-exit handler
participant Ring as Per-CPU raw ring
VMM->>CPU: Set guest RFLAGS.TF
VMM->>Guest: Resume
Guest->>CPU: Execute one instruction
CPU->>VMM: #DB VM exit, exit code 0x41
VMM->>VMM: Require DR6.BS and WeSetTf
VMM->>Ring: Store resulting RIP, RFLAGS, CR3, and GPRs
VMM->>CPU: Set guest RFLAGS.TF again
This is continuous single-step observation, not an interactive debugger command. The public client can arm, query, stop, and shut down capture, but it does not expose step, pause, or register-edit commands.
The handler also checks that a debug exit belongs to SvmTrace. It requires both the single-step status bit in DR6 and a per-CPU marker showing that SvmTrace set Trap Flag. Kernel-mode RIPs clear Trap Flag, while a later tracked execute fault can re-engage stepping when execution returns to tracked user-mode code.
Note
A record is produced only when trace writing is enabled and the raw ring accepts the slot. If the ring reaches its backpressure threshold, the handler keeps stepping but drops the slot. A later full anchor can expose the sequence gap.
The default build contains the C++20 library, CLI, and synthetic parser tests. It does not build or load the driver.
git clone https://github.com/ntkrnlmp/svmtrace.git
cd svmtrace
cmake -S . -B build -A x64
cmake --build build --config Release
ctest --test-dir build -C Release --output-on-failureInspect one per-CPU v5 stream:
build\Release\svmtrace.exe inspect C:\svmtrace_trace_cpu0.binDump decoded records as CSV:
build\Release\svmtrace.exe dump C:\svmtrace_trace_cpu0.bin
build\Release\svmtrace.exe dump C:\svmtrace_trace_cpu0.bin 100The optional final argument stops output after that many decoded rows. The CLI prints this schema:
cpu,sequence,kind,rip,rflags,cr3,reason
The decoder visits records as it reads frames, so callers do not need to load the whole trace into memory.
Note
Everything in this section is synthetic. The addresses and register values illustrate the record semantics. They are not a hardware capture, benchmark, or claim about a specific machine.
Assume the target entry page is the execute gate, RFLAGS begins at 0x202, and the
target begins with these instructions:
0x0000000140001000 mov eax, 7
0x0000000140001005 add eax, 1
0x0000000140001008 test eax, eaxThe gate page first causes a nested page fault. SvmTrace clears the gate's NX bit, starts a trace session, and sets Trap Flag. The next two intercepted debug exits can then be interpreted like this:
| Instruction that completed | RIP reported by #DB |
Resulting RAX | Resulting RFLAGS | Record kind |
|---|---|---|---|---|
mov eax, 7 at 0x140001000 |
0x140001005 |
0x7 |
0x202 |
session_start |
add eax, 1 at 0x140001005 |
0x140001008 |
0x8 |
0x202 |
instruction |
Illustrative CLI output for that synthetic sequence:
cpu,sequence,kind,rip,rflags,cr3,reason
3,10,session_start,0x140001005,0x202,0x12345000,0
3,11,instruction,0x140001008,0x202,0x12345000,0
The key point is visible in the first row: RIP=0x140001005 describes the boundary
after the mov, and RAX=7 is available through the C++ record even though the CLI's
compact CSV view does not print every GPR.
How the same state becomes an anchor and delta
flowchart LR
A[Session start state<br/>RIP 0x140001005<br/>RAX 7] -->|full 704-byte record| B[Anchor baseline]
B --> C[Next state<br/>RIP +3<br/>RAX +1]
C -->|RIP varint + GPR mask + RAX delta| D[Compact delta]
D --> E[TraceReader reconstructs<br/>RIP 0x140001008<br/>RAX 8]
The encoder compares each raw state with the preceding state. It writes changed GPRs behind a 16-bit mask, represents nearby RIPs and GPR changes as signed variable-length integers, and periodically emits a full anchor again.
TraceReader calls a visitor once for every reconstructed record. Return false
to stop early.
#include <svmtrace/trace.hpp>
#include <cinttypes>
#include <cstdio>
#include <optional>
int main() {
const svmtrace::TraceReader trace("svmtrace_trace_cpu0.bin");
std::optional<svmtrace::CpuState> previous;
const auto summary = trace.read([&](const svmtrace::Record& record) {
if (previous && previous->gpr[0] != record.state.gpr[0]) {
std::printf("seq=%" PRIu64 " rip=0x%" PRIx64
" rax: 0x%" PRIx64 " -> 0x%" PRIx64 "\n",
record.sequence,
record.state.rip,
previous->gpr[0],
record.state.gpr[0]);
}
previous = record.state;
return true;
});
std::printf("decoded %" PRIu64 " instruction records\n",
summary.instructions);
}The public model is typed and independent of the kernel driver:
svmtrace::Record
.kind // instruction, session_start, or session_end
.cpu // logical processor that owns this stream
.sequence // reconstructed record order
.tsc // present on anchors and session-end records
.reason // gate id, exit reason, or session-end reason
.state.rip
.state.rflags
.state.cr3
.state.gpr[16]
.state.ymm[16][4] // represented by v5; current capture path writes zeros
.state.xstate_bv // represented by v5; current capture path writes zeroDriver control is a separate API:
#include <svmtrace/client.hpp>
svmtrace::DriverClient driver;
driver.connect();
driver.set_target(pid);
driver.arm();
const auto status = driver.status();
driver.stop();Analysis applications can therefore use TraceReader without connecting to the
\\.\svmtrace device.
flowchart LR
A[Target entry page] -->|execute NPF| B[SVM VM-exit handler]
B -->|clear NX and set TF| C[Target instruction]
C -->|intercepted #DB| B
B -->|full raw state| D[Per-CPU ring]
D --> E[Per-CPU worker]
E --> F[Anchor and delta encoder]
F --> G[LZ4 or raw frames]
G --> H[(Per-CPU v5 file)]
H --> I[TraceReader]
I --> J[C++ visitor]
I --> K[inspect]
I --> L[dump]
| Layer | Source | Responsibility |
|---|---|---|
| SVM capture | driver/svmtrace_driver.c, driver/vmexit.asm |
Configure VMCBs and NPT, handle execute faults, intercept debug exits, capture guest state |
| Per-CPU buffering | driver/svmtrace_driver.c |
Keep encoding, compression, and file I/O out of the VM-exit hot path |
| v5 encoding | driver/svmtrace_driver.c |
Convert full raw slots into anchors and deltas, then write framed per-CPU files |
| Decoder | src/trace.cpp, src/lz4_block.cpp |
Validate frames, decode LZ4 blocks, and reconstruct typed records |
| CLI and client | cli/main.cpp, src/client.cpp |
Inspect streams and issue explicit driver IOCTLs |
The capture and analysis halves share a file contract, not a runtime dependency. That keeps offline decoding usable when the experimental driver is not loaded.
The standalone gate definition contains one target gate. During arm, the driver
reads the selected process's PE AddressOfEntryPoint, resolves that page to a guest
physical address, and marks the corresponding nested page-table entry NX.
sequenceDiagram
participant CLI as svmtrace CLI
participant Driver as SVM driver
participant NPT as Nested page table
participant CPU as Target CPU
participant Worker as Per-CPU worker
CLI->>Driver: set-target PID
Driver->>Driver: resolve image and PE entry point
CLI->>Driver: arm
Driver->>NPT: mark entry page NX
Driver->>CPU: request translation flush
CPU->>Driver: execute NPF at entry page
Driver->>NPT: clear NX on gate page
Driver->>CPU: set guest RFLAGS.TF
loop while the target session remains active
CPU->>Driver: intercepted single-step #DB
Driver->>Driver: verify DR6.BS and WeSetTf
Driver->>Worker: publish full state to per-CPU ring
Driver->>CPU: set RFLAGS.TF and resume
end
Worker->>Worker: encode anchors and deltas
Worker->>Worker: write raw or LZ4 frames
Capture state transitions
stateDiagram-v2
[*] --> Idle
Idle --> TargetSelected: set-target PID
TargetSelected --> Armed: arm
Armed --> Recording: entry-page execute NPF
Recording --> Recording: accepted #DB step
Recording --> Armed: target CR3 changes
Recording --> TargetSelected: stop
Armed --> TargetSelected: stop
TargetSelected --> Idle: select another target
Idle --> [*]: shutdown SVM
The VM-exit assembly switches to a dedicated host stack and uses VMSAVE and VMLOAD around the C handler. The C hot path copies state into a fixed raw slot. A worker later performs delta encoding, LZ4 compression, and file I/O.
Each logical processor writes C:\svmtrace_trace_cpu{N}.bin. TraceReader
recognizes version 5, a framed little-endian stream.
+-------------------------------+
| 16-byte file header |
+-------------------------------+
| decoded_size : u32 | frame header
| stored_size : u32 |
+-------------------------------+
| raw or LZ4 block payload |
+-------------------------------+
| next frame ... |
+-------------------------------+
| Offset | Size | Field |
|---|---|---|
0x00 |
4 | Magic bytes FTRC |
0x04 |
4 | Format version 5 |
0x08 |
4 | Logical CPU identifier |
0x0c |
4 | Flags, with bit 0 marking a framed body |
A decoded frame is at most 64 KiB. When decoded_size == stored_size, the payload
is raw. Otherwise the payload is one LZ4 block that must decode to exactly the
declared size. The worker starts a new frame before a record could cross a frame
boundary.
flowchart LR
A[Session start<br/>full state] --> D1[Delta<br/>changed fields]
D1 --> D2[Delta<br/>changed fields]
D2 --> AN[Anchor<br/>full state]
AN --> D3[Delta<br/>changed fields]
D3 --> Z[Session end<br/>RIP, TSC, reason]
| Record | Wire type | Decoder result |
|---|---|---|
| Anchor | 01 in the low header bits |
One instruction record with a full baseline |
| Session start | 10 in the low header bits |
One session_start record with full baseline and gate id |
| Delta | 00 in the low header bits |
One reconstructed instruction record |
| Session end | 11 in the low header bits |
One session_end record with final RIP and reason |
Full anchors carry sequence, TSC, exit reason, RIP, RFLAGS, CR3, 16 GPRs, the v5
YMM area, and xstate_bv. The encoder emits a full anchor at session start, for the
first state after a reset, and when 64 records have elapsed since the previous
anchor.
Delta header bits announce whether the record contains changed GPRs, RFLAGS, YMM
registers, or a full RIP. Otherwise RIP and GPR changes use signed variable-length
integers. TraceReader applies each delta to the previous state and increments the
sequence value.
The decoder checks data before exposing a record to a visitor:
- file magic, version, and framed-body flag;
- complete frame headers and payloads;
- decoded and stored frame sizes;
- exact LZ4 output size, offsets, and match lengths;
- complete fixed records and variable-length integers;
- availability of every field selected by a delta mask;
- the requirement that a delta follows an initialized anchor.
SvmTrace is not a branch-packet decoder. Its source implements a deliberately different observation path.
| Property | SvmTrace implementation | Branch-only model |
|---|---|---|
| Trigger after session start | Trap Flag produces a debug exception after an instruction | A control-flow event produces a packet or event |
| Straight-line instructions | Visible through consecutive debug exits | No branch event occurs between them |
| State in this repository | RIP, RFLAGS, CR3, and 16 GPRs in accepted live slots | State must come from another source or be inferred |
| Storage | Full anchors plus changed-field deltas | Control-flow-oriented encoding |
| Runtime effect | Every intercepted step enters the VM-exit handler | Depends on the branch tracing mechanism |
| Intended use | Narrow research sessions where state transitions matter | Broader control-flow reconstruction |
flowchart TB
Q{What must the trace answer?}
Q -->|Which edge executed?| B[Branch-oriented trace]
Q -->|What state followed each instruction?| S[SvmTrace]
S --> R[Compare consecutive GPR and flag values]
S --> C[Follow straight-line execution]
S --> A[Associate state with CR3 and sequence]
No performance comparison is presented here. The repository contains no checked-in hardware captures or benchmark harness that could support one.
Requirements:
- Windows 10 or Windows 11
- Visual Studio 2022 with Desktop C++ tools
- CMake 3.22 or newer
cmake -S . -B build -A x64
cmake --build build --config Release
ctest --test-dir build -C Release --output-on-failureThe default configuration builds:
| Target | Output | Purpose |
|---|---|---|
svmtrace |
Static or import library selected by CMake | Reader, LZ4 decoder, and driver client |
svmtrace_cli |
svmtrace.exe |
inspect, dump, and driver commands |
svmtrace_tests |
Test executable | Synthetic raw and LZ4 v5 parser checks |
The optional driver build requires a matching Windows Driver Kit:
cmake -S . -B build-driver -A x64 `
-DSVMTRACE_BUILD_DRIVER=ON `
-DSVMTRACE_WDK_VERSION=10.0.26100.0
cmake --build build-driver --config ReleaseThe repository does not ship a driver binary or loader. Use the normal Windows signed or test-signed driver path on an isolated machine.
From an elevated shell after the driver is installed and started:
svmtrace driver set-target 1234
svmtrace driver arm
svmtrace driver status
svmtrace driver stop
svmtrace driver shutdownstatus reports:
bootstrap: complete|pending
watchdog: clear|triggered
armed: yes|no
active CPUs: <count>
target PID: <pid>
target image: <base>..<end>
NX pages: <count>
NPF exits: <count>
debug exits: <count>
The status fields map directly to DriverStatus and the driver's
SVMTRACE_STATUS_RESPONSE.
The checked-in tests use synthetic files, which makes their evidence precise and repeatable without implying that CI exercised AMD SVM hardware.
| Check | Evidence in this repository |
|---|---|
| Library, CLI, and tests compile on Windows | .github/workflows/ci.yml configures and builds the default CMake targets |
| Raw framed v5 decoding | parser_round_trip(false) constructs and reads a synthetic raw frame |
| LZ4 framed v5 decoding | parser_round_trip(true) wraps the same fixture in a literal-only LZ4 block |
| Anchor reconstruction | Tests assert CPU id, initial RIP, and initial RAX |
| Delta reconstruction | Tests assert sequence, RIP delta, RAX delta, and RFLAGS change |
| Session-end decoding | Tests assert record kind and end reason |
| Invalid magic rejection | bad_magic_is_rejected() verifies a TraceError |
| Live SVM capture | Not exercised by the current CI workflow |
Run the same validation locally:
ctest --test-dir build -C Release --output-on-failureThe diagrams and walkthrough in this README are explanatory renderings of the source and synthetic record semantics. No fabricated hardware screenshot or capture result is presented as validation.
SvmTrace is a research prototype, not a production tracing facility. The default build exercises the offline reader and CLI. Driver compilation is opt-in, and live capture requires independent testing on suitable AMD hardware.
Each CPU writes an independent stream. TraceReader preserves that per-CPU order
but does not merge streams or normalize TSC values. The current live hot path zeros
the v5 YMM and xstate_bv fields. Ring backpressure can create sequence gaps, and
single-step VM exits materially perturb target execution.
Those properties matter when interpreting a trace. A successful build and synthetic parser test establish that the checked-in user-mode components agree on the v5 file contract. They do not establish live capture correctness on a particular processor, firmware revision, or Windows build.
The AMD SVM driver began as an uncommitted prototype in a private research workspace and was extracted on 21 July 2026. The source directory had no commit history, so a stronger provenance claim is not possible. Target-specific binaries, traces, microcode, loader code, and surrounding research data were not copied.
driver/sflz4.c and driver/sflz4.h are adapted from SFLZ4 code by Nigel Tao. The
prototype identifies that code as Apache License 2.0, but the exact upstream revision
was not recorded. The adaptation accepts a caller-owned hash table and uses Windows
kernel memory routines. The original format article is available at
https://nigeltao.github.io/blog/2022/lz4-format.html. The C++ decoder in
src/lz4_block.cpp is a separate implementation of the documented LZ4 block format.
No project-wide license has been added. Choose one only after confirming authorship and rights for the original driver prototype.