Debug Registers
Both X86 and X64 architectures provide debug registers. In the processor, the debug registers can be accessed through DR0 - DR7 registers and also through MSR's.
Debug registers are different from another registers. Debug registers hold the address of memory and I/O locations - or we can say them 'breakpoints' and we can set a breakpoint with these registers. To put it more simply,
Here's a figure from the intel's volume:
Especially, in our case, DR6 and DR7 are really important. We will see them in detail.
- DR0–DR3 Registers: These are the breakpoint address registers. Each holds a 64-bit linear address defining one hardware breakpoint slot. The processor supports up to four concurrent hardware breakpoints, one per slot. These registers are read/write and can be updated at any privilege level when CR4.DE is clear, or restricted to ring 0 when it is set.
- DR4 and DR5 Registers These registers are legacy aliases. When CR4.DE is clear, accessing DR4 or DR5 silently redirects to DR6 and DR7 respectively. When CR4.DE is set, the access raises an #UD exception. In practice, DR4 and DR5 should be considered non-existent on modern systems.
- DR6 Register: DR6 is the debug status register. The processor writes to it when a #DB exception fires, encoding the reason for the exception. Software reads DR6 to determine which breakpoint triggered and why. Critically, the processor never clears DR6 — the exception handler is responsible for zeroing it before returning, otherwise stale bits from a previous exception can cause misdiagnosis.
- DR7 Register: DR7 is the debug control register. It arms or disarms each of the four breakpoint slots and defines, per slot, two properties: the trigger condition (instruction execution, data write, or data read/write) and the operand width to match (1, 2, 4, or 8 bytes). DR7 is the primary interface through which a debugger configures hardware breakpoints.
Detecting Breakpoints
When a memory address is written to DR0–DR3, the processor halts execution upon reaching it — not through software polling, but via a dedicated hardware comparator circuit operating autonomously at the silicon level.
The internal design of this circuit is not publicly documented in modern processors, but Intel's US Patent 5,694,589 (filed 1995, granted 1997) provides a detailed architectural description of its Pentium-era implementation, and serves as the primary reference for this section.
The patent addresses a scaling problem specific to superscalar execution. When multiple instructions are decoded in parallel each clock cycle, the conventional design requires one comparator per decode pipeline — each performing the identical comparison against DR0–DR3. The patent resolves this by moving the comparison upstream to the instruction fetch stage, where a single comparator unit covers all decode pipelines regardless of dispatch width.
In the patent, figure 2 shows the problem:
FIG. 2 lays out the problem directly. Each decode pipeline — ID₁ through IDₙ — carries its own breakpoint detection block, each one housing M comparators wired against DR0–DR3. To make this concrete: the IFU fetches a 16-byte aligned block from the instruction cache. That block is handed to the Instruction Length Decoder, which splits it into individual instructions and distributes them across N decode pipelines. Each pipeline then performs its own independent comparison:
Fetch block @ 0x00401000 (16 bytes)
┌──────────────────────────────────────────────┐
│ instr₁ instr₂ instr₃ ... instrₙ│
│ 0x00401000 0x00401003 0x00401006 ... │
└──────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
BPDET₁ BPDET₂ BPDET₃
0x00401000 0x00401003 0x00401006
== DR0–DR3? == DR0–DR3? == DR0–DR3?
For a processor dispatching N instructions per cycle, that means N identical comparison circuits firing every cycle against the same four registers. The hardware cost scales with dispatch width, not with any architectural necessity.
Figure 3 in the patent shows the invention:
FIG. 3 eliminates the redundancy at its source. Block 200, the breakpoint detection logic, is pulled out of the decode stage and repositioned adjacent to the IFU, operating directly on the Fetch IP. The comparison happens once, upstream, before the instruction stream has been split across decode pipelines. The patent does this by comparing only the upper 28 bits of the Fetch IP against DR0–DR3 — the lower 4 bits are then expanded through a 4:16 decoder to identify which byte within the 16-byte fetch block carries the breakpoint:
DR0 = 0x00401000
├── upper 28-bit ──┤ ├─ lower 4 ─┤
0x0040100 0x0
Fetch IP = 0x00401000
├── upper 28-bit ──┤
0x0040100 → match
Lower 4-bit = 0x0 → 4:16 decoder → bit 0
│
▼
0x00401000 is the first byte
of the fetch block → #DB
before decode pipelines
A MUX sits between the detection block and the Instruction Length Decoder — if the fetch address matches a debug register, the signal is resolved there. The decode pipelines in FIG. 3 have no breakpoint logic under them at all.
To put it more simply, the address written to DR0–DR3 is not a hint or a flag — it is a direct input to the comparator circuit. The moment mov dr0, rax executes, that address is live in hardware. Every subsequent fetch is checked against it.
The IFU fetches a 16-byte aligned block from the instruction cache. The Instruction Length Decoder determines the boundary of each instruction within that block — x86 instructions are variable-length, anywhere from 1 to 15 bytes — and distributes them across the decode pipelines. Each pipeline takes one instruction and converts it from binary opcode into micro-operations:
Fetch block @ 0x00401000:
[48 89 C8] [48 83 EC 08] [FF D0]
3 byte 4 byte 2 byte
ID₁ → 0x00401000 → mov rax, rcx
ID₂ → 0x00401003 → sub rsp, 8
ID₃ → 0x00401007 → call rax
In FIG. 2, each pipeline checks its own instruction address against DR0–DR3 independently. ID₁ compares 0x00401000, ID₂ compares 0x00401003, ID₃ compares 0x00401007 — the same four registers, checked N times per cycle.
In FIG. 3, the check is pulled upstream to the fetch boundary. The upper 28 bits of the Fetch IP are compared against DR0–DR3 before the block reaches the length decoder. The lower 4 bits identify which byte within the 16-byte block is armed, resolved through a 4:16 decoder:
DR0 = 0x00401003
├─ upper 28-bit ─┤ ├─ lower 4 ─┤
0x0040100 0x3
Fetch IP = 0x00401000
├─ upper 28-bit ─┤
0x0040100 → match
Lower 4-bit = 0x3 → 4:16 decoder → bit 3
│
▼
0x00401003 is the fourth byte
of the fetch block → #DB
before decode pipelines
By the time an instruction reaches any decode unit, the hardware has already decided whether that address is armed. The #DB is not generated at dispatch or at execution — it is generated at fetch. The instruction never advances further. No instruction is modified, no execution path is altered, no software layer is consulted. The comparator either matches or it does not, and that determination is made before anything downstream has a chance to observe the instruction at all.
Of course, things are quite different with modern processors today, but I’ve used this resource solely as a reference to help us understand the basic background.
Generating #DB Exception
CPU generates #DB when a breakpoint is triggered. This #DB, in fact, is an exception from IDT (Interrupt descriptor table).
When the CPU encounters a breakpoint condition — whether from a hardware debug register match, a single-step TF flag, or a data watchpoint — it vectors through IDT vector 1 to the #DB handler. The descriptor at IDT[1] holds the gate type, DPL, and the handler's segment:offset. Before transferring control, the processor pushes the exception frame onto the stack: RFLAGS, CS, RIP, and — since #DB is a fault/trap hybrid — optionally an error code, though #DB itself carries none.
What makes #DB interesting architecturally is its dual nature. It is simultaneously a fault (for instruction breakpoints: RIP points at the faulting instruction) and a trap for the breakpoins.
Adding our Handler into IDT
Here's nasm assembly project that adds our handler into IDT[1]:
IDT_BASE equ 0x10000
idt_limit: dw 0
idt_base_addr: dq 0
setup_idt:
; Clear the IDT
mov rdi,IDT_BASE
mov rcx,(256 * 16) / 8
xor rax,rax
rep stosq
mov rcx,256
mov rdi,IDT_BASE
.fill:
call .write_gate
add rdi,16
loop .fill
; Now we can write our handle to IDT
mov rdi,IDT_BASE + (1 * 16)
mov rax,DbHandler
call .write_gate_rax
mov word [idt_limit], (256 * 16) - 1
mov qword [idt_base_addr], IDT_BASE
lidt [idt_limit]
ret
.write_gate:
push rax
mov rax, CatchAll
call .write_gate_rax
pop rax
ret
.write_gate_rax:
push rbx
push rax
mov rbx, rax
mov [rdi+0], bx ; offset[15:0]
mov word [rdi+2], 0x18 ; code64 selector
mov byte [rdi+4], 0x00 ; IST=0
mov byte [rdi+5], 0x8E ; P=1 DPL=0 64-bit interrupt gate
shr rbx, 16
mov [rdi+6], bx ; offset[31:16]
shr rbx, 16
mov [rdi+8], ebx ; offset[63:32]
pop rax
pop rbx
ret
Here's a diagram for this project:
After clearing the IDT and filling all 256 entries with the catch-all handler, the code installs the actual #DB handler at IDT[1] — vector 1 being the architecturally reserved slot for debug exceptions:
mov rdi, IDT_BASE + (1 * 16)
mov rax, DbHandler
call .write_gate_rax
Each IDT entry in 64-bit mode is 16 bytes. The * 16 offset lands rdi exactly at the second descriptor slot.
The .write_gate_rax routine constructs the descriptor manually, field by field, conforming to the 64-bit interrupt gate format defined in the SDM Vol. 3A 6.14:
mov [rdi+0], bx ; offset[15:0]
mov word [rdi+2], 0x18 ; segment selector
mov byte [rdi+4], 0x00 ; IST = 0
mov byte [rdi+5], 0x8E ; type/attr byte
shr rbx, 16
mov [rdi+6], bx ; offset[31:16]
shr rbx, 16
mov [rdi+8], ebx ; offset[63:32]
The handler's 64-bit address is split across three non-contiguous fields — [0:1], [6:7], [8:11] — because the descriptor format inherited its layout from the original protected mode design and extended it in place rather than redesigning it.
0x18 at offset +2 is the code segment selector. In a typical GDT layout this is the third descriptor (index 3, 3 * 8 = 0x18), a 64-bit code segment. The processor loads CS from this field on gate entry, which determines the privilege level and addressing mode of the handler.
Lastly, LIDT expects a 10-byte memory operand: a 2-byte limit followed immediately by an 8-byte linear base address:
mov word [idt_limit], (256 * 16) - 1
mov qword [idt_base_addr], IDT_BASE
lidt [idt_limit]
The limit is always (n * 16) - 1 for n descriptors — off-by-one because the limit is the last valid byte offset, not the count. If the CPU receives a vector whose offset exceeds the limit, it generates a #GP(vector*8+2) rather than dispatching the handler, so undersizing the limit is a hard fault condition.
Triggering a Breakpoint with DR7 Register
DR6 and DR7 registers are the major player in a debugger. If you want to set a condition for a breakpoint, you need to use DR7. If you check the result of a trigger, you need to check the result of DR6.
There are many condition to trigger a breakpoint. Here are them:
- Execution breakpoint (fetch): The processor triggers #DB when the instruction pointer reaches the address stored in a DR slot. This is the closest hardware equivalent to a traditional software breakpoint (int3), with one critical difference: the target instruction is never modified. The condition code for this type is 00b in the DR7 condition field.
- Data write breakpoint: The processor monitors the specified address for write operations. When any instruction writes to that address — regardless of which register or addressing mode is used — #DB fires after the write completes. Condition code: 01b. I/O read/write breakpoint. When CR4.DE is set, the processor can watch a specific I/O port address. Any IN or OUT instruction targeting that port raises #DB. This condition is only available when CR4.DE is enabled; without it, the encoding is reserved. Condition code: 10b
- Data read/write breakpoint. The broadest condition. #DB fires on any read or write to the watched address, whether the access is a load, store, or read-modify-write. Instruction fetches are explicitly excluded — the processor does not trigger on fetch even if the instruction pointer passes through the watched region. Condition code: 11b.
- One constraint applies across all conditions: The breakpoint address in DR0–DR3 must be aligned to the access width defined in DR7. A 4-byte watchpoint on an unaligned address produces undefined behavior on some microarchitectures and silently fails to trigger on others.
We can also benefit from the intel's volume:
Using DR7 in NASM Assembly
We can simply trigger a breakpoint with NASM Assembly:
BITS 64
lmode64:
; [...]
call setup_idt
mov rsi,msgIDTOK
call serial_print
; Write DebugTarget function to DR0
mov rax,DebugTarget
mov dr0,rax
; Add 0x00000001 value to DR7 (Execution)
mov rax,0x00000001
mov dr7,rax
; Trigger the breakpoint
call DebugTarget
hlt
As you can see, we just configure the IDT table, write the function address (DebugTarget) to DR0 register, prepare condition for the breakpoint in DR7 register. 0x00000001 means "trigger the breakpoint if the address is executed".
My DbHandler:
msgHello db "I love bare-metal coding :>", 13, 10, 0
DbHandler:
push rax
push rbx
push rcx
push rdx
push rsi
push rdi
push rbp
push r8
push r9
push r10
push r11
push r12
push r13
push r14
push r15
mov rsi,msgHello
call serial_print
xor rax,rax
mov dr7,rax
Exit:
pop r15
pop r14
pop r13
pop r12
pop r11
pop r10
pop r9
pop r8
pop rbp
pop rdi
pop rsi
pop rdx
pop rcx
pop rbx
pop rax
iretq
In the handler, just a message is printed and reset the value of DR7 register.
Here's the result:
This is simple using of DR7 register.
Setting the Writing Condition to a Breakpoint
We can continue to take advantage of the DR7's features.
DR7 isn't a single-bit register. The 0x00000001 we used earlier is the minimal configuration — it only enables the local breakpoint condition for DR0.
Each debug register (DR0–DR3) has its own enable bit pair and condition field. This means four independent breakpoints can be active simultaneously, each with a different address, type, and access width.
- Condition Encoding (R/W bits): The R/W field for each breakpoint slot controls what kind of access triggers the exception:
; R/W = 00 → execution breakpoint (instruction fetch)
; R/W = 01 → write watchpoint
; R/W = 10 → I/O read/write (CR4.DE must be set)
; R/W = 11 → read/write watchpoint (not on execution)
- LEN Fields: For execution breakpoints, LEN must always be 00. For data watchpoints, LEN encodes the access width:
LEN = 00 → 1 byte
LEN = 01 → 2 bytes
LEN = 10 → 8 bytes (64-bit mode only)
LEN = 11 → 4 bytes
Now here's an example:
watch_var: dd 0, 0
BITS 64
lmode64:
; [...]
call setup_idt
mov rsi,msgIDTOK
call serial_print
; DR1 = write watchpoint on a data address
mov rax,watch_var
mov dr1, rax
; DR7 encoding:
; L0=1 (enable DR0 local), R/W0=00, LEN0=00 → bits [0], [17:16], [19:18]
; L1=1 (enable DR1 local), R/W1=01, LEN1=11 → bits [2], [21:20], [23:22]
;
; L0 = bit 0 = 1
; L1 = bit 2 = 1
; R/W1 = bits [21:20] = 01
; LEN1 = bits [23:22] = 11
;
; 0000 0000 1101 0000 0000 0000 0000 0101
mov rax, 0x00D00005
mov dr7, rax
; Now trigger the breakpoint
mov rax,0x1
mov qword [watch_var],rax
hlt
The flow is same. We prepare DR1 and DR7 registers and trigger the breakpoint. Here's the result:
Conclusion
The examples covered here represent only the surface of what the debug register architecture exposes. DR0–DR3 accept any linear address — code, data, or I/O — and DR7 encodes the full condition space: execution, write, read/write, access width, local and global scope, all independently configurable per register. The hardware does the rest without kernel involvement, without modifying the target code, and without observable overhead.
For anyone going deeper, Intel's Software Developer's Manual Volume 3, Chapter 19 (CHAPTER 19 - DEBUG, BRANCH PROFILE, TSC AND INTEL® RESOURCE DIRECTOR) is the authoritative reference — it covers the full DR7 encoding, DR6 status semantics, interaction with single-step mode, and the edge cases that matter when building anything serious on top of this mechanism.







Top comments (0)