1. 从一段“诡异”的代码说起:为什么sizeof不是函数?
几年前,我在为一个嵌入式项目调试一段内存拷贝的代码时,遇到了一个让我至今记忆犹新的“坑”。代码片段大致如下:
void copy_data(char *dest, const char *src, size_t size) { // 假设这里需要根据数据类型大小做一些预处理 int data_type_size = sizeof(src); // ... 其他逻辑 memcpy(dest, src, size); }当时,我的意图是想获取源数据指针src所指向内容的大小(比如一个int或一个结构体)。我理所当然地写下了sizeof(src),心想这应该返回指针指向的数据类型大小。然而,程序运行起来后,内存拷贝总是出错,数据对不上。排查了很久,最后才发现,sizeof(src)返回的并不是char的大小(1字节),也不是src指向的字符串长度,而是char*这个指针变量本身在内存中占用的字节数!
在我的开发环境(64位系统)上,这个值是8。
那一刻我才彻底明白,sizeof是一个运算符,它在编译时就确定了结果,它“看”的是变量或类型的声明,而不是运行时的值。对于指针src,它的类型就是char*,所以sizeof(src)就是sizeof(char*)。这个教训让我意识到,很多C语言初学者,甚至是有一定经验的开发者,都可能对sizeof存在误解,仅仅把它当作一个“获取大小”的工具,而忽略了它作为编译时运算符的本质。
所以,今天我们就来彻底拆解sizeof运算符。这不是一个简单的“查手册”就能完全掌握的知识点,它涉及C语言编译原理、内存对齐、类型系统等底层概念。无论你是正在啃《C Primer Plus》的新手,还是工作中需要与内存、硬件打交道的嵌入式或系统程序员,深入理解sizeof都至关重要。它能帮你写出更高效、更安全的代码,也能让你在调试一些“灵异”内存问题时,快速定位到根源。
2.sizeof的本质:编译时求值的运算符
首先,我们必须从最根本的概念上纠正一个常见的错误认知:sizeof是C语言的一个单目运算符,不是函数。这个区别至关重要,它直接决定了sizeof的行为特性。
2.1 运算符与函数的根本区别
为什么强调它是运算符?因为函数调用发生在程序运行时,而sizeof的求值发生在程序编译时。编译器在生成最终的可执行文件之前,就已经计算出了所有sizeof表达式的结果,并将这个结果作为一个常量替换到代码中。
我们可以用一段简单的代码来验证:
#include <stdio.h> int main() { int a = 10; size_t size_int = sizeof(a); // 等价于 sizeof(int) size_t size_type = sizeof(int); printf("sizeof(a) = %zu\n", size_int); printf("sizeof(int) = %zu\n", size_type); // 关键验证:sizeof 内的表达式不会被执行! int i = 0; size_t size_expr = sizeof(++i); // ++i 这个操作不会被执行! printf("sizeof(++i) = %zu, i = %d\n", size_expr, i); // i 的值仍然是 0! return 0; }运行这段代码,你会发现i的值在sizeof(++i)之后依然是0。因为编译器只关心++i这个表达式的类型(这里是int),并计算该类型的大小。它根本不会去生成执行++i的机器指令。如果sizeof是函数,那么参数++i会先被求值,然后传递进去,i就会变成1。
这个特性也意味着,sizeof可以用于一些函数无法使用的场合,比如求一个不完整数组的大小(在函数参数中,数组会退化为指针,但sizeof在定义数组的作用域内可以获取其总大小)。
2.2sizeof的两种使用形式
作为运算符,sizeof有两种语法形式:
sizeof(类型): 例如sizeof(int),sizeof(char*)。sizeof 表达式: 例如sizeof a,sizeof(a + b)。注意,当表达式是单个变量时,括号有时可以省略,但为了清晰和避免优先级问题,我强烈建议始终使用括号。
例如,sizeof a + b会被解析为(sizeof a) + b,这可能不是你的本意。而sizeof(a + b)则明确表示求表达式a+b结果类型的大小。
2.3 返回类型:size_t
sizeof的返回值类型是size_t。这是一个定义在<stddef.h>等头文件中的无符号整数类型。它的具体大小(位数)是平台相关的,但保证足够大,能够表示该平台上任何对象在内存中占用的字节数。
在打印size_t类型的值时,应该使用%zu格式说明符(C99标准引入)。使用错误的格式符(如%d,%u)可能导致未定义行为,在64位系统上尤其容易出错。
// 正确的打印方式 printf("Size is: %zu bytes\n", sizeof(int)); // 错误的打印方式(可能在64位系统产生警告或错误输出) printf("Size is: %d bytes\n", sizeof(int)); // 不推荐!3. 深入核心:sizeof如何计算各种类型的大小
理解了sizeof是编译时运算符后,我们来看看它面对不同操作数时的具体计算规则。这里的水,比想象中要深。
3.1 基本数据类型:并非理所当然
很多人认为sizeof(char)一定是1,sizeof(int)一定是4。前半句是对的,C标准明确规定sizeof(char)等于1字节。并且,sizeof返回的“1字节”在标准中被称为char的大小,其他类型的大小都是它的倍数。
但sizeof(int)等于4,这只是一种常见情况,并非C语言标准的规定。C标准只规定了int的最小范围(至少能表示 -32767 到 32767),并没有规定其具体的字节数。它的大小由编译器和目标平台(CPU架构、操作系统)共同决定,遵循所谓的“数据模型”。
常见的几种数据模型:
- LP32 / ILP32: 在32位系统(如x86 Linux/Windows)上常见。
int,long,pointer都是32位(4字节)。 - LP64: 在64位Unix-like系统(如x86-64 Linux/macOS)上主流。
int是32位(4字节),long和pointer是64位(8字节)。 - LLP64: 在64位Windows系统上使用。
int和long都是32位(4字节),long long和pointer是64位(8字节)。
所以,编写跨平台代码时,绝不能对int,long等类型的大小做硬编码假设。如果需要确定大小的整数,请使用<stdint.h>中定义的int8_t,uint32_t,int64_t等类型。
3.2 数组类型:sizeof的“高光时刻”
这是sizeof最有用也最容易产生困惑的地方之一。当操作数是一个数组名时,sizeof返回的是整个数组在内存中占用的总字节数。
int arr[10]; printf("sizeof(arr) = %zu\n", sizeof(arr)); // 输出: 40 (假设 int 为4字节, 4 * 10 = 40) // 计算数组元素个数的一个经典惯用法 size_t element_count = sizeof(arr) / sizeof(arr[0]); printf("Number of elements: %zu\n", element_count); // 输出: 10sizeof(arr) / sizeof(arr[0])这个公式是C语言中计算静态数组元素个数的“标准答案”,它是在编译时计算的常量表达式,非常高效安全。
但是,这里有一个巨大的“坑”:当数组名作为函数参数传递时,它会退化为指向其首元素的指针。此时,在函数内部使用sizeof获取到的便是指针的大小,而不是原数组的大小。
void print_size(int array_param[]) { // 等价于 void print_size(int *array_param) // 这里 array_param 已经是一个指针了! printf("Inside function - sizeof(array_param): %zu\n", sizeof(array_param)); // 输出指针大小 (如 8) printf("Inside function - sizeof(*array_param): %zu\n", sizeof(*array_param)); // 输出 int 大小 (如 4) } int main() { int my_array[20]; printf("In main - sizeof(my_array): %zu\n", sizeof(my_array)); // 输出整个数组大小 (如 80) print_size(my_array); return 0; }这是无数C程序员踩过的坑。如果你需要在函数内部知道传入数组的大小,必须额外传递一个表示数组长度的参数。
3.3 结构体与联合体:内存对齐的“魔术”
sizeof应用于结构体(struct)或联合体(union)时,结果往往不等于其各成员大小简单相加。这背后是“内存对齐”在起作用。
内存对齐: 现代计算机的CPU并非可以随意从任意地址读取数据。为了提升访问效率,CPU通常要求数据的地址是其自身大小的整数倍。编译器为了满足这个要求,会在结构体成员之间插入“填充字节”。
看一个例子:
struct example1 { char a; // 1字节 // 编译器插入3字节填充 (padding),因为下一个int需要4字节对齐 int b; // 4字节 char c; // 1字节 // 编译器可能再插入3字节填充,使得整个结构体大小是最大成员对齐值的整数倍 }; printf("sizeof(struct example1): %zu\n", sizeof(struct example1)); // 很可能输出 12 struct example2 { int b; // 4字节 char a; // 1字节 char c; // 1字节 // 为了满足int对齐,a和c后面只需要2字节填充即可 }; printf("sizeof(struct example2): %zu\n", sizeof(struct example2)); // 很可能输出 8example1和example2的成员一模一样,只是顺序不同,sizeof的结果却相差了4个字节!这就是内存对齐导致的。合理的成员排列可以节省内存,这在内存受限的嵌入式系统中尤为重要。
注意: 对齐规则和填充策略是编译器相关的,但遵循ABI(应用程序二进制接口)规范。你可以使用
#pragma pack(n)或__attribute__((packed))等编译器扩展来修改对齐方式,但这可能以牺牲性能为代价,并可能导致一些平台相关的兼容性问题。
对于联合体(union),sizeof返回的是其最大成员的大小,同时也会考虑对齐。
union my_union { int i; char c[10]; double d; }; // sizeof(union my_union) 至少是10(char数组大小),还要向上对齐到double的对齐要求(通常是8)。 // 最终大小可能是16。3.4 指针类型:永远的大小
无论指针指向什么类型的数据,sizeof对任何指针类型(int*,char*,void*, 函数指针等)的求值结果,都是该指针变量本身在内存中占用的字节数。这取决于目标平台的寻址能力:
- 32位系统: 指针通常是4字节。
- 64位系统: 指针通常是8字节。
printf("sizeof(int*) = %zu\n", sizeof(int*)); printf("sizeof(char*) = %zu\n", sizeof(char*)); printf("sizeof(void*) = %zu\n", sizeof(void*)); printf("sizeof(void (*)()) = %zu\n", sizeof(void (*)())); // 函数指针 // 在同一个平台上,这些输出值通常是一样的。3.5 函数类型与void
sizeof不能用于函数类型或不完整的类型(如未指定长度的数组、前置声明的结构体)。
// sizeof(void); // 错误:无效应用于 void 类型 // sizeof(main); // 错误:无效应用于函数名(函数类型)但是,sizeof可以用于void表达式,这在GNU C扩展中允许,结果为1。不过,依赖这种非标准特性会损害代码的可移植性。
4. 实战进阶:sizeof在工程中的典型应用与陷阱
了解了基本规则,我们来看看在实际项目中,sizeof如何被正确或错误地使用。
4.1 动态内存分配:malloc的好搭档
这是sizeof最经典、最重要的应用场景之一。在调用malloc,calloc,realloc等动态内存分配函数时,必须准确计算需要分配的字节数。
// 为10个int分配空间 int *dynamic_array = (int*)malloc(10 * sizeof(int)); // 注意:不是 malloc(10 * sizeof(int*))! 那是分配10个指针的空间。 // 为一个结构体分配空间 struct MyStruct *p = (struct MyStruct*)malloc(sizeof(struct MyStruct)); // 更安全、更推荐的写法,避免类型名写错: struct MyStruct *p_safe = malloc(sizeof(*p_safe)); // 如果 p_safe 的类型未来改变了,这行代码无需修改。使用sizeof(*pointer)作为malloc的参数是一种最佳实践,它让代码与类型声明紧密绑定,减少了因类型修改而忘记更新malloc参数的风险。
4.2 内存操作函数:安全与效率的基石
memcpy,memset,memcmp等函数都需要一个表示字节数的参数。sizeof在这里不可或缺。
struct Data src, dest; // 复制整个结构体 memcpy(&dest, &src, sizeof(struct Data)); // 初始化数组为0 int arr[100]; memset(arr, 0, sizeof(arr)); // 使用整个数组的大小 // 比较两个结构体是否二进制相等 if (memcmp(&obj1, &obj2, sizeof(struct Data)) == 0) { // 相等 }4.3 泛型编程的雏形:offsetof宏
在C语言中,我们可以利用sizeof和指针运算来实现一些简单的泛型操作。标准库<stddef.h>中的offsetof宏就是一个典范,它用于计算结构体成员相对于结构体起始地址的偏移量。它的一个典型实现就依赖于sizeof:
// offsetof 宏的一个可能实现 (编译器通常内置) #define OFFSETOF(type, member) ((size_t)&(((type *)0)->member))理解这个宏需要一些技巧:它先将地址0强制转换为type*类型,然后访问其member成员并取地址。由于起始地址是0,这个地址值就是偏移量。虽然不直接使用sizeof,但sizeof在理解结构体布局和指针运算时是基础。
4.4 常见陷阱与避坑指南
对字符串使用
sizeof:char str1[] = "Hello"; char *str2 = "World"; printf("sizeof(str1)=%zu\n", sizeof(str1)); // 输出 6 (包含结尾的 '\0') printf("sizeof(str2)=%zu\n", sizeof(str2)); // 输出 8 (指针大小) printf("strlen(str1)=%zu\n", strlen(str1)); // 输出 5 (字符串长度,不含'\0')记住:
sizeof(数组)返回总分配大小(包括\0),sizeof(指针)返回指针大小,strlen返回字符串长度。在函数参数中使用
sizeof获取数组大小: 如前所述,这是无效的。必须显式传递大小参数。误用
sizeof计算动态分配数组的大小:int *p = malloc(100 * sizeof(int)); size_t wrong_size = sizeof(p); // 错误!这是指针大小,不是分配的内存大小。 // C语言没有标准方法在运行时获取 malloc 分配块的大小。动态分配的内存块大小需要程序员自己管理。
忽略括号导致的优先级问题:
int a = 10; double b = 20.0; size_t s1 = sizeof a + b; // 等价于 (sizeof a) + b,结果是 double 类型。 size_t s2 = sizeof (a + b); // 计算表达式 a+b (结果为double) 的大小。始终使用括号是避免此类问题最简单的方法。
5. 编译器视角与底层探索
要真正吃透sizeof,我们需要稍微了解一下编译器在背后做了什么。
5.1 编译时求值的实现
当编译器遇到sizeof表达式时,它会:
- 确定操作数的类型。如果是表达式,则确定其结果的类型。
- 根据目标平台的ABI(应用程序二进制接口)规范,查询该类型的大小。这个信息在编译器设计时就已经确定,并内置在编译器中。
- 将
sizeof表达式替换为一个整型常量。这个常量会被直接写入生成的目标代码(汇编或机器码)中,程序运行时没有任何计算开销。
你可以通过查看编译器生成的汇编代码来验证这一点。使用-S选项(在GCC/Clang中)可以让编译器生成汇编文件,你会看到sizeof的结果直接以立即数(如$4,$8)的形式出现。
5.2sizeof与_Alignof(C11)
C11标准引入了_Alignof运算符,用于查询类型的对齐要求。它和sizeof是兄弟,都是在编译时工作。理解对齐对于深入理解sizeof在结构体上的行为非常有帮助。
#include <stdalign.h> // 为了使用 alignof 宏(通常定义为 _Alignof) struct S { char c; int i; }; printf("Alignment of char: %zu\n", alignof(char)); // 通常是1 printf("Alignment of int: %zu\n", alignof(int)); // 通常是4 printf("Alignment of struct S: %zu\n", alignof(struct S)); // 通常是其成员最大对齐值,即4 printf("Size of struct S: %zu\n", sizeof(struct S)); // 考虑对齐后的大小,如8_Alignof返回的值,就是该类型在内存中分配起始地址需要满足的“整数倍”条件。结构体的对齐要求通常是其成员中最大的那个_Alignof值。
5.3 可变长度数组(VLA)与sizeof的运行时行为
C99标准引入了可变长度数组(VLA),这给sizeof带来了一个特例。当sizeof的操作数是一个VLA时,它的求值可能发生在运行时,而不再是纯粹的编译时常量。
void func(int n) { int vla[n]; // VLA size_t size = sizeof(vla); // 这行代码在运行时计算! size = n * sizeof(int) printf("VLA size: %zu\n", size); }这是因为VLA的大小在编译时无法确定,取决于运行时传入的参数n。这是一个非常重要的例外情况,在编写需要常量表达式的场合(如静态数组大小、case标签、位域长度等)时,不能使用涉及VLA的sizeof。
6. 举一反三:从sizeof延伸出的编程思维
深入理解sizeof,不仅仅是掌握一个运算符,更是培养一种严谨的、贴近机器模型的编程思维。
数据抽象与内存具象: C语言给了程序员直接操作内存的能力,sizeof就是连接高级语言类型系统和底层内存布局的桥梁。当你写sizeof(struct PacketHeader)时,你心里应该能大致勾勒出这个结构体在内存中是如何“摆放”的,哪里可能有填充字节。这种能力对于网络编程、协议解析、硬件寄存器映射等场景至关重要。
可移植性意识: 硬编码数字(如int size = 4;)是代码可移植性的天敌。sizeof提供了一种与平台无关的方式来描述数据大小。任何与内存尺寸相关的操作,都应该优先考虑使用sizeof来计算。
编译时与运行时: 清晰地区分哪些事情发生在编译时(如sizeof(类型)),哪些发生在运行时(如strlen),是写出高效、正确代码的关键。编译时能解决的问题,绝不留到运行时。
防御性编程: 在使用memcpy,malloc等函数时,养成使用sizeof计算大小的习惯,并优先使用sizeof(*ptr)这种形式,可以避免许多因类型修改而引入的隐蔽bug。这是一种低成本、高收益的防御性编程实践。
最后,回到我开头提到的那个“坑”。那次经历让我养成了一个习惯:每当我要使用sizeof时,我都会停下来问自己两个问题:“我现在操作的对象,它的类型到底是什么?”以及“我期望sizeof在这里给我什么?是整个对象的大小,还是指针的大小,或者是其他什么东西?” 多问这两个问题,能帮你避开sizeof绝大多数常见的陷阱。