
如果让我从C语言里挑出一个最容易让新手当场放弃的知识点指针排第二没人敢排第一。很多同学上课听到“指针就是存放地址的变量”时觉得好像也没什么可一旦开始写*p、a、p这些东西马上头大。更扎心的是指针在后面的数组、字符串、函数传参、文件操作里无处不在绕是绕不开的。这篇文章我就按我这些年带新人的实际经验把指针从零开始拆开讲一遍既有比喻也有可抄的代码争取让零基础的同学看完能自己写出不崩的指针程序也让已经学过但总在细节上翻车的人有一次系统性的查漏补缺。1. 指针卡住初学者的真正原因它不是难点而是一个“门牌号”1.1 不要把指针理解成“一个神秘东西”把它理解成“门牌号”我见过太多教程把指针渲染成一种“高级能力”好像学会了指针就掌握了C语言的灵魂。听起来很玄实际上指针就是计算机里一个特别朴素的东西——它保存了一个内存地址而内存地址就是个数字像门牌号一样。你可以这样理解整个内存就是一栋巨大的公寓楼每个房间都有一个门牌号。普通变量住在某个房间里比如int a 10意思是你在某个房间放了数字10房间门牌号可能是0x7ffd1234abcd。指针变量干的事情就是把门牌号记下来int *p a就是“我拿个小本子记下了10住在哪个房间”。为什么需要这个小本子因为C语言的函数调用默认是“把值抄一份递过去”你想在函数里改外边的变量就得把门牌号告诉它让它按门牌号去房间改。再加上数组是连续排列的一排房间拿着起始门牌号就可以推算出后面每一个房间在哪——这就构成了C语言里几乎所有高效操作的基础。所以指针不神秘它就是一个用来装“地址数字”的变量。越早用这个朴素的角度去理解它后面越少受折磨。1.2 解引用就是按门牌号进屋取货有了门牌号你总得用它干点什么这就引出*p这个符号。*在C语言里是个多面手声明的时候它表示“这是一个指针”使用的时候放在指针变量前面意思是“根据这个地址去那个房间里把东西取出来或者放进去”。这个动作叫“解引用”通俗说就是按门牌号进屋。#include stdio.h int main(void) { int a 42; int *p a; // p 记下 a 的门牌号 printf(a 的地址: %p\n, (void*)a); printf(p 的值(也就是门牌号): %p\n, (void*)p); printf(通过 *p 进屋看到的值: %d\n, *p); *p 100; // 按门牌号进屋把房间里的值改成 100 printf(a 现在的值: %d\n, a); // 100 return 0; }这段代码我建议每一个初学指针的人第一件事就是亲手敲一遍、编译一遍、跑一遍。你会发现最关键的一个现象p的值和a一模一样而*p和a在功能上完全等价。读的时候等价写的时候也等价——*p 100会真的把a改成100因为它们指向同一个房间。1.3 指针本身也是变量所以它也有地址再往下走一步就容易产生第一个思维卡点指针既然也是变量那它自己是不是也住在某个房间里答案是肯定的。所以你写int **pp p完全合法——pp 指向 p 这间“记着门牌号的房间”。很多人一看到int **就晕其实只要拆开看int*是一个指向int的指针类型int**就是“指向int*的指针”类型。用门牌号的语言说pp的小本子上记着“p 这间房间在哪里”而 p 这间房间里面放着 a 的门牌号。想通过 pp 拿到 a 的值就要连续进屋两次**pp。这个点先不用深究你只需要记住一个结论指针变量的本质是变量是变量就有地址有地址就能被另一个指针指着。这为后面的二级指针、链表等都铺了路。2. 从“看得懂”到“写得对”指针四种基本操作的代码规范2.1 建立肌肉记忆指针只有四种核心操作我把指针的入门操作压缩成四个词带过很多新人都觉得够用定义、取地址、解引用、赋值。定义int *p;声明 p 是“指向 int 的指针变量”。取地址p a;把变量 a 的门牌号装进 p。解引用*p按 p 里的地址访问对应房间。赋值*p 99;把99放进那个房间或者p b;让 p 改去记住另一个门牌号。这四个动作只要在编辑器里反复敲上十遍肌肉记忆自然就形成了。很多题目做错不是“指针”哪里高深而是这四个动作的排列组合没练熟。我见过一个同学考试时把p a写成*p a编译报错才意识到自己一直在往房间里塞“地址数字”而不是改门牌号——这类错误就是基本功不牢的表现。2.2 声明指针最怕类型写错类型决定“进屋后看到什么东西”int *p和char *pc的区别是什么不是“都存地址吗”这么简单。类型的意义在于当你写*p时编译器知道应该按多少个字节去读取、按什么格式去解释这块内存。同一块内存用 int 指针读和用 char 指针读结果完全可能不一样。比如地址0x1000这块内存里存着4个字节01 00 00 00用int*读出来是1假设小端用char*读出来就是1只取第一个字节但用double*读就完全错乱了。所以指针类型不是摆设它决定了你解引用时“按什么尺寸进屋、进屋看到的东西怎么理解”。这就牵扯到一个键盘上容易敲错的点int* p, q;这一行你以为 p 和 q 都是指针其实只有 p 是指针q 是普通 int。C语言的原罪之一就是声明语法容易产生误导*只作用于紧跟它的那个变量名。想声明两个指针必须写int *p, *q;。这种细节就是笔试常客也是实际项目里容易埋雷的地方。2.3 初始化避免未知不要用没有明确指向的指针新手写指针最容易犯的第一个错就是定义了指针但不让它指向任何地方然后直接解引用int *p; // p 里装着一个“不知道是多少”的地址 *p 100; // 往未知门牌号里放东西 —— 轻则崩溃重则悄悄改坏别的数据这种没有明确指向的指针叫“野指针”。它比空指针更危险因为空指针至少你能判断出来而野指针可能指向某个合法地址你往里写数据时程序不报错但是另一个变量被改成了垃圾值找半天找不到原因。我的习惯是定义指针时如果暂时不知道让它指向谁就直接赋NULL这样至少“确定它是空的”后面任何解引用之前都检查一下if (p ! NULL)。业务上需要指向谁再重新赋值。宁可空着不可野着。2.4 两个易混淆的const指向的对象不能变还是指针自己不能变很多C语言教材会把 const 和指针的组合放在比较靠后的位置但网上常看到一个搜索词叫“顶层指针和底层指针可以相互赋值吗”可见大家在这里卡得厉害。C语言里其实没有“顶层/底层”这种说法这是C那边的提法但表达的困惑是相通的const int *p; // p 指向一个 const int不能通过 *p 修改值但 p 本身可以改 int *const q; // q 是一个 const 指针q 不能改指向但 *q 可以改值用门牌号的语言解释第一种是“你进了一个房间房间里的东西不让动”第二种是“你的小本子上写了死门牌号不能改但到了房间想怎么收拾随便你”。这两个能不能相互赋值C/C 的规则比较绕入门阶段你只需要记住一条实用保护原则带着const修饰的地址数据不要随便丢给不带 const 的指针否则等于绕过了保护。编译器在这种时候报错是在保护你不是在刁难你。3. 灰色地带最扎人数组名、指针数组、数组指针和字符串3.1 数组名直接赋值给指针可以但数组名不是指针变量“数组名就是指针”这句话在初学圈流传极广也错得极隐蔽。准确的说法是在大部分表达式中数组名会“退化”成指向首元素的指针。比如int arr[5] {11, 22, 33, 44, 55}; int *p arr; // 合法arr 退化为 arr[0]但数组名本身不是变量你不能让它指向别处arr; // 错误!数组名不是可修改的变量它更像是一个“固定门牌号”虽然能当作地址用但不能被重新赋值。这种细节怎么记用sizeof和来验证最直观sizeof(arr)返回整个数组占用的字节数比如5 * sizeof(int)sizeof(p)返回指针本身占用的字节数64位机器上通常是8。如果一个东西真的“只是指针”这两个结果不会差这么多。所以下次再看到“数组名就是指针”请你精确地理解成数组名在多数场景下可以被当作首元素地址来用但它不是一个变量。3.2 指针算术p1到底跳了几个字节指针是可以做加减法的但这里的“1”不是地址数字直接加1而是跳过一个“指针所指类型”的长度。如果p是int*p 1在地址上实际上是p 4假设 int 占4字节。如果p是char*p 1就真的只是地址加1。不明白这一点的人写数组遍历时会写出各种诡异结果。比如用一个int*去顺次访问char数组每跳一次就越过4个字节拿到的数据全是错位的。反过来用char*去遍历 int 数组每次只走1个字节同样会把整数拆成碎片。正确的配合方式是指针的类型要和被访问的数据类型匹配。访问 int 数组就用int*访问 char 数组就用char*。下标arr[i]本质上就是*(arr i)这是一个非常优美的等价关系理解了它你就同时理解了数组、指针遍历、下标访问这三件事。3.3 字符串的本质字符串常量和字符数组的差异C语言里没有真正的字符串类型字符串要靠字符数组或者字符指针来模拟。两种写法看着很像底层差别却很大char s1[] hello; // 字符数组内容存在栈上可以修改 char *s2 hello; // 字符串常量存在只读区不能通过 s2 修改很多人在字符串逆序、字符串拼接等练习题上翻车就是因为用了第二种写法之后想修改内容程序直接崩。比如网上常见的字符串逆序题如果你写成char *str abcdef;然后原地交换字符运行时会收到段错误改成char str[] abcdef;就一切正常。记忆方法也简单s1是你自己买的一排房子随便装修s2是一块挂了“请勿动”牌子的公共区域只能看不能改。这也是为什么所有正经C语言教材都反复强调“字符串常量不可修改”。3.4 指针数组 vs 数组指针一句话记忆法这两个概念是C语言地基里的著名翻车点区分方法只需要看*和[]的优先级与结合方式写法名称本质现场怎么理解int *p[3]指针数组一个数组存了3个int*p 是一个数组元素是指针int (*p)[3]数组指针一个指针指向“包含3个int”的数组p 是一个指针指向整个数组括号是救命的。int *p[3]里[]的优先级高于*所以 p 首先是一个数组数组里装的是指针int (*p)[3]里括号把*p绑在一起所以 p 首先是指针指向一个长度为3的int数组。指针数组最常见的用途就是装多个字符串char *fruits[3] {apple, banana, orange};每个元素都是一个字符串的起始地址。而数组指针主要用于指向二维数组的“行”比如要设计一个函数接收一个3列若干行的二维数组时形参就能写成int (*a)[3]。4. 多级指针与函数指针指针不只是“指向变量”4.1 二级指针当你想修改另一个指针的指向为什么需要二级指针先说结论想在函数里修改一个指针变量的指向就得把指针变量的地址传进去也就是二级指针。这跟“想在函数里修改一个int变量的值得传它的地址”是一模一样的道理。void init_ptr(int **pp, int *target) { *pp target; // 改动的是外部 p 的指向 } int main(void) { int a 10; int *p NULL; init_ptr(p, a); // 传的是 p 的地址 printf(%d\n, *p); // 10 return 0; }如果你只传p给函数函数内部修改的只是 p 的副本外面的 p 依然是NULL。很多人第一次写链表的插入函数时被这个点折磨过——为什么我在函数里已经让head指向新节点了回到main里 head 还是空原因就是传值没传地址。搞清楚二级指针你就顺便把“参数传递的本质”彻底理解了不管什么类型直接传变量函数拿到的是副本传地址函数才能动原件。4.2 函数指针把函数当成“可传递的数据”函数也有地址因为编译后的代码就放在内存某个位置。函数指针就是指向这个位置的指针它让“把函数当数据传来传去”成为可能。int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int main(void) { int (*op)(int, int); // 声明一个函数指针指向“两个int参数、返回int”的函数 op add; printf(%d\n, op(3, 4)); // 7 op sub; printf(%d\n, op(3, 4)); // -1 return 0; }声明int (*op)(int, int)的读法是先看括号里*op表示 op 是指针再往右看(int, int)表示它指向一个带两个int参数的函数最左边的int表示函数返回值是int。函数指针最常见的用处是回调比如C标准库的qsort就需要你传入一个比较函数让它在你自定义的规则下排序。实际写代码时这种能力能让程序非常灵活一份核心逻辑通过切换不同的函数指针实现不同的行为。4.3 指针函数是“返回指针的函数”函数指针才是“保存函数地址的指针”这两个词因为长得像初学者几乎逢考必混。其实看字面指针函数本质是函数返回值是指针。比如char *get_name(void);返回一个字符串首地址。函数指针本质是指针指向函数。比如前面写的int (*op)(int, int);。区别就一个关键点*是不是被括号绑在函数名上。绑在一起是函数指针没绑就是指针函数。这个记住了面试笔试的基础选择题就不会白送分。5. 真正让程序崩掉的问题野指针、越界和非法地址的排查思路5.1 段错误与非法地址为什么有时什么都能编译通过新手最崩溃的时刻代码编译零错误零警告一运行就 “Segmentation fault”。段错误的本质就是程序访问了一个不属于自己或不允许访问的内存地址。结合指针来看无非这么几种情况对未初始化指针解引用指针里是随机地址访问非法。对NULL解引用地址0附近基本永远非法。数组越界p[i]越过了合法区域踩到了别的变量或者系统保护区。修改字符串常量前面说的只读区不可写。函数返回了局部变量地址函数结束栈空间释放返回的地址变成悬空。这种问题编译阶段不会举报因为它属于运行时错误。好消息是大多数时候它比“程序悄悄出错但结果看着正常”要好排查得多。程序崩了说明问题很明确地摆在那里。5.2 野指针、悬空指针和空指针是三种不同的灾难我把这三种情况放在一起说因为它们经常被混为一谈空指针指向NULL很好识别访问必崩但可事先判断。野指针没有初始化的指针地址随机行为不可预测最危险。悬空指针曾经指向一块合法内存但内存被释放free或超出作用域后指针还在但指向的东西已经没了。悬空指针有个典型场景函数返回局部数组地址或者用完后free(p)但没把 p 置NULL后面又继续*p ...。释放内存之后把指针置NULL是我在项目里看到的比较有效的防御习惯因为这样至少后续的解引用可以用if (p ! NULL)拦住。5.3 传值还是传址函数参数里的指针到底是干嘛的这一段可以说是指针应用的核心。写一个交换函数基本就是检验懂没懂的试金石void swap_bad(int a, int b) { int t a; a b; b t; // 改的是形参副本外部完全无感 } void swap_good(int *a, int *b) { int t *a; *a *b; *b t; // 通过地址修改外部变量 }为什么要传地址因为C语言的函数参数传递是“值传递”你传进函数的是变量的值不是变量本身。函数里对形参的任何修改都不会影响实参。只有你把变量的地址也是值但它是门牌号传进去函数才能按门牌号去修改原房间里的内容。这些道理听着简单但真到了写链表、树、栈这些数据结构时最常出问题的地方还是在“我该传结构体本身还是传结构体指针我要不要传二级指针”我的建议是每次写函数时都问自己一个问题这个函数要修改谁的内容如果修改的是调用者的局部变量那就必须传地址或指针。5.4 实用检查方法加打印、上调试器、绕不开的valgrind排查指针问题我自己的顺序是加打印定位崩溃点在关键操作前后printf一个标记比如printf(step1\n)看程序走到哪一步才崩把范围缩小。这是最朴素也最快的方法。打印指针值和关键变量值看地址是不是0x0看是不是你预期的那个地址范围。用调试器跑一下比如 gdb崩了之后用bt看调用栈能直接看到崩在哪一行、是什么函数。如果涉及动态内存用 valgrind 这类工具扫一遍它会明确告诉你哪一行做了非法读写、哪块内存泄漏了。这些工具并不高级但真的能解决绝大多数新手定位不到问题的情况。关键在于先假设自己的代码有问题然后用手段一步步缩小范围不要盯着代码干瞪眼。6. 零基础到入门这5个小实验值得亲手敲一遍6.1 实验一打印地址破除神秘感写一个程序定义几个不同类型变量打印它们的值、地址、指针的值。再用sizeof打印变量大小和指针大小。这个实验的唯一目的就是让你亲眼见到“地址是个数字、指针是个变量、类型影响解释方式”。五分钟的事情收益非常大。6.2 实验二用指针遍历数组定义一个数组分别用下标和指针两种方式遍历打印每个元素地址和值。然后试试p 1到底跳了多少字节把地址以十六进制打印出来看差距。这一步能让“指针算术”从概念变成亲眼所见的事实。6.3 实验三实现字符串逆序自己写一个函数接收一个字符数组原地逆序。注意两点只能用char[]不能用char*指向常量字符串函数里利用指针首尾交换考虑\0的位置。这个实验综合考察了数组、指针、字符串、函数传参是入门阶段性价比最高的练习之一。6.4 实验四实现自己的交换函数写两个版本一个传值一个传址各自调用后打印结果。你会从结果上彻底理解为什么传值改不了外部变量。然后可以扩展成交换两个指针本身这时你就会发现必须用二级指针——于是二级指针也通了。6.5 实验五模拟链表节点的新增用一个结构体表示节点里面存一个int数据和一个指向下一个节点的指针。尝试写一个在头部插入节点的函数先写传一级指针的版本再改成二级指针观察哪种写法能真正更新外部的头指针。做到这里你已经不是入门水平了指针的核心应用场景已经基本覆盖。最后再多说一句这些年带新人下来我的体会很多人以为自己“理解了指针”但真正写出稳定不崩的指针代码靠的是大量动手实验和对“地址”“房间”“门牌号”这种朴素模型的熟练运用。如果你在看这篇之前压强式地刷过很多题目但始终没打通那就放下题海战术先亲手把上面这五个小实验敲一遍指针这关自然就过了。