指针这东西,聊到第三篇,说明你已经跨过了“指针是什么”“指针怎么读写变量”“指针和数组到底啥关系”这些坎,开始往进阶区走了。这篇我打算把指针体系里最绕的几个点一次性理清楚:数组指针和指针数组怎么区分、二级指针为什么在函数传参里绕不开、函数指针和回调到底怎么落地,以及笔试面试里那些“看上去简单一做就错”的指针题到底在考什么。这篇文章适合正在学C语言准备考试的朋友,也适合写嵌入式、写底层代码时需要跟内存打交道的开发者,看完能帮你把指针这块拼图补完整。
同时我也想说一句:指针是C语言里最容易被误解、也最值得花时间吃透的部分。前面两篇讲的是“怎么用”,这一篇更多是“怎么用对”“怎么用活”。我会把实际开发中踩过的坑、调过的错也一并写进来,尽量让每个概念都能落地,而不是停留在教科书层面的定义。
1. 数组指针与指针数组:定义只差一个括号,行为差之千里
1.1 先分清谁是谁:优先级决定身份
先说一个我见过无数人翻车的现象:int *p[5] 和 int (*p)[5] 这俩声明,看着差不多,实际上一个是“数组”,一个是“指针”。判断方法很简单,看 p 先跟谁结合:* 的优先级低于 [],所以 int *p[5] 里 p 先跟 [5] 结合,说明它是一个数组,数组里面每个元素都是 int* 类型;而 int (*p)[5] 里 p 先跟 * 结合,说明它是一个指针,指针指向的是一整个长度为 5 的 int 数组。
用人话讲:指针数组是“装着指针的盒子”,数组指针是“指向盒子的绳”。这个类比虽然土,但在最初阶段帮助很大。我曾经带过的一个实习生,在写命令解析器的时候定义了一个 char *args[10],想往里面塞字符串,结果一不留神写成了 char (*args)[10],编译直接报错。后来我让他用“先结合”这个方法去分析,一句话就点通了。
实际写代码的时候,我建议大家不要靠“背”来区分,靠分析。看到声明就先问自己:p 旁边那个 * 有没有被括号跟 p 绑在一起?绑了就说明指针身份优先;没绑、被 [] 抢了先,那它就是数组。这个习惯练出来了,后面看任何复杂声明都不慌。
1.2 二维数组传参:数组指针的实战场景
数组指针最常见的应用场景是二维数组的函数传参。很多人写二维数组传参的时候习惯这样写:
c复制void printMatrix(int arr[3][4], int rows) {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < 4; j++) {
printf("%d ", arr[i][j]);
}
printf("\n");
}
}
这么写没问题,但换个角度看其实有隐患:int arr[3][4] 这种写法在形参里会被“退化”成 int (*arr)[4],也就是数组指针。既然本质是数组指针,为什么不直接把它写出来呢?直接写出来有个好处,就是别人看函数声明时能立刻明白参数结构,而不是靠“读代码猜形状”。
正确写法是这样的:
c复制void printMatrix(int (*arr)[4], int rows) {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < 4; j++) {
printf("%d ", arr[i][j]);
}
printf("\n");
}
}
这两种写法语义完全等价,但后者更清楚地表明:这个函数接收的是一个“指向一行(4个整数)的指针”。理解了这一点,你就能明白为什么二维数组传参时第二维必须写死——因为指针在做 arr + 1 运算时,需要知道每一行到底有多长,才能准确跳转到下一行。如果你不指定列数,编译器就不知道一次移动多少个字节。
这里有个细节容易踩坑:如果你试图写 void printMatrix(int **arr) 去接收一个二维数组,编译时会报警告,运行后大概率错误。因为二维数组名是“指向数组的指针”,它的层级跟“指向指针的指针”并不一样。虽然有些场景下 int** 可以用来访问指针数组,但拿来访问真正意义上的二维数组,步长是不匹配的。这个坑我见过不少刚接触指针的人往里跳。
1.3 指针数组的经典用法:保存字符串集合
指针数组最典型的实战场景是保存一堆字符串。比如写一个小命令行工具,需要把用户输入的多个参数存起来。这时你可以这样做:
c复制char *commands[] = {
"help",
"version",
"quit",
"status"
};
commands 是一个数组,里面有 4 个 char* 元素,每个元素保存一个字符串常量的首地址。这样做的好处是:字符串本身不拷贝,数组里只存地址,省内存、初始化也快。访问的时候 commands[0] 拿到的就是 "help" 这个字符串的首地址,直接 printf("%s\n", commands[0]) 就能打印。
如果用二维字符数组 char commands[4][10],那每一行都要预留 10 个字节,如果字符串长短不一,空间浪费就很明显。尤其当你处理的字符串很多、长度差异很大的时候,指针数组的优势非常突出。我在实际项目里写菜单表、协议解析表、命令映射表时,几乎都是指针数组的标准用法。
c复制// 命令映射表,配合函数指针可以做成一个迷你调度器
struct Command {
char *name;
void (*handler)(void);
};
等等,函数指针这块我后面专门讲,这里先留个钩子。反正你记住:指针数组是把“地址”当成集合元素来管理,这在处理一组字符串、一组函数、一组对象句柄的时候都特别好用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 二级指针:函数里修改外部指针的唯一方案
2.1 为什么需要二级指针:值传递的困局
在C语言里,函数参数默认是按值传递的。这意味着形参是实参的拷贝,你在函数里改形参,外面不会变。这个大家都知道,但放到指针身上就经常被忽视。很多人写过这样的代码:
c复制void allocateMemory(int *p) {
p = (int *)malloc(10 * sizeof(int));
}
int main() {
int *ptr = NULL;
allocateMemory(ptr);
if (ptr == NULL) {
printf("分配失败\n");
}
}
发现问题没有?allocateMemory 内部确实把 p 指向了新内存,但 p 只是 ptr 的拷贝,函数结束后,ptr 依然是 NULL。这个问题我都不记得见过多少次了,尤其是刚学动态内存分配的同学,十有八九会写错。
要解决这个问题,核心思路是:既然函数只能通过地址去修改外部变量,那我们要修改的是指针变量本身,就必须把指针变量的地址传进去。指针变量的地址是什么类型?就是二级指针 int **。于是正确的写法变成了这样:
c复制void allocateMemory(int **p, int n) {
*p = (int *)malloc(n * sizeof(int));
if (*p == NULL) {
// 处理分配失败
}
}
int main() {
int *ptr = NULL;
allocateMemory(&ptr, 10);
}
对照一下:一级指针 int *p 能修改 p 所指向的 int;二级指针 int **p 能修改 p 所指向的 int*。每一层就多了一层间接性。这个规律是可以外推的,三级指针修改二级指针指向的变量的值,四级指针修改三级指针,但实际上代码里写到二级就够用了,再往上基本是自找麻烦。
2.2 二级指针的经典应用:链表头插法与树操作
除了内存分配,二级指针在链表、二叉树这些数据结构里也很常见。拿单链表头插法举例:
c复制void insertHead(ListNode **head, int val) {
ListNode *newNode = (ListNode *)malloc(sizeof(ListNode));
newNode->val = val;
newNode->next = *head;
*head = newNode;
}
如果这里只传一级指针 ListNode *head,那你修改的只是形参,外边的头指针还是老样子,插完等于白插。用二级指针,就能直接在原头指针上做更新。我早期写链表的时候也犯过这个错,后来索性给自己定了个规矩:只要函数里需要修改“指针变量的指向”,一律用二级指针。
这个规则其实可以推广:如果你看到一个函数需要让外边的指针“指向别处”,那就必须传这个指针的地址。比如链表的删除函数要修改前驱节点的 next 指针,虽然 next 本身是 ListNode*,但我们需要修改的是它,所以通常要传 ListNode ** 或通过头节点的指针间接操作。理解这一个逻辑,二级指针的应用基本就通透了。
2.3 内存分配失败检查:新手最容易漏的一环
说到 malloc,我必须多嘴提醒一句:malloc 是有可能返回 NULL 的。很多人写了十年八年代码也可能因为没检查返回值而在线上的某个角落翻车。我在自己的代码里,凡是涉及动态内存分配,都会先判断再使用:
c复制int **createMatrix(int rows, int cols) {
int **matrix = (int **)malloc(rows * sizeof(int *));
if (matrix == NULL) {
return NULL;
}
for (int i = 0; i < rows; i++) {
matrix[i] = (int *)malloc(cols * sizeof(int));
if (matrix[i] == NULL) {
// 分配失败,需要释放之前已分配的每一行
for (int j = 0; j < i; j++) {
free(matrix[j]);
}
free(matrix);
return NULL;
}
}
return matrix;
}
这个例子同时展示了二级指针的常见用途:二级指针动态创建一个矩阵,每一行又是一个一级指针指向一维数组。这里的 matrix 本质上是个指针数组——数组里每个元素是 int*。这就是把指针数组和二级指针打通的最好案例,两个概念在很多场景下是同一回事。
释放的时候也要注意顺序,先释放每一行,再释放所有指向行的指针集合,顺序反了会直接泄漏内存或者导致访问违例。这些细节你面试时写不出来没关系,但真实代码里必须严谨,一旦内存管理出问题,跑起来就是段错误或者内存泄漏,排查起来相当耗时。
3. 函数指针:把函数当数据进行传递
3.1 函数指针的声明与分析
函数指针这个概念,一开始很容易把人吓住,但说到底它不过是一个变量,变量里存的是函数的入口地址。C语言里函数名本身就代表函数的入口地址,所以把函数名赋值给函数指针,跟把数组名赋值给指针很像。
最简单的声明方式:
c复制int add(int a, int b) {
return a + b;
}
int main() {
int (*fp)(int, int) = add;
int result = fp(3, 5);
printf("%d\n", result); // 8
}
这个声明怎么读?我的技巧是“剥洋葱”:先从 fp 开始,(*fp) 说明 fp 是一个指针;再看右边的 (int, int),说明它指向的函数接受两个 int 参数;最左边 int 说明函数返回值也是 int。所以合起来就是:fp 是一个指向“接受两个int参数、返回int”的函数的指针。
注意函数指针的语法里那个括号不能省,写成 int *fp(int, int) 就变成了“一个函数,参数是(int,int),返回 int*”,意思完全不同。这也是C语言声明里最坑的地方之一,括号是身份的分水岭。
有了函数指针,就可以把“行为”本身当成一种参数或数据来传递。这就为回调函数打下了基础。
3.2 回调函数实战:通用排序与状态机
回调函数是函数指针最实际的应用。比如你写一个通用的升序降序排序器,不需要为每一种排序规则都单独写一个函数,而是把“比较规则”作为回调传进去:
c复制typedef int (*CompareFunc)(int, int);
int intCompareAsc(int a, int b) {
return a - b;
}
int intCompareDesc(int a, int b) {
return b - a;
}
void bubbleSort(int arr[], int n, CompareFunc cmp) {
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (cmp(arr[j], arr[j + 1]) > 0) {
int tmp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = tmp;
}
}
}
}
这样调用的时候,想按升序传 intCompareAsc,想按降序传 intCompareDesc,排序函数本身完全不用改。这个模式在实际项目里应用极广:嵌入式里注册中断回调、GUI里绑定按钮点击事件、网络库里设置数据到达通知,背后的本质都是函数指针。
我在一个物联设备项目里写过一套按键扫描逻辑,就是用函数指针数组来做的。按键不同,触发不同回调,代码结构非常清晰,新增一个按键事件只需要往数组里加一个函数指针,不用改框架代码。这种扩展方式,比堆一长串 switch-case 要优雅太多。
另外提一点:typedef 定义函数指针类型会让代码可读性大幅提升。你不想每次写 int (*fp)(int, int) 这种长声明吧?直接 typedef int (*CompareFunc)(int, int),后面用 CompareFunc 就行。这是我在代码审查时经常给新人建议的一点。
3.3 函数指针数组与命令分发
如果说单个函数指针解决的是“传一个行为”,那函数指针数组解决的就是“按索引查行为”。比如协议解析里,根据消息类型分发到不同处理函数:
c复制void handleLogin(void) { /* ... */ }
void handleLogout(void) { /* ... */ }
void handleQuery(void) { /* ... */ }
void (*handlers[])(void) = {
handleLogin, handleLogout, handleQuery
};
// 按消息类型调用
handlers[msgType]();
这个写法把顺序 if-else 的判断复杂度降到了 O(1),而且增删处理逻辑只需维护数组。新手可能觉得这个写法“太高级”,但其实它就是指针数组和函数指针的组合,理解到这一层,C语言的间接寻址之美就体现出来了。
4. 指针与字符串:只读区与栈区的一步之遥
4.1 字符串字面量 vs 字符数组:能不能修改是关键
字符串在C语言里没有独立的类型,本质是字符数组加一个 \0 结尾。但同样写一个 "hello",在不同上下文里行为完全不同。看看这两行代码:
c复制char str[] = "hello";
char *p = "hello";
str 是在栈上开辟了一块可读写内存,把 "hello\0" 逐字符拷贝进去,所以你可以安全地修改 str[0]。而 p 指向的是一个字符串字面量,这个字面量通常存放在只读数据段,试图修改 p[0] 会引发未定义行为,在Linux上基本直接段错误。
这个知识点在考题里经常出现,比如“下面哪种方式可以修改字符串?”这类题。我总结一个判断方法:看它是不是数组。数组就有自己的空间,可以改;指针只是“指着别处”,它指向的内容能不能改,要看内容的位置。
我当年第一次写字符串翻转程序时,就是定义成 char *str = "hello"; 然后想原地翻转,结果一运行就段错误,排查半天才发现是只读区域的问题。这个教训记忆非常深刻,后来凡是需要修改字符串内容,我统一用字符数组。
4.2 字符串逆序:指针实现的两种思路
字符串逆序是经典题,PTA、C语言习题里都有。用指针实现,最常见的做法是双指针从两端往中间走:
c复制void reverseString(char *s) {
if (s == NULL) {
return;
}
char *left = s;
char *right = s;
while (*right != '\0') {
right++;
}
right--; // 指向最后一个有效字符
while (left < right) {
char tmp = *left;
*left = *right;
*right = tmp;
left++;
right--;
}
}
这里有个容易出错的地方:right-- 之前要先把 right 指到字符串末尾的 \0,再往回退一位。很多人会忘记先找到 \0 的位置,导致指针越界。另一个常见错误是循环条件写成 left != right,当字符串长度为偶数时两个指针会擦肩而过,永远不相等,最后越界访问。用 left < right 才是安全的写法。
第二种思路是用指针加偏移量来写,本质一样但更体现“指针就是地址”的概念:
c复制void reverseString(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;
}
}
这种写法在考试里写起来更快,而且不容易因为指针前后移动搞混边界。两种方法我都推荐练一练,锻炼从不同视角理解同一片内存。
4.3 字符指针遍历的边界检查
不管用哪种方式处理字符串,都要留意数组越界问题。C语言不会给你做边界检查,数组越界写可能当时不出错,但在某个意想不到的时刻让程序崩溃。尤其是你拿指针遍历字符串时,循环条件必须明确基于 \0 或长度值,不能想当然。
在实际项目中,我习惯性地在函数入口对指针参数做一次 NULL 判断再往下走。很多人觉得“这个函数我明明传了有效值”,但防御式编程能帮你省下大量的排查时间。低级错误往往出现在故障场景里,指针为空、越界、内存损坏,这些都不是“正常情况”下能复现的问题,往往需要靠日志、调试器、甚至内存检测工具才能定位。
5. 数组与指针笔试题:sizeof、strlen和加减法的一场混战
5.1 sizeof 数组名 vs 指针变量
这是笔试题里出现频率最高的考点之一。核心规则就是:sizeof(数组名) 返回整个数组的字节数,而 sizeof(指针变量) 返回指针本身的大小。在64位系统上指针大小一般是8字节,32位系统是4字节。注意,数组名一旦作为函数参数传递,就会退化成指针,于是 sizeof 的结果也变了。
来看一段典型代码:
c复制char str[] = "hello";
char *p = str;
printf("%zu\n", sizeof(str)); // 6,5个字符加一个\0
printf("%zu\n", sizeof(p)); // 8,指针本身的大小
printf("%zu\n", strlen(str)); // 5,不包含\0
printf("%zu\n", strlen(p)); // 5
sizeof 算的是“盒子”多大,strlen 算的是“里面的内容到哪结束”。这个类比可以帮助记忆:盒子大小是固定的,内容是动态的。数组名在 sizeof 眼里是完整的盒子,在别的语境里往往被当成指向盒子的绳。
这个考点其实考的是“数组名不是指针”这一本质。我看到很多初学者把数组名和指针画等号,结果在 sizeof 这里栽跟头。正确的理解是:数组名在大多数表达式中会退化成指向首元素的指针,但在 sizeof 和取址运算符 & 的场景下,它仍然代表整个数组。
5.2 二维数组旁路:指针加减的步长问题
二维数组名的加减也经常出题。比如:
c复制int arr[3][4];
int (*p)[4] = arr;
此时 p + 1 指向的是 arr[1],也就是跳过了整整4个int。有人会问,数组名退化后不是指向首元素吗?首元素是 int[4],那 p + 1 自然跳4个int。关键在于:指向的对象类型决定步长。这是一个通用原则,一维数组 int *p 指向 int,步长是 sizeof(int);二维数组 int (*p)[4] 指向 int[4],步长是 4 * sizeof(int)。步长由“指针的类型”决定,而不是由“地址本身是多大的数”决定。
再看一个容易混淆的写法:
c复制int arr[3][4];
int *q = &arr[0][0];
此时 q + 1 只跳过一个 int,因为 q 是 int*。同样是操作一块二维数组的内存,用不同的指针类型访问,步长完全不一样。这种题在考试里也经常作为“陷阱”出现。
我比较推荐的记忆方式:指针加减单位看它声明时的类型。如果声明成 int (*p)[4],觉得复杂就先看括号里 *p 中间去掉指针符号后剩什么类型,剩 int[4],那步长就是 sizeof(int[4])。
5.3 指针比较与索引:a[i] 与 *(a+i) 的等价性
C语言里 a[i] 本质上是 *(a+i) 的语法糖。这个结论很多人知道,但真正理解它的人不多。因为有了这个等价性,甚至 i[a] 也可以访问 a[i],因为 *(i+a) 跟 *(a+i) 没有区别。我曾经面试时把这道题抛给候选人,能一眼反应过来的,指针基础一般都不差。
但要注意,这个等价性成立的前提是:a 是一个指针或数组名。如果 a 是一个多维数组,a[i][j] 等价于 *(*(a+i)+j)。这里外层 *(a+i) 拿到的是第i行的地址,它本身又是个“指向int的指针”,再 +j 偏移到第j列,最后解引用得到元素值。每一步都是指针运算,理解了它就理解了多维数组访问的底层逻辑。
6. 常见问题与排查技巧:指针挂了,程序到底错在哪
6.1 段错误的第一反应:是不是碰了不该碰的内存
段错误(Segmentation Fault)估计是每个C程序员最熟悉的报错。它的本质是程序访问了无权访问的内存,比如空指针解引用、访问已释放的内存、数组越界写、修改只读字符串等。
我排查段错误的习惯是有步骤的。先看日志和崩溃栈,用 gdb 跑一遍,找到具体崩在哪一行,再往前看是哪个指针的值异常。如果手头没有调试器,就用 printf 在关键位置打印指针的地址和值,一步步缩小范围。老牌工具 valgrind 也能帮忙定位非法内存访问,就是跑起来慢一点。
很多初学者一看到段错误就懵,其实它反而比一些“静默错误”好查。静默错误是程序不崩溃但结果不对,往往意味着内存已经被悄悄破坏,比如越界写导致的“隔一段时间才炸”,这种才是真正难调的。
6.2 野指针、悬空指针与内存泄漏
野指针是没有初始化的指针变量,内存里存的是一个随机值,指向哪里完全不可控。规矩只有一条:每次定义指针时都赋初值,不能确定指向什么时也要指向 NULL。判断的时候用 if (p != NULL) 兜底,这样至少能避免部分随机行为。
悬空指针是曾经指向有效内存,但内存被 free 之后指针还在。free 之后没有把你的指针变量置 NULL,这块内存可能被系统回收或重新分配给别的对象。后续再访问它,就是未定义行为。我个人的习惯是:free 完立刻把指针置 NULL,而且我还会在释放前检查这是一块自己malloc的内存。这个习惯在面对共享指针、多模块引用同一个资源时尤其重要。
内存泄漏虽然不直接造成崩溃,但长时间运行的程序(比如服务器、嵌入式设备)会因内存不断减少而性能下降,最终导致系统不稳定。写C代码时,我要求在每条分配路径上都写释放逻辑,而且必须确认释放函数与分配函数匹配:malloc 对应 free,calloc 也对应 free。如果你在C++里用了 new,那就得配 delete,别混用。
6.3 指针调试三板斧:gdb、printf、valgrind
聊到调试,我把自己常用的一套流程分享出来。首先当然是编译时开启调试信息(-g),用 gdb 跑。gdb 里最实用的几个命令:bt 看调用栈,print 打印变量值,x 查看内存内容。指针变量用 print p 看它存的地址,再用 x/4wx p 看这个地址附近4个字的内容,基本能判断指针指向是否合理。
如果没有调试环境,printf流是最后的退路。我喜欢在指针操作的关键位置输出“地址-值”对应关系。比如二分查找的边界指针变化,链表各个节点的指针地址,printf一打出来,逻辑错误肉眼可见。但printf也有副作用:改一行代码可能改变程序的内存布局,让本来存在但没暴露的问题消失,排查过程容易被误导,所以最终定位还是要回归调试器。
对于更复杂的堆内存问题,valgrind 是很靠谱的检测工具。它能识别未初始化内存的读取、越界读写、重复释放、泄漏等。我第一次用的时候它直接指出了一个“越界写4字节”的问题,而这正是程序几分钟后就崩溃的根源。工欲善其事必先利其器,这套三板斧用熟了,指针问题基本不会困住你太久。
6.4 常见的指针笔试题速查
我把面试和考试里经常出现的指针相关点整理成一个速查表,方便大家对照复习:
| 考察点 | 关键结论 |
|---|---|
sizeof(数组名) vs sizeof(指针) |
前者是整个数组字节数,后者是指针自身大小 |
strlen 与 sizeof |
strlen 不包含 \0,sizeof 包含 |
| 函数传参时的数组名 | 退化成指针,丢掉了数组长度信息 |
| 指针加减 | 移动的字节数 = 指针指向类型的大小 |
char *p = "hello" |
指向只读区,不能修改内容 |
char p[] = "hello" |
栈上拷贝,可以修改 |
| 二级指针的使用场景 | 函数内部需要修改外部指针变量的指向 |
| 函数指针 | 存储函数入口地址,可用于回调和多态 |
| 空指针与野指针 | 空指针指向0地址,野指针指向随机地址 |
提示:这些点听起来简单,但没有动手敲过代码,考试时往往容易写错。强烈建议每个点都自己写个10行以内的小程序验证一遍。指针这东西,看一百遍不如亲手调试一遍,踩过一次坑比抄十遍笔记都管用。
7. 指针的进阶扩展:从C到C++的一点启发
7.1 智能指针:C++对C指针痛点的补丁
聊C语言的指针,很难不提一嘴C++里的智能指针。很多人从C转C++,最大的惊喜之一就是终于不用手动 free 了。std::unique_ptr、std::shared_ptr、std::weak_ptr 分别对应独占所有权、共享所有权、观察式引用三种内存管理策略。它们解决的核心问题就是C语言里最容易翻车的那几样:忘记释放、重复释放、悬空指针。
但从学习角度看,我不建议你还没吃透原始指针就直接上智能指针。因为智能指针的很多设计与原始指针的语义一脉相承,比如解引用、成员访问、数组索引,底层还是在跟裸指针打交道。理解原始指针的内存模型,再看智能指针的“生命周期管理”概念,就会轻松很多。
7.2 嵌入式开发中的指针场景
如果你做嵌入式开发,指针的使用频率会更高。寄存器访问、缓冲区管理、协议栈解析、中断回调,几乎离不开指针。有时候你会见到直接操作绝对地址的情况,比如把某个外设寄存器的地址强制转换成一个指针类型来访问,这需要用指针类型转换的特性。不过这类代码可读性差且平台相关,实际项目中一定要加详尽的注释说明地址来源和操作意图。
串口接收一帧不定长数据时,一种常见做法是维护一个环形缓冲区,用两个指针分别表示写位置和读位置。生产者写入时移动写指针,消费者读取时移动读指针,当写指针追上读指针时说明缓冲区满。这个模型里写指针和读指针本身就是缓冲区数组中的两个偏移量,理解指针运算能帮你更快看懂这类代码。
7.3 从指针角度看数据结构的本质
数据结构里大量使用指针,原因很朴素:指针让我们能在不确定的内存位置维持对象之间的关系。链表、树、图的节点之间没有物理上的相邻关系,全靠指针把它们串起来。理解这一点之后,你再看各种算法书里的伪代码,会觉得豁然开朗。
我用二叉树举过一个例子:树的插入操作如果设计为返回新子树根节点,那么递归调用时就不需要二级指针;如果设计成直接修改原节点指针,那就要在函数里传入二级指针。两种风格都能实现,但背后的指针理解程度是不同的。能把一种数据结构写出多种指针风格的实现,说明你对指针和内存布局的掌握已经到了比较扎实的程度。
code复制
写到这里,我想起自己当年刚开始啃指针时,也曾在凌晨两点对着 `int (*p)[5]` 和 `int *p[5]` 发呆,也曾在 `free` 之后忘记置空然后被悬空指针折磨。踩过这些坑之后才明白,指针本质上没有那么玄学,它不过是一套“用地址间接操作数据”的规则体系。你只要把规则拆成几个核心点——声明和类型决定步长与身份、函数传参靠值传递要改外部就传地址、动态内存要配对齐、指针指向要始终可控——绝大部分问题都能稳住。希望在读这篇的你,也能把指针当成工具,而不是心头之患,在C语言这条路上越走越顺。
