C语言scanf与printf完全指南:从入门到工程化实践
2026/9/9 6:42:20 网站建设 项目流程

学了C语言几年的朋友,回头再看scanf和printf,往往会感叹一句:这两个函数天天见,但真要说出个所以然来,还真不容易。我自己带过不少初学者,发现大家卡住的往往不是链表、指针那些“大东西”,反而是scanf输入不对、printf输出乱格式、中文字符乱码这类基础问题。这篇文章就把这两个函数掰开揉碎讲一遍,从最基础的调用逻辑,到缓冲区、返回值、格式控制符,再到中文乱码、重定向、嵌入式场景下的printf改造,全部串起来讲。无论你是刚学C语言的大学生,还是准备计算机二级、蓝桥杯这类竞赛,或者工作中需要用C做嵌入式开发,这篇文章都值得收藏慢慢翻。

1. 先把基本功盘明白:scanf、printf的调用逻辑与格式说明符速查

1.1 scanf和printf到底是什么关系

很多人把scanf和printf当成两个孤立的函数,实际上它们是一对“镜像”操作:printf负责把内存中的数据按指定格式“格式化”后输出到标准输出(通常是屏幕),scanf负责从标准输入(通常是键盘)读取字符流,按指定格式“解析”后存进内存变量。

打个比方:printf像是在盖房子时把图纸画出来给别人看,scanf则是看图纸把房子结构记进脑子里。一个向外表达,一个向内解析。两者共用同一套“格式说明符”语法,也就是%d%f%s这些标记,但使用规则有本质区别,尤其是传参方式,这个后面详细说。

我还想强调一个底层概念:C语言中不存在真正意义的“输入输出语句”,一切IO都是函数调用。printf和scanf只是标准库函数,它们的核心工作依赖操作系统提供的文件描述符机制,stdin、stdout、stderr是三个默认打开的“流”。理解这一点,后面理解fprintf、freopen重定向会顺畅很多。

1.2 格式说明符速查表

我在教学中习惯让学生先背一张速查表,把常用格式说明符和它的含义对应起来。这里整理出最常用的部分,也是笔试面试最爱考的:

格式说明符含义典型用法
%d有符号十进制整数printf("%d", n);
%i等价于%d,scanf中略有区别scanf("%i", &n);
%u无符号十进制整数printf("%u", n);
%f / %lf单精度/双精度浮点数printf("%f", f);
%c单个字符printf("%c", ch);
%s字符串,按\0结束printf("%s", str);
%x / %X十六进制整数,小写/大写字母printf("%x", n);
%o八进制整数printf("%o", n);
%p指针地址printf("%p", ptr);
%e / %E科学计数法浮点数printf("%e", f);
%g / %G自动选%f或%e中更短的形式printf("%g", f);
%%输出一个百分号printf("100%%");

注意表格里%f%lf的对应关系。在printf中,float参数会被自动提升为double,所以%f可以输出float和double;但scanf必须严格区分,float%fdouble%lf,搞混了会直接导致内存写入错位,程序崩溃或者数据完全不对。这一点极容易踩坑,后面章节会用示例代码验证。

1.3 最小可运行的例子

先给个最简单的例子,把输入输出串起来跑一遍:

#include <stdio.h> int main(void) { int age; float height; printf("请输入年龄和身高(用空格或回车分隔):"); scanf("%d%f", &age, &height); printf("年龄:%d岁,身高:%.2f米\n", age, height); return 0; }

这里有几个细节值得注意:

  1. scanf的格式串里%d%f之间没有空格,但实际输入时可以用空格、Tab或回车分隔,因为scanf对于数值类型会自动跳过空白字符。
  2. printf里的%.2f表示保留两位小数,这是格式控制的常见用法。
  3. scanf必须传变量的地址&age&height,而printf直接传值ageheight,这个差异背后是C语言的“值传递”机制,下一章详细讲。

2. scanf的隐藏关卡:缓冲区残留、返回值与传地址这三个绕不开的坎

2.1 输入缓冲区:回车键按下之后究竟发生了什么

很多初学者会碰到一个经典问题:连续两次调用scanf读取字符,第一次输入完按回车,第二次scanf根本没执行就跳过了。这就是缓冲区残留问题。

键盘输入的数据并不会直接送到程序里,而是先进入一个叫“输入缓冲区”的内存区域。你按下回车键时,\n字符也会被写进缓冲区。scanf读取数据时,会根据格式说明符“消费”缓冲区里的部分内容,但消费规则各有不同:

  • %d%f会自动跳过前导空白字符(包括空格、Tab、换行),所以数值输入时回车残留在缓冲区通常不是问题。
  • %c只读取一个字符,不会跳过空白。第一次输入字符后按下的回车符\n,会被第二次的%c原样读走。

来看这个经典反例:

#include <stdio.h> int main(void) { char a, b; printf("请输入第一个字符:"); scanf("%c", &a); printf("请输入第二个字符:"); scanf("%c", &b); printf("a=%c b=%d\n", a, b); return 0; }

程序运行后,你输入x加回车,期待第二次输入y,结果发现第二个scanf直接读取到了\n。输出结果往往是b=10,也就是换行符的ASCII码。

解决办法也不复杂,在第二次scanf之前把缓冲区残留的清掉,常见做法:

while (getchar() != '\n');

这行代码会不断读取字符,直到遇到换行符为止,相当于把缓冲区“冲刷”干净。另一个方法是scanf格式串里加一个空格,让%c前导空格跳过空白字符:

scanf(" %c", &b);

格式串开头的空格告诉scanf先跳过所有空白字符再读取。这也是我在实际开发中最常用的做法,简单可靠。

2.2 返回值:判断成功还是要死循环的关键

scanf的返回值是成功匹配并赋值的输入项个数。比如scanf("%d%d", &a, &b)成功读到两个整数就返回2,只读到一个就返回1,没读到就返回0,读到文件结尾或发生错误时返回EOF(即-1)。

这个返回值在很多场景下是程序逻辑的核心。典型应用是多组数据输入的循环读取:

#include <stdio.h> int main(void) { int sum = 0; int num; while (scanf("%d", &num) == 1) { sum += num; } printf("总和:%d\n", sum); return 0; }

运行这段程序,不断输入整数,程序持续累加,直到你输入Ctrl+Z(Windows)或Ctrl+D(Linux)表示输入结束,循环退出。很多在线评测系统的多组输入题目就是这个套路。

还有一种写法是while (scanf("%d", &n) != EOF),这两种在绝大多数情况下等价,但严格来说!= EOF== 1有细微差别:如果输入了一个无法解析的字符(比如abc),scanf返回0,!= EOF仍会继续循环,导致死循环;== 1则会直接退出。从工程角度,我建议用== 成功匹配的数量这种写法更严谨。

那么问题来了:如果真输入了abc,scanf返回0之后,abc还留在缓冲区里,你怎么处理?这时候需要用while(getchar() != '\n');清掉这行残留,程序才能继续运行。否则下一次scanf读到a还是失败,陷入死循环。

2.3 为什么scanf传地址而printf传值

这个问题几乎每个初学者都会问。本质上C语言只有值传递一种参数传递方式,scanf要修改变量的值,就必须拿到变量在内存中的地址,通过指针间接写入。而printf只读取变量的值,不需要修改,所以直接传值即可。

我用一个类比帮助理解:你让快递员帮你取件(scanf),得告诉他地址(&a);你只是向别人展示你手里的东西(printf),直接把东西递过去(a)就行。

数组名在这个规则下有点特殊。char str[100]作为参数传给scanf时,直接写scanf("%s", str)而不是scanf("%s", &str),因为数组名在表达式中会退化为指向首元素的指针,本身就是一个地址值。写成&str反而会得到“指向整个数组的指针”,类型不匹配,在部分编译器下会有警告。这也是C语言初学者容易混淆的地方,记住一句话:数组名本身就是地址。

2.4 scanf读取字符串的边界问题

%s读字符串会遇到一个限制:遇到空格、Tab、换行就停止。所以scanf("%s", str)根本无法读入“Hello World”这样的带空格字符串,只会读到“Hello”。

如果要读入一整行,可以用gets(极不推荐,因为它不检查缓冲区长度,容易溢出,C11标准已删除),或者fgets

char str[100]; fgets(str, sizeof(str), stdin);

fgets会连换行符一起读入,所以输出时经常要多处理一个\n。也可以使用scanf的%[^\n]这种扫描集语法:

scanf("%[^\n]", str);

这个语法表示匹配除换行符以外的任意字符,可以读入带空格的整行。但注意它不会消费尾部的换行符,下一次读取前需要手动清掉。

我个人的建议是:工程代码里尽量用fgets读行,再用sscanf解析,这是最稳健的组合,后面第四章会专门讲sscanf的用法。

3. printf的格式控制打磨:宽度、精度、标志位与常见组合的真面目

3.1 格式串的基本结构:%[标志][宽度][.精度][长度修饰符]类型

很多人看printf格式串时只关注%d%f这些类型符,其实格式串还有一套完整的“修饰语法”:

%[标志][最小宽度][.精度][长度修饰符]类型

四部分都可以组合,我用几个例子拆解:

  • %-10d-是标志,表示左对齐;10是最小宽度,不足10位用空格补齐。
  • %08x0标志表示用0填充;8表示最小宽度为8位。
  • %8.3f8是最小宽度;.3是精度,表示保留3位小数。
  • %ldl是长度修饰符,表示long类型。
  • %zuz是长度修饰符,表示size_t类型。

这几个部分可以任意组合,理解这个结构后,看到再复杂的格式串也能一眼拆解。

3.2 宽度与对齐:%5d、%-5d、%05d背后的意义

宽度控制在实际项目中特别常用,尤其是打印表格、对齐日志时。看这段代码的输出结果:

#include <stdio.h> int main(void) { printf("|%5d|\n", 42); // 右对齐,宽度5,输出 | 42| printf("|%-5d|\n", 42); // 左对齐,宽度5,输出 |42 | printf("|%05d|\n", 42); // 右对齐,宽度5,空位补0,输出 |00042| printf("|%+5d|\n", 42); // 显示正号,输出 | +42| return 0; }

宽度指定的数值如果小于实际位数,宽度会被忽略,不会截断数据。比如%2d输出1234,结果就是完整的“1234”,不要指望它只显示两位。

0补位标志和-左对齐标志是冲突的,因为左侧对齐后右侧都是空格,没有补零的余地,实际行为由编译器决定,标准没有明确要求。所以别写%-05d这种无意义代码。

对齐格式在实际打印中很实用,比如打印学生成绩表:

printf("%-10s %6s %6s\n", "姓名", "语文", "数学"); printf("%-10s %6.2f %6.2f\n", "张三", 87.5, 92.0); printf("%-10s %6.2f %6.2f\n", "李四", 91.5, 88.5);

名字左对齐、成绩右对齐,整个表格看着非常舒服。

3.3 精度不是只对浮点数有效

说到精度,很多人只会想到%.2f保留两位小数。实际上精度对字符串和整数也有作用:

printf("%.5s\n", "hello world"); // 只输出前5个字符:hello printf("%.5d\n", 42); // 等价于%05d,输出00042 printf("%.2f\n", 3.14159); // 输出3.14

字符串截断功能在处理超长字符串时很实用,可以防止一大片文字刷屏。在处理用户输入或日志输出时,我经常用它限制长度,比如printf("%.80s\n", log_message),只打印前80个字符。

3.4 浮点输出的显示策略与十六进制风格

浮点数输出有几个容易混淆的点。%f默认保留6位小数,%e用科学计数法,%g会自动在两者之间选择更短的形式:

printf("%f\n", 3.1415926535); // 3.141593,四舍五入保留6位 printf("%.2f\n", 3.14159); // 3.14 printf("%e\n", 12345.6789); // 1.234568e+04 printf("%g\n", 12345.6789); // 12345.7,自动选择

十六进制输出对调试非常有帮助,%x配合#标志可以输出带0x前缀的形式:

int n = 255; printf("%#x\n", n); // 0xff printf("%#X\n", n); // 0XFF printf("%#o\n", n); // 0377

这里#标志的作用是让输出带上C语言风格的进制前缀。我在调试协议数据时经常用%02x这种格式,把字节以两位十六进制输出,方便对照协议文档:

unsigned char buf[] = {0xAA, 0x01, 0x02, 0x3C}; for (int i = 0; i < sizeof(buf); i++) { printf("%02x ", buf[i]); } printf("\n");

输出aa 01 02 3c,协议帧一目了然。

4. 从终端到文件和字符串:fprintf、fscanf、sprintf、snprintf的定位与取舍

4.1 printf和fprintf的关系:只不过流对象不同

理解了printf只是fprintf的一个特例,整个格式化输出家族就串起来了。printf本质上就是fprintf(stdout, ...),把格式化数据写到标准输出流。而fprintf可以把数据写到任何FILE*流,最常见的就是文件。

反过来,scanf就是fscanf(stdin, ...),从标准输入读取格式化数据。

这个认知在日常开发里非常有用。比如程序需要同时输出到屏幕和日志文件,fprintf可以一个操作同时搞定:

FILE *log = fopen("log.txt", "a"); if (log) { fprintf(log, "[INFO] %s:%d 用户登录成功\n", __FILE__, __LINE__); fclose(log); }

4.2 用fprintf/fscanf保存和加载带格式数据

我来写一个完整案例:把一组学生成绩保存到文件,再从文件读出来。这个过程展示了fprintf和fscanf的配合。

#include <stdio.h> #include <string.h> typedef struct { char name[20]; int score; } Student; int main(void) { Student students[] = { {"张三", 87}, {"李四", 92}, {"王五", 78} }; int count = 3; // 写入文件 FILE *fp = fopen("scores.txt", "w"); if (fp == NULL) { perror("打开文件失败"); return 1; } for (int i = 0; i < count; i++) { fprintf(fp, "%s %d\n", students[i].name, students[i].score); } fclose(fp); // 读回文件 Student read_students[10]; int read_count = 0; fp = fopen("scores.txt", "r"); if (fp == NULL) { perror("打开文件失败"); return 1; } while (fscanf(fp, "%19s %d", read_students[read_count].name, &read_students[read_count].score) == 2) { read_count++; } fclose(fp); // 打印读回结果 for (int i = 0; i < read_count; i++) { printf("%s: %d\n", read_students[i].name, read_students[i].score); } return 0; }

这里有几个细节值得展开:

  1. %19s限制字符串读取长度,防止超出name[20]缓冲区,这是很多开发者容易忽略的安全点。
  2. fscanf的返回值判断用的是== 2,确保每次迭代都完整读到了姓名和分数。如果文件意外损坏,循环能安全退出,不会用未初始化的数据。
  3. 我特意用了结构体存储数据,结构化读写可以让代码更清晰。

4.3 字符串格式化:sprintf的隐患、snprintf的正确姿势

sprintf把格式化的内容写入字符串缓冲区,它和printf的关系可以类比为:printf输出到屏幕,sprintf输出到内存字符串。这个函数在生成消息、拼接SQL、构建协议报文时非常常用。

但sprintf有个著名缺陷:不检查缓冲区长度,很容易导致缓冲区溢出,这是无数安全漏洞的根源。C99标准引入了snprintf,它多了一个size参数,指定最大写入字符数(包括末尾的\0):

char buf[64]; snprintf(buf, sizeof(buf), "温度:%.2f,湿度:%.2f%%", 25.36, 60.5);

这里sizeof(buf)告诉snprintf最多只能写63个有效字符加一个\0,超出的部分被安全截断。我见过不少老代码还在用sprintf,每次看都替他们捏把汗,一旦拼接的字符串长度超出预期,轻则数据损坏,重则程序崩溃甚至被利用执行任意代码。

另外一个容易忽略的点:snprintf的返回值是“如果缓冲区足够大,应当写入的字符数”,而不是实际写入的字符数。如果返回值为负数表示编码错误,如果返回值大于等于缓冲区大小,说明发生了截断。在严格要求完整性的场景(比如拼网络协议报文),你要检查这个返回值:

int len = snprintf(buf, sizeof(buf), "%s:%d", host, port); if (len < 0) { // 编码错误 } else if (len >= (int)sizeof(buf)) { // 截断了,说明buf太小 }

4.4 sscanf的“完整消费”检查

sscanf从字符串中解析数据,它的灵活度和fscanf一样,同时有一个很少有人用到的技巧:通过检查返回值加尾随字符占位,判断字符串是否被完整解析。

最常见的场景是解析用户输入,判断输入是否合法:

#include <stdio.h> int main(void) { char input[100]; int year, month, day; char extra; printf("输入日期(格式:2024-05-20):"); fgets(input, sizeof(input), stdin); // %d-%d-%d 解析日期,%c捕获后面多余字符 if (sscanf(input, "%d-%d-%d%c", &year, &month, &day, &extra) == 3) { printf("解析成功:%d年%d月%d日\n", year, month, day); } else { printf("格式不对,请按 2024-05-20 格式输入\n"); } return 0; }

关键点在extra这个“陷阱字符”。如果用户输入2024-05-20abc,sscanf的返回值是4,因为%c匹配到了a,这样就知道输入后面还有多余内容,可以拒绝处理。如果返回值是3,说明日期解析完正好到了字符串末尾,后面没有任何垃圾字符。

这个思路可以有效过滤掉“用fgets读入字符串,再用sscanf验证格式”场景下的无效输入,比单纯用%d%d%d解析要稳健得多。

5. 高频实战问题处理:中文乱码、printf重定向和循环读入的一揽子方案

5.1 中文乱码的根因与实用解法

中文乱码是C语言初学阶段最让人崩溃的问题之一,字符串明明写的是中文,运行出来全是锟斤拷或者���。出现这个问题的根本原因是“三方编码不一致”:源文件保存时的编码、编译器编译时认定的编码、控制台显示代码页,这三者只要有一个不匹配,就会乱码。

Windows下最容易出问题。我用几种常见环境说明:

  1. MSVC(Visual Studio):默认源文件可能是GBK/GB2312保存,控制台默认代码页936(GBK),一般没事。但如果你把源文件另存为UTF-8(不带BOM),MSVC会按本地代码页去解释,中文就变乱码了。
  2. MinGW GCC + VSCode:VSCode默认UTF-8,GCC也按UTF-8解释源文件,但Windows终端默认代码页是GBK,输出UTF-8的字节序列就被解析成乱码。
  3. Linux + GCC:整个链路几乎都是UTF-8,一般不会乱码。

最简单的处理思路:统一到UTF-8。源文件保存为UTF-8,编译器按UTF-8处理,终端代码页切到65001(UTF-8)。

具体操作上,Windows终端可以在程序里调用API切换代码页:

#include <stdio.h> #ifdef _WIN32 #include <windows.h> #endif int main(void) { #ifdef _WIN32 SetConsoleOutputCP(CP_UTF8); #endif printf("你好,世界\n"); return 0; }

也可以直接在终端执行chcp 65001手动切换。如果用的是VSCode,把终端设置成默认65001,或者在设置里加"terminal.integrated.profiles.windows"配置项,也可以避免手动切换的麻烦。

还有一个坑很多人没意识到:UTF-8的中文字符串占用的字节数和GBK不一样。比如sizeof("你好")在UTF-8下是7(6个字节加结尾\0),在GBK下是5(4个字节加结尾\0)。如果你用char str[5]去存“你好”,在UTF-8环境下会溢出。处理中文字符串时,务必预留足够空间,不要按字符数估算字节数。

5.2 printf重定向:嵌入式串口、文件日志与自定义输出

printf默认输出到标准输出流,但在嵌入式开发中,单片机没有屏幕,printf输出的内容默认会被丢弃。这时候就需要“重定向printf”,让它输出到串口、LCD显示屏或其他外设。

不同编译环境的实现方式不同。ARM GCC环境下,典型做法是重实现_write函数:

#include <stdio.h> #include <stdarg.h> // 假设这是你的串口发送单字节的函数 void uart_send_char(char c); int _write(int file, char *ptr, int len) { for (int i = 0; i < len; i++) { uart_send_char(ptr[i]); } return len; }

有了这个底层重定向,printf就会自动把内容逐字节发送到串口。这是嵌入式开发中最常用的调试手段,看串口助手打印日志,比用烧录器断点调试方便得多。

另一种“重定向”是把printf输出转到文件,借助freopen:

#include <stdio.h> int main(void) { // 把stdout重定向到文件 FILE *fp = freopen("output.txt", "w", stdout); if (fp == NULL) { perror("freopen失败"); return 1; } printf("这条内容会写入文件,而不是屏幕\n"); fclose(fp); return 0; }

这个技巧在自动化测试、批量处理数据时非常好用,程序逻辑完全不变,只是输出的流向变了。

如果是希望printf同时输出到多个地方,或者想增加时间戳、日志级别,直接重定向就不够了。更通用的做法是在printf外面包一层自己的日志函数,利用C语言的可变参数机制实现。这个我在第6章给出完整模板。

5.3 scanf循环读入的标准模板与异常输入处理

很多做题和实际开发场景下,需要连续读取未知数量的输入,直到文件结束。前面提到的while (scanf(...) == n)是最基础的模板,但真正的工程场景要考虑“输入错了怎么办”。

这段代码展示了一个相对健壮的循环读入:

#include <stdio.h> int main(void) { int num; int result; int count = 0; printf("请输入若干整数(输入非数字结束):\n"); while ((result = scanf("%d", &num)) == 1) { count++; printf("第%d个数:%d\n", count, num); } if (result == 0) { printf("检测到非数字输入,程序结束\n"); // 清掉残留的无效输入,避免影响后续逻辑 while (getchar() != '\n'); } else { printf("检测到EOF,程序结束\n"); } return 0; }

这个例子里result保存了scanf的返回值,分三种情况处理:返回1说明读取成功;返回0说明缓冲区里第一个字符就无法解析,通常意味着输入了非数字;返回EOF说明输入流已经结束(Ctrl+Z/Ctrl+D)。分开处理能给出更明确的提示,也方便后续清理残留。

另外提一个scanf与gets混淆的经典错误:scanf("%d", &n)可以读取数字,但数字后面的换行符会留在缓冲区里。如果你紧接着用getsfgets读字符串,会直接读到一个空行。这时候还是老办法,在切换读取方式前用while(getchar() != '\n');清掉缓冲。

6. 进阶认知与个人习惯:参数提升、未定义行为与日志打印的工程化落地

6.1 printf背后的可变参数与默认参数提升

printf能处理任意数量和类型的参数,靠的是C语言的“可变参数”机制。它的原型是:

int printf(const char *format, ...);

...代表不定数量的参数。在可变参数函数内部,参数会经历“默认参数提升”:float提升为doublecharshort提升为int。这就解释了为什么printf里%f可以同时输出float和double,因为float进函数时已经变成double了。同理,%d也可以输出char类型。

这也解释了为什么scanf浮点类型不能混用%f%lf:scanf接收的是指针,不存在参数提升,它必须知道指针指向的是4字节的float还是8字节的double,才能正确写入数据。如果你用%f去读double变量,scanf只会写入前4字节,整个变量的值就废了。

理解这一点后,你还能看懂一个经常出现在面试题里的坑:printf使用%d输出long类型,或者用%f输出float变量时如果精度不对,都是类型不匹配的问题。正确的做法是注意长度修饰符:long%ldlong long%lldsize_t%zu

6.2 参数求值顺序是未定义行为,别在printf里写i++

有一个经典坑几乎人人都跳过:在printf参数里写自增表达式会怎样?

int i = 5; printf("%d %d\n", i++, ++i);

这段代码在不同编译器下可能输出5 76 65 6等各种结果,因为C语言标准规定:函数参数的求值顺序是“未指定行为(unspecified behavior)”,编译器可以自由决定从左到右还是从右到左求值。正常情况下顺序不重要,但一旦参数里有i++++i这类有副作用的表达式,结果就完全不可预测了。

我自己用GCC和MSVC实测过,同一个表达式在两套编译器下输出不一样。所以遇到这种代码,最好的处理方式是两个字:别写。把自增拆到独立的语句里:

printf("%d\n", i); i++;

这个原则也适用于表达式里的嵌套赋值、函数调用等副作用操作。面试问“这段代码输出什么”的时候,可以直接回答“未定义行为,无法确定”,这才是正确答案。

6.3 工程化日志打印的一个小模板

了解了printf的重定向和可变参数机制后,可以做一个实用的小工具:一个带日志级别和时间戳的日志函数。这个模板我在各类项目中反复用,无论PC还是嵌入式都能改造适配。

#include <stdio.h> #include <stdarg.h> #include <time.h> #define LOG_DEBUG 0 #define LOG_INFO 1 #define LOG_WARN 2 #define LOG_ERROR 3 static int log_level = LOG_DEBUG; void set_log_level(int level) { log_level = level; } void log_message(int level, const char *fmt, ...) { if (level < log_level) { return; } const char *level_str[] = {"[DEBUG]", "[INFO]", "[WARN]", "[ERROR]"}; time_t now = time(NULL); struct tm *tm_now = localtime(&now); char time_str[32]; strftime(time_str, sizeof(time_str), "%Y-%m-%d %H:%M:%S", tm_now); printf("%s %s ", time_str, level_str[level]); va_list args; va_start(args, fmt); vprintf(fmt, args); va_end(args); printf("\n"); } int main(void) { log_message(LOG_INFO, "程序启动"); log_message(LOG_DEBUG, "调试信息:变量x=%d", 42); log_message(LOG_WARN, "警告:内存使用率%.1f%%", 85.3); log_message(LOG_ERROR, "错误:文件打开失败"); set_log_level(LOG_WARN); log_message(LOG_DEBUG, "这条不会显示"); log_message(LOG_ERROR, "这条会显示"); return 0; }

这里用到了vprintf,它是printf的“可变参数版”,接收va_list类型的参数。整个模式可以灵活扩展:把vprintf换成vfprintf(fp, ...)输出到文件,或者换成自定义的重定向函数输出到串口,日志系统就搭起来了。

实际工作中我会在这个基础上再加三个能力:按日期自动切换日志文件、日志文件大小限制、线程安全(加锁)。对于小型项目和嵌入式设备,这个雏形已经足够用了。

6.4 我在实际使用中积累的几个小习惯

到这里,scanf和printf的用法基本讲透了。最后分享几个我个人写C代码多年的小习惯,不一定人人都认同,但确实帮我省了很多调试时间。

第一,scanf的返回值永远不忽略。凡是用了scanf,就一定判断返回值。哪怕只是单变量输入,返回0或者EOF都意味着程序状态已经不受控制,继续往下走只会产生更多问题。

第二,能用snprintf绝不用sprintf。这个没有任何妥协余地,缓冲区安全是现代C编码的底线。

第三,printf的宽度精度控制尽量用上。调试数据按固定宽度输出,十六进制用%02x统一两位,浮点用%.2f限制小数位数,这些细节一旦养成习惯,输出会规范很多,排查问题也快。

第四,日志输出保留时间和级别信息。不要打印裸的消息,加上时间戳和级别,问题定位效率翻倍。这不需要多复杂的框架,上面那段模板就够了。

第五,遇到scanf和printf相关的问题,先查缓冲区、再查格式串、最后查类型匹配。这个排查顺序能覆盖九成以上的格式输入输出问题。

十几年前我自己学C语言时,也被scanf的缓冲区坑得死去活来,当时没有这么系统的梳理,全靠一点一点踩坑积累。现在把这些经验写出来,希望你能少走一些弯路。这两个函数虽然基础,但用好了,整个程序的健壮性和可维护性都会上一个台阶。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询