
如果说C语言里有什么知识点能让新手一夜“破防”那十有八九是指针。循环、数组刚学完的时候很多人都觉得自己会写C了结果一碰到*、、地址这些字眼就开始懵。这篇就是专门来拆“指针的概念与指针变量”的它到底是什么、为什么C语言绕不开它、定义和使用的完整套路是什么、常见的坑又在哪里。简单说指针就是一个用来存放地址的变量它能让你直接操作内存能让函数真正修改外面的变量也是后面学链表、动态内存分配、数据结构绕不开的基石。适合正在学C语言的同学、准备期末或笔试面试的读者也适合那些写了好一阵子代码但始终对指针“隔了一层”的人。1. 先把内存和地址的关系捋清楚1.1 内存就是一排编了号的小格子要理解指针不能只在语法层面记“指针是指向变量的变量”你得下沉一层先看内存模型。计算机运行程序时程序里的变量、常量、指令都会存放在内存里。内存可以理解成一排排连续的“格子”每个格子有唯一的编号这个编号就是地址。我们定义int a 10;操作系统会从内存中找一块区域给a用在常见的32位平台上int占4个字节也就是说a占了相邻的4个格子。这里有一个特别关键的认知程序代码里写a编译器最终要把这个名字翻译成一个地址再通过地址去读写对应的格子。“格子”这个比喻再往生活里靠一步就是公寓楼。整栋楼就是内存每个房间的编号就是地址房间里住的人就是变量值。你只知道“有一个房间里有10号客人”但不知道房号你是没法找到他的。C程序里所有变量的访问本质上都是通过地址完成的。1.2 取地址运算符 怎么知道变量住在哪个房间既然地址是变量真实的位置标识那怎么拿到一个变量的地址C语言给出了一个运算符读作“取地址”。int a 10; printf(a %d\n, a); printf(a 的地址 %p\n, (void *)a);这里a就是“变量 a 的地址”。打印地址用%p它会把地址以十六进制形式输出比如0060FEF4。建议在输出时把a强转成void *这是规范写法因为%p原本期望的是void *类型。虽然很多编译器接受你直接传a但标准写法更稳妥尤其是在开了-Wall之类严格警告的编译环境下。有一点值得强调取地址并不是什么高端操作它就是让程序告诉你“这个变量放在哪儿”。不同编译器、不同运行环境下同一个变量地址可能会变化这不影响逻辑。1.3 地址本身也是一种数据于是有了指针下面这个认知是整个指针学习的第一个转折点地址是一个数值而数值是可以被存进变量里的。int a 10; int *p a; printf(p 保存的地址 %p\n, (void *)p); printf(a 的值 %p\n, (void *)a);可以看到p里保存的地址和a是完全一样的。也就是说p本身也是一个变量但它存的东西不是普通数字而是另一个变量在内存中的地址。这种“专门用来保存地址的变量”就叫指针变量。这里有个非常常见的概念混淆把“地址”和“指针”当成两个无关的东西。其实指针就是用来保存地址的变量地址是指针里装的内容。相当于你把门牌号抄在了一张纸条上这张纸条就是指针门牌号就是地址。后面的所有指针操作无非是“拿着纸条去找对应的房间”。2. 指针变量的定义与基础操作2.1 定义语法int *p 到底怎么读先看最常见的定义写法int *p;拆开来理解int说明这个指针指向的是一个int类型的变量*说明p是一个指针变量p变量名。连起来就是p 是一个指向 int 类型数据的指针变量。很多教材喜欢用int* p;这种写法把int*当成一个整体类型来看。两种写法本质一样但我个人更建议写成int *p;原因在于多变量声明时会减少误导。看这个反面教材int *p, q;这是p是指针q只是普通int。如果把星号贴在int后面写成int* p, q;很容易让人以为p和q都是指针结果q其实是int后面用它给指针赋值时直接编译报错。正确写法是int *p, *q, n;只有p和q是指针n是普通int。2.2 初始化别让野指针害了你指针变量声明之后一定要初始化。如果你只写int *p;那么p里存的是一个“垃圾值”这个垃圾值指向哪里完全不可控。万一后面执行*p 100;程序可能正好往一个不属于自己的内存地址写了数据轻则数据被莫名改写重则当场段错误崩溃。这种指向不确定地址的指针就是“野指针”。正确的初始化方式有三种指向一个已存在的变量int *p a;置为NULL表示不指向任何有效对象int *p NULL;指向运行时动态分配的内存p malloc(sizeof(int));这个后面专题讲很多有一定经验的程序员会坚持“声明指针就顺手置 NULL”这个习惯原因就是NULL指向的0地址是明确非法的解引用它会直接崩溃而不像野指针那样“悄悄干坏事”。你宁愿程序立刻崩在眼前也不想让它带着错误继续跑。2.3 解引用运算符 *通过地址找到房间有了地址怎么取到地址里保存的数据用*。这里的*在定义语句里是“声明指针”的标记在表达式里却是“解引用”运算符作用是根据指针里保存的地址找到对应的内存单元。int a 5; int *p a; int b *p; // 读取把 p 指向的值取出来b 5 *p 99; // 写入把 99 写到 p 指向的内存里a 变为 99 printf(a %d\n, a); // 99*p出现在等号右边是读取内存中的值出现在等号左边是向内存中写入值。这块招很多人迷糊的地方在于p是地址*p是地址里的值两者一混代码就全乱了。我自己教别人的时候一贯强调记忆点p 是门牌号*p 是房间里的人。和*互为逆运算*a能得到a*p还是p。理解这一层后面看到表达式时就不会被绕晕。2.4 指针的加减地址不是简单加1指针也支持加减运算但它的加减和普通整数不同。假设int a[5] {1,2,3,4,5}; int *p a[0];p 1并不是地址值加 1而是跳到下一个 int 的地址也就是地址值加 4sizeof(int)。这个设计是为了配合数组遍历对int *指针加 1就是指向下一个 int 元素对char *指针加 1只是指向下一个字节。所以指针必须有类型编译器才知道移动一步到底要走多远。类型不对指针加减就是在乱跳。这一块在后面的数组与指针部分会大量用到。3. 实战函数里修改外部变量为什么必须用指针3.1 C函数的参数传递是“值复制”很多人在第一次写交换函数时就发现函数里换得好好的出来一看外面变量纹丝不动。这就要说到C语言的参数传递机制值传递。void change(int n) { n 100; } int main() { int x 1; change(x); printf(x %d\n, x); // 输出 1 return 0; }change(x)做的事情是把x里的数值1复制一份给形参n。n和x是两个独立的内存空间你在change里改n只是改了一张复印件原件当然不受影响。3.2 用指针参数让函数直接改“原件”如果想让函数直接修改调用者的变量就把它地址传过去用指针接收。void change(int *p) { *p 100; } int main() { int x 1; change(x); printf(x %d\n, x); // 输出 100 return 0; }这里change(x)传递的不是x的值而是x的地址。函数内部的*p 100;通过地址直接找到x所在的内存格子把 100 写进去。类似你把自己的门牌号告诉别人别人不是对着你的照片做修改而是直接上你家把东西换掉。经典的两数交换例子配合指针长这样void swap(int *a, int *b) { int temp *a; *a *b; *b temp; } int main() { int x 3, y 5; swap(x, y); printf(x %d, y %d\n, x, y); // 输出 x 5, y 3 return 0; }逐步拆解swap(x, y)的执行过程形参a保存x的地址b保存y的地址temp *a取出x的值 3 暂存*a *b将y的值 5 写入x*b temp把 3 写入y。四行代码操作的其实是x和y的原始内存空间。3.3 哪些场景必须用指针参数日常工作里下面这些情况基本逃不开指针参数需要修改调用方变量像上面的交换、累加、修改状态不传地址就无法生效传大型结构体如果逐个复制整个结构体内存和时间成本都高传指针只复制地址函数需要“返回”多个结果比如写一个函数同时计算商和余数除了return一个结果另一个必须通过指针参数带出来。还有一点要特别提一下指针参数接收的实参是地址但形参本身传的其实还是“值”只不过这个值是地址值。也就是说指针参数仍然是值传递只是这个值恰好是地址所以能通过它去间接修改目标变量。理解这个后面学二级指针才不绕。4. 数组、字符串与指针最容易越学越乱的一节4.1 数组名就是首元素地址数组和指针的关系可以说是C语言里最纠缠不清的一段关系。先记一个最重要的结论数组名可以当作指向首元素的指针。int arr[4] {1, 2, 3, 4}; int *p arr;arr其实就是arr[0]也就是数组第一个元素的地址。所以int *p arr;完全合法。但数组名和指针变量有一个关键区别数组名是常量地址不能被重新赋值。你写arr会被编译器拒绝因为数组名不是变量它不能指向别的地方。而指针p是变量你可以让p指向数组中的任意位置p arr; // 指向第 0 个元素 p; // 指向第 1 个元素还有个高频考点sizeof(arr)和sizeof(p)的结果完全不同。sizeof(arr)在32位平台上等于4 * sizeof(int) 16因为arr这时候代表“整个数组”而sizeof(p)只是指针变量本身的大小32位平台是464位平台是8。两者含义差着十万八千里。4.2 用指针遍历数组下标和指针怎么等价既然p指向数组首元素那p i就指向第i个元素*(p i)就是第i个元素的值。这和arr[i]是完全等价的写法。int arr[5] {10, 20, 30, 40, 50}; int *p arr; for (int i 0; i 5; i) { printf(%d , *(p i)); }也可以直接用指针移动的方式遍历int *q; for (q arr; q arr 5; q) { printf(%d , *q); }这两种写法的区别在于*(p i)没有改变p本身的位置每次还是从头计算q则让指针逐步后移。用q arr 5判断结束条件时最后一个q会移动到arr 5这个“越界一个元素”的位置但这个位置只做比较用不去解引用是安全的。这里提醒一句只有地址做比较是没问题的越界后解引用就不行。4.3 字符指针和字符串常量能不能改是关键字符串在C语言里没有专属类型一般用字符数组或者字符指针来处理。两种写法看起来像本质却不同char str1[] hello; // 字符数组h,e,l,l,o,\0 被复制到数组里 char *str2 world; // 指针 str2 指向字符串常量 worldstr1是可写的因为数组里的每个字符都存在栈或全局内存里你可以随意修改str1[0] H;str2的情况完全不同。str2指向的是一个字符串常量这个常量通常被放在只读区域任何修改它的尝试都可能产生未定义行为。str2[0] W; // 危险不要这么写很多初学者会问“为什么我的代码编译不报错一运行就崩溃”答案就在这里——编译阶段无法发现修改只读内存的错误运行时才暴露出来。如果既要一个可变的字符串又要用指针访问就老老实实创建字符数组然后让指针指向数组。还有一个和字符串相关的经典细节hello的sizeof是 6不是 5。因为它包含了字符串结束标志\0。而strlen(hello)返回 5它只统计到\0之前。初学者往往在这上面卡很久其实只是“字符串字面量自带一个隐藏的结尾标记”这一件事没想清楚。5. 二级指针指针的指针到底在什么场合用5.1 为什么需要二级指针一级指针变量本身也是一个变量它也有自己的地址。当你需要“修改某个指针变量本身”而不是“修改指针指向的数据”时就要用到二级指针。看一个反面案例void initPointer(int *p) { int value 99; p value; // 只是修改了形参调用方 p 没有任何变化 } int main() { int *p NULL; initPointer(p); printf(%d\n, *p); // 崩溃或未定义行为 return 0; }initPointer里的p value;修改的只是形参指针的副本。函数结束后调用方的p依然是NULL。要是想真正改变调用方p的指向就得把p的地址传进去也就是传p对应的形参类型是int **。5.2 二级指针的定义与使用方法int x 1; int *p x; int **pp p;逐层拆解pp保存的是p这个变量自身的地址*pp取出p也就是一个int ***pp取出p指向的x也就是int。和一级指针的套路完全一致只是多了一层“地址套地址”。再看一个能够修正上面错误的动态分配函数#include stdlib.h void initPointer(int **pp) { *pp malloc(sizeof(int)); if (*pp ! NULL) { **pp 42; } } int main() { int *p NULL; initPointer(p); printf(p 指向的值 %d\n, *p); // 输出 42 free(p); p NULL; return 0; }这里malloc动态分配了一块内存*pp malloc(...)就是“把这块内存的地址写到p里”因为*pp等价于p。5.3 现实中的二级指针场景二级指针并不只存在于考题里真实代码中常见于以下场景需要改变指针指向的动态内存比如函数内部初始化一个指针再让调用方持有必须传二级指针指针数组的处理比如main函数的char *argv[]参数本质上可以视作char **链表、二叉树的插入操作在空树插入根节点、改变头指针时为了统一代码逻辑常用二级指针。这部分可以先建立“如果要改指针本身就得交出指针的地址”这个印象具体的动态内存专题再深挖。6. 常见错误、调试技巧与避坑清单6.1 高频错误对照表现象可能原因排查/解决思路程序段错误崩溃解引用NULL指针或野指针检查指针是否初始化打印指针地址确认输出一堆垃圾值访问了已释放的内存或者指针没有正确指向目标free之后立即置NULL再检查分配是否失败编译警告类型不匹配int *和char *、void *混用检查指针类型必要时显式强转某个变量莫名被改坏数组越界写或者指针偏移错误仔细检查数组边界打印指针偏移前后的地址函数里改了数据外面没变形参是值传递没有传地址形参改成指针实参传变量或数组名修改字符串时崩溃字符指针指向字符串常量只读区不可写换用字符数组存储需要修改的字符串6.2 调试指针问题的实用手段遇到指针调试我最常用的方法有三板斧第一打印地址。在关键节点打印printf(p %p\n, (void *)p); printf(a %p\n, (void *)a); printf(*p %d\n, *p);地址是否合理、两个地址是否一致通常一眼就能看出问题。第二开编译器警告。编译时加-Wall -g大多数类型不匹配的隐患在编译阶段就会被提示。第三用调试工具定位崩溃点。用调试器跑一遍看程序死在哪一行再查那一行涉及哪些指针。做动态内存相关检查时也可以用内存检测工具它能帮你揪出越界读、越界写、重复释放这类隐蔽问题。6.3 几条保命经验以下这些经验大多是我自己踩坑后总结出来的写在这里比单独讲理论更实在不要返回局部变量的地址。函数局部变量随着函数调用结束就销毁了返回它的地址外面拿到的是一个悬空的地址再访问就是未定义行为。如果确实要返回就用动态内存分配或由调用方传入缓冲区。声明时顺手初始化。int *p NULL;这个习惯成本极低收益很高。哪怕后面忘了赋值程序崩溃时至少能立刻定位到问题而不是在某个随机位置崩溃。给指针起能看懂的名字。pNum、pHead、pFirst这类名字比p、ptr、p1好太多。尤其是写链表、树的时候变量名就是你的思维地图。先在纸上画“谁指向谁”。很多指针错误根本不用调试画两三张地址指向图就能自己找出答案。数组、指针、二级指针之间的关系画一遍比看十遍书都管用。分清“改指向”和“改指向的值”。p q是让 p 指向 q 所指的地方*p *q是把 q 指向的值复制到 p 指向的地方。这句看起来简单但每次写的时候过一下脑子能少一半错误。我个人最深的体会是学指针的“顿悟点”就是把地址当成一个普通数据来看。地址说白了就是一串数字它可以被赋值、被比较、被传进函数也可以作为一个变量存起来。能透彻理解是“取地址”、*是“按地址找房间”再往后看链表、树、动态内存都会顺畅得多。最后分享一个小习惯我每次写完一段涉及指针的代码都会在注释里写一句“这个指针指向哪里、目的是什么”看似多余但几个月后回看代码你会发现这行注释比代码本身还能救命。