Home · ASPLOS 2025 · Reliability, Testing & Verification

Debugger Toolchain Validation via Cross-Level Debugging

Yibiao Yang, Maolin Sun, Jiangchang Wu, Qingyang Li and Yuming Zhou

Conference
ASPLOS 2025
Published
2025
Pages
280-294
Citations
7

A debugger can quietly lie — showing the wrong line or the wrong variable value — and there is no obvious way to test whether it is telling the truth, because you would need to already know the right answer; this paper from Nanjing University finds an answer hiding in plain sight, by stepping through the very same compiled program two ways in the same debugger, once line-by-line and once instruction-by-instruction, and checking that the two accounts agree, which they must if both are correct, catching 27 real bugs in GDB and LLDB, many of which appear even without any compiler optimization and so were invisible to every prior tool.

The argument in one glance

  1. Problem

    A buggy debugger lies about your program, and you cannot easily tell

    When a developer sets a breakpoint and steps through code, they trust the debugger to report the truth: which source line just ran, what each variable holds. If the debugger toolchain — the compiler that emits debug information plus the debugger that consumes it — is itself wrong, it silently misleads them, sending them to hunt bugs that are not there and overlook ones that are. Testing whether a debugger is honest is hard, because you would need to already know the correct answer to compare against.

  2. What people did

    Compare an optimized build against an unoptimized one

    Prior validation work debugs the same program compiled two ways — with and without optimization — and looks for places where the two traces disagree, treating the unoptimized trace as the trustworthy reference. Any discrepancy is flagged as a debug-information bug introduced by optimization.

  3. Why it fell short

    Two different builds are not comparable, and only optimization bugs show up

    Optimization aggressively deletes variables, removes source constructs, and reorders instructions, so the optimized and unoptimized executables are genuinely different programs whose traces cannot be lined up cleanly — producing a flood of false alarms. Worse, the whole method can only find faults tied to optimization; a debugger bug that appears even at the lowest optimization level, unrelated to any optimization, is structurally invisible to it.

  4. This paper's idea

    Step through the same executable two ways and check the accounts agree

    Cross-Level Debugging debugs one single executable at two granularities in the same debugger — line-by-line (source level) and instruction-by-instruction (instruction level) — and checks three relations that must hold if both are correct: the same source lines get reached, in the same order, with the same variable values. A violation is a bug. No second build, no assumed reference, and it works at any optimization level, including none.

Why this problem is worth solving

The tool under suspicion is one developers lean on precisely when they are most confused, which is what makes its failures so costly.

A debugger toolchain is the pair of tools that together make interactive debugging possible: the compiler, which alongside machine code emits debug information — a map tying each machine instruction back to a source line, and recording where each variable lives and what its name and type are — and the debugger, which reads that map to let a developer set breakpoints, step through execution, inspect the call stack, and print variable values. Both halves must be correct for the experience to be trustworthy. If the compiler’s map is wrong or the debugger misreads it, the developer is shown a fiction — paused at the wrong line, or told a variable holds a value it does not — and every conclusion they draw from that fiction is corrupted. Because the whole point of a debugger is to be the source of ground truth about a running program, its silent errors are uniquely damaging.

The reason these tools go under-tested is a classic obstacle in software testing.

A test oracle is the mechanism that decides whether a test passed — the source of the expected answer against which the observed behavior is judged. For most software the oracle is easy: you know what output the input should produce. For a debugger it is brutally hard, because the expected behavior is an enormous, dynamic record of which lines executed, in what order, and what every variable held at each step, and computing that expected record in advance, for an arbitrary program and an arbitrary sequence of debug commands, would take exactly the kind of correct debugger you are trying to test, or an enormous amount of human labor. Without an oracle there is no automated test, which is why debugger toolchains have been validated far less thoroughly than the compilers they sit beside — and why the paper’s core contribution is not a new tool so much as a new oracle.

What people did before, and where it broke

  • Check variable values with hand-crafted actionable programs. One line of work builds special programs whose variables have known values at known points, then checks the debugger reports them correctly across optimization levels. Where it broke. It targets variable values specifically and leans on programs constructed for the purpose, so it covers a narrow slice of debugger behavior.
  • Define trace invariants across optimized and unoptimized runs. The Debug2 line establishes invariants over the hit line, the backtrace, and function parameters, comparing an optimized build’s trace against an unoptimized reference. Where it broke. It still compares two different executables, so optimization’s deletions and reorderings make many traces genuinely incomparable, and it can only surface faults that optimization introduces.
  • Study the completeness of debug information. Other work asks whether debug information is complete — whether variables that should be inspectable actually are. Where it broke. Completeness is one property among many; it does not check that the values, lines, and ordering the debugger reports are actually correct.
  • Trust the unoptimized trace as the oracle. The shared assumption underneath the optimized-versus-unoptimized approach is that debugging the unoptimized build gives the right answer. Where it broke. That assumption fails for any bug present at the lowest optimization level, since then the reference itself is wrong and there is nothing correct to compare against.
  • The gap. Every prior method needed either a purpose-built program or a second, differently-optimized executable to serve as a reference, which confined them to optimization-related bugs and to comparisons that optimization itself renders unreliable. What was missing was an oracle that needs no external reference at all — one derived from a single build.

How Cross-Level Debugging works

The Devil pipeline: two views of one execution, checked for agreement

  1. new

  2. new

  3. new

  4. new

  5. new

The idea

The oracle is hiding inside the tool: two views of the same run must tell the same story. The obstacle was that testing a debugger seemed to need a correct answer computed some other way. Cross-Level Debugging finds that answer without leaving the debugger, by exploiting a structural fact about how source maps to machine code: one source line compiles to many instructions, but each instruction belongs to exactly one source line. So instruction-level stepping is simply a finer-grained view of the very same execution that source-level stepping shows coarsely. Two views of one deterministic run cannot legitimately disagree — if line-by-line stepping says a line executed, or ran before another, or that a variable held some value there, instruction-by-instruction stepping of the identical binary must say the same. When they diverge, at least one of them is wrong, and the toolchain has a bug. The oracle is not an external reference at all; it is an internal consistency that correctness forces. Manufacturing a trustworthy oracle out of a relation that any correct implementation must satisfy, rather than out of a known-good answer you do not have, is the same move the sanitizer-testing work covered elsewhere on this site makes when it builds an oracle from whether a crash site still executes, deciding a silent tool is wrong without ever needing a reference tool that is right.

Because it compares one build to itself, it escapes the optimization trap entirely. The prior methods were shackled to optimization: they compared an optimized executable against an unoptimized one, so they could only ever see bugs that optimization caused, and even then the comparison was noisy because the two builds were different programs. Cross-Level Debugging compares a single build against itself at two granularities, which cuts that shackle. It runs at any optimization level, including the lowest, where a great many debugger bugs actually live — bugs that have nothing to do with optimization and that the optimized-versus-unoptimized approach can never surface because at that level its own reference is equally wrong. In the study, eleven of the eighteen confirmed bugs appeared with no optimization at all, and all four rated critical were among them. Catching the faults in the compiler-debugger contract that are unrelated to optimization, precisely the ones a differential-across-optimization method structurally cannot reach, is the same territory the missing-variable debug-information work covered elsewhere on this site explores, hunting the ways a debugger loses track of program state even in plainly compiled code.

Turn an interactive, un-batchable tool into something a framework can exhaustively poke. A debugger is awkward to test because it is interactive — its behavior depends on a live sequence of human commands issued against a running program’s state — so there is no obvious set of inputs to sweep. Devil, the framework, makes the behavior systematically explorable: it forwards the program to a randomly chosen location, by breakpoint or by stepping, to reach varied program states, then drives the debugger automatically to step all the way to exit at each level, scripting the command sequences that record locations and variable values. What was a manual, one-off interaction becomes a repeatable batch experiment that can be run across a whole test suite and every optimization level, so tens of thousands of programs can be checked without a human in the loop until a violation appears. Converting a tool that is normally exercised by hand into one that a harness can drive exhaustively and compare against an expectation is the same discipline the emulator-fidelity work covered elsewhere on this site applies, systematically locating the instructions where a supposedly faithful tool quietly diverges from the truth.

Following one bug through Devil

Catching GDB bug #27151, which appears at every optimization level

A tiny program: allocate, memset, print, return. This is the real bug that opens the paper, simplified. It reproduces from -O0 to -O3, so no optimized-versus-unoptimized method could find it.

Step 1 of 5

Compile with debug info and start in the debugger

The program is compiled with gcc -O0 -g, and Devil starts it under GDB with a breakpoint at main, ready to step through to the end at each level.

Because the bug shows even at -O0, the unoptimized build that prior tools trust as their oracle is itself wrong here — there is no correct reference to compare against.

Why it works

  • Same executable, same input, so any disagreement is real. Prior methods compared two different builds whose traces optimization had made incomparable → but two stepping strategies run the identical binary on the identical input, one deterministic execution viewed twice → so a divergence between the traces cannot be an artifact of comparison and must be a fault in the toolchain.
  • Instruction level is a strict refinement of source level, so the relations are forced. Each instruction maps to exactly one source line while a line spans many instructions → so instruction-level stepping is a finer view of the same run, and reachability, order, and value agreement must hold by construction → which means a violation is a sound bug signal that needs no known-good answer to interpret.
  • It never trusts an unoptimized reference, so it works at -O0. The optimized-versus-unoptimized approach assumes the unoptimized trace is correct → but Cross-Level Debugging derives its check from one build alone, making no such assumption → so it finds the eleven-of-eighteen confirmed bugs that appear with no optimization, which prior tools structurally cannot see.
  • Forwarding to random locations widens what gets exercised. Stepping only from the program’s start reaches a narrow band of states → but starting from many randomly chosen locations drives the debugger through more of the program’s behavior → so more latent discrepancies surface than a fixed entry point would reveal.
  • One oracle audits both halves of the toolchain. A trace violation does not presuppose whether the compiler or the debugger erred → because both emit or consume the same debug-information contract that the relations check → so a single method exposed bugs later attributed to the debugger and to the compiler alike, seven and eight respectively among the confirmed.

What it costs (trade-offs and scope)

  • It checks consistency, not correctness. The oracle only fires when the two levels disagree, so a bug that corrupts both the source-level and instruction-level view in exactly the same way produces no violation and slips through — an inherent limit of any self-consistency oracle, which cannot see errors shared by both sides.
  • A violation localizes to the toolchain, not to the culprit. When the relations break, the method flags the toolchain but does not say whether the compiler or the debugger is at fault; that still takes manual diagnosis, and at least one reported issue (GDB #26061) was closed as not-a-GDB-bug because the fault lay in GCC.
  • Every violation needs manual triage. One underlying bug can produce many violations, so a human must inspect each before reporting to avoid duplicates and invalid reports — real labor that the automated part does not remove.
  • The headline count is softer than 27. Of the 27 reported bugs, 18 are confirmed and 12 fixed; the remainder are unconfirmed, new, duplicate, or judged not-a-bug, so the settled result is closer to the confirmed eighteen than the reported twenty-seven.
  • Strong assumptions about the program under test. The technique assumes the input is well-formed, deterministic, and single-threaded, with no undefined behavior — the GCC test suite was chosen precisely because it avoids undefined behavior — so concurrency bugs and undefined-behavior interactions are out of scope.
  • The three relations are unevenly useful. Reachability preservation found about two-thirds of the confirmed bugs and all four critical ones, value consistency found five, and order preservation found only one, so much of the power rests on a single relation and the others contribute at the margin.
  • It presumes the debugger supports both stepping modes faithfully enough to compare. The whole method drives step and stepi against each other, which only works on debuggers that offer instruction-level stepping — and that same machinery is part of what is under test.
  • The scope is C on two toolchains, and the overhead is real. Only C programs on GDB and LLDB were evaluated, so generality to other languages and debuggers is asserted rather than shown; instruction-level stepping of a whole program is slow (up to several seconds per program on Clang at -O0), over eighty percent of the suite was excluded for not compiling standalone, and random start points make coverage nondeterministic — mitigated but not erased by selective debugging.

The evaluation

Three ways to test whether a debugger tells the truth

PropertyOptimized-vs-unoptimized trace diffDebug2 (trace invariants)Cross-Level Debugging (Devil)
What it compares
Works with no optimization (-O0)?
Needs a trusted external reference?
What it can localizeOptimization-related debug-info bugsOptimization-related trace bugs
ResultBounded to optimization faults
Comparison on GDB and LLDB (latest trunk), driven by the GCC 12.1.0 C test suite of 45,115 programs, from which roughly a few thousand per toolchain survive filtering. Devil and the prior tool Debug2 are shown to be complementary.Underlined values explain themselves when clicked.

Check yourself

Check your understanding

  1. 1. Why is validating a debugger toolchain so hard in the first place?

  2. 2. What is the core insight of Cross-Level Debugging?

  3. 3. Why can Cross-Level Debugging find bugs that the optimized-versus-unoptimized approach cannot?

  4. 4. Which limitation most affects how the results should be read?

Sources

The paper is “Debugger Toolchain Validation via Cross-Level Debugging” by Yibiao Yang, Maolin Sun, Jiangchang Wu, Qingyang Li, and Yuming Zhou, published at ASPLOS 2025, pages 279-293, DOI 10.1145/3669940.3707271. The authors are at the State Key Laboratory for Novel Software Technology, Nanjing University.

This explainer is written from the full 15-page text. Everything above comes from the paper’s own sections: the abstract and introduction framing the oracle problem and the limits of comparing optimized against unoptimized executables; the background on the debugger toolchain, debug information, and prior validation work (Li et al., Di Luna et al. Debug2, Assaiante et al.), with the motivating GDB #27151 example; the approach section formalizing the three relations — reachability preservation, order preservation, and value consistency — and the Devil algorithm of forwarding to a random location, stepping at the source and instruction levels, recording traces of locations and variable values, and comparing them, with the three illustrative bugs (GDB #27151 for R#1, GDB #95360 for R#2, LLVM #46040 for R#3); and the evaluation across five research questions covering the 27 reported bugs with 18 confirmed and 12 fixed, the optimization-level breakdown (eleven confirmed at -O0), the importance analysis (four P2-critical), the per-relation contribution (R#1 about two-thirds), the comparison with Debug2 showing the two are complementary, and the overhead measurements for compilation, execution, and source- and instruction-level stepping.

Related papers on Reliability, Testing & Verification

See all 80 →