3. 中间件架构设计:分层架构、模块解耦、接口定义规范

做嵌入式中间件,说白了就是搭积木。但积木怎么搭,搭完怎么拆,拆完怎么换,这才是真功夫。我见过太多项目,中间件写成一坨“神级”代码——一个文件几千行,全局变量满天飞,最后谁都不敢动。嗯,咱们今天就把这事聊透。

3.1 分层架构:别让所有代码挤在一个房间里

分层架构的核心思想就一句话:各司其职,互不越界。我习惯把中间件分成三层:硬件抽象层(HAL)、核心服务层(CSL)、应用接口层(AIL)。

三层职责速览

  • HAL(硬件抽象层):封装寄存器操作、外设驱动差异。换MCU只改这层。
  • CSL(核心服务层):实现协议栈、任务调度、内存管理。与硬件无关。
  • AIL(应用接口层):提供API给业务代码。隐藏内部实现细节。

举个例子。我在一个物联网网关项目里,底层从STM32F4换到GD32F4,只改了HAL层大约800行代码,上层纹丝不动。你想想看,如果不分层,那得翻多少文件?

中间件三层架构示意图 应用接口层(AIL) API:mqtt_publish() / sensor_read() / timer_start() 核心服务层(CSL) MQTT协议栈 / 环形缓冲区 / 软件定时器 / 状态机 硬件抽象层(HAL) SPI_Read() / UART_Send() / GPIO_Write() 依赖方向:AIL → CSL → HAL,禁止反向调用

我的习惯:每层单独建一个文件夹,头文件里用宏开关控制可见性。比如 #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;

接口设计铁律:

  1. 接口参数不超过5个,超过就用结构体
  2. 禁止在接口里做动态内存分配,调用方自己准备缓冲区
  3. 所有接口必须是可重入的(reentrant),加锁由调用方决定
  4. 接口文档必须写清楚:前置条件、后置条件、阻塞/非阻塞

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注释,接口定义和文档同步生成。

嗯,分层架构、模块解耦、接口规范,这三件事做好了,中间件就像搭乐高——想换哪块换哪块,想测哪块测哪块。你想想看,是不是这个理?