尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C语言数组与sizeof:从内存布局到函数传参的完整指南

C语言数组与sizeof:从内存布局到函数传参的完整指南 如果你刚开始学C语言一定绕不开两个概念一维数组、二维数组以及那个长得像函数、其实不是函数的sizeof。我在实际教学和写代码的过程中见过很多新手在同一个地方翻车明明在main函数里sizeof数组算长度算得好好的一旦把数组传进某个函数sizeof立刻返回一个864位系统指针大小然后整个程序逻辑就乱了。问题不在编译器而在你对数组名身份的把握。这篇文章不搞理论轰炸就以我自己的踩坑经验为主线把一维数组、二维数组的内存布局以及sizeof各种计量场景一次讲透顺便解决一批典型考试题和刷题时的迷惑。1. 数组和sizeof为什么总是被放在一起讨论1.1 数组名在表达式里的变身规则C语言里有一条至关重要、但初学者经常忽略的规则数组名在绝大多数表达式中会隐式转换成指向数组首元素的指针。这句话初看很抽象我用例子说明int a[5] {1, 2, 3, 4, 5}; printf(%p\n, a); // 打印a[0]的地址 printf(%p\n, a[0]); // 打印a[0]的地址这两行输出在绝大多数环境下是同一个值因为数组名a在printf的表达式里被转换为int *也就是指向首个元素的指针。但规则有例外。当数组名作为sizeof的操作数、作为取地址运算符的操作数、或者用于初始化字符数组的字符串字面量时不会发生这种转换。这正是我们在数组上使用sizeof能得到整个数组大小的前提。理解了这条规则你再看sizeof(a)和sizeof(p)之间的差距就不会觉得奇怪了。后面我会专门展开。1.2 sizeof是运算符不是函数很多人以为sizeof是类似strlen的库函数其实大错特错。sizeof是一个编译期运算符在编译阶段就能确定结果而不是运行到那一行才计算。语法上它有两种写法sizeof(int) // 对类型操作必须加括号 sizeof x // 对变量操作括号可有可无 sizeof(x) // 对变量操作时也常加括号但不是函数调用对类型使用sizeof时括号是必须的因为类型名本身是一个由多个词组成的语法单元对变量使用时有括号无括号都行。但为了统一大多数项目习惯写成sizeof(x)容易读。补充一点C99之后引入了可变长数组VLA此时如果sizeof的操作数是VLA它可能需要在运行时才能确定结果这是例外。初学阶段和绝大多数考试里我们仍按编译期求值理解即可。1.3 新手阶段最容易踩的三个坑第一个坑用sizeof当字符串长度。#include stdio.h #include string.h int main(void) { char str[] hello; printf(%zu\n, sizeof(str)); // 6因为包含结尾的\0 printf(%zu\n, strlen(str)); // 5没有空字符 return 0; }hello这个字符串字面量在内存里实际是h,e,l,l,o,\0六个字节所以数组str的长度是6。想表示有效字符个数必须用strlen。第二个坑在函数参数里对数组使用sizeof。void foo(int arr[]) { // 这里的sizeof(arr)几乎肯定不是数组大小 }原因我会在第五章讲透这里先记住结论数组一进函数参数列表就退化成了指针。第三个坑以为sizeof会执行里面的表达式。int i 5; printf(%zu\n, sizeof(i)); // 输出4 printf(%d\n, i); // 仍然是5因为sizeof在编译期只关心表达式结果的类型i的类型是int所以sizeof(i)得到4在int为4字节的机器上而i根本不会执行。2. 一维数组把连续内存这件事讲透2.1 声明、初始化与缺省规则一维数组的声明语法是类型 名字[元素个数]。例如int scores[10];这样就在内存中申请了10 × sizeof(int)个连续字节。数组要求元素个数必须是正整数常量这在C89/90里是硬性要求即使到了C99可变长数组也是特殊存在不建议在初学阶段乱用。初始化的常见写法int a[5] {1, 2, 3}; // 剩下的a[3]、a[4]自动置0 int b[] {1, 2, 3}; // 自动推导长度b长度为3 char name[] Tom; // name长度是4因为包括\0使用初始化列表时如果实际元素个数少于数组长度剩余元素会被自动初始化为0这一点在二维数组里同样适用。2.2 下标访问a[i]本质是*(ai)数组的连续内存布局决定了访问逻辑a[i]实际上等价于*(a i)。因为数组名a在表达式中先退化成int *a i做的是地址运算指向从首元素开始偏移i个int的位置再用*解引用取值。这里有个经典冷知识点由于加法满足交换律a[i]也可以写成i[a]它们在语法上等价。我不建议你故意这么写但刷题时遇到没见过这种写法的人至少要知道它不报错。地址和值要分清int a[5] {10, 20, 30, 40, 50}; printf(%d\n, a[2]); // 值30 printf(%p\n, (void *)a); // 地址指向a[0] printf(%p\n, (void *)(a 2)); // 地址指向a[2]a 1不是地址加一个字节而是加一个int的大小。这是指针运算和普通整数运算最大的区别。2.3 sizeof(a)和sizeof(p)为什么差这么多看代码#include stdio.h int main(void) { int a[5] {1, 2, 3, 4, 5}; int *p a; printf(sizeof(a) %zu\n, sizeof(a)); printf(sizeof(p) %zu\n, sizeof(p)); return 0; }在64位系统、int为4字节的典型环境下输出是sizeof(a) 20 sizeof(p) 8原因就是第一节提到的例外sizeof拿到的是a作为数组类型的完整大小即5 × 4 20而p是一个指针变量它的类型是int *sizeof只计算指针本身占多少字节与指向什么没有任何关系。换个更直观的说法数组是一个容器指针是一个“写着地址的标签”容器大小由内部所有元素决定标签大小只跟地址编码方式有关。再看一组容易混淆的表达式printf(%zu\n, sizeof(a)); // a的类型是int(*)[5]在64位下是8 printf(%zu\n, sizeof(*p)); // *p就是a[0]类型int结果是4 printf(%zu\n, sizeof(a 0)); // a0退化成指针结果是8这里必须强调a本身是数组类型但在a 0这种算术表达式里已经退化成指针了所以sizeof(a 0)是指针大小只有单独作为sizeof操作数时才保持数组身份。这个差异就是很多细节题的考点。3. 二维数组核心在于数组的数组3.1 内存布局行优先的连续大块二维数组在C语言里的本质是一维数组的一维数组。比如int matrix[3][4];你可以把matrix拆成三层理解matrix是一个长度为3的数组matrix的每个元素都是一个长度为4的int数组每个int元素占4字节这里以常见平台为例。内存里三行四列共12个int按“行优先”连续存放。顺序是matrix[0][0], matrix[0][1], matrix[0][2], matrix[0][3], matrix[1][0], matrix[1][1], matrix[1][2], matrix[1][3], matrix[2][0], matrix[2][1], matrix[2][2], matrix[2][3]相邻行之间没有空隙matrix[1][0]紧挨着matrix[0][3]。因此访问matrix[i][j]时编译器本质上是按下标公式算偏移matrix[i][j] (int *)matrix i * 4 j这里的4就是列数。之所以二维数组传参时列数不能省就是因为没有列数编译器根本不知道i该偏移多少。3.2 初始化的几种写法完全初始化int matrix[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} };不想花括号分行写也可以int matrix[3][4] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};顺序按行优先依次填充效果一样。但我建议还是用分行花括号这样代码可读性好漏数据时一眼就能看出来。部分初始化时没有写到的元素自动为0int matrix[3][4] { {1, 2}, {3} };那么matrix[0][2]、matrix[1][1]一直到matrix[2][3]全部是0。另外一个常见写法是省略第一维长度int matrix[][4] { {1, 2, 3, 4}, {5, 6, 7, 8} };编译器会根据初始化列表推断出matrix第一维为2。第二维不能省略否则编译器无法确定每行的步长也就无法分配连续内存。3.3 二维数组名的类型层级二维数组名在sizeof、下标访问和普通表达式里有完全不同的表现。用下面的代码可以直观看到#include stdio.h int main(void) { int matrix[3][4]; printf(sizeof(matrix) %zu\n, sizeof(matrix)); printf(sizeof(matrix[0]) %zu\n, sizeof(matrix[0])); printf(sizeof(matrix[0][0]) %zu\n, sizeof(matrix[0][0])); printf(sizeof(matrix 1) %zu\n, sizeof(matrix 1)); return 0; }在常见平台上输出可能是sizeof(matrix) 48 sizeof(matrix[0]) 16 sizeof(matrix[0][0]) 4 sizeof(matrix 1) 8解释一下matrix的类型是int [3][4]所以sizeof结果是3 × 4 × 4 48。matrix[0]的类型是int [4]所以是16。matrix[0][0]的类型是int所以是4。matrix 1中matrix退化为指向二维数组第一行的指针也就是int (*)[4]类型是指针所以sizeof(matrix 1)是指针大小。这里有一个很关键的渐变matrix数组名在普通表达式里退化成int (*)[4]也就是“行指针”。因此matrix 1跳过的不是一个元素而是整整一行4个int共16字节。这个行指针的概念是理解二维数组传参的基础。3.4 用sizeof推导行数和列数二维数组写死了3、4当然没问题但现实中数组大小可能被宏定义或将来修改写死数字很容易出错。利用sizeof可以安全地推导#include stdio.h int main(void) { int matrix[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; size_t rows sizeof(matrix) / sizeof(matrix[0]); size_t cols sizeof(matrix[0]) / sizeof(matrix[0][0]); printf(rows %zu, cols %zu\n, rows, cols); return 0; }逻辑很简单sizeof(matrix)是整个二维数组的字节数sizeof(matrix[0])是第0行的字节数也就是一行的大小两者相除行的个数sizeof(matrix[0][0])是一个元素的大小sizeof(matrix[0]) ÷ sizeof(matrix[0][0])就是每行元素个数。这样即使以后把matrix[3][4]改成matrix[8][6]推导代码一行都不用改。我在实际项目中很喜欢用到这个技巧。4. sizeof在真实项目中的计量场景与边界4.1 不同数据类型的sizeof和平台差异C语言标准只保证sizeof(char)等于1其他类型的大小都由编译器根据目标平台决定。在常见的桌面平台中大致规律如下类型32位平台典型大小64位平台典型大小char11short22int44long4Windows/ 8Linux4Windows/ 8Linuxfloat44double88int *48所以写代码时不要假设指针一定是8字节或4字节用sizeof去算才是可移植做法。打印sizeof结果建议用%zu这是C99专门为size_t类型提供的格式符比较安全。4.2 sizeof不会对表达式求值前面提过sizeof(i)不会自增。再举一个更“吓人”的例子int a[5]; printf(%zu\n, sizeof(a[10]));你不会真的去访问a[10]这个越界元素。因为sizeof只需要知道表达式a[10]的类型是int根本不会在运行时去计算地址、更不会去读取内存。结果是4在int为4字节的环境里程序也不会崩溃。写这种代码本身没有意义但它能帮助理解“编译期求值”的含义。4.3 sizeof与strlen字符数组里最容易混的一对字符数组是初学者使用最频繁也最容易混的一类数组。#include stdio.h #include string.h int main(void) { char str[] abc; char *p str; printf(sizeof(str) %zu\n, sizeof(str)); // 4 printf(strlen(str) %zu\n, strlen(str)); // 3 printf(sizeof(p) %zu\n, sizeof(p)); // 864位环境 printf(strlen(p) %zu\n, strlen(p)); // 3 return 0; }sizeof关心的是类型占用的内存大小strlen关心的是从给定地址开始到字符\0之前的字符个数。str数组大小是4因为字符串字面量abc自动带了一个\0但strlen只数a、b、c所以是3。指针p本身的大小是8但strlen(p)依然能算出3因为它会顺着指针指向的内存继续扫描直到遇到空字符。日常刷PTA这类题目时经常会出现“字符串逆序”“统计字符”之类的题处理的关键就是不要用sizeof代替strlen去遍历。遍历应该用strlen的结果或者老老实实地判断当前字符不是\0。4.4 结构体对齐会让sizeof变大虽然标题讲的是数组但结构体数组也经常碰到sizeof这里顺带提醒一下。C语言为了内存访问效率会在结构体成员之间插入填充字节导致结构体大小可能大于成员大小之和。struct Packed { char c; int x; }; int main(void) { struct Packed arr[10]; printf(%zu\n, sizeof(arr)); printf(%zu\n, sizeof(struct Packed)); return 0; }在常见的64位Linux默认对齐规则下sizeof(struct Packed)是8而不是5因为int按4字节对齐c后面被填充了3个空字节。因此arr总大小是10 × 8。如果你期望的是“一维数组总大小元素个数×成员手算大小”就会算错。这也是动态分配结构体数组时必须用sizeof(struct Packed)而不能手写每个成员大小之和的原因。5. 函数参数中的数组sizeof失效的根源与补救5.1 参数列表里的数组声明会被调整为指针这是C语言里最反直觉的规则之一。先看代码void foo(int arr[10]) { // 你以为arr是长度为10的数组 // 实际上arr就是int *arr } void bar(int arr[]) { // 等价于int *arr } void baz(int *arr) { // 这才是根本形式 }三个函数签名在这条规则下完全等价数组作为函数参数时会自动退化成指向首元素的指针。因此在foo函数内部写sizeof(arr)得到的是指针大小而不是10 × sizeof(int)。如果你在函数内天真地写void foo(int arr[10]) { size_t n sizeof(arr) / sizeof(arr[0]); // 结果8 / 4 2 }这在64位环境下会得到一个可笑的2完全不是10。这个问题不影响编译但会让逻辑悄悄错掉特别隐蔽。之所以这样设计是因为C语言传数组时不可能把整个数组复制进栈里代价太大。编译器采取的策略是“传首地址让函数内部用指针操作”所以数组形参只是语法糖本质还是指针。5.2 长度必须显式传一维数组最稳妥的做法既然函数内部拿不到数组长度正确做法就是调用者把长度传给函数。#include stdio.h void print_array(int arr[], size_t n) { for (size_t i 0; i n; i) { printf(%d , arr[i]); } putchar(\n); } int main(void) { int scores[] {98, 87, 76, 65, 54}; print_array(scores, sizeof(scores) / sizeof(scores[0])); return 0; }注意调用处的写法先把数组长度用sizeof除法算出来作为第二个参数传进去。这样无论是数组长度还是元素类型改变调用处都不容易出错。如果数组是动态分配得来的比如malloc出来的连续内存那么没有“数组名”也就没有sizeof去求长度。唯一的办法就是自己用一个变量保存长度并和那段内存一起维护。很多人在这里栽过跟头所以我建议把“动态数组自带长度变量”当成默认习惯。5.3 二维数组传参时行指针和列宽二维数组传参比一维更讲究。假设有一个三行四列的数组void print_matrix(int a[][4], size_t rows) { for (size_t i 0; i rows; i) { for (size_t j 0; j 4; j) { printf(%d , a[i][j]); } putchar(\n); } } int main(void) { int matrix[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; print_matrix(matrix, sizeof(matrix) / sizeof(matrix[0])); return 0; }这里void print_matrix(int a[][4], size_t rows)真正的参数类型是int (*a)[4]。也就是说a是一个指向“长度为4的int数组”的指针。第一维“行数”可以省略因为函数传进来的只是一个行指针至于有多少行完全由rows这个参数决定但第二维4不能省因为编译器要根据它来计算公式里的偏移量。如果把形参写成int **a那就不对了。int **a指向的是“指针的指针”它不能直接指向二维数组首行。很多初学指针的人看到matrix可以传给int **就认为二者等价这是错误认知。正确匹配关系是实参类型形参类型int matrix[3][4]int a[][4]或int (*a)[4]int *p[4]指针数组int **pint matrix[3][4]取matrixint (*a)[3][4]二维数组要想只传一个指针就访问行列必须把列宽作为类型的一部分。如果列数不确定C99之后可以用变长数组作为形参void print_matrix_vla(int rows, int cols, int a[rows][cols]) { // 行、列都由参数指定 }这种写法在初学阶段不常用但刷题和做课程设计时如果实现更灵活的函数可以了解一下。6. 用sizeof提升代码安全性的几个实用技巧6.1 数组长度宏的正确姿势为了避免在每个调用处都写sizeof(arr) / sizeof(arr[0])可以封装成一个宏#define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0]))用法int data[] {1, 2, 3, 4, 5}; size_t n ARRAY_LEN(data);宏里面的(a)为什么要加两层括号因为a可能被传成表达式不加括号会有运算符优先级问题。(a)[0]则保证取第一个元素。但注意这个宏只能用在真正的数组上如果把它用在指针上得到的是指针大小/元素大小结果毫无意义。所以使用前一定要确认你传给宏的是数组名不是函数参数里那个已经退化成指针的“伪数组”。更严谨的做法是用_Static_assert做编译期类型检查让类型不是数组时直接编译报错这个属于进阶技巧工程里很有用但这里不展开。6.2 动态分配二维数组时如何借助sizeof很多人动态分配二维数组会用“指针数组”的方式先分配行指针数组再逐行分配列数据。这样优点是行列可变缺点是多层malloc和free容易漏。如果你提前知道列数可以用一行malloc分配整个二维数组#include stdio.h #include stdlib.h int main(void) { size_t rows 5; int (*matrix)[4] malloc(rows * sizeof(*matrix)); if (matrix NULL) { return 1; } for (size_t i 0; i rows; i) { for (size_t j 0; j 4; j) { matrix[i][j] (int)(i * 4 j); } } free(matrix); return 0; }这里的matrix类型是int (*)[4]*matrix就是int[4]sizeof(*matrix)正好是一行的大小rows * sizeof(*matrix)就是整个连续内存的大小。分配之后可以直接用matrix[i][j]访问free也只需要一次。这种写法的好处是内存连续、缓存友好而且在分配语句里不写死元素大小万一类型从int变成doublesizeof(*matrix)会自动跟着变。对比一下两种分配写法int *p1 malloc(n * sizeof(int)); // 写死了类型 int *p2 malloc(n * sizeof(*p2)); // 跟随变量类型我建议习惯第二种。当后期把p2改成其他类型指针时不需要回头改sizeof后面的内容减少一处修改点。6.3 拷贝、遍历和边界检查的习惯数组拷贝最容易越界。如果用memcpy可以先确认目标数组容量足够再用sizeof参与计算int src[10] {1, 2, 3, 4, 5}; int dst[10]; memcpy(dst, src, sizeof(dst));这里使用sizeof(dst)而不是sizeof(src)是为了防止两边数组类型不一致时要么拷多、要么拷少。比如src是char src[40]dst是int dst[10]直接抄sizeof(src)会越界写坏内存。所以拷贝前建议写成这样if (sizeof(src) sizeof(dst)) { memcpy(dst, src, sizeof(dst)); } else { // 至少拷贝较小者并记录有效长度 size_t copy_bytes sizeof(src) sizeof(dst) ? sizeof(src) : sizeof(dst); memcpy(dst, src, copy_bytes); }字符数组拷贝还要额外考虑空字符。如果你想拷贝的不是整个数组比如只拷贝数组前三个整型元素int src[10] {1, 2, 3, 4, 5}; int dst[3]; size_t count sizeof(dst) / sizeof(dst[0]); memcpy(dst, src, count * sizeof(dst[0]));这里的count是目标数组能容纳的元素数而不是源数组的总数。平时写操作前先用sizeof算一遍“目标能装多少”比盯着循环变量硬跑要安全得多。最后再分享一个我个人的习惯凡是写出某个数组下标我都会下意识问自己“这个下标的上界是多少”如果上界来自某个sizeof计算我会单独写一个const size_t n ARRAY_LEN(...)而不是在循环条件里直接写数字。数组一旦重新定义大小只要所有地方都基于sizeof推导就不会出现“只改了声明忘了改循环上界”这种隐藏越界。C语言的内存模型给了程序员很大的自由度自由度越大越需要习惯用sizeof这种编译期手段去约束边界。希望我踩过的这些坑能帮你少走几段弯路。
返回列表