
1. 想明白指针先重新认识内存地址很多人学指针卡住不是智商问题而是脑子里缺了一张“内存地图”。C语言的变量本质上就是一块内存区域而指针就是用来记录这些区域位置的“门牌号”。理解指针有一个好办法把内存想象成一个大宾馆的走廊每个房间有编号。房间大小不一样有的一间住4个字节有的一间住8个字节。程序运行的时候我们的变量就是在这些房间里存东西。变量名是什么变量名是这个房间在代码里的名字住客本身比如a 3意思是往a这个房间放了一个数字3。而指针变量呢它本身也是一个房间只不过这个房间里存放的内容是另一个房间的门牌号——地址。1.1 地址、门牌号和变量名的区别先看一段最简单的代码#include stdio.h int main(void) { int a 3; int *p a; printf(变量 a 的值%d\n, a); printf(变量 a 的地址%p\n, (void*)a); printf(指针 p 里存放的内容也是地址%p\n, (void*)p); printf(通过指针 p 解引用拿到 a 的值%d\n, *p); return 0; }输出大概是这样的地址每次运行都不同变量 a 的值3 变量 a 的地址0x7ffc5a3b4c14 指针 p 里存放的内容也是地址0x7ffc5a3b4c14 通过指针 p 解引用拿到 a 的值3关键点有三个a是取地址运算表示“把a这个变量的门牌号拿出来”。int *p a;的意思是定义一个指针变量p它里面装的是a的地址。*p是解引用意思是“顺着p里存的门牌号找到那个房间去访问里面的内容”。我觉得多数人当时没搞清的是a这个变量名在编译后几乎不存在了。程序跑起来之后CPU只知道一堆地址不知道什么叫“a”。a只是写代码时给人看的标签真正的“地址”才是机器找数据用的钥匙。搞懂这一点后面所有指针问题都顺了。1.2 地址有几个字节32位和64位的差异一个指针变量的大小取决于平台。常见情况平台地址宽度指针大小32位程序32位4字节64位程序64位8字节写代码时你可以用sizeof(int*)确认printf(int* 的大小%zu\n, sizeof(int*));在64位Linux/Windows上输出一般是8在32位环境是4。这能解释很多初学者困惑为什么指针变量明明只是存个地址却占了8字节因为地址本身上限就是那么宽64位系统地址总线决定了它必须用64位8字节才能表示所有内存位置。再给你一颗定心丸指针的“指向类型”不会改变指针变量本身的大小char*、int*、long long*在同一个平台上大小都一样。类型不同影响的是“解引用时从哪个位置读几个字节”这个先记在心里下面马上讲。1.3 指针指向类型的作用步长和解读方式还是用房间做比喻。int *p和char *cp本质上内存里存的都是地址。那为什么非要区分类型因为当你写*p的时候编译器必须知道从p指向的地址开始连续读几个字节再把这几个字节解释成什么类型。char*解引用从那个地址读1个字节当成字符处理。int*解引用从那个地址连续读4个字节按整数解释。double*解引用从那个地址连续读8个字节按浮点数解释。另外指针做加减法时步长也由类型决定。比如int arr[5] {10, 20, 30, 40, 50}; int *p arr[0]; printf(%d\n, *p); // 10 printf(%d\n, *(p 1)); // 20p 1并不是“地址数值加1”而是“跳到下一个 int 的位置”也就是地址数值加了sizeof(int)一般是4。这个设计就是为了方便数组遍历。所以声明指针时写清楚类型不是形式主义是给编译器规定“步长”和“读取宽度”。2. 指针变量怎么用声明、初始化、解引用接下来进入实操。建议你打开编译器跟着敲这一节每行代码都值得亲手验证。2.1 声明语句怎么读星号属于谁三种写法都可以int *p; int* p; int * p;它们含义一样。初学者常纠结“星号是靠着类型还是靠着变量”在C语言里星号属于变量名那一侧声明int* p更像一种强调风格的写法但多变量声明时会有坑int* p1, p2; // p1是指针p2是普通int如果连着写多个指针必须写成int *p1, *p2, *p3;就是说*是跟着变量名走的每个指针变量前面都要有自己的*。这个坑我见过很多次面试题也常考。2.2 初始化让指针先有归宿声明指针之后最好立刻给它一个明确的初始值。C语言不会自动帮你把局部指针变量初始化为NULL。int *p; // 危险p里面是一个不确定的随机地址这时候如果直接*p 100;相当于随机选了一个内存地址往里写数据轻则覆盖别的变量重则直接段错误崩溃。正确做法int a 0; int *p a; int *q NULL; // 暂时没有目标起码有个“无效值”可判断NULL在C语言里通常定义为((void*)0)它表示这个指针不指向任何有效对象。判断指针是否可用最常用的就是if (q ! NULL) { *q 42; }不过要注意if (p)和if (p ! NULL)是等价的都是“p 非空”才执行。C语言里直接写if(p)更常见简洁。2.3 解引用、取地址和“左值”概念*p不只是一个“取值”操作它也可以出现在赋值号左边。比如int a 1; int *p a; *p 99; printf(%d\n, a); // 99为什么*p能赋值因为解引用后它代表了p指向的那个内存位置本身也就是一个“左值”——可以写入的实体。理解这一点后面写字符串、操作链表都会用到。还有个高频误区p a和*p a看起来有点像但含义完全相反。前一个是把地址放进指针后一个是把值写进指针指向的位置。初学者一旦混了代码跑起来全是歪的。我建议你每次写完指针操作心里默念一遍“左边是给指针换地址还是给目标换内容”。2.4 打印指针的正确姿势打印地址一定要用%p并且转成void*打印C标准要求printf(地址是%p\n, (void*)p);不要用%x或%d来打印指针。虽然某些编译器能跑但严格来说行为未定义而且在高位地址场景下输出可能截断排查问题时会误导你。顺便说一下打印NULL指针用printf(%p, p)也没问题会输出(nil)或0x0之类。3. 指针最核心的应用传参和修改实参学了声明和打印接下来要解决一个经典问题为什么C语言里要传指针才能“改变函数外面变量的值”3.1 值传递的本质传的是副本C语言函数参数默认是值传递。什么意思void swap_fail(int x, int y) { int temp x; x y; y temp; } int main(void) { int a 3, b 5; swap_fail(a, b); printf(a%d, b%d\n, a, b); // 还是3和5 return 0; }过程是这样的调用swap_fail(a, b)时函数栈里创建了两个新变量x和y它们只是把a和b的值复制了一份。你在函数里交换的始终是x和y跟外面的a、b毫无关系。函数一结束x、y连同副本一起随风飘散。3.2 传指针把地址当值传递要修改实参就得让函数知道实参住在哪里也就是把地址传过去。void swap_ok(int *x, int *y) { int temp *x; *x *y; *y temp; } int main(void) { int a 3, b 5; swap_ok(a, b); printf(a%d, b%d\n, a, b); // 5和3成功交换 return 0; }这时x和y仍然是副本但副本的内容是地址。通过地址解引用函数就能直接读写main里那块内存。这就像你给室友留了一张纸条上面写着“帮我把我桌上的钥匙递给门外的人”虽然纸条本身是复制品但照着纸条上的位置门外的室友真能拿到钥匙。记住口诀想通过函数修改普通变量的值就传那个变量的地址。更宽泛地讲凡是需要“带回来结果”的参数都用指针。3.3 C里有人说引用更省事热词里也提到“引用 指针 区别”。简单说几句C的int x a;本质上是编译器帮你实现的隐式指针写起来像普通变量用起来有指针的效果。但在C语言里没有引用只能用指针。如果你学C引用确实更安全可以少写*和不过理解了指针理解引用完全是降维打击。3.4 用const限定指针读懂声明顺序C语言里const和指针组合有点绕但非常常用尤其是函数参数int strlen_my(const char *s);const char *ss本身可以被修改但*s不能通过s修改即指向的内容只读。char *const ss本身不能改但*s可以改。const char *const s都不能改。记忆方法const修饰的是紧跟它后面的东西。const char *s中const后面是char所以char也就是指针指向的内容不能改char *const s中const后面是*s所以指针本身不能改。在函数设计上如果函数只读取数组、不修改内容参数尽量写成const char*。这样既能接收字符串字面量也能防止在函数里误写导致运行时崩溃。4. 指针与数组的纠缠从一维到二维数组和指针在C语言里几乎是一对双胞胎也是最多人犯糊涂的地方。先把最基础的结论摆出来arr作为右值时通常代表数组首元素的地址。数组名本身不是一个普通变量它不能被整体重新赋值比如arr other;是错的。数组不是指针只是它会在很多表达式中“衰减”成指针。4.1 一维数组遍历的多种写法#include stdio.h int main(void) { int arr[5] {10, 20, 30, 40, 50}; for (int i 0; i 5; i) { printf(%d , arr[i]); } printf(\n); int *p arr; // 等价于 int *p arr[0]; for (int i 0; i 5; i) { printf(%d , *(p i)); } printf(\n); for (int i 0; i 5; i) { printf(%d , *p); // 利用指针后移 } printf(\n); return 0; }*p这句值得仔细讲。运算符优先级里*和都是后缀优先的所以*p等价于*(p)。整个表达式的值是*p当前指向的元素副作用是p自增到下一个元素。这样写循环特别像用迭代器简洁有力。4.2 二维数组和数组指针int arr[3][4]是3行4列的二维数组。在内存里它其实是连续存放的12个int按行优先排列。这里有一个著名的复杂声明int (*p)[4]; // p指向“含4个int的数组” int *q[4]; // q是“含4个int指针的数组”两者长得像含义却完全不同。括号改变了结合顺序int (*p)[4]先看到*p说明p是指针再看到[4]说明它指向一个长度为4的int数组。这种指针叫“数组指针”。int *q[4]先看到q[4]说明q是数组数组里面每个元素是int*。这种数组叫“指针数组”。用的时候int arr[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; int (*p)[4] arr; // p指向第一行一个长度为4的数组 for (int i 0; i 3; i) { for (int j 0; j 4; j) { printf(%2d , p[i][j]); // 或 *(*(pi)j) } printf(\n); }arr[i][j]在才能上是*(*(arri)j)先定位到第i行的数组再取第j个元素。数组指针对应的也就是这种行指针的语义。4.3 字符串指针和字符数组看似一样天差地别字符串相关的问题几乎是C语言面试必考。看这段代码char *s1 hello; char s2[] hello;区别在于s1是一个指针指向字符串字面量。字符串字面量通常存储在只读数据段尝试s1[0] H;是未定义行为多数平台会崩溃。s2是一个长度为6的字符数组含末尾的\0它把字符串字面量拷贝到了自己的栈空间可以随意修改s2[0] H;。很多初学者写了char *s abc;之后试图修改运行直接段错误一脸懵。看一眼就明白了你用指针指向了只读区域当然不能写。如果想让函数接收字符串并安全读取习惯上写void print_string(const char *s) { while (*s) { putchar(*s); s; } }while (*s)的判断逻辑很C语言*s是非零字符则继续循环读到\0数值0则停止。这种写法比for (int i 0; s[i] ! \0; i)更地道也更能体现指针的流动感。4.4 指针数组的应用字符串表指针数组最常见的场景是管理多个字符串const char *names[] { 张三, 李四, 王五, 赵六 }; for (int i 0; i 4; i) { printf(%s\n, names[i]); }names是一个数组里面每个元素都是const char*指向不同的字符串常量。这个结构非常像一个小型数据库的表适合存菜单项、错误信息、状态名等。5. 更实用的进阶指针的指针、函数指针、结构体指针语法上再往上走一层就是“指针的指针”和“函数指针”。这一节是很多教程里讲得最快但实际工程中又特别常用的部分。5.1 二级指针想改指针本身就要拿指指针的指针一级指针int *p用来修改它所指向的普通变量。如果想在函数里修改调用方传入的指针变量本身比如让指针指向另一块新内存就得用二级指针也就是“指针的指针”。一个典型场景是链表插入void insert_head(struct Node **head, int data) { struct Node *new_node malloc(sizeof(struct Node)); new_node-data data; new_node-next *head; *head new_node; // 通过二级指针修改外部的head }这里为什么不能只用struct Node *head因为如果传head本身函数内部修改head只会改副本外部的头节点指针不变。只有传head也就是二级指针才能从函数内部把外部的头节点指针挪到新节点上这才达到“插入头部”的效果。另一个常考知识点是动态二维数组。如果你要分配一个int** matrix每行长度可以不同就需要二级指针。底层逻辑是matrix本身是一个int**它指向一个int*数组这个数组里的每个int*又指向一行数据。实际使用不要忘了释放先释放每一行再释放行指针数组。5.2 函数指针把函数当作参数传递C语言里函数本身也有地址。函数名就代表函数的入口地址。函数指针就是保存这个入口地址的变量。声明格式看起来吓人但拆开看很逻辑int (*handler)(int, int);这是声明一个指针handler它指向一个函数该函数接受两个int返回一个int。回忆之前数组指针的读法先看到括号里的*handler知道它是指针再看到(int, int)知道它指向的函数的参数最左边的int是返回值。最简单的用法#include stdio.h 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) add; printf(%d\n, op(3, 4)); // 7 op sub; printf(%d\n, op(8, 3)); // 5 return 0; }函数指针最大的作用就是“回调”。比如写一个工具函数对一个数组里的每个元素执行某种操作void for_each(int *arr, int n, int (*func)(int)) { for (int i 0; i n; i) { arr[i] func(arr[i]); } }调用时把square、double_it这类函数传进去这个工具函数就能复用。C标准库的qsort也是这样设计的它接受一个比较函数的指针由用户决定排序规则。理解函数指针后你对“函数是一等公民”这类说法也会有更明确的C语言视角理解。5.3 结构体指针隐藏的-运算符结构体在C语言中是组合数据的方式结构体指针则是最常用的访问方式。核心只有一个运算符记忆点(*ptr).member和ptr-member完全等价。因为-太常用C语言专门设计了这个符号免得你写一堆(*p).next。一个链表示例struct Node { int data; struct Node *next; }; void print_list(const struct Node *head) { const struct Node *cur head; while (cur ! NULL) { printf(%d - , cur-data); cur cur-next; } printf(NULL\n); }注意const struct Node*和struct Node* const的搭配。上面写法表示“不能通过 cur 修改节点内容”但 cur 本身可以移动适合只读遍历。写成struct Node *const cur才表示指针本身固定你想遍历数组的时候就不能这么干。结构体指针在工程中一个隐蔽的好处是传参成本低传结构体本身则整个结构体要复制一份传指针只复制一个地址。如果结构体很大比如有几十个字段传值会白白消耗大量栈空间和拷贝时间所以C工程里几乎全部用指针传递。5.4 指向结构体的指针和自引用struct Node *next;为什么可以在结构体定义里用指向自己的指针因为编译到这里时struct Node这个类型名已经足够用来声明指针了。指针只需要尺寸信息不需要完整的结构体布局而指针的尺寸编译器已经知道。读到这里你可以想想“如果不连续分配链表怎么串起来”——答案就是每个节点都存了下一个节点的地址。这是一种非常常见的“自引用结构”也是链表、二叉树、图这些数据结构的地基。6. 我踩过的指针的坑排查崩溃和野指针说真的任何学指针的人都会经历一段“三天两头段错误”的日子。我把常见的问题整理成一张排查表再讲几个排查思路能帮你少踩很多坑。现象常见原因对策程序一运行就段错误定义了未初始化的指针并解引用初始化int *p NULL;函数里修改实参失败只传了值没传地址改传指针char *s abc; s[0]A崩溃指向字符串字面量只读区域改用char s[] abc;指针越界访问数组p超出了数组范围仍继续解引用遍历时检查边界释放内存后继续使用悬垂指针释放后立即置NULLfree时崩溃指针不是malloc/返回的原始地址比如加了偏移保存原始指针再free打印乱码类型不匹配如%d打印char*地址严格使用%p和类型转换6.1 野指针和悬垂指针野指针没有指向有效对象的指针。比如声明了没赋值或者指向了已离开作用域的局部变量。悬垂指针指针曾指向有效内存但该内存已被释放或回收。典型悬垂例子int *get_pointer(void) { int local 1; return local; // 错误local在函数结束后就不存在了 }local是栈上变量函数返回后栈帧被销毁地址虽然还能指向那块区域但内容已不受保护。第二次调用其他函数很可能会覆盖这块区域。返回值必须是malloc出来的堆内存或者指向静态存储区的变量。平时写代码我习惯在释放指针后立刻补一句free(p); p NULL;这样后续如果误用了p至少会在if(p)时发现是空指针不至于解引用一块已经被回收的内存。6.2 排查思路先确认指针是谁给的遇到崩溃第一步不要盲目改代码先问自己三句话指针变量的初始值是什么是取自还是数组名还是malloc还是函数参数在解引用之前指针是否可能被改成了别的值比如越界、自增过头、被意外的p ...覆盖这个指针指向的内存的“生命周期”是否还在有一个小技巧在怀疑的指针附近打印地址和值printf(p%p, *p%d\n, (void*)p, *p);如果p打印出0x0说明是空指针如果打印出一个看似随机的地址就要查指针的来源和赋值是否被破坏。6.3 编译器是你的第一位调试员早年间我学C没开警告就瞎撞。后来才知道Windows上IDE或Linux上GCC编译时加-Wall -Wextra能立刻抓出一堆指针问题。gcc -Wall -Wextra -stdc11 -o program program.c比如未初始化局部变量、类型不匹配、符号比较等问题编译器都能提示。别忽略警告警告不是“可看可不看”大多数时候它已经指出了你接下来几个小时的排查方向。7. 指针题目参考答案之外的思考方式保姆级教程的最后一环我想给你一套做题和自测的方法。C语言指针题目范围很广但形式就那么几种声明解读、传参改值、字符串操作、动态内存、链表。很多新手卡住是因为“只会抄代码不会画图”。7.1 画内存图学习指针的最强工具我强烈建议你在纸上或白板上画出每个变量的方框和地址。比如int a 10; int *p a; int **pp p;画三个方框a方框里写10p方框里写a也就是a方框的地址pp方框里写p。然后你就会发现*pp等价于p**pp等价于a。这比背一万句“二级指针指向一级指针”都管用。内存图能避免一个特别常见的思维错误——把当成一个普通的数值符号而不知它是“某某方框的坐标”。多画几次概念会自然沉淀成直觉。7.2 动手复刻这几个经典函数不要拿《 C程序设计语言》等经典书只读不练。下面这组函数每个都值得独立实现再做优化my_strlen(const char *s)计算字符串长度。my_strcpy(char *dest, const char *src)把src拷贝到dest。my_strcmp(const char *a, const char *b)比较字符串大小。my_atoi(const char *s)把字符串转成整数。逆序字符串输入hello输出olleh要求用一个指针从前往后一个指针从后往前。实现链表节点的头插、遍历、删除删除时要修改被删除节点的前一个节点的next。这些题目刚好把指针的核心用法都过了一遍遍历、步长、数组衰减、字符操作、结构体指针、二级指针。热搜里也有“字符串逆序c语言pta”说明这是很多人学指针时的拦路虎。给一个逆序字符串的参考实现void reverse_string(char *s) { char *left s; char *right s; while (*right) { right; } right--; // 指向最后一个非\0字符 while (left right) { char temp *left; *left *right; *right temp; left; right--; } }代码本身不难关键是两个指针相向移动。这能顺便理解“指向尾部”和“指向最后一个有效字符”的微妙区别。7.3 进一步在真实项目中体会指针如果你已经把上面的函数都写通了我建议去做一个带点实际意义的小项目比如自己写一个文本统计工具读取一个文件统计单词数、行数、字符数用指针遍历缓冲。写一个保存学生信息的链表增删查改用二级指针处理头插。重写qsort的调用用函数指针实现按姓名排序、按分数排序。在这些项目里你会真正感受到指针不是用来“考试”的而是C语言运作的最小骨骼。文件的读写、链表的组织、回调机制全都离不开它。我个人回顾学指针的过程最想强调的一句话是不要害怕段错误而要提前用“内存图初始化边界检查编译警告”把自己保护起来。指针报错确实刺眼但它的每次崩溃都在告诉你“你还不了解这台机器具体在哪找数据”。跑通几个函数、画了几张图之后指针带给你的就不再是头疼而是对程序运行本质的掌控感。