3. 中间件架构设计:分层架构、模块解耦、接口定义规范
做嵌入式中间件,说白了就是搭积木。但积木怎么搭,搭完怎么拆,拆完怎么换,这才是真功夫。我见过太多项目,中间件写成一坨“神级”代码——一个文件几千行,全局变量满天飞,最后谁都不敢动。嗯,咱们今天就把这事聊透。
3.1 分层架构:别让所有代码挤在一个房间里
分层架构的核心思想就一句话:各司其职,互不越界。我习惯把中间件分成三层:硬件抽象层(HAL)、核心服务层(CSL)、应用接口层(AIL)。
三层职责速览
- HAL(硬件抽象层):封装寄存器操作、外设驱动差异。换MCU只改这层。
- CSL(核心服务层):实现协议栈、任务调度、内存管理。与硬件无关。
- AIL(应用接口层):提供API给业务代码。隐藏内部实现细节。
举个例子。我在一个物联网网关项目里,底层从STM32F4换到GD32F4,只改了HAL层大约800行代码,上层纹丝不动。你想想看,如果不分层,那得翻多少文件?
我的习惯:每层单独建一个文件夹,头文件里用宏开关控制可见性。比如 #ifdef HAL_INTERNAL 只暴露给CSL层,应用层根本看不到底层寄存器定义。
3.2 模块解耦:别让A模块崩了,B模块跟着陪葬
模块解耦,说白了就是“你死你的,我活我的”。我见过最惨的案例:一个传感器驱动里直接调了Wi-Fi模块的发送函数,结果Wi-Fi模块升级接口,传感器驱动跟着崩,整个系统瘫痪。这就是耦合的代价。
解耦的三大原则,我列个表:
| 原则 | 说明 | 反例 |
|---|---|---|
| 依赖倒置 | 模块依赖抽象接口,不依赖具体实现 | 直接调用 UART1_Send() |
| 消息驱动 | 模块间通过消息队列通信,不直接调用函数 | 全局变量传参 |
| 单一职责 | 一个模块只做一件事,且做好 | 驱动里写业务逻辑 |
具体怎么做?我推荐用回调函数 + 注册机制。比如传感器模块需要上报数据,它不关心谁在收,只负责调用注册好的回调指针。
/* 解耦示例:传感器模块注册回调 */
typedef void (*sensor_callback_t)(uint8_t *data, uint16_t len);
static sensor_callback_t g_user_cb = NULL;
void sensor_register_callback(sensor_callback_t cb) {
g_user_cb = cb; // 应用层注册进来
}
void sensor_task(void) {
uint8_t buf[64];
while (1) {
read_sensor_data(buf);
if (g_user_cb) {
g_user_cb(buf, sizeof(buf)); // 只调用,不关心谁处理
}
osDelay(100);
}
}
我曾经踩过的坑:回调函数里直接做了耗时操作,导致传感器采集周期被拉长。后来我强制规定——回调里只做消息投递,不做业务处理。耗时操作扔到独立任务里。
3.3 接口定义规范:API是合同,签了就别随便改
接口定义,我把它看作“模块之间的合同”。合同签了,就不能随便撕毁。我在团队里推行过一套规范,效果还不错,分享给你:
3.3.1 命名规范
- 模块前缀:每个模块用2~3个大写字母开头,比如
MQTT_Connect()、SEN_Read()、TIM_Start() - 动词+名词:
xxx_Init()、xxx_Deinit()、xxx_Send()、xxx_Receive() - 返回值统一:成功返回0,失败返回负错误码。别搞什么返回指针、返回布尔值混着来
3.3.2 参数规范
| 类型 | 规范 | 示例 |
|---|---|---|
| 输入参数 | const 指针 + 长度 | int MQTT_Publish(const char *topic, const uint8_t *data, uint16_t len) |
| 输出参数 | 非const指针,函数返回实际长度 | int SEN_Read(uint8_t *buf, uint16_t max_len) |
| 配置参数 | 结构体传参,方便扩展 | int MQTT_Init(const mqtt_cfg_t *cfg) |
3.3.3 错误码规范
我习惯用枚举定义错误码,每个模块独立一份:
/* mqtt_err.h */
typedef enum {
MQTT_OK = 0,
MQTT_ERR_PARAM = -1, // 参数错误
MQTT_ERR_TIMEOUT = -2, // 超时
MQTT_ERR_BUF_FULL = -3, // 缓冲区满
MQTT_ERR_NET = -4, // 网络异常
MQTT_ERR_UNKNOWN = -99 // 未知错误
} mqtt_err_t;
接口设计铁律:
- 接口参数不超过5个,超过就用结构体
- 禁止在接口里做动态内存分配,调用方自己准备缓冲区
- 所有接口必须是可重入的(reentrant),加锁由调用方决定
- 接口文档必须写清楚:前置条件、后置条件、阻塞/非阻塞
3.4 实战案例:一个消息队列中间件的接口设计
光说不练假把式。咱们拿一个轻量级消息队列中间件来走一遍流程。这个我在一个数据采集项目里用过,效果不错。
第一步:定义接口头文件
/* msg_queue.h */
#ifndef MSG_QUEUE_H
#define MSG_QUEUE_H
#include <stdint.h>
/* 配置结构体 */
typedef struct {
uint8_t *pool; // 内存池指针
uint16_t pool_size; // 内存池大小
uint16_t msg_max; // 最大消息数
uint8_t overwrite; // 满时是否覆盖旧消息
} mq_cfg_t;
/* 错误码 */
typedef enum {
MQ_OK = 0,
MQ_ERR_FULL = -1,
MQ_ERR_EMPTY= -2,
MQ_ERR_PARAM= -3
} mq_err_t;
/* API声明 */
mq_err_t MQ_Init(const mq_cfg_t *cfg);
mq_err_t MQ_Put(const uint8_t *data, uint16_t len);
mq_err_t MQ_Get(uint8_t *buf, uint16_t *len);
uint16_t MQ_Count(void);
void MQ_Reset(void);
#endif
第二步:单元测试验证接口
接口定义完,先写测试用例,再写实现。这叫测试驱动开发(TDD)。我习惯用Cmockery或者Unity框架:
/* test_msg_queue.c */
void test_mq_init_with_null_cfg(void) {
TEST_ASSERT_EQUAL(MQ_ERR_PARAM, MQ_Init(NULL));
}
void test_mq_put_and_get(void) {
mq_cfg_t cfg = { .pool = pool_buf, .pool_size = 256, .msg_max = 10 };
MQ_Init(&cfg);
uint8_t data[] = "Hello";
TEST_ASSERT_EQUAL(MQ_OK, MQ_Put(data, 5));
uint8_t buf[16];
uint16_t len = 16;
TEST_ASSERT_EQUAL(MQ_OK, MQ_Get(buf, &len));
TEST_ASSERT_EQUAL(5, len);
TEST_ASSERT_EQUAL_MEMORY(data, buf, 5);
}
我的经验:接口设计阶段就写测试用例,能逼你把接口想清楚。比如上面那个 MQ_Get 的 len 参数,到底是输入最大长度还是输出实际长度?测试用例一写,歧义就没了。
3.5 避坑指南:接口设计里那些血泪教训
我曾经犯过的错:
- 接口参数顺序不统一:有的函数
(buf, len),有的(len, buf),调试时看花眼。后来我强制所有接口统一为(目标, 源, 长度)。 - 回调函数不加上下文指针:导致回调里拿不到模块状态,只能靠全局变量。现在我的回调都带一个
void *arg。 - 接口文档滞后:代码改了,文档没改,新人接手一脸懵。我现在用Doxygen注释,接口定义和文档同步生成。
嗯,分层架构、模块解耦、接口规范,这三件事做好了,中间件就像搭乐高——想换哪块换哪块,想测哪块测哪块。你想想看,是不是这个理?