我教C语言这些年,答疑时碰到最多的就是指针。基础语法讲完,大部分同学都觉得自己懂了:“指针就是存地址的变量嘛”,结果一到写代码就原形毕露——数组传参传不明白、指针改不动实参、const一加就报警告、链表写两行就段错误。这篇“中篇”要解决的就是这些绕不开的核心问题:指针到底怎么操作内存、它和数组是什么关系、为什么函数里改指针不生效、const到底锁的是谁。上篇如果你已经搞定了“指针是什么、怎么初始化、怎么解引用”,那这篇就是帮你把指针真正用到实战里去。
1. 指针与数组:绕不开的那点纠葛
1.1 下标访问的本质:编译器偷偷干了什么
先说一个很多人没意识到的真相:C语言里的arr[i]并不是什么高级语法,它本质上就是*(arr + i)的语法糖。你写arr[3],编译器会翻译成“从数组首地址往后偏移3个元素的位置,然后解引用”。
这个理解特别重要,因为它决定了你怎么看待数组越界。很多人觉得数组越界是“下标太大了”,其实不是。C语言压根不检查下标是否合法,arr[100]在编译阶段根本不会报错,它只是老老实实地帮你计算出“首地址 + 100 × 元素大小”这个地址,然后去访问那块内存。至于那块内存是不是你的,它不管。
再延伸一下:既然arr[i]等价于*(arr + i),那i[arr]理论上也是合法的,因为加法交换律嘛。这个写法在真实的项目中没人会这么写,但面试题里偶尔会出现,你只要知道它背后的原理是同一个,就不会被问懵。
1.2 数组名和指针:像,但真的不一样
这里几乎是所有新手的第一道坎。数组名在大多数表达式里会“退化”成指向首元素的指针,但它本身并不是指针变量。区别在哪?
数组名没有自己的存储空间,它只是那块内存区域的名字。所以你不能对数组名做自增自减,arr++直接编译失败,因为arr不是一个可以修改的左值。但指针可以,p++完全合法。
sizeof(arr)和sizeof(p)的结果也不同。数组名用在sizeof里时不会退化,它返回的是整个数组的字节数;而指针的sizeof返回的是指针本身的大小,64位系统下通常是8个字节。我见过太多人在函数参数里写void foo(int arr[]),然后一上来就sizeof(arr)想拿数组长度——拿到的永远是8,不是数组大小。为什么?因为数组作为函数参数时也会退化成指针,这个坑后面细说。
还有两个例外:除了sizeof,&arr也是一个不退化的情况。&arr的类型是“指向整个数组的指针”,也就是int (*)[N],它和arr退化成int*的地址值一样,但含义完全不同。arr + 1跳过一个元素,&arr + 1跳过整个数组。很多人在做内存操作时用错这两个表达式,导致偏移量差了“数组大小”这么多。
1.3 指针加减的步长:为什么要分类型
指针的加减运算,单位不是字节,而是“所指向类型的大小”。这句话是一切的基石。
int arr[5] = {10, 20, 30, 40, 50}; int *p = arr; printf("%p\n", p); // 假设 0x1000 printf("%p\n", p + 2); // 0x1008,不是 0x1002p + 2实际偏移了 2 × sizeof(int) = 8 个字节。这是设计上的必然——因为解引用*(p + 2)要拿到一个完整的 int,如果偏移量不是元素大小的整数倍,取出来的数就是错位的。
理解了步长,你就知道为什么void*不能做加减运算。void类型没有大小,编译器不知道偏移单位是多少。这在后面讲泛型指针时还要展开。
指针和指针之间可以相减,得到的是“相隔多少个元素”,不是字节数。但两个指针相加是没有意义的,两个地址加在一起你要它干什么用?这就像问“北京和上海加起来是多少公里”,完全说不通。
1.4 字符串指针和字符数组:一个能改,一个改了会崩
char s1[] = "hello"; char *s2 = "hello"; s1[0] = 'H'; // 合法 s2[0] = 'H'; // 大概率崩溃s1是在栈上分配的一块字符数组,内容是拷贝过来的副本,改它没问题。s2指向的是字符串字面量,在只读数据区,改它属于未定义行为,大多数系统上直接段错误。
很多同学在这个坑上栽过,而且是在项目收尾阶段才炸出来。排查手段其实很简单,就是看清楚这个指针到底指向哪里:是指向栈上的数组,还是指向字面量常量区。这个判断习惯要培养起来,写字符串处理代码时先问自己一句:“这块内存我能写吗?”
2. 二级指针:什么时候非它不可
2.1 一个经典困局:函数里改了指针,外面为什么没变
C语言里所有参数传递都是值传递。很多人能接受“传 int 进去改了没用”,但到了指针这里就卡住了:“我传的是指针啊,怎么会改不了?”
关键在于:传指针进去,复制的也是这个指针变量的值,也就是那个地址。你在函数内部拿到的是这个地址的一份拷贝,通过它确实能修改指向的那块内存,但你修改不了外面那个指针变量本身。如果函数里写了p = something,改的只是内部拷贝,外部的p纹丝不动。
void change_ptr(int *p) { p = (int *)malloc(sizeof(int)); *p = 42; } int main() { int *p = NULL; change_ptr(p); // p 依然是 NULL,内存也泄漏了 }想解决这个问题,就得把“指针变量”本身也当作一块内存,通过“指向这个指针变量的指针”去修改它。这就是二级指针存在的意义。
2.2 二级指针的典型场景:改实参、链表头、字符串数组
最常见的应用之一,就是在函数内部给外部指针分配内存:
void alloc_int(int **p) { *p = (int *)malloc(sizeof(int)); **p = 42; } int main() { int *p = NULL; alloc_int(&p); printf("%d\n", *p); // 42 free(p); }这里&p的类型是int**,*p解引用拿到的是外部的p变量本身,给它赋值就等于修改外部的指针。
第二个高频场景是链表的头节点插入。如果你要在头部插入一个新节点,而头指针又可能为空,那就需要修改“头指针变量”的值。用二级指针传入&head,函数内部*head = new_node就能直接更新链表头。如果只用一级指针,函数内部改了头,外面还是旧的,整个链就断了。
第三个场景是遍历字符串数组。假设你有一个char *strs[3],想让一个函数接收它并遍历,形参就要写成char **strs。因为数组退化成指向首元素的指针,首元素是char*,所以指针的类型就是char**。
2.3 二维数组为什么不是二级指针
这个混淆太常见了。int a[3][4]作为函数参数时,形参是int (*a)[4],也就是“指向含4个int的数组的指针”,不是int**。为什么?
因为二维数组在内存里是连续排布的一整块,没有“存放指针的数组”这种东西。a[i][j]的本质是*(*(a + i) + j)。a + i跳过了 i 个长度为4的整型数组,得到的还是一个指针。而int**的语义是“指针指向一个指针”,需要内存里真的存在指针对象。这两者在内存布局上完全不同,直接混用,崩溃是必然的。
我建议你用一个小程序打印一下两者的sizeof,再实际取地址看看偏移,比背十遍概念都管用。
3. 指针和const:到底在锁谁
3.1 三种形态,一个记忆方法
const和指针的组合有三种写法,新手容易混乱:
const int *p1; // p1 指向一个 const int int *const p2 = &x; // p2 本身是 const const int *const p3 = &x; // 两边都 const记法就一条:从右往左读。const int *p1读出来是“p1 is pointer to const int”——指针指向的对象是const的,所以不能通过*p1修改值,但p1可以指向别处。int *const p2读出来是“p2 is const pointer to int”——指针本身是const的,所以p2不能指向别处,但*p2能改。
这个区分不是文字游戏,而是工程上的分工责权。写API的时候,入参用const int *p,是在告诉调用者:“我只会读你传进来的数据,不会改。”调用者看到这个签名,就能放心地把数据交给你。如果哪天你试图在函数里写*p = ...,编译器会直接报错,这就是用编译器帮你守住承诺。
3.2 天天报错的一个场景:const指针的隐式转换
看这段代码:
char *p = "hello"; const char *cp = p; // 合法合法,因为把char*赋给const char*,是增加限制,编译器允许。但反过来:
const char *cp = "hello"; char *p = cp; // 编译警告或错误char*意味着调用方“将来可以通过这个指针修改内容”,而cp指向的是只读数据。如果你强行把const char*赋给char*,等于把一个只读内存交给了可能写入的指针,这是要被警告的。
想绕过去,可以用强制转换。但请你在写这种代码之前想清楚:你真的确定这块内存可以写吗?如果确定,那转就转了;如果不确定,千万别转,否则程序哪一天在某个神秘的地方崩溃,你查三天三夜都查不到这里。
还有一层更隐晦的规则:int **不能隐式转换成const int **。很多初学者以为“加个const还能有啥问题”,但C语言标准里,这个赋值是非法的。原因是一个绕弯子的安全隐患:如果你允许这种转换,那某个作者就可能通过const int **去写一个原本指向const数据的指针,从而绕过类型系统的保护。简而言之,多级指针的const是不传递的。
4. 指针做函数参数:传辅助、传地址、传指针的指针
4.1 每一层“传法”能做什么,一张表格说清楚
我把指针传参的三种形态放在一起做个对比,写代码前先对照一下:
| 形参 | 能改实参指向的内存 | 能改实参指针的值 | 典型场景 |
|---|---|---|---|
int p | 不能 | 不适用 | 基础类型传值 |
int *p | 能 | 不能 | 修改数组元素、结构体字段 |
int **p | 能 | 能 | 分配内存、改链表头、字符串数组遍历 |
关键结论就一条:想改谁,就传谁的地址。想改普通变量,传一级指针;想改指针变量本身,传二级指针。这个规律往上扩展到三级四级都是一样的。
4.2 数组参数退化的坑:函数里拿不到数组长度
void print_array(int arr[]) { printf("%zu\n", sizeof(arr)); // 8,不是数组大小 }int arr[]写在形参列表里,等价于int *arr。这是语法糖,但也是坑的源头。在函数内部,sizeof(arr)永远是指针大小,不是数组大小。所以传数组的同时,必须把长度也传进来:
void print_array(int *arr, size_t len) { for (size_t i = 0; i < len; i++) { printf("%d\n", arr[i]); } }有人问:“为什么C不直接在数组里存个长度,这样就不用传了?”因为C的设计哲学是“不为你做你不想要的事”。数组退化成指针,一方面性能好(不像某些语言传数组要复制或者引用计数),另一方面也意味着调用者可以传“某个数组中间的指针”进来,灵活性非常高。代价就是责任在程序员身上——你必须自己记着长度。
4.3 一个实战级代码对比:返回指针 vs 二级指针
链表头插入这个场景,我见过两种写法。一种是用返回值:
Node *insert(Node *head, int val) { Node *new_node = (Node *)malloc(sizeof(Node)); new_node->val = val; new_node->next = head; return new_node; } head = insert(head, 42);另一种是二级指针:
void insert(Node **head, int val) { Node *new_node = (Node *)malloc(sizeof(Node)); new_node->val = val; new_node->next = *head; *head = new_node; } insert(&head, 42);两种都行,但风格差异很大。返回值方式要求调用者记得接住返回值,一旦忘记赋值,头就丢了。二级指针方式更直白:你传入头指针的地址,函数直接更新它,调用者不需要处理返回值,出错率低一些。工程上我更推荐二级指针,尤其是头节点可能为空的情况,代码意图也更清晰。
5. 常见问题与排查技巧实录
5.1 段错误、野指针、悬空指针:怎么定位
日常开发里和指针相关的问题,基本逃不出这几个类型。我把典型的错误和排查方法列成表:
| 症状 | 常见原因 | 排查入口 |
|---|---|---|
| 段错误 | 解引用了NULL指针 | 解引用前检查指针是否为NULL |
| 段错误 | 解引用了悬空指针(返回局部变量地址) | 检查函数是否返回了栈上变量的地址 |
| 段错误 | 越界写入了只读区 | 检查是否修改字符串字面量 |
| 输出乱值 | 野指针未初始化 | 初始化指针为NULL或有效地址 |
| 内存泄漏 | 指针被覆盖,释放不到原块 | 分配和释放尽量成对,写在同一个函数 |
| 崩溃偶发 | 释放了同一块内存两次 | 释放后立即置NULL |
悬空指针是典型中的典型,比如:
int *bad() { int x = 10; return &x; }x是栈上的局部变量,函数返回后它的内存就被回收了。指针虽然还保存着那个地址,但地址里的内容已经不再合法。多调用几次别的函数,那块内存可能被覆盖成乱七八糟的东西。这类问题用-Wall -fsanitize=address编译一下,往往能直接定位到出错行。
5.2 排查方法论:打印、工具、读代码的顺序
我排查指针问题有一套固定的顺序。第一步,用printf("%p", p)打印关键指针的地址值,先分清是“地址本身是垃圾”还是“地址没问题但内容被篡改”。第二步,查看指针是否为NULL,很多时候问题就是“调用前没判断,直接解引用了一个NULL指针”。第三步,用调试器打印调用栈(比如GDB的bt命令),看崩溃点在哪个函数、哪一行,再往前找是谁传了错误的指针。第四步是在可疑的写操作前后打印内存内容,确认是谁把数据改坏了。
这套流程看起来朴素,但实测非常管用。我见过某个同学链表写了一天,各种崩。最后打印了一下头指针的地址,发现第二次插入后头指针变成了之前已经释放掉的节点地址。原因就是插入函数里申请新节点后,被误操作覆盖了next指向。这种情况如果不用打印去核对每一步的指针值,光靠肉眼读代码,真的很难发现。
5.3 几个调试利器
编译时加-g是基础,配合gcc -Wall -Wextra能把很多可疑用法直接变成警告。更要推荐的是地址消毒器(AddressSanitizer),编译时加上-fsanitize=address,运行时如果出现越界访问、释放后再用、双重释放,程序会直接打印出出错的源代码行号。这在学习阶段说不上是“神器”,说它是“救命工具”都不夸张。
另外,调试时别嫌打印地址麻烦。我自己的习惯是,凡是指针改动的地方,都加上一行打地址的日志。日志是最直观的线索,尤其在链表、树这类结构里,地址变了没变,一目了然。
写在最后:我自己的一点体会
指针这一块,最难的不是语法,而是脑海里有没有那块内存的画面。每次看到*p,我在心里看到的是“根据地址找到一块内存”;看到p++,我看到的是“指针移动到下一个元素”;看到&p,我看到的是“指针变量在内存里的地址”。把这些操作和画面对应起来,指针就不再是抽象的符号,而是具体的动作。
如果你现在还在被指针折磨,我建议你做一件事:把本篇每个例子都自己敲一遍,然后在关键位置打印地址和值,对照着看输出。别急着写链表和树,先把指针运算、二级指针、const组合这些基础动作练熟,后面数据结构部分会轻松非常多。
下一篇我们可以聊聊函数指针、回调机制,以及指针和动态内存管理的一些进阶话题。到时候你会发现,基础这里越扎实,后面越省力。