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
INT3bytes in a live process and produces a much later, entirely mystifyingExternal 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;
readWritetherefore also fires on writes, and aread-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:
-
Thread-name announcements are consumed first.
TThread.NameThreadForDebuggingraises exception code0x406D1388purely 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. -
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,logStackorbreak— is the decision. -
If no rule matches, the filters decide —
delphi,av,all,unhandled. Filters are the fallback, not a parallel mechanism.
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.