3. 系统架构设计原则:模块化、分层、接口与模式
做嵌入式系统,说白了就是在做“拆”与“合”的平衡。拆得不好,后期改一个功能要动半套代码;合得不好,模块之间互相扯皮,调试起来想砸板子。这一章我把自己这些年踩过的坑、总结出的原则,跟你好好聊聊。
核心观点:架构设计不是为了好看,是为了降低风险。一个清晰的架构,能让你在项目后期少掉80%的头发。
3.1 模块化设计:高内聚、低耦合
模块化不是什么新鲜概念,但真正做好的团队不多。我见过太多“伪模块化”——函数是分开的,但全局变量满天飞,改一个模块得重新编译整个工程。
模块化的本质是什么?
- 高内聚:一个模块只做一件事,并且把它做好。比如LED驱动模块,只负责控制LED亮灭、闪烁、PWM调光,不掺和按键扫描的事。
- 低耦合:模块之间通过定义好的接口通信,不直接访问对方的内部变量。你改你的内部实现,只要接口不变,我就不用动。
我个人习惯在项目初期就画好模块依赖图。举个例子,一个温控器项目,我会这样划分:
| 模块名 | 职责 | 对外接口 | 依赖模块 |
|---|---|---|---|
| temp_sensor | 读取温度传感器数据 | get_temperature() | I2C驱动 |
| heater_ctrl | 控制加热器开关 | set_heater(on/off) | GPIO驱动 |
| pid_controller | PID算法计算 | pid_compute(target, current) | 无(纯算法) |
| display | 显示当前温度与状态 | display_update(temp, status) | LCD驱动 |
我的经验:模块的.c文件里,尽量只暴露必要的函数声明在.h中,内部函数全部用static修饰。这样别人一看你的.h文件,就知道这个模块能干什么,不用翻你的.c。
3.2 分层架构:HAL / OS / APP
分层架构是嵌入式系统里最经典的套路。我习惯把它分成三层:硬件抽象层(HAL)、操作系统层(OS)、应用层(APP)。
为什么要分层?你想想看,如果应用代码里直接操作寄存器,换一颗MCU,所有代码都得重写。分层之后,换芯片只需要改HAL层,应用层基本不动。
下面这张图是我常用的分层结构:
各层职责要清晰:
- HAL层:封装所有硬件操作。比如写一个
hal_gpio_write(pin, level),应用层不用关心这个引脚是PA0还是PB1。 - OS层:负责任务调度、信号量、消息队列。如果不用RTOS,裸机也可以抽象出一个简单的调度器。
- APP层:只关心业务逻辑。比如“温度超过50度就关加热器”,不关心这个温度是从哪个传感器读来的。
我曾经踩过的坑:在一个项目中,同事为了省事,在APP层直接调用了HAL层的寄存器操作函数。后来换了一颗MCU,HAL层重写了,但APP层里散落着十几个寄存器操作,改得痛不欲生。记住:跨层调用是架构的毒药。
3.3 接口定义:API 与 ABI
接口是模块之间的契约。我习惯把接口分成两类:API(应用编程接口)和ABI(应用二进制接口)。
API:源代码级别的接口。比如一个函数声明int get_temperature(void);,调用方包含头文件就能用。API关注的是函数名、参数、返回值。
ABI:二进制级别的接口。它规定了函数调用时参数怎么传、栈怎么用、寄存器怎么分配。ABI在动态库、固件升级、多语言混合编程时特别重要。
| 对比项 | API | ABI |
|---|---|---|
| 关注层面 | 源代码 | 二进制 |
| 变化影响 | 重新编译即可 | 需要重新链接或加载 |
| 典型场景 | 模块间函数调用 | 动态库、Bootloader跳转 |
| 稳定性要求 | 中等 | 极高(一旦定义,尽量不改) |
我个人习惯在项目初期就把API文档写出来,哪怕实现还没写。这样各个模块的开发者可以并行工作,只要接口对齐就行。
一个小技巧:定义API时,尽量使用不透明指针(opaque pointer)。比如typedef struct temp_sensor* temp_handle_t;,外部只知道这是个句柄,不知道内部结构。这样你改内部实现,外部完全无感。
3.4 设计模式:状态机、回调与观察者
嵌入式系统里,设计模式不用学太多,掌握三个就够用:状态机、回调、观察者。
3.4.1 状态机
状态机是嵌入式里最常用的模式。一个按键、一个通信协议、一个设备控制流程,都可以用状态机来建模。
为什么用状态机?因为嵌入式系统本质上是“事件驱动”的。状态机把系统行为拆成“状态”和“事件”,逻辑清晰,不容易遗漏边界情况。
// 一个简单的按键状态机示例
typedef enum {
KEY_IDLE,
KEY_DEBOUNCE,
KEY_PRESSED,
KEY_LONG_PRESS
} key_state_t;
key_state_t key_state = KEY_IDLE;
void key_fsm(key_event_t event) {
switch (key_state) {
case KEY_IDLE:
if (event == KEY_DOWN) {
key_state = KEY_DEBOUNCE;
start_timer(10); // 10ms消抖
}
break;
case KEY_DEBOUNCE:
if (event == TIMEOUT) {
if (read_key() == PRESSED) {
key_state = KEY_PRESSED;
start_timer(1000); // 长按计时
} else {
key_state = KEY_IDLE; // 抖动,回到空闲
}
}
break;
case KEY_PRESSED:
if (event == KEY_UP) {
key_state = KEY_IDLE;
notify_short_press();
} else if (event == TIMEOUT) {
key_state = KEY_LONG_PRESS;
notify_long_press();
}
break;
// ... 其他状态
}
}
我的经验:状态机最怕“状态爆炸”。如果状态超过10个,建议拆成多个子状态机,或者用状态表驱动。我曾经接手过一个项目,一个状态机里塞了30多个状态,调试时根本理不清。后来拆成3个子状态机,每个不到10个状态,世界清净了。
3.4.2 回调函数
回调是嵌入式里解耦的利器。你定义一个接口,别人注册一个函数,事件发生时你调用它。这样你的模块不需要知道谁在监听。
// 定时器模块:注册回调
typedef void (*timer_callback_t)(void* arg);
typedef struct {
uint32_t period;
timer_callback_t callback;
void* arg;
} timer_t;
void timer_register(timer_t* tmr, timer_callback_t cb, void* arg) {
tmr->callback = cb;
tmr->arg = arg;
}
// 定时器中断里调用
void timer_isr(timer_t* tmr) {
if (tmr->callback) {
tmr->callback(tmr->arg);
}
}
注意:回调函数里不要做耗时操作,比如延时、打印。回调通常是在中断上下文或高优先级任务里执行的,耗时操作会阻塞整个系统。我见过有人在回调里调printf,结果系统卡死——因为printf本身用了互斥锁,而中断里不能加锁。
3.4.3 观察者模式
观察者模式是回调的升级版。一个主题(Subject)维护一个观察者列表,状态变化时通知所有观察者。这在传感器数据分发、UI更新等场景非常实用。
// 观察者模式简单实现
#define MAX_OBSERVERS 10
typedef struct {
void (*update)(void* data);
void* data;
} observer_t;
typedef struct {
observer_t observers[MAX_OBSERVERS];
int count;
} subject_t;
void subject_attach(subject_t* sub, void (*update)(void*), void* data) {
if (sub->count < MAX_OBSERVERS) {
sub->observers[sub->count].update = update;
sub->observers[sub->count].data = data;
sub->count++;
}
}
void subject_notify(subject_t* sub, void* event_data) {
for (int i = 0; i < sub->count; i++) {
sub->observers[i].update(event_data);
}
}
什么时候用观察者?当一个数据源有多个消费者时。比如温度传感器数据,既要送给PID控制器,又要送给显示模块,还要送给日志记录模块。用观察者模式,传感器模块只管通知,不关心谁在听。
3.5 架构设计避坑清单
最后,我把自己这些年踩过的坑整理成一张清单,你写代码时可以对照着检查:
| 常见问题 | 后果 | 解决方案 |
|---|---|---|
| 全局变量满天飞 | 模块耦合严重,改一处崩一片 | 用接口传递数据,模块内部变量加static |
| 跨层调用 | 换MCU时大量代码需要重写 | 严格遵守分层调用规则 |
| API定义不明确 | 联调时才发现接口对不上 | 先写接口文档,再写实现 |
| 状态机状态过多 | 逻辑混乱,难以维护 | 拆分子状态机或用状态表 |
| 回调里做耗时操作 | 系统响应变慢或死锁 | 回调只做标记,实际处理放到主循环 |
架构设计没有银弹,但这些原则能帮你少走弯路。记住:好的架构是改出来的,不是设计出来的。先跑通,再优化,别一开始就想搞个完美的架构——那往往是过度设计的开始。