这一章在讲什么?

前面所有章节的程序都有一个共同弱点:程序一结束,数据就没了。变量活在内存里,断电即逝。这一章解决这个问题——用文件(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 失败):

  1. FILE 指针——要移动的文件;
  2. 偏移量(offset)——必须(long) 类型,从起始点移动多少字节,可正可负可零;
  3. 模式——起始点,三个常量:
    • 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 的内幕

概念模型帮你理解前面所有函数如何协作:

  1. fopen() 打开文件,建立缓冲区(读写模式有两个)和一个描述文件的数据结构(文件位置指示器、错误与文件结尾指示器、缓冲区指针等),返回指针。这一步叫”打开了一个(stream)”。
  2. 调用 getc()、fscanf()、fgets() 等输入函数时,一大块数据(典型 512 字节或其倍数)从文件拷入缓冲区,函数实际从缓冲区取数;所有 stdio.h 输入函数共享同一个缓冲区,所以换着函数读也能无缝衔接。
  3. 缓冲区读空后,再搬下一块进来。读完最后一块的最后一个字符后,文件结尾指示器置位,下一次输入函数调用返回 EOF。
  4. 输出同理:先写进缓冲区,缓冲区满了才整体写出到文件。

/图:ungetc() 把字符推回输入流

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 的低级错误。