ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C语言指针完全指南:内存地址、野指针与常见错误排查

C语言指针完全指南:内存地址、野指针与常见错误排查 很多刚开始学 C 语言的同学对指针的第一反应都是难。我自己当年也被指针折磨过一阵子总觉得它神神秘秘的像个看不见摸不着的东西。后来做嵌入式开发和系统编程的时间久了回头再看指针其实它就是一个普通人——一个专门用来保存地址的变量。这篇文章是《关于指针的理解》系列的第一篇我们把基础概念一次性讲透内存地址是什么、指针变量怎么定义、*和怎么用、指针和数组是什么关系、二级指针和函数指针又是怎么一回事。适合正在学 C 语言的在校生、刚入行的嵌入式工程师以及那些用 C/C 写过一点代码但始终对指针心里没底的开发者。内容不会太烧脑但会比教科书上讲得更人话一些因为指针这东西光看语法是学不会的你得先在脑子里建立一张内存地址地图。1. 先从内存说起变量和地址的关系1.1 内存就是一排编了号的房间要理解指针必须先理解内存。现代操作系统里内存可以简化地看成一条超长的数组每个字节都有一个唯一的编号这个编号就是地址。就像一栋公寓楼每个房间都有门牌号你要找人办事得先知道门牌号不然就只能挨家挨户敲门。C 语言里的普通变量本质上就是程序员给某个内存房间起的一个别名。比如你写int a 10;这句话干了三件事在内存中划出 4 个字节具体大小取决于平台这里按常见的 32 位int算的空间把这 4 个字节当成一个整体名字叫a然后把数字 10 按二进制形式存进去。关键点在于a这个名字只存在于编译阶段编译成机器码之后CPU 访问数据靠的是地址不是名字。你在代码里写a编译器会把它翻译成访问某个内存地址上的 4 个字节。这个地址具体是多少你不需要关心但你可以用运算符把它取出来printf(a 的地址%p\n, a);%p是专门打印地址的格式符。运行一次程序你可能会看到类似0x7ffe15a3b4c8的输出。同一个程序每次运行地址都可能不一样因为现代操作系统有地址空间随机化ASLR但无论地址怎么变变量在内存中有固定位置这件事是不变的。很多人学到这里会犯一个思维误区以为a这个变量本身就拥有那个地址。更准确的看法是地址是内存房间的门牌a只是编译器替我们记住的一个标签。普通变量保存的是房间里的内容指针保存的是房间的门牌这两者角色的差异就是理解指针的起点。1.2 什么是指针保存地址的变量明白了地址之后指针就好说了。指针也是一个变量只不过普通变量存的是数字、字符、浮点数指针变量存的是地址。你可以把指针理解成一张便利贴上面抄了一个门牌号。便利贴本身不关心门牌号指向什么它只负责记录。定义指针的语法很简单int *p;读法是p是一个指针它指向的类型是int。很多人初学时会纠结这个星号到底贴在int这边还是p这边其实两种写法在语法上都可以。int* p更容易看出类型是int*而int *p更容易提醒你p本质上是个变量只是类型特殊。我个人的习惯是写int *p把星号和变量名放一起因为声明多个变量时不容易踩坑——你写int* p, q;的时候q其实是int而不是int*这个坑不少人都踩过。指针存的是地址地址本质上是无符号整数所以无论什么类型的指针在同一个平台上大小都一样64 位系统上是 8 字节32 位系统上是 4 字节。你可以用sizeof(int*)、sizeof(char*)验证一下结果应该完全一致。这也是面试里常问的基础题指针大小跟类型无关跟平台有关。1.3 为什么指针必须区分类型既然指针存的就是一个整数那为什么不直接用int或者一个通用类型保存地址非要分成int*、char*、double*答案是编译器需要知道解引用时读多少字节。int a 65; char *cp (char *)a; printf(%d\n, *cp);int占 4 个字节char占 1 个字节。int*解引用时编译器会按 4 字节读取char*解引用时只读 1 字节。如果类型不匹配比如把一个double*强转成int*去解引用读出来的数据就是模模糊糊的一段字节流具体值还跟平台的字节序有关。void*是唯一的例外它是无类型指针。它可以接收任意类型的指针赋值反过来任意类型的指针也可以接收void*在 C 语言里可以隐式转换。void*适合做通用数据传递比如malloc、memcpy返回的都是void*但void*不能直接解引用因为编译器不知道该读几个字节。你要用的时候必须把它转回具体的类型。这就好比便利贴上只写了一个门牌号但没说门后面住的是猫还是狗你进屋之前得自己确认。2. 指针变量的定义与基本操作2.1 三个核心概念声明、取地址、解引用星号在 C 语言里有三种完全不同的身份这是初学者最容易晕的地方声明时放在类型和变量名之间表示这是一个指针变量。解引用时一元运算符放在指针变量前面表示访问这个指针所指向的那个内存单元。乘法时二元运算符就是普通的乘号。单独拎出来看书可能觉得还好混合在一起就乱了。直接上代码int a 10; int *p a; // 声明 p并把 a 的地址赋给它 printf(%d\n, *p); // 解引用 p取出 p 指向的内存里的值输出 10a的意思是取出变量 a 的地址它产生一个类型为int*的值*p的意思是根据 p 里保存的地址去访问那块内存。这两个操作正好是互逆的是给变量找地址*是给地址找变量。还有一个关键认知指针本身也是变量也有自己的地址。p作为变量你照样可以对它取地址得到int**。也就是说便利贴本身也贴在一个位置上你也可以记录存放便利贴的那个位置。这就是后面二级指针的来源先记住这个结论。2.2 初始化、NULL 与野指针指针在使用前必须先初始化这是 C 语言里最老生常谈、也最容易被忽视的规矩。一个没有初始化的局部指针变量里面存的是一个随机值你不知道它会指向哪块内存。如果这时候直接解引用轻则读到垃圾数据重则直接段错误崩溃。int *p; // 错误示范p 是野指针 *p 100; // 踩雷程序大概率当场崩溃或悄悄写坏别的数据正确做法是初始化。如果暂时没有明确的目标地址就赋成NULLint *p NULL;NULL在 C 语言中通常定义为((void*)0)它不指向任何合法的内存对象解引用NULL会直接产生运行时错误。很多初学者觉得反正都是错误有什么区别区别大了野指针出错的时机和位置是随机的、不可预知的而空指针错误是稳定可复现的。你调试一个稳定崩溃的问题比调试一个偶尔崩一次的问题要轻松得多。所以工程上的规范是所有指针定义时必须初始化解引用前必须判空。这条规则能帮你挡掉至少一半的指针相关 bug。2.3 指针的运算比较、加减、自增自减指针不是只能存地址它还能参与运算而且运算规则跟普通整数完全不一样。指针与整数加减p n表示向前移动 n 个元素的位置移动的字节数是n * sizeof(指针指向的类型)。这在遍历数组时非常常用。指针与指针相减只有指向同一个数组的两个指针相减才有意义结果是两个地址之间相隔的元素个数而不是字节数。指针比较可以用、!、、比较两个指针。指向同一个数组时比较的是元素先后关系。指针自增自减p等价于p p 1把指针往后移动一个元素。特别注意不同类型的指针不能直接互相赋值int*不能赋给double*否则编译器会报警告C 语言里可以用强制转换强行赋但后果自负。这就好比你把写着猫房的便利贴贴到狗房的门上开门之前你以为是猫结果蹦出来一条狗。2.4 用指针做函数参数模拟引用传递C 语言是值传递函数参数传进去的都是副本。你想在函数里修改调用者的变量直接传变量本身没用必须传变量的地址也就是传指针。void swap(int *a, int *b) { int tmp *a; *a *b; *b tmp; } int main() { int x 3, y 5; swap(x, y); // 传地址过去才能真的交换 printf(%d %d\n, x, y); // 输出 5 3 }swap函数里通过*a和*b直接操作的是调用者那块内存所以交换生效。这就是很多人说的用指针模拟引用传递。等以后学了 C你会发现引用int更直接但 C 语言里没有这个语法只能靠指针。理解了这个场景后面再看二级指针就水到渠成——因为修改调用者的指针变量需要传指针变量的地址也就是二级指针。3. 指针与数组剪不断理还乱的关系3.1 数组名就是指向首元素的指针常量在 C 语言里数组名和指针有非常亲密的关系。绝大多数情况下数组名会退化成指向数组首元素的指针。比如int arr[5] {10, 20, 30, 40, 50}; int *p arr; // arr 在这里等价于 arr[0]arr作为表达式使用时它的值就是首元素的地址。所以arr[i]和*(arr i)在本质上是完全等价的编译器把下标运算全部转换成了指针算术。这也是为什么 C 语言里可以写出2[arr]这种奇怪的形式——因为[]只是一个语法糖a[i]会被翻译成*(a i)那i[a]自然就是*(i a)结果一样。但有一个地方要注意数组名不是真正意义上的指针变量它没有自己的存储空间你不能对数组名做自增自减操作。arr是非法的因为数组名是地址常量不是左值。这个区别在面试里经常被拿出来考arr和arr[0]一样但arr的类型却是int(*)[5]指向整个数组步长完全不同。这一层搞不清楚后面学二维数组会吃大亏。3.2 指针算术移动步长由类型决定指针加减整数移动的步长不是字节而是所指向类型的大小。这是指针算术最容易出错的地方也是笔试面试的高频考点。int arr[5] {10, 20, 30, 40, 50}; int *p arr; printf(%d\n, *(p 2)); // 输出 30p2 实际跳过了 8 个字节p 2并不是在p的地址上简单加 2而是加2 * sizeof(int)。编译器看到p的类型是int*就知道移动的单位是 4 个字节。如果换成char*p 2就真的只加 2 个字节。这个特性用好了遍历数组的姿势就很灵活。比如你要从数组第 3 个元素开始每隔一个元素输出一个字符char str[] abcdefghij; char *p str 2; while (p str 10) { putchar(*p); p 2; } // 输出cegi网上搜C 语言数组指针移动指定位输出字符基本都是类似的套路。理解了指针每步移动的距离完全由类型决定再去写各种基于数组的遍历会顺畅很多。后面刷算法题时用的双指针法快慢指针原理底层都是这套指针算术快慢指针说白了就是让两个指针以不同速度在数组或链表上移动快的一次走两步慢的一次走一步利用速度差来判断环形结构、找中间节点。步长的计算方式不变变的只是每次跳几步而已。3.3 指针数组与数组指针这两个名字非常容易搞混我每次讲课都要强调一遍从右往左读声明就能分清谁是主体。指针数组它是一个数组数组里的每个元素都是指针。声明形式int *arrP[5]; // 一个数组包含 5 个 int* 元素数组指针它是一个指针指向整个数组。声明形式int (*pArr)[5]; // 一个指针指向 int[5] 这样的数组注意括号int (*pArr)[5]必须带括号否则会被解析成指针数组。为什么要区分清楚因为它们的运算规则完全不同。pArr指向一个内含 5 个 int 的数组所以pArr 1移动的步长是一个完整数组的大小也就是5 * sizeof(int)。这在处理二维数组时特别有用比如遍历一个二维数组的行int matrix[3][5]; int (*row)[5] matrix; // row 指向第一行 for (int i 0; i 3; i) { printf(第 %d 行的第一个元素%d\n, i, (*row)[0]); row; // 跳到下一行 }刚学的时候我建议你手动画一张内存图把数组名、指针变量、指针指向的目标分别画成三个格子标清楚谁存了谁的地址。画上十个例子声明再绕也能看得懂。3.4 字符串与 char*最常见的实用场景指针数组存字符串是初学阶段最常见的用法进入实操之后几乎天天用到const char *week[] {周一, 周二, 周三, 周四, 周五, 周六, 周日};这里week是一个数组每个元素都是const char*分别指向一个字符串常量。这样的好处是每个字符串长度可以不一样week[0]和week[1]占用的内存大小跟字符串的实际内容无关都只占一个指针的大小8 字节。如果改用二维字符数组char week[7][10]每一行都得按最长的字符串来预留空间浪费内存不说长度还写死不灵活。同理main函数的char *argv[]做的就是这件事——操作系统把命令行参数按空格拆好每个参数是一个字符串指针放在一个指针数组里传给你。理解了指针数组存放字符串这个模型你就看懂了 C 语言里大量字符串列表的底层结构。4. 指针的指针与结构体指针4.1 二级指针的本质二级指针就是指向指针的指针。定义一个再简单不过int a 10; int *p a; int **pp p;pp里面存的是p的地址。你写*pp得到的是p写**pp得到的是a。展开说就是pp - p - a两次解引用才拿到真正的数据。有些同学会问有这个必要吗绕两层有什么意义答案是当你需要在函数里修改调用者的指针本身时就必须靠二级指针。C 语言是值传递swap那个例子里已经看过了函数参数传进去的都是副本。如果你传一个int*进去函数里修改这个指针变量的值调用者那边的指针是不会变的。要想改变调用者的指针就得传指针的地址也就是int**。4.2 典型应用在函数里分配内存最常见的场景是在函数内部malloc并希望调用者能拿到这块内存。void createBuffer(int **buf, int size) { *buf (int *)malloc(size * sizeof(int)); if (*buf NULL) { // 分配失败处理错误 } } int main() { int *array NULL; createBuffer(array, 100); // 传的是 array 的地址 if (array ! NULL) { // 正常使用 array free(array); } return 0; }如果createBuffer的参数是int*函数里malloc出来的地址只会赋给形参的副本函数一返回这块内存就泄漏了调用者手里的array还是NULL。这是一个非常经典的问题很多人刚写链表、写回调函数时都在这上面栽过跟头。链表操作里想要在函数里修改头指针同样要传二级指针。比如void insertHead(Node **head, int data) { Node *newNode (Node *)malloc(sizeof(Node)); newNode-data data; newNode-next *head; *head newNode; // 关键修改的是调用者的头指针 }二重指针已经够用工程上很少用到三级以上因为三重间接的代码基本没法维护属于典型的坏味道。如果哪天发现自己要写int ***p先停下来想想是不是设计有问题。4.3 结构体指针- 运算符的正确理解结构体指针单独提一下因为热词里有c语言结构体指针这也是使用频率极高的场景。struct Student { int id; char name[20]; }; struct Student stu; struct Student *ps stu;ps指向stu你可以通过(*ps).id访问成员但更常见、更简洁的写法是ps-id。-只是一个语法糖等价于解引用再加成员访问两步操作。ps-name的本质就是(*ps).name先根据地址找到结构体再取内部成员。一个容易忽略的细节是访问嵌套结构体和结构体里的指针时-和.的混用要特别小心。比如ps-name得到的是char[20]数组如果你在函数里改它直接写成strcpy(ps-name, 张三)就行但如果结构体里有一个char *alias字段你想修改alias本身指向谁就得传ps-alias或者使用二级指针。说到底还是你要修改的是房间里的内容还是便利贴上的门牌号这个问题。5. 函数指针与函数指针数组5.1 函数也有地址函数编译后也是一段二进制代码当然也有地址。函数指针就是指向函数入口地址的指针。定义方法看起来有点绕int add(int a, int b) { return a b; } int (*fp)(int, int) add; // fp 是函数指针 printf(%d\n, fp(3, 4)); // 输出 7声明的读法依然是从右往左fp是一个指针指向一个接收两个int参数、返回int的函数。注意赋值时直接用函数名add就可以函数名本身就代表函数的地址不需要再加。当然加上也合法C 语言允许只是多此一举。函数指针最大的价值在于解耦和回调。比如一个排序函数希望排序算法本身不关心元素的比较规则那就可以把比较函数作为参数传进去。C 标准库的qsort就是这么设计的回调参数就是一个函数指针int cmpInt(const void *a, const void *b) { return *(int *)a - *(int *)b; } int arr[] {5, 2, 8, 1, 9}; qsort(arr, 5, sizeof(int), cmpInt);cmpInt里先把void*转回int*再解引用。整个数组的排序逻辑由库函数完成怎么比较两个元素由我们决定。这就是策略模式在 C 语言里的实现方式也是函数指针最典型的应用场景。5.2 函数指针数组一张查表驱动的菜单函数指针数组就是把一组函数指针放在一起形成一张跳转表。典型的应用是实现命令分发比如一个文本菜单用户输入 1 执行功能一输入 2 执行功能二。用switch当然可以写但每加一个功能就要改分支判断用函数指针数组加功能只需要往数组里追加一个元素完全不用动分发逻辑。void menuA(void) { printf(功能 A\n); } void menuB(void) { printf(功能 B\n); } void menuC(void) { printf(功能 C\n); } void (*menus[])(void) {menuA, menuB, menuC}; int cmd; while (scanf(%d, cmd) 1) { if (cmd 0 cmd 3) { menus[cmd](); // 根据输入直接调用对应函数 } }这种写法把处理逻辑和分发逻辑完全分离了。GUI 框架里的消息映射表、虚拟机里的指令表、嵌入式里的中断向量表本质上都是函数指针数组。理解了这一层你再去看很多框架源码会发现核心机制就那么几个套路查表、回调、分发。函数指针数组就是其中最常见的一张表。5.3 函数指针的类型还是指针还有一点值得提醒函数指针本身也是指针它同样受指针大小跟平台有关这条规则约束。在 64 位系统上函数指针也是 8 字节。不过在某些特殊平台上函数指针和数据指针的大小可能不一样比如一些哈佛架构的嵌入式芯片这个在单片机开发里偶尔会遇到。写可移植代码时不要把函数指针强转成int来存储这是未定义行为。6. 指针常见错误与排查实战6.1 三类臭名昭著的指针初学指针的人迟早会遇到这三个名词野指针、空指针、悬垂指针。野指针未初始化或指向已释放内存的指针行为完全不可预期。它可能碰巧指向一块合法的内存让你偷偷改坏了别的数据却毫无察觉也可能指向非法地址直接段错误。空指针指向NULL也就是地址 0解引用必然出错但错误可以稳定复现程序一定崩不会玩悬念。悬垂指针指针原本指向的对象已经被free或者被函数 return 销毁但指针还在。解引用它访问到的是一块已经被收回的内存内容可能是旧数据也可能是被别的代码改写的垃圾。这三种错误在大型项目里最容易造成灵异事件程序偶尔崩崩的地方跟实际出问题的地方毫无关系。因为错误发生的那一刻没有暴露直到过了一段时间那块被污染的内存才被别的代码读取于是栈回溯根本找不到真凶。6.2 经典案例回调里的空指针搜热词时看到一条timer 执行查询时报警空指针虽然多半是 Java 里的NullPointerException但 C 语言里也有完全对应的场景。比如定时器回调里访问一个全局指针char *g_name; void timerHandler(void) { printf(name: %s\n, g_name); // 如果 g_name 是 NULL 或已释放直接崩 }正确做法是访问前先判空同时用锁保护共享指针的读写。更重要的原则是永远不要假设它肯定初始化了尤其是在回调、多线程、异步这种你控制不了调用时机的场景下。回调函数什么时候被触发可能由完全无关的模块决定你必须每次都检查前提条件。6.3 调试指针问题的实用技巧第一开编译器的地址消毒器。GCC/Clang 都支持-fsanitizeaddress编译时加上这个选项越界访问、悬垂指针、栈溢出会在运行时立刻暴露并且直接告诉你出错的代码位置比 printf 大法好用一个量级。第二善用打印。把关键指针的值用%p打出来观察它是否合理如果打出来是0x0说明是空指针如果是0xdeadbeef这类特征值说明内存已被调试工具填充如果是一个特别大的、看不出规律的地址可能已经越界指向了别处。第三每次free之后立刻把指针置为NULL能有效避免重复释放的 double-free 问题。第四多提一个习惯不要在printf里直接解引用可能非法的指针先判空再打印打印和解引用分开定位问题时心里更有底。6.4 常见问题速查表症状可能原因排查思路程序偶发崩溃且崩溃位置不稳定野指针或悬垂指针用 valgrind 或 ASan 跑一遍关注invalid read/write解引用NULL崩溃指针未初始化或函数未成功赋值在解引用前加判空打印指针地址确认数组越界但没立刻崩pn的步长理解错误检查n是不是乘了sizeof(类型)确认指针类型函数里分配内存返回后拿不到参数传值导致副本丢失改用二级指针或者返回指针字符串输出乱码指针指向非法内存或未加\0检查字符串是否以\0结尾确认拷贝长度打印地址一直不变指针存的是函数地址而非变量地址确认取地址是否用了区分函数指针这张表是我平时带人时总结的并不全面但覆盖了初学者 80% 以上的故障场景。遇到问题先对号入座比瞎改一通效率高得多。7. 工程实践里的心得7.1 命名与注释也是防守写指针代码命名和注释不是小事。我见过很多人把指针变量起名叫p、p1、p2半年后回来看自己的代码根本分不清哪个是指向堆内存的、哪个是指向栈变量的、哪个是数组的游标。工程上建议在名字里体现用途比如pBuf表示缓冲区指针pHead表示链表头指针it表示迭代游标。声明时顺手写一行注释说明这个指针的生命周期由谁管理、指向的是谁能省下后面大量的调试时间。还有一个我自己踩过的坑函数参数里传入的指针到底是输入参数还是输出参数一定要在注释里说清楚。调用者以为函数会改它的内容结果函数只是读了一下或者调用者以为只是读结果函数悄悄改了它指向的数据。这类误会用一句话注释就能避免但绝大多数项目里没人写。7.2 从裸指针到智能指针的距离热词里有一组智能指针实现智能指针使用方法与陷阱c智能指针面试题这确实是 C 面试的高频重点。核心思想其实一句话用 RAII 把裸指针包起来构造时分配资源析构时自动释放让内存泄漏和异常安全问题在语言层面就被拦住。C11 提供的unique_ptr、shared_ptr、weak_ptr分别对应独占所有权、共享所有权和不影响生命周期的弱引用。它们解决的问题本质上仍然是指针生命周期管理这件事只不过工具更强了。但我要提醒一点学智能指针之前必须先吃透裸指针。智能指针的底层就是裸指针加引用计数如果你不理解什么是悬垂指针、不知道为什么要区分左值和右值那shared_ptr的循环引用问题你根本看不出门道。网上搜C 智能指针面试题问来问去无非就是那几个点什么时候用unique_ptr、shared_ptr线程安全吗、weak_ptr怎么解决循环引用这些问题的答案都建立在指针基础之上。7.3 一点个人体会指针的基础概念看似简单真正吃透需要反复实践。光看文章、背结论没有用我的建议是每个例子都自己敲一遍然后打开调试器Linux 下用 gdbWindows 下用 Visual Studio 的调试器单步执行每一步观察这个格子里存的值是什么、和*计算完的结果是什么。把内存图画出来多画几次你就彻底不怕指针了。我自己当年是怎么开窍的就是有一天突然意识到指针不过是一个装着门牌号的变量我把它交给函数、写进数组、放进结构体都没有改变这个本质。它跟int、char没有高下之分只是存的东西特殊一点。想通这一点之后前面的雾就散了。这篇先讲到这里后面的系列会继续聊指针和内存管理、const 修饰符的细节、链表实战以及 C 智能指针的完整解析。如果你正在指针的门槛上徘徊别急把基础概念拿稳后续的路会越走越宽。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进