简介:围绕 Windows Socket 中 send() 函数的拦截与数据落盘,这份资料面向具备一定 C/C++ 与网络编程基础、希望学习 API Hook 技术的开发者及安全审计工具研究者,重点解决如何截获 ws2_32.dll 的发送数据并写入 OB 文件的问题。压缩包共 23 个文件,约 898KB,包含 cpp/h 源码、dll/lib/def 等工程与链接文件,dsp/dsw/opt 等 VC6 工程配置,以及 pdb/obj/ilk/pch 等编译调试中间产物和说明文档,便于在旧版开发环境中直接打开、编译与对照调试。已有 1263 人学习下载。资料围绕 IAT 替换、自定义钩子处理与原函数转发等关键环节给出可运行工程,读者可据此理解发送缓冲区的捕获、OB 文件写入以及保持原始 send 正常发包的流程,并进一步迁移到网络监控、协议分析和安全审计场景;同时需关注钩子带来的稳定性、性能开销与合法合规问题。
1. 抓包工具看不到的那一段:从 ws2_32.dll 的 send 下刀
调一个私有长连接协议的时候,我遇到过最难受的情况:抓包工具里只有一堆看不出规律的分片,业务侧的日志又只打印"发送成功"三个字,中间那段真正被塞进 socket 的字节完全是个黑匣子。协议是自己的、字段是加密的、连包长都被上层重组过,想在应用层复现一遍成本太高。真正省事的做法只有一个方向:在进程里把ws2_32.dll的send拦住,拿到调用瞬间的buf和len,原样落成一个二进制.ob文件,事后再慢慢拆。ws2_32.dll拦截 send 函数并把发送内容写到 ob 文件里,本质就是这么一件事——它解决的是"我知道程序发了什么,但拿不到原始字节"这个问题。适合做协议调试、第三方组件逆向、私有 RPC 排障的从业者,也适合想搞明白用户态 API 钩子到底怎么落地的人。
2. 三条拦截路线:IAT、内联、代理 DLL 的取舍
拦ws2_32!send这件事,Windows 用户态至少有三条成熟路线,每条路线的覆盖范围、稳定性、实现成本差别很大。选错路线的典型后果是:代码写完了、DLL 也注进去了、日志一条没有,然后花两天怀疑是自己钩子写错了——其实目标程序压根没走你钩的那条路径。这一章把三条路线的边界摊开讲清楚,后面再动手。
2.1 IAT Hook:改导入表最省事,盲区也最明确
IAT Hook 的思路是改数据而不是改代码:PE 文件加载后,导入表里每一项都存着"这个函数在当前进程里的真实地址",我们把这个地址项替换成自己的函数地址,调用方call [IAT 项]的时候自然就跳到我们这里。好处是不需要反汇编引擎、不需要处理指令对齐、不需要考虑函数头被别的钩子占用的冲突,实现难度最低。
// 遍历指定模块的导入表,把 dllName!funcName 对应的 IAT 项换成 newFunc // target 一般传 GetModuleHandle(nullptr) 拿主模块,也可以传某个子模块句柄 bool HookImportTable(HMODULE target, const char* dllName, const char* funcName, void* newFunc, void** oldFunc) { auto* dos = (IMAGE_DOS_HEADER*)target; if (dos->e_magic != IMAGE_DOS_SIGNATURE) return false; auto* nt = (IMAGE_NT_HEADERS*)((BYTE*)target + dos->e_lfanew); auto& dir = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (dir.VirtualAddress == 0) return false; // 没有导入表,直接放弃 auto* imp = (IMAGE_IMPORT_DESCRIPTOR*)((BYTE*)target + dir.VirtualAddress); for (; imp->Name; ++imp) { if (_stricmp((char*)((BYTE*)target + imp->Name), dllName) != 0) continue; // FirstThunk 是加载后已被填成真实地址的那份表;OriginalFirstThunk 是名字表 auto* thunk = (IMAGE_THUNK_DATA*)((BYTE*)target + imp->FirstThunk); auto* orig = (IMAGE_THUNK_DATA*)((BYTE*)target + (imp->OriginalFirstThunk ? imp->OriginalFirstThunk : imp->FirstThunk)); for (; thunk->u1.Function; ++thunk, ++orig) { if (IMAGE_SNAP_BY_ORDINAL(orig->u1.Ordinal)) continue; // 按序号导入的没法比对名字 auto* byName = (IMAGE_IMPORT_BY_NAME*)((BYTE*)target + orig->u1.AddressOfData); if (strcmp((char*)byName->Name, funcName) != 0) continue; DWORD prot = 0; // IAT 所在页通常是只读的,改之前必须先放开写权限 VirtualProtect(&thunk->u1.Function, sizeof(void*), PAGE_READWRITE, &prot); if (oldFunc) *oldFunc = (void*)thunk->u1.Function; // 备份原始地址,后面转发要用 thunk->u1.Function = (ULONG_PTR)newFunc; VirtualProtect(&thunk->u1.Function, sizeof(void*), prot, &prot); return true; } } return false; }这段代码里几个参数值得单独说:target决定钩子生效的范围,只钩主模块就只能拦住主模块的静态导入;oldFunc是必须的,因为我们的替身函数最终要把请求转发给真正的send,没有原始地址就成了死循环。另外IMAGE_DIRECTORY_ENTRY_IMPORT这个目录项在开启绑定导入(Bound Import)的程序里判断方式略有不同,遇到那种程序需要连IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT一起看。这条路线的盲区也很明确:目标程序如果是运行时LoadLibrary("ws2_32.dll")再GetProcAddress("send")动态取的地址,IAT 里根本没有这一项,钩子永远不触发。这是后面排查章节里排第一的坑。
2.2 内联 Hook:改函数头几字节,覆盖广但要处理指令对齐
内联 Hook(Detour)改的是函数入口的机器码:在send的前 5 个字节写入一条jmp rel32跳到我们的替身函数,被覆盖掉的原指令拷贝到一块自己申请的可执行内存里,末尾再接一条跳回send+5的指令,这块内存就是常说的 trampoline。替身函数需要调用原始实现时,跳 trampoline 而不是跳send,否则又回到自己身上。
// 在 target 头部写入 jmp dest,用 nop 填平剩余字节 // len 必须 >= 5,且必须是完整指令的总长度,截断到半条指令必然崩 static bool WriteJmp(void* target, void* dest, size_t len) { if (len < 5) return false; DWORD prot = 0; if (!VirtualProtect(target, len, PAGE_EXECUTE_READWRITE, &prot)) return false; auto* p = (BYTE*)target; p[0] = 0xE9; // jmp rel32 *(int32_t*)(p + 1) = (int32_t)((BYTE*)dest - (p + 5)); // 相对当前指令末尾的偏移 for (size_t i = 5; i < len; ++i) p[i] = 0x90; // 多出来的字节填 nop,别留残指令 VirtualProtect(target, len, prot, &prot); // 改完代码一定要刷指令缓存,多核机器上不刷会出现时灵时不灵 FlushInstructionCache(GetCurrentProcess(), target, len); return true; }真正的难点不在WriteJmp这十几行,而在len怎么算——x86/x64 指令长度是 1 到 15 字节不等,send开头常见的是mov edi,edi(2 字节热补丁位)加push ebp加mov ebp,esp这类组合,长度凑不满 5 就得往后吞更多指令,吞的过程中如果碰到跳转目标落在被覆盖区域内的相对跳转指令,还得做重定位。工业界的做法是直接上成熟库,Detours、Minhook 这类都处理过这些边界;自己手写只适合做验证和小范围实验。这条路线的好处是覆盖面最广,静态导入、动态取址、甚至GetProcAddress之后再转发一层都能拦住,代价是稳定性和调试成本。
2.3 代理 DLL:为什么 ws2_32 这一招不好使
很多人第一反应是"放一个同名ws2_32.dll到 exe 目录,导出同名函数,内部再转发给系统那份"。这个思路在某些 DLL 上确实有效,但ws2_32.dll是个典型反例:它属于系统核心组件,加载器在解析这类模块名时会走一套固定的查找顺序,优先级最高的是系统目录里那一份,工作目录里的同名文件经常连看都不看一眼。要确认这一点很简单,在目标进程里枚举已加载模块,看ws2_32.dll的完整路径是不是System32下那份——绝大多数情况下都是。
所以代理 DLL 这条路要么改成"钩一个不属于系统核心的中间 DLL",要么改成"改目标程序的导入表让它先加载我们自己的 DLL 再转发"。前者要看目标程序的依赖结构,不是每个程序都有合适的中间层;后者属于静态改造,得动二进制文件。综合下来,ws2_32!send这个场景里,代理 DLL 是成本最高、命中率最低的一条路,除非你的目标程序本身就是自己编译的、能重新链接。
| 路线 | 覆盖范围 | 实现成本 | 主要风险 | 适用场景 |
|---|---|---|---|---|
| IAT Hook | 只覆盖静态导入 | 低 | 动态取址完全漏掉 | 目标程序导入表完整、快速验证 |
| 内联 Hook | 静态+动态全覆盖 | 中高 | 指令对齐、多线程改写竞态 | 通用拦截、长期使用 |
| 代理 DLL | 取决于加载路径 | 中 | 系统核心 DLL 基本无效 | 自研程序或非核心 DLL |
我的实际选择通常是组合拳:先用 IAT Hook 快速验证"这个程序到底走不走send",确认走之后再换内联 Hook 补全覆盖面。这样最省时间,也不至于一上来就陷进反汇编细节里。
3. 从拿到真实地址到写下第一条记录
路线定了,接下来是工程落地。这一章的目标很具体:让MySend能被调起来、能正确转发、能拿到真实的返回值和真实的发送长度,并且不在DllMain里炸掉。看起来是三件小事,实际每一件都有能让进程当场退出的坑。
3.1 把 ws2_32 的真实地址钉在全局变量里
替身函数必须知道原始实现在哪,否则没法转发。取地址本身不难,难在时机:如果在DllMain的DLL_PROCESS_ATTACH里调LoadLibrary或GetProcAddress,就撞上了加载器锁——此时加载器正持有内部锁,任何再次进入加载器逻辑的调用都可能死锁。血泪经验是:所有需要"再加载一个模块"的初始化,一律推迟到替身函数第一次被调用的时刻做,用一次性的惰性初始化保护起来。
#include <mutex> using send_t = int (WSAAPI*)(SOCKET, const char*, int, int); using WSASend_t = int (WSAAPI*)(SOCKET, LPWSABUF, DWORD, LPDWORD, DWORD, LPWSAOVERLAPPED, LPWSAOVERLAPPED_COMPLETION_ROUTINE); static send_t Real_send = nullptr; // 指向系统原始实现 static WSASend_t Real_WSASend = nullptr; // 只拿模块句柄,不做任何函数解析,可以在安全时机反复调 static HMODULE GetRealWs2() { HMODULE h = GetModuleHandleW(L"ws2_32.dll"); // 已加载就直接复用 if (!h) h = LoadLibraryW(L"ws2_32.dll"); // 未加载才真正加载 return h; } // 惰性初始化:第一次进替身函数时才解析真实地址,避开 DllMain 的加载器锁 static void EnsureInit() { static std::once_flag once; std::call_once(once, [] { HMODULE h = GetRealWs2(); if (!h) return; Real_send = (send_t)GetProcAddress(h, "send"); Real_WSASend = (WSASend_t)GetProcAddress(h, "WSASend"); }); }std::call_once在这里不是为了性能,是为了正确性——一个多线程的客户端可能有十几个线程同时首次调用send,不加保护就会出现两个线程同时往同一个函数指针写地址,虽然写的值一样,但在某些编译器优化下这类无保护的全局写本身就不安全。GetModuleHandleW优先、LoadLibraryW兜底的顺序也有讲究:绝大多数进程在第一次send调用时ws2_32.dll早就加载完了,走GetModuleHandleW不增加模块引用计数,卸载时省事。
3.2 MySend 的三件事:记录、转发、原样返回
替身函数的签名必须和原函数完全一致,包括调用约定。send在WinSock2.h里的声明是int WSAAPI send(SOCKET s, const char *buf, int len, int flags),WSAAPI在 32 位下展开为__stdcall,64 位下没有实际意义。签名写错的表现是:钩子能进去、日志能打、返回的时候栈指针错了,进程直接崩在返回指令上。
// 替身函数:先转发拿真实返回值,再按真实发送长度记录,最后原样返回 int WSAAPI MySend(SOCKET s, const char* buf, int len, int flags) { EnsureInit(); if (!Real_send) { // 解析失败宁可报错,也别假装成功 WSASetLastError(WSAEINVAL); return SOCKET_ERROR; } int ret = Real_send(s, buf, len, flags); // 顺序很关键:先发,后记 // len > 0 且确实发出去了才记录;ret 才是真实写进内核的字节数 if (len > 0 && ret > 0) { RecordOutbound(s, buf, ret, flags); } return ret; }这里有两个容易忽略的细节。第一个是记录长度用ret还是len:send在阻塞套接字上可能只发出一部分,返回值小于len是正常现象,这时候按len记会把调用方缓冲区里"还没发出去"的脏数据也一起写进.ob,事后拆包会发现长度字段和实际负载对不上。第二个是失败分支:ret == SOCKET_ERROR时错误码可能是WSAEWOULDBLOCK(非阻塞套接字缓冲区满)或WSAENOTCONN,这两种情况一个字节都没出去,不能记。
3.3 挂上去:IAT 替换与内联替换的调用点
有了替身函数,剩下就是把 IAT 项或者函数头改掉。用 2.1 里的HookImportTable,只需要一行调用,把主模块里ws2_32.dll的send换掉,同时把原始地址存进Real_send——注意这种情况下Real_send是从 IAT 里捞出来的,EnsureInit里那次解析就变成了兜底。
// 在目标进程里执行,把主模块的 ws2_32!send 换成 MySend void InstallHook() { void* origin = nullptr; if (HookImportTable(GetModuleHandleW(nullptr), "ws2_32.dll", "send", (void*)MySend, &origin)) { if (origin) Real_send = (send_t)origin; // 用 IAT 里捞到的真实地址覆盖 } // 导出函数转发、插件式加载、或者目标本身是 DLL 的场景, // 把 GetModuleHandleW(nullptr) 换成对应模块句柄再钩一次 }如果目标程序是动态取址的,IAT 这条走不通,就得换内联路线:把GetProcAddress拿到的地址做一次WriteJmp,len用成熟库算好,trampoline 分配在靠近目标函数的地址空间里(32 位下jmp rel32的偏移范围只有 ±2GB,离太远跳不过去)。这一步是整个方案里最容易翻车的地方,建议先在记事本这类简单程序上跑通,再往目标进程上放。
4. 把发送内容落成 .ob:格式、分片与写盘线程
.ob这个文件后缀没有行业标准,怎么定义完全看自己。但如果格式设计得糙,事后解析的痛苦会十倍于写入时的省事。我踩过的坑集中在三处:头部没有序号导致乱序、没有长度字段导致整个文件没法随机定位、以及直接在send里同步fwrite导致业务卡顿。这一章把格式和写盘路径一次定死。
4.1 send 的返回值语义,决定了你该记多少字节
先把这个前提说透,后面所有设计都建立在它上面。send返回值大于 0 表示"已经交给内核的字节数",可能小于len;返回SOCKET_ERROR表示一个字节都没交出去,具体原因看WSAGetLastError()。还有两个边界:len == 0时send通常直接返回 0 不做任何事,不需要记录;flags里带MSG_OOB时发的是带外数据,和普通数据流不在一起,需要单独打标记,否则回放的时候顺序会错乱。
还有一个更隐蔽的问题:buf指向的内存归调用方所有,send返回后调用方随时可能复用。所以记录动作里必须有一次真正的memcpy,绝不能只存指针等后台线程去读——这是异步写盘方案里最经典的一类悬垂指针,表现是.ob里偶尔出现几条完全无关的乱码。
4.2 .ob 的格式:定长头加变长负载
我用的格式很简单,32 字节定长头加变长体,读的时候先读头再按len读体,任何一条坏了都能靠魔数定位到大致位置。
#pragma pack(push, 1) struct ObRecHeader { uint32_t magic; // 'OB01' = 0x3130424F,用来做完整性校验和断点定位 uint32_t seq; // 全局自增序号,多线程写入后靠它还原真实顺序 uint64_t tickMs; // GetTickCount64,跨进程换算时间轴用 uint64_t socketId; // (uint64_t)s,只在单次运行内唯一,别指望跨进程稳定 uint32_t len; // 后面紧跟的负载字节数 uint16_t family; // AF_INET / AF_INET6,回放建 socket 要用 uint16_t port; // 对端端口,网络字节序,getpeername 取 }; #pragma pack(pop) static_assert(sizeof(ObRecHeader) == 32, "头长度变了,解析脚本也得跟着改");seq这个字段看起来多余——文件里记录的先后顺序不就是发送顺序吗?实际不是。多线程写盘、批量刷缓冲区的时候,落盘顺序和send调用顺序会错开,没有seq就只能靠tickMs猜,而GetTickCount64的分辨率在十几毫秒量级,同一毫秒内几十条记录根本排不开。family和port是我后来补上的:只有 socket 句柄的话,事后完全分不清哪条记录属于哪个连接,补上对端信息之后一眼就能按连接分组。
4.3 写盘路径:生产者只入队,消费者批量写
绝对不要在send里直接fwrite。磁盘慢的时候一次写盘能卡几十毫秒,而send是在业务线程上调用的,等于把磁盘 IO 的抖动直接传导给了业务。我见过最惨的情况是某个实时性要求高的客户端,加了同步写盘之后帧率从 60 掉到 15,查了两天才定位到钩子上。
// 生产者:send 线程只做一次拷贝入队,绝不碰文件句柄 static std::mutex g_mtx; static std::condition_variable g_cv; static std::deque<std::unique_ptr<ObRecord>> g_queue; static std::atomic<uint32_t> g_seq{0}; static constexpr size_t kHighWater = 100000; // 队列上限,防内存被撑爆 void RecordOutbound(SOCKET s, const char* buf, int len, int flags) { auto rec = std::make_unique<ObRecord>(); rec->hdr.magic = 0x3130424F; rec->hdr.seq = g_seq.fetch_add(1, std::memory_order_relaxed); rec->hdr.tickMs = GetTickCount64(); rec->hdr.socketId = (uint64_t)(uintptr_t)s; rec->hdr.len = (uint32_t)len; rec->body.assign(buf, buf + len); // 必须深拷贝,buf 归调用方 { std::lock_guard<std::mutex> lk(g_mtx); // 背压策略:队列打满就丢最老的,宁可少记也不让业务进程 OOM if (g_queue.size() >= kHighWater) g_queue.pop_front(); g_queue.push_back(std::move(rec)); } g_cv.notify_one(); }消费者是单独一条线程,批量取、批量写,每批结束fflush一次。批量大小和刷新间隔是一对权衡参数:批太小刷新太频繁,磁盘压力大;批太大刷新太稀疏,进程被强杀时会丢一大段。我一般用 256 条一批、200 毫秒超时兜底,实测在每秒几千条send的场景下 CPU 占用可以忽略。
// 消费者:单线程批量写盘,按大小轮转文件 void WriterLoop(const std::wstring& basePath) { FILE* fp = _wfopen(basePath.c_str(), L"wb"); if (!fp) return; std::vector<std::unique_ptr<ObRecord>> batch; while (g_running.load()) { std::unique_lock<std::mutex> lk(g_mtx); g_cv.wait_for(lk, std::chrono::milliseconds(200), [] { return !g_queue.empty() || !g_running.load(); }); while (!g_queue.empty() && batch.size() < 256) { batch.push_back(std::move(g_queue.front())); g_queue.pop_front(); } lk.unlock(); for (auto& r : batch) { fwrite(&r->hdr, sizeof(r->hdr), 1, fp); if (r->hdr.len) fwrite(r->body.data(), 1, r->hdr.len, fp); } batch.clear(); fflush(fp); // 崩溃时尽量保住已写内容 // 单文件超过 64MB 就换下一个,避免尾巴上一个几 G 的文件不好处理 if (ftell(fp) > 64LL * 1024 * 1024) { fclose(fp); fp = _wfopen(NextName(basePath).c_str(), L"wb"); if (!fp) return; } } fclose(fp); }这段代码里g_running是std::atomic<bool>,进程卸载 DLL 前置 false 并join写线程,否则写线程还在用已经释放的FILE*,必崩。NextName的实现按自己习惯来,我一般用basePath加时间戳加序号。
5. 常见问题排查:五个真实翻车现场
钩子这类东西的特点是"不报错但不对",编译器不会帮你,调试器断点又容易被反调试干扰。下面这五条是我实际遇到过、并且每次带新人都要重新讲一遍的问题,按出现频率排。
5.1 加载与注入阶段的坑
现象:DLL 一注入,目标进程直接卡死在启动画面,任务管理器里 CPU 占用为 0。原因:初始化代码写在了DllMain的DLL_PROCESS_ATTACH分支里,里面调了LoadLibrary或者GetProcAddress。此时加载器持有内部锁,再次进入加载器逻辑形成自死锁,进程既不崩也不动。 解决:DllMain里只做最轻的事——保存模块句柄、起一个延迟线程或者干脆什么都不做。所有需要加载模块、解析符号、创建线程的操作,全部挪到替身函数第一次被调用时用std::call_once完成,或者用一个独立的初始化线程Sleep(0)之后再跑。
现象:注入成功、钩子安装成功、日志显示InstallHook返回 true,但.ob文件一条记录都没有。原因:目标程序根本没用静态导入的send。典型情况是它通过GetProcAddress动态取地址,或者内部用的是WSASend、sendto、甚至自己封装了一层的WriteFile到已关联的 socket 句柄。 解决:先用调试器在ws2_32!send、ws2_32!WSASend上下断点,确认目标到底走哪个导出。走到WSASend就把替身补齐,走sendto同理。IAT 路线覆盖不到动态取址,这种情况必须换内联 Hook,或者把GetProcAddress本身也钩一层。
5.2 运行与数据完整性
现象:.ob能写出来,但负载内容是乱的,同一个seq里混着两条不同连接的数据。原因:多个业务线程同时调send,替身函数里用了共享的缓冲区做中间中转,没加锁或者锁的范围不对,一个线程刚写完头另一个线程就开始写体。 解决:生产者路径上不要有任何共享可变状态。上面 4.3 的做法是每条记录独立make_unique,队列操作用互斥量保护,缓冲区拷贝在锁外完成。如果追求极致性能可以改成每线程独立的队列加无锁结构,但绝大多数场景一把锁就够了。
现象:记录条数明显少于实际发送次数,特别是高峰期丢得更多。原因:队列设了上限,达到上限后按策略丢最老的记录;或者写盘线程被磁盘 IO 拖住,队列长期处于满的状态。 解决:先看是哪种。临时把kHighWater调大十倍再跑一遍,如果丢包明显减少说明是队列容量问题,需要调大上限或者提高写盘吞吐(换缓冲 IO、降低fflush频率)。如果调大没用,说明是写盘线程本身卡住了,检查是不是文件轮转时在同步关闭大文件,或者杀毒软件在扫新生成的.ob。
现象:.ob文件最后一部分截断,头里的len写着 1200,实际体只有 300 字节。原因:进程被强杀或者崩溃,写盘线程正好写到一半,头部已经落盘、负载还在用户态缓冲区里没刷出去。 解决:这是异步写盘的固有代价,只能降低概率不能完全消除。实践上把刷新间隔压到 200 毫秒以内、每批写完立刻fflush,可以把丢失窗口控制在一批以内。解析脚本那边必须能容忍尾部截断——读到短读就直接停,不要试图补齐。
现象:32 位目标程序上钩子生效,64 位目标上进程秒崩。原因:替身函数的调用约定写错,32 位下send是__stdcall,由被调用方清栈;写成__cdecl之后返回时栈指针错位,执行流直接飞出去。另外 64 位下jmp rel32的 ±2GB 范围限制也可能让内联 Hook 写到一半失败。 解决:替身函数的签名直接照抄WinSock2.h里的声明,用WSAAPI宏而不是手写__stdcall。内联 Hook 在 64 位下必须用 trampoline 分配器(VirtualAlloc2带地址提示,或者遍历保留区找近地址),随便VirtualAlloc一块内存很可能超出跳转范围。
6. 验证与进阶:先自证,再上真实目标
钩子写完最忌讳直接往生产程序上放。我现在的固定流程是:先用一个自己写的最小回环程序把整条链路跑通,确认.ob能被正确解析出来、内容和发送的字节逐字节一致,再考虑往目标进程上装。这个习惯救过我至少三次——很多看起来是"钩子没生效"的问题,其实是格式设计本身有 bug,用真实目标去查只会把问题搅得更乱。
验证用的服务端不需要复杂,一个socket+bind+listen+accept+recv的循环就够,客户端用同样的一份代码发固定模式的数据(比如 0 到 255 循环填充),这样.ob里的内容一眼就能看出对不对。
import struct, sys # 和 C 端的 ObRecHeader 严格对应,字段少一个都会解析错位 HDR = struct.Struct("<IIQQIHH") # magic, seq, tickMs, socketId, len, family, port def iter_records(path): with open(path, "rb") as f: while True: raw = f.read(HDR.size) if len(raw) < HDR.size: # 尾部截断,正常结束 return magic, seq, tick, sock, n, fam, port = HDR.unpack(raw) if magic != 0x3130424F: # 魔数对不上说明中间已经烂了 print(f"[!] 魔数错误 seq={seq},从此处中断") return body = f.read(n) if len(body) < n: # 写盘线程没来得及 flush 的典型表现 print(f"[!] 尾部截断: 期望 {n}, 实得 {len(body)}") return yield seq, tick, sock, fam, port, body if __name__ == "__main__": prev_tick = 0 for seq, tick, sock, fam, port, body in iter_records(sys.argv[1]): delta = tick - prev_tick prev_tick = tick head = body[:16].hex() print(f"{seq:06d} +{delta:4d}ms sock={sock} :{port} len={len(body):5d} {head}")这个脚本除了看内容,还有个隐藏用途:delta那一列能直接暴露发送节奏。正常业务应该是均匀的或者有规律的,如果看到某一段delta突然变成 0 然后密集出现几百条,说明那次是突发批量发送;如果看到某条len异常大(比如几十万字节),大概率是接收方做了大块缓冲。这些信息在拆协议的时候比内容本身还有用,能帮你快速定位到"哪一段是控制指令、哪一段是数据块"。
再往前走一步,是把recv也拦下来。双向都记之后,.ob就有了完整的会话上下文,回放时按tickMs交错重放就能复现出原始交互时序。这里有个细节要注意:recv的替身如果返回 0(对端关闭),记录里应该写一条len=0的关闭标记,否则回放的时候分不清"没收到数据"和"连接断了"。我在格式里留了flags字段的高位做这类标记,最低位表示方向,次低位表示连接事件。
还有一个我至今保持的习惯:每次改完格式,先拿上一次的.ob旧文件跑一遍新的解析脚本,确认向后兼容。因为格式字段一旦加进去,旧文件就永远缺那几个字段,解析脚本要么兼容要么明确拒绝,最怕的是默默按 0 填充然后在业务上误判。从这个角度看,magic里那个版本号字符其实是最值钱的两个字节——它让我在半年后翻出一个旧.ob时,第一眼就知道该用哪版脚本去读。希望帮到你。
本文还有配套的精品资源,点击获取