模块初始化与清理:写好“入场”与“退场”
写嵌入式程序久了,你会发现一个规律:大部分崩溃,都发生在启动和关闭的时候。
为什么?因为启动时资源还没就位,关闭时资源正在被撕扯。我早年做的一个车载项目,就因为在关机时没有按顺序释放 DMA 缓冲区,导致下一次开机直接硬 fault。从那以后,我对模块的 init 和 deinit 就格外上心。
今天我们就聊聊,怎么给每个模块写好“入场”和“退场”的规矩。
1. 模块的生命周期:从出生到销毁
一个模块,说白了就是一组数据和操作这些数据的函数。它的生命周期通常分四步:
- 初始化(init):分配资源、设置默认值、注册中断或回调。
- 运行(run):正常提供服务。
- 暂停/休眠(suspend/sleep):低功耗场景下临时关闭,但不释放资源。
- 清理(deinit):释放资源、注销中断、归还内存。
你想想看,如果 init 和 deinit 没写好,模块就像个不关水龙头的人——要么打不开,要么关不上。
核心原则:init 和 deinit 必须成对出现,且调用顺序要严格对称。谁先 init,谁后 deinit。
2. init 函数:把“地基”打牢
我个人习惯,每个模块只暴露一个 xxx_init() 函数。它内部做三件事:
- 参数校验:传入的指针、句柄、配置参数是否合法。
- 资源分配:malloc、信号量创建、队列创建、定时器注册。
- 状态置位:把模块内部的状态机切到“就绪”状态。
来看一个简单的 UART 驱动模块:
// uart_driver.h
typedef struct {
uint32_t baudrate;
uint8_t data_bits;
uint8_t stop_bits;
uint8_t parity;
} uart_config_t;
typedef struct uart_module uart_module_t;
// 初始化:返回句柄,失败返回 NULL
uart_module_t* uart_init(uart_config_t *cfg);
// 清理:归还所有资源
void uart_deinit(uart_module_t *handle);
实现里我会加一个“防重复初始化”的检查:
uart_module_t* uart_init(uart_config_t *cfg) {
if (cfg == NULL) return NULL;
// 检查硬件是否已被占用
if (is_uart_busy(cfg->port)) {
// 我曾经遇到过两个模块抢同一个外设,结果一个发一个收全乱套
return NULL;
}
uart_module_t *mod = (uart_module_t*)malloc(sizeof(uart_module_t));
if (mod == NULL) return NULL;
// 配置硬件寄存器
hal_uart_set_baudrate(cfg->port, cfg->baudrate);
hal_uart_set_format(cfg->port, cfg->data_bits, cfg->stop_bits, cfg->parity);
// 创建接收队列
mod->rx_queue = osMessageQueueNew(64, sizeof(uint8_t), NULL);
if (mod->rx_queue == NULL) {
free(mod);
return NULL;
}
mod->state = MODULE_READY;
return mod;
}
小技巧:init 函数里如果中途失败,一定要把已经分配的资源全部释放掉,再返回 NULL。这叫“原子性”——要么全成功,要么像没发生过。
3. deinit 函数:优雅地“退场”
deinit 比 init 更容易写崩。为什么?因为 deinit 执行时,可能有别的任务还在用这个模块的资源。
我建议 deinit 遵循以下步骤:
- 通知所有使用者:模块即将关闭,请停止访问。
- 等待正在进行的操作完成:比如等待 DMA 传输结束、等待队列清空。
- 释放资源:按 init 的逆序释放。
- 句柄置空:防止野指针。
void uart_deinit(uart_module_t *handle) {
if (handle == NULL) return;
if (handle->state == MODULE_UNINIT) return; // 防止重复释放
// 1. 标记模块为“正在关闭”
handle->state = MODULE_CLOSING;
// 2. 等待接收队列清空(超时 100ms)
uint8_t dummy;
while (osMessageQueueGet(handle->rx_queue, &dummy, NULL, 10) == osOK) {
// 丢弃残留数据
}
// 3. 关闭硬件中断
hal_uart_disable_irq(handle->port);
// 4. 释放队列
osMessageQueueDelete(handle->rx_queue);
// 5. 释放内存
free(handle);
// 注意:调用方需要把外部指针置 NULL
}
警告:不要在中断服务函数里调用 deinit!我曾经见过有人在 ISR 里释放信号量,结果死锁了整个系统。deinit 必须放在任务级代码中执行。
4. 模块生命周期管理:谁负责调用?
在一个多文件项目中,模块的 init/deinit 不能乱来。我常用的策略有两种:
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 集中式管理 | 小型项目、裸机程序 | 调用顺序一目了然 | 耦合度高,新增模块要改主文件 |
| 分散式注册 | 大型项目、RTOS 环境 | 模块自管理,扩展性好 | 调试时调用链不直观 |
我个人偏爱“集中式管理”,尤其是在 bootloader 或安全关键系统中。我会在 main.c 里写一个 system_init() 函数,按依赖顺序调用各个模块的 init:
// system_init.c
void system_init(void) {
// 先底层,后上层
bsp_clock_init(); // 时钟系统
bsp_gpio_init(); // GPIO
bsp_uart_init(); // 调试串口
bsp_i2c_init(); // I2C 总线
sensor_init(); // 传感器模块(依赖 I2C)
display_init(); // 显示模块(依赖传感器数据)
app_task_init(); // 应用任务
}
关闭时,顺序反过来:
void system_deinit(void) {
app_task_deinit();
display_deinit();
sensor_deinit();
bsp_i2c_deinit();
bsp_uart_deinit();
bsp_gpio_deinit();
bsp_clock_deinit();
}
避坑指南:我曾经在一个项目中,把 deinit 写成了“能关就关,关不掉拉倒”。结果系统休眠时,一个传感器没关干净,漏电把电池耗光了。从那以后,我要求每个 deinit 必须返回成功/失败状态,主循环里检查所有模块是否都成功关闭。
5. 资源释放的“三板斧”
嵌入式系统里,资源就那么几种。我总结了一个检查清单:
- 内存:malloc 了就要 free,别忘了结构体内部的指针。
- 硬件外设:关闭时钟、关闭中断、GPIO 恢复默认状态。
- RTOS 对象:信号量、消息队列、互斥锁、任务——创建了就要删除。
- 文件句柄:如果用了文件系统,记得 close。
- 网络连接:socket、TCP 连接要主动关闭。
嗯,这里要注意:释放的顺序和分配的顺序相反。比如你 init 时先 malloc 了 A,再 malloc 了 B,那么 deinit 时先 free B,再 free A。否则 A 释放后,B 里可能还存着指向 A 的指针,变成野指针。
6. 用 SVG 理清思路
下面这张图,画的是一个典型模块从 init 到 deinit 的完整流程:
7. 写在最后
模块的 init 和 deinit,说白了就是“有借有还”。你借了内存、借了外设、借了中断,就得在 deinit 里一一还回去。
我见过太多项目,只写 init 不写 deinit,觉得“反正系统一直跑着,不用关”。结果产品要做低功耗休眠时,发现根本关不干净,只能加班重构。嗯,别让自己陷入这种窘境。
从今天开始,每个新模块都先写好 deinit 再写 init——你会发现,代码的健壮性会上一个台阶。