在C语言里printf大概是生命周期最长的一个函数。我入行写的第一行代码就是printf(hello world\n)但真正把这行代码背后的格式化规则吃透已经是很多年之后的事了。教科书上一笔带过的%d、%f、%s看起来人畜无害可一旦要打印一张对齐的报表、一帧协议报文、一段十六进制哈希摘要或者要把日志输出量精确统计出来就会发现printf的格式串远不止“找个地方填值”那么简单。这篇内容不是入门扫盲而是把我这些年实际用printf的经验按“格式串语法、六个高频场景、五个典型坑”整理出来。你不需要一次记住全部细节但理解这一整套通用的格式化规则之后调试日志、数据对齐、跨平台打印这些问题都会顺很多。1. 别把printf当成简单的管道输出1.1 一条格式串拆开其实是五个独立字段printf的格式串由两大部分组成普通字符和转换说明。普通字符原样输出转换说明以%开头最多可以拆成六段。我自己记了一句口诀百分号开头接着是标志、宽度、点精度、长度修饰、转换类型。比如这条代码printf(%-8.3lf|%-12s, 3.14159, hello);拆开看就非常清晰%开头-是标志左对齐不写默认右对齐8是宽度整个字段至少占8个字符不够用空格补.3是精度小数点后保留3位l是长度修饰符对应long类型配合f表示doublef是转换类型拆开之后任何奇怪的格式串都能逆推出来。我见过很多人在网上搜“C语言打印两位小数怎么写”其实就是%.2f这一小段。他们之所以要搜是因为把格式串当成整体在背没有意识到%和f之间还有几个可以自由组合的槽位。一旦理解了槽位就有了组合能力而不是背模板。理解这套结构还有一个更实际的意义printf是按格式串去读取可变参数的格式串里的每一个槽位都在“约定”后面参数的类型和显示方式。槽位写错后面参数全都会错位而且程序不一定报错。后面第4章会详细讲这类事故。1.2 可变参数机制为什么float在printf里按double传printf是一个可变参数函数原型是int printf(const char *restrict format, ...)。C标准对可变参数有一个非常关键的规则叫“默认实参提升”在传入可变参数列表时float会提升为doublechar和short会提升为int。这个机制解释了为什么printf里用%f打印float和double都能正常工作——因为float早就被提升成了doubleprintf内部读取的始终是double。同理printf用%c打印一个char时实际收到的是一个int。但同样的占位符体系在scanf里完全是另一回事。scanf没有可变参数的默认提升机制读取float必须用%f读取double必须用%lf用错就可能读到错误的内存宽度轻则数据错乱重则整个程序崩溃。这一点是无数初学者踩坑的源头。我当年调试一个输出乱七八糟温度值的程序最后发现只是printf写错了格式它不会编译报错但运行结果就是不对。打个比方快递单上的收件人信息填错了快递员并不知道包裹里本来是什么只能按装箱单上的描述去处理。printf就是那个快递员%d、%f、%s就是装箱单上的描述描述错处理结果必然错。2. 占位符里的每个槽位都值得重新认识2.1 五个标志位对齐、正负号、前缀、补零标志位一共有五个作用很直观但很多人从来没系统看过。先看这张速查表标志含义示例输出-左对齐printf(%-5d|, 12)12 |正数也显示正号printf(%d, 12)12空格正数前面留一个空格printf(% d, 12)12#备选形式十六进制加0x八进制补前导0printf(%#x, 255)0xff0用0填充空白printf(%05d, 12)00012这里有几个细节是新手最容易搞混的。第一-和0同时出现时0直接失效。原因很简单左对齐之后字段后面没有填充区补零无从谈起。所以printf(%-05d, 12)输出的不是00012而是12 。第二和空格同时出现时空格失效。正负号的显示优先级比空格高写printf(% d, 12)依然输出12。第三#标志在不同转换类型下行为不同。对十六进制%x非零值前面会加0x对八进制%o会强制让结果以0开头对浮点%f会强制显示小数点即使小数位是0。比如printf(%#.0f, 3.0)输出3.没有#则输出3。第四个坑隐蔽一些0标志在整数转换里和精度同时出现时会被精度“压过”。因为精度本身已经规定了最小数字位数前导零由精度负责0标志就不再追加填充。printf(%05.3d, 12)输出的不是00012而是 012。这些行为在C标准文档里都有明确描述但平时没人会去翻标准。知道它们是标准行为而不是编译器bug遇到意外输出时就不会慌了。2.2 宽度与精度搞懂这两栏格式就通了一半宽度指的是“整个输出字段的最小字符数”不是最大字符数。如果实际内容比宽度还长宽度会被忽略不会截断。比如printf(%4d, 12345)会完整输出12345。很多新手以为宽度会限制数字位数这是误区。精度就复杂多了它根据数据类型有三种完全不同的含义数据类型精度作用示例输出整数最少数字位数不足补零不截断printf(%5.3d, 12)012浮点小数点后的位数printf(%.2f, 3.14159)3.14字符串最多输出的字符数超出截断printf(%.5s, hello world)hello注意整数的精度填的是“最少位数”和宽度的“空间占位”是两个维度。%5.3d表示数字本身至少3位整个字段占5位所以数字部分补零成012再整体右对齐补两个空格。字符串的精度恰好和整数相反它限制的是“最多能打多少字符”。打印超长字符串时这个特性非常有用后面第三个实战场景会详细讲。还有一个冷知识当整数精度为0且待打印的数也是0时不会输出任何数字。printf(%.0d, 0)的输出是空字符串而不是0。某些老的格式化约定在数学上很别扭但C99之后标准统一了这种行为。时间日期格式化是宽度和精度的经典用途比如printf(%04d-%02d-%02d, 2024, 3, 5)输出2024-03-05。没有%02d你得到的是2024-3-5日志排序时就会错乱。2.3 转换类型与长度修饰符对症下药转换类型决定怎么解释参数长度修饰符决定参数在内存中的尺寸。先看转换类型%d、%i有符号十进制整数printf里两者等价%u无符号十进制%o八进制%x、%X十六进制大小写区别%f、%F十进制浮点%e、%E科学计数法%g、%G自动选择%a、%A十六进制浮点%c字符%s字符串%p指针地址%n把已打印字符数写入一个int变量%%打印一个百分号长度修饰符则是一组“尺寸标记”告诉printf这个参数按多大的类型去读hhsigned char或unsigned charhshort或unsigned shortllong或unsigned long宽字符时也可配合%slllong long或unsigned long longLlong doublezsize_ttptrdiff_tjintmax_t或uintmax_t最常用的就是%zu打印size_t。strlen返回的就是size_t在64位平台上是8字节如果用%d去打印%d只读取4字节值超过2^32时结果被截断。我在很多老代码里见过这种写法短字符串碰巧没问题一处理长文本就出怪事。专门提一下inttypes.h头文件里的PRIu64、PRId64这些宏。它们解决的是跨平台类型宽度不一致的问题后面第4章会有详细例子。这个头文件的价值被严重低估了。3. 六个高频场景可以直接抄的printf写法3.1 对齐输出一个表格打印表格是我最常遇到的printf场景。基础模板长这样printf(%-4s %-10s %10s\n, ID, Name, Price); printf(%-4d %-10s %10.2f\n, 1, Apple, 3.5); printf(%-4d %-10s %10.2f\n, 12, Banana, 1299.99);效果大致是ID Name Price 1 Apple 3.50 12 Banana 1299.99这里的设计思路是ID和Name用左对齐方便阅读Price用右对齐让小数点对齐方便比较大小。%10.2f的意思是整个数字占10个字符保留两位小数右对齐。有个大坑必须提醒printf的宽度是按字节数计的不是按显示宽度。一个中文字符在UTF-8下占3个字节直接写%15s去对齐中文名视觉上会差出好几个字符位。我处理中英文混排时一般先写个辅助函数把字符串的显示宽度算出来再手动补空格。别指望printf对中文自动对齐。3.2 打印十六进制与内存地址协议调试和三字节数组打印几乎都绕不开十六进制。最标准的写法是%02xunsigned char mac[6] {0xde, 0xad, 0xbe, 0xef, 0x12, 0x34}; printf(MAC %02x:%02x:%02x:%02x:%02x:%02x\n, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);%02x表示十六进制、至少两位、不足补零。输出deadbeef1234这种格式正好是MAC地址、哈希摘要、CRC校验码的标准打印姿势。打印指针则要专门用%p。注意%p的参数必须是void*所以规范写法是printf(%p\n, (void *)ptr)。%p的输出格式在不同平台上是实现定义的glibc下通常输出0x开头的十六进制。如果你需要严格可控的日志格式可以先把指针强转成uintptr_t再用十六进制打印。调试协议时我还经常需要打印二进制位但标准库没有%b转换类型只能自己写循环移位判断或者用掩码逐位拼成0/1字符串。伪代码是for (int i bits-1; i 0; i--) buf[i] 0 ((x i) 1);。这种自给自足的写法在嵌入式调试里很常见。3.3 打印浮点数位数与误解最常用的需求是保留两位小数printf(%.2f, salary)。但浮点打印有几个深层规则很多人没有系统了解。默认的%f会打印6位小数所以printf(%f, 3.14)输出的是3.140000而不是3.14。这不是“四舍五入成3.14”而是固定打印6位。%g更适合快速查看数值。它会自动根据指数选择科学计数法还是小数表示法还会去掉末尾无意义的零。例如%g打印3.140000得到3.14打印1234567.0得到1.23457e06。这个格式在调试日志里比%f舒服很多。还有两个更进阶的知识点。一是想无损打印double的二进制值用%a打印十六进制浮点或者用%.17g打印足够精度的十进制都能保证读回来得到完全相同的double。二是不要对浮点十进制的“精确性”有任何幻想printf(%.20f, 0.1)通常会输出0.10000000000000000555这是二进制浮点表示0.1时的固有误差。金融计费这类对精度敏感的代码绝不能用浮点加减乘除再靠printf取整。3.4 用星号实现动态宽度和精度很多场景里宽度和精度是运行期才能确定的这时可以用星号占位int width 10; printf(%*d\n, width, 42); // 相当于 %10d printf(%.*f\n, 2, 3.14159); // 相当于 %.2f printf(%-*s|\n, 12, left); // 相当于 %-12s如果宽度和精度都想动态指定就按顺序提供两个参数printf(%*.*f, 10, 3, 3.14159)。这种写法在日志系统里尤其好用可以根据日志级别动态决定缩进宽度根据配置决定浮点打印精度。对比一下自己拼格式串的做法先char fmt[32]; snprintf(fmt, sizeof(fmt), %%%ds, width); 再printf(fmt, str)。用星号可以直接免掉这层拼接代码更干净也不容易写错。3.5 截断字符串与摘要显示调试时打印超长字符串特别是数组、协议payload、大段JSON会把终端的滚屏刷到想哭。字符串精度正好用来限制长度const char *data GET /index.html HTTP/1.1\r\nHost: example.com\r\n; printf(%.28s\n, data); // 只打前28字节 printf(%.*s...\n, 24, data); // 前24字节加省略号像grep一样注意%s在遇到二进制缓冲区时非常危险。缓冲区没有以\0结尾直接用%s会一直读下去直到碰上随机内存里的某个零字节这段不可控的输出可能很长也可能触发越界。安全做法是交给长度限定printf(%.s, (int)len, (char)buf)或者直接fwrite(buf, 1, len, stdout)。我调试二进制帧时踩过不止一次这种坑结构体里某个字符串字段没置\0结果printf一路打出来一堆乱码。3.6 用printf返回值统计日志体量printf是有返回值的。成功时返回已输出的字符数出错时返回负值。这个返回值可以用来做很多事。比如把printf封装成统一的日志入口顺便返回本次日志的字节数static int log_msg(const char *fmt, ...) { int n; va_list ap; printf([%s:%d] , __FILE__, __LINE__); va_start(ap, fmt); n vprintf(fmt, ap); va_end(ap); return n; }写日志框架时可以用这个返回值累计当天日志总字节数达到阈值就触发日志轮转。这个思路很实用。还有一个基于printf返回值但更冷门的东西%n。它不打印任何内容而是把目前为止已经打印的字符数写入一个int变量。例如int len 0; printf(abc%n, len); // len 3%n在安全视角下属于“能写内存”的危险能力平时不要乱用但了解它可以帮你理解printf的溢出。4. 踩坑清单这些坑我基本都踩过4.1 为什么printf半天什么都不输出最常见的原因不是格式串写错而是标准输出缓冲区没来得及刷新。stdout在连接终端时通常是行缓冲遇到换行符会刷新一旦被重定向到管道或文件就变成全缓冲缓冲区可能要到几KB甚至更大才刷一次。所以你的程序连续打印多条日志如果一直不带\n又遇到进程崩溃最后几条就丢了。解决办法不外乎三招日志统一带\n关键节点调fflush(stdout)进程启动后可以用setvbuf(stdout, NULL, _IOLBF, 0)把stdout强制设为行缓冲不过重定向到文件时仍然可能变回全缓冲需要自己有意识。还有一类典型场景是进度条。用\r回到行首实现原地刷新但同时意味着没有\n如果不调用fflush终端上只会看到静止画面。4.2 类型不匹配程序不报错但输出全乱printf是可变参数函数编译器默认不检查格式串和后面参数是否匹配。printf(%d, 3.14)或printf(%f, 42)都能编译通过但运行结果是按占位符类型去解释内存里的位模式得到的基本是一堆随机值。参数个数多一个少一个也一样会错位。我举几个经常遇到的例子。printf(%f, 1/2)输出0.000000看起来像是格式写错其实是1/2的整数除法先得到了0再去格式化这个0。运算问题伪装成了格式问题。printf(%d, strlen(s))在64位平台上不合法。strlen返回size_t8字节%d只读低4字节。字符串长度小于2^32时碰巧能对大字符串一上来就错乱。正确写法是%zu。还有一个常见混淆float变量传给printf使用%f没问题因为float被提升成double但绝对不要用%f去配合scanf读取double。scanf没有提升机制%f读取4字节double需要8字节写错必然出问题。我的经验是编译时开-Wall -Wextra再加-Wformat让编译器帮你核对占位符类型能拦住大部分隐患。4.3 跨平台uint64_t到底该用%lu还是%llu这是被问烂的问题根源在于long在Windows和Linux上的宽度不一致。Linux上使用LP64模型long是64位Windows使用LLP64模型long依然32位64位整数只有long long和指针。于是同一个uint64_t在某平台的typedef可能是unsigned long在另一个平台上却是unsigned long long。如果你写死%lu总有一边的平台编译不匹配。C99给出的标准解法是inttypes.h里的可打印宏#include inttypes.h uint64_t x 0xdeadbeefdeadbeefULL; printf(x % PRIu64 \n, x); printf(x % PRIx64 \n, x);PRIu64在Linux下展开为lu在Windows下展开为llu宏展开后和前面的%拼成正确格式串。打印size_t同样有%zu这个标准写法不过直到MSVC 2015之后的版本Windows平台才对%zu有完整支持。我建议跨平台项目里所有整数类型都用相应宏或%zu、%td这套标准写法而不是赌long的宽度。4.4 让编译器帮你看格式串与其自己对着格式串和后边参数肉眼检查不如直接把编译选项打开gcc -Wall -Wextra -Wformat -o app main.c-Wformat专门检查printf、scanf、strftime这类函数里占位符和实参是否匹配。printf(%d, (size_t)1)在这种检查下会立刻报警。如果是自己封装的日志函数可以用编译器属性把格式串信息传给它static int log_msg(const char *fmt, ...) __attribute__((format(printf, 1, 2)));第一个1表示fmt是第1个参数第二个2表示可变参数从第2个参数开始。声明之后log_msg里的格式串也会享受-Wformat的检查所有日志调用都会在编译期被审查类型。这个能力让我少了很多半夜排查数据的经历。4.5 安全底线格式串绝不能来自用户输入最后必须提一条红线格式串只能由开发者静态提供用户输入、网络数据、日志内容都只能作为可变参数传进去。如果直接把外部输入当格式串用printf(user_input); // 危险攻击者可以用%x读取栈上数据用%s读取任意内存内容甚至用%n往指定地址写数据。这类问题有一个专门的安全术语叫格式化字符串漏洞。正确写法永远是把外部数据当成参数传给%sprintf(%s, user_input);写日志函数时同样要注意别写log(msg)直接把消息体当格式串要写log(%s, msg)。记住这条%永远是你自己写的格式外部数据永远是参数。这条底线别破。最后说一个我自己的使用习惯。写一行printf之前我会先在注释里把想要的输出样子写出来再倒推格式串。想打印时间戳加级别加消息就确定%s %-5s %s\n的组合想打印表格就先把每一列宽度画出来再套宽度和精度。平时只写printf(%d, x)可能看不出差距但真到排查棘手问题、需要日志又好读又稳定时这个习惯能省下大量时间。printf这门手艺规则其实不多抽时间系统过一遍比每次遇到问题都现搜现查要划算得多。