☰
C语言size_t打印用%d为何出错?彻底搞懂%zu正确用法
2026/10/7 17:50:11 网站建设 项目流程

开头就直接进入场景,不说废话。这其实是我去年 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 int,4 字节
  • 64 位 Linux:unsigned long,8 字节
  • 64 位 Windows:unsigned long long,8 字节

%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(变成 18446744073709551611),a + 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是int,64 位 Linux 是long,64 位 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 -Wformat=2 -Werror=format -o app app.c

-Wformat=2在基础-Wformat之上增加了对strftime等更多函数的检查,-Werror=format直接把格式化相关的警告变成编译错误。这套组合拳打完之后,上面那种%d打印size_t的代码连编译都过不去,你说它是“不吉利“,我说这才是项目该有的体质。

clang 用户还可以用-Wformat-nonliteral或-Wformat-security检查非常量的格式串:

clang -Wall -Wextra -Wformat=2 -Wformat-security -o app app.c

我自己在一个 20 万行代码的通信模块上跑过一次全量重编译,加了-Werror=format之后一口气蹦出来 300 多个错,其中大概 60% 是size_t用了%d/%u,20% 是%lx与long long混用,剩下的是把char*当整数的老古董写法。改完之后,程序在 64 位环境下的稳定性肉眼可见地提升了一个档次。

5.2 团队规范:从编码风格上杜绝混乱

编译器警告能拦得住错误,但拦不住“绕过去”的手。项目里最常见的规避姿势是“直接强转成 unsigned long 再用 %lu”,这相当于把警告盖掉,问题并没有根治。

我比较推荐的代码风格有这几条:

  • 所有sizeof/strlen/malloc参数相关的打印统一%zu
  • 指针差值打印统一%zd
  • 大型size_t转网络字节序、写日志、做序列化的地方,必须留注释说明位宽假设
  • 提交 CI 的编译参数里保留-Werror=format,不放宽

另外建议在项目的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 程序员应该形成肌肉记忆的基础规范。编译器的警告,写代码时多花的一秒钟,换来的是一整条链路上少一类的随机故障。这笔账,怎么算都划算。

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

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

立即咨询