Lecture 14: 文件 I/O (File I/O in C)

目录 · ← l13 · l15 →

Lecture 14: 文件 I/O (File I/O in C)

概述

本讲要解决的问题是:程序如何把数据从文件、键盘、管道读进来,再写出去, 并且做到”同一份代码既能对文件工作,也能对键盘和管道工作”。引入的机制是 C 的流 (stream) 抽象—— FILE* 加上 fopen/fclose/fgetc/fgets/fscanf/fprintf/fread/fwrite 这一整套库函数, 它们在 Unix 文件描述符 (file descriptor) 之上再加一层缓冲 (buffering)。 这一讲把第 1–4 讲的 LC-3 内存映射 I/O(KBSR/KBDR/DSR/DDR 那几个寄存器) 提升到操作系统级抽象,也是第 15–16 讲”把数据结构存进文件、再读回来”的前提。

核心概念与底层机制图解

  • 流 (Stream) 与 FILE 抽象:C 程序把每个 I/O 通道看成一串连续的字节,用一个 FILE* 句柄操作它。
    • 直观解释FILE* 像水管的接头:你只关心倒水与接水,不关心水来自井里、河里还是水厂—— 文件、键盘、管道、网络在程序眼里是同一种东西
    • 底层机制图解:Unix 里所有 I/O 都归结为文件描述符 (file descriptor),即内核中 “每进程打开文件表”的小整数下标。C 的 FILE 结构包住一个描述符,并自带一块缓冲区:
      FILE* ──► ┌───────────────────────────┐      内核侧(打开文件表):
                │ 文件描述符 fd = 3         │──┐   ┌─────────────────────────┐
                │ 缓冲区指针 / 读写位置      │  └─► │ [0]=键盘 [1]=屏幕       │
                │ 缓冲区数组 / EOF / 错误标志│      │ [2]=屏幕(错误) [3]=文件 │
                └───────────────────────────┘      └─────────────────────────┘
      

      这也是著名的”一切皆文件”:read/write 系统调用对普通文件、终端、管道、套接字都适用, 所以早期 Internet 服务大多先在键盘/屏幕上调试,然后由 inetd 把网络连接替换成标准输入输出就上线了。

    • 作用域与存储期:三个标准流 stdin(描述符 0)、stdout(1)、stderr(2) 具有静态存储期 (static storage duration),由 C 运行时在 main 之前建立、在退出时自动关闭。 fopen 返回的 FILE* 指向堆上由库分配的对象,必须靠 fclose 释放。
  • 打开与关闭文件 (fopen / fclose)FILE* fopen (const char* path, const char* mode); 失败返回 NULL
    • 直观解释fopen 像去图书馆借书——书可能不在(路径错)、可能没权限,所以每次都要查返回值
    • 底层机制图解mode 是字符串,决定打开方式与是否截断:

      mode含义mode含义
      "r"/"rb"只读,文件必须存在"r+" 系列读写,文件必须存在
      "w"/"wb"只写,先清空(不存在则创建)"w+" 系列先清空,再读写
      "a"/"ab"只写,追加到末尾"a+" 系列追加并允许读

      "b" 是历史遗留的二进制标记;在 MS-DOS 等系统上它会阻止 CR/LF 被悄悄改写,在 Unix 上无实际差别。 int fclose (FILE* stream) 返回 0 成功、EOF 失败——失败常常发生在关闭时, 因为此时才把缓冲区真正写回磁盘,所以 fclose 的返回值必须检查。

    • 作用域与存储期:每个进程能同时打开的描述符数量有上限(ulimit -n,本机 16384)。 忘记 fclose 就是泄漏文件描述符 (file descriptor leak):循环里反复 fopen 却不关闭, 程序会先耗尽描述符,随后所有 fopen 都返回 NULL——这类 bug 在长跑服务里很致命。
  • 三种粒度的读写:字符、行、格式化
    • 直观解释:字符 I/O 像用吸管一滴一滴喝,行 I/O 像按杯喝,格式化 I/O 像按菜谱配好料再端上来。
    • 底层机制图解
      • int fgetc (FILE*) / int getc (FILE*):读一个字节;返回 int 而不是 char, 因为必须有一个”不属于任何字节”的值表示失败,那就是 EOF(-1),而 0xFF 是合法字节。 getc 是宏(内联进你的函数,代码更大但更快),fgetc 是库函数(代码更小)。
      • int fputc (int c, FILE*) / putc:写一个字节;getchar/putchar 是 stdin/stdout 的快捷方式。
      • char* fgets (char* s, int size, FILE*):读最多 size-1 个字节,遇行尾/文件尾/缓冲区满即停, 把换行符留在数组里并补 '\0';它是处理行式输入的最佳工具。
      • int fputs (const char* s, FILE*):写字符串,不加换行puts 会自动补一个 '\n'
      • int fprintf:格式化写,返回写入字符数或负值;int fscanf:格式化读,返回成功转换的字段个数
      • gets 已被 C11 删除,永远不要使用:它没有长度参数,任何长度的输入都会溢出缓冲区。
    • 作用域与存储期fgets 写入的是调用者提供的数组,其存储期由调用者决定; 函数本身不分配任何内存,所以绝不要把栈上的缓冲区地址返回出去。
  • 解析文本:strtokstrtolsscanfsnprintf
    • 直观解释fgets 负责”把整行端上桌”,解析函数负责”把这一行切成小块并看懂它们”。
    • 底层机制图解
      • char* strtok (char* s, const char* delim):按分隔符切分,会就地修改原字符串(把分隔符改成 '\0'); 第一次传字符串,之后传 NULL 表示”继续切同一个串”。它不是线程安全的,也不能处理嵌套。
      • long strtol (const char* s, char** endptr, int base):把字符串转成长整型; endptr 指回第一个未能转换的字符,配合 errno == ERANGE 可以区分”转换成功”“后面有垃圾”“溢出”三种情况。
      • int sscanf (const char* s, const char* fmt, ...):从字符串里做格式化读取(与 fscanf 同族), 适合”先用 fgets 取一行、再反复尝试不同格式”的健壮解析策略:解析失败的行可以原样回显给用户。
      • int snprintf (char* s, size_t size, const char* fmt, ...):把格式化结果写进字符串,带长度限制
    • 作用域与存储期strtok 内部用一个静态变量记录位置(static storage duration), 所以它跨调用保存状态——这正是它不可重入的原因。解析出来的指针都指向原字符串内部, 原字符串一旦被覆盖或释放,这些指针立刻失效。
  • EOF 与错误是两回事 (feof / ferror)
    • 直观解释feof 是”上一次读为什么停下来”的事后报告,不是”接下来还有没有数据”的事前预测
    • 底层机制图解feof(f) 只有在一次读取已经因为到达文件尾而失败之后才变为真。 因此 while (!feof (f)) { fscanf (f, ...); ... } 必然多执行一轮:最后一轮里 fscanf 失败, 但变量没有被写入,于是把那上一次的旧值又处理了一遍。正确写法是测试输入函数的返回值while (1 == fscanf (f, "%d", &v))while (NULL != fgets (buf, size, f))。 同理,fgetc 返回 EOF 后要用 ferror(f) 区分”正常结束”与”真的出错了”。
    • 作用域与存储期:EOF 与错误标志都存在 FILE 对象里,存储期与流相同; clearerr(f) 可以清除它们,rewind(f) 会同时清标志并把位置移回开头。
  • 文本格式 vs 二进制格式 (fread / fwrite)
    • 直观解释:文本像”用文字写下来”,二进制像”把内存原样复印一份”。
    • 底层机制图解size_t fwrite (const void* p, size_t size, size_t n, FILE*)nsize 字节的”东西”原样写出;size_t fread (...) 原样读回,二者返回成功处理的个数(不是字节数)。 二进制更省空间也更快(不需要十进制转换),但有三个代价:不可读不可移植 (大小端、浮点表示、结构体填充都可能不同)、难以升级格式。 另外要”把数据结构存进文件”就必须先扁平化 (flatten):把指针换成下标或偏移, 否则下次运行进程地址变了,指针就毫无意义。
    • 作用域与存储期fread/fwrite 不分配内存,读写的是调用者给的缓冲区; 若要把整个数组一次读写,缓冲区通常来自 malloc(第 16 讲)。
  • 重定向、管道与缓冲 (redirection, pipes, buffering)
    • 直观解释:重定向是”把水管接到别的水龙头上”,缓冲是”在水管中间装一个水箱”。
    • 底层机制图解:shell 在启动程序前就把描述符 0/1/2 换成别的文件:

      ./prog > out.txt 把 stdout 重定向到文件(先清空),>> 改为追加,< in.txt 重定向 stdin, 2> err.txt 只重定向 stderr,cat in.txt \| ./prog 用管道把前一个进程的 stdout 接到后一个进程的 stdin。 同时写 stdoutstderr 的程序,因此可以做到”正常结果进文件、错误信息留在屏幕”。 缓冲方面的关键事实:缓冲区在 exit/return 时自动刷新,但 abort/段错误不会。 写到终端时 stdout 通常是行缓冲(每遇换行就刷),重定向到文件或管道时变成全缓冲(默认 4 KB 左右), 于是”程序崩了但输出不见了”变成一个与平台有关的坑。fflush(stdout) 能立刻把缓冲区推出去; setvbuf(stdout, NULL, _IOLBF, 0) 可以强制行缓冲,让程序在重定向后行为一致。

    • 作用域与存储期:缓冲区属于 FILE 对象;进程死亡时它随进程消失,未刷出的数据就永远丢了。

代码示例与底层机制分析

示例 1:读记录文件 → 排序 → 写出汇总

代码 (C):

/* n1_record_sort.c   用法: ./n1_record_sort <input> <output>
 * 编译: gcc -g -std=c99 -Wall -Werror n1_record_sort.c -o n1_record_sort */
#include <stdio.h>
#include <stdint.h>
#include <string.h>

#define MAX_RECORDS 50
#define MAX_LINE    64

typedef struct {
    char    name[16];
    int32_t score;
} record_t;

/* Return the number of records read, or -1 on a malformed line. */
static int32_t
read_records (FILE* in, record_t* recs, int32_t max_records)
{
    char    line[MAX_LINE];
    int32_t n = 0;
    int32_t value;

    while (NULL != fgets (line, MAX_LINE, in)) {
        if (2 != sscanf (line, "%15s %d", recs[n].name, &value)) {
            return -1;                    /* not "name score" */
        }
        recs[n].score = value;
        n++;
        if (max_records == n) {
            break;
        }
    }
    if (0 != ferror (in)) {               /* EOF and an error differ! */
        perror ("read");
        return -1;
    }
    return n;
}

static void
sort_records (record_t* recs, int32_t n)   /* insertion sort, descending */
{
    int32_t  i, j;
    record_t current;

    for (i = 1; n > i; i++) {
        current = recs[i];
        for (j = i - 1; 0 <= j; j--) {
            if (current.score <= recs[j].score) {
                break;
            }
            recs[j + 1] = recs[j];
        }
        recs[j + 1] = current;
    }
}

int
main (int argc, char* argv[])
{
    FILE*    in;
    FILE*    out;
    record_t recs[MAX_RECORDS];
    int32_t  n, i, total = 0;

    if (3 != argc) {
        fprintf (stderr, "syntax: %s <input> <output>\n", argv[0]);
        return 2;
    }
    if (NULL == (in = fopen (argv[1], "r"))) {      /* always check! */
        perror (argv[1]);
        return 1;
    }
    n = read_records (in, recs, MAX_RECORDS);
    if (0 != fclose (in)) {                          /* fclose can fail   */
        perror ("close input");
        return 1;
    }
    if (0 > n) {
        return 1;
    }

    sort_records (recs, n);

    if (NULL == (out = fopen (argv[2], "w"))) {
        perror (argv[2]);
        return 1;
    }
    fprintf (out, "records: %d\n", (int)n);
    for (i = 0; n > i; i++) {
        fprintf (out, "%-3d %-8s %4d\n", i + 1, recs[i].name, (int)recs[i].score);
        total += recs[i].score;
    }
    fprintf (out, "average: %.2f, best: %s\n",
             (double)total / (double)n, recs[0].name);
    if (0 != fclose (out)) {                         /* flushes the buffer */
        perror ("close output");
        return 1;
    }

    printf ("wrote %d records to %s\n", (int)n, argv[2]);
    return 0;
}

输入文件 n1_input.txt 的内容是 alice 91bob 72carol 85dave 64erin 91frank 100,每行一条。

真实运行输出:

$ ./n1_record_sort n1_input.txt n1_summary.txt
wrote 6 records to n1_summary.txt
$ cat n1_summary.txt
records: 6
1   frank     100
2   alice      91
3   erin       91
4   carol      85
5   bob        72
6   dave       64
average: 83.83, best: frank

【代码做什么?】

  1. main 检查参数个数,然后 fopen(argv[1], "r") 打开输入文件,立刻判断是否为 NULL
  2. read_recordsfgets 一行一行读(因此天然处理行式文本),再用 sscanf(line, "%15s %d", ...) 解析出姓名与分数;返回值不等于 2 就判定该行格式错误并返回 -1。%15s 防止姓名写爆 name[16]
  3. 读完输入后用 ferror 区分”正常读完”与”读出错”,随后 fclose检查返回值
  4. sort_records 做插入排序(降序)。实测 alice 与 erin 同为 91 分,因为插入排序在相等时停止(稳定排序), 输出里 alice 排在 erin 前面。
  5. "w" 打开输出文件,逐行 fprintf 写出排名、姓名、分数,最后写平均分与最高分; fclose 把缓冲区写回磁盘(失败会返回 EOF,所以这里也要检查)。
  6. 最后向 stdout 打印一行简短的状态信息。

【底层机制透视】 这段代码几乎把整讲的 I/O 规则用了一遍:fgets 提供行边界sscanf 提供字段解析ferror 提供错误与 EOF 的区分fclose 提供缓冲区的最终落盘。 注意 "w" 模式的语义是先截断,与 "a" 的追加语义完全不同; read_records 里”读到上限就 break“的检查若省略,超长输入会持续写 recs[MAX_RECORDS] 之后的栈内存—— 这是第 10 讲强调过的经典越界错误。最后,fprintf 的返回值(写入字符数)在本例中没有检查; 磁盘写满、管道破裂这类错误只有靠检查它(以及 fclose 的返回值)才能发现。

【内存布局图解】

  栈 (main 的帧)                          堆 / 库内部 (fopen 分配)
  ┌──────────────────────────────┐        ┌────────────────────────────┐
  │ recs[50]  每个 record_t 24 B  │        │ FILE 对象 for n1_input.txt │
  │   [0] "alice" 91  ← 输入顺序  │        │  fd = 3                    │
  │   [1] "bob"   72              │        │  buffer[4096]  ← 一次读入  │
  │   ...                         │        │  读写位置/EOR/错误标志      │
  │ 排序后 [0] "frank" 100        │        └────────────────────────────┘
  │ n / i / total  (自动存储期)    │        ┌────────────────────────────┐
  │ in / out (FILE*)  ← 各 8 字节   │───────►│ FILE 对象 for 输出文件      │
  └──────────────────────────────┘        └────────────────────────────┘

【与汇编的对应】 LC-3 只有内存映射 I/O(KBSR/KBDRDSR/DDR 各占一个地址),没有操作系统、没有文件系统; C 里的一次 fgets 在 LC-3 上会展开成一串 LDI/STI(轮询就绪位、搬运数据), 而在 Unix 上它变成”从 FILE 缓冲区拷一行,必要时再发一次 read 系统调用”。 两者共享的机器事实是:数据必须经过寄存器逐字搬运,缓冲只是减少搬运次数。 可以用 TRAP 类比标准流的入口:

; LC-3 没有文件概念:读一个字符要轮询键盘状态寄存器
POLL    LDI R0, KBSR       ; R0 = 键盘状态寄存器内容
        BRzp POLL          ; 就绪位为 0 就继续等
        LDI R0, KBDR       ; R0 = 键入的字符
; C 里对应的概念是 fgetc(stdin):一次调用返回一个字符或 EOF
        JSR FGETC_CALL     ; 进入 C 库函数(内部可能触发 read 系统调用)
        ADD R1, R0, #1     ; 比较是否为 EOF(-1)
        BRz GOT_EOF

用第 12 讲的字符处理循环(getc 逐字符 + putc 逐字符)就能写出与 cat 等价的程序, 只不过 C 版本读写的是 FILE*,而 LC-3 版本读写的是设备寄存器。

示例 2:读 stdin 的过滤器——与管道和重定向协作

代码 (C):

/* n2_wc_stdin.c  用法: cat file | ./n2_wc_stdin    或   ./n2_wc_stdin < file
 * 编译: gcc -g -std=c99 -Wall -Werror n2_wc_stdin.c -o n2_wc_stdin */
#include <stdio.h>
#include <stdint.h>

int
main (int argc, char* argv[])
{
    FILE*   in = stdin;                 /* default: standard input */
    int64_t lines = 0, words = 0, chars = 0;
    int32_t in_word = 0;
    int32_t c;

    if (2 == argc) {                    /* optional file argument */
        if (NULL == (in = fopen (argv[1], "r"))) {
            perror (argv[1]);
            return 1;
        }
    } else if (1 != argc) {
        fprintf (stderr, "syntax: %s [file]\n", argv[0]);
        return 2;
    }

    while (EOF != (c = fgetc (in))) {   /* fgetc returns int, not char */
        chars++;
        if ('\n' == c) {
            lines++;
        }
        if (' ' == c || '\t' == c || '\n' == c || '\r' == c) {
            in_word = 0;
        } else if (0 == in_word) {
            words++;
            in_word = 1;
        }
    }

    if (0 != ferror (in)) {             /* a real error, not end of file */
        perror ("read");
        if (stdin != in) {
            (void)fclose (in);
        }
        return 1;
    }
    if (stdin != in) {
        (void)fclose (in);
    }

    printf ("lines %ld, words %ld, chars %ld\n", (long)lines, (long)words, (long)chars);
    return 0;
}

真实运行输出(三种调用方式结果一致):

$ cat n1_input.txt | ./n2_wc_stdin
lines 6, words 12, chars 51
$ ./n2_wc_stdin < n1_input.txt
lines 6, words 12, chars 51
$ ./n2_wc_stdin n1_input.txt
lines 6, words 12, chars 51

【代码做什么?】

  1. in 默认指向 stdin;只有在命令行给了文件名时才 fopen。于是同一个程序既当过滤器又当普通工具
  2. fgetc 逐字符读取,直到返回 EOF。用 int32_t c 接住返回值——不能声明成 char
  3. 每读一个字符 chars++;遇到换行 lines++;用 in_word 状态机统计单词边界(连续的非空白字符算一个单词)。
  4. 循环结束后用 ferror(in) 判断是”正常到达文件尾”还是”读取出错”,并在出错时报 perror 并返回 1。
  5. in 不是标准输入则 fclose;随后把三个计数打到 stdout

【底层机制透视】 关键在于”同一份代码对文件、管道、键盘都工作“,靠的是 shell 在启动程序之前就把描述符 0 换掉了: < filestdin 指向一个普通文件,cat x \| prog 让它指向管道读端,程序无需知道差异。 另一个细节是 c 的类型:fgetc 要返回 257 种可能的值(256 个字节 + 一个 EOF),char 装不下; 本机 char 有符号,字节 0xFF 会被当成 -1,于是”遇到 0xFF 就以为文件结束”,文件被提前截断。 in_word 这类跨迭代保存的状态必须声明在循环之外。

【内存布局图解】

  进程启动时(由 shell/内核建立):
  ┌───────────────────────────────────────────────────────────┐
  │ 描述符 0  ──►  管道读端  │  文件 n1_input.txt  │  键盘      │
  │ 描述符 1  ──►  终端      │  文件 out.txt(若 > out.txt)     │
  │ 描述符 2  ──►  终端(错误专用,永远不被 > 影响)             │
  └───────────────────────────────────────────────────────────┘
  FILE* in = stdin 只是给"描述符 0"套了一层缓冲壳:
  ┌──────────────────┐   read(fd=0, buf, 4096)
  │ FILE{fd=0,buf}   │ ─────────────────────────► 内核/管道/磁盘
  └──────────────────┘   fgetc 先吃 buf 里的字节,吃空了才再发一次系统调用

【与汇编的对应】

; LC-3 里"读一个字符"是轮询设备寄存器;C 里是 fgetc(stdin)。
; 真正把两者连起来的是同一件事:数据必须经寄存器搬进内存。
GETC    LDI R0, KBSR           ; 轮询状态寄存器
        BRzp GETC
        LDI R0, KBDR           ; R0 = 一个字符
        ; 判断是否为 EOF(-1):
        ADD R1, R0, #1
        BRz  DONE              ; R0 == -1 -> 结束
        ADD R1, R1, #0         ; 否则统计逻辑(chars++ / 判断空白)
        BRnzp GETC
DONE    ; 退出循环,写结果

LC-3 版本的”stdin”就是键盘设备寄存器;C 版本把同样的轮询循环藏在 fgetc 与缓冲层之下。

示例 3:解析一行文本——strtokstrtolsscanfsnprintf

代码 (C):

/* n4_parse.c
 * 编译: gcc -g -std=c99 -Wall -Werror n4_parse.c -o n4_parse */
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>

int
main (void)
{
    char    line[] = "42, alice , 91.5";
    char*   field;
    char*   end;
    char    out[32];
    int32_t n = 0;
    int32_t id = 0;
    char    name[16] = "";
    double  score = 0.0;
    long    value;

    printf ("--- strtok: split on commas and blanks ---\n");
    field = strtok (line, ", \t");         /* modifies line in place */
    while (NULL != field) {
        printf ("field %d: [%s]\n", (int)n, field);
        n++;
        field = strtok (NULL, ", \t");
    }

    printf ("\n--- strtol with full error checking ---\n");
    errno = 0;
    value = strtol ("-1234xyz", &end, 10);
    printf ("\"-1234xyz\" -> %ld, errno=%d, stopped at '%s'\n", value, errno, end);
    errno = 0;
    value = strtol ("9999999999999999999999", &end, 10);
    printf ("overflow case -> %ld, errno=%d (ERANGE=%d)\n", value, errno, ERANGE);

    printf ("\n--- sscanf: check the return value! ---\n");
    n = sscanf ("id=7 name=bob score=88.5", "id=%d name=%15s score=%lf",
                &id, name, &score);
    printf ("converted %d fields: id=%d name=%s score=%.1f\n",
            (int)n, (int)id, name, score);
    n = sscanf ("garbage here", "id=%d name=%15s score=%lf", &id, name, &score);
    printf ("garbage input converted %d fields (values left unchanged)\n", (int)n);

    printf ("\n--- snprintf builds a line safely ---\n");
    n = snprintf (out, sizeof (out), "%d:%s:%.1f", 7, "bob", 88.5);
    printf ("snprintf wrote %d characters: [%s]\n", (int)n, out);

    return 0;
}

真实运行输出:

--- strtok: split on commas and blanks ---
field 0: [42]
field 1: [alice]
field 2: [91.5]

--- strtol with full error checking ---
"-1234xyz" -> -1234, errno=0, stopped at 'xyz'
overflow case -> 9223372036854775807, errno=34 (ERANGE=34)

--- sscanf: check the return value! ---
converted 3 fields: id=7 name=bob score=88.5
garbage input converted 0 fields (values left unchanged)

--- snprintf builds a line safely ---
snprintf wrote 10 characters: [7:bob:88.5]

【代码做什么?】

  1. strtok(line, ", \t")"42, alice , 91.5" 就地切成三段:42alice91.5, 连空格都被跳过了(因为空格也在分隔符集合里)。
  2. strtol("-1234xyz", &end, 10) 得到 -1234,end 指向 'x',于是”数字后面有垃圾”被精确识别。
  3. 第二个 strtol 演示溢出:字符串超出 long 范围,函数返回 LONG_MAX(本机 9223372036854775807) 并把 errno 设为 ERANGE(34)。只看返回值会把溢出误当成一次成功的巨大数值
  4. sscanf 在格式匹配时返回转换成功的字段数(3),遇到垃圾输入返回 0, 且不会修改输出变量——所以调用前应把它们初始化,否则读到的是不确定的旧值或垃圾值。
  5. snprintf 有长度上限,返回”假如空间足够本应写入的字符数”(10),并把 out 变成 7:bob:88.5

【底层机制透视】 这四个函数代表两种互补的解析策略:先切分再转换fgets + strtok + strtol)与直接格式匹配sscanf)。 课程推荐前者处理”人写的文件”:人可能少写字段、多打空格,而 sscanf 一次匹配失败后不知道失败在哪, strtok 分出的每一段却都能单独校验并回声给用户。实测 errno=34 提醒我们: 返回值 + errno + endptr 三者要一起看,才能把”成功”“部分成功”“溢出”“完全不是数字”分开。 最后注意 strtok 就地改写了 line(把分隔符换成 '\0'),所以必须传入可写字符串 (char line[] = "...",而不是 char* line = "..." 这种指向只读字面量的写法)。

【内存布局图解】

  char line[] = "42, alice , 91.5";     ← 可写的栈数组
  0    1  2  3 4 5 6 7  8  9 ...
  '4' '2' '\0' 'a' 'l' 'i' 'c' 'e' '\0' ...
        ▲      ▲                    ▲
        │      │                    └── 第 3 段 "91.5"(先跳过空格)
        │      └── 第 2 段 "alice"
        └── 第 1 段 "42":strtok 把 ',' 与空格写成 '\0'
  每个 field 指针都指向 line 内部 —— line 一旦失效,所有 field 都变成悬空指针

【与汇编的对应】

; 字符分类在汇编里就是若干次比较;下面判断 R0 是否为分隔符 ','
; 以及把 ',' 就地改成 '\0'(这就是 strtok 的核心动作)
        LDI R1, COMMA          ; R1 = ',' 的 ASCII 码 44
        NOT R1, R1
        ADD R1, R1, #1         ; R1 = -44
        ADD R2, R0, R1         ; R2 = c - ','
        BRnp NEXT_FIELD        ; 不是分隔符 -> 继续扫描
        AND R2, R2, #0
        STR R2, R3, #0         ; 把分隔符位置写为 '\0':字段到此结束
        ; 记录下一个字段的起始地址(相当于 strtok 内部的静态变量)
NEXT_FIELD
        ; 数字转换则是循环:digit = c - '0';  acc = acc * 10 + digit

strtok 的”就地写 '\0'“与 strtol 的”逐位乘十累加”在汇编里都是极短的循环—— 它们的困难不在指令,而在边界条件的判断(溢出、无数字、只有分隔符)。

常见错误与调试技巧

  • 不检查 fopen 的返回值:文件不存在时 fopen 返回 NULL,随后 fgets(NULL) 直接崩溃。 调试perror(argv[1]) 打印失败原因;strace -e trace=openat ./prog 看内核拒绝了哪次打开; gdbp in 确认是否为 (FILE *) 0x0
  • 经典的 while (!feof(f)) 多处理一条(不存在的)记录feof 只在一次读取失败之后才为真。 实测对比(n6_eof.c):正确写法输出 read 10 / read 20 / read 30 后循环结束; 错误写法输出 read 10 / read 20 / read 30 / read 30——最后一行是被重复处理的旧值

    下面是故意写错的演示,请勿模仿:

    while (0 == feof (in)) {              /* 错误写法:多跑一轮 */
        (void)fscanf (in, "%d", &value);  /* 返回值被忽略 */
        printf ("%d\n", value);           /* EOF 时打印的是上一次的旧值 */
    }
    

    调试:把循环条件换成对 fscanf/fgets 返回值的判断; gdbbreak n6_eof.c:<行号> 后反复 p valuenext,可以看见最后一轮 fscanf 返回 -1 而变量未变。

  • charfgetc/getc 的返回值char c = fgetc(f);char 有符号的平台上会把 0xFF 变成 -1, 与 EOF 混淆,导致文件被提前当成”读完”。调试:把类型改成 intgdb 下用 x/16xb buf 检查输出是否在中途被截断。
  • gets 读字符串:它没有长度限制,长输入直接冲垮栈。 调试gcc -Wall 会报 implicit declaration/dangerous;改用 fgets(buf, sizeof (buf), stdin); 用 gcc -fsanitize=address(或 valgrind ./prog)可以看到 Invalid write ... on thread 1's stack
  • 忘记 fclose 造成文件描述符泄漏:循环里反复 fopen 不关闭,最后所有 fopen 都失败。 调试ulimit -n 查看上限;ls /proc/<pid>/fd \| wc -l 观察描述符数量是否持续增长; valgrind --track-fds=yes ./prog 会在退出时列出未关闭的描述符。
  • 崩溃前输出”消失”(缓冲区没刷):重定向到文件后程序中途 abort()/段错误,缓冲区内容全部丢失。 实测(n7_buffering.c):不加 fflush 时输出文件大小为 0,加了 fflush(stdout) 后同样崩溃却写出 36 字节

    下面是故意让程序崩溃的演示,请勿模仿(结果依平台与缓冲模式而异):

    for (i = 1; 3 >= i; i++) { printf ("step %d of 3\n", (int)i); }
    fflush (stdout);        /* ← 去掉这一行,重定向到文件时输出会全部丢失 */
    abort ();               /* abort 不刷新 stdio 缓冲区 */
    

    调试./prog > out.txt; wc -c out.txt 检查文件大小是否符合预期; 需要”边跑边看”时用 stdbuf -oL ./prog 或调用 setvbuf(stdout, NULL, _IOLBF, 0)

关键要点

  • C 的 I/O 是两层抽象:内核的文件描述符(0/1/2 与其它小整数)+ 库的 FILE* 缓冲流。 程序因此可以完全不知道”对面”是文件、管道还是设备。
  • 每一次 I/O 调用都必须检查返回值fopen 要查 NULLfscanf/fgets/fgetc 要查转换结果, fclose 要查是否写出成功;feof 只能用来事后判断原因,不能用来当循环条件。
  • 行式文本用 fgets + sscanf/strtok/strtol 解析(因为能对失败的行做回声与重试), 二进制数据用 fread/fwrite(更省空间更快,但不可移植、需要扁平化指针)。
  • 重定向(><2>)与管道(\|)只是在启动前替换描述符, 把输出写到 stdout、错误写到 stderr 的程序天然就能与它们协作。
  • 缓冲区在 return/exit 时自动刷新,在 abort/崩溃时不会; 在乎输出的顺序与完整性时,要显式 fflush 或设置缓冲模式。

思考题(带答案)

问题 1:下面的代码想读出一个文件里所有的数字之和,它错在哪里?给出两种正确写法。

int sum = 0, v;
while (!feof (f)) {
    fscanf (f, "%d", &v);
    sum += v;
}

答案:错在两处。① feof(f)一次读取失败之后才为真,因此循环体会多执行一轮, 此时 fscanf 返回 0/EOF 且不修改 v,于是把上一次的数字重复累加了一次; ② fscanf 的返回值被完全忽略,无法区分”读到一个 0”“格式不匹配”“到达文件尾”。 正确写法一:while (1 == fscanf (f, "%d", &v)) { sum += v; }; 正确写法二:先用 fgets 读行,再在行内用 sscanf/strtol 解析并统计成功次数。 两种写法都把输入函数的返回值放在循环条件里,这是处理 EOF 的唯一可靠方式。

问题 2./prog < in.txt > out.txt 中,如果程序用 printf 打印进度、用 fprintf(stderr, ...) 打印错误, 用户会看到什么?为什么这样设计有用?

答案:正常输出(进度信息)进入 out.txt,错误信息仍显示在终端上。 因为重定向只替换被指定的描述符> 默认只改描述符 1(stdout),描述符 2(stderr)仍指向终端。 这样程序可以把”结果数据”与”给人看的诊断信息”分流:结果便于交给下一个程序(\|), 错误也不会被淹没在数据里。若希望错误也进文件,需显式写 2> err.txt> out.txt 2>&1; 反过来,把错误打进 stdout 的程序在 prog > out.txt 时会让用户”看不到任何错误”。

问题 3:为什么 fread/fwrite 存下来的结构体文件通常不能跨机器读取?举出三个具体原因。

答案:① 字节序 (endianness):x86-64 是小端,int32_t 1 写成 01 00 00 00,大端机器读出来是 0x01000000; ② 结构体填充 (padding):本机实测 struct {int32_t id; char tag[8]; double value;}sizeof 是 24 (4 + 8 + 8 = 20,另有 4 字节填充),换编译器或换 ABI 后字段偏移就可能全部错位; ③ 表示差异double 的 IEEE 754 编码、long 的宽度、甚至 char 是否有符号都可能不同。 再加上指针本身是地址(必须扁平化成下标或偏移)。所以可移植的持久化格式要么用文本, 要么显式规定”大端、固定宽度、无填充”的二进制协议。