
很多学 C 语言的朋友学到指针这一章就卡住了。指针这个东西说难确实难但一旦想通它到底在干嘛后面的链表、树、回调函数、各种高级数据结构的代码都会变得顺理成章。这篇文章我就按我自己当初搞懂指针的过程来写从内存地址开始一层层拆开讲。不管你是刚学完 if/else 的新手还是被题逼到头秃的刷题党看完之后再去写代码至少不会怕指针了。我决定把指针讲成一个“找门牌号”的故事因为本质上就是这么回事。你写的每个变量都住在内存里内存里的每个位置都有地址指针就是用来存地址的变量。搞明白这一点剩下的无非是各种变化存谁的地址、怎么存取、多存几层。接下来我们把这套东西掰开揉碎过一遍。1. 指针的本质地址、内存与变量之间的关系1.1 变量只是给内存地址起的外号我特别记得第一次看int a 10;这行代码时的困惑a 到底存在哪其实你可以把内存想象成一栋巨大的公寓楼每个房间都有一个编号比如0x7ffee2b3b5ac这个编号就是地址。int a 10;相当于在某个房间住了个住户住户名字叫 a房间里有张写好的纸纸上写着 10。当你写int *p a;时你做的就是把 a 的房间门牌号记在了另一张纸条上。也就是说p的类型是“指向 int 的指针”p 的值是 a 的地址而*p则代表“顺着 p 里存的地址去找那个房间里的内容”。在这里*p就等价于a也就是 10。为什么要多此一举用地址去访问变量因为 C 语言里函数传参是按值传递的。你在函数里修改形参外面不会有一点点变化这个时候就需要把地址传给函数让函数顺着地址找到原始房间去改内容。除此之外变量名在编译期间基本就不存在了程序运行时访问变量靠的就是地址。你可以把变量名理解为给人看的标签而地址才是机器真正认的东西。指针就是让我们也能以机器的方式去操作数据。1.2 指针大小相同但“步长”完全不同新手最容易忽略的一点一个 int 类型的指针int *p和一个 double 类型的指针double *p2它们本身占的内存大小通常在同一个平台上是一样的比如 64 位系统下是 8 字节但它们指向的目标大小完全不同。看似都是“地址”为什么类型这么重要这里要引出“步长”的概念。p 1并不是在地址上加 1而是加上sizeof(int)个字节。如果 int 是 4 字节那p 1的地址就比p大 4p2 1呢就大 8假设 sizeof(double) 是 8。这个步长规则决定了指针在数组里移动时能准确走到下一个元素。这个特性直接体现在下标运算上arr[i]其实就是*(arr i)的语法糖。我以前一直以为下标是数组自带的魔法其实它就是个指针移动加解引用的缩写。理解了这一点就能想通很多奇怪现象比如3[arr]这种写法在 C 语言里居然能编译通过因为3[arr]等于*(3 arr)而加法是满足交换律的。虽然没人会正常这么写但它能帮助你彻底理解数组和指针的关系。2. 指针的基本操作声明、赋值、解引用2.1 星号在不同位置有完全不同的含义C 语言的指针声明写起来让很多人头疼因为*这个符号在声明、取值、乘法三个场景里来回切换。我建议你记住一条原则声明语句里的*只是在告诉编译器“我声明的是一个指针类型”而表达式里的*才是解引用操作。看这段最经典的代码#include stdio.h int main() { int a 10; int *p a; // a 表示取 a 的地址p 是 int 指针 printf(a %d\n, a); printf(a %p\n, (void *)a); printf(p %p\n, (void *)p); // p 的值就是 a printf(*p %d\n, *p); // 解引用得到 10 *p 20; // 等价于 a 20; printf(a %d\n, a); // 输出 20 return 0; }这里面最容易写错的就是int* p还是int *p的问题。编译器其实都接受但我个人建议写int *p因为语法上*是和变量名结合的而不是和类型。不信你看int* p, q;这种写法你以为 p 和 q 都是指针实际上只有 p 是指针q 是普普通通的 int。初学者在同一个声明里混合指针和普通变量时很容易踩这个坑。取地址和解引用*是一对相反的操作把变量变成地址*把地址变回变量。只要记住这个对偶关系大部分基础代码就不会糊涂。2.2 空指针、野指针和悬空指针必须分清指针最坑的地方不是它多难理解而是出错时机很隐蔽。我见过很多同学代码检查了半天最后发现是某个指针没初始化。这里有三类指针必须特别警惕。第一类是空指针也就是值为 NULL 的指针。标准做法是当下没有合法地址可存时就让指针指向 NULL。在绝大多数主流实现里空指针的值是 0也就是不访问任何有效内存。解引用空指针通常是段错误Segmentation Fault虽然程序崩了但它至少给了你明确的信号。所以写完指针最好立刻判空if (p ! NULL) { ... }。第二类是野指针也叫未初始化指针。声明了一个int *p;但没赋值就直接*p 10;这时的 p 里存着一个随机的地址解引用它就是往一块不属于你的内存里乱写。轻则读到垃圾数据重则直接崩溃。这不是编译器会帮你兜底的事C 语言的规矩就是“你不对内存负责那后果就你来扛”。第三类是悬空指针dangling pointer指的是指针曾经指向一块合法的内存但内存已经被释放了指针还残留着之前的地址。最典型的场景就是函数返回了局部变量的地址int *bad(void) { int x 100; return x; // 大错特错x 是局部变量函数返回后就没了 }这种代码运行时有时候碰巧能打印出 100有时候打印出垃圾值因为那块栈内存已经被别的东西覆盖了。这类 bug 最磨人推荐用-Wall编译选项加上调试器检查或者干脆从设计上避免返回局部变量地址。提示每次 free 掉一块内存之后顺手把指针置为 NULL是防止悬空指针最简单有效的习惯。虽然不能解决所有问题但能把很多“幽灵式” bug 变成立刻崩溃的错误调试难度直接降一个等级。3. 指针与数组最紧密也最容易混的一对3.1 数组名到底是不是指针很多教程会说“数组名就是指针”这句话严格来讲有毛病。数组名的确代表数组首元素的地址在绝大多数表达式中它会被隐式转换为指针但它不是一个指针变量。数组名是常量性质的值不能对数组名做arr这种赋值操作。看这个例子#include stdio.h int main() { int arr[5] {1, 2, 3, 4, 5}; printf(arr %p\n, (void *)arr); printf(arr[0] %p\n, (void *)arr[0]); printf(arr %p\n, (void *)arr); printf(sizeof(arr) %zu\n, sizeof(arr)); // 5 * 4 20 printf(arr 1 %p\n, (void *)(arr 1)); printf(arr 1 %p\n, (void *)(arr 1)); return 0; }同样的地址值arr和arr在打印时看起来一样但它们类型完全不同加 1 后的结果也完全不同。arr 1跳过一个元素4 字节而arr 1跳过整个数组20 字节。因为arr的类型是int *而arr的类型是int (*)[5]也就是指向含有 5 个 int 的数组的指针。sizeof(arr)也很有代表性如果数组名“就是指针”那 sizeof(arr) 应该是指针大小也就是 8但实际输出是 20。这说明数组名保留了它“整个数组”的身份信息只有在表达式求值时才退化成指针。这种语言细节如果你在刷题或者做嵌入式开发时频繁使用指针算术一定要做到心中有数。3.2 指针数组和数组指针阅读理解式区分这两组词看起来像绕口令但只要抓住“谁是谁的修饰”就能分清。int *p[5]因为有[]的优先级高于*所以它先是一个数组数组里有 5 个元素每个元素都是int *这叫指针数组。而int (*p)[5]先是一个指针指向上面的那种数组这叫数组指针。指针数组最典型的应用是存放字符串。比如const char *weekdays[] {Monday, Tuesday, Wednesday};这里weekdays是一个数组数组里的每个元素都是指向字符串字面量的指针。写这种代码时你不需要关心每个字符串长度只需要知道第 i 个元素的首地址。反过来数组指针更多用于处理二维数组让你能以“指向一整行”的方式去访问数据。二维数组本身是连续存放的int a[3][4]在内存里就是 12 个 int 依次排列。a[1]这种写法取到的并不是“一行”的内存而是那一行的首地址。如果你想用一个指针变量去遍历二维数组可以用int (*p)[4]这样 p 指向的是“每行 4 个 int”的结构p1 刚好跳一行。这种带括号的指针声明最好配合 typedef 使用不然代码读起来真的像解密游戏。说到字符串我就想起一个几乎所有教程都会有的练习字符串逆序。很多人写这个题最容易翻车的地方不是算法而是试图去修改字符串字面量。char *s hello; s[0] H;这段代码在有些编译器上能过但运行时会崩溃或者悄悄无效因为字符串字面量通常存放在只读区。正确做法是char s[] hello;把它看成字符数组然后才能放心就地逆序。4. 指针与函数参数传递、函数指针与回调4.1 为什么 swap 必须传指针几乎所有 C 语言教材都会拿交换两个数来讲“地址传递”。见过这段吧#include stdio.h void swap(int *a, int *b) { int tmp *a; *a *b; *b tmp; } int main() { int x 3, y 5; swap(x, y); printf(x%d, y%d\n, x, y); // 输出 x5, y3 return 0; }如果写成void swap(int a, int b)函数内部确实交换了但交换的是形参的副本外面 x 和 y 根本不受影响。C 语言里只有值传递所谓“地址传递”的本质也是值传递只是传的这个值恰好是地址。这个区分非常关键因为后面学多级指针时你会遇到“要修改指针变量本身”的场景那时候同样的问题又会问一遍想改变一个指针变量的值应该传什么答案是传指针的地址也就是二级指针。这里顺便提一下 C 的引用很多人在网上搜“c 引用 指针 和值传递”就是想搞清楚这三者的关系。引用本质上是给已有变量起一个别名函数里操作引用就等于操作原变量不像指针还需要显式解引用。但 C 语言没有引用只能传指针。不过反过来想C 的指针更透明所有的间接访问都是显式的遇到问题也更容易定位。相反还有一个容易被轻视的问题scanf 为什么非要用n也是因为 scanf 需要把外部变量的地址传进去才能把读到的数据写回你的变量。很多人写scanf(%d, n);导致 n 没有变化就是因为漏了取地址符。如果你的代码里 n 是个数组名那么scanf(%s, buf)不需要加因为数组名本来就退化成首元素地址了。搞懂指针不仅帮助你理解函数传参还能帮你避开 scanf 使用中的各种坑。4.2 函数指针把函数当作数据来传递函数指针是新手最容易自乱阵脚的一块。其实思路很简单函数本身也住在内存里自然也有地址。函数指针就是一个指向函数的指针声明形式长这样int (*fp)(int, int);。这表示fp是一个指针指向一个接收两个 int、返回 int 的函数。可以这样用#include stdio.h int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int main() { int (*fp)(int, int) add; // 函数名就是函数地址 printf(%d\n, fp(5, 3)); // 调用 add fp sub; printf(%d\n, fp(5, 3)); // 调用 sub return 0; }函数指针最常见的价值在于做回调函数和分发表。举个例子你要写一个通用的排序函数具体比较规则由调用方决定这时候就可以把比较函数指针作为参数传进去。标准库的qsort正是这么设计的它的最后一个参数是int (*compar)(const void *, const void *)。更进一步把多个函数指针放进一个数组就是函数指针数组。这个技术常用于实现命令分发比如一个简单的计算器#include stdio.h 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 divide(int a, int b) { return b ? a / b : 0; } int main() { int (*ops[4])(int, int) {add, sub, mul, divide}; int choice 2; // 比如用户输入 2 表示乘法 printf(res %d\n, ops[choice](6, 3)); // 输出 18 return 0; }这段代码比写一长串 switch 清爽得多以后加新运算只需要在数组里多放一个函数名。这个套路在嵌入式命令解析、GUI 回调、状态机跳转里都很常见。顺便提醒一下函数指针还有个孪生兄弟叫“指针函数”它指的是“返回值为指针的函数”比如int *getArray();。两者的区别同样看括号层级int (*fp)(void)是函数指针int *fp(void)是返回指针的函数。5. 指针的指针与动态内存管理5.1 二级指针到底哪种情况必须用讲完“想改 int 用 int *”那“想改 int *”怎么办答案就是“用 int **”。二级指针平常在代码里出现的场合不算多但一旦出现基本都是在函数内部要修改调用方的指针变量本身。最经典的例子是链表头插。你定义了一个链表头指针head想写一个函数insert_head把新节点插到头部同时让 head 指向新节点。如果函数参数只写Node *head你在函数内部改了 head 的值外面的 head 依然指向原来的节点。所以要么函数返回新的头指针调用时重新赋值要么就传Node **head在函数内部通过*head newNode;修改外面的头指针。代码大概是这样的typedef struct Node { int data; struct Node *next; } Node; void insert_head(Node **head, int data) { Node *newNode (Node *)malloc(sizeof(Node)); if (newNode NULL) { return; } newNode-data data; newNode-next *head; *head newNode; // 把新节点设为头同时修改外部 head 的值 }二级指针的本质是“存着一个指针变量的地址”所以对**pp的操作就等于“通过 p 这个指针去操作它背后那块内存”。画图理解是最快的先画一个小方框叫 a再画一个箭头指向它的方框叫 p再画一个箭头从 pp 指向 p那么*pp是 p 本身**pp是 a。只要把箭头图弄明白多级指针无非是箭头的叠加。5.2 malloc 和 free 的注意事项以及智能指针的对比动态内存分配在 C 语言里离不开malloc、calloc、realloc、free这四个函数。malloc负责在堆上申请一块指定大小的内存返回这块内存的起始地址。很多人写int *p malloc(sizeof(int) * 10);时忘记判断返回值是否为 NULL一旦堆内存不够后续解引用就是段错误。严格起见申请后都要检查int *arr (int *)malloc(10 * sizeof(int)); if (arr NULL) { fprintf(stderr, malloc failed\n); return; }这里有两个高频坑。第一个是sizeof没写对手动写malloc(40)在 int 为 4 字节的机器上没问题但一旦换平台就崩了所以必须用sizeof写尺寸。第二个是忘记释放内存导致内存泄漏。C 语言没有自动垃圾回收你malloc一次就要free一次。而且free只能对从 malloc/calloc/realloc 返回的指针调用不能对局部数组名调用重复 free 同一个指针也是未定义行为。现在很多人在 C 里就用智能指针来规避这些问题。std::unique_ptr和std::shared_ptr能在对象离开作用域时自动释放堆内存相当于给指针加了一件自动回收的雨衣。原理听着高级本质还是 RAII资源获取即初始化构造函数里拿到资源析构函数里释放资源。这跟 C 语言手动 malloc/free 做的事情一样只是 C 把释放时机关进了生命周期的管理里。如果你先学 C 再接触 C看智能指针会理解得特别快因为你知道它们到底在帮你省哪个步骤。6. 常见问题与排查技巧实录6.1 遇到段错误怎么定位段错误Segmentation Fault是 C 语言程序最经典也最吓人的崩溃方式。其实它并不神秘多半就是访问了不属于你的内存。常见诱因包括解引用空指针、野指针、数组越界、free 之后继续访问、写入了只读内存。我看到这类问题一般直接用调试器定位。GDB 用法很简单编译时加-g选项然后运行gdb ./a.out程序崩溃后输入btbacktrace看调用栈再用frame切到对应的函数看一下出错的代码行。比如你在 GDB 里看到#0 0x0000555555555152 in main ()回到源码去看十有八九就是某个指针瞎解引用了。没有 GDB 的场合可以用打印日志法在关键位置加printf(here point 1\n);看输出停在哪一行然后重点检查那一段的指针逻辑。如果不想依赖调试器就要养成写检查代码的习惯用函数前先if (p NULL) return -1;在数组循环里务必检查边界。数组越界是最隐蔽的因为编译器不会报错数组名退化成指针后p[i]只是把 i 当成了偏移量i 超出范围时你仍然在“尝试”访问那块地址有时没崩只是运气好越过了其他进程或者映射页的具体表现不同而已。6.2 指针调试技巧与避坑清单个人经验里最有用的一个调试技巧是“把地址打出来看”。用printf(%p\n, (void *)p);打印指针的值能直观看出这个指针是否指向预期区域。如果打印出来是0x0说明是空指针如果是很怪异的地址比如0x1很有可能是对 NULL 做了偏移或者某个整数被强制赋值给了指针。%p配合(void *)转换是为了避免编译器警告这个细节也别忽略。下面这张表是我整理的避坑清单每次遇到指针相关 bug 我都会对照走一遍。现象常见原因排查方向一解引用就段错误空指针或野指针加判空检查指针是否初始化变量值在函数内正常出函数就没变忘了传地址形参是值传递确认函数参数是否写成了*打印字符串乱码字符指针未指向合法字符串或字符串被修改检查字符串初始化、\0结尾free 后程序崩溃悬空指针、重复 freefree 后置 NULL确保一次分配对应一次释放数组越界但没崩越界恰好落在可写区域加固边界检查不要依赖运行结果的好坏指针替换后原数据变了多个指针指向同一块内存明确所有权谁分配谁维护谁释放常见问题里还有一个很容易忽略的文件操作。FILE *fp fopen(data.txt, r);这个 fp 本质也是指针。它不能拿来随意解引用或算术运算但必须判空、必须 fclose。很多人想到“指针”时只盯着内存忘记文件指针和二级指针都是指针家族的一员。双指针法Two Pointers也在很多题库里出现。配合指针算术这套方法能高效解决数组去重、链表找环、有序数组求和等问题。什么意思呢就是维护两个不同速度或不同出发点的指针往中间走比如一个指向头一个指向尾根据当前和的大小决定移动哪一个。这里要特别注意边界写法while (left right)和while (left right)差之毫厘、失之千里多测几个边界样例你就深有体会了。最后再分享一个我踩过很多次坑之后得出的教训画图。看一个复杂指针表达式如果脑子转不过来就在纸上画方框、画箭头。比如int (*p[3])(int)这种声明先找 p发现它先和[]结合成数组再和*结合表示数组里存的是指针最后是函数签名。画出来你就再也不怕阅读这类声明了。指针题看起来千变万化核心其实就是“地址 步长 内存所有权”这三个维度。把这三点揉进骨子里C 语言指针就不再是你的拦路虎而是你写底层逻辑时最顺手的一把工具。