4. 资源受限编程模型
模块化设计,说白了就是「拆」的艺术。但怎么拆、拆到什么粒度、接口怎么定,才是真正考验功力的地方。我见过太多项目,一开始拍脑袋分了几个文件,写到后面全成了「上帝模块」——一个文件几千行,谁都不敢动。
这一章,我们聊聊嵌入式里真正能落地的模块化原则。不是教科书上的空话,而是我在几个量产项目里踩过坑之后总结出来的实战经验。
接口设计:模块之间的「合同」
接口是模块之间唯一的沟通渠道。接口设计得好,两边各改各的,互不干扰。设计得不好,改一个地方,牵连一片。
我个人习惯把接口分为三类:
| 接口类型 | 典型形式 | 耦合度 | 适用场景 |
|---|---|---|---|
| 函数调用接口 | API 函数、回调函数 | 低 | 模块间同步调用 |
| 消息/事件接口 | 队列、信号量、事件标志 | 极低 | 异步通信、任务间解耦 |
| 共享数据接口 | 全局变量、共享内存 | 高 | 尽量避免,必要时加保护 |
我在项目中遇到过最典型的反面教材:一个温控模块直接读取另一个电机模块的内部结构体变量。后来电机模块改了数据结构,温控那边编译直接报错,查了半天才发现是耦合太紧。
接口设计三原则:
- 最小化——只暴露必须暴露的东西,能隐藏的全隐藏
- 稳定性——接口一旦发布,尽量不做破坏性修改
- 自文档化——看函数名和参数就知道怎么用,不需要翻文档
来看一个实际例子。这是某传感器驱动模块的接口头文件:
/* sensor_driver.h —— 只暴露用户需要的接口 */
#ifndef SENSOR_DRIVER_H
#define SENSOR_DRIVER_H
#include <stdint.h>
#include <stdbool.h>
/* 初始化传感器,返回是否成功 */
bool sensor_init(void);
/* 读取温度值,单位 0.1°C,失败返回 INT32_MAX */
int32_t sensor_read_temperature(void);
/* 读取湿度值,单位 0.1%RH,失败返回 INT32_MAX */
int32_t sensor_read_humidity(void);
/* 设置采样间隔,单位 ms */
void sensor_set_interval(uint32_t interval_ms);
#endif /* SENSOR_DRIVER_H */
注意看,这个头文件里没有暴露任何内部寄存器地址、I2C 读写函数、状态机变量。用户模块只需要知道「初始化、读温度、读湿度、设间隔」这四个操作就够了。至于底层用的是 I2C 还是 SPI,是轮询还是中断,那是模块内部的事。
小技巧:接口头文件里不要 include 内部实现用的头文件。如果用户模块因为 include 了你的接口,被迫也 include 了一堆底层驱动头文件,说明接口设计已经失败了。
模块边界划分:切在哪,怎么切
模块边界划分是架构设计中最难的一步。切得太粗,模块内部还是一团乱麻;切得太细,接口数量爆炸,管理成本飙升。
我一般遵循「业务语义优先,硬件边界辅助」的原则。什么意思?
- 业务语义优先:一个模块应该对应一个完整的业务能力。比如「温控模块」负责温度采集、控制算法、执行器驱动,这三件事天然属于同一个业务域。
- 硬件边界辅助:如果两个功能跑在不同的硬件外设上(比如 UART 和 SPI),那它们天然适合分模块。但如果跑在同一个外设上(比如同一个定时器既做 PWM 又做输入捕获),就要看业务上是否应该拆开。
举个例子。我曾经接手过一个智能家居网关项目,原来的代码把所有通信协议(Wi-Fi、Zigbee、蓝牙)都写在一个文件里。看起来都是「通信」,但业务上完全不一样:Wi-Fi 负责跟云端交互,Zigbee 负责跟传感器通信,蓝牙负责本地调试。我把它们拆成三个独立模块,每个模块只负责自己的协议栈和数据处理。后来 Wi-Fi 模块要升级协议版本,只改了 3 个文件,其他模块零影响。
避坑指南:我曾经犯过一个错误——为了「复用」把两个业务逻辑完全不同的模块硬塞在一起。结果每次改需求都要在两个业务逻辑之间做「隔离手术」,比重新写还痛苦。记住:复用是结果,不是目标。不要为了复用而强行合并。
单一职责原则在嵌入式中的落地
单一职责原则(SRP)说:一个模块只应该有一个引起它变化的原因。在嵌入式里,这句话可以翻译成:一个模块只做一件事,并且把这件事做好。
但落地的时候,很多人会问:「一个函数算一个模块吗?一个文件算一个模块吗?」
我的答案是:模块的粒度取决于你的项目规模和团队分工。
| 项目规模 | 模块粒度 | 典型模块数 | 单人维护代码量 |
|---|---|---|---|
| 小项目(< 10K 行) | 一个 .c/.h 对 | 5-10 | < 2000 行 |
| 中项目(10K-100K 行) | 一个文件夹 | 10-30 | < 5000 行 |
| 大项目(> 100K 行) | 一个子目录树 | 30+ | < 10000 行 |
来看一个违反 SRP 的典型例子:
/* 坏例子:一个模块干了三件事 */
void system_manager_task(void *param)
{
/* 1. 读取传感器数据 */
sensor_read(&temp, &humidity);
/* 2. 控制继电器 */
if (temp > 30) {
relay_on();
}
/* 3. 通过 Wi-Fi 上报数据 */
wifi_send(temp, humidity);
/* 4. 更新 OLED 显示 */
oled_display(temp, humidity);
}
这个函数看起来「功能完整」,但仔细想想:传感器、继电器、Wi-Fi、OLED,这四个东西任何一个发生变化,都要改这个函数。这就是四个变化原因耦合在一起。
重构之后:
/* 好例子:每个模块只做自己的事 */
void sensor_task(void *param)
{
sensor_read(&temp, &humidity);
data_queue_send(temp, humidity);
}
void control_task(void *param)
{
data_queue_receive(&temp, &humidity);
if (temp > 30) {
relay_on();
}
}
void report_task(void *param)
{
data_queue_receive(&temp, &humidity);
wifi_send(temp, humidity);
}
void display_task(void *param)
{
data_queue_receive(&temp, &humidity);
oled_display(temp, humidity);
}
每个任务只做一件事,通过消息队列解耦。传感器模块改了,不影响其他三个;Wi-Fi 协议升级了,只改 report_task。这就是 SRP 带来的好处。
判断是否违反 SRP 的简单方法:当你需要用一个「和」字来描述一个模块的职责时,大概率已经违反了。比如「这个模块负责读取传感器和控制继电器和上报数据」——三个「和」,三个变化原因。
模块化设计核心逻辑图
下面这张图展示了模块化设计的核心逻辑:从业务需求出发,经过接口定义、边界划分、职责分配,最终形成高内聚低耦合的模块结构。
实战中的几个判断标准
说了这么多原则,怎么判断自己的模块化设计做得好不好?我总结了几个简单的检查清单:
- 修改一个需求,改几个文件?理想情况是 1-2 个。如果超过 3 个,说明耦合太紧。
- 能不能单独测试这个模块?如果为了测试一个模块,需要把整个系统都跑起来,说明边界没划好。
- 换一个硬件平台,要改多少代码?硬件相关代码应该集中在少数几个模块里,业务逻辑应该与硬件无关。
- 新同事看代码,能不能快速找到某个功能在哪?模块命名和目录结构应该直观到「一看就知道在哪」。
一个实用技巧:每次新建一个 .c 文件之前,先问自己三个问题:这个文件要做什么?它依赖哪些其他模块?其他模块依赖它什么?如果答不上来,说明边界还没想清楚,先别急着写代码。
模块化设计不是一蹴而就的。我自己的经验是:第一版往往分得不够好,但随着对业务理解的加深,会逐步重构出更合理的边界。关键是要有「随时可以重构」的信心——而这份信心,正来自于良好的接口设计和清晰的模块边界。