news 2026/10/17 17:22:28

逆向基础:文件读写与二进制数据生命周期训练

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
逆向基础:文件读写与二进制数据生命周期训练

1. 这个“读文件→内存→回文件”操作,到底在练什么基本功?

很多人第一次看到“滴水三期逆向基础系列(一)——读取文件到内存再读取回文件”这个标题,第一反应是:这不就是个C语言入门题?fopen、fread、fwrite三连,十几行代码搞定,至于单独列为“逆向基础第一课”吗?我最初也这么想。直到我在某跨平台图像处理Demo的调试中,连续三天卡在一个看似简单的崩溃点上:程序从资源包里解压出一张PNG,加载进内存做像素级处理,再保存为新文件——结果新文件总在特定分辨率下打不开,用十六进制编辑器一对比,发现文件头后面莫名其妙多出4个字节的00 00 00 00,而原始文件头之后紧跟着的是89 50 4E 47(PNG魔数)。问题不在算法,不在压缩逻辑,就在“把内存里那块数据原样倒回磁盘”这个最朴素的动作里。

这才真正理解了滴水把这个动作单拎出来讲的深意:它根本不是教你怎么写IO函数,而是在训练你对二进制数据生命周期的肌肉记忆。逆向分析时,你面对的永远不是“一个图片”或“一段文本”,而是内存里一串没有标签的字节流;你修改的不是“变量值”,而是某个地址上连续N个字节的原始排列;你验证修复是否成功,靠的不是程序弹窗说“保存成功”,而是用WinHex逐字节比对文件头、校验和、段表偏移量。这个“读→内存→回写”的闭环,就是把抽象的“内存”概念砸进你手指尖的第一锤——它强迫你直面三个关键事实:

第一,文件不是“内容”,而是“结构化字节序列”。一个PE文件的.text段起始位置,由IMAGE_SECTION_HEADER.VirtualAddress字段决定,这个字段本身只是4个字节;你读进内存后,如果直接按char*指针去解析,而没考虑PE头的大小、节对齐粒度(通常是0x1000),那么你眼中“代码段开头”的地址,可能实际指向了节头表的中间。所谓“读到内存”,本质是把磁盘上按特定格式排列的字节,原封不动搬运到RAM的某个连续区域,搬运过程不附带任何语义解释。

第二,内存里的“数据”没有自动类型绑定。你在调试器里看到0x00401234地址存着0x6A 0x01 0x58,这串字节可以是push 1; pop eax的机器码,也可以是0x016A(十进制362)和0x0058(十进制88)两个小端序短整型,甚至可以是ASCII字符串"j\x01X"。它的含义完全取决于你用什么指令去执行它、用什么数据类型去读取它。而“读取回文件”这个动作,就是把你赋予它的临时含义,重新固化为磁盘上的静态字节序列——如果你在内存里误把代码当数据改了,或者把数据当代码执行了,回写后的文件必然损坏。

第三,“原样”二字背后全是陷阱。fread(buf, 1, size, fp)读取的字节数,未必等于你期望的size;fwrite(buf, 1, size, fp)写入的字节数,也可能小于size;更隐蔽的是,文本模式打开文件时,\n会被自动转为\r\n(Windows)或\r(Mac),而二进制模式则严格一对一映射。逆向中遇到的绝大多数“文件写入后无法加载”问题,根源都在这里——你以为自己在操作“原始字节”,实际上IO库在你眼皮底下悄悄做了转换。

所以,这个系列第一课,练的不是编码能力,而是对数据底层形态的敬畏心与控制力。它要求你关掉IDE的智能提示,不用任何高级封装,亲手用CreateFile/ReadFile/WriteFile(Windows)或open/read/write(Linux)系统调用,盯着DWORD返回值、errno错误码、缓冲区地址和长度,确保每一个字节都按你预设的路径流动。这不是复古情怀,而是逆向工程师的“正骨术”:先让你的手指记住字节的重量,才能在面对千兆字节的固件镜像时,一眼看出哪个扇区的CRC校验位被篡改了。

提示:别急着写代码。先用HxD或010 Editor打开任意一个可执行文件,手动记下前16个字节(如4D 5A 90 00 03 00 00 00 04 00 00 00 FF FF 00 00),再用你的程序读取同一文件的前16字节到内存,最后把内存数据写入新文件。用十六进制编辑器对比三个文件的开头——只有当你亲眼确认三者完全一致时,才算真正迈过了第一道门槛。

2. 为什么必须绕过高级语言的IO封装?系统调用才是真相现场

很多初学者会疑惑:既然C标准库的fread/fwrite已经封装得足够好,为什么滴水教程强调要用CreateFile+ReadFile这种Windows API,或者Linux下的open+read?难道只是为了“显得更底层”?答案是否定的。这背后有三个无法绕开的硬性原因,直接关系到你后续能否看懂加壳程序、分析驱动模块、或者调试内核级Hook。

2.1 文件句柄 vs 文件指针:两种世界观的分水岭

C标准库的FILE*是一个高度抽象的概念。它内部维护着缓冲区、文件位置指针、错误状态标志,甚至可能对小块读写进行合并优化。当你调用fseek(fp, 0x1000, SEEK_SET),标准库可能不会立刻向内核发起寻址请求,而是先检查自己的缓冲区里有没有缓存这部分数据;fclose(fp)时,它还会自动刷写缓冲区。这种设计极大提升了文本处理效率,但对逆向分析却是灾难性的——你永远不知道fread返回的字节,是刚从磁盘读上来的“新鲜货”,还是从标准库缓冲区里“借”来的旧数据。而逆向分析的核心前提,就是确定性:你必须100%确信,此刻内存中的字节,就是磁盘上那个物理扇区此刻存储的内容。

系统调用则完全不同。CreateFile返回的HANDLE(Windows)或open返回的int fd(Linux),是操作系统内核直接分配给你的、指向文件对象的唯一凭证。它不带任何缓存逻辑,不隐藏任何中间步骤。ReadFile(hFile, buf, 1024, &bytesRead, NULL)这一行,就是向内核发出一条明确指令:“请把hFile所指文件从当前文件指针位置开始的1024字节,原样拷贝到buf指向的内存地址”。内核会严格遵循,返回真实读取的字节数,如果文件剩余不足1024字节,它就只读多少。这种“所见即所得”的透明性,是逆向分析的生命线。当你在OllyDbg里看到ReadFile被调用,你立刻能推断出:程序接下来要处理的数据,必然位于buf地址开始的连续内存区域,且长度等于bytesRead的值——这个推断链条,绝不会因为标准库的缓冲策略而断裂。

2.2 错误处理的颗粒度差异:从“失败”到“为什么失败”

标准库IO函数的错误反馈极其粗糙。fread失败时,你只能通过ferror(fp)知道“出错了”,但具体是磁盘坏道、权限不足、还是网络存储超时?它一概不告诉你。而系统调用的错误码,精确到令人发指。以Windows为例:

  • ReadFile返回FALSE时,调用GetLastError():
    • ERROR_HANDLE_EOF:文件已到末尾(正常结束,非错误)
    • ERROR_ACCESS_DENIED:进程无权访问该文件(常见于被杀毒软件锁定)
    • ERROR_INVALID_HANDLE:传入的HANDLE无效(说明前面CreateFile可能失败了但你没检查)
    • ERROR_INSUFFICIENT_RESOURCES:系统资源耗尽(如句柄数超限)

这些错误码不是摆设。在分析一个顽固的加壳程序时,我曾遇到它反复调用ReadFile读取自身PE头,但每次读取后都触发异常。用GetLastError()捕获到ERROR_INVALID_HANDLE,顺藤摸瓜发现:壳在解密过程中,偷偷用DuplicateHandle复制了一个无效的句柄并用于后续读取——这个细节,用fread是绝对无法暴露的。系统调用的错误码,就是逆向工程师的“听诊器”,它让你能听到程序内核深处最细微的杂音。

2.3 内存映射与直接IO:绕过缓冲区的终极控制

某些高级场景下,标准库IO甚至根本无法满足需求。比如分析一个内存马(Memory-Only Malware),它从网络下载加密载荷,解密后直接在内存中执行,全程不落地到磁盘。此时你需要的不是“读文件”,而是“把内存中某块数据当作文件来解析”。Windows的CreateFileMapping+MapViewOfFile,或Linux的mmap,允许你将一个文件(或一块内存)直接映射到进程的虚拟地址空间。这样,你无需ReadFile,只需用指针*(DWORD*)pMappedAddr就能直接读取文件头的e_lfanew字段。这种零拷贝、高效率的访问方式,是标准库fread望尘莫及的。而“读取文件到内存再读取回文件”这个基础动作,正是你掌握内存映射技术前的必经台阶——只有先彻底理解“普通读写”中数据如何流动,你才能理解“映射读写”中数据为何能跳过中间环节。

注意:在Windows上,务必使用GENERIC_READ | GENERIC_WRITE标志打开文件,并指定CREATE_ALWAYS(覆盖创建)或OPEN_ALWAYS(存在则打开,不存在则创建)标志。若用CREATE_NEW而文件已存在,CreateFile会直接失败并返回INVALID_HANDLE_VALUE,这是新手最常见的卡点。Linux下同理,open的O_CREAT必须配合O_WRONLY或O_RDWR,且需提供正确的mode_t权限掩码(如0644),否则open会因权限不足失败。

3. 实操拆解:一个“安全可靠”的文件搬运工,需要几步?

现在,我们把理论落到代码上。目标很明确:写一个函数,输入源文件路径和目标文件路径,将源文件完整、精确、无损地复制到目标路径。这个函数必须能处理任意大小的文件(从几字节到几个GB),并在出错时给出明确诊断。下面是我基于某高校逆向实验室教学实践总结出的、经过上百次实测验证的六步法,每一步都对应一个真实踩过的坑。

3.1 第一步:用系统调用打开文件,而非fopen

// Windows版本 HANDLE hSrc = CreateFileA( szSrcPath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL ); if (hSrc == INVALID_HANDLE_VALUE) { DWORD dwErr = GetLastError(); printf("Open source file failed: %lu\n", dwErr); return FALSE; }

关键点解析:

  • OPEN_EXISTING而非CREATE_ALWAYS:源文件必须存在,这是“读取”的前提。用CREATE_ALWAYS会清空源文件,这是毁灭性错误。
  • FILE_SHARE_READ:允许其他进程同时读取该文件。如果源文件正被记事本打开,没有这个标志,CreateFile会失败。
  • FILE_ATTRIBUTE_NORMAL:显式指定属性,避免因文件被标记为READONLY或HIDDEN导致意外失败。

Linux等效代码:

int fd_src = open(szSrcPath, O_RDONLY); if (fd_src == -1) { perror("Open source file failed"); return FALSE; }

3.2 第二步:获取文件大小,但绝不依赖fseek+ftell

// Windows: 使用GetFileSizeEx,支持大于4GB的文件 LARGE_INTEGER liSize; if (!GetFileSizeEx(hSrc, &liSize)) { printf("GetFileSizeEx failed: %lu\n", GetLastError()); CloseHandle(hSrc); return FALSE; } // liSize.QuadPart 即为文件总字节数

为什么不用fseek(fp, 0, SEEK_END); ftell(fp)?因为ftell返回long,在32位系统上最大值约2GB,超过即溢出为负数。而GetFileSizeEx返回LARGE_INTEGER(64位),stat系统调用的st_size字段也是off_t(通常64位),这才是现代大文件的正确打开方式。

3.3 第三步:分配内存缓冲区,大小需精心计算

// 缓冲区大小设为64KB(65536字节),这是Windows I/O的推荐粒度 const DWORD BUFFER_SIZE = 65536; BYTE* pBuf = (BYTE*)VirtualAlloc(NULL, BUFFER_SIZE, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (!pBuf) { printf("VirtualAlloc failed: %lu\n", GetLastError()); CloseHandle(hSrc); return FALSE; }

关键考量:

  • 为什么是64KB?小于4KB(页大小)会导致频繁系统调用,性能差;大于1MB又可能因内存碎片分配失败。64KB是Windows文档明确推荐的“高效I/O缓冲区大小”。
  • 为什么用VirtualAlloc而非malloc?VirtualAlloc分配的内存页具有明确的读写属性,且可被WriteFile直接使用;malloc分配的堆内存,在某些安全加固环境下可能被标记为不可执行(NX bit),虽不影响写入,但为后续可能的代码注入分析埋下伏笔。统一用VirtualAlloc,思维更连贯。

3.4 第四步:分块读取与写入,循环中必须检查每一次调用

HANDLE hDst = CreateFileA( szDstPath, GENERIC_WRITE, 0, // 不共享写入 NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL ); if (hDst == INVALID_HANDLE_VALUE) { printf("Create dest file failed: %lu\n", GetLastError()); VirtualFree(pBuf, 0, MEM_RELEASE); CloseHandle(hSrc); return FALSE; } DWORD totalBytesRead = 0; DWORD totalBytesWritten = 0; DWORD bytesRead, bytesWritten; while (totalBytesRead < liSize.QuadPart) { // 计算本次应读取的字节数(防止越界) DWORD toRead = min(BUFFER_SIZE, (DWORD)(liSize.QuadPart - totalBytesRead)); if (!ReadFile(hSrc, pBuf, toRead, &bytesRead, NULL)) { printf("ReadFile failed at offset %lu: %lu\n", totalBytesRead, GetLastError()); break; } if (bytesRead == 0) { // 文件提前结束,异常情况 printf("Unexpected EOF at offset %lu\n", totalBytesRead); break; } if (!WriteFile(hDst, pBuf, bytesRead, &bytesWritten, NULL)) { printf("WriteFile failed at offset %lu: %lu\n", totalBytesWritten, GetLastError()); break; } if (bytesWritten != bytesRead) { // 部分写入,严重错误 printf("Partial write: expected %lu, got %lu\n", bytesRead, bytesWritten); break; } totalBytesRead += bytesRead; totalBytesWritten += bytesWritten; }

核心逻辑:

  • min(BUFFER_SIZE, ...)防越界:最后一块数据可能不足64KB,必须动态计算。
  • bytesRead == 0是致命信号:正常读取不会返回0,除非文件被其他进程截断或磁盘故障。
  • bytesWritten != bytesRead必须中断:这意味着磁盘已满、权限不足或硬件错误,继续写入只会产生垃圾数据。

3.5 第五步:收尾工作,关闭句柄并释放内存

// 必须检查CloseHandle的返回值! if (!CloseHandle(hSrc)) { printf("Close src handle failed: %lu\n", GetLastError()); } if (!CloseHandle(hDst)) { printf("Close dst handle failed: %lu\n", GetLastError()); } if (!VirtualFree(pBuf, 0, MEM_RELEASE)) { printf("VirtualFree failed: %lu\n", GetLastError()); }

为什么CloseHandle也要检查?因为如果句柄已被其他代码意外关闭,再次CloseHandle会失败,这往往意味着资源管理混乱,是大型项目中内存泄漏的前兆。

3.6 第六步:完整性校验——用MD5而非文件大小

// 复制完成后,计算源文件和目标文件的MD5哈希值 char szSrcMD5[33], szDstMD5[33]; if (CalcFileMD5(szSrcPath, szSrcMD5) && CalcFileMD5(szDstPath, szDstMD5)) { if (strcmp(szSrcMD5, szDstMD5) == 0) { printf("Copy success! MD5 matched.\n"); } else { printf("Copy failed! MD5 mismatch.\n"); } }

仅比较文件大小是远远不够的。我曾遇到一个案例:源文件是10MB的PDF,目标文件大小也是10MB,但用Adobe Reader打不开。用WinHex对比发现,目标文件开头多了8个字节的00 00 00 00 00 00 00 00,而结尾少了最后2个字节。这是因为WriteFile在最后一次调用时,bytesWritten返回值被忽略,程序错误地认为写入完成。MD5校验能100%捕捉这种“字节错位”错误。

实操心得:在调试阶段,务必在ReadFile和WriteFile调用前后,用printf("Offset: %lu, Read: %lu, Write: %lu\n", totalBytesRead, bytesRead, bytesWritten)打印日志。我见过太多人因为一个<写成<=,导致循环多执行一次,把缓冲区末尾的垃圾数据也写进了文件。日志不是累赘,是你和代码之间的翻译官。

4. 从“搬运工”到“解剖刀”:这个基础动作如何支撑逆向实战?

现在,让我们跳出“复制文件”的表层,看看这个看似简单的动作,如何成为你拆解复杂软件的基石。在某次分析一个国产办公软件的文档保护机制时,这个基础能力直接决定了成败。

4.1 场景还原:被加密的DOCX模板文件

该软件提供“模板中心”,用户下载的.dotx文件(Word模板)在本地存储时,被一层自定义加密包裹。常规思路是:用Process Monitor监控软件启动时读取了哪些文件,找到加密后的模板路径,然后尝试用fread读取其内容——结果得到一堆乱码。因为加密层在应用层实现,fread读到的是密文,毫无意义。

真正的突破口,来自对“读取文件到内存”这一动作的深度理解。我用API Monitor工具,挂载到该软件进程,重点监控ReadFile调用。当软件加载一个模板时,我捕获到如下关键序列:

  1. CreateFile打开加密模板文件,返回hTemplate = 0x1234
  2. ReadFile(hTemplate, 0x00567890, 4096, &bytesRead, NULL)→ 读取4096字节密文到内存地址0x00567890
  3. 紧接着,ReadFile(hTemplate, 0x00568890, 8192, &bytesRead, NULL)→ 读取下一段
  4. 关键点:在两次ReadFile之间,软件调用了VirtualAlloc(0x00A00000, 0x10000, ...)分配了一块新的内存区域,然后调用了一个疑似解密函数sub_00405678(0x00567890, 0x00A00000, 4096)

这个调用链揭示了真相:软件并非直接在原缓冲区解密,而是将密文读入一块临时内存(0x00567890),再将其作为输入,传递给解密函数,解密结果输出到另一块新分配的内存(0x00A00000)。而0x00A00000这块内存,正是后续CreateFileMapping映射到Word引擎的地址。

4.2 如何利用“读取到内存”定位解密逻辑?

有了这个洞察,我的操作就非常清晰了:

  • 在ReadFile返回后,立即在调试器中下断点:bp 0x00405678(解密函数入口)
  • 当断点命中,查看寄存器:ECX指向密文缓冲区0x00567890,EDX指向明文缓冲区0x00A00000,ESI是长度4096
  • 单步执行,观察0x00A00000地址处的字节如何从00 00 00...变为50 4B 03 04...(ZIP魔数,DOCX本质是ZIP包)
  • 此时,我已完全掌控了解密过程:输入是0x00567890的4096字节,输出是0x00A00000的4096字节,算法就在sub_00405678里。

如果没有对“读取文件到内存”这一动作的深刻理解,我会把精力浪费在分析fread的封装层,或者盲目搜索“decrypt”字符串,永远找不到这个隐藏在ReadFile调用间隙里的关键函数。

4.3 进阶应用:内存补丁与文件回写

定位了解密函数,下一步就是绕过它。我的方案是:在ReadFile成功后,sub_00405678调用前,用调试器将0x00567890地址的密文字节,替换成我预先准备好的、已解密的明文字节(例如一个合法的、内容为空的DOCX文件头)。这样,当sub_00405678被执行时,它其实是在对明文做“解密”,结果自然还是明文——实现了逻辑绕过。

但绕过只是开始。为了让这个补丁持久化,我需要把修改后的内存数据,原样写回磁盘文件。这时,“读取回文件”的能力就派上大用场了。我编写了一个独立的小工具,功能是:

  • 用CreateFile以GENERIC_READ | GENERIC_WRITE打开原始加密模板
  • SetFilePointer将文件指针移动到0x00567890对应的文件偏移(需根据ReadFile的调用次数和缓冲区大小反推)
  • WriteFile将我准备好的明文字节写入该位置

这个工具的核心,就是本系列第一课所训练的“读取回文件”能力。它不关心文件是什么格式,不解析任何结构,只做一件事:把内存里0x00567890开始的N个字节,精准地覆盖到磁盘文件的指定偏移处。最终,我成功生成了一个“免解密”的模板文件,双击即可在未安装该软件的电脑上用Word直接打开。

踩坑实录:在计算文件偏移时,我最初错误地认为ReadFile每次读取的都是连续的文件块。实际上,该软件为了混淆,会在两次ReadFile之间插入一个SetFilePointer(hTemplate, 0x1000, FILE_CURRENT, NULL),导致第二次读取的位置跳到了第一个块之后的0x1000字节处。若不仔细跟踪SetFilePointer的调用,回写的偏移就会错位,导致文件损坏。这再次印证:逆向不是猜谜,而是对每一个系统调用行为的严谨追踪。

5. 常见误区与避坑指南:那些让老手也皱眉的细节

即使你已熟练写出“读取-内存-回写”的代码,仍有一些极易被忽视的细节,会在关键时刻让你陷入数小时的无谓调试。这些不是语法错误,而是对底层机制理解偏差导致的“逻辑陷阱”。

5.1 误区一:“sizeof(buffer)就是我要读的字节数”

新手常犯的错误是:

char buffer[1024]; ReadFile(hFile, buffer, sizeof(buffer), &bytesRead, NULL);

看起来天衣无缝,但sizeof(buffer)在函数参数中,会退化为指针大小(通常是4或8),而非数组长度1024!正确写法必须是显式数字或sizeof(buffer)放在定义处:

#define BUFFER_SIZE 1024 char buffer[BUFFER_SIZE]; ReadFile(hFile, buffer, BUFFER_SIZE, &bytesRead, NULL);

或者用std::vector<char>(C++):

std::vector<char> buffer(1024); ReadFile(hFile, buffer.data(), (DWORD)buffer.size(), &bytesRead, NULL);

5.2 误区二:忽略字节序(Endianness)对“读取回文件”的影响

这个误区在处理结构体时尤为致命。假设你要读取一个PE文件的IMAGE_DOS_HEADER:

#pragma pack(push, 1) typedef struct _IMAGE_DOS_HEADER { WORD e_magic; // "MZ" WORD e_cblp; // 字节数/扇区 // ... 其他字段 } IMAGE_DOS_HEADER; #pragma pack(pop) IMAGE_DOS_HEADER dosHdr; ReadFile(hFile, &dosHdr, sizeof(dosHdr), &bytesRead, NULL);

e_magic字段在文件中是小端序4D 5A,在x86/x64 CPU上,WORD类型自动按小端序解释,所以dosHdr.e_magic的值是0x5A4D(十进制23117),而非0x4D5A。这本身没问题。但如果你随后修改了dosHdr.e_cblp,再用WriteFile(hFile, &dosHdr, sizeof(dosHdr), ...)写回去,一切正常。

然而,如果你在修改后,错误地用memcpy把dosHdr的内存布局,复制到一个char*缓冲区,再写入文件:

char* pBuf = new char[sizeof(dosHdr)]; memcpy(pBuf, &dosHdr, sizeof(dosHdr)); // 这里发生了什么? WriteFile(hFile, pBuf, sizeof(dosHdr), ...);

memcpy是字节级拷贝,pBuf里的字节顺序,就是dosHdr在内存中的原始布局。由于dosHdr.e_magic在内存中是4D 5A(小端序),pBuf[0]=0x4D,pBuf[1]=0x5A,写入文件后完全正确。但如果dosHdr是一个包含DWORD(4字节)字段的结构体,而你运行在大端序CPU(如某些嵌入式ARM)上,memcpy出来的字节序就会与文件期望的相反。因此,“读取回文件”的安全前提是:你操作的内存数据,其字节序必须与目标文件格式规范一致。对于PE、ELF等标准格式,这意味着你必须在小端序CPU上操作,或手动进行字节序转换。

5.3 误区三:WriteFile后不调用FlushFileBuffers

这是一个极其隐蔽的坑。WriteFile成功返回,只表示数据已提交给操作系统内核的写入队列,并不保证已写入物理磁盘。在某些情况下(如系统崩溃、断电),这部分数据会丢失,导致文件损坏。对于逆向分析,这意味你辛苦修改的内存数据,可能根本没落盘。

正确做法是:

if (!WriteFile(hDst, pBuf, bytesRead, &bytesWritten, NULL)) { // 错误处理... } // 强制刷新到磁盘 if (!FlushFileBuffers(hDst)) { printf("FlushFileBuffers failed: %lu\n", GetLastError()); }

Linux下对应fsync(fd_dst)。虽然会略微降低性能,但对于需要100%数据一致性的逆向场景,这是不可省略的步骤。

5.4 误区四:在多线程环境中,共享文件句柄引发竞态

想象这样一个场景:主线程用CreateFile打开一个日志文件,然后将HANDLE传递给多个工作线程,每个线程都调用WriteFile向该文件追加日志。表面看没问题,但实际会发生什么?

WriteFile对同一个HANDLE的并发调用,其行为是未定义的。Windows文档明确指出:“对同一文件句柄的并发读写操作可能导致数据损坏或未定义行为。” 你可能会看到日志行交错、部分日志丢失,甚至文件头被覆盖。

解决方案只有两个:

  • 方案A(推荐):每个线程自己CreateFile打开文件(用FILE_SHARE_WRITE标志),然后SetFilePointer到文件末尾再写入。这样各线程操作的是独立的文件指针。
  • 方案B:用一个专用的日志线程,所有其他线程通过线程安全的队列(如std::queue+std::mutex)发送日志消息,由日志线程统一WriteFile。

逆向分析中,你经常会遇到多线程写入配置文件、缓存文件的场景。识别出这种竞态模式,往往是定位程序崩溃根源的关键。

最后分享一个小技巧:在调试ReadFile/WriteFile时,不要只盯着返回值。用调试器的“内存窗口”,在ReadFile调用前,查看pBuf地址的初始内容(通常是随机垃圾);调用后,再看pBuf地址的内容是否已更新为你期望的字节。如果pBuf内容没变,而ReadFile却返回成功,那一定是pBuf地址传错了(比如传了&pBuf而不是pBuf),这是指针错误的典型症状。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 17:18:46

MySQL事务详解:从ACID到JDBC实操,避开事务失效的坑

1. 先从一次转账说起&#xff1a;事务到底是什么1.1 一个让人后怕的线上场景我之前在某公司的交易系统里排查过一个悬案&#xff1a;用户发起提现&#xff0c;后台一查&#xff0c;钱扣了&#xff0c;但账户余额增加了&#xff0c;两边对不上。一开始怀疑是并发扣款&#xff0c…

作者头像 李华
网站建设 2026/10/17 17:18:38

VDA 5 Pocket Guide:测量不确定度评估的实战指南

简介&#xff1a;这是面向汽车行业质量与测量人员的 VDA 5 袖珍指南&#xff08;Pocket Guide&#xff09;&#xff0c;围绕 2021 年第三版《测量和检验过程》整理核心变化&#xff0c;适合供应商、制造商及检验过程策划与审核人员快速学习新版要点。资源包共 1 个 PDF 文件&am…

作者头像 李华
网站建设 2026/10/17 17:18:00

DBUtils核心QueryRunner与DBCP连接池:简化JDBC开发实战

写 SQL 不难&#xff0c;难的是把每次查询前前后后的连接获取、预编译语句、参数绑定、结果集映射、资源关闭这套“管道工程”重复几十遍还不出错。这也是我当年从原生 JDBC 转向 DBUtils 的起因。这个系列走到第三十篇&#xff0c;正好把两块内容放在一起&#xff1a;DBUtils …

作者头像 李华
网站建设 2026/10/17 17:16:03

红色党建风党政工作总结PPT模板:视觉规范与python-pptx批量生成

简介&#xff1a;红色党建风党政部门工作总结PPT模板&#xff0c;专为党政机关与企事业单位年终述职、阶段汇报等场景设计&#xff0c;帮助使用者高效梳理工作目标、展示成果、总结不足并规划未来。模板内置五大标准板块&#xff1a;工作目标与背景、工作成果与亮点、挑战与解决…

作者头像 李华
网站建设 2026/10/17 17:14:13

Unity3D跑酷游戏源码解析:从零搭建到性能优化

简介&#xff1a;这是一份面向Unity3D初学者与独立开发者的跑酷游戏开发实战资源&#xff0c;围绕《Unity3D跑酷游戏开发详解》展开&#xff0c;帮助读者从零搭建可运行的3D跑酷项目。内容覆盖环境搭建与场景设置、角色模型与Animator动画状态机、Rigidbody与Collider物理碰撞、…

作者头像 李华