软件分层架构:分层设计原则与职责划分

做嵌入式系统开发,我见过太多人一上来就写代码,把所有功能堆在一个文件里。结果呢?项目到后期改一个 bug 引出三个新 bug,调试起来像大海捞针。说白了,这就是没做好分层。

分层架构不是什么高深理论,它就是一套“各司其职”的规矩。我习惯把嵌入式软件切成三层:驱动层、中间件层、应用层。每层只管自己的事,层与层之间通过接口通信。这样做的好处,你想想看——哪层出问题,就只查哪层,不用把整个系统翻个底朝天。

分层设计的三条铁律

先说说我总结的三条原则,这些年带项目全靠它们兜底:

  • 单向依赖:上层只能调用下层,下层绝不能反向依赖上层。应用层调中间件,中间件调驱动,驱动直接操作硬件。一旦出现循环依赖,你的架构就崩了。
  • 接口稳定:层与层之间的接口一旦定下来,尽量不要改。我在一个车载项目中吃过亏——驱动层改了 SPI 接口函数名,结果应用层和中间件层全要跟着改,光回归测试就多花了两周。
  • 每层可替换:换硬件时只改驱动层,换算法时只改应用层。如果换个 MCU 要重写整个软件,那你的分层就是摆设。

核心观点:分层不是把代码拆开就完事了,关键是让每层都“可独立测试、可独立替换”。

驱动层:硬件的翻译官

驱动层是离硬件最近的一层。它的职责就一个:把寄存器的读写操作,封装成上层能理解的函数接口。

举个例子,你要读一个温度传感器的值。驱动层要做的是:

  • 初始化 I2C 控制器(配置时钟、引脚、速率)
  • 发送传感器地址和寄存器地址
  • 读取返回的原始数据(可能是个 16 位整数)
  • 把这个原始值通过函数返回给上层

驱动层不关心这个温度值是 25℃ 还是 30℃,它只管把硬件吐出来的东西原样交出去。我曾经见过有人把温度换算公式写在驱动里,结果换了传感器型号,驱动层和应用层都得改——这就是典型的职责不清。

/* 驱动层示例:I2C 温度传感器驱动 */
int32_t temp_sensor_read_raw(void)
{
    uint8_t buf[2];
    int32_t raw;

    i2c_start(0x48);           /* 发送传感器地址 */
    i2c_write(0x00);           /* 发送寄存器地址 */
    i2c_stop();
    i2c_start(0x48 | 0x01);    /* 读模式 */
    buf[0] = i2c_read_ack();
    buf[1] = i2c_read_nack();
    i2c_stop();

    raw = ((int32_t)buf[0] << 8) | buf[1];
    return raw;                 /* 只返回原始值,不做换算 */
}

个人习惯:驱动层函数命名统一加模块前缀,比如 temp_sensor_、motor_ctrl_。这样在代码里一眼就能看出哪部分是驱动,哪部分是应用。

中间件层:承上启下的粘合剂

中间件层夹在驱动和应用之间,干的是“脏活累活”。它把驱动层拿到的原始数据加工成有意义的业务信息,或者把应用层的指令拆解成驱动层能执行的步骤。

还是那个温度传感器的例子。中间件层要做的是:

  • 调用驱动层的 temp_sensor_read_raw()
  • 根据传感器手册的公式,把原始值换算成摄氏度
  • 做滤波处理(比如滑动平均,去掉噪声)
  • 把处理后的温度值传给应用层

中间件层还经常负责协议解析、数据缓存、任务调度这些通用功能。我做过一个项目,应用层需要同时处理温度、湿度、气压三个传感器。中间件层统一提供了“传感器数据管理器”,应用层只管调用 sensor_get_temperature(),根本不用管底层是 I2C 还是 SPI。

/* 中间件层示例:温度换算与滤波 */
float temp_sensor_get_celsius(void)
{
    int32_t raw;
    float temp;
    static float filter_buf[5];
    static uint8_t idx = 0;
    float sum = 0.0f;

    raw = temp_sensor_read_raw();       /* 调用驱动层 */
    temp = (float)raw * 0.0625f;        /* 换算公式:0.0625℃/LSB */

    filter_buf[idx] = temp;             /* 滑动平均滤波 */
    idx = (idx + 1) % 5;
    for (int i = 0; i < 5; i++) {
        sum += filter_buf[i];
    }
    return sum / 5.0f;                  /* 返回滤波后的温度 */
}

避坑指南:我曾经在中间件层里直接操作了 GPIO 寄存器,因为当时觉得“就改一下,方便”。结果硬件改版后 GPIO 引脚重映射了,中间件层和驱动层都得改。记住:中间件层只能调用驱动层接口,不能直接碰硬件。

应用层:业务逻辑的舞台

应用层是离用户最近的一层。它只关心“做什么”,不关心“怎么做”。

应用层的代码应该像一份需求文档:

  • 如果温度超过 30℃,开启风扇
  • 如果湿度低于 20%,启动加湿器
  • 每 5 秒上报一次传感器数据到云端

你看,这些描述里没有 I2C、没有寄存器、没有 SPI。应用层只需要调用中间件层提供的接口,剩下的脏活累活全交给下层。

/* 应用层示例:温湿度控制逻辑 */
void app_climate_control(void)
{
    float temp = temp_sensor_get_celsius();     /* 调用中间件层 */
    float hum  = hum_sensor_get_percent();

    if (temp > 30.0f) {
        fan_set_speed(100);                     /* 调用中间件层 */
    } else if (temp > 25.0f) {
        fan_set_speed(50);
    } else {
        fan_set_speed(0);
    }

    if (hum < 20.0f) {
        humidifier_set_state(1);                /* 开启加湿器 */
    }
}

关键点:应用层里不应该出现任何硬件相关的宏定义、寄存器地址、延时函数。如果看到应用层代码里有 HAL_GPIO_WritePin() 或者 delay_ms(),那说明分层没做好。

三层职责对比

为了让你看得更清楚,我整理了一张对比表:

层次 核心职责 依赖对象 典型内容 可替换性
驱动层 封装硬件操作 MCU 寄存器、外设 GPIO、I2C、SPI、UART 驱动 换 MCU 时重写
中间件层 数据加工、协议解析 驱动层接口 滤波算法、协议栈、文件系统 换算法时重写
应用层 业务逻辑、用户交互 中间件层接口 控制策略、状态机、UI 逻辑 换需求时重写

分层架构全景图

下面这张图展示了三层之间的调用关系和依赖方向。注意箭头都是从上层指向下层,没有反向的。

嵌入式软件分层架构全景图 应用层 业务逻辑 · 控制策略 · 用户交互 调用中间件层接口,不直接操作硬件 中间件层 数据加工 · 协议解析 · 滤波算法 调用驱动层接口,向上提供业务接口 驱动层 寄存器操作 · 外设初始化 · 中断处理 直接操作硬件,向上提供抽象接口 硬件(MCU · 传感器 · 执行器) 依赖方向 单向向下

我的经验:刚开始做分层时,别追求一步到位。先按“驱动层 + 应用层”两层结构跑起来,等发现应用层代码里混了太多硬件操作时,再抽取出中间件层。分层是迭代出来的,不是设计出来的。

常见分层误区

这些年我带过的项目里,分层出问题的情况我见得多了。挑几个典型的说说:

  • 驱动层里写业务逻辑:比如在 GPIO 中断里直接做状态机跳转。结果换个引脚,状态机逻辑也得跟着改。
  • 应用层直接调驱动:图省事,在应用代码里写 HAL_I2C_Master_Transmit()。短期看是快了,长期看是给自己挖坑。
  • 中间件层太薄:只做了一层简单的封装,所有换算和逻辑还是散落在应用层。说白了就是没起到“承上启下”的作用。
  • 层间接口太复杂:一个函数传七八个参数,调用方记都记不住。接口应该保持简洁,能传结构体就别拆成散参数。

避坑指南:我曾经在一个项目中,驱动层直接暴露了寄存器地址给应用层。当时觉得“反正就两个人在写代码,没事”。结果后来项目交接,新来的同事完全搞不清哪些地址是驱动层的、哪些是应用层的。最后花了三天重构,才把耦合拆开。记住:接口就是契约,契约越清晰,后期越省心。

分层架构这件事,说白了就是“把复杂问题拆成简单问题,然后逐个解决”。驱动层管硬件,中间件层管加工,应用层管业务。每层做好自己的事,别越界。你想想看,如果每个工程师都只关注自己那一层的代码质量,整个项目的可维护性是不是就上去了?