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));
}
核心思想:静态内存分配的本质是「用空间换时间,用确定性换灵活性」。在嵌入式世界里,确定性比高性能更重要。一个每次耗时1ms的函数,比一个平均0.5ms但偶尔耗时5ms的函数更可靠。

4.4 小结

这一节的内容,其实就三个关键词:灵活、管理、确定

  • 函数指针让代码灵活,回调机制让框架通用。
  • 静态链表让数据管理有章可循,不依赖动态内存。
  • 静态分配策略让我们在资源受限的环境下,依然能写出稳定可靠的代码。

你想想看,这些技术组合起来,能做出什么?一个支持动态注册任务的调度器、一个可配置的通信协议栈、一个零碎片的缓冲区管理模块……这些都是嵌入式深度学习推理框架的底层基石。

下一节,我们就要开始搭建真正的推理框架了。到时候你会发现,今天讲的这些东西,全都会用上。