这一章在讲什么?
前面所有章节的程序都有一个共同弱点:程序一结束,数据就没了。变量活在内存里,断电即逝。这一章解决这个问题——用文件(file)把数据存到磁盘上。你会学到 fopen() 打开文件、getc()/fprintf() 读写文件、fclose() 关闭文件,还能用 fseek() 在文件里”翻页”跳转到任意位置,用 fwrite() 把数据原封不动地存成二进制。
13.1 与文件通信
什么是文件
文件(file)就是一段有名字的存储区,通常在磁盘上。对操作系统来说文件内部结构很复杂(可能散落成碎片、带有元数据),但 C 程序只关心一件事:C 把文件看作连续的字节序列,每个字节都可以单独读出。
ANSI C 提供两种看待文件的视角:
- 文本视图(text view):程序看到的可能与磁盘内容不同。比如旧式 MS-DOS 文本文件用
\r\n表示换行,C 程序读入时会自动把它映射成\n,写出时再转换回去。 - 二进制视图(binary view):文件里的每个字节都原样呈现在程序面前,不做任何映射。

在 Unix/Linux 系统上只有一种文件格式,两种视图完全相同。
I/O 的两个层次与三个标准文件
- 低级 I/O(low-level I/O):直接使用操作系统提供的基本服务,各平台不统一。
- 标准高级 I/O(standard high-level I/O):使用 C 库的标准函数包和 stdio.h,ANSI C 只保证这一套,可移植性好,本章主角就是它。
另外,C 程序启动时会自动打开三个文件:
| 标准文件 | 文件指针 | 默认指向 |
|---|---|---|
| 标准输入 | stdin | 键盘 |
| 标准输出 | stdout | 屏幕 |
| 标准错误输出 | stderr | 屏幕 |
getchar()、scanf() 读的是 stdin;printf()、putchar() 写的是 stdout。stderr 专门用来送错误信息——即使你把标准输出重定向到文件,错误消息仍然会打到屏幕上。
标准 I/O 的两大优点:函数多、针对性强;自带缓冲(buffered)——数据按大块(通常 512 字节或更多)搬运到缓冲区,程序再从缓冲区逐个字节取用,速度远快于逐字节搬运。
13.2 用标准 I/O 读文件:清单 13.1 count.c
/* count.c -- 使用标准 I/O 统计文件字符数 */
#include <stdio.h>
#include <stdlib.h> /* ANSI C exit() 原型 */
int main(int argc, char *argv[])
{
int ch; /* 存放读到的每个字符 */
FILE *fp; /* "文件指针" */
long count = 0;
if (argc != 2)
{
printf("Usage: %s filename\n", argv[0]);
exit(1);
}
if ((fp = fopen(argv[1], "r")) == NULL)
{
printf("Can't open %s\n", argv[1]);
exit(1);
}
while ((ch = getc(fp)) != EOF)
{
putc(ch, stdout); /* 等价于 putchar(ch); */
count++;
}
fclose(fp);
printf("File %s has %ld characters\n", argv[1], count);
return 0;
}逐段拆解:
- argc 检查:命令行没有给出文件名就打印用法提示退出。argv[0] 是程序自己的名字,用它拼提示消息,程序改名后提示自动跟着变。
- exit():关闭所有打开的文件并终止程序。习惯上 0(或宏 EXIT_SUCCESS)表示正常结束,非 0 表示异常。在 main() 的最初一次调用里,
return 0;与exit(0);效果相同;区别是 exit() 在任何函数里调用都能终止整个程序,而 return 只会退出一层函数。
fopen():打开文件
fopen(文件名, 模式) 返回一个 FILE 指针,后续所有 I/O 函数都靠它指认这个文件。FILE 是 stdio.h 里定义的派生类型,指针指向的并不是文件本身,而是一个”数据包”,里面记录着缓冲区位置、缓冲区装了多少、当前读写位置等信息。打不开(磁盘满、文件名非法、无权限……)时返回 NULL,所以必须检查。
常用模式字符串:
| 模式 | 含义 |
|---|---|
"r" | 读文本文件,文件必须已存在 |
"w" | 写文本文件:已存在则清空,不存在则创建 |
"a" | 追加写:保留原内容,从末尾接着写 |
"r+" / "w+" / "a+" | 读写两用(update),细节各有不同 |
"rb" "wb" "ab+" 等 | 加 b 即二进制模式 |
"w" 模式会清空已有文件
只要用
"w"系列模式打开一个已存在的文件,它的内容立刻被截断为零——原来写的东西全没了。想保留旧内容请用"a"追加模式。这是新手最常踩的坑之一。
getc() / putc() 与文件结尾
ch = getc(fp) 从 fp 指定的文件读一个字符;putc(ch, fpout) 把字符写进指定文件(注意参数顺序:字符在前,文件指针在后)。putc(ch, stdout) 就等价于 putchar(ch)。
读到文件末尾时,getc() 返回特殊值 EOF。注意 C 是”先读过头才发现到头”——程序必须先尝试读取,读失败了才知道文件结束。因此读文件要用入口条件循环,把读取放进 while 的判断里:
int ch;
FILE *fp;
fp = fopen("wacky.txt", "r");
while ((ch = getc(fp)) != EOF)
putchar(ch); /* 处理输入 */反例是先判断后读:while (ch != EOF) 第一次比较时 ch 还没赋值;即使侥幸通过,读到的 EOF 也会被当成普通字符处理。两个毛病都是硬伤。
fclose():关闭文件
fclose(fp) 关闭文件并冲刷(flush)缓冲区——把缓冲区里还没落盘的数据写出去。成功返回 0,失败返回 EOF(磁盘满、磁盘被拔出等都可能失败)。认真的程序应该检查:
if (fclose(fp) != 0)
printf("Error in closing file %s\n", argv[1]);13.3 一个简单的文件压缩程序:清单 13.2 reducto.c
同时打开两个文件是常见需求。这个程序从源文件每 3 个字符保留 1 个,写进一个 .red 后缀的新文件:
// reducto.c -- 把你的文件压缩掉三分之二!
#include <stdio.h>
#include <stdlib.h> /* exit() */
#include <string.h> /* strcpy(), strcat() */
#define LEN 40
int main(int argc, char *argv[])
{
FILE *in, *out; /* 两个 FILE 指针 */
int ch;
char name[LEN]; /* 存放输出文件名 */
int count = 0;
// 检查命令行参数
if (argc < 2)
{
fprintf(stderr, "Usage: %s filename\n", argv[0]);
exit(1);
}
// 设置输入文件
if ((in = fopen(argv[1], "r")) == NULL)
{
fprintf(stderr, "I couldn't open the file \"%s\"\n", argv[1]);
exit(2);
}
// 设置输出文件
strncpy(name, argv[1], LEN - 5); // 拷贝文件名
name[LEN - 5] = '\0';
strcat(name, ".red"); // 追加 .red 后缀
if ((out = fopen(name, "w")) == NULL)
{
fprintf(stderr, "Can't create output file.\n");
exit(3);
}
// 拷贝数据
while ((ch = getc(in)) != EOF)
if (count++ % 3 == 0)
putc(ch, out); // 每 3 个字符打印第 1 个
// 收尾
if (fclose(in) != 0 || fclose(out) != 0)
fprintf(stderr, "Error in closing files\n");
return 0;
}几个技巧值得记住:
- fprintf() 与 printf() 相同,只是第一个参数是文件指针。把错误消息送到 stderr 是标准惯例。
- 拼文件名:用 strncpy() 拷贝原名(留出后缀和 ‘\0’ 的空间),strcat() 追加 “.red”,得到 eddy → eddy.red 这样的新名字。
- 两个文件各自独立打开、关闭。系统允许同时打开的文件数有限(常见 10~20 个),同一个文件指针也可以先后用于不同文件,只要不是同时打开。
13.4 文件版 I/O 四兄弟:fprintf()、fscanf()、fgets()、fputs()
fprintf()/fscanf() 和 printf()/scanf() 用法一样,只是多一个文件指针参数且放在最前面。下面的程序把单词追加进文件,再回读显示(清单 13.3 addaword.c):
/* addaword.c -- 使用 fprintf()、fscanf() 和 rewind() */
#include <stdio.h>
#include <stdlib.h>
#define MAX 40
int main(void)
{
FILE *fp;
char words[MAX];
if ((fp = fopen("wordy", "a+")) == NULL)
{
fprintf(stdout, "Can't open \"words\" file.\n");
exit(1);
}
puts("Enter words to add to the file; press the Enter");
puts("key at the beginning of a line to terminate.");
while (gets(words) != NULL && words[0] != '\0')
fprintf(fp, "%s ", words);
puts("File contents:");
rewind(fp); /* 回到文件开头 */
while (fscanf(fp, "%s", words) == 1)
puts(words);
if (fclose(fp) != 0)
fprintf(stderr, "Error closing file\n");
return 0;
}"a+" 模式意味着:读得到全文件,写只能追加在末尾。每次运行程序,新单词都接在上次内容后面。rewind() 接收文件指针,把读写位置拨回文件开头,让后面的 fscanf() 循环能把整个文件重新读一遍。
fgets() 与 fputs():行读行写
fgets(buf, MAX, fp):三个参数——存储地址、字符串最大长度、文件指针。读到换行符、读满 MAX-1 个字符或遇见文件结尾就停,然后补上 ‘\0’。如果整行读入,换行符\n会保留在字符串里(gets() 是把换行丢掉的)。fputs(buf, fp):把字符串写进文件,不额外添加换行符(puts() 会加)。
正因为 fgets() 保留换行、fputs() 不添换行,两者配套使用刚好收支相抵,是比 gets()/puts() 更安全的搭档。一个最小的回显程序(清单 13.4 parrot.c):
/* parrot.c -- 使用 fgets() 和 fputs() */
#include <stdio.h>
#define MAXLINE 20
int main(void)
{
char line[MAXLINE];
while (fgets(line, MAXLINE, stdin) != NULL && line[0] != '\n')
fputs(line, stdout);
return 0;
}按回车开头的空行会让 line[0] 是 ‘\n’,循环结束;文件结束(fgets 返回 NULL)也能结束。
fgets() 缓冲区小于一行时输出会"粘连"
如果一行超过 MAXLINE-1 个字符,fgets() 只读前 19 个,且字符串里没有换行符;下次 fgets() 会从断点继续读。于是 fputs() 把几段输出印在同一行上,看起来像程序出错了。其实这是截断读的正常行为——数组要足够装下整行,或者干脆逐字符读取,才不会出这种”怪事”。
13.5 随机访问:fseek() 和 ftell()
前面的函数都是从文件头顺序读到尾。fseek() 让你把文件当数组用,直接跳到任意字节。清单 13.5 reverse.c 用它把一个文件倒着显示:
/* reverse.c -- 倒序显示文件内容 */
#include <stdio.h>
#include <stdlib.h>
#define CNTL_Z '\032' /* DOS 文本文件的 eof 标记 */
#define SLEN 50
int main(void)
{
char file[SLEN];
char ch;
FILE *fp;
long count, last;
puts("Enter the name of the file to be processed:");
gets(file);
if ((fp = fopen(file, "rb")) == NULL) /* 只读 + 二进制模式 */
{
printf("reverse can't open %s\n", file);
exit(1);
}
fseek(fp, 0L, SEEK_END); /* 跳到文件末尾 */
last = ftell(fp);
for (count = 1L; count <= last; count++)
{
fseek(fp, -count, SEEK_END); /* 从末尾往回退 */
ch = getc(fp);
if (ch != CNTL_Z && ch != '\r')
putchar(ch);
}
putchar('\n');
fclose(fp);
return 0;
}fseek() 三个参数(返回值 0 成功、-1 失败):
- FILE 指针——要移动的文件;
- 偏移量(offset)——必须(long) 类型,从起始点移动多少字节,可正可负可零;
- 模式——起始点,三个常量:
SEEK_SET:文件开头SEEK_CUR:当前位置SEEK_END:文件末尾
组合示例:
fseek(fp, 0L, SEEK_SET); // 回到文件开头
fseek(fp, 10L, SEEK_SET); // 前进到第 10 个字节处
fseek(fp, 2L, SEEK_CUR); // 从当前位置前进 2 字节
fseek(fp, 0L, SEEK_END); // 跳到文件末尾
fseek(fp, -10L, SEEK_END); // 从末尾回退 10 字节ftell() 返回 long 型的当前位置(二进制模式下即”距文件开头多少字节”,首个字节记 0)。reverse.c 的套路是:先跳到文件末尾,用 ftell() 拿到总字节数 last,然后循环里 fseek(fp, -count, SEEK_END) 依次回退 1、2、3……个字节,逐个读出打印——文件就被倒着读完了。
为什么用 "rb" 二进制模式?因为 DOS 文本文件末尾可能有 Ctrl+Z 标记、换行存的是 \r\n 两个字节,文本模式会自动做映射,倒着读就乱了。二进制模式下这些字节原样可见,程序里专门过滤了 \r 和 Ctrl+Z。
fseek() 的偏移量必须是 long 类型
第二个参数要用
10L这样的 long 字面量,写10在某些平台上会因类型不匹配出问题。另外 ANSI C 只保证文本模式下 4 种用法可用:fseek(file, 0L, SEEK_SET)(回开头)、fseek(file, 0L, SEEK_CUR)(原地不动)、fseek(file, 0L, SEEK_END)(到末尾)、以及fseek(file, ftell返回值, SEEK_SET)。其他组合在二进制模式下还要看实现是否支持 SEEK_END,可移植性要留心。
更大的文件:fgetpos() 与 fsetpos()
fseek()/ftell() 用 long 表示位置,文件超过约 20 亿字节就力不从心。ANSI C 新增了 fpos_t 类型(可能是结构体)与配套函数:
int fgetpos(FILE *stream, fpos_t *pos);把当前位置存进 *pos;int fsetpos(FILE *stream, const fpos_t *pos);用之前 fgetpos() 得到的值恢复位置。
13.6 标准 I/O 的内幕
概念模型帮你理解前面所有函数如何协作:
- fopen() 打开文件,建立缓冲区(读写模式有两个)和一个描述文件的数据结构(文件位置指示器、错误与文件结尾指示器、缓冲区指针等),返回指针。这一步叫”打开了一个流(stream)”。
- 调用 getc()、fscanf()、fgets() 等输入函数时,一大块数据(典型 512 字节或其倍数)从文件拷入缓冲区,函数实际从缓冲区取数;所有 stdio.h 输入函数共享同一个缓冲区,所以换着函数读也能无缝衔接。
- 缓冲区读空后,再搬下一块进来。读完最后一块的最后一个字符后,文件结尾指示器置位,下一次输入函数调用返回 EOF。
- 输出同理:先写进缓冲区,缓冲区满了才整体写出到文件。

13.7 其他标准 I/O 函数
- ungetc(c, fp):把一个字符推回输入流,下一个输入函数会先读到它。典型用法:读到冒号为止后发现读多了,用 ungetc() 把冒号退回去。标准只保证一次推回一个。
- fflush(fp):把输出缓冲区里未写出的数据立刻发送到文件(冲刷缓冲区)。fp 为 NULL 时冲刷所有输出缓冲区。对输入流使用效果未定义。
- setvbuf(fp, buf, mode, size):在 fopen() 之后、其他操作之前,给流指定自定义缓冲区。mode 取
_IOFBF(满缓冲)、_IOLBF(行缓冲)、_IONBF(无缓冲)。如果数据处理对象恰好 3000 字节一个,就可以定制等大的缓冲区。 - feof(fp) / ferror(fp):输入函数返回 EOF 有两种可能——读到文件尾,或发生读错误。这两个函数帮你区分:feof() 在文件尾时返回非零;ferror() 在发生读写错误时返回非零。
二进制 I/O:fread() 与 fwrite()
用 fprintf() 存数字其实是存成了字符串:fprintf(fp, "%f", 1.0/3.0) 存下的是 8 个字符,精度可能丢失,读回来也未必是原值。要精确、一致地保存数值,最可靠的办法是用和内存里完全相同的位模式存进文件——这就是二进制形式,fread()/fwrite() 提供这项服务:
size_t fwrite(const void * restrict ptr, size_t size,
size_t nmemb, FILE * restrict fp);
size_t fread (void * restrict ptr, size_t size,
size_t nmemb, FILE * restrict fp);ptr 是数据地址(void * 是”万用”指针类型),size 是每块字节数,nmemb 是块数。保存 10 个 double:
double earnings[10];
fwrite(earnings, sizeof(double), 10, fp); // 写入 10 块
fread(earnings, sizeof(double), 10, fp); // 原样读回返回值是成功读写的块数,正常等于 nmemb,出错或到文件尾时会小于它。

清单 13.6 append.c 综合运用:把多个源文件依次追加到一个目标文件里,复制时用 fread()/fwrite() 每次搬 1024 字节,并用 setvbuf() 给文件配了 1024 字节的缓冲区,还用 ferror() 检查读写错误。核心拷贝函数只有几行:
void append(FILE *source, FILE *dest)
{
size_t bytes;
static char temp[BUFSIZE]; // 只分配一次
while ((bytes = fread(temp, sizeof(char), BUFSIZE, source)) > 0)
fwrite(temp, sizeof(char), bytes, dest);
}二进制文件 + 随机访问:清单 13.7 randbin.c
二进制 I/O 与随机访问是天生一对——每个值占的字节数固定,位置好算。这个程序把 1000 个 double 存进文件,再按索引任意取值:
/* randbin.c -- 随机访问、二进制 I/O */
#include <stdio.h>
#include <stdlib.h>
#define ARSIZE 1000
int main()
{
double numbers[ARSIZE];
double value;
const char * file = "numbers.dat";
int i;
long pos;
FILE *iofile;
// 生成一组 double 值
for (i = 0; i < ARSIZE; i++)
numbers[i] = 100.0 * i + 1.0 / (i + 1);
// 打开文件
if ((iofile = fopen(file, "wb")) == NULL)
{
fprintf(stderr, "Could not open %s for output.\n", file);
exit(1);
}
// 以二进制格式写入数组
fwrite(numbers, sizeof(double), ARSIZE, iofile);
fclose(iofile);
if ((iofile = fopen(file, "rb")) == NULL)
{
fprintf(stderr,
"Could not open %s for random access.\n", file);
exit(1);
}
// 从文件中读取选定的值
printf("Enter an index in the range 0-%d.\n", ARSIZE - 1);
scanf("%d", &i);
while (i >= 0 && i < ARSIZE)
{
pos = (long) i * sizeof(double); // 计算偏移
fseek(iofile, pos, SEEK_SET); // 跳过去
fread(&value, sizeof(double), 1, iofile);
printf("The value there is %f.\n", value);
printf("Next index (out of range to quit):\n");
scanf("%d", &i);
}
// 收尾
fclose(iofile);
puts("Bye!");
return 0;
}关键一行是 pos = (long) i * sizeof(double);:第 i 个 double 距文件开头正好 i × 8 字节,fseek() 一步到位,fread() 把那 8 个字节原样拷进 value。没有格式转换、没有精度损失,二进制文件无法用文本编辑器直接看,但数据分毫不差。
选对工具就成功了一半
记住这组搭配:想保存文本、希望记事本能打开看——文本模式 + getc()/fprintf()/fgets();想保存数值且要求读回来分毫不差——二进制模式 + fwrite()/fread()。数字先转成字符串再存,精度就回不来了。
关键概念与小结
C 程序把输入输出都看作字节流。怎么解读这些字节,取决于你用的函数:用 fread() 就是原样二进制;用 getc()/fgets() 就当作字符;用 fscanf() 则按格式说明符把字符翻译成数值。文件指针(FILE *)是所有后续操作的身份证,拿到它之后就不再需要文件名。
要点回顾:fopen() 打开文件并建立缓冲区与数据结构,失败返回 NULL 必须检查;文件结尾是”读过头”才被发现的,判断要紧跟在读取之后;fseek()/ftell()(及 fgetpos()/fsetpos())实现随机访问,二进制模式下最可靠;feof()/ferror() 区分”到头了”与”出错了”。
一个自检清单
写文件程序时问自己四个问题:文件打开成功了吗(fopen 判 NULL)?用 “w” 会不会误删旧文件?读完有没有 fclose()?错误消息是走 stderr 还是 stdout?这四问能挡住绝大多数文件 I/O 的低级错误。
自测题(点击展开)
1.
"w"模式打开一个已存在的文件会发生什么? 文件内容被截断为零——原数据全部丢失,程序从空白文件开始写。想保留内容应使用"a"追加模式。2. 程序如何知道读到了文件末尾? C 的输入函数是”先读过头才发现到头”:getc() 试图在文件末尾之后再读时返回 EOF。正确写法是把读取放进循环条件,如
while ((ch = getc(fp)) != EOF),保证 EOF 不会被当作有效字符处理。3.
fseek(fp, -10L, SEEK_END);是什么意思? 把文件位置指示器移到距文件末尾往前 10 个字节处(倒数第 10 个字节)。偏移量为负表示从起始点向文件开头方向移动,且必须是 long 类型(写成 -10L)。4. 用 fprintf() 和用 fwrite() 保存数值 8238201 有何区别? fprintf() 把它转换成 7 个字符组成的字符串存储;fwrite() 把它在内存中的二进制表示(如 4 字节的 int 位模式)原样写入文件。前者可读但可能改变占用空间与精度,后者精确一致但人眼读不懂。
5. fscanf() 读到 EOF 时,如何区分”文件结束”与”读错误”? 用 feof(fp) 与 ferror(fp):若上次输入操作触发了文件结尾,feof() 返回非零值;若发生了读写错误,ferror() 返回非零值。