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去计算长度,数值就偏大。后来排查了一晚上,发现问题出在我忘了在数据末尾补\0,strlen一直往后走,直到遇到一个恰好是零的字节才停下来。那次之后我就形成了一个习惯:凡是自己构造字符串,要么在分配时用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被当作参数传给了qsort,qsort在内部通过函数指针去调用它,完成元素大小的比较。这就是面向对象语言里策略模式的C语言实现方式。函数指针在嵌入式领域尤其常见,中断向量表、状态机、菜单驱动的控制逻辑,很多都是靠函数指针把"要执行的函数"和"触发它的条件"解耦开来。我用函数指针的一个经验是:一定要用typedef给它起别名,比如typedef int (*callback_t)(int, int);,否则满屏的int (*)(int, int)看着头大,代码可读性会急剧下降。另外别做跨平台不安全的骚操作,比如用void *强行转存函数指针,标准并没有保证这样转换是安全的,换到某些架构上就可能出问题。指针这门课,说到底就是内存、地址和类型这三样东西的组合。你先把"指针变量只是保存地址的变量"装进脑子,再带着"类型决定步长和访问宽度"去理解指针运算,最后把野指针、悬垂指针、越界这三种典型风险刻进心里,C语言的指针这关就算真正过了。我自己的体会是,指针难不在语法,而在脑子里有没有一张清晰的内存地图。地图有了,剩下的就是熟练度的问题。遇到指针相关的崩溃,别急着乱试,先画内存图,把每个变量、每个指针指向哪里画清楚,十有八九一眼就能看到问题所在。
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去计算长度,数值就偏大。后来排查了一晚上,发现问题出在我忘了在数据末尾补\0,strlen一直往后走,直到遇到一个恰好是零的字节才停下来。那次之后我就形成了一个习惯:凡是自己构造字符串,要么在分配时用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被当作参数传给了qsort,qsort在内部通过函数指针去调用它,完成元素大小的比较。这就是面向对象语言里策略模式的C语言实现方式。函数指针在嵌入式领域尤其常见,中断向量表、状态机、菜单驱动的控制逻辑,很多都是靠函数指针把"要执行的函数"和"触发它的条件"解耦开来。我用函数指针的一个经验是:一定要用typedef给它起别名,比如typedef int (*callback_t)(int, int);,否则满屏的int (*)(int, int)看着头大,代码可读性会急剧下降。另外别做跨平台不安全的骚操作,比如用void *强行转存函数指针,标准并没有保证这样转换是安全的,换到某些架构上就可能出问题。指针这门课,说到底就是内存、地址和类型这三样东西的组合。你先把"指针变量只是保存地址的变量"装进脑子,再带着"类型决定步长和访问宽度"去理解指针运算,最后把野指针、悬垂指针、越界这三种典型风险刻进心里,C语言的指针这关就算真正过了。我自己的体会是,指针难不在语法,而在脑子里有没有一张清晰的内存地图。地图有了,剩下的就是熟练度的问题。遇到指针相关的崩溃,别急着乱试,先画内存图,把每个变量、每个指针指向哪里画清楚,十有八九一眼就能看到问题所在。