模块划分原则:高内聚低耦合在嵌入式中的落地
做嵌入式架构设计,说白了就是两件事:切模块和定接口。模块切得好,后面重构、测试、移植都顺;切得不好,改一个 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。接口隔离的好处是:依赖关系变清晰了,模块之间的影响范围也变小了。
我的习惯:写接口头文件的时候,先问自己三个问题:
- 这个接口的所有方法,调用方真的都需要吗?
- 能不能拆成两个更小的接口?
- 如果只暴露 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 四个原则的关系与实战框架
这四个原则不是孤立的。我画了一张图,帮你理清它们的关系:
在实际项目中,我建议你按这个顺序来应用:
- 先切模块:用高内聚低耦合把大系统拆成小模块
- 再定职责:用单一职责原则确保每个模块只做一件事
- 然后拆接口:用接口隔离原则让每个模块只暴露必要的方法
- 最后抽象:用依赖倒置原则让模块之间通过接口通信
我曾经犯过的错:在一个车载 OTA 项目中,我一开始就把四个原则全用上了,结果接口层写了 10 多个抽象结构体,代码量翻了一倍,调试周期也拉长了。后来我学乖了:先保证高内聚低耦合,其他原则在重构时逐步引入。记住,架构是迭代出来的,不是一步到位的。
2.6 小结
这四个原则,说白了就是让你在写代码的时候多问自己几个问题:
- 这个模块是不是只做一件事?
- 它依赖的东西是不是都是它真正需要的?
- 如果换硬件,要改多少代码?