资讯详情

C语言指针辨析:数组指针、指针数组与二级指针一次讲透

📅 2026/10/20 12:55:55 | 华诺云谱 👁 阅读
C语言指针辨析:数组指针、指针数组与二级指针一次讲透
把int *p[3]、int (*p)[3]和int **pp并排放在一起绝大多数人会在头两秒内卡壳。我当年在某项目里排查一个段错误查了整整一下午最后发现就是把数组指针和指针数组混着用了——声明差一个括号函数传参时整个类型都变了编译器报了一堆看不懂的警告我硬是没往这儿想。这事之后我花了好几天把这三个概念彻底收拾明白发现它们其实没有那么玄只是被“指针”“数组”这两个词反复组合再加上C语言那套声明语法优先级的坑才显得傻傻分不清楚。这篇就是专门给还在“分不清”的人写的。我会从声明语法、内存布局、函数传参和动态内存分配几个角度把数组指针、指针数组、二级指针一次讲透争取让你看完之后不用再背口诀而是真正能“一眼辨认”。我尽量用大白话但涉及的代码和内存模型都是实打实的看完可以直接在编译器里跑。1. 分不清的根源C声明语法里那点优先级的事先回答一个最根本的问题为什么这三个东西这么容易混淆因为它们长得太像了。int *p[3]和int (*p)[3]的差别只有一个括号而int **pp看起来只是“多了一个星号”。但它们的含义南辕北辙——一个是数组一个是指针另一个还是指针只是指向对象不同。1.1 中括号的优先级比星号高这是总开关C语言里声明变量时[]下标运算符的优先级比*指针运算符高。这意味着什么呢看int *p[3]解析器先看到p[3]知道 p 首先是一个有 3 个元素的数组然后数组的元素类型是int *——所以 p 是一个“装着3个指针的数组”。而int (*p)[3]用括号把*p先圈起来括号的存在强制改变了结合顺序——p 首先是一个指针这个指针指向的东西是“有3个整数的数组”。所以它叫“指向数组的指针”简称数组指针。打个比方中括号像是一个“粘性很高”的容器谁先靠上去谁就先被装进去。不括起来int * 和数字 3 就会像排队一样先组成数组括起来星号才有机会抢在前面。1.2 三个声明对应的“步长”完全不同真正决定你后续代码怎么写、能不能编译过的是这些声明背后“一步跨多远”的问题。指针加减运算不是按字节走的而是按它指向对象的体积跨步int *pp 1 跨越 1 个 int。int (*p)[5]p 1 跨越 5 个 int因为 p 指向的整个对象是“5个int组成的一整块”。int **pppp 1 跨越 1 个int *也即一个地址值的宽度。这就好比走路步长。普通人一步几十厘米但你要是踩在一辆板车上你推一下它整个板车就移动了几米。数组指针就是这样一辆“板车”它指向的是整个数组货柜而不是货柜里的某一个格子。这个步长概念如果不建立起来后面看arr、arr、arr[0]的类型对比时会继续懵。2. 指针数组一个装着地址的普通数组先讲最容易理解的——指针数组。名字拆开就是“指针”和“数组”的偏正短语核心词是“数组”。它本质上就是一个数组只不过数组的每个元素是地址。2.1 内存布局看起来是什么样假设我这样声明int a 10, b 20, c 30; int *parr[3] {a, b, c};parr 里有三个格子每个格子存的是一个int*地址值。内存示意图大致是parr[0] - 存 a 的值parr[1] - 存 b 的值parr[2] - 存 c 的值sizeof(parr)在 64 位机器上是 24 字节3 个指针 × 8 字节如果换成int arr[3]那就是 12 字节3 个 int × 4 字节。所以指针数组里面装的不是整数而是地址。你遍历它拿到的是一个一个地址再用*解引用才能拿到真正的整数。这里有一个很直觉的应用场景如果你有多个字符串且长度长短不齐用指针数组比用二维字符数组要省空间。字符串常量本身就是“指向 const char 的指针”所以声明一个const char *strs[]就相当于把一个一个字符串指针装进了同一个数组。const char *names[] {alice, bob, charlie, dora};names[0] 是 alice但准确说 names[0] 存的是alice首字母的地址。这种写法在做菜单映射、错误码转字符串、配置项解析时非常常见我在不少小工具里都是用这个结构写的遍历直接for (i 0; i 4; i) printf(%s\n, names[i]);方便得很。2.2 典型应用命令行参数的 argv如果你写过 C 语言的 main那么你已经见过最经典的指针数组int main(int argc, char *argv[])argv 就是一个char *argv[]即“指针数组”。argv[0] 指向程序名argv[1] 指向第一个参数依次类推。string 类型的命令行参数天然就是一个字符串指针列表所以接口设计成char *argv[]是顺理成章的。这同时也说明了一个规律当一组数据本身就是“地址/字符串”集合时指针数组就是最自然的数据容器。说白了它跟一个普通数组没有任何区别只是元素的单位从 int、float 变成了地址。2.3 和二维字符数组的取舍不少初学者会拿char str[3][16]和char *str[3]对比这个对比特别有用特性char str[3][16]char *str[3]存储方式连续的一块48字节内存数组本身24字节字符串另行存放能否改字符串内容可以原地修改视字符串是否常量而定空间利用率固定行长短字符串浪费空间长度灵活几乎不浪费初始化方式逐字符拷贝只是把指针指向字符串常量适用场景需要原地修改且行长均匀只读字符串集合、参数表所以不需要硬背只需要记住一点你声明的是一个数组数组元素是地址那它就是指针数组。你声明的是一个指针指针指向数组那就是数组指针——这就是接下来要拆开的部分。3. 数组指针一个指向整块数组的指针数组指针这个词的语法结构和“山谷里的风”类似核心词是“指针”。它不是数组是一个指针。这个指针的特殊之处在于它指向的对象是数组而不是数组里某一个元素。3.1 为什么说步长是它的灵魂来看这行int (*pa)[5] NULL;pa 是一个指针它指向的类型是“有5个int的数组”。一旦 pa 指向了这样的数组pa 1 就会整体跳过 5 个 int 的距离。这一点和指针数组有着天壤之别——指针数组的parr 1只是跳过 8 字节即数组的第二个元素而pa 1跳过的是 20 字节的整块数组。这个“大步长”特性正是它能当好二维数组“行指针”的原因。我们看一个完整例子int matrix[2][5] { {1, 2, 3, 4, 5}, {6, 7, 8, 9, 10} }; int (*row)[5] matrix; // matrix 是 int (*)[5] 类型这时 row 指向第一行row 1指向第二行因为它一次性跨越了一行的 5 个整数。访问元素时(*row)[i]是“解引用得到那一行的数组再去下标 i”。更惯用的写法是row[0][i]、row[1][i]编译器会自动按行跨越。3.2 数组名和数组指针的微妙关系很多初学者以为“数组名就是指针”这个说法必须修正数组名在绝大多数表达式中会退化为首元素地址但数组名本身不是一个指针变量它的类型里保存着数组维度信息。matrix的类型是int (*)[5]指向长度为5的int数组的指针而不是int *更不是int **。这也是很多人犯错的根源。你写int *p matrix;编译器大概率报警告因为在matrix[0][0]是 int 的上下文里matrix是一个行指针行指针和单元素指针的类型不一致。强制转换也能编过但访问方式就变成了一维视角步长完全错了。我最早在这个细节上栽过跟头拿int *p matrix之后想p[1][2]直接编译报错因为 p 已经被降级成普通 int 指针p[1]拿到的都不是第二行。后来我才明白要让 p 能按行访问二维数组必须声明成int (*p)[5]让 p 的步长跟行宽匹配。3.3 数组指针不只是“二维数组专用”二维数组遍历是数组指针最经典的应用但它用在一维数组上也会带来不同的语义。比如int arr[10]; int (*p10)[10] arr;这里arr的类型是int (*)[10]指向的是“整个 arr 这个 10 元素数组”。*p10解引用后得到的是一个有着 10 个 int 的数组对象(*p10)[i]等价于arr[i]。这时你会发现sizeof(*p10)是 40 字节而不是 4 字节很多人在sizeof上会踩坑。顺带一提arr和arr在数值上一样但类型完全不同。前者是数组指针指向整个数组后者退化成int *指向首元素。数值相同不代表类型相同接着在函数传参里这种差异会突然从“无所谓”变成“生死存亡”。4. 二级指针指向指针的指针一级指针存的是普通变量的地址那二级指针存的是什么它存的是“指针变量”的地址。很多场景下一级指针搞不定的事二级指针轻松搞定——最典型的就是“要在函数里改变外面指针变量的值”和“动态构建二维数组”。4.1 二级指针的内存模型别想得太玄声明一个二级指针只需要一个星号接一个星号int a 10; int *p a; // p 指向 ap 的值是 a 的地址 int **pp p; // pp 指向 ppp 的值是 p 的地址内存中发生的事情是a 在某个地址存着 10p 这个指针变量在另一个地址存着 a 的地址pp 又在另一个地址存着 p 的地址。要拿到最终整数你需要**pp——第一次解引用得到 p 的值第二次解引用得到 a。用一句话说就是一级指针拐一个弯到数据二级指针拐一个弯到指针再拐一个弯才能到数据。4.2 动态分配二维数组的两种姿势很多人在需要“二维数组”时习惯写int a[3][4]但在运行时才知道行数、列数时就必须借助二级指针动态分配。最常见的写法是分两步int **matrix; matrix malloc(rows * sizeof(int *)); if (matrix NULL) return -1; for (int i 0; i rows; i) { matrix[i] malloc(cols * sizeof(int)); if (matrix[i] NULL) { /* 要记得清理之前已分配的内存 */ } }matrix 先被分配成一个“指针数组”——每一行是一个指针然后再让每个指针指向 malloc 出来的一行内存。此时matrix[i][j]访问的就是第 i 行第 j 列。用完之后的释放顺序必须是反的先释放每一行再释放 matrix 本身这个顺序写错就会内存泄漏或者 double free。也可以一次性分配一整块内存int **matrix malloc(rows * sizeof(int *)); int *data malloc(rows * cols * sizeof(int)); for (int i 0; i rows; i) matrix[i] data i * cols;这种方式的优点是内存连续数据访问对缓存更友好释放时只需释放两次matrix 和 data缺点是代码多一步指针计算。两种方式我都用过如果行列规模不大、建模阶段图省事用第一种如果数据量大、追求访问性能用第二种。4.3 函数里要修改指针本身必须传二级指针这里有一个在封装初始化函数时必踩的坑。假设你想写一个函数来分配内存并把指针带出来void alloc_bad(int *p) { p malloc(100 * sizeof(int)); }调用方int *p NULL; alloc_bad(p);这个函数执行完p 还是 NULL。为什么因为 C 的参数是值传递函数里的 p 是实参的副本你让副本指向了新内存外面的 p 根本没变。这就是经典的“传值改不了外边的变量”。要让函数改变外边的指针变量你必须收到“外边那个指针变量本身”的地址也就是传二级指针void alloc_good(int **pp) { *pp malloc(100 * sizeof(int)); } int *p NULL; alloc_good(p);这时函数内部通过*pp ...修改的是 p 变量自己外面自然生效。理解了这一点你再看自己在文件操作、链表初始化和各种资源管理代码里看到的int **handle、Node **head这类参数一下子就通了——不是设计者故意写得绕是 C 语言的值传递机制逼出来的。5. 混战实测用一段代码看清三者差异概念讲了一堆不如直接上代码。我写一段对比程序把三种类型的声明、sizeof 和访问方式打印出来你在自己的机器上跑一下就能看到这种差异有多么直观。5.1 声明对比与 sizeof 差异#include stdio.h int main(void) { int a 10, b 20, c 30; int *parr[3] {a, b, c}; // 指针数组3个指针 int (*parr2)[3]; // 数组指针指向含3个int的数组 int **pp; // 二级指针 printf(sizeof(parr) %zu\n, sizeof(parr)); // 24 printf(sizeof(parr2) %zu\n, sizeof(parr2)); // 8因为parr2本身是个指针 printf(sizeof(pp) %zu\n, sizeof(pp)); // 8 /* 把 parr2 指向一个真正的数组 */ int tmp[3] {100, 200, 300}; parr2 tmp; pp parr[0]; printf(parr[0] 的值是: %p\n, (void*)parr[0]); printf((*parr2)[0] %d\n, (*parr2)[0]); printf(**pp %d\n, **pp); return 0; }注意sizeof(parr)是 24 字节而sizeof(parr2)只有 8 字节。这是因为 parr 本身是一个数组数组的大小等于元素大小乘元素个数而 parr2 只是一个指针变量——它指向多大的数组那是“别人家”的事情不是它自己的体积。这个对比很能说明问题指针数组里“数组”是本体指针只是元素数组指针里“指针”是本体数组只是它指向的猎物。5.2 典型错误之一把数组指针当成指针数组来用我见过的一种错误写法是这样的int arr[3][3]; int **pp arr; // 试图用二级指针接二维数组名这个在很多编译器下会报不兼容的类型警告但勉强强制转换后能编译运行结果大概率错乱。原因还是一样arr的类型是“指向第一个元素长度为3的int数组的指针”也就是int (*)[3]它和int **的内存含义完全不同。二级指针期待的是“一个指针变量的地址”但arr这个名字表示的本来就是地址值本身你拿它的值喂给int**后面的pp[i][j]实际上在用一个错误的步长去扫内存。如果你非要“看效果”可以运行这段int arr[2][3]; int (*row)[3] arr; // 对这是正确的姿势 int *bad (int*)arr; // 强行当成一维用步长也错row 1跳过一整行 3 个 int而bad 1只跳过一个 int。同一个地址因为“解释它的类型”不同加法运算后的落点也完全不同。理解了步长差异你就明白为什么编译器会拦着你不让你拿int **去接二维数组名——它们跨步的计量单位根本不在一个频道上。5.3 典型错误之二释放顺序写反导致崩溃动态分配的二维数组释放顺序是必须背下来的经验/* 正确先释放内层再释放外层 */ for (int i 0; i rows; i) free(matrix[i]); free(matrix); /* 错误先 free(matrix) 的话matrix[i] 已经变成野指针 */ free(matrix); for (int i 0; i rows; i) free(matrix[i]); // 踩非法地址这个错误我在帮同事排查问题时见过不止一次。症状是一会儿崩溃、一会儿不崩溃特别难定位。实际上崩溃发生在第二轮循环访问matrix[1]时因为整个指针数组内存已经被归还给系统读到的地址值已经不可信。像这种问题只要动手画一下内存模型很快就能看出来不需要靠猜。6. 识别技巧、记忆套路与调试建议到了最后这一部分我想分享一些我实际工作中沉淀下来的辩认和调试经验不一定在教科书上看到过但对快速解决问题很有帮助。6.1 一眼识别的“三步法”遇到任何复杂的指针声明我都用三步走先找变量名。看变量名最近处有没有括号——如果变量名右边先出现[]那它是数组如果变量名先被*绑定比如出现在圆括号里那它是指针。再看它剩下的修饰是“什么数组”还是“指向什么”。举例int *p[3]变量名 p 右边紧跟[3]所以是数组元素是int *。int (*p)[3]变量名 p 先被括号里的*绑住所以是指针指向int [3]。int **pp变量名 pp 右边没有任何[]只有两个*所以它是指向指针的指针。这个方法几乎可以拆解 C 语言里 90% 的复杂声明包括函数指针那类更怪的写法。6.2 调试时先画内存图再决定下一步遇到指针相关 bug 时我习惯先在纸上画三样东西变量的地址、变量存的值、这个值指向的目标。每次画完多半 bug 原因就清楚了。比如segment fault有时不是因为你解引用了空指针而是你把它当成了另一个类型的指针导致错位访问。画图能让你迅速看到“步长”和“存储类型”是否匹配。用调试工具时也可以打印类型信息比如使用编译器对sizeof或报错信息的提示不要急着改代码。编译器对类型不兼容的警告非常精准往往第一行警告就是病根——你读懂它就不会去改无关的行。6.3 几条从实战里攒下来的经验最后给几条具体的操作建议都是我踩过或者帮别人排查过的坑函数参数里当你需要传出指针本身时用二级指针当你只需要修改指针指向的内容时用一级指针。不要混用。数组名和数组名数值相同但类型不同前者常退化成元素指针后者是数组指针。需要多维遍历时优先考虑int (*)[N]而不是int **。声明复杂类型时加括号一定不会错宁可多写一对括号也不要赌优先级。释放动态分配的二维数组务必记住“先内层、后外层”释放完把指针置成 NULL避免后续误用。拿一句话总结这三个东西就是指针数组是“一堆指针”数组指针是“指向堆的指针”二级指针是“指向指针的指针”。它们分别对应着数据结构的三种组织习惯——平铺一堆地址、按行锁定一块连续区域、通过一层重定向再找指针。真正常用起来没有谁比谁更高级只有用对场景的问题。我在实际项目里最深的感受是这类概念看着像考试题但一旦你在代码里被int**和int (*)[N]搞出过一两个内存错误就再也不会忘了。别担心记不住多跑几个不同写法的 hello world 程序、多打印几次地址和 sizeof比背十遍口诀都管用。下一次再有人问你“数组指针和指针数组的区别”你可以吐槽一句括号真的能救命。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑