首页 / 资讯中心 / 文章详情

C语言scanf和printf避坑指南:格式化输入输出与缓冲区详解

C语言scanf和printf避坑指南:格式化输入输出与缓冲区详解 ★ FEATURED ARTICLE
很多初学C语言的朋友第一次接触scanf和printf的时候都会有一种“好像很简单”的错觉一个负责输出一个负责输入照着教程抄几遍就能跑。但真的开始做题、写点带交互的小程序时各种莫名其妙的问题就来了——输入一个字符后程序直接跳过下一步、数字格式对不上、中文打印出来是乱码、scanf卡在循环里出不去……这些坑我在入门阶段几乎全踩过一遍。这篇是C语言入门系列的第三篇专门把scanf和printf这两个基础函数讲透。我会先从它们到底在底层做了什么说起再逐个拆解格式控制符、缓冲区、类型不匹配、乱码问题最后用fgets和sscanf的组合给出更安全的输入方案。内容适合刚学完变量和分支循环、正在被各种输入输出问题折磨的初学者也适合想系统梳理格式化I/O知识的自学者。花二十分钟看完很多以前“只能死记”的规则你会突然明白为什么要这样写。1. 先搞清楚printf和scanf到底在解决什么问题1.1 格式化输入输出的核心价值printf和scanf都定义在stdio.h头文件里属于C标准库自带的输入输出函数。它们做的工作本质上是一种“文本和二进制数据之间的转换”printf把内存里的数值、字符、字符串按照你指定的格式转换成可读的文本送到标准输出设备一般是屏幕scanf则反过来把用户在键盘上输入的文本按照指定格式解析成程序需要的二进制数据存进变量。这句话听起来抽象但理解它特别关键。你想想内存里的int变量占4个字节存的是补码这是硬件层面的数据而屏幕上显示的“42”是三个字符是人类能看懂的样子。printf负责把前者翻译成后者scanf负责把后者翻译回前者。没有这个“翻译层”程序跟人之间根本没法交流。那为什么不直接用putchar和getchar读写字符非要搞一套格式化函数因为大多数场景下我们要的不是“读一个字符”而是“读一个整数”“读一个带小数的数字”“读一个单词”。scanf的格式化能力能让你一行代码就把一串字符解析成多个不同类型的变量省掉大量手写解析逻辑。同理printf也能把不同类型的数据一次性排列成美观的文本。这套机制从C语言诞生一直沿用到今天哪怕现在各种高级语言的格式化输出比如Python的f-string、Java的String.format底子也都是这套思路。1.2 一次最简单的调用背后发生了什么来看一个最基础的程序#include stdio.h int main(void) { int age 0; printf(请输入你的年龄); scanf(%d, age); printf(你输入的年龄是%d\n, age); return 0; }这段代码能跑通但很多人没想过printf(请输入你的年龄)执行后字符串是直接打到屏幕上的吗其实不是。标准输出默认是“行缓冲”模式也就是说printf先要把字符写进内存里的一个缓冲区等遇到换行符\n、缓冲区满、或者程序正常退出时才真正把内容刷到屏幕。如果上面第一行printf没有加\n在有些环境下你甚至可能看不到这句提示因为缓冲区还没被刷新。这就是为什么很多人调试时觉得“printf没执行”其实它执行了只是输出还堵在缓冲区里。再看scanf(%d, age)。程序执行到这里会阻塞等待用户从键盘输入。注意终端一般也是行缓冲用户敲的内容先进入系统维护的输入缓冲区直到按下回车这一整行才算交给程序。然后scanf才从输入缓冲区里按格式解析数据。这就是为什么你输入完数字之后必须按回车程序才会继续往下走。缓冲区这个机制是后面理解各种奇怪问题的总钥匙。printf输出顺序错乱、scanf读不到想要的字符、程序循环卡死归根结底都和缓冲区有关。入门阶段不要求你手动管缓冲区但一定要知道它的存在。2. printf使用格式串就是你的“输出图纸”2.1 占位符体系怎么搭配printf的第一个参数叫“格式串”里面除了普通字符原样输出之外还可以包含格式占位符。占位符总是以%开头告诉printf“这里要插入一个变量的值变量类型是这样那样的”。我习惯把格式串理解成一张图纸普通字符是固定印刷上去的内容%后面的部分标明了哪里要留空、留多大的空、用什么风格填充。常用占位符我整理了一张表格式符对应类型说明%dint有符号十进制整数%uunsigned int无符号十进制整数%ffloat / double十进制浮点数%edouble科学计数法浮点数%gdouble根据数值大小自动选择%f或%e%cint / char单个字符%schar*字符串遇到\0停止%pvoid*指针地址十六进制输出%x/%Xunsigned int十六进制整数%ounsigned int八进制整数%%无输出一个百分号实际写代码时最常用的莫过于%d、%f、%c和%s。有个很多人刚学时会犯的错输出float类型变量时用%d结果屏幕上出现一个莫名其妙的巨大数字。原因是printf是可变参数函数编译器在编译时并不强制检查格式符和实参类型是否匹配运行时按格式符解读了内存里的字节类型对不上读出来的东西自然就是垃圾值。2.2 宽度、精度和对齐一口气讲完占位符后面还能跟一些修饰符用来控制输出的宽度、精度、对齐方式和补位字符。完整结构是%[标志][宽度][.精度][长度修饰符]格式符标志位常用的有这么几个-左对齐默认是右对齐。正数前面加正号负数前面加负号。0用0填充宽度而不是空格。空格正数前面加空格负数前面加负号。宽度指定的是一共占多少字符位置精度对浮点数表示小数点后保留几位对字符串表示最多输出几个字符。看个例子#include stdio.h int main(void) { int num 42; printf([%5d]\n, num); // [ 42] printf([%-5d]\n, num); // [42 ] printf([%05d]\n, num); // [00042] double pi 3.14159265; printf([%.2f]\n, pi); // [3.14] printf([%8.2f]\n, pi); // [ 3.14] char name[] C语言; printf([%10s]\n, name); // [ C语言] printf([%-10s]\n, name); // [C语言 ] printf([%.3s]\n, name); // 按字节截断中文场景要小心 return 0; }这里想多说一句“长度修饰符”。%ld输出long%lld输出long long%lf在printf里其实和%f等价因为float传给可变参数函数时会自动提升为double所以printf从来不会收到float类型的值。但到了scanf那边情况完全相反%f读float%lf读double写错就翻车。这个差异导致的问题特别多后面专门说。还有两个很实用但不常被提到的点动态宽度和动态精度。写法是用*占位然后在参数列表里给值printf(%*d\n, 5, 100); // 宽度为5输出 [ 100] printf(%.*f\n, 2, 3.14159); // 保留2位小数输出 3.14这在做表格对齐、打印菜单时非常方便不用提前走固定宽度写死。2.3 转义序列换行、制表符和其他控制字符转义序列也是printf里的常客。最常用的是\n换行和\t制表符还有\\输出反斜杠、\输出双引号。初学者容易忘记字符串是双引号包着的如果要在输出内容里显示双引号本身必须写成\否则编译器会认为字符串提前结束了。\n这个换行还有个隐藏身份它通常会触发stdout缓冲区的刷新。这也是前面说的“printf内容被堵住”的最常见解决办法——如果发现某个输出迟迟不显示先试试在字符串末尾加个\n。如果连\n都不能满足你比如你想不换行就看到输出可以用fflush(stdout)强制刷新缓冲区。这是标准行为可以放心用。3. printf容易踩坑类型不匹配和中文乱码3.1 格式符与类型不匹配的后果上面提过printf是可变参数函数编译器默认不会帮你校验格式符与参数类型的一致性。但现代编译器其实可以检查前提是你写对了格式串。GCC/Clang在编译时加上-Wall选项会对明显的类型不匹配给出警告MSVCVisual Studio的编译器也会有类似提示。养成把警告当错看的习惯能帮你提前挡掉大部分麻烦。比较坑的是隐式类型转换带来的“假匹配”。比如char ch A; printf(%d\n, ch); // 输出65 printf(%c\n, ch); // 输出Achar类型在传给printf时会自动提升为int所以用%d输出char是可以的因为低位正好是ASCII码。short同理会提升为int。但反过来如果你有一个int值想按字符输出只要值在ASCII范围内也能碰巧正确。这种“碰巧正确”会让人产生错误的自信一旦值超出范围就全乱套。浮点类型更容易翻车float f 1.5f; printf(%f\n, f); // 正确float提升为double后按%f输出 printf(%d\n, f); // 错误内部字节被当作int解读结果完全不可控float和double在传给printf时都变成double了所以%f可以直接用。但如果你用了%d去读一个double的字节序列因为double占8字节、int只占4字节可能只会读取到部分数据输出结果随机性很大。这种错误编译器不一定能发现因为类型经过了合法转换实参是double格式符是%d编译器没有理由报警。所以写printf时格式符和实参类型必须自己心里有数。有一个实用技巧printf之前可以在脑子里走一遍“这个参数传进去以后会变成什么类型”。int、char、short变intfloat变doubledouble还是double指针类型是void*用%p输出。养成这个习惯之后格式符的选择基本不会错。3.2 中文乱码到底是怎么来的“printf中文乱码”是出现频率极高的问题尤其Windows GCC比如Dev-C、Code::Blocks环境下。根本原因说穿了就三个环节的编码不一致源文件保存时用的编码、运行时代码里字符串常量的编码、控制台显示时使用的编码。具体来说Windows中文版控制台默认代码页是936GBK如果你的源文件是以UTF-8编码保存的字符串字面量在编译时会被当作UTF-8字节序列运行后printf往控制台输出的是UTF-8字节而控制台按GBK去解码两个对不上显示出来就是乱码。反过来源文件用GBK保存编译器按GBK处理字符串控制台也按GBK显示就正常但如果控制台被切换成UTF-8代码页65001GBK字节又变成乱码。解决方案有这么几种从简单到复杂一是统一源文件和终端的编码。最省事的是在Windows下把源文件保存为ANSI编码中文Windows下就是GBK终端用默认的936代码页不乱码。缺点是一旦换到Linux或Mac这类默认UTF-8的环境GBK编码的源文件反而会出乱码跨平台性差。二是让控制台支持UTF-8。在Windows上可以在代码开头调用系统API#include windows.h SetConsoleOutputCP(CP_UTF8);前提是源文件保存为UTF-8编码编译器按UTF-8处理字符串终端切换到UTF-8代码页三者就统一了。这个方法是微软提供的API不属于标准C库只适用于Windows平台但确实能解决很大一部分实际需求。三是不依赖控制台代码页直接用专门的终端工具比如VSCode的集成终端、Windows Terminal它们对UTF-8支持良好。配合chcp 65001命令或者设置好终端默认编码乱码问题基本能灭掉一大半。我个人现在写C代码一律用UTF-8编码 VSCode集成终端跨平台搬到Linux也不会出奇奇怪怪的问题。还有一个小细节把printf的输出重定向到文件时命令行里用符号输出内容的编码就是源文件里字符串的原始字节序列跟控制台无关了。如果你用文本编辑器打开重定向出的文件发现乱码同样去检查源文件编码和编辑器打开的编码是否一致即可。3.3 printf输出的去向与重定向顺带说一下“printf重定向”这个关键词。printf的输出目标是标准输出流stdout默认对应屏幕。但在命令行里可以使用重定向符把stdout转接到文件./program output.txt这时候printf的内容不会出现在屏幕上而是写入output.txt文件。重定向到文件后stdout的缓冲模式可能从行缓冲变成全缓冲也就是说程序异常崩溃时缓冲区的内容还没来得及写入文件导致输出文件不完整。这个行为在调试日志时非常坑你以为程序跑到了某个分支结果日志里压根没有。解决办法还是那句重要日志后面主动加fflush(stdout)或者日志里带\n不足以完全保证全缓冲模式下必须手动刷。4. scanf使用比printf容易踩坑十倍4.1 地址操作符与变量类型的关系scanf的第一个特点就是除了格式串后面跟的全部是“地址”不是变量本身。原因很简单——C语言函数参数是值传递的scanf拿到变量副本不管怎么改外面的变量都不会变。所以必须传变量的地址让scanf通过指针往地址里写数据。这个规则衍生出几个常见的入门错误。第一基础类型变量必须加取地址符int age; scanf(%d, age); // 正确 scanf(%d, age); // 错误age的值被当成地址轻则段错误重则搞坏内存第二数组名本身就是地址不需要再加char name[20]; scanf(%s, name); // 正确name就是char*类型 scanf(%s, name); // 类型是char(*)[20]虽然地址值一样但类型不对第三指针变量如果已经指向了合法的内存空间传指针本身就行char buf[100]; char *p buf; scanf(%s, p); // 正确p指向buf很多初学者被困住其实就是没想清楚“我要给哪个变量填值就把那个变量的地址交给scanf”。这里必须补一个关键概念C语言里“地址”和“指针”是一体两面的。运算符取变量地址返回一个指向该类型的指针scanf内部通过这个指针间接写入数据。你不需要在这里深入钻研指针但得形成肌肉记忆int用double用char当然也要用唯独char数组名不用。4.2 格式串里的空白字符与%c的特殊性scanf的格式串里空白字符空格、\t、\n的含义和printf完全不同。在scanf中格式串里的空白字符表示“跳过输入流中的任意空白字符包括零个”。比如int a, b; scanf(%d %d, a, b);这里%d之间的空格可以匹配输入中的任意多个空格、换行、制表符。所以你输入“3 5”“3回车5”“3 5”都可以。甚至你在格式串里写\n它也不会要求用户必须换行一样是跳过空白。但%c是个例外。%c不跳过任何空白字符它直接读取下一个字符哪怕那个字符是空格或换行。这个特性让很多人栽跟头int num; char ch; scanf(%d, num); scanf(%c, ch);你输入“5回车”第一次scanf读取了整数5但回车产生的\n还留在输入缓冲区里第二次scanf的%c直接读到了这个换行符ch的值变成10即\n完全不是你想要的字符。解决办法是%c前面的格式串里加一个空格写成scanf( %c, ch)。这个空格会匹配掉之前残留的空白字符让%c读到真正的非空白字符。这是入门阶段必须掌握的经典处理方式务必记牢。顺带提一下%s它读取字符串时也会跳过前导空白但遇到空白字符会停止因此读不了带空格的句子。如果输入“Hello world”scanf(%s, buf)只能读到“Hello”。想读一整行得用fgets后面会讲。4.3 scanf的返回值是干什么用的scanf返回值也值得多说。它返回的是“成功匹配并赋值的输入项数量”。比如scanf(%d %d, a, b)如果两个都读到了返回2只读到一个返回1一个都没读到返回0如果读取过程中遇到文件结束标志比如输入流到达文件末尾返回EOFEOF在stdio.h中一般是-1。利用返回值可以判断输入是否合法int a; if (scanf(%d, a) ! 1) { printf(输入的不是一个整数程序跳过\n); }这个习惯特别重要。很多课程作业里scanf写一次就完事了没人检查返回值但实际做题或者写工具时用户可能输入“abc”“3.14”这种根本不符合要求的字符这时候scanf会失败变量a的值不确定程序进一步处理就会得到莫名其妙的结果。有返回值做判断至少程序不会失控。4.4 缓冲区残留与那场著名的死循环前面讲过scanf从输入缓冲区读数据输入具体是“3 abc 5”这种一行内容时如果第一次用%d读到了3后面的“abc 5”会残留。这时候再执行一次%d看到的不是空白或换行而是字母a匹配失败返回0a的值保持原样而字母a依然留在缓冲区里。如果在循环里重复scanf它会一次又一次读同一个a每次都失败程序陷入死循环屏幕上疯狂刷提示信息。这个问题的标准处理方案有几种方案一在格式串中清掉残留。用scanf(%*c)读走并丢弃一个字符注意*表示“赋值抑制”即读取但不赋值。不过一次性只丢一个字符如果残留多个坏字符得循环丢。方案二使用getchar配合循环把缓冲区里剩余字符全部读走while (getchar() ! \n);注意如果输入流里已经没有字符了getchar会阻塞等待新输入所以这行代码最好放在确认当前缓冲区有残留的情况下用。方案三最彻底也是我推荐的做法——不直接用scanf处理不可信输入改用fgets先读一行再用sscanf解析。这个方案下面专门讲。还要提一个争议颇大的函数fflush(stdin)。很多老教材会写它来清空输入缓冲区但C标准明确规定fflush只对输出流有效对输入流的行为是未定义的。在Windows上MSVC的实现里fflush(stdin)碰巧能用但换到Linux/macOS、换到GCC行为可能完全不同。我的建议是彻底别用避免养成坏习惯。想清空输入缓冲就用getchar循环别图省事。5. scanf和fgets怎么选以及经典综合案例5.1 安全读入fgets和sscanf组合思路fgets的原型是char *fgets(char *str, int size, FILE *stream);它从指定流stdin就是标准输入读取最多size-1个字符存储到str中并在末尾自动补上\0。它遇到换行符会停止读取并且会把换行符也存进缓冲区这点和scanf(%s)不同。如果读取成功返回str读到文件末尾或出错返回NULL。和scanf相比fgets的好处有三点一是有长度限制不会像scanf(%s, buf)那样无脑越界写坏内存。只要size传的是缓冲区大小就不会溢出。二是能读取包含空格的整行文本这是scanf(%s)做不到的。三是错误处理清晰fgets返回NULL能明确判断“读不到内容”。但fgets读进来的是字符串要从中解析出整数、浮点数还得靠sscanf。sscanf是scanf的“字符串版”它从字符串中解析数据而不是从键盘流中读取。组合起来就成了一个既安全又灵活的输入方案#include stdio.h int main(void) { char line[100]; int age; char name[30]; printf(请输入姓名和年龄); if (fgets(line, sizeof(line), stdin) NULL) return 1; // 从line中解析先读一个字符串再读一个整数 if (sscanf(line, %s %d, name, age) ! 2) { printf(输入格式不对\n); return 1; } printf(姓名%s年龄%d\n, name, age); return 0; }这里sscanf的用法和scanf几乎一样但输入源是字符串。麻烦点在于fgets会保留末尾的换行符。如果只读一个字符串name后面可能会带着\n打印时会出现换行异常。常见处理办法是手动去掉换行line[strcspn(line, \n)] \0;或者更简单粗暴地判断最后一字符if (line[strlen(line) - 1] \n) { line[strlen(line) - 1] \0; }5.2 实际开发时的选型建议关于“scanf和fgets的区别”我的实际建议是分场景题目明确说“输入一个整数”“输入两个浮点数”并且输入格式非常规整、数量固定时直接用scanf最方便代码简短。这个时候要注意返回值判断以及不要混用%c和%d需要在%c前加空格跳过残留。需要读取用户输入的一整行、或者用户可能输入错误格式、或者做文件逐行解析时用fgets sscanf组合。虽然代码多几行但程序健壮性提升一大截。尤其写命令行小工具、课程设计、任何“需要和人交互”的场景我都会优先用fgets。还要补充一个对比例子直观展示两者差异char buf1[50], buf2[50]; scanf(%s, buf1); // 输入 hello worldbuf1得到hello fgets(buf2, sizeof(buf2), stdin); // 输入 hello worldbuf2得到hello world\n两者读取机制完全不同用错场景会出现一类很隐蔽的bug先scanf读数字再fgets读字符串你会发现fgets拿到的是那个残留的换行符直接跳过等待用户输入。这就是输入流状态没管理好导致的连锁反应。5.3 综合案例一个带输入校验的成绩统计程序把前面的要点串起来写一个有点实际意义的小程序接收若干个学生的姓名和成绩计算平均分并按要求格式化输出。要求用fgets防止越界用sscanf做解析用printf的宽度和精度控制输出格式。#include stdio.h #define MAX_STUDENTS 5 #define NAME_LEN 20 int main(void) { char line[100]; char names[MAX_STUDENTS][NAME_LEN]; double scores[MAX_STUDENTS]; int count 0; double sum 0.0; printf(请输入%d个学生的姓名和成绩格式姓名 成绩\n, MAX_STUDENTS); while (count MAX_STUDENTS) { printf(第%d个学生, count 1); if (fgets(line, sizeof(line), stdin) NULL) { break; // 输入结束 } // 去掉末尾换行 line[strcspn(line, \n)] \0; if (line[0] \0) { break; // 空行结束输入 } // 解析姓名和成绩 if (sscanf(line, %19s %lf, names[count], scores[count]) ! 2) { printf(格式错误请重新输入\n); continue; } sum scores[count]; count; } if (count 0) { printf(没有输入任何学生数据\n); return 1; } // 格式化输出表格 printf(\n%-10s %10s\n, 姓名, 成绩); printf(--------------------\n); for (int i 0; i count; i) { printf(%-10s %10.2f\n, names[i], scores[i]); } printf(--------------------\n); printf(平均成绩%.2f\n, sum / count); return 0; }这个小程序里有几个值得注意的点。姓名读取时用的是sscanf(line, %19s, names[count])这里写19是为了防止姓名超过NAME_LEN-1的长度而覆盖数组边界sscanf支持这种“最大宽度”限定和scanf一致。(count)用的是数组名本身就是地址不需要加。成绩读取用的是%lf因为scores是double数组。如果你用%f去读doublesscanf只会往内存里写入4字节而double占8字节后面的4字节是脏数据最终计算结果完全不对。这是scanf系函数最经典的错误之一比起printfscanf这里对%f和%lf的区分是严格且必须的。格式输出部分%-10s让姓名左对齐%10.2f让成绩右对齐并保留两位小数。这样打印出来的表格整齐美观也是在入门阶段就能体会到printf强大之处的小案例。6. 常见问题与排查技巧速查6.1 高频报错与行为异常的快速判断把我在实际刷题和写代码时频繁遇到的scanf/printf问题整理成一张速查表建议收藏现象可能原因解决方案输入整数后程序跳过后续输入上一次scanf读了%d输入缓冲区里残留换行符下一次%c或fgets直接读到换行后续格式串加空格如 %c或用fgets整体读入再解析循环中scanf反复失败程序卡死输入了不符合格式的字符坏字符一直留在缓冲区用while (getchar() ! \n);清理缓冲改用fgetssscanfprintf输出顺序乱、内容不显示标准输出是行缓冲未刷新加\n调试时用fflush(stdout)printf输出中文乱码源文件编码与控制台代码页不一致统一UTF-8 支持UTF-8的终端或统一ANSI 默认代码页printf输出数字完全不对格式符与实参类型不匹配开启编译警告脑子里过一遍参数最终类型float转doublechar转intscanf用%f读double结果不对%f读4字节double需要%lf改为%lfscanf(%s, buf)读不了空格%s遇到空白停止换fgets读整行fgets读到的字符串末尾多一个换行fgets会保留换行符用strcspn或strlen手动去掉参数是char*时误加数组名已是地址再加类型变二级指针数组名直接传变量才用这个表里大部分条目的根源都能回溯到两个核心机制缓冲区管理和变长参数类型检查缺失。搞懂这两点scanf和printf的坑基本就都关上了。6.2 几个容易被忽视的细节最后再说三个我很想单独强调的细节都是实战中容易“卡一下才反应过来”的点。第一printf的返回值是输出字符的总数。这个返回值用得不算多但偶尔能用上。比如你想输出一个分隔线线长正好等于某个进度条的宽度可以先拿到长度再动态决定。不过入门阶段知道它存在就够了别一上来就追求各种奇技淫巧。第二scanf的格式串中%s、%d等会在遇到空白时自动跳过前导空白唯独%c、%[和%n不会。这不是设计缺陷是为了灵活处理字符数据。你只要记住“%c特殊、处理残留用空格开头”这个口诀就行。第三关于缓冲区清空不要迷信fflush(stdin)。跨平台代码里它就是个定时炸弹。用getchar循环清理或者用fgets重新设计输入流程才是可移植的做法。这也是很多企业笔试和面试里喜欢追问的点fflush(stdin)到底能不能用答案是不能依赖因为标准未定义。还有一些和“查看栈空间”相关的问题比如“局部变量越少所占栈空间越小”——这个问题理论上和栈帧优化有关但入门阶段不必过度关注。编译器在-O优化下可能复用栈空间变量个数和栈大小的关系没那么直接。把它记在心里等你学到汇编和栈帧布局之后自然就懂了。根据我自己的经验scanf和printf真正想熟练掌握光看文章不行一定得亲手敲代码、亲手制造几个错误再修复。比如故意写错类型、故意在循环里输入非法字符、故意混用%d和%c看看会发生什么。把错误踩一遍再回头看这些原理记忆会非常深刻。最后分享一个对我调试帮助很大的小技巧写printf的时候把调试信息带上文件名和行号比如printf([%s:%d] 当前x%d\n, __FILE__, __LINE__, x);这样程序一跑哪个文件哪一行输出了什么一目了然排查问题效率直接翻倍。这个习惯我到现在写C代码还在用也推荐给你试试。
阅读完成 · 觉得有帮助?
咨询建站