目录
引言
下面是手写C++调试器的中篇,调试器的实现需深入结合操作系统底层机制与硬件特性。本文围绕 Windows 平台调试器的核心功能展开,通过代码示例与原理分析,详细阐述调试会话建立、调试事件捕获与处理、断点机制实现(包括软件断点、硬件断点、单步调试等)、调试信息获取(寄存器、反汇编、栈数据)以及调试符号解析等关键流程,完整呈现从进程创建到异常处理的调试闭环逻辑,为理解调试器底层实现提供技术参考。
一、调试器的实现
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) {
// 注意:此API通常需要以管理员权限运行并获取SeDebug特权后调用方可成功
return DebugActiveProcess(dwPid);
}
1.3 等待调试事件
void DebugEventLoop() {
// 函数主要分为3个部分:1. 等待调试事件,2. 处理调试事件,3. 回复调试子系统
DEBUG_EVENT dbgEvent = { 0 };
DWORD dwRetCode = DBG_CONTINUE;
while (true) {
// 1. 等待调试事件(超时时间设为-1表示无限等待)
if (!WaitForDebugEvent(&dbgEvent, INFINITE)) return;
// 2. 处理调试事件
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:
printf("DLL被卸载\\n");
break;
case OUTPUT_DEBUG_STRING_EVENT:
printf("调试信息\\n");
break;
}
// 3. 回复调试子系统
ContinueDebugEvent(
dbgEvent.dwProcessId,
dbgEvent.dwThreadId,
dwRetCode
);
}
}
下面是需要注意的关键信息:
– `WaitForDebugEvent` 函数的第二个参数代表超时时间,单位是毫秒。如果传入 `-1` 或者 `INFINITE`,就意味着会进行无限等待。
– `DEBUG_EVENT` 结构体包含了三方面的信息:
1. 调试事件代码,例如 `EXCEPTION_DEBUG_EVENT`;
2. 产生该事件的进程ID和线程ID;
3. 事件的具体信息,这些信息存于联合体 `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`指令(软件断点);
– 使用调试寄存器(硬件断点)。
副作用与处理流程:
1. 异常触发后:调试器需先清除主动设置的断点或标志位(如恢复被修改的内存字节、重置TF标志),避免对进程产生持续影响。
2. 用户交互:清除副作用后,向用户展示反汇编信息、寄存器状态等,并等待用户指令(如继续执行、单步调试)。
3. 后续处理:不同类型的异常需匹配特定的清除逻辑(如软件断点需恢复原始机器码,硬件断点需清空调试寄存器),具体实现将在后续章节详细讨论。 。
1.5 系统断点
调试器创建被调试进程时,系统会依次发送进程创建、模块加载等一系列调试事件。当所有事件处理完毕后,系统会发送一个异常代码为 `EXCEPTION_BREAKPOINT` 的 `EXCEPTION_DEBUG_EVENT` 异常事件,该事件标志着被调试进程已完成初始化准备就绪,此事件被称为系统断点。调试器必须在接收到系统断点后,才能安全地对被调试进程设置断点。
1.6 获取调试信息
(1)获取寄存器信息
当调试事件产生时,`DEBUG_EVENT` 结构体中会保存触发该事件的进程ID和线程ID。借助这些ID,可获取对应的线程句柄。获得线程句柄后,便能通过调用 `GetThreadContext` 函数获取线程的环境块(即寄存器信息)。
CONTEXT ct = { 0 };
ct.ContextFlags = CONTEXT_ALL; // 或 CONTEXT_CONTROL(仅获取EIP、ESP等控制寄存器)
if (GetThreadContext(hThread, &ct)) {
// 访问寄存器值,如 ct.Eax、ct.Eip
}
(2)获取反汇编信息
反汇编信息可通过反汇编引擎将机器码转换而来,而机器码必须来源于被调试进程,且需翻译的机器码不能随意获取,必须是当前EIP指向地址的机器码。因此,可通过以下步骤实现:
1. 获取线程环境块:通过线程句柄调用 `GetThreadContext`,获取包含EIP寄存器的线程环境信息。
2. 读取目标内存:使用 `ReadProcessMemory`,根据EIP指向的地址读取进程内存中的机器码数据。
3. 反汇编转换:将读取的机器码传入反汇编引擎(如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)) {
// 使用反汇编引擎(如X86Disasm)解析buff中的机器码
}
}
(3)获取栈信息
栈数据存储于一段连续的内存空间,其基地址由寄存器 **ESP(栈指针寄存器)** 保存。若需查看栈信息,可按以下步骤操作:
1. 获取线程环境块:通过调用 `GetThreadContext` 函数,获取包含 ESP 寄存器值的线程上下文信息。
2. 定位栈内存地址:从线程环境块中提取 ESP 寄存器的值,作为栈空间的起始内存地址。
3. 读取栈数据:使用 `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)) {
// 解析buff中的栈数据(如按DWORD打印前10个值)
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; // 进位标志(进位/错位时为1)
unsigned Reserve1 :1;
unsigned PF :1; // 奇偶标志(结果低位含偶数个1时为1)
unsigned Reserve2 :1;
unsigned AF :1; // 辅助进位标志(位3有进位/借位时为1)
unsigned Reserve3 :1;
unsigned ZF :1; // 零标志(结果为0时为1)
unsigned SF :1; // 符号标志(结果为负时为1)
unsigned TF :1; // 陷阱标志(为1时CPU单步执行指令)
unsigned IF :1; // 中断标志(为0时屏蔽中断)
unsigned DF :1; // 方向标志(控制字符串操作方向)
unsigned OF :1; // 溢出标志(结果超出机器范围时为1)
unsigned IOPL :2; // I/O特权级(0~3级)
unsigned NT :1; // 任务嵌套标志
unsigned Reserve4 :1;
unsigned RF :1; // 恢复标志(为1时禁止响应断点异常)
unsigned VM :1; // 虚拟8086模式标志
unsigned AC :1; // 内存对齐检查标志
unsigned VIF :1; // 虚拟中断标志(与VIP配合使用)
unsigned VIP :1; // 虚拟中断挂起标志
unsigned ID :1; // CPUID支持标志(为1时支持CPUID指令)
unsigned Reserve5 :10;
} REG_EFLAGS, *PREG_EFLAGS;
代码实现:设置单步断点函数
void SetSingleStepBreakpoint(HANDLE hThread) {
CONTEXT ct = { 0 };
ct.ContextFlags = CONTEXT_CONTROL; // 仅获取/设置控制相关寄存器(含EFLAGS)
if (GetThreadContext(hThread, &ct)) {
PREG_EFLAGS pEflags = reinterpret_cast<PREG_EFLAGS>(&ct.EFlags);
pEflags->TF = 1; // 设置TF标志位为1(启用单步模式)
SetThreadContext(hThread, &ct); // 提交线程环境块修改
}
}
1.8 实现软件断点(int 3指令,0xCC)
软件断点实现原理
软件断点通过CPU的`int 3`指令实现,该指令的机器码为`0xCC`。其核心逻辑为:
1. 安装断点:在目标地址写入`0xCC`,替换原指令的第一个字节。
2. 触发异常:CPU执行`0xCC`时触发`EXCEPTION_BREAKPOINT`异常(陷阱异常),调试器捕获该事件。
3. 恢复现场:调试器处理异常后,将原字节写回目标地址,并调整指令指针(EIP减1)以跳过`int 3`指令。
陷阱异常特性:`int 3`指令执行后才会触发异常,因此异常发生时EIP已指向`int 3`的下一条指令,需手动将EIP减1以定位到实际断点地址。
软件断点的副作用与处理
处理步骤:
1. 下断前读取目标地址的1字节原始数据并保存。
2. 写入`0xCC`触发异常。
3. 异常处理时恢复原始字节,并将EIP减1以修正执行流。
代码实现:软件断点操作函数
(1)设置软件断点
BOOL SetSoftwareBreakpoint(HANDLE hProcess, LPVOID pAddress, BYTE* oldByte) {
DWORD dwSize = 0;
// 读取原始字节
if (!ReadProcessMemory(hProcess, pAddress, oldByte, 1, &dwSize)) {
return FALSE;
}
// 写入0xCC(int 3指令)
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; // 启用Dr0局部断点
unsigned GO :1; // 启用Dr0全局断点
unsigned L1 :1; // 启用Dr1局部断点
unsigned G1 :1; // 启用Dr1全局断点
unsigned L2 :1; // 启用Dr2局部断点
unsigned G2 :1; // 启用Dr2全局断点
unsigned L3 :1; // 启用Dr3局部断点
unsigned G3 :1; // 启用Dr3全局断点
unsigned LE :1; // 已弃用(降低CPU频率)
unsigned GE :1; // 已弃用(降低CPU频率)
unsigned Reserve1 :3;
unsigned GD :1; // 调试寄存器保护位(1=修改寄存器触发异常)
unsigned Reserve2 :2;
unsigned RW0 :2; // Dr0断点类型(0=执行,1=写入,2=读写)
unsigned LEN0:2; // Dr0断点长度(0=1字节,1=2字节,2=4字节,3=8字节)
unsigned RW1 :2; // Dr1断点类型
unsigned LEN1:2; // Dr1断点长度
unsigned RW2 :2; // Dr2断点类型
unsigned LEN2:2; // Dr2断点长度
unsigned RW3 :2; // Dr3断点类型
unsigned LEN3:2; // Dr3断点长度
} 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;
// 查找可用的调试寄存器(DR0~DR3)
if (pDr7->LO == 0) { // DR0未使用
ct.Dr0 = uAddress;
pDr7->RW0 = 0; // 执行断点类型
pDr7->LEN0 = 0; // 长度1字节
pDr7->LO = 1; // 启用局部断点
} else if (pDr7->L1 == 0) { // DR1未使用
ct.Dr1 = uAddress;
pDr7->RW1 = 0;
pDr7->LEN1 = 0;
pDr7->L1 = 1;
} else if (pDr7->L2 == 0) { // DR2未使用
ct.Dr2 = uAddress;
pDr7->RW2 = 0;
pDr7->LEN2 = 0;
pDr7->L2 = 1;
} else if (pDr7->L3 == 0) { // DR3未使用
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) { // 长度2字节,对齐到2的倍数
uAddress &= ~1;
} else if (dwLen == 4) { // 长度4字节,对齐到4的倍数
uAddress &= ~3;
} else if (dwLen != 1) { // 仅支持长度1、2、4
return FALSE;
}
// 查找可用的调试寄存器
if (pDr7->LO == 0) {
ct.Dr0 = uAddress;
pDr7->RW0 = type; // 断点类型(1=写入,2=读写)
pDr7->LEN0 = dwLen - 1;// LEN字段取值0-3(对应1-8字节)
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;
} else {
return FALSE;
}
return SetThreadContext(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`。
使用调试符号时,主要涉及以下操作流程,且符号处理器需先完成初始化,方可进行后续操作:
1. 初始化符号处理器:建立符号处理上下文,关联被调试进程。
2. 加载指定模块的符号:按需加载目标模块的符号表(如DLL或EXE的符号文件)。
3. 根据地址获取符号名:将内存地址转换为对应的函数名或变量名。
4. 根据符号名获取符号地址:通过函数名或变量名查找其在内存中的具体地址。
5. 根据地址获取源代码行号及文件名:定位汇编指令对应的源代码位置(需符号文件包含调试信息)。
核心操作与函数说明
1. 初始化符号处理器
BOOL SymInitialize(
_In_HANDLE hProcess, // 被调试进程句柄(必填,不可为NULL)
_In_opt_PCSTR UserSearchPath, // 符号搜索路径(多个路径用分号分隔,可传NULL)
_In_BOOL fInvadeProcess // 是否枚举进程所有模块符号(TRUE=全枚举,FALSE=按需加载)
);
参数说明:
– `hProcess`:必须为被调试进程句柄,不可传入当前进程句柄(`GetCurrentProcess`)。
– `UserSearchPath=NULL`时,搜索路径优先级:
1. 应用程序当前工作目录;
2. 环境变量 `_NT_SYMBOL_PATH` 定义的路径(若存在);
3. 环境变量 `_NT_ALTERNATE_SYMBOL_PATH` 定义的路径(若存在)。
– `fInvadeProcess`:若传入 `TRUE`,会对进程所有模块的符号进行枚举。不过,更高效的做法是传入 `FALSE`,之后运用 `SymLoadModule64` 函数逐个模块地进行符号枚举。
2. 加载指定模块的符号
DWORD64 WINAPI SymLoadModule64(
_In_ HANDLE hProcess, // 进程句柄(需与SymInitialize一致)
_In_opt_ HANDLE hFile, // 模块文件句柄(可从模块加载事件获取)
_In_opt_ PCSTR ImageName, // 模块文件路径(支持相对/绝对路径,可留空)
_In_opt_ PCSTR ModuleName, // 模块名(可留空)
_In_ DWORD64 BaseOfDll, // 模块加载基址(必填)
_In_ DWORD SizeOfDll // 模块字节大小(可传0)
);
使用示例:
// 在模块加载事件中加载符号
SymLoadModule64(
m_hCurrProcess, // 进程句柄
dbgEvent.u.LoadDll.hFile, // 模块文件句柄
path, // 模块路径(通过GetModuleFileName获取)
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;
// 根据名字查询符号信息,输出到 pSymbol 中
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, // 位移量(传0)
_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`:根据地址获取模块信息(如模块基址、大小等)。
注意事项
1. 结构体内存分配:`SYMBOL_INFO` 包含不定长数组 `Name[1]`,需确保缓冲区足够大(`MAX_SYM_NAME` 通常定义为 256 或更大)。
2. 符号搜索路径:若 `UserSearchPath` 未指定且环境变量未配置,可能导致符号加载失败。
3. 权限要求:部分API(如 `SymInitialize`)需调试器具备 `SE_DEBUG_NAME` 权限(管理员权限)。
通过合理使用Sym系列API,调试器可实现符号化调试,将内存地址转换为可读性更高的函数名和变量名,极大提升调试效率。
1.12 实现高级断点
(1)单步步过断点(Step Over)
单步步过断点通过结合TF断点和软件断点实现:
1. 若当前指令为普通指令,通过设置TF断点(陷阱标志)使CPU单步执行该指令;
2. 若当前指令为`call`(函数调用)或`rep`(重复操作)指令,则在目标地址的下一条指令处设置软件断点(写入`0xCC`),使程序直接跳过函数或重复操作的内部逻辑,在目标地址的下一条指令处中断。
(2)API断点
API 断点的实现本质上是先获取函数名对应的地址,然后在该地址处设置一个软件断点。
获取函数名对应地址有两种方法:
1. 遍历所有模块的导出表,将其中的函数名与目标函数名进行匹配。不过这种方式既耗时又费力。
2. 运用调试符号处理器,借助符号名来获取对应的地址。这种方式简单直接。下面为你介绍获取一个符号对应地址的方法:
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 核心功能模块
调试器的核心功能模块包括:
1. 调试事件循环:通过调用 `WaitForDebugEvent` 函数持续获取调试事件,并将事件分发到对应的处理函数进行逻辑处理。
2. 断点系统:支持多种断点类型的实现及断点的增删查,包括软件断点、硬件断点、内存断点和单步断点等,用于控制程序执行流程。
3. 调试信息获取:能够读取寄存器、内存堆栈、反汇编代码,并解析调试符号等信息。
4. 用户交互:接收用户输入的调试命令(如单步执行、设置断点),并向用户输出调试信息(如寄存器值、内存数据)。
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 = 0;
switch (pDbgEvent->dwDebugEventCode) {
// 第一部分是异常调试事件
case EXCEPTION_DEBUG_EVENT:
dwRet = DispatchException(&pDbgEvent->u.Exception); // 进入到第三层分发异常
return dwRet; // 返回到框架的第一层
// 第二部分是其他调试事件
default:
return DBG_CONTINUE;
}
}
// 框架的第三层
DWORD DispatchException(EXCEPTION_DEBUG_INFO* pExcDbgInfo) {
// 第三层是专门负责修复异常的
// 如果是调试器自身设置的异常,那么可以修复,返回DBG_CONTINUE
// 如果不是调试器自身设置的异常,那么不能修复,返回DBG_EXCEPTION_NOT_HANDLED
switch (pExcDbgInfo->ExceptionRecord.ExceptionCode) {
case EXCEPTION_BREAKPOINT: // 软件断点
// 修复断点
break;
case EXCEPTION_SINGLE_STEP: // 硬件断点和TF断点
// 修复断点
break;
case EXCEPTION_ACCESS_VIOLATION: // 内存访问断点
// 修复断点
break;
default:
return DBG_EXCEPTION_NOT_HANDLED;
}
UserInput(); // 和用户进行交互
// 返回到框架的第二层中
return DBG_CONTINUE;
}
// 处理用户输入的函数,完成目的3
void UserInput() {
// 输出信息,完成目的2
printf("断点在地址 %08X 上触发\\n", pDbgEvent->u.Exception.ExceptionAddress);
// 输出反汇编代码
// 输出寄存器信息
// 接收用户输入,完成目的3
char buff[100];
while (1) {
printf("请输入命令:");
if (gets_s(buff, sizeof(buff)) != NULL) {
if (buff[0] == 't') { // 单步步入
// 单步步入的具体操作
printf("执行单步步入操作\\n");
} else if (strcmp(buff, "bp") == 0) { // 设置断点
// 设置断点的具体操作
printf("设置断点操作\\n");
} else if (buff[0] == 'g') {
break; // 跳出循环,返回到框架的第三层中
} else {
printf("无效命令,请重新输入\\n");
}
}
}
}
需要注意:
– 调试器需要维护一个断点列表,记录每个断点的类型、地址、原始字节等信息。
– 处理异常时,需要区分是调试器主动触发的异常(如断点)还是进程自身产生的异常,避免错误处理。
平台声明:以上文章转载于《CSDN》,文章全部或者部分内容、文字的真实性、完整性、及时性本站不作任何保证或承诺,仅作参考。
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/linshantang/article/details/147353062