C语言字符串读取:gets与fgets差异、风险与安全实践

发布时间:2026/10/1 13:29:52
C语言字符串读取:gets与fgets差异、风险与安全实践 写 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 一张表看清两者的行为差异把两者的行为细节摆在一起对比很多坑一眼就能看出来对比维度getsfgets长度参数无有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这条警告会直接变成错误编译都过不去。实操提示可以把-Werrorimplicit-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; }第二种更简洁用strcspnbuf[strcspn(buf, \n)] \0;strcspn返回的是从开头起连续不属于\n的字符个数也就是第一个换行符的下标如果没有换行符它返回字符串长度正好指向结尾的\0把\0赋成\0等于没做事。一行代码覆盖两种情况实测下来很稳。不过要注意strcspn遇到\0也会停下所以只适合处理文本行。比换行符更容易出事的是截断。假设缓冲区只有 16 字节输入却有两百多个字符fgets会读走 15 个字符剩下的一百多个还老老实实躺在输入流里。如果你的代码接着调用一次fgets它会立刻读到上次剩下的一大坨看起来就像凭空多出来一行。这在交互式程序里表现为菜单错乱、循环多跑几轮在文件解析里表现为读到了一堆碎片。判断有没有读完一整行靠的是检查缓冲区里有没有\nif (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和ferrorif (feof(stdin)) { printf(正常读到文件末尾\n); } else if (ferror(stdin)) { perror(读取失败); }日常写个小工具很多人直接忽略这一步但如果是处理重要数据的程序这个区分是必要的不然因为磁盘错误少读了一半数据和文件本来就这么多你会分不清。另外feof只有在尝试读并失败之后才会返回真千万不要写成while (!feof(fp))这种模式——那样循环会多跑一轮最后一行被处理两次这是很常见的一个坑。3.4 从标准输入读和从文件读的差别fgets可以从任意流读但不同流的行概念并不完全一样。从stdin读的时候终端通常处于行缓冲模式用户敲回车之前程序拿不到数据这是终端驱动的工作方式不是fgets的问题。而从普通文件读的时候fgets就是老老实实按\n切分没有缓冲模式这回事。还有一个容易忽略的点行尾符的具体字节。在 Unix 系文本文件里行尾是单个\n0x0A如果文件来自某些其他平台行尾可能是\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当终止符而是继续读下去。也就是说如果输入流里含有字节 0fgets会把它连同后面的内容一起写进缓冲区然后在末尾再补一个\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读到空字符串为什么数字转换失败这类问题定位清楚比盯着代码猜要快得多。我见过太多人花一两个小时在格式串上找问题最后发现只是流里卡着一个没消费的回车。