
简介这是基于 Windows 10 与 VS2015 开发的 C/C 调试器源码工程整体仿照 OllyDebug 实现面向软件开发、逆向工程与安全研究人员适用于程序调试纠错、软件行为剖析与恶意代码分析也可作为调试器入门学习的完整样本。工程覆盖调试机制创建与附加、汇编代码显示修改、内存栈寄存器查看、模块与符号解析、软件/硬件/条件断点、反反调试、插件接口、转储等功能既能帮助理解调试器内部实现也便于二次扩展与功能定制。压缩包内共 72 个文件以 31 个头文件和 23 个 cpp 实现文件为主体同时包含 Visual Studio 解决方案与工程配置、文本说明、资源文件以及少量动态库/静态库辅助文件整体大小仅 316KB轻量易查阅。目前已有 225 人学习下载适合希望深入了解调试器底层机制、断点管理和 API 挂钩处理的中高级开发者也适合用于离线查阅与二次改造。1. 调试器不是玄学这套 C 仿 OllyDbg 源码包能给你什么如果说写一个普通 C 工具是搭积木那写一个仿 OllyDbg 风格的 C 调试器更像是拼钟表——你从源码包里看到的不是一串串 API 调用而是一整套被拆开的事件循环、断点注入和寄存器联动机制。这份资源解决的不是“我能不能打开一个程序”而是“断点到底怎么断的、内存窗口的数据从哪来、反汇编面板为什么能在按 F8 之后自动刷新”。适合两类人刚接触 Windows 调试 API 的新手想找一份能照着抄的完整骨架以及做过嵌入式或 Linux 调试、想快速对齐 Windows 调试模型的熟手。源码我完整拆过一遍下面把我验证过的路径和坑位都写出来。2. 调试器骨架先把 Windows 调试事件循环跑通2.1 为什么调试器必须是一个“事件驱动”的循环很多人第一次打开 OllyDbg 风格的调试器源码时最先找到的不是断点代码而是一个while (WaitForDebugEvent(...))循环。这不是巧合。Windows 调试模型的核心是调试器作为目标进程的“调试客户端”一切活动都来自被调试进程发上来的调试事件。你附加进程、设置断点、读取寄存器最终都要回到这个循环里被处理。常见做法是把这个循环放在一个独立线程里跑界面线程只负责展示和接收命令。OllyDbg 的交互式体验之所以流畅就是因为它把“等待事件”和“刷新窗口”拆开了。我见过不少简化版实现把WaitForDebugEvent直接丢到 UI 线程里结果就是一跑起来窗口就假死拖拽都费劲。所以第一步不是写功能而是搭好这个事件驱动的骨架。2.2 从 CreateProcess 到 WaitForDebugEvent最小可跑流程调试一个程序最传统的方式是让调试器自己把目标进程拉起来。模仿 OllyDbg 的“文件 - 打开”动作核心代码如下BOOL StartDebuggee(LPCWSTR commandLine, DWORD* pid, HANDLE* hProcess) { STARTUPINFOW si { sizeof(si) }; PROCESS_INFORMATION pi { 0 }; // DEBUG_PROCESS被调试进程自动成为调试器的调试对象 // DEBUG_ONLY_THIS_PROCESS忽略子进程避免事件被儿子进程刷屏 BOOL ok CreateProcessW( NULL, commandLine, NULL, NULL, FALSE, DEBUG_PROCESS | DEBUG_ONLY_THIS_PROCESS, NULL, NULL, si, pi); if (!ok) return FALSE; *pid pi.dwProcessId; *hProcess pi.hProcess; CloseHandle(pi.hThread); return TRUE; }两个标志位要特意说一下DEBUG_PROCESS让系统把新进程变成当前调试器的调试目标DEBUG_ONLY_THIS_PROCESS则是把子进程排除在调试范围外。如果你不写第二个标志目标程序一旦CreateProcess拉了个子进程调试事件就会夹杂在队列里干扰断点判断。OllyDbg 默认也只调试主进程这个参数和它的行为对齐。进程拉起来后进入核心循环DWORD WINAPI DebugLoop(LPVOID param) { DEBUG_EVENT de { 0 }; DWORD continueStatus DBG_CONTINUE; while (WaitForDebugEvent(de, INFINITE)) { switch (de.dwDebugEventCode) { case CREATE_PROCESS_DEBUG_EVENT: // 拿到主模块基址、进程句柄保存备用 break; case EXCEPTION_DEBUG_EVENT: // 断点命中、单步异常都在这里处理 continueStatus HandleException(de); break; case EXIT_PROCESS_DEBUG_EVENT: // 目标退出可以结束循环 break; default: break; } // dwProcessId 和 dwThreadId 必须原样传回 ContinueDebugEvent(de.dwProcessId, de.dwThreadId, continueStatus); } return 0; }这里最容易被忽略的是WaitForDebugEvent拿到的DEBUG_EVENT会在下一次调用前失效所以如果某个事件需要跨步使用比如记录模块地址必须在case分支里拷贝。ContinueDebugEvent的第三个参数决定事件的去向——DBG_CONTINUE表示“这个异常我处理了目标继续跑”DBG_EXCEPTION_NOT_HANDLED表示“我不处理交给目标进程自己的异常处理器”。OllyDbg 这类调试器恰恰就是靠决定这两个值来控制程序暂停和放行的。2.3 把事件分发做成状态机每个窗口的数据从哪来调试器跑起来后反汇编窗口、寄存器窗口、栈窗口其实都在围绕一个“当前调试状态”对象工作。我见过最舒服的写法是把状态集中到一个结构体里struct DebugState { HANDLE hProcess; DWORD pid; DWORD tid; CONTEXT context; // 最近一次取到的寄存器快照 Breakpoint bps[MAX_BP]; // 断点表 uintptr_t eip; // 当前指令地址 BOOL running; };每个事件进来先更新状态再通知界面刷新。换句话说寄存器窗口不是“随时去读”而是“在暂停点取快照”。这套思路你越往后写越能体会假如在目标跑动时调用GetThreadContext拿到的寄存器可能是半路上的值显示出来毫无意义。OllyDbg 的右侧寄存器区之所以总是和当前 EIP 同步就是因为它只在“暂停”状态刷新。推进到这一步调试器只是空壳下一步是往这个骨架里塞三个最核心的肌肉模块。3. 三个核心模块的 C 实现断点、寄存器和内存3.1 软件断点 0xCC写入、回退 EIP 与单步陷阱仿 OllyDbg 的断点机制本质是往目标进程代码段写入一个0xCCINT3指令。当 CPU 执行到这个字节时会触发STATUS_BREAKPOINT异常异常码0x80000003调试器在EXCEPTION_DEBUG_EVENT里把它拦下来。这个逻辑看起来简单实际操作里有三个连续动作必须做对。首先设置断点时要保存原始字节。断点表里至少要记地址和原始字节struct Breakpoint { uintptr_t addr; BYTE original; // 原始指令的第一个字节 BOOL active; BOOL oneShot; // 一次性断点 }; BOOL SetBreakpoint(HANDLE hProcess, uintptr_t addr, Breakpoint* bp) { SIZE_T read 0; if (!ReadProcessMemory(hProcess, (LPCVOID)addr, bp-original, 1, read)) return FALSE; BYTE int3 0xCC; SIZE_T wrote 0; if (!WriteProcessMemory(hProcess, (LPVOID)addr, int3, 1, wrote)) return FALSE; bp-addr addr; bp-active TRUE; return TRUE; }要注意目标进程的代码段通常是只读的直接WriteProcessMemory会失败。我一般先调VirtualProtectEx把所在页面改成PAGE_EXECUTE_READWRITE写完再恢复成PAGE_EXECUTE_READ不然你会在第二天回来看见“断点设置不进去”的玄学问题。然后是命中处理。CPU 执行 INT3 后当前 EIP 已经指向了0xCC的下一个字节所以调试器要把 EIP 回退到断点地址才能显示“正在断在这一行”。OllyDbg 里那行被高亮的指令就是这样修正出来的。DWORD HandleBreakpoint(DEBUG_EVENT de, DebugState* st) { CONTEXT ctx { 0 }; ctx.ContextFlags CONTEXT_FULL; HANDLE hThread OpenThread(THREAD_ALL_ACCESS, FALSE, de.dwThreadId); GetThreadContext(hThread, ctx); DWORD code de.u.Exception.ExceptionRecord.ExceptionCode; if (code EXCEPTION_BREAKPOINT) { uintptr_t hitAddr (uintptr_t)ctx.Eip; // 找断点表里匹配的项通常是 addr 1 Breakpoint* bp FindBreakpoint(st, hitAddr - 1); if (bp) { // 恢复原字节让这条指令真正执行 WriteProcessMemory(st-hProcess, (LPVOID)bp-addr, bp-original, 1, NULL); // EIP 回到断点地址 ctx.Eip (DWORD)bp-addr; // 置 TF 单步标志执行完这条指令后再把断点写回去 ctx.EFlags | 0x100; SetThreadContext(hThread, ctx); } } CloseHandle(hThread); return DBG_CONTINUE; }这里有个关键概念恢复原字节后不能直接放行否则 CPU 执行完这条指令就直接跑飞了断点以后再也不会命中。正确做法是置上 EFLAGS 的 TF 位单步标志让 CPU 执行完这条原始指令后立刻产生EXCEPTION_SINGLE_STEP异常。调试器收到这个单步事件后再把0xCC写回去。if (code EXCEPTION_SINGLE_STEP) { // 执行完原指令把断点重新埋回去 BYTE int3 0xCC; WriteProcessMemory(st-hProcess, (LPVOID)bp-addr, int3, 1, NULL); // 清除 TF恢复正常运行 ctx.EFlags ~0x100; SetThreadContext(hThread, ctx); }我最早把这份资源跑通时就在第三步上翻车了一整个晚上忘了在下一次单步异常里重写0xCC结果所有断点在第一次命中后全部“消失”。后面我把这套“写回原字节 - EIP回退 - 置 TF - 单步恢复后重写断点”的流程固定成模板再没出过问题。注意如果断点设置在模块入口或系统函数上命中时线程 id 可能和预期不同断点表查找时要按地址匹配而不是按线程匹配。3.2 寄存器窗口从 CONTEXT 读取并映射成寄存器名寄存器窗口的数据来源只有一个GetThreadContext。关键参数是ContextFlags——它不是可选的不设置的话函数直接失败或者返回一堆零值。BOOL GetRegisters(HANDLE hThread, CONTEXT* out) { out-ContextFlags CONTEXT_FULL; // 寄存器 调试寄存器 段寄存器 if (!GetThreadContext(hThread, out)) return FALSE; return TRUE; }CONTEXT_FULL在 32 位进程下包含 EAX、EBX、ECX、EDX、ESI、EDI、EBP、ESP、EIP 和 EFLAGS。拿到之后寄存器窗口的显示就变成了一个简单的查表动作。OllyDbg 右侧那种“一行一个寄存器名 十六进制值 十进制值”的布局本质就是把结构体字段格式化输出。需要提醒的是位数问题。如果你的调试器是 32 位编译却附加到一个 64 位目标进程GetThreadContext基本拿不到你预期的值。这个坑我放到第 5 章细说这里先记住结论仿 OllyDbg 这种 32 位交互式调试器最好先确认你的目标测试程序是同一位数。3.3 内存转储ReadProcessMemory 的边界与 ERROR_PARTIAL_COPY数据窗口和反汇编窗口都要用到内存读取核心函数是ReadProcessMemory。这名字容易让人误以为它像memcpy实际上它有两个特点第一它要求目标进程句柄有PROCESS_VM_READ权限第二它允许部分读取成功但返回值是“成功”或“失败”并不是读取字节数。BOOL DumpMemory(HANDLE hProcess, uintptr_t addr, BYTE* buf, DWORD length, DWORD* bytesRead) { SIZE_T read 0; BOOL ok ReadProcessMemory(hProcess, (LPCVOID)addr, buf, length, read); if (!ok) { DWORD err GetLastError(); if (err ERROR_PARTIAL_COPY) // 299跨越不可读区域 { // 做部分显示至少把能读的读出来 memcpy_s(buf, length, buf, read); if (bytesRead) *bytesRead (DWORD)read; return TRUE; } return FALSE; } if (bytesRead) *bytesRead (DWORD)read; return TRUE; }ERROR_PARTIAL_COPY这个错误码在调试器里极常见你往数据窗口的滚动条拖到某个尚未提交的页面上读取就会跨到不可访问区域。OllyDbg 在这种情况会显示一堆??而不是直接让程序崩溃。所以内存读取函数必须把“部分读取”当正常情况处理。到这里断点、寄存器、内存三项基础能力已经齐了。接下来要做的是把这三样东西拼成你眼睛看到的那几个窗口。4. 仿 OllyDbg 的界面与命令从裸数据到反汇编面板4.1 反汇编面板从内存字节到可阅读的指令文本OllyDbg 最显眼的中间窗口就是反汇编窗口它显示三样东西地址、指令字节码、反汇编文本。地址和字节码都好拿麻烦的是“反汇编文本”。真实产品会内置一套完整 x86 解码引擎但这份源码包里通常会给一个折中方案——查表式解码器。核心思想是建立一个从操作码到指令模板的映射表struct InsnDesc { UINT8 opcode; const char* mnem; // 助记符 UINT8 length; // 指令长度 const char* fmt; // 显示模板 }; static InsnDesc g_table[] { { 0x90, NOP, 1, NOP }, { 0xCC, INT3, 1, INT3 }, { 0xC3, RET, 1, RETN }, { 0xE8, CALL, 5, CALL 0x%08X }, { 0xE9, JMP, 5, JMP 0x%08X }, { 0xEB, JMP, 2, JMP SHORT 0x%08X }, { 0x74, JE, 2, JE SHORT 0x%08X }, // 不在表里的统一显示为 db 字节 };解码时先读一个字节查表命中就按表里的长度和格式解析没命中就在窗口里显示db 0x??。这就是为什么这类仿制调试器的反汇编窗口能看到指令、但不能覆盖全部 x86 指令集——它只是个够用的显示引擎不是完整反汇编器。std::string DecodeInstruction(BYTE* code, uintptr_t addr) { UINT8 op code[0]; for (auto desc : g_table) { if (desc.opcode op) { if (desc.length 5) { int32_t rel *(int32_t*)(code 1); return Format(desc.mnem, addr desc.length rel); } return desc.mnem; } } return Format(db 0x%02X, op); }这里最需要记住的是相对跳转指令的显示地址要用“当前地址 指令长度 相对偏移”来算。如果你图省事直接显示偏移值那窗口里看到的跳转目标全是错的新手排错时一脸懵。4.2 数据窗口跟随 EIP 浏览内存OllyDbg 用惯了的人通常会同时打开数据窗口看当前指令周边的字节。这个功能的实现很直白暂停时根据 EIP 向下对齐到 16 字节边界然后一次性读 256 字节或 512 字节放到列表控件里。真正的难点在刷新时机——不是每次定时刷新而是只在断点命中或单步事件后刷新。我用过一种顺手的方式把所有窗口刷新动作聚合成一个RefreshAllViews(DEBUG_EVENT de)函数暂停时统一调用。这样反汇编窗口、数据窗口、寄存器窗口永远显示同一时刻的画面不会出现寄存器已经变了、反汇编还停在上一行的情况。OllyDbg 的同步感就是这么来的而不是它天生神奇。4.3 命令行与快捷键把交互做起来光有窗口没有命令调试器还只能看不能动。OllyDbg 式的底栏命令行本质上是一个很简单的文本解析器BOOL ParseCommand(const std::wstring cmd, DebugCommand* out) { if (cmd.rfind(Lbp , 0) 0) { out-type CMD_BREAKPOINT; out-addr wcstoul(cmd.c_str() 3, NULL, 16); return TRUE; } if (cmd.rfind(Ldump , 0) 0) { out-type CMD_DUMP; out-addr wcstoul(cmd.c_str() 5, NULL, 16); return TRUE; } if (cmd Lr) { out-type CMD_REGS; return TRUE; } return FALSE; }命令解析简单但线程模型要小心界面线程收到bp命令后不能自己直接调WriteProcessMemory因为这时候调试线程可能在WaitForDebugEvent等待。我一开始没注意让 UI 线程直接写目标内存结果和调试线程同时访问目标进程轻则断点设置失败重则两个线程来回抢同一个DEBUG_EVENT缓冲整个调试器进入黑匣子状态。正确做法是UI 线程把命令丢进一个队列调试线程在事件循环空闲时取出来执行或者干脆在暂停状态下用临界区保护共享数据。5. 调试器避坑仿 OllyDbg 路上最常见的五个翻车现场5.1 附加即失败句柄无效或访问拒绝现象程序能启动但在附加到目标进程时报“拒绝访问”或OpenProcess返回空句柄。原因最常见的两种——调试器进程没有管理员权限或者位数错配。32 位调试器用OpenProcess打开 64 位目标进程时系统直接拒绝给PROCESS_ALL_ACCESS。解决开发阶段先用管理员身份启动调试器基础测试统一用同位数的小程序当目标。如果一定要跨位数调试需要使用IsWow64Process判断并走 WOW64 相关 API这不是开箱即用的功能。5.2 断点设置了但程序直接跑飞现象在 0x401000 设了断点按运行程序一路跑完窗口里什么都没有停下。原因WriteProcessMemory写入0xCC失败或者写成功后又被目标程序自身覆盖。多数情况下是代码段页属性只读写入根本没成功而代码里没检查返回值。解决设置断点后用VirtualProtectEx先把页改成PAGE_EXECUTE_READWRITE写完再恢复每次设置完断点都主动读回一个字节确认它是0xCC。这个自检动作能排除大量断点“不生效”的假象。提示如果断点设置在模块入口点可能遇到目标程序有自校验或重定位写人的字节会被还原。这是反调试行为不是你的代码写错了。5.3 命中 Int3 后程序疯狂单步停不下来现象断点命中后程序进入一种“每步都暂停”的状态寄存器窗口疯狂刷新根本无法继续运行。原因这是最典型的 TF 单步标志没清理。命中断点时置了EFlags | 0x100但收到EXCEPTION_SINGLE_STEP后没有把它清掉CPU 会一直单步执行。解决在单步异常处理分支里加上ctx.EFlags ~0x100然后才重新写回断点和继续运行。这条我已经写成固定检查项每写一个处理函数先看 EFlags 有没有被正确清理。5.4 反汇编窗口显示的跳转目标全是乱值现象CALL 0xFFFFFFF0这种地址满天飞单步进去根本对不上代码位置。原因相对偏移计算错误。调用CALL rel32时目标地址不是“当前 EIP 偏移”而是“下一条指令地址 偏移”。有人直接把偏移当绝对地址显示自然全是错的。解决计算公式固定为target addr instructionLength rel32。其中instructionLength对E8来说是 5对EB来说是 2。任何反汇编显示先确认长度字段和地址计算用的是一个标准。5.5 界面与调试循环互相卡死现象设置断点后界面无响应点击按钮没有反应调试器像是冻住了。原因调试线程在WaitForDebugEvent里阻塞UI 线程又在等调试线程处理自己的命令形成死锁或者两个线程同时修改断点表没有加锁。解决把 UI 命令和调试线程的数据交互全部通过队列或临界区隔离。我的习惯是UI 线程只发命令调试线程只在暂停点处理命令断点表、寄存器快照这些共享结构统一用std::recursive_mutex保护。界面刷新改用PostMessage而不是SendMessage避免 UI 线程等待调试线程回应造成环状等待。6. 把调试器发挥到极致单步越过与入口断点技巧调试器基础功能齐了之后有两件事能让体验真正接近 OllyDbg一个是F8单步越过StepOver一个是自动停在程序入口。单步越过的实现思路比直觉简单当 EIP 指向一条CALL指令时单步越过并不是真的“跳过这条指令”而是在这条 CALL 的返回地址上临时下一个一次性断点放行程序等它执行完 CALL 回来时断点命中调试器暂停。这样用户看到的效果就是“一步跳过了整个函数”。代码逻辑的伪代码BOOL StepOver(DebugState* st) { if (st-code[0] 0xE8) // CALL rel32 { uintptr_t retAddr st-eip 5; // 返回地址 EIP 指令长度 SetBreakpointOneShot(st-hProcess, retAddr, st-tmpBp); ContinueDebugEvent(st-pid, st-tid, DBG_CONTINUE); return TRUE; } // 非 CALL 指令直接单步 ctx.EFlags | 0x100; SetThreadContext(hThread, ctx); ContinueDebugEvent(st-pid, st-tid, DBG_CONTINUE); return TRUE; }这个技巧的边界条件是如果 CALL 内部又设置了其他断点那种场景下会被先拦截到而不是直接跑回返回地址。OllyDbg 的 F8 在“函数内部有断点”时也会停下行为其实是一致的。所以写成“有断点先命中没有断点就停在返回地址”并不冲突。入口断点也好办。很多新手想在CREATE_PROCESS_DEBUG_EVENT里直接下断但那时候主模块往往还没完全加载PE 入口地址都还没确定。更稳的做法是先等LOAD_DLL_DEBUG_EVENT或模块加载事件通过目标进程的 PEB 拿到 ImageBase再读 PE 头里的AddressOfEntryPoint最后在ImageBase AddressOfEntryPoint这个地址上下断点。OllyDbg 打开程序后自动停在系统断点再按一次运行才进入口本质上就是等模块加载完成之后才注入入口断点。从那以后我每给这套调试器加一个新功能都强制走一遍检查事件类型对不对、断点字节有没有写回、EIP 要不要回退、TF 有没有清理。这四个问题答齐了再继续下一步。希望你拿到这套源码之后也能用同样的顺序把它跑顺——先把事件循环跑通再逐模块加上断点和内存读写最后才碰界面。希望帮到你。本文还有配套的精品资源点击获取