跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客我的书AI学习GitHub 精选镜像AI 生图工具UI配色美学关于
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
C++

从零手写 C++ 调试器:Windows 平台实现原理与代码

Windows 平台下使用 C++ 手写调试器的核心实现流程。涵盖通过 CreateProcess 和 DebugActiveProcess 启动或附加调试会话,利用 WaitForDebugEvent 循环捕获调试事件。重点解析软件断点(int 3)、硬件断点(DR 寄存器)、单步调试(TF 标志)及内存访问断点的底层机制与异常处理逻辑。介绍如何获取寄存器、反汇编信息及栈数据,并结合 Sym 系列 API 加载调试符号以增强可读性。最后总结调试器框架的三层架构设计,包括事件分发、异常修复及用户交互模块。

FrontendX发布于 2026/2/24更新于 2026/9/1063 浏览
从零手写 C++ 调试器:Windows 平台实现原理与代码

引言

本文讲解 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) {
    // 注意:此 API 通常需要以管理员权限运行并获取 SeDebug 特权后调用方可成功
     (dwPid);
}
return
DebugActiveProcess

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");
            }
        }
    }
}

需要注意:

  • 调试器需要维护一个断点列表,记录每个断点的类型、地址、原始字节等信息。
  • 处理异常时,需要区分是调试器主动触发的异常(如断点)还是进程自身产生的异常,避免错误处理。

目录

  1. 引言
  2. 一、调试器的实现
  3. 1.1 创建进程进行调试
  4. 1.2 附加进程进行调试
  5. 1.3 等待调试事件
  6. 1.4 处理调试事件
  7. 1.5 系统断点
  8. 1.6 获取调试信息
  9. 1.7 实现单步断点(TF 标志位)
  10. 1.8 实现软件断点(int 3 指令,0xCC)
  11. 1.9 实现硬件断点(调试寄存器 DR0~DR3)
  12. 1.10 实现内存访问断点
  13. 1.11 获取调试符号(Sym 系列 API)
  14. 1.12 实现高级断点
  15. 二、调试器编写流程总结
  16. 2.1 核心功能模块
  17. 2.2 精简框架示例

更多推荐文章

查看全部
  • 深入解析 WebView 的概念、功能、应用场景及优劣势
  • 无人机基本组成与结构设计
  • 算法优选:位运算实战技巧
  • Git 撤销上一次提交实战:reset、revert 与 amend 用法详解
  • 网络安全护网行动与红蓝对抗机制详解
  • 前缀和算法详解:一维、二维及经典题目
  • OpenRFT:基于强化微调实现大模型领域推理泛化
  • AcousticSense AI 多场景:ASR 文本 + 音频频谱双模态流派推断
  • 腾讯 Claw 三剑客横评:WorkBuddy、QClaw、CodeBuddy 三款 AI Agent 实测对比与选型指南
  • C++ 实现 2048 游戏核心逻辑解析
  • Node.js 运行机制解析:事件循环与模块系统详解(上)
  • 前缀和算法:从一维到二维的实战应用
  • Android 插件化技术:动态创建 Activity 模式详解
  • Python FastAPI 入门教程:新手快速上手指南
  • 原生 JS 实现点击 li 标签输出索引
  • C++ 继承机制详解:概念、访问控制与虚拟继承
  • C++ 实现 2048 小游戏实战教程
  • 机器学习:逻辑回归算法原理与实战
  • Java JVM 核心原理与性能调优实战指南
  • 机器人 SLAM 技术核心原理解析

相关免费在线工具

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

  • Markdown转HTML

    将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

  • HTML转Markdown

    将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online

  • JSON 压缩

    通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online

  • JSON美化和格式化

    将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online