
都说C语言难十个人里有八个会告诉你“指针最难”。我当年学指针也是一头雾水绕来绕去搞不清*和到底谁是谁。后来工作写了几年嵌入式又转到应用层写C才发现指针这东西就像骑自行车——没学会之前觉得怎么可能不倒学会之后想忘都忘不掉。指针的本质并不复杂复杂的是它和数组、字符串、函数、结构体、内存管理搅在一起时产生的各种组合拳。这篇内容就是我从“背概念”到“真正理解指针”的完整总结覆盖指针变量、指针数组、函数指针、结构体指针、C里的顶层/底层指针、智能指针还有实际开发里最常见的空指针排查经验。不管你是刚学C语言的在校生还是写了几年业务代码想回来补基础的开发者这篇都适合静下心来看完。1. 指针到底是什么先甩掉“难”的心理包袱1.1 指针就是一个装着地址的变量很多人一看到指针就紧张其实把它想成“一个存着门牌号的纸条”就简单了。普通变量存的是值本身比如int a 10变量a里存的就是数字10而指针变量存的是另一个变量的地址也就是它在内存里的“门牌号”。int *p a这句话的意思是定义了一个指针pp里面放的不是10而是a所在内存单元的地址。有了这个门牌号你就能找到a。通过*p去访问a的值这个动作叫解引用。解引用就是顺着门牌号找到房间把里面的东西取出来或者改掉。所以*p 20等价于a 20。这里有个现象很奇妙你用指针改了值原变量也会变因为你改的根本就是同一个内存位置。面试里经常考一道题sizeof(p)是多少在64位系统上无论指针指向的是int、char还是结构体只要是指针变量大小都是8字节32位系统是4字节。为什么因为它本质上就是个地址地址的长度是固定的。这个知识点记牢了很多关于内存布局的困惑都能解开。1.2 指针变量在内存里的真实状态我拿一段代码拆开看int a 10; int *p a;这段代码在内存中干了三件事。第一系统给a分配4字节空间存入10第二系统给p分配8字节空间存入a的起始地址第三p自身的地址又另说。所以内存里其实有三个关键值a的值是10p的值是a的地址p的值是p的地址。再想深一层*p是10p是a的地址p是存放p那个变量的地址。这三者每次写代码都要分清楚。很多野指针问题就是这里糊了有人把p当成值来用有人把*p当成地址来传结果程序跑着跑着就崩。用生活类比再走一遍a就像小区里的一户人家p是你手里记着“某栋某单元某号”的纸条p是纸条自己存放的位置。你要给这家人送东西你顺着纸条找过去解引用而不是把纸条本身当人家。这个概念一旦通了底层代码阅读能力会上一个台阶。1.3 空指针和野指针两个必须躲开的坑空指针是NULL或nullptr它指向地址0访问它几乎必然崩溃。野指针更阴险它是一个已经失效的地址比如函数返回了局部变量的地址或者指针指向的内存被释放后没有置空。int *getValue() { int x 100; return x; // 危险 }这段代码运行完x的空间理论还在但已经被系统回收p指向的是一块“无主之地”。你继续用*p去读有时候碰巧能读出100有时候读出的就是垃圾值这就是典型的未定义行为。处理这类问题的经验每次释放完内存立刻把指针置为NULL拿到外部传入的指针第一步就判断是否为NULL再谈后续操作。这个习惯值多少钱我曾经线上服务因为这个吃了大亏从那时起就再也不敢省这一步。2. 指针和数组、字符串最容易出事的组合2.1 数组名和指针是也不是数组名在绝大多数表达式里会“退化”成指向首元素的指针。注意是“退化”不是“等于”。int arr[5] {1, 2, 3, 4, 5}; int *p arr; // p 指向 arr[0]这时p[2]和arr[2]是同一个值都可以用。但sizeof(arr)是205个intsizeof(p)是8地址大小说明数组名并不是真正的指针它只是在参与运算时“变”成了指针。此外arr本身不能自增自减arr是编译错误而p合法这也是区分两者的关键特征。指针加减整数的规则也值得展开。p 1不是地址加1字节而是加上“一个元素的大小”。如果p是int *p 1向后走4字节如果p是char *p 1只走1字节。这个步进规则由指针类型决定理解了这个p[i]和*(p i)等价就顺理成章了——编译器在底层就是用指针算术实现的。2.2 指针数组和数组指针名字绕死人不偿命这是C语言初学者的重灾区。一句话先记住区分方法先看谁和方括号结合再看谁是主体。指针数组int *p[3]。因为[]优先级高于*所以p先是一个数组数组里有3个元素每个元素是int *类型的指针。数组指针int (*p)[3]。括号把*和p绑在一起所以p是一个指针它指向一个长度为3的int数组。指针数组最常见的用法就是存字符串。比如char *month[] {January, February, March};这里month是一个指针数组每个元素month[0]、month[1]、month[2]都是char *分别指向字符串常量。这样设计的好处是你不需要知道哪个字符串最长每个元素都是8字节指针数组整体大小固定遍历和交换都特别快——交换元素时只交换指针不拷贝字符串内容。数组指针则更多用于指向二维数组的行。比如int matrix[2][3] {{1,2,3},{4,5,6}}; int (*row)[3] matrix; // row 指向第一行row的类型是“指向含3个int的数组的指针”row 1会跳过整整3个int也就是一行。这种类型在传二维数组给函数时非常重要。2.3 字符串与指针常量区、栈区、堆区别指错地方字符串在C语言里本质是char数组或者指向char的指针。但是两种写法有差异char *s1 hello; // s1 指向字符串常量区 char s2[] hello; // s2 是栈上数组s1指向的内存区域是只读的你尝试s1[0] H运行时直接崩溃或者未定义行为。s2则是在栈上开了6个字节hello加\0你可以直接修改s2[0]。很多初学者分不清这两个写出了能编译、运行却莫名崩溃的代码排查半天才发现是改到了常量区。再来说char *p[5]和char (*p)[5]配合字符串的区别。前者每个元素都是独立的char *适合存5个长度不等的字符串后者是一个指向“存放5个字符的数组”的指针适合处理定长记录。业务里解析固定格式报文时后者很实用。如果字符串数量动态变化C里一般用char **也就是二级指针指向一堆char *的起始位置。这个后面讲指针的指针时会碰到。3. 指针的进阶玩法指针的指针、函数指针、结构体指针3.1 指针的指针两层门牌号什么时候用二级指针就是“指向指针的指针”。声明方式int **pp。可以用两层地址来理解p存的是变量的地址pp存的是p的地址。想通过pp拿到原变量的值需要两次解引用**pp。二级指针最常见的场景是在函数内部修改外部指针变量的值。看这个例子void initPtr(int **pp) { *pp (int *)malloc(sizeof(int)); **pp 42; } int main() { int *p NULL; initPtr(p); // 此时 p 指向有效内存*p 42 }如果initPtr的参数是int *p你在函数里改p改的只是形参的拷贝外面的p还是NULL。传int **才能让函数拿到p本身的地址进而修改p的内容。想改int就传int *想改int *就传int **这个口诀特别好用。不过在实际项目里二级指针往往用来管理动态二维结构比如指针数组的动态分配char **strs (char **)malloc(n * sizeof(char *)); for (int i 0; i n; i) { strs[i] (char *)malloc(len * sizeof(char)); }这就是“每个元素都是字符串”的动态集合。用完记得逐层释放先释放里面的每个字符串再释放外层指针数组。3.2 函数指针和指针函数一个“指向函数”一个“返回指针”这两个名字也很折磨人。区分方法同样是看和谁结合。函数指针int (*func)(int, int)。func是一个指针指向一个“接收两个int返回int”的函数。经典用途是回调机制。int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int (*calc)(int, int) add; int result calc(3, 4);这种写法的价值在于函数可以像变量一样传来传去。比如排序函数qsort的最后一个参数就是比较函数的函数指针int compare(const void *a, const void *b) { return (*(int *)a - *(int *)b); } qsort(arr, n, sizeof(int), compare);指针函数则简单得多就是返回值类型是指针的函数。比如char *strcpy(char *dest, const char *src)返回值是char *。实际开发里函数指针还能用来做轻量级“策略模式”。比如一个图像处理库根据用户选择的不同滤波器把对应的处理函数地址传给统一的处理入口。比起写一堆if-else函数指针让代码清晰很多扩展新滤波器也不用改主流程。3.3 结构体指针数据封装和链表的基本功结构体指针在C语言里的地位等同于“类指针”在C里的地位。声明方式struct Student *p访问成员的语法是p-name等价于(*p).name。箭头符号其实就是一个“解引用加取成员”的缩写。结构体指针最大的优势是传参效率。当结构体比较大时直接传结构体整个结构体会被拷贝一份浪费时间和栈空间传指针只拷贝8字节地址效率高得多。链表这个经典结构就是结构体指针的典型应用struct Node { int data; struct Node *next; };next字段存的是下一个节点的地址每个节点间通过指针串起来。想插入一个节点核心操作就是改相邻两个节点的next指针。很多人在写链表时容易出错是因为没有在纸上画清楚指针指向前后的状态。我自己的经验是动手写代码前先把“谁指向谁”画出来确认没有丢失引用再动键盘错误至少减少八成。4. C里的指针新世界顶层/底层指针、智能指针和万能指针4.1 顶层指针和底层指针到底能不能相互赋值进入C之后const和指针结合会产生两个概念顶层指针和底层指针。这两个名词在CPrimer里讲得很细但很多人当时没看明白。顶层指针top-level pointer表示指针本身是常量也就是你不能修改指针的指向声明形式是int *const p。这种情况下指针指向的对象可以改但p不能再指向别的地方。底层指针low-level pointer表示指针指向的对象是常量也就是你不能通过这个指针修改所指向的值声明形式是const int *p。这时p本身可以指向别的位置但不能用来修改当前对象。那“顶层指针和底层指针可以相互赋值吗”这个问题答案没那么绝对关键在于赋值时底层const的匹配规则const int *p底层和int *const p顶层类型上其实不一致前者是“指向const int的指针”后者是“指向int的const指针”。所以在类型系统看来它们并不是可以直接互赋的类型。赋值的规则是底层const必须匹配——非常量指针可以赋给底层const指针即int *赋给const int *可以反过来const int *赋给int *不行因为你不能丢掉“不能修改”的约束。顶层const则不影响赋值因为拷贝一个指针不会改变它本身是否const的属性。举个例子int a 10; const int *p1 a; // 底层const可以把 int* 赋给它 int *const p2 a; // 顶层const指针本身不能改 int *p3 p1; // 错误不能把 const int* 赋给 int* const int *p4 p2; // 正确int* 可以赋给 const int*所以“可以相互赋值吗”的完整回答是底层const从非常量向常量方向赋值是允许的反过来不允许顶层const不影响拷贝赋值操作但两个变量类型不同必须按照上述规则对待。很多人在重构代码时把const int *直接塞给int *参数编译报错还不理解原因其实就是这条规则在发挥作用。4.2 智能指针把裸指针装进“托管壳”CRAII思想下的智能指针解决的核心问题是“忘了释放”和“释放了又释放”。裸指针使用new分配内存后如果提前return或者中间抛异常很容易漏掉delete于是内存泄漏。智能指针把内存释放逻辑绑定到对象的析构当智能指针对象离开作用域析构函数自动释放底层资源程序员不需要手动调用delete。现代C里最常用的是unique_ptr和shared_ptr以及配套的weak_ptr。unique_ptr是独占所有权。它不允许拷贝只能移动用std::make_uniqueT()创建。它适合“一个对象只归一个所有者管”的场景比如工厂函数返回一个对象时可以用unique_ptr安全传递所有权。std::unique_ptrint p std::make_uniqueint(42); std::unique_ptrint q std::move(p); // 转移所有权p 变为空shared_ptr支持多个指针共享同一个对象内部使用引用计数当最后一个shared_ptr销毁时对象被释放。用std::make_sharedT()创建效率更高。它适合多个模块共同使用同一个资源比如多个线程同时访问同一个配置对象。weak_ptr是shared_ptr的好搭档。它“旁观”而不“拥有”不增加引用计数。当你想访问对象时先调用lock()尝试提升为shared_ptr如果原对象已经销毁lock()返回空指针。这个机制专门用来解决循环引用导致的“内存泄漏”——两个对象互持shared_ptr引用计数永远到不了0内存就泄露了。把其中一个改成weak_ptr循环就断了。记忆这些智能指针的用法可以打个比方unique_ptr是独生子只允许一个家长shared_ptr是合租房多个租客共同持有最后一个离开才退租weak_ptr是观察员只看房不住房也不影响退租时间。这样一想什么时候该用哪个心里就有数了。4.3 万能指针void*能指向一切但别乱用C语言里的void *被称为万能指针它可以接收任意类型的指针不需要强转。malloc的返回值就是void *它不知道你要申请的是什么类型只负责分配字节数由你强转成需要的类型。void *的问题在于丢失了类型信息。p不能做因为不知道“步长”解引用也不能做因为不知道“宽度”。必须先强转回具体的类型才能操作。所以void *一般只用来做中转比如通用接口的入参void printAny(void *data, int type) { if (type 1) { int *p (int *)data; printf(%d\n, *p); } }这种写法灵活但牺牲了编译器帮你做类型检查的能力。如果不小心把一个float *传给按int *解析的函数数据直接是乱的而且编译器不会报错。所以实际项目中void *用得非常克制一般只出现在底层公共库的接口层业务代码里见到它要提高警惕确认调用者的类型完全匹配再加充分的注释说明。5. 空指针问题排查那些年我们调过的崩溃5.1 空指针是怎么来的从timer查询到回调现场还原一下最典型的空指针事故。比如一个用C写的服务启动了一个定时器定时器每300毫秒去数据库执行一次查询查询成功后将结果封装成对象更新到某个全局map里。某天版本上线后日志里出现这样的报错error: null pointer exception when timer executes query一看就是定时器回调里访问了空指针。排查思路按这四步走第一确认定时器是否还在运行——如果定时器已经停止但回调函数里没有对操作对象做判空第二确认查询结果对象是否每次都有值——如果数据库查询失败返回了空指针代码直接拿着空对象调方法第三确认对象生命周期——如果查询回调使用了一个成员变量指针而该指针在查询执行过程中被其他线程置空同样崩第四查调用链——看日志崩溃前最后一次成功的操作是什么缩小范围。这类问题的共同点是代码里用了“外部传入的指针”或“异步回调时捕获的指针”而外部环境的不可控性导致指针生命周期不明确。排查空指针时我从来不靠肉眼“瞪”而是先加日志把每次进入回调时关键指针的值打出来看它是从哪一步开始变成0x0的。5.2 排查空指针的实用手段第一个有用的工具是断言。在Debug版本里对关键指针加assert(p ! NULL)程序一跑到这里就会停下来直接定位问题行。注意发布版本会通过NDEBUG关闭assert所以别想着用assert做防御它只做“开发期检查”。第二个是gdb。程序崩溃后用gdb加载core文件执行bt看堆栈再用frame 5跳到对应函数print p查看指针的值。如果值是0x0再往前翻函数调用看它是从哪个返回值得来的。一套流程下来基本能确定空指针的源头。第三个是静态检查工具。C/C的cppcheck、clang-tidy可以在编译前检查出明显的空指针解引用。配合CI流程这种低级错误在合入主线前就能拦住。如果是shared_ptr辖下的指针变成空还得排查引用计数的状态用use_count()看一下当前持有数量。有时候你以为还有一个线程持有着对象实际上那个线程早就退出了shared_ptr析构了其他线程握着weak_ptr访问就崩。5.3 预防空指针的编程习惯排查再多都不如预防。我的代码规范里立了这样几条铁律所有函数入口处先校验指针参数是否为NULL不是的直接返回错误码。所有new或malloc的结果立刻检查是否成功失败的场景要单独处理。释放完内存后立刻将指针置NULL防止悬垂指针二次释放。回调函数里不假设外部对象一定存活尽量用智能指针管理生命周期或者用订阅者模式替代裸指针回调。异步任务访问成员变量前确认任务自身的生命周期大于成员变量必要时用std::weak_ptr加锁提升。这些规矩一开始执行时会觉得繁琐但长期下来线上崩溃减少的收益远大于写作成本。我见过太多事故最后定位下来都是一行“少了一个NULL判断”的锅。一个小时的排查不如一秒钟的防御。6. 指针学习避坑速查表与自测清单这里把常见问题整理成一张表方便随时翻阅易错点问题表现正确姿势野指针使用了已释放的内存释放后置NULL或用智能指针返回局部变量地址函数返回后数据乱掉返回静态变量、堆内存或由调用方提供缓冲数组名自增编译报错用另一个指针变量遍历指针数组/数组指针混淆类型错误编译失败看*和[]的结合优先级常量字符串被改动运行时崩溃区分char *s和char s[]const int *赋给int *编译拒绝注意底层const匹配规则忘记释放内存内存泄漏用unique_ptr/shared_ptr同时delete两次堆损坏delete后置NULL或用unique_ptr回调访问空指针异步崩溃生命周期用shared_ptr/weak_ptrvoid*强转错类型数据乱码强转前确认源类型或避免使用最后再附一套自测清单可以用来检查自己是不是真的理解了指针int *p[3]和int (*p)[3]分别怎么读const int *p、int *const p、const int *const p三者的区别一个函数要修改外部int变量的值应该传什么要修改外部int *变量的值应该传什么arr作为数组名在多数表达式中变成什么sizeof(arr)和sizeof(p)为什么不同两个shared_ptr相互引用会导致什么问题怎么解决void *能解引用吗为什么函数指针int (*cmp)(const void *, const void *)用在排序里它的参数和返回值在比较时意义是什么这七道题如果不需要翻书就能答出来指针这块基本就过关了。答不出来也不用急回头看对应小节再对着代码动手写几遍比什么都管用。我个人在实际项目里反复确认过一件事指针的问题九成以上不是“语法不会”而是“内存布局没在脑子里”和“生命周期没有管理起来”。所以我在带新人的时候很少让他们背语法而是让他们画图——每写一个指针操作先在纸上画出内存状态。画着画着很多看似玄学的问题就自己消失了。这篇内容也是按这个思路组织的建议你一边读一边把每个例子都在纸上画一遍内存布局理解的效果会比单纯读代码好很多。