
指针这块无数人挂在上面。反过来说只要弄懂了指针C语言就算真正入门了。我这些年带过不少新人也见过很多从别的语言转过来的朋友大家卡住的地方几乎都一模一样变量名和内存地址到底是什么关系数组名是不是指针int *p[3]和int (*p)[3]哪个是数组哪个是指针之所以把指针和数组放一起讲是因为在C语言里这两个概念本来就是一体的。祖师爷Dennis Ritchie在《The C Programming Language》也就是大家常说的KR里用了一整章讲指针与数组而且刻意把两者放在同一个框架里讲。后面所有的C语言教材不管中文英文几乎都在抄这个结构。这一期咱们就把这对老CP彻底拆开揉碎从内存模型讲到实战排错保证你看完能搞懂它们到底是怎么回事。如果你是正在啃C语言的学生、准备面试的求职者或者写了很多代码但对指针始终有点虚的开发者这一期值得静下心读完。我尽量用大白话讲每段都有代码验证看完可以直接照着敲。1. 先从最底层的认知说起变量、地址和指针1.1 变量不过是门牌号与房间内容的绑定很多同学学指针时最大的障碍是把int a直接理解成a就是那块内存。其实更准确的说法是a是给一块内存起的名字真正的内存地址才是系统访问它的凭据。当你写下int a 5;编译器做的事情是什么它会在栈上找一块4字节的内存假设地址是0x7ffd1234然后把5这个值写进去。之后你写a编译器就翻译成访问0x7ffd1234这块内存。这里有个特别关键的认知内存地址才是计算机真正认识的东西。变量名只是给人看的。就好比你住酒店服务员不会喊那位叫张三的客人请开门而是直接刷房卡——房号才是系统识别的依据。取地址符就是拿到这个房号printf(%p\n, (void*)a); // 输出 a 的内存地址%p是专门打印指针的格式这个细节后面还会提到。记住一个原则任何时候看到a你就把它当成a的门牌号看到a非指针场景下就当成门牌号指向房间里放的东西。1.2 指针变量就是专门存门牌号的变量知道了地址的概念指针就简单了指针是一个变量里面存的是别人的地址。int a 5; int *p a; // p 存的是 a 的地址int *p读作指向int的指针。注意p和a一样自己也是个变量它也有自己的地址你可以p。只不过p里放的内容比较特殊——不是5而是0x7ffd1234这种地址值。解引用操作符*就是拿着这个门牌号去开房printf(%d\n, *p); // 输出 5因为 p 指向 a*p 就是到 a 的房间里取值为什么C语言需要一个指针变量最核心的动机是函数参数传递。C语言的函数传参是值传递也就是说你传进去的是变量的内容副本而不是变量本身。看这段经典代码void swap(int x, int y) { int tmp x; x y; y tmp; } int main() { int a 1, b 2; swap(a, b); printf(a%d b%d\n, a, b); // a1 b2没换成 }为什么没换成因为x和y拿到的是1和2这两个值的副本你在函数里交换的是副本跟a、b半毛钱关系都没有。如果想真正修改外面的变量就必须把变量的地址传进去void swap(int *x, int *y) { int tmp *x; *x *y; *y tmp; } swap(a, b); // 现在 a 和 b 真的交换了这个例子解释了指针存在的第一层意义通过地址间接访问调用者的内存。后续所有的指针用法——包括链表、树、回调、二级指针——都是在这层意义上扩展出来的。1.3 指针算术p1 不是地址1而是跳到下一个同类型元素指针最反直觉的一个规则是加减法。很多人一开始写p1下意识以为就是内存地址加1。错。char *pc (char*)100; // 假设地址是100 int *pi (int*)100; // 假设地址是100 printf(%p\n, pc 1); // 101char 占1字节 printf(%p\n, pi 1); // 104int 占4字节在常见平台上p1不是地址1而是跳到从p开始的下一个同类型元素。编译器会按指针指向的类型大小来换算p1实际上等于(char*)p sizeof(指向的类型) * 1。为什么这样设计因为指针算术要直接服务数组。数组的元素是同类型、连续存放的如果指针加1刚好跳一个元素那遍历数组就变成了p这么简单。下一节你会看到数组下标a[i]本质就是在做这种指针算术。2. 数组名的本质数组不是指针但处处都在退化成指针2.1 a[i] 的真身就是 *(ai)先说结论这是整个C语言数组最核心的等价关系a[i] 等价于 *(a i)什么意思当你写a[2]编译器会把它换算成*(a2)先把数组名a当作首元素的地址向后移动2个元素的距离解引用得到那个元素。这个等价关系激进到什么程度你甚至可以反过来写printf(%d\n, 2[a]); // 合法等价于 a[2]因为2[a]会被编译器换算成*(2a)而加法满足交换律。虽然没人提倡这么写但这个冷知识能帮你彻底理解下标运算的本质方括号[]不过是一种指针偏移解引用的语法糖。写个代码验证一下#include stdio.h int main() { int a[5] {10, 20, 30, 40, 50}; printf(a[2] %d\n, a[2]); printf(*(a2) %d\n, *(a 2)); printf(2[a] %d\n, 2[a]); return 0; }三个输出全是30。2.2 数组名不是指针sizeof 和 会揭穿真相网上到处有人说数组名就是指针这句流传极广的话其实不准确。准确的说法是在大多数表达式中数组名会退化decay成指向首元素的指针但在某些场合它保留了数组身份。用sizeof一测便知int a[5] {1,2,3,4,5}; int *p a; printf(%zu\n, sizeof(a)); // 20 5个int × 4字节数组身份 printf(%zu\n, sizeof(p)); // 8 或 4指针自己的大小sizeof(a)返回整个数组占用的字节数而不是指针大小。如果数组名真是指针这里应该输出指针大小。所以数组名不是指针它是一个表达整块连续数组的标识符只是在需要元素首地址的语境下会自动转换成首元素指针。再看a和a[0]的区别这个坑特别多int a[5]; printf(%p\n, (void*)a); // 整个数组的地址 printf(%p\n, (void*)a[0]); // 首元素的地址 printf(%p\n, (void*)a); // a 退化成首元素地址打印出来三个地址值通常是一样的数字相同但类型完全不同a的类型是int (*)[5]指向含5个int的数组的指针a[0]和a的类型是int *。类型不同做指针算术时效果就完全不同printf(%p\n, (void*)(a 1)); // 跳过一个int4字节 printf(%p\n, (void*)(a 1)); // 跳过整个数组20字节所以遇到a1不要以为它跟a1是一回事。它跨越了整个数组。2.3 退化规则函数参数里的 int a[] 其实是个指针数组名会在哪些场合保留数组身份呢sizeof、、字符串字面量初始化字符数组。除此之外几乎所有表达式里数组名都会变成首元素指针。最典型的场景是函数传参void func(int arr[]) { // 这里的 arr 本质上是 int *arr不是数组 printf(%zu\n, sizeof(arr)); // 输出的是指针大小 }很多初学者在这里懵掉我明明传的是数组为什么sizeof(arr)是8指针大小而不是数组大小因为C语言规定数组作为函数参数时会退化成指针。int arr[]、int arr[5]、int *arr在函数参数里完全是同一个东西。这也解答了一个经典问题为什么在函数里不能直接用sizeof(arr)/sizeof(arr[0])算元素个数因为arr已经是指针了除出来是错的。要在函数外算好数组长度再传进去或者用宏定义长度这是C语言的通行做法。3. 指针数组与数组指针一句话分清两个老冤家3.1 声明优先级看谁先跟 p 绑定这是无数面试题和考试题的常客。先看两行声明int *p[3]; // 指针数组p先和[3]结合是数组数组里有3个int指针 int (*p)[3]; // 数组指针p先和*结合是指针指向含有3个int的数组C语言声明的解析规则其实很简单先看标识符变量名按优先级谁先结合就是谁。方括号[]和解引用*的优先级是[]更高所以int *p[3]中p先跟[3]结合——p是数组数组元素类型是int *。而int (*p)[3]加了括号括号里的*p优先级先被处理——p首先是指针这个指针指向的对象是有3个int的数组。用sizeof验证int *p[3]; // 数组3个指针 int (*q)[3]; // 指针指向含3个int的数组 printf(%zu\n, sizeof(p)); // 24或123个指针的总大小 printf(%zu\n, sizeof(q)); // 8或4一个指针大小记忆方法可以这样看p名字旁边先贴着谁。*p[3]里p[3]先组成一个数组前面再加元素是指针(*p)[3]里(*p)先组成一个指针它指向的是长度3的数组。3.2 指针数组的经典应用存放字符串指针数组最爽的用法之一是存放一组字符串。比如你想写个关键字表const char *keywords[] { if, while, return, for };这里keywords是一个数组每个元素是const char *指向一个字符串字面量。优点非常明显不用为一堆字符串分别预留固定长度的空间字符串按需分配有多少长度占多少内存。对比一下用二维字符数组char keywords[4][10] { if, while, return, for };while和return实际上只用到5、6个字符但每行都固定留了10字节浪费。而且如果要改字符串内容字符数组还得保证长度够不如指针灵活。遍历指针数组也很自然for (int i 0; i 4; i) { puts(keywords[i]); // keywords[i] 是一个 const char* }注意一个安全点直接把字符串字面量赋给char *在旧标准里允许但字符串字面量本质上是只读的。写char *s hello; s[0] H;在大多数平台上会运行时报错段错误。所以建议声明为const char *用编译器的类型检查帮你拦住这个坑。如果你确实想修改字符内容应该用字符数组比如char s[] hello;。3.3 数组指针的用武之地二维数组传参数组指针看着绕但它在二维数组传参时是绕不开的。看这个函数void print_matrix(int (*m)[3], int rows) { for (int i 0; i rows; i) { for (int j 0; j 3; j) { printf(%d , m[i][j]); } printf(\n); } }int (*m)[3]翻译过来就是m是一个指针指向一个有三个int的数组所以m[i]取到第i行一个长度3的数组m[i][j]取到该行第j个元素。**也可以把参数写成int m[][3]效果完全一样。**函数声明里的多维数组第一维可以省略后面的维数必须写全。为什么因为第一维是行数调用时根据实际传的数组来决定而后面的维数列数3参与指针算术——编译器要算m[i][j]的地址必须知道每行占多少字节。这点经常被忽略如果写int m[][]编译器会直接报错。4. 函数与指针的纠缠函数指针和指针函数4.1 指针函数返回值为指针的函数指针函数是说返回值是指针。看一个例子把字符串反转#include stdio.h #include string.h char *reverse_in_place(char *s) { int len strlen(s); for (int i 0, j len - 1; i j; i, j--) { char tmp s[i]; s[i] s[j]; s[j] tmp; } return s; } int main() { char buf[] hello; printf(%s\n, reverse_in_place(buf)); return 0; }这里有个非常重要的问题不要返回指向函数内局部数组的指针。比如char *bad() { char buf[100] temp; return buf; // 错buf 是栈上局部变量函数结束后就失效了 }buf的生命周期止步于函数返回。你拿到一个指向已经释放的栈内存的指针后续一访问就是未定义行为崩溃的概率极大。那怎么办三种常见做法在函数内部用static修饰局部数组延长生命周期到程序结束用malloc在堆上分配由调用方负责free像上面的reverse_in_place一样直接改调用者传入的缓冲区返回传入的指针。第三种在C语言里最实用很多标准函数如strcpy也是这个套路——返回目标字符串指针。4.2 函数指针把函数地址当作变量值保存函数也有地址——程序加载到内存后每个函数都是一段机器码这段机器码的首地址就是函数指针。函数指针最大的价值是让你可以把函数当作参数传来传去实现回调机制。声明看一下int (*fp)(int, int); // fp 是一个指针指向两个int参数、返回int的函数赋值和调用的完整例子#include stdio.h int add(int a, int b) { return a b; } int mul(int a, int b) { return a * b; } void process(int (*f)(int, int), int x, int y) { int r f(x, y); // 通过函数指针调用 printf(result %d\n, r); } int main() { int (*fp)(int, int) add; printf(%d\n, fp(3, 4)); // 7 process(mul, 3, 4); // 把 mul 的地址传给 process return 0; }这里process接受一个函数指针调用方可以传任意一个输入两个int、返回int的函数进去——这就是回调。标准库的qsort就是最著名的例子你写一个比较函数把它传给qsort库函数用它来比较任意两个元素。#include stdlib.h int cmp_int(const void *a, const void *b) { int x *(const int*)a; int y *(const int*)b; return (x y) - (x y); // 返回负、零、正对应 、、 } int main() { int arr[] {5, 2, 8, 1, 9}; int n sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(int), cmp_int); return 0; }cmp_int接收的两个参数是const void*需要先种类型转换回int*再取值——它比较的是元素不是元素的起始地址。函数指针还有一个进阶玩法函数指针数组。比如一个简单命令解析器void cmd_open() { puts(open); } void cmd_close() { puts(close); } void cmd_save() { puts(save); } void (*cmds[3])() { cmd_open, cmd_close, cmd_save };按编号访问等价于一个函数路由表。很多状态机、菜单系统、指令分发的核心就是这张表。4.3 三兄弟对比表写法含义简单判断int *f()返回int指针的函数指针函数看括号f先跟()结合int (*f)()函数指针指向返回int的函数*f整体先结合f是指针int *(*f)()函数指针返回int指针(*f)是函数指针函数返回int*5. 二级指针和二维数组的内存真相5.1 char ** 到底是什么一张指针数组的首地址很多人在命令行程序遇到int main(int argc, char **argv)看到char **就发怵。其实argv的值指向一个数组的第一个元素这个数组的每个元素都是char*每个指向一个命令行参数字符串。所以argv是指向char*的指针。拆解一下argv // char**指向 argv[0] argv[0] // char*指向字符串比如 ./a.out argv[1] // char*指向第一个命令行参数如果你想遍历所有参数for (int i 0; i argc; i) { printf(%s\n, argv[i]); }也可以纯用指针游走char **p argv; for (int i 0; i argc; i) { puts(*p); p; // 跳到下一个 char* }这是一条很有代表性的内存链argv指向一个指针数组而数组里的每个指针再去指向字符串。很多管理大量可变字符串的程序用的就是这种指针数组二级指针结构。5.2 什么时候必须用二级指针在函数里修改外部指针本身面试中有一道出现频率极高的题目为什么我写了这个getMemory函数外面调用完p还是NULLvoid getMemory(char *p) { p (char*)malloc(100); } int main() { char *p NULL; getMemory(p); if (p NULL) { printf(还是空的\n); } }这里p的值是NULL传给函数时char *p参数复制了NULL这个值。函数里p malloc(100)修改的是形参副本跟外面的p无关。你希望修改指针变量本身的值就得把指针变量的地址传进去也就是二级指针void getMemory(char **p) { *p (char*)malloc(100); } int main() { char *p NULL; getMemory(p); // 传 p 的地址 if (p ! NULL) { strcpy(p, ok); printf(%s\n, p); free(p); } }**判断规律函数内部要修改外部变量的值就传这个变量的地址。**如果这个变量本身是个指针那传进去的自然是指针的地址——二级指针。如果函数只修改指针指向的内容一级指针就够了如果要修改指针本身比如让主调函数的指针指向新分配的内存必须二级指针。5.3 二维数组不等于二级指针这是个常见的代码陷阱。看代码int a[3][4]; // 想传给这样声明的函数 void func(int **arr); // 错类型不匹配a在表达式中退化成什么退化成int (*)[4]指向长度为4的int数组的指针而不是int**。为什么两者完全不同看内存布局。二维数组int a[3][4]是一整块连续内存a[0][0]到a[2][3]是12个int挨着的。你访问a[1][2]编译器计算偏移跳过一整行4个int再往右两个int。这个计算只需要知道行宽度是4用int (*)[4]就能描述。而char **argv的内存结构是argv指向一个指针数组数组里每个元素又指向另外一处字符串。两级内存之间不是连续的一块跳转关系靠指针保存。所以把那两种东西互相替代要么是编译警告要么是运行段错误。二维数组的正确传参方式就用上一节讲的数组指针void func(int m[][4], int rows); // 等价于 void func(int (*m)[4], int rows)或者干脆一维化处理把二维数组当作一段连续内存用int *访问void func(int *m, int rows, int cols) { // 用 m[i * cols j] 代替 m[i][j] }6. 结合热词延伸的经典实战场景6.1 循环队列数组下标与指针算术的经典搭档数据结构教材里经常出现假设以数组q[m]存放循环队列中的元素同时以rear和length分别指示队尾和队列长度这类题目。不管怎么描述底层都是同一件事用数组模拟环形缓冲区通过下标也就是指针算术实现入队出队。环形队列的要点是下标取模。入队int q[100]; int front 0, rear 0; // 队列元素实际存储在 [front, rear) 之间 void enqueue(int x) { q[rear] x; rear (rear 1) % 100; }出队int dequeue(void) { int x q[front]; front (front 1) % 100; return x; }(rear 1) % 100就是让下标在0到99之间循环。如果改用指针也是同理q rear指向当前队尾位置到达数组末尾后跳回q开头。数组下标q[i]和指针*(qi)在这里就是同一种思维。6.2 树状数组下标的二进制运算本质也是指针思维树状数组这类数据结构玩的全是下标运算。核心的lowbit操作int lowbit(int x) { return x (-x); }查询前缀和int sum(int i) { int s 0; while (i 0) { s tree[i]; i - lowbit(i); } return s; }单点修改void add(int i, int delta) { while (i n) { tree[i] delta; i lowbit(i); } }这个算法能高效维护前缀和本质上就是把跳到下一个需要更新的位置这种操作建立在i的二进制规律上。i本身就是数组的指针只不过跳转规则不是1而是lowbit的加减。学树状数组之前把指针算术吃透理解这里会顺畅得多。6.3 冒泡排序的指针写法排序是C语言基础里最常见的练习变量版和指针版对照一下就会发现数组和指针操作几乎是同一套逻辑void bubble_sort(int *arr, int n) { for (int i 0; i n - 1; i) { for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; } } } }如果你愿意也可以在函数里改用指针void bubble_sort(int *arr, int n) { for (int i 0; i n - 1; i) { for (int *p arr; p arr n - 1 - i; p) { if (*p *(p 1)) { int tmp *p; *p *(p 1); *(p 1) tmp; } } } }p在数组里滑动、*(p1)访问下一个元素这里能直观感受到指针算术数组连续内存这对组合有多自然。7. 常见问题与排查技巧实录7.1 段错误与野指针三板斧定位崩溃指针引起的崩溃十有八九是段错误Segmentation Fault。最常见的诱因有以下几类按出现频率排对NULL指针解引用指针越界访问比如数组只有3个元素你却访问第5个用了已经释放或失效的内存悬垂指针数据类型搞错比如把int*强转成char*然后越界读写。排查步骤我总结成三板斧第一板斧打印出来看看。在可疑指针使用前加一行printf(%p\n, (void*)p);用%p打印指针注意强转成void*是规范做法。如果打印出来是0x0基本可以确定是对NULL解引用。第二板斧检查越界。数组越界不一定会立刻崩溃但会悄悄改坏相邻内存最后在很遥远的地方爆炸。怀疑越界时把循环边界size打印出来跟数组元素个数对比这是最笨也最有效的方法。第三板斧用调试器。gdb是排查C语言程序问题的利器。编译时加-g选项运行崩溃后执行gdb ./a.out core然后在gdb里敲btbacktrace查看调用栈能直接定位到崩溃发生在哪个函数、哪一行。很多人觉得gdb难用其实只需要记住bt、print 变量名、list这几个命令排查指针问题就已经比盲猜快十倍了。7.2 为什么修改字符串字面量会崩溃这段代码的崩溃非常迷惑人char *s hello; s[0] H; // 段错误而这样写却没问题char s[] hello; s[0] H; // 正常区别在于存储位置。char *s指向的是字符串字面量它通常被放在只读数据段任何写操作都会触发段错误。而char s[]是在栈上复制了一份内容所以可以修改。判断方式也很简单你希望字符串可以被修改就声明成数组你只是读取它可以声明成指针。在C语言里用const char *来声明指向字面量的指针是从规范上就杜绝这类错误。7.3 const与指针const修饰的是谁const修饰指针有两种位置位置不同含义完全不同const int *p; // p指向的内容不可变但p本身可以指向别处 int *const p; // p本身不可变一旦赋值不能再改指向但内容可改 const int *const p; // 内容和指向都不可变记忆技巧const靠近谁就修饰谁。const int *p里面const修饰的是int指向的对象所以对象内容只读int *const p里面const修饰的是p本身所以p不能重新赋值。实用场景函数参数里写const char *s意思就是我只读你的字符串不会改它。调用方看到这个签名心理上就踏实了编译器也会帮你检查意外修改。7.4 为什么 scanf 输入字符串不用 符号这个问题几乎每届新生都问。看代码char buf[100]; scanf(%s, buf); // 不要写成 buf因为buf是数组名在表达式中自动退化成首元素地址char*而scanf需要的就是一个char*所以直接传buf。如果画蛇添足写buf类型是char (*)[100]两者在数值上经常一样但语义不同有些编译器会警告极端情况下行为还会出问题。这里顺便提一个常见的坑scanf(%s, buf)不检查输入长度超出缓冲区就越界写。要养成习惯用限宽方式scanf(%99s, buf); // 最多读99个字符留1个给\08. 把指针和数组统一起来看一个模型理解整个C很多人学了指针、数组、二维数组、二级指针每个知识点单拿出来都懂但一综合就乱。我后来想明白一件事可以把整个体系压缩成一个模型内存就是一条巨大的走廊走廊两侧是一排房间。每个房间有门牌号地址、房间类型类型决定房间多大、房间里的东西内容。指针就是一个便利贴上写着门牌号的便条。数组就是连续一排相同大小的房间。指针移动一步按房间大小走数组下标操作本质就是从这排房间的第一个走到目标房间。二级指针就是便条上的便条你找到第一个便条上面写着第二个便条的位置顺着找到第二个便条才看到真正的东西。二维数组则是一排房间每间又分成若干小隔间访问时你先跳过几整间房再在小隔间里找。这个概念我实际用下来非常有效尤其在帮别人排错时。每次拿到一个指针报错先问三个问题这个指针指向哪块内存这块内存有多大这块内存的生命周期到什么时候三个问题能答上来指针问题就已经解决了八成。个人经验是学指针不要怕犯错但务必要追求每出一条错都能解释清楚原因。当年我学C语言时花了一个通宵把KR那本小书里指针和数组那章反复读了几遍又动手跑完书里所有示例之后再去写链表、写树、写动态内存管理手感明显不一样。这期内容也是从那套经典框架里消化出来的希望对你有点用。最后分享一个小技巧调试指针时给所有指针变量加上带语义的名字比如p_dest、p_src、next_node不要统统叫p。指针在C语言里已经够绕了不要再让名字加大理解难度。等你能顺畅地在一个复杂的多级指针场景里从头到尾算出每个地址的值指针这关就算真正过了。