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:

  1. It is faster to use as it avoids the code edit-recompile cycle for instrumentation
  2. No instrumentation all over the code
  3. 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 (-g for C, cargo build debug profile or a release profile with debug = 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/-O3 inline functions, drop argument names, and reorder calls. Use -O0 (C) and opt-level = 0 (Rust) for both. debug = true alone 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:

ExitMeaning
0equivalent — both programs ran to completion matching at every sync point
1divergence — the tool localized a difference; the final event carries both backtraces
2timeout or max_steps hit before divergence or exit
3tool 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_Pipelinehmmer::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).

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:

  1. --name-map C_RE=RUST_RE (recommended). Regexes; the tool treats any pair as equivalent if both matchers hit. Anchor with ^...$.
  2. Explicit exact locations in --sync. GDB accepts Rust qualified names directly: --sync 'process_chunk=mycrate::module::process_chunk'.
  3. #[no_mangle] pub extern "C" fn in the Rust port. The symbol will be unmangled, but the DWARF DW_AT_name still 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_ok with args_matched: true: both sides matched at this sync; keep reading.
  • finish_ok with return_value: "...": a return = true sync had matching return values.
  • divergence with func_mismatch: the name-map didn't equate the two stop points. Fix the name-map or the sync point.
  • divergence with arg_mismatch and empty diffs: arg LIST shapes differ (usually different counts). The function signatures don't align across languages — use arg_map to pair specific names, or accept this is a structural mismatch.
  • divergence with return_value_mismatch: this is the real bug. The two ports agree up to this call but disagree on its result.
  • divergence with one_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). Inspect last_matched to see how far they agreed.

Known limitations

LimitationWorkaround
Multithreaded targetsRun single-threaded. Out of scope.
Optimized buildsUse -O0 on both sides. debug = true alone is not sufficient.
Rust runtime framesSkip via --skip-rust-file '*rustlib*' '*rayon*' etc.
main symbol collision in Rust binariesPrefer deeper sync points (e.g. hmmsearch::main or the first user-code function) over main.
Rayon/parallel iteratorsSync 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 stringGood 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

  1. error: expected ^done for break-insert: Function "X" not defined: the symbol isn't visible to GDB in that binary. Verify with nm -C BIN | grep X (C side) or nm -C BIN | grep -i X (Rust side with demangling). Static/inline functions won't appear.

  2. Divergence on the first sync with func_mismatch: your name-map is missing. Rust's debug name is usually crate::module::func, not func.

  3. Divergence on arg_mismatch with diffs: []: arg lists have different lengths. Add an arg_map to the sync point to compare only the semantically-equivalent arguments.

  4. Divergence on one_exited_early: check the last sync_ok/step_ok. The side that kept going has more work to do; either add more sync points covering that region or bump max_steps.

  5. Hangs: increase --timeout. Multi-threaded targets (not supported) often manifest as hangs here — the breakpoint fired on a different thread than GDB was watching.

  6. Rayon/parallel code in Rust: set RAYON_NUM_THREADS=1 in the env that launches gdb-tv. This collapses rayon to serial execution on the same thread, matching C's single-threaded assumption.