
学C语言的人接触到的第一个函数多半是printf紧接着就是scanf。这两个函数在教材里常年被放在前五章讲法也简单回车一次、输出一串看起来人畜无害。可恰恰是这种“人畜无害”的错觉让它们成了真实代码里最容易被忽视的入口。我这些年看过不少项目也帮人排查过各种怪问题很大一部分bug最后都能追溯回一行scanf或者printf的用法上。这篇就专门讲输入输出函数里那些容易被忽略的细节重点围绕scanf和printf展开也会带上fgets、sscanf、putchar这些经常一起搭配的函数。适合刚学完C语言基础、想写真实小程序的人也适合在企业项目里维护过老代码、时不时被输入输出坑一把的开发者。1. scanf远没有表面那么“无害”1.1 写代码的第一件事确认scanf到底成没成功很多人刚开始写scanf都是照着书敲一行就完事完全不看返回值。可实际上scanf的返回值是它最有用的信息之一。它返回的是“成功匹配并赋值的输入项数量”如果输入与格式串对不上或者提前碰到了文件结束符返回值就会小于你期望的数目甚至是EOF。比如下面这种写法int n; int ret scanf(%d, n); if (ret 1) { // 成功读到一个整数 } else if (ret EOF) { // 输入流已经关闭读不到数据 } else { // 用户输入了不是一个合法整数的东西 }别小看这个判断。如果不写用户输入一个“abc”回车scanf只会在缓冲区里看到字母a由于它不符合%d的预期函数立刻返回0而n根本不会被赋值。这时候你接着用n继续算程序行为全凭运气因为n的值取决于栈上残留的旧数据有些编译器甚至会给你初始化成0有些则是随机值。排查这种问题相当费劲因为它不是稳定复现的。写循环的时候返回值更是关键。最常见的标准输入循环写法是int n; while (scanf(%d, n) 1) { // 每读到一个整数处理一次 }这个循环会在读不到合法整数或者遇到EOF的时候自然退出。很多初学教材喜欢用while(!feof(stdin))但feof的判断时机经常滞后往往是最后一次读取已经失败之后循环体还在多跑一轮然后就拿到一个无效值。直接用scanf的返回值来判断逻辑要清晰得多。多参数情况也是一样的道理。比如同时读年龄和分数int age; double score; int r scanf(%d%lf, age, score);如果用户只输入了一个数字就回车r返回1age有值score没被赋值。如果你不检查后面拿score计算平均分就是拿脏数据在算。我自己的习惯是只要输入不是一次性成功宁可重新提示用户输入也不要赌输入流的正确性。1.2 残留缓冲区为什么读完数字再读字符总翻车这是C语言输入函数里最著名的坑没有之一。很多初学者写交互式菜单程序时会这样写int num; char op; printf(请输入数字); scanf(%d, num); printf(请输入操作符); scanf(%c, op);跑起来之后发现第二个scanf根本没有等待用户输入程序一下就跳过去了。等打印op的值出来看往往是换行符的ASCII码10。原因在于scanf读取输入时是面向“流”的它从缓冲区里取数据上一次调用停在哪里下一次就从哪里继续。%d会先跳过输入流里的空白字符比如空格、制表符、换行然后一直读到连续的数字并且在遇到第一个非数字字符时停下来。这个“停下来”的位置往往就是数字后面的回车。回车没有地方去就留在缓冲区里。接着%c不会跳过任何空白字符它直接把缓冲区里的第一个字符取走于是拿到的是换行符。解决办法也简单最常见的是在%c前面加一个空格scanf( %c, op);格式串里的空格作用就是让scanf先跳过任意数量的空白字符再做匹配。这是改起来最干净、可读性最高的写法。如果是清掉一整行残留可以用这个循环int c; while ((c getchar()) ! \n c ! EOF) ;这个循环会把当前位置到换行符之间的字符全部读走并且丢掉。注意一定要判断EOF否则当输入流被重定向到文件、文件已经读完时getchar一直返回EOF循环就永远跳不出去变成一个死循环。同样的坑在混合使用scanf和fgets时也会发作。比如先scanf读一个整数再用fgets读一行字符串fgets不会跳过缓冲区的换行它会把换行符当成一整行读走。所以实际读取的line是空的。要避免这种问题要么在scanf之后主动清掉缓冲区要么就用fgets统一读取再去解析。1.3 读字符串时不做长度限制等于主动开门请缓冲区溢出scanf的%s看起来省事其实特别危险char name[16]; scanf(%s, name);用户要是输入一个长度超过16字节的字符串%s会不紧不慢地一直写下去直到把缓冲区越界填满。结果是后面栈上的其他变量被覆盖程序表现各种诡异要么字符串打印得乱七八糟要么函数返回的时候跳到错误地址要么直接崩溃。这类问题在C语言里属于典型的未定义行为也是老生常谈的缓冲区溢出漏洞。如果一定要用scanf读字符串正确做法是带上宽度限制。宽度精确指定允许接收的最大字符数不包含结尾的\0char buf[16]; scanf(%15s, buf);这样scanf最多读入15个字符第16个字节留给\0。注意宽度必须比数组长度小1这个1的余量是对付字符串终结符的。%s读到空白字符就停所以它没法读包含空格的字符串。要想读一整行除了用fgets也可以用%[^\n]意思是“读所有不是换行符的字符”scanf(%[^\n], line);但这样写同样有溢出风险而且换行符本身不会被消费掉下一次读取还得小心处理。比来比去复杂输入还是fgets更稳。后面会有专门一节讲fgets和scanf怎么取舍。2. printf的格式化能力不只是“打印”2.1 变参函数里的类型暗号printf是可变参数函数它不知道参数的真实类型只能靠格式串里的占位符来推断。也就是说格式串写错程序员自己也知道错了但编译期不一定能拦住运行时输出的就是垃圾值。这里有一个很多教程没讲透的点printf的占位符和scanf的占位符类型对应关系不完全一样。因为printf拿到的是值并且float在可变参数里会被自动提升成double而scanf拿到的是指针必须精确指向某种类型。几个常见对应关系我先列成一张表类型printf用法scanf用法备注int%d%d最常用long%ld%ld32位long在Windows和64位Linux上宽度不同long long%lld%lld大整数用这个float%f%fscanf必须指向floatdouble%f也可以%lf老代码里printf用%f更多char%c%c注意缓冲区残留字符串%s%sscanf不读空格地址%p%p打印指针时最规范典型错误是scanf用%d读long long或者用%lf配了一个float指针。前者只写入4字节高4字节不动后者会往4字节的变量里写8字节直接越界破坏相邻内存。这类问题不崩溃就算运气好崩溃了往往指向的错误位置和真实位置隔了十万八千里非常难查。另外printf的格式串和实参数量不匹配也会引发未定义行为。比如printf(%d %d, 1);第二个%d会读取一个根本不存在的参数输出什么完全看栈上的随机残留。这个问题在C语言里不会像Python那种强类型语言一样抛异常而是肉眼可见的乱码。2.2 宽度、精度、标志位组合起来把printf当排版工具用很多人用printf只停留在%d、%s其实它的格式化能力非常强组合起来可以做对齐、补零、限制小数位这些排版操作。这几个参数在写命令行工具、生成日志、输出表格时特别有用。先看宽度。%5d表示输出占5个字符宽度默认右对齐printf(|%5d|\n, 42); // | 42| printf(|%-5d|\n, 42); // |42 | printf(|%05d|\n, 42); // |00042|-号表示左对齐0表示用零填充而不是空格。日志里打印序号、金额这些列对齐之后整个输出会清爽很多。再看精度。精度在%d和%f上含义不同。对整数来说精度表示最少输出的数字位数对浮点数来说精度表示小数点后保留几位printf(%.2f\n, 3.14159); // 3.14 printf(%10.2f\n, 3.14159); // 3.14 printf(%-10.2f|\n, 3.14159); // 3.14 |总有C初学者问“用printf输出4位小数怎么弄”答案就是printf(%.4f\n, x);。不过要注意无论输出几位浮点数本身能表示的精度是有限的超出浮点本身的精度时后面打印出来的位数只是近似值别指望它恢复出原始数据。动态指定宽度也是printf的一个隐藏技能用*占位int width 8; printf(%*d\n, width, 42); // 相当于 printf(%8d\n, 42);这在生成报表、根据运行时条件调整列宽时很好用。另外还有%#x输出带0x前缀的十六进制、%d强制输出正号、% d用空格代替正号这些变体。平时接触不多但需要用的时候查书不如直接记住这几个效率高得多。2.3 printf中文乱码不是printf的错是编码链条断了printf中文乱码几乎是每个在Windows上用VSCode学C语言的人都会撞上的问题。明明源码里写的是中文编译也没报错一跑起来控制台输出的全是“涓夊鍒”之类的乱码。根因其实不在printf而在“源文件编码”和“终端代码页”没对上。比如VSCode默认用UTF-8保存源码源码里的中文字符串就以UTF-8字节序列存进可执行文件。而Windows的命令提示符窗口在旧版本上默认还是GBK代码页936从一个编码写到另一个编码每个汉字的分界都对不上自然全是乱码。解决路子有几条任选其一都能缓解。第一统一编码。把源文件和终端全部切到UTF-8在Windows 10以上的系统里可以先执行chcp 65001切换控制台代码页再运行程序。这个方案最贴近现代习惯但老控制台对UTF-8支持不稳定偶尔会出现刷新问题。第二在程序里主动设置控制台代码页#include windows.h int main(void) { SetConsoleOutputCP(CP_UTF8); // 其他代码 }这个写法在Windows下有效基本能治好现代版Windows控制台的输出乱码。第三Linux和macOS下终端默认就是UTF-8一般不会出现这种乱码所以很多人第一次在Linux上编译运行同样的代码会惊讶地发现不乱了。除了中文还有一种乱码是因为格式串写错比如%c却传了一个中文字符串或者把char当作整数打印。排障的时候先分清是编码问题还是格式问题别一上来就怀疑编译器。3. scanf和fgets怎么选不是随便决定的3.1 一个擅长按“词”读一个擅长按“行”读scanf和fgets针对的是完全不同的场景。scanf本质上是格式化读取它擅长从输入流里按空白分割提取出一个个字段fgets则是纯粹的按行读取它把一整行内容放入缓冲区包括末尾的换行符。这个差异决定了它们的匹配场景完全不同。我用一个表格把这两兄弟放在一起对比比较项scanf(%s)fgets(buf, size, stdin)读取单位单个空白分隔的字段整行缓冲区安全必须手动限宽有size限制一般安全是否跳过空白会不会末尾换行符不读入会带进字符串与%d混用的坑缓冲区残留残留换行导致fgets拿空行典型用途读取数字、单词读取整行、包含空格的字符串从这几点就能看出一个基本规律如果你要的是数字和简单单词用scanf顺手如果你要读一整行带空格的文本比如用户输入命令、地址、句子那fgets才是正确工具。fgets的size参数是它最值得信任的地方。它最多读取size-1个字符剩下的空间给\0。缓冲区永远不会被写爆这也是它能在现代C代码里取代gets和裸奔%s的原因。注意如果输入行比缓冲区还长fgets只读走前半段剩下的还留在输入流里下一轮读取还会看到那半行。遇到这种情况需要有意识地清空剩余输入否则可能逻辑错乱。3.2 fgets加sscanf读取解析两不误fgets只管把行读进来不负责解析。真正好用的组合是用fgets拿一行再用sscanf从这行字符串里按格式提取字段。这样既能利用fgets的缓冲区安全又能享受scanf的格式化能力。举个例子想读一个整数后面跟着一个字符串而且字符串可能含空格char line[256]; int id; char name[128]; if (fgets(line, sizeof(line), stdin) NULL) { // 读到EOF直接结束 return 1; } if (sscanf(line, %d %127[^\n], id, name) 2) { // 解析成功 } else { // 格式不对整行无效 }这里%127[^\n]读的是“除换行符以外的所有字符”最多127个字符。sscanf的失败不会影响输入缓冲区因为字符串已经完整读进来了之后还可以尝试第二次解析。这种“先整行吸收进来再慢慢处理”的思路比scanf直接怼标准输入好排错太多。实战里我还遇到过一种情况程序希望读取多行配置每行格式是keyvalue。用fgets逐行读然后用sscanf解析char key[32]; int value; if (sscanf(line, %31[^]%d, key, value) 2) { // 处理key/value }这个套路在写配置解析器、命令行交互工具时都是完全可以照抄的模板。核心思路只有一个不要让scanf在“怎么读”和“读什么”上同时负责任读的任务交给fgets解析的任务交给sscanf责任分离之后思路会清晰很多。3.3 传出漏洞不要拿用户输入直接当格式串printf的工作原理本身就是解释执行格式串这意味着格式串里%后面的内容具有反直觉的“代码”效果。如果程序直接把用户输入当作printf的第一个参数就会被利用这叫格式化字符串漏洞。举个例子用户输入了一个字符串程序这样打印char buf[100]; fgets(buf, sizeof(buf), stdin); printf(buf); // 错误示范如果用户输入的是“hello”输出自然是hello。但如果用户输入的是%s%s%s%s%s%s%s%sprintf就会认为格式串要求好几个字符串参数可实际上一个参数都没传。它就会从栈上读取并不属于它的内存内容要么输出大量栈里的垃圾数据要么直接访问非法地址导致崩溃。恶意一点的操作还能通过格式化串往内存里写数据那就不仅仅是崩溃问题了。正确写法永远是给一个确定的格式串让用户输入只作为参数printf(%s, buf);这个原则不只在printf这里成立写日志、输出错误信息时也一样。任何“把运行时拼接出来的字符串放进格式串”的操作都要先敲响警钟。工程上可以配合静态检查工具很多编译器在开-Wformat-security警告时也会提醒这种代码有风险。4. 工程实战嵌入式输出和输入卫生4.1 单片机上printf没输出多半是没把stdout接到串口嵌入式开发里有一类问题特别经典明明代码写了printf(hello\r\n)可串口调试助手什么也不显示还以为是printf坏了。其实在裸机环境里printf默认依赖底层fputc而这个函数并没有现成的实现或者它默认向某个并不存在的标准输出端写字符。解决办法就是告诉printf往哪里输出这叫重定向。在大多数基于ARM的工程里做法是实现一个fputcint fputc(int ch, FILE *file) { // 这里把ch通过串口发送出去 while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET) ; USART_SendData(USART1, (uint8_t)ch); return ch; }实现之后printf内部的格式化过程照常进行最终会把一个个字符交给fputc再由fputc送到UART的发送寄存器。于是在调试终端就能看到完整的printf输出。在IAR、Keil这类IDE里有的还需要额外开启“MicroLib”或使用重定向宏这跟编译器的运行库实现有关。具体细节按编译器版本找对应文档但核心永远是这个fputc重定向。同样scanf在嵌入式里也可以重定向到串口接收需要的则是抽象出一个读取底层数据的函数。很多调试代码只重定向输出因为单片机主要做数据采集调试主要看它说什么但偶尔需要往终端输入命令控制单片机行为那就得把输出和输入一起处理。4.2 printf打印不出来先想想缓冲刷没刷普通PC环境里printf后面如果不加换行符终端上也可能看不到输出这也是标准库缓冲机制在起作用。标准库内部给stdout维护了一块缓冲区当stdout连接到终端时通常是行缓冲遇到换行符就自动刷新当stdout被重定向到文件或者不标准的设备时可能变成全缓冲缓冲区满了才统一写一次。嵌入式重定向到串口以后很容易踩这个坑。你在代码里顺序写了printf(start...); for (int i 0; i 100000; i) task(); printf(done\r\n);如果第一个printf没有换行而底层串口又没等到缓冲区刷新你可能什么都看不到直到done\r\n出现一行“start...done”才一起飞出来。这不是你的代码逻辑有问题而是输出被缓冲住了。要解决最简单也是最常用的办法是在关键输出后面加换行符或者在需要立即输出的地方强制刷新fflush(stdout);如果希望拿了输出就立刻往上送不依赖缓冲策略可以在初始化时把stdout设置成无缓冲模式setvbuf(stdout, NULL, _IONBF, 0);这样每次printf调用都会立即刷新适合调试阶段用。缺点是频繁读写底层硬件性能会受到明显影响正式版本里要慎重。4.3 工程里输入输出代码的卫生习惯写业务代码和写课程作业最大的区别就是没人保证用户的输入一定是格式正确的。一个健壮的程序必须把输入输出这部分当成“外部接口”来对待。我后来总结出几条原则每条都是用成本换来的第一读入必查返回值。不管是scanf还是fread只要函数带了返回值就说明它有失败的可能。不检查返回值就开始用变量是把未定义行为直接放进了你的代码。第二不混用行读取和字符读取除非你非常清楚缓冲区现在的状态。最经典的组合坑就是先%d再%c解决办法是格式串里显式加空格或者用getchar把换行吃掉。第三字符串输入一律带长度限制。scanf用%15ssscanf用%127[^\n]fgets用sizeof目的都是防止缓冲区溢出。哪怕从理论上讲输入长度不会太长也不要把长度设置在代码之外一个用户的回车键可以击穿你所有的假设。第四编译时打开常见警告。-Wall -Wextra -Wformat这些编译选项能帮你发现相当一部分格式串和参数类型不匹配的问题。它们不是灵丹妙药但能拦截掉最粗心的一整类错误。5. 高频报错与排查记录5.1 “function printf declared implicitly”到底是什么意思这个编译警告/错误几乎每个忘带头文件的人都会见一次。英文原文是function printf declared implicitly意思是编译器在遇到printf时没有看到任何关于它的声明所以只能隐式地假设它的返回类型是int。在老的C89/C90标准里编译器对这种隐式声明很宽容顶多给个警告。到了C99标准隐式int声明被移除没有看到原型就直接调用函数属于语法错误。所以有些人拿着老教材在Linux上编译用gcc默认gcc11这种新版本编译器就会从原来的警告升级成error。这是个标准发展的历史产物不是你电脑有问题。解决办法很直白在源文件开头加上#include stdio.h如果你用的是C除了cstdio许多老工程也照样用stdio.h。只要这个头文件在前面出现过printf的声明也就进入了编译器视野警告自然消失。还有一种少见的情况头文件确实加了但位置不准比如放在宏保护区间外被条件编译跳过了。这时候就要检查编译预处理之后头文件是否真正被包含。可以用gcc -E查看预处理输出搜一下stdio.h的内容是否在里面。5.2 输入非法字符后程序像死循环一样刷屏典型场景是这么写的int n; while (scanf(%d, n) ! 1) { printf(输入不合法请重试); }运行之后无论重试多少次printf一直在刷屏用户根本停不下来。原因在于scanf的失败并不会自动把非法输入从缓冲区里拿走。用户在终端输入“abc”回车scanf处理到字母a发现不匹配函数返回0但a、b、c、回车这些字符还在缓冲区里。下一次循环再调用scanf读到的还是同一个a于是又失败又来一遍形成死循环。正确写法是把失败的输入行全部清掉再重新读取int n; int ret; while (1) { ret scanf(%d, n); if (ret 1) { break; } if (ret EOF) { // 输入流已经结束退出循环 return 1; } // 清空当前行残留 int c; while ((c getchar()) ! \n c ! EOF) ; printf(输入不合法请重试); }这里的关键就是getchar循环加EOF判断。很多教材只写while(getchar() ! \n);在管道输入或者文件重定向场景下如果文件没有换行就直接EOF这个简化版直接死循环。加EOF判断是真正的生产级写法。还有一个坑如果用户按了CtrlZWindows或CtrlDLinux结束输入getchar返回EOFscanf返回EOF继续读下去没有意义必须跳出循环。忘记处理EOF程序也会看起来“卡死”。5.3 常见问题排查速查表症状常见原因第一处理方式scanf读数字后下一个scanf直接跳过缓冲区残留换行%c前加空格或清空到行尾scanf后调fgets读出来是空行残留换行被fgets先吃掉了scanf后先getchar清一行残留printf中文乱码源文件编码与终端代码页不一致统一UTF-8或设置控制台代码页printf输出垃圾值格式串与实参类型不匹配核对%d、%ld、%f对应类型串口调试收不到printfstdout未重定向或缓冲未刷新实现fputc重定向串口关键处fflush编译报implicit declaration缺少#include stdio.h加头文件输入非法字符后死循环scanf失败但不消费非法字符清空当前行并判断EOF最后再说一点我自己的个人习惯。我现在不管多小的程序读入之前一定会先想清楚“缓冲区这一刻剩下的东西是什么”。交互式输入看起来只是程序里微不足道的一小段但它恰恰是程序跟外部世界接触的边界边界上任何想当然都会演变成生产事故。如果你刚开始学C语言别急着跳过这些“小细节”把这一进一出写稳后面写再多复杂逻辑都踏实得多。