Breakpoints and exceptions internals

Two mechanisms with almost nothing in common except that both surface as “the program stopped”: software breakpoints, which are patched bytes, and hardware watchpoints, which are four CPU registers. Then the layer that decides whether a given exception should stop you at all.

Software breakpoints: INT3

A source breakpoint is one byte. The debugger resolves the line to an address, saves the original byte there, and writes 0xCC — the INT3 instruction. When execution reaches it the CPU raises EXCEPTION_BREAKPOINT, the debug event loop receives it, the original byte is restored, the instruction pointer is backed up over the patch, and the stop is reported.

Two consequences follow directly from “the debugger modified your code”:

  • Only the debugger that planted a patch can remove it. A clean detach restores every byte. An adapter that is killed outright cannot, which is why a force-killed attached session leaves INT3 bytes in a live process and produces a much later, entirely mystifying External exception 80000003. See chapter 10.
  • An address is not a stable identity. Address breakpoints are therefore stored as (module, RVA) and re-resolved whenever the owning module maps, which is what makes them survive ASLR and a package being unloaded and reloaded at a different base. An address in a module that is not currently loaded is refused, rather than planted at a location that may belong to something else once a module maps there.

A breakpoint requested on a routine’s begin line is relocated to the first real statement, because at begin the parameters have not yet been spilled to the frame and the Variables panel would show the caller’s leftovers.

Conditions, hit counts and logpoints are all evaluated after the trap: the breakpoint fires every time, and the engine decides whether to report it or resume silently. A condition that fails to evaluate resumes — which is why a conditional breakpoint referencing something out of scope never appears to fire.

Hardware watchpoints: DR0–DR3

Data breakpoints are not patched code. They are the x86 debug registers, and the hardware defines what is possible:

  • Four slots, per process. Not per thread and not per frame — DR0 to DR3 are all there is, and there is no software emulation fallback. The fifth request is refused by name, reporting what holds the slots.
  • Widths are 1, 2, 4 or 8 bytes, and the address must already be aligned to the width. A misaligned request is refused rather than rounded to something that would watch the wrong bytes.
  • There is no read-only watch. The hardware offers write and read-or-write; readWrite therefore also fires on writes, and a read-only request is refused instead of being silently upgraded into something that answers a different question.
  • Arming touches live thread contexts, so the session must be stopped to add or remove one. A removal requested while running does not free the slot until the next stop.

Watching a local is the interesting case. A local’s address is a stack offset that is only meaningful while its frame lives; afterwards the same address is whatever the next call put there. The DAP frontend supports it by pinning the watchpoint to the specific frame invocation and withdrawing it — with a console message and a breakpoint-removed event — the moment that frame’s lifetime ends. The MCP frontend refuses a bare local outright instead. Both are the same judgement: never let a watchpoint silently continue on reused stack.

How an exception is decided

Every first-chance exception arrives at the debug event loop, and something has to decide whether it becomes a stop, a log line, or nothing at all. The order is fixed:

  1. Thread-name announcements are consumed first. TThread.NameThreadForDebugging raises exception code 0x406D1388 purely to carry a string. It is intercepted before any filter or rule runs, the name is captured, and it never surfaces as a stop — which also means no exception rule can ever match it.
  2. The rule list is evaluated, first match wins. Project rules and shared machine-wide rules are concatenated into a single ordered list, project rules first. A matching rule’s action — ignore, log, logStack or break — is the decision.
  3. If no rule matches, the filters decidedelphi, av, all, unhandled. Filters are the fallback, not a parallel mechanism.
Because project and shared rules are one list rather than two tiers, a rule with no criteria at the end of a project’s exceptionRules matches everything and makes every shared rule unreachable. This is the single most common way a machine-wide rules file appears to stop working in one project.

The criteria themselves are flat fields on the rule object, AND-ed when present and treated as wildcards when absent, with action required. The full table is in the reference; the behaviour is in chapter 4.

unit and the line criteria refer to the raise site — the first frame with known source — not the address the OS reported. Using them therefore makes the engine walk the stack on every exception, which is a measurable cost with the all filter enabled. Rules that use only class, classIs, code or the message criteria do not pay it.

First-chance passthrough

Breaking on a first-chance exception must not prevent the program’s own try/except from running when you continue. The engine reports the stop and then passes the exception through to the debuggee’s handler chain, so a first-chance break is an observation rather than an interception.

Stepping out of an exception is the harder half. The engine derives the enclosing handler’s address from the binary and sets a one-shot breakpoint there. Where that address cannot be proven — every try/finally, and every bare except on a 32-bit target — the step is refused with a reason instead of resuming somewhere plausible, on the same principle as everything else here: a wrong resume point is undetectable downstream.