恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C语言字符串输入:gets、fgets、scanf对比与安全实践
首页
资讯中心
/
C语言字符串输入:gets、fgets、scanf对比与安全实践
C语言字符串输入:gets、fgets、scanf对比与安全实践
发布时间:2026/10/20 8:16:12
字符串输入这件事写C语言的人天天都会碰但能一次把gets、fgets、scanf这三者讲清楚的可能不多。不少入行一两年的开发者代码里已经不会再用gets了但要问它当年是怎么出问题的、fgets为什么能兜住、scanf在什么场景下反而更好用往往就答不上来。这篇文章就围绕这三种获取字符串的方法把行为差异、性能取舍、安全性问题一条条拆开说。想看结论的可以直接跳到最后一节但建议还是从头过一遍。准备校招面试的、还在用scanf读用户输入的、想把代码写得更稳的都可以对照着检查一遍自己手头的代码。我会从函数原型讲到缓冲区溢出再讲到工程里怎么搭配使用全程给可直接运行的代码示例。1. 三种方法先放在一起看建立整体印象1.1 函数原型与第一印象C语言从标准输入读字符串最常见的就是三种写法char *gets(char *s); char *fgets(char *s, int size, FILE *stream); int scanf(const char *format, ...);gets是最老牌的函数参数只有一个目标缓冲区指针设计意图很简单从标准输入读一整行去掉末尾换行符存到s里成功返回s读到文件结束或出错返回NULL。听着挺方便问题也恰恰出在这个方便上——它完全没有告诉你缓冲区到底有多大也没有任何机制防止写入越界。C11标准已经把它标记为废弃后续新标准直接将其移出标准库。fgets是在gets基础上补出来的更安全版本多出两个参数size表示缓冲区大小stream表示数据来源。从stdin读就是标准输入从文件指针读就是读文件。它最多读入size-1个字符并统一在末尾补上\0理论上杜绝了缓冲区溢出。代价是它会保留行尾的换行符后面需要自己处理。scanf是格式化输入家族的一员%s格式说明符可以读取单词它跳过前导空白然后一直读到下一个空白字符为止。它同样需要你在格式串里写明缓冲区长度比如%19s否则跟gets一样有溢出风险。最典型的区别是scanf(%s, s)永远读不到带空格的字符串。1.2 三分钟看完行为差异表下面这张表把关键差异整理在一起后面每一行都值得展开。维度getsfgetsscanf(%s)读取单位一整行一整行受长度限制一个单词是否限制写入长度不限制危险限制为 size-1需要格式串指定是否保留末尾换行符不保留保留不保留对前导空白直接作为内容直接作为内容跳过对字符串内的空格保留保留丢弃返回结果指针/NULL指针/NULL成功赋值的个数缓冲区溢出风险极高极低取决于格式串现行标准状态已移除正常使用正常使用单看表格结论好像很简单用fgets就完了。实际工程里也确实如此但如果你不清楚fgets保留换行符会带来什么连锁反应不清楚scanf在缓冲区残留问题上的表现后面还是会踩坑。2. 为什么gets被逐出标准缓冲区溢出不是小问题2.1 一个可以复现的崩溃现场先看一段最容易出事的代码#include stdio.h int main(void) { char buf[16]; printf(请输入任意字符串: ); gets(buf); // 演示坏习惯千万别这么写 printf(你输入的是: %s\n, buf); return 0; }假设用户输入了20个字符的字符串比如AAAAAAAAAAAAAAAAAAAA。内存中buf只分配了16字节紧挨着它的还有函数的其他栈数据。gets的逻辑是读到你输入回车为止然后把所有字符连同结尾的\0一起写进缓冲区。20个字符加一个\0共21字节全部从buf起始地址连续写入。前16字节放进了buf剩下5个字节直接写到了buf后面的栈空间。这堆越界数据会破坏什么取决于编译器和栈布局。被覆盖的可能是栈上的其他局部变量也可能是保存的函数返回地址。一旦返回地址被改写成无效地址函数返回时ret指令弹出这个坏地址程序立刻段错误崩溃如果攻击者精心构造这段输入甚至可以把返回地址改成恶意代码的入口也就是经典的栈溢出攻击。我早年用旧编译器还原过这个场景输入一次超长字符串程序报错前还会先打印出一堆乱码。原因就是越界数据污染了临近的栈空间连局部变量的值都变了。工程里一旦出现这种问题排查成本极高而且很难稳定复现。2.2 标准怎么说从C11废弃到彻底移除gets的问题太严重C语言标准委员会在C11标准中正式把它标记为废弃deprecated随后在更新的标准版本里直接将其从标准库移除。也就是说现代C编译器在默认标准模式下遇到gets会直接报错或警告比如常见的提示就是这个函数隐式声明或已删除。替代方案有两个思路。一个是直接用fgets(s, sizeof(s), stdin)这是最通用的做法。另一个是标准里提供了一个叫gets_s的安全版它强制要求传入缓冲区大小读不下就报错。但gets_s作为可选扩展并非所有平台都实现了而且它仍有输入过长时直接放弃这种略显僵硬的行为。实际项目中fgets的使用频率远高于gets_s。现在新写的代码里如果还有人用gets不用怀疑一定是历史遗留或者随手从旧代码抄来的。在代码评审里看到gets第一反应就是打回去换fgets这条经验我沿用至今也建议你执行。2.3 工程上为什么不能用gets技术层面的风险已经够了工程上还有更实际的原因第一编译器都不支持了。用现代编译器和标准头文件编译gets警告信息直接影响CI流程的把关质量。第二gets的契约注定了无法做防御性编程。它是标准函数你没法在中间层拦截唯一的安全措施就是不调用它。第三代码可读性差。不知情的人看到gets会误以为读一整行是安全的默认行为继而复制到其他项目隐患就扩散了。所以结论斩钉截铁实测下来任何情况下都没有不得不使用gets的理由。哪怕只是为了快速写个测试小工具也不值得冒这个险。3. 换行符与空格一段输入三种结果3.1 实测代码和输出代码看得再多不如跑一遍。下面这个程序同时用三种方法尝试读取输入#include stdio.h int main(void) { char a[128]; char b[128]; char c[128]; printf(请输入一行内容: ); gets(a); // 不推荐仅对比演示 printf([gets] - [%s]\n, a); fflush(stdin); // 有些环境需要清理残留后面会专门讲 printf(请输入一行内容: ); fgets(b, sizeof(b), stdin); printf([fgets] - [%s]\n, b); printf(请输入一行内容: ); scanf(%s, c); printf([scanf] - [%s]\n, c); return 0; }如果输入的是hello world在我的环境中输出大致是[gets] - [hello world] [fgets] - [hello world ] [scanf] - [hello]注意到三个关键差异了吗第一gets读到了完整的一行hello world末尾的换行符被丢弃。第二fgets读到的字符串里带着\n所以printf打印出来之后直接换了一行看起来像多了一个空行。第三scanf只读到了hello空格后面的world留在了输入缓冲区里没有被当前调用消费掉。如果输入的是不含空格的hello三者打印出来都是hello唯一的区别还是fgets的字符串末尾会多一个换行符。3.2 为什么fgets要保留换行很多人不理解为什么fgets不学gets把换行符去掉非要留着让程序员手动处理这是设计理念的差异。fgets的本职是通用的从任意文本流读一行它的契约是忠实保留读到的原始字节包括换行符至于你要不要保留这个换行符是你自己的业务逻辑。比如你在解析一个配置文件每行末尾的换行符原本就是文件的一部分解析时应该把行长什么样原样保留由上层决定如何剥离。可以这样类比gets像快递员帮你拆开包装把商品递到手上fgets则把包裹连同包装一起交给你拆不拆是你的事。单看一次输入帮拆似乎方便但想保留完整包装却已经没了的场景同样常见比如刚才说的解析文件、按行统计长度的工具。3.3 scanf为什么读不到带空格的字符串scanf(%s, c)内部遵循一个非常明确的规则读入以空白字符串为分隔符的单词。空格、制表符、换行符在它眼里都是分隔符不是在字符串里保存的字符。所以它可以轻松实现读取一个单词这种需求比如命令行工具里解析一个个参数。代价是你想通过它读取张三 李四这种整行内容时它只会返回张三。有一个相对小众的格式%[^\n]能读到换行符之前的所有字符可以看作scanf版的fgets。但它既不限制长度除非写明比如%20[^\n]又容易在读不到内容时留下奇怪的残留状态工程上我见到用它成功跑稳的项目不多。能用fgets就不用这种花式格式。4. fgets的正确用法不只用对还要处理好后续4.1 读满一行后的换行符处理既然fgets会把换行符带进来多数业务场景里这个\n是需要去掉的。标准处理方式是char line[128]; if (fgets(line, sizeof(line), stdin) ! NULL) { line[strcspn(line, \n)] \0; printf([%s]\n, line); }strcspn(line, \n)返回line中第一次出现换行符的位置下标如果整个字符串里没有换行符则返回字符串长度也就是末尾\0的下标。不管哪种情况把该位置的字符替换成\0都不会产生多余副作用。这个技巧必须安利因为它比下面的写法更稳line[strlen(line) - 1] \0;如果输入恰好是一整行超长内容fgets会截断并直接在末尾放\0此时字符串里根本没有换行符。strlen(line) - 1指向的是最后一个有效字符这一句会把你辛辛苦苦读到的最后一个字符丢掉。而strcspn版本遇到没有换行符的情况只是把原有的\0再赋值一次完全不影响内容。4.2 空行和EOF的处理细节fgets还有一个容易误判的点它读到空行时返回的字符串内容是\n也就是一个换行符加一个\0注意不是空字符串。很多新手第一次处理文件行时都会在这里愣一下明明是个空行怎么读出来不是空的原因正是fgets保留了换行。你需要判断的是line[0] \n而不是strlen(line) 0。另外fgets返回NULL有两种情况输入流到达文件末尾或者读取过程中发生错误。区分这两者的方式是检查错误标志if (fgets(line, sizeof(line), stdin) NULL) { if (ferror(stdin)) { perror(读取失败); } else { printf(正常结束\n); } }日常交互程序中fgets返回NULL最常见的原因就是用户按了EOF快捷键Unix/Linux下是CtrlDWindows下是CtrlZ加回车。你的程序必须把这种情况当成正常结束来处理否则代码可能陷入死循环。4.3 fgets加sscanf工程中最常用的输入范式fgets解决了安全地读一整行的问题但它返回的是纯文本你要数字、要结构化的字段还得再解析一步。这时把fgets和sscanf组合起来用是工程里最顺手的范式#include stdio.h #include string.h int main(void) { char line[256]; int id; char name[64]; printf(请输入 id 和姓名空格分隔: ); if (fgets(line, sizeof(line), stdin) NULL) { printf(未读取到输入\n); return 1; } int result sscanf(line, %d %63s, id, name); if (result 2) { printf(解析成功: id%d, name%s\n, id, name); } else { printf(解析失败请按格式输入\n); } return 0; }这个组合比scanf直接读标准输入稳定的多。首先fgets一次性把整行读进缓冲区行读完之后这一行数据就变成一个普通字符串想在怎么解析就怎么解析解析失败还可以回退、重试甚至打印出原始输入帮助调试。其次sscanf不会再去动标准输入缓冲区不会把用户后续的输入搞乱。%63s这种写法中的长度限制是为了防止name只有64字节时读入过长数据越界。sscanf同样存在溢出风险但格式串里写明长度后风险就可控了。5. scanf到底哪里不好三种典型坑5.1 坑一不能读带空格的字符串前面已经演示过scanf(%s, c)遇到空格直接停。很多从Python转过来写C的人特别容易在这里栽跟头——Python的input()和scanf完全不同它行为上更接近gets读一整行去掉换行符而C的scanf是分词器不是行读取器。想要scanf读一个带空格的整行只能用%[^\n]这种字符扫描集格式。但字符扫描集一旦加上了它不会像普通%s那样自动跳过前导空白而且前面提到过它同样需要手动指定长度。你看为了达到读一整行这个目的scanf的使用成本已经远超fgets了。5.2 坑二缓冲区残留问题最经典的现象先scanf(%d)再fgets会发现后面这个fgets根本没有等待用户输入直接读到了一个换行符。演示代码#include stdio.h int main(void) { int n; char s[100]; printf(请输入一个整数: ); scanf(%d, n); // 输入 42 后回车 printf(请输入一行字符串: ); fgets(s, sizeof(s), stdin); // 没有等待输入直接返回 printf(n%d, s[%s]\n, n, s); return 0; }原因很简单scanf按格式读取时数字42被消费掉了但紧随其后的那个回车键产生的换行符还在输入缓冲区里。接着fgets一连读到换行符就立即返回整个字符串内容就是一个\n。程序看起来就像跳过了第二次输入。规避办法有两个。第一个是用循环把缓冲区的换行残留读掉while (getchar() ! \n);这个循环会接收并丢弃输入缓冲区里直到第一个换行符为止的全部字符。第二个是一劳永逸的方案也就是用fgets统一读行再用sscanf解析而不是直接混用scanf和fgets。我基本只用后者因为业务逻辑里输入源一旦统一就不会再出现清没清干净的焦虑。5.3 坑三格式串与类型不匹配这一坑更多是新手级的但项目里也见过有人犯。常见的有int n; scanf(%s, n); // 格式要求字符串却传给 int 变量 char buf[16]; scanf(%s, buf); // 没写长度限制长输入直接溢出第一种写法行为未定义轻则写入越界重则破坏函数栈。第二种写法是经典的溢出隐患现代编译器会输出警告但很多人会选择无视。我建议把scanf的使用场景收得很窄只在清楚了解格式控制、并且能保证长度安全时才碰它。凡是涉及读一行用户输入再解析一律走fgetssscanf。实测下来这可以消灭90%的输入类bug。6. 问题速查表与我的最终建议6.1 常见问题速查表把上面提到的问题归拢成速查表随时可以回来翻现象根因解决方案输入超长内容程序崩溃gets无边界写入换成fgets并限制大小打印结果末尾多一行空行fgets保留了换行符用strcspn去掉\n输入hello world只读到helloscanf按空白分段读取改用fgets或写%[^\n]先scanf后fgets第二次输入被跳过缓冲残留换行符统一用fgetssscanf空行读出来不是空字符串fgets返回\n判断line[0]\nfgets返回NULL以为出错了实际是EOF结合ferror判断原因输入一个单词时scanf似乎正常因为没碰到空格长期一致性考虑仍推荐fgets6.2 五条铁律我个人多年的经验最终沉淀成这五条选择原则gets任何情况下都不写看到就改。读行、读用户输入、读配置文件一律默认fgets。需要格式化解析时fgets读行再用sscanf解析。只有明确要按空白符切分单词的场景才用scanf且务必限制长度。新增代码优先保证边界可控而不是图省事。6.3 一点个人体会三者的区别看起来很琐碎但每次出问题都出在我以为它这样读的想当然上。我自己早期写的一个小工具就是混合用了scanf和fgets结果用户一输入就跳过一行排查了两天才意识到是缓冲区残留问题。从那以后所有交互式输入代码全部统一成fgetssscanf。这里再分享最后一个技巧要处理输入输出时我会先把每次输入打印出来核对哪怕只是临时调试printf([debug] 读到: %s\n, line);别小看这一行。字符串输入里很多坑根源都是实际读到的内容和你想的完全不一样。先把读到的东西看清楚再去分析是换行问题、空格问题还是残留问题排查速度快一倍。用gets、fgets、scanf这么多年我对它们的建议就一句话宁可用起来麻烦一点也要让每一条输入都被明明白白地掌控在手里。