用C++从零实现一个OllyDbg风格调试器:事件循环、断点与反汇编
简介这是一款基于Windows 10平台、以C/C语言实现的调试器源码工程整体仿照OllyDebug设计适合软件开发人员、逆向工程师及安全研究人员学习调试器底层机制与实战技巧。工程覆盖创建与附加进程、汇编代码查看与修改、内存/栈/寄存器操作、模块列表、软件与硬件断点、条件断点、反反调试及插件扩展等核心功能并附带导入表/导出表解析、符号解析、源码调试与DUMP等附加模块。资源共72个文件以31个头文件与23个C源文件为主辅以工程配置、文本说明及少量依赖库压缩包整体仅316KB但目录结构完整便于直接打开工程研读。当前已有225人学习下载。通过这份源码读者可深入理解断点管理、调试事件分发、反调试对抗等关键实现也可作为二次开发或安全分析工具的基础框架。1. 这个压缩包到底在做什么用 C 重写一个 OllyDbg 风格调试器面对一个只在发布版崩溃的二进制日志无法给出更多线索时多数人第一反应是打开 OllyDbg 下断点、单步、看堆栈。可当你需要批量加断点、定制显示逻辑或者把调试行为接到自己的自动化工具里时闭源工具的扩展点往往不够用。于是不少逆向工程师都会想如果自己用 C 写一个核心交互都按 OllyDbg 思路走、但代码全在自己手里的调试器很多问题就好办了。这个压缩包对应的正是这条路一个基于 Windows 调试 API 的用户态调试器仿 OllyDbg 的布局和交互支持启动或附加进程、软件断点、单步、寄存器内存读写、反汇编和符号显示。它适合做动态分析、崩溃定位、协议调试的人也适合想把调试器底层机制彻底搞清楚的 C 开发者。这里不假设你已经拿到了某份源码只讲从空工程一步步搭出可靠方案的完整路径关键的坑放在第五章。如果你只是想在目标程序里快速看几个 API确实用现成工具更快但如果你想在自己产品里嵌入调试能力或者想理解 OllyDbg 那些“顺手”的操作背后发生了什么这个方向就非常值得投入。2. 调试器必须先立住的模型从 OllyDbg 到自研调度的四个模块2.1 用户态调试的底层模型调试事件如何到达调试器OllyDbg 本质上是 Windows 调试 API 的前台。启动目标进程时它用CreateProcess并带上DEBUG_PROCESS标志让系统把所有和被调试进程相关的调试事件发给你。附加一个正在运行的进程则用DebugActiveProcess。无论哪种方式后续交互都围绕一个核心对象DEBUG_EVENT。DEBUG_EVENT是操作系统投递给调试器线程的消息结构里面包含了事件类型和进程、线程 ID。常见的事件有CREATE_PROCESS_DEBUG_EVENT、CREATE_THREAD_DEBUG_EVENT、EXCEPTION_DEBUG_EVENT、LOAD_DLL_DEBUG_EVENT、EXIT_PROCESS_DEBUG_EVENT等。其中最重要的就是EXCEPTION_DEBUG_EVENT软件断点命中、单步完成、目标进程自身崩溃都会通过异常事件告诉你。调试器的运行模型看起来很简单先WaitForDebugEvent等待一个事件处理完再ContinueDebugEvent恢复目标线程。但这里有个容易被新手忽略的点ContinueDebugEvent必须被调用目标线程才会继续执行。如果调试器在事件处理里做了耗时的 UI 操作目标进程就会卡住。OllyDbg 实际上把事件循环放在独立线程主线程只负责渲染和接收用户输入自研调试器第一版就应该照这个方式分层。2.2 自研调试器的模块划分与编译环境选型OllyDbg 的界面看起来是“一个窗口里好几块面板”但代码上建议拆成四块调试核心、断点管理、反汇编模块、UI。我一般会先把调试核心做成与 UI 无关的库在命令行下验证事件循环和断点逻辑再接界面。这样调试事件处理和界面渲染不会互相纠缠。DebugCore封装事件循环、进程创建/附加、线程上下文、内存读写。BreakpointManager记录断点地址、原指令字节、条件表达式。DisasmEngine把字节序列转成指令文本输出地址、字节、助记符、操作数。UiPanelWin32 自绘窗格分成反汇编窗口、寄存器窗口、堆栈窗口、内存转储窗口。编译环境选型不算复杂Windows SDK 加 MSVCC17。所有 Win32 API 直接使用 W 版本进程路径和符号名统一用std::wstring避免 TCHAR 宏带来的混乱。源码组织上DebugLoop在事件线程里跑UiPanel在主线程两者之间放一个命令队列。比如用户双击一个地址下断点UI 线程不能直接写目标进程内存而是投递一个SetBreakpointCmd给事件线程事件线程在空闲时执行。只有这样目标进程才不会因为调试器界面卡顿而跟着卡死。2.3 从 OllyDbg 借鉴但不必照抄的交互设计OllyDbg 最经典的布局是“地址 | 十六进制字节 | 反汇编文本 | 注释”每一行对应一条指令。自研时可以先画一个纯文本日志界面跑通核心但既然标题是“仿 ollydebug 实现”最好还是做出同样的表格效果。关键是当前行要高亮并且要让反汇编窗口跟随 EIP 或选中行自动滚动。另一个值得借鉴的是“反汇编窗口只渲染可见行”。OllyDbg 不会一次性反汇编整个模块只解码用户当前能看到的那几十行滚动时再把新行送进缓存。自研调试器如果也这么做即使目标进程的代码段很大UI 也能保持流畅。这些交互细节放到后面章节展开先记住一点先有稳定的事件循环再谈好看的界面。3. 用 C 实现调试核心事件循环、寄存器与断点3.1 事件循环的最小实现WaitForDebugEvent 与 ContinueDebugEvent调试核心第一步是跑通事件循环。下面这段代码可以用作DebugCore的骨架// debug_core.cpp bool DebugCore::RunLoop(const std::wstring targetPath) { STARTUPINFOW si{ sizeof(si) }; PROCESS_INFORMATION pi{}; // 用 DEBUG_PROCESS 启动目标系统会把调试事件投递给当前进程 BOOL created CreateProcessW( targetPath.c_str(), // 目标进程路径 nullptr, // 命令行参数 nullptr, nullptr, // 默认安全属性 FALSE, // 不继承句柄 DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS, // 只调试这个进程 nullptr, nullptr, si, pi); if (!created) return false; DEBUG_EVENT de{}; DWORD continueStatus DBG_CONTINUE; for (;;) { // 阻塞等待调试事件 if (!WaitForDebugEvent(de, INFINITE)) break; // 分发事件返回恢复方式 continueStatus HandleDebugEvent(de); // 告诉系统这个事件处理完了目标线程可以继续 ContinueDebugEvent( de.dwProcessId, // 目标进程 ID de.dwThreadId, // 触发事件的线程 ID continueStatus); // DBG_CONTINUE 或 DBG_EXCEPTION_NOT_HANDLED } return true; }这段代码里最需要注意的是CONTINUE状态。DBG_CONTINUE表示“这个异常我已经处理了”适合断点和单步异常DBG_EXCEPTION_NOT_HANDLED表示“调试器不处理请交给目标进程自身的异常逻辑”适合访问违规这类目标进程自己的崩溃。如果全部返回DBG_CONTINUE目标进程的异常处理器将永远不会触发行为会变得很怪。WaitForDebugEvent第二个参数是超时时间用INFINITE是最常见的做法。事件循环必须放在独立线程因为INFINITE会让线程一直阻塞如果放在主线程窗口就拖不动了。有限超时也能做但需要处理超时重入的状态机没有明显收益第一版直接INFINITE更稳。3.2 读取寄存器与内存Context 与进程内存 API调试器停留在断点时用户要看到寄存器当前值。获取寄存器需要GetThreadContext读取目标进程内存需要ReadProcessMemory。下面是对应的封装bool GetThreadContextSnapshot(DWORD tid, CONTEXT ctx) { HANDLE hThread OpenThread( THREAD_GET_CONTEXT, // 只读上下文不需要设置权限 FALSE, tid); if (!hThread) return false; // 必须先设置 ContextFlags否则系统只返回部分寄存器 ctx.ContextFlags CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS; BOOL ok ::GetThreadContext(hThread, ctx); CloseHandle(hThread); return ok; } bool ReadTargetMemory(HANDLE hProcess, DWORD_PTR address, void* buffer, SIZE_T length) { SIZE_T readBytes 0; BOOL ok ReadProcessMemory( hProcess, reinterpret_castLPCVOID(address), buffer, length, readBytes); // 读取数量必须等于请求数量部分读取可能导致反汇编越界 return ok readBytes length; }这里最容易翻车的就是忘记设置ctx.ContextFlags。有人会困惑明明传了引用为什么寄存器读出来全是 0就是因为系统要求调用者先告诉它需要哪部分上下文。THREAD_GET_CONTEXT权限足够读如果还需要写寄存器、单步时修改 EFlags就要用THREAD_SET_CONTEXT权限位需要同时放开。ReadProcessMemory的地址是目标进程内的地址不能拿宿主进程的指针直接用。另外建议一次读取长度不要超过一页某些安全软件会拦截跨页读取。反汇编层读取指令时通常只需要 16 个字节按 16 字节读就行不要贪多。3.3 软件断点、单步与异常处理INT3 和 TF 标志联动软件断点的原理是把指令首字节替换成0xCC也就是INT3。命中后目标进程会抛出EXCEPTION_BREAKPOINT异常调试器在异常事件里拿到当前异常地址。先看设置断点的代码// breakpoint_manager.cpp bool BreakpointManager::Set(BYTE* address) { BYTE int3 0xCC; BYTE old 0; SIZE_T written 0; // 读出断点位置原有的第一个字节后面恢复要用 if (!ReadProcessMemory(m_hProcess, address, old, 1, written)) return false; // 如果原来就是 0xCC说明已经是断点不允许重复设置 if (old 0xCC) return false; if (WriteProcessMemory(m_hProcess, address, int3, 1, written)) { m_map[address] { old, true }; // 地址 - {原字节, 是否启用} return true; } return false; }真正的难点在断点命中后的“四步恢复”。如果直接把0xCC改回原字节然后继续目标进程会从断点地址重新执行原指令但执行完后又走到已经被恢复的位置逻辑会乱。正确顺序是这样的// handle_debug_event.cpp: 只展示 EXCEPTION_BREAKPOINT 分支 case EXCEPTION_BREAKPOINT: { auto bp m_bpMgr.Find((BYTE*)exceptionAddress); if (!bp) break; // 不是自己下的断点交给目标进程 // 1. 把断点位置恢复成原指令首字节 SIZE_T written 0; WriteProcessMemory(m_hProcess, exceptionAddress, bp-originalByte, 1, written); // 2. 把 EIP/RIP 回退到断点地址确保原指令会被执行 CONTEXT ctx{}; ctx.ContextFlags CONTEXT_CONTROL; GetThreadContext(m_hThread, ctx); #ifdef _WIN64 ctx.Rip exceptionAddress; #else ctx.Eip exceptionAddress; #endif SetThreadContext(m_hThread, ctx); // 3. 设置 EFLAGS 的 TF 位(0x100)让 CPU 单步执行一条指令 ctx.ContextFlags CONTEXT_CONTROL; ctx.EFlags | 0x100; SetThreadContext(m_hThread, ctx); // 先恢复执行等待接下来的 EXCEPTION_SINGLE_STEP 事件 return DBG_CONTINUE; }为什么需要设置 TF 标志因为断点处原字节已经被执行过替换你需要让 CPU 先执行原指令执行完成后再重新写回0xCC。TF 标志会让 CPU 执行完下一指令后立刻产生单步异常调试器在单步异常分支里重新写入0xCC然后把 TF 清掉继续运行。整个顺序错一步断点处的指令要么被丢失要么永远停在原地。注意exceptionAddress必须从EXCEPTION_DEBUG_EVENT的ExceptionRecord.ExceptionAddress拿不要自己硬猜。在 64 位系统上还要区分Rip和Eip否则上下文会被截断。这个分支是调试器最容易出错的地方值得多花时间验证。4. 反汇编、符号解析与 UI 刷新让调试器“看得出来”4.1 集成反汇编引擎从字节到指令文本的流水线调试核心拿到的只是指令字节界面要显示“地址、按钮、指令文本”。自研时最省力的是集成一个 x86/x64 反汇编库但你不需要在业务代码里关注它内部实现只需要把引擎封装成一个函数// disasm_engine.h struct DisasmLine { DWORD_PTR address; // 指令地址 uint8_t bytes[16]; // 原始字节 int byteCount; // 指令长度 wchar_t text[128]; // 反汇编文本助记符 操作数 wchar_t comment[64]; // 额外注释 }; // disasm_engine.cpp bool DisasmEngine::Decode( const uint8_t* code, // 从目标进程读到的字节 size_t maxSize, // 最大字节数 DWORD_PTR address, // 指令虚拟地址 DisasmLine out) { if (m_disasmFn nullptr) return false; // 反汇编引擎内部结构不同库字段名不同这里只做示意 DISASM_INSN insn{}; insn.EIP static_castUIntPtr(address); int status m_disasmFn( const_castuint8_t*(code), maxSize, static_castINT64(address), insn, m_options); if (status 0) return false; out.address address; out.byteCount insn.Size; memcpy(out.bytes, insn.InstructionBytes, insn.Size); swprintf(out.text, L%s %s, insn.Mnemonic, insn.Operands); return true; }反汇编结果里的byteCount非常关键。UI 在计算下一行地址时用address byteCountx86 指令长度从 1 到 15 字节不等不能假设固定长度。引擎封装成m_disasmFn的好处是以后想换实现、或者加上指令级注释只需要改这一层事件循环和 UI 都不用动。maxSize建议限制在 15 到 16 字节以内避免一条指令过长越界。每次 UI 请求反汇编时先读目标进程内存再交给引擎如果ReadProcessMemory失败显示??而不是让 UI 崩溃。读取失败的常见原因是目标模块被卸载或页面被保护这种情况下地址已经失效直接清掉缓存行。4.2 符号解析把地址翻译成函数名和行号OllyDbg 在反汇编窗口会标出“这个 Call 是谁”。自研调试器不必完整实现 PDB 解析最常见的是调用 Windows DbgHelp 库的SymFromAddr代码量很小bool LoadSymbols(HANDLE hProcess, const std::wstring modulePath) { DWORD64 base SymLoadModuleEx( hProcess, nullptr, modulePath.c_str(), nullptr, 0, // 让系统自动读取模块基址 0, nullptr, 0); return base ! 0; } std::wstring ResolveSymbol(HANDLE hProcess, DWORD_PTR address) { // SYMBOL_INFO 需要额外空间存储符号名 char buffer[sizeof(SYMBOL_INFO) MAX_SYM_NAME * sizeof(TCHAR)] {0}; SYMBOL_INFO* sym reinterpret_castSYMBOL_INFO*(buffer); sym-SizeOfStruct sizeof(SYMBOL_INFO); sym-MaxNameLen MAX_SYM_NAME; DWORD64 displacement 0; // 符号内偏移可以显示 0x10 这种后缀 if (SymFromAddr(hProcess, address, displacement, sym)) { return std::wstring(sym-Name); } return L; }使用 DbgHelp 前需要先SymInitialize这里LoadSymbols里的SymLoadModuleEx可以主动加载模块 PDB。LOAD_DLL_DEBUG_EVENT事件里会带上 DLL 路径你要在事件循环里对它调用LoadSymbols否则动态加载的模块解析不出函数名。SymFromAddr返回的displacement是符号内偏移比如一个函数开头的0x0和中间的0x2A。在反汇编窗口可以显示成sub_1400010000x2A这个细节会让调试器看起来专业很多。4.3 仿 OllyDbg 的主要窗口分区与刷新策略OllyDbg 的布局是在一个主窗口里放多个面板。自研时用 Win32 子窗口即可最基础的分区是顶部反汇编窗、寄存器窗、堆栈窗、内存转储窗。注册窗口类时用WS_CHILD | WS_VSCROLL每个窗格独立滚动。刷新策略比绘制本身更重要。OllyDbg 之所以流畅是因为它不重做无用功反汇编窗口只渲染当前可见行并且里面有缓存。下面是一个简化的行绘制示例// ui_panel.cpp void DisasmPanel::DrawLine(HDC hdc, int row, const DisasmLine line, bool current) { wchar_t text[256]; swprintf(text, L%08X , line.address); for (int i 0; i line.byteCount; i) { swprintf(text wcslen(text), L%02X , line.bytes[i]); } swprintf(text wcslen(text), L %s, line.text); RECT rc{ 0, row * m_rowHeight, m_width, (row 1) * m_rowHeight }; if (current) { FillRect(hdc, rc, m_highlightBrush); } DrawTextW(hdc, text, -1, rc, DT_LEFT | DT_VCENTER); }DrawLine只负责一行WM_PAINT里根据滚动偏移计算可见行数逐行取缓存。缓存通常是一个std::unordered_mapDWORD_PTR, DisasmLine当目标进程执行到新地址时只把新行加入缓存不淘汰旧行。这样反复滚动同一个区域不会重复反汇编。寄存器窗口刷新也是在收到调试事件后由DebugCore通知 UI 更新当前行和寄存器值然后只重绘对应面板而不是整个主窗口都刷新。OllyDbg 的当前行高亮用系统高亮色即可不要用花哨的自定义色否则在低分辨率屏幕上会显得很脏。5. 调试器实现避坑现象、原因与修复笔记5.1 断点命中后程序继续执行时跳过一条指令现象第一次 INT3 断点能停下来点击“继续”后程序直接从断点下一行开始执行原指令没跑有时候还会再次停在同一个断点形成死循环。原因没有做“恢复原字节 回退 EIP 单步执行原指令 重新下断点”这四步。直接把0xCC改回去后EIP 还停在断点地址CPU 会把已经被恢复的原指令重新解释和执行这就等于跳过了当前指令甚至会在同一个位置反复触发。解决严格按第三章的断点处理分支执行。关键顺序是先恢复原字节再回退EIP/RIP然后置 TF 标志等单步异常。在单步异常里重新写回断点同时清掉 TF。写回断点前要再读一次原字节防止用户恰好也把该地址改成新指令。5.2 附加 64 位进程时寄存器上下文读出一片零现象附加一个 64 位进程后打开寄存器窗口所有值都是 0但目标进程明明正常运行。原因调试器本身被编译成了 32 位。32 位代码使用 32 位CONTEXT结构去读 64 位线程系统按 32 位结构去解析读出来的自然是无效数据。反过来如果你在 64 位系统里调试 WOW64 进程32 位程序跑在 64 位系统用GetThreadContext读到的也不是完整的 32 位寄存器。解决如果只调试 64 位目标调试器直接编译成 x64必须同时支持 32 位和 64 位时用IsWow64Process判断目标环境然后选择CONTEXT还是WOW64_CONTEXT。不要用sizeof(DWORD_PTR)猜更不要只改宏开关否则上下文在两种模式下会悄悄截断。5.3 OpenProcess 拒绝访问权限和调试特权现象附加一个以管理员权限运行的目标时OpenProcess(PROCESS_ALL_ACCESS)返回空GetLastError()是 5。原因访问令牌权限不足。即使当前账户是管理员进程默认也可能开了 UAC 过滤普通权限无法打开高权限进程。另一个原因是某些系统保护进程不允许常规调试器打开。解决调试器的 manifest 里声明requireAdministrator并在启动时用OpenProcessToken和AdjustTokenPrivileges开启SeDebugPrivilege。注意开启特权必须在打开进程句柄之前执行否则还是失败的。对于系统保护进程不开内核驱动基本无解建议直接放弃。附带一条经验手动OpenProcess前先用DebugActiveProcess尝试有时系统自动给的句柄比手动打开的权限更充足。5.4 反汇编窗口刷新太慢把反汇编风暴关进缓存现象移动反汇编窗口滚动条CPU 占用瞬间飙到 100%窗口滚动一顿一顿断点一多连单步都变卡。原因WM_PAINT里对所有可见行都做了ReadProcessMemory加反汇编滚动会触发大量WM_VSCROLL消息每次都重复解码。进入目标进程内存是系统调用几千行解码下来自然卡死。解决做两层缓存。第一层是地址到已解码行的哈希表未命中才调用反汇编第二层是双缓冲位图滚动时先移动整个位图不立刻解码新区域。松手后再异步预取可见行。另外调试事件循环里不要做反汇编事件处理只保存地址和字节展示留给 UI 线程。缓存失效的时机是模块卸载和断点修改其余时间都该命中。5.5 单步进入系统调用或 DLL 后事件丢失现象单步执行到某些系统调用或LoadLibrary时调试器等不到预期事件目标进程也不动了像被卡住。原因部分系统调用和加载器流程会产生多个异常而事件处理线程如果做了耗时的反汇编和文件 IOWaitForDebugEvent和ContinueDebugEvent之间的间隔会拉长内核里事件堆积最终表现为死锁。另一个常见原因是把DBG_EXCEPTION_NOT_HANDLED和DBG_CONTINUE用错导致系统服务异常没人处理。解决HandleDebugEvent里绝对不做 UI 和反汇编只做必要的上下文保存和断点状态切换展示数据放到消息队列里。调试事件循环的“等待 - 处理 - 继续”间隔尽量短。对于自己产生的断点和单步异常返回DBG_CONTINUE其他异常返回DBG_EXCEPTION_NOT_HANDLED。这样目标进程的异常机制能正常工作事件也不容易堆叠。6. 让调试器更好用条件断点、脚本化与指令跟踪核心能跑、断点不翻车之后下一步我把精力放在 OllyDbg 最让人舒服的几个点上条件断点、命令行交互、指令跟踪。这三个功能做出来调试器才算从“玩具”变成“工具”。条件断点可以这样扩展断点管理器在BreakpointManager里给每个断点增加一个可选表达式字段命中时先调用一个简单的表达式计算器比如识别eax 0x100或[esp] 0x401000。计算器只有常量、寄存器、内存访问和比较运算符几百行就能写完。命中时如果条件不满足不进入暂停状态而是立刻做“恢复原字节 单步 重新下断点”那套流程让程序继续跑。这样在循环内只关心特定值调试效率会高很多。命令行交互也值得做。仿 OllyDbg 的命令行输入bp 0x401000下断点r eax1改寄存器d esp显示内存。命令解析模块本身不复杂但它让调试器可以脱离鼠标操作也为后续自动化打基础。我会把命令解析放在 UI 层但命令执行通过命令队列发给事件线程避免直接操作目标进程。指令跟踪是我最推荐的验证手段。实现一个环形缓冲在每次单步事件里记录当前地址、寄存器值、反汇编文本这样你可以对比两次运行产生的 trace。固定输入下相同路径的执行流应该完全一致如果不一致说明断点处理或事件恢复逻辑有问题。给模拟项目 X 加上这个功能后我很快就定位到了一个单步异常处理里的条件判断错误比肉眼盯窗口高效得多。这里有一条血泪经验先做命令行版本把事件循环和断点跑稳再写 UI。我刚开始直接钻进窗口绘制结果核心 bug 全被界面盖住了一单步就翻车。调试器表面看是界面实际上是状态机越能把每一步执行录下来越能看清自己的实现。建议你也把这个顺序定下来希望帮到你。本文还有配套的精品资源点击获取