第3章 分层架构基础:分层思想与接口解耦
做单片机开发久了,你会发现一个规律:代码量一旦超过5000行,维护成本就开始指数级上升。我早年接手过一个智能家居项目,前任把所有逻辑都塞在一个main.c里,足足8000行。改一个LED闪烁频率,要翻遍整个文件,生怕动到别的功能。那次之后,我彻底倒向了分层架构。
分层架构不是什么高深理论。说白了,就是把代码按职责切分成几个堆叠的层。每层只管自己的事,层与层之间通过固定的接口通信。你想想看,这跟公司组织架构很像——前台接待、中层管理、后端执行,各司其职。
3.1 三层模型:表示层、业务层、驱动层
在单片机领域,我习惯把软件分成三层:
- 表示层(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 接口设计的三条军规
我总结了几条接口设计经验,分享给你:
- 参数要抽象:别把硬件细节暴露出去。比如驱动层不要用
SetPin(GPIOA, GPIO_PIN_0, 1),而是封装成LED_On(0)。业务层不需要知道LED接在哪个引脚上。 - 返回值要统一:我习惯所有接口函数返回
int8_t,0表示成功,负数表示错误码。这样上层可以统一做错误处理。 - 初始化要集中:每个驱动模块提供一个
_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 分层架构的落地步骤
如果你现在想把一个混乱的项目改成分层架构,我建议按这个步骤来:
- 先画依赖图:把现有代码里所有函数调用关系画出来,找出哪些是硬件操作,哪些是业务逻辑。
- 定义接口边界:确定驱动层要暴露哪些API,写进头文件。业务层只能通过这些API访问硬件。
- 逐层剥离:从驱动层开始,把硬件操作代码抽出来。每抽完一层,编译一次,确保不报错。
- 接口测试:写一个简单的测试程序,只调用驱动层接口,验证硬件功能正常。
- 业务层重构:把业务逻辑中的硬件操作替换成驱动层接口调用。
一个小技巧:重构时,可以先用 #if 0 ... #endif 把旧代码注释掉,新代码写在旁边。等新代码验证通过,再删掉旧代码。这样随时可以回退,心里不慌。
分层架构这件事,说白了就是用接口隔离变化。驱动层会变(换芯片、换外设),表示层会变(换屏幕、换按键),但业务层应该相对稳定。把稳定的核心逻辑保护起来,把易变的部分隔离出去——这就是分层思想的精髓。