25. 【C语言】二进制文件与随机读写

📅 2026/7/5 15:00:55 👁️ 阅读次数 📝 编程学习
25. 【C语言】二进制文件与随机读写

上一篇文章我们学会了用fprintffscanf读写文本文件。文本文件最大的好处是可读——拿记事本打开就能看懂。但它也有明显的短板:存一个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写入文件。

优缺点对比:

文本文件二进制文件
可读性人可以直接看懂乱码(需要程序解读)
文件体积较大(数字越长越大)紧凑(固定大小)
读写速度慢(需要格式转换)快(直接搬移内存)
精度可能损失(浮点数)完全不损失
跨平台安全需注意字节序和类型大小

什么时候用二进制?游戏存档、数据库文件、图像/音频/视频数据、大量传感器数据记录——只要数据量大、结构固定、不靠人眼阅读,就优选二进制。


二、freadfwrite:读写“裸”数据块

这两个函数是二进制 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:文件指针。
  • 返回值:成功读/写的数据块个数(不是字节数!)。

写入二进制数据

把几个不同类型的数据一次写入文件:

#include<stdio.h>intmain(void){FILE*fp=fopen("data.bin","wb");// 注意 "wb":二进制写模式if(fp==NULL){perror("打开文件失败");return1;}intn=42;doublepi=3.14159265;charmsg[]="Hello";fwrite(&n,sizeof(int),1,fp);// 写入一个 intfwrite(&pi,sizeof(double),1,fp);// 写入一个 doublefwrite(msg,sizeof(char),5,fp);// 写入 5 个 char(不含 '\0')fclose(fp);return0;}

"wb"中的b明确告诉操作系统“我是二进制模式”。在 Linux/macOS 上"w""wb"没有实际区别,但 Windows 上文本模式会自动把\n转换为\r\n,二进制模式则不会。为了跨平台,写二进制一定要加b

读取二进制数据

#include<stdio.h>intmain(void){FILE*fp=fopen("data.bin","rb");if(fp==NULL){perror("打开文件失败");return1;}intn;doublepi;charmsg[6]={0};// 多留一位给 '\0'fread(&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_count=fread(&n,sizeof(int),1,fp);if(read_count!=1){printf("读取失败或文件结束\n");}

三、结构体 + 二进制 = 天然绝配

结构体的内存布局是连续的(考虑对齐),因此可以一次性把整个结构体写入或读出,极其方便。

#include<stdio.h>typedefstruct{charname[20];intid;floatscore;}Student;intmain(void){// 写入FILE*fp=fopen("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);// 读取fp=fopen("students.bin","rb");Student students[10];intcount=0;while(fread(&students[count],sizeof(Student),1,fp)==1){count++;}fclose(fp);for(inti=0;i<count;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 个学生(索引 5)fseek(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_size=ftell(fp);// 文件大小(字节数)

3.rewind:快捷回到开头

rewind(fp);

等价于fseek(fp, 0, SEEK_SET);,但同时会清除文件流的错误标志。


五、实战:小型学生信息管理系统(二进制存储 + 随机读写)

把结构体、动态内存、随机访问结合,做一个完整的小型管理系统。数据以二进制存储,支持添加、列表、修改、查找、删除功能。

student_system.c

#include<stdio.h>#include<stdlib.h>#include<string.h>#defineFILENAME"students.dat"#defineMAX_NAME20typedefstruct{charname[MAX_NAME];intid;floatscore;}Student;// 追加一个学生记录voidadd_student(void){FILE*fp=fopen(FILENAME,"ab");if(fp==NULL){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*fp=fopen(FILENAME,"rb");if(fp==NULL){printf("暂无记录。\n");return;}Student s;intcount=0;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(count==0)printf("(无记录)\n");fclose(fp);}// 根据学号查找intfind_student_by_id(inttarget_id,Student*out){FILE*fp=fopen(FILENAME,"rb");if(fp==NULL)return-1;intindex=0;while(fread(out,sizeof(Student),1,fp)==1){if(out->id==target_id){fclose(fp);returnindex;// 返回记录索引}index++;}fclose(fp);return-1;}// 修改学生成绩voidupdate_score(void){inttarget_id;printf("输入要修改的学号: ");scanf("%d",&target_id);FILE*fp=fopen(FILENAME,"r+b");// 二进制读写模式if(fp==NULL){printf("文件不存在。\n");return;}Student s;longpos;intfound=0;while((pos=ftell(fp))>=0&&fread(&s,sizeof(Student),1,fp)==1){if(s.id==target_id){printf("当前成绩: %.1f,新成绩: ",s.score);scanf("%f",&s.score);fseek(fp,pos,SEEK_SET);// 回到这条记录的开头fwrite(&s,sizeof(Student),1,fp);// 覆盖写入found=1;break;}}fclose(fp);printf(found?"修改成功。\n":"未找到该学号。\n");}// 删除学生(通过创建新文件并跳过删除项)voiddelete_student(void){inttarget_id;printf("输入要删除的学号: ");scanf("%d",&target_id);FILE*fp=fopen(FILENAME,"rb");if(fp==NULL){printf("文件不存在。\n");return;}FILE*temp=fopen("temp.dat","wb");if(temp==NULL){perror("临时文件创建失败");fclose(fp);return;}Student s;intfound=0;while(fread(&s,sizeof(Student),1,fp)==1){if(s.id==target_id){found=1;// 跳过这条记录}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;intidx=find_student_by_id(id,&s);if(idx>=0)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"模式,新记录自动加在末尾。
  • 修改:用"r+b"模式,先用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/Ofwritefread直接搬移内存,高效紧凑,与结构体配合尤其方便。
  • 随机访问fseek让你在文件中任意定位,ftell告诉你当前位置,rewind一键回开头。
  • 实战组合:用二进制 + 随机读写实现了学生信息的增、查、改、删,体验了“文件即数据库”的雏形。

现在你对文件操作已经有了相当全面的掌握。但 C 语言还有一个非常强大却容易被滥用的部分——预处理器。从下一篇开始,我们将进入预处理指令的世界:宏定义的技巧与陷阱,条件编译如何让一套代码适配多平台,以及#include背后更深层的管理艺术。准备好了吗?


课后小练习

  1. 写一个程序,用二进制方式把 100 以内的所有偶数写入evens.bin,然后再读取回来验证数据是否正确。
  2. fseekftell实现一个函数long file_size(const char *filename),返回文件的大小(字节数)。如果文件不存在,返回 -1。
  3. 在上面的学生管理系统中增加一个“交换第 i 和 第 j 条记录”的功能:通过fseek定位两条记录,读取到内存,然后交换并写回。
  4. (小挑战)设计一个简单的“键值数据库”文件格式:每条记录是int key+int value。实现put(key, value)get(key)操作。提示:可以将整个文件读入数组,在内存中查找/修改,再写回;或者用fseek随机遍历。哪种方式更高效?什么时候用哪一种?

我们下期见!

💡获取本系列示例代码请访问 GitCode 仓库。