模块划分原则:高内聚低耦合在嵌入式中的落地

做嵌入式架构设计,说白了就是两件事:切模块和定接口。模块切得好,后面重构、测试、移植都顺;切得不好,改一个 bug 能牵连三个文件,你想想看,那得多头疼。

我这些年经手的项目,从 8 位 MCU 到多核 SoC,踩过不少坑。今天就把四个核心原则掰开揉碎讲清楚。每个原则我都会结合嵌入式场景,给你看实际代码和避坑经验。

2.1 高内聚低耦合——模块划分的基石

这个原则听起来很理论,但落地其实很直观。高内聚就是一个模块只干一类事,低耦合就是模块之间少牵连。

我的判断标准很简单:

  • 高内聚:改一个功能,只改一个模块
  • 低耦合:删一个模块,不影响其他模块

举个例子。我之前接手过一个智能家居网关项目,原来的代码把 WiFi 驱动、MQTT 协议解析、设备配网逻辑全塞在一个文件里。结果呢?换一个 WiFi 模组,要改 2000 行代码,还不敢动,一动就崩。

重构后我把它拆成三层:

模块 职责 对外接口
wifi_drv WiFi 收发、连接管理 wifi_send(), wifi_recv()
mqtt_client MQTT 协议栈、心跳、重连 mqtt_publish(), mqtt_subscribe()
app_provision 配网逻辑、状态机 prov_start(), prov_stop()

你看,每个模块只做一件事。后来换 WiFi 模组,只改 wifi_drv 一个文件,其他两个模块一行代码没动。这就是高内聚低耦合的好处。

小技巧:画模块依赖图的时候,箭头越少越好。如果两个模块之间箭头超过 3 条,说明耦合度太高了,得重新切。

2.2 单一职责原则——一个模块只做一件事

这个原则和「高内聚」是孪生兄弟。但很多嵌入式工程师容易犯一个错:把「功能」和「职责」搞混了。

比如一个温度传感器模块,你可能会写:

// 错误示范:一个模块干了三件事
void temp_sensor_task(void) {
    // 1. 读取传感器
    uint16_t raw = i2c_read(0x48);
    // 2. 计算温度
    float temp = raw * 0.0625;
    // 3. 上传到云端
    mqtt_publish("temp", temp);
}

这个模块的职责是什么?读取、计算、上传。三件事。如果哪天要换传感器型号,或者改上传协议,都得动这个文件。

我建议这样拆:

// 职责 1:传感器驱动
uint16_t temp_drv_read(void) {
    return i2c_read(0x48);
}

// 职责 2:温度计算
float temp_calc(uint16_t raw) {
    return raw * 0.0625;
}

// 职责 3:数据上报
void temp_report(float temp) {
    mqtt_publish("temp", temp);
}

每个函数只做一件事。测试的时候,你可以单独测驱动、单独测算法、单独测上报。这就是单一职责的威力。

我曾经踩过的坑:在一个 BLE 门锁项目里,我把加密算法和蓝牙通信写在一起。后来要升级加密算法,结果蓝牙连接也受影响。从那以后,我强制自己:一个 .c 文件只暴露 3-5 个 API,超过这个数,说明职责太多了。

2.3 接口隔离原则——不要强迫模块依赖它不需要的东西

这个原则在嵌入式里特别容易被忽视。很多人的接口设计是「大而全」的:

// 错误示范:一个接口包含所有功能
typedef struct {
    void (*init)(void);
    void (*send)(uint8_t *data, uint16_t len);
    void (*recv)(uint8_t *data, uint16_t *len);
    void (*config)(uint32_t baud, uint8_t parity);
    void (*sleep)(void);
    void (*wakeup)(void);
} uart_if_t;

如果某个模块只需要发送数据,它却要依赖整个 uart_if_t 结构体。这就是接口污染。

正确的做法是:按使用场景拆分接口。

接口 包含方法 适用场景
uart_tx_if send() 日志输出、数据上报
uart_rx_if recv() 命令接收、数据采集
uart_ctrl_if init(), config(), sleep() 驱动初始化、低功耗管理

这样,日志模块只需要依赖 uart_tx_if,它根本不需要知道怎么初始化 UART。接口隔离的好处是:依赖关系变清晰了,模块之间的影响范围也变小了。

我的习惯:写接口头文件的时候,先问自己三个问题:

  1. 这个接口的所有方法,调用方真的都需要吗?
  2. 能不能拆成两个更小的接口?
  3. 如果只暴露 2 个方法,够不够用?

2.4 依赖倒置原则——面向接口编程,不要面向实现

这个原则在嵌入式里最容易被误解。很多人觉得「嵌入式资源有限,搞抽象层太浪费」。其实恰恰相反:抽象层是保护你代码不被硬件绑架的防火墙。

来看一个反面教材:

// 错误示范:直接依赖具体硬件
void app_send_data(uint8_t *data, uint16_t len) {
    // 直接调用 STM32 的 HAL 库
    HAL_UART_Transmit(&huart1, data, len, 1000);
}

如果哪天要换芯片,或者从 UART 改成 SPI,你得改所有调用 HAL_UART_Transmit 的地方。这就是面向实现编程的代价。

正确的做法是定义一个抽象接口:

// 抽象接口:不依赖任何具体硬件
typedef struct {
    void (*send)(uint8_t *data, uint16_t len);
    void (*recv)(uint8_t *data, uint16_t *len);
} transport_if_t;

// 应用层只依赖这个接口
void app_send_data(transport_if_t *iface, uint8_t *data, uint16_t len) {
    iface->send(data, len);
}

然后具体的实现由底层提供:

// STM32 UART 实现
void uart_send(uint8_t *data, uint16_t len) {
    HAL_UART_Transmit(&huart1, data, len, 1000);
}

transport_if_t uart_if = {
    .send = uart_send,
    .recv = uart_recv
};

你看,应用层不再依赖具体的硬件。换芯片的时候,只需要换底层的实现,应用层代码一行都不用改。

依赖倒置的核心思想:

  • 高层模块(业务逻辑)不应该依赖低层模块(硬件驱动)
  • 两者都应该依赖抽象接口
  • 抽象接口不应该依赖具体实现,具体实现应该依赖抽象接口

2.5 四个原则的关系与实战框架

这四个原则不是孤立的。我画了一张图,帮你理清它们的关系:

模块划分四大原则关系图 模块划分目标 高内聚低耦合 模块内部紧密,模块间松散 单一职责原则 一个模块只做一件事 接口隔离原则 按需暴露最小接口 依赖倒置原则 面向接口,不面向实现 实战建议 先保证高内聚低耦合,再逐步引入其他三个原则 不要一开始就追求完美,迭代重构才是常态 典型嵌入式模块划分示例 应用层 → 业务逻辑层 → 抽象接口层 → 硬件驱动层 每层只依赖下一层的抽象接口,不依赖具体实现

在实际项目中,我建议你按这个顺序来应用:

  1. 先切模块:用高内聚低耦合把大系统拆成小模块
  2. 再定职责:用单一职责原则确保每个模块只做一件事
  3. 然后拆接口:用接口隔离原则让每个模块只暴露必要的方法
  4. 最后抽象:用依赖倒置原则让模块之间通过接口通信

我曾经犯过的错:在一个车载 OTA 项目中,我一开始就把四个原则全用上了,结果接口层写了 10 多个抽象结构体,代码量翻了一倍,调试周期也拉长了。后来我学乖了:先保证高内聚低耦合,其他原则在重构时逐步引入。记住,架构是迭代出来的,不是一步到位的。

2.6 小结

这四个原则,说白了就是让你在写代码的时候多问自己几个问题:

  • 这个模块是不是只做一件事?
  • 它依赖的东西是不是都是它真正需要的?
  • 如果换硬件,要改多少代码?