11_指针入门_地址指针变量解引用与指针运算
指针入门地址、指针变量、解引用与指针运算一、本篇文章要解决什么问题如果你问学过 C 语言的人什么最难十个人里有八个会说指针。不是指针本身有多难而是它在教法上出了很多问题很多教材一上来就扔给你int *p a; *p 10;这种代码然后让你死记硬背星号是解引用取地址符是取地址。你背是背下来了但回到座位上还是不知道指针到底在干什么。这篇文章要做的不是让你背公式而是帮你从内存的角度真正理解指针。具体要回答这些问题为什么要发明指针没它行不行内存地址到底是什么长什么样指针变量和普通变量有什么区别和*到底在做什么什么是空指针什么是野指针为什么野指针比编译错误可怕得多指针加减 1 为什么有时候加 1 个字节有时候加 4 个字节这篇文章是四篇指针系列的第一篇是所有指针知识的地基。看完这篇后面讲数组和指针、函数和指针的时候你才不会觉得像在看天书。二、先用一个简单例子理解2.1 图书馆找书的故事假设你在一座大图书馆里找一本书。有三种办法知道书在哪办法一你手里直接拿着一本实体书。任何时候翻开就能读但你只能同时拿一本书换书就得跑回书架。这个直接拿着书就相当于 C 语言里的普通变量。办法二你手里不拿书而是拿一张纸条上面写着这本书在 3 楼 A 区第 7 排第 12 格。你顺着纸条上的信息去找就能拿到书。而且一张纸条可以随时改成指向另一本书。这个纸条上记着书的位置就是C 语言的指针。办法三你手里拿一张纸条上面写着看第二张纸条第二张纸条上才写着书的位置。这就是二级指针第 12 篇会讲。2.2 为什么需要纸条指针现在问你如果你要写一个函数这个函数要能修改外面某个变量的值怎么办上一篇讲函数的时候说过C 语言默认是值传递——你把变量传给函数函数拿到的是一个副本改了副本不影响原变量。voidaddTen(intx){xx10;// 改的是副本外面的变量不变}那如果我就是想让函数能修改外面的变量呢比如写一个swap函数交换两个变量的值这时候就需要指针了。你不把值给函数而是把变量的地址给函数。函数顺着地址找到变量本身就能修改它。就像你告诉朋友那本书在书架第三格朋友就可以自己去拿书或换书——不需要你亲自跑一趟。这就是指针存在的核心原因让一段代码能够访问和修改另一段代码里的数据而不需要把整个数据复制一份。三、核心知识点讲解3.1 什么是内存地址要理解指针先要理解内存。把计算机内存想象成一条超级长的街道街道两边排列着一栋栋房子。每栋房子有两个属性门牌号地址比如 0x1000、0x1001、0x1002……这是唯一标识这个房子的编号里面的住户数据比如整数 10、字符 ‘A’这是真正存的东西内存模型简化版 地址: 0x1000 0x1001 0x1002 0x1003 0x1004 0x1005 0x1006 0x1007 ┌────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┐ 内容: │ │ │ │ │ │ │ │ │ └────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┘ -- 每个格子 1 个字节 -- 当你写: int a 10; 编译器在内存里找一个空闲区域占 4 个连续字节因为 int 是 4 字节 a 这个变量名就是这 4 个字节的别名。 地址: 0x1000 0x1004 ┌──────────────────────────┐ 内容: │ 10 00 00 00 │ -- a 的值 10二进制形式存储 └──────────────────────────┘ ↑ a 的地址是 0x1000取这 4 个字节的起始地址关键理解在 C 语言里每个变量在定义的时候编译器就在内存里给它分配了一块空间。这块空间的起始字节的编号就是这个变量的地址。你可以用运算符拿到这个地址。3.2 运算符取地址是取地址运算符。把它放在一个变量前面就得到这个变量在内存中的地址。inta10;printf(a 的值是%d\n,a);// 输出 10printf(a 的地址是%p\n,(void*)a);// 输出类似 0000008A3C6FF794%p是专门用来打印地址的格式占位符。你每次运行程序输出的地址可能不一样——这是操作系统分配的不同机器、不同时刻运行结果都不同。所以不要指望地址是一个固定的数字你只需要理解它是一个能唯一找到某个变量的门牌号。图11-1 变量、地址与指针的关系重点这是全篇最核心的图帮读者一次看清楚普通变量、指针变量、取地址、解引用四个概念的关系。3.3 指针变量一个专门存地址的变量知道了地址是什么指针变量就好理解了指针变量就是一个变量但这个变量不存常规数据而是存另一个变量的地址。inta10;// 一个普通整数变量存的是 10int*pa;// 一个指针变量存的是 a 的地址怎么读int *p很多初学者卡在指针的语法上。推荐你这样理解int *p的意思是*p是一个int类型的数据倒推回去p是一个指针它指向的那个数据是int类型的或者更简单的读法“p 是一个指向 int 的指针”。3.4 内存示意图变量、地址、指针的关系下面这张图是理解指针最关键的一张图。建议你花 3 分钟认真看假设 a 的地址是 0x1000p 的地址是 0x2000 内存布局 地址 0x1000 (变量 a 的起始地址) ┌─────────────────────┐ │ 10 │ ← a 的值int 占 4 字节 └─────────────────────┘ ↑ │ p 保存了这个地址 │ 地址 0x2000 (变量 p 的起始地址) ┌─────────────────────┐ │ 0x1000 │ ← p 的值存的是地址通常占 4 或 8 字节 └─────────────────────┘ 关系 a 的值是 10 a 的值是 0x1000 p 的值是 0x1000也就是 a *p 的值是 10 顺着 p 里存的地址找到的那个数据) 口诀 是问你在哪 —— a 得到 a 的地址 * 是去那个地址看看里面有什么 —— *p 得到 p 指向的那个地址里的内容3.5 * 运算符解引用间接访问*放在指针变量前面意思是顺着这个指针里存的地址去找到那个地址里存的数据。这个操作叫解引用也叫间接访问。inta10;int*pa;printf(*p %d\n,*p);// 输出 10因为 *p 就是 a*p20;// 通过 p 间接修改 a 的值printf(a %d\n,a);// 输出 20a 真的被改了运行结果*p 10 a 20重要区别——定义时的 * 和使用时的 * 不是一回事int*pa;// 这里的 * 表示p 是指针类型不是解引用*p20;// 这里的 * 是解引用操作顺着 p 指向的地址去修改数据同一个符号*在定义语句中是声明指针类型在执行语句中是解引用。初学者经常混淆这两个含义多写几次就习惯了。图11-2 指针修改外部变量的流程帮读者理解指针在函数参数传递中的核心价值为第 13 篇做铺垫。3.6 指针的初始化指针在定义时必须初始化或者至少给它一个明确的初始值。这是 C 语言里最严格的安全纪律。正确做法——定义时初始化inta10;int*pa;// 定义的同时初始化为 a// 或者先定义再赋值int*q;qa;// 正确q 现在指向 a危险做法——定义指针但不初始化int*p;// p 里面是一个随机值*p10;// 把 10 写到随机地址里——程序可能崩溃或者更糟静默破坏数据int *p;定义了一个指针变量但没有给它赋值。这时候 p 里面存的是什么是一个随机的、不确定的地址值。这个值可能是 0x00000001也可能是 0x8A3C6FF7——取决于这块内存上一次被用过之后残留了什么数据。然后你*p 10;试图把 10 写到这个随机地址里。这相当于你随机拨了一个电话号码然后对那个人说你家沙发上现在放了一本书——轻则没人理你程序崩溃重则真有人把书放上去了破坏了其他数据。图11-3 指针运算步长示意图重点直观解释为什么指针 1 不是加 1 个字节这是指针运算最容易困惑的地方。3.7 空指针——一个安全的空纸条为了让未指向任何有效数据的指针能被识别C 语言规定了一个特殊值NULL。int*pNULL;// p 明确地什么都不指if(p!NULL)// 使用前检查{*p10;// 安全确认 p 是有效的才访问}NULL 的本质是一个等于 0 的地址。从 C 语言标准的角度解引用 NULL 属于未定义行为——在实际的现代操作系统Windows、Linux、macOS上地址 0 附近的内存受保护、不可读写因此解引用 NULL 通常会导致程序立即崩溃。虽然崩溃听起来可怕但这其实是好事问题能被立刻发现和定位比静默出错容易排查得多。图11-4 空指针、野指针、悬空指针对比图让初学者一眼区分三种危险指针知道什么情况用什么名字。3.8 野指针——未初始化的危险指针野指针是指从未初始化过的指针变量。它不等于 NULL里面存的是一个随机的、不确定的地址值。因为if (p ! NULL)检查不出来使用它会导致未定义行为——程序可能崩溃也可能悄悄破坏其他数据。错误示例不要复制运行仅用于理解概念int*p;// 野指针p 未初始化里面是随机值*p10;// 危险把 10 写到随机地址——程序可能崩溃3.9 悬空指针——指向已作废的地址悬空指针是曾经指向一个有效对象但该对象的生命周期结束后指针仍然保留着原来的地址。它不等于 NULL指向的地址曾经合法但现在已无效访问它同样是未定义行为——而且比野指针更隐蔽因为代码可能在测试时正常运行换一个环境或时机就崩溃。错误示例不要复制运行仅用于理解概念#includestdio.hint*getPointer(void){intx10;returnx;// 危险x 是局部变量函数返回后 x 的内存就失效了}intmain(void){int*pgetPointer();// p 现在是一个悬空指针——指向已释放的栈内存// printf(%d\n, *p); // 未定义行为可能正常也可能崩溃return0;}正确做法不要让函数返回局部变量的地址。如果确实需要让函数产出一个指针要么返回动态分配的内存第 19 篇讲要么让调用者传入地址让函数去填充。3.10 空指针、野指针、悬空指针三者区别重点很多教程把这三个概念混在一起讲初学者越看越迷糊。分开说类型定义值解引用后果空指针明确赋值为 NULL 的指针NULL即 0程序立即崩溃好能被发现野指针从未初始化过的指针随机值未定义行为——可能崩溃可能悄悄破坏数据差难以排查悬空指针指向内存已经被释放的指针原来合法但现在无效的地址未定义行为——代码可能正常运行几个月后才出问题最差int*p1NULL;// 空指针——安全的int*p2;// 野指针——未初始化p2 里是随机值// 悬空指针示例int*p3;{inttemp42;p3temp;// p3 指向 temp}// temp 被销毁p3 变成悬空指针3.11 指针运算为什么指针 1 不是加 1 个字节先看一段代码它的结果会让很多初学者感到意外#includestdio.hintmain(void){intarr[3]{10,20,30};int*parr;printf(p %p\n,(void*)p);printf(p 1 %p\n,(void*)(p1));printf(p 2 %p\n,(void*)(p2));return0;}运行结果具体地址每次运行不同但差值固定p 0000008A3C6FF790 p 1 0000008A3C6FF794 p 2 0000008A3C6FF798观察到什么了p 1的地址比p多了4不是 1。p 2多了8。原因p是int *类型它知道自己指向的是int占 4 字节。所以p 1的意思是往后跳过一个int的距离而不是往后偏移 1 个字节。这个跳过的距离就是sizeof(int)也就是 4 字节。内存示意图假设 int 占 4 字节 arr[0] 10 arr[1] 20 arr[2] 30 ┌─────────────────┬─────────────────┬─────────────────┐ │ 10 │ 20 │ 30 │ └─────────────────┴─────────────────┴─────────────────┘ ↑ ↑ ↑ p p1 p2 地址差 4 地址差 4 如果 p 是 char *p1 就只偏移 1 字节因为 sizeof(char) 1 如果 p 是 double *p1 会偏移 8 字节因为 sizeof(double) 8这就是为什么指针类型很重要指针的类型告诉编译器每次做加减运算时要跳多远。int *p和char *p存的都是地址地址本身没有类型之分但编译器根据指针的类型来决定运算时的步长。同样的道理*p解引用时指针类型告诉编译器从这个地址开始读几个字节。int *p读 4 字节double *p读 8 字节。四、完整代码示例下面这个程序把本节所有知识点串在一起你可以完整复制运行#define_CRT_SECURE_NO_WARNINGS#includestdio.hintmain(void){// 第一部分基本指针操作 printf( 第一部分基本指针操作 \n);inta100;int*pa;// p 指向 aprintf(a 的值%d\n,a);printf(a 的地址%p\n,(void*)a);printf(p 保存的地址%p\n,(void*)p);printf(*p 的值解引用%d\n,*p);// 通过指针修改变量的值*p200;printf(\n执行 *p 200 之后\n);printf(a 的值变成了%d\n,a);printf(*p 的值变成了%d\n,*p);// 第二部分空指针检查 printf(\n 第二部分空指针检查 \n);int*qNULL;printf(q 的值NULL%p\n,(void*)q);if(q!NULL){printf(q 不为空*q %d\n,*q);}else{printf(q 是空指针不能解引用跳过访问。\n);}// 第三部分指针运算 printf(\n 第三部分指针运算 \n);intarr[]{10,20,30,40,50};int*pArrarr;// 数组名就是首元素地址不需要 printf(通过指针遍历数组\n);for(inti0;i5;i){printf(*(pArr %d) %d (地址: %p)\n,i,*(pArri),(void*)(pArri));}printf(\n验证sizeof(int) %u\n,(unsignedint)sizeof(int));printf(所以 int 指针每 1地址增加 %u\n\n,(unsignedint)sizeof(int));// 第四部分不同指针类型的步长对比 printf( 第四部分不同指针类型的步长 \n);charcX;doubled3.14;char*pcc;double*pdd;printf(char 指针 pc %p, pc1 %p (增加 %u 字节)\n,(void*)pc,(void*)(pc1),(unsignedint)sizeof(char));printf(double 指针 pd %p, pd1 %p (增加 %u 字节)\n,(void*)pd,(void*)(pd1),(unsignedint)sizeof(double));return0;}关于(void *)强制转换%p要求参数是void *类型。虽然大多数编译器不强制但为了代码规范和消除警告把各种指针都转成(void *)再传给%p是标准做法。五、运行结果 第一部分基本指针操作 a 的值100 a 的地址0000008A3C6FF794 p 保存的地址0000008A3C6FF794 *p 的值解引用100 执行 *p 200 之后 a 的值变成了200 *p 的值变成了200 第二部分空指针检查 q 的值NULL0000000000000000 q 是空指针不能解引用跳过访问。 第三部分指针运算 通过指针遍历数组 *(pArr 0) 10 (地址: 0000008A3C6FF7A0) *(pArr 1) 20 (地址: 0000008A3C6FF7A4) *(pArr 2) 30 (地址: 0000008A3C6FF7A8) *(pArr 3) 40 (地址: 0000008A3C6FF7AC) *(pArr 4) 50 (地址: 0000008A3C6FF7B0) 验证sizeof(int) 4 所以 int 指针每 1地址增加 4 第四部分不同指针类型的步长 char 指针 pc 0000008A3C6FF773, pc1 0000008A3C6FF774 (增加 1 字节) double 指针 pd 0000008A3C6FF788, pd1 0000008A3C6FF790 (增加 8 字节)六、代码逐行解析第一部分基本指针操作inta100;int*pa;第一行在内存中分配 4 个字节存入整数 100这片内存的代号是a第二行在内存中另分配一块空间通常 4 或 8 字节取决于系统是 32 位还是 64 位把a的地址存进去这块空间的代号是pprintf(a 的地址%p\n,(void*)a);printf(p 保存的地址%p\n,(void*)p);注意这两行输出的是同一个地址。这说明p a确实把a的地址存到了p里。*p200;printf(a 的值变成了%d\n,a);通过*p解引用修改了 p 指向的那个内存位置的内容。因为 p 指向的是 a所以 a 的值也跟着变了。这就是指针的核心能力间接访问和修改数据。第二部分空指针检查int*qNULL;if(q!NULL){...}在用指针之前检查它是否为 NULL是一个好习惯。虽然这里我们知道 q 肯定是 NULL因为刚赋的值但在真实项目中指针经过多次传递后你无法确定它是否有效——先检查再用。第三部分指针运算——遍历数组int*pArrarr;// 不需要 因为数组名本身就是地址arr的值就是数组首元素的地址所以int *pArr arr等价于int *pArr arr[0]。for(inti0;i5;i){printf(*(pArr %d) %d\n,i,*(pArri));}pArr 0指向第一个元素*(pArr 0)得到 10pArr 1指向第二个元素地址偏移了 4 字节*(pArr 1)得到 20这种*(pArr i)的写法等价于arr[i]实际上编译器就是按这种方式处理数组下标的第四部分不同指针类型步长对比char*pcc;double*pdd;我们看到pc 1只增加了 1 字节因为sizeof(char) 1而pd 1增加了 8 字节因为sizeof(double) 8。指针的类型决定了它运算时的步长这是理解后面指针与数组的关键。七、初学者常见错误错误1定义多个指针时忘了给每个加 *// 错误写法int*p,q;// 你以为是两个指针实际只有 p 是指针q 是普通 int// 正确写法int*p,*q;// 每个指针变量都要有自己的 *// 或者分开写更清晰int*p;int*q;这是 C 语言语法的一个坑。int *p, q;里*只修饰pq是普通的int。推荐每个指针单独一行清楚又安全。错误2解引用未初始化的指针// 危险写法int*p;*p100;// p 指向哪不知道程序可能崩溃// 安全写法inta;int*pa;*p100;// 安全p 明确指向 a这是 C 语言初学者最容易犯的严重错误而且编译器不一定报错。一定要记住指针定义后立即让它指向一个有效地址或者设为 NULL。错误3把 和 * 的含义搞反inta10;int*pa;// a —— 取 a 的地址a 住在哪// *p —— 访问 p 指向的那个数据去 p 记的地址看看里面有什么// 错误理解 是里面的内容* 是地址// 正确理解 是地址* 是这个地址里的内容口诀 看右边得到地址* 看右边得到数据。错误4没有意识到局部变量的地址在函数返回后失效// 错误写法int*createValue(){intx42;returnx;// 返回局部变量的地址——悬空指针}// 正确写法一返回变量值非指针intcreateValue(){return42;}// 正确写法二如果确实需要指针用动态内存第 19 篇会讲// 或者让调用者传入一个变量的地址voidfillValue(int*p){*p42;// 安全修改的是调用者的变量}错误5使用 scanf 时对基本类型变量忘了加 intage;scanf(%d,age);// 正确age 前加 // 原因scanf 需要知道把数据存到哪所以需要变量的地址charname[20];scanf(%19s,name);// 正确name 前不加 但必须限制宽度// 原因数组名本身就是地址第 12 篇详细讲%19s 防止溢出八、练习题练习题1指针基本操作写出下面程序的输出#includestdio.hintmain(void){intx5,y10;int*px;*py;py;*p30;printf(x %d, y %d\n,x,y);return0;}不要运行先用纸笔推导出结果再上机验证。练习题2通过指针交换两个数写一个swap函数通过指针交换两个整数的值。函数内部允许使用临时变量temp。在main函数中定义两个整数调用swap交换它们然后在main中打印交换后的结果确认值真的被交换了。函数声明参考void swap(int *a, int *b)。练习题3遍历与修改定义一个包含 6 个元素的double数组{1.5, 2.5, 3.5, 4.5, 5.5, 6.5}。使用指针遍历这个数组打印每个元素的值和地址使用指针把每个元素的值都加上 0.5再次遍历打印修改后的结果观察地址的变化确认double指针每次 1 地址增加了多少九、本篇总结指针就是存地址的变量。普通变量存数据指针变量存另一个数据的地址 取地址解引用*a问a 在哪*p去 p 指的地址看里面有什么指针类型决定了运算步长int *p做p1跳 4 字节char *p跳 1 字节double *p跳 8 字节永远不要使用未初始化的指针它不是语法错误但会让程序出现随机行为。要么初始化为变量要么初始化为NULL空指针更容易排查野指针更隐蔽NULL 解引用通常会很快暴露问题而野指针可能潜伏很久才出错