ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

二刷C语言指针:从内存模型到实战避坑全攻略

二刷C语言指针:从内存模型到实战避坑全攻略 1. 二刷指针前先想明白一个问题为什么指针难学如果你已经学过一遍C语言回头看指针多多少少会有一个感觉当时好像听懂了做题也勉强能做对但真要自己在项目里用遇到段错误、野指针、内存泄漏立刻心里没底。这其实是绝大多数C语言学习者的共同经历因为指针是C语言里唯一一个绕过计算机底层真实运作机制就很难学明白的知识点。数组、循环、函数这些东西你都可以在上层逻辑里理解个大概。数组就是一组数据排排队循环就是重复做某件事函数就是一段可以反复调用的代码。但指针不是指针的本质是两个东西一个是内存地址这个概念另一个是地址运算这套规则。你如果不知道一个变量在内存里是怎么存的、一个函数调用时参数是怎么传的、数组名和指针变量到底有什么区别指针就是一团浆糊。所以二刷C语言指针我的建议是不要把精力花在背概念、背题上而要带着三个问题去看每一段代码这行代码在内存里做了什么这个操作会不会产生歧义如果我换一种写法编译器会怎么理解这篇文章我会把指针的核心知识体系重新梳理一遍从内存模型讲起一直说到多级指针、函数指针和动态内存管理的实战细节全程用代码说话最后再给出一批经典的笔试题解析和避坑指南。适合已经上过一遍C语言课程、但觉得自己对指针不够扎实的人也适合正在准备面试、想系统回顾指针要点的读者。2. 指针的本质以及二刷时必须纠正的几个认知2.1 内存、地址与变量名的三角关系很多教材在讲指针的时候第一句话就是指针就是地址。这句话没错但它省略了太多背景导致新手只记住了一个结论没理解上下文。我们需要回到最底层计算机内存是一块连续的字节空间每个字节都有一个编号这个编号就是内存地址。比如32位系统上地址范围是0x00000000到0xFFFFFFFF64位系统上更大。而程序里的变量是什么变量其实是编译器帮你分配的一块内存区域的别名。你写int a 10;编译器在栈上找4个字节假设int占4字节把这块区域记作a然后把10的二进制形式放进去。之后你在代码里写a编译器就知道你要操作的是那块被命名为a的4字节内存。问题是CPU执行指令的时候并不知道变量名这种东西。int c a b;这条语句编译器早期还能把a和b的名字翻译成某个偏移量但到了更复杂的场景比如你写int *p a;你其实是在说请把a这个变量所在内存区域的起始地址保存到p这个变量里。对CPU来说p里存的不是普通数据而是一个位置信息。所以二刷指针时我建议把所有指针变量都理解为两层第一层它是一个变量它有自己的内存空间第二层它里面存的是另一个内存空间的地址。这两层缺一不可。我在教学和面试中见过太多人写来写去只记得指针存地址却忽略了指针变量自己也有地址这一层结果学到二级指针int **pp;的时候彻底懵了。2.2 指针变量与普通变量在定义上的障眼法int *p;和int * p;和int* p;哪种写法是对的都对。void*、char*、struct node*这些类型看起来千变万化但本质都是指针变量区别只是指针指向的那个目标的数据类型。这里有一个初学者最容易踩的坑int* p1, p2;这句话里p1是指针没问题但p2是普通的int变量不是指针。因为*在C语言的声明语法里是结合到单个变量名的。如果想让p1、p2都是指针必须写成int *p1, *p2;。这种细节二刷阶段一定要有意识地纠正过来否则在阅读别人代码、或者自己声明多个指针时很容易埋下隐患。还有一个很隐蔽的认知陷阱有人觉得指针变量是固定的里面存的地址就是那个地址不会变。实际上指针变量本身是一个左值你完全可以修改它让它指向别处。p b;之后p就不再指向a了。同时你还可以修改p指向的那块内存里的值*p 20;。这看起来简单但修改指针指向和修改指针指向的内容这两件事是二刷指针时首先要在代码里区分清楚的基本功。2.3 二刷重点和*的运算语义是取地址运算符*在表达式中是解引用运算符也就是访问该地址上存储的数据。二刷时我特别推荐一个练习对着一段代码把每个变量的内存布局图画出来。比如有这样一个简单的场景int a 10; int b 20; int *p a; *p 30; p b;第一行内存中某个位置有4字节存着10我们把这个位置叫A。第二行位置B存着20。第三行定义指针变量pp自己的空间叫CC里存的是A的地址。第四行*p意味着操作p所指向的那块内存也就是把A里的值从10改成30。第五行p改为指向B即把C里存的值改成B的地址。每读一段指针代码都在脑海里过一遍这个过程。等你把这张地址图建立起来后面所有指针语法都不再是死记硬背。3. 指针与数组二刷真正要搞懂的重点数组和指针的关系是C语言里最容易被误解的一块内容。很多人背下了数组名就是首元素地址这句话但一到写代码还是会出问题。我建议二刷时把数组和指针的问题拆成三个层面来看数组名的本质、下标访问的实质、以及指针运算的规则。3.1 数组名的本质不是指针但会退化严格来说数组名并不是指针变量它是一个不可修改的地址常量指向数组的第一个元素。在大部分表达式中数组名会被隐式转换为指向首元素的指针这就是所谓的数组名退化。看这几行代码int arr[5] {1, 2, 3, 4, 5}; printf(%p\n, arr); // 输出首元素地址 printf(%p\n, arr[0]); // 同样输出首元素地址 printf(%p\n, arr); // 输出整个数组的地址前两个输出通常是相同的地址值但第三个arr虽然数值上可能和前者一样但类型是完全不同的arr的类型是int (*)[5]即指向含5个int元素的数组的指针。这个东西在指针运算里和arr有本质区别arr 1会跳过一个int也就是4字节arr 1会直接跳过整个数组也就是20字节。这个问题在笔试和面试里是经典考点二刷阶段必须亲手写一次验证印象才会深。3.2 下标访问的本质arr[i]其实是*(arr i)的语法糖在C语言中arr[i]会被编译器翻译成*(arr i)。这一点非常关键因为它解释了为什么arr[i]也可以写成i[arr]。第一次看到i[arr]这种写法的人一定以为这是什么高级技巧其实就是编译器把下标运算对称处理了arr[i]等价于*(arr i)而加法是交换的所以i[arr]等价于*(i arr)。虽然现实中没人这么写代码但很多面试题喜欢出这种来考察你是否真理解了下标运算的底层。指针的加减法不是简单的整数加减而是按照指针指向的类型大小来推进的。int *p arr; p 1;这个操作地址值实际加了sizeof(int)个字节。这是指针运算与普通整数运算最大的区别。二刷时必须把指针加1地址值加多少这个问题彻底弄清楚它背后隐藏的是C语言对内存布局的抽象。3.3 一维数组与指针的灵活组合一维数组和指针组合最常见的就是遍历。推荐三种写法int arr[5] {1, 2, 3, 4, 5}; int *p; // 写法一下标遍历 for (int i 0; i 5; i) { printf(%d , arr[i]); } // 写法二指针偏移遍历 p arr; for (int i 0; i 5; i) { printf(%d , *(p i)); } // 写法三指针递增遍历 for (p arr; p arr 5; p) { printf(%d , *p); }第三种写法里p让指针移动到下一个元素这是指针运算最典型的实际应用。注意一个细节p arr 5这个比较依靠的是指向同一数组内的指针可以比较大小这个语言规则。如果两个指针不是指向同一个数组比较结果是未定义行为。这个细节在做算法题、操作缓冲区时很容易踩到。3.4 二维数组、数组指针和指针数组一次分清这是二刷指针时最大的分水岭。二维数组和指针数组名字相近结构却完全不同。int arr[3][4]是一个真正的二维数组内存里是连续的12个int访问方式是通过行和列两个下标。int *arr[4]是一个数组里面存了4个int指针每个指针可以指向独立的内存区域所以它适合表示长短不一的字符串数组。而int (*arr)[4]是一个指针指向一个含有4个int的数组。面试里最经典的题目是下面的代码输出什么int a[3][4] {0}; printf(%p\n, a); printf(%p\n, a 1); printf(%p\n, a 1);a是二维数组名退化成指向第一行数组的指针类型是int (*)[4]所以a 1地址值增加一行的大小即4 * sizeof(int) 16字节。a的类型是int (*)[3][4]指向整个二维数组a 1直接跳过48字节。指针数组和数组指针的区分落实到代码里本质就是看[]和*的优先级。[]的优先级高于*所以int *arr[4]先解释成有4个元素的数组每个元素是int *而int (*arr)[4]小括号让*先生效arr是一个指针变量它指向的对象是含4个int的数组。二刷阶段我强烈建议自己写一个二维数组遍历函数参数用数组指针void print_matrix(int (*m)[4], int rows) { for (int i 0; i rows; i) { for (int j 0; j 4; j) { printf(%d , m[i][j]); } printf(\n); } } int data[3][4] {{1,2,3,4}, {5,6,7,8}, {9,10,11,12}}; print_matrix(data, 3);这里m[i][j]访问的本质是*(*(m i) j)。一开始可能有点绕但多画几次内存图自然就通了。4. 指针与字符串处理C语言里最常见的实战场景4.1 字符串字面量与字符数组的差异在C语言中字符串处理是笔试和面试的重灾区。很多初学者做过char *s hello;之后顺手就想用s[0] H;去修改第一个字符结果程序直接崩溃。原因在于hello是一个字符串字面量它在C标准里是只读的放在只读数据段试图修改它属于未定义行为。而char s[] hello;则是在栈上复制了一份字符串是可以修改的。二刷必须把这个区别刻在脑子里char *s1 hello; // 指向只读字符串字面量不能通过s1修改内容 char s2[] hello; // 可修改的字符数组 s2[0] H; // 合法这个知识点在实际项目中很常见比如处理命令行参数、拼接路径、修改配置文件内容时如果没有意识到字符串常量的只读性很容易写出偶发崩溃的代码。4.2 指针数组与字符串数组的选择之前提过int *arr[4]是指针数组在字符串场景下最常见的用法是char *months[] { January, February, March, April, May, June };这里每个元素是char *指向各自的字符串字面量。好处是内存占用小只存指针不复制整段字符串坏处是这些字符串内容不可修改。如果需要修改字符串内容就应该用二维字符数组char months[6][16] { January, February, March, April, May, June };这样每个字符串都是可写的但代价是每行都要开辟16字节空间即使实际内容很短也会浪费内存。实战中如果字符串列表需要频繁修改、排序、增删我会倾向于用char *数组加上动态内存分配而不是固定大小的二维数组。这个选型思路等你真正写过一个命令行工具或配置文件解析器就会有直观的感受。4.3 字符串逆序一道题讲透指针操作字符串逆序经常用C语言出题比如PTA上的经典题目。很多人的第一版写法是这样的void reverse(char s[]) { int len strlen(s); for (int i 0; i len / 2; i) { char tmp s[i]; s[i] s[len - 1 - i]; s[len - 1 - i] tmp; } }这个能跑但用指针写会更直观地体现指针操作的精髓void reverse(char *s) { char *left s; char *right s strlen(s) - 1; while (left right) { char tmp *left; *left *right; *right tmp; left; right--; } }这里left和right两个指针从字符串两端向中间移动解引用交换内容。对比下标写法我们能看到指针版本的优势不需要维护两个下标变量直接用指针的位置关系表达从两端向中间这个过程。很多二刷的同学在这个阶段会发现指针并不是用来炫技的它让代码逻辑更接近问题的自然表达。4.4 字符串库函数的指针本质strlen、strcpy、strcmp这些函数的底层实现全部依赖指针运算。以strcpy为例一个不查源码也能写出来的版本是char *my_strcpy(char *dest, const char *src) { char *ret dest; while (*dest *src) ; return ret; }while (*dest *src)这条语句非常经典它把复制字符判断是否为\0指针后移三个操作压缩到了一行里。很多人第一次看会愣住但只要理解了赋值表达式的值是被赋给的值就知道当*src是\0时赋值结果是0循环结束。这段代码也是理解为什么指针二刷值得花时间的一个绝佳例子——用下标写这个函数当然可以但指针版本会让你真正感受到C语言的表达力。5. 指针与函数传参、改变实参和函数指针5.1 为什么C语言函数参数默认是值传递C语言函数参数传递的方式是值传递也就是说实参的值会被拷贝一份给形参。这个特性导致一个常见的困惑为什么在函数里交换两个数外面没有变void swap(int a, int b) { int tmp a; a b; b tmp; } int main(void) { int x 3, y 5; swap(x, y); printf(%d %d\n, x, y); // 结果还是 3 5 }原因是swap函数里交换的是形参a和b的值它们只是实参x和y的副本。想要在函数里改变外面的变量就必须传入变量的地址然后用指针去修改void swap(int *a, int *b) { int tmp *a; *a *b; *b tmp; } int main(void) { int x 3, y 5; swap(x, y); printf(%d %d\n, x, y); // 5 3 }二刷时很多人会问为什么int *a就能改到外面答案在于*a并不是形参a自己那个指针变量的内容而是a指向的那块内存。我们传入的是x的地址*a就代表x这块内存对*a赋值就等于改x。区分指针变量本身和指针指向的内存在这一刻变得非常重要。5.2 用指针参数输出多个结果在实际编程中一个函数需要返回多个值的情况非常常见。比如写一个函数统计一个整数数组的最大值和最小值void get_min_max(const int arr[], int n, int *min, int *max) { *min *max arr[0]; for (int i 1; i n; i) { if (arr[i] *min) { *min arr[i]; } if (arr[i] *max) { *max arr[i]; } } }调用时传入min_val和max_val函数通过解引用把结果写回。这个模式在工程代码里特别常用比如函数需要同时返回结果和错误码或者长度和分配是否成功。理解了这个模式你读很多C语言系统库的API比如解析参数的函数会感觉突然顺了很多。5.3 函数指针把函数当成数据来传递函数指针是C语言进阶的一个重要标志很多教程把它放在很后面讲其实它的语法并不复杂。一个函数指针的声明是int (*handler)(int, int);这表示handler是一个指针指向一个接受两个int参数、返回int的函数。注意小括号又出现了它强制*先与handler结合让handler是指针。如果你写int *handler(int, int);就变成了一个返回int*的函数声明完全是两码事。给函数指针赋值和调用int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int main(void) { int (*p)(int, int) add; int r1 p(3, 4); // 7 p sub; int r2 p(3, 4); // -1 }函数名在表达式中会退化为函数指针和数组名的退化逻辑类似所以p add;不用取地址符。真正理解函数指针之后你会看到很多高级用法比如把函数指针放在结构体里模拟面向对象的行为或者把函数指针作为另一个函数的参数实现回调机制。5.4 回调函数的应用以排序和事件处理为例使用标准库的qsort函数是理解回调函数最直观的例子。qsort的函数指针参数就是用来让开发者告诉它如何比较两个元素int compare_int(const void *a, const void *b) { int ia *(const int *)a; int ib *(const int *)b; return (ia ib) - (ia ib); } int arr[] {9, 3, 7, 1, 8}; qsort(arr, 5, sizeof(int), compare_int);const void *表示一个指向未知类型的只读数据的指针qsort用它来接收任意类型的数据指针。在回调函数中我们必须用(const int *)把它转回具体的类型再解引用比较。这个模式需要习惯一下但它背后体现的把算法与数据类型解耦的思想对你以后学C的函数对象、Python的排序键函数都会有很大的帮助。在嵌入式开发里回调函数还常用于定时器处理、按键检测、网络事件处理等场景。比如你定义一个结构体里面存了事件类型和对应的处理函数指针上层代码根据事件类型调用handler-callback(data);就能实现非常灵活的分发逻辑。这也是为什么二刷指针时不建议跳过函数指针——它是很多现代C项目架构的基石。6. 多级指针、const关键字与动态内存管理6.1 二级指针指针的指针到底有什么用二级指针是初学指针时比较畏难的部分。其实把内存图画出来就没那么玄乎。int a 10; int *p a; int **pp p;内存结构是这样的a是int类型存10p是int*类型存a的地址pp是int**类型存p的地址。三级、四级指针以此类推每多一级就多一层间接引用。二级指针最常见的实际应用场景是在函数里修改调用者的指针变量本身的值。比如写一个初始化链表的函数void init_node(struct node **head) { *head (struct node *)malloc(sizeof(struct node)); (*head)-next NULL; }为什么要用二级指针因为我们需要修改的是调用者那边的头指针head本身。如果只传一级指针struct node *head函数里修改的只是这个指针的副本。只有把头指针的地址传进来通过*head才能让外部那个指针变量真正指向新分配的内存。很多项目代码里都有这种情况你想在一个函数里让一个指针指向新地址那这个函数就必须接收指针的地址也就是二级指针。类似的还有删除链表头节点后需要更新头指针的场景。我在网上看到过很多初学者问为什么链表头插入要传二级指针答案就是这个。6.2 const与指针的组合规则const修饰指针的规则也是二刷时的重灾区。其实只要记住一个判断方法看const在*的左边还是右边。const int *p;const修饰int表示p指向的是一个只读的int不能通过p修改那个int的值但p本身可以改指向。int *const p;const修饰p表示p本身不可修改必须初始化但可以通过p修改它指向的值。const int *const p;两样都不能改。现实中const int *p使用得最频繁它向代码的读者和编译器承诺我只读取你传进来的数据不会去改它。这个特性在写字符串处理函数时尤其重要比如void print_string(const char *s);不仅是对调用者的承诺也让编译器帮我们检查是否误改了只读字符串。二刷阶段我强烈建议凡是只读不写的指针参数一律加上const。这不仅是一个好习惯也是对代码可维护性的一种基本尊重。6.3 动态内存分配malloc、calloc和realloc的取舍在栈上声明数组大小必须是编译期确定的常量。但实际程序中数组大小往往要到运行时才知道这时候就需要堆内存。malloc(size)分配size字节连续内存不初始化里面是旧数据。calloc(n, size)分配n个size字节的内存并且每个字节都清零。realloc(ptr, new_size)调整之前分配的内存大小可能移动位置。使用动态内存的完整模板是int *arr; int n 100; arr (int *)malloc(n * sizeof(int)); if (arr NULL) { fprintf(stderr, malloc failed\n); exit(EXIT_FAILURE); } // 使用 arr free(arr);每一条都有讲究。n * sizeof(int)不能写成n * 4因为sizeof(int)在不同平台上不一样写sizeof(int)既明确又可移植。malloc返回void *赋值给int *需要类型转换在C语言里不转换也能编译但转换之后更清晰。更重要的是必须检查返回值是否为NULL因为系统内存不足时malloc会返回NULL。这个检查在嵌入式环境、长时间运行的服务器程序里尤其重要。free的时候还有一个容易犯的错释放之后没有把指针置NULL导致出现悬空指针。所谓悬空指针就是指针仍然保存着那块内存的地址但内存已经被释放了再去访问就是未定义行为。安全做法是free(arr); arr NULL;这样即使误用了也能在运行时检查出问题而不是踩到随机地址上。6.4 常见内存错误排查技巧二刷指针动态内存错误排查是绕不开的一部分。我分享几个我实际调试中的经验第一段错误(Segmentation fault)往往发生在空指针解引用、数组越界、访问已释放内存、缓冲区溢出。排查时先说清楚哪一行崩了再用调试器查看相关指针的地址值。第二内存泄漏难发现但后果严重。可以借助工具帮助检测比如Linux的valgrind或者ASanAddressSanitizer编译选项。ASan是集成在编译器里的内存检测工具编译命令加-fsanitizeaddress就能开启能精确告诉你哪一行越界了、哪块内存泄漏了。第三用一个调试宏包一层malloc和free是很多项目里常用的手段。比如#define DEBUG_MALLOC(size) \ debug_malloc(size, __FILE__, __LINE__) void *debug_malloc(size_t size, const char *file, int line) { void *ptr malloc(size); if (ptr NULL) { fprintf(stderr, malloc failed at %s:%d\n, file, line); } return ptr; }这样每次分配失败、泄漏的时候日志里能直接定位到源码位置排查起来比翻核心转储快得多。7. 数组与指针的经典笔试题和面试题解析7.1 数组名与运算符的组合题很多公司面试C语言岗位必考数组名和取地址的组合。常见题目是int arr[] {1, 2, 3, 4, 5}; int *p arr;问sizeof(arr)、sizeof(p)、*arr 1、*(arr 1)、arr 1分别是什么。sizeof(arr)是整个数组的字节数即5 * sizeof(int)通常为20。sizeof(p)是指针变量的大小32位系统下为464位系统下为8。*arr 1是arr[0] 1即2。*(arr 1)是arr[1]即2。arr 1是跳过整个数组之后的地址数值上等于arr 5但类型不同。这类题测试的是三个层次的掌握数组名的退化时机、指针运算的推进步长、sizeof在数组名和指针变量上的区别。二刷时建议亲自动手写一个程序把每一个表达式的值都打印出来尤其是地址值。7.2 数组指针二维数组的进阶题另一类高频题int a[3][4] { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; int (*p)[4] a;问*(*(p 1) 2)、*(p[1] 3)、p[2][1]的值。这里p是指向含4个int的数组的指针p 1会跳过一行4个整数所以*(*(p 1) 2)是a[1][2]即7。*(p[1] 3)中p[1]相当于a[1]加3后指向第1行最后一个元素值为8。p[2][1]就是a[2][1]值为10。做这类题我推荐一个方法先写出行指针和元素指针的层级不要试图一步跳出答案。等熟练之后你会发现二维数组的指针层次和无符号整数运算一样自然。7.3 指针字符串相关的笔试题集锦字符串部分也有几个高频考点。题目一char *str hello;和char str[] hello;的区别是什么。答案是前者不能修改字符串内容后者可以前者sizeof(str)取决于指针大小后者取决于数组长度前者字符串字面量存储在只读区域后者是栈上的数组。题目二下面这段代码为什么会崩char *s hello; s[0] H;答案是试图修改字符串字面量属于未定义行为实际执行时通常段错误。题目三char arr[] hello; char *p arr; printf(%lu %lu\n, sizeof(arr), sizeof(p));输出是多少arr长度是6包括结束符\0p是指针大小64位下是8。很多初学者会输出6 6就是因为没区分清楚数组名和指针的本质。7.4 链表的指针操作题目链表的增删改查是C语言指针应用的综合题。比如头插法void insert_head(struct node **head, int val) { struct node *new_node (struct node *)malloc(sizeof(struct node)); if (new_node NULL) return; new_node-val val; new_node-next *head; *head new_node; }为什么这里必须用二级指针struct node **head因为我们要修改调用者那边head指针变量本身。如果你传的是struct node *head在函数里给它重新赋值外部不会感知。很多链表实现喜欢用返回新头指针的方法比如struct node *insert_head(struct node *head, int val)这也是一种风格但在某些场景下比如统一接口二级指针更直接。删除节点的时候同样要小心。如果删除的是头节点需要更新头指针如果删除的是中间节点需要前一个节点的next指向后一个节点。指针操作在链表里的每一步都体现了修改指针指向与修改指针指向的内容的差异做几道链表题比背十遍概念更管用。8. 野指针、内存泄漏和常见bug的排查实战8.1 野指针产生的五大原因二刷指针避坑是重中之重。我总结一下野指针的常见来源第一未初始化指针就使用。比如int *p; *p 10;这里的p指向哪里完全不可知给这个未知地址赋值轻则数据被覆盖重则段错误。解决办法是定义时就初始化要么int *p NULL;要么指向一个合法对象。第二指针指向栈上的局部变量函数返回后局部变量失效。比如int *func(void) { int a 10; return a; }函数返回后a的内存已经失效但这个地址仍然存在访问它就是未定义行为。编译时开启-Wreturn-local-addr可以警告这类错误。第三malloc/free之后没有把指针置NULL或者free了两次。free两次会造成double free错误这通常发生在工程代码里多个函数共享同一个指针释放责任不清。第四数组越界。我见过一个典型的bug循环里数组下标计算错误写到了数组边界之外可能不会立刻崩溃而是把旁边某个变量的值覆盖了导致整个程序行为诡异。这种bug最隐蔽因为它可能不报错只是逻辑不对。第五指针类型不匹配强转之后解引用。比如把一个int *强转成char *然后操作如果预期是对齐或大小不同会出现奇怪的行为。8.2 用调试工具定位指针问题我自己的排查流程一般是先复现问题尽可能缩小到最小复现用例然后用调试器比如gdb查看崩溃点附近的指针值再用ASan或者valgrind找出内存错误的准确位置。举个例子一段代码在Linux上运行时报段错误用gdb启动gdb ./a.out (gdb) run (gdb) btbt打印出调用栈能直接看到程序在哪一行崩溃。然后在崩溃行查看相关变量print p、print *p、info locals。很多时候你会看到p的值是0x0空指针或者是一个奇怪的地址。如果是内存泄漏valgrind的经典用法是valgrind --leak-checkfull --show-leak-kindsall ./a.out输出会告诉你哪一行分配的内存在程序退出时还没有释放。如果是大项目--leak-checkfull可能输出非常多可以先跑一个小用例或者用过滤参数缩小范围。8.3 常见问题速查表我在下面整理了一张速查表二刷时可以直接对照排查症状常见原因快速排查方法段错误空指针解引用、野指针、越界gdb查看崩溃栈确认是哪一行程序崩溃但没规律内存越界、double free用ASan重新编译定位越界点内存持续增长分配后没释放valgrind或ASan检测泄漏字符串内容异常修改了字符串字面量检查指针指向的是字面量还是数组函数改不了外部变量参数传的是值而不是地址检查形参类型是否带*二维数组取值不对混淆数组指针和指针数组用typedef定义数组类型简化这张表我自己在带新人时经常用每一条都对应一个真实的调试故事。8.4 避免指针bug的思路和编码习惯从长期工程实践来看指针相关的bug是可以被系统性压低的。几个关键习惯定义指针时初始化不确定指向什么就置NULL。养成这个习惯之后你在使用前检查if (p ! NULL)就能避免很多空指针崩溃。把所有malloc和free的操作集中在相关的接口层不要分散在业务逻辑代码里。一个结构体如果负责管理一块缓冲区就让它的初始化函数分配、销毁函数释放其他代码只调用接口。使用const限制只读参数让编译器帮你检查误改。保持函数短小一个函数只做一件事。指针问题往往出现在一个函数里同时操作太多指针、绕来绕去的时候。把代码拆细减少间接层级问题自然暴露得早。二刷指针练熟笔试题是一方面更重要的是把这些防御性编码习惯内化为本能。指针本身不可怕可怕的是对它没有敬畏心。9. 二刷实践建议如何规划你的指针强化训练9.1 分三阶段复习路线第一周重新打基础。回到最底层的内存模型亲手把数据类型、变量、地址、指针变量的关系画出来。认真做一遍所有数组名下标的笔试题不跳过任何一个想当然的地方。第二周集中做指针与函数、动态内存、字符串、结构体链表相关的编程题。不要只看答案每一道题都要在白纸上写出变量内存图再上机验证。推荐找一些经典的习题集比如翁恺老师的练习题从指针部分开始一道一道过。第三周综合项目实战。写一个需要大量指针操作的完整案例比如实现一个字符串处理工具、一个简单的哈希表、一个命令行地址簿、一个经典的单向链表库。项目里必须包含动态内存分配、函数指针、二级指针修改等操作这样才能把零散的知识点连成体系。9.2 推荐练习项目和题目我建议二刷阶段优先做这几个项目因为它们覆盖了指针的几乎所有核心场景实现自己的字符串处理库。包括strlen、strcpy、strcat、strcmp、strchr、字符串逆序、单词统计等函数全部用指针实现并且和标准库对比测试结果。实现链表和基本操作。包括头插、尾插、删除指定值、查找、反转、销毁。注意头指针的二级指针使用以及释放内存的规范操作。实现一个简单哈希表。键值对存储、查找、删除涉及到二级指针、动态内存、字符串复制能把指针和内存管理的技能全部练到。用函数指针实现一个简化版的计算器。读入两个数和运算符通过函数指针表分发到对应的函数而不是一堆if-else。这些项目都不大但每一项做完都会有一种原来之前没学透的感觉。9.3 二刷阶段的核心心得我在给别人的学习建议里常说一句话指针不是用来背的是用来画的。遇到复杂的指针表达式不要试图用语言去绕直接在纸上画出内存布局这个变量在哪个地址那个指针存了哪个地址一旦画出来题目基本就解出来了。第二句话是多上机多调试故意写错代码。学指针最有效的方式之一就是故意制造一个段错误然后用gdb看它到底是怎么崩的。只有见过崩溃现场你才真正理解野指针、越界访问这些抽象概念到底意味着什么。第三句话是去读真实项目里的代码。比如开源的小型C项目、嵌入式驱动代码、系统库源码你会看到指针在真实工程里是如何被大量使用的。教材里那些为了讲解而讲解的例子和真实世界里的指针使用还是有一定距离的。我个人在实际操作中还有一个小习惯每次写完一组指针相关的代码都会重新问自己三个问题——每个指针指向的对象是什么类型那个对象在内存里的生命周期是到什么时候我对这个指针的修改是修改了指针本身还是修改了它指向的内容如果三个问题都能立刻回答上来这段代码基本就不会出现低级错误。二刷指针说到底就是把这三个问题变成一种本能反应。
返回列表