☰
C语言指针进阶:数组指针、函数指针、智能指针与空指针排查
2026/10/9 4:35:19 网站建设 项目流程

指针这两个字,估计劝退过不少 C 语言初学者。网上讲指针的文章多到能堆成山,但大多数要么只讲语法不讲场景,要么上来就搬高深理论把人劝退。这篇是《指针相关》系列的第二篇,上一期我们聊过指针变量、取地址、解引用、指针算术这些地基,这一期我打算把进阶内容一次性理清楚:指针数组和数组指针的区分、函数指针数组、双指针和快慢指针在算法里的用法、指针碰撞这个概念,还有 C++ 智能指针的实现思路和使用陷阱,最后放一个我实际排查定时任务空指针异常的完整过程。

这篇适合谁?正在学 C/C++ 的学生、刚入行的嵌入式或后端开发,以及准备面试想系统过一遍指针的朋友。看完可以拿着里面的代码直接敲,遇到类似问题还可以复用那套排查思路。废话不多说,开始。

1. 先补一层地基:指针变量、指针赋值和二级指针

1.1 指针变量存的是地址,但它自己也有地址

相信来看这篇文章的人,基本都知道int *p = &a;是什么意思。但我还是想先把一个很容易被忽略的点摆出来:指针变量也是变量,它自己也要占内存、也有地址。这句话在理解二级指针时特别关键。

你定义一个int a = 10;,编译器在内存里划一块4字节空间存10,这块空间的起始地址就是&a。接着定义int *p = &a;,编译器又划一块空间给 p 存 a 的地址,这块空间的起始地址就是&p。也就是说,p 的值是 a 的地址,而 p 自己也有一个地址&p。那要用一个变量来存&p,就只能用二级指针int **pp = &p;。

很多人学二级指针晕,本质上就是把“变量的值”和“变量的地址”两件事搞混了。你可以在纸上画三个盒子:a 盒子里装10,p 盒子里装“指向 a 盒子的门牌号”,pp 盒子里装“指向 p 盒子的门牌号”。想清楚这个,二级指针就是一层纸。

1.2 指针赋值:三种写法,三种语义

热词里有“指针赋值”,这块确实容易乱。我总结最常见的三种写法,代码极其简单,但语义完全不同:

int a = 10, b = 20; int *p = &a; // 情况1:p 指向 a int *q = &b; p = q; // 情况2:地址拷贝,p 也指向 b *p = a; // 情况3:把 a 的值写到 p 指向的内存

情况1是在初始化时把 a 的地址交给 p;情况2是让 p 和 q 指向同一个变量 b;情况3是把 a 的值赋给 p 当前指向的内存,这时 p 指向谁就非常关键。如果 p 还没指向任何有效内存就执行*p = a;,这就是典型的空指针写入或野指针写入,轻则内存被莫名其妙改掉,重则直接段错误。

我见过很多学生在写链表时,把p = p->next;和p->next = node;搞反,本质也是没分清“改指针本身”和“改指针指向的字段”。记住一个小口诀:改 p 用p = ...,改 p 指向的东西用*p = ...,改结构体字段用p->field = ...。这三句话能帮你躲过一大半低级错误。

1.3 二级指针到底用来干嘛:改外面的指针

接着上面说,二级指针最常见的需求是:在函数里修改“调用者的指针变量本身”。举一个具体例子,你想写一个函数,给指针分配10个 int 的空间,让调用者能继续用:

void wrong_alloc(int *p) { p = malloc(sizeof(int) * 10); // 只改了形参,外面拿不到 } void right_alloc(int **p) { *p = malloc(sizeof(int) * 10); // 解引用后写入调用者的 p }

调用方式也不同:wrong_alloc(p);表面看起来没问题,但函数退出后 p 依然是原来的值;而right_alloc(&p);会把 malloc 的返回值写到 p 本身。C 语言是值传递,函数形参是实参的拷贝,想改实参本身就必须传实参的地址,这就是“取地址再传”的通用规律。链表插入、二叉树的创建,还有各种需要“在函数里改变外部指针指向”的场景,都逃不开二级指针。

事后看热词里“指针的指针”为什么总被讨论,因为它不是语法难,而是很多人没建立“变量的地址”这一层抽象。把 1.1 的那个盒子图画一遍,这一小节基本不会再丢分。

2. 指针数组和数组指针:四组形态一次说清

2.1 四种组合怎么表示:看变量名先和谁结合

热词里有“c语言 四组指针指针怎么表示”,我猜大家问的是指针和数组组合的四种常见声明。放在一起看:

int *p; // 指针变量 int **pp; // 指针的指针 int *arr[10]; // 指针数组:10个元素,每个元素是 int* int (*arrp)[10]; // 数组指针:指向"含10个 int 的数组"

判断两种数组形态最简单的方法,就是看变量名先和谁结合。在int *arr[10]里,arr 先和[10]结合,说明 arr 是数组,数组元素类型是int *;在int (*arrp)[10]里,因为加了括号,arrp 先和*结合,说明 arrp 是指针,它指向一个长度为10的 int 数组。别去死记“哪个是数组哪个是指针”,每次看见声明就做一次“先找变量名,再看先和谁结合”的动作,十秒钟就能出答案。

为什么这个区分在实际工程里重要?因为一旦你把int *[10]和int (*)[10]混用,编译器会给出类型不匹配的警告或错误,很多人在这时候还坚持“反正都是指针,强转一下得了”,结果就是二维数组传参后行列访问全部错位,调试到怀疑人生。

2.2 指针数组存放字符串:省内存还好交换

指针数组最常用的场景之一就是存放一批字符串,典型的写法:

char *colors[] = {"red", "green", "blue", "black"};

这里 colors 是一个有4个元素的数组,每个元素都是char *,分别指向四个字符串常量在只读区的位置。这么做有什么好处?第一,字符串内容不会被拷贝,数组里只存地址;第二,想修改顺序,比如把 blue 排到最前面,只需要交换colors[2]和colors[0]这两个指针,不需要搬动字符串内容,效率极高。

对比一下二维字符数组char colors[][8],每行必须预留最大字符串长度,短字符串也会占满8字节。我实际试过存一份包含几十个常见英文单词的列表,指针数组只占几百字节,而二维数组至少要预留几千字节。做嵌入式或者资源紧张的场景,这个差异非常现实。

注意:字符串常量是只读的,colors[0][0] = 'R';是非法的。如果你确实要修改字符串,应该用可写的字符数组初始化指针数组。这块老手也会踩,尤其是在做参数解析的时候直接改写 argv 里的字符串,很容易产生未定义行为。

2.3 数组指针与文件指针:两个“指针”长得很像,用法天差地别

数组指针int (*matrix)[4]在二维数组传参里是宝贝。函数写成void print_matrix(int (*matrix)[4], int rows),实参传入二维数组名 matrix,函数内部matrix[i][j]的访问依然成立。为什么?因为二维数组名在传递时退化为“行指针”,即指向第一行的指针,matrix + 1移动的是整行,不是单个 int。这个特性在做图像处理、矩阵运算时特别常用,你只要改一下参数类型,就能把不同行数的二维数组按统一方式处理。

再说文件指针,热词里也带到了。FILE *fp = fopen("data.txt", "r");这里的 fp 是指向FILE结构体的指针,它和内存里普通的 int 指针、数组指针有着本质区别。FILE 内部维护了文件位置指示器、缓冲区状态等一整套状态,你移动 fp 本身几乎毫无意义,文件的读写位置靠fseek、rewind这些函数来控制。所以不要试图对FILE *做加法减法运算,它在概念上更像一个“文件句柄”,而不是普通内存地址。

2.4 指针按指定位输出字符:指针算术的实战

热词里有一个很具体的需求:C语言数组指针移动指定位输出字符。比如字符串char s[] = "hello world";想从第5个字符开始输出,最直观的写法:

char *p = s + 5; // 指向 s[5] printf("%s\n", p);

这里要讲清s + n其实就是&s[n]。%s会从 p 指向的位置一直输出到'\0',所以s + 5输出的是从第6个字符开始的子串。如果你只想输出第5个到第7个字符,可以这样:

for (int i = 0; i < 3; i++) { putchar(*(s + 5 + i)); }

指针加法的字节数由指针类型决定:char *加1移动1字节,int *加1移动4字节。这种“移动指定位输出字符”的实现,只要抓住“数组名在表达式中退化为首元素地址,加 n 等于取第 n 个元素的地址”这一点,随你怎么玩都行。但要注意越界:n 如果超过字符串长度,s + n的行为就是未定义,可能读到随机数据甚至崩溃。写这类代码前最好先用strlen做一次边界检查。

3. 函数指针与函数指针数组:把代码当数据用

3.1 函数指针的声明和调用

先看最基本的写法。写一个加法函数:

int add(int a, int b) { return a + b; } int (*fp)(int, int); // fp 是一个函数指针 fp = add; // 函数名退化为函数指针,不需要 & int result = fp(3, 4);

声明里有括号的原因,和数组指针是同一个道理:int *fp(int, int)表示 fp 是一个函数,返回int *;只有int (*fp)(int, int)才表示 fp 是指针,指向一个“接收两个 int、返回 int”的函数。圆括号不是装饰,是语法必需品。

函数名为什么可以直接赋值给函数指针?因为在大多数表达式中,函数名会隐式转换为函数指针,这和数组名退化为首元素指针是同一套机制。所以fp = add;和fp = &add;都合法,你不需要记太多细节,知道两种写法等价就行。

3.2 函数指针数组:命令分发的实战

函数指针数组就是“把一堆函数指针放进数组”。典型场景是命令解析。我以前维护过一个简单的控制台程序,里面有 add、sub、mul、div 四条命令。最开始的实现用一堆 strcmp 加 if 判断,加第三个命令时已经有点烦躁,加到第五个命令时我看不下去了,改成函数指针数组:

typedef int (*operation)(int, int); int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } int mul(int a, int b) { return a * b; } int div_ex(int a, int b) { return a / b; } operation ops[] = {add, sub, mul, div_ex}; const char *cmd_names[] = {"add", "sub", "mul", "div"}; int execute(const char *cmd, int a, int b) { for (int i = 0; i < 4; i++) { if (strcmp(cmd, cmd_names[i]) == 0) { return ops[i](a, b); } } return 0; }

以后要加 pow 命令,只需要再写一个函数,然后往两个数组里各加一项,execute 函数一行不用改。这种“查表”思想在很多正式项目里都有应用,比如协议解析、状态机、菜单系统。它把一个分支很多的 switch 变成了一张表,代码结构清晰,扩展成本很低。

顺带说一句,热词里还有“c#指针用法”。C# 默认是托管代码,普通场景用委托来实现类似函数指针的效果,委托可以理解为“类型安全的函数指针”。只有在 unsafe 块里才允许用真正的指针做内存操作。如果你从 C 转 C#,先理解函数指针和委托的对应关系,再看 unsafe 代码就会顺畅很多。

3.3 函数指针的边界与注意事项

函数指针好用,但不是没有边界。先说类型匹配,函数指针的签名必须严格一致:参数个数、参数类型、返回值,一个不匹配,编译器该报错就报错。如果靠强转硬来,调用时实参怎么传、返回怎么收都会变成“约定大于校验”,项目一大迟早出事。

再说可移植性。标准没有保证函数指针能转换成void *再转回来,虽然很多平台在实践上可以,但跨平台代码别玩这种花活。嵌入式开发里,还要特别注意函数指针占用的空间以及对齐方式,不同架构下函数指针的大小可能不一样。

嵌入式场景里函数指针地位很高:中断向量表本质就是一张函数指针表,状态机转移表也常是函数指针数组。我自己调试过一个 RTOS 的定时器回调,回调是用函数指针存的,结果回调里访问了已释放的内存,排查了很久才发现是生命周期的问题,这个案例放到第六章详细说。

4. 双指针法与快慢指针:指针思维在算法和内存分配里

4.1 双指针法:一快一慢干翻一堆数组题

双指针法是算法题里在线性数据结构上最常用的套路之一。核心思想就一句话:用两个指针,单方向移动或者相向移动,避免重复遍历。典型应用是数组反转、有序数组两数之和、移除元素、合并有序数组。

看“移除指定值”的例子。要求原地删除数组里所有等于 val 的元素,返回新长度。暴力做法是每删一个元素就整体前移,O(n²)。双指针法可以做到 O(n),一个 fast 指针负责扫描,一个 slow 指针负责记录存放位置:

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

整个过程不额外开数组,空间 O(1)。很多人听别人讲双指针时觉得“就这?”,但真到了白板写题,卡住的往往是边界条件:fast 能不能等于 slow、slow 是否应该先加一、循环退出后的新长度是多少。建议你自己写十几个用例,把过程一步步画出来,比背答案管用得多。

4.2 快慢指针原理:为什么一定能追上

快慢指针是双指针的一种特殊形态,最常见的两个应用是“找链表中间节点”和“检测链表是否有环”。原理不复杂:慢指针每次走一步,快指针每次走两步。如果链表没有环,快指针会先走到末尾 null;如果有环,快指针会一直在环里转,慢指针进入环后,两个指针的相对速度就是“快指针每个时间单位靠近慢指针一步”,所以在有限步内必定相遇。

找中间节点的实现:

struct ListNode { int val; struct ListNode *next; }; struct ListNode *middleNode(struct ListNode *head) { struct ListNode *slow = head; struct ListNode *fast = head; while (fast != NULL && fast->next != NULL) { slow = slow->next; fast = fast->next->next; } return slow; }

这里要注意循环条件必须同时判断fast != NULL和fast->next != NULL,否则 fast 走两步时可能对空指针解引用。这个细节面试里经常被拿来挖坑。判环的 Floyd 算法也一样,只是相遇后还要再走一圈才能找到环入口。

4.3 指针碰撞:内存分配中的一个经典概念

热词里有“指针碰撞”,这个词在算法题里不常见,但在 JVM 内存管理和一些对象池实现里是个经典概念。简单说,有一片连续空闲内存,维护一个“分配指针”,指向已分配内存和空闲内存的分界处。每次分配新对象,只需要把分配指针向空闲区域挪动一段,挪动距离等于对象大小,这个过程又快又简单,不需要遍历空闲列表。当分配指针和内存区域末尾“碰撞”时,说明这片区域已经满了,需要 GC 或者扩展。这种分配方式就叫指针碰撞。

把它和快慢指针放在一起看很有意思:一个指针负责标记已处理的和未处理的边界,另一个指针负责向前探索;区别只是快慢指针追的是“环”,指针碰撞追的是“内存上限”。这种把指针当作边界标记的思想,在算法和底层系统里反复出现。

5. 智能指针:C++ 把 C 指针的坑系统性补上

5.1 智能指针的实现思路:RAII 和引用计数

C 语言里 malloc/free 全靠自觉,漏了 free 会内存泄漏,多余 free 直接崩溃。C++ 给出的系统级解法就是智能指针,核心思想叫 RAII:资源的生命周期绑定到一个对象的生命周期上,对象构造时获取资源,对象析构时自动释放资源。

用最简单的代码理解一下,一个极简智能指针长这样:

template <typename T> class SmartPtr { public: explicit SmartPtr(T *p = nullptr) : ptr(p) {} ~SmartPtr() { delete ptr; } T &operator*() { return *ptr; } T *operator->() { return ptr; } private: T *ptr; };

这个版本能自动 delete,但拷贝它就会出大问题:默认拷贝构造函数把 ptr 直接复制,两个对象析构时会对同一块内存 delete 两次。所以标准库的 shared_ptr 引入了引用计数:每多一个共享者,计数加一;每析构一个对象,计数减一;计数归零时最后一个对象负责 delete。这份管理逻辑也是很多面试手写题的原型。

5.2 三种智能指针怎么选

  • unique_ptr:独占所有权,禁止拷贝,允许移动,零额外开销。能用它就用它。
  • shared_ptr:共享所有权,内部有引用计数,拷贝会增加计数。适合真正需要多个持有者的场景。
  • weak_ptr:指向 shared_ptr 管理的对象但不增加计数,也不保证对象存活。用它观察资源、打破循环引用。

选型逻辑一句话:默认 unique_ptr,确实要共享才用 shared_ptr,担心循环引用或要缓存观察用 weak_ptr。不要一上来就全上 shared_ptr,它虽然用起来最无脑,但原子计数和堆控制块的性能开销是实打实的。

5.3 智能指针使用方法与常见陷阱

陷阱一:不要用同一个裸指针初始化两个 shared_ptr。假设int *raw = new int(10);,然后写shared_ptr<int> a(raw); shared_ptr<int> b(raw);,a 和 b 各自持有一份引用计数,都认为自己是唯一管理者,析构时同一块内存被 delete 两次。解决办法是统一用make_shared,或者确保每个裸指针只交给一个智能指针。

陷阱二:循环引用。A 类里有shared_ptr<B>,B 类里有shared_ptr<A>,互相持有导致引用计数永远到不了 0,内存泄漏。解决方法就是把其中一个持有者换成 weak_ptr,需要访问时临时lock()拿一个 shared_ptr,用完即走。

陷阱三:shared_ptr 的拷贝不是免费的。它要原子地增减引用计数,多线程下粒度再细也有竞争开销。别把 shared_ptr 当普通指针到处按值传,考虑传const shared_ptr<T>&或者裸指针、引用。尤其在函数内部频繁转手的情况下,这一条能省下不少无谓的计数操作。

陷阱四:不要把 this 直接包装成 shared_ptr。如果对象自身要获得指向自己的 shared_ptr,必须继承std::enable_shared_from_this<T>,然后调用shared_from_this()。直接包装 this 会导致同一个对象被管理两次,析构时双 free。

陷阱五:unique_ptr 放入容器和作为函数返回值时要理解移动语义。std::vector<std::unique_ptr<T>>里 push 一个临时 unique_ptr 没问题,但把一个左值 unique_ptr 塞进去必须std::move。

5.4 面试题速查与一个手写 SharedPtr

问题核心答案
智能指针原理RAII,用对象生命周期管理资源释放;shared_ptr 额外加引用计数
shared_ptr 线程安全引用计数本身原子操作,但所指对象不保证线程安全
weak_ptr 作用观察资源、打破循环引用,使用前 lock() 提升为 shared_ptr
unique_ptr 与 auto_ptr 区别unique_ptr 真正禁止拷贝、支持移动;auto_ptr 拷贝会转移所有权,已被废弃
enable_shared_from_this 作用让对象安全返回指向自身的 shared_ptr
为什么循环引用会泄漏两边引用计数至少为1,永远到不了0

最后给一个简化版 SharedPtr,供面试手写参考。这个版本没有考虑线程安全和定制删除器,但已经把引用计数的核心逻辑写全了:

template <typename T> class SharedPtr { public: explicit SharedPtr(T *p = nullptr) : ptr(p), count(new int(1)) {} SharedPtr(const SharedPtr &other) : ptr(other.ptr), count(other.count) { ++(*count); } ~SharedPtr() { if (--(*count) == 0) { delete ptr; delete count; } } SharedPtr &operator=(const SharedPtr &other) { if (this != &other) { --(*count); if (*count == 0) { delete ptr; delete count; } ptr = other.ptr; count = other.count; ++(*count); } return *this; } T &operator*() { return *ptr; } T *operator->() { return ptr; } private: T *ptr; int *count; };

面试时在这个基础上再讲清楚线程安全和 weak_ptr 的扩展点,基本就能过关。

6. 空指针排查实录:定时任务报空指针怎么查

6.1 报错现场:问题永远比表面深一层

“timer 执行查询报空指针”这类问题,在服务端和客户端开发里我都遇到过。比如有个定时任务,每隔一段时间去查一次数据库或缓存,某次重启后开始疯狂报空指针。第一反应千万不要是“哪个变量是 null”。空指针本身只是一个症状,病因往往藏在三个地方:对象还没初始化就被使用、对象在回调执行前被提前释放、某个依赖项没注入成功。

我记得有一次,现象是定时任务执行查询时,某个 manager 对象明明是启动时创建的,但一到定时回调就报空。查了半天发现是并发初始化导致的:两个线程竞争,其中一个分支还没有把 manager 注入完成,回调就先跑起来了。后来加了初始化完成标志和判空保护才稳定下来。这类问题难就难在不是每次都会触发,跟时序强相关。

6.2 一套能落地的排查步骤

我的排查套路基本是五步,非常机械但也非常有效:

  1. 先看堆栈,确定空指针报在哪一行,不要一上来就翻业务逻辑。
  2. 列出从入口到报错行之间的所有变量,逐个问“它一定能初始化吗”。
  3. 检查生命周期,尤其是回调场景:对象在哪里创建,在哪里销毁,回调会不会在这两者之外被触发。
  4. 临时加上防御性判空和日志,打印关键变量为 null 时的上下文。
  5. 修复后继续观察几个周期,确认是偶发还是稳定复现,再移除多余日志。

C/C++ 里对应的检查则是:指针是否未初始化、是否 free 之后忘了置 NULL、是否有多个指针指向同一块内存但其中一个已经被释放。只要有“悬垂指针”的情况,表现就是有时候能用、有时候崩溃,非常难复现。

6.3 防御性编程心得和两个实用工具

这些年我总结的防御性编程习惯,基本就是对付空指针的:

  • 所有可能返回 null 的接口,使用前强制判空,哪怕你觉得“这里不可能”。
  • free/delete之后立刻把指针置为NULL/nullptr,后续误用也能很快定位。
  • 回调函数里第一件事校验“我依赖的对象还活着吗”。
  • 定时任务这类异步路径比同步路径更容易踩空指针,因为执行顺序不可控。

排查工具方面,C/C++ 强烈推荐 AddressSanitizer 和 Valgrind。AddressSanitizer 用编译参数-fsanitize=address开启,它能直接告诉你哪块内存被非法访问、什么时候被释放、调用栈是什么。Valgrind 更重量级,适合做内存泄漏和非法读写的整体扫描。如果在 Java/C# 场景,就靠堆栈日志和链路追踪定位调用来源。不要只靠肉眼盯着代码猜,工具能帮你把案发现场还原出来。

指针这套东西,我写过代码、踩过坑、也在面试中被问过无数回。前后折腾这么多年,最大的体会是:指针说到底就是三件事——地址、类型、生命周期。地址决定它指向哪,类型决定一次移动多少字节,生命周期决定这块内存能不能用。数组指针、函数指针、双指针、智能指针,全都是在这三件事上做文章。你如果把“变量的地址”和“变量的值”彻底分开,再把“分配了就要释放、释放了就不能再碰”变成肌肉记忆,指针的绝大多数坎都能跨过去。这篇是《指针相关》系列的第二篇,后面我打算再写一篇专门讲链表、哈希表这些数据结构里的指针应用,把指针真正用起来。评论区也可以聊聊你遇到过最诡异的指针 bug,说不定下一篇的素材就是你贡献的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询