写 C 的人,几乎都在某个时刻跟字符串读取纠缠过。你可能只是想从键盘上读一个名字,或者从配置文件里读一行文本,结果程序莫名其妙崩了、读到的字符串缺了一半、又或者明明敲了回车却像什么都没读到。这些问题的根子,十有八九落在gets和fgets这两个函数上。它们长得很像,名字只差一个字母,行为却差着一条命——一个早就被标准扫地出门,一个至今仍是读取文本行的主力工具。这篇文章就是围绕这两个函数展开的:它们各自的读取机制是什么、为什么gets必须从代码里清出去、fgets的换行符和截断怎么处理、EOF 怎么正确判断、跟scanf混用时为什么会出鬼、超长行怎么安全读完。无论你是刚学 C 语法的新手,还是手头有一堆老代码要维护的老兵,这里的内容都能直接拿去用。
1. 从一次崩溃说起:gets 与 fgets 到底差在哪
1.1 gets 的工作方式与它天生的缺陷
gets的函数原型短得让人放松警惕:
char *gets(char *s);它只接收一个参数——目标缓冲区的首地址。调用之后,它会从标准输入不断取字符,直到碰上换行符或者文件结束,然后把换行符丢掉,在末尾补一个'\0',再把这个指针原样返回。读取失败时返回NULL。整套逻辑看起来干净利落,问题恰恰出在"只接收一个参数"上。
这个函数不知道你的缓冲区有多大。它没有任何途径去获知这件事——没有长度参数,也没有全局的长度信息。这意味着它的循环条件里只有"还没遇到换行符",没有"还没写满缓冲区"这一条。你给它一个 10 字节的数组,它照样会一直往里塞,塞到第 11、第 12 个字符的时候,写的已经不是你的数组,而是数组后面的内存。这就是缓冲区溢出,而且是那种最干脆、最没有技术含量的溢出——不需要构造特殊载荷,只要输入足够长就能触发。
我在早期带新人的时候做过一个演示:定义一个char name[8],然后让人随便敲一串字符。输入短的时候一切正常,输入长一点程序就打印一堆乱码,再长一点直接段错误。那个新人当时的表情我到现在还记得——他没想到一个"读字符串"的函数能有这种破坏力。更麻烦的是,溢出的后果完全不可预测:可能覆盖相邻的局部变量,可能改掉函数返回地址,可能只是让程序安静地跑完但在别的地方出错。这类 bug 排查起来非常费劲,因为它崩溃的位置和出错的位置往往隔了很远。
1.2 fgets 的设计思路与边界控制
fgets的原型多出两个参数:
char *fgets(char *s, int n, FILE *stream);第二个参数n就是关键。它告诉函数:最多往缓冲区里写n - 1个字符,最后一个字节留给'\0'。也就是说,无论输入多长,函数都不会写出这个范围。读取过程中如果遇到了换行符,它会停下来,并且把这个换行符也存进缓冲区;如果读满n - 1个字符还没遇到换行符,它也会停下来,剩下的字符留在输入流里,等下一次读取。
第三个参数是流指针。这一点经常被忽略:fgets不是只能从键盘读,它可以从任何FILE *读——文件、管道、重定向的输入都行。stdin只是其中最常见的一个。这让fgets天然适合写配置解析、日志读取这类代码,同一套读取逻辑换个流就能复用。
我个人的判断是:fgets的设计体现了一种"把控制权交回调用者"的思路。它不猜你想要什么,只负责把数据安全地搬进缓冲区,至于换行符留不留、剩下的字符怎么处理、读完了要不要继续,全都由你决定。这种设计看起来比gets啰嗦,但正是这点啰嗦换来了安全。
1.3 一张表看清两者的行为差异
把两者的行为细节摆在一起对比,很多坑一眼就能看出来:
| 对比维度 | gets | fgets |
|---|---|---|
| 长度参数 | 无 | 有,n |
| 最大写入字符数 | 无上限 | n - 1 |
| 换行符处理 | 读取并丢弃 | 读取并保留 |
| 遇换行后剩余输入 | 无剩余,已读到换行 | 无剩余,已读到换行 |
| 缓冲区写满时 | 继续写,越界 | 停止,剩余留在流中 |
| 返回值 | 成功返回s,失败返回NULL | 成功返回s,失败返回NULL |
能否读含'\0'的数据 | 不能,遇换行即止 | 能继续读,但'\0'后内容用strlen看不到 |
| 标准状态 | C99 标记过时,C11 移除 | 一直保留 |
这张表里我最想强调的是"缓冲区写满时"这一行。同样是输入超长,gets继续写、fgets停下来,这一个差别就是崩溃和正常的差别。另一个容易踩的是"换行符处理"那一行——从gets迁移到fgets之后,很多人发现字符串末尾多了个'\n',比较字符串的时候怎么都不相等,原因就在这里。
注意:C11 标准已经正式把
gets从标准库中移除。你在新编译器上直接用gets,很可能遇到链接错误或者编译警告,这不是编译器坏了,是标准有意为之。
2. gets 为什么必须从代码里清出去
2.1 缓冲区溢出的现场还原
光说"不安全"不够直观,写个最小例子看看会发生什么:
#include <stdio.h> int main(void) { char buf[8]; int flag = 0; printf("请输入内容: "); gets(buf); if (flag != 0) { printf("flag 被改动了,值是 %d\n", flag); } printf("你输入的是: %s\n", buf); return 0; }在典型的栈布局下,局部变量buf和flag紧挨着。如果输入超过 7 个字符加上结尾的'\0',多出来的字节就会顺着栈往高地址方向写,很可能直接盖到flag上。输入 8 个字符时,flag就有可能从 0 变成一个非零值,程序打印出那句"flag 被改动了",而你的代码里根本没有任何地方给flag赋值。
这就是我常说的"看不见的写操作"。gets只会按你的输入长度写,不会觉得有什么问题,编译器也不会在运行时拦住它。更危险的情况是覆盖函数返回地址——攻击者可以借此改变程序执行流。虽然日常写业务代码未必会被人专门攻击,但一次意外的手滑输入就足以让程序崩掉,这个代价已经够大了。
2.2 编译器与标准的态度变化
回头看这条时间线,标准委员会的动作其实很明确。C89 时代gets就已经名声不好,但还在标准里;C99 把它标记为"过时"(obsolescent),意思是建议不要用,未来可能删掉;到了 C11,它被正式移出标准库。与此同时,标准引入了gets_s作为补救方案,但这个东西的可用性一言难尽——它属于附录 K 的可选边界检查接口,不是所有实现都提供,微软的实现和标准描述之间还有出入。
编译器的态度也值得注意。GCC 在检测到gets调用时会给出明确的警告,提示这个函数"危险且不应使用"。较新的 glibc 里虽然还留着头文件声明(考虑到历史代码兼容),但你在代码里写gets时基本一定会看到警告。如果编译选项里开了-Werror,这条警告会直接变成错误,编译都过不去。
实操提示:可以把
-Werror=implicit-function-declaration加到编译选项里。这个选项在把代码升级到新标准时特别管用,因为一旦gets的声明从你使用的头文件里消失,所有还在调用它的地方都会立刻暴露出来,一个都跑不掉。
2.3 老代码里 gets 的替换策略
接手过十几年前的老项目,代码里散落着几十处gets,直接一刀切替换是有风险的,得按步骤来。
第一步是定位。用grep -rn '\bgets\s*(' .把所有调用点找出来,别只搜gets(,因为写法可能是gets (buf)或者gets(buf),甚至有人拿它当函数指针用。找完之后统计数量,心里有个数。
第二步是逐个判断缓冲区的真实大小。这是最费时间也最关键的一步。你得回头看每个buf是在哪定义的、数组长度是多少、有没有可能传进来的是个指针(比如缓冲区是函数参数)。如果传进来的是指针,那这个缓冲区的大小信息在函数内部根本拿不到,必须去调用方确认,然后要么改成显式传长度参数,要么用宏定义统一管理长度。
第三步是替换并调整后续逻辑。把gets(buf)换成fgets(buf, sizeof(buf), stdin)只是开头,后面通常要加一段去掉换行符的代码,否则所有字符串比较、拼接、写文件的地方都可能受影响。我一般会顺手写个小的封装函数,把这些重复动作收进去。
第四步是回归测试。重点测三种输入:正好占满缓冲区的、超出缓冲区的、只敲一个回车的。超出缓冲区那种输入在老代码里可能从来没被正常处理过,替换之后行为会变,得确认新行为符合预期。
3. fgets 的正确打开方式
3.1 三个参数分别怎么填
fgets(s, n, stream)里最容易出错的是n。它表示缓冲区总容量,不是你想读的字符数。比如你有char buf[64],那n就填 64,函数最多往里写 63 个字符加一个'\0'。
最常见的写法是把sizeof直接套进去:
char buf[64]; if (fgets(buf, sizeof(buf), stdin) != NULL) { /* 处理 buf */ }用sizeof(buf)而不是硬编码 64 有个明显好处:以后你把数组改成 128,这里不用跟着改。但这里有个陷阱,我必须专门提醒——sizeof只在数组还在作用域内时才有效。一旦你把buf当参数传给另一个函数,它在函数内部就退化成了指针,sizeof得到的是指针大小(64 位机器上通常是 8),跟缓冲区真实容量毫无关系。这就是经典的"sizeof 传参陷阱"。
/* 错误写法:函数内部 sizeof(buf) 是指针大小 */ void read_input(char *buf) { fgets(buf, sizeof(buf), stdin); /* 只读了 7 个字符 */ } /* 正确写法:把长度显式传进来 */ void read_input(char *buf, int size) { fgets(buf, size, stdin); }这种 bug 特别隐蔽,因为它不崩溃,只是读取长度莫名其妙变短了,短输入的时候完全看不出来。
3.2 换行符、截断与剩余字符
fgets保留换行符这件事,是被抱怨最多的设计。但从函数的角度想,它其实没得选:它不知道自己读的是配置文件里的一行、网络协议的一行,还是用户随手敲的一句话。保留换行符等于保留了"这一行到这里结束了"的信息,调用者想删就删;如果它擅自丢掉,调用者反倒无法还原。这是信息保留原则的体现。
去掉末尾换行符,我常用的写法有两种,第一种是手动检查:
size_t len = strlen(buf); if (len > 0 && buf[len - 1] == '\n') { buf[len - 1] = '\0'; }第二种更简洁,用strcspn:
buf[strcspn(buf, "\n")] = '\0';strcspn返回的是从开头起连续不属于"\n"的字符个数,也就是第一个换行符的下标;如果没有换行符,它返回字符串长度,正好指向结尾的'\0',把'\0'赋成'\0'等于没做事。一行代码覆盖两种情况,实测下来很稳。不过要注意,strcspn遇到'\0'也会停下,所以只适合处理文本行。
比换行符更容易出事的是截断。假设缓冲区只有 16 字节,输入却有两百多个字符,fgets会读走 15 个字符,剩下的一百多个还老老实实躺在输入流里。如果你的代码接着调用一次fgets,它会立刻读到上次剩下的一大坨,看起来就像"凭空多出来一行"。这在交互式程序里表现为菜单错乱、循环多跑几轮,在文件解析里表现为读到了一堆碎片。
判断有没有读完一整行,靠的是检查缓冲区里有没有'\n':
if (strchr(buf, '\n') == NULL) { /* 这一行没读完,流里还有剩余数据 */ int c; while ((c = getchar()) != '\n' && c != EOF) { /* 丢掉剩余字符 */ } }3.3 返回值和 EOF 的正确判断
fgets的返回值只有两种情况:成功返回目标缓冲区指针(也就是你传进去的那个s),遇到文件结束或者读取出错时返回NULL。注意它不会返回流里剩余的字符数,这一点跟getline不一样。
所以在循环里读文件的标准写法是:
char line[256]; while (fgets(line, sizeof(line), stdin) != NULL) { /* 处理这一行 */ }这里有个值得注意的细节:fgets返回NULL并不代表一定到了文件末尾,也可能是读取过程中出了错。想区分这两种情况,得在循环结束后查feof和ferror:
if (feof(stdin)) { printf("正常读到文件末尾\n"); } else if (ferror(stdin)) { perror("读取失败"); }日常写个小工具,很多人直接忽略这一步;但如果是处理重要数据的程序,这个区分是必要的,不然"因为磁盘错误少读了一半数据"和"文件本来就这么多"你会分不清。另外,feof只有在尝试读并失败之后才会返回真,千万不要写成while (!feof(fp))这种模式——那样循环会多跑一轮,最后一行被处理两次,这是很常见的一个坑。
3.4 从标准输入读和从文件读的差别
fgets可以从任意流读,但不同流的"行"概念并不完全一样。从stdin读的时候,终端通常处于行缓冲模式,用户敲回车之前程序拿不到数据,这是终端驱动的工作方式,不是fgets的问题。而从普通文件读的时候,fgets就是老老实实按'\n'切分,没有缓冲模式这回事。
还有一个容易忽略的点:行尾符的具体字节。在 Unix 系文本文件里,行尾是单个'\n'(0x0A);如果文件来自某些其他平台,行尾可能是'\r'加'\n'两个字节。用fgets读这样的文件,缓冲区末尾会是'\r'和'\n'连着出现。如果你的代码只删掉'\n',那个'\r'会留在字符串里,导致字符串比较失败、数字转换出问题。处理方式要么是在去换行时同时处理两种字符,要么在读入之后统一做一次清洗:
size_t len = strlen(buf); while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r')) { buf[--len] = '\0'; }这个循环写法比if稳,因为它能处理"\r\n"这种两个字符连在一起的情况,从后往前一个一个削掉。
4. 实战:把输入读取封装成可靠工具
4.1 清空行缓冲的两种写法
前面提到超长行会留下残余数据,清空的办法有两种,用起来差别不小。
第一种是逐字符丢弃:
int c; while ((c = getchar()) != '\n' && c != EOF) { ; /* 空循环体,纯粹消费字符 */ }这种写法简单直接,不依赖缓冲区大小,适用于输入量不大的场景。缺点是字符多的时候要循环很多次,效率一般。另外要注意把EOF一起判断,否则输入被重定向到一个没有换行结尾的文件时,这个循环会一直转到文件尾才停。
第二种是直接丢弃整行:
char waste[128]; fgets(waste, sizeof(waste), stdin);但这只在"剩余数据不超过 127 字节"时有效,否则一次调用清不干净,还得再套一层循环。所以更稳妥的写法是循环调用fgets直到读到换行:
char waste[256]; while (strchr(waste, '\n') == NULL && fgets(waste, sizeof(waste), stdin) != NULL) { ; }两种方法我都用过,实际项目里我倾向于第一种,因为它不占额外的栈空间,逻辑也更好读。只有在输入行长本来就很大的场景(比如读取某些日志行)才会考虑第二种。
4.2 封装一个自己的 read_line
把上面这些细节收进一个函数,调用方就清爽了。下面这个版本我用了很久,处理了换行符、截断、EOF 三种情况:
#include <stdio.h> #include <string.h> /* * 从 stream 读一行到 buf。 * 返回 1 表示成功读到一行,0 表示到达文件末尾或出错。 * 成功时 buf 中不含行尾换行符。 */ int read_line(char *buf, int size, FILE *stream) { if (buf == NULL || size <= 0) { return 0; } if (fgets(buf, size, stream) == NULL) { buf[0] = '\0'; return 0; } /* 去掉行尾的 \n 或 \r\n */ size_t len = strlen(buf); while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r')) { buf[--len] = '\0'; } /* 如果这一行没读完,把剩余字符丢掉 */ if (len == (size_t)(size - 1)) { int c; int hit_newline = 0; while ((c = fgetc(stream)) != EOF) { if (c == '\n') { hit_newline = 1; break; } } (void)hit_newline; } return 1; }这段代码里有几个设计决定值得说明。第一,返回值用 1 和 0 而不是指针,因为调用方真正关心的是"有没有读到东西",指针本身没什么用。第二,函数内部用fgetc而不是getchar来丢弃剩余字符,因为参数是stream,这样从文件读的时候丢弃的也是文件里的剩余,逻辑统一。第三,判断"是否读满"用的是len == size - 1,而不是检查strchr(buf, '\n'),原因是如果文件恰好以'\n'结尾并且这行刚好占满缓冲区,strchr会找到那个换行但随后被删掉了,用长度判断更直接。
注意:
size参数用int是为了跟fgets的原型保持一致。虽然缓冲区大小理论上可能超过INT_MAX,但那种规模的栈缓冲区本身就不现实,用int够用且不容易出隐式转换的问题。
4.3 和 scanf 混用时的那点事
scanf和fgets混用是新手翻车率最高的场景之一。原因在于scanf的格式转换在做完之后,会把第一个不属于该格式的字符留在输入流里。最常见的就是读数字的时候,scanf("%d", &n)读走了数字,但用户敲的那个回车还留在流里。紧接着调用fgets,它一看流里有个'\n',立刻"读到一行",于是你拿到一个空字符串。
int n; char name[32]; printf("请输入年龄: "); scanf("%d", &n); /* 回车留在了流里 */ printf("请输入姓名: "); fgets(name, sizeof(name), stdin); /* 读到一个空行 */ printf("姓名: %s\n", name); /* 输出空 */解决办法有三个。最简单的混用方案是先用getchar()吃掉那个回车,但这只在确定只有一个残留字符时管用。更通用的办法是调用前面那套清空缓冲区的逻辑。最省心的办法是干脆别混用——数字也用fgets读进来,再用strtol或sscanf转换:
char line[64]; int n; if (read_line(line, sizeof(line), stdin)) { char *end = NULL; long v = strtol(line, &end, 10); if (end != line && *end == '\0') { n = (int)v; } }这套流程看起来比一句scanf啰嗦,但它把所有输入都当成字符串处理,边界统一,不会出现"流里莫名其妙多出个字符"的情况。我现在写任何需要用户反复输入的小工具,都走这条路。
至于用scanf("%s")读字符串,本质上跟gets是同一种毛病——没有长度限制。如果非要用,可以加宽度限定:scanf("%31s", name),31 对应char name[32]的容量减一。但这个宽度必须写死在格式串里,改数组长度的时候容易漏改,属于那种"当时能跑、以后会炸"的写法。
4.4 读取超长行的完整方案
有些场景就是不知道行有多长,比如解析用户粘贴过来的一大段文本、读取日志里被拼成长串的一行。这时候固定缓冲区再怎么加大都不保险,得用"读一段、存一段、动态扩容"的方式。
#include <stdio.h> #include <stdlib.h> #include <string.h> /* * 读取一整行,自动扩容。 * 返回 malloc 出来的字符串,调用方负责 free。 * 失败返回 NULL。 */ char *read_long_line(FILE *stream) { size_t cap = 128; size_t len = 0; char *buf = malloc(cap); if (buf == NULL) { return NULL; } int c; while ((c = fgetc(stream)) != EOF) { if (len + 1 >= cap) { size_t new_cap = cap * 2; char *tmp = realloc(buf, new_cap); if (tmp == NULL) { free(buf); return NULL; } buf = tmp; cap = new_cap; } if (c == '\n') { break; } buf[len++] = (char)c; } if (len == 0 && c == EOF) { free(buf); return NULL; } buf[len] = '\0'; return buf; }这段代码的核心思路是:容量不够就翻倍,每次读一个字符进缓冲区。翻倍扩容(而不是每次加固定长度)是为了摊平realloc的次数,均摊下来每次追加操作是常数时间。realloc失败时一定要先把原来的指针保存好再释放,直接用buf = realloc(buf, new_cap)的写法在失败时会丢掉原指针,造成内存泄漏,这个坑我在早期代码里踩过。
如果你的目标平台支持 POSIX,那还有个更省事的办法:直接用getline。
char *line = NULL; size_t cap = 0; ssize_t nread; while ((nread = getline(&line, &cap, stdin)) != -1) { /* line 里包含换行符,nread 是读到的字节数 */ } free(line);getline会自动分配和扩容,返回读到的字节数,出错或到末尾返回 -1。它保留换行符,所以该删还是要删。要注意它是 POSIX 2008 引入的,不是 C 标准库的一部分,跨平台项目里用之前得确认目标环境支持。另外它分配的缓冲区要用free释放,别用delete或者忘了释放。
5. 常见问题与排查实录
5.1 一张速查表
下面这些是我和同事在实际项目里反复遇到的问题,整理成表方便对照:
| 现象 | 常见原因 | 处理方式 |
|---|---|---|
| 字符串比较永远不相等 | fgets保留了末尾换行符 | 读到后去掉\n和\r |
| 程序莫名崩溃或打印乱码 | 用了gets或scanf("%s")无长度限制 | 改用fgets加长度参数 |
fgets读到空字符串 | 前一次scanf留下了换行符 | 清空输入流或统一用fgets读 |
| 第 N 次循环读到了"多出来的一行" | 上一行超长被截断,残余还在流里 | 检测未读到换行时清空流 |
| 文件最后一行被处理两次 | 用了while (!feof(fp))模式 | 改成判断fgets返回值 |
| 函数里读到的数据总是很短 | sizeof(指针)拿不到数组长度 | 显式把缓冲区大小传进函数 |
| 数字解析总失败 | 字符串里带了\r或前导空白 | 先去行尾符,再用strtol并检查end |
| 读到二进制数据后半段丢失 | fgets遇到'\0'后strlen截断 | 改用fread并记录返回字节数 |
5.2 几个只有踩过才知道的细节
第一个细节关于fgets的n等于 1。如果你传了fgets(buf, 1, stdin),函数只会往buf[0]写一个'\0',什么都不读,也不会消费流里的字符。这个行为在标准里是允许的,但结果就是死循环——你以为在读,其实每次都在原地打转。
第二个细节关于fgets遇到'\0'。它不像gets那样把'\0'当终止符,而是继续读下去。也就是说,如果输入流里含有字节 0,fgets会把它连同后面的内容一起写进缓冲区,然后在末尾再补一个'\0'。可你之后用strlen去量长度,函数会在第一个'\0'处停下,看起来数据"短了一截"。处理二进制数据千万别用fgets配strlen,该用fread,它返回真实读到的字节数,不受内容影响。
第三个细节是关于编译器优化。有些优化等级下,编译器可能把fgets调用合并或者内联,这时候你在调试器里单步会看到跳来跳去。如果排查输入相关的问题,建议先把优化关掉(-O0)再看。
第四个细节是关于缓冲区大小和实际可读字符的关系。char buf[10]最多装 9 个有效字符,因为要留一个位置给'\0'。这个数字关系在写常量的时候很容易搞错,尤其当你在宏里定义长度,又要在别处减一的时候。我的习惯是宏一律定义为"缓冲区总字节数",减一的操作全部由fgets自己完成,不在代码里手动减。
5.3 关于性能的一点补充
有人担心fgets逐个字符读会不会慢。实际上不会,因为fgets内部是在FILE的缓冲区上操作的,不是每读一个字符就调用一次系统调用。真正影响性能的是缓冲区大小和输入规模的比例。如果缓冲区只有 64 字节,而每行平均 1000 字节,那每行要调用两次fgets,多出来的开销主要在调用和字符串操作上。把缓冲区调到 512 或 1024 通常就足够了,没必要一次开几兆的栈空间——栈空间有限,开太大了反而容易栈溢出。
fgets和fread的吞吐差别也值得一提。对于大文件按行处理,fgets的字符串处理开销会比fread配手动扫描换行符略高,但代码可读性和安全性明显更好。除非真的是性能瓶颈点,否则我没必要为了那点差距牺牲可维护性。
6. 同类函数的对比与选型建议
6.1 gets_s 到底能不能用
gets_s是 C11 附录 K 给出的替代品,原型是:
char *gets_s(char *s, rsize_t n);从参数看,它像是给gets补上了长度约束。但它的行为有个容易忽略的坑:如果缓冲区放不下整行,它会丢弃输入流里剩余的字符,并且把s[0]设成'\0'。也就是说,要么读到完整一行,要么什么都没读到,不存在截断这种中间状态。这跟fgets的截断行为差别很大,从fgets迁移过去的时候如果没意识到这点,逻辑会出问题。
更现实的问题是可用性。附录 K 属于可选接口,不是所有编译器都提供。GCC 默认不提供gets_s,你在 Linux 上写这段代码很可能编译不过。所以除非你的目标环境明确支持,否则我不建议把gets_s作为主要方案。它的定位更像是对历史代码的兼容层,而不是新代码的首选。
6.2 getline 与 fread 各自的适用场景
三个函数放在一起看,各自的强项就很清楚了。fgets是通用主力,有固定缓冲区的时候用它最省事,跨平台性也最好。getline适合行长完全不可预测的场景,它能自动扩容,代价是依赖 POSIX 环境并且需要手动释放内存。fread面向二进制和定长记录,它不关心换行,返回实际字节数,读结构化数据(结构体数组、定长记录)时最合适。
选择的时候问自己三个问题就行:这一行最长可能有多长?数据里有没有可能包含'\0'或别的特殊字节?目标平台有哪些接口可用?三个问题答完,选哪个基本就定了。
6.3 我在选型上的习惯
做了这么多年 C 代码,我现在的默认习惯是:所有文本行的读取都走fgets加固定缓冲区,超长行用循环处理,二进制数据一律fread。gets在任何新代码里出现都是不允许的,老代码里遇到就改。getline只在明确知道平台支持并且确实需要时用,因为它引入的内存管理责任会多一层,团队里如果新人多,容易忘记释放。
至于跟scanf的关系,我的做法是把它限制在"解析已知格式的字符串"这个用途上,比如从已经读好的一行里提取几个字段。涉及到直接从流里读用户输入的时候,一律用fgets先读整行,再在内存里解析。这样输入流的状态始终由我掌控,不会出现残留字符导致的诡异行为。
最后分享一个我在实际排查中总结出来的小技巧:当你不确定输入流里到底还剩什么的时候,可以在关键位置加一段调试代码,把流里剩下的内容逐个字节打印出来,标注 ASCII 码值。换行是 10,回车是 13,空格是 32。这个方法看起来笨,但它能在几分钟内把"为什么fgets读到空字符串""为什么数字转换失败"这类问题定位清楚,比盯着代码猜要快得多。我见过太多人花一两个小时在格式串上找问题,最后发现只是流里卡着一个没消费的回车。