第3章 分层架构基础:分层思想与接口解耦

做单片机开发久了,你会发现一个规律:代码量一旦超过5000行,维护成本就开始指数级上升。我早年接手过一个智能家居项目,前任把所有逻辑都塞在一个main.c里,足足8000行。改一个LED闪烁频率,要翻遍整个文件,生怕动到别的功能。那次之后,我彻底倒向了分层架构。

分层架构不是什么高深理论。说白了,就是把代码按职责切分成几个堆叠的层。每层只管自己的事,层与层之间通过固定的接口通信。你想想看,这跟公司组织架构很像——前台接待、中层管理、后端执行,各司其职。

3.1 三层模型:表示层、业务层、驱动层

在单片机领域,我习惯把软件分成三层:

  • 表示层(Presentation Layer):负责与人交互。按键扫描、LCD显示、LED指示、串口命令解析,都归它管。
  • 业务层(Business Layer):核心逻辑所在。温度控制策略、状态切换规则、报警判断算法,都在这一层。
  • 驱动层(Driver Layer):直接操作硬件。GPIO读写、定时器配置、ADC采样、SPI/I2C收发,是真正的“搬砖工”。

这三层的关系,我用一张图来展示:

单片机软件三层架构 表示层(Presentation Layer) 按键扫描 | LCD显示 | LED指示 | 串口命令解析 职责:人机交互,数据格式化 业务层(Business Layer) 温度控制策略 | 状态切换规则 | 报警判断算法 职责:核心逻辑,与硬件无关 驱动层(Driver Layer) GPIO读写 | 定时器配置 | ADC采样 | SPI/I2C收发 职责:直接操作硬件寄存器 箭头方向:上层调用下层接口,下层不依赖上层

注意箭头方向——上层调用下层,下层绝不反向依赖上层。这是分层架构的铁律。我在项目中见过有人把驱动层函数直接调用业务层变量,结果一换硬件平台,业务层跟着崩,那叫一个惨。

3.2 接口与解耦:让代码“可插拔”

分层的目的,是为了解耦。解耦的手段,是定义清晰的接口。

什么叫接口?在C语言里,接口就是一组函数声明。比如驱动层对外暴露的API:

// driver_led.h —— 驱动层接口
#ifndef DRIVER_LED_H
#define DRIVER_LED_H

#include <stdint.h>

void LED_Init(void);
void LED_On(uint8_t ledId);
void LED_Off(uint8_t ledId);
void LED_Toggle(uint8_t ledId);

#endif

业务层只需要知道这三个函数的存在,完全不管底层是GPIO直接驱动,还是通过I2C扩展芯片。这就是解耦。

核心原则:业务层代码里,不能出现任何硬件寄存器操作。如果看到业务层里直接写 GPIOA->ODR |= 0x01,那说明分层失败了。

3.3 接口设计的三条军规

我总结了几条接口设计经验,分享给你:

  1. 参数要抽象:别把硬件细节暴露出去。比如驱动层不要用 SetPin(GPIOA, GPIO_PIN_0, 1),而是封装成 LED_On(0)。业务层不需要知道LED接在哪个引脚上。
  2. 返回值要统一:我习惯所有接口函数返回 int8_t,0表示成功,负数表示错误码。这样上层可以统一做错误处理。
  3. 初始化要集中:每个驱动模块提供一个 _Init() 函数,在系统启动时统一调用。别让业务层去调用驱动层的初始化细节。

我的习惯:接口头文件里只放函数声明和必要的类型定义。宏定义、静态函数、内部结构体,统统放在对应的 .c 文件里。这样上层包含头文件时,不会被一堆内部细节污染。

3.4 一个实际案例:温控系统分层

拿一个恒温控制器来举例。假设需求是:读取DS18B20温度,控制加热器,通过按键设定目标温度,LCD显示当前值和设定值。

分层之后,代码结构是这样的:

层级 文件 职责
表示层 ui.c / ui.h 按键扫描、LCD刷新、菜单逻辑
业务层 controller.c / controller.h PID计算、温度比较、加热器开关策略
驱动层 ds18b20.c / ds18b20.h
heater.c / heater.h
lcd1602.c / lcd1602.h
单总线时序、GPIO控制、LCD指令发送

业务层调用驱动层接口的代码,大概长这样:

// controller.c —— 业务层代码
#include "controller.h"
#include "ds18b20.h"
#include "heater.h"

void Controller_Task(void)
{
    float temp = DS18B20_ReadTemperature();  // 调用驱动层
    float target = 25.0f;

    if (temp < target - 0.5f)
    {
        Heater_On();   // 调用驱动层
    }
    else if (temp > target + 0.5f)
    {
        Heater_Off();  // 调用驱动层
    }
}

你看,业务层完全不知道DS18B20是单总线协议,也不知道加热器是继电器还是MOS管。哪天把DS18B20换成NTC热敏电阻,只需要重写驱动层,业务层一行代码都不用改。

我曾经踩过的坑:有个项目,业务层直接调用了驱动层的内部延时函数 delay_us()。后来换了主频更高的芯片,延时参数全要重新调。如果当时业务层只调用 DS18B20_ReadTemperature(),驱动层内部自己处理延时,就不会有这种麻烦。

3.5 分层带来的实际收益

我整理了一个对比表,帮你直观感受分层前后的差异:

维度 不分层(裸奔) 分层架构
代码可读性 逻辑与硬件混杂,难以理解 每层职责清晰,新人上手快
硬件更换 需要全局搜索修改,风险极高 只改驱动层,业务层不动
团队协作 多人改同一文件,冲突频繁 每人负责一层,接口联调即可
单元测试 必须依赖硬件才能测试 业务层可脱离硬件做模拟测试
代码复用 几乎无法跨项目复用 驱动层和业务层均可独立复用

嗯,这里要注意一点:分层不是银弹。对于只有几百行代码的小项目,强行分层反而增加复杂度。我个人的判断标准是——当项目预计超过3000行代码,或者未来有硬件变更可能,就值得分层。

3.6 分层架构的落地步骤

如果你现在想把一个混乱的项目改成分层架构,我建议按这个步骤来:

  1. 先画依赖图:把现有代码里所有函数调用关系画出来,找出哪些是硬件操作,哪些是业务逻辑。
  2. 定义接口边界:确定驱动层要暴露哪些API,写进头文件。业务层只能通过这些API访问硬件。
  3. 逐层剥离:从驱动层开始,把硬件操作代码抽出来。每抽完一层,编译一次,确保不报错。
  4. 接口测试:写一个简单的测试程序,只调用驱动层接口,验证硬件功能正常。
  5. 业务层重构:把业务逻辑中的硬件操作替换成驱动层接口调用。

一个小技巧:重构时,可以先用 #if 0 ... #endif 把旧代码注释掉,新代码写在旁边。等新代码验证通过,再删掉旧代码。这样随时可以回退,心里不慌。

分层架构这件事,说白了就是用接口隔离变化。驱动层会变(换芯片、换外设),表示层会变(换屏幕、换按键),但业务层应该相对稳定。把稳定的核心逻辑保护起来,把易变的部分隔离出去——这就是分层思想的精髓。