拓冰建站拓冰建站
首页 / 资讯中心 / 正文

嵌入式C语言二级指针详解:改外部指针、Linus链表、输出型参数实战

上一篇讲面向对象把封装、继承、多态在 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_bufferrx_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 自身不可改最外层是 constconst 修饰的是它左边那个*没有左边就修饰最右边的基类型。读的时候从右往左一层一层剥就不会搞错。什么时候该用二级指针遇到这几种情况就该考虑二级指针了需要在函数内部修改调用者的指针变量操作链表时想消除头节点和中间节点的特殊判断管理指针数组多个缓冲区、设备表、驱动表实现输出型参数函数通过参数返回数据。核心就一句话当你需要修改一个指针变量本身而不是它指向的数据时就需要传这个指针的地址也就是二级指针。二级指针不是高深的概念是 C 语言值传递机制的一个自然延伸。理解了为什么需要它比记住怎么写它更重要。不要因为二级指针看起来复杂就刻意回避。在该用的地方不用反而会逼自己写出更绕、更容易出 bug 的代码。有用的话点个在看让更多还在跟二级指针较劲的嵌入式工程师看到。标签嵌入式 C语言 二级指针 链表 指针
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门