3、模块解耦核心思想:接口与实现分离,依赖倒置原则在嵌入式中的应用,回调函数与事件驱动机制
好,咱们进入第三章。这一章讲的是模块解耦的核心思想,说白了就是怎么让你的代码「活起来」,而不是一改就崩、一加功能就重构。
我刚开始做嵌入式那几年,写代码都是「想到哪写到哪」。一个 .c 文件里,传感器读取、数据处理、串口打印全揉在一起。后来项目越做越大,改一个传感器的时序,结果把整个系统的逻辑都带崩了。嗯,从那以后我才真正开始琢磨——到底怎么把模块拆开,让它们各干各的,互不干扰?
3.1 接口与实现分离:先定协议,再写代码
接口与实现分离,是模块解耦的第一把钥匙。你想想看,如果两个模块之间直接调用对方的函数,那它们就绑死了。一个改了,另一个也得跟着改。
正确的做法是:先定义接口(也就是协议),再分别实现。接口放在头文件里,实现放在源文件里。调用方只依赖接口,不依赖具体实现。
举个例子,假设我们要设计一个温湿度传感器驱动。接口可以这样定义:
// sensor_interface.h
#ifndef SENSOR_INTERFACE_H
#define SENSOR_INTERFACE_H
#include <stdint.h>
typedef struct {
float temperature;
float humidity;
} sensor_data_t;
// 接口:初始化传感器
int32_t sensor_init(void);
// 接口:读取数据
int32_t sensor_read(sensor_data_t *data);
// 接口:进入低功耗模式
void sensor_sleep(void);
#endif
然后具体的传感器实现(比如 SHT30)放在另一个文件里:
// sht30_driver.c
#include "sensor_interface.h"
#include "sht30_regs.h" // 芯片寄存器定义
int32_t sensor_init(void) {
// 配置 I2C、复位芯片、校验 ID
// 我在项目中遇到过,有些传感器上电后需要等待 100ms 才能稳定
HAL_Delay(100);
return 0;
}
int32_t sensor_read(sensor_data_t *data) {
uint8_t buf[6];
// 发送读取命令,接收 6 字节数据
// 解析温度、湿度
data->temperature = (buf[0] << 8 | buf[1]) * 175.0f / 65535.0f - 45.0f;
data->humidity = (buf[3] << 8 | buf[4]) * 100.0f / 65535.0f;
return 0;
}
void sensor_sleep(void) {
// 发送休眠命令
}
调用方(比如应用层)只需要包含 sensor_interface.h,完全不知道底层用的是 SHT30 还是 DHT22。哪天要换传感器,只需要换一个 .c 文件,接口不变,上层代码一行都不用改。
3.2 依赖倒置原则:让高层模块不依赖底层细节
依赖倒置原则(DIP)是 SOLID 原则中的一条。它的核心思想是:高层模块不应该依赖低层模块,两者都应该依赖抽象接口。
你可能会问:「这不就是接口与实现分离吗?」其实不完全一样。接口与实现分离是「怎么拆」,依赖倒置是「谁依赖谁」。传统做法是应用层直接调用驱动层,驱动层直接调用 HAL 库。依赖倒置之后,应用层定义接口,驱动层去实现这个接口。依赖的方向反过来了。
我画了一张图,帮你理解这个关系:
你看,右侧的依赖方向是「应用层 → 抽象接口 ← 驱动实现」。应用层不关心驱动怎么实现,驱动实现也不关心应用层怎么用。两者通过接口解耦。
interface/),不依赖任何硬件相关的代码。
3.3 回调函数:让模块主动通知,而不是被动轮询
回调函数是实现事件驱动的基础。说白了,就是「你告诉我该做什么,到了时机我通知你」。这样调用方不需要一直轮询状态,节省 CPU 资源,代码也更清晰。
在 STM32 HAL 库中,回调函数随处可见。比如串口接收完成回调、定时器溢出回调、外部中断回调。但很多人只是机械地往 HAL_UART_RxCpltCallback 里塞代码,没有意识到这其实是一种解耦手段。
我建议的做法是:在驱动层定义回调函数指针,让应用层注册自己的处理函数。这样驱动层不需要知道应用层的具体逻辑。
// sensor_interface.h(扩展)
typedef void (*sensor_data_ready_cb_t)(sensor_data_t *data);
int32_t sensor_register_callback(sensor_data_ready_cb_t cb);
void sensor_start_continuous_read(void);
// sht30_driver.c(扩展)
static sensor_data_ready_cb_t user_callback = NULL;
int32_t sensor_register_callback(sensor_data_ready_cb_t cb) {
if (cb == NULL) return -1;
user_callback = cb;
return 0;
}
// 在定时器中断或 DMA 完成中断中调用
static void on_data_ready(sensor_data_t *data) {
if (user_callback != NULL) {
user_callback(data); // 通知应用层
}
}
// app_main.c
static void my_data_handler(sensor_data_t *data) {
// 处理温湿度数据
printf("Temp: %.2f, Humi: %.2f\n", data->temperature, data->humidity);
}
void app_init(void) {
sensor_init();
sensor_register_callback(my_data_handler);
sensor_start_continuous_read();
}
你看,驱动层和应用层完全解耦。驱动层只管采集数据,应用层只管处理数据。中间通过回调函数连接。
3.4 事件驱动机制:用状态机管理复杂流程
回调函数解决了「谁通知谁」的问题,但实际项目中,一个模块可能同时处理多个事件。这时候就需要事件驱动机制。
事件驱动的核心是:模块内部维护一个状态机,外部事件(比如按键按下、数据到达、定时器超时)触发状态切换,状态切换时执行对应的动作。
我整理了一个简单的对比表格,帮你理解轮询和事件驱动的区别:
| 对比项 | 轮询方式 | 事件驱动方式 |
|---|---|---|
| CPU 占用 | 高(一直循环检查) | 低(有事件才处理) |
| 响应延迟 | 取决于轮询周期 | 事件触发即响应 |
| 代码结构 | 线性、容易膨胀 | 模块化、状态清晰 |
| 扩展性 | 加功能要改主循环 | 加状态或事件即可 |
| 典型场景 | 简单传感器读取 | 按键、通信协议、多任务 |
举个例子,一个按键模块的状态机可以这样设计:
typedef enum {
KEY_STATE_IDLE,
KEY_STATE_DEBOUNCE,
KEY_STATE_PRESSED,
KEY_STATE_LONG_PRESS,
KEY_STATE_RELEASED
} key_state_t;
typedef void (*key_event_cb_t)(uint8_t key_id, uint8_t event_type);
typedef struct {
GPIO_TypeDef *port;
uint16_t pin;
key_state_t state;
uint32_t press_time;
key_event_cb_t callback;
} key_t;
然后在定时器中断中每 10ms 调用一次状态机处理函数:
void key_process(key_t *key) {
uint8_t level = HAL_GPIO_ReadPin(key->port, key->pin);
switch (key->state) {
case KEY_STATE_IDLE:
if (level == 0) { // 按下
key->state = KEY_STATE_DEBOUNCE;
key->press_time = HAL_GetTick();
}
break;
case KEY_STATE_DEBOUNCE:
if (level == 0 && (HAL_GetTick() - key->press_time) > 20) {
key->state = KEY_STATE_PRESSED;
if (key->callback) key->callback(key_id, KEY_EVENT_PRESS);
} else if (level == 1) {
key->state = KEY_STATE_IDLE; // 抖动,复位
}
break;
// 其他状态...
}
}
3.5 总结:解耦三板斧
这一章的内容,我总结成三个核心动作,你可以直接用在项目中:
- 接口与实现分离:头文件只放声明,不放实现。调用方只依赖接口,不依赖具体驱动。
- 依赖倒置:让高层模块定义接口,低层模块去实现。依赖方向从「高层依赖低层」变成「双方依赖抽象」。
- 回调 + 事件驱动:用回调函数实现模块间的通知机制,用状态机管理模块内部的复杂流程。
嗯,这三板斧用好了,你的代码会变得非常「干净」。每个模块各司其职,改一个模块不会牵连其他模块。我在后来的项目中,几乎所有的驱动都按照这个思路来写,维护成本直线下降。