1. 为什么文件操作是C语言里最容易翻车的地方
刚接触C语言文件操作的人,十有八九会觉得这块内容"看起来很简单"——不就是打开文件、读一读、写一写、关掉吗?fopen、fgets、feof、EOF这几个函数和宏,翻来覆去就那么点东西。但真正在项目里写过文件读写逻辑的人都知道,这块内容恰恰是bug的高发区:读文件多读一行、少读一行、读到乱码、文件句柄泄漏、程序在别人机器上跑就崩……这些问题几乎每个C程序员都踩过。
我自己最早写文件操作的时候,就干过一件蠢事:用while(!feof(fp))配合fgets读文件,结果最后一行被重复处理了两次。当时百思不得其解,明明文件里就三行数据,为什么循环跑了四次?后来才搞明白,feof这个宏的语义和大多数人的直觉是反的——它不是在"文件还没读完"时返回真,而是在"上一次读取已经撞到了文件末尾"之后才返回真。这个认知偏差,是无数人踩坑的根源。
这篇文章就是围绕FILE结构、fopen、fgets、feof、EOF这一套最基础也最核心的文件操作组合展开的。我会从实际使用的角度,把每个函数的行为边界、返回值含义、常见误用场景讲透,并且给出可以直接抄作业的代码模板。不管你是刚学C语言的学生,还是工作几年但文件操作一直靠"复制粘贴"的开发者,看完之后应该都能对这套东西有一个清晰、准确、不模糊的认识。
需要提前说明的是,C标准库的文件操作函数有很多,fopen/fgets/feof/EOF只是其中最常用的一组。本文聚焦在这几个点上,但会顺带把相关的fclose、ferror、fgetc、fread等函数在必要的地方带出来,因为实际写代码时它们往往是配套使用的,单独讲一个函数反而讲不清楚。
2. FILE结构体到底是什么:一个被隐藏起来的"文件句柄"
2.1 FILE不是文件本身,而是操作系统文件的一个包装
很多人第一次看到FILE *fp的时候,会下意识以为FILE就是文件在内存里的表示。这个理解不算错,但不够准确。FILE是C标准库定义的一个结构体类型(通常在stdio.h里通过typedef定义),它封装的是"一个打开的文件流"所需要的全部状态信息。这些信息包括但不限于:
- 文件描述符(在类Unix系统上就是那个整数fd)
- 当前读写位置指针
- 缓冲区指针和缓冲区大小
- 缓冲区中有效数据的起止位置
- 错误标志和文件结束标志
- 文件打开模式相关的一些状态位
换句话说,FILE是标准库在操作系统文件接口之上加的一层"缓冲层"和"状态管理层"。你调用fopen,标准库会去调用操作系统的open(或类似接口)拿到一个文件描述符,然后分配一块缓冲区,把这些东西打包成一个FILE结构体,返回它的指针给你。你后续所有的fgets、fprintf、fclose操作,都是通过这个指针来间接操作底层文件的。
这个设计的意义在于:缓冲。操作系统层面的文件读写是相对昂贵的系统调用,如果每读一个字节都调用一次系统接口,性能会非常差。标准库通过FILE结构体维护一块缓冲区,一次性从操作系统读一大块数据进来,然后你的fgets、fgetc从缓冲区里取数据,只有缓冲区空了才再去操作系统读下一块。写操作同理,数据先写到缓冲区,缓冲区满了或者调用fflush/fclose时才真正落盘。
理解这一点很重要,因为它解释了很多"看起来奇怪"的现象。比如你往文件里写了数据,但用另一个程序去读却读不到——因为数据还在缓冲区里没落盘。再比如你fopen了一个文件却忘了fclose,程序结束时数据可能丢失——因为缓冲区没被刷新。
2.2 为什么FILE通常要用指针来操作
标准库的接口设计里,fopen返回的是FILE *而不是FILE,所有函数接收的也都是FILE *。原因有两个层面。
第一个层面是大小未知。FILE结构体的具体定义是标准库实现相关的,不同平台、不同编译器下大小可能不一样,标准并没有规定它必须多大。如果按值传递,调用者需要知道这个结构体的大小才能分配栈空间,这在跨平台场景下是不现实的。用指针就绕开了这个问题——调用者只需要知道"有个东西叫FILE,我用指针指向它"就够了。
第二个层面是共享和生命周期管理。一个打开的文件流可能被多个函数共享使用,用指针传递可以保证大家操作的是同一个流对象。而且FILE对象的生命周期是由标准库管理的(fopen时分配,fclose时释放),调用者不应该自己去拷贝或者销毁它,指针语义天然契合这种"借你用但不归你管"的关系。
实际写代码时,你几乎不需要关心FILE结构体内部长什么样。你只需要记住:FILE *是一个句柄,fopen给你一个,fclose收回一个,中间所有操作都通过这个句柄进行。试图去访问FILE内部字段(比如fp->_fileno这种)是非常不推荐的做法,因为不同实现下字段名和布局都不一样,代码完全不可移植。
2.3 标准流:三个不用fopen就能用的FILE指针
有一个细节很多人学了很久都没注意到:stdin、stdout、stderr这三个东西,本质上就是FILE *类型的指针,而且它们在程序启动时就已经被标准库自动打开好了。
stdin:标准输入流,通常对应键盘输入stdout:标准输出流,通常对应终端或控制台输出stderr:标准错误流,通常也对应终端,但用于输出错误信息
这意味着你平时用的printf、scanf、getchar、putchar,底层其实都是在操作FILE流。printf(...)等价于fprintf(stdout, ...),scanf(...)等价于fscanf(stdin, ...)。理解了这一点,你就能明白为什么fgets可以从stdin读一行,也可以从文件读一行——因为它们的参数类型都是FILE *,接口是统一的。
这个统一性带来一个很实用的技巧:你可以写一个函数,参数是FILE *,然后这个函数既能处理文件,也能处理标准输入。比如写一个"从任意流读取一行并处理"的函数,传stdin就是交互式输入,传文件指针就是批量处理文件,代码复用度很高。
3. fopen的打开模式:每一种模式背后都有明确的读写语义
3.1 六种基础模式的行为差异
fopen的原型是FILE *fopen(const char *filename, const char *mode),第一个参数是文件路径,第二个参数是打开模式字符串。模式字符串决定了这个流是读还是写、是文本还是二进制、文件不存在时怎么办、文件已存在时怎么办。下面这张表把最常用的几种模式列清楚:
| 模式 | 含义 | 文件不存在时 | 文件已存在时 | 读写位置 |
|---|---|---|---|---|
"r" | 只读 | 打开失败,返回NULL | 正常打开 | 文件开头 |
"w" | 只写 | 创建新文件 | 清空原内容 | 文件开头 |
"a" | 追加写 | 创建新文件 | 保留原内容 | 文件末尾 |
"r+" | 读写 | 打开失败,返回NULL | 正常打开 | 文件开头 |
"w+" | 读写 | 创建新文件 | 清空原内容 | 文件开头 |
"a+" | 读写追加 | 创建新文件 | 保留原内容 | 读在开头,写在末尾 |
这里有几个点特别容易出错,我逐个说。
第一,"w"和"w+"会无条件清空已存在的文件。这是很多人丢数据的元凶。你本来想往文件里追加点东西,结果用了"w",一打开原内容全没了。要追加就用"a"或"a+"。
第二,"r"和"r+"在文件不存在时不会创建文件,直接返回NULL。所以用这两个模式时,必须检查返回值,否则后面拿NULL去fgets就是段错误。
第三,"a"模式下,所有的写操作都强制发生在文件末尾,即使你用fseek把位置指针移到前面也没用——这是标准规定的行为,追加模式下的写入永远在末尾。但"a+"模式下读操作可以从任意位置读(配合fseek),写操作仍然强制在末尾。
第四,在模式字符串里加b(如"rb"、"wb")表示二进制模式。在类Unix系统上,文本模式和二进制模式没有区别;但在Windows上,文本模式会把\n转换成\r\n写入,读取时再把\r\n转回\n。如果你处理的是图片、音频、可执行文件这类二进制数据,必须加b,否则数据会被破坏。
3.2 fopen失败的处理:不检查返回值就是在埋雷
fopen失败时返回NULL,失败原因可能是文件不存在、权限不足、路径错误、文件被占用等等。实际项目中,每一次fopen都必须检查返回值,这是铁律。我见过太多代码直接FILE *fp = fopen(...); fgets(buf, size, fp);,一旦文件打不开,程序立刻崩溃。
正确的处理方式是这样的:
FILE *fp = fopen("data.txt", "r"); if (fp == NULL) { perror("fopen data.txt failed"); return -1; } /* 后续操作 */ fclose(fp);perror会把你传入的字符串和当前errno对应的错误描述一起打印出来,比单纯打印"打开失败"有用得多。比如文件不存在时它会打印fopen data.txt failed: No such file or directory,一眼就能看出问题所在。
注意:
fopen失败后不要试图去fclose,因为fp是NULL,fclose(NULL)是未定义行为,多数实现下会直接崩溃。
3.3 路径问题:相对路径的基准是"当前工作目录"而不是"程序所在目录"
这是新手最容易困惑的问题之一。你写fopen("data.txt", "r"),程序运行时到底去哪里找这个文件?答案是:当前工作目录(current working directory),而不是可执行文件所在的目录。
这两个目录在开发时经常是同一个(比如你在项目目录下用命令行运行程序),所以问题不会暴露。但一旦程序被放到别的地方运行,或者被其他程序以不同的工作目录启动,就会找不到文件。比如你在IDE里点运行按钮,工作目录可能是项目根目录;你双击可执行文件,工作目录可能是可执行文件所在目录;你用系统服务方式启动,工作目录可能是/或C:\Windows\System32。
解决办法有两个:一是使用绝对路径,二是先获取程序所在目录再拼接路径。绝对路径简单直接但不够灵活,拼接路径更健壮但代码稍复杂。实际项目中,配置文件、日志文件这类东西,通常会用拼接方式确定路径,避免依赖工作目录。
4. fgets的读取行为:它到底什么时候停,返回什么
4.1 fgets的三个参数和返回值语义
fgets的原型是char *fgets(char *str, int n, FILE *stream)。它的行为可以精确描述为:从stream中读取字符,直到遇到换行符\n、或者读满n-1个字符、或者到达文件末尾,然后把读到的内容加上一个\0结束符存入str,返回str。如果一开始就遇到文件末尾或读取出错,返回NULL。
这里有几个关键细节:
- 最多读
n-1个字符,因为要留一个位置给\0。所以如果你写fgets(buf, 100, fp),最多读99个字符。 - 换行符会被读入并保留在字符串里。这一点和很多人想的不一样。如果一行是
hello\n,fgets读到的字符串是"hello\n\0",长度是6。如果你不想要这个换行符,需要自己处理掉。 - 返回值是
str本身(成功时)或NULL(失败或EOF时)。返回str而不是别的,是为了支持链式调用,比如while(fgets(...))这种写法。
4.2 换行符的处理:一个必须养成的习惯
因为fgets会把换行符保留在缓冲区里,所以处理文本行时几乎总要考虑怎么去掉它。常见的做法是:
char buf[256]; if (fgets(buf, sizeof(buf), fp) != NULL) { size_t len = strlen(buf); if (len > 0 && buf[len - 1] == '\n') { buf[len - 1] = '\0'; } }这段代码检查最后一个字符是不是\n,是的话就替换成\0。注意要先判断len > 0,否则空字符串会越界访问buf[-1]。
还有一种情况需要特别注意:如果一行特别长,超过了缓冲区大小,fgets会分多次读取,第一次读满n-1个字符(没有换行符),下一次继续读。这时候你按行处理的逻辑就会出问题——一行被当成了多行。处理超长行的标准做法是检查读到的字符串末尾有没有换行符,如果没有且没到EOF,说明这行还没读完,需要继续读并拼接。这个逻辑写起来有点绕,但处理日志文件、CSV文件时经常需要。
4.3 fgets和gets的区别:为什么gets被废弃了
老教材里可能还会提到gets函数,它的原型是char *gets(char *str),从stdin读一行,不限制长度。这个函数因为无法防止缓冲区溢出,已经在C11标准里被正式移除,任何新代码都不应该使用它。
fgets相比gets的核心改进就是多了n这个参数,调用者必须指定缓冲区大小,函数保证不会写越界。虽然用起来麻烦一点(要处理换行符、要指定大小),但安全性是值得的。实际项目中,凡是涉及用户输入或外部数据读取的地方,都必须用带长度限制的函数。
5. feof和EOF:一对最容易用错的组合
5.1 EOF是一个整数常量,不是一个字符
EOF是定义在stdio.h里的一个宏,通常展开为-1。它的作用是作为"读取函数遇到文件末尾或错误"时的返回值。注意,EOF是int类型,不是char类型。这一点非常重要,因为fgetc这类函数返回的就是int,目的是能够区分"读到的字节0xFF"和"EOF(-1)"。
如果你把fgetc的返回值存到char变量里,就会出问题:当文件里真的有一个字节是0xFF时,存到char(有符号的情况下)就变成了-1,和EOF无法区分,程序会误以为文件结束了。正确做法是:
int c; while ((c = fgetc(fp)) != EOF) { /* 处理字符c */ }用int接收,和EOF比较,这是标准写法。
5.2 feof的语义:它是"事后判断",不是"事前预测"
feof(fp)返回非零值表示"这个流上已经发生过文件结束",返回0表示"还没发生过"。关键在于"已经发生过"这四个字——它是在你尝试读取并且读到了末尾之后才被设置的标志,而不是提前告诉你"接下来要读到末尾了"。
这就解释了为什么下面这种写法是错的:
/* 错误写法 */ while (!feof(fp)) { fgets(buf, sizeof(buf), fp); /* 处理buf */ }假设文件有三行。循环过程是:
- 第一次:
feof为假(还没读到末尾),fgets读第一行,成功。 - 第二次:
feof为假,fgets读第二行,成功。 - 第三次:
feof为假,fgets读第三行,成功。 - 第四次:
feof仍然为假(因为还没尝试读第四行),fgets尝试读,发现到末尾了,返回NULL,buf内容不变(还是第三行的内容)。然后处理buf,第三行被处理了两次。 - 第五次:
feof现在为真,循环结束。
结果就是最后一行被重复处理。正确写法是直接用fgets的返回值作为循环条件:
/* 正确写法 */ while (fgets(buf, sizeof(buf), fp) != NULL) { /* 处理buf */ }fgets返回NULL就说明读完了或者出错了,循环自然结束,不会多处理一次。
5.3 feof和ferror的配合使用
fgets返回NULL有两种可能:到达文件末尾,或者发生了读取错误。如果你需要区分这两种情况,就要在循环结束后检查feof和ferror:
while (fgets(buf, sizeof(buf), fp) != NULL) { /* 处理 */ } if (feof(fp)) { /* 正常读完 */ } else if (ferror(fp)) { /* 读取出错 */ perror("read error"); }ferror(fp)返回非零表示流上发生过错误。这个检查在读取网络文件、设备文件时特别重要,因为这些场景下出错是常态,不能简单当成"读完了"处理。
提示:
feof和ferror的标志一旦被设置就不会自动清除,除非调用clearerr(fp)或者对流执行了fseek、rewind等操作。所以如果你要重复使用同一个流,记得在适当的时候清除标志。
6. 一套完整的文件读取模板与常见坑位排查
6.1 按行读取文本文件的标准模板
把前面讲的东西整合起来,一个健壮的按行读取文本文件的函数大概长这样:
#include <stdio.h> #include <string.h> #include <errno.h> int process_file(const char *path) { FILE *fp = fopen(path, "r"); if (fp == NULL) { fprintf(stderr, "cannot open %s: %s\n", path, strerror(errno)); return -1; } char buf[1024]; int line_no = 0; while (fgets(buf, sizeof(buf), fp) != NULL) { line_no++; size_t len = strlen(buf); if (len > 0 && buf[len - 1] == '\n') { buf[len - 1] = '\0'; } else if (len == sizeof(buf) - 1) { /* 行太长,缓冲区被填满,需要处理剩余部分 */ /* 这里简单处理:继续读直到换行或EOF */ int c; while ((c = fgetc(fp)) != '\n' && c != EOF) { /* 丢弃超长部分,或者做拼接处理 */ } } /* 在这里处理buf */ printf("line %d: %s\n", line_no, buf); } if (ferror(fp)) { fprintf(stderr, "error reading %s\n", path); fclose(fp); return -1; } fclose(fp); return 0; }这个模板覆盖了几个关键点:检查fopen返回值、用fgets返回值控制循环、处理换行符、处理超长行、检查ferror、确保fclose被调用。
6.2 常见坑位排查表
下面这张表列出了文件操作中最常见的几个坑,以及对应的现象和解决办法:
| 现象 | 可能原因 | 排查方法 | 解决办法 |
|---|---|---|---|
| 最后一行被处理两次 | 用了while(!feof(fp)) | 检查循环条件 | 改用fgets返回值控制循环 |
| 文件打不开但没报错 | 没检查fopen返回值 | 加返回值检查 | 用perror或strerror打印原因 |
| 写入的数据读不到 | 缓冲区未刷新 | 检查是否fclose或fflush | 确保程序结束前关闭文件 |
| 二进制文件读取后损坏 | 用了文本模式 | 检查模式字符串 | 加b,如"rb" |
| 读到的字符串末尾有奇怪字符 | 换行符没处理 | 打印字符串长度和内容 | 手动去掉末尾\n |
| 程序在别人机器上找不到文件 | 相对路径依赖工作目录 | 打印当前工作目录 | 用绝对路径或拼接程序目录 |
| 读到0xFF字节就提前结束 | 用char接收fgetc返回值 | 检查变量类型 | 改用int接收 |
6.3 资源泄漏:别忘了fclose
fopen成功后会占用一个文件描述符和一块缓冲区,这些资源在fclose之前不会释放。如果程序里反复fopen却不fclose,文件描述符会被耗尽,后续fopen就会失败。在类Unix系统上,一个进程默认能打开的文件描述符数量是有限的(通常1024),达到上限后就会报Too many open files。
更隐蔽的问题是:如果函数中间有多个返回分支,很容易在某条路径上漏掉fclose。解决办法是尽量让函数只有一个出口,或者用goto统一清理:
FILE *fp = fopen(path, "r"); if (fp == NULL) return -1; int ret = 0; /* ... 各种操作,出错时设置ret并goto cleanup ... */ cleanup: fclose(fp); return ret;这种"单一出口+统一清理"的模式在C代码里非常常见,能有效避免资源泄漏。
7. 几个容易被忽略但很实用的细节
7.1 fgets读stdin时的行为
fgets(buf, sizeof(buf), stdin)是从标准输入读一行。它的行为和从文件读一样:遇到换行符停止,换行符保留在缓冲区里。但有一个区别:如果用户输入的行超过了缓冲区大小,多余的字符会留在输入缓冲区里,影响下一次读取。比如缓冲区是10字节,用户输入了20个字符,fgets读走9个字符,剩下11个字符(包括换行符)留在输入流里,下一次fgets会立刻读到这些残留数据。
处理这个问题的方法是在检测到行被截断后,主动清空输入缓冲区:
if (strchr(buf, '\n') == NULL) { int c; while ((c = getchar()) != '\n' && c != EOF) { /* 丢弃 */ } }这个技巧在写交互式程序时很有用,能避免用户的超长输入影响后续操作。
7.2 文本模式和二进制模式的实际差异
前面提到过,Windows上文本模式会做\n和\r\n的转换。这个转换在读写文本文件时是透明的,你不用管。但如果你用文本模式去读一个二进制文件(比如JPEG图片),里面的\r\n字节序列会被"吃掉"一个\r,导致数据损坏。反过来,用二进制模式读文本文件,读到的内容会包含\r,处理起来也麻烦。
判断标准很简单:文件内容是人能读的文本,用文本模式;文件内容是给程序解析的二进制数据,用二进制模式。不确定的时候,用二进制模式更安全,因为它不做任何转换,读到什么就是什么。
7.3 大文件读取的性能考虑
fgets配合默认缓冲区(通常4KB或8KB)读取大文件时,性能是可以接受的,因为标准库的缓冲机制已经帮你做了批量读取。但如果你需要更高的性能,可以:
- 用
setvbuf增大缓冲区,比如设成64KB或256KB - 用
fread一次读一大块,然后自己在内存里按行切分 - 用内存映射(
mmap)直接映射文件到内存(仅限类Unix系统)
对于大多数应用场景,默认配置就够了。只有处理GB级别的大文件、或者对读取速度有极致要求时,才需要考虑这些优化。过早优化是万恶之源,先把功能写对,性能问题等真正遇到瓶颈时再处理。
7.4 文件路径中的中文和空格
fopen的路径参数是const char *,在Windows上,如果路径包含中文,需要确保源文件编码和系统编码匹配,否则可能打不开文件。在Linux上,路径就是字节序列,只要字节序列和文件系统里的名字一致就能打开,不存在编码转换问题。
路径中有空格时,fopen本身不受影响,因为它是函数调用不是命令行解析。但如果你是通过命令行参数传路径,shell会把空格当作分隔符,需要用引号包起来。这是shell层面的问题,不是fopen的问题。
8. 从文件操作延伸出去:几个相关的函数和场景
8.1 fread和fwrite:二进制读写的首选
处理二进制数据时,fread和fwrite比fgets/fprintf更合适。它们的原型是:
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream); size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);fread从流中读取nmemb个大小为size的元素,返回实际读到的元素个数。注意返回值是元素个数不是字节数。如果返回值小于nmemb,可能是到了文件末尾或者出错了,需要用feof和ferror区分。
用fread读整个文件到内存的典型写法:
fseek(fp, 0, SEEK_END); long size = ftell(fp); rewind(fp); char *buf = malloc(size); size_t n = fread(buf, 1, size, fp);这里fread的size参数设为1,nmemb设为size,返回值就是实际读到的字节数。这种用法在处理图片、序列化数据时很常见。
8.2 fseek和ftell:随机访问文件
fgets是顺序读取,只能从头往后读。如果需要跳到文件的某个位置读取,就要用fseek和ftell。fseek(fp, offset, whence)把位置指针移动到指定位置,whence可以是SEEK_SET(从开头算)、SEEK_CUR(从当前位置算)、SEEK_END(从末尾算)。ftell(fp)返回当前位置相对于文件开头的偏移量。
一个常见的用途是获取文件大小:
fseek(fp, 0, SEEK_END); long file_size = ftell(fp); rewind(fp); /* 回到开头 */注意fseek在文本模式下有可移植性限制,偏移量必须是ftell返回过的值或者0。在二进制模式下没有这个限制,可以任意偏移。所以做随机访问时,建议用二进制模式打开。
8.3 临时文件:tmpfile和tmpnam
有时候程序需要创建临时文件来存放中间数据,用完就删。标准库提供了tmpfile()函数,它创建一个临时文件并返回文件指针,程序结束时自动删除。这个函数用起来很简单:
FILE *tmp = tmpfile(); if (tmp == NULL) { /* 处理错误 */ } /* 像普通文件一样读写 */ fclose(tmp); /* 关闭时自动删除 */tmpfile的好处是自动清理,不用担心忘记删文件。缺点是文件位置和名字不可控,调试时看不到内容。如果需要可控的临时文件,可以用tmpnam生成文件名再fopen,但tmpnam有安全风险(生成的名称可能被预测),不推荐在安全敏感的场景使用。
9. 我踩过的几个真实坑和总结出来的经验
第一个坑就是开头提到的while(!feof(fp))。这个坑的迷惑性在于,它"看起来"很合理——"只要没到文件末尾就继续读",逻辑上没毛病。但feof的语义是"已经到末尾了",不是"还没到末尾",所以这个条件永远晚一拍。我后来养成的习惯是:永远用读取函数的返回值作为循环条件,不要用feof做循环判断。feof只在循环结束后用来区分"正常结束"和"出错"。
第二个坑是fgets的缓冲区大小和换行符。有一次处理一个配置文件,每行大概几十个字符,我开了256字节的缓冲区,觉得绰绰有余。结果有一行特别长(用户手动编辑时粘贴了一大段),超过了256字节,fgets分两次读,第二次读到的内容被当成新的一行处理,导致解析逻辑完全错乱。后来我加了超长行检测和处理逻辑,才解决这个问题。经验就是:永远不要假设输入的行长度是可控的,尤其是处理用户提供的文件时。
第三个坑是文件路径。开发时程序在项目目录下运行,用相对路径没问题。部署到服务器上,用systemd启动,工作目录变成了/,所有相对路径全部失效。排查了半天才发现是工作目录的问题。后来学乖了,所有配置文件路径都从环境变量或命令行参数读取,或者基于程序自身位置拼接绝对路径,不再依赖工作目录。
第四个坑是忘记fclose。写了一个循环处理多个文件的程序,每个文件fopen后处理完就进入下一个,忘了fclose。处理到第1000多个文件时程序报Too many open files。这个问题的教训是:fopen和fclose必须成对出现,最好用RAII式的思维(虽然C没有RAII),在函数入口打开,在所有出口之前关闭。
第五个坑是文本模式读二进制文件。有一次需要读取一个自定义格式的二进制文件,里面有些字节是0x0D 0x0A,在Windows上被文本模式转换成了0x0A,导致解析出来的数据长度对不上。改成"rb"模式后问题消失。这个坑的教训是:处理非文本数据时,模式字符串里一定要加b。
这些坑说起来都不复杂,但每一个都真实地浪费过我几个小时甚至半天的时间。文件操作这块内容,知识点本身不难,难的是对各种边界情况的把握和对函数语义的精确理解。希望这篇文章能帮你少走一些弯路。