news 2026/9/30 4:41:11

彻底分清C语言数组指针与指针数组:声明、内存、应用与避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
彻底分清C语言数组指针与指针数组:声明、内存、应用与避坑

数组指针和指针数组,绝对是C语言里最经典的一对“双胞胎恶魔”。名字几乎一样,含义却完全相反,面试八股题爱考,日常编程里也经常因为用错而酿成内存访问事故。我做C/C++开发这些年,见过不少刚入行的同事在这俩概念上摔跟头,包括我自己早期也被绕晕过。这篇就把它们彻底拆开,从声明语法、内存布局、初始化遍历、函数传参到典型误区,一层层讲透,配合可以直接跑的示例代码。不管你是在校学生、准备面试,还是工作中需要写底层代码,看完这篇应该都能彻底分清,不再靠死记硬背。

1. 先别急着背结论,把两个概念的本质拆开

很多人学这俩概念时,老师会直接甩出“数组指针是指向数组的指针,指针数组是存放指针的数组”这种结论。结论没错,但如果只背结论,不理解背后的声明语法和内存模型,换个写法照样懵。所以第一步,先把这两个词的字面意思和C语言语法彻底对应上。

1.1 指针数组:本质是一个数组,元素是指针

“指针数组”四个字,主词是“数组”,修饰词是“指针”。所以它的本质是一个数组,只不过这个数组里装的不是int、char这些普通数值,而是地址值。

比如int *arr[5];,根据C语言的运算符优先级,下标运算符[]的优先级高于解引用运算符*,所以arr会先和[5]结合,变成“一个有5个元素的数组”。那这个数组的每个元素是什么类型呢?去掉数组部分看剩下的声明,int *,也就是每个元素都是一个int *类型的指针。

到这里可以下一个结论:int *arr[5]声明了一个数组,数组名为arr,它占用的内存是5个指针变量连续排列的空间。每个元素都能单独存放一个int型变量的地址,也能单独被赋值、被修改。你可以把它理解成一个“地址抽屉柜”,柜子本身是固定的,有5个抽屉,每个抽屉里放的是一个地址。

1.2 数组指针:本质是一个指针,指向的对象是数组

“数组指针”四个字,主词是“指针”,修饰词是“数组”。所以它的本质是一个指针,只不过这个指针指向的不是单个变量,而是一整块连续的同类型数组。

关键写法是int (*p)[5];。这里为什么必须加括号?因为如果不加括号,就变成了int *p[5],也就是上一节说的指针数组。加了括号之后,*先和p结合,说明p首先是一个指针变量,指针指向的对象类型由剩下的int [5]决定,也就是“一个内含5个int元素的数组”。

所以int (*p)[5]的意思是:定义一个指针p,这个指针只能指向“数组类型为 int[5]”这样的对象。如果你让它指向一个长度为5的int数组,p就指向该数组的第一个元素;如果你对p执行p+1,指针会直接跳过一整段长度为5个int的内存区域,而不是只跳过一个int。

1.3 声明语法里的优先级规则是分水岭

很多人分不清这两个概念,根子就在运算符优先级上。C语言里,[]和()的优先级高于*。这条规则直接导致:

写法结合顺序本质含义
int *arr[5]arr先和[5]结合数组数组里存了5个int*指针
int (*p)[5]p先和*结合指针指针指向含5个int的数组

一个从右往左看声明的技巧:找到标识符(变量名),然后先向右看,遇到[或(再向左看。int *arr[5],从arr向右看,先看到[5],说明它是数组;int (*p)[5],从p先向右看到),遇到右括号就得先回头向左看,看到*,说明它是指针。这套“右左法则”后面还会用到,是阅读一切复杂C声明的基础。

这里提前说一个参悟方法:写代码时如果想偷懒验证自己的理解,可以用sizeof直接看大小。sizeof(arr)在64位系统上是5乘以8字节等于40字节(每个指针占8字节),而sizeof(p)只是8字节(一个指针变量的大小)。一个占40字节,一个占8字节,两者内存模型完全不同。

2. 从内存布局看懂数组指针和指针数组的本质差异

语法层面的理解只是第一步。真要彻底掌握这俩概念,必须切换到内存视角,看看它们到底在内存里长什么样。这也是调试指针相关bug时最关键的能力——脑子里能浮现出内存布局图。

2.1 指针数组的内存结构:一排连续的指针变量

先看指针数组。假设有以下代码:

int a = 100, b = 200, c = 300; int *arr[3]; arr[0] = &a; arr[1] = &b; arr[2] = &c;

这时的内存布局是这样的:数组arr自己占一块连续的内存,长度是3个指针,每个指针8字节(64位系统),总共24字节。每个指针里存的分别是局部变量a、b、c的地址。也就是说,arr数组里的三个元素,各自指向内存中三个分散的int变量。

如果用文字画个示意图:

arr 数组(连续24字节) +--------+--------+--------+ | &a | &b | &c | +--------+--------+--------+ a、b、c 三个int变量在内存中可能相隔很远

要访问b的值,需要二级寻址:先到arr[1]取出地址,再到那个地址取出整数。用代码写就是*(arr[1]),或者直接用arr[1]这个指针加解引用语法*arr[1]。实际上a、b、c这些变量在内存里的位置可以完全不连续,因为数组里保存的只是地址,地址之间无需关联。

2.2 数组指针的内存结构:一个指向连续数据块的指针

再看数组指针。典型场景是让它指向一个二维数组的某一行或整个二维数组的首行。

int matrix[3][5] = {0}; int (*p)[5] = matrix;

这时的内存布局完全不一样。p只是一个单独的指针变量,占8字节。matrix是一个二维数组,在内存中是15个int连续排列,总共60字节(假设int为4字节)。p指向的其实是matrix的第一个元素,也就是matrix[0]那一行,而matrix[0]本身又是一个包含5个int的一维数组。

关键差异在于步长。如果定义一个普通的int *q = matrix[0],那么q+1只跳过4字节,也就是一个int。但如果用数组指针p,p+1会跳过一整行,也就是5个int,20字节。这个步长信息是编译器根据int (*p)[5]声明中的[5]推断出来的。

所以数组指针的“数组”二字不是摆设,指针指向的是一个整体对象,而这个对象的大小在声明时已经确定。后面用p[i][j]访问二维数组元素时,编译器实际做的事情是:*( *(p + i) + j )。先让p跳i行,得到第i行的地址,再解引用得到一维数组名,再在这个一维数组里偏移j个元素。

2.3 用代码验证类型和内存大小

建议你亲自跑一下这段验证代码,印象会深得多:

#include <stdio.h> int main(void) { int a = 100, b = 200, c = 300; int *arr[3] = {&a, &b, &c}; int matrix[3][5] = {0}; int (*p)[5] = matrix; printf("sizeof(arr) = %zu\n", sizeof(arr)); // 24 printf("sizeof(p) = %zu\n", sizeof(p)); // 8 printf("&arr[0] = %p\n", (void*)&arr[0]); printf("&arr[1] = %p\n", (void*)&arr[1]); printf("matrix = %p\n", (void*)matrix); printf("matrix+1 = %p\n", (void*)(matrix + 1)); printf("p = %p\n", (void*)p); printf("p+1 = %p\n", (void*)(p + 1)); return 0; }

运行后你会发现两件事:第一,arr数组元素的地址是连续且等距的,中间隔了8字节;第二,matrix与matrix+1的地址差是20字节,正好是一行5个int的大小,而p+1与p的差值同样是20字节,因为p的类型就是“指向含5个int数组的指针”。

注意:这里的%zu是sizeof返回值的正确格式说明符。很多人习惯用%d打印sizeof的结果,在部分平台会出警告,虽然大多数时候能跑,但不严谨。

3. 实操环节:初始化、遍历、取值,一套流程跑通

概念说得再玄,落到代码上无非就是“怎么声明、怎么初始化、怎么访问元素”。下面从最常用的操作出发,把两种指针的典型用法完整走一遍。

3.1 指针数组的初始化与遍历

指针数组最常见的初始化方式有三种:直接初始化、逐元素赋值、循环赋值。直接初始化通常用在已知目标地址的场景:

int a = 10, b = 20, c = 30; int *arr[3] = {&a, &b, &c}; for (int i = 0; i < 3; i++) { printf("arr[%d] = %p, *arr[%d] = %d\n", i, (void*)arr[i], i, *arr[i]); }

这里有个细节值得注意:arr[i]是第i个元素的地址值,*arr[i]才是该地址指向的int变量。很多初学者会写错成*arr[i]和arr[i]分不清,其实只要记住:arr[i]本身是一个指针,要取目标值必须再加一个星号解引用。

如果元素是指向动态分配内存的指针,通常是先分配再逐元素赋值:

int *arr[3]; for (int i = 0; i < 3; i++) { arr[i] = malloc(sizeof(int)); if (arr[i] != NULL) { *arr[i] = i * 100; } }

这种写法在C语言里很常见,相当于用一个指针数组管理了多个独立分配的内存块。用完记得逐个free,先free数组里的指针,再用free整个数组变量(如果数组本身是动态分配的)。

3.2 数组指针绑定二维数组并逐行访问

数组指针最常见的用途就是配合二维数组使用。绑定过程很简单,二维数组名本身就带“数组指针”语义:

int matrix[3][5]; int (*p)[5] = matrix; for (int i = 0; i < 3; i++) { for (int j = 0; j < 5; j++) { p[i][j] = i * 5 + j; } }

为什么不直接用matrix而要再定义一个p?当然可以直接用matrix,但p的存在说明我们能把“二维数组的行”当作一个独立类型来传递和操作。比如封装一个专门打印矩阵的函数,参数类型就写成数组指针,传入的是任意一个“有5列的二维数组”的首地址。这种情况下,数组指针就是函数与数据之间的桥梁。

访问元素时,p[i][j]和matrix[i][j]完全等价,因为p和matrix的地址值相同、类型兼容。底层运算逻辑是:p指向一个含5个int的数组,p+i指向第i行这个数组,*(p+i)得到第i行这个数组的数组名(首元素地址),再*(p+i)+j得到第i行第j个元素的地址,最后再解引用。

3.3 重点场景:指针数组存放字符串

这是实战里最常见的指针数组用法,也是相关热词里反复出现的主题。在C语言里,字符串本质上就是字符数组,而用指针数组存放多个字符串,是一种极其经典的数据组织方式。

char *fruits[] = {"apple", "banana", "cherry", "durian"};

这个声明咋一看可能容易懵:fruits是一个指针数组,每个元素是char *,指向一个字符串字面量。字符串字面量在内存中是一个只读字符数组,fruits[0]存的是字符串"apple"的首字符地址。遍历时直接用%s打印:

for (int i = 0; i < 4; i++) { printf("%s\n", fruits[i]); }

那为什么不用二维字符数组来存呢?比如char arr[4][20] = {...}。两者区别主要体现在内存利用率和操作灵活性上。第一,用二维数组时每一行长度固定为20字节,无论字符串实际多短都会占满20字节,空间浪费严重;用指针数组时每个指针只占8字节,字符串数据以实际长度存放在只读区,紧凑得多。第二,对字符串排序时,指针数组只需交换指针变量的值,即8字节的数据,而二维数组需要整行整行地交换字符内容,代价完全不是一个量级。第三,字符串长度差异越大,指针数组的优势越明显。

对比维度char arr[4][20]char *fruits[]
内存占用每行固定20字节,共80字节4个指针共32字节,字符串数据另算
修改字符串内容可修改对应字符字符串字面量通常只读,重赋值需改指针
交换两个字符串逐字符拷贝,代价高交换指针即可,代价极低
适用场景需要修改字符串内容时只需要读取和重排字符串时

我平时做命令行工具时,命令表和字符串菜单就喜欢用指针数组。比如把错误信息集中放到一个char *err_msgs[]里,通过枚举下标访问,改文案时只需要改一个字符串字面量,不用动业务逻辑代码,维护起来非常舒服。

4. 实战场景:函数参数、二维数组与算法里的指针思想

理解概念只是地基,真正考验功力的是“什么时候用哪个”。这一章从实际开发中最常见的几个场景出发,讲清楚选择哪种方式的依据。

4.1 函数形参里的数组指针,为什么不能省列数

写过二维数组传参的人应该见过这种代码:

void printMatrix(int (*m)[5], int rows) { for (int i = 0; i < rows; i++) { for (int j = 0; j < 5; j++) { printf("%4d", m[i][j]); } printf("\n"); } }

调用时传入二维数组名即可:

int matrix[3][5] = {0}; printMatrix(matrix, 3);

注意,int (*m)[5]这里的5绝对不能省略。为什么?因为数组指针的步长依赖这个“5”。编译器在计算m[i][j]的地址时,需要知道每一行跨越多少个int,才能算出第i行的起始位置。如果把函数声明成void printMatrix(int **m, int rows),就会出大问题——matrix的类型是int (*)[5],int **完全不是一个类型,虽然地址值一样,但解引用和步长计算规则全错了。

引用我之前踩过的一个坑:把二维数组直接传给int **形参,编译报警或运行崩溃。刚接触时以为二维数组名就是“指针的指针”,其实在C语言中,二维数组名是“数组的数组”,降级后变成“指向数组的指针”,而不是“指向指针的指针”。两者在内存布局上就不一样:int **指向一个存放指针的内存单元,而int (*)[5]指向一个存放整型数组的内存块。

4.2 指针数组在命令行参数与配置表里的经典应用

指针数组一个广为人知的应用场景就是main函数的参数:

int main(int argc, char *argv[])

这里的argv就是典型的指针数组,每个元素指向一个命令行参数字符串。argv[0]是程序名,argv[1]是第一个参数,以此类推。为什么用指针数组而不是二维字符数组?因为命令行参数个数不固定,每个参数字符串长度也不固定,指针数组天然适合“数量动态、内容只读”的场景。

类似的思路在很多嵌入式项目里也常见。比如定义一张命令表:

static const char *commands[] = { "start", "stop", "reset", "status" };

再配合一个函数映射表,就能实现简单的命令行分派。这种写法可读性好,新增一个命令只需要往表格里加一行,不需要修改冗长的if-else链。指针数组在这里的价值就是“把一组异构字符串组织成统一可遍历的结构”。

4.3 快慢指针:指针思想在算法中的延伸

最近看到有关“JS快慢指针有序数组原地去重”的讨论,很多初学者误以为这个“快慢指针”和“数组指针”有什么关系。严格来说,后者是C语言类型系统里的概念,前者是算法思想里的技巧,但两者内核相通:都是用一个“移动游标”去访问和控制数据结构里的位置。快慢指针在有序数组原地去重时,核心逻辑是用一个慢索引指向当前已去重区域的尾部,一个快索引扫描整个数组,遇到不重复元素就搬到慢索引的位置。这套思路和C语言中指针偏移数组元素、控制写入位置的思维方式如出一辙。

比如用C写一个有序数组原地去重:

int removeDuplicates(int* nums, int numsSize) { if (numsSize == 0) return 0; int slow = 0; for (int fast = 1; fast < numsSize; fast++) { if (nums[fast] != nums[slow]) { slow++; nums[slow] = nums[fast]; } } return slow + 1; }

这里的slow和fast虽然不是指针类型,但本质上就是在用“指针思维”操作数组下标。用好这套思维,回头看数组指针和指针数组的差别,也会更从容——说到底,指针是你手里的一根“遥控器”,关键是搞清遥控器指向的对象是什么类型,以及按下“加一”键时它跳多远。

5. 常见误区与排查技巧实录

概念看懂了,代码写起来还是会遇到各种奇怪的报错和运行崩溃。这一章专门整理我在实际调试中反复遇到的典型误区,以及排查方法。建议把这部分当成“避坑手册”收藏,遇到类似问题时回来翻翻。

5.1 把二维数组名当int **传参

这是最高频的误区。有些编译器甚至会直接报不兼容警告,但很多人看到警告不重视,程序一跑就段错误。原因前面说过了:matrix的类型是int (*)[5],而int **要求指针指向的位置存放一个int *类型的值。matrix指向的位置存放的是一个 int,解引用后拿到的根本不是一个合法的地址。

排查方法很简单:看编译警告,如果提示“incompatible pointer type”,别忽略。另外可以用typeof或者读代码的方式自查:int **m要能用m[i][j]访问元素,要求m指向的是一段“指针数组”的起始地址,而普通二维数组在内存里从头到尾全是int数据,没有指针数组这层结构。

5.2 数组指针步长理解错误

另一个常见问题是以为p+1就跳到下一个元素。前面反复强调,数组指针的对象是整个数组,p+1跳过的是整个数组的长度。如果写成:

int arr[5] = {1, 2, 3, 4, 5}; int (*p)[5] = &arr; printf("%d\n", *(p+1)); // 错误,越界访问

这里*(p+1)访问的是arr之后一整段内存,已经越界,行为未定义。正确访问第i个元素是(*p)[i],或者更直观地利用数组指针与二维数组的配合:p指向的是“整个长度为5的数组”,(*p)才是那个数组的名字,(*p)[i]就是数组的第i个元素。如果需要遍历arr,直接用for (int i = 0; i < 5; i++) (*p)[i]。

在调试这类问题时,打印地址差是最直接的验证方式。写几行测试代码,用%p打印p和p+1的地址,看差值是否等于数组的总字节数。这一步能直观验证你对步长的理解。

5.3 使用“右左法则”快速读懂复杂声明

遇到int (*(*p)(int))(int)之类的函数指针声明时,很多人直接崩溃。其实只要掌握“右左法则”,这类问题都能拆解:先找到标识符,然后从标识符开始向右看,遇到)再向左看,依此类推。

右左法则具体步骤:

  1. 找到变量名,比如p。
  2. 从p开始向右看,遇到什么就先说什么。
  3. 遇到右括号)后回头向左看,遇到*说明当前层次是指针。
  4. 一层一层剥开,直到整个声明分析完。

拿int (*p)[5]举例:从p向右看,遇到),回头向左看遇到*,说明p是指针。继续向右看,遇到[5],说明指针指向的对象是一个长度为5的数组。数组元素类型是int,所以整体是“指向含5个int数组的指针”。再看int *arr[5]:从arr向右看,遇到[5],说明arr是数组;再向左看,遇到int *,说明数组元素是int*。清清楚楚。

5.4 一段可自测的完整示例

我习惯在给团队培训时用它当作随堂测试题,读者也可以把它粘贴到本地编译运行:

#include <stdio.h> int main(void) { // 指针数组:arr 是一个数组,存放了 3 个 char* 指针 char *arr[3] = {"C", "Java", "Python"}; // 数组指针:p 是指针,指向包含 2 个 int 的数组 int row1[2] = {10, 20}; int row2[2] = {30, 40}; int matrix[2][2] = {{10, 20}, {30, 40}}; int (*p)[2] = matrix; // 验证指针数组 printf("arr[0] = %s\n", arr[0]); printf("arr[1] = %s\n", arr[1]); // 验证数组指针访问二维数组 for (int i = 0; i < 2; i++) { for (int j = 0; j < 2; j++) { printf("p[%d][%d] = %d\n", i, j, p[i][j]); } } // 打印步长 printf("matrix = %p\n", (void*)matrix); printf("matrix+1 = %p\n", (void*)(matrix + 1)); printf("p = %p\n", (void*)p); printf("p+1 = %p\n", (void*)(p + 1)); return 0; }

运行后重点观察两组输出:第一组是arr[0]、arr[1]能直接打印字符串,这是指针数组存字符串的直观体现;第二组是matrix和matrix+1的地址差,以及p和p+1的地址差,二者完全一致,这意味着p的步长就是一行二维数组的长度。这个小实验做完,这两个概念基本不会再混。

提示:如果使用C++编译器编译,建议把字符串字面量赋值给char *时加上const,以免编译器报字符串常量转类型警告。纯C环境下一般没这个问题。

个人经验方面,我最后再分享一个小习惯:学习指针相关概念时,永远用“类型 + 内存 + 步长”三个维度去分析一个声明。遇到任何一个指针变量,先问三个问题——它是什么类型?它指向什么对象?它加一之后跳多少字节?这三个问题回答清楚了,不管是数组指针、指针数组、函数指针还是二级指针,都不会绕晕。实际开发中如果还拿不准,就写个小demo,用printf打印地址和步长验证一下,比自己对着代码干想高效得多。这套方法我带过很多人都说管用,建议你也试试。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 4:40:54

Spring Cloud微服务OAuth2实战:授权服务器与资源服务器搭建指南

1. 项目整体思路拆解&#xff1a;为什么微服务要拥抱OAuth21.1 从一次登录说起做后端开发这些年&#xff0c;但凡涉及到用户登录、权限控制&#xff0c;几乎躲不开Spring Cloud Security和OAuth2这两个词。很多刚接触微服务的同学会有个疑惑&#xff1a;单体应用里我用SessionC…

作者头像 李华
网站建设 2026/9/30 4:40:05

Python Word表格提取指南:从python-docx到批量汇总Excel

1. 先把场景摊开&#xff1a;为什么我要用 Python 去折腾 Word 表格在我接触过的自动化需求里&#xff0c;Word 表格提取可能是出现频率最高、也最容易被低估的一项。很多朋友觉得“不就是把表格内容复制出来嘛”&#xff0c;但现实是&#xff1a;你每天收到的报价单、项目排期…

作者头像 李华
网站建设 2026/9/30 4:40:03

Vue3移动端表格组件实战:性能优化与触控交互设计全解析

如果你在移动端 H5 里做过表格&#xff0c;应该跟我有同感&#xff1a;PC 端那一套成熟的 table 方案&#xff0c;搬到手机浏览器里&#xff0c;要么横向滚动卡顿&#xff0c;要么列宽乱成一片&#xff0c;要么点击手势跟页面滚动的冲突怎么调都不对。我最近在公司项目里反复折…

作者头像 李华
网站建设 2026/9/30 4:39:36

Java毕设实战:垃圾分类查询管理系统开发全流程解析

最近翻出当年折腾的Java毕设——垃圾分类查询管理系统&#xff0c;正好赶上Java毕设选题和面试话题都比较火的节点&#xff0c;就拿出来聊聊。这个项目的完整叫法可以有很多&#xff1a;Java智能垃圾分类查询平台、全民垃圾分类指导管理系统&#xff0c;本质上就是一套基于Java…

作者头像 李华
网站建设 2026/9/30 4:39:36

aethermagic实战:用声明式装饰器统一Python参数管理与超参数调优

写Python也快十年了&#xff0c;自认对各种“魔法式”库见得多、也踩得多。但第一次接触aethermagic这个包的时候&#xff0c;还是愣了一下——它跟你常见的requests、pandas完全不是一个路数&#xff0c;它不解决“某个具体功能”&#xff0c;它解决的是无数个Python项目里最烦…

作者头像 李华