
指针的基础知识这个话题我讲了十几年每次带新人或者答疑都能碰到一堆看似懂了、一写就废的情况。很多人把指针当成C/C的鬼门关背了概念、刷了题真到用的时候还是懵。原因很简单——指针不是靠背会的是靠在内存里想问题学会的。你如果能脑补出变量在内存里长什么样指针的一大半疑惑会自己消失。这篇文章不打算给你一本面面俱到的教科书而是以我多年写C/C的实际经验为线索把指针最核心的几块内容——指针变量本身、指针与数组、指针与函数、const修饰、二级指针、智能指针、双指针法——串起来讲清楚顺便把那些你在课本上很难问出口的为什么也一并解决。适合读这篇的人有两类一类是刚接触C/C、被指针搞到怀疑人生的初学者另一类是会用指针、但说不清原理、偶尔被各种指针错误坑到的进阶者。前者可以跟着思路把地基打牢后者能借这篇文章把零散的知识点串成体系。我尽量少说废话多用代码和场景说话。1. 指针到底是什么从内存地址重新认识变量1.1 变量不是名字是一块内存很多初学者对变量的理解就是一个名字比如int a 5;他们会觉得a就代表 5 这个值。这句话在语义上没错但在计算机的视角下是错的。真相是a这个名字对应内存中的一块区域这块区域有起始地址、有大小int 通常是 4 字节里面存放了数值 5。变量名只是编译器帮你做地址映射的一个符号运行时CPU根本不认识a它只知道某个内存地址。这个观念的转变特别重要。你可以在纸上画一个长条形的内存条把每个字节编号成地址然后把变量放进去。比如在 32 位系统下变量a可能占据地址 0x7ffeefbff5ac 开始的 4 个字节。取地址运算符a得到的就是这块内存的起始地址。我自己带学生时喜欢说一句话地址是内存的门牌号变量是门牌号下住的房间值就是房间里放的东西。指针变量再特殊它本身也只是一个房间只不过房间里放的不是普通的值而是另一个房间的门牌号——地址。整个过程套娃一样但理解清楚之后你会发现指针一点都不玄。1.2 指针变量的声明与初始化声明是态度初始化是能力C 语言的指针声明很简单int *p;这行代码读作p 是一个指向 int 类型的指针变量。注意这里的*只是声明语法的一部分表示这是一个指针。很多新手在这里第一个坑就来了int* p;和int *p;哪种写法对其实都对风格问题。但如果你写int* p, q;那么只有p是指针q是普通 int。为了避免这种误解我个人的习惯是写成int *p;让*和变量名靠在一起每次声明时都在心里默念*p是一个 int这样连变量声明带使用方式都记住了。声明了指针之后最重要的一件事给指针赋值。一个未初始化的局部指针变量里面装的是垃圾值你不知道它指向哪里。如果你在这个状态下直接解引用程序可能崩溃可能修改了不该改的数据还可能运气好没出事——第三种情况最可怕因为它让你误以为代码没问题。int a 10; int *p a; // p 指向 a *p 20; // 解引用等价于 a 20 printf(%d\n, a); // 输出 20这里*p出现在赋值语句左侧表示p 指向的那个内存位置。学指针必须养成一个习惯看到*p就问自己它是在读还是在写读就是取 p 指向位置的值写就是往 p 指向位置存值。1.3 空指针与野指针新手最容易踩的底层坑空指针就是值为NULL或者 C 里的nullptr的指针它不指向任何有效对象。野指针则是指向已释放内存或者指向随机地址的指针。很多人会把这两个混为一谈实际上区别挺大。先说空指针。解引用空指针在绝大多数平台会直接段错误Segmentation Fault这其实是操作系统在保护你告诉你你有 bug。很多入门课上来就让你写链表、写二叉树如果你不检查 malloc 返回是否为空就直接用很容易出这种事。规范的做法是int *p (int *)malloc(sizeof(int)); if (p NULL) { // 处理内存分配失败 return -1; } *p 42; free(p); p NULL; // 释放后置空防止野指针释放内存之后把指针置空这是我反复强调的习惯。free(p)之后p 指向的这块内存已经还给系统了但 p 本身还保存着那个地址——它成了野指针。如果后面不小心又*p 100;行为是未定义的可能不会立刻崩溃然后等到某个时刻莫名崩溃排查起来极其痛苦。所以释放之后立刻置空哪怕后面忘了判空也只会是空指针错误——至少在崩溃方式上是可预期的。2. 指针与数组的纠缠数组名、指针运算与多维数组2.1 数组名不是指针但处处像指针教科书上一个经典说法数组名是首元素的地址。这句话严格来说是不准确的。数组名在绝大多数表达式中会退化为指向首元素的指针但它本身不是指针变量——它是一个地址常量不能像指针那样被重新赋值。int arr[5] {1, 2, 3, 4, 5}; int *p arr; // 合法arr 退化为 arr[0] // arr p; // 不合法arr 不能作为左值我的理解方式是数组名是整片内存的锚点它在编译期就确定了没法改。所以如果你写一个函数想通过参数修改数组名指向是做不到的。这也是为什么在函数间传递数组时通常传递的是首元素地址也就是指针而不是数组本身。2.2 指针算术p1 到底加了几个字节这个东西很多教程会直接用指针加一就是下一个元素来带过但我见过太多人栽在字节数上。直接讲规则指针加 n实际地址增加 n × sizeof(指针指向的类型)字节。int arr[5] {10, 20, 30, 40, 50}; int *p arr; printf(%d\n, *p); // 10 printf(%d\n, *(p 2)); // 30为什么地址要按类型大小跳因为指针运算的根本目的是在数组元素之间移动而不是在字节之间移动。p 2的含义是跳过两个 int每个 int 占 4 字节所以地址上跳过 8 字节。如果指针是char*加一就是跳 1 字节如果是double*加一就是跳 8 字节。我用一句话概括指针算术帮你跳过了类型大小的细节但你要知道它确实在发生。这也解释了为什么void*不能做算术运算——它没有类型大小编译器不知道跳多少。2.3 指针数组与数组指针两个完全不同的东西这组概念是面试高频题也是很多自学者绕不开的坑。判断方法就一句话看最后结合的是数组还是指针。指针数组int *p[5];它是一个数组数组里每个元素都是int*指针。如果英文读法就是array of pointers。数组指针int (*p)[5];它是一个指针指向一个含有 5 个 int 的数组。读法是pointer to array of 5 ints。为什么*p[5]是数组因为[]的优先级高于*所以p先和[5]结合是个数组数组里每个元素再和*结合是指针。而(*p)[5]中括号强制了p先和*结合所以 p 是指针后面[5]说明它指向的块是 5 个 int 的数组。指针数组最常见的用途之一就是存放字符串。比如const char *days[] {Monday, Tuesday, Wednesday, Thursday, Friday};这里的days是数组每个元素是const char*指向字符串字面量。这种写法比二维数组灵活因为每个字符串的长度可以不同不需要为最长的那个预留空间。我在处理命令行参数、配置文件解析时经常这么干省内存又直观。数组指针则多用于二维数组严格说叫数组的数组的参数传递。比如void print_matrix(int (*m)[4], int rows) { for (int i 0; i rows; i) { for (int j 0; j 4; j) { printf(%d , m[i][j]); } printf(\n); } }m指向一个长度为 4 的 int 数组m[i]就是第 i 行的数组名m[i][j]是第 i 行第 j 个元素。这里m1跳的是 4×sizeof(int) 个字节这正好对应二维数组中下一行的概念。2.4 多维数组的指针访问别被表象骗了很多人觉得 C 语言没有真正的多维数组这句话站在内存布局的角度是对的。int a[3][4]在内存里就是连续的 12 个 int按行优先排列。a[1][2]在内存中的地址就是首地址偏移(1*4 2)*sizeof(int)。掌握这个底层布局你就能理解为什么用双指针int **无法直接接收二维数组名。a的类型是int (*)[4]指向 4 个 int 的数组的指针而int **是指向int*的指针两种类型不兼容。正确传参方式是用数组指针或者把二维数组扁平化成一个一维数组再传void func(int *flat, int rows, int cols) { // 通过 flat[i*cols j] 访问第 i 行第 j 列 }这个方法在写图像处理、矩阵运算时特别好用因为避免了数组指针语法带来的可读性问题。我可以很直白地告诉你把多维数组当成一维来处理往往比硬撑高维指针更不容易出错。3. 指针与函数参数传递的真相与函数指针3.1 值传递为什么换不了两个数两数交换是 C 语言被问烂的经典题。看起来很基础却是理解值传递和地址传递的钥匙。看这段代码void swap(int a, int b) { int tmp a; a b; b tmp; }调用swap(x, y)之后x 和 y 并没有交换。为什么因为 C 语言的函数参数传递是值传递a和b是 x 和 y 的副本你在副本上怎么折腾都影响不到原件。这就像你把身份证复印件交给办事员他在复印件上改名字原件不会变。要修改外部变量就必须把变量的地址传进去void swap(int *a, int *b) { int tmp *a; *a *b; *b tmp; } // 调用 swap(x, y);a和b是地址的副本但地址指向的内存是同一个。通过*a解引用你操作的是 x 本身。注意这里有个细节tmp是普通 int不是指针因为我们要交换的是值本身。这个例子引出了一个非常重要的认知指针参数不是为了炫技而是为了建立函数与调用者之间的共享通道。你需要修改外部的值时就把地址传进去。3.2 引用传递C 对指针参数的一次包装C 引入了引用swap可以写成这样void swap(int a, int b) { int tmp a; a b; b tmp; }引用本质上是变量的别名编译器在底层实现时通常就是指针但语法上让你直接操作变量本身。好处是省去了和*的书写麻烦坏处是容易掩盖这里会修改外部数据的事实。我见过不少同事在阅读别人代码时被一个普通参数的函数悄悄改了值搞得一头雾水。我的实践经验是如果函数要修改外部变量用引用或指针都行但务必通过命名让意图明确。比如readConfig(const std::string path, Config out)参数名叫out一眼就知道是这个函数要往外面吐数据。别学某些库传一堆指针进去鬼知道哪个是输入哪个是输出。3.3 函数指针与指针函数别再被名字绕晕这两个概念只差一个字含义天差地别。指针函数返回值是指针的函数。比如char *getName(int id);它本身是函数返回一个char*。函数指针指向函数的指针。比如int (*fp)(int, int);fp是一个指针指向一个返回 int、接收两个 int 参数的函数。函数指针的语法比较反直觉尤其是那对括号。int (*fp)(int, int)中fp先跟*结合是个指针然后指向一个函数类型。如果没有括号写成int *fp(int, int)那就是指针函数了。函数指针最大的价值是实现回调。典型场景排序时传入比较函数。int compare_asc(const void *a, const void *b) { return (*(int *)a) - (*(int *)b); } qsort(arr, n, sizeof(int), compare_asc);qsort需要一个比较函数指针你给它不同的比较函数排序行为就完全变了——升序、降序、按绝对值排序全由你传入的指针决定。这就是把行为作为参数传递的思想。现在很多高级语言的 lambda、函数对象本质上都是在这个思路上发展的。3.4 二级指针什么时候需要指针的指针二级指针int **pp就是指向指针的指针。很多新手在学完一级指针后看到**就头大其实冷静分析一下就好。一级指针用来修改一个变量的值二级指针用来修改一个一级指针的值——也就是让一个指针变量重新指向别的地址。最常见的场景是在函数内部给形参分配内存并希望调用者能取得这个新指针。void create_buffer(char **buf, int size) { *buf (char *)malloc(size); } int main() { char *p NULL; create_buffer(p, 100); // 现在 p 指向了分配的内存 free(p); }如果不传二级指针只传char *buf函数内部分配的内存地址会丢失——因为buf是外部p的副本给副本赋值不影响外部的p。这正是 3.1 节讲的同一件事只是被修改的对象从普通变量换成了指针变量。想通这一点二级指针就没有任何神秘色彩。另一个经典场景是链表中删除节点。如果你要在遍历链表时改动头指针本身就需要二级指针或引用。我用二级指针时有个习惯在纸上画出谁指向谁的链条标清楚每一步修改的是哪个位置。图一旦画清楚代码就不会写错。4. 指针的修饰const 组合与指针的指针细节4.1 常量指针与指针常量靠偏旁记忆const和指针组合在一起产生四个组合读起来各不相同写法含义记忆技巧const int *p;p 指向的 int 值不可通过 p 修改指针可改const 修饰*pint const *p;同上同上int *const p;p 本身是常量不可指向别处指向的 int 可改const 修饰 pconst int *const p;值和指向都不可改两边都锁死记忆方法其实很简单const修饰最近的那个量。const int *p中const修饰的是int所以*p这个 int 不能被赋值int *const p中const修饰的是p所以 p 不能再指向其他地址。我在代码评审中经常发现有人把这俩搞混导致在某个大型函数里频繁修改const指针指向的变量编译器报错后一脸茫然地来问。这里有个实战建议如果函数不打算修改指针指向的数据参数尽量写成const修饰的类型比如void dump(const char *str)。这既是给调用者传递我不会乱改的信号也让编译器帮你抓错误。4.2 顶层 const 与底层 constC 里的精确说法C 中区分了顶层 consttop-level const和底层 constlow-level const。顶层 const 表示指针本身是常量底层 const 表示指针指向的对象是常量。上面的表格里int *const p是顶层 constconst int *p是底层 const。这个区分的实用价值在类型匹配上。比如拷贝一个底层 const 指针时规范要求也只能用底层 const 指针来接收否则会丢掉限制。这属于细枝末节但理解了它对读懂 STL 源码里的各种 const 组合有巨大帮助。我的建议是先记住 变量自身不可改 顶层指向内容不可改 底层后面用到了再加深。4.3 指针的指针与 const别在这个组合上翻车简单的const char **和char *const *区分也常让熟练工一时卡壳。我通常的做法是拆开读。const char **可以看作const char *的指针即*(pp)是const char*类型而pp是指向这种类型指针的指针。如果你记不清绕一点但稳妥的办法是别写那么复杂的嵌套修饰用 typedef 给中间类型起别名。typedef const char *String; String *pp; // 指向 String 的指针这几乎消除了所有嵌套 const 的阅读障碍。我在团队里大力推行复杂指针类型必起 alias不是因为我们不懂而是代码是给未来的人包括三个月后的自己读的。5. 从裸指针到智能指针C 的内存管理容器化5.1 裸指针手动管理内存的麻烦C 语言的指针本身是强大而危险的程序员得手动 malloc/free 配对。麻烦在于忘记释放造成泄漏提前释放造成悬挂指针释放后又使用造成未定义行为。项目大了之后这种三个错误选一个的游戏会把人折磨疯。我入行头几年在某个底层模块里就吃过这个亏某个回调函数里分配内存后有个异常路径提前 return忘了 free结果内存泄漏在用户态累积到一定程度整个服务无响应。排查了整整一天最后还是靠代码走查找到的。这种事一次就够之后我彻底转向让 RAII 来管生命周期的思路。5.2 智能指针三兄弟unique_ptr、shared_ptr、weak_ptrC11 之后标准库把智能指针扶正了从裸指针到智能指针的转变核心思维是资源随对象生随对象灭。对象析构时它的析构函数会自动释放资源你不再需要手动编织 malloc 和 free 的配对网络。std::unique_ptr独占所有权。同一时刻只能有一个指针拥有资源不能拷贝但可以移动。我写局部资源、类成员时最喜欢用这个它几乎没有额外开销。std::shared_ptr共享所有权。内部维护一个引用计数最后一个指向资源的智能指针销毁时计数归零资源释放。适合多个模块需要共同拥有同一对象的场景。std::weak_ptr不参与计数的观察者。它指向 shared_ptr 管理的对象但不增加引用计数用来解决 shared_ptr 之间的循环引用问题。一个很常见的循环引用陷阱类 A 持有指向 B 的 shared_ptr类 B 也持有指向 A 的 shared_ptr互相引用导致两个对象永远释放不了。解决办法是让其中一方持有 weak_ptr。我后来养成了一个习惯谁的资源依赖谁谁就用 weak_ptr。父子关系里父指向子的常常是 shared_ptr子指向父的用 weak_ptr这样不会成环。5.3 自己动手实现一个 mini 智能指针理解智能指针的底层原理用起来才不至于心虚。我做培训时常让学员实现一个简化版SharedPtr。核心就两个东西裸指针 引用计数指针计数放在堆上所有 shared_ptr 共享。template typename T class SharedPtr { public: explicit SharedPtr(T *ptr nullptr) : ptr_(ptr), count_(new size_t(ptr ? 1 : 0)) {} SharedPtr(const SharedPtr other) : ptr_(other.ptr_), count_(other.count_) { if (ptr_) (*count_); } SharedPtr operator(const SharedPtr other) { if (this ! other) { release(); ptr_ other.ptr_; count_ other.count_; if (ptr_) (*count_); } return *this; } ~SharedPtr() { release(); } T *operator-() const { return ptr_; } T operator*() const { return *ptr_; } private: void release() { if (ptr_ --(*count_) 0) { delete ptr_; delete count_; } } T *ptr_; size_t *count_; };实现完这个你再去读标准库的智能指针源码会发现思路完全一致只是多了线程安全、自定义删除器、别名构造这些工程化细节。我强烈建议每个 C 学习者都做一次这件事写一个能用的智能指针哪怕不成熟。这个过程比看十篇文章都管用。5.4 智能指针不是万能药circle 引用与线程安全智能指针解决了资源释放的问题但不解决所有问题。shared_ptr的引用计数操作在多线程环境下保证原子性但指向的对象本身是否线程安全由你自己的代码决定。这不是智能指针的责任范围。还有就是循环引用问题前面已经提过 weak_ptr 的办法。我再补充一个更简单的实战规避思路尽量用 unique_ptr除非确实需要共享所有权。unique_ptr 语义清晰、开销最小、几乎不涉及循环引用问题shared_ptr 只有在确认多个所有权时才用。很多项目里滥用 shared_ptr 造成性能损耗和设计腐坏根源往往是建模时没有想清楚谁真正拥有资源。6. 指针的实战博弈双指针法与常见错误排查6.1 双指针法不只面试题还是性能利器双指针法在热搜词里出现它并不是 C/C 特有概念而是算法中的常见套路指的是用两个指针从不同方向或不同速度遍历数据。常用的场景包括有序数组去重、两数之和、链表找环、翻转数组等。我们拿有序数组去重举例。要求原地去重返回新长度额外空间 O(1)。如果没思路的人会开一个新数组再拷回去但双指针一行都不用开int removeDuplicates(int *nums, int numsSize) { if (numsSize 0) return 0; int slow 0; for (int fast 1; fast numsSize; fast) { if (nums[fast] ! nums[slow]) { slow; nums[slow] nums[fast]; } } return slow 1; }慢指针指向已处理好的尾部快指针在前面探路。快指针发现新元素就把它搬到慢指针后面一个位置。跑完之后前slow1个位置就是无重复的有序数组。这种方式不仅省内存而且每个元素只扫描一次时间复杂度 O(n)。双指针的另一个经典场景是链表找环。快指针每次走两步慢指针每次走一步如果链表有环它们一定会在环内相遇。这个结论可以推广到很多追踪类问题。我给新手一个建议看到有序数组 原地操作 双指针控制边界这种描述先把双指针方案列进候选再根据具体条件筛选。6.2 空指针异常从timer执行查询报空指针说起热搜词里有一条timer执行查询是报空指针这类的报错信息在 Java 和 C/C 里都经常出现。拿 C 语言举例解引用 NULL 几乎立刻崩溃拿 C 举例解引用空指针会触发未定义行为而 Java 会抛 NullPointerException。为什么 timer 执行查询时会报空指针我见过的最常见原因是异步路径中你需要访问的对象的生命周期已经结束了。比如一个定时器回调回调触发时原本应该存在的资源已经被释放或者被置空。经典的错误写法是Timer t; SomeObject *obj new SomeObject(); t.start(1000, []() { obj-query(); // 糟糕lambda 捕获了裸指针 }); delete obj;当定时器回调执行时obj已经被 delete 了回调里的obj-query()就是访问悬挂指针行为未定义表现可能是崩溃、空指针或者其他诡异错误。对策分两层。第一层是生命周期管理回调里应该捕获std::shared_ptr而不是裸指针让资源存活的时长由所有的使用者共同决定。第二层是防御式判空在解引用前检查指针if (obj nullptr) { // 记录日志提前返回 return; } obj-query();我在写生产代码时认可的规则是外部传入的指针先假定可能是空按需判空自己创建的资源保证生命周期比所有使用者长。这两条结合空指针异常能减少八成。6.3 指针引发的内存错误用什么工具能救你指针用得好是利器用不好是凶器。遇到段错误、莫名变量值被改别靠眼看靠工具。在 Linux 上我基本都靠 AddressSanitizerASan编译时加个-fsanitizeaddress就能编译出检测内存越界、释放后使用等问题的版本。比传统的 Valgrind 快很多适合在 CI 里跑。如果本地是 WindowsVisual Studio 自带 /fsanitizeaddress 选项也能用。先用 ASan 跑测试基本能定位到哪一行越界/释放后使用再根据调用栈去分析逻辑漏洞。有一个我多次遇到的诡异场景一个函数里数组越界写导致另一个毫不想干的变量被改。这种情况下打印日志完全没用因为你看不见内存被踩。用 ASan 跑一次它会在越界发生那一瞬间报告直接把根因揪出来。我认为这是现代 C/C 工程最值得养成的性能习惯之一每次提交前用 ASan 跑一遍单测花的时间很少但能挡住大量深水炸弹。6.4 指针跳跃与指针赋值别被魔幻操作带偏指针跳跃这个词如果指单纯的指针加减我在前文已经讲过了。如果是指某些技巧性写法我的态度是能读懂就放心用不能读懂就老老实实写成易懂的版本。比如在嵌入式开发里往固定地址写寄存器值#define REG_BASE (0x40000000) *(volatile uint32_t *)REG_BASE 0x01;这行代码做了三件事把整数0x40000000强转为volatile uint32_t*解引用它然后写入。volatile告诉编译器不要优化掉这次写操作因为地址指向的是外设寄存器。这类代码是底层开发必要的魔法但你最好写注释说明意图。指针赋值的坑则更隐蔽。比如int *a ...; int *b a;b a让 b 和 a 指向同一块内存。这本身没问题问题在于很多人默认两个指针指向同一块内存 它们彼此独立于是其中一个释放了内存另一个继续用。这就是著名的悬垂指针共享问题。解决思路还是回到所有权明确谁负责释放不要让两个指针都觉得自己说了算。在 C 里交给智能指针在 C 里就靠注释、命名和代码评审来约束。7. 写在最后的实战体会指针这个东西我在面试别人时喜欢让候选人解释一句传一个指针给函数函数里改指针指向的内容调用者能看到吗看起来基础但往往能筛掉一大半背了答案不懂原理的人。原因就在于指针牵扯的是一整套内存视角的思维方式变量是一块内存地址是门牌号指针是存放门牌号的变量解引用是敲门进屋。把这条链路在脑子里转熟了后面所有高级话题——结构体指针、链表、树、回调、智能指针——都是在给这条主链配不同场景的配件。我自己的学习路径是先把和*的语义在纸上画了无数遍再亲手实现链表和二叉树的增删查改然后试着写一个内存池最后做一遍 mini 智能指针。每走一步指针在脑子里就实体化一分。如果你正卡在指针上我不建议继续堆刷题而是停下手头的题拿出调试器单步跟踪几个带指针的小程序亲眼看看地址和值怎么变化。眼见为实之后指针就再也不是玄学了。