DEV Community

bekoo
bekoo

Posted on

[My Debugger Journey] Working on Debug Registers with NASM

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)