
1. 从“指针很可怕”到“指针就是门牌号”每次讲到C语言指针总是那个绕不过去的坎。不管是刚入门的大学生还是工作几年后回来补基础的嵌入式开发新手十个里有八个会说“指针太难了”。说实话我第一次接触指针的时候也是一头雾水看着int *p这种写法完全不知道它在表达什么。后来在项目里被段错误折磨过几回又被野指针坑过几次才慢慢摸索出一点门道。这篇文章是“简单理解指针”系列的第一篇目标只有一个帮你把指针最核心的概念搞清楚。我会抛开教科书的抽象定义用大家都能理解的类比和大量可运行的代码来拆解。读完这篇文章你会明白指针到底是什么、为什么C语言非要搞出个指针来、指针变量怎么用、以及最常犯的几种错误怎么避免。适合的人群很明确正在学C语言但被指针卡住的学生、准备面试想梳理基础知识的求职者、以及工作中需要读写C代码但基础不牢的开发者。内容会从内存和地址的底层逻辑讲起一步一步带你走进指针的世界。2. 为什么需要指针从内存地址说起2.1 内存就像一栋宿舍楼要理解指针先得理解内存。你可以把计算机的内存想象成一大栋宿舍楼每个房间都有一个唯一的房间号也就是内存地址。我们的程序在运行的时候各种数据就住在这些房间里面整数变量住一个房间字符串变量可能占好几个相邻的房间。关键在于CPU读写数据时靠的是地址不是变量的名字。打个比方宿管阿姨要找张三她并不知道张三长什么样她只需要知道张三住在302房间然后去302敲门就行。变量名是给人看的地址才是机器真正使用的。举个例子你在代码里写int age 25;编译器会在内存中找一块4字节的空间假设int类型占4字节把这个空间的首地址跟age这个名字绑定起来。当程序运行到需要使用age的地方CPU做的事情其实是根据编译时确定好的地址去那块内存读取4个字节的数据。这就引出了一个关键问题如果我们能在程序运行的时候动态地知道某个变量住在哪个地址然后去访问那个地址岂不是比死板地通过变量名操作更灵活这就是指针存在的最根本理由。2.2 直接寻址 vs 间接寻址在没有指针的情况下代码里的一切操作都是直接寻址写age 30就是直接往age对应的内存地址写入数据。这种方式的局限性很大——函数之间传递数据时你只能传值过去传的就是一份拷贝。我举个最常见的例子void change(int a) { a 100; } int main() { int num 5; change(num); printf(%d\n, num); // 输出还是5 }原因是参数传递默认是值传递change函数拿到的a是num的“复印件”函数里改的是复印件原件根本没动。这在很多场景下非常不便。但如果我们知道num的地址把这个地址传给函数函数拿到地址后通过地址去访问原始数据并修改结果就完全不同了void change(int *p) { *p 100; } int main() { int num 5; change(num); printf(%d\n, num); // 输出是100 }这里的int *p就是一个指针变量它存的是num的地址。num是取地址操作把num的房间号告诉change函数。*p 100是解引用意思是去p里面保存的那个房间号对应的房间把 100 这值放进去。这就是间接寻址。它让函数拥有修改外部变量的能力也让数据结构链表、树、图成为可能。没有指针C语言就失去了一大半灵魂。3. 指针变量声明、初始化与基本操作3.1 指针变量的声明到底难在哪初学者最困惑的往往是声明语法int *p里的*到底是什么意思为什么有的教材写作int* p有的写作int *p其实两种写法都对只是强调的角度不同。int* p强调“p是一个指向 int 类型的指针”int *p强调“对p解引用得到的是 int 类型”。从编译器角度来说这两种写法没有任何区别。真正要理解的是*在声明语句和普通表达式中的作用完全不同。在声明里*表示“这个变量是指针类型”在表达式中*表示“解引用这个指针”。同一个符号两种身份这也是指针难学的原因之一。看几个常见的声明int *p; // p 是指向 int 的指针 char *cp; // cp 是指向 char 的指针 double *dp; // dp 是指向 double 的指针注意指针变量本身也占内存空间存的不是普通数据而是一个地址。在32位系统上指针变量占4字节在64位系统上占8字节。无论int *p还是char *cp指针变量本身的大小是一样的区别只是“它指向的目标类型不同”。3.2 初始化区分“指向”和“赋值”声明了一个指针变量接下来要做的事情就是让它“指向”某个地方。这个步骤极其关键因为未初始化的指针就像手里拿着一张写着乱码的纸条你根本不知道上面写的房号是哪个房间乱闯的话轻则拿错数据重则把整个程序搞崩溃。int main() { int value 42; int *p; // 声明指针但此时它里面装的是随机值 p value; // 让 p 指向 valuevalue 就是 value 这个变量的地址 printf(%d\n, *p); // 解引用输出 42 return 0; }这里有两个运算符需要分清是取地址运算符作用是获得某个变量的地址。*是解引用运算符作用是访问指针所指向的那个存储单元。还有一个概念叫“指针的指针”也就是二级指针。在声明时写作int **pp意思是pp指向一个int *类型的指针。这个系列后面会专门讲第一阶段能理解一级指针就够了。3.3 指针的类型为什么不能乱写很多人会问既然指针变量存的都是地址那我能不能用int *p去指向一个 double 类型的变量答案是语法上能行为上不能。double d 3.14; int *p d; // 编译器报警告某些编译选项下直接报错根本原因在于指针的类型决定了解引用时读取多少字节、如何解释这些字节。int *解引用时会从起始地址读取4字节并按整型解释double *解引用时读取8字节并按浮点型解释。如果类型不匹配读取的字节数、解释方式全都对不上号数据自然是错的。生活里类比一下地址就像是“门牌号”但门牌号没有告诉你这个房间是单人间还是套间。指针的类型就是这个信息——它告诉你这个房间有多大、里面住的是什么东西。只知道门牌号不知道房间类型贸然推门进去肯定出问题。4. 指针与数组剪不断理还乱的关系4.1 数组名到底是不是指针这是C语言社区里争论最多的问题之一。严格来说数组名不是指针但在大多数表达式中数组名会“退化”为指向首元素的指针。这句话听起来绕我拆开解释。int arr[5] {10, 20, 30, 40, 50}; int *p arr; // 这里 arr 退化为指针等价于 p arr[0] printf(%d\n, *p); // 10 printf(%d\n, *(p1)); // 20当你把arr赋值给一个int *指针时arr自动转换成arr[0]也就是指向数组第一个元素的指针。你不需要再写arr虽然也能取地址但arr的类型是“指向整个数组的指针”即int (*)[5]这是另一个概念初级阶段不建议深挖。但“数组名不是指针”体现在哪体现在sizeof(arr)上。如果arr是指针sizeof(arr)应该等于指针大小8字节或4字节但实际上sizeof(arr)等于整个数组占用的字节数。在64位系统上sizeof(arr)输出的是205个int乘以4字节而不是8。一句话总结在大多数场景下数组名都能当指针用但它自身的“数组身份”依然保留尤其体现在 sizeof 和取地址时。4.2 指针运算为什么 p1 不一定是地址1说完了数组名必须讲讲指针运算。这可能是很多人学指针时的第二道坎。int arr[5] {10, 20, 30, 40, 50}; int *p arr; printf(%p\n, p); // 假设输出 0x1000 printf(%p\n, p 1); // 输出 0x1004不是 0x1001为什么 p 加 1地址却加了4因为指针运算的单位是“所指向类型的大小”不是1个字节。p是int *每移动一步应该跳过一整块 int 的空间4字节这样才能从arr[0]跳到arr[1]。如果加1只变化1字节那取到的数据就会错位毫无意义。类比理解你在宿舍楼的走廊里每一间房的宽度是4米。从302走到303你应该往前迈4米而不是1米。p1让你从地址0x1000走到0x1004正好跳过了一个int的空间。同样的道理也适用于其他类型char *cp ...; // cp1 地址变化1字节 double *dp ...; // dp1 地址变化8字节这背后其实也体现了 C 语言“指针有类型”的深层价值编译器知道每个类型占多大空间因此能为你自动计算正确的偏移量。4.3 用指针遍历数组的常见写法理解了指针运算用指针遍历数组就顺理成章了#include stdio.h int main() { int arr[5] {10, 20, 30, 40, 50}; int *p; // 写法一下标法 for (int i 0; i 5; i) { printf(%d , arr[i]); } printf(\n); // 写法二指针法 for (p arr; p arr 5; p) { printf(%d , *p); } printf(\n); return 0; }两种写法输出的结果完全一样。写法二的关键在于p arr 5这个比较。因为指针可以比较大小arr 5指向数组最后一个元素之后的位置注意这是合法的指针位置只是不允许解引用。当p等于这个位置时说明遍历已经结束。我在教初学者的时候会让他们把写法二多写几遍因为它强迫你理解指针自增的含义——p不是地址值加1而是让指针移动到下一个元素的位置。5. 指针作为函数参数改值、交换与输出型参数5.1 为什么传指针能修改外部变量前面已经提过值传递的局限。这里再展开讲一个经典案例交换两个变量的值。// 错误示范传值交换 void swap_wrong(int a, int b) { int temp a; a b; b temp; } // 正确示范传指针交换 void swap_right(int *a, int *b) { int temp *a; *a *b; *b temp; } int main() { int x 3, y 5; swap_wrong(x, y); printf(wrong: x%d, y%d\n, x, y); // 3 5没变 swap_right(x, y); printf(right: x%d, y%d\n, x, y); // 5 3交换成功 return 0; }swap_wrong失败的原因是函数内部的a和b是x和y的副本交换的是副本原件毫发无损。而swap_right拿到了x和y的地址通过解引用直接操作那块内存自然能把原件改掉。值得警惕的是int temp *a;这一步。temp是一个普通 int 变量用它来暂存*a的值这里没有用指针因为我们要暂存的是值不是地址。很多初学者会在这里晕建议反复咀嚼这句话需要修改外部变量的值时传进去是指针变量地址需要暂存一个值的时候直接用一个普通变量就行。5.2 输出型参数让函数“带出”多个结果C语言的函数只能有一个返回值但实际开发中经常遇到“一个函数需要产生多个结果”的需求。比如计算两个整数的和与差#include stdio.h // 通过指针参数带出结果 void calc(int a, int b, int *sum, int *diff) { *sum a b; *diff a - b; } int main() { int s, d; calc(10, 3, s, d); printf(sum%d, diff%d\n, s, d); // sum13, diff7 return 0; }这种用指针参数带回多个结果的模式在嵌入式开发、系统编程中非常常见。比如读取一个文件的长度和内容或者内置很多类似 “读取某个设备状态同时返回错误码” 的函数都是这个套路。写这类代码时有一个很重要的习惯在函数入口处先判断传入的指针是否为空。因为调用方可能会不小心传入空指针如果不加保护函数内部解引用空指针会导致程序段错误崩溃。void calc(int a, int b, int *sum, int *diff) { if (sum NULL || diff NULL) { return; } *sum a b; *diff a - b; }这条习惯看起来简单但在大型项目里能帮你省下无数排查段错误的时间。我在实际工作中写底层接口时几乎每个函数都会做这种防御性判断。5.3 什么时候该传指针什么时候该传值这是我的学员最容易问的问题。其实判断标准很简单直接传值数据量小且不需要修改原变量。比如传一个 int、char、double很轻量。传指针需要修改原变量或者数据量较大比如传一个很大的结构体传指针可以避免结构体拷贝的开销。传指针还有一个深意允许“没有值”的状态。当指针为 NULL 时可以表示“这里没有数据”这是一种约定俗成的表达方式。下面这个场景可以直接感受到传指针的必要性。假设你有一个很大的结构体struct BigData { int data[10000]; char name[256]; double scores[500]; }; // 传值函数参数会拷贝整个结构体性能灾难 void process_by_value(struct BigData d) { // ... } // 传指针只拷贝一个地址8字节性能好太多 void process_by_pointer(struct BigData *d) { // ... }我见过不少初级工程师在项目里犯了传大结构体导致的性能问题。很多初学者觉得传值简单看着安全但在嵌入式这种资源受限的环境里一次不小心的结构体拷贝可能直接把栈空间打爆。这个教训很深刻。6. 指针的常见错误与调试经验6.1 未初始化指针最经典的“野指针”这是新手最容易踩的坑也是 C 语言中最危险的操作之一int *p; // p 里面是随机垃圾值 *p 10; // 试图向一个未知地址写入数据运行上面的代码轻则数据被写到不该写的地方重则直接段错误。因为p的值是随机的它可能指向系统关键内存区域强行写入可能导致整个程序崩溃。正确的姿势是要么初始化指针要么让它指向一个合法的存储单元。int value 0; int *p value; // 明确指向 // 或者 int *p NULL; // 明确表示“当前不指向任何地方”我个人的习惯是指针声明时如果暂时没有合适的对象让它指向就直接初始化为NULL。这样后续解引用前检查一下p ! NULL就能避免大量野指针问题。6.2 解引用 NULL 指针int *p NULL; *p 10; // 运行时崩溃段错误NULL 在 C 语言中就是地址 0操作系统规定这个地址不可读写所以解引用 NULL 必然触发段错误。这不是坏事因为段错误是一种快速失败机制——程序直接崩溃你马上知道哪里出问题了。最怕的反而是野指针因为它可能“碰巧”没有崩溃但数据已经在错误的地方被改写bug 会在很久之后以诡异的形式暴露。所以在调试时看到segmentation fault先别慌优先检查是不是有指针在解引用前是 NULL 或未初始化。6.3 悬空指针指向已释放的内存这个坑在首次学习时较少遇到但一旦接触堆内存分配就会碰到int *p (int*)malloc(sizeof(int)); free(p); *p 10; // 错误p 指向的内存已经被释放free之后那块内存的操作权交还系统继续通过p去访问就是未定义行为。我见过很多项目里出现“看起来是随机崩”的bug最后定位都是悬空指针。一个实用的习惯是free 之后立即将指针置为 NULL。这样再次访问时会在解引用前触发p ! NULL的检查从而避免悬空指针问题。虽然不能根治所有问题但至少能让你更早发现bug。free(p); p NULL;6.4 修改字符串常量导致的崩溃还有一个常见错误和指针以及字符串字面量有关char *p hello; p[0] H; // 崩溃字符串字面量是只读的代码里的hello是一个字符串字面量存储在只读数据区任何修改它的尝试都会导致段错误。要注意区别这种情况和字符数组char arr[] hello; arr[0] H; // 合法arr 是本地数组可修改这里再次体现指针和数组的微妙关系也提醒我们看到char *p ...这种写法默认应该把字符串当只读数据来对待。6.5 排查段错误的三板斧如果你在使用指针时遇到段错误按照下面的顺序排查效率最高检查是否解引用了未初始化或 NULL 的指针。看代码里所有指针声明处是否都有明确的指向或 NULL 初始化。检查是否修改了字符串字面量。凡是char *p ...之后对p指向的内存做写操作基本都有问题。检查是否访问了越界内存。比如数组下标越界或者指针已经移出了合法范围还在解引用。如果手头有 gdb可以直接编译时加-g选项运行后backtrace查看崩溃调用栈能快速定位到具体是哪一行。这是调试指针问题最直接的武器。7. 指针相关的经典笔试题与易错点7.1 sizeof 与 strlen 的差异初学者经常混淆这两个关键字。看下面的代码char s[] hello; char *p s; printf(%zu\n, sizeof(s)); // 6包含末尾的 \0 printf(%zu\n, sizeof(p)); // 8指针自身大小64位系统 printf(%zu\n, strlen(s)); // 5不包含 \0这里看得很清楚sizeof是运算符在编译期就能确定结果对数组求sizeof得到整个数组长度对指针求sizeof只得到指针本身的字节数。而strlen是函数在运行期扫描字符串直到遇到\0。很多笔试题专门针对这个知识点出题。记住一句话数组用 sizeof 看整体指针用 sizeof 看自身。7.2 指针赋值与类型匹配判断下列代码哪些是合法的int a 10; int *p a; int **pp p; // 合法pp 指向指针 p int *q pp; // 不合法类型不匹配q 是 int*pp 是 int**二级指针的概念在这里初见端倪pp存的是p的地址*pp就是p**pp就是a。虽然“指针的指针”这个话题值得单独写一篇但在这个系列的第一篇里你只需要能认出这种写法的含义知道*的数量对应解引用的层次就行。7.3 指针算术与数组的边界笔试题里经常出现这种写法int arr[] {1, 2, 3, 4, 5}; int *p arr; printf(%d\n, *(p 3)); // 4 printf(%d\n, p[3]); // 4这里 p[3] 等价于 *(p3) printf(%d\n, *p); // 1先解引用 p 得到 1再让 p 指向下一个元素 printf(%d\n, *p); // 3先让 p 指向下一个元素再解引用最后一行的输出可能出乎很多人的意料。*p和*p的区别本质上是后缀自增和前缀自增的优先级问题。后缀的优先级高于解引用*所以*p等价于*(p)也就是先取当前值指针再移动*p等价于*(p)指针先移动再取值。这类题目在笔试题里出现频率极高我强烈建议你把每一行的行为和输出都自己推导一遍再写代码验证。亲手跑一遍比看十遍解析都管用。7.4 用表格总结指针的常见声明含义下面这张表适合打印出来贴在工位上随时提醒自己声明含义int *pp 是指向 int 类型的指针int **pp 是指向 int* 类型的指针即二级指针int *p[5]p 是数组数组元素是 int* 类型的指针即指针数组int (*p)[5]p 是指向数组的指针数组有5个 int即数组指针int *func()func 是函数返回值是 int* 类型int (*func)()func 是函数指针指向一个返回 int 且无参数的函数其中“指针数组”和“数组指针”是最容易混淆的组合也是面试高频题。第一篇文章先记住表格里的这些模式后续系列会在具体场景里逐一展开。8. 写在最后指针到底该怎么练很多人学指针靠“背概念”背到*和的用法、背到“指针就是地址”但一旦脱离书本就不知道怎么写。我个人的体会是指针的知识密度不高但练习密度要求极高。我在实际项目中遇到过一位刚入行的同事拿着一个空指针导致崩溃的bug看了很久没定位出来。后来我用gdb带着他看核心转储文件他亲眼看到崩溃发生在解引用地址为0x0的那一行才真正理解了NULL指针到底意味着什么。从那以后他写指针代码前都会习惯性地加空指针判断。所以最后给你三点具体的建议第一动手写。不要只看教程自己把本文里的代码全部敲一遍然后试着改一改比如把int *改成double *观察行为变化。实验是理解C语言最好的老师。第二动手画。遇到指针相关的代码别急着看输出结果先在纸上画出每个变量的地址和值。比如int **pp把pp、*pp、**pp三层关系画清楚研究一遍胜过读十遍书。第三动手break。故意写一些错误的代码比如解引用未初始化指针、越界访问数组观察程序如何崩溃。知道“错误长什么样”真正遇到时才不慌。指针的入门第一篇就到这里。这个系列后续还会讲到数组与指针的深度纠缠、函数指针与回调机制、字符串与指针的家族恩怨、以及动态内存分配和二级指针的实战用法。如果你照着这篇文章的代码练过一遍其实就已经把这个门槛跨过去了一大半。剩下的靠时间打磨。