Language Runtimes · Virtual Machines
Line Tables, Breakpoints, and Single Stepping in an Interpreter
How a debugger maps a bytecode offset back to a source line, how breakpoints are placed without corrupting the code, and what stepping costs per instruction.
A debugger attached to a running interpreter must answer three questions with only a code offset and a frame pointer as input. Which source line is executing? What are the values of the local variables? Where should execution stop next? The runtime does not compute any of these while running normally, because none of them is needed to execute the program. They exist only when a debugger is attached, and the format has to carry enough information for a debugger to reconstruct them after the fact.
From offset to line
The mapping from code offset to source line is not a function. Several offsets map to one line, because a line compiles to many instructions, and one offset maps to exactly one line, but the set of lines a method covers is not contiguous and the mapping is not monotone: a finally block duplicated at several points in the method carries the same line numbers at widely separated offsets.
The JVM stores this as a LineNumberTable attribute, an array of pairs, each giving a start offset and the line number that applies from that offset until the next entry’s start. A lookup is a search for the greatest start_pc less than or equal to the query offset. The table is small, typically one entry per source line that produced code, so a linear scan or a binary search over a sorted array is enough.
DWARF solves the same problem with a more elaborate encoding, because it must cover native code from many languages and compilers. A line number program is a small bytecode of its own, with opcodes that advance the address, advance the line, set a flag, or emit a row. Special opcodes combine an address advance and a line advance into one byte by dividing the ranges into buckets, so the common case of “next instruction, one line later” costs one byte. A consumer runs the program once, building a table of rows, and then answers queries against that table. The design exists because native code can be large and the table must be compact; the special-opcode scheme is what keeps a typical function’s line table to a few bytes per line.
For an interpreter, the line table has a second use: it defines what “step over a line” means. A step-over sets a temporary breakpoint at the first offset whose line number differs from the current one and whose line is in the same frame, which requires the table to be ordered so that the next distinct line can be found by scanning forward. Because a line can appear again later, the scan must stop at the first change rather than at the last occurrence of the current line.
Breakpoints without patching code
The classic native breakpoint replaces the instruction at the target address with a trap instruction, saving the original byte. On x86 the trap is the single-byte int3, opcode 0xCC. When the processor executes it, control transfers to the debugger, which restores the original byte, rewinds the instruction pointer by one, and lets the instruction execute under single-step before reinserting the trap.
An interpreter can do the same thing one level up: replace the bytecode at the target offset with a breakpoint opcode whose handler notifies the debugger. The handler then restores the original opcode, adjusts the instruction pointer back to the breakpoint offset, and arranges for the original instruction to execute next. The rewind is necessary because the breakpoint opcode has already advanced the pointer past itself.
The alternative is a side table. The interpreter consults a per-method bitmap of breakpoint offsets before executing each instruction. That costs a lookup per instruction on the normal path, which is unacceptable for a runtime that is not being debugged, so implementations either compile the check out entirely when no debugger is attached or use the patching approach, which costs nothing until a breakpoint is set.
Patching has a constraint the side table does not: the code must be writable, and if the code array is shared between threads, a breakpoint set by one thread’s debugger stops all threads. Debuggers that support per-thread breakpoints must therefore keep the breakpoint in a side structure and check it only for the affected thread, or duplicate the code. Most implementations accept the global behavior because a debugger stopping one thread while others run is rarely what the user wants anyway.
Stepping and the cost of a check
Single stepping means stopping after every instruction, or after every instruction that begins a new source line. Implemented naively, it sets a “trap next” flag that the dispatch loop checks after every handler, which means one branch per instruction. The branch is perfectly predicted (the flag does not change), so the cost is a few cycles per instruction, but it is on the critical path of the dispatch loop and it cannot be eliminated without duplicating the loop.
The usual solution is two dispatch loops generated from the same source, one with the check and one without, selected by a runtime flag. The check-free loop is used normally, and the checked loop takes over when a debugger requests stepping. That keeps the normal path clean at the cost of doubling the interpreter’s text size, which is the same trade that appears when a runtime specializes its interpreter for other reasons.
Stepping granularity interacts with the line table. Instruction stepping stops at every bytecode, which for a stack machine means a source statement can take a dozen stops, including intermediate pushes that have no source meaning. Line stepping stops only when the line number changes, which produces one stop per statement but requires the line lookup per instruction while stepping. Debuggers therefore default to line stepping and expose instruction stepping as a separate mode.
Reading locals without re-executing
The local variable table names each local’s slot range and its source name, so a debugger can show x = 3 rather than slot 1 = 3. The JVM’s LocalVariableTable gives, per entry, a start offset, a length, a name, a descriptor, and the slot index. The length is the scope over which the variable is live, which is why the same slot can appear under different names in different ranges: the compiler reuses slots for variables whose scopes do not overlap.
Reading a local’s value requires knowing its type, because the slot holds a raw word. A long occupies two slots, a reference is a pointer the debugger must interpret against the heap, and a slot that is not live has no value at all. The table’s descriptor field supplies the type, and the debugger uses the runtime’s own object model to format it. A slot that holds a reference to an object whose class has been unloaded cannot be formatted, which is why debuggers handle that case by reporting the reference rather than its contents.
Reconstructing the value of an expression that is not a local, such as a field read that has already been popped, requires either re-execution or a stack map that says what was on the operand stack. The JVM’s stack map frames give the types but not the offsets of the stack slots relative to the frame, though the layout is fixed by the frame design, so a debugger can compute them: the operand stack begins after the locals, and the depth at the current offset is declared by the frame. That is the same information the garbage collector uses, which is why a debugger and a collector can share the frame-walking code.
Optimized code and the limits of the mapping
Everything above assumes the code corresponds closely to the source. Once a runtime optimizes, the correspondence breaks: a variable may live in a register for part of its scope and on the stack for another part, a loop may be unrolled so that one offset covers three source iterations, and a function may be inlined so that one frame contains locals from several source functions.
Native debuggers solve this with location lists: each variable has a list of ranges, each range naming a location (a register, a stack offset, or a computed address). DWARF’s location expressions can even describe a value that must be computed by evaluating a small stack program. That machinery is what makes debugging optimized code possible at all, and it is why the debugging information for an optimized binary can be larger than the code.
An interpreter that compiles hot methods has the same problem. The compiled code needs its own mapping from machine offset to bytecode offset to source line, and a debugger must be able to step from interpreted code into compiled code and back. Runtimes handle this by keeping the bytecode as the canonical description and mapping compiled offsets to bytecode offsets, so that a breakpoint set on a bytecode offset is translated into the corresponding compiled address. A bytecode offset with no compiled counterpart, because the optimizer removed it, cannot be broken on, and the debugger must report that rather than silently misplacing the breakpoint.
Deoptimization completes the picture. When a compiled frame must be inspected at a point the optimizer did not anticipate, the runtime reconstructs an interpreted frame from the compiled one using metadata that describes where each bytecode local lives at that machine offset. That reconstruction is the same operation the runtime performs when an assumption behind an optimization fails, and the metadata it needs is the same. A runtime that supports both debugging and deoptimization therefore maintains one description of frame state rather than two.
The frame layout this metadata describes, and the suspension points that make a generator’s frame visible to a debugger at all, are covered in interpreter stack frame layout and resumable frames for generators and coroutines. What debugging adds is the requirement that this metadata remain accurate under transformation, which is the constraint that keeps optimized-code debugging expensive.
References
- DWARF Debugging Information Format, Version 5 — DWARF Debugging Information Format Committee
- The Java Virtual Machine Specification, Java SE 8 Edition — Oracle
- Intel 64 and IA-32 Architectures Software Developer Manuals — Intel Corporation
- ECMA-335: Common Language Infrastructure (CLI) — Ecma International