Introduction
Single-step breakpoints are fundamental debugging mechanism that allows execution to pause after every single instruction. Unlike hardware breakpoints which trigger at a specific address, single-step mode traces every instruction sequentially.
In this post, we will see these breakpoints in detail with NASM + QEMU.
Single-Step Breakpoints and Trap Flag
A debugger can determine single-step breakpoints with DR6 register. DR6 register holds BS (Single-Step Bit) and a debugger can use this bit. BS is 14 bit of DR6 register:
But that's not all. This bit just shows the breakpoint is triggered in 'single-step progress'. We can't change if we want 'single-step'.
TF (Trap Flag) is the real mechanism behind single-step execution. It lives at bit 8 of the RFLAGS register and cannot be set directly with a MOV instruction. Instead, we manipulate it indirectly through the stack.
When a #DB exception occurs, the CPU pushes the current RFLAGS onto
the stack as part of the exception frame. This saved copy is what IRETQ will restore when returning from the handler. By modifying bit 8 of this saved RFLAGS before executing IRETQ, we control whether single-step continues after the handler returns.
The CPU automatically clears TF before delivering the #DB exception. This is by design — without this behavior, the handler itself would be single-stepped, causing an infinite recursive exception loop. By clearing TF on entry and only restoring it in the saved RFLAGS, we ensure single-step applies only to the code being debugged, not to the handler.
Simple Project in NASM
Here's an example from my NASM Project:
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 rbx, dr6
; Start single-step progress
test rbx, (1 << 0)
jnz .hw_bp_hit
; BS set → single-step fired
; After the first execution of #DB (with TF=1),
; CPU will set BS bit of DR6
test rbx, (1 << 14)
jnz .single_step
jmp Exit
.hw_bp_hit:
xor rax, rax
mov dr6, rax
xor rax, rax
mov dr7, rax
; Enable TF flag and exit (CPU will generate #DB again)
or qword [rsp + 136], (1 << 8)
jmp Exit
.single_step:
; In each single-step, write the value of RIP
mov rax, [rsp + 120]
call serial_hex64
dec qword [step_count]
jz .stop_stepping
; Pass 1 to TF (for Single-Step)
or qword [rsp + 136], (1 << 8)
jmp Exit
.stop_stepping:
; Pass 0 to stop
and qword [rsp + 136], ~(1 << 8)
xor rax, rax
mov dr6, rax
jmp Exit
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
The idea is simple: In the first execution of #DB exception, we set TF flag to 1 and exit from the handler. Then CPU generates #DB exception with BS=1. In the handler, we check this bit:
; After the first execution of #DB (with TF=1),
; CPU will set BS bit of DR6
test rbx, (1 << 14)
jnz .single_step
In the .single-step, we just print the value of RIP, decrease the step_count and pass 1 to TF flag:
.single_step:
; In each single-step, write the value of RIP
mov rax, [rsp + 120]
call serial_hex64
; step_count = 0x5
dec qword [step_count]
jz .stop_stepping
; Pass 1 to TF (for Single-Step)
or qword [rsp + 136], (1 << 8)
jmp Exit
As we can see, the idea is simple. Here's the result:
I assigned the value 0x5 to the step_count variable, so each step is performed based on this value. Also my breakpoint is based on DebugTarget function:
DebugTarget:
mov rax, 0x1
mov rbx, 0x2
add rax, rbx
mov rcx, rax
mov rdx, 0x0
ret


Top comments (0)