
开头就直接进入场景不说废话。这其实是我去年 code review 时被同事怼了一句“你%d打出的是个啥”之后花了一下午把整个项目里所有size_t打印全部翻出来重改的起因。size_t是 C 语言里几乎天天碰的类型——sizeof的返回值、strlen的返回值、malloc的参数、数组下标的计算全是它。但我发现很多人对这个类型的认知就停留在“它是个无符号整数”真正写打印和格式化时还在下意识用%d、%u甚至%lu结果就是编译器警告一片程序输出莫名其妙严重的时候直接在 64 位系统上读出垃圾值。这篇就把size_t和格式化占位符%zu这件事彻底讲透包括底层原因、正确写法、scanf 端的坑、跨平台移植方案以及怎么用编译器武装自己。1. 一个%d引发的连环崩溃size_t 打印事故现场我先还原一个真实事故发生过程。之前在做网络报文解析模块代码里有这么一段#include stdio.h #include string.h int main(void) { char buf[64] hello world; size_t len strlen(buf); printf(buffer length: %d\n, len); return 0; }这段代码在 32 位 ARM 板上跑了两年没事直到某天有人把同一个模块编到 64 位 x86 服务器上。编译时冒出来一条警告warning: format ‘%d’ expects argument of type ‘int’, but argument 2 has type ‘long unsigned int’ [-Wformat]当时团队里有人觉得“警告而已不影响功能”直接忽略了。于是诡异的事情来了程序里凡是报文长度超过 4GB 的场景打印出来的长度全是负的或者一个跟实际值毫无关系的巨大整数。为什么因为%d在 64 位平台上只能读到栈上前 4 个字节的数据而size_t是 8 字节的无符号整数两边的内存解释方式完全对不上。这个例子不是个别现象。我见过有人用%u打印len在 64 位 Linux 上看到的是把size_t的低 4 字节当成unsigned int输出也见过有人在 Windows 上明明用%lu却依然打错因为 MSVC 的size_t在 64 位下映射到的是unsigned long long而%lu对应unsigned long。这类问题最讨厌的地方在于结果不总是一样的——有时候输出刚好“看起来对”出现幻觉般的正确有时候完全错乱让人怀疑是逻辑 bug 而不是格式化问题。排查这种问题有个老办法先把格式化符号放一边直接看类型本身是怎么定义的。我们下面从编译器的角度把size_t的底裤扒干净。2. 为什么 size_t 不能和 %d 混用底层类型定义的真相2.1 size_t 的出身typedef 链size_t不是一个 C 语言关键字它是一个通过typedef定义的别名。标准只规定“size_t是sizeof运算符结果的类型能够表达对象大小的最大范围”但具体落在哪个基础类型上由每个平台自己决定。打开你最熟悉的头文件大概率能找到类似这么一行// 64位 Linux glibc 里的常见定义 typedef unsigned long int size_t;而 32 位 Windows / Linux 上它是typedef unsigned int size_t;MSVC 的 64 位 Windows 上它又是unsigned __int64映射成unsigned long long。也就是说size_t在不同平台上是三张不同的脸32 位环境unsigned int4 字节64 位 Linuxunsigned long8 字节64 位 Windowsunsigned long long8 字节%d固定要求参数类型是int也就是 4 字节有符号整数。当 8 字节的size_t传给%d变参函数printf 那一类拿到的是栈上或寄存器里的原始字节然后按int的规则去解释其中的一部分。这是未定义行为——不是“警告一下就行”的瑕疵而是标准明确禁止的操作后果完全不可预测。2.2 整数提升与变参的陷阱很多人说“我加了强制转换就没事了”比如printf(%d, (int)len);这个写法能消除警告但它引入了另一个问题如果len的真实值大于INT_MAX强制转换会把高字节丢掉打印出来的是一个翻转后的负数。把类型隐患转成数据精度的隐患本质上还是在埋雷。正确思路不是靠转换去迁就不正确的占位符而是让占位符去匹配真实的类型。还有一种情况容易踩坑表达式里混合了size_t和普通整数整体提升到size_t但人还按int去预期结果。size_t a 10; int b -5; if (a b 0) { printf(positive\n); } else { printf(non-positive\n); }因为b先被提升成无符号size_t变成 18446744073709551611a b的结果又大又正输出永远是positive。这类 bug 和格式化无关但同样是“类型不匹配”的连锁反应我在 2.1 里提到的项目里就发生过类似逻辑误判。3. %zu 的正确打开方式printf 与 scanf 的使用细节3.1 printf 家族%zu、%zx、%zd%zu的存在就是为了精确匹配size_t。它的规则很简单z是一个长度修饰符含义是“后面的整数类型是size_t或对应的有符号版本ssize_t”所以%zu无符号size_t%zx无符号size_t的十六进制形式%zd有符号版本对应ssize_t或ptrdiff_t一类日常用得最多的是%zu。strlen的结果、sizeof的结果、vector 容量、文件偏移、报文长度这类一律用%zu是绝对安全的。char text[] hello; size_t n sizeof(text) / sizeof(text[0]); printf(element count: %zu\n, n); printf(hex address offset: %zx\n, (size_t)0x1A2B);这里有朋友会问%zd里的z是给有符号数用的那sizeof永远是非负的是不是就没必要%zd了对%zd的正确用途是打印ptrdiff_t——两个指针相减的结果类型。比如int arr[10]; int *p arr[3]; int *q arr[8]; ptrdiff_t diff q - p; printf(diff %zd\n, diff);如果你在打印指针差值时用了%ld或者%d又会重新掉进平台差异的坑32 位上ptrdiff_t是int64 位 Linux 是long64 位 Windows 是long long。%zd把这些差异全部吸收掉。3.2 sizeof 到底该用什么格式sizeof的返回值类型是size_t这一点从 C89 开始就没变过。我在网上还看到有人争论“sizeof应该用%lu因为 Linux 上它是 unsigned long”。这在 64 位 Linux 的 glibc 上确实“碰巧能用”但换到 Windows 就翻车。所以如果你是写跨平台代码别赌“我永远不会换平台”sizeof的打印一律%zu才是从类型层面锁死正确性。顺带提一个很多人不知道的细节在 C11 里sizeof的结果是编译期常量变长数组 VLA 除外所以你可以写printf(%zu, sizeof(int))编译器在编译期就确定了类型不会产生任何运行时额外开销。%zu不是“慢”的代名词它和%d在性能上的差距可以忽略。3.3 scanf 配套%zu 的输入侧应用格式化不止发生在输出端。scanf读入size_t变量时同样要配套%zu。#include stdio.h int main(void) { size_t count 0; printf(请输入元素个数: ); if (scanf(%zu, count) ! 1) { printf(读取失败\n); return 1; } printf(你输入的是: %zu\n, count); return 0; }注意scanf的%zu要求你把size_t变量的地址传进去这个地址的类型必须是size_t*。如果写成unsigned long n; scanf(%zu, n); // 错误类型不匹配编译器同样会警告因为unsigned long*和size_t*在类型系统里是不同指针。这里更危险——scanf因为用户输入导致溢出时直接向不匹配的指针位置写入数据会破坏栈上相邻变量甚至产生可利用的内存漏洞。早期做题库“在霍格沃茨找零钱”这类题目时很多学生用%d读金额然后赋值给size_t一旦输入超过INT_MAX就得到负值转换后的巨大无符号数整个找零逻辑立刻崩掉其实就是这里埋的雷。4. 跨平台与进阶从 %zu 到 PRIuMAX 的移植方案4.1 如果编译器太老不支持 z 修饰符怎么办C99 才把z修饰符纳入标准。遇到自称“支持 C99”但实际实现残缺的老式编译器%zu可能打出 0 或者乱码。这种情况常见于嵌入式行业——有些芯片厂商的 GCC 魔改版本停留在 C89 或 C99 的早期实现上。老代码里的兜底方案是%lu 强制转换但要注意C 标准只保证 unsigned long 至少能容纳 32 位不保证一定能容纳 size_t 的完整范围。严格可移植的做法是转成uintmax_t用PRIuMAX宏#include stdio.h #include stdint.h #include inttypes.h size_t len something; printf(len % PRIuMAX \n, (uintmax_t)len);PRIuMAX是inttypes.h里定义的宏在对应平台上会展开成正确的格式字符串比如lu或llu。这段代码在 32 位、64 位、Windows、Linux 上都能正确编译运行代价只有一个需要一个不损失信息的强制转换。由于uintmax_t一定不小这个转换总是安全的。不过PRIuMAX的语法看起来确实丑字符串字面量拼接很容易看走眼。我的建议是新代码、现代编译器直接%zu干净利落要维护老代码、跨编译器的库上PRIuMAX或检测宏__STDC_VERSION__4.2 一个实际的跨平台测试对比我在同一份代码里分别用%d、%lu、%zu打印同一个size_t变量在 Ubuntu 64 位和 Windows 64 位下跑出来的结果如下写法Ubuntu 22.04 (gcc 11)Windows (MSVC 2022)是否安全printf(%d, len)随机低4字节值随机低4字节值否printf(%u, len)随机低4字节值随机低4字节值否printf(%lu, len)正确平台不一致可能截断不推荐printf(%zu, len)正确MSVC 2015 正确是printf(% PRIuMAX, (uintmax_t)len)正确正确是注意 MSVC 的情况Visual Studio 2015 之前的老版本不支持%zu你可能会看到zh之类的奇怪输出2015 及之后的版本已经跟 C99 对齐%zu正常工作。所以如果你的读者里有大量 Windows 用户建议统一采用%zu避免%lu出现在 Windows 上截断成 32 位的问题。4.3 缓冲区溢出与格式字符串漏洞的提醒格式化占位符不是“打印小事”。格式串如果被外部输入控制%zu这类长度修饰符会被攻击者利用来读取和写出内存。虽然%zu本身没有%n那么臭名昭著但任何把用户可控字符串直接扔给printf的行为都应该禁止// 错误做法 char user_input[128]; gets(user_input); // 另一个坑 printf(user_input); // 正确做法 printf(%s, user_input);同理使用%zu时一定要保证参数是真正的size_t。有些代码喜欢写printf(%zu, 3)字面量 3 是int这里的参数类型不匹配同样是 UB只是因为你打了个小整数所以看起来正常。正确写法是printf(%zu, (size_t)3)或者直接把表达式设计成返回size_t。5. 编译器警告与代码规范把隐患消灭在前端5.1 gcc / clang 的 -Wformat 家族现代编译器能发现大部分size_t格式化错误。关键在于你有没有让警告真正暴露出来以及有没有把警告升级成错误。gcc -Wall -Wextra -Wformat2 -Werrorformat -o app app.c-Wformat2在基础-Wformat之上增加了对strftime等更多函数的检查-Werrorformat直接把格式化相关的警告变成编译错误。这套组合拳打完之后上面那种%d打印size_t的代码连编译都过不去你说它是“不吉利“我说这才是项目该有的体质。clang 用户还可以用-Wformat-nonliteral或-Wformat-security检查非常量的格式串clang -Wall -Wextra -Wformat2 -Wformat-security -o app app.c我自己在一个 20 万行代码的通信模块上跑过一次全量重编译加了-Werrorformat之后一口气蹦出来 300 多个错其中大概 60% 是size_t用了%d/%u20% 是%lx与long long混用剩下的是把char*当整数的老古董写法。改完之后程序在 64 位环境下的稳定性肉眼可见地提升了一个档次。5.2 团队规范从编码风格上杜绝混乱编译器警告能拦得住错误但拦不住“绕过去”的手。项目里最常见的规避姿势是“直接强转成 unsigned long 再用 %lu”这相当于把警告盖掉问题并没有根治。我比较推荐的代码风格有这几条所有sizeof/strlen/malloc参数相关的打印统一%zu指针差值打印统一%zd大型size_t转网络字节序、写日志、做序列化的地方必须留注释说明位宽假设提交 CI 的编译参数里保留-Werrorformat不放宽另外建议在项目的printf风格规范里直接把“禁用%u打印size_t”“禁用未经转换的%lu打印size_t”写成明文。给新人培训 C 语言基础时关于 size_t 和占位符的那一段最好的教学示例就是让每个人都亲手编辑一个故意写错的程序并观察输出乱象——这个“眼见为实”的过程比背一百遍%zu都管用。5.3 最后再分享一个排查小技巧如果在老代码里发现打印结果不稳定先不要急着断言行缓冲、线程同步之类的复杂问题。第一步全局搜一下打印sizeof和strlen结果的代码行第二步把格式串单独抽出来对照变量类型看第三步启用-Wformat重编把每一条警告当成 bug 修。这个习惯我用了很多年。有一次我在调试一个虚拟内存统计工具时输出里的“剩余空间”偶尔会出现超大值找了三天原因最后就是一行printf(%d, remaining)在 64 位系统上把size_t打成了负数。那一刻我真的想把当年的自己按在显示器前。C 语言的类型系统很古老但规矩清楚了它其实是可控的。size_t配%zu从来不是一个“高级技巧”而是一个合格 C 程序员应该形成肌肉记忆的基础规范。编译器的警告写代码时多花的一秒钟换来的是一整条链路上少一类的随机故障。这笔账怎么算都划算。