ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C语言指针彻底搞懂:内存地址、*p、p、**p核心原理与实战避坑

2026/9/20 9:56:51 拓冰建站 浏览量
C语言指针彻底搞懂:内存地址、*p、p、**p核心原理与实战避坑 C语言这玩意儿劝退率最高的概念绝对是“指针”没有之一。尤其是*p、p、p这三个符号来回切换再加上一个**p很多初学者直接原地爆炸。我这些年带过不少人也看了无数帖子发现大家卡住的根本原因不是笨而是没人把“内存地址”这件事儿真正用大白话讲透。这篇文章我打算换个讲法不搞玄学直接给你把内存模型铺开用一套贯穿全文的例子把p、*p、p、**p的底层逻辑、类型关系、运算规则一次说清楚。同时把所有相关的函数传参、数组陷阱、常见段错误全部串起来讲保证你看完能彻底“通”一次而不是死记硬背“星号就是取值”这种半吊子口诀。1. 先翻底牌变量、地址和内存模型很多人一上来就背“指针就是地址”其实这句话害人因为“地址”这两个字太抽象。咱们先把计算机内存想成一栋巨大的公寓楼每个房间有唯一的门牌号比如0x7ffeefbff5a8。你定义一个int a 10系统就在公寓楼里给你分配一个房间门牌号假设是0x7ffeefbff5a8房间里面放着数字10。1.1 变量名只是“帮主人找房的标签”你写的a这个变量名本质上就是一个标签方便程序员在代码里引用那个房间。但CPU并不认识aCPU只认门牌号也就是地址。编译器在编译时会把a转换成对应的地址0x7ffeefbff5a8然后去这个地址读写数据。这里有个关键点类型决定了这个房间里放的数据怎么解释。同样的地址0x7ffeefbff5a8如果按int类型读取出4个字节解释成一个整数10如果按char类型读只取1个字节解释成ASCII码。地址是死的类型是活的这个思维贯穿整个指针学习务必记住。1.2 指针变量也是“普通变量”只是里面存的是门牌号int *p a这行代码很多初学者把它理解成“p变成了a”。错大错特错。真实情况是系统又分配了一个新的房间门牌号假设是0x7ffeefbff5b0这个房间里面存放的内容不是普通数字而是别人的门牌号——a的房间号0x7ffeefbff5a8。所以指针变量也是变量它自己也有地址只不过它存储的数据很特殊是另一个变量的地址。既然它有地址你就可以再用一个指针去存放它的地址这就引出了二级指针。很多人在这一步就绕晕了其实就是公寓楼套公寓楼的问题一层层往下指就是了。你只要心里始终装着“门牌号”和“房间里放的啥”这两件事指针就不可怕。2. 全网最彻底的 p、*p、p 三角关系拆解下面咱们进入正戏。先放一个贯穿全文的“标本”后面所有解释都围绕它展开int a 10; int *p a;我假设a的地址是0x1000p这个指针变量自己也有个地址假设是0x2000。拿着这两个数字整个指针世界瞬间清晰了。为了让你一眼看明白我先把结论放出来后面再逐步验证。2.1 一张表把 p / *p / p 全部说清表达式含义类型值在本例中a变量a自己房间里放的数字int10a变量a的房间门牌号int*0x1000p指针变量p房间里放的数字int*0x1000*p访问p房间里记着的门牌号去那个房间取数据int10p指针变量p自己的房间门牌号int**0x2000这张表是整个文章的灵魂你把它抄在本子上对照着往下读。咱们逐步拆解每一个p是什么它就是一个普普通通的变量只不过它的值是0x1000恰好是a的地址。你在代码里写printf(%p, p)打印出来的就是0x1000。*p是什么这叫“解引用”。它的执行过程分两步第一步取出p房间里的内容拿到门牌号0x1000第二步拿着这个门牌号去0x1000房间把里面的10取出来。所以*p的值是10。p是什么它是在问“p这个房间本身的门牌号是多少”答案是0x2000。很多人老是忘记指针变量自己也有地址总觉得指来指去就指到天边了。不任何变量都有地址指针变量也不例外。p的类型是int**因为它是“指向一个int*变量的地址”。2.2 动手验证用代码把每个表达式的值打出来光看理论容易飘咱们直接上代码把上面的结论全部验证一遍。这段代码你可以原封不动地复制到你的编译器里跑#include stdio.h int main(void) { int a 10; int *p a; printf(a %d\n, a); printf(a %p\n, (void*)a); printf(p %p\n, (void*)p); printf(*p %d\n, *p); printf(p %p\n, (void*)p); return 0; }你跑完之后会发现p打印出来的值和a一模一样*p打印出来的值和a一模一样p是一个全新的、跟a完全不同的地址。这就是最直观的证据存入p的值就是a的地址而*p就是通过这个地址间接找到了a房间并读出内容。我特别强调一点读代码的时候心理活动一定要是“p这个房间里放的是0x1000*p就是按照0x1000这个门牌号去找房间拿数据”而不是单纯念“p等于a”。前者是理解内存后者只是背概念。2.3 为什么* p可以做左值类型和步长是关键很多教材里说*p可以出现在赋值号的左边比如*p 20这时候a的值就变成了20。为什么因为*p本质上是“用地址去定位一个房间”它已经定位到了a所在的房间*p 20就等价于“给a房间重新放一个数字20”。这里顺势引出一个特别重要的概念指针的类型决定了1跳多少字节。p的类型是int*所以p1的地址是0x1004假设int占4字节而不是0x1001。如果p的类型是char*那p1就是0x1001。这就是为什么指针必须声明类型——编译器需要知道每次寻址的“步长”否则它不知道该跳多远。同理a的类型是int*p的类型是int**你绝对不能搞混。类型不仅是给编译器看的规则更是你自己判断代码对不对的思维工具。我一看到有人写int *q p;就知道他类型没整明白因为p是int**不能直接塞给int*。3. * p 和 ** p二级指针到底多挖了一层什么搞懂了p和p二级指针**p就成了顺理成章的事。既然指针变量p自己也有地址0x2000那我再定义一个指针变量pp让它存放p不就行了int **pp p;这里pp的类型是int**读作“指向int*的指针”俗称二级指针。它房间里的内容是0x2000也就是p的地址。你可能会问这绕来绕去到底图个啥别急后面的函数传参部分你就会看到它的巨大价值。3.1 二级指针的三个表达式pp、* pp、** pp咱们还是拿着地址数字来推导不玩虚的。假设a的地址是0x1000p的地址是0x2000pp的地址是0x3000。那么表达式含义值pppp房间里放的数字0x2000*pp去0x2000房间p变量读内容0x1000**pp先取0x2000房间内容得0x1000再去0x1000房间读内容10仔细看*pp的值恰好等于p的值都是0x1000**pp的值恰好等于*p的值都是10。这是一层一层剥洋葱的过程每个星号都是一次“解引用”剥掉一个星号就是顺着地址往下走一层。**pp比*p多一个星号就是多绕了一层最终到达的还是a变量。3.2 从一星到两星每一颗星都代表一次“间接”我特别喜欢用一个比喻来解释如果p是“张三家的门牌号”那么*p就是“去张三家里拿东西”pp是“记录张三门牌号记在哪张纸条上的信息”那么*pp就是“先去拿那张纸条从纸条上看到张三家的门牌号”**pp就是“拿着纸条上的门牌号去张三家里拿东西”。这就是“间接寻址”的魅力所在多一层指针就多一层间接。实际开发中你很少需要超过二级的指针因为层数越多代码越难读逻辑越容易乱。如果你看到三层、四层指针的代码大概率是设计出了问题赶紧重构。3.3 顶层指针和底层指针能不能互相赋值说到这顺便提一个很多教材爱考的点const int *p底层指针指向的内容不可变和int *const p顶层指针指针本身不可变。这两个概念跟“*p能不能改”直接相关也容易跟二级指针混在一起。简化理解const int *p意味着*p不能赋值但p可以指向别处int *const p意味着p不能指向别处但*p可以改。规则是底层指针不能用来修改它指向的值所以你不能把一个const int*直接赋值给int*否则等于绕过了const限制。顶层指针的复制则相对宽松。这块内容建议结合二级指针一起学因为int **和const int **之间也存在类似的不兼容问题面试题里特别喜欢挖这个坑。4. 为什么需要二级指针函数参数里的值传递陷阱光讲概念不落地等于白学。下面看二级指针最经典的应用场景——在函数内部修改外部指针变量本身。这也是很多学员第一次写链表时翻车的地方。4.1 C语言函数参数一律是值传递所有人都知道“C语言函数传参是值传递”但很多人没意识到传指针也是值传递你的形参p拿到的是实参的“拷贝”不是实参本身。你修改形参p不会影响实参。void wrong_alloc(int *p) { p (int*)malloc(sizeof(int)); *p 42; }在这个函数里p是实参的拷贝你在函数里让p指向新分配的内存确实改变了形参p但调用方的指针变量仍然是原来的值。等函数返回后你分配的内存就“丢”了内存泄漏而调用方的指针依然没指向有效内存后面*p就是野指针操作。4.2 错误示范试图用一级指针在函数内分配内存直接上个能说明问题的简化版代码#include stdio.h #include stdlib.h void wrong_alloc(int *p) { p (int*)malloc(sizeof(int)); // 修改的是形参拷贝 *p 42; // 假设malloc成功这里能写进去 } int main(void) { int *p NULL; wrong_alloc(p); if (p ! NULL) { printf(%d\n, *p); } return 0; }你运行这个程序大概率什么都打印不出来因为p在main函数里还是NULLwrong_alloc里分配的地址没有传回来。这就是典型的值传递陷阱你传了指针的值但没传“指针变量的地址”。4.3 正确示范传 p让函数内能直接修改实参指针这时候二级指针就登场了。如果我们把p传给函数形参就是int **pp在函数内部*pp就能直接修改外部实参p的值void right_alloc(int **pp) { *pp (int*)malloc(sizeof(int)); **pp 42; } int main(void) { int *p NULL; right_alloc(p); if (p ! NULL) { printf(%d\n, *p); // 输出 42 free(p); } return 0; }为什么能成功因为p传进去之后*pp就相当于外部变量p本身。*pp malloc(...)等价于p malloc(...)这一步就真的把实参指针给改了。这也是为什么链表的头插操作、二叉树的插入操作里经常出现二级指针的原因——它们都需要修改指针变量本身而不仅仅是修改指针指向的内容。4.4 顺带一提链表的头插为什么也常见 **head很多人在学链表时总被**head搞迷糊。其实道理完全一样你想在链表头部插入一个新节点前提是“头指针要更新”让它指向新节点。这个头指针head本身就是个指针变量你要修改它就必须传它的地址也就是用struct Node **head接收。如果你只用一级指针struct Node *head你在函数里把形参head改了外部的主头指针还是原来的等于插了个寂寞。所以记住这句话**只要函数要修改“指针变量本身的值”就得上二级指针。**今天你搞懂了p和**pp的关系链表这里的**head就是顺手的事。5. 数组名、数组名、指针运算地址的“步长”陷阱接下来聊一个和p异曲同工但特别容易踩坑的地方——数组名。很多人写了几年C遇到arr和arr还是会懵因为两者的值是同一个地址但类型完全不同导致加减运算结果天差地别。5.1 int arr[5]arr 和 arr 的类型与值咱们写int arr[5]假设数组首地址是0x5000。arr在这里是一个指向数组首元素arr[0]的指针严格说它在大多数表达式中会“退化”成int*所以arr的值是0x5000步长是4字节arr1是0x5004。而arr的类型是int (*)[5]也就是指向整个数组的指针它的值虽然也是0x5000但步长是整个数组的大小20字节arr1直接变成0x5014。表达式类型值1的结果arrint*0x50000x5004arr[0]int*0x50000x5004arrint (*)[5]0x50000x5014很多人只记“值一样”忘了类型不一样结果一算指针加减就翻车。这道题在面试里出现频率极高本质考的就是“指针类型决定步长”这个核心概念。5.2 常考题完整推导* (arr) 到底是啥来咱们推导一道经典题*(arr)等于什么arr是“指向整个数组的指针”类型是int (*)[5]。对它解引用一次得到的是它指向的那个东西也就是整个数组arr类型是int[5]。在表达式中这个数组又退化成了指向首元素的指针int*所以*(arr)最终的值是0x5000类型是int*。发现没有*(arr)和arr的值一样、类型也一样。于是很多人就以为*可以随便抵消其实那是之后又被“数组退化”规则绕回去了。这种题就像剥洋葱一层层看类型和值别凭感觉。5.3 二维数组和二级指针真不是一回事顺带强调一个高频误区二维数组名不是二级指针。int matrix[3][4]的类型是int (*)[4]也就是指向“长度为4的int数组”的指针。int **pp是“指向int*的指针”两者的步长和内存布局完全不一样。二维数组在内存里是连续排布的matrix[i][j]等价于*(*(matrixi)j)靠的是编译器静态计算偏移二级指针则是一个指针指向另一个指针可能指向动态分配的、不连续的内存。你可以把二维数组理解成“一维数组的一维数组”编译器知道每一行有多长所以能用算法计算偏移。而二级指针本身只存了一个地址你要拿它模拟二维矩阵还得自己手动分配每一行。两者不能混用否则轻则警告重则段错误。6. 指针相关的常见报错和排查经验指针学习中最让人崩溃的就是报错而且往往是“编译过了运行就崩”——段错误。这一节我不讲教科书上的长篇大论直接把我实际操作中遇到的几种情况和排查办法倒出来希望能帮你省点掉头发的时间。6.1 段错误的本质是访问了非法内存段错误说到底是程序访问了不属于它的内存地址。常见场景有解引用空指针*p 10p是NULL、解引用野指针p指向已释放或未分配的内存、数组越界越到别的进程的地盘上。排查段错误的第一方法就是加打印二分定位。先用printf在代码各个关键点输出确认崩在哪一行然后把范围缩小再仔细检查那附近的指针状态。别上来就瞎改没有调查就没有发言权。6.2 野指针的四种来源我总结过野指针的诞生往往逃不出这四种情况定义了指针但没初始化里面是随机值free之后没把指针置为NULL指针变成悬空指针返回了局部变量的地址函数栈帧销毁后地址无效这个非常隐蔽指针运算越界指向了数组范围外。对付这些毛病的办法很简单指针定义时一律初始化为NULL释放后立刻赋值NULL函数不要返回局部变量地址可以返回堆内存地址或使用输出参数指针加减后时刻盯紧边界条件。养成这些习惯你能躲过至少八成指针炸弹。6.3 二级指针使用中最容易翻车的三个点用二级指针时我见过很多人栽在下面三个地方第一忘了给二级指针本身分配空间。如果int **pp只是声明了没有让它指向一个合法的int*变量你直接*pp malloc(...)就会段错误因为你写到了不知道什么地方去。第二把二维数组和二级指针混用。前面说了两者类型完全不同强行赋值编译器就会警告运行起来更是风险极高。老老实实用对应的类型别贪方便。第三free的时候只free了内层忘了free外层。用二级指针模拟二维矩阵时如果你先free了外层指针那内层每一行的地址就全丢了内存泄漏非常严重。正确顺序是先free所有内层再free外层。6.4 打印地址、GDB、Valgrind 三板斧排查指针问题我强烈建议你掌握这三个工具第一printf打印地址。%p格式化输出地址是最朴素的观察手段。打印p、p、*p能快速确认指针指向哪、指针本身在哪。第二GDB调试器。print p、print *p、print p能边运行边查看变量状态。watch命令还能监视某个地址是否被修改抓野指针特别好用。遇到段错误直接跑bt命令看崩溃调用栈。第三Valgrind。这是内存问题的神器跑一次就能把非法读写、内存泄漏、使用未初始化值全给你列出来。在Linux上装好之后直接valgrind ./a.out它会报告具体的错误行号。我用它抓出过无数个自己盯半天都发现不了的越界写。7. 我的实操体会今天的内容怎么用起来最后说点个人体会。指针这个东西你真的不能只靠看文章学会必须自己动手写、动手画。我建议你拿出纸笔把文章里的那几个例子的内存图画一遍地址编号用最简单的0x1000、0x2000这种然后手动推导*pp、**pp分别是多少。画图这个习惯我从初学一直保留到现在遇到再复杂的指针组合也几乎不绕晕。画完图之后再把几个经典代码亲手敲一遍交换两个整数值的swap、在函数内分配内存返回给调用者的代码、链表头插操作。每一段都改一改参数一级指针改成二级指针亲眼看看行为和报错的变化。这个过程比看一百篇教程都管用。另外一个特别实用的小建议写指针相关的代码时把类型注释写出来。比如你写int **pp p;心里默念“pp是int**指向int*变量pp是int指向int变量**pp是int就是a”。初期甚至可以写成注释int **pp p; /* pp: int*, 保存指针变量p的地址 */这种“边写边念类型”的习惯能帮你避免大量低级错误。等你哪天发现不用注释也能条件反射地说出每个表达式的类型和值恭喜你指针这关你是真的过了。这篇文章写到这里该讲的坑和用法都讲到了。你如果从头跟着推导下来应该能明显感觉到p、*p、p、**p不再是四个孤立的符号而是同一套内存寻址逻辑下的不同表达。指针不可怕可怕的是不画图就硬想。拿起笔把你的地址编号写下来比什么都强。