资讯动态

C语言指针进阶:数组、二级指针与函数指针的深度剖析

发布时间:2026/10/16 5:16:52 来源:尧图企业网站定制
我刚带完一个刚毕业的同事他指着报错信息一脸茫然为什么swap都写对了值还是没交换我瞟了一眼他传的是a和b不是a和b。这种问题在C语言指针上简直太常见了。很多人学指针时概念都知道——存地址嘛、间接访问嘛——可一到写代码就露怯。原因只有一个对指针到底在内存里干了什么没有建立起画面感。上一篇讲了指针的基础这篇《指针详解2》我直接挑最折磨人的几个硬骨头展开数组与指针的爱恨纠葛、二级指针和函数指针、复杂声明怎么读以及那些让你段错误到怀疑人生的边界操作。每一节都有代码、有报错现场、有我当时排查的完整思路你照着走一遍能把脑子里关于指针的最后一块盲区补上。1. 先拿回那些遗忘的细节指针本质回顾1.1 指针变量也是一个变量它有自己的身份信息聊硬核内容之前必须把地基再夯一遍。指针变量本质上就是一个普普通通的变量只不过它存的不是整数、不是字符而是另一个变量的地址。既然是变量它自己就有三样东西地址、大小、类型。地址这个指针变量在内存里占据哪个位置可以用p取到。大小在64位系统上指针变量占8个字节在32位系统上占4个字节。不管你是char *还是int *只要是指针同一平台上大小都一样。类型描述它指向的目标是什么类型这决定了你通过它读写多少个字节、按什么规则解释那些字节。初学者最常犯的错就是把指针变量和它指向的变量混为一谈。我见过有人写出p 100;想当然地以为我让p的值为100那p指向的地方就是100。错这叫你把100这个整数存进了指针变量p自己然后你下次*p的时候系统会拿100当作一个内存地址去访问段错误直接教你做人。打个比方指针变量像一张写着门牌号的纸。你可以往纸上写字但那不等于你往那个门牌号的房子里放了东西。你想往房子里放东西必须拿着纸走到那个地址推开门把东西放进去。在C语言里推开门的动作就是解引用*p。所以请记住铁律第一条指针变量没初始化之前它就是一张乱写的便签纸你根本不知道上面是个什么地址。务必先赋予它一个合法地址再解引用。#include stdio.h int main(void) { int a 10; int *p; // 此时 p 的值是未定义的野指针 p a; // 正确的做法先把 a 的地址写进 p *p 20; // 通过 p 修改 a 的值 printf(a %d\n, a); // 输出 20 return 0; }1.2和*两个操作符背后的逆运算关系是取地址*是解引用。在表达式中*p可以出现在赋值号左边也可以出现在右边。出现在左边时它表示p所指向的那个位置你可以往里写值出现在右边时它表示把p所指向的位置的值取出来。这是C语言中少有的、能直接表达左值语义的操作符。还有一个特别容易懵的点声明指针时也用*比如int *p。声明里的*和解引用时的*长得一样但含义完全不同。声明里它是类型的一部分表示p是一个指向int的指针表达式里它是操作符表示访问p所指向的位置。一个靠上下文区分没有捷径只能多写多读。另外要注意取地址和解引用只差一步但中间隔着类型系统。a的结果类型是指向a类型的指针即int *p a而*p的结果类型就是int。如果你把一个char *赋给int *p不少编译器会直接报warning因为类型不匹配。C语言里类型不是挂件它决定sizeof的大小、指针加减的步长、解引用时读几个字节。这些细节就是指针进阶的核心。2. 数组与指针纠缠最深的几个角落2.1 数组名和a到底是不是一回事值相同含义天差地别先看一段代码#include stdio.h int main(void) { int a[5] {1, 2, 3, 4, 5}; printf(a %p\n, (void*)a); printf(a[0] %p\n, (void*)a[0]); printf(a %p\n, (void*)a); printf(a 1 %p\n, (void*)(a 1)); printf(a 1 %p\n, (void*)(a 1)); return 0; }在你的机器上跑一下你会发现a、a[0]、a三个值完全相同都指向数组首地址。a 1和a[0] 1相同a 1比a大sizeof(int)即4个字节。而a 1比a大sizeof(a)即20个字节直接跳过了整个数组。这就是数组名最反直觉的地方a在绝大多数表达式中会退化decay为指向首元素的指针类型是int *而a的类型是指向整个数组的指针即int (*)[5]。两个地址的数值是一样的但步长完全不同。这个差异在二维数组和动态分配多维数组时尤其致命。如果你写了个函数接收数组却用错了步长轻则数据错位重则越界写坏堆。理解这一点你就理解了一个老生常谈为什么数组传参会退化为什么函数里sizeof(arr)得到的是8而不是数组大小。因为参数里的int arr[]本质上是int *arr它已经是指针了。2.2 指针加减恐怖的不是加1而是加一个单位p n不是简单地给地址值加n而是加n * sizeof(*p)。这个规则在代码里随处可见但很多人没真正重视。char *p指向一个字符串p 1就是移动到下一个字符偏移1字节。int *p指向一个int数组p 1移动到下一个int偏移4字节。struct MyStruct *pp 1移动一个结构体的大小可能是几十字节。所以当你看到*(p 3)时脑子里要立刻建立一张图p指向的基址加3个元素单位再取那个位置的值。写代码时计算偏移量和步长是出现off-by-one错误最多的场景特别是用指针遍历数组时int a[5] {1, 2, 3, 4, 5}; int *p a; for (int i 0; i 5; i) { // 等价写法*(p i) 等价于 p[i] 等价于 a[i] printf(%d , p[i]); }这里p[i]其实是*(p i)的语法糖。C标准里下标操作符的定义就是指针运算加解引用。所以别说p[i]了甚至i[p]都能编译通过并正常工作因为i[p]等价于*(i p)加法交换律一用毫无违和。这个彩蛋在面试里常被拿来当冷知识考但你理解原理后它其实就是指针运算的必然结果。不过这只是语法层面的合法写代码时千万不能真的把下标倒过来写可读性差到离谱没有任何编译器会拦你但下一个人review你代码时会想打人。2.3 二维数组不要把数组的数组和指针数组混为一谈二维数组int a[3][4]在内存里是连续排布的12个int。a的类型是int (*)[4]——一个指向含有4个int的数组的指针。所以a 1会跨过一整行也就是4个int的大小。这就是行指针。而int *b[3]是指针数组——数组里有3个元素每个元素都是int *。它和上面的int (*)[4]是完全不同的类型。一个典型的动态二维数组就长这样int rows 3, cols 4; int **matrix (int **)malloc(rows * sizeof(int *)); for (int i 0; i rows; i) { matrix[i] (int *)malloc(cols * sizeof(int)); }这种写法中matrix是指向指针的指针每一行是单独malloc出来的一块内存。它的内存布局和真正的二维数组不一样但用起来很像。很多初学者区分不清这两者导致动态分配时传参、释放时出问题要么free只释放了外层而内存泄漏要么释放了行指针却忘了外层数组。一个实用的判断口诀看到*往左边结合先分清数组里有指针还是指针指向数组。int *a[5]是a是数组数组里有5个指针int (*p)[5]是p是指针指向一个5个int的数组。这是C语言声明的经典难点后面第4节我会专门讲读法。3. 向函数传递参数与返回指针的秘密3.1 按值传递陷阱为什么swap必须传指针写任何一个初版swap绝大多数人第一遍都是这样的void swap(int x, int y) { int t x; x y; y t; }然后在main里调用swap(a, b)一打印没变。为什么因为C语言函数传参是按值传递——x和y是a和b的拷贝你在函数里改的是拷贝原变量当然纹丝不动。想让函数修改实参必须把实参的地址传进去函数内部通过解引用访问实参void swap(int *x, int *y) { int t *x; *x *y; *y t; }x、y虽然还是拷贝但它们拷贝的是a、b的地址所以*x就是main里的a。这就是传地址的本质——它依然符合值传递只是值本身变成了地址。理解这一点后很多迷惑自然解开为什么某些函数能修改外部的数组因为数组传参退化为指针即传入了首元素地址你在函数里arr[i] 1其实是在通过地址修改原数组。为什么结构体传参大且慢因为整个结构体被拷贝了一份。如果你只是想读传const指针是更好的选择。3.2 想要修改指针变量本身那得传二级指针上一小节说函数形参是指针时你可以通过解引用修改它指向的变量。但如果你想把指针变量本身改变了也就是让调用者的指针指向别处只传一级指针是不够的。经典的场景是链表头插你想在链表头部插入一个新节点让头指针指向新节点。如果你这样写void insertHead(Node *head, Node *newNode) { newNode-next head; head newNode; // 只修改了形参 head外部的 head 没变 }外部调用后头指针还是旧的新节点丢了。正确做法是传入头指针的地址也就是二级指针void insertHead(Node **head, Node *newNode) { newNode-next *head; *head newNode; }head是指向头指针的指针所以*head才是调用者的头指针对*head赋值就真正修改了外部的链表头。为什么要传二级指针因为函数要修改调用者变量本身的值就必须拿到这个变量的地址。如果这个变量是指针那它的地址就是指针的指针也就是二级指针。这个逻辑往深处推三级四级指针同理链表、树、哈希表的增删操作里非常常见。一个判断标准如果函数内部要改的是指针指向的内容用一级指针如果函数内部要改的是指针本身用二级指针。3.3 返回指针的两个铁律别还魂别悬空函数返回指针的坑比传参还隐蔽。第一个铁律不能返回局部变量的地址。局部变量在函数返回时就销毁了返回的地址成了一个悬垂指针dangling pointer访问它属于未定义行为。看下面这个反例int *getValue(void) { int x 42; return x; // 悬垂指针x 已销毁 }有些编译器会警告有的不警告但运行时随时可能给你一个莫名其妙的错。之前在某个项目里查过一个诡异bug一个函数偶尔返回正确的值偶尔返回垃圾持续一周最后一步一步跟到根因就是这个局部变量地址被返回了。为什么偶尔对因为栈上那个位置还没有被其他函数调用覆盖值碰巧还在一旦多调用几层函数栈帧被复用数据立刻花掉。这种bug非常难复现隐蔽性极强。第二个铁律返回堆内存地址时调用者必须记得free而且要保证只free一次。很多人喜欢写一个创建对象的函数返回malloc出来的指针这没问题但责任边界要划清楚谁分配谁释放或者文档写明白由调用者释放。否则时间长了代码里就到处是泄漏和双重释放。还有一个特殊情况返回指向字符串字面量的指针。比如const char *getMsg(void) { return hello; }是安全的因为字符串字面量有静态存储期程序整个生命周期都在。但要注意你不能通过这个指针修改内容修改字符串字面量是未定义行为很多平台会直接段错误。如果一定要可修改的字符串用static char buf[]或返回堆拷贝。4. 多级指针与复杂声明如何一层层拔开4.1 二级指针不是天书它是指针的地址有了前面链表头插的例子二级指针就有了非常扎实的落地场景。但从底层看二级指针不过也是一个普通变量它里面存的是一级指针变量的地址。int a 10; int *p a; // p 存了 a 的地址 int **pp p; // pp 存了 p 的地址p解引用得到a的值10。pp解引用得到p本身一个地址。**pp解引用两次先*pp得到p再*(*pp)得到a。在内存里画出来就是三条线pp→p→a。每次多一级指针就多一次间接寻址。二级指针最常见的三个用途我都见过实际代码修改调用者的指针变量链表头插、删除节点。动态分配二维数组也就是int **矩阵。输出参数函数内部创建一个资源通过二级指针把地址带出去给调用者。比如很多初始化写法initObject(objPtr)函数内部*objPtr malloc(...)。很多初学者一看到**就慌其实不用慌从右往左读一个*就是一个指向。int **pppp是指向int *的指针也就是指向整型指针的指针。就是这么简单。4.2 右左法则把复杂声明读出来的唯一可靠方法面试和工程里都会遇到这种让人头皮发麻的声明int *(*(*fp)(int))[5];别看吓自己。给你一套方法——右左法则从变量名开始先往右看再往左看遇到括号就跳进括号里继续右左直到整个声明读完。每一层读出是什么。以int *(*(*fp)(int))[5]为例找到变量名fp。fp右边是(int)说明fp是一个函数参数是int。括号外面左边是*说明这个函数返回一个指针。再往右看到[5]说明返回的指针指向一个有5个元素的数组。数组元素类型是int *即整型指针。连起来fp是一个函数指针指向的函数接收一个int参数返回一个指针这个指针指向一个长度为5的int *数组。读完之后是不是也没那么可怕再来看一个更常见的例子typedef int (*operation)(int, int);这个typedef定义了一个函数指针类型operation它指向接收两个int返回int的函数。你可以用它声明operation op add; // add 是实现为 int add(int, int) 的函数 int r op(3, 4); // 等价于调用 add(3, 4)很多公司的C代码大量使用这种typedef让函数指针好读。不建议绕开typedef直接写复杂的原始声明可读性太差容易出错工程实践里能让机器做的事别让大脑做用typedef拆解多级指针和函数指针是成熟团队的经验。4.3 const与指针四个组合两个方向const和指针搭配有四种写法别看晕其实核心就是问两个问题const修饰的是指针本身还是指针指向的内容。写法含义可以改指向可以改指向的内容const int *pp指向一个const int可以不可以int const *p同上const在int后面效果一样可以不可以int *const pp本身是const指针不可以可以const int *const p两者都const不可以不可以记忆技巧const修饰的是它右边紧挨着的那个词。const int *p中const修饰int指向的内容不可改int *const p中const修饰p本身指针不可改。工程里的建议函数形参一律用const修饰指向的内容比如const char *str。这样等于给调用者一个承诺我这个函数不会篡改你传进来的缓冲区。这个习惯能提前拦住一大批由于误修改共享数据而引发的灵异bug。当初我在某个通信解析模块里就是靠给所有只读形参加const把一个几天一崩的指针冲突出局旧代码新代码再没犯过。4.4 函数指针回调机制是C语言工程化的基石函数指针最实用的场景是回调callback。C标准库的qsort就是最经典的例子你不必为每种排序类型重写一遍快排你只需要提供一个比较两个元素大小的函数然后把它的函数指针传给qsort。#include stdio.h #include stdlib.h int compareInt(const void *a, const void *b) { int ia *(const int *)a; int ib *(const int *)b; return (ia ib) - (ia ib); // 防溢出写法 } int main(void) { int arr[] {5, 2, 9, 1, 7}; size_t n sizeof(arr) / sizeof(arr[0]); qsort(arr, n, sizeof(int), compareInt); for (size_t i 0; i n; i) printf(%d , arr[i]); return 0; }注意qsort比较函数里为什么要先转成const int *再取值因为qsort不知道你传的数组到底是什么类型统一用void *接受你必须在回调里做类型还原。这也顺带复习了void *的使用原则可以转成任何指针但转回来时必须用原始类型否则解释字节的方式就错了。函数指针还有一个强得多的应用做表驱动开发。比如协议解析根据消息类型调用不同的处理函数你可以建一个结构体数组成员是类型号函数指针然后循环查表调用。代码立刻就从一堆switch-case变成一张可扩展的表。无论是加新协议还是改旧逻辑都不动主流程。这种设计模式在底层通信、UI事件回调、状态机里到处都是。5. 最容易翻车的边界操作与自测题5.1 野指针、悬垂指针、内存泄漏三兄弟各坏一处这三个名词很多人混着用但成因和处理完全不同野指针指针变量未被初始化或者被随便赋了一个无效值。它的值是垃圾你完全不知道它指向哪里。解决办法很简单声明时初始化为NULL或者直接赋值合法地址。悬垂指针指针曾经指向合法的内存但那份内存被free了或者超出了作用域。后续访问它就是访问已释放的内存可能读出垃圾值、可能崩溃、可能被复用写成别的数据。解决办法free之后立刻把指针置为NULL以后每次使用前检查是否为NULL。内存泄漏指针本身没问题但malloc出来的空间再也找不到地址了——因为你把指针变量改了或弄丢了。解决办法释放逻辑要跟分配逻辑成对养成分配时就想好谁释放的习惯。在实际项目中这三种问题经常叠加出现。我在排查一个服务模块的内存问题时用过三板斧代码审计、编译器消毒工具如AddressSanitizer、压力测试复现。第一步至少能找出八成问题尤其是free后再使用和双重释放。5.2 解引用NULL一次标准段错误拆解C语言里NULL在绝大多数实现上都等于((void*)0)。操作系统保护了地址0所在的虚拟内存页所以对空指针解引用会触发段错误segmentation fault。#include stdio.h int main(void) { int *p NULL; printf(%d\n, *p); // 段错误对空指针解引用 return 0; }这不是偶然崩溃而是操作系统强制拦截了你访问非法内存的行为。站在开发者的角度段错误来得越早越好——它表明错误在发生处立刻暴露而不是悄悄破坏数据后在几万行代码之外开花。所以不要讨厌段错误要喜欢它学会用调试器定位它。一个常见误区free(NULL)是合法的什么也不做。所以很多清理函数里先判断再释放实际上多此一举直接free(p)就好但释放后置NULL的习惯还是要保留。5.3 几个据说很多人都会错的经典自测题理论讲再多不如做几道题。下面这几道都是我实际面试和带新人的时候高频使用的你试着自己做一遍再下拉看解析。自测1char *getStr(void) { char buf[] hello; return buf; }B. 编译报错因为要返回指针必须用完指针声明不完全是。正确答案返回的是悬垂指针因为buf是函数内的局部数组储存在栈上函数返回后内存已失效。自测2char *s hello; s[0] H;正确答案未定义行为。字符串字面量存储在只读区具体平台可能不同但C标准不允许修改在常见Linux环境下这里触发段错误。如果要修改应写成char s[] hello;这样是栈上数组可以改。自测3最容易错int a[3][4]; int (*p)[4] a; p[1][2] 99;p是行指针p[1]就是第二行p[1][2]是第二行第三个元素。这个写法合法效果等价于a[1][2] 99。很多人会觉得这里二维数组退化成了指针的指针其实不是它退化成了行指针int (*)[4]而不是int **。自测4int (*fp(void))[10];fp是一个函数它接收void参数返回一个指针指向一个含有10个int的数组。注意这里没有(*)包裹函数名所以fp是函数而不是指针。对比int (*fp(void))[10]函数返回数组指针int (*fp(void))[10]读的时候按右左法则确认函数fp优先不是先读指针。做完这四道题如果你每一道都能说清楚为什么那么指针这块硬骨头你已经啃下大半了。5.4 一条实用的排查链路用调试器看指针亲眼看内存最后教一个我经常用的排查方法调试器里别只是看报错要主动看指针的长相。假设你在Linux下用GCC和GDB。碰到段错误先取回core文件或用gdb打开程序输入(gdb) run (gdb) btbt可以看到当前调用栈定位到崩溃的那一行。然后(gdb) p ptr # 打印指针 ptr 的值看它是不是 NULL 或一个奇怪的地址 (gdb) p/x ptr # 以十六进制打印地址 (gdb) x/4bx ptr # 以十六进制查看 ptr 指向内存的前 4 个字节x/4bx的意思是以十六进制显示4个字节b是bytex是十六进制。这个命令能直接验证这个指针指向的内容里到底是什么。排查思路上有一条铁律先确认指针的值是否合法再确认指针指向的内存是否还有效最后再看内容是否符合预期。顺序反了你往往会在错误的方向上浪费好几个小时。就比如之前我遇到一个崩溃栈上指针的值看起来挺正常的0x7fff...开头但一x查看内容全是垃圾再一bt发现这指针指向的栈帧早就已经返回销毁了——一个典型悬垂指针。如果没有用x命令直接看内存光看地址正常这个表象搞不好会怀疑到指针根本不是问题绕一大圈弯路。指针这东西说到底是内存地址的抽象。绝大多数bug本质上是你对某块内存的生命周期、类型解释、归属权没看透。用调试器直接盯着内存看少猜测多验证是根治这类问题最可靠的心法。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑