数组名和指针不是一回事,这是 C 语言笔试里最高频的“送命题”,也是很多人在实际代码里莫名其妙踩坑的重灾区。不少初学者背结论背得很熟:数组名会退化成指针、a 和 &a 一样、函数传数组传的是指针……但真到做题或调试时,还是分不清sizeof(a)、&a、a+1到底等于什么。这篇文章就把这个坑从头拆一遍,结合笔试常考题型和实际调试经验,告诉你什么时候数组名是指针,什么时候不是,以及遇到这类题应该按什么顺序判断。
如果你是准备校招笔试、刚学指针,或者写 C 语言项目时被数组传参搞烦过,这篇都值得看完。我不打算只列结论,而是把判断思路和验证方式一起讲清楚,让你下次看到int a[5]时能直接得出正确答案。
1. 先把结论摆出来:数组名到底是不是指针
很多人学 C 语言时会听到一句话:“数组名就是指针。”这句话在大方向上学着用没问题,但严格抠起来是错的。数组名在大多数表达式里会“退化”成指向首元素的指针,这是 C 语言标准里规定的数组到指针的转换规则。但数组名本身作为一个类型,是int[5]这样的数组类型,不是int*类型。
1.1 数组名的本质
看这段代码:
int a[5] = {1, 2, 3, 4, 5}; printf("a 的类型推断:%zu\n", sizeof(a)); printf("a+0 的类型推断:%zu\n", sizeof(a+0));在 64 位环境里,sizeof(a)结果是20(5 个 int,每个 4 字节),而sizeof(a+0)结果是8(一个指针的大小)。如果数组名真是指针,sizeof(a)也应该是 8,但实际不是。
原因在于:a是数组对象本身,sizeof作用于数组名时,返回的是整个数组占用的字节数。而a+0这个表达式里,a先退化成了指向首元素的指针,再进行加法运算,结果类型是int*,所以sizeof得到的是指针大小。
判断一个标识符是不是指针,最直接的方式就是看它的类型。a的类型是int[5],p如果声明成int *p,类型才是int*。类型不同,能做操作自然不同。
1.2 什么时候数组名会“看起来像指针”
数组名在以下情况中会退化成指向首元素的指针:
- 作为赋值操作的右值
- 参与算术运算,比如
a+1 - 作为函数实参传递
- 与指针比较或运算
但以下情况不会退化:
sizeof(a)&a取整个数组的地址- 对数组名使用
typeof、_Alignof等编译期操作
笔试经常挖的坑,就是在这两类情况之间来回切换。比如:
int a[5]; printf("%p %p\n", a, &a);打印出来的地址值通常是相同的(同一个内存起始位置),但类型不同:a的类型是int*(退化为首元素指针),&a的类型是int(*)[5],也就是指向整个数组的指针。地址值一样不代表类型一样,后续做加减时结果会完全不同。
2. 笔试最爱挖的坑:sizeof、&a、指针加减
2.1 sizeof(a) 和 sizeof(p) 为什么不一样
这是最直接的一个坑。看下面代码,写出输出:
int a[5] = {0}; int *p = a; printf("%zu %zu\n", sizeof(a), sizeof(p));答案在 64 位环境是20 8。sizeof(a)是整个数组大小,sizeof(p)是指针变量大小。
如果数组作为函数参数传入,情况又变了:
void func(int arr[]) { printf("%zu\n", sizeof(arr)); }这里sizeof(arr)是指针大小,因为函数形参的数组写法会被编译器处理成指针。也就是说,int arr[]和int *arr在这个函数里等价。
这个坑特别容易让新手产生混乱:为什么同一个名字,在函数外是 20,在函数内是 8。原因就是形参发生了“退化”。C 语言里数组作为参数传递时,不会把整个数组拷贝进函数,而是传递指向首元素的指针。这也是为什么函数里想拿到数组长度,必须额外传一个长度参数。
2.2 &a 和 &a[0] 的区别
&a[0]是取数组第一个元素的地址,类型是int*。&a是取整个数组的地址,类型是int(*)[5]。两者的地址值一样,但步长不一样。
int a[5] = {0}; printf("%p\n", a); // 首元素地址 printf("%p\n", &a[0]); // 首元素地址 printf("%p\n", &a); // 整个数组地址,数值上和前面相同 printf("%p\n", a + 1); // 地址 + 4 字节 printf("%p\n", &a + 1); // 地址 + 20 字节如果你把&a + 1强转成int*,它指向的是整个数组之后的内存位置。很多笔试代码会写:
int a[5] = {1,2,3,4,5}; int *p = (int*)(&a + 1); printf("%d\n", p[-1]);结果是5。因为&a+1越过了整个数组,再往回退一个 int,刚好回到最后一个元素。这种题考的就是指针类型步长。做错了通常不是不会加减,而是没有意识到&a的类型决定+1跳了多远。
2.3 指针加减和数组下标
数组下标a[i]本质上等价于*(a + i)。但要注意,这个等价性建立在a是数组名或指针的前提下。`
常见笔试题:
int a[5] = {10, 20, 30, 40, 50}; int *p = a; printf("%d %d\n", p[2], *(p + 3));这题答案是30 40,只要清楚下标就是偏移就行。但如果写成:
int *p = a + 4; printf("%d\n", p[-2]);答案是30。p指向最后一个元素,p[-2]就是往前偏移两个元素,指向a[2]。负数下标在 C 语言里是合法的指针偏移,只是容易越界,不是语法错误,这是很多人没意识到的。
再看出题人爱考的组合:
int a[3][4] = {0}; printf("%zu\n", sizeof(a)); // 48 printf("%zu\n", sizeof(a[0])); // 16 printf("%zu\n", sizeof(a[0][0])); // 4如果对二维数组的“数组的数组”理解不深,这里很容易写成sizeof(a)是 4 或 8。实际上a是包含 3 个int[4]的数组,所以总大小是 3×4×4=48 字节。
3. 函数传参时数组名做了什么
函数传参是笔试和实际项目里最容易踩坑的地方。很多人写完一个数组处理函数,发现sizeof(arr)不对,或者二维数组传参报错,都是因为没搞清楚函数形参里数组名发生了什么。
3.1 形参退化成指针
C 语言规定,数组作为函数参数时,会退化为指向首元素的指针。这个退化发生在编译期,函数内部无法通过形参得到原始数组的大小。
void print_array(int arr[], int n) { for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); }arr[]在编译器眼里就是int *arr。所以:
void print_size(int arr[10]) { printf("%zu\n", sizeof(arr)); // 8,不是 40 }[10]只是给阅读者看的提示,编译器不检查实参数组的实际长度。就算你传入int arr[5],编译器也不会报错,因为形参就是指针。
这带来的常见问题是:很多初学者在函数里用sizeof(arr)/sizeof(arr[0])计算长度,结果完全错误。正确做法是额外传入长度参数,或者使用宏定义固定长度。
3.2 二维数组传参的尺寸问题
二维数组传参比一维更复杂。看这个函数:
void func(int arr[][4]) { // 可以访问 arr[i][j] }这里第二维必须写清楚,因为编译器需要知道每一行有多少个元素,才能计算arr[i]的偏移。如果写成int arr[][],编译会报错,因为不知道每一行的长度,无法确定行指针步长。
也有人会这么写:
void func(int (*arr)[4]) { // 等价于 int arr[][4] }int (*arr)[4]是指向一行(包含 4 个 int 的数组)的指针。这种写法在函数内部可以用arr[i][j],和二维数组名退化的结果一致。
请注意,int *arr[]和int (*arr)[4]完全不同。前者是指针数组,数组里每个元素都是int*;后者是数组指针,指向一个数组。笔试经常让判断这两个声明:int *a[4]因为优先级[]高于*,所以 a 先和[]结合,是大小为 4 的指针数组;而int (*a)[4]中 a 先和*结合,是指向数组的指针。
3.3 怎么设计函数签名才不踩坑
实际项目里,我更推荐用显式指针加长度参数,而不是依赖数组形参的简化写法。原因有三个:
- 明确表达函数不修改数组时,用
const修饰指向的内容,可读性好。 - 长度单独传参,避免函数内部调用
sizeof。 - 二维数组传参时,明确写出列宽,或用一维方式手动计算偏移,不容易出边界问题。
示例:
void process_matrix(int rows, int cols, int matrix[][cols]) { for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { matrix[i][j] += 1; } } }C99 支持变长数组(VLA)作为形参,cols必须是函数参数中在前面出现的变量。编译时用支持 C99 或更高标准的编译器即可。在嵌入式环境里,如果编译器对 VLA 支持不好,就退回到int (*matrix)[COLS],把列宽定义为宏。
4. 字符串、指针数组、多维数组的边界
字符串和字符数组这一块,笔试题非常爱考,因为字符串字面量、字符数组、字符指针三者的内存位置和可修改性不同。很多人直接混淆,导致代码运行后崩溃还不知道为什么。
4.1 字符串字面量和字符数组
看两段代码:
char s1[] = "hello"; char *s2 = "hello";s1是一个字符数组,内容存储在栈上或全局区(取决于声明位置),可以修改s1[0]。s2是一个指针,指向字符串字面量。字符串字面量通常存储在只读区,修改s2[0]会触发未定义行为,很多系统上直接段错误。
笔试里常写成:
char *p = "hello"; p[0] = 'H'; // 危险操作正确答案是:这是未定义行为,程序可能崩溃。不应该修改字符串字面量指向的内容。
但如果是:
char p[] = "hello"; p[0] = 'H'; // 合法,p 是可修改的数组这里 p 是数组名,不是指针。虽然它经常被用来和字符串交互,但类型是char[6],而不是char*。sizeof(p)是 6(包含结尾空字符),sizeof(s2)是 8(指针大小),这两者很容易被忽略。
4.2 指针数组存放字符串
指针数组适合存放多个字符串,尤其是字符串长度不一致的情况。比如:
const char *names[] = {"Alice", "Bob", "Charlie"};names是数组,数组的每个元素是const char*。names[0]是一个指针,指向字符串字面量"Alice"。
如果用二维字符数组:
char names[][8] = {"Alice", "Bob", "Charlie"};每个字符串都占 8 字节,不足的部分补零。sizeof(names)是 24,sizeof(names[0])是 8。这种方法适合字符串长度固定或较短的情况,但浪费空间;指针数组只存指针,空间占用更小,但字符串字面量本身仍然占存储。
两者的另一个区别是:二维数组的内容可以安全修改(因为每一个char元素都是数组的一部分,是可修改对象),指针数组如果用const char*指向字面量,则不能通过该指针修改内容。面试官经常用这个点来区分考生到底理解的是内存布局还是只记住了“字符串就是字符指针”。
4.3 多维数组和指针数组区别
二维数组和指针数组在访问形式上都是a[i][j],但底层逻辑完全不同。
二维数组int a[3][4]的内存是连续分布的一整块 12 个 int。a[i][j]的地址计算公式是:(int*)a + i * 4 + j。因为每一行长度固定,所以编译器可以自动算偏移。
指针数组int *p[3]中,p本身占据 3 个指针的空间,每个p[i]可以指向不同的内存区域。p[i][j]实际上是先取p[i]的值(一个指针),再对这个指针做偏移。三块内存不一定连续,长度也可以完全不同。
这也解释了为什么二维数组作函数参数时必须写列宽。如果只传int a[][],编译器不知道每行多长,无法计算行偏移。而指针数组作参数直接写int *p[]或int **p都行,因为每个元素本身就是一个指针,不需要知道指向的数组有多长。
实际开发中我的建议是:如果数据是一张矩形的表,用二维数组;如果每一行长度差异很大,或者要动态分配,用指针数组或二级指针。
5. 笔试现场怎么快速判断:一张表和一套排查顺序
笔试时间紧,不能每道题都从头推一遍,你要有快速判断的套路。我总结了一套顺序,做题时按这个来,准确率会高很多。
5.1 常见判断表
遇到一个表达式时,先判断它是数组名还是指针变量,再看操作符。这张表可以帮你快速定位:
| 表达式 | 类型 | 值/结果 | 典型陷阱 |
|---|---|---|---|
a | int[5],表达式中退化为int* | 首元素地址 | 不要误以为数组名永远是数组类型 |
&a | int(*)[5] | 整个数组地址(数值与首地址相同) | 地址值相同,但+1步长不是 4,是 5×sizeof(int) |
a[0] | int | 第一个元素 | 不是指针 |
&a[0] | int* | 首元素地址 | 和a数值相同,类型也相同 |
a + 1 | int* | 跳过一个元素 | 不要和&a + 1混淆 |
&a + 1 | int(*)[5] | 越过整个数组 | 如果强转成int*,再减 1 指向最后一个元素 |
sizeof(a) | 无 | 整个数组字节数 | 数组名没退化 |
sizeof(&a) | 无 | 指针大小 | &a是指针,不是数组 |
函数形参int arr[] | int* | 指针 | 用sizeof是错的 |
int *p[5] | 指针数组 | 数组元素是指针 | 优先级:[]高于* |
int (*p)[5] | 数组指针 | 指向数组的指针 | 不是数组!是一个指针 |
5.2 做题时的思考顺序
遇到数组和指针题,我会按以下流程判断:
- 先看这个操作符是什么:
sizeof、&、*、[]、+、-。 - 如果是
sizeof和&操作数直接作用于数组名,数组名不退化,按数组类型处理。 - 如果是一般运算表达式,数组名退化为首元素指针,按指针规则处理。
- 看指针的类型,而不是仅看地址值。类型决定步长。
- 看是否涉及函数传参,函数形参里的数组声明一律当作指针。
- 再看是否涉及字符串字面量,判断内存是否可修改。
比如题目问:
int a[3][4]; printf("%zu %zu %zu\n", sizeof(a), sizeof(a+1), sizeof(*(a+1)));逐步分析:sizeof(a)是 48(数组名作用于 sizeof 不退化);a+1中 a 退化为指针,a+1的类型是int(*)[4],是指针,所以sizeof(a+1)是 8;*(a+1)是对行指针解引用,得到int[4]类型的左值,数组名没有再进行运算,此时sizeof作用于数组类型,结果是 16。
很多人在sizeof(*(a+1))这里犯错,以为结果是 4。实际上*(a+1)是一个长度为 4 的数组对象,不是单个 int。
再看一道常见题:
char *str[] = {"ab", "cde"}; printf("%zu %zu\n", sizeof(str), sizeof(str[0]));str是包含 2 个指针的数组,sizeof(str)是 16(64 位环境下两个指针)。str[0]是指针变量,sizeof(str[0])是 8。别把它和sizeof("ab")混淆,后者是 3,包括结尾的空字符。
5.3 自己验证的方式
笔试准备的阶段,我不建议直接背答案,建议每个结论都亲手跑一遍。可以写一个简单的测试程序:
#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)); printf("a=%p, &a=%p\n", (void*)a, (void*)&a); printf("a+1=%p, &a+1=%p\n", (void*)(a+1), (void*)(&a+1)); return 0; }输出会直接展示地址值和步长差异。建议你在自己的编译器上把每种声明都打一遍,尤其是int *p[5]和int (*p)[5]的sizeof和赋值。
提示:
printf打印指针时建议强转成void*,这样符合格式串要求,也可以避免某些编译器在%p下报警告。
6. 学习建议:不要靠背题,靠理解内存模型
我最想强调的一点是:数组名和指针这个问题,靠背结论应付不了笔试变化题。面试官稍微改一下操作符、换换维度,答案就不同。只有把内存模型画清楚,才能真正稳定得分。
6.1 画内存图是基本功
遇到任何数组指针问题,我的习惯是在纸上画格子。比如int a[3][4],画 3 行 4 列的格子,每个格子写一个 int。a是整个格子的起始位置,a[1]是第二行格子的起始位置,a[1][2]是第二行第三列的格子。
指针变量的图要画成“一个格子存地址,箭头指向目标格子”。数组名的图就是一个区域,没有单独的箭头变量。这两个模型一旦在脑子里定型,不容易出错。
比如int *p = a;画出来就是 p 变量里存储了 a 第一行第一个格子的地址。int (*p)[4] = a;画出来是 p 指向一整行(长度为 4 的数组),所以p+1跳一行。这个差异用格子图一眼就能看出来。
6.2 练习环境
练习 C 语言不需要复杂的工具。本地装一个 GCC 或 Clang,写一个.c文件,用命令行编译运行就够了。很多在线判题系统也支持 C 语言,可以快速验证代码输出。
如果你在 Windows 上用的是集成开发环境,注意编译标准。至少支持 C99 的编译器比较合适,因为变长数组和for循环声明等特性会用到。如果编译器版本较老,某些写法可能报错,不一定是你的逻辑问题,也可能是标准太旧。
嵌入式方向的同学还要注意,交叉编译器的实现和 PC 上的 GCC 可能有差异,尤其是sizeof大小、默认对齐方式、位域内存布局。做笔试题时,除非题目特别说明,一般按照通用 32 位或 64 位平台来算;32 位平台指针是 4 字节,64 位平台是 8 字节,sizeof(int)也不一定总是 4,但绝大多数 PC 环境是 4。
6.3 常见误区再强调
最后把容易反复犯的误区集中列一下:
- 误区一:
a就是&a[0]。严格说,表达式值上大多数时候相等,但&a的类型完全不同。&a[0]是int*,&a是int(*)[5],做题时要区分。 - 误区二:函数形参里的数组和数组名是一回事。形参里的
int arr[]已经被替换成int *arr,所以函数里算不出原始长度。 - 误区三:任何字符串都是字符数组。字符串字面量是只读的,用字符数组初始化和用指针指向字面量,可修改性完全不同。
- 误区四:
int *p[5]和int (*p)[5]读起来差不多。先从优先级和结合性入手,[]优先于*,所以前者是数组,后者是指针。 - 误区五:看到地址值一样就认为类型一样。地址值只是内存位置,类型决定步长和可执行操作。
- 误区六:
&a + 1会越界访问。其实它只是跳到数组末尾之后的位置,属于“指向数组之后的下一个位置”的合法指针值,但你不能解引用它,除非进行强转和恰当偏移。 - 误区七:数组下标越界会立刻报错。C 语言不会自动检查下标,越界往往导致难以察觉的内存破坏。笔试里可能问结果,但实际代码中千万不要依赖这种未定义行为。
如果准备笔试时间紧,把这些误区对应的代码都自己跑一遍,记住输出,再理解为什么。如果时间充裕,建议从 C 标准里关于数组转换的规则看起,配合指针运算和 sizeof 规则一起理解,效果会好很多。这不是投机取巧,而是把底层逻辑打通,以后写嵌入式、操作系统、数据结构相关的 C 代码都会少踩很多坑。