嵌入式C语言二级指针详解:改外部指针、Linus链表、输出型参数实战
2026/8/18 18:44:21 网站建设 项目流程

上一篇讲面向对象,把封装、继承、多态在 C 里的实现理了一遍。这篇回到一个更基础、却更让人犯怵的东西:二级指针。

指针是 C 语言的核心,这点搞嵌入式的人不会有异议。但一说到二级指针,很多人的反应是"知道有这个东西,平时不怎么用",甚至觉得它是炫技用的,实际项目里用不上。

事实恰恰相反。如果你写过链表操作、做过动态内存管理、设计过模块初始化接口,大概率已经碰到过"一级指针搞不定"的场景。那些场景里,二级指针不是可选项,是唯一正确的方案。

这篇结合嵌入式项目里的真实场景,把二级指针的用法掰开讲清楚:它本质是什么、什么时候必须用、怎么用才不容易出错。

二级指针到底是个什么东西

一句话:二级指针就是"存放指针变量地址"的指针。

听起来绕,画个图就清楚了。假设有这段代码:

int val = 42; int *ptr = &val; int **pp = &ptr;

三者的解引用关系很直接:*pp得到 ptr 的值,也就是 val 的地址;**pp得到 val 的值,也就是 42;修改*pp就是在修改 ptr 本身。

最后这一点是关键。如果你想在一个函数里改变调用者的指针变量指向哪里,就必须传这个指针的地址进去,也就是用二级指针。这不是什么高级技巧,是 C 语言值传递机制下的必然结果。

最经典的场景:函数内部改外部指针

这是二级指针使用频率最高的场景,也是新手最容易踩的坑。

先看一个错误示范。写一个函数,内部分配一段缓冲区,让调用者拿到这块内存:

void alloc_buffer(uint8_t *buf, size_t size) { buf = (uint8_t *)malloc(size); if (buf) { memset(buf, 0, size); } } void app_init(void) { uint8_t *rx_buf = NULL; alloc_buffer(rx_buf, 256); // rx_buf 仍然是 NULL!分配的内存泄漏了 }

调用完 alloc_buffer,rx_buf 还是 NULL。为什么?C 语言的参数传递是值拷贝。函数内部的 buf 只是 rx_buf 的一份副本,改 buf 跟 rx_buf 没有半毛钱关系。

正确做法是传二级指针:

void alloc_buffer(uint8_t **buf, size_t size) { *buf = (uint8_t *)malloc(size); if (*buf) { memset(*buf, 0, size); } } void app_init(void) { uint8_t *rx_buf = NULL; alloc_buffer(&rx_buf, 256); // rx_buf 现在指向分配好的 256 字节内存 }

&rx_buf进去,函数通过*buf就能直接修改 rx_buf 本身的值。

这个模式在嵌入式里非常常见。很多模块的初始化接口都是这种设计:函数内部创建实例,通过二级指针把句柄交给调用者。比如:

int uart_create(uart_handle_t **handle, const uart_config_t *cfg); int spi_create(spi_handle_t **handle, const spi_config_t *cfg);

第一个参数就是二级指针,函数内部分配资源后,把句柄写回给调用者。FreeRTOS 的xTaskCreate、很多 HAL 库的xxx_Init(handle),背后都是这套思路。

链表操作:二级指针让代码优雅一个档次

链表是嵌入式开发的基础数据结构。插入和删除时,二级指针能消除大量特殊情况判断,让代码干净很多。

先用一级指针写链表插入,大概是这样:

typedef struct node { int data; struct node *next; } Node; void list_insert(Node *head, int value) { Node *new_node = (Node *)malloc(sizeof(Node)); new_node->data = value; new_node->next = NULL; if (*head == NULL) { *head = new_node; // 空链表,直接当头节点 return; } Node *cur = *head; while (cur->next != NULL) { cur = cur->next; } cur->next = new_node; // 插到尾部 }

这段代码要判断*head是否为空,头插和尾插是两套逻辑。链表删除更麻烦,删头节点和删中间节点又是两套处理。

再看 Linus Torvalds 推崇的二级指针写法,这是链表删除:

void list_remove(Node **head, int target) { Node **curr = head; while (*curr) { if ((*curr)->data == target) { Node *to_delete = *curr; *curr = (*curr)->next; // 直接修改"指向当前节点的那个指针" free(to_delete); return; } curr = &((*curr)->next); } }

这段代码没有任何"是不是头节点"的特殊判断。为什么能做到?关键在于curr是一个Node **,它始终指向"某个指针变量"。

核心思想是:curr 不是指向"当前节点",而是指向"指向当前节点的那个指针变量"。不管这个指针变量是 head 还是某个节点的 next 成员,操作方式完全一样。所以不需要区分"删头节点"还是"删中间节点"。

这个技巧在嵌入式的任务队列、消息链表、定时器链表里都很实用。代码少了 if-else,不仅更简洁,出 bug 的概率也更低。Linus 当年在访谈里专门拿这个举例,说"能想到用二级指针简化链表的人,才真正理解了指针"。

指针数组:管理多个缓冲区

嵌入式项目里经常要管理多个缓冲区。比如一个通信模块同时维护多个通道的收发缓冲区,或者一个日志模块管理多个日志区。这时候二级指针就派上用场了。

先理清"指针数组"和"二级指针"的关系:

uint8_t *buffers[4]; // 指针数组:4个指针,每个指向一块缓冲区 uint8_t **pp = buffers; // 二级指针,指向这个指针数组的首元素

每个缓冲区的大小可以不同,这是二级指针相比二维数组的优势。二维数组要求每一行的列数相同,指针数组没有这个限制。

一个实际例子:为多个 UART 通道动态分配接收缓冲区。

#define UART_CH_MAX 4 typedef struct { uint8_t **rx_bufs; // 指向各通道接收缓冲区的指针数组 size_t *buf_sizes; // 各通道缓冲区大小 uint8_t ch_count; // 通道数 } uart_manager_t; int uart_manager_init(uart_manager_t *mgr, const size_t *sizes, uint8_t count) { mgr->ch_count = count; // 分配指针数组 mgr->rx_bufs = (uint8_t **)malloc(count * sizeof(uint8_t *)); if (!mgr->rx_bufs) return -1; mgr->buf_sizes = (size_t *)malloc(count * sizeof(size_t)); if (!mgr->buf_sizes) { free(mgr->rx_bufs); return -1; } // 逐个通道分配缓冲区 for (uint8_t i = 0; i < count; i++) { mgr->rx_bufs[i] = (uint8_t *)malloc(sizes[i]); if (!mgr->rx_bufs[i]) { for (uint8_t j = 0; j < i; j++) free(mgr->rx_bufs[j]); free(mgr->buf_sizes); free(mgr->rx_bufs); return -1; } mgr->buf_sizes[i] = sizes[i]; memset(mgr->rx_bufs[i], 0, sizes[i]); } return 0; }

用的时候,通过索引访问各通道的缓冲区:

uint8_t *buf = mgr->rx_bufs[ch]; size_t len = mgr->buf_sizes[ch];

注意:在内存受限的 MCU 上,频繁 malloc/free 容易产生内存碎片。如果通道数量和缓冲区大小在编译期就能确定,更推荐用静态数组加指针数组的方式来做。动态分配那套留给资源宽裕、通道数运行时才确定的场景。

实战:用二级指针做模块注册与查找

稍微复杂一点的嵌入式项目,经常需要一套"注册-查找"机制。比如有多个传感器驱动,想在运行时根据名称找到对应的驱动并调用它的接口。

先定义传感器驱动的接口结构体:

typedef struct { const char *name; int (*init)(void); int (*read)(float *value); void (*deinit)(void); } sensor_drv_t;

然后用一个"指针数组"来管理已注册的驱动:

#define SENSOR_DRV_MAX 8 static sensor_drv_t *drv_table[SENSOR_DRV_MAX]; static uint8_t drv_count = 0; int sensor_register(sensor_drv_t *drv) { if (drv_count >= SENSOR_DRV_MAX) return -1; drv_table[drv_count++] = drv; return 0; } sensor_drv_t *sensor_find(const char *name) { for (uint8_t i = 0; i < drv_count; i++) { if (strcmp(drv_table[i]->name, name) == 0) { return drv_table[i]; } } return NULL; }

这段代码里drv_table本身就是一个sensor_drv_t *的数组,它等价于sensor_drv_t **。需要遍历整张表,或者把表传给另一个函数时,传递的就是二级指针:

void sensor_init_all(sensor_drv_t **table, uint8_t count) { for (uint8_t i = 0; i < count; i++) { if (table[i] && table[i]->init) { table[i]->init(); } } } // 调用 sensor_init_all(drv_table, drv_count);

这个模式的好处是:驱动的实现和管理完全分离。各个传感器驱动可以分别写在不同的 .c 文件里,主模块只负责注册和调度,不需要知道具体有哪些驱动。这跟上一篇讲的面向对象里"函数指针表做多态"是一脉相承的思路,只是这里用指针数组把多个实例组织起来。

输出型参数:返回错误码,数据走参数

嵌入式开发里有一类很常见的函数设计模式:函数执行某个操作,操作结果通过参数"输出"给调用者,返回值只用来表示成功或失败。

举个例子,一个配置解析模块要从 Flash 里读配置数据,返回解析后的配置结构体:

typedef struct { uint32_t baud_rate; uint8_t data_bits; uint8_t stop_bits; uint8_t parity; } uart_cfg_t; int config_load_uart(uart_cfg_t **cfg_out) { uint8_t raw[sizeof(uart_cfg_t)]; if (flash_read(CONFIG_ADDR, raw, sizeof(raw)) != 0) { return -1; } if (!config_crc_check(raw, sizeof(raw))) { return -2; } uart_cfg_t *cfg = (uart_cfg_t *)malloc(sizeof(uart_cfg_t)); if (!cfg) return -3; memcpy(cfg, raw, sizeof(uart_cfg_t)); *cfg_out = cfg; // 通过二级指针输出给调用者 return 0; }

调用方式:

uart_cfg_t *my_cfg = NULL; int ret = config_load_uart(&my_cfg); if (ret == 0 && my_cfg != NULL) { uart_init_with_config(my_cfg); free(my_cfg); }

这种"函数返回错误码,数据通过二级指针输出"的模式,在嵌入式 SDK 和中间件里非常普遍。好处有三:返回值专门用于错误处理,语义清晰;输出参数可以是 NULL,调用者通过检查判断是否拿到有效数据;便于扩展,以后要返回多个结果,加几个输出参数就行,返回值结构不用动。

二级指针的常见陷阱

解引用前必须检查空指针。二级指针涉及两层解引用,任何一层为 NULL 都会导致硬件异常,在 MCU 上通常是 HardFault。

void process(int **pp) { // 错误:不检查就解引用 int val = **pp; // 如果 pp 是 NULL,或者 *pp 是 NULL,直接崩 // 正确:逐层检查 if (pp != NULL && *pp != NULL) { int val = **pp; } }

不要返回局部变量的地址。

int **bad_func(void) { int val = 100; int *ptr = &val; return &ptr; // 返回了栈上局部变量的地址! }

函数返回后栈帧销毁,这块地址立刻失效,外面用就是野指针。要返回指针,指向的东西必须是堆上的、静态的、或者由调用者传入的。

搞清楚***各自在改什么。函数参数里,想改调用者的指针 ptr,用*pp = xxx;只是想改 ptr 指向的数据,用一级指针就够了。很多人把这两件事搞混,该用二级指针的地方用了一级,结果改了个寂寞。

嵌入式环境下慎用多级 malloc。堆空间有限,很多 MCU 的堆只有几 KB;内存碎片,反复 malloc/free 不同大小的块会产生碎片;释放顺序,多级分配的内存,释放时要从内层往外层释放。

// 分配:先外层,再内层 int **matrix = (int **)malloc(rows * sizeof(int *)); for (int i = 0; i < rows; i++) { matrix[i] = (int *)malloc(cols * sizeof(int)); } // 释放:先内层,再外层 for (int i = 0; i < rows; i++) { free(matrix[i]); } free(matrix);

顺序反了,外层先 free 掉,内层那批指针就悬空了,再 free 就是 double free 或者访问已释放内存。

const 修饰二级指针时要看清层次。

const int **pp; // pp 指向的指向的数据不可改(最终数据是 const) int *const *pp; // pp 指向的指针不可改(中间层是 const) int **const pp; // pp 自身不可改(最外层是 const)

const 修饰的是它左边那个*,没有左边就修饰最右边的基类型。读的时候从右往左,一层一层剥,就不会搞错。

什么时候该用二级指针

遇到这几种情况,就该考虑二级指针了:需要在函数内部修改调用者的指针变量;操作链表时想消除头节点和中间节点的特殊判断;管理指针数组,多个缓冲区、设备表、驱动表;实现输出型参数,函数通过参数返回数据。

核心就一句话:当你需要修改一个指针变量本身,而不是它指向的数据时,就需要传这个指针的地址,也就是二级指针。

二级指针不是高深的概念,是 C 语言值传递机制的一个自然延伸。理解了"为什么需要它",比记住"怎么写它"更重要。不要因为二级指针"看起来复杂"就刻意回避。在该用的地方不用,反而会逼自己写出更绕、更容易出 bug 的代码。

有用的话点个在看,让更多还在跟二级指针较劲的嵌入式工程师看到。


标签:嵌入式 C语言 二级指针 链表 指针

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询