1. 指针不是洪水猛兽它只是一个装着地址的变量很多人学到指针就卡住了指针指针变量指向这些词绕来绕去越绕越晕。我身边不止一个朋友在学到这一章时直接放弃了C语言说指针太难了完全不知道它在干什么。其实指针本身特别小理解它只需要一张内存示意图。我第一次给别人讲指针时先抛了一个问题你定义一个int变量a系统到底在你的电脑里做了什么其实就两件事在内存里找一块能放下int的空间然后把这块空间的起始地址和一个名字a绑定起来。就这么简单。指针是什么呢就是想办法把那块空间的地址也存到一个变量里。存地址的变量就叫指针变量。这样理解之后指针从抽象概念一下子变成一个具体的东西它就是一个装着数字的普通变量只不过这个数字不是普通的数值比如3.14或者1024而是一块内存的地址。你完全可以把指针变量当成一个写着门牌号的纸条需要的时候按纸条上的门牌号去找对应房间里的东西。1.1 用一栋公寓楼理解地址与变量我把内存比作一栋有大量房间的公寓楼。每个房间都有编号这就是地址每个房间能存放不同大小的行李对应不同类型的变量。变量a就是某个房间门上贴的名字牌你可以随时把新内容放进房间也可以把旧内容取出来。指针变量p则是记录a住哪个房间的一张便签。注意便签本身也是一个独立的纸条它也得有个地方放着也就是说指针变量p自己也有一个房间也有自己的地址照样可以再用一个指针去记录它。这就是后面二级指针概念的雏形。很多学生觉得指针抽象是因为脑子里没有这张公寓楼图。一旦有了图什么取地址、间接访问、指针运算全都是在房间和便签之间来回操作而已。C语言里有两个操作符跟这张图直接对应是查房间编号*是按编号进房间取东西。写int a 10; int *p a;a拿到的就是a房间的编号*p则是拿着p这张便签上写的编号走进对应房间把里面那个10取出来。很多刚学的人会把声明里的*和表达式里的*搞混声明中的*只是说明这个变量是指针类型而表达式中的*才是真正的按地址访问操作。这两者一定要分开。1.2 指针变量的大小与最小操作单位这里有个反直觉的事实在64位系统上不管指针指向的是int、char还是double指针变量本身占用的内存大小几乎总是8字节32位系统上则是4字节。原因是地址编号的位数是固定的指针变量存的只是一个编号而不是编号指向的那些内容。这就像你写一个门牌号不管那个房间是大别墅还是小单间门牌号写在便签上所占的纸面大小都一样。但关键来了虽然所有地址编号看起来都一样长指针的类型却决定了你按这个地址去访问时到底读取多大范围的内存。比如int *p指向地址0x100*p默认会从0x100开始读4个字节假设int是4字节得到一个int值而char *cp指向同样的0x100*cp只会读1个字节。所以类型不是可有可无的装饰它是访问内存时按什么宽度去解释的规则。这个理解在以后做网络协议解析、图像数据处理时会反复用上几乎所有底层的字节操作都要把指针转成unsigned char *再逐字节处理。2. 指针的类型一个地址为什么还要分成int *、char *、double *上一节说了所有指针变量的大小一样那么问题就来了既然大小一样为什么C语言非要搞出int *、char *、double *这么多种指针类型直接统一用一个通用指针不行吗答案是指针类型决定了指针运算的步长也决定了访问时的数据宽度。void *确实是一个无类型指针能接住任何地址但它不能直接解引用也不能直接做加减运算因为编译器不知道你到底想按几个字节来理解这块内存。2.1 指针加减到底加了什么很多人第一次写指针运算时会被结果吓一跳。假设有个int a[5]数组元素在内存中是紧挨着排列的每个int占4字节。如果我让int *p a[0]然后执行p 1你以为地址只加了1实际上它加了4因为p 1在C语言中的含义是往后移动一个int的距离而不是往后移动一个字节。初学者很容易在这里懵掉因为在普通数值变量里n 1确实就是数值加1但指针的1是以类型宽度为单位的。我常用的类比还是公寓楼两个相邻的编号之间差多少取决于房间类型。如果上下两层都是单人间编号差1如果都是双人间编号可能差2甚至更多。C语言的指针算术就是约定用类型宽度作为步长这让代码在遍历数组时特别自然你写p它就精确地跳到下一个元素所在的位置你不需要关心每个元素到底占几个字节编译器已经帮你算好了。这也是为什么遍历数组用指针有时候比用下标看起来更简洁、更贴近底层。2.2 别在void *和函数指针上做算术既然指针运算与类型宽度绑定那void *就麻烦了这个类型没有宽度所以标准C里对void *做加减是不允许的部分编译器作为扩展允许按1字节处理但那不是标准行为跨平台会出问题。我建议你在自己的代码里不要依赖这种扩展如果真的需要逐字节移动明确转成unsigned char *来做。函数指针也要注意void (*fp)(void)这种指向函数的指针不应该做算术运算函数的地址和数据的地址本来就是两套体系对函数指针做加减没有实际意义还容易让程序走上奇怪的执行路径。我在调试一些小型实时系统时见过有人试图对函数指针做偏移来找相邻函数这种行为在平台间几乎没有可移植性一旦换编译器或优化等级结果就完全不可预测。3. 指针与数组一对天然绑定的兄弟别再分开学数组和指针在C语言里的关系近到几乎分不开。很多教科书把数组和指针分成两章讲这是造成理解割裂的重要原因。数组名、下标访问、指针算术这三样东西本质上是同一件事的三种表达。我建议你把它们放在一起学越早建立这个认知后面学字符串处理、二维数组、函数传参时就越顺。3.1 数组名不是指针变量但它会退化很多资料会简单地说数组名就是指针这话对了一半。数组名的确会在绝大多数表达式中被退化为指向首元素的指针比如你可以写int *p a;把数组名直接赋给指针。但数组名本身并不是一个指针变量它是不能被人为赋值的你不能写a p因为a不是一个左值。这一点用sizeof最能看出区别sizeof(a)返回的是整个数组占用的字节数比如int a[4]在sizeof(int)为4字节的平台上返回16而sizeof(p)只返回指针变量本身的大小通常是8。很多公司的笔试题就喜欢在这里设陷阱因为一知半解的人最容易栽。3.2 下标访问的本质就是指针运算先看一个有趣的冷知识C语言里a[i]在编译器内部是会被改写成*(a i)的。既然乘法满足交换律加法也满足那么*(a i)和*(i a)就是同一个东西于是i[a]这种看起来像精神污染的写法在C语言里居然是合法的。我在讲这一节时总会跟同学强调这个冷知识不是用来炫技的它是在提醒你数组下标的本质就是指针偏移理解了这一层很多奇怪代码你一眼就能看穿。用指针遍历数组时下面这种写法很常见int a[4] {10, 20, 30, 40}; int *p; for (p a; p a 4; p) { printf(%d , *p); }这里p a 4比较的是地址大小p移动的是一个int的宽度。你注意看这个循环里完全没有出现下标但每一个*p取到的都是数组元素。这就是指针算术最典型的应用场景。等学了链表之后你会发现遍历数组和遍历链表在思路上其实是一致的移动指针停下取内容。3.3 数组传参给函数时会发生什么数组作为函数参数时有一个隐蔽的退化陷阱。很多人以为在函数形参里写void func(int arr[])就能在函数内部用sizeof(arr)算出数组大小。这是错的。形参里的int arr[]在编译器眼里等价于int *arr也就是说数组已经退化成了指针sizeof(arr)得到的只是指针大小而不是数组大小。这就是为什么C标准库里的好多函数比如memcpy、qsort都必须额外接收一个长度参数因为函数根本无从得知数组到底有多长。这带来一个很重要的实践习惯不要指望能在函数内部猜到数组长度要么把长度作为参数传进去要么在数组末尾放一个哨兵值比如字符串的\0就是哨兵用遍历的方式去探测边界。这两种方式各有各的适用场景你在写代码时要有意识地二选一否则越界问题早晚找上门。4. 指针作为函数参数传值、传址与const的三角关系C语言的函数参数只有一种传递方式那就是按值传递。几乎所有初学者在这里都会产生误解觉得C语言存在引用传递之类的东西。其实你写void swap(int *a, int *b)函数收到的依旧是一个值只不过这个值是一个地址。按值传递的是普通变量时修改形参不会影响实参按值传递的是地址时你通过这个地址能修改到原变量——这就是传址的本质它也是按值传递的一种只是传的值恰好是别人的地址。4.1 swap这个经典例子为什么必须用指针先来看最经典的交换两个变量void swap_bad(int a, int b) { int tmp a; a b; b tmp; } void swap_good(int *a, int *b) { int tmp *a; *a *b; *b tmp; }swap_bad看起来逻辑完全正确但它交换的只是形参的两个副本函数一旦返回副本消失外部的实参纹丝不动。swap_good不一样它拿到的是实参的地址*a *b等于直接去写实参所在的房间所以交换真正发生在了原来的变量身上。这个例子几乎每一本C语言教材都会讲但很少有资料把为什么必须传指针讲透。核心就是一句话你需要修改调用者那个变量本身而不是它的副本就必须用指针把那个变量的地址传进来。这个道理看着简单实际应用时很容易忘。我见过不少同学背下了swap要传指针等到写二叉树的插入函数要在函数里把一个节点指针挂到树的某个位置时却忘了给根节点指针传二级指针结果根节点永远没被更新。逻辑是一样的你想修改的是调用者手里的那个指针变量本身所以必须传指针的地址也就是二级指针。4.2 大结构体传参时为什么要用指针加const除了需要修改原变量这个理由另一个很实际的理由是性能。假设你有一个结构体里面放了一个很大的字符数组和其他字段typedef struct { char name[64]; int scores[10]; long id; } Student;如果你直接void print_student(Student s)每次调用整个结构体的内容都会被复制一遍到栈上。结构体越大、调用越频繁性能浪费就越明显。改用void print_student(const Student *s)函数调用时只复制一个8字节的地址速度快得多。加const是在对外承诺我这个函数只读不写不会去修改你传进来的结构体。这种写法定接口时非常有用别人看到const Student *就知道这个函数不会破坏他的数据。4.3 const与指针组合的读法const int *p和int * const p的区别是C语言里的经典易错点。我提供一个很实用的读法口诀从右往左读const修饰谁谁就不可变。const int *p先看到*p再看到const意味着*p不可变也就是说不能通过p修改它指向的内容int * const p先看到p再看到const意味着p本身不可变也就是说p一旦指向某个地址就不能再让它指向别处了但通过p去改它指向的内容是允许的。如果写成const int * const p那就是两者都不可改指向的地址固定内容也不能通过它修改。工程里最常见的接口写法是const char *比如strlen(const char *s)它表达的意思是这个函数保证不会修改你传入的字符串这是C语言里一种薄弱但常用的约定借助类型的const表达意图让调用者放心。5. 指针与字符串C语言里没有字符串类型却处处是指针C语言没有一个原生的字符串类型。字符串通常被表示为一个以\0结尾的字符数组而任何提到字符串的场合几乎都离不开字符指针。理解字符指针和字符数组的区别是绕过大量隐蔽bug的起点。这一节我建议你仔细看因为很多线上事故的根源就藏在这里。5.1 字符串字面量与字符数组完全是两回事看这两段代码char *s1 hello; char s2[] hello;乍一看差不多实际上存储位置和修改能力天差地别。hello是一个字符串字面量它通常被放在只读区具体位置由运行时决定但标准规定修改它的行为是未定义的。s1这个指针变量指向这个只读区如果你尝试s1[0] H程序很可能直接崩溃或者产生一段诡异的未定义行为。而s2[] hello是在栈上分配一个能装6个字节的数组5个字符加一个\0把字符串内容逐个复制进去数组里的内容是可修改的所以s2[0] H完全没问题。这个区别在调试代码时经常让人费解同一个字符串改一下声明方式写入时就崩溃再改回数组形式又正常了。我建议你形成条件反射只要字符串需要被修改就一律用字符数组不要用char *直接指向字面量。反过来如果只是读取用const char *最安全还能顺便告诉编译器别人不要改它。5.2 为什么字符串处理函数都接收char *参数strcpy(dest, src)、strlen(s)、strcmp(a, b)这些函数接收的都是指针原因有两点。一是历史惯例C语言从一开始就想让字符串处理显得轻巧不愿意拷贝整个字符串传指针更高效二是数组作为参数本来就会退化为指针所以这些函数根本不需要知道数组长度它们靠\0来判定字符串在哪结束。strlen的逻辑就是不断把指针往后移动逐字节判断是否遇到\0。这个机制有一个严重的前提字符串必须在有效内存范围内正确结束于\0。我在这里踩过一个大坑。以前自己拼接一段协议报文往缓冲区里逐个填数据填完以后直接调用了打印函数结果打印出来内容看起来完全正常可一旦用strlen去计算长度数值就偏大。后来排查了一晚上发现问题出在我忘了在数据末尾补\0strlen一直往后走直到遇到一个恰好是零的字节才停下来。那次之后我就形成了一个习惯凡是自己构造字符串要么在分配时用memset把整个缓冲区清零要么在拼接的最后一步显式写一个\0。这个习惯到现在帮我避开了很多莫名其妙的越界访问。6. 指针最容易引发的崩溃野指针、悬垂指针和越界指针带给C语言的不仅是灵活还有三类非常经典的崩溃源野指针、悬垂指针和缓冲区越界。初学者如果完全没有预防意识会出现一种特别让人崩溃的情况代码一会儿能跑一会儿不能跑debug模式正常release模式必崩换个编译器结果还不同。大多数这类问题都来自指针指向了不该指向的地方或者写了不该写的内存。6.1 未初始化指针也就是野指针最典型的野指针就是int *p; *p 10;你没有给p一个合法的地址p里存的可能是任何垃圾值。往这个未知地址写入10运气好写到一块没人用的内存程序没有任何反应运气不好直接写到关键数据区域或非法地址程序立刻段错误Segmentation Fault。问题在于段错误还不是最坏的结果最坏的是往一个合法但被你误用的地址写了值悄悄破坏了其他变量等程序运行好久之后才冒出一个毫无头绪的计算错误。这种bug最难查因为表面症状和真正原因相隔十万八千里。我的习惯很简单定义指针时立刻初始化为NULL或者一个确定有意义的地址绝不声明不初始化。释放内存之后也马上把指针置为NULL。这样即使后面误用了指针程序也会在第一次访问时就暴露问题而不是在几小时之后才出错。6.2 悬垂指针地址还在东西已经不归你了悬垂指针比野指针更隐蔽因为指针变量里存的是一个曾经合法、但现在已经失效的地址。最经典的来源是返回局部变量的地址int *bad() { int x 100; return x; }函数bad返回后x的生命周期就结束了栈帧被回收。你拿到的x指向的是一块已经被退房的内存内容随时会被其他函数覆盖。你再去读它得到的值可能是100也可能是任意一个乱七八糟的数完全取决于是什么函数恰好复用了这块栈空间。这类bug不一定会稳定复现所以排查起来特别折磨人。另一个常见来源是free之后仍然继续使用那块内存。释放只是告诉操作系统这块内存我不用了但内存里的数据不会立刻被清零指针变量里存的地址也不会自动改变。你继续通过这个指针读写运气好时读到的还是旧数据运气差时这块内存已经被分给别人写入新数据你读到的是别人的内容。为了避免悬垂指针我坚持两个原则一是谁分配谁释放分配和释放尽量放在相邻的代码块里二是释放后置空把所有指向这块内存的指针全部设置为NULL。6.3 缓冲区越界一个不小心写过了头缓冲区越界也是指针使用失控的一种表现。最典型的写法是char buf[8]; strcpy(buf, hello world);hello world加上结尾的\0一共12个字节而buf只有8个字节的空间strcpy根本不管目标有多大它会一路把12个字节写完后面4个字节已经踩进了相邻的内存覆盖了其他变量甚至可能覆盖了函数的返回地址。这不仅仅是功能bug在很多历史事故里这种越界还会被利用来改写程序的控制流形成严重的安全漏洞。我的建议是使用strncpy这类有长度限制的函数同时必须清楚strncpy如果不满足长度条件是不会自动追加\0的所以你最好在目标缓冲区末尾手动置0。一个可靠的做法是char buf[12]; memset(buf, 0, sizeof(buf)); strncpy(buf, src, sizeof(buf) - 1);先整体清零再复制最后留出一个字节的位置保证字符串必然以\0结尾。这个流程看着啰嗦但它能挡住一大批边界问题。7. 从基础到进阶二级指针、指针数组与函数指针学完前面的内容你已经算掌握了指针的主干。要想在实际工程里游刃有余还有三样东西值得花时间搞明白二级指针、指针数组、函数指针。它们出现的频率非常高尤其在嵌入式回调、命令行解析和通用数据结构的实现里。7.1 二级指针为什么需要指向指针的指针二级指针通俗说就是保存指针变量地址的指针。需要用到它的场景最常见的是在函数内部修改外部的指针变量本身。比如你想写一个函数让外部的一个char *指向新分配的内存void bad_alloc(char *s) { s malloc(100); // 这里只改了形参的副本 } void good_alloc(char **s) { *s malloc(100); // 这里通过二级指针修改了外部的指针变量 }如果只传char *s函数内部给s赋值外部的指针变量完全不受影响你回到调用者那里一看它还是NULL。而传char **s之后*s malloc(100)就相当于往外部那个指针变量的房间写入新地址外部指针才会真正指向新分配的区域。写二叉树的插入操作、链表的头插法时也经常遇到同样的需求所以二级指针不是一个理论概念是实实在在的工程工具。7.2 指针数组与数组指针名字长得像含义差很远这一对概念几乎每次笔试面试都会出现。int *p[3]是指针数组意思是一个数组里有3个元素每个元素都是int *类型的指针常见于char *argv[]这种命令行参数表。int (*p)[3]是数组指针意思是p指向一个含有3个int元素的数组。它们的区别来自运算符优先级[]的优先级高于*int *p[3]的p先和[]结合所以是数组int (*p)[3]用括号让p先和*结合所以是指针。我用数组指针遍历二维数组时踩过一个坑。当时写int (*p)[3] arr想用p去逐行访问二维数组结果循环步长写错了打印出来的全是错乱的数据。反省之后发现p 1移动的是一个整行也就是3个int的距离而不是一个int的距离。用指针处理多维数组时一定要时刻提醒自己当前指针的类型宽度是哪个级别是元素级别、行级别还是更高层。7.3 函数指针把函数当作数据来传递函数指针最核心的价值在于回调。C语言里函数名本身就是函数的入口地址你可以把这个地址存进指针变量在需要的时候通过指针调用它。一个典型的用法是给排序函数传比较器typedef int (*compare_fn)(const void *, const void *); int cmp_int(const void *a, const void *b) { int x *(const int *)a; int y *(const int *)b; return (x y) - (x y); } qsort(arr, n, sizeof(int), cmp_int);这里cmp_int被当作参数传给了qsortqsort在内部通过函数指针去调用它完成元素大小的比较。这就是面向对象语言里策略模式的C语言实现方式。函数指针在嵌入式领域尤其常见中断向量表、状态机、菜单驱动的控制逻辑很多都是靠函数指针把要执行的函数和触发它的条件解耦开来。我用函数指针的一个经验是一定要用typedef给它起别名比如typedef int (*callback_t)(int, int);否则满屏的int (*)(int, int)看着头大代码可读性会急剧下降。另外别做跨平台不安全的骚操作比如用void *强行转存函数指针标准并没有保证这样转换是安全的换到某些架构上就可能出问题。指针这门课说到底就是内存、地址和类型这三样东西的组合。你先把指针变量只是保存地址的变量装进脑子再带着类型决定步长和访问宽度去理解指针运算最后把野指针、悬垂指针、越界这三种典型风险刻进心里C语言的指针这关就算真正过了。我自己的体会是指针难不在语法而在脑子里有没有一张清晰的内存地图。地图有了剩下的就是熟练度的问题。遇到指针相关的崩溃别急着乱试先画内存图把每个变量、每个指针指向哪里画清楚十有八九一眼就能看到问题所在。
阅读完成 · 觉得有帮助?