gdb-translation-verifier
June 1, 2026 · View on GitHub
A CLI that drives two GDB sessions in parallel — one for a C/C++ reference binary and one for its Rust port — and reports the first point at which the two programs diverge in behavior.
Because this software does not need instrumentation of the code, it has two major advantages:
- It is faster to use as it avoids the code edit-recompile cycle for instrumentation
- No instrumentation all over the code
- It is the theoretical gold standard for equivalence of two programs. It is however only useful before new features are added to the translation (i.e. different I/O layer, rayon, etc)
Audience: LLM agents. This README is optimized for correct, unattended use by another LLM. It describes what the tool does, what it does not, and how to build the inputs it needs.
When to use this tool
- You are porting C/C++ code to Rust and need to find where the port's behavior first disagrees with the original at function boundaries.
- Both binaries are compiled with debug info (
-gfor C,cargo builddebug profile or a release profile withdebug = true). - Both binaries read the same input and are expected to follow similar call-graph shapes.
Do NOT use this tool when
- Targets are multi-threaded. Out of scope. GDB stepping follows a single thread and both sides may diverge onto different worker threads. Compile or configure both binaries to run single-threaded.
- Targets are heavily optimized. Release builds with
-O2/-O3inline functions, drop argument names, and reorder calls. Use-O0(C) andopt-level = 0(Rust) for both.debug = truealone in release is not enough. - You need instruction-level divergence detection. This tool operates at function-entry granularity.
Output contract
The tool emits NDJSON events on stdout (one JSON object per line), progress/warnings on stderr, and an exit code:
| Exit | Meaning |
|---|---|
| 0 | equivalent — both programs ran to completion matching at every sync point |
| 1 | divergence — the tool localized a difference; the final event carries both backtraces |
| 2 | timeout or max_steps hit before divergence or exit |
| 3 | tool error (gdb missing, binary not found, bad config, …) |
Event shapes (field names stable, consume by event discriminator):
{"event":"started", "c_label":"c", "rust_label":"rust"}
{"event":"sync_ok", "kind":"entry", "c_func":..., "rust_func":..., "args_matched":bool, "arg_diffs":[...], "watch_matched":bool, "watch_diffs":[...]}
{"event":"step_ok", "c_func":..., "rust_func":...}
{"event":"finish_ok", "c_func":..., "rust_func":..., "return_value":"..."}
{"event":"divergence", "reason":{...}, "c_frame":{...}, "rust_frame":{...}, "c_backtrace":[...], "rust_backtrace":[...], "last_matched":[[c,rust], ...]}
{"event":"done", "status":"equivalent|divergence|timeout|max_steps", "stops":N}
reason is one of:
{"func_mismatch":{"c":..., "rust":...}}— different function names at a sync/step{"arg_mismatch":{"func":..., "diffs":[{"name_c":..., "name_rust":..., "value_c":..., "value_rust":...}, ...]}}{"watch_mismatch":{"func":..., "diffs":[{"expr_c":..., "expr_rust":..., "value_c":..., "value_rust":...}, ...]}}{"return_value_mismatch":{"func":..., "c":"...", "rust":"..."}}{"one_exited_early":{"which":"c|rust", "reason":"..."}}
Parse stdout line-by-line; stop on the first divergence or done event.
Two operating modes
Sync-point mode (default; most reliable)
You provide pairs of breakpoints — one C location, one Rust location that
should be logically equivalent. Both sides run with GDB's continue, stopping
at their next breakpoint; on each sync the tool compares function names (with
name-map normalization) and arguments. If return = true on a sync point, the
tool also finishes both sides and compares return values.
Use this when you know good waypoints (e.g. p7_Pipeline ↔
hmmer::pipeline::pipeline_one_simd).
Trace mode (--trace)
After a single entry breakpoint, both sides step function-by-function.
Compared per step; reorder_window tolerates small out-of-order differences.
Requires skip lists to avoid drowning in runtime frames.
Use this when you suspect call-graph divergence and don't know where.
Minimal invocation (sync mode)
gdb-tv \
--c-bin /path/to/reference \
--rust-bin /path/to/port \
--c-arg input.dat --rust-arg input.dat \
--sync 'process_chunk=crate::process_chunk:return' \
--name-map '^process_chunk$=^(?:.+::)?process_chunk$' \
--timeout 30 --max-steps 1000
The :return suffix tells the tool to also compare the return value after
-exec-finish. The --name-map handles Rust's module-prefixed debug names
(e.g. crate::process_chunk vs plain process_chunk).
TOML config (recommended for non-trivial setups)
Put the invocation in a file and pass --config path.toml. CLI flags override
scalars and append to vectors.
c_bin = "/path/to/c"
rust_bin = "/path/to/rust"
c_args = ["in.dat"]
rust_args = ["in.dat"]
timeout = 30
max_steps = 10000
trace = false
# Skip rules — essential. See "Skip lists" below.
skip_rust_files = ["*rayon*", "*rustlib*", "*.cargo/registry/*"]
skip_c_files = ["*/sysdeps/*"]
[[sync]]
c = "process_chunk"
rust = "mycrate::process_chunk"
when_c = "len > 1000"
when_rust = "len > 1000"
return = true
arg_map = [["buf", "buffer"], ["len", "len"]]
watch_map = [["state->count", "state.count"], ["cursor", "cursor"]]
[[name_map]]
c = '^process_chunk$'
rust = '^(?:.+::)?process_chunk$'
[entry] # only used when trace = true or --trace
c = "main"
rust = "main"
See examples/config.toml for a commented template.
Resolving Rust names
Rust's debug info identifies functions by their fully-qualified path:
crate_name::module::function. The C side typically shows the raw symbol:
process_chunk. You have three ways to bridge this:
--name-map C_RE=RUST_RE(recommended). Regexes; the tool treats any pair as equivalent if both matchers hit. Anchor with^...$.- Explicit exact locations in
--sync. GDB accepts Rust qualified names directly:--sync 'process_chunk=mycrate::module::process_chunk'. #[no_mangle] pub extern "C" fnin the Rust port. The symbol will be unmangled, but the DWARFDW_AT_namestill contains the module path, so this alone does NOT make GDB report a bare name. Name mapping is still required.
Finding Rust function names
nm -C target/debug/mybin | grep my_function
# or
gdb -batch -ex 'info functions my_function' target/debug/mybin
Skip lists (trace mode essentials)
Without skip rules, trace mode on real Rust code drowns in runtime frames.
GDB's skip function / skip file tell GDB to step THROUGH matching
functions. Pass patterns via --skip-rust-file / --skip-rust-func /
--skip-c-file / --skip-c-func (repeatable) or the equivalent config
fields.
Typical Rust skip set:
--skip-rust-file '*rayon*'
--skip-rust-file '*rustlib*'
--skip-rust-file '*.cargo/registry/*'
--skip-rust-func 'core::ptr::drop_in_place*'
--skip-rust-func 'alloc::*'
Typical C skip set:
--skip-c-file '*/sysdeps/*'
--skip-c-file '*/libc/*'
--skip-c-func '__libc_*'
Arg comparison
On each sync, the tool captures frame arguments via
-stack-list-arguments 1. If a sync point has an arg_map, only mapped args
are compared (pair-wise, c_name vs rust_name). Without arg_map, comparison
is positional and fails when arg counts differ.
Values are compared as strings. For pointers you get 0xDEADBEEF; for
primitives you get "42", "true", etc. For structs you may get
"{ field = value, ... }" — brittle; avoid comparing struct args.
Conditional syncs and watched expressions
For hot functions, put sync points in TOML and add conditions so GDB only stops on the relevant family:
[[sync]]
c = "BLAST_GreedyGappedAlignment"
rust = "newblast::greedy_align_with_seed"
when_c = "q_off == 994 && s_off == 3897720"
when_rust = "q_seed == 994 && s_seed == 3897720"
watch_map = [
["rev_start_point.start_q", "left.seed.start_q"],
["rev_start_point.start_s", "left.seed.start_s"],
]
when_c and when_rust are passed directly to GDB condition, so keep them
valid for the respective language/debug info. watch_map expressions are
evaluated with -data-evaluate-expression at each matched sync point.
Library use for complicated watches
When GDB expression syntax is not enough, use the crate as a library and attach
callback watches directly to SyncPoint.watch_callbacks. Each callback receives
the active Driver and StopEvent and returns the normalized value to compare.
This is the intended path for:
- chunk-local vs absolute coordinate normalization
- extracting nested fields that GDB prints awkwardly
- comparing derived values rather than raw debugger strings
See examples/watch_callbacks.rs for a complete library-mode setup that uses callback watches to normalize values before comparison.
Interpreting results
sync_okwithargs_matched: true: both sides matched at this sync; keep reading.finish_okwithreturn_value: "...": areturn = truesync had matching return values.divergencewithfunc_mismatch: the name-map didn't equate the two stop points. Fix the name-map or the sync point.divergencewitharg_mismatchand emptydiffs: arg LIST shapes differ (usually different counts). The function signatures don't align across languages — usearg_mapto pair specific names, or accept this is a structural mismatch.divergencewithreturn_value_mismatch: this is the real bug. The two ports agree up to this call but disagree on its result.divergencewithone_exited_early: one binary terminated before the other reached the next sync. Often indicates the Rust port exits a loop one iteration sooner (or later). Inspectlast_matchedto see how far they agreed.
Known limitations
| Limitation | Workaround |
|---|---|
| Multithreaded targets | Run single-threaded. Out of scope. |
| Optimized builds | Use -O0 on both sides. debug = true alone is not sufficient. |
| Rust runtime frames | Skip via --skip-rust-file '*rustlib*' '*rayon*' etc. |
main symbol collision in Rust binaries | Prefer deeper sync points (e.g. hmmsearch::main or the first user-code function) over main. |
| Rayon/parallel iterators | Sync directly on the inner closure's kernel function; expect deep backtraces. |
| Different arg lists across languages (C struct ptr vs Rust N params) | Use arg_map to pair only comparable ones; accept structural mismatch otherwise. |
return_value is a string | Good for scalars; fragile for composites — compare at scalar return points. |
Build
cargo build --release
# binary at target/release/gdb-tv
Requires: gdb ≥ 8.0 (MI3 + Rust v0 demangling), cc, rustc (for
integration tests).
Tests
cargo test
16 tests total: MI parser unit tests + real-gdb integration tests + synthetic divergence detection (sync mode + trace mode).
Troubleshooting for an LLM
-
error: expected ^done for break-insert: Function "X" not defined: the symbol isn't visible to GDB in that binary. Verify withnm -C BIN | grep X(C side) ornm -C BIN | grep -i X(Rust side with demangling). Static/inline functions won't appear. -
Divergence on the first sync with
func_mismatch: your name-map is missing. Rust's debug name is usuallycrate::module::func, notfunc. -
Divergence on
arg_mismatchwithdiffs: []: arg lists have different lengths. Add anarg_mapto the sync point to compare only the semantically-equivalent arguments. -
Divergence on
one_exited_early: check the lastsync_ok/step_ok. The side that kept going has more work to do; either add more sync points covering that region or bumpmax_steps. -
Hangs: increase
--timeout. Multi-threaded targets (not supported) often manifest as hangs here — the breakpoint fired on a different thread than GDB was watching. -
Rayon/parallel code in Rust: set
RAYON_NUM_THREADS=1in the env that launches gdb-tv. This collapses rayon to serial execution on the same thread, matching C's single-threaded assumption.