ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

size_t和%zu详解:彻底告别C语言格式化输出的坑

size_t和%zu详解:彻底告别C语言格式化输出的坑 直接用%d打印size_t我敢打赌八成写过C语言的人都在这个上面栽过跟头。警告倒还好最怕的是在64位系统上打印出来的结果直接变成乱码或者负数排查半天最后发现只是个格式化占位符的问题。今天咱们就好好聊聊size_t这个类型以及它的专用打印占位符%zu到底是怎么一回事为什么非用它不可。这篇文章会把size_t的前世今生、%zu的来龙去脉、实际编码里的注意事项和各类翻车现场都讲一遍。无论你是刚学C语言的新手还是偶尔写写维护代码的老手看完之后应该能彻底告别这类格式化的坑。1. size_t到底是个什么类型1.1 从sizeof说起先做个最简单的实验任何C语言编译器里跑一下#include stdio.h int main(void) { int arr[10] {0}; printf(sizeof(arr) %d\n, sizeof(arr)); return 0; }在32位系统上可能一切正常输出40。但如果你用的是64位Linux或者macOS编译器大概率会甩出一条警告warning: format %d expects argument of type int, but argument 2 has type long unsigned int这里点名了sizeof的实际类型是long unsigned int也就是无符号长整型。但更准确地说sizeof的返回值类型是size_t。size_t并不是一个内置的C语言基本类型而是一个通过typedef定义出来的别名。在标准库里它的定义通常在stddef.h、stdio.h、stdlib.h这些头文件里。你可以把它理解为“用来表示对象大小或数组元素个数”的正整数类型。它到底底层是unsigned int还是unsigned long取决于平台和编译器。在32位平台上size_t通常是4字节的unsigned int在64位平台上它通常是8字节的unsigned long。但这只是通常情况谁要是写死底层类型谁就会在跨平台移植时吃大亏。1.2 为什么一定要是无符号你有没有想过为什么size_t非得是无符号的明明对象大小也可以是负数比如两个指针相减的结果可能是负的。道理很简单对象大小、数组长度、内存偏移量这些东西在逻辑上就不应该出现负数。一个数组的长度要么是0要么是正数不存在“负四个元素”这种概念。让size_t成为无符号类型等于把这个类型能表达的数值范围翻了一倍。比如32位下unsigned int能表示到42亿多而普通的int只能表示到21亿多这样一来在处理大数组、大文件、大内存块的时候size_t就能容纳更大的长度值。另外无符号整数在溢出时有着明确的行为按模回绕。比如一个4字节的unsigned int加到了最大值4294967295再加1就变成0。这种回绕特性在指针运算、内存分配算法中其实有它的用途。虽然日常写代码不希望你依赖溢出回绕但标准库的实现里确实会利用这一点做边界判断。1.3 它和int、long、unsigned long的区别很多人把size_t和unsigned long混为一谈在Linux x86_64平台上确实两者宽度一致但换到Windows x64上呢Windows的long是4字节而size_t是8字节。你再看int就更不用说了在几乎所有主流64位平台上int都是4字节但size_t在64位平台是8字节。只要你的程序用了int来接sizeof的结果在64位平台上就必然发生截断。把sizeof的结果赋给int类似于把一个64升的水桶里的水倒进32升的桶里数据量小的时候没事一旦对象大小超过int的表示范围立刻出问题。虽然日常写的小例子里数组大小根本不会超过21亿但这种隐患属于“平时不炸一炸就是疑难杂症”。我自己的经验是凡是涉及“大小”“长度”“个数”的变量一律用size_t。这不仅是类型安全问题更是代码可读性的问题。看到size_t读者就能立刻明白这个变量表示的是一个非负的尺寸值而不是什么任意的整数。2. %zu是怎么来的为什么非它不可2.1 C99标准引入的格式化占位符在C89/C90时代printf的格式化占位符并没有专门给size_t设计一个。那时候人们怎么写最常见的是用%u假设size_t是unsigned int或者用%lu假设它是unsigned long。这两种假设在特定平台上都能跑但没有一个是通用的。到了C99标准C语言委员会终于意识到这个问题专门为size_t增加了一系列长度修饰符其中就包括z。在printf家族里%zu表示“对应的参数是size_t类型”。注意z不是“zero”的意思而是“size”的缩写因为size_t关键词里有个s但s已经被字符串占用了所以选了字母z德语里“大小”是Größe不过官方说法就是代表size反正记住是z就行。C11标准沿用了这一规定C17也一样。所以今天任何符合C99及以上标准的编译器都应该支持%zu。如果你遇到的编译器连%zu都不认识那基本可以判定它是个古董级环境。2.2 为什么不能直接用%d、%u或%lu直接说结论%d期望一个int%u期望一个unsigned int%lu期望一个unsigned long而size_t在不同平台上可能和以上任意一个底层类型相同也可能都不同。关键在于printf是个可变参数函数。调用它时你传进去的值会按照默认实参提升规则处理但具体类型信息不会传给printf内部。printf只能通过格式串里的占位符去猜测你传进来的参数是什么类型并按照那个类型去解析一堆字节。如果你传的是size_t却告诉printf它是int那printf就会按int的大小去读参数。两个类型宽度不一致时轻则打印结果莫名其妙重则导致后面的参数全部错位程序直接崩溃。举个具体例子。在64位Linux上size_t是8字节int是4字节。如果写size_t s 12345678901234; printf(%d\n, s);printf会按4字节去读取这个参数。结果打印出来的数完全不是12345678901234可能是某个截断后的值。更危险的是如果后面还有别的参数printf从栈上或寄存器中读取后续参数的位置也会偏移因为%d只消费了4字节而实际参数占了8字节剩下的4字节被当成下一个参数的一部分了。同样%lu在Windows x64上也不行因为Windows的long是4字节size_t是8字节。盲目使用%lu在Windows上照样踩坑。2.3 配套的%zd、%zu、%zxz修饰符不只配合u使用还有%zu无符号size_t%zd有符号ssize_t严格说C标准里没有ssize_t但很多系统扩展支持用于带符号的大小值如read的返回值%zxsize_t的十六进制形式%zo八进制形式其中%zd在C标准里其实有点灰色地带因为标准没有定义有符号的size_t但POSIX之类的系统里定义了ssize_t所以%zd在Linux下可用在Windows的MSVC下可能会有问题。日常打印非负的长度用%zu最保险。至于%zx在打印内存地址偏移量或者某种十六进制大小值的时候很有用。比如你想看一个结构体的大小用十六进制表示struct Foo { char a; int b; }; printf(sizeof(struct Foo) %zx\n, sizeof(struct Foo));输出会是8如果对齐后确实是8字节。不过平时还是%zu十进制的多。3. 正确使用%zu的实操要点3.1 打印sizeof和strlen的结果最典型的场景就是打印sizeof和strlen的返回值。这两个函数的返回值都是size_t。#include stdio.h #include string.h int main(void) { char str[] hello; int nums[100]; printf(strlen(str) %zu\n, strlen(str)); printf(sizeof(str) %zu\n, sizeof(str)); printf(sizeof(nums) %zu\n, sizeof(nums)); return 0; }输出strlen(str) 5 sizeof(str) 6 sizeof(nums) 400注意strlen(str)是5因为字符串hello正好5个字符sizeof(str)是6因为字符串末尾还有一个隐式的\0。这个区别新手容易混但这不是本文重点重点是你用%zu打印就绝对不会出现类型不匹配的警告。3.2 打印数组下标和循环变量用得多了你就会发现凡是和“元素个数”相关的循环用size_t做循环变量是最安全的。比如#include stdio.h int main(void) { int arr[100]; for (size_t i 0; i sizeof(arr) / sizeof(arr[0]); i) { arr[i] (int)i; } for (size_t i 0; i sizeof(arr) / sizeof(arr[0]); i) { printf(arr[%zu] %d\n, i, arr[i]); } return 0; }这里用%zu打印i完美匹配。如果你用了%d编译器会警告因为i是size_t。也许你会说大小又不超100转换一下也没啥。确实但万一以后数组改大了呢万一这个循环被复制到别处处理一个超大文件呢保持类型一致才能让代码在更大规模的数据下依然正确。还有一个需要警惕的坑for (size_t i n - 1; i 0; i--)这种写法。因为size_t永远是非负的i 0永远成立循环就变成了死循环。正确的写法是for (size_t i n; i 0; i--) { // 这里访问 i - 1 }这个坑很多老手都踩过我当年就因为这个问题排查了一个下午。所以用size_t做循环变量时逆向循环一定要小心。3.3 参数传递中的类型提升陷阱你有没有想过为什么printf(%zu, sizeof(x))是安全的但printf(%d, sizeof(x))就不安全再看这个size_t s 42; printf(%zu\n, s);这里printf怎么知道参数是size_t其实它不知道它只是按照格式串里的%zu去解释参数。它假定参数是一个size_t而因为你的实参确实是size_t所以就没问题。但如果你传的是一个unsigned long在Linux上size_t就是unsigned long本质相同没问题如果在Windows上就不一样了unsigned long是4字节size_t是8字节那么你的实参类型和%zu期望的类型就不匹配了行为就未定义。这种事很少发生因为大家通常只在sizeof、strlen这类明确返回size_t的地方用%zu。但如果你自己定义了一个size_t变量然后把它赋给unsigned long再打印就埋下隐患了。我的建议是在64位Windows下开发时统一用%zu并且不要把size_t隐式转换成其他类型。3.4 跨平台兼容性处理说到跨平台不得不提Visual Studio。MSVC在较新的版本中已经支持%zu但有个前提你需要使用_CRT_STDIO_ISO_C99或较新的SDK。在老的VS2013、VS2015上%zu可能没有被正确支持会输出乱码。我当时踩坑是在VS2015上用%zu打印一个size_t结果输出不是数字而是字符串zu。后来查了一下发现MSVC的printf实现没有完全遵守C99直到VS2015 Update 2才正式支持z修饰符。所以如果你还在维护老项目有条件就升级编译器没条件就用跨平台方案先将size_t转换为unsigned long long再用%llu打印printf(%llu\n, (unsigned long long)sizeof(struct Foo));这种写法在任何C99编译器下都能工作因为unsigned long long是标准类型%llu也是标准占位符。唯一的缺点是打印出来的值可能比你预期的理论最大范围小因为unsigned long long的宽度不可能小于size_t但理论上在极少数平台上可能比size_t大所以转换是安全的不会有截断风险。这在需要兼容老编译器的项目里是个保底招数。4. 常见问题与排查技巧实录4.1 打印出来的值变成负数是怎么回事这是典型症状用%d打印sizeof或strlen的结果输出一个负数比如-12。原因很简单size_t是无符号类型它的二进制表示比如0xFFFFFFFFFFFFFFF4如果printf按%d去解释就会当成有符号数读最高位是1于是变成了负数。本质上就是“同一串字节按有符号整数解释”的经典误解。解决办法不是把%d换成%u就完事因为%u的宽度可能和size_t不一致。正确做法是用%zu让printf按size_t的宽度和符号去解释。4.2 编译警告“格式指定类型与实参类型不匹配”我在GCC和Clang下经常看到这种警告warning: format ‘%d’ expects argument of type ‘int’, but argument 2 has type ‘long unsigned int’ [-Wformat]这时候别无视它。从代码行为上来说小数值可能碰巧输出正确但这纯属侥幸。一旦数值超过int的表示范围输出就出错了。我见过最可怕的是一个把strlen结果用%d打印的代码在生产环境里处理一个超过2GB的文件结果打印出来的文件长度是负数导致后续逻辑彻底混乱。如果你用的是GCC/Clang建议在编译时加上-Wall -Wextra -Wformat这几个选项让这类警告无处遁形。如果是嵌入式环境编译器极其老旧那更要重视警告因为老编译器对格式串的检查可能不到位但行为上的错误依然存在。4.3 MSVC和GCC的显示差异在Windows下MSVC的printf和Linux的glibcprintf对z修饰符的支持情况不完全相同。现代MSVCVS2015在默认模式下列表使用%zu是可以的但是有个细节如果你在printf调用时没有包含stdio.h或者没有定义__USE_MINGW_ANSI_STDIO在MinGW环境下行为可能会不一致。MinGW-w64的用户应该比较熟悉默认情况下MinGW的printf是MSVC的printf如果不用__USE_MINGW_ANSI_STDIO宏%zu可能不会被正确解析。解决办法是在编译命令中加-D__USE_MINGW_ANSI_STDIO1或者直接用%Iu这种MSVC特有的占位符。注意%Iu是MSVC特有的不是标准C跨平台代码不推荐。我的建议是跨平台项目一律使用%zu并确保所有编译器支持C99。如果必须兼容老环境用强制转换%llu方案。如果团队里有人用MinGW记得在构建脚本里统一加上宏定义避免大家互相埋雷。4.4 老代码迁移时如何快速排查所有隐患假设你手里有一个老项目里面到处都是printf(%d, sizeof(x))的写法现在要改成%zu怎么快速定位所有该改的地方先说笨办法全局搜索sizeof一个个看上下文。对于小项目还行大项目就太累了。聪明点的办法开启所有编译警告。GCC下用-Wformat -Wformat2通常会标出所有不匹配的格式化。但是要注意有些警告只在你传的实参类型与格式串不匹配时才触发如果实参本身是int而不是size_t即使它是从sizeof转换来的也可能不会触发。比如int n sizeof(arr); printf(%d\n, n);因为n是int编译器不警告但你已经丢了信息。这种情况就需要人工检查了。还有一种隐藏的坑当你用%zu打印一个“普通int变量”时也会得到警告提示类型不匹配。所以迁移时不要盲目替换要理解每个变量的真实类型。5. 我的实操心得与建议5.1 什么时候该用%zu什么时候别用我个人的原则是只要打印的值是size_t类型就用%zu。具体来说sizeof的结果、strlen的结果、memcpy的第三个参数、malloc的参数、数组下标变量、容器的大小字段全是size_t都应该用%zu。但如果你明确知道这个变量不是size_t就别硬套%zu。比如函数的返回值是int你为了吃“标准”的定心丸强行用%zu反而会造成不匹配问题。在C语言里类型准确比“看起来标准”更重要。5.2 建议开启编译警告把问题扼杀在摇篮这是我写C语言十几年来养成的最强烈的习惯之一编译时开启-Wall -Wextra -Werrorformat或者至少不要忽略格式警告。很多格式化问题都是可以靠编译器自动揪出来的不需要你自己熬夜盯屏幕。在CMake里可以这样设if(CMAKE_C_COMPILER_ID MATCHES GNU|Clang) add_compile_options(-Wall -Wextra -Wformat2 -Werrorformat-security) endif()这样一旦有人写了不匹配的格式化编译直接报错省得运行时才炸。当然有些老代码会因为这个报错而无法编译那就需要逐个修复。这其实不是坏事宁可编译期痛苦也不要运行时崩溃。5.3 与scanf配合时还要注意一点咱们这篇文章主要讲printf但scanf里也有对应的%zu。不过这里有个重要的陷阱scanf需要参数指针而指针的大小必须与格式串期望一致。scanf(%zu, n)要求n必须是真正的size_t*如果你传了unsigned int*同样会出问题。而且scanf的%zu行为在C标准里是明确的吗严格说C标准只规定了fscanf的z修饰符用于size_t指针这个应该没问题。但如果你在Windows老的MSVC上可能还是不认。跨平台时同样要谨慎。5.4 一个综合示例最后放一个综合了上面所有知识点的示例代码可直接运行验证#include stdio.h #include string.h #include stddef.h int main(void) { char text[] C language size_t; size_t len strlen(text); size_t cap sizeof(text) / sizeof(text[0]); printf(text \%s\\n, text); printf(字符数: %zu\n, len); printf(数组容量: %zu\n, cap); // 十六进制形式 printf(容量十六进制: 0x%zx\n, cap); // 多参数混合注意顺序和类型匹配 printf(len%zu, cap%zu, diff%td\n, len, cap, (ptrdiff_t)(cap - len - 1)); return 0; }这里用到了%td对应ptrdiff_t类型两个指针相减的结果类型。实际思考cap - len没问题都是size_t无符号运算cap - len - 1因为cap是6len是17cap - len - 1会得到无符号下溢然后再转成ptrdiff_t。输出时用%td这在C标准里也是合法的。不过这种做法有点绕这里只是为了演示相关占位符的存在。实际工作中我更喜欢把这类边界计算拆成有符号类型再做避免无符号回绕导致隐藏bug。但这是另一个话题了。关于size_t和%zu我最后再说一点这不是什么高深知识但恰恰是这种基础细节最容易让一个看起来很稳的程序在特定平台上翻车。如果看完这篇文章你能养成两个习惯——声明大小变量用size_t打印size_t用%zu那这门功课就算真正过关了。
返回列表