3. 分层架构设计原则:高内聚低耦合、接口隔离、依赖倒置

做嵌入式开发久了,你会发现一个规律:代码腐烂的速度,往往和项目复杂度成正比。刚写完时逻辑清晰,三个月后自己都不想看。为什么?因为模块之间缠得太死了。

我刚开始做单片机项目时,也喜欢把所有逻辑塞进一个 main.c。后来一个带协议解析的项目让我吃了大亏——改一个传感器驱动,结果 UI 显示也跟着崩。从那以后,我彻底转向了分层架构。

分层不是花架子,它是让你在项目后期还能睡个安稳觉的保障。这一章,我们就来聊聊三个核心原则:高内聚低耦合、接口隔离、依赖倒置。

3.1 高内聚与低耦合:模块的「分」与「合」

说白了,高内聚就是一个模块只干一件事,并且把这件事干好。低耦合就是模块之间尽量少说话,说话也只通过约定的接口。

概念 含义 反面例子
高内聚 模块内部功能紧密相关,职责单一 一个函数既做 CRC 校验又刷新 LCD 屏幕
低耦合 模块间依赖最小化,修改互不影响 协议层直接调用 GPIO 寄存器操作硬件
我的经验法则:如果你发现一个 .c 文件超过 500 行,或者一个函数里出现超过 3 个不同层次的 API 调用,就该考虑拆分了。

举个例子。一个典型的单片机项目,我会这样划分模块:

  • 硬件抽象层(HAL):封装 MCU 外设寄存器操作,如 SPI 读写、GPIO 控制
  • 协议解析层:处理数据帧的组包、校验、解析,不关心数据从哪来
  • 业务逻辑层:根据解析结果执行具体动作,如控制电机、更新显示
  • 应用层:协调各模块工作,处理用户交互

每一层只依赖它的下一层,绝不跨层调用。这样,哪天你要换 MCU,只需要重写 HAL 层,上面的代码基本不用动。

3.2 接口隔离:别让模块知道太多

接口隔离原则,用大白话说就是:不要强迫模块依赖它用不到的东西。

我见过不少代码,一个结构体里塞了十几个函数指针,结果每个使用者只用到其中两三个。一旦修改这个结构体,所有模块都得重新编译——这就是典型的「胖接口」问题。

避坑指南:我曾经在一个项目里,把 SPI 驱动、Flash 读写、日志输出全部塞进同一个头文件。后来想单独测试日志模块,发现根本没法编译——因为它依赖了 SPI 的初始化代码。这就是接口没有隔离的代价。

正确的做法是:每个模块只暴露它真正需要暴露的接口。比如:

// 不好的做法:一个头文件暴露所有
#include "hal_all.h"
void hal_spi_write(uint8_t *data, uint16_t len);
void hal_spi_read(uint8_t *data, uint16_t len);
void hal_gpio_set(uint8_t pin, uint8_t level);
void hal_timer_start(uint32_t period);
// ... 还有几十个函数

// 好的做法:按模块拆分接口
// spi_interface.h
typedef struct {
    void (*write)(uint8_t *data, uint16_t len);
    void (*read)(uint8_t *data, uint16_t len);
} spi_if_t;

// gpio_interface.h
typedef struct {
    void (*set)(uint8_t pin, uint8_t level);
    uint8_t (*get)(uint8_t pin);
} gpio_if_t;

这样,协议解析层只需要依赖 spi_if_t,它根本不知道 GPIO 和 Timer 的存在。接口越小,依赖越轻,代码越容易测试。

3.3 依赖倒置:让高层决定规则

依赖倒置原则听起来有点绕,其实核心就一句话:高层模块不应该依赖低层模块,两者都应该依赖抽象接口。

你想想看,传统的分层是「上层调用下层」。但这样有个问题:如果底层硬件变了,上层逻辑也得跟着改。依赖倒置就是把这个关系反过来——让上层定义接口,下层去实现这个接口。

注意:依赖倒置不是「上层直接调用下层实现」,而是「上层声明我需要什么,下层提供什么」。这个思维转变,是区分「码农」和「架构师」的分水岭。

来看一个实际例子。假设我们要实现一个数据采集系统:

// 定义抽象接口(由业务层定义)
typedef struct {
    uint8_t (*init)(void);
    uint8_t (*read_sample)(uint16_t *value);
    void (*power_down)(void);
} sensor_if_t;

// 业务层代码只依赖这个接口
void data_collector_run(sensor_if_t *sensor) {
    sensor->init();
    uint16_t sample;
    if (sensor->read_sample(&sample) == 0) {
        // 处理采样数据
    }
    sensor->power_down();
}

// 底层实现:温度传感器
static uint8_t temp_init(void) { /* 初始化温度传感器 */ }
static uint8_t temp_read(uint16_t *v) { /* 读取温度值 */ }
static void temp_power_down(void) { /* 关闭传感器 */ }

const sensor_if_t temp_sensor = {
    .init = temp_init,
    .read_sample = temp_read,
    .power_down = temp_power_down
};

看到没?业务层 data_collector_run 根本不关心底层是温度传感器还是压力传感器。只要实现了 sensor_if_t 接口,就能无缝接入。这就是依赖倒置的威力。

3.4 模块划分与接口定义实战

理论说完了,我们来看一个具体的模块划分案例。这是一个典型的物联网网关项目:

模块名 职责 对外接口
uart_drv UART 收发,DMA 管理 uart_send(), uart_recv_cb_register()
protocol_parser Modbus/自定义协议解析 parser_feed_byte(), parser_get_frame()
data_manager 数据缓存、去重、持久化 data_store(), data_query()
network_mgr Wi-Fi/以太网连接管理 net_connect(), net_send()
app_main 业务编排,状态机调度 app_init(), app_run()

每个模块的接口定义,我习惯放在单独的头文件中,并且只暴露必要的函数和类型。内部实现细节全部用 static 隐藏。

个人习惯:我会在接口头文件里加一个「使用说明」注释块,写明这个模块的初始化顺序、线程安全要求、以及典型用法。这样接手的人不用翻代码就能上手。

下面这张图展示了我们这套分层架构的整体关系:

单片机软件分层架构示意图 应用层(app_main) 业务编排 · 状态机调度 · 用户交互 业务逻辑层(data_manager / network_mgr) 数据缓存 · 去重 · 持久化 · 连接管理 协议解析层(protocol_parser) 帧解析 · 校验 · 组包 · 超时处理 硬件抽象层(uart_drv / spi_drv) 外设寄存器封装 · DMA 管理 · 中断处理 接口隔离 每层只暴露 最小接口集 依赖倒置: 高层定义接口 低层实现接口 高内聚: 模块职责单一 内部实现隐藏 低耦合: 只依赖相邻层 不跨层调用 数据流向 硬件中断 → HAL 层接收 原始字节 → 协议解析层 解析结果 → 业务逻辑层 业务事件 → 应用层调度

嗯,这张图基本概括了我们这一章的核心思想。每一层只和相邻层通信,接口由上层定义,下层实现。这样,即使底层硬件全部换掉,上层的业务逻辑代码一行都不用改。

总结一下:高内聚让模块职责清晰,低耦合让修改风险可控,接口隔离让依赖最小化,依赖倒置让架构灵活可扩展。这四个原则配合使用,才能构建出经得起时间考验的嵌入式软件。