ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C语言结构体与文件操作:数据打包到持久化存储全解析

C语言结构体与文件操作:数据打包到持久化存储全解析

C语言学到结构体和文件操作这一块,算是真正站在了“实用程序”的门槛上。前面的数组、循环、函数,本质上都是在跟散装数据打交道,变量来变量去,程序一结束数据就烟消云散。而结构体让数据有了“容器”和“形状”,文件操作让程序有了“长期记忆”。这篇提纲是系列的第四篇,我把这两大块内容按常见的教学节奏和数据流串起来,从定义、初始化的基础写法,到指针、内存分配的进阶操作,再到文本文件、二进制文件的读写与定位,最后附上我实际教学中遇到的各类问题和排查经验。无论是刚啃完指针的初学者,还是想快速捡起这两块内容去写课设、做小工具的朋友,这篇提纲都值得你从头到尾过一遍。

1. 结构体:C语言的数据打包术

1.1 从散装数据到组合类型:结构体到底解决什么问题

先想一个很常见的需求:你写一个学生管理系统,要记录一个学生的学号、姓名、年龄、成绩。没有结构体的时候怎么写?要么定义四个独立变量,要么开几个平行数组。

int id[50]; char name[50][20]; int age[50]; float score[50];

这样的问题很明显:数据是“散装”的,第 i 个学生的四个属性散落在四个数组里,你得小心翼翼地维护下标同步。一旦涉及排序、函数传参、动态增删,这个同步成本会迅速失控。比如写一个函数按成绩排序,你得同时交换四个数组的元素,稍不留神就错位。

这就是结构体存在的理由:它把逻辑上属于同一个实体的多个属性,打包成一个新的组合类型。你可以像操作一个 int 变量一样,去定义一个“学生”变量,赋值、传参、比较、存储,一切按整体进行。

从内存视角看,结构体是一段连续的内存块,里面的成员按定义顺序依次排列(实际会有对齐问题,后面细说)。这一点很重要:它决定了结构体变量可以直接用赋值号整体拷贝,可以一次性写入文件,也可以整体作为函数参数传递(不过传值有开销,实际工程里大多数传指针)。

1.2 定义、初始化与访问:一套能直接抄的写法

结构体的定义语法,说白了就是用 struct 关键字声明一个模板,里面列出成员。

struct student { int id; char name[20]; int age; float score; };

定义完模板之后,struct student 就是一个完整的类型名,可以拿来定义变量。

struct student s1; // 默认初始化,成员是未定值(局部变量) struct student s2 = {1001, "张三", 18, 89.5f}; // 按顺序初始化 struct student s3 = {.id = 1002, .name = "李四", .age = 19, .score = 92.0f}; // 指定成员初始化

这里有几个实操要点。第一,花括号初试化时成员顺序必须和定义完全一致,数量可以少,不能跳;少了的成员自动置零。第二,指定成员初始化(.成员名=)是后来才加入的语法,不少老教材不提,但实用性很强,尤其在结构体成员很多、只想初始化其中几个的时候特别管用。第三,字符数组成员是数组,不能直接用赋值号塞字符串,初始化时可以用花括号直接灌进去,但程序运行途中想改,就得用 strcpy:

strcpy(s1.name, "王五"); // 不能 s1.name = "王五";

访问成员的操作符就是点号 .。打个比方:结构体是一个收纳盒,点号就是你打开某个小格子。

s1.age = 20; s1.score = 95.5f; printf("学号:%d,姓名:%s,成绩:%.1f\n", s1.id, s1.name, s1.score);

如果你想复制整个结构体,直接用赋值号就行:

struct student temp; temp = s1; // 这是深拷贝吗?不,这是浅拷贝——有陷阱,见下文

如果结构体里只有普通变量、数组,整体赋值没什么问题。但如果结构体里出现了指针成员,整体赋值只会复制指针本身(地址),不会复制指针指向的那块内存。这个细节在后面深入时会再次遇到,先埋个伏笔。

1.3 结构体数组、嵌套与自引用:从“一个”到“一批”再到“串起来”

单个结构体变量解决“一个学生”的问题,实际项目中很少只处理一个实体。结构体数组就是最直接的批量方案:

struct student stu[50]; // 能记录50个学生

访问第 j 个学生的成绩,就是 stu[j].score。遍历、查找、排序,本质上和普通数组操作一致,只是元素从 int 变成了 struct student。

嵌套结构体也顺理成章:结构体的成员本身可以是一个结构体,就像抽屉里再放一个小收纳盒。

struct birthday { int year; int month; int day; }; struct student { int id; char name[20]; struct birthday bd; };

访问嵌套成员就用连续点号:stu[0].bd.year。

自引用是另一个重要玩法——结构体内部包含一个指向自己类型(注意:是指针,不是结构体本身)的成员。这是链表、树、图等所有动态数据结构的基石:

struct node { int data; struct node *next; };

为什么必须是“指向自身类型的指针”而不能直接放一个 struct node next?因为如果直接放一个 struct node next,编译器在确定 struct node 大小时会发现它里面又有一个 struct node,形成一个无限递归,结构体变成无限大,这是不可能的。而指针的大小是固定的(32位平台4字节,64位平台8字节),循环定义就能被切断。这个点每次讲链表时都要强调,理解它,你就理解了所有链式结构的设计起点。

2. 结构体的灵魂:指针与内存管理

2.1 结构体指针:点号与箭头的默契分工

结构体变量本质是一段内存,有地址。指向它的指针就是结构体指针。

struct student s1; struct student *p; // p是一个指向struct student的指针 p = &s1;

有了指针 p,访问成员有两种写法,一是 (*p).age,二是 p->age。前者是“先用解引用拿到结构体本体,再用点号访问”,后者是语法糖,箭头操作符的意义就是用指针直接穿过结构体取成员,更直观也更安全(不会出现解引用优先级问题)。

实际开发中,结构体指针比结构体本体使用得更频繁,原因有三。其一是避免大块内存的拷贝——如果结构体里有几百字节的数组,作为函数参数直接传 struct student 会整体压栈,开销不小;传指针只传地址,成本固定。其二是方便修改原对象——函数内通过指针修改成员,能直接影响外部的结构体变量,而不需要返回值来回倒腾。其三是配合动态内存分配使用,结构体指针是堆内存的唯一访问方式。

值得留意的是运算符优先级:-> 的优先级很高,和点号、下标一样属于最高档。写 p->age = ++p->age 这种代码时,++ 会先作用于 p->age 的值再赋值,逻辑没问题,但可读性很差,我一般建议别炫技,老老实实分行写。

2.2 动态内存分配:让结构体摆脱“写死个数”的束缚

结构体数组有一个硬伤:大小写死。定义 struct student stu[50],就只能装50个学生,多了装不下,少了白占内存。但程序在运行时无法提前知道需要几个学生。这时候就轮到 malloc 登场:

struct student *stu = (struct student*)malloc(sizeof(struct student) * n);

这行代码的含义是:在堆上申请一段足够 n 个 struct student 连续存放的内存,返回首地址。n 是运行时算出来的,可以是100,也可以是500,完全由需求决定。

两个关键细节要停一下。

第一,sizeof(struct student) 不能写成 sizeof(struct student*) 或别的什么。前者是计算“结构体实际占用多少内存”,后者是计算“指针变量占多少内存”,两者可能差了十倍。新手写 malloc 时最容易在这处翻车:申请了指针的大小去存放结构体,越界写坏一片堆内存,程序时好时坏,极难排查。要多用 sizeof(类型名) 而不是手动数字节数。

第二,malloc 返回 void*,在 C 中可以隐式转换到任意类型指针,但为了明确意图和兼容旧编译器,显式强转是更稳妥的习惯。申请的堆内存用完必须 free,并且 free 之后指针要置 NULL,防止野指针:

free(stu); stu = NULL;

这里的“为什么”很好理解:malloc 申请的内存不会自动释放,程序不结束它就一直在那儿,循环里反复 malloc 不 free,内存占用会持续上涨(内存泄漏)。free 之后指针虽然还保存着旧地址,但那段内存已经失效,再去访问就是未定义行为,置 NULL 是为了让后续误用立刻崩溃,而不是悄悄出错。

2.3 typedef 与结构体:代码可读性的“破冰锤”

C 语言里 struct student 作为完整类型名,每次声明变量都要敲一遍“struct ”,代码长了确实啰嗦。typedef 就是给类型起别名的机制:

typedef struct student { int id; char name[20]; int age; float score; } Student;

从此之后,Student 与 struct student 完全等价,声明变量时就可以直接写 Student s1;。很多开源项目干脆把 typedef 和结构体定义合在一起写成匿名结构体,看起来更简洁:

typedef struct { int x; int y; } Point;

不过这种“匿名结构体 + typedef”也有代价:因为结构体本身没有标签,后续想做自引用(比如链表节点)或者提前类型声明时就会吃亏。遇到链表、树这类自引用结构,老老实实用带标签的写法,不要贪省那几个字。

用 typedef 的深层意义不仅是少打字,更是信息抽象。使用者看到 Student 这个类型名,不需要关心里面是几个成员、占用多大内存,他只关心“这是一个学生”。当设计大型程序时,类型名本身就是文档,能让代码的意图传达得更清楚。

3. 文件操作:让数据在断电后依然存活

3.1 文件操作的“三段论”:打开、读写、关闭

讲文件操作,我常用“记账本”来类比。你用笔记本记东西,流程是什么?打开本子、写字、合上本子。程序操作文件也是三件事:fopen 打开文件、fread/fwrite/fprintf/fscanf 读写数据、fclose关闭文件。

怎么强调“忘记关闭”的风险都不为过。程序在运行时,数据先写入缓冲区(内存),fclose 时才真正把缓冲区的内容刷到磁盘。如果不调用 fclose,程序正常退出时虽然系统会自动清理(C 标准也要求正常返回时刷新缓冲区),但程序异常崩溃时,缓冲区里的数据大概率丢失。即使程序正常退出,你也无法控制刷盘时机,数据可能处于“看似写了、其实没落盘”的中间态。所以文件操作完成后立刻 fclose,是必须刻进肌肉记忆的习惯。

文件打开的典型写法:

FILE *fp = fopen("data.txt", "r"); if (fp == NULL) { perror("文件打开失败"); return 1; } // 读写操作... fclose(fp);

关键点不在 fopen 这一行,而在紧接着的判断。fopen 返回 NULL 的概率远比想象中高:路径写错、文件不存在、权限不足、磁盘满,都会导致失败。不检查返回值就直接进行读写,程序会解引用空指针,行为完全不可预期(多数是段错误)。

3.2 fopen 模式全家桶:看懂模式字符串,避免“打开就报错”

fopen 的第二个参数是模式字符串,一个字符代表一种打开方式,仔细看其实有规律:

模式含义文件存在文件不存在位置
r只读(文本)从头读打开失败开头
w只写(文本)清空原内容创建新文件开头
a追加写(文本)保留原内容创建新文件末尾
r+读写(文本)可读可写打开失败开头
w+读写(文本)清空原内容创建新文件开头
a+读和追加写(文本)保留原内容创建新文件读从头,写永远到末尾
rb / wb / ab / rb+ / wb+ / ab+对应的二进制模式规则同上规则同上同上

实操里最常出的问题有两处。一处是混淆 r+ 和 w+:如果你用自己的训练数据,第二次运行想“修改”文件,随手用了 w+,文件原有内容会被瞬间清空,这可能是灾难性的。另一处是追加模式 a+ 的位置语义:读可以从头开始,写却永远被强制追加到末尾,即使你做了 fseek,也改变不了“写永远追加”这个行为。

特别提醒一点:在 Windows 平台上,文本模式下的读写会在 \n 到 \r\n 之间做自动转换。C 程序里写 \n,文件里可能存的是 \r\n;读的时候反过来自动把 \r\n 还原成 \n。这个转换在大多数场景是省心的,但如果你的数据格式依赖精确字节数(比如二进制协议文件),必须用 b 模式。用文本模式处理二进制数据,文件会被“篡改”,轻则数据错乱,重则程序崩溃。

3.3 文本文件读写:fprintf 和 fscanf 的成对艺术

文本文件是“人可读”的存储方式,读写的基本工具就是 fprintf 和 fscanf。它们和 printf、scanf 的参数格式几乎一致,只是多了一个文件指针参数:

// 写入 FILE *fp = fopen("students.txt", "w"); if (fp == NULL) { perror("打开文件失败"); return 1; } fprintf(fp, "%d %s %d %.2f\n", stu.id, stu.name, stu.age, stu.score); // 读取 FILE *fp2 = fopen("students.txt", "r"); struct student temp; fscanf(fp2, "%d %s %d %f", &temp.id, temp.name, &temp.age, &temp.score);

文本读写最关键的原则是“写格式与读格式必须严格对称”。你用什么格式写进去,就必须用对应格式读出来。比如写的时候 %d 后面跟一个空格,读的时候也要用 %d 跳过这个空格;写的时候用了 \n,读的时候用循环逐行读,才能真正按预期解析。

看字符串成员要格外小心:fscanf 里 %s 读入的字符串默认遇到空白字符(空格、制表符、换行)就结束。如果学生名字是“张 三”,这个空格会导致读入变成两个字段,后续数据全部错位。所以要么约定姓名无空格,要么用 fgets 逐行读取再自行解析。

fprintf 的格式串里用来分隔数据的空格、换行,本质上是“数据之间的分界符”。只要你能保证写入和读取的顺序、分隔符一致,文件格式的正确性就稳了一半。这套对称性思维,是后续学习任何序列化格式(JSON、XML、CSV)的底层直觉。

4. 二进制文件与文件定位实战

4.1 二进制读写:fwrite 和 fread 的高效交换

文本文件的读写需要格式编码、解码,效率偏低。当处理的数据量较大,或者你要存储的就是一堆结构体,用二进制模式配合 fwrite/fread 更直接高效。

typedef struct { int id; char name[20]; int age; float score; } Student; Student stu = {1001, "张三", 18, 89.5f}; FILE *fp = fopen("student.dat", "wb"); if (fp == NULL) { perror("打开文件失败"); return 1; } fwrite(&stu, sizeof(Student), 1, fp); fclose(fp);

fwrite 的参数四项依次是:数据源地址、单个数据块的字节数、数据块数量、文件指针。上面例子里的 1 表示一次写一个 Student。如果是一个 Student 数组要整体写进去,可以:

Student stu[50]; fwrite(stu, sizeof(Student), 50, fp);

对应的读取是:

Student stu[50]; size_t n = fread(stu, sizeof(Student), 50, fp);

fread 的返回值是“成功读取的元素个数”。如果文件不够长,返回的值会小于请求的 50,而不是直接报错。实战里标准做法是拿返回值判断是否读完:

Student temp; while (fread(&temp, sizeof(Student), 1, fp) == 1) { // 逐个处理 temp }

这个循环的“为什么”很重要:fread 到文件末尾时不会自动停下,而是返回0,循环条件据此退出。如果你用 feof 来判断,要理解 feof 只在读取操作越过文件末尾后才置位,新手用 feof 判断“文件是否结束”经常会多读一次或者漏读一次,因为标记设置时机比直觉晚。直接利用 fread 的返回值作为循环条件,是最无歧义的写法。

4.2 文件指针的移动:fseek 与 rewind 的“回放”技能

每个打开的文件都有一个“文件位置指示器”,记录下一次读或写在文件中的哪个位置。刚打开的时候,位置在开头(r模式)或末尾(a模式)。fseek 可以移动到任意位置,原型:

int fseek(FILE *fp, long offset, int whence);

参数 whence 有三种基准点:

基准点意义
SEEK_SET从文件开头计算偏移
SEEK_CUR从当前位置计算偏移
SEEK_END从文件末尾计算偏移(offset 通常为负值)

offset 的单位是字节。比如“跳到一个文件开头第10个字节的位置再读”:

fseek(fp, 10L, SEEK_SET);

“跳到距文件末尾前20字节处”:

fseek(fp, -20L, SEEK_END);

rewind(fp) 等价于 fseek(fp, 0L, SEEK_SET),把位置拨回开头。

这个机制在处理“定长记录”时威力巨大。比如前面那个 Student 结构体,如果 sizeof(Student) 是固定长度的(成员不含变长数据),那么第 k 个学生就能直接定位到文件偏移量 k * sizeof(Student) 处,不需要从头读 k 个学生再丢弃。这种随机访问能力让二进制文件和 fseek 成为“像数组一样用文件”的利器。

使用 fseek 要注意返回值:成功返回0,失败返回非0(比如偏移越过文件尾部也是允许的,但这是“扩展文件”的前奏,需要后续写入才有意义)。文件指针移动后如果想了解当前位置,用 ftell(fp) 返回相对于文件开头的字节偏移。

4.3 一个小项目:学生信息持久化存储的完整流程

把结构体和文件综合起来,我经常给学生布置一个小练手:实现一个学生信息的增删查改。这个项目看着小,但它把结构体、指针、文件读写、定位全串起来了,很适合检验这几章的掌握程度。

我先给一个最小能跑的骨架,重点展示“写入-读取-定位修改”的循环。

#include <stdio.h> #include <stdlib.h> #include <string.h> typedef struct { int id; char name[20]; int age; float score; } Student; // 追加一个学生记录 void append_student(const char *filename, Student *s) { FILE *fp = fopen(filename, "ab"); if (fp == NULL) { perror("打开文件失败"); return; } fwrite(s, sizeof(Student), 1, fp); fclose(fp); } // 读取文件中所有学生 void read_all(const char *filename) { FILE *fp = fopen(filename, "rb"); if (fp == NULL) { perror("打开文件失败"); return; } Student s; while (fread(&s, sizeof(Student), 1, fp) == 1) { printf("学号:%d,姓名:%s,年龄:%d,成绩:%.2f\n", s.id, s.name, s.age, s.score); } fclose(fp); } // 按学号修改成绩 void update_score(const char *filename, int target_id, float new_score) { FILE *fp = fopen(filename, "rb+"); // r+ 二进制模式 if (fp == NULL) { perror("打开文件失败"); return; } Student s; int found = 0; while (fread(&s, sizeof(Student), 1, fp) == 1) { if (s.id == target_id) { s.score = new_score; fseek(fp, -1 * (long)sizeof(Student), SEEK_CUR); // 回退到这条记录的开头 fwrite(&s, sizeof(Student), 1, fp); // 覆盖写回 found = 1; break; } } fclose(fp); if (found) { printf("修改成功\n"); } else { printf("未找到学号为 %d 的记录\n", target_id); } } int main(void) { Student stu1 = {1001, "张三", 18, 89.5f}; Student stu2 = {1002, "李四", 19, 92.0f}; append_student("student.dat", &stu1); append_student("student.dat", &stu2); printf("修改前:\n"); read_all("student.dat"); update_score("student.dat", 1002, 99.0f); printf("修改后:\n"); read_all("student.dat"); return 0; }

这个 demo 里最关键的一行是定位修改时的 fseek:fread 读到目标记录后,文件指针已经停在“这条记录之后”。要覆盖这条记录,必须先回退 sizeof(Student) 字节,退到记录开头,再 fwrite 覆盖。这里如果不回退,fwrite 会写在目标记录之后,效果是“插入”(实际上可能覆盖下一条的前半截),程序逻辑就错了。这是一种看似简单但实际上手最容易错的细节。

这个例子的设计思路可以继续延伸:删除记录可以标记删除,也可以重写整个文件;按姓名查找可以顺序遍历,数据量大时可以引入索引。但底层的原理骨架,已经全在这个最小实现里。

4.4 文本文件还是二进制文件:结合场景做选择

文本文件“人类可读”,用记事本打开就能看,适合配置信息、日志、简单数据交换。二进制文件“紧凑、高效、定位方便”,但同一结构体在不同编译器的内存布局可能不同,导致文件不跨平台兼容。怎么选?我给三条实践建议:

场景推荐方式理由
配置文件、日志、调试输出文本人能直接读,出问题好排查
数据量大、需要随机访问二进制读写快,fseek 精确定位
不同语言/系统间交换数据文本(或CSV/JSON)避免结构体对齐和字节序问题

说到字节序(大端小端),这是二进制文件跨平台老话题里绕不开的一环。结构体中的多字节整数在内存里的存储顺序因 CPU 架构而异,同一份二进制文件,某平台上读得通,换一台机器就可能变成一个“看不懂的乱码”。这也是为什么互联网传输普遍采用文本协议(HTTP、JSON)的原因之一。

5. 常见问题与排查技巧

5.1 文件“写不进去”或“写一半”:先想想缓冲区和关闭时机

我见过太多人跑来问,代码明明调用了 fprintf,文件里却是空的。排查方向大致有三个。

第一,fopen 失败了,文件指针是 NULL,后续写操作其实什么都没做,却被忽略了。这种问题在 fp == NULL 的判断里就能抓住,关键是别省略判断。

第二,没有调用 fclose。C 库的写操作默认有缓冲,数据先积攒在内存缓冲区里,直到缓冲区满、程序正常退出、或者显式 fclose / fflush 时才会真正刷到磁盘。如果你中途断电、进程被杀,积攒在缓冲区里的数据就没了。调试时可以手动调用 fflush(fp) 强制刷新,看数据是否立刻出现在文件里。

第三,写的是内存里“还没准备好”的数据。比如结构体里有个字符串指针,你直接把它 fwrite 进文件,写进去的只是地址(指针的数值),而不是字符串内容。等程序下次启动,那个地址对应的内存早就不在了,读出来要么是乱码,要么直接崩溃。这也就是我前面埋的伏笔:含指针的结构体,不能无脑整体 fwrite。解决办法:把变长内容先转成定长数组(就像我们例子里的 char name[20]),或者分开处理,先写长度再写内容。

5.2 结构体对齐:为什么 sizeof 比你手算的多

这是一个非常经典的“百思不得其解”:定义了一个结构体,两个 int 一个 char,总共 9 个字节(4+4+1),但 sizeof 结果却是 12。原因在于编译器的内存对齐规则:为了让 CPU 高效访问内存,编译器会把结构体填充到满足对齐要求的字节边界。

简单说,编译器会在成员后面填充一些“空字节”,让每个成员的地址都是其对齐数的整数倍。当一个结构体需要包含多个成员时,编译器还会在结构体末尾补零,使整个结构体大小是最大成员对齐数的整数倍。

因此,定义一个结构体时,成员顺序会影响实际内存占用。比如:

struct A { char c; int i; char d; }; // 1+3填充+4+1+3填充 = 12 struct B { char c; char d; int i; }; // 1+1+2填充+4 = 8

同样三个成员,只因顺序不同,节省了 4 字节。数据量小无所谓,大结构体数组或网络传输场景就值得注意了。结构体对齐除了影响内存占用,还会直接影响二进制文件的写入内容——写到文件里的是包含填充字节的原始内存,不同编译器、不同平台填充规则不同,读出来自然不同。

如果你想把结构体“瘦身”写文件,可以:

#pragma pack(push, 1) // 按1字节对齐 typedef struct { char c; int i; char d; } PackedStruct; #pragma pack(pop)

但警告一句:这是编译器扩展,不是 C 语言标准;禁用对齐后访问性能会下降,且可能遇到某些硬件平台不支持非对齐访问的问题。工程里要权衡,不要因为省几个字节引入莫名其妙的问题。

5.3 文件路径与权限:新手最容易卡住的细节

fopen 打开失败,原因里很大一部分是路径问题。C 语言里的路径字符串,在 Windows 上写 “C:\Users\name\data.txt”,反斜杠是转义符,正确写法是 “C:\Users\name\data.txt” 或用正斜杠 “C:/Users/name/data.txt”。这两种写法都行,因为 Windows 的 API 都能识别正斜杠。

相对路径也容易踩坑:程序的工作目录(当前目录)不是 exe 所在目录,而是启动程序的目录。如果你用 IDE 调试,工作目录通常是项目目录,不是 debug/release 目录。解决方法是打印当前工作目录,或在代码里显式构造绝对路径,或者把数据文件放到约定的固定位置。

权限问题排在第二位:只读文件、只读目录、UAC 限制,都可能导致 fopen 返回 NULL。排查时先用系统命令确认文件存在、可读写,再回看模式字符串是否写错(比如把 r 写成 w,把本来要读的文件瞬间清空)。权限被拒时 perror 会输出清晰的错误信息,翻译成人话就是确认“路径存在、权限够、模式正确”。

还有一个高频低级错误:文件打开成功后,读写代码写在了后面,但条件分支写错导致文件过早关闭,接下来的操作在一个非法指针上进行。这个问题比较隐蔽,建议写完文件操作后,用“打开-操作-关闭”的三步心态重新审一遍代码,确认每个分支上 fp 的状态都是“已经成功打开,并且尚未关闭”。

5.4 读了一堆乱码?文本换行与编码问题

二进制和文本模式的最大差异,就是换行符转换。在 Windows 的文本模式下,程序输出 \n,文件里会写成 \r\n(回车换行);读取时会把 \r\n 还原成 \n。Linux 下没有这个转换,文本模式和二进制模式完全一致。

这带来一个典型问题:在 Windows 上用“w”模式写入的文件,拿二进制模式 “rb” 去读,会把 \r\n 里的 \r 也读进来,字符串尾部多出一个疑似垃圾字符(显示成^M)。反过来,又会导致解析错位。

中文乱码则是另一回事。如果程序里用 GBK 编码写入文本文件,再用 UTF-8 的编辑器打开看,满屏乱码是正常的。这不算 C 语言的问题,是编码不匹配。建议程序里统一使用 UTF-8 保存字符串和处理文件,现代编译器(比如较新版本的常用 IDE 和主流编译器)的默认源字符集通常已支持 UTF-8,跨平台时会更省心。

最后提一个文本读取常见错误:用 fgetc 或 fscanf 读到文件末尾后,未判断返回值就继续使用变量。比如循环里 fscanf 失败(文件已读完),后面的 printf 用了一个未被更新的旧值,输出结果看起来像是“多读了一行重复数据”。解决办法是每次读取后立刻检查返回值,读到 EOF 就结束循环,不要依赖 feof 判断。

结尾:学到这里,你已经能做什么

每次带完结构体和文件这一章,我都会让学生回去做一个“通讯录”:用结构体存联系人,用文件存通讯录,支持增删改查。说真的,做完这个练手项目,你已经具备了一个完整工具型程序的骨架——输入、逻辑、持久化。再往后学链表、算法、多文件工程组织,你会发现都是在给这个骨架补充更强的能力。我在实际教学中感受到,很多人学到这章最容易卡住的是“指针 + 结构体 + 文件”三者组合时的问题,这类问题的共同特征是:本质都是内存访问越界或数据格式不对称。遇到诡异 bug,先排查文件打开是否成功,再检查读写格式是否配对,最后考虑指针问题,八成能快速定位。这一篇是提纲,篇幅有限没法每个点都展开讲透,但核心骨架和最重要的一手经验都在了。多敲几遍代码,比读十遍教程都管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进