第2章:嵌入式软件架构设计

好,咱们直接进入正题。嵌入式软件架构,说白了就是给代码搭骨架。骨架搭得好,后面写起来就顺风顺水。骨架搭得歪,后面改起来……嗯,那滋味我尝过。

2.1 分层架构:把代码像抽屉一样整理

我个人习惯把嵌入式软件分成三层:硬件抽象层(HAL)、中间层、应用层。你想想看,这样分有什么好处?

  • 硬件抽象层(HAL):直接跟寄存器、外设打交道。比如读写GPIO、配置UART。这一层只做一件事:把硬件细节藏起来。
  • 中间层:负责协议栈、文件系统、任务调度。它不关心底层是STM32还是GD32,只关心上层给它什么指令。
  • 应用层:业务逻辑全在这。比如“按键按下时发送数据包”。这一层最好别出现任何寄存器操作。

核心原则:上层可以调用下层,下层绝不能反过来调用上层。这叫单向依赖。我在项目中见过有人为了省事,在HAL里直接调用应用层的函数……结果换了个MCU,整个应用层都得重写。

2.2 模块划分原则:别把鸡蛋放一个篮子里

模块怎么切?我总结了三条铁律:

  1. 高内聚、低耦合:一个模块只干一件事,干好。模块之间通过接口通信,别共享全局变量。
  2. 接口稳定:接口一旦定下来,就别轻易改。我吃过这个亏——有一次改了接口参数类型,结果下游五个模块全得跟着改,那周我加了三天班。
  3. 可替换性:每个模块都应该能独立测试、独立替换。比如你换了一个传感器,只需要换掉对应的驱动模块,其他代码不动。

我的小技巧:写模块之前,先画一张模块依赖图。箭头指向谁,谁就是被依赖的。箭头越少,耦合越低。我曾经用这个方法把一个2000行的单文件拆成了8个模块,代码可读性直接翻倍。

2.3 接口设计规范:定好规矩,少吵架

接口设计,说白了就是定协议。我一般用结构体+函数指针的方式。举个例子:

// 定义一个UART驱动接口
typedef struct {
    int32_t (*init)(uint32_t baudrate);
    int32_t (*send)(uint8_t *data, uint16_t len);
    int32_t (*recv)(uint8_t *data, uint16_t len);
} uart_driver_t;

// 使用示例
uart_driver_t uart1 = {
    .init = uart1_init,
    .send = uart1_send,
    .recv = uart1_recv
};

这样设计的好处是:你换一个UART硬件,只需要换一个结构体实例,上层代码完全不用动。我在一个物联网项目里用过这个模式,后来从STM32F1换到F4,应用层一行代码没改。

2.4 接口命名规范

命名这件事,看着小,其实坑很多。我建议:

  • 函数名用模块名_动作_对象格式,比如uart_send_bytetimer_start_ms
  • 宏定义全大写,比如#define UART_BAUDRATE_115200 115200
  • 全局变量加前缀,比如g_uart_rx_buffer。我见过有人全局变量和局部变量重名,调试了三天才发现……

避坑指南:我曾经在一个项目里用了#define定义了一个叫MAX的宏,结果跟标准库里的MAX冲突了。从那以后,我所有宏定义都加模块前缀,比如UART_MAX_BUFFER_SIZE

2.5 接口文档:别偷懒

接口写完了,文档也得跟上。我一般用Doxygen风格注释:

/**
 * @brief 初始化UART
 * @param baudrate 波特率,支持9600、115200等
 * @return 0成功,-1失败
 */
int32_t uart_init(uint32_t baudrate);

你想想看,三个月后你自己回来看代码,没有注释,你还能记得每个参数是干嘛的吗?反正我不行。

2.6 模块间通信:用消息队列,别用全局变量

模块之间怎么传数据?我强烈建议用消息队列。全局变量虽然方便,但调试起来简直是噩梦。你根本不知道哪个模块在什么时候改了它。

消息队列的好处:

  • 解耦:发送方和接收方不需要知道对方的存在。
  • 异步:发送方发完就走,接收方有空再处理。
  • 可追踪:你可以打印所有消息,方便调试。

我的经验:在一个多任务系统里,我用消息队列代替了全局变量,bug数量直接减少了60%。因为每个模块只处理自己的消息,不会互相干扰。

2.7 总结

嵌入式软件架构,说白了就是三个字:分、定、稳。分好层,定好接口,稳住依赖。做到这三点,你的代码就能像乐高积木一样,想怎么拼就怎么拼。

嗯,下一章咱们聊聊任务调度和实时性设计。到时候我会分享一个我踩过的坑——优先级反转,那叫一个惨。