Lecture 14: 文件 I/O (File I/O in C)
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写入的是调用者提供的数组,其存储期由调用者决定; 函数本身不分配任何内存,所以绝不要把栈上的缓冲区地址返回出去。
- 解析文本:
strtok、strtol、sscanf、snprintf:- 直观解释:
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*)把n个size字节的”东西”原样写出;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。 同时写stdout与stderr的程序,因此可以做到”正常结果进文件、错误信息留在屏幕”。 缓冲方面的关键事实:缓冲区在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 91、bob 72、carol 85、dave 64、erin 91、frank 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
【代码做什么?】
main检查参数个数,然后fopen(argv[1], "r")打开输入文件,立刻判断是否为NULL。read_records用fgets一行一行读(因此天然处理行式文本),再用sscanf(line, "%15s %d", ...)解析出姓名与分数;返回值不等于 2 就判定该行格式错误并返回 -1。%15s防止姓名写爆name[16]。- 读完输入后用
ferror区分”正常读完”与”读出错”,随后fclose并检查返回值。 sort_records做插入排序(降序)。实测 alice 与 erin 同为 91 分,因为插入排序在相等时停止(稳定排序), 输出里 alice 排在 erin 前面。- 以
"w"打开输出文件,逐行fprintf写出排名、姓名、分数,最后写平均分与最高分;fclose把缓冲区写回磁盘(失败会返回EOF,所以这里也要检查)。 - 最后向
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/KBDR、DSR/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
【代码做什么?】
in默认指向stdin;只有在命令行给了文件名时才fopen。于是同一个程序既当过滤器又当普通工具。- 用
fgetc逐字符读取,直到返回EOF。用int32_t c接住返回值——不能声明成char。 - 每读一个字符
chars++;遇到换行lines++;用in_word状态机统计单词边界(连续的非空白字符算一个单词)。 - 循环结束后用
ferror(in)判断是”正常到达文件尾”还是”读取出错”,并在出错时报perror并返回 1。 - 若
in不是标准输入则fclose;随后把三个计数打到stdout。
【底层机制透视】 关键在于”同一份代码对文件、管道、键盘都工作“,靠的是 shell 在启动程序之前就把描述符 0 换掉了: < file 让 stdin 指向一个普通文件,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:解析一行文本——strtok、strtol、sscanf、snprintf
代码 (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]
【代码做什么?】
strtok(line, ", \t")把"42, alice , 91.5"就地切成三段:42、alice、91.5, 连空格都被跳过了(因为空格也在分隔符集合里)。strtol("-1234xyz", &end, 10)得到 -1234,end指向'x',于是”数字后面有垃圾”被精确识别。- 第二个
strtol演示溢出:字符串超出long范围,函数返回LONG_MAX(本机9223372036854775807) 并把errno设为ERANGE(34)。只看返回值会把溢出误当成一次成功的巨大数值。 sscanf在格式匹配时返回转换成功的字段数(3),遇到垃圾输入返回 0, 且不会修改输出变量——所以调用前应把它们初始化,否则读到的是不确定的旧值或垃圾值。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看内核拒绝了哪次打开;gdb下p 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返回值的判断;gdb里break n6_eof.c:<行号>后反复p value、next,可以看见最后一轮fscanf返回 -1 而变量未变。 - 用
char接fgetc/getc的返回值:char c = fgetc(f);在char有符号的平台上会把0xFF变成 -1, 与EOF混淆,导致文件被提前当成”读完”。调试:把类型改成int;gdb下用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要查NULL,fscanf/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 是否有符号都可能不同。 再加上指针本身是地址(必须扁平化成下标或偏移)。所以可移植的持久化格式要么用文本, 要么显式规定”大端、固定宽度、无填充”的二进制协议。
