十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

指针至此:从二级指针到智能指针,五大核心场景全面击破

指针至此:从二级指针到智能指针,五大核心场景全面击破 早几年前我在公司内部做C语言分享课讲完总有同事追着问同一个问题“后面的坑你倒是讲啊指针到底什么时候用一级、什么时候用二级数组和数组指针到底是不是一回事”问的人多了我就答应写个“下篇”。这篇“指针two”就是那场拖延了很久的兑现。它不是什么开源项目也不是某个编译器的特殊版本而是把指针从“听说过、会解引”往“能安全上生产环境”推一把的进阶整理。读者最好已经认识指针变量写过int *p a知道*和的基本含义。如果你正在被二级指针、指针数组、数组指针、函数指针、智能指针甚至嵌入式里那堆寄存器映射搞得头大这篇就是冲你来的。为什么叫“指针two”这篇文章到底要讲什么1.1 名字的两层含义“指针two”这个标题我琢磨过一阵。它有两层意思。第一层很直接这是指针系列的第二篇是“指针进阶”的代号。基础篇讲完指针是什么、怎么声明、怎么取址解引、怎么和函数配合到了第二篇就该处理真正让初学者崩溃的“指针的指针”和“指针与数组纠缠在一起”的问题了。第二层更贴近技术本身two 也可以理解成 double“指针two”就是“二级指针double pointer”——int **这种“指向指针的指针”。很多人在这个点上卡住不是理解不了概念而是想不明白“我明明有一级指针就够了为什么非要再多绕一层”。这篇文章会花一整章把这个问题讲透。所以整体内容就围绕四条线展开指针自己是怎么引用的二级指针指针和数组到底怎么组合指针数组、数组指针函数能不能被当作数据传递函数指针、回调以及现代C里如何用智能指针替代裸指针最后再落到嵌入式寄存器访问和空指针排查这些实战场景。1.2 什么人需要这篇内容如果你只写业务逻辑整天处理字符串、链表、回调你会需要它。如果你在做嵌入式天天对着寄存器操作手册你会需要它。如果你在准备面试经常看到“双指针合并有序数组”这种写法你更会需要它。一个比较常见的学习误区是把指针当成语法细节来背而不是当成“存地址的变量”来理解。其实指针变量和普通变量没有任何本质区别它也有自己的地址也可以被别的指针指向。把这个观念立住后面所有的“链式”操作就都顺了。这篇不会讲太偏门的花活所有例子都来自实际写代码会遇到的场景。每个章节尽量做到“是什么、为什么、怎么用、踩过什么坑”四件事讲完读者可以直接把代码摘走再加工。二级指针为什么有时候必须多绕一层2.1 一级指针传参的根本局限先复刻一个经典翻车现场。很多人写过一个类似malloc封装的函数void create_buffer(char *p, int size) { p (char *)malloc(size); }调用的时候也像模像样char *buf NULL; create_buffer(buf, 1024); if (buf NULL) { // 崩溃或逻辑错误因为这里 buf 永远是 NULL }问题是C语言函数传参是值传递。buf本身的值是 NULL这个 NULL 被复制了一份给了形参p。函数里面malloc出来的地址只写进了pbuf自己还是 NULL。函数一返回p这个副本就消失了。这就是“想修改一个变量必须拿到它的地址”这个铁律的延伸。想修改int的值传int *想修改int *的值就得传int **。正确的写法是void create_buffer(char **p, int size) { *p (char *)malloc(size); } char *buf NULL; create_buffer(buf, 1024); // 传 buf 的地址这里p是char ***p就代表buf本身给*p赋值等于给buf赋值。如果觉得绕把char **读成“一个指向 char 类型指针的指针”或者更通俗一点第一层解引用拿到的是那根“指针”第二层解引用拿到的才是真正想要的数据或内存块。2.2 链表的头结点为什么必须用二级指针链表插入头结点是二级指针的另一大主战场。typedef struct Node { int data; struct Node *next; } Node; void insert_head(Node **head, int value) { Node *new_node (Node *)malloc(sizeof(Node)); new_node-data value; new_node-next *head; *head new_node; }如果这里只传Node *head函数内部改了head指向新节点调用者手里的头指针依然纹丝不动整个链表相当于没插上。唯一看似可行的替代方案是让函数返回新的头指针然后调用处写成head insert_head(head, value)。这样也能工作但每次插入都要记得接住返回值稍不留意就丢了链头。用二级指针之后函数直接改调用者手里的头指针调用处不用接收返回值语义更明确这个操作就是“改链表的头”而不是“给你一个新的链表头”。我自己在带新人时有个小经验只要函数内部需要“改变调用者的指针本身”就必须再加一层指针如果只是读指针指向的内容或者修改指向的内容一级指针就够了。2.3 二级指针使用时的三个硬性提醒第一不要对空指针直接解引。用二级指针前先确认外层指针不是 NULL。比如char **pp NULL; **pp a;这在运行时就是经典的段错误。第二多级指针不是越“深”越厉害。常见代码里出现三级指针已经很罕见四级基本是在炫技。多级指针只会让可读性急剧下降能封装的结构就封装不要靠叠星号表达自己很懂指针。第三char **argv和char *argv[]是同一个东西。命令行参数里常见的argv本质就是一个char *数组的首地址函数参数书写时完全等价。理解了这一点很多教程里的困惑会少一大半。指针数组与数组指针绕晕所有人的孪生兄弟3.1 先从写法上分清谁是谁很多人在这一步开始怀疑人生因为这两个概念长得实在太像。写法名字本质int *p[3]指针数组一个数组数组里有3个元素每个元素都是int *int (*p)[3]数组指针一个指针它指向一个“包含3个int的数组”记忆口诀我一般这样教先找标识符p看谁先和它结合。[]的优先级高于*所以int *p[3]里p先和[3]结合成数组数组里装的是int *。而int (*p)[3]用括号强行让p先和*结合所以它是一个指针这个指针指向一个长度为3的int数组。一句话总结没有括号时p是数组加了括号后p是指针。3.2 指针数组的经典使用场景字符串集合最大的价值体现在处理字符串集合上。比如写一个简单的调色板const char *color_names[] { red, green, blue, black, white };color_names是一个有5个元素的数组每个元素是一个const char *指向一块只读的字符串字面量。这样写的好处是不需要为每条字符串单独起一个名字可以用下标访问比如color_names[2]就是blue。在命令行解析、日志级别表、错误码描述等场景里这种方法特别常用。它的底层逻辑是把一组“指针”排成一个数组再统一处理比定义一堆独立的字符串变量清爽得多。3.3 数组指针与二维数组传参数组指针最常见的归宿是二维数组的函数传参。看这个例子void print_matrix(int (*mat)[4], int rows) { for (int i 0; i rows; i) { for (int j 0; j 4; j) { printf(%d , mat[i][j]); } putchar(\n); } } int main(void) { int m[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; print_matrix(m, 3); return 0; }这里的mat就是一个数组指针它指向“每行有4个int”的数组。把m传进去时二维数组名会退化成指向其首行的指针也就是int (*)[4]类型这和形参正好匹配。如果形参写成int **mat编译器会直接警告或报错。因为int **的含义是“指向指针的指针”而二维数组名退化出来的东西并不是这个。这是很多人的坑以为二维数组传参就是用二级指针实际上它们是两个模型。int a[10][10]的内存是连续排布的10行每行10个int而int **完全不知道“每行有多少个”这回事。3.4 一个容易忽略的坑sizeof 遇上数组退化数组名在大部分表达式中会“退化”成指向首元素的指针但sizeof是个特例。int arr[10] {0}; void func(int a[]) { // 这里 a 已经退化成指针sizeof 得到的是指针大小不是数组大小 size_t n sizeof(a) / sizeof(a[0]); // 在64位机器上结果是 2不是 10 }这个特性不算指针数组独有的问题但很容易在“数组和指针混用”的项目里被一起引爆。解决方法是在传参时同时把数组长度传进去不要指望在函数内部用sizeof求出原数组长度。函数指针与回调把函数当成数据传递4.1 函数指针变量的声明与调用先纠正一个最常被混淆的概念。“函数指针”和“指针函数”完全是两回事。概念写法示例含义函数指针int (*fp)(int, int)fp是一个指针它指向一个函数指针函数int *get_value(void)函数返回值是一个int *函数指针的声明和数组指针很像*要和函数名先用括号绑在一起否则就变成了“返回指针的函数”。实际使用int add(int a, int b) { return a b; } int main(void) { int (*calc)(int, int) add; // 函数名就是函数的入口地址 int result calc(3, 5); // 可以直接用指针变量调用函数 printf(%d\n, result); // 输出 8 return 0; }函数名和数组名有一点点相似在不取地址的情况下函数名会隐式转换成函数指针所以calc add和calc add都可以。调用时calc(3, 5)和(*calc)(3, 5)也等价。新手只需要记住最简单的一种写法其他写法是别人秀存在感用的实际项目里保持一致即可。4.2 回调机制定时器里的常见用法函数指针最大的实用价值是回调。拿定时器来说很多框架允许你“到时间了去执行某个函数”但框架不可能提前知道你要执行什么于是让你把函数指针传进去。typedef void (*timeout_handler)(void *context); void register_timer(timeout_handler handler, void *context, int delay_ms) { // 内部保存 handler 和 context // delay_ms 时间到之后调用 handler(context) }实际使用时void on_query_timeout(void *ctx) { Query *q (Query *)ctx; // 检查 q 是否仍然有效再执行后续逻辑 } Query query; register_timer(on_query_timeout, query, 5000);这种做法的精髓在于“延迟执行”把调用关系从编译期固定变成了运行期注入。很多 UI 库、网络库、定时器框架都靠这种机制解耦。在 C 里std::function是函数指针的“升级版”除了普通函数指针还能绑定 lambda、成员函数等。但理解底层函数指针仍然重要因为很多 C 接口和嵌入式场景不接受std::function。4.3 回调里最容易出现的空指针问题回调空指针是最常见的实际事故。原因往往不是函数指针本身为空而是调用回调时作为上下文的void *context指向的对象已经被释放了。定时器场景尤其典型注册回调的时候对象还活着等定时器真正触发的时候对象可能已经被销毁了。热搜词里的“timer执行查询是报空指针”十有八九就是这个。排查思路是这样的先看崩溃堆栈确认是不是在回调函数里解引了一个空指针或野指针。再回看注册点对象生命周期是否覆盖回调触发点。如果无法保证覆盖要么在回调前取消注册要么用带生命周期绑定的机制比如智能指针包装的弱回调。个人经验写回调注册接口时尽量把“取消注册”也设计进去不要只提供“注册”没有“反注册”。否则后期排查生命周期问题会非常痛苦。4.4 用函数指针数组代替 switch-case另一种实用技巧是函数指针数组。比如协议解析时根据消息类型直接索引对应的处理函数void process_login(void *data); void process_logout(void *data); void process_ping(void *data); void (*handlers[])(void *) { process_login, process_logout, process_ping }; void dispatch(int type, void *data) { if (type 0 type 3) { handlers[type](data); } }相比之下用一长串switch-case不仅看起来臃肿新增消息类型时还要修改多个地方。函数指针数组把“类型”和“处理函数”的映射关系集中放到了一张表里后续扩展和维护都直观不少。C17 智能指针现代工程里的默认选择5.1 从裸指针的痛点说起C语言的指针自由而危险使用不当就是内存泄漏和迷之崩溃。C 在引入智能指针之前项目里大量时间花在“有没有所有人都记得 delete”这件事上。有个经典段子每个 C 项目里都有一个“回收内存的负责人”他每天的工作就是看哪里 new 了没 delete。后来智能指针的普及让这个岗位在大部分项目里失业了。智能指针本质是一个类模板把裸指针包起来利用 RAII 技术对象创建时拿到资源对象析构时自动释放资源。哪怕中途抛了异常栈展开也会触发析构资源一样能释放比手动调用delete可靠得多。5.2 unique_ptr、shared_ptr、weak_ptr 怎么选C11 维护C14 补上make_uniqueC17 开始智能指针已经完全是日常标配。三种指针各有分工。类型所有权模型典型场景核心特性unique_ptr独占所有权资源归属明确的场景不可拷贝可移动析构即释放shared_ptr共享所有权多个对象共同持有一个资源引用计数最后一个释放者销毁资源weak_ptr不拥有资源观测、防循环引用不增加引用计数使用时临时提升为shared_ptr实际项目里能用unique_ptr的地方优先用unique_ptr。这不是因为它更高级而是因为它语义最清晰这个资源只有一个“主人”析构时机确定没有计数开销不担心循环引用。只有当资源确实需要多方共享时才考虑shared_ptr。shared_ptr的引用计数本身有原子操作开销虽然绝大多数情况下可以忽略但搞清楚语义依然是第一位的。weak_ptr最典型的应用是打破循环引用。比如两个对象都持有对方的shared_ptr引用计数永远降不到0资源永远不会被释放。其中一方改成weak_ptr就能打破这个环。5.3 shared_ptr 的循环引用演示与解决写一个最小复现#include memory struct B; struct A { std::shared_ptrB b; }; struct B { std::shared_ptrA a; }; int main() { std::shared_ptrA pa std::make_sharedA(); std::shared_ptrB pb std::make_sharedB(); pa-b pb; pb-a pa; // 函数结束pa 和 pb 析构但两个对象互相引用引用计数都保持在1内存泄漏 return 0; }解决方式是其中一侧改成weak_ptrstruct B; struct A { std::shared_ptrB b; }; struct B { std::weak_ptrA a; // 不增加 A 的引用计数 };使用时先提升if (std::shared_ptrA pa b-a.lock()) { // 说明 A 还活着可以安全使用 }lock()如果返回空shared_ptr说明对象已经销毁这正是经典“先探查再使用”的防御姿势。5.4 智能指针的几个实操提醒第一永远不要把同一个裸指针交给两个shared_ptr管理。等于给一个人办了两次“所有权”等于释放两次运行时直接销毁两块内存崩溃得毫无悬念。正确做法是分别用make_shared创建两个互相独立的资源或者用shared_ptr从已有的shared_ptr拷贝构造。第二尽量用make_shared和make_unique不要手动new再包进去。这不仅是代码风格问题还涉及异常安全。比如某个参数构造时抛异常裸new出来的指针可能没来得及交给智能指针就泄漏了。第三回调里尽量保存weak_ptr而不是shared_ptr或裸指针。这样至少能在使用前检查对象是否还活着大幅降低“定时器回调里访问已释放对象”这类事故的发生概率。指针与寄存器嵌入式开发里的直接地址操作6.1 指针天生就是为硬件准备的在嵌入式开发里指针不仅是指向“内存里的变量”更是直接访问“物理地址上硬件寄存器”的手段。很多人初学 C 语言觉得指针很抽象一旦接触单片机开发瞬间就懂了原来指针就是个地址我往这个地址写值就是在告诉硬件该干什么。比如操作一个 GPIO 的输出寄存器代码长这样#define GPIO_BASE 0x40021000U #define GPIO_ODR (GPIO_BASE 0x14U) void gpio_toggle_pin(void) { volatile uint32_t *reg (volatile uint32_t *)GPIO_ODR; *reg ^ (1U 13); }这里GPIO_ODR是一个整数地址强转成volatile uint32_t *之后*reg的读写就直接对应硬件寄存器的读写。6.2 为什么寄存器指针一定要加 volatilevolatile在这里不是可选是必须。它告诉编译器“这个地址的内容可能被外部改变”千万不要优化掉。如果把寄存器访问声明成普通指针编译器可能觉得“对一个地址写了又读结果还是自己写的那个数”顺手优化成直接从寄存器里读自己写入的值。对于普通内存变量问题不大但对硬件寄存器就是灾难因为硬件的电平根本不听编译器安排。更危险的是*reg ^ (1U 13)这种“读改写”操作。如果没有volatile编译器可能把多次读改写合并成一次结果写入完全打乱硬件时序。这种事排查起来让人非常崩溃因为代码写得多漂亮都没用反汇编一查全明白但几个人会第一时间想到查反汇编呢。6.3 结构体指针映射寄存器组寄存器数量多的时候一般不会一个个单独定义宏而是把一组寄存器定义成结构体然后用结构体指针指向基地址。这在 STM32 等开发中是非常普遍的做法。typedef struct { volatile uint32_t MODER; volatile uint32_t OTYPER; volatile uint32_t OSPEEDR; volatile uint32_t PUPDR; volatile uint32_t IDR; volatile uint32_t ODR; // ... } GPIO_TypeDef; #define GPIOA ((GPIO_TypeDef *)0x48000000U)之后操作就很清楚了GPIOA-ODR | (1U 5);这种做法的好处是代码可读性高寄存器地址的偏移由编译器按结构体成员排列自动计算不容易出现人手写偏移地址写错的情况。缺陷是结构体对齐可能与芯片手册定义不一致所以专业外设库在定义结构体时通常会检查sizeof、用packed属性控制对齐避免成员之间被编译器“塞”填充字节。6.4 嵌入式指针调试的一点经验硬件问题不像纯软件问题那么好定位但指针相关的坑大部分还是可以用老办法排查。遇到“寄存器写不进值”这种问题先看指针变量本身值是否正确。用调试器观察变量看它是不是指向了目标基地址。很多新手在这里栽跟头原因是强转写错了地址或者宏定义拼错了偏移。再看目标地址是否允许当前总线访问。有些地址默认处于复位状态需要先打开对应外设时钟。这时候不是指针语法问题而是硬件配置问题但表现往往也是“访问某个地址没反应”。最后再看读写宽度是否匹配。比如寄存器要求32位访问你却用uint8_t *去写硬件可能直接忽略一次字节写入。很多教“指针类型决定了读写宽度”的文章讲得很抽象放到这里就非常具象了。双指针算法与空指针排查面试题和生产事故一网打尽7.1 双指针合并有序数组不只是刷题技巧“双指针合并有序数组”是面试里非常高频的一道题但它绝不是只在面试里有用。比如两个有序日志文件要合并成一个有序文件两个有序数据源要汇总都能套用这个思路。C 版示例std::vectorint merge_sorted(const std::vectorint a, const std::vectorint b) { std::vectorint result; result.reserve(a.size() b.size()); size_t i 0, j 0; while (i a.size() j b.size()) { if (a[i] b[j]) { result.push_back(a[i]); } else { result.push_back(b[j]); } } // 把剩余部分追加进去 while (i a.size()) { result.push_back(a[i]); } while (j b.size()) { result.push_back(b[j]); } return result; }两个指针分别遍历两个数组每次都取当前更小的那个值。因为两个数组已经有序所以每次比较后前进对应指针整体只需要扫描一遍时间复杂度是O(nm)。如果用暴力做法嵌套循环逐个比较复杂度是O(n*m)。数据量小看不出来数据量到几十万时差距就是毫秒和秒级的区别。双指针真正厉害的地方不是“快”而是利用有序性避免了大量无意义的比较。注意这里说的“双指针”指算法模型不是语言层面的int **。但两种“双指针”在工程项目里都很常见面试时一定要先确认面试官问的是哪个不然容易答错方向。7.2 空指针问题排查思路别再瞎加 if 判断生产环境里空指针异常是高频事故尤其是和定时器、异步回调、网络请求一起出现的时候。很多人一遇到空指针第一反应就是在那里加一个if (ptr ! NULL)完事。但真正的问题往往不是“这一行解引了空指针”而是“为什么这个指针在这个时间点会是空/野”。我的排查顺序一般是这样第一步看崩溃堆栈定位到具体是哪一行访问了什么成员。不要看整个调用链发呆先锁定“最小失败点”。第二步检查指针变量是什么时候初始化的有没有可能从来没赋过值。C 语言里未初始化的局部指针变量是随机值解引它崩溃非常合理这个最容易被忽略因为代码审查看不到明显错误。第三步检查指针有没有被提前释放。悬空指针最阴险内存已经还给系统指针值还是原地址第一次解引可能没崩溃第二次、第三次才崩而且崩的位置可能离释放点十万八千里。这种情况看堆栈不一定管用最好在释放点打日志或者把释放后的指针统一置空让后续访问快速暴露。第四步如果是回调场景考虑生命周期问题。回调触发时上下文对象是否还活着如果拿不准优先用智能指针weak_ptr或者注册时绑定好生命周期。7.3 空指针、野指针、悬空指针对比很多人把这三个混在一起先分清定义。类型定义典型危险空指针值是 NULL 或 nullptr不指向任何对象解引必崩野指针未被初始化随机指向某处行为不可预测可能篡改任意内存悬空指针曾经有效所指对象已被释放内存可能已被复用偶尔能跑通偶尔崩溃应对策略也完全不同。空指针要靠防御性检查野指针要靠“变量必须初始化”的编码纪律悬空指针要靠“释放后置空”和“生命周期管理”。在 C 里智能指针是这套问题最有效的整体解决方案因为它消除了“忘记释放”“提前释放”和“不知道谁来释放”这三大根本矛盾。我个人在实际项目中养成的习惯是C 项目里所有指针变量定义时立即初始化任何free或delete之后立刻把原指针置为 NULL回调上下文一律用带生命周期的对象管理。C 项目里如果条件允许裸指针只用来“观测”所有权全部交给智能指针。这套准则执行久了大部分和指针相关的崩溃其实是可以提前避免的。如果你手头正好有一个查了半天的空指针崩溃别急着加非空判断按上面这套顺序理一遍大概率比瞎试更快找到真凶。
返回列表