
如果你已经开始写指针了说明你已经迈过了 C 语言的第一道坎。但说实话过了基础关之后大多数人会陷入另一种状态变量名看得懂代码也跑得通可一旦面试官问起“指针数组和数组指针有什么区别”或者让你解释“为什么这里要传二级指针”就开始支支吾吾。这篇“指针2”就是为这批读者准备的。上一篇我们把指针的内存模型、取地址和解引用的基本操作讲透了这篇继续往深走指针和数组的纠缠、函数指针、多级指针、const 组合以及我踩过的那些运行时崩溃坑。适合已经学过指针基础、想在笔试面试和实际项目里少吃亏的同学。1. 指针的本质地址、类型与步长的三角关系1.1 指针变量里装的是什么一句话指针变量里装的是别人的地址。这句话看着简单藏着的细节不少。一个整数变量存的是数值比如 42一个字符变量存的是字符编码一个指针变量存的是另一个变量在内存中的门牌号。这个门牌号的位数由机器字长决定。32 位机器上指针占 4 字节能寻址 2 的 32 次方个地址也就是 4GB 内存空间64 位机器上指针占 8 字节能寻址 2 的 64 次方。这就是为什么同一个程序在 32 位和 64 位环境下编译后sizeof(int *)的结果不一样前者是 4后者是 8。很多初学者在拷贝代码时发现把一个地址强行塞进int后程序崩溃往往就是没注意这个平台差异。更重要的推论是指针变量本身也是变量它也有自己的地址。于是你就能对一个指针取地址得到“指针的指针”也就是二级指针。这个嵌套关系在后面的链表操作和内存分配场景里会反复出现。还有一个默认值要记住地址 0 在 C 语言里专门指代“空指针”通常写作NULL解引用空指针会直接引发段错误。理解“指针变量里装的是地址”这件事后面所有内容都是从这一点长出来的。1.2 为什么指针必须要有类型如果门牌号可以对应不同大小的房间那拿到门牌号之后你还得知道房间里到底多大。指针类型就是在告诉你解引用这个指针的时候按照多大的步长去操作。int *自增 1地址加 4 个字节char *自增 1地址加 1 个字节double *自增 1地址加 8 个字节。看似简单的p其实等价于p p sizeof(*p) * 1。所以类型不是摆设它决定编译器怎么计算地址偏移。这也能解释一件事为什么void *可以接收任何地址却不能直接解引用因为它没有“步长”信息。编译器拿到一个void *不知道应该按几个字节去读取数据。想解引用必须先显式转换成具体的指针类型比如(int *)pv。这是指针里最容易出 bug 的地方类型转换错了步长跟着错你读出来的数据自然就是乱码。还有个衍生知识点因为p 1和p 2之间差的是元素大小的字节数所以数组的指针遍历才能写成p。要是没有这个步长概念所有指针算术都得退化成手工字节偏移代码就没法看了。1.3 与 *一对容易绕晕的逆运算是取地址*是解引用两者互为逆运算。*p等于p*a等于a。但要注意只作用于对象或变量也就是左值*则能作用于任意合法的指针表达式。为什么很多人会晕因为叠加起来读起来别扭。比如int a 10; int *p a;这里的a类型是int *不是int把a直接赋给int变量会有警告强行转过去再解引用就是一场灾难。再比如函数参数里写int *p调用时传var你实际上是把变量var的地址传进去函数内部就能通过*p修改var。这个“间接一层”的思维是后面理解二级指针的基础。想清楚这一步再看多级指针就不会晕*每多写一层相当于往“指向的目标”再深入一层。**pp是先取pp里存的地址再取那个地址里存的地址最后才拿到真正的数据。建议初学者在纸上把变量名、地址、值三列画出来箭头连一下比盯着屏幕看十分钟都管用。2. 指针与数组最容易翻车的三座大山2.1 数组名不是指针但总是被当成指针教科书里有一句经典误导“数组名就是指针”。严格说数组名是一个表示整个数组对象的名字它的值在大多数表达式里会“退化”成首元素地址。所谓退化就是int a[3]这个数组名在表达式中出现时编译器会把它当成int *来处理。但有三处不会退化。第一是sizeof(a)结果是整个数组占用的字节数也就是 12而不是一个指针的大小第二是a结果是int (*)[3]也就是指向整个数组的指针第三是用数组名初始化字符串时比如char s[] hello它决定了数组的长度为 6。把这三条记住了很多诡异问题能直接消失。比如有人写int a[10]; int *p a;然后拿p去做步长移动结果发现p 1直接跳了 40 个字节而不是 4 个。原因就是a的类型是“指向 10 个 int 的数组的指针”步长是整个数组的大小。这个地方一错后面的二维数组遍历就全乱套了。2.2 指针数组和数组指针一张表说清很多面试题喜欢问int *p[3]和int (*p)[3]的区别。读声明有个实用工具叫右左法则从标识符开始先向右看遇到]说明是数组遇到)说明是函数或要向左看。int *p[3]p先碰上[所以p是一个长度为 3 的数组数组元素是int *也就是装了三个指针的数组叫指针数组。int (*p)[3]括号使p先与*结合所以p是一个指针指向一个包含 3 个int的数组叫数组指针。声明读法本质典型用途int *p[3]p 是数组元素是 int*指针数组存放一组字符串首地址、命令表int (*p)[3]p 是指针指向含 3 个 int 的数组数组指针二维数组按行遍历、行操作用代码记忆更牢pArr[0]可以被赋值为某个int变量的地址而pRow a[0]之后可以通过pRow[i][j]访问二维数组。这两者的区别用一次就记住了下次看到声明时先找括号位置基本不会错。2.3 字符指针的陷阱字符串常量到底能不能改char *s hello;这行代码在绝大多数编译环境里能通过但你只要执行s[0] H;最好情况是直接段错误运气差一点是静默篡改只读区域然后在其他地方爆出灵异 bug。原因很简单字符串常量放在只读数据区编译器不允许修改。正确写法是char s[] hello;这时字符串被复制到栈上可修改。至于char *s与char s[]在参数传递时的区别背住一条传数组名进去会退化成指针函数内部sizeof(s)已经失效。所以凡是可能修改内容的字符串处理函数都要额外传一个长度参数或者在结尾约定\0哨兵。另外字符串指针数组是二维字符串最常见的写法char *keywords[] {if, while, for, return};。每个元素是一个char *指向一个不可修改的字符串常量。这种写法在命令解析、关键字检索里非常常见比二维字符数组省内存但前提是你不去改写里面的内容。2.4 函数参数里的数组退化带来的 sizeof 坑写一个void foo(int arr[10])然后在函数内部sizeof(arr)结果不是 40而是 8 或 4。因为形参里的int arr[10]在编译时被重写成了int *arr数组的边界信息全部丢失。这也是为什么int foo(int a[])和int foo(int *a)这两种写法完全等价。既然退化无可避免应对方式就是参数里显式传一个长度参数或者规定一个哨兵值比如字符串结尾的\0。我见过太多课程设计里的 bug学生把二维数组传进函数里面用sizeof(arr)/sizeof(arr[0])算行数结果算出来完全不是那么回事。尤其是在“二维数组传参后到底还剩几个维度”这个问题上规则是第一维度会退化成指针第二维度必须保留。int matrix[rows][cols]作为参数时实际样式是int (*matrix)[cols]。搞清楚这个退化规则后面解鞍点、旋转矩阵这类题思路都会清晰很多。3. 函数指针与指针函数从声明到回调3.1 先分清“函数指针”和“指针函数”函数指针本质是一个指针变量只不过它存的是函数入口地址。指针函数本质是一个函数只不过它的返回值是指针类型。也就是说函数指针是变量指针函数是函数。这两个词顺序太像靠死记容易混。我的建议是看修饰符位置返回类型 (*变量名)(参数列表)是函数指针返回类型 *函数名(参数列表)是指针函数。一句话看见星号在标识符左边且被括号包住大部分情况是函数指针星号紧贴返回值类型的就是指针函数。一个常见场景是返回字符串的函数char *get_name() { ... }它是标准的指针函数。而int (*handler)(int)则是函数指针handler可以指向任何“接收一个 int、返回 int”的函数比如abs或者你自己写的negate。3.2 声明函数指针的规则与简化写法定义一个函数指针的标准语法是返回值类型在左括号包住星号和名字后面跟参数列表。比如int add(int a, int b) { return a b; } int (*pFunc)(int, int); pFunc add; int result pFunc(3, 5);这里pFunc(3, 5)和(*pFunc)(3, 5)是等价的C 语言里函数名本身就能当函数指针用。很多人非要写星号再括号其实多此一举。为了不把自己绕晕推荐用typedef简化typedef int (*BinaryOp)(int, int); BinaryOp op add; int result op(3, 5);声明函数指针数组也靠它typedef int (*BinaryOp)(int, int); BinaryOp ops[] {add, sub, mul, div};比原始语法好读太多。遇到更复杂的签名比如void (*signal(int sig, void (*func)(int)))(int)这种反人类声明用typedef拆成两个别名之后难度直线下降。3.3 实战场景排序回调、命令表、状态机函数指针不是用来炫技的。最经典的例子是 C 标准库的qsort。排序一个结构体数组时你不能告诉qsort怎么比大小只能提供一个比较函数指针typedef struct { int id; char name[32]; } Student; int cmp_by_id(const void *a, const void *b) { const Student *sa (const Student *)a; const Student *sb (const Student *)b; return sa-id - sb-id; } qsort(arr, n, sizeof(Student), cmp_by_id);这个比较函数返回负数、0 或正数qsort内部全靠这个函数指针决定交换顺序。这就是“策略模式”的本源。又比如网络编程或命令处理里协议包到来后根据命令号查函数指针表进行分发。传统写法是一长串switch-case维护起来头大用函数指针数组增删命令只需扩展一张表int cmd_start(void); int cmd_stop(void); int cmd_reset(void); typedef int (*CommandHandler)(void); CommandHandler commands[] {cmd_start, cmd_stop, cmd_reset};状态机也是同样的道理每个状态绑定一个处理函数状态转移就是切换函数指针。这个模式在通信协议解析、游戏 AI 里应用极其广泛。3.4 函数指针作为参数回调机制把函数指针当参数传给另一个函数就是回调。它的价值在于留下“挂钩点”框架代码不需要知道业务细节只负责在特定时机调用注册进来的函数。C 语言没有 C 的 lambda 和std::function但函数指针已经能覆盖相当多场景。比如一个通用的事件循环void event_loop(int times, void (*callback)(int)) { for (int i 0; i times; i) { callback(i); } }调用方只要传入函数名就能把它挂在循环里执行。网上很多关于“值传递、引用传递”的讨论值得顺带说一句C 里没有真正的引用一切参数都是值想模拟引用语义要么传指针要么传指针的指针。回调其实就是传递“函数行为”这个值理解了这个后面读框架源码会轻松很多。4. 多级指针与 const 组合再深一层的内功4.1 二级指针的内存模型把指针变量也当成一个普通变量来看二级指针就好理解了。假设int a 5; int *p a;。如果我想在某个函数里修改p本身让p指向别处只传p给函数是不够的——因为p作为一个值传进去函数里操作的是p的副本。要改p本身就得把p的地址传进去int **pp p;。于是pp指向pp指向aa存着 5。这就是二级指针。我见过不少同学在“指针的指针”上卡很久原因不是概念难而是想靠文字推演。多级指针最好用图画出a、p、pp三个格子分别标上地址和值箭头从指针指向目标。画完就明白*pp等于p**pp等于a。4.2 二级指针的典型用途修改外部指针变量最简单的场景是初始化一个堆上分配的指针void alloc_and_store(int **pp, int value) { *pp (int *)malloc(sizeof(int)); **pp value; } int *ptr NULL; alloc_and_store(ptr, 100);这里*pp malloc(...)修改的是外部变量ptr本身。如果只传一级指针函数内部*p malloc(...)改的是 p 副本的指向函数返回后外部ptr依然是NULL等于白做。同样道理链表头插函数往往需要二级指针。头节点本身是Node *变量插入新头时要改写 head 这个外部指针void insert_head(Node **head, int val) { Node *new_node (Node *)malloc(sizeof(Node)); new_node-data val; new_node-next *head; *head new_node; }如果不传给二级指针很多初学者选择“返回新头节点”来绕开也能跑但代码丑陋且容易漏释放。这个场景在栈、树、图等数据结构里反复出现值得一次吃透。4.3 const 与指针的结合规律const能把指针搞出四种排列。判断方法先看const和星号谁在左边const int *pconst修饰int所以*p不可变p可变。这叫指向常量的指针。int *const pconst修饰指针自身所以p不可变*p可变。这叫常量指针。int const *p和const int *p等价。const int *const p指针和指向的内容都不能变。实际工程里函数参数尽量用const Type *。它只表达“我只读不写”的意图既能防止误改调用方数据也能让阅读代码的人一眼看出函数不会修改实参内容。但要注意const只限制变量在当前代码路径下的使用方式不保证内存绝对安全硬件层面如果发生了非法写入它拦不住。4.4 结构体指针与链表操作结构体指针是链表、树结构里的主力。定义struct Node后Node *变量存的是下一个节点的地址。访问成员有两种方式(*pNode).data和pNode-data后者是前者的语法糖。写链表时最常见的崩溃是删除节点忘了在free当前节点前保存next指针Node *tmp head; head head-next; free(tmp);这里删头节点没问题因为head head-next是在free(tmp)之前完成的。如果写反了先free(head)再取head-next拿到的就是悬空指针。另一个常见问题是遍历时直接free当前节点导致下一步无法走到下一个节点。正确做法是用临时变量缓存next再free当前节点。这些坑不是算法问题是指针生存期没想清楚。5. 指针的坑与调试排查实录5.1 野指针、悬空指针、空指针别再傻傻分不清空指针NULL是合法的指针值表示不指向任何对象解引用它通常直接段错误。野指针是声明后没有初始化的指针它的值随机解引用可能崩也可能悄悄访问到脏数据。悬空指针是指向已释放内存块或已离开作用域变量的指针比如free(p)之后没有把p置NULL继续解引用p等于访问已被回收的地址。这三种问题都源于你持有的是一个过期地址。应对策略指针声明时必须初始化malloc和free要配对free之后立刻置NULL避免返回局部变量的地址。有些同学觉得free之后置NULL很多余我建议他们把这个习惯刻进肌肉记忆。free只是把内存归还给堆管理器并不改变指针变量里存的地址值。不置NULL万一后面代码误用了这个指针你根本不知道它在访问一个已经被回收的地址置了NULL至少能快速暴露问题而不是让 bug 潜伏到无法定位。5.2 常见段错误与崩溃排查方法段错误是 C 程序员最常见的深夜伙伴。常见原因空指针解引用、数组越界写坏相邻指针、字符串常量区写入、多线程共用全局指针、重复释放同一块内存。排查绕不开四个字缩小范围。先用printf打点在嫌疑函数入口打印指针地址和值再把可疑代码注释掉看崩溃是否消失最后用调试器。我个人的习惯是任何时候出现段错误先检查指针是不是NULL再看是不是释放后还在用最后的最后才怀疑“编译器出错”。编译器出错概率极低怀疑它之前先检查自己的代码。二分法是排查段错误的利器在程序中间位置加打印看崩溃发生在哪一半然后递归缩小区域。几十万行代码也能在几分钟内锁定嫌疑函数。5.3 定位指针问题的三件套printf、gdb、valgrindprintf虽土但在很多场景最快。在某个函数入口打印p、p、*p能立刻判断指针层级有没有错位。比如打印结果地址看起来正常但值却是个奇怪的负数就要怀疑是不是类型转换出了问题。gdb 是重型武器。常用操作break设断点print p看值ptype p看类型x/4gx p按地址看内存。有一次朋友问我为什么指针改不了我让他 gdb 打印p马上发现他传的是普通值而不是地址问题一目了然。valgrind 专治内存泄漏和非法访问valgrind --leak-checkfull ./a.out它会明确告诉你哪一行malloc没释放哪一次访问是非法访问。很多学校的 C 语言课程不教这些工具但实际工程里它们不可或缺。建议把这三样组合使用先printf快速定位大致范围再用 gdb 详细看守最后用 valgrind 兜底检查内存问题。5.4 常见问题速查表现象可能原因排查方向程序输出一堆乱码字符串指针没有\0结尾或指向只读区被改写检查字符串分配方式打印指针值和内容某个变量的值莫名其妙变化数组越界写坏了相邻内存检查循环边界用 valgrind 定位非法写入段错误空指针解引用、释放后继续使用、栈溢出gdb 看崩溃调用栈检查指针是否NULL内存不断增长malloc后没有free或丢了指针valgrind--leak-checkfull指针函数传参后外部没变化传的是指针值不是指针的地址需要修改外部指针时改用二级指针sizeof(arr)结果不对数组在函数参数里退化成指针显式传长度参数不要依赖sizeof这张表是我实际维护项目时总结的覆盖了日常能遇到的绝大部分指针问题。遇到问题先查表能省不少时间。6. 实操时才会知道的几个细节6.1 用双指针思路做字符串逆序很多初学者看到“字符串逆序”第一个反应是开一个新数组倒着复制。用双指针法更符合指针训练的调性左指针指向字符串头右指针指向尾交换两个字符后左指针递增、右指针递减直到相遇。void reverse(char *s) { if (!s) return; char *left s; char *right s strlen(s) - 1; while (left right) { char tmp *left; *left *right; *right tmp; left; right--; } }这段代码全是指针运算边界条件用left right判断比下标写法更直观。双指针思路在冒泡排序、二分查找、有序数组两数之和里也经常出现本质上都是地址移动和边界控制的组合。值得说的是strlen(s) - 1这一步strlen返回的长度不包括\0所以右指针初始指向最后一个有效字符。如果写成s strlen(s)右指针就指到了\0交换后字符串会多出一个空字符排查时容易卡壳。6.2 二维数组与鞍点用指针遍历比下标更有意思在二维数组中寻找鞍点也就是该位置是行上最大、列上最小课本解法通常用i、j下标。用指针也能做核心是搞清行指针的移动规律。int matrix[rows][cols]作为参数时实际类型是int (*matrix)[cols]。此时matrix[i][j]等于*(*(matrix i) j)。第一维会退化成指针第二维的cols必须保留否则编译器无法计算每行的步长。很多人卡在这类题不是算法思路问题而是“二维数组的数组名传参后到底还剩多少维度”没搞清。理解了退化和步长鞍点、旋转矩阵、蛇形遍历这些题在纸上推导一遍就能写对代码。6.3 指针代码的自我检查清单我写出过太多自毁式代码后来慢慢养成一套检查习惯。每次写完涉及指针的函数会按这几个问题自查一遍写之前有没有在纸上画一个简单的内存图每个malloc是否有对应的free每次free后是否把指针置为NULL函数参数里只读不写的指针是否加了const循环移动指针时边界条件是否处理了最后一个元素打印指针用的是%p而不是%d指针和int宽度未必一致用错格式符是未定义行为。这些检查项看起来琐碎但每一条背后都对应过我真实崩过的程序。比如打印指针用%d在 64 位机器上会截断高 32 位输出一个完全没意义的数排查半天才发现是打印格式的问题。最后分享一个我的真实习惯写任何涉及指针的代码之前先在纸上把“变量名、地址、值”画成一行连着箭头的图。看起来有点笨但它帮我解决过 90% 的函数指针和多级指针困惑。有一次面试官让我手写链表反转我画了三个指向节点的指针就把逻辑理清了。如果你现在还在为指针头疼不妨也试试这个土办法踏踏实实走一遍比刷一百道选择题都管用。