指针的高级话题:复杂指针声明解析、const与指针、指针与结构体、指针与链表

指针学到这个阶段,很多人会卡在「复杂声明」和「const 组合」上。说实话,我当年刚接触 C 语言时,看到 int *(*(*fp)(int))[10] 这种声明,第一反应是「这谁写的代码,想干嘛?」。后来在嵌入式项目里调试一个函数指针数组时,才真正体会到——复杂声明不是炫技,是实际需求逼出来的。

这一章,我带你彻底搞懂四件事:

  • 复杂指针声明怎么读(右左法则)
  • const 和指针的各种组合
  • 指针在结构体里的玩法
  • 指针怎么撑起链表这个数据结构

核心观点:指针声明就是「从内向外、先右后左」的阅读游戏。const 的位置决定了谁不能变。结构体指针是操作复杂数据的钥匙。链表是动态内存管理的经典案例。

指针高级话题知识体系 指针高级话题 复杂指针声明 右左法则解析 const与指针 四种组合方式 指针与结构体 -> 运算符、嵌套 指针与链表 动态内存、节点链接 四个主题层层递进,从语法到数据结构

1. 复杂指针声明解析:右左法则

先给你一个「心法」——右左法则。怎么用?从最里面的标识符开始,先往右看,再往左看,遇到括号就切换方向。说白了,就是「剥洋葱」,一层一层往外读。

举个例子:

int *(*(*fp)(int))[10];

别慌。我们从 fp 开始:

  1. fp 右边是 ),左边是 *,所以 fp 是一个指针。
  2. 往右看,遇到 (int),说明这个指针指向一个函数,参数是 int
  3. 再往左看,遇到 *,说明函数返回值是一个指针。
  4. 再往右看,遇到 [10],说明这个指针指向一个长度为 10 的数组。
  5. 再往左看,遇到 *,说明数组元素是指针。
  6. 最后往左看,遇到 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