4、C语言基础回顾(下):函数指针与回调、链表实现、静态内存分配策略
好,咱们接着聊。上一节我们把指针和内存管理的基本功打扎实了,这一节要讲的东西,才是真正让C语言在嵌入式世界里「活起来」的关键。
函数指针、链表、静态内存分配。这三个东西,说白了就是:怎么让代码更灵活、怎么管理动态数据、怎么在资源受限的环境下活下去。我当年刚入行时,觉得这些概念太抽象,直到在项目里被坑过几次,才真正明白它们的价值。
4.1 函数指针与回调机制
先问一个问题:你写了一个排序函数,想让它既能排整数数组,又能排结构体数组,怎么办?
你可能会说:「用void*啊,上一节刚学的。」没错,但还不够。排序的比较逻辑是不同的——整数比大小和结构体比某个字段,完全是两码事。这时候,函数指针就派上用场了。
4.1.1 函数指针的声明与使用
函数指针,顾名思义,就是指向函数的指针。它的声明方式有点绕,但记住一个口诀:返回值类型 (*指针名)(参数列表)。
// 声明一个函数指针,指向返回int、参数为两个int的函数
int (*cmp_func)(int, int);
// 定义一个比较函数
int int_compare(int a, int b) {
return a - b; // 升序
}
// 让指针指向这个函数
cmp_func = int_compare;
// 通过指针调用函数
int result = cmp_func(5, 3); // result = 2
嗯,这里要注意:*cmp_func 外面的括号不能省。省掉就变成 int *cmp_func(int, int),那是一个返回int指针的函数声明了。我刚开始学的时候,这个坑踩了不下三次。
4.1.2 回调函数:让代码活起来
回调函数,就是通过函数指针参数,把一个函数「传」给另一个函数去调用。这样做的好处是:你写一个通用框架,具体行为由调用者决定。
我在项目中遇到过这样一个场景:写一个按键扫描模块,不同按键按下后要执行不同的操作。如果用switch-case,每加一个按键就要改源码,太蠢了。用回调函数,就优雅得多。
// 定义回调函数类型
typedef void (*button_callback_t)(void);
// 按键注册结构
typedef struct {
uint8_t pin;
button_callback_t on_press;
button_callback_t on_release;
} button_t;
// 使用示例
void led_on(void) {
GPIO_SetBits(GPIOA, GPIO_Pin_0);
}
void led_off(void) {
GPIO_ResetBits(GPIOA, GPIO_Pin_0);
}
button_t btn1 = {GPIO_Pin_1, led_on, led_off};
你看,主循环里只需要遍历这个结构体数组,检测到按键状态变化时,直接调用对应的回调函数。新增按键?加一个结构体实例就行,不用动核心代码。
4.2 链表的嵌入式实现
链表,教科书上都有。但嵌入式里的链表,和PC上的链表有一个本质区别:我们很少用动态内存分配。malloc/free在单片机里是奢侈品,甚至有些RTOS环境根本不让用。
那怎么办?用静态链表。说白了,就是预先分配一个固定大小的数组,每个元素当作链表节点,通过索引来链接。
4.2.1 静态链表的节点设计
#define MAX_NODES 32
typedef struct {
int data;
int next; // 下一个节点的索引,-1表示末尾
} static_node_t;
static_node_t node_pool[MAX_NODES];
int head = -1; // 链表头索引,-1表示空链表
int free_head = 0; // 空闲链表头
// 初始化:将所有节点串成空闲链表
void pool_init(void) {
for (int i = 0; i < MAX_NODES - 1; i++) {
node_pool[i].next = i + 1;
}
node_pool[MAX_NODES - 1].next = -1;
free_head = 0;
head = -1;
}
这种设计的好处是:内存分配时间确定,没有碎片问题。我做过一个传感器数据采集的项目,每秒要处理200个数据包,用静态链表管理缓冲区,一次内存分配都没出过问题。
4.2.2 插入与删除操作
// 从空闲池分配一个节点
int alloc_node(void) {
if (free_head == -1) return -1; // 池满
int node = free_head;
free_head = node_pool[node].next;
return node;
}
// 在链表头部插入
int insert_head(int data) {
int node = alloc_node();
if (node == -1) return -1;
node_pool[node].data = data;
node_pool[node].next = head;
head = node;
return 0;
}
4.3 静态内存分配策略
说到内存分配,很多从PC转过来的工程师第一反应就是malloc。但在单片机里,我建议你能不用就不用。为什么?
- 碎片问题:频繁分配释放,堆空间会碎成一片一片的,最后明明有内存,却分配不出一个大块。
- 时间不确定:malloc的耗时和当前堆状态有关,实时性要求高的场景根本不敢用。
- 内存泄漏:嵌入式设备一跑就是几个月甚至几年,泄漏一点点内存,最终都会崩。
4.3.1 三种静态分配策略
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 全局数组 | 固定大小的缓冲区 | 最简单,访问最快 | 灵活性差,浪费空间 |
| 内存池 | 固定大小的对象频繁分配 | 无碎片,分配时间O(1) | 每个对象大小固定 |
| 静态链表 | 动态数据结构(队列、栈) | 灵活,可动态增删 | 需要预分配最大容量 |
我个人最常用的是内存池。比如一个通信协议栈,需要频繁分配和释放数据包。每个包大小固定,用内存池管理,分配和释放都是常数时间,而且绝对不会产生碎片。
4.3.2 一个简单的内存池实现
#define POOL_SIZE 16
#define BLOCK_SIZE 64
typedef struct {
uint8_t buffer[POOL_SIZE][BLOCK_SIZE];
uint8_t bitmap[POOL_SIZE / 8 + 1]; // 位图标记使用情况
} mem_pool_t;
void* pool_alloc(mem_pool_t* pool) {
for (int i = 0; i < POOL_SIZE; i++) {
if (!(pool->bitmap[i / 8] & (1 << (i % 8)))) {
pool->bitmap[i / 8] |= (1 << (i % 8));
return &pool->buffer[i];
}
}
return NULL; // 池满
}
void pool_free(mem_pool_t* pool, void* ptr) {
int index = ((uint8_t*)ptr - pool->buffer[0]) / BLOCK_SIZE;
pool->bitmap[index / 8] &= ~(1 << (index % 8));
}
4.4 小结
这一节的内容,其实就三个关键词:灵活、管理、确定。
- 函数指针让代码灵活,回调机制让框架通用。
- 静态链表让数据管理有章可循,不依赖动态内存。
- 静态分配策略让我们在资源受限的环境下,依然能写出稳定可靠的代码。
你想想看,这些技术组合起来,能做出什么?一个支持动态注册任务的调度器、一个可配置的通信协议栈、一个零碎片的缓冲区管理模块……这些都是嵌入式深度学习推理框架的底层基石。
下一节,我们就要开始搭建真正的推理框架了。到时候你会发现,今天讲的这些东西,全都会用上。