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层,应用层基本不动。

下面这张图是我常用的分层结构:

嵌入式系统分层架构 应用层(APP) 业务逻辑 · 任务调度 · 用户交互 例:温控算法、数据显示、报警逻辑 操作系统层(OS) 任务管理 · 同步互斥 · 内存管理 例:FreeRTOS、RT-Thread、裸机调度 硬件抽象层(HAL) 寄存器封装 · 外设驱动 · 板级支持 例:GPIO、UART、I2C、SPI、ADC驱动 ⚠ 上层只能调用相邻下层接口,禁止跨层调用(如APP直接操作寄存器) 换MCU时只需重写HAL层,APP和OS层基本不动

各层职责要清晰:

  • 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定义不明确 联调时才发现接口对不上 先写接口文档,再写实现
状态机状态过多 逻辑混乱,难以维护 拆分子状态机或用状态表
回调里做耗时操作 系统响应变慢或死锁 回调只做标记,实际处理放到主循环

架构设计没有银弹,但这些原则能帮你少走弯路。记住:好的架构是改出来的,不是设计出来的。先跑通,再优化,别一开始就想搞个完美的架构——那往往是过度设计的开始。