☰
C语言文件操作与数据处理实战:从结构体落盘到排序回写
2026/10/21 17:48:59 网站建设 项目流程

上周末整理旧硬盘,翻出一本2015年培训班发的C语言补充讲义,扉页上写着“第十一章 文件操作与数据处理”。那会儿课上用圆珠笔抄代码,画流程图,页边角还留着一个当时的疑问:“fopen和fclose到底谁在前谁在后”。如今重看,这个问题当然是幼稚了,但这一章从结构体到文本加密、再从排序到文件回写的内容,依然值得拿出来重新走一遍。它不是讲什么高深技巧,而是把一个数据处理程序最基本的闭环讲透了:内存里的结构体数据,怎么落到磁盘,怎么加密,怎么读回来排序,再写回去。这篇文章,我就顺着当年的笔迹,把这章实战内容重新读一遍,也把这几年踩过的坑一并补上。

1. 翻出2015年的第十一章笔记,我先看到了什么

1.1 那个年代学文件操作,硬件和课堂的氛围都不一样

2015年,培训班还是典型的“上午讲课、下午上机”节奏。前面十章的课程安排大致是:变量、分支、循环、数组、指针、字符串,到第十一章突然上文件操作,大家其实都是懵的。当时上机用的机器性能很一般,老师反复强调文件不要乱存,磁盘空间紧张,项目路径写错可能导致整个练习区变慢。就是在这种环境下,第十一章被安排成课程尾声的重点实战章节。

这一章的内容放到现在来看其实不多:打开文件、读写文件、关闭文件,利用结构体数组处理一批学生成绩数据,加密后写入文本,再读回来排序。全部代码加起来大概一百来行,可那是我第一次意识到,程序不只是在屏幕上打几个数字,而是可以把运行结果永久留下来。培训班里有很多同学卡在这一章,因为前面学数组、学指针还能靠逻辑推,文件操作涉及外部环境、文件状态、字节流这些概念,出错时连调试都不知道从哪下手。

所以这次重读笔记时,我没有把旧代码当一个“古董”看,而是把它当成一次现场复习。当年的示例代码简朴,很多边界条件没写清楚,正好借这个机会把文件操作模块化、补全错误处理,变成一个能直接拿到真实数据上跑的版本。

1.2 笔记首页那句“文件是程序的记忆”,我隔了九年才看懂

笔记本第一页上有句被红笔框起来的话,应该是当时老师写在黑板上的:“文件是程序的记忆”。那时我把它当考试金句背下来,但并没有真正理解。

九年后回头看,这句话其实点中了文件操作的本质。一个程序在内存里运行,变量、结构体、数组随时存在,可一旦退出,内存被释放,一切归零。要让数据跨时间、跨运行存在,就必须把数据写到文件里。小到一个学生成绩单,大到业务系统的用户档案,底层都是同一个逻辑:把内存中的结构化数据序列化成文件字节,下次运行再反序列化回来。数据库之所以复杂,是因为加了索引、事务、并发控制,而它最底层的存储能力,和C语言文件的落盘思路同源。

想通了这一点,再看第十一章的标题“文件操作与数据处理”,就会明白它为什么把结构体、加密、排序三者放在一起。结构体定义了数据在内存中的排列方式,文件决定了数据如何持久化,加密是数据在磁盘上的一种变换,排序则是对读回数据后的处理。整个链路缺一不可。

2. 结构体落盘:把一个学生信息库完整写进文件

2.1 为什么非要用结构体来组织数据

第十一章的实例是经典的“学生成绩管理”:每个学生有姓名、学号、三门课成绩。如果用三个数组分别存姓名、学号、成绩,代码也能写,但会出现一个问题:数组下标把三个维度的数据强行关联,一旦要删除某个学生、按成绩调整顺序,所有数组都要同步移动,非常容易出bug。

结构体的意义在于把一组相关数据打包成一个逻辑单位。一个struct Student就是一个学生的完整档案,studentArray[i]里的姓名、学号、成绩天然属于同一个人。不仅如此,结构体在内存中是连续分布的,一条记录对应一段连续内存块,这给文件写入提供了极大便利。

当年笔记里定义的结构体大概长这样:

#define NAME_LEN 20 struct Student { char name[NAME_LEN]; char id[10]; int score1; int score2; int score3; };

注意这里用的是定长字符数组,而不是char *指针。原因很现实:文件写入需要知道每条记录的固定大小,如果用指针,写进文件的是地址值而不是字符串内容,下次程序运行读回来,那个地址早已失效。只要你想把结构体直接写进文件,字段就必须是定长数组或固定大小的类型,这是当年笔记里用红笔标注过的一条教训。

2.2 fwrite一次写入与逐字段写入的区别

笔记里给出的核心写入代码是fwrite版本:

FILE *fp = fopen("students.dat", "wb"); if (fp == NULL) { perror("无法打开文件"); return 1; } fwrite(students, sizeof(struct Student), n, fp); fclose(fp);

fwrite四个参数分别是:内存缓冲区地址、单个数据块大小、数据块数量、文件指针。整句话的意思是,把内存中从students开始的n个sizeof(struct Student)大小的连续数据,原样复制到文件里。这种写法的优点是高效、简单、类型信息天然保留,读取时也用同样的结构体定义,就是一次内存拷贝。

另一种做法是逐字段用fprintf写入文本:

fprintf(fp, "%s %s %d %d %d\n", students[i].name, students[i].id, students[i].score1, students[i].score2, students[i].score3);

两者差别很大。fwrite写的是二进制原样字节,文件打开后是一堆不可读字符,但读写速度快、代码简洁。fprintf写的是文本,人眼可读、可以用文本编辑器查看、方便导入导出,但每次读写都要做一次格式化解析,字段之间要有分隔符,读取时还要按相同格式逆向解析。第十一章笔记里两种方法都讲了,当时不太理解为什么同一个问题要写两遍,后来才意识到,这就是“二进制文件”和“文本文件”两条路线的分叉口。

2.3 结构体对齐:当年没写进笔记本的隐藏知识点

这次重读我特地问了自己一个问题:sizeof(struct Student)真的是字段字节数之和吗?答案是未必。编译器为了CPU访问效率,会在结构体成员之间插入填充字节,这叫结构体对齐。

比如上面的结构体,name占20字节,id占10字节,三个int各占4字节,看起来总字节数应该是20+10+4+4+4=42。但按常见32位编译器,id后面可能需要补充2个填充字节,使下一个int按4字节边界对齐,于是sizeof可能变成44,甚至在64位平台上都不一样。

当年培训班笔记没有提对齐这个概念,因为它不教底层编译原理。但这是文件操作的真坑:用fwrite写入时,所有填充字节也会被写进文件。如果你在同一台机器、同一个编译器下读回,问题不大;可如果你把文件发给别人用另一个编译器处理,或者程序升级后改变结构体定义,读回来的数据就会错位。稳妥做法是数据落地时采用显式固定格式,比如逐字段存取,或者在结构体上使用打包属性,但这会牺牲一些性能。说到底,fwrite的便捷是建立在“结构体布局一致”这个前提上的,做文件数据交换时必须时刻记得这一点。

3. 文本加密实战:异或加密和它的两种改进方向

3.1 一个字节密钥的异或加密,为什么能写进教程

第十一章的后半部分安排了文本加密,当时的示例是异或加密。原理简单到一句话就能讲完:一个字节与密钥做异或得到密文,密文再与同一个密钥异或即可还原原文,因为对任意字节a和密钥k,恒有(a ^ k) ^ k == a。

为什么培训班要选它当教材?因为异或加密能同时复习三样东西:位运算、文件逐字节读写、对称加密的基本思想。代码量小,逻辑一目了然,跑起来还能看到文件内容变成乱码,数学上又自洽,很适合作为教学案例。

当年抄下来的加密函数核心循环大致是:

void encryptFile(FILE *fin, FILE *fout, unsigned char key) { int ch; while ((ch = fgetc(fin)) != EOF) { fputc((unsigned char)ch ^ key, fout); } }

解密时调用同一个函数即可,因为加密再解密等于异或两次。这个函数定义很干净,但站在今天的角度看,有两个问题需要补写。

3.2 重读代码后发现的两个隐患

第一个隐患是把fgetc返回值和EOF混用。fgetc返回的是int类型,正常情况下返回读到的字节值,范围是0到255;读到文件末尾或出错时返回EOF,EOF的值通常是-1。因为返回值是int,所以能区分255和EOF,用char变量接收就会出问题。上面代码里用int接收ch,再通过判断ch == EOF退出循环,逻辑没毛病。但很多人会因为图省事写成while((ch = fgetc(fin)) != EOF),这里ch必须声明成int,而不是char,否则对于包含0xFF字节的二进制文件,会出现“还没到结尾就被误判成EOF”的问题。笔记里没写这个细节,我当年第一次做加密练习就栽在这。

第二个隐患是单字节密钥的强度几乎为零。密钥空间只有256种可能,把所有密钥值穷举一遍,哪怕全手工测试,也能轻易找到有意义的明文。更关键的问题是,单字节密钥对所有字符做同样的变换,文件的统计特征几乎原样保留,频率分析一下就能猜出密钥的大致范围。所以这种加密只适合教学、适合当玩具,绝不能用来保护真实数据。如果谁在公司项目里用这个思路做加密,等于把数据脱光了放硬盘上。

3.3 把单密钥升级成多密钥和位置混淆

重读时,我给这个练手项目做了两个简单升级。第一是多字节密钥循环参与异或,例如:

const unsigned char key[] = {0x1F, 0x2A, 0x3C, 0x4D}; size_t klen = sizeof(key) / sizeof(key[0]); size_t idx = 0; int ch; while ((ch = fgetc(fin)) != EOF) { fputc((unsigned char)ch ^ key[idx % klen], fout); idx++; }

这样相同明文字节在不同位置会得到不同密文,统计特征被弱化。第二是加入位置混淆,让第i个字节额外异或一个与i相关的常数,比如i & 0x0F。这样密钥不再是单一重复模式,破解难度上升一个数量级。当然,这类代码写起来很有意思,但我在笔记的空白处补了一句新感想:正统加密需要成熟的密码库,自己写的流密码仅供学习和理解。想在这种主题下走得更深,可以去研究密钥流生成算法,但从工程角度讲,专业的事交给专业库。

4. 从文件读回来再排序:数据处理最完整的闭环

4.1 读文件之前,必须先确认文件是啥格式

排序的前提是把数据从文件读回内存。读文件最忌上来就写fscanf或fread,你得先确认文件当初是怎么写出来的。

如果之前用的是fwrite二进制写入,那么读回时要用fread,并且使用同一个struct Student类型:

FILE *fp = fopen("students.dat", "rb"); if (fp == NULL) { perror("无法打开文件"); return 1; } struct Student readBack[100]; int n = 0; size_t count = fread(readBack, sizeof(struct Student), 100, fp); n = (int)count; fclose(fp);

注意fread的返回值是成功读取的元素个数,不是字节数,所以要直接拿它作为元素数量。如果文件里记录数少于100,fread会在文件结尾处停下,count就是实际数量,这个用法比feof判断更可靠。

如果之前用的是fprintf文本写入,那么读回时应该一段一段解析,比如:

while (fscanf(fp, "%s %s %d %d %d", readBack[n].name, readBack[n].id, &readBack[n].score1, &readBack[n].score2, &readBack[n].score3) == 5) { n++; }

两种方式按文件格式匹配即可。千万不要用fread去读fprintf生成的文本文件,也不要指望fscanf能解析fwrite写出的二进制数据,格式错位会让数据面目全非。

4.2 用qsort排序,把冒泡留给作业本

2015年培训班讲排序时正处在“数组排序”章节,那会儿大家还在手写冒泡。所以笔记里第十一章的排序部分保留了冒泡排序的流程图。但这次重读我决定换成qsort。原因是C标准库已经提供了稳定、通用的排序函数,工程实践中没有理由自己造一个O(n²)的轮子。

qsort的用法固定,难点只在比较函数:

int cmpByScore(const void *a, const void *b) { const struct Student *pa = (const struct Student *)a; const struct Student *pb = (const struct Student *)b; return (pa->score1 > pb->score1) - (pa->score1 < pb->score1); }

比较函数返回负值、零、正值分别表示a小于、等于、大于b。写成(pa->score1 > pb->score1) - (pa->score1 < pb->score1)是为了避免直接用差值,当score是浮点数时差值会有精度问题,用大小比较更安全。

调用只需要一行:

qsort(readBack, n, sizeof(struct Student), cmpByScore);

这次重读我特意比较了冒泡和qsort的差距。数据量只有几十条时,冒泡的劣势看不出来;一旦升到几万条,冒泡要跑几秒,qsort几乎是瞬间完成。C语言的确是基础,但基础不等于要重复造轮子,标准库放在那里,不用的理由只有一个:你还没学到它。

4.3 排序后写回文件:覆盖、备份和模式选择

排序完成的下一步是写回文件。这里有个很容易踩的坑:用“wb”还是“ab”。把“a”当成“追加”,还是“覆盖”,决定了文件是否保留原有内容。

当年笔记里的需求是生成一份排序结果,所以我推荐先备份原始文件,再写新文件。做法是:排序后打开一个新文件比如“students_sorted.dat”,以“wb”写入排序后的数组。这样原始数据保留,新文件是排序结果,对比检查时谁都不会改坏原文件。如果想直接覆盖原文件,必须先确认你在内存里已经保留了所有数据,否则一旦打开原文件写入失败,数据就彻底丢了。

另外,写回文件同样要区分二进制和文本需求。排序结果需要给人看时,我用fprintf格式化成对齐的文本:

FILE *fp = fopen("students_sorted.txt", "w"); if (fp == NULL) { perror("无法打开文件"); return 1; } for (int i = 0; i < n; i++) { fprintf(fp, "%s %s %d %d %d\n", readBack[i].name, readBack[i].id, readBack[i].score1, readBack[i].score2, readBack[i].score3); } fclose(fp);

这个版本比二进制更适合演示,因为你能直接打开看结果。而如果排序后还要再被程序读取,那继续用二进制fwrite反而更方便。所以写代码前,一定要先问一句:这个文件是给人看,还是给程序看的?两种答案对应两种模式。

5. 重读笔记之后才想明白的坑,全在这三条里

5.1 换行符:一个字节引发的“看起来正常”的错乱

笔记里讲过文本文件按行读写,但没有往深一层讲换行符在不同平台上不一致的问题。在文本模式下,程序里写一个'\n',Windows系统会转换为'\r\n'两个字符存盘,读取时反向把'\r\n'还原成'\n'。在二进制模式下,'\n'就是一个字节0x0A,不做任何转换。

这个差异放在当年纯中文Windows环境不明显,因为大家基本只在文本模式写文件。现在做跨平台开发就必须重视:一个在Windows下生成的文本文件,直接放到类Unix环境读取,每一行末尾会多出一个\r,打印时经常看到奇怪的换行效果;反过来,Unix换行文件拿到Windows的记事本里会显示成一行。解决办法是统一用二进制模式“rb”、“wb”打开文件自己处理换行,或者用文本模式时明确知道平台转换规则。写文件落盘前先想清楚目标平台,比事后清理\r高效太多。

5.2 feof不是你想的那样用

第十一章笔记里,当年很多同学读文件时都用过:

while (!feof(fp)) { fscanf(fp, "%s %s %d %d %d", ...); // 处理读取的数据 }

这看似聪明,实则会在文件末尾多处理一次。feof只有在读取函数试图越过文件末尾之后,才会被置位。也就是说,读完最后一条有效数据时,feof还是假,程序会再进入循环一次,这次fscanf读取失败,但代码里如果没有检查返回值,就会把上一次循环残留的数据又处理一遍,造成重复记录或越界访问。

正确的写法是把读取函数的结果作为循环条件:

while (fscanf(fp, "%s %s %d %d %d", readBack[n].name, readBack[n].id, &readBack[n].score1, &readBack[n].score2, &readBack[n].score3) == 5) { n++; }

fscanf返回成功转换的元素个数,等于5才说明读到了一条完整记录。读到末尾时会返回EOF,循环自然退出。这样写不仅代码短,还天然处理了“最后一行的空白导致异常读取”的问题。

5.3 中文内容读写时的字节梦魇

排序、加密都和数据内容打交道,一旦数据里有中文,2015年的笔记本就变得有点旧了。那会儿很多中文系统默认使用GBK编码,一个汉字占2字节;现在主流环境已经广泛转向UTF-8,一个汉字占3字节。同样一块char name[20]字段,能存下的汉字数量完全不同。

如果用fprintf写字符串,程序内部以多少字节一个汉字当作字符数组来看待,文件里就会以相应编码写出对应的字节。写回后用fscanf按%s读入,遇到空格、换行符会截断,中文通常不含空格,所以整段能被读回来。但这不代表没有坑:如果你的char数组长度是20,而一个UTF-8中文名含有7个汉字,共21字节,就会溢出写坏内存;GBK年代同样容量的数组能多存两个汉字。这个细节在2015年不太起眼,如今却直接决定一个文件处理程序是否支持中文数据。

涉及加密时要更小心。异或加密会把中文字节的高位字节改得面目全非,文件变成纯二进制流,这是预期行为。解密时只要按原顺序异或回来,就能还原成原始字节,进而恢复中文。但如果加密时分了块,或用了缓冲区,就必须保证密文写入顺序和读取解密顺序一致,否则文件直接变成一摊乱码。

6. 重读之后我做成了一件小事:把四段代码练成一体

这次重读第十一章笔记,我给自己定了一个小目标:把结构体定义、文件写入、加密、读取排序、回写这几个片段串成一个完整程序。程序流程是:录入一批学生信息放进结构体数组,以二进制fwrite方式写入students.dat;对students.dat做异或加密生成students_enc.dat;再把students.dat读回内存,按总成绩用qsort排序;排序结果用fprintf写入students_sorted.txt并打印到屏幕。

串起来之后,你会发现几件事。第一,文件操作本身代码量不大,真正的复杂度全部集中在格式约定和状态管理上——你用什么格式写出去,就要用对应格式读回来,加密和解密的规则要完全对称。第二,错误处理远比当年笔记里写的多,打开文件要检查NULL,写入后要检查fflush与fclose的返回值,不然数据可能还在缓冲区里没落盘。第三,一个功能完整的C语言数据处理程序,其实不需要多少高深语法,文件操作就是它走向真实世界的那道门槛。

最后,分享一个整理旧笔记时很实用的小技巧:不要把旧代码直接拿来跑,而是先抄一遍,边抄边问为什么这么写。当年觉得理所当然的写法,现在看可能是没有处理边界;当年一笔带过的概念,现在看可能是整个环节的命门。我就是这样把第十一章重新读了一遍,读完又顺手在自己的练习目录里多建了一个mini项目。对你来说,如果手边也翻出了类似的旧代码,不妨试试这个笨办法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询