C语言指针进阶:数组指针、函数指针、智能指针与空指针排查

发布时间:2026/10/9 4:35:23
C语言指针进阶:数组指针、函数指针、智能指针与空指针排查 指针这两个字估计劝退过不少 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 盒子里装10p 盒子里装“指向 a 盒子的门牌号”pp 盒子里装“指向 p 盒子的门牌号”。想清楚这个二级指针就是一层纸。1.2 指针赋值三种写法三种语义热词里有“指针赋值”这块确实容易乱。我总结最常见的三种写法代码极其简单但语义完全不同int a 10, b 20; int *p a; // 情况1p 指向 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_ptrint a(raw); shared_ptrint b(raw);a 和 b 各自持有一份引用计数都认为自己是唯一管理者析构时同一块内存被 delete 两次。解决办法是统一用make_shared或者确保每个裸指针只交给一个智能指针。陷阱二循环引用。A 类里有shared_ptrBB 类里有shared_ptrA互相持有导致引用计数永远到不了 0内存泄漏。解决方法就是把其中一个持有者换成 weak_ptr需要访问时临时lock()拿一个 shared_ptr用完即走。陷阱三shared_ptr 的拷贝不是免费的。它要原子地增减引用计数多线程下粒度再细也有竞争开销。别把 shared_ptr 当普通指针到处按值传考虑传const shared_ptrT或者裸指针、引用。尤其在函数内部频繁转手的情况下这一条能省下不少无谓的计数操作。陷阱四不要把 this 直接包装成 shared_ptr。如果对象自身要获得指向自己的 shared_ptr必须继承std::enable_shared_from_thisT然后调用shared_from_this()。直接包装 this 会导致同一个对象被管理两次析构时双 free。陷阱五unique_ptr 放入容器和作为函数返回值时要理解移动语义。std::vectorstd::unique_ptrT里 push 一个临时 unique_ptr 没问题但把一个左值 unique_ptr 塞进去必须std::move。5.4 面试题速查与一个手写 SharedPtr问题核心答案智能指针原理RAII用对象生命周期管理资源释放shared_ptr 额外加引用计数shared_ptr 线程安全引用计数本身原子操作但所指对象不保证线程安全weak_ptr 作用观察资源、打破循环引用使用前 lock() 提升为 shared_ptrunique_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 一套能落地的排查步骤我的排查套路基本是五步非常机械但也非常有效先看堆栈确定空指针报在哪一行不要一上来就翻业务逻辑。列出从入口到报错行之间的所有变量逐个问“它一定能初始化吗”。检查生命周期尤其是回调场景对象在哪里创建在哪里销毁回调会不会在这两者之外被触发。临时加上防御性判空和日志打印关键变量为 null 时的上下文。修复后继续观察几个周期确认是偶发还是稳定复现再移除多余日志。C/C 里对应的检查则是指针是否未初始化、是否 free 之后忘了置 NULL、是否有多个指针指向同一块内存但其中一个已经被释放。只要有“悬垂指针”的情况表现就是有时候能用、有时候崩溃非常难复现。6.3 防御性编程心得和两个实用工具这些年我总结的防御性编程习惯基本就是对付空指针的所有可能返回 null 的接口使用前强制判空哪怕你觉得“这里不可能”。free/delete之后立刻把指针置为NULL/nullptr后续误用也能很快定位。回调函数里第一件事校验“我依赖的对象还活着吗”。定时任务这类异步路径比同步路径更容易踩空指针因为执行顺序不可控。排查工具方面C/C 强烈推荐 AddressSanitizer 和 Valgrind。AddressSanitizer 用编译参数-fsanitizeaddress开启它能直接告诉你哪块内存被非法访问、什么时候被释放、调用栈是什么。Valgrind 更重量级适合做内存泄漏和非法读写的整体扫描。如果在 Java/C# 场景就靠堆栈日志和链路追踪定位调用来源。不要只靠肉眼盯着代码猜工具能帮你把案发现场还原出来。指针这套东西我写过代码、踩过坑、也在面试中被问过无数回。前后折腾这么多年最大的体会是指针说到底就是三件事——地址、类型、生命周期。地址决定它指向哪类型决定一次移动多少字节生命周期决定这块内存能不能用。数组指针、函数指针、双指针、智能指针全都是在这三件事上做文章。你如果把“变量的地址”和“变量的值”彻底分开再把“分配了就要释放、释放了就不能再碰”变成肌肉记忆指针的绝大多数坎都能跨过去。这篇是《指针相关》系列的第二篇后面我打算再写一篇专门讲链表、哈希表这些数据结构里的指针应用把指针真正用起来。评论区也可以聊聊你遇到过最诡异的指针 bug说不定下一篇的素材就是你贡献的。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询