指针的高级话题:复杂指针声明解析、const与指针、指针与结构体、指针与链表
指针学到这个阶段,很多人会卡在「复杂声明」和「const 组合」上。说实话,我当年刚接触 C 语言时,看到 int *(*(*fp)(int))[10] 这种声明,第一反应是「这谁写的代码,想干嘛?」。后来在嵌入式项目里调试一个函数指针数组时,才真正体会到——复杂声明不是炫技,是实际需求逼出来的。
这一章,我带你彻底搞懂四件事:
- 复杂指针声明怎么读(右左法则)
- const 和指针的各种组合
- 指针在结构体里的玩法
- 指针怎么撑起链表这个数据结构
核心观点:指针声明就是「从内向外、先右后左」的阅读游戏。const 的位置决定了谁不能变。结构体指针是操作复杂数据的钥匙。链表是动态内存管理的经典案例。
1. 复杂指针声明解析:右左法则
先给你一个「心法」——右左法则。怎么用?从最里面的标识符开始,先往右看,再往左看,遇到括号就切换方向。说白了,就是「剥洋葱」,一层一层往外读。
举个例子:
int *(*(*fp)(int))[10];
别慌。我们从 fp 开始:
fp右边是),左边是*,所以fp是一个指针。- 往右看,遇到
(int),说明这个指针指向一个函数,参数是int。 - 再往左看,遇到
*,说明函数返回值是一个指针。 - 再往右看,遇到
[10],说明这个指针指向一个长度为 10 的数组。 - 再往左看,遇到
*,说明数组元素是指针。 - 最后往左看,遇到
int,说明这些指针指向int。
合起来:fp 是一个函数指针,指向的函数接受 int 参数,返回一个指针,该指针指向一个长度为 10 的数组,数组元素是 int *。
我的习惯:遇到复杂声明,我会先用 typedef 拆解。比如上面那个,先 typedef int *(*ArrPtr)[10];,再 ArrPtr (*fp)(int);,瞬间清晰。我在项目中维护过一段 20 年前的通信协议栈代码,里面全是这种嵌套声明,不改 typedef 根本没法看。
2. const 与指针的复杂组合
const 和指针组合起来,有四种情况。我见过很多工程师在这上面翻车,包括我自己刚入行时也搞混过。
| 写法 | 含义 | 示例 |
|---|---|---|
const int *p |
指针指向的值不可变 | *p = 10; ❌ |
int *const p |
指针本身不可变 | p++; ❌ |
const int *const p |
指针和指向的值都不可变 | 两者都 ❌ |
int const *p |
等价于 const int *p |
同上 |
怎么记?看 const 在 * 的哪边。const 在 * 左边,修饰的是指向的值;const 在 * 右边,修饰的是指针本身。
我曾经踩过的坑:在嵌入式驱动里,用 const int *p 指向一个硬件寄存器地址,然后试图通过 *p = 0xFF 写值。编译器报错我才反应过来——const 把值锁死了。正确的做法是用 volatile uint32_t *const p,既保证指针地址不变,又能写寄存器。
3. 指针与结构体
结构体指针在嵌入式里太常用了。你想想看,一个结构体可能包含几十个成员,如果每次传参都复制整个结构体,栈空间瞬间爆炸。用指针传参,只传 4 字节(32 位机)或 8 字节(64 位机),效率高得多。
基本用法:
typedef struct {
int id;
char name[32];
float value;
} Sensor;
void update_sensor(Sensor *s) {
s->id = 1; // 等价于 (*s).id = 1
s->value = 3.14f;
}
这里 -> 运算符是结构体指针的专属语法。我建议你养成习惯:只要看到结构体指针,就用 ->,代码更清晰。
结构体里还能嵌套指针,比如链表节点:
typedef struct Node {
int data;
struct Node *next; // 指向下一个节点
} Node;
这种自引用的结构体,是链表的基石。注意,这里必须用 struct Node *,不能用 Node *,因为 typedef 还没定义完。
实际项目经验:我在做传感器数据采集时,用结构体指针数组管理多个传感器。每个传感器有自己的校准参数、滤波系数、数据缓冲区。用指针操作,代码简洁,内存也省。如果不用指针,光复制结构体就能把堆栈撑爆。
4. 指针与链表
链表是动态数据结构的经典代表。它的核心思想很简单:每个节点包含数据和指向下一个节点的指针。最后一个节点的 next 指向 NULL。
创建一个简单链表:
Node *head = NULL;
Node *second = NULL;
Node *third = NULL;
head = (Node *)malloc(sizeof(Node));
second = (Node *)malloc(sizeof(Node));
third = (Node *)malloc(sizeof(Node));
head->data = 1;
head->next = second;
second->data = 2;
second->next = third;
third->data = 3;
third->next = NULL;
遍历链表:
void print_list(Node *head) {
Node *current = head;
while (current != NULL) {
printf("%d ", current->data);
current = current->next;
}
}
插入节点(在头部插入):
void insert_head(Node **head, int new_data) {
Node *new_node = (Node *)malloc(sizeof(Node));
new_node->data = new_data;
new_node->next = *head;
*head = new_node;
}
注意:插入头部时,为什么传 Node **head?因为要修改 head 指针本身的值。如果只传 Node *head,你修改的是形参,实参不会变。这个细节我在面试时问过很多人,能答对的不到一半。
删除节点:
void delete_node(Node **head, int key) {
Node *temp = *head, *prev = NULL;
if (temp != NULL && temp->data == key) {
*head = temp->next;
free(temp);
return;
}
while (temp != NULL && temp->data != key) {
prev = temp;
temp = temp->next;
}
if (temp == NULL) return;
prev->next = temp->next;
free(temp);
}
我的建议:链表操作一定要画图。我在纸上画节点和箭头,标清楚每个指针的指向,再写代码。特别是删除和插入,画图能避免 90% 的 bug。另外,每次 malloc 后记得检查返回值,嵌入式系统内存有限,malloc 可能失败。
链表的优点:插入和删除快(O(1) 时间复杂度,如果已知位置),内存利用率高(按需分配)。缺点:不支持随机访问,查找慢(O(n))。
在实际项目中,我常用链表管理动态任务队列、事件缓冲、内存池的空闲块。比如一个网络协议栈,收包速率不稳定,用链表缓冲最合适——来一个包,分配一个节点挂上去,处理完就释放。
公众号:蓝海资料掘金营,微信deep3321