25. 【C语言】二进制文件与随机读写 上一篇文章我们学会了用fprintf和fscanf读写文本文件。文本文件最大的好处是可读——拿记事本打开就能看懂。但它也有明显的短板存一个double要写成3.14159265358979这十几字节读回来还要解析精度可能损失速度也慢。如果你需要高效地存储大量数值或者想快速跳到文件中间读写某条记录那就得换另一种思路——把数据在内存中的样子原封不动地写入磁盘。这就是二进制文件。配合 C 语言提供的随机访问函数你就能像操作数组一样操作文件中的任意位置。一、二进制文件 vs 文本文件底层都一样解读方式不同不管文本文件还是二进制文件对于计算机来说都是 0 和 1 的字节流。区别只在于你如何解读这些字节。比如一个整数123456789十六进制0x075BCD15在内存中占 4 字节内存内容小端: 15 CD 5B 07文本方式写入fprintf(fp, %d, 123456789);会把它转换为字符串123456789写出 9 个 ASCII 字符31 32 33 34 35 36 37 38 39。二进制方式写入fwrite(n, sizeof(int), 1, fp);直接把那 4 字节15 CD 5B 07写入文件。优缺点对比文本文件二进制文件可读性人可以直接看懂乱码需要程序解读文件体积较大数字越长越大紧凑固定大小读写速度慢需要格式转换快直接搬移内存精度可能损失浮点数完全不损失跨平台安全需注意字节序和类型大小什么时候用二进制游戏存档、数据库文件、图像/音频/视频数据、大量传感器数据记录——只要数据量大、结构固定、不靠人眼阅读就优选二进制。二、fread与fwrite读写“裸”数据块这两个函数是二进制 I/O 的核心。size_tfwrite(constvoid*ptr,size_tsize,size_tcount,FILE*stream);size_tfread(void*ptr,size_tsize,size_tcount,FILE*stream);ptr内存中数据的地址。size每个数据块的字节数通常用sizeof。count要读写的数据块个数。stream文件指针。返回值成功读/写的数据块个数不是字节数。写入二进制数据把几个不同类型的数据一次写入文件#includestdio.hintmain(void){FILE*fpfopen(data.bin,wb);// 注意 wb二进制写模式if(fpNULL){perror(打开文件失败);return1;}intn42;doublepi3.14159265;charmsg[]Hello;fwrite(n,sizeof(int),1,fp);// 写入一个 intfwrite(pi,sizeof(double),1,fp);// 写入一个 doublefwrite(msg,sizeof(char),5,fp);// 写入 5 个 char不含 \0fclose(fp);return0;}wb中的b明确告诉操作系统“我是二进制模式”。在 Linux/macOS 上w和wb没有实际区别但 Windows 上文本模式会自动把\n转换为\r\n二进制模式则不会。为了跨平台写二进制一定要加b。读取二进制数据#includestdio.hintmain(void){FILE*fpfopen(data.bin,rb);if(fpNULL){perror(打开文件失败);return1;}intn;doublepi;charmsg[6]{0};// 多留一位给 \0fread(n,sizeof(int),1,fp);fread(pi,sizeof(double),1,fp);fread(msg,sizeof(char),5,fp);printf(n%d, pi%.8f, msg%s\n,n,pi,msg);fclose(fp);return0;}读取的顺序和类型必须与写入时严格一致。如果读的类型对不上结果将是垃圾值。检查fread的返回值fread可能读不到你期望的数量文件损坏、意外结束。应该检查返回值size_tread_countfread(n,sizeof(int),1,fp);if(read_count!1){printf(读取失败或文件结束\n);}三、结构体 二进制 天然绝配结构体的内存布局是连续的考虑对齐因此可以一次性把整个结构体写入或读出极其方便。#includestdio.htypedefstruct{charname[20];intid;floatscore;}Student;intmain(void){// 写入FILE*fpfopen(students.bin,wb);Student s1{Alice,1001,92.5};Student s2{Bob,1002,85.0};fwrite(s1,sizeof(Student),1,fp);fwrite(s2,sizeof(Student),1,fp);fclose(fp);// 读取fpfopen(students.bin,rb);Student students[10];intcount0;while(fread(students[count],sizeof(Student),1,fp)1){count;}fclose(fp);for(inti0;icount;i){printf(%s %d %.1f\n,students[i].name,students[i].id,students[i].score);}return0;}注意如果结构体中有指针成员比如char *name不能直接fwrite。因为写入的是指针的值一个地址而不是指针指向的内容读回来时那个地址早已无效。包含指针的结构体需要手动序列化逐个成员处理。四、随机访问在文件中“跳来跳去”到目前为止我们读写文件都是顺序的——从头往后不能回头不能直接定位。但很多时候我们想直接跳到第 100 条记录、或者回到开头重读。这就需要随机访问。每个文件流内部维护一个当前位置指示器记录下一次读写将在哪个字节偏移处进行。C 语言提供了三个关键函数来操作它。1.fseek定位到指定位置intfseek(FILE*stream,longoffset,intwhence);stream文件指针。offset偏移量字节正数向后移负数向前移。whence参照点可选SEEK_SET文件开头SEEK_CUR当前位置SEEK_END文件末尾示例fseek(fp,0,SEEK_SET);// 回到文件开头fseek(fp,sizeof(Student)*5,SEEK_SET);// 跳到第 6 个学生索引 5fseek(fp,0,SEEK_END);// 跳到文件末尾fseek(fp,-100,SEEK_END);// 从文件末尾往前退 100 字节fseek(fp,10,SEEK_CUR);// 从当前位置往后跳 10 字节返回值成功返回 0失败返回非 0。2.ftell获取当前位置longftell(FILE*stream);返回当前字节偏移从文件开头算起出错返回-1L。fseek(fp,0,SEEK_END);longfile_sizeftell(fp);// 文件大小字节数3.rewind快捷回到开头rewind(fp);等价于fseek(fp, 0, SEEK_SET);但同时会清除文件流的错误标志。五、实战小型学生信息管理系统二进制存储 随机读写把结构体、动态内存、随机访问结合做一个完整的小型管理系统。数据以二进制存储支持添加、列表、修改、查找、删除功能。student_system.c#includestdio.h#includestdlib.h#includestring.h#defineFILENAMEstudents.dat#defineMAX_NAME20typedefstruct{charname[MAX_NAME];intid;floatscore;}Student;// 追加一个学生记录voidadd_student(void){FILE*fpfopen(FILENAME,ab);if(fpNULL){perror(打开文件失败);return;}Student s;printf(姓名 学号 成绩: );scanf(%s %d %f,s.name,s.id,s.score);fwrite(s,sizeof(Student),1,fp);fclose(fp);printf(已添加。\n);}// 列表所有学生voidlist_students(void){FILE*fpfopen(FILENAME,rb);if(fpNULL){printf(暂无记录。\n);return;}Student s;intcount0;printf(---- 学生列表 ----\n);while(fread(s,sizeof(Student),1,fp)1){printf(#%d: %s, 学号%d, 成绩%.1f\n,count,s.name,s.id,s.score);count;}if(count0)printf(无记录\n);fclose(fp);}// 根据学号查找intfind_student_by_id(inttarget_id,Student*out){FILE*fpfopen(FILENAME,rb);if(fpNULL)return-1;intindex0;while(fread(out,sizeof(Student),1,fp)1){if(out-idtarget_id){fclose(fp);returnindex;// 返回记录索引}index;}fclose(fp);return-1;}// 修改学生成绩voidupdate_score(void){inttarget_id;printf(输入要修改的学号: );scanf(%d,target_id);FILE*fpfopen(FILENAME,rb);// 二进制读写模式if(fpNULL){printf(文件不存在。\n);return;}Student s;longpos;intfound0;while((posftell(fp))0fread(s,sizeof(Student),1,fp)1){if(s.idtarget_id){printf(当前成绩: %.1f新成绩: ,s.score);scanf(%f,s.score);fseek(fp,pos,SEEK_SET);// 回到这条记录的开头fwrite(s,sizeof(Student),1,fp);// 覆盖写入found1;break;}}fclose(fp);printf(found?修改成功。\n:未找到该学号。\n);}// 删除学生通过创建新文件并跳过删除项voiddelete_student(void){inttarget_id;printf(输入要删除的学号: );scanf(%d,target_id);FILE*fpfopen(FILENAME,rb);if(fpNULL){printf(文件不存在。\n);return;}FILE*tempfopen(temp.dat,wb);if(tempNULL){perror(临时文件创建失败);fclose(fp);return;}Student s;intfound0;while(fread(s,sizeof(Student),1,fp)1){if(s.idtarget_id){found1;// 跳过这条记录}else{fwrite(s,sizeof(Student),1,temp);}}fclose(fp);fclose(temp);remove(FILENAME);rename(temp.dat,FILENAME);printf(found?删除成功。\n:未找到该学号。\n);}intmain(void){intchoice;while(1){printf(\n1.添加 2.列表 3.查找 4.修改 5.删除 0.退出\n);printf(选择: );scanf(%d,choice);switch(choice){case1:add_student();break;case2:list_students();break;case3:{intid;printf(输入学号: );scanf(%d,id);Student s;intidxfind_student_by_id(id,s);if(idx0)printf(找到: %s 成绩 %.1f\n,s.name,s.score);elseprintf(未找到。\n);break;}case4:update_score();break;case5:delete_student();break;case0:return0;default:printf(无效选项。\n);}}}重点分析追加用ab模式新记录自动加在末尾。修改用rb模式先用ftell记录读取位置读到目标后用fseek回退到该位置再fwrite覆盖。这就用到了随机定位。删除因为从文件中“挖掉”一块很难常用策略是创建临时文件把要保留的记录复制过去跳过目标再替换原文件。这也是随机读写的一种变通应用。六、常见错误与陷阱1. 忘记b模式Windows 下最要命fopen(data.bin,w);// Windows 下会把 0x0A 变成 0x0D 0x0A在 Windows 上文本模式会转换换行符破坏二进制数据。写二进制文件永远加b。2. 读写的结构体包含指针typedefstruct{char*name;// 指针intid;}Bad;fwrite(bad,sizeof(Bad),1,fp);// 写入的是指针值不是字符串包含指针的结构体不能直接二进制读写。只对不含指针的“纯数据”结构体使用。3. 对fread返回值检查不足fread(s,sizeof(Student),5,fp);// 期望读 5 个实际可能只读 3 个永远用返回值判断实际读到了多少块。4.fseek偏移量计算错误fseek(fp,5,SEEK_SET);// 跳到第 5 字节不是第 5 个记录若想跳第n条记录偏移量应为n * sizeof(Student)。5. 跨平台字节序和大小不一致一台机器上写入的二进制文件另一台可能读出来是错的比如大端 vs 小端int4 字节 vs 2 字节。如果要在不同平台间交换二进制数据需要设计固定的字节序和类型大小如使用int32_t进行序列化。七、小结今天你打开了文件操作的另一半世界二进制 I/Ofwrite和fread直接搬移内存高效紧凑与结构体配合尤其方便。随机访问fseek让你在文件中任意定位ftell告诉你当前位置rewind一键回开头。实战组合用二进制 随机读写实现了学生信息的增、查、改、删体验了“文件即数据库”的雏形。现在你对文件操作已经有了相当全面的掌握。但 C 语言还有一个非常强大却容易被滥用的部分——预处理器。从下一篇开始我们将进入预处理指令的世界宏定义的技巧与陷阱条件编译如何让一套代码适配多平台以及#include背后更深层的管理艺术。准备好了吗课后小练习写一个程序用二进制方式把 100 以内的所有偶数写入evens.bin然后再读取回来验证数据是否正确。用fseek和ftell实现一个函数long file_size(const char *filename)返回文件的大小字节数。如果文件不存在返回 -1。在上面的学生管理系统中增加一个“交换第 i 和 第 j 条记录”的功能通过fseek定位两条记录读取到内存然后交换并写回。小挑战设计一个简单的“键值数据库”文件格式每条记录是int keyint value。实现put(key, value)和get(key)操作。提示可以将整个文件读入数组在内存中查找/修改再写回或者用fseek随机遍历。哪种方式更高效什么时候用哪一种我们下期见获取本系列示例代码请访问 GitCode 仓库。