
很多人第一次被指针搞到头大不是在单级指针上而是在翻开某个成熟项目的头文件、看到int (*p)[5]和int *p[5]并排出现的那一刻——长得几乎一样含义却差了十万八千里。再往下读还有char *argv[]、函数指针当参数往里塞、字符指针指着一串字面量却被随手改了一下……这些其实都属于 C 语言里指针进阶最核心、也最容易翻车的那一小撮知识点数组指针、指针数组、字符指针。这篇文章不打算从什么是指针讲起默认你已经会写int *p a这种基础操作。我们要解决的是那层看着懂、写起来错、考试面试老被问的进阶部分这两种声明到底怎么读、内存里长什么样、sizeof 和步长各是多少、传参为什么必须那样写、字符指针改字符串为什么会崩以及二级指针、函数指针、空指针这些高频连带考点。无论你是刚学完 C 基础想再往上走一步的学生还是工作里偶尔要读底层代码、需要把某个老库改一改的开发者这篇都能当作一份可以反复查的手册来用。1. 先搞懂声明怎么读括号、优先级与右左法则指针进阶的一切混乱根源几乎都在声明语法上。C 的类型声明是中缀表达式写出来的读到int *p[5]这种复合类型时很多人下意识从左边往右边念结果就念反了。想真正弄明白数组指针和指针数组的区别第一步不是去记结论而是掌握一套能让你独立推导出任何复杂声明的读法。1.1 优先级和结合性[]凭什么比*先结合C 语言的运算符优先级里后缀运算符[]数组下标和()函数调用的优先级比一元运算符*解引用/指针声明符高。记住这一条很多困惑就自动解开了。看int *p[5]这里的p会优先和右边最近的[5]结合也就是说p首先是一个大小为 5 的数组数组里装的是什么再往左看是int *所以数组元素是指向 int 的指针。合起来int *p[5]是一个数组里面有 5 个int *元素这叫指针数组。再看int (*p)[5]这里多了一对括号强行把*和p绑在一起于是p先和*结合成为一个指针然后这个指针指向[5]即一个大小为 5 的数组数组元素是int。合起来int (*p)[5]是一个指针它指向一个含 5 个 int 的数组这叫数组指针。一句话总结记忆点看括号跟谁结合。*和变量名被括号包在一起它是指针[]直接贴在变量名后面它是数组。1.2 右左法则一把能读所有复杂声明的万能钥匙优先级只够应付这两种遇到更复杂的还是得靠右左法则right-left rule。规则很朴素从最内层的括号里的变量名开始先向右看遇到[]说明是数组遇到()说明是函数再向左看遇到*说明是指针如果遇到括号就把括号里的内容重新按这套规则读一遍。拿int (*p[10])[20]练手从p开始先向右看是[10]说明p是数组再向左看是*说明元素是指针出了内层括号向右看是[20]说明这个指针指向大小 20 的数组再向左看是int。结论p是一个数组有 10 个元素每个元素是指向含 20 个 int 数组的指针。这是一个数组元素是数组指针。我用一张表把常见的几种声明和它们的准确含义对齐方便你对照声明中文读法是什么sizeof64 位int4 字节int *p指向 int 的指针指针8int *p[5]含 5 个int*的数组指针数组40int (*p)[5]指向含 5 个 int 数组的指针数组指针8int (*p[5])(int)含 5 个函数指针的数组指针数组元素是函数指针40int (*p)(int)指向参数 int、返回 int函数的指针函数指针8char *p指向 char 的指针字符指针8char **p指向指向 char 的指针的指针二级指针8提示读声明时不要从左往右一口气念。先找变量名再按先右后左推导出错率会大幅下降。这个习惯能帮你避开后面百分之八十的坑。2. 数组指针步长、传参和二维数组那点事搞清声明只是第一步真正决定程序能不能跑对的是这个指针加减 1 的时候内存会跳多远以及它的类型能不能正确接收二维数组。这两件事是数组指针最核心的价值也是面试和实际编码里最常出问题的地方。2.1 数组名的退化与两个例外在 C 里数组名在绝大多数场合会退化成指向首元素的指针。也就是说int a[5]里的a在表达式中通常等价于a[0]类型是int *。这就是为什么数组能直接传给int *参数。但这个退化有两个例外记住它们非常重要sizeof(a)这里a不退化结果是整个数组的字节数5 * 4 20a这里不退化取的是整个数组的地址类型是int (*)[5]也就是数组指针。这就引出一个经典困惑a和a的值是一样的都是数组首字节的地址但类型不同导致加 1 之后的结果完全不同。#include stdio.h int main(void) { int a[5] {1, 2, 3, 4, 5}; printf(%p\n, (void*)a); // 假设 0x7ffe1000 printf(%p\n, (void*)a); // 同样是 0x7ffe1000 printf(%p\n, (void*)(a 1)); // 0x7ffe1004跳过 1 个 int printf(%p\n, (void*)(a 1)); // 0x7ffe1014跳过整个数组20 字节 return 0; }实测下来a 1走 4 字节a 1走 20 字节。这个差异是理解数组指针的钥匙——数组指针的步长是整个数组的大小而普通指针的步长是元素的大小。2.2 二维数组传参为什么非得用数组指针二维数组int a[3][5]在内存里是一段连续的 15 个 int并不是3 个指针。它的每一行a[i]本身是一个int[5]类型的数组a[i]在表达式里退化成int *指向行首元素。而对整个二维数组取地址、或者a这个数组名退化时得到的类型是int (*)[5]——指向含 5 个 int 数组的指针正是数组指针。所以二维数组传参的正确写法是这样几种等价形式void f1(int (*a)[5], int row); // 显式写成数组指针 void f2(int a[][5], int row); // 等价写法更常见 void f3(int a[3][5]); // 第一维可以给也可以不给注意第二维[5]绝对不能省。因为编译器在计算a[i][j]的地址时需要知道一行有多长即a i要跳过多少个字节这必须靠第二维的长度才能算出来。第二维缺失编译器根本不知道步长直接报错。那能不能用int **接收二维数组这是新手最容易踩的坑答案是不能。void wrong(int **a, int row, int col) { printf(%d\n, a[0][0]); // 崩溃或乱码 } int main(void) { int a[3][5] {{1}}; wrong((int**)a, 3, 5); // 编译能过运行炸 return 0; }原因在于a[0][0]会被编译器翻译成*(*(a 0) 0)。当a是int **时*(a 0)会把二维数组开头那几个 int 的字节当成一个指针来解释然后拿着这个假指针去解引用指向一片完全不相关的内存。二维数组的内存里根本没有存指针存的都是 int 数据本身所以这种强转必然出错。我第一次踩这个坑是在写一个矩阵打印函数时图省事用了int **结果在小数据上偶尔能跑数据一大就崩排查了半天才发现是类型理解的根子错了。2.3 数组指针与二维数组的配合一个可复现的遍历例子理解了原理实操就顺了。下面是一个规范的行列遍历把数组指针作为参数传进去#include stdio.h // a 类型是 int (*)[COLS]行指针 void print_matrix(int (*a)[4], int rows) { for (int i 0; i rows; i) { for (int j 0; j 4; j) { printf(%d , a[i][j]); // a[i] 是第 i 行类型 int[4] } printf(\n); } } int main(void) { int m[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; print_matrix(m, 3); // m 退化成 int (*)[4] return 0; }运行时a[i]之所以能正确取到第 i 行是因为a的类型告诉编译器每行 4 个 inta i精确跳过i * 16字节。把参数写成int (*a)[4]和写成int a[][4]在编译后完全等价选哪个纯看可读性——我个人偏向显式写int a[][4]团队里读起来更直观。注意sizeof(*a)在函数里拿到的是 16一行的大小而不是整个矩阵大小。因为a是指针不是数组。在函数内部想拿到总行数只能靠额外传参指望sizeof是不行的。3. 指针数组一个能装下整张字符串表的容器相比数组指针指针数组在日常开发里的出场率高得多尤其是处理一批长度不一的字符串时。它和二维字符数组看起来都能存字符串表但内存布局、修改权限和灵活性完全不同选错了要么浪费空间要么改不动数据。3.1 指针数组存字符串经典又实用的用法char *arr[3]是一个含 3 个字符指针的数组。每个元素都是一个char *指向一串字符。把它初始化成字符串表是嵌入式、命令行工具里最常见的写法#include stdio.h int main(void) { char *week[] {Mon, Tue, Wed, Thu, Fri}; // week 类型是 char *[5]每个元素指向一个字符串字面量 for (int i 0; i 5; i) { printf(%s\n, week[i]); } printf(sizeof(week) %zu\n, sizeof(week)); // 405 个指针 return 0; }这里有个特别值得注意的点sizeof(week)是40而不是所有字符加起来的总长度。因为数组里存的是 5 个指针每个 8 字节字符串本身存放在别处通常是只读数据段。这正是指针数组省空间的地方——每个元素只是 8 字节的地址字符串长短不影响数组本身的大小。对比一下用二维字符数组存同样内容char week2[5][10] {Mon, Tue, Wed, Thu, Fri}; // 每个字符串固定占 10 字节总共 50 字节即使Mon只用了 4 个字节二维字符数组把每一行的长度都固定了短字符串会被大量浪费。如果用最长的字符串来定列宽短的那些就白占空间定小了长字符串又存不下。当一批字符串长度参差不齐时指针数组是更聪明、更通用的选择这也是热词里指针数组存放字符串经常被搜的原因。下表把两种方案摆在一起看对比项char *week[5]指针数组char week[5][10]二维字符数组内存布局5 个连续地址 若干分散的字符串5*10 连续字节是否可修改指向字面量时不可改内容内容可自由修改灵活性字符串长短任意可重新指向别的串长度固定最长受列宽限制交换两行交换指针即可极快需要整体拷贝字符串排序开销小动指针大动数据排序场景下这个差异尤其明显指针数组排序时只需要交换数组里的指针8 字节移动而二维字符数组排序要整行整行地拷贝。数据量大时前者性能优势是数量级的。3.2 指针数组和数组指针的一步之遥回到最容易混淆的地方。既然指针数组存字符串这么常见那char *argv[]也就顺理成章了——这是main函数的经典签名int main(int argc, char *argv[]) { ... } // argv 类型是 char **因为数组作为参数会退化成指向首元素的指针 // argv[] 是指针数组退化后成为指向 char* 的指针即 char **这里有个很微妙的细节函数参数里的数组声明会被编译器自动改写为指针。所以char *argv[]和char **argv在参数位置是完全等价的。而这也解释了一个经典困惑——为什么main的第二个参数写成数组形式实际却是个二级指针进一步说它和指针数组存字符串是同一套底层逻辑。但请注意区分char *a[5]指针数组5 个指针sizeof是 40char (*a)[5]数组指针指向含 5 个 char 的数组sizeof是 8。一字之差、括号移位含义和大小全变。我在带新人时反复强调写这种声明看到就说读法别靠感觉猜。4. 字符指针字符串字面量那个改不得的坑char *s hello;这行代码几乎是每个 C 初学者都会写的但它埋着一个直到程序崩溃才暴露的隐患。搞清字符指针指向哪里、能不能改是避免莫名段错误的关键。4.1char *s和char s[]的内存真相两者初始化时看起来一样都是得到一串 hello但在内存里完全不同char *s1 hello; // s1 在栈上指向只读数据段的字符串字面量 char s2[] hello; // s2 在栈上是一份可写的副本 s1[0] H; // 危险字符串字面量位于只读段通常触发段错误 s2[0] H; // 完全合法改的是栈上的副本关键区别在于char *s1s1本身是一个放在栈上的指针变量它指向只读数据段里的一段字符串字面量。这段字面量是程序只读数据的一部分修改它是未定义行为在大多数系统上会直接引发段错误Segmentation fault。char s2[]s2是栈上真实分配的字符数组初始化时把内容拷贝了进去。修改它就是改自己栈上的数据完全没问题。我用一张表把这个差异说清楚这是面试里几乎必问的对比项char *s hellochar s[] hellos是什么指针变量8 字节字符数组6 字节含结尾\0内容存哪字符串字面量在只读区内容拷贝到栈上能否修改内容不能未定义行为可以能否重新指向可以s world不可以数组名不是左值sizeof(s)864.2 段错误的复现、定位与规避这个坑之所以难查是因为它有时能跑通。某些编译器/平台把字符串字面量放在可写段早期或某些嵌入式环境修改它不会立刻崩于是程序看起来正常直到换了个环境突然炸掉。这种不确定性最坑人。复现很简单#include stdio.h int main(void) { char *s hello; s[0] H; // 未定义行为多数系统上段错误 printf(%s\n, s); return 0; }排查这类崩溃时我一般按这个链路走先用编译器警告gcc -Wall -Wextra通常不会直接报这个错因为语法合法。但有些静态分析工具如-Wwrite-strings等能提前提示把字面量赋给char *。开工前先审类型看到char *p ...立刻警觉——这个指针指向的可能是只读数据不要往里写。用调试器看地址出问题时用 gdb 打印p的地址如果落在只读段通常和代码段、rodata 相近那基本就是它。修复方式要么改成char s[] hello要么用const char *s hello让编译器帮你把不许改这件事在类型层面定死。提示养成习惯凡是只想读、不想改的字符串一律用const char *。这样一旦哪行代码试图写它编译器直接报错拦住你比运行期段错误强太多。这是我最推荐的一个防坑做法。一个常见的连带问题是接口参数。如果某个函数只是读字符串参数就写const char *s调用方传字面量、传栈上数组、传指针都行如果函数要修改内容参数才写char *s并且调用方必须传可写内存数组而非字面量。5. 二级指针与空指针值传递、动态分配和释放后的收尾指针进阶绕不开二级指针。它的存在只有一个理由在函数里修改指针本身。理解了值传递这个本质前提二级指针就不再神秘空指针、野指针这些高频问题也能顺手解决。5.1 为什么换个值必须传指针的指针C 语言函数参数是值传递参数是一份拷贝。要修改外面的变量就传它的地址一级指针要修改外面的指针变量本身就得传指针的地址二级指针。看这个经典场景——在函数里为一个char *分配内存#include stdlib.h #include string.h // 正确写法传二级指针才能改到外面的 buf int make_buf(char **out, size_t n) { *out (char *)malloc(n); if (*out NULL) return -1; // 分配失败要返回绝不继续用 strcpy(*out, ok); return 0; } int main(void) { char *buf NULL; if (make_buf(buf, 16) ! 0) return 1; /* 使用 buf */ free(buf); buf NULL; // 释放后立刻置空防止野指针 return 0; }如果参数写成一的一级指针char *out那么out只是外面buf的一份拷贝*out malloc(...)改的是拷贝外面的buf还是 NULL。这就是二级指针存在的根本原因。5.2 空指针、野指针与释放后的收尾和二级指针一起出现的通常还有几类指针状态问题。我把它们整理成一张对照表方便排查状态成因后果规避手段空指针初始化为 NULL或 malloc 失败解引用崩溃用前判空野指针未初始化、或 free 后未置空随机崩溃或写坏内存定义即初始化free 后置 NULL悬空指针指向的内存已被释放读写已归还的内存释放后不再使用该指针内存泄漏malloc 后忘记 free内存持续增长配对分配与释放我个人的收尾习惯是三条铁律指针定义时就初始化不确定指向哪先给NULL绝不裸定义。free之后立刻置NULL哪怕这个变量马上要出作用域也顺手置一下成本极低。每次malloc都检查返回值在内存紧张的环境嵌入式、长时间运行的服务里这是保命操作。这三条看起来啰嗦但能干掉绝大多数指针相关偶发崩溃。带团队时我发现出问题的代码往往不是逻辑有多复杂而是这几条基本纪律没守。注意free(NULL)是安全的、什么都不做。所以释放后置 NULL不会影响后续可能重复的释放调用反而能避免对同一块内存 double free 的经典问题。6. 函数指针和结构体指针把进阶知识用进真实代码学到这里指针的形状基本都认识了剩下的是把它们用起来。函数指针和结构体指针是实际工程中出场率极高的两种回调、事件分发、数据结构封装全都离不开它们。6.1 函数指针把函数当参数传函数指针的声明是int (*fp)(int, int)用之前先用右左法则读一遍fp先和*结合是指针再向右看(int, int)是参数向左看int是返回类型——指向参数是两个 int、返回 int的函数的指针。#include stdio.h int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } // 用函数指针做参数实现运行时选择策略 int calc(int (*op)(int, int), int a, int b) { return op(a, b); } int main(void) { printf(%d\n, calc(add, 3, 2)); // 5 printf(%d\n, calc(sub, 3, 2)); // 1 return 0; }这里的价值在于解耦calc不需要知道具体做哪种运算把怎么做通过函数指针传进来即可。这就是简单的策略模式C 库里qsort的最后一个比较函数参数就是典型的函数指针用法。这里必须区分两个常被搞混的词函数指针int (*fp)(int)是一个指针指向函数指针函数int *f(int)是一个函数返回指针。记忆点同上括号把*和名字绑一起就是指针跟着()直接贴上去就是函数。这两个词经常出现在热词里函数指针和指针函数考的就是这个括号。6.2 结构体指针-的那口语法糖结构体指针用起来最常见的就是-运算符typedef struct { int x; int y; } Point; void move(Point *p, int dx, int dy) { p-x dx; // 等价于 (*p).x dx p-y dy; }p-x就是(*p).x的简写。为什么要用结构体指针传参而不是直接传结构体因为传结构体是整体拷贝结构体一大每次调用都复制一遍开销可观传指针只复制一个地址8 字节。所以凡是函数需要修改结构体、或者结构体较大一律用指针。实测经验当一个结构体超过几个字段就养成用指针传参的习惯。这不仅能改到原对象也显著降低拷贝成本尤其在循环内部频繁调用的函数里差别很明显。6.3 智能指针的启发手动管理的替代思路热词里出现了智能指针智能指针实现虽然那主要是 C 的设施但它背后的思路对写 C 也很有启发。核心就一句用对象的生命周期来自动管理资源。C 里std::unique_ptr、std::shared_ptr在析构时自动释放避免了手动free遗漏。在纯 C 里虽然没有语言级的智能指针但可以借鉴这个思路——把资源和它的释放函数绑定在一起。比如定义一个结构体同时存一个函数指针作为析构器typedef struct { void *data; void (*destroy)(void *); // 释放函数 } Holder; void holder_free(Holder *h) { if (h h-destroy) h-destroy(h-data); }这样资源的释放逻辑被集中到一处调用方只需要调用holder_free不再需要记住每种资源各自的释放方式。这是手动管理环境下向自动管理靠拢的务实做法。至于热词里那种扫描器扫不到东西timer 报空指针的问题排查思路其实是一致的先确认指针当前是不是有效状态是否为空、是否已被释放、是否越界再去看是不是生命周期错配。我把这个作为指针类问题的通用入口比一上来就怀疑编译器靠谱得多。指针进阶真正的门槛从来不是语法本身有多难而是每一条声明背后都对应着一块真实的内存和一套步长规则。把右左法则练熟、把步长和sizeof算清、把字面量不能改刻进肌肉记忆、把初始化与释放的纪律守住你再看那些曾经让人头大的声明会觉得它们不过是在老老实实描述内存的样子。剩下的功夫就靠多读真实代码、多动手改一改来磨了——遇到拿不准的声明先在纸上用右左法则读一遍再写个小例子验证步长比对着屏幕猜要快得多。