【第51节】Windows编程必学之从零手写C++调试器中篇(仿ollydbg)

2025-04-22 0 301

目录

引言

一、调试器的实现

1.1 创建进程进行调试  

 1.2 附加进程进行调试  

1.3 等待调试事件  

1.4 处理调试事件

1.5 系统断点

1.6 获取调试信息 

1.7 实现单步断点(TF标志位)

1.8 实现软件断点(int 3指令,0xCC) 

1.9 实现硬件断点(调试寄存器DR0~DR3) 

1.10 实现内存访问断点 

1.11 获取调试符号(Sym系列API)  

1.12 实现高级断点 

二、调试器编写流程总结 

2.1 核心功能模块  

2.2  精简框架示例  


引言

        下面是手写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以定位到实际断点地址。  

软件断点的副作用与处理  

【第51节】Windows编程必学之从零手写C++调试器中篇(仿ollydbg)

处理步骤:  
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断点。  

硬件断点设置规则  
【第51节】Windows编程必学之从零手写C++调试器中篇(仿ollydbg)

调试寄存器 `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个元素保存实际发生异常的内存虚拟地址。  

断点响应和用户交互流程如下:  

 

【第51节】Windows编程必学之从零手写C++调试器中篇(仿ollydbg)

 

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

遇见资源网 编程语言 【第51节】Windows编程必学之从零手写C++调试器中篇(仿ollydbg) http://www.ox520.com/157726.html

常见问题

相关文章

发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务