引言
本文讲解 Windows 平台 C++ 调试器的核心实现流程。涵盖通过 CreateProcess 和 DebugActiveProcess 启动或附加调试会话,利用 WaitForDebugEvent 循环捕获调试事件。重点解析软件断点(int 3)、硬件断点(DR 寄存器)、单步调试(TF 标志)及内存访问断点的底层机制与异常处理逻辑。介绍如何获取寄存器、反汇编信息及栈数据,并结合 Sym 系列 API 加载调试符号以增强可读性。最后总结调试器框架的三层架构设计,包括事件分发、异常修复及用户交互模块。
一、调试器的实现
1.1 创建进程进行调试
bool Open(const char* pszFile) {
if (pszFile == nullptr) return false;
BOOL bRet = FALSE;
STARTUPINFOA stcStartupInfo = { sizeof(STARTUPINFOA) };
PROCESS_INFORMATION stcProcInfo = { 0 };
bRet = CreateProcessA(
pszFile,
NULL,
NULL,
NULL,
FALSE,
DEBUG_ONLY_THIS_PROCESS | CREATE_NEW_CONSOLE,
NULL,
NULL,
&stcStartupInfo,
&stcProcInfo
);
return bRet;
}
1.2 附加进程进行调试
bool Open(DWORD dwPid) {
return DebugActiveProcess(dwPid);
}
1.3 等待调试事件
void DebugEventLoop() {
DEBUG_EVENT dbgEvent = { 0 };
DWORD dwRetCode = DBG_CONTINUE;
while (true) {
if (!WaitForDebugEvent(&dbgEvent, INFINITE)) return;
switch (dbgEvent.dwDebugEventCode) {
case EXCEPTION_DEBUG_EVENT:
printf("异常事件,异常代码:%08X\n", dbgEvent.u.Exception.ExceptionRecord.ExceptionCode);
break;
case CREATE_PROCESS_DEBUG_EVENT:
printf("进程创建\n");
break;
case CREATE_THREAD_DEBUG_EVENT:
printf("线程创建\n");
break;
case EXIT_PROCESS_DEBUG_EVENT:
printf("进程退出\n");
return;
case EXIT_THREAD_DEBUG_EVENT:
printf("线程退出\n");
break;
case LOAD_DLL_DEBUG_EVENT:
printf("DLL 加载\n");
break;
case UNLOAD_DLL_DEBUG_EVENT:
();
;
OUTPUT_DEBUG_STRING_EVENT:
();
;
}
(dbgEvent.dwProcessId, dbgEvent.dwThreadId, dwRetCode);
}
}
下面是需要注意的关键信息:
WaitForDebugEvent 函数的第二个参数代表超时时间,单位是毫秒。如果传入 -1 或者 INFINITE,就意味着会进行无限等待。
DEBUG_EVENT 结构体包含了三方面的信息:
- 调试事件代码,例如
EXCEPTION_DEBUG_EVENT;
- 产生该事件的进程 ID 和线程 ID;
- 事件的具体信息,这些信息存于联合体
u 中。不同的事件对应不同的字段,比如 CREATE_PROCESS_DEBUG_EVENT 对应的字段是 u.CreateProcessInfo。
1.4 处理调试事件
调试事件处理核心逻辑
调试器的核心功能围绕调试事件处理展开,其中:
简单调试事件:包括进程/线程的创建与退出、DLL 加载与卸载、调试字符串输出等,主要用于收集被调试进程信息。
示例:
进程创建事件触发时,可从 CREATE_PROCESS_DEBUG_INFO 结构体中获取进程的 OEP(入口点)地址,以便对 OEP 设置断点。
EXCEPTION_DEBUG_EVENT 异常事件:是调试功能的核心驱动事件。例如,用户按下 F11(单步步入)时,调试器将目标线程的 TF 标志位(陷阱标志)置 1,触发以下流程:
TF 置 1 → 执行代码 → CPU 产生单步中断 → 调用 IDT(中断描述符表)函数 → 操作系统分发异常 → 调试子系统发送调试事件 → 调试器捕获 EXCEPTION_DEBUG_EVENT → 显示反汇编信息
因此,调试器的核心处理逻辑集中于异常事件。
异常事件分类与处理原则
异常事件分为两类,调试器必须明确区分并差异化处理:
(1)处理被调试进程自身产生的异常
触发场景:进程代码存在 bug(如缓冲区溢出、访问空指针等)引发的异常。
处理逻辑:
- 此类异常属于进程运行时错误,调试器无法直接修复(部分进程自备异常处理程序)。
- 调试器需将异常透传给进程处理,调用
ContinueDebugEvent() 时传入 DBG_EXCEPTION_NOT_HANDLED,避免干扰进程自身逻辑。
(2)处理调试器主动制造的异常
触发场景:调试器为控制进程执行流主动生成的异常,包括:
- 设置 TF 标志位(单步调试);
- 写入
int 3 指令(软件断点);
- 使用调试寄存器(硬件断点)。
副作用与处理流程:
- 异常触发后:调试器需先清除主动设置的断点或标志位(如恢复被修改的内存字节、重置 TF 标志),避免对进程产生持续影响。
- 用户交互:清除副作用后,向用户展示反汇编信息、寄存器状态等,并等待用户指令(如继续执行、单步调试)。
- 后续处理:不同类型的异常需匹配特定的清除逻辑(如软件断点需恢复原始机器码,硬件断点需清空调试寄存器),具体实现将在后续章节详细讨论。
1.5 系统断点
调试器创建被调试进程时,系统会依次发送进程创建、模块加载等一系列调试事件。当所有事件处理完毕后,系统会发送一个异常代码为 EXCEPTION_BREAKPOINT 的 EXCEPTION_DEBUG_EVENT 异常事件,该事件标志着被调试进程已完成初始化准备就绪,此事件被称为系统断点。调试器必须在接收到系统断点后,才能安全地对被调试进程设置断点。
1.6 获取调试信息
(1)获取寄存器信息
当调试事件产生时,DEBUG_EVENT 结构体中会保存触发该事件的进程 ID 和线程 ID。借助这些 ID,可获取对应的线程句柄。获得线程句柄后,便能通过调用 GetThreadContext 函数获取线程的环境块(即寄存器信息)。
CONTEXT ct = { 0 };
ct.ContextFlags = CONTEXT_ALL;
if (GetThreadContext(hThread, &ct)) {
}
(2)获取反汇编信息
反汇编信息可通过反汇编引擎将机器码转换而来,而机器码必须来源于被调试进程,且需翻译的机器码不能随意获取,必须是当前 EIP 指向地址的机器码。因此,可通过以下步骤实现:
- 获取线程环境块:通过线程句柄调用
GetThreadContext,获取包含 EIP 寄存器的线程环境信息。
- 读取目标内存:使用
ReadProcessMemory,根据 EIP 指向的地址读取进程内存中的机器码数据。
- 反汇编转换:将读取的机器码传入反汇编引擎(如 XED、Capstone),转换为可读性的汇编指令。
关键要点:确保操作的内存地址属于被调试进程的合法空间,且调试器具备相应的内存读取权限。
CONTEXT ct = { 0 };
ct.ContextFlags = CONTEXT_CONTROL;
if (GetThreadContext(hThread, &ct)) {
BYTE buff[512];
DWORD dwRead = 0;
if (ReadProcessMemory(hProcess, (LPCVOID)ct.Eip, buff, sizeof(buff), &dwRead)) {
}
}
(3)获取栈信息
栈数据存储于一段连续的内存空间,其基地址由寄存器 ESP(栈指针寄存器) 保存。若需查看栈信息,可按以下步骤操作:
- 获取线程环境块:通过调用
GetThreadContext 函数,获取包含 ESP 寄存器值的线程上下文信息。
- 定位栈内存地址:从线程环境块中提取 ESP 寄存器的值,作为栈空间的起始内存地址。
- 读取栈数据:使用
ReadProcessMemory 函数,以 ESP 地址为起点,读取目标进程栈内存中的数据。
注意事项:需确保调试器具备访问目标进程内存的权限,且读取的内存地址在进程合法地址空间内,避免触发内存访问异常。
CONTEXT ct = { 0 };
ct.ContextFlags = CONTEXT_CONTROL;
if (GetThreadContext(hThread, &ct)) {
BYTE buff[512];
DWORD dwRead = 0;
if (ReadProcessMemory(hProcess, (LPCVOID)ct.Esp, buff, sizeof(buff), &dwRead)) {
for (int i = 0; i < 10; ++i) {
printf("%08X\n", ((DWORD*)buff)[i]);
}
}
}
1.7 实现单步断点(TF 标志位)
单步断点实现原理
单步断点通过 CPU 标志寄存器中的 TF 标志位(陷阱标志)实现,核心逻辑为:
- 设置 TF 标志:将线程环境块中的 TF 标志位设为 1,CPU 每执行完一条指令后触发
EXCEPTION_SINGLE_STEP 异常。
- 多线程处理:若被调试进程包含多个线程,需为所有线程设置 TF 标志位,或仅在当前触发异常的线程中设置,避免因线程环境独立导致断点失效。
标志寄存器 EFLAGS 位段定义
typedef struct _EFLAGS {
unsigned CF : 1;
unsigned Reserve1 : 1;
unsigned PF : 1;
unsigned Reserve2 : 1;
unsigned AF : 1;
unsigned Reserve3 : 1;
unsigned ZF : 1;
unsigned SF : 1;
unsigned TF : 1;
unsigned IF : 1;
unsigned DF : 1;
unsigned OF : 1;
unsigned IOPL : 2;
unsigned NT : 1;
unsigned Reserve4 : 1;
unsigned RF : 1;
unsigned VM : 1;
unsigned AC : 1;
VIF : ;
VIP : ;
ID : ;
Reserve5 : ;
} REG_EFLAGS, *PREG_EFLAGS;
代码实现:设置单步断点函数
void SetSingleStepBreakpoint(HANDLE hThread) {
CONTEXT ct = { 0 };
ct.ContextFlags = CONTEXT_CONTROL;
if (GetThreadContext(hThread, &ct)) {
PREG_EFLAGS pEflags = reinterpret_cast<PREG_EFLAGS>(&ct.EFlags);
pEflags->TF = 1;
SetThreadContext(hThread, &ct);
}
}
1.8 实现软件断点(int 3 指令,0xCC)
软件断点实现原理
软件断点通过 CPU 的 int 3 指令实现,该指令的机器码为 0xCC。其核心逻辑为:
- 安装断点:在目标地址写入
0xCC,替换原指令的第一个字节。
- 触发异常:CPU 执行
0xCC 时触发 EXCEPTION_BREAKPOINT 异常(陷阱异常),调试器捕获该事件。
- 恢复现场:调试器处理异常后,将原字节写回目标地址,并调整指令指针(EIP 减 1)以跳过
int 3 指令。
陷阱异常特性:int 3 指令执行后才会触发异常,因此异常发生时 EIP 已指向 int 3 的下一条指令,需手动将 EIP 减 1 以定位到实际断点地址。
软件断点的副作用与处理
处理步骤:
- 下断前读取目标地址的 1 字节原始数据并保存。
- 写入
0xCC 触发异常。
- 异常处理时恢复原始字节,并将 EIP 减 1 以修正执行流。
代码实现:软件断点操作函数
(1)设置软件断点
BOOL SetSoftwareBreakpoint(HANDLE hProcess, LPVOID pAddress, BYTE* oldByte) {
DWORD dwSize = 0;
if (!ReadProcessMemory(hProcess, pAddress, oldByte, 1, &dwSize)) {
return FALSE;
}
BYTE cc = 0xCC;
if (!WriteProcessMemory(hProcess, pAddress, &cc, 1, &dwSize)) {
return FALSE;
}
return TRUE;
}
(2)移除软件断点
BOOL RemoveSoftwareBreakpoint(HANDLE hProcess, LPVOID pAddress, BYTE oldByte) {
DWORD dwSize = 0;
return WriteProcessMemory(hProcess, pAddress, &oldByte, 1, &dwSize);
}
1.9 实现硬件断点(调试寄存器 DR0~DR3)
硬件断点实现原理
硬件断点通过设置调试寄存器实现,需将以下信息写入对应寄存器:
- 断点地址:存储于
DR0~DR3 寄存器(每个寄存器对应一个断点)。
- 断点长度:通过
DR7 的 LENO~LEN3 位段设置(每个位段占 2 位,取值 0~3,对应长度 1、2、4、8 字节)。
- 断点类型:通过
DR7 的 RWO~RW3 位段设置(0=执行断点,1=写入断点,2=读取/写入断点)。
- 断点启用状态:通过
DR7 的 L0~L3 位段控制(1=启用局部断点,仅当前线程有效)。
注意:每个线程拥有独立的线程环境块(TEB),若需控制多线程程序的所有线程,需为每个线程单独设置硬件断点或 TF 断点。
硬件断点设置规则
调试寄存器 DR7 位段定义
typedef struct _DBG_REG7 {
unsigned LO : 1;
unsigned GO : 1;
unsigned L1 : 1;
unsigned G1 : 1;
unsigned L2 : 1;
unsigned G2 : 1;
unsigned L3 : 1;
unsigned G3 : 1;
unsigned LE : 1;
unsigned GE : 1;
unsigned Reserve1 : 3;
unsigned GD : 1;
unsigned Reserve2 : 2;
unsigned RW0 : 2;
unsigned LEN0: 2;
unsigned RW1 : 2;
unsigned LEN1: 2;
unsigned RW2 : ;
LEN2: ;
RW3 : ;
LEN3: ;
} DBG_REG7, *PDBG_REG7;
代码实现:硬件断点设置函数
(1)设置硬件执行断点
BOOL SetHardwareExecBreakpoint(HANDLE hThread, ULONG_PTR uAddress) {
CONTEXT ct = { CONTEXT_DEBUG_REGISTERS };
if (!GetThreadContext(hThread, &ct)) {
return FALSE;
}
PDBG_REG7 pDr7 = &ct.Dr7;
if (pDr7->LO == 0) {
ct.Dr0 = uAddress;
pDr7->RW0 = 0;
pDr7->LEN0 = 0;
pDr7->LO = 1;
} else if (pDr7->L1 == 0) {
ct.Dr1 = uAddress;
pDr7->RW1 = 0;
pDr7->LEN1 = 0;
pDr7->L1 = 1;
} else if (pDr7->L2 == 0) {
ct.Dr2 = uAddress;
pDr7->RW2 = 0;
pDr7->LEN2 = 0;
pDr7->L2 = 1;
} else if (pDr7->L3 == 0) {
ct.Dr3 = uAddress;
pDr7->RW3 = 0;
pDr7->LEN3 = 0;
pDr7->L3 = 1;
} else {
return FALSE;
}
return SetThreadContext(hThread, &ct);
}
(2)设置硬件读写断点
BOOL SetHardwareRWBreakpoint(HANDLE hThread, ULONG_PTR uAddress, DWORD type, DWORD dwLen) {
CONTEXT ct = { 0 };
ct.ContextFlags = CONTEXT_DEBUG_REGISTERS;
if (!GetThreadContext(hThread, &ct)) {
return FALSE;
}
PDBG_REG7 pDr7 = &ct.Dr7;
if (dwLen == 2) {
uAddress &= ~1;
} else if (dwLen == 4) {
uAddress &= ~3;
} else if (dwLen != 1) {
return FALSE;
}
if (pDr7->LO == 0) {
ct.Dr0 = uAddress;
pDr7->RW0 = type;
pDr7->LEN0 = dwLen - 1;
pDr7->LO = 1;
} else if (pDr7->L1 == 0) {
ct.Dr1 = uAddress;
pDr7->RW1 = type;
pDr7->LEN1 = dwLen - 1;
pDr7->L1 = 1;
} else if (pDr7->L2 == 0) {
ct.Dr2 = uAddress;
pDr7->RW2 = type;
pDr7->LEN2 = dwLen - 1;
pDr7->L2 = 1;
} else if (pDr7->L3 == 0) {
ct.Dr3 = uAddress;
pDr7->RW3 = type;
pDr7->LEN3 = dwLen - 1;
pDr7->L3 = 1;
} {
FALSE;
}
(hThread, &ct);
}
1.10 实现内存访问断点
内存访问断点的实现依赖于内存访问异常机制。当程序尝试访问没有权限的内存分页时,系统会触发内存访问异常。若要设置执行断点或读写断点,均可将目标地址所在的内存分页权限设置为无访问权限。
Windows 系统以 4KB 为一个内存分页单位,每个分页有独立的访问属性,因此同一分页内的任何访问异常都会触发内存访问异常。例如,在地址 0x401500 设置内存执行断点后,0x401000~0x401FFF 整个分页内的代码执行都会触发异常。
精准定位断点的方法:
当调试器捕获到内存访问异常时,先检查异常地址是否为预设断点地址。若不是,需临时移除内存断点,设置 TF 单步断点让进程继续运行。进程执行一条指令后,调试器再次捕获单步事件,重新设置内存断点。通过反复'移除 - 单步 - 重置'操作,确保程序停在目标地址,此时才能向用户界面显示信息并交互。
软件断点效率较低,例如 OD 调试器仅支持设置 1 个软件断点。对于读写内存断点,分页内的读写操作会触发异常,但异常事件记录的是指令地址而非实际访问的内存地址。内存访问异常的详细信息通过 EXCEPTION_RECORD 结构体的 ExceptionInformation 数组传递:
- 数组第 0 个元素表示异常类型(0=读取异常,1=写入异常,8=执行异常);
- 第 2 个元素保存实际发生异常的内存虚拟地址。
断点响应和用户交互流程如下:
1.11 获取调试符号(Sym 系列 API)
微软调试符号处理 API 简介
微软提供了一套专门用于调试符号处理的 API(Sym 系列函数),可获取二进制文件的调试信息,包括:
- 符号名(函数名、全局变量名、局部变量名);
- 行号信息(汇编指令在源文件中的行号);
- 文件名(汇编指令对应的源代码路径)。
通过这些 API,可实现根据地址获取符号名、根据汇编指令地址获取行号等功能。
依赖环境与库文件
- 头文件:需包含
<Dbghelp.h>。
- 库文件:链接
Dbghelp.lib,对应动态库为 dbghelp.dll。
- 系统默认路径:
C:\Windows\System32(版本较低)。
- 更新版本路径:VS 安装目录下的
\Common7\IDE\dbghelp.dll。
使用调试符号时,主要涉及以下操作流程,且符号处理器需先完成初始化,方可进行后续操作:
- 初始化符号处理器:建立符号处理上下文,关联被调试进程。
- 加载指定模块的符号:按需加载目标模块的符号表(如 DLL 或 EXE 的符号文件)。
- 根据地址获取符号名:将内存地址转换为对应的函数名或变量名。
- 根据符号名获取符号地址:通过函数名或变量名查找其在内存中的具体地址。
- 根据地址获取源代码行号及文件名:定位汇编指令对应的源代码位置(需符号文件包含调试信息)。
核心操作与函数说明
1. 初始化符号处理器
BOOL SymInitialize(
_In_ HANDLE hProcess,
_In_opt_ PCSTR UserSearchPath,
_In_ BOOL fInvadeProcess
);
参数说明:
hProcess:必须为被调试进程句柄,不可传入当前进程句柄(GetCurrentProcess)。
UserSearchPath=NULL 时,搜索路径优先级:
- 应用程序当前工作目录;
- 环境变量
_NT_SYMBOL_PATH 定义的路径(若存在);
- 环境变量
_NT_ALTERNATE_SYMBOL_PATH 定义的路径(若存在)。
fInvadeProcess:若传入 TRUE,会对进程所有模块的符号进行枚举。不过,更高效的做法是传入 FALSE,之后运用 SymLoadModule64 函数逐个模块地进行符号枚举。
2. 加载指定模块的符号
DWORD64 WINAPI SymLoadModule64(
_In_ HANDLE hProcess,
_In_opt_ HANDLE hFile,
_In_opt_ PCSTR ImageName,
_In_opt_ PCSTR ModuleName,
_In_ DWORD64 BaseOfDll,
_In_ DWORD SizeOfDll
);
使用示例:
SymLoadModule64(
m_hCurrProcess,
dbgEvent.u.LoadDll.hFile,
path,
NULL,
(DWORD64)dbgEvent.u.LoadDll.lpBaseOfDll,
0
);
3. 根据符号名获取地址
BOOL WINAPI SymFromName(
_In_ HANDLE hProcess,
_In_ PCTSTR Name,
_Inout_ PSYMBOL_INFO Symbol
);
结构体定义:
typedef struct _SYMBOL_INFO {
ULONG SizeOfStruct;
ULONG TypeIndex;
ULONG64 Reserved[2];
ULONG Index;
ULONG Size;
ULONG64 ModBase;
ULONG Flags;
ULONG64 Value;
ULONG64 Address;
ULONG Register;
ULONG Scope;
ULONG Tag;
ULONG NameLen;
ULONG MaxNameLen;
TCHAR Name[1];
} SYMBOL_INFO, *PSYMBOL_INFO;
函数使用说明:由于该函数所使用的结构体中,最后一个参数是不定长数组(Name[1]),因此结构体实际大小不固定。在调用此函数时,必须谨慎传入大小合适的结构体,否则将无法正确获取符号信息。在目标地址存在有效符号的前提下,可通过以下方法有效获取符号名:
SIZE_T GetSymAddress(HANDLE hProcess, const char* pszName) {
char buffer[sizeof(SYMBOL_INFO) + MAX_SYM_NAME * sizeof(TCHAR)];
PSYMBOL_INFO pSymbol = reinterpret_cast<PSYMBOL_INFO>(buffer);
pSymbol->SizeOfStruct = sizeof(SYMBOL_INFO);
pSymbol->MaxNameLen = MAX_SYM_NAME;
if (!SymFromName(hProcess, pszName, pSymbol)) {
return 0;
}
return static_cast<SIZE_T>(pSymbol->Address);
}
使用注意:
- 需预分配足够大的缓冲区(
sizeof(SYMBOL_INFO) + MAX_SYM_NAME * sizeof(TCHAR))。
- 初始化
SizeOfStruct = sizeof(SYMBOL_INFO) 和 MaxNameLen = MAX_SYM_NAME。
4. 根据地址获取符号名
BOOL WINAPI SymFromAddr(
_In_ HANDLE hProcess,
_In_ DWORD64 Address,
_Out_opt_ PDWORD64 Displacement,
_Inout_ PSYMBOL_INFO Symbol
);
此函数也是把获取到的信息输出到表示符号的结构体里,所以它的使用方式和 SymFromName 一样。
这个函数可用于获取汇编指令里的函数名。比如,存在一些汇编指令,像 call 401004,在这条指令中,401004 是一个地址(并非这条指令自身所在的地址);还有 call [403000],这里的 403000 同样是一个地址,准确来说它是一个指针,该指针所指向的内容才是函数的地址。对于 call 401004 这种情况,我们能直接根据这个地址获取到函数名;而对于 call [403000] 这种情况,我们要先读取 0x403000 处的 4 字节内容作为函数地址,再进行解析才能得到函数名。当然,这一切的前提是有符号文件可供使用。
示例代码:
BOOL GetSymName(HANDLE hProcess, SIZE_T nAddress, CString& strName) {
DWORD64 dwDisplacement = 0;
char buffer[sizeof(SYMBOL_INFO) + MAX_SYM_NAME * sizeof(TCHAR)];
PSYMBOL_INFO pSymbol = reinterpret_cast<PSYMBOL_INFO>(buffer);
pSymbol->SizeOfStruct = sizeof(SYMBOL_INFO);
pSymbol->MaxNameLen = MAX_SYM_NAME;
if (!SymFromAddr(hProcess, nAddress, &dwDisplacement, pSymbol)) {
return FALSE;
}
strName = pSymbol->Name;
return TRUE;
}
5. 其他常用函数
SymGetLineFromAddr64:根据汇编指令地址获取源代码行号和文件路径。
SymGetModuleInfo64:根据地址获取模块信息(如模块基址、大小等)。
注意事项
- 结构体内存分配:
SYMBOL_INFO 包含不定长数组 Name[1],需确保缓冲区足够大(MAX_SYM_NAME 通常定义为 256 或更大)。
- 符号搜索路径:若
UserSearchPath 未指定且环境变量未配置,可能导致符号加载失败。
- 权限要求:部分 API(如
SymInitialize)需调试器具备 SE_DEBUG_NAME 权限(管理员权限)。
通过合理使用 Sym 系列 API,调试器可实现符号化调试,将内存地址转换为可读性更高的函数名和变量名,极大提升调试效率。
1.12 实现高级断点
(1)单步步过断点(Step Over)
单步步过断点通过结合 TF 断点和软件断点实现:
- 若当前指令为普通指令,通过设置 TF 断点(陷阱标志)使 CPU 单步执行该指令;
- 若当前指令为
call(函数调用)或 rep(重复操作)指令,则在目标地址的下一条指令处设置软件断点(写入 0xCC),使程序直接跳过函数或重复操作的内部逻辑,在目标地址的下一条指令处中断。
(2)API 断点
API 断点的实现本质上是先获取函数名对应的地址,然后在该地址处设置一个软件断点。
获取函数名对应地址有两种方法:
- 遍历所有模块的导出表,将其中的函数名与目标函数名进行匹配。不过这种方式既耗时又费力。
- 运用调试符号处理器,借助符号名来获取对应的地址。这种方式简单直接。下面为你介绍获取一个符号对应地址的方法:
DWORD64 GetApiAddress(HANDLE hProcess, LPCSTR szApiName) {
SYMBOL_INFO symbol = { sizeof(SYMBOL_INFO) };
symbol.MaxNameLen = MAX_SYM_NAME;
if (SymFromName(hProcess, szApiName, &symbol)) {
return symbol.Address;
}
return 0;
}
二、调试器编写流程总结
2.1 核心功能模块
调试器的核心功能模块包括:
- 调试事件循环:通过调用
WaitForDebugEvent 函数持续获取调试事件,并将事件分发到对应的处理函数进行逻辑处理。
- 断点系统:支持多种断点类型的实现及断点的增删查,包括软件断点、硬件断点、内存断点和单步断点等,用于控制程序执行流程。
- 调试信息获取:能够读取寄存器、内存堆栈、反汇编代码,并解析调试符号等信息。
- 用户交互:接收用户输入的调试命令(如单步执行、设置断点),并向用户输出调试信息(如寄存器值、内存数据)。
2.2 精简框架示例
(1)调试器框架建立目的
调试器框架的建立主要有以下三个目的:
- 持续接收目标进程的调试事件。
- 在合适的时候输出反汇编信息、线程环境块等内容。
- 接收用户的控制指令。
(2)调试循环框架搭建
框架第一层(达成目的 1)
负责接收调试事件,然后把调试事件传递给一个函数进行处理。将该函数的返回值作为 ContinueDebugEvent 函数的第三个参数。
框架第二层
把调试事件分成两部分。一部分处理进程创建与退出、线程创建与退出、DLL 加载与卸载、调试字符串输出以及内部错误;另一部分专门处理异常事件。
框架第三层(达成目的 2 和 3)
这一层主要处理异常事件。因为异常能细分为多种类型,且不同类型异常的恢复方法不同,所以要分类处理。同时,在这一层把信息输出给用户,并接收用户的输入。
void StartDebug(const TCHAR* pszFile ) {
if (pszFile == nullptr) {
return;
}
STARTUPINFOA stcStartupInfo = { sizeof(STARTUPINFOA) };
PROCESS_INFORMATION stcProcInfo = { 0 };
BOOL bRet = FALSE;
bRet = CreateProcessA(
pszFile,
NULL,
NULL,
NULL,
FALSE,
DEBUG_ONLY_THIS_PROCESS,
NULL,
NULL,
&stcStartupInfo,
&stcProcInfo
);
DEBUG_EVENT dbgEvent = { 0 };
DWORD dwRet = DBG_CONTINUE;
while (1) {
WaitForDebugEvent(&dbgEvent, INFINITE);
dwRet = DispatchEvent(&dbgEvent);
ContinueDebugEvent(dbgEvent.dwProcessId, dbgEvent.dwThreadId, dwRet);
}
}
DWORD DispatchEvent(DEBUG_EVENT* pDbgEvent) {
DWORD dwRet = ;
(pDbgEvent->dwDebugEventCode) {
EXCEPTION_DEBUG_EVENT:
dwRet = (&pDbgEvent->u.Exception);
dwRet;
:
DBG_CONTINUE;
}
}
{
(pExcDbgInfo->ExceptionRecord.ExceptionCode) {
EXCEPTION_BREAKPOINT:
;
EXCEPTION_SINGLE_STEP:
;
EXCEPTION_ACCESS_VIOLATION:
;
:
DBG_EXCEPTION_NOT_HANDLED;
}
();
DBG_CONTINUE;
}
{
(, pDbgEvent->u.Exception.ExceptionAddress);
buff[];
() {
();
((buff, (buff)) != ) {
(buff[] == ) {
();
} ((buff, ) == ) {
();
} (buff[] == ) {
;
} {
();
}
}
}
}
需要注意:
- 调试器需要维护一个断点列表,记录每个断点的类型、地址、原始字节等信息。
- 处理异常时,需要区分是调试器主动触发的异常(如断点)还是进程自身产生的异常,避免错误处理。