简介这套调试器源码使用C/C在VS2015下开发基于Windows 10环境实现了一个类似OllyDebug的可运行调试工具面向软件开发人员、逆向工程师和安全研究人员可帮助他们完成程序调试纠错、软件行为剖析与恶意样本分析等任务。包体共72个文件核心包括31个h头文件、23个cpp源文件覆盖调试器主体逻辑另有工程配置、动态库、说明文档以及编译中间产物压缩包仅316KB轻量而完整。源码实现了从创建或附加目标进程到查看与修改汇编、内存、栈、寄存器再到模块列举与断点管理的完整调试链路并支持软件断点、硬件断点、条件断点、反反调试及插件扩展底层还涉及导入导出表解析和符号解析。学习这份代码可以深入理解Windows调试机制、断点原理与PE结构解析方法也可作为上手调试器开发的紧凑示例已有225人学习下载。1. 拿到 C 版本调试器仿 OllyDbg 的压缩包它到底解决什么问题拿到一个名为「C版本调试器仿ollydebug实现」的压缩包时先别急着解压跑界面。OllyDbg 这类用户态调试器真正值钱的部分不是花哨的汇编窗口而是背后那套断点状态机。用 C 实现一个仿 OllyDbg 的调试器核心要解决的是挂接目标进程、接收调试事件、维护软件/内存/硬件断点三套机制、再把寄存器和反汇编结果渲染到窗口。压缩包里是源码还是 Demo 不关键关键是你拿到这里面的设计思路后能不能自己也搭出一个能暂停、能单步、能看内存和寄存器的闭环。这个方向适合谁读已经写过进程内存修改、Hook 或注入类工具现在想补齐调试能力的开发者或者想从零仿一个同类工具的从业者。我一直觉得调试器是最容易被低估的工程界面只是壳断点状态机才是魂。这篇笔记按最可靠的落地路径拆开讲先从事件循环说起。2. 先搭调试事件循环挂接进程、等待事件、恢复执行的三个要点2.1 为什么一切都要挂在 WaitForDebugEvent 上用户态调试器的模型是被动的目标进程被内核挂起调试器用 WaitForDebugEvent 等待事件拿到事件后才有资格读写目标的内存和寄存器处理完再用 ContinueDebugEvent 让目标继续跑。OllyDbg 的暂停、继续、单步全部建立在这个循环上。所以我建议的顺序是先写一个控制台版的最小循环验证挂接和事件到达再往上盖界面。新手常犯的错误是先把界面画得漂亮再回头补调试循环结果发现界面刷新和目标执行根本对不上。我一般把调试循环单独放一个线程界面只消费它投递出来的事件通知。调试事件是同步的处理事件期间目标进程完全冻结所以不能在这个回调里做任何耗时操作比如写日志文件、刷新整个窗口这些都得延后。2.2 最小事件循环代码能暂停、能继续、能退出// 最小调试事件循环仿 OllyDbg 的暂停/继续机制 #include windows.h #include tlhelp32.h #include cstdio static HANDLE g_hProcess NULL; static HANDLE g_hThread NULL; static volatile bool g_quit false; DWORD GetPidByName(const char* name); // 实际通过进程快照找 PID bool NeedPassToTarget(DEBUG_EVENT* ev) { // 返回值决定异常是否要交给目标进程自己处理 if (ev-u.Exception.ExceptionRecord.ExceptionCode EXCEPTION_BREAKPOINT) return false; // 断点由调试器吞掉 if (ev-u.Exception.ExceptionRecord.ExceptionCode STATUS_GUARD_PAGE_VIOLATION) return false; // 内存断点由调试器吞掉 return true; // 其他异常交还给目标 } void RunDebugLoop(DWORD pid) { if (!DebugActiveProcess(pid)) { // 常见失败原因权限不足、目标已经在被另一个调试器附加 printf(DebugActiveProcess failed: %lu\n, GetLastError()); return; } DEBUG_EVENT ev {}; while (!g_quit) { if (!WaitForDebugEvent(ev, INFINITE)) { g_quit true; break; } switch (ev.dwDebugEventCode) { case CREATE_PROCESS_DEBUG_EVENT: { // 用内核给的句柄不要自己再 OpenProcess 一次 g_hProcess ev.u.CreateProcessInfo.hProcess; g_hThread ev.u.CreateProcessInfo.hThread; break; } case EXCEPTION_DEBUG_EVENT: { if (ev.u.Exception.dwFirstChance) { // 断点、单步、内存访问违规都从这里进入 HandleDebugException(ev); } break; } case EXIT_PROCESS_DEBUG_EVENT: g_quit true; break; default: break; // DLL 加载/卸载、输出字符串等噪音事件先忽略 } ContinueDebugEvent(ev.dwProcessId, ev.dwThreadId, NeedPassToTarget(ev) ? DBG_EXCEPTION_NOT_HANDLED : DBG_CONTINUE); } DebugActiveProcessStop(pid); }这段代码的逻辑说明WaitForDebugEvent 返回后目标进程内所有线程都处于挂起状态直到 ContinueDebugEvent 被调用。这保证了调试器在处理事件期间读内存是安全的不会出现目标指令边跑边改的竞态。ContinueDebugEvent 的第二个参数是关键DBG_CONTINUE 表示调试器已处理该异常DBG_EXCEPTION_NOT_HANDLED 表示不处理交还给目标进程自己的异常处理器。如果断点异常被交还给目标目标会直接触发未处理异常而崩溃这是常见的错误。参数说明INFINITE 超时会让调试线程一直挂起等待目标没有事件时 CPU 占用为零缺点是一旦调试器自身要退出需要先置位 g_quit而不能靠超时轮询。DebugActiveProcess 只针对已存在的进程如果想启动一个新进程来调试应该用 CreateProcess 的 DEBUG_PROCESS 标志两者挂接路径不同。拿到进程句柄后后续 ReadProcessMemory、WriteProcessMemory、VirtualProtectEx 都优先用它而不是重新打开因为重新打开的句柄权限不一定够。2.3 寄存器上下文读取前必须设置的 ContextFlags调试器要显示寄存器、单步、写断点都离不开上下文结构。OllyDbg 的寄存器窗口每次暂停都会刷新所有通用寄存器和标志位这个动作对应的是 GetThreadContext 一次调用。但新手经常会遇到返回值成功、寄存器全是 0 的问题原因基本是 ContextFlags 没设置。CONTEXT GetThreadCtx(HANDLE hThread) { CONTEXT ctx {}; // 通用寄存器 标志位 调试寄存器一次性全读回来 // 少一个 CONTEXT_DEBUG_REGISTERSDr0~Dr7 就全是 0 ctx.ContextFlags CONTEXT_FULL | CONTEXT_DEBUG_REGISTERS; if (!GetThreadContext(hThread, ctx)) { // 目标线程不是挂起状态时这里会直接失败 return {}; // 返回空上下文让上层显示 ?? } return ctx; }参数说明ContextFlags 决定读取哪些寄存器组。CONTEXT_FULL 包含通用寄存器和 EFlagsCONTEXT_DEBUG_REGISTERS 额外包含 DR0~DR7。一次调用全部读回的另一个好处是硬件断点的命中状态能同步更新到寄存器窗口避免再调一次。GetThreadContext 必须在线程挂起时调用调试事件处理回调里是安全的如果在用户点“暂停”按钮时直接调用目标线程可能正在内核态执行读取会失败。这个失败信号很重要我一般把失败当成“目标正在忙”来处理而不是弹错误框。3. 断点三件套软件断点、内存断点、硬件断点的实现与参数选择断点是 OllyDbg 的灵魂。三种断点各有适用场景软件断点最常用会修改目标代码硬件断点不修改代码但数量有限内存断点用页保护实现适合监控数据访问。下面这个对比表可以直接作为选型参考断点类型实现机制数量限制是否改代码典型用途软件断点写入 0xCC (INT3)无限制是下在代码地址硬件断点DR0~DR3 DR7 控制位最多 4 个否下在代码或数据内存断点页保护属性 PAGE_GUARD随内存页数否监控数据访问3.1 软件断点0xCC 的命中恢复流程软件断点的原理是把目标地址的第一个字节替换成 0xCC也就是 INT3 指令的机器码。CPU 执行到这里会触发 EXCEPTION_BREAKPOINT调试器在事件回调里拿到这个异常。但有个细节CPU 触发 INT3 后EIP 已经指向 0xCC 之后的下一条指令了所以命中后必须把 EIP 回退到断点地址否则断点只触发一次后面的逻辑全乱。struct SoftBP { DWORD addr; BYTE orig; // 被 0xCC 覆盖掉的原始字节 bool active; }; std::vectorSoftBP g_bps; // 下断点读原始字节写 0xCC bool SetSoftBP(DWORD addr) { BYTE int3 0xCC; SoftBP bp {}; bp.addr addr; // 先读后写原始字节必须存下来恢复时要用 SIZE_T read 0; if (!ReadProcessMemory(g_hProcess, (LPCVOID)addr, bp.orig, 1, read)) return false; WriteProcessMemory(g_hProcess, (LPVOID)addr, int3, 1, nullptr); bp.active true; g_bps.push_back(bp); return true; } // 命中处理返回 true 表示调试器吞掉这次断点 bool OnBreakpointHit(DWORD threadId, DWORD addr) { SoftBP* bp FindBreakpoint(addr); if (!bp) return false; // 不是自己下的断点放行 // 先把 0xCC 替换回原始字节 WriteProcessMemory(g_hProcess, (LPVOID)bp-addr, bp-orig, 1, nullptr); // 拿到上下文EIP 回退到断点地址同时置 TF 位准备单步 CONTEXT ctx GetThreadCtx(g_hThread); ctx.Eip bp-addr; // 32 位目标用 Eip64 位用 Rip ctx.EFlags | 0x100; // TF执行完下一条指令后再触发单步异常 SetThreadContext(g_hThread, ctx); g_pendingRestore bp; // 单步异常时要把断点写回去 return true; }完整流程是断点已下 → 目标执行到 0xCC触发异常 → 调试器恢复原始字节 回退 EIP 置 TF → ContinueDebugEvent 用 DBG_CONTINUE 继续 → 目标执行一条原始指令 → 单步异常触发 → 调试器把 0xCC 重新写回 → 窗口刷新停住等待用户操作。这个流程里有一个容易被忽略的窗口期从恢复原始字节到重新写回 0xCC 之间如果其他线程恰好执行到了这个地址就会穿过断点。OllyDbg 在暂停所有线程的模型下没有这个问题因为调试事件处理期间目标进程本来就全停着。参数说明EIP 回退一字节是因为 0xCC 占一个字节。如果原来下断点的指令是 0xCC 自己比如目标代码里本来就有 INT3命中后不能盲目回退必须用 FindBreakpoint 先确认。这个校验逻辑别省我在早期版本里就是因为没校验把目标程序自带的异常处理当成自己的断点结果断点管理整个乱套。3.2 内存断点用页保护属性做访问监控内存断点不修改任何代码它的原理是把目标地址所在的页设置成 PAGE_GUARD。当目标进程访问这个页时CPU 触发 STATUS_GUARD_PAGE_VIOLATION 异常调试器在异常回调里判断访问地址是否落在监控区间内。// 设置内存访问断点监控 addr 位置长度 1~4 字节都行 bool SetMemoryBP(DWORD addr, size_t size) { DWORD oldProtect 0; // PAGE_READWRITE 是基础属性PAGE_GUARD 是关键 // 目标页一旦被访问系统先抛出 GUARD_PAGE 异常并自动清掉 GUARD 位 BOOL ok VirtualProtectEx(g_hProcess, (LPVOID)addr, size, PAGE_READWRITE | PAGE_GUARD, oldProtect); if (!ok) { // 地址不在已提交内存范围内时会失败 return false; } m_bpRange {addr, size}; // 记录监控区间命中后判断 return true; } // 在异常回调里判断 bool OnGuardPageHit(DWORD faultAddr) { // 访问地址必须落在区间内否则不是自己的内存断点 if (faultAddr m_bpRange.addr || faultAddr m_bpRange.addr m_bpRange.size) return false; // 命中后 PAGE_GUARD 已被系统自动清除 // 需要重新设置一次否则后续访问不再触发 SetMemoryBP(m_bpRange.addr, m_bpRange.size); return true; }参数说明VirtualProtectEx 的 size 会按页粒度生效所以要监控的区间尽量对齐到页边界否则会把同一页里不相关的访问也算进来。PAGE_GUARD 是一次的命中后系统自动清除该标志所以处理完必须重新设置否则第二次访问就穿过了。这个机制决定了内存断点天然适合“第一次访问时停下来”的场景不适合做长期计数监控。OllyDbg 里的内存断点命中后也是自动失效的行为一致。3.3 硬件断点DR0~DR3 与 DR7 的编码规则硬件断点用的是处理器调试寄存器不修改代码所以能绕过对 0xCC 的检测。Dr0~Dr3 存四个断点地址DR7 控制每个断点的启用状态、类型和长度。类型分执行、写入、读写执行型断点的长度位必须设为 0这是硬性规则。// 设置硬件断点 // drIndex: 0~3对应 DR0~DR3 // type: 0执行, 1写, 3读写 bool SetHardwareBP(HANDLE hThread, DWORD addr, int drIndex, int type) { CONTEXT ctx {}; ctx.ContextFlags CONTEXT_DEBUG_REGISTERS; if (!GetThreadContext(hThread, ctx)) return false; DWORD regs[4] {ctx.Dr0, ctx.Dr1, ctx.Dr2, ctx.Dr3}; regs[drIndex] addr; // 断点地址 ctx.Dr0 regs[0]; ctx.Dr1 regs[1]; ctx.Dr2 regs[2]; ctx.Dr3 regs[3]; // DR7 低 8 位是启用位0 号断点用 bit01 号用 bit2以此类推 ctx.Dr7 ~(3 (drIndex * 2)); // 先清零该断点相关位 ctx.Dr7 | (1 (drIndex * 2)); // 置局部启用位 // 类型和长度在 DR7 的 16~31 位每 4 位一组 ctx.Dr7 ~(0xF (16 drIndex * 4)); // 清零 ctx.Dr7 | (type (16 drIndex * 4)); // 写入类型 return SetThreadContext(hThread, ctx) ! 0; }DR7 的位分配可以用下面这张表记住DR7 位段作用取值bit0/2/4/6断点 0~3 的局部启用位1 启用bit1/3/5/7全局启用位一般不用bits 16~19断点 0 的类型和长度类型低 1 位 长度高 1 位每组 4 位断点 1~3 类推长度第 2 位参数说明硬件断点是线程上下文的一部分每个线程的 DR 寄存器是独立的所以要对所有目标线程分别设置只在主线程设会漏掉其他线程的访问。执行型断点长度必须为 0否则设置无效。硬件断点命中后调试寄存器里会留下断点地址目标程序一旦检查 DR 寄存器就能发现调试器。OllyDbg 的做法是在暂停显示寄存器时把读回的 DR0~DR3 清零再展示这个技巧放到后面讲窗口时细说。4. 反汇编引擎接入与内存窗口让调试器“看得懂”目标代码4.1 为什么不自研 x86 解码器而是接开源引擎x86/x64 指令是变长的最长可达 15 字节ModRM、SIB、前缀组合复杂到反直觉。自己做解码器光是 RDOS 等特殊情况就能耗掉几周时间而且边界错误很难查。OllyDbg 当年是自己写了一套解码表但那是靠海量调试撑起来的。我的建议是接一个成熟的开源反汇编引擎自己只写一层薄封装。选型标准就三条能输出指令长度、能格式化助记符和操作数、能标注指令类型调用、跳转、返回。长度用来定位下一条指令格式化文本用来显示类型用来做代码高亮。至于引擎内部用了什么算法不是调试器开发者需要操心的。我一般会在工程里单独建一个模块封装反汇编逻辑UI 层只消费一种统一的指令结构体。这样以后想换引擎只需要改封装层不需要动窗口代码。4.2 指令条目结构与格式化输出仿 OllyDbg 的汇编窗口每一行至少要显示四个列地址、原始字节、汇编文本、注释。注释是 OllyDbg 很讨喜的设计比如 call 指令会显示被调用的地址jmp 会显示跳转目标帮我做标记这样阅读效率高很多。// 统一的反汇编指令条目UI 只认这个结构 struct DisasmLine { DWORD addr; // 虚拟地址 BYTE raw[16]; // 原始机器码用于字节列显示 int rawLen; // 指令长度x86 最长 15 字节 char text[64]; // 格式化后的汇编文本 char comment[64]; // 注释call 的目标函数、跳转地址等 int flags; // 指令分类调用/跳转/返回/普通 }; // 解码一条指令填充上面的结构 DisasmLine DecodeOne(const BYTE* buf, DWORD addr) { DisasmLine line {}; line.addr addr; // 引擎输入一段内存 地址 // 输出指令长度 len、格式化文本 text、分类标志 flags int len Disasm(buf, addr, line.text, line.flags); line.rawLen len; memcpy(line.raw, buf, min(len, 16)); // 如果是 call/jmp读取操作数里的目标地址再做注释 if (line.flags FLAG_CALL) { DWORD target ResolveTargetAddr(buf, addr, len); FormatComment(line.comment, target); } return line; }参数说明DecodeOne 传入的内存至少要有 15 字节否则遇到长指令会越界这一点需要在调用侧保证。ResolveTargetAddr 处理相对跳转和间接跳转相对跳转的目标是addr len 操作数间接跳转要读内存才能得到目标所以取不到时注释留空就好不要硬猜。我给这个结构体加 flags 字段后高亮代码和断点图标都变得很简单值得早期就设计上。4.3 内存转储窗口与实际刷新策略内存窗口显示的是目标进程地址空间里的原始字节。OllyDbg 的 dump 窗口每行 16 字节左侧地址、中间十六进制、右侧 ASCII。实现不难难点在刷新时机。BYTE g_dump[16][16]; // 16 行 × 16 字节的缓冲区 DWORD g_dumpBase 0x400000; // 当前窗口左上角地址 void RefreshDump() { SIZE_T read 0; // 目标进程在调试事件处理期间是挂起的此刻读内存最稳 // 如果读失败可能是指针指向了未提交的内存区域 BOOL ok ReadProcessMemory(g_hProcess, (LPCVOID)g_dumpBase, g_dump, sizeof(g_dump), read); if (!ok) { // 填充成 ?? 占位而不是让窗口卡住或崩溃 memset(g_dump, 0xCC, sizeof(g_dump)); return; } // 刷新到列表控件第 i 行第 j 列显示 g_dump[i][j] }刷新策略上我吃过亏一开始用定时器每秒全量刷新结果目标在运行的时候读到的内存一直在变窗口滚动根本跟不上。后来改成事件驱动只在断点命中、单步暂停、用户滚动时刷新。这样既保证数据一致又不会让 UI 线程忙死。滚动时 g_dumpBase 按 16 字节对齐调整同时高亮当前选中的地址让用户能跟着 EIP 走。5. 常见问题与排查仿 OllyDbg 路上最容易翻车的 5 处5.1 软件断点命中后 EIP 没有回退断点反复触发现象目标反复停在同一条指令上单步也不动或者断点计数一下就乱了。 原因CPU 执行 INT3 后 EIP 已经指向下一条指令处理器中断时会把异常触发点后的地址给调试器。 解决命中后从上下文里把 EIP 减 1或者像我上面那样直接赋值为断点地址。注意赋值比减 1 更稳妥因为你校验过地址确实是自己下的断点直接赋值不会因为操作数长度不同而出错。5.2 单步标志 TF 被异常处理吞掉程序像开了加速现象设置 TF 后目标程序不是执行一条指令而是直接跑到下一个断点甚至跑飞。 原因某些异常流程进入时处理器会修改 EFlags之前设置好的 TF 位被清掉单步请求被吞。 解决每次单步前重新取上下文重写 TF 位不要指望一次设置管到底。顺序必须是先恢复内存、再改 EIP、再置 TF、最后 ContinueDebugEvent。如果顺序反了单步执行的是 0xCC 而不是原始指令会再次触发断点。5.3 32 位调试器附加 64 位目标读写内存大面积失败现象DebugActiveProcess 返回成功但 ReadProcessMemory 读回来的全是失败或全 0。 原因32 位调试器的 CONTEXT 结构在 64 位目标下只读取到一部分寄存器窗口显示鬼畜同时 64 位目标的指针长度是 8 字节32 位代码里用 DWORD 存地址会截断。 解决调试器本体直接编译成 64 位版本再判断目标进程是 32 位还是 64 位来决定 CONTEXT 结构。判断目标位数用 IsWow64Process目标是 32 位时读取的是 Wow64 上下文目标是 64 位时用原生上下文。这个分支建议在事件循环初始化时确定一次后面所有取上下文的地方都走同一个函数。5.4 界面线程和调试线程共用一个消息循环拖动窗口时目标卡死现象拖动调试器窗口时目标进程的逻辑完全停住松开鼠标才恢复。 原因调试事件处理和窗口消息处理挤在同一个线程WaitForDebugEvent 把线程阻塞住了窗口消息得不到处理。 解决把事件循环放到独立线程事件到达后向 UI 线程投递自定义消息窗口只在收到消息时刷新。不要小看这个坑它不会崩但会让调试器显得极其难用而且很难意识到是线程模型的问题。5.5 调试器痕迹被目标识别断点下方指令被反复检查现象目标程序到了某段逻辑后行为异常但内存里找不到 0xCC怀疑被调试器检测了。 原因常见检测点是 PEB 里的 BeingDebugged 标志以及异常端口查询。调试器附加后系统会自动设置这些标志目标程序一查就知道自己被附加了。 解决在调试循环初始化时把目标进程 PEB 中的 BeingDebugged 标志清零对于校验 DebugPort 的目标需要用原生 API 把查询结果拦掉或篡改。硬件断点可以在暂停显示寄存器时把 DR0~DR3 清零再展示防住另一类检查。OllyDbg 本身也有类似的隐藏插件说明这是调试器的基本功不是偏门技巧。6. 进阶打磨条件断点、调用栈回溯与日志窗口的落地顺序6.1 条件断点表达式求值的最小实现条件断点的价值是让调试器只在满足条件时停下来省去手动跳过大量无用断点的时间。OllyDbg 的表达式语法很全但日常高频用的就两类寄存器比较、指针解引用比较。我建议先做这两类语法用一行字符串表达别上来就做完整表达式解析器。// 条件表达式求值只做两类高频形式 // 支持 eax0x401000 和 [esp4]0x100 两种 bool EvalCondition(const char* expr, CONTEXT ctx) { DWORD val 0, target 0; // 形式一寄存器直接比较 if (sscanf(expr, eax0x%x, val) 1) return ctx.Eax val; if (sscanf(expr, ecx0x%x, val) 1) return ctx.Ecx val; // 形式二取内存值比较比如 [esp4] 是第一个参数 int off 0; if (sscanf(expr, [esp%d]0x%x, off, val) 1) { DWORD addr ctx.Esp off; SIZE_T read 0; ReadProcessMemory(g_hProcess, (LPCVOID)addr, target, sizeof(target), read); return target val; } return true; // 解析不了就当无条件断点别静默忽略 }参数说明条件不成立时断点逻辑要把 TF 清掉、恢复原始字节然后用 DBG_CONTINUE 继续不要走单步流程否则条件断点会拖慢整个程序执行。实测下来一个[esp4]0x100这样的条件断点在普通目标上能让调试循环慢 20% 左右换成更复杂的表达式会更多所以条件断点的求值函数要尽量精简。6.2 调用栈回溯与日志窗口先有数据再有分析调用栈回溯是暂停状态下最常用的分析动作。常见做法是从 ESP 向上扫描栈内存每四个字节当候选返回地址再结合模块范围判断是否落在可执行区域内。这个暴力回溯在 200 行代码内能做出来效果足够日常分析用。日志窗口不需要专门设计就是一个环形缓冲加列表控件把断点命中、模块加载、异常事件都追加进去。高频日志要注意批量追加别一条条触发 UI 刷新。落地顺序我的建议是事件循环 → 软件断点 → 反汇编窗口 → 硬件断点 → 内存断点 → 条件断点。我自己早期跳过了内存断点去做别的回头补时发现页保护相关的边界问题比预想多。后来换了顺序每层都有验证过的改动机反而推进更快。这个压缩包如果只给你留下了六个字“仿 OllyDbg 实现”那最值得先复刻的就是这套顺序——先把断点状态机磨利界面本质上只是一个表达层。希望帮到你。本文还有配套的精品资源点击获取