Working on Single-Step Breakpoints in a Debugger
Working on Single-Step Breakpoints in a Debugger
在调试器中实现单步断点
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.
简介
单步断点是一种基础的调试机制,允许程序在执行完每一条指令后暂停。与在特定地址触发的硬件断点不同,单步模式会按顺序跟踪每一条指令。在这篇文章中,我们将结合 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.
单步断点与陷阱标志 (Trap Flag)
调试器可以通过 DR6 寄存器来确定单步断点。DR6 寄存器包含 BS(单步位),调试器可以使用该位。BS 是 DR6 寄存器的第 14 位。但这还不是全部,该位仅表示断点是在“单步执行过程中”触发的,我们无法通过它来开启单步模式。TF(陷阱标志)才是单步执行背后的真正机制。它位于 RFLAGS 寄存器的第 8 位,不能直接通过 MOV 指令设置。相反,我们通过栈间接操作它。
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.
当 #DB 异常发生时,CPU 会将当前的 RFLAGS 推入栈中,作为异常帧的一部分。当从处理程序返回时,IRETQ 指令会恢复这个保存的副本。通过在执行 IRETQ 之前修改栈中保存的 RFLAGS 的第 8 位,我们可以控制处理程序返回后是否继续单步执行。CPU 在触发 #DB 异常前会自动清除 TF 位。这是设计使然——如果没有这种行为,处理程序本身也会被单步执行,从而导致无限递归异常循环。通过在进入时清除 TF 并仅在保存的 RFLAGS 中恢复它,我们确保单步执行仅应用于被调试的代码,而不是处理程序本身。
Simple Project in NASM
Here’s an example from my NASM Project:
NASM 简单项目示例
以下是我 NASM 项目中的一个示例:
DbHandler:
push rax ... 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 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. In the .single-step, we just print the value of RIP, decrease the step_count and pass 1 to TF flag.
其核心思想很简单:在第一次执行 #DB 异常时,我们将 TF 标志设置为 1 并退出处理程序。随后 CPU 会生成一个 BS=1 的 #DB 异常。在处理程序中,我们检查该位。在 .single-step 部分,我们只需打印 RIP 的值,减少 step_count,并将 1 传给 TF 标志。
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:
正如我们所见,思路很简单。结果如下:我将 0x5 赋值给 step_count 变量,因此每一步都基于此值执行。此外,我的断点是基于 DebugTarget 函数的:
DebugTarget:
mov rax, 0x1
mov rbx, 0x2
add rax, rbx
mov rcx, rax
mov rdx, 0x0
ret