
简介一款基于 Windows 10、使用 VS2015 与 C/C 语言实现的调试器工程设计上参考 OllyDbg工程内含 TangDebugger 主程序、MyDll 插件示例与 HookAPI 说明文件整体目录组织便于二次开发。面向软件开发、逆向分析、安全研究人群适用于程序调试纠错、软件行为剖析、恶意样本分析等场景。项目覆盖创建与附加进程、汇编指令显示修改、内存与栈数据读写、寄存器查看修改、模块枚举等基础能力同时具备软件断点、硬件断点、条件断点、反反调试与插件扩展等进阶特性对理解 Windows 调试原理、PE 导出导入表解析、符号解析及源代码调试均有直接帮助。压缩包共 72 个文件核心由 23 个 cpp 源文件与 31 个 h 头文件构成另含 sln/vcxproj 工程配置、dll/lib 库文件、说明 txt 与 rc 资源文件整体约 316KB结构清晰、易于使用 VS2015 直接加载编译。已有 222 人学习下载适合希望对照源码研究断点管理、调试事件分发、HookAPI 思路与插件接口设计的中高级开发者。 用C实现一个仿OllyDbg的调试器这活儿我干了差不多三个月。标题里这行字看着简单真正落地的时候才知道这坑有多深——光是把Windows调试API吃透、把反汇编指令流捋顺、再把UI做得像那么回事就已经劝退了身边好几个朋友。但这恰恰是C开发者极好的一次底层能力训练你会接触到进程内存、线程上下文、断点机制、异常分发、汇编指令编码这些平时写业务代码完全碰不到的东西全都挤在一个项目里逼你搞明白。这篇文章我把整个项目的设计过程、核心代码思路和踩坑记录整理一遍。你如果正准备自学调试器实现或者想深入理解OllyDbg这类工具背后的原理这份记录可以直接当参考路线。1. 整体设计与选型思路1.1 为什么是OllyDbg而不是x64dbg或WinDbg很多人会问现在x64dbg开源、功能更强为什么不直接仿它我的理由有三点。第一OllyDbg的界面布局极具辨识度顶部是反汇编窗口下面是命令栏左侧是寄存器面板右侧是数据窗口和栈窗口。这个布局源自上世纪90年代SoftICE时代的交互习惯信息密度高对调试器的功能展示非常高效。仿照它做UI相当于先确立了产品形态不用自己瞎编界面。第二OllyDbg的本质是一个32位用户态调试器用到的技术栈相对经典调试事件循环、软件断点、内存断点、反汇编引擎、表达式求值器这些都是调试器的基本功适合从零开始复刻。x64dbg基于Qt和其插件体系代码复杂度和依赖都高直接仿反而是给自己加负担。第三OllyDbg的操作逻辑简单直接F7单步、F8步过、F2设断点、CtrlG跳转地址。这些快捷键几乎是调试器的行业标准我的实现可以直接沿用不需要额外设计交互范式。技术选型上我用了纯Win32 API C17反汇编引擎用的是BeaEngine一个轻量级、支持x86/x64的C库UI直接用原生Windows控件和GDI绘制不引入MFC、Qt这些重量级框架。这样做的最大好处是依赖少、编译快、整个项目逻辑都在自己掌控范围内也符合OllyDbg那个年代工具小而纯粹的气质。1.2 调试器的功能边界与架构分层开始动手前我给项目划了一条功能边界不追求做成OllyDbg的全量替代品而是先把最常用的核心链路跑通。具体来说就是四件事加载或附加一个32位进程进入调试状态。反汇编窗口显示目标进程的汇编指令流支持实时刷新。F2设置/取消软件断点F7/F8实现单步步入和单步步过。寄存器窗口、栈窗口、内存窗口能同步显示并支持编辑。这套功能跑通了就已经是一个麻雀虽小五脏俱全的调试器雏形。至于条件断点、表达式求值、脚本功能、插件系统这些全部延后处理——它们不是核心路径前期投入只会拖慢进度。架构上我把工程分成了三个模块彼此尽量解耦模块职责关键文件DebugEngine调试会话管理、事件循环、断点管理DebugEngine.cpp、DebugEvent.cppDisassembler指令解码、地址格式化DisasmManager.cppUI层反汇编视图、寄存器面板、窗口布局MainWindow.cpp、DisasmView.cppDebugEngine是核心不依赖任何UI代码所有调试状态通过回调函数通知界面刷新。UI层只负责展示和捕获用户操作把命令转成DebugEngine的调用。这样的分层让后期换UI框架或者加命令行模式都容易逻辑也清爽。2. 调试核心原理事件循环与调试API2.1 调试事件循环是调试器的心脏Windows调试API的基础模型是调试器通过DebugActiveProcess附加到目标进程或者通过CreateProcess以调试模式创建目标进程然后进入一个事件循环不断调用WaitForDebugEvent等待调试事件。目标进程里每发生一次异常、线程创建、DLL加载、进程退出系统都会生成一个DEBUG_EVENT结构发给调试器。void DebugEngine::RunLoop() { DEBUG_EVENT de {}; while (!m_stopRequested WaitForDebugEvent(de, INFINITE)) { DWORD continueStatus DBG_CONTINUE; switch (de.dwDebugEventCode) { case EXCEPTION_DEBUG_EVENT: continueStatus HandleException(de); break; case CREATE_PROCESS_DEBUG_EVENT: OnCreateProcess(de); break; case CREATE_THREAD_DEBUG_EVENT: break; case LOAD_DLL_DEBUG_EVENT: OnLoadDll(de); break; case EXIT_PROCESS_DEBUG_EVENT: OnExitProcess(de); break; } ContinueDebugEvent(de.dwProcessId, de.dwThreadId, continueStatus); } }这一段就是整套调试机制的骨架。ContinueDebugEvent决定让目标进程继续正常运行还是把异常转交回目标进程自己处理。比如你遇到一个断点异常EXCEPTION_BREAKPOINT如果返回DBG_EXCEPTION_NOT_HANDLED程序会直接崩溃正确的做法是处理完断点后返回DBG_CONTINUE。这个循环跑得稳不稳直接决定调试器卡不卡。我最初的版本在每次断点停下后都重读几百条指令刷新反汇编窗口导致UI肉眼可见的掉帧。后来优化成只重读当前可见行约60条指令再加一个20条指令的预取瞬间顺滑。这就是可见即加载思路反汇编本身是重I/O操作不能每次刷新都全量生成。2.2 软件断点与异常分发的联动软件断点的经典原理是把目标地址处的原始指令第一个字节临时替换为0xCCINT 3指令执行到这里时CPU触发EXCEPTION_BREAKPOINT异常调试器收到事件后把EIP回退一个字节、恢复原始字节让用户查看当前状态等待下一步操作。bool DebugEngine::SetSoftwareBreakpoint(uintptr_t addr) { BYTE int3 0xCC; SIZE_T written 0; WriteProcessMemory(m_hProcess, (LPVOID)addr, int3, 1, written); // 保存原始字节以及原始指令长度用于断点命中后的恢复 m_breakpoints[addr].origByte m_origByteCache[addr]; m_breakpoints[addr].enabled true; return true; }断点命中处理有个需要注意的操作顺序我最初是直接恢复字节后就让用户单步结果发现同一地址的断点第二次就失效了。原因很简单恢复原始字节后没有重新写入0xCC。正确流程应该是命中后先恢复字节、回退EIP等用户触发F7/F8时再临时隐藏断点、执行一条指令然后立刻重新下断。这个临时隐藏-单步-恢复断点的过程就是OllyDbg里自动步过断点的实现雏形。如果你做过硬件调试器会更容易理解这就是软件调试里的条件停靠操作。3. 核心功能的代码实现3.1 附加进程与目标进程的初始化附加和创建调试进程是两种入口。创建进程的方式适合调试自己写的程序附加方式适合调试已经跑起来的进程。两种调用的前置操作略有不同但核心都是拿到目标进程的句柄和进程ID。bool DebugEngine::AttachToProcess(DWORD pid) { if (!DebugActiveProcess(pid)) { DWORD err GetLastError(); // 0x5表示拒绝访问通常是因为权限不足或以管理员运行调试器 if (err ERROR_ACCESS_DENIED) { // 提示用户以管理员权限重新启动调试器 } return false; } // 附加成功后系统会收到EXCEPTION_BREAKPOINT事件表示调试器已接管 m_pid pid; m_hProcess OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); m_hThread OpenThread(THREAD_ALL_ACCESS, FALSE, m_threadId); return true; }这里有个细节附加成功后拿到的第一个事件一定是EXCEPTION_BREAKPOINT这是系统为了让调试器完成初始化而故意触发的调试器自身断点。很多刚接触调试API的同学会误以为目标进程已经有了断点实际上是正常的握手信号必须把它吞掉不能当成用户设置的断点来处理。还有一点32位调试器只能调试32位目标进程。做跨位数调试需要64位版本或者WOW64层适配这一块复杂度直接翻倍我第一版先砍掉了等核心跑通再考虑。3.2 单步执行TF标志位与指令长度单步执行的核心是设置EFLAGS寄存器里的TFTrap Flag陷阱标志。置1后CPU执行完下一条指令就会触发EXCEPTION_SINGLE_STEP异常这时候调试器就拿到了控制权。void DebugEngine::StepInto() { CONTEXT ctx GetThreadContext(); // 重置断点 RestoreAllBreakpoints(); // 设置陷阱标志执行一条指令后暂停 ctx.EFlags | 0x100; // TF bit SetThreadContext(m_hThread, ctx); ContinueDebugEvent(m_pid, m_threadId, DBG_CONTINUE); }步过StepOver的实现比步入复杂一层遇到CALL指令时需要在返回地址处临时下一个一次性断点然后让程序全速运行这样就能停在CALL的下一条指令同时不进入子函数内部。这一步需要读取当前指令的反汇编结果来判断是否为CALL。我用BeaEngine解码后看Instruction.Mnemonic是不是call由此决定下断点的目标地址。这个逻辑调试器里叫临时断点法比逐条单步执行一个巨长函数高效得多。3.3 寄存器与内存视图的读取寄存器视图比较简单GetThreadContext一次就能拿全。麻烦的是如何把寄存器名和值对应起来我建立了一张静态映射表struct RegEntry { const char* name; DWORD offset; }; RegEntry g_regs[] { {EAX, offsetof(CONTEXT, Eax)}, {EBX, offsetof(CONTEXT, Ebx)}, {ECX, offsetof(CONTEXT, Ecx)}, {EDX, offsetof(CONTEXT, Edx)}, {EBP, offsetof(CONTEXT, Ebp)}, {ESP, offsetof(CONTEXT, Esp)}, {ESI, offsetof(CONTEXT, Esi)}, {EDI, offsetof(CONTEXT, Edi)}, {EIP, offsetof(CONTEXT, Eip)}, };内存窗口的更新更讲究策略。目标进程的地址空间是随时变化的所以我只在收到调试事件、程序停下来时刷新一次绝不在运行态做轮询式刷新。每次刷新时读取当前EIP附近约4KB的内存并缓存到UI层滚动窗口时直接查缓存确实需要跨页了再重新读取。寄存器窗口另外一个隐藏功能是编辑——双击寄存器的值可以直接修改这会调用SetThreadContext更新目标线程上下文。这个功能在调试循环条件时特别好用不用重新编译程序就能改变运行逻辑。4. UI布局与交互设计4.1 仿OllyDbg的多窗格布局OllyDbg的界面本质上是一个可停靠的列表集合。我用的方案是主窗口用CreateWindow建立若干子窗体控件每个子窗体对应一种视图反汇编、寄存器、内存、栈。子窗体都是自绘的用WM_PAINT里重绘不依赖ListView等标准控件因为标准控件在实时刷新场景下太笨重。布局上参考了OllyDbg的比例分配上方反汇编窗口占窗口宽度的50%。左下角寄存器面板占高度1/3。右侧数据窗口显示十六进制字节和ASCII预览。底部栈窗口显示ESP地址范围内的内容。4.2 让UI刷新不再卡顿第一个版本刷新时我是直接全量重绘结果就是拖动滚动条时整个窗口闪烁严重。后来做了三个优化第一双缓冲绘图。在内存DC里画完一整帧再BitBlt到窗口闪烁问题立刻消失。第二按需刷新。程序运行中不触发任何重绘只在断点命中或单步完成后重绘当前视图。第三滚动优化。记录当前滚动位置对应目标进程的哪个虚拟地址重绘时只绘制可见范围内的指令和数据而不是从EIP开始全部生成。代码层面反汇编视图的WM_PAINT处理大致是这样case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); HDC memDc CreateCompatibleDC(hdc); HBITMAP memBmp CreateCompatibleBitmap(hdc, rect.right - rect.left, rect.bottom - rect.top); SelectObject(memDc, memBmp); // 根据滚动位置遍历虚拟地址逐行反汇编并绘制 uintptr_t addr m_topAddr 0; for (int row 0; row visibleRows; row) { DisasmResult result m_disasm.DisassembleAt(addr); DrawRow(memDc, row, result); addr result.size; // 指令长度不同逐条递增 } BitBlt(hdc, 0, 0, width, height, memDc, 0, 0, SRCCOPY); DeleteObject(memBmp); DeleteDC(memDc); EndPaint(hwnd, ps); break; }绘制每行的内容包含四部分地址、机器码字节8-10个字节、助记符、操作数。这看起来简单实际做起来最麻烦的是对齐——不同指令长度不一样助记符的长度也不一样需要按列计算x坐标。5. 调试器路上踩过的坑5.1 断点恢复后EIP没有回退导致逻辑错乱第一次实现断点命中时我拿到EXCEPTION_BREAKPOINT事件就急着恢复原始字节忽略了EIP此时已经在0xCC指令之后的位置导致恢复后程序从断点地址的下一个字节继续运行指令全错。正确做法是在恢复字节之前把ctx.Eip - 1回退到断点指令的起始位置。这个教训我在代码注释里标了三遍现在每次写调试相关逻辑都会下意识检查一下EIP的语义。5.2 单步执行时断点干扰F7单步时如果下一条指令恰好也是一个断点地址那么系统会触发断点异常而不是单步异常此时进入死循环一样的事件分发。解决办法是在单步前记录当前断点列表在执行单步前把命中地址附近的断点临时移除单步完成后全部恢复。这个机制我在StepInto函数里已经体现了一段真正的完整实现还要维护一个隐藏断点列表避免重复移除。5.3 反汇编引擎的指令边界问题BeaEngine解码时有个要注意的地方如果给它的缓冲区不够大恰好指令结尾被截断它可能解码失败或者返回错误长度。我的处理是每次从目标地址读取64字节x86指令最长也就15字节这样即使遇到凌乱数据也足够解码完整指令。另外一个坑是0x0F 0x0B这类UD2指令BeaEngine能正确解码但是显示出来的助记符是ud2如果你拿到的数据是随机字节反汇编窗口会出现很多莫名其妙的指令这是正常现象不是引擎坏了。5.4 权限不足无法调试系统进程附加到系统进程或者受保护进程时经常遇到打开进程失败。常见原因是调试器自身没有管理员权限。这个问题的解法很简单右键以管理员身份运行。如果你做的是商业级调试器那就需要写驱动去突破保护了篇幅受限我这边就先岸在用户态调试的范围内。5.5 栈回溯与线程切换调试器还需要支持多线程和调用栈回溯这部分我做得比较初步。EXCEPTION_DEBUG_EVENT事件只告诉你哪个线程触发了异常你需要遍历目标进程的所有线程逐个做OpenThread和GetThreadContext。而栈回溯本质上是手动模拟执行pop EBP / ret的链条从当前EBP地址取出父函数的EBP再取出返回地址。这一段的代码不简单但理解了原理写起来也就那样。6. 常见问题速查表问题现象直接原因解决办法附加进程失败错误码5权限不足以管理员身份运行调试器断点设置成功但不触发地址无效或页面属性不可写检查目标模块基址和ASLR偏移单步执行后程序直接结束处理单步异常时返回了DBG_EXCEPTION_NOT_HANDLED单步异常继续时全部返回DBG_CONTINUE反汇编窗口显示乱码缓冲区不足导致指令截断每地址解码至少读取64字节程序运行状态时刷新数据闪屏在运行态做轮询刷新改成事件驱动刷新程序停下来再更新寄存器窗口值不更新没有在线程启动时关联上下文在CREATE_THREAD事件里缓存线程句柄7. 后续可以扩展的方向这个项目做到现在核心调试链路已经完全能用了。后续几个方向我列在下面想继续深入的朋友可以直接选一条走第一表达式求值器。OllyDbg的命令行可以输入dump [eax4]、bp 401000这一类表达式。实现上需要先写一个词法分析器然后做中缀表达式求值支持寄存器名、十六进制字面量和加减乘除这套逻辑类似编译器前端的迷你版。第二条件断点。在断点管理里存储一个std::functionbool()条件命中断点时先求值条件不满足就继续运行。这一步依赖表达式求值器而且要注意条件求值本身不能改变目标进程的状态。第三多架构支持。当前只支持32位x86改成x64需要处理64位寄存器和不同的调用约定还要处理WOW64下32位进程的调试。每个人学调试器都会经历一个从看别人工具功能很神奇到自己实现时被各种底层细节折磨的过程。我这次做下来最深的体会是调试器本质上是操作系统的翻译官——你通过API问系统要进程的状态系统告诉你当前发生了什么然后再由你决定让目标程序怎么走。理解了这一点很多看似高深的调试功能都能拆成简单的API调用加上体面的UI组织。最后还是那句话别怕重复造轮子调试器这东西你亲手实现一次胜过看十本底层书籍。本文还有配套的精品资源点击获取