硬件断点
调试寄存器
IA-32处理器定义了8个调试寄存器,分别是DR0\~DR7。在32位模式下,它们都是32位的;在64位模式下为64位。
在32位模式下,DR4与DR5是保留的,当调试扩展(DE)功能被启用,即CR4的DE位为1时,任何对DR4与DR5的引用都会导致一个非法指令异常;当DE被禁用时,DR4和DR5分别是DR6与DR7的别名寄存器,等价于访问后者。寄存器的内容图示如下:
- DR0\~DR3为调试地址寄存器,长度与CPU位数一致
- DR6为调试状态寄存器,64位时高32位保留未用
- DR7为32位的调试控制寄存器,64位下高32位保留未用
通过以上寄存器最多可以设置4个断点,其基本分工为:
- DR0\~DR3用来指定断点的内存地址或I/O地址
- DR6用于在调试事件发生时向调试器报告事件的详细信息
- DR7用于进一步定义断点的中断条件
调试地址寄存器
调试地址寄存器用于指定断点的地址,对于设置在内存空间中的断点,这个地址应该是线性地址而非物理地址,因为CPU是在线性地址被翻译为物理地址前来做断点匹配工作的。这意味着,在保护模式下,我们不能使用调试寄存器来针对一个物理内存地址来设置断点。
调试控制寄存器
在DR7寄存器中,有24位是被划分成4组,与4个调试寄存器相对应的控制位,如G0、R/W0
对应DR0
。下表为DR7各个位域的含义:
简称 | 全称 | 比特位 | 描述 |
---|---|---|---|
R/W0\~R/W3 | 读写域 | R/W0: 16\~17 R/W1: 20\~21 R/W2: 24\~25 R/W3: 28\~29 | 用来指定被监控地址的访问类型,其含义如下: 00:仅当执行对应地址的指令时中断 01:仅当向对应地址写数据时中断 10:当向相应地址进行I/O操作时中断 11:当向响应地址读写数据时中断,读指令除外。 |
LEN0\~LEN3 | 长度域 | LEN0: 18\~19 LEN1: 22\~23 LEN2: 26\~27 LEN3: 30\~31 | 用来指定要监控区域的长度,其含义如下:00:1Byte 01:2Bytes 10:8Bytes 11:Bytes |
L0\~L3 | 局部断点启用 | L1: 0 L1: 2 L2: 4 L3: 6 | 用来启用或禁止对应断点的局部匹配,如果该位设置为1,当CPU在当前任务中检测到满足所定义的断点条件时便中断,并自动清除此位。如果设置为0便禁止此断点。 |
G0\~G3 | 全部断点启用 | G0: 1 G1: 3 G2: 5 G3: 7 | 用来全局启用和禁止对应的断点,如果该位设置为1,当CPU在任何任务中检测到满足所定义的断点条件时都会中断;如果该位设置为0,便禁止此断点。当断点条件发生时不会自动清除此位。 |
LE和GE | 启用局部或者全局精确断点(Local and Global exact breakpoint Enable) | LE: 8 GE: 9 | 自486开始的IA-32处理器都将忽略这两位设置。此前该两位用来启用或禁止数据断点匹配,当设置有数据断点时,需要启用本设置,此时CPU会降低执行速度,以监视和保证当有指令要访问符合断点条件的数据时产生调试异常 |
GD | 启用访问检测(General Detect Enable) | 13 | 启用或禁止对调试寄存器的保护,当设置为1时,如果CPU检测到修改调试寄存器的指令,CPU在执行该指令前产生一个调试异常 |
调试控制寄存器的各个位域提供了多种选项,我们可以通过不同的位组合来定义各种调试条件。
R/W
R/W位是常用的断点访问类型指定位,即以何种方式访问地址寄存器中指定的地址时中断,以下是3类典型的使用方式:
- 读/写内存中的数据时中断,这种断点又称为数据访问断点(Data Access Breakpoint)。利用数据访问断点可以监控全局变量或局部变量的读写操作。比如在进行某些复杂的系统级调试或者多线程调试时,我们需要监控位于
0x12345678
的变量a
,在windbg中可以通过命令ba w4 0x12345678
来达成目的。ba
意为break on access
,w4 0x12345678
意为在以目的地址为起始的4字节内存区进行写操作时中断;同理将w4
替换成r4
可以获得读取断点。现代调试器支持复杂的条件断点,比如当某个变量等于某个值时中断,该操作也可以用数据访问断点实现:设置一个数据访问断点监视变量,每当这个变量的值发生改变时,CPU会通知调试器,由调试器检查变量的值,如果不满足条件则返回CPU继续执行,如果满足则中断到调试环境。 - 执行内存中代码时中断,这种断点又称为代码访问断点(Code Access Breakpoint)或指令断点(Instruction Breakpoint)。代码访问断点从实现上看与软件断点类似,都是当CPU执行指定地址的指令时开始中断,但是通过寄存器实现的硬件断点并不需要像软件断点一样插入断点指令。这个优点在某些情况下十分有用,比如调试位于ROM上的代码。此外,软件断点需要当目标代码被加载进内存后才可设置,而调试断点无此限制。
- 读写I/O端口时中断,又称为I/O访问断点(Input/Output Access Breakpoint),I/O访问断点对于调试使用输入输出的设备驱动程序十分有用,也可以利用I/O访问断点来监控对I/O空间的非法操作,提升系统的安全性。
长度域定义了要监视区域的长度,对于不同的断点访问类型,需要设置不同的的长度位。对于代码访问断点,长度位应设置为00,另外,地址寄存器应该指向指令的起始字节,也就是说,CPU只会用指令的起始字节来检查代码断点的匹配。对于数据和I/O访问断点,只要断点区域中任意一字节在被访问的范围内便会触发;同时这两种类型的断点还有边界对齐的特性:2字节区域按字对齐,4字节区域按双字对齐,8字节区域按4字对齐,CPU在检查断点匹配时会自动去除相应数量的最低位,因此如果地址没有按要求对齐可能无法设置正确的断点。比如希望将DR0
设置为0xA003
,LEN设为11
,实现对0xA003\~0xA006
区域的监控,在这种情况下,只有0xA003
被访问时才会触发断点,因为按双字对齐,CPU在检查地址匹配时会自动屏蔽起始地址的低两位,即0xA000
,后三个地址对齐后都是0xA004
,无法触发断点。
对于指令断点有一点需要特别注意:设置紧跟在单个SS寄存器操作的指令的断点永远不会被触发,因为系统要保证SS和EIP的一致性,在执行SS与ESP操作时会禁止执行所有中断和异常,直到执行完下一条指令。比如设置在第二行的断点永远不会被触发
MOV SS, EAX
MOV ESP, EBP
但是有三个及以上相邻的操作时,CPU只会保证对第一条指令采取中断禁止。设置在第三行的断点可以被触发
MOV SS, EDX
MOV SS, EAX
MOV ESP, EBP
IA-32手册推荐使用LSS
指令来加载SS和ESP寄存器的值,通过LSS
一条指令可以改变SS和EIP两个寄存器的值。
调试异常
IA-32架构专门分配了两个中断向量来支持软件调试(1、3),向量3用于处理#BP,向量1用于处理#DB。硬件断点产生的异常为#DB,所以当硬件断点发生时,CPU会执行1号向量对应的处理例程。
下面是各种导致调试异常的情况及该情况所产生的异常类型
异常情况 | DR6标志 | DR7标志 | 异常类型 |
---|---|---|---|
因为EFlags[TF]=1导致的单步异常 | BS=1 | 陷阱 | |
调试寄存器DRn和LENn定义的指令断点 | Bn=1 and (Gn=1 or Ln=1) | R/Wn=0 | 错误 |
调试寄存器DRn和LENn定义的写数据断点 | Bn=1 and (Gn=1 or Ln=1) | R/Wn=1 | 陷阱 |
调试寄存器DRn和LEn定义的I/O读写断点 | Bn=1 and (Gn=1 or Ln=1) | R/Wn=2 | 陷阱 |
调试寄存器DRn和LEn定义的数据读写断点 | Bn=1 and (Gn=1 or Ln=1) | R/Wn=3 | 陷阱 |
当DR7的GD位为1时,企图修改调试寄存器 | BD=1 | 错误 | |
TSS的T标志为1时进行任务切换 | 陷阱 |
对于错误类异常,因为恢复执行后断点条件仍然存在,为了避免反复发生异常,调试软件必须在使用IRETD
指令返回重新执行触发异常的指令前将标志寄存器的RF(Resume Flag)位设为1,告诉CPU不要在执行返回后的第一条指令时产生调试异常,CPU在执行完该指令后会清除该标志位。
调试状态寄存器
调试状态寄存器(DR6)的作用是当CPU检测到匹配断点条件的断点或有其他调试事件发生时,用来向调试器的断点异常处理程序传递断点的异常信息,以便调试器可以很容易地识别出发生的是什么调试事件。下表为调试状态寄存器位域的说明
简称 | 全称 | 比特位 | 描述 |
---|---|---|---|
B0 | Breakpoint 0 | 0 | 处理器检测到满足断点条件0的情况 |
B1 | Breakpoint 1 | 1 | 处理器检测到满足断点条件1的情况 |
B2 | Breakpoint 2 | 2 | 处理器检测到满足断点条件2的情况 |
B3 | Breakpoint 3 | 3 | 处理器检测到满足断点条件3的情况 |
BD | 检测到访问调试寄存器 | 13 | 与DR7的GD位联系,当GD位被设置为1而且CPU发现了要修改调试寄存器的指令时,CPU会停止执行这条指令并将BD位设置为1,然后将执行权交给调试异常处理程序 |
BS | 单步(Single Step) | 14 | 与标志寄存器的TF位相联系,如果该位为1,这表示异常是有单步执行模式触发的,与调试异常的其他情况相比,单步调试的优先级最高,因此当该标志为1时,其他标志可能也为1 |
BT | 任务切换(Task Switch) | 15 | 这一位与TSS中的T标志(Trap Flag)有关,当CPU执行任务切换时,如果发现下一个任务的TSS中T标志为1,则会设置BT位,并中断到调试中断处理程序 |
由于单步执行、硬件断点等多种情况触发的异常使用都使用的同一向量号,所以调试器需要使用调试状态寄存器来判断是什么原因引发的异常。一个数据的访问可能与多个断点定义的条件相匹配,这时CPU会设置多个调试状态寄存器的标志位来显示出所有匹配的断点。
硬件断点的设置方法
出于安全性考虑,只有在实模式或者保护模式的内核态下才能访问调试寄存器,否则会导致保护性异常。用户态调试器需要通过访问线程的上下文数据来间接访问调试寄存器。线程上下文结构用来保存线程的当前执行状态,在多任务系统中,当一个任务被挂起时,包括通用寄存器值在内的线程上下文信息会被保存起来,当该线程恢复执行时,保存的内容又会被恢复到寄存器中。以下是VS 2017中调试进程的栈帧信息,可以看到有明显的从用户态到内核态的转换
0 2925f790 77e0b43e ntdll!NtWaitForDebugEvent+0xc
01 2925f7a8 75aaf627 ntdll!DbgUiWaitStateChange+0x1e
02 2925f834 75aaf5f2 KERNELBASE!WaitForDebugEventWorker+0x31
03 2925f840 55be7a96 KERNELBASE!WaitForDebugEventEx+0x12
04 2925f8c0 55be7a38 vsdebugeng_impl!Win32BDM::CWin32Debug::WaitForDebugEvent+0x4b
05 2925f8d8 55be79ae vsdebugeng_impl!Win32BDM::CBaseDebugMonitor::CallWaitForDebugEvent+0x1e
06 2925f96c 75d40179 vsdebugeng_impl!Win32BDM::CBaseDebugMonitor::DebugLoop+0x73
07 2925f97c 77dc662d KERNEL32!BaseThreadInitThunk+0x19
08 2925f9d8 77dc65fd ntdll!__RtlUserThreadStart+0x2f
09 2925f9e8 00000000 ntdll!_RtlUserThreadStart+0x1b
下为用户态程序设置硬件断点的示例
#include<iostream>
#include<stdio.h>
#include<stdlib.h>
#include<Windows.h>
int main()
{
CONTEXT thread_context;//上下文结构体
HANDLE hThread = GetCurrentThread();//获取当前进程的句柄
DWORD test_var = 0;
if (!IsDebuggerPresent())//检测是否处于被调试状态
{
std::cout << "this program can only run in debugger!";
return E_FAIL;
}
thread_context.ContextFlags = CONTEXT_DEBUG_REGISTERS | CONTEXT_FULL;//设置线程上下文的类型
if (!GetThreadContext(hThread, &thread_context))//通过句柄获取进程的上下文
{
std::cout << "Failed to get thread context.";
return E_FAIL;
}
thread_context.Dr0 = (DWORD)& test_var;//将DR0设置为test_var的地址
thread_context.Dr7 = 0xF0001;//F代表4字节读写访问断点,01代表启用DR0断点
if (!SetThreadContext(hThread, &thread_context))//将线程上下文应用于句柄所指向的线程
{
std::cout << "Failed to set thread context";
return E_FAIL;
}
test_var = 1;//修改test_var的值,触发断点
GetThreadContext(hThread, &thread_context);//获取修改后的线程上下文
printf("DR6:%x",thread_context.Dr6);
return S_OK;
}
单击运行,在test_var
后一行触发了调试异常
触发断点的位置后移一行的原因为数据访问断点导致的调试异常是陷阱类异常,当触发异常时指令已经执行完成,与此类似,INT 3
引发的也是陷阱类异常,但是系统会进行特殊处理以回到上一条指令的位置。对于错误类异常,系统会返回到指令执行前的状态,这样对于某些错误类异常可以通过异常处理例程纠正错误来继续执行。在windbg下的调试信息如下:
(3dd0.2e2c): Single step exception - code 80000004 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=00000001 ebx=003ba000 ecx=00000000 edx=00000000 esi=004ff2a4 edi=004ff720
eip=010f26e4 esp=004ff2a4 ebp=004ff720 iopl=0 nv up ei pl nz na po nc
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000202
Breakpoints!main+0x124:
010f26e4 8bf4 mov esi,esp
归纳
硬件断点不需要像软件断点一样插入指令,而是通过CPU的寄存器实现。硬件断点有许多优点,但是也有诸多不足,其中最明显的就是数量限制。在多处理器系统中,硬件断点是与CPU相关的,针对一个CPU设置的硬件断点并不适用于其他CPU。
关于变量监视与数据断点,虽然可以使用调试寄存器来实现,但并非所有调试器的实现都基于调试寄存器。有些调试器(如VC6)设置并启用数据断点后,调试器会记录下每个变量的值,然后以单步的方式恢复程序执行,这样被调试在执行一条指令后会因为调试异常中断到调试器,调试器收到调试事件后会检查变量的值是否发生改变,如果没有改变则设置单步标志继续执行。由于以这种方式实现的数据断点功能不是通过调试寄存器实现的,因此没有数量限制,但是效率底下,会拖慢被调试程序的运行速度。
文章评论