ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C/C++ 中 sizeof 运算符深度解析:数组退化、内存对齐与类布局的字节陷阱

C/C++ 中 sizeof 运算符深度解析:数组退化、内存对齐与类布局的字节陷阱 一个老生常谈却又让无数人翻车的sizeof()今天一次讲透。很多 C/C 新手觉得sizeof()不就是算字节数吗背几个公式就会了。但真到笔试面试、实际项目里一碰上指针、数组传参、字符串、结构体对齐、空类继承sizeof()的答案往往和直觉相差十万八千里。我见过太多人在函数里sizeof(数组参数)算出个 8然后一脸懵地排查半天也见过有人被结构体内存对齐坑到缓存命中率暴跌。这篇东西我会把sizeof()在基本数据类型、指针、数组、字符串、函数、结构体、类、联合体中的行为全部拆开讲一遍每个知识点都配上“为什么”和可直接落地的经验适合正在学 C/C 的初学者也适合打算系统梳理底层细节的进阶者。1. sizeof 的本质运算符不是函数先说最容易被忽略的一点sizeof是运算符不是函数。这一点直接决定了你对它的理解是否准确。1.1 编译期求值和“不求值”的特性sizeof在绝大多数情况下是编译期求值的也就是说它的结果在程序编译阶段就已经确定不会在运行时去计算。所以它后面跟表达式的时候表达式本身不会真的被执行。int i 10; printf(%zu\n, sizeof(i)); // 输出的值不是 i 的结果而是 int 的大小 printf(%d\n, i); // i 依然是 10不是 11我把这段代码实际跑过sizeof(i)输出 432 位 int 环境下但i的值还是 10。这个特性很多人叫它“不求值语义”你可以在宏定义和泛型编程里利用它做安全判断比如判断两个类型是否一致、检查表达式合法性但不实际执行它。有一个例外C99 引入的可变长数组VLA是运行时求值的。也就是说sizeof作用于 VLA 时会真的去计算大小。int n 10; int arr[n]; printf(%zu\n, sizeof(arr)); // 运行时计算结果这点在嵌入式开发或者写底层库时偶尔会遇到需要留意。凡是sizeof算出来的是编译期常量还是运行期值决定了你能不能拿它去做数组长度、位域宽度、switch case标签等需要编译期常量的场景。1.2 写法区别类型必须加括号表达式可以不加sizeof的写法有讲究。作用于表达式时括号可以省略作用于类型时必须加括号。int a 0; sizeof a; // 合法表达式可以不加括号 sizeof(int); // 合法类型必须加括号 // sizeof int; // 这段是错的编译不过我建议不管后面跟的是什么统一加括号。代码是给团队看的少一点“竟然还能这么写”的惊奇时刻多一点可读性。有些静态检查工具也会对此提出警告提前统一风格能少踩不少坑。1.3 sizeof 的返回值类型sizeof的返回值类型是size_t这是无符号整数类型在头文件里通常定义为unsigned long或unsigned long long。打印它要使用%zuC99 起很多新手用%d打印在 64 位系统上可能得到截断的异常值。printf(%zu\n, sizeof(int)); // 标准写法 printf(%lu\n, (unsigned long)sizeof(int)); // 老代码常见写法另外因为size_t是无符号类型写sizeof(a) - 1这种表达式时一定要小心如果sizeof(a)是 0那结果会变成一个很大的正数而不是负数。内存拷贝、循环边界这类代码里容易踩坑。2. 基本数据类型与指针的 sizeof基础类型的sizeof结果其实是跟着平台走的不要死记硬背具体的字节数要理解背后的规则和为什么在不同平台会有差异。2.1 C 标准只规定了最小范围C 标准对基本类型只规定了保证的最小范围并没有规定具体字节数。常见平台的实际情况是这样的字节为单位类型32 位平台64 位平台LP64说明char11sizeof(char)恒为 1short22至少 2 字节int44通常 4 字节long48平台差异最大的类型long long88C99 引入至少 8 字节float44通常遵循 IEEE 754double88通常遵循 IEEE 754pointer48见下文Windows 上 64 位long依然是 4 字节Linux 上 64 位long是 8 字节这就是为什么跨平台代码里推荐使用int32_t、int64_t这些固定宽度类型。写网络协议、文件格式解析时用固定宽度类型能避免同一份代码在不同系统上解析出的字节数不同。2.2 指针的 sizeof一律是指针自身的大小指针变量保存的是内存地址sizeof(指针变量)是地址本身所需的字节数和它指向什么类型无关。在同一平台上int*是 8 字节char*也是 8 字节struct Test*还是 8 字节。int *p_int NULL; char *p_char NULL; double *p_double NULL; printf(%zu\n, sizeof(p_int)); // 64 位平台输出 8 printf(%zu\n, sizeof(p_char)); // 输出 8 printf(%zu\n, sizeof(p_double)); // 输出 8千万不要写sizeof(p_int)然后以为能得到“一个 int 指针指向的数据大小”。想要指针指向的目标大小正确的写法是sizeof(*p_int)。我见过不少人在做动态内存分配时写成int *arr (int *)malloc(sizeof(arr)); // 错拿到的只能放一个指针 int *arr (int *)malloc(sizeof(*arr) * N); // 对一个 int 的大小乘以 N第一种写法在 64 位平台上只分配了 8 字节后续写arr[1]、arr[2]就直接越界了。2.3 函数指针与智能指针的 sizeof函数指针的大小通常与普通指针一致但标准并没有强制保证。在 x86/x64 平台上普通函数指针和成员函数指针的大小可能不一样尤其是涉及虚继承的成员函数指针有些编译器会把它实现成比普通指针更大的结构体。如果你要做函数指针的封装或者序列化最好先sizeof确认一下不要假设。C 的智能指针和裸指针不是一个概念。std::unique_ptr通常和裸指针一样大不引入额外开销std::shared_ptr内部有两个指针一个指向对象一个指向控制块所以通常sizeof(std::shared_ptrT) 16。这在内存紧张的嵌入式场景里值得注意一个shared_ptr顶四个裸指针容器里存大量对象时内存开销会明显变大。核心经验涉及指针的sizeof先问自己到底想算“指针本身”还是“指针指向的对象”。很多 bug 的根源就是把这两者混为一谈。3. 数组的 sizeof完整数组与退化陷阱数组是sizeof最容易出教学事故的地方也是面试官最爱挖坑的区域。理解数组的退化decay规则基本就理解了一大半。3.1 完整数组变量sizeof 返回总字节数对完整数组变量使用sizeof得到的是整个数组占用的总字节数等于元素个数乘以单个元素大小。int a[10]; printf(%zu\n, sizeof(a)); // 10 * 4 40 printf(%zu\n, sizeof(a) / sizeof(a[0])); // 10元素个数第三行是个非常经典的求数组元素个数写法实测非常稳定。注意这里除的是a[0]而不是具体的int这样即使以后数组类型改成short或struct公式也不用改。但这里有个致命前提a必须是“完整数组变量”。一旦数组作为参数传给函数它就退化成指针了。void func(int arr[]) { printf(%zu\n, sizeof(arr)); // 输出 8不是 40 }C/C 里数组作为函数参数时int arr[]实际上被编译器调整为int *arr。在函数内部对参数做sizeof拿到的永远是指针大小。这不是 bug是语言规则但确实坑了无数人。正确的做法是同时传递长度参数void func(int arr[], size_t n) { // 使用 n不要用 sizeof(arr)/sizeof(arr[0]) }3.2 二维数组和多维数组二维数组的sizeof要分层理解int matrix[3][4]; printf(%zu\n, sizeof(matrix)); // 3 * 4 * 4 48整个矩阵 printf(%zu\n, sizeof(matrix[0])); // 4 * 4 16第一行 printf(%zu\n, sizeof(matrix[0][0])); // 4一个元素前三行分别对应“矩阵本身”“一行”“一个元素”。这种分层关系在写矩阵运算、图像处理代码时很常用。matrix[0]本身是数组类型长度为 4 的int数组所以sizeof(matrix[0])是完整的行大小但matrix[1]这种表达式在绝大多数场景下会退化成int *只有sizeof和能保留它的数组属性。二维数组传参同样会退化。函数形参写成int matrix[][4]时第一维会退化成指针实际等价于int (*matrix)[4]。在函数内对matrix做sizeof得到的是指针大小 8对matrix[0]做sizeof得到的才是 16一行的大小。这就是为什么处理二维数组时行数必须额外传列数可以靠类型体现。3.3 数组转字符串和复制操作的 sizeof 陷阱网上经常有“数组转字符串”的问题本质上是字符数组和char*的区别。定义字符数组并用字符串字面量初始化char buf[] hello; printf(%zu\n, sizeof(buf)); // 6包含结尾的 \0这里很容易犯一个错误很多教程会用strcpy(dest, src);来复制字符串如果目标缓冲区的空间分配用的是sizeof(char*)或者sizeof(src)src 是函数参数缓冲区大小就不够然后发生缓冲区溢出。我见过的生产环境崩溃里有一类就是这种问题引起的。安全的写法是复制之前明确知道目标缓冲区的容量用snprintf或带长度限制的函数来复制别用裸的strcpy。在 C 里直接用std::string管理字符串从根源上避开 C 风格字符串的缓冲区管理问题。4. 字符串的 sizeof 与 strlen八字不合的一对很多新手以为sizeof和strlen都是算字符串长度其实两者要解决的问题完全不同。sizeof算的是“内存占用字节数”strlen算的是“字符串有效字符数不包含结尾的 \0”。一个是编译期算类型和对象的大小一个是运行期扫描内存找\0。4.1 数组形式与指针形式的结果差异同样一个字符串放在数组里和放在指针里的sizeof结果天差地别char str1[] hello; char *str2 hello; printf(%zu\n, sizeof(str1)); // 6整个字符数组 printf(%zu\n, strlen(str1)); // 5有效字符 printf(%zu\n, sizeof(str2)); // 8指针自身大小 printf(%zu\n, strlen(str2)); // 5有效字符str1是数组sizeof包含结尾的\0str2是指针sizeof只能得到 864 位平台。很多跨函数传递字符串的 bug 就出在这里有人在子函数里用sizeof(src)代替strlen(src) 1 来分配内存结果只分配了 8 字节后面的字符全部写越界。4.2 字符串字面量的 sizeof 和宽字符字符串字面量本质上是一个数组所以对字面量本身取sizeof也包含结尾的\0printf(%zu\n, sizeof(hello)); // 6 printf(%zu\n, sizeof(Lhello)); // 平台相关Linux 64 位上是 24一个宽字符 4 字节6 个字符含结尾空字符宽字符的情况尤其容易迷惑人。Linux 上wchar_t是 4 字节Windows 上是 2 字节同一句sizeof(Lhello)在两个平台的值不一样。如果你的代码要跨平台解析宽字符串不要假设wchar_t的大小用sizeof(wchar_t)来计算。4.3 拼接、截断的常见误用场景还有一类问题strlen返回的是不带\0的长度而很多缓冲区分配代码写的是malloc(strlen(s))这其实少算了一个字节。正确写法是malloc(strlen(s) 1)。在处理文件路径、网络协议消息时我习惯用一个统一封装size_t string_buf_size strlen(data) 1; // 不要省那个 1 char *out (char *)malloc(string_buf_size);这个“1”是无数缓冲区溢出漏洞的来源之一属于安全编码规范里被反复强调的基础要求。另外sizeof和strlen在性能上也有差异sizeof是编译期常量VLA 除外零开销strlen要 运行时扫描内存直到遇到\0长字符串上会累积可观的时间成本。5. 结构体与联合体的 sizeof内存对齐躲不开结构体的sizeof是经验型工程师和刚入门者拉开差距的地方。它不单单是成员大小之和还涉及内存对齐、填充字节、位域等规则。5.1 内存对齐规则和计算示例结构体的对齐规则可以概括为两点每个成员按照自身对齐数对齐到相应偏移位置结构体整体大小要补齐到最大对齐数的整数倍。看个例子struct A { char c; // 偏移 0占 1 字节 int i; // 对齐到 4偏移 4~7 double d; // 对齐到 8偏移 8~15 }; printf(%zu\n, sizeof(struct A)); // 16如果把成员顺序换一下struct B { char c; // 偏移 0 double d; // 对齐到 8偏移 8~15 int i; // 对齐到 4偏移 16~19 }; printf(%zu\n, sizeof(struct B)); // 24不是 20最后要补齐到 8 的倍数同样三个成员只是顺序不同大小从 16 变成 24。在写网络协议、文件头结构、GPU Vertex 数据时成员顺序直接影响结构体大小和解析效率。我的习惯是把大类型double、指针、64 位整数放在前面小类型放在后面尽量让结构体紧密排列减少填充字节。5.2 offsetof 与结构体成员偏移offsetof宏定义在stddef.h中返回成员在结构体中的字节偏移量#include stddef.h struct A { char c; int i; }; printf(%zu\n, offsetof(struct A, i)); // 4这个宏在实现通用序列化、反射式遍历结构体、手写对象池时非常有用。需要注意的是offsetof只适用于标准布局类型如果结构体里有虚函数或者继承自带虚函数的基类使用offsetof的行为在标准上就有争议了。C11 之前会有问题C11 以后要求标准布局类型才能使用否则行为未定义。5.3 #pragma pack 的对齐控制与风险有时候我们希望结构体紧密排列比如读写二进制文件时文件格式本身就是紧凑的编译器填充会导致解析错位。这时候可以用#pragma pack#pragma pack(push, 1) struct FileHeader { char magic[4]; // 4 uint32_t size; // 4 uint16_t ver; // 2 }; #pragma pack(pop) printf(%zu\n, sizeof(struct FileHeader)); // 10#pragma pack(push, 1)将对齐数设为 1成员之间不再填充结构体大小等于所有成员之和。代价是访问效率下降在部分架构上甚至可能引发总线错误比如 ARM 上非对齐访问可能导致异常。我的建议是只在对二进制格式有严格要求的代码里使用并且必须加push/pop成对出现避免影响后续结构体的对齐设置。5.4 位域、空结构体和联合体C 和 C 里允许定义位域位域成员的存储与编译器实现强相关struct BitField { unsigned int a : 3; unsigned int b : 5; unsigned int c : 8; }; printf(%zu\n, sizeof(struct BitField)); // 结果取决于编译器通常是 4sizeof不能直接作用于位域成员也不能取位域成员的地址。跨平台存储位域的顺序也可能不同大端、小端下位域分配方向不同。用位域优化内存占用没问题但一旦涉及二进制序列化最好自己手动做位运算别依赖位域的编译器实现。空结构体在 C 和 C 里行为不同。GCC 的 C 编译环境下sizeof(struct Empty)结果是 0C 里是 1为了让每个对象有唯一地址。在写跨语言接口时这个差异很可能埋雷。联合体的sizeof等于最大成员的大小但还要考虑对齐。比如union U { char c[13]; // 13 int i; // 4 }; printf(%zu\n, sizeof(union U)); // 16对齐到 4 的倍数且能放下最大成员联合体常用于实现类型双关type punning、协议解析里的不同视图但 C 标准对“读一个联合体的非活动成员”是未定义行为实际开发中要用得谨慎。6. 类的 sizeof空类、虚函数、继承与静态成员从 C 的结构体到 C 的类sizeof的规则在成员对齐的基础上又加入了虚函数表指针、继承、静态成员等概念。6.1 空类为什么是 1加虚函数为什么是 8C 里空类sizeof是 1不是 0。因为标准要求同一类型的对象必须有不同地址所以编译器为每个空对象分配至少 1 字节。class Empty {}; printf(%zu\n, sizeof(Empty)); // 1一旦类里有虚函数编译器会向类中插入一个虚表指针vptr这个指针的大小就是平台上指针的大小64 位下是 8class WithVirtual { public: virtual void f(); }; printf(%zu\n, sizeof(WithVirtual)); // 8这里有个常见疑问既然虚函数表是一个类共有的为什么每个对象要额外占 8 字节因为每个对象需要通过自己的 vptr 找到对应动态类型的虚表这种“空间换多态”的代价是必须为每个对象承担的。6.2 继承体系中的 sizeof非虚继承与虚继承非虚继承时派生类对象的内存布局是先放基类部分再放派生类新增成员class Base { int a; // 4 }; class Derived : public Base { int b; // 4 }; printf(%zu\n, sizeof(Derived)); // 8这种简单场景下派生类大小基本等于基类大小加自身成员大小再按最大对齐补齐。虚继承会引入另一个指针虚基类指针情况就复杂了class VBase { virtual void f(); }; class VDerived : virtual public VBase { int x; };sizeof(VDerived)在常见编译器下可能是 16 或 24具体取决于编译器实现。我不建议死记这类结果因为不同编译器的布局策略不统一写出的代码一旦依赖具体大小就很容易在换编译器后出问题。需要做的是在代码里及时用sizeof验证你的假设。6.3 静态成员、空基类优化与成员函数静态成员变量属于类不属于对象所以sizeof(类)不包含静态成员的大小class WithStatic { static int s; // 不算大小 int a; // 4 }; printf(%zu\n, sizeof(WithStatic)); // 4成员函数本身也不计入对象大小因为函数代码在代码段对象里并不会保存函数体。还有一个经常会考的点是空基类优化EBO。在继承一个空类时编译器允许让派生类不额外占用那 1 字节class EmptyBase {}; class DerivedFromEmpty : public EmptyBase { int a; }; printf(%zu\n, sizeof(DerivedFromEmpty)); // 某些编译器为 4而不是 8这个特性在标准库分配器、某些模板元编程技巧里都有应用。如果是包含组合空类对象就不会有这个优化sizeof会额外多出至少 1 字节。6.4 C 中 sizeof 在泛型编程里的经典用法在实践中sizeof配合模板能做很多编译期判断。比如检测一个类型是否可拷贝构造template typename T struct IsCopyConstructible { template typename U static auto test(int) - decltype(U(std::declvalconst U()), std::true_type()); template typename static std::false_type test(...); static constexpr bool value decltype(testT(0))::value; };核心就是利用decltype里的表达式如果合法sizeof/decltype才能在编译期求值。这类技巧在 SFINAE、类型萃取、序列化库的自动生成里非常常见。理解了sizeof的“编译期求值、不求值语义”看这些代码就不会觉得是黑魔法了。7. 常见问题排查与速查表最后整理一份排查清单和速查表这些全是我在实际项目里踩过或帮别人排查过的坑。7.1 排查思路与典型 bug 现场第一个经典场景函数内sizeof数组参数得到指针大小。void process(int data[]) { for (int i 0; i sizeof(data) / sizeof(data[0]); i) { // 循环次数错了sizeof(data) 是 8data[0] 是 4结果是 2 // 但实际数组长度可能是 100 } }排查这类问题的思路很明确先用printf或调试器打出sizeof(data)的值如果发现是 8 或者 4直接确认是数组退化。修复方法是改成void process(int data[], size_t count)。第二个常见问题sizeof和strlen混用。char *buf malloc(strlen(src)); // 少 1 字节末尾 \0 写越界 char *buf malloc(strlen(src) 1); // 正确这种问题隐蔽性很高因为很多情况下越界写到的内存没有立刻崩溃直到有别的数据被覆盖才表现出诡异的行为。用 AddressSanitizer 编译-fsanitizeaddress时这类越界会立刻被报告出来。第三个问题结构体里sizeof与序列化不匹配。struct Header { uint8_t version; uint32_t length; uint8_t flags; }; fwrite(hdr, sizeof(hdr), 1, fp);sizeof(Header)在默认对齐下是 12不是 6。如果写文件时按 12 字节写入但解析方按 6 字节解析整个文件格式立刻崩坏。排查方法是把结构体大小和成员偏移打出来和文档规定逐项对比。第四个问题C 中sizeof用于不完整类型或位域。struct Forward; // 不完整类型 sizeof(Forward); // 编译错误这不难理解编译器不知道这个类型有多大。对位域取sizeof也会报错。所以当你看到“invalid application of sizeof to incomplete type”或类似的报错时检查是不是类型定义没包含全、指针用成了值、或者对位域取了大小的操作。7.2 常用速查表下面这张表是我平时会保留一份在手里的速查表方便快速回忆。注意带平台的项要根据实际编译环境确认。场景结果或公式关键提醒char1C/C 标准保证普通指针64 位8与指向类型无关普通指针32 位4与指向类型无关sizeof(arr)数组变量元素个数 * 元素大小仅限完整数组变量数组做函数参数退化为指针大小必须额外传长度sizeof(hello)6包含结尾\0strlen(hello)5运行时扫描不含\0结构体对齐到最大对齐数的整数倍成员顺序影响大小联合体最大成员大小考虑对齐不能访问非活动成员C UB空类1C 保证唯一地址带虚函数的类含 vptr通常 8 起继承体系按编译器布局静态成员不计入对象大小不影响sizeof(类)sizeof作用在表达式上不执行表达式编译期求值VLA 除外刚入门的读者看到这张表先不要急着背去编译器里跑一遍亲手验证每个结果印象会深得多。尤其是结构体对齐和数组退化多改几次成员顺序、多传几次数组参数很快就能形成条件反射。7.3 快速判断“要不要用 sizeof”的思维模型我的个人习惯是遇到sizeof问题先问三个问题第一个问题我想知道的是“类型/对象的内存占用”还是“字符串的有效长度”前者用sizeof后者用strlen或者std::string::size()。第二个问题这个变量现在还是完整的数组吗它有没有在表达式里退化成指针只要它被传进函数、赋值给指针、参与算术运算大概率已经退化了sizeof就不再表示完整数组的大小。第三个问题这个结果是不是编译期常量我可不可以拿它定义数组长度、做模板参数如果需要编译期常量sizeof的结果在绝大多数场景下都满足但如果是在 VLA 上使用就不是编译期常量了。这三个问题想清楚九成以上的sizeof坑都能避开。7.4 关于本主题的一些真实体会我在实际项目里的一个最深的体会是sizeof这个知识点初看很小但它往往是代码 bug 的源头而不是表面上的“大小算错了”那么简单。比如结构体对齐问题会影响缓存性能会影响网络协议解析会影响文件格式兼容性数组退化问题会导致静默的数据截断和越界写入这些 bug 不会当场崩溃而是在几个月后线上环境里突然爆发。我自己在写新代码时凡是涉及二进制的结构、跨模块的缓冲区大小计算都会先把sizeof的结果用printf或者static_assert显式验证一遍。C11 之后我可以直接写static_assert(sizeof(Header) 24, Header layout changed unexpectedly);一旦将来有人在结构体里加了个成员、改了成员顺序或修改了对齐方式这个static_assert会在编译期直接报错而不是让线上解析逻辑悄然出错。这个习惯帮我拦截了非常多隐性布局变更问题。最后分享一个真正实用的小技巧在调试结构体或者排查内存错乱时我经常把sizeof和offsetof的结果组合成一个表一目了然地对照每个成员的偏移和总大小。打印模板大概是这样的printf(sizeof(struct Foo) %zu\n, sizeof(struct Foo)); printf(offsetof(c) %zu\n, offsetof(struct Foo, c)); printf(offsetof(i) %zu\n, offsetof(struct Foo, i)); printf(offsetof(d) %zu\n, offsetof(struct Foo, d));一旦发现某个成员偏移与预期不符基本就能定位到是内存对齐设置、成员顺序还是平台上基本类型大小不同导致的问题。把这些小工具固化到自己的调试流程里遇到sizeof相关的疑难问题就不容易手忙脚乱了。
返回列表