1. 从一道作业题说起:为什么输入输出函数值得单独讲
我记得刚学C语言那会儿,第一次交上去的作业是“输入三个学生的成绩,输出平均分”。代码写得飞快,大概长这样:
printf("请输入三个成绩:"); scanf("%d%d%d", &a, &b, &c); printf("平均分=%d", (a+b+c)/3);乍一看没毛病。但运行的时候发现,我敲完三个数字按回车,程序瞬间结束,连“平均分=85”这种结果都看不清。后来又遇到更诡异的情况:输入带小数点的分数,结果一塌糊涂;用scanf读字符,回车键好像还在缓冲区里捣乱,getchar()拿到的永远不是你以为的那个字符。
这些坑,全都集中在C语言的输入输出函数上。坦白讲,C语言的IO函数设计得不算友好,但它足够底层、足够直白,理解了它的脾气,后面学文件操作、网络编程甚至嵌入式开发,思路都会顺很多。这篇博文不打算按教材目录逐条复述函数原型,而是把这些函数按“格式化输出、格式化输入、字符级IO、字符串IO、文件IO亲戚”五个维度拆开,每一块都配合我实际踩过的坑来讲。你最好打开编译器跟着敲一遍,光看不练,第二天准忘。
2. printf与格式化输出:不只是“打印个东西”那么简单
2.1 格式控制串到底是什么
很多人把printf当成“会把括号里的内容原样显示在屏幕上”的工具,所以看到printf("%d %f %s", a, b, c)就蒙了。实际上,printf的第一个参数是“模板”,后面跟着的是“填入模板的数据”。模板里的%d、%f、%s这些占位符,就像是个贴了标签的插槽,第几个占位符就对应第几个参数。
一张图就能看明白:
printf("姓名:%s 年龄:%d 身高:%.1f", name, age, height); // 模板里有三个插槽 三个参数按顺序填进去这种设计的好处是模板和数据分离。你可以在运行时决定要打印什么数据,而不需要为每一种数据都写一条单独的打印语句。坏处是,一旦占位符的类型和参数类型不匹配,轻则输出乱码,重则未定义行为,程序可能直接崩溃。比如你用%d去打印一个float,取出来的字节解释方式完全错误,这在嵌入式调试时尤其恶心。
常用占位符就那几个,列个速查表你自己保存:
| 占位符 | 含义 | 典型用法 |
|---|---|---|
| %d | 有符号十进制整数 | printf("%d", 42) |
| %u | 无符号十进制整数 | printf("%u", 42u) |
| %f | 浮点数,默认6位小数 | printf("%f", 3.14) |
| %.2f | 浮点数,保留2位小数 | printf("%.2f", 3.14159) |
| %c | 单个字符 | printf("%c", 'A') |
| %s | 字符串(以\0结尾) | printf("%s", "hello") |
| %p | 指针地址 | printf("%p", &a) |
| %x / %X | 十六进制整数 | printf("%x", 255) |
| %o | 八进制整数 | printf("%o", 8) |
| %e | 科学计数法 | printf("%e", 12345.678) |
| %ld | long类型整数 | printf("%ld", 123456L) |
| %lld | long long类型整数 | printf("%lld", 123456LL) |
注意:
%f和%lf在printf中的效果是一样的,因为printf的变长参数会做默认实参提升,float自动提升为double。但在scanf里,%f对应float*,%lf对应double*,混用会出大问题。这个问题下面细说。
2.2 宽度、精度与对齐:让输出看起来整齐划一
考试排行榜、报表这种场景,输出必须对齐。printf提供了格式修饰符,能让你精确控制每个字段占几列。
格式完整写法大概是这样的:
%[标志][宽度][.精度][长度]格式字符举个例子:printf("%-10s|%8.2f|%6d\n", name, score, rank);
这里每个字段的含义:
-:左对齐,不加默认右对齐;10s:字符串最少占10列,不足补空格;8.2f:总宽8列,小数部分2位;6d:整数占6列。
跑一下这个代码,输出效果是:
张三 | 88.50| 1 李四 | 91.00| 2 王五 | 76.25| 3名字那一列,“张三”两个字后面补了8个空格,凑满10列,然后|严丝合缝地对齐。这种细节在写终端菜单、日志对齐、作业提交时的输出效果里非常加分。以前我帮同组同学改代码,很多输出乱糟糟的,其实就是没在意宽度控制。
还有几个容易忽略的用法:
%05d:宽度为5,不足用0补齐,比如printf("%05d", 42)输出00042;%6.2f:如果数字超过宽度限制,宽度自动失效,不会截断数字,比如printf("%6.2f", 12345.678)输出12345.68——注意它保留了两位小数,一共8个字符,宽度被撑破了;%*d:动态宽度,宽度由下一个参数指定,比如printf("%*d", width, value),有时候写通用格式化工具函数时很管用。
我个人的建议是:成绩、金额这类输出一定要写精度,.2f防呆;宽度可以配合表格场景使用,但别没事就加,因为中文是双字节字符,ASCII字母是单字节,混排时“宽度”计算经常对不齐。处理中文对齐需要按字节数手动计算,这个后面实操篇我会给个通解方案。
2.3 转义字符与换行
人们在printf里写的\n实际上是C语言的转义序列,不是单纯的“换行字母”。ASCII控制字符表里,换行是LF(0x0A),回车是CR(0x0D)。在Windows的文本模式里,输出\n会翻译成\r\n两个字节;Linux下只输出LF。这个差异在跨平台处理文件、串口数据时会造成困惑。
常用的转义字符:
| 转义序列 | ASCII码 | 含义 |
|---|---|---|
| \n | 0x0A | 换行 |
| \t | 0x09 | 水平制表符(Tab) |
| \r | 0x0D | 回车 |
| \ | 0x5C | 反斜杠本身 |
| " | 0x22 | 双引号 |
| ' | 0x27 | 单引号 |
| \0 | 0x00 | 空字符(字符串结束标志) |
| \a | 0x07 | 响铃 |
新手写路径时最容易踩这个坑:Windows路径C:\Users\name在C语言的字符串里要写成"C:\\Users\\name",否则\U会被当成一个未知转义序列,轻则警告,重则编译错误。
3. scanf与格式化输入:用户输入不是你想的那么简单
3.1 为什么要加&符号
这是初学者问得最多的一个问题:scanf("%d", &a)里的&是干啥的,忘了写会怎样?
C语言函数的传参机制是值传递,也就是说,你把变量a传进去,函数拿到的是a的值的副本,函数内部无论怎么改,都不会影响外面的a。而scanf的任务恰恰是把读到的数据写进你这个变量里。要修改外面的变量,只能拿到这个变量的地址,通过地址去写内存。&a就是取变量a的地址,scanf通过这个地址找到内存位置,把键盘读到的值写进去。
忘了写&,在大多数编译器上不会直接报错,而是给你一个段错误或者崩溃,因为scanf把60这种无符号整数当成地址,直接往那个地址上写数据,大概率越界。有个例外:%s格式符对应的是字符数组名,数组名本身就是地址,所以不用加&。
3.2 空白字符与缓冲区:scanf最坑的地方
键盘输入本质上是把字符流送进一个缓冲区,scanf是从缓冲区里按格式读取。这里有个关键规则:对于%d、%f这些数值格式,scanf会先跳过前面所有的空白字符(空格、Tab、换行),然后从第一个非空白字符开始解析,遇到不能属于该数值类型的字符就停止,并且把那个字符留在缓冲区里。
这个规则带来几个连锁反应:
- 你在输入里敲的多余空格、多个换行,都会被自动忽略,不会干扰数值读取;
- 如果输入的第一个非空白字符根本就不是数字,比如你输入了字母a,scanf会读取失败,返回0,
a保持原值(其实不确定,标准里说如果读取失败,目标对象的值是不确定的); - 停止位置的那个字符会继续留在缓冲区,影响下一次读取。
典型的坑就是连续读取:
int num; char ch; scanf("%d", &num); scanf("%c", &ch); // 你以为ch会得到你下一个输入的字符实际跑一下你会发现,ch拿到的往往是换行符\n,因为第一次scanf敲完数字后按下回车,换行符留在了缓冲区里,第二次scanf的%c立刻把这个残留换行读走了。
解决办法有三种:
- 在
%c前面加空格:scanf(" %c", &ch),空白匹配会让scanf跳过所有空白字符再去读一个非空白字符; - 用
getchar()手动把缓冲区的换行吃掉; - 最推荐:不要混用scanf和getchar/fgets,统一用fgets读整行再解析,这个后面讲。
3.3 检查scanf的返回值:这是排查问题的第一把钥匙
scanf的返回值是成功匹配并赋值的项数。比如scanf("%d %d", &a, &b)如果两个都读到了,返回2;如果第一个就失败,返回0;如果遇到文件末尾,返回EOF(-1)。
很多线上bug就是因为没检查返回值。比如你写了一个菜单程序,用户输入了一个字母,scanf返回0,但代码还继续用这个变量做运算,结果就是个垃圾值。更隐蔽的情况是考试、面试题里让你判断某段代码的输入会不会出问题,关键就在返回值上。
我写代码的习惯是,凡是scanf,必定套一个条件:
if (scanf("%d", &value) != 1) { // 处理非法输入,比如清空缓冲区 while (getchar() != '\n'); }while (getchar() != '\n');这行是把缓冲区里剩下的字符全部读完(直到换行),等于是“清空本次输入残留”,这招在写菜单循环时几乎必用。
3.4 scanf格式串中的字面字符:你必须原样输入
scanf("(%d,%d)", &x, &y)这种写法意味着输入必须包含括号和逗号,比如(3,4)。格式串里的空格是一个特殊指令:匹配任意数量的空白字符(包括零个)。比如scanf("%d %d", &a, &b)中间那个空格,表示两个数字之间可以有任意数量空白,甚至没有空白也能解析(因为%d本身就会跳过空白)。
初学者经常在scanf里写scanf("%d,\n%d", &a, &b)这种魔性格式,结果自己都搞不清要输入什么格式才能匹配上。我的建议是:scanf格式串越简单越好,最好全是%d %f %s加空格,任何字面字符都会成为“必须输入”的负担。
4. 字符与字符串IO:getchar、putchar、gets、fgets、puts
4.1 getchar与缓冲区阻塞机制
getchar()从标准输入读一个字符,返回int类型(注意不是char),原因是要能返回EOF,EOF是-1,char装不下所有可能的字符加一个EOF标记。所以正确写法是:
int c; while ((c = getchar()) != EOF) { putchar(c); }这里的循环模式可以原样复制成一个“逐字符复制输入”的小工具,实际工作里写个简易处理管线经常用到。理解了getchar,你就理解了输入缓冲的机制:当你运行程序,敲了一行字符并回车,这些字符先被放进缓冲区(行缓冲模式),getchar才逐个取走。
还有一种常见需求:只读取一个字符然后立即响应,比如“按任意键继续”。Windows下有getch()这种非标准函数,Linux下则要用system("read -n1")或者改写终端属性,到了这一步你已经进入系统编程的范畴了。初学者不搞这些花活,先掌握getchar即可。
4.2 为什么教材不让你用gets
gets()这个函数可以从标准输入读一行字符串,但它默认不检查缓冲区大小。你声明一个char buf[10],然后gets(buf),用户输入了100个字符,gets会一股脑全写进buf,直接越过边界,把后面内存里的东西全踩了。这叫缓冲区溢出,C语言最著名的安全漏洞之一。
虽然现在新标准里gets已经被移除,但很多老教材和网课还是会出现它。看到任何使用gets的代码,替换方案就是fgets(buf, size, stdin)。
fgets的行为差别在于:
- 最多读取size-1个字符,或者读到换行就停;
- 换行符会被保留在缓冲区里(gets会丢弃换行符);
- 自动在末尾添加
\0。
所以fgets读完一行,缓冲区里往往带着一个尾巴\n,如果你的业务逻辑不需要换行符,得手动去掉:
char buf[128]; fgets(buf, sizeof(buf), stdin); buf[strcspn(buf, "\n")] = 0; // 用strcspn找\r或\n的位置,替换成\0这段代码很多人见过,但没深究。strcspn是“返回第一个匹配到集合中任一字符的下标”,如果缓冲区里有换行,就把它所在位置改成\0;如果没有(比如输入超过了缓冲区长度),strcspn返回\0的下标,把\0改成\0,没影响。这行代码在任何需要“读一行、去换行”的场景都可以直接复制粘贴。
4.3 puts和fputs:输出字符串的细微差别
puts(str)做了两件事:输出字符串内容、自动加一个换行。适合输出整行提示信息。fputs(str, stdout)则只输出内容,不加换行。这两个函数的好处是比printf高效,因为不需要解析格式串。
我最常用的是调试时打印字符串数组内容:
char line[256]; fgets(line, sizeof(line), stdin); fputs(line, stdout); // 原样输出,保留换行另外记住一个原则:如果输出内容里包含%、\这类特殊字符,直接用puts或fputs,别用printf("%s", str),省得格式注入出错。printf(str)这种写法是严格禁止的,一旦用户输入含有%d、%n,程序就遭殃了,这个后面实战部分细聊。
5. 字符串格式化“亲戚”:sprintf、snprintf、sscanf、fprintf、fscanf
5.1 从printf到sprintf:输出到内存
printf负责把格式化数据写到标准输出,sprintf则是写到字符数组里。两者的格式控制规则完全相同。比如你要拼一个带时间戳的文件名:
char filename[64]; sprintf(filename, "log_%d_%02d.txt", year, month);sprintf有个和gets一样的毛病:不检查缓冲区大小。目标数组空间不够,照样溢出。所以C99之后推荐用snprintf:
snprintf(filename, sizeof(filename), "log_%d_%02d.txt", year, month);第二个参数是缓冲区总大小,它最多写入size-1个字符,然后补\0,如果内容被截断,返回值是“本应写入的字符数”,可以用来判断是否截断。实际开发里我几乎只用snprintf,sprintf只在面试题里出现了。
还有一个容易被忽略的细节:snprintf的返回值可能是负数(编码错误时),用时最好判断一下:
int n = snprintf(buf, sizeof(buf), "..."); if (n < 0 || n >= sizeof(buf)) { // 出错或截断 }5.2 sscanf:从字符串里“读取”数据
sscanf是scanf的字符串版,从字符串解析数据,而不是从键盘。它的用途很广:解析一行CSV、读配置文件、提取日志里的时间戳。
char line[] = "张三,88.5,1"; char name[32]; float score; int rank; sscanf(line, "%[^,],%f,%d", name, &score, &rank);这里%[^,]是扫描集(scanset),意思是“匹配除逗号外的任意字符”,用来解析被逗号分隔的字段。扫描集是scanf家族里最强大也最容易被忽略的格式符,它的规则:
%[a-z]匹配一连串小写字母;%[^,]匹配一连串非逗号字符;%[^\n]匹配除换行外的一切字符,这常用来“读一行非空白内容”。
配合sscanf,你能写出非常灵活的解析器,而不用费力去手写字符判断。我写串口通信协议解析经常用它,效率比逐字符判断高一个量级。
5.3 fprintf和fscanf:面向文件的输入输出
从内存、屏幕、到文件,IO目标可以切换。fprintf和fscanf比printf/scanf多一个FILE*参数,指定输出的文件流。
FILE *fp = fopen("data.txt", "w"); if (fp == NULL) { perror("打开文件失败"); return -1; } fprintf(fp, "%d %s %.2f\n", id, name, score); fclose(fp);写入成功后,用文本编辑器打开data.txt能看到一行整齐的记录。反过来用fscanf读文件:
FILE *fp = fopen("data.txt", "r"); while (fscanf(fp, "%d %s %f", &id, name, &score) == 3) { printf("%d %s %.2f\n", id, name, score); } fclose(fp);注意这里的%s读取是以空白为分隔符的,所以文件名和成绩中间最好不要有空格。如果一行记录里字段本身含空格(比如姓名是“张 三”),fscanf就无能为力了,得改用fgets加sscanf的组合。
这个组合拳的写法非常实用,我写个人记账脚本、小型数据库demo,清一色是这个套路。
6. 常见问题速查表与踩坑实录
把这几年代码里遇见的IO问题汇总成一个表,每个都给出症状、原因和解决方案,你们直接对照着查:
| 现象 | 原因 | 解决 |
|---|---|---|
| 程序没输出结果就结束了 | printf没加\n,行缓冲未刷新 | 加\n,或调用fflush(stdout) |
| scanf后getchar总是读到换行 | %d读完后残留\n在缓冲区 | %c前加空格,或统一用fgets |
| 输入字母导致死循环 | scanf失败但不清空缓冲区,残留字符反复匹配失败 | 检查返回值,失败后while(getchar()!='\n');清空 |
用%f读double,结果乱码 | scanf的%f期望float*,double需要%lf | 用%lf匹配double* |
| 输出有奇怪的“烫烫烫” | 字符数组没初始化,或字符串没终止符\0 | 初始化数组,或使用snprintf自动补\0 |
| gets报错警告 | 函数不安全,被标准废弃 | 改fgets |
| eof文件读取多读一行 | 没检查fscanf返回值就进入循环体 | while (fscanf(...)==N) |
| 中文对齐乱 | 宽度按字节算,中文字符占多个字节 | 按字节宽度计算对齐,或用中文环境提供的API |
| 地址被高亮成0x...英雄:%p打印指针可能会吓到新手,但这是正常 | 指针地址本身,没毛病 | 别慌 |
再补充几个我实际调试中见过的高发场景:
场景一:循环菜单里的输入残留
while (1) { printf("1.新增 2.查询 3.退出:"); scanf("%d", &cmd); // 用户输入“1 abc”,此时abc残留在缓冲区 // 下一次循环,scanf直接读到a,返回0,cmd没更新 // 于是菜单无限循环 }正确做法是每次scanf之后都把残留清干净,或者干脆用fgets读一行再sscanf解析:
char line[128]; fgets(line, sizeof(line), stdin); if (sscanf(line, "%d", &cmd) != 1) { printf("输入无效\n"); continue; }这种写法可以同时处理“输入数字+垃圾字符”、“输入字母”、“输入超长行”三种异常,不会出现死循环。
场景二:格式化字符串漏洞
如果哪个同学写了printf(user_input),并且user_input是用户可以控制的,那攻击者输入%x %x %x就能把栈上的数据带出来,输入%s可能直接把程序打崩。这是早期的著名安全漏洞攻击方式。哪怕写本机调试工具,也要养成printf("%s", user_input)的习惯。
场景三:IO与缓存不同步
Linux管道场景下,printf输出的内容不会立刻到文件或管道里,而是攒在缓冲区,进程结束后一次性flush。如果你printf; sleep(1); printf想做个动画,却不加fflush(stdout),会发现第二个printf直接跟着第一个一起出了。解决方案就是fflush(stdout)——这是一个常被忽略但调试特别有用的函数。
7. 从输入输出到工程实践:三个我强烈推荐的小习惯
讲到这,输入输出函数的基本面貌已经清楚了。但作为C语言里天天接触的“门面”,我还有一些使用习惯,写给打算把C当作长期生产力工具的读者。
习惯一:不要反复混用scanf与fgets。标准输入只有一个缓冲区,两种读取方式对缓冲区的理解不一样,混用很容易出现幽灵数据。我的策略是:程序里统一用fgets读整行,再用sscanf解析。这样缓冲区永远在一行一次读完后保持干净,逻辑上最容易控制。
习惯二:长字符串的格式化输出使用snprintf分段处理。比如你要往一个日志模块的缓冲区里依次写入时间戳、级别、消息、结束符,不要一行写完一个巨大的sprintf(这种代码根本没法维护),而是分成多次snprintf,每次都传当前已写位置和剩余长度。这个模式在嵌入式日志系统、网络协议拼包、COM口指令构造里很常见。写一个简化版示意:
char buf[256]; int pos = 0; pos += snprintf(buf + pos, sizeof(buf) - pos, "[%d-%02d-%02d] ", y, m, d); pos += snprintf(buf + pos, sizeof(buf) - pos, "[%s] ", level); pos += snprintf(buf + pos, sizeof(buf) - pos, "%s\n", message);每次追加都基于当前偏移量,既不会覆盖前面的内容,又不会溢出。返回值pos还能告诉你已经写了多少字,相当顺手。
习惯三:别让输出格式成为你调试的负担。很多时候,一段代码行为不对,但你不知道问题出在哪。一个急招是加入关键变量的“观察点打印”:
fprintf(stderr, "DEBUG: line=%d, a=%d, b=%d\n", __LINE__, a, b);为什么不写stdout要写stderr?因为stderr默认是无缓冲的,立刻输出,而且重定向stdout到文件时,stderr依然在终端上,调试信息不会被混进结果文件里。配合__LINE__宏,崩溃前最后一条输出在哪一目了然。等到问题解决,再统一删除这些调试代码。
在我看来,输入输出函数虽然基础,但它其实是理解C语言数据流动的第一扇门。掌握了printf家族的格式串,你就理解了数据如何被“编码”成文本;掌握了scanf家族的解析规则,你就理解了文本如何被“解码”成数据。一进一出,正好构成C程序每天在做的最核心事情。后面你写文件、写网络、写串口,无非是把标准输入输出替换成文件流和套接字流,底层的格式化、缓冲区、返回值判断,思路是完全一致的。