1. 架构设计总览:低耦合设计原则、组件化思想、通信模式对比
各位同学,咱们直接进入正题。嵌入式架构设计这件事,说白了就是「拆」和「连」的艺术。拆得不好,代码像一团乱麻;连得不好,系统跑起来到处是坑。我做了十几年嵌入式,见过太多项目因为架构没想清楚,后期调试改到崩溃。
这一章,我们先搭个框架。把低耦合、组件化、通信模式这几个核心概念理清楚。后面所有章节的实战,都建立在这个基础上。
1.1 低耦合设计原则:为什么你的代码改一处就崩一片?
先问个问题:你有没有遇到过这种情况?改了一个传感器的驱动,结果 LCD 显示乱了,按键响应也变慢了。这就是典型的耦合过紧。
低耦合,说白了就是让模块之间「知道得越少越好」。A 模块调用 B 模块,A 不需要知道 B 内部怎么实现的,只需要知道 B 能提供什么服务。
低耦合的核心衡量指标:
- 扇入/扇出系数:一个模块被多少个其他模块调用(扇入),又调用了多少个其他模块(扇出)。扇出超过 5,就要警惕了。
- 接口稳定性:模块间的接口定义是否频繁变动。接口一变,所有调用方都得改。
- 数据依赖深度:A 修改了全局变量,B、C、D 是否受影响?深度越浅越好。
我在一个车载项目中遇到过:一个温度采集模块直接修改了全局结构体里的 8 个字段,结果显示模块、报警模块、日志模块全都依赖这个结构体。后来需求变更,温度采集要增加滤波算法,改了结构体定义,好家伙,编译报了 30 多个错误。这就是血的教训。
我的习惯做法:每个模块只暴露 3-5 个 API 函数,所有数据通过参数传递,绝不共享全局变量。如果非要共享,用消息队列或者回调函数。
1.2 组件化思想:把系统拆成乐高积木
组件化不是什么新概念。但嵌入式领域,很多人把它理解成了「把 .c 文件分得细一点」。不对。组件化的核心是可替换性和可复用性。
你想想看,一个真正的组件应该像 USB 设备一样:插上去就能用,拔下来系统不受影响。你的代码能做到吗?
我一般把嵌入式系统拆成三层:
| 层级 | 职责 | 典型组件 | 依赖关系 |
|---|---|---|---|
| 应用层 | 业务逻辑、状态机 | 任务调度、用户界面、协议解析 | 依赖服务层接口 |
| 服务层 | 功能封装、数据处理 | 传感器融合、PID 控制、日志服务 | 依赖驱动层抽象 |
| 驱动层 | 硬件抽象、寄存器操作 | GPIO、I2C、SPI、UART 驱动 | 不依赖上层 |
每一层内部再拆成独立的组件。组件之间通过接口契约通信,而不是直接调用函数。
注意:组件化不是越细越好。我见过有人把一个 I2C 驱动拆成 7 个文件:初始化、发送、接收、中断、超时、错误处理、配置。结果一个简单的读操作要跨 4 个文件跳转。适可而止。
1.3 通信模式对比:轮询、中断、DMA、消息队列
组件之间怎么传数据?这是嵌入式架构里最关键的决策。选错了模式,轻则 CPU 空转,重则数据丢失。
我整理了一张对比表,你直接存下来:
| 模式 | CPU 占用 | 实时性 | 数据吞吐量 | 代码复杂度 | 典型场景 |
|---|---|---|---|---|---|
| 轮询 | 高(一直占着 CPU) | 差(取决于轮询频率) | 低 | 低 | 按键扫描、简单状态检测 |
| 中断 | 低(触发时才执行) | 好(立即响应) | 中 | 中 | 外部事件、数据接收 |
| DMA | 极低(硬件搬运) | 中(依赖配置) | 高 | 高 | 大数据块传输、ADC 采集、显示刷新 |
| 消息队列 | 中(调度开销) | 中(队列深度影响) | 中 | 中高 | 多任务通信、异步事件处理 |
嗯,这里要注意:没有银弹。我见过有人非要用 DMA 传一个字节的数据,结果配置 DMA 的时间比轮询还长。也见过有人所有地方都用消息队列,结果系统里堆了 20 多个队列,内存爆了。
1.3.1 轮询:简单但费电
轮询是最原始的方式。CPU 不停地问:「数据来了吗?来了吗?来了吗?」
// 轮询示例:等待 UART 接收一个字节
while (!(USART1->SR & USART_SR_RXNE)) {
// CPU 在这里空转,啥也不干
}
uint8_t data = USART1->DR;
这种写法,CPU 利用率极低。在低功耗场景下,轮询就是灾难。我曾经在一个电池供电的设备里,因为用了轮询,待机电流从 50uA 飙到了 2mA。
1.3.2 中断:实时响应,但要小心嵌套
中断解决了 CPU 空转的问题。数据来了,硬件通知 CPU,CPU 暂停当前任务去处理。
// 中断服务函数:UART 接收中断
void USART1_IRQHandler(void) {
if (USART1->SR & USART_SR_RXNE) {
uint8_t data = USART1->DR;
// 把数据放入缓冲区
ring_buffer_write(&rx_buf, data);
}
}
中断的问题在于:中断服务函数里不能做复杂操作。我见过有人在中断里做浮点运算、调用 printf、甚至申请动态内存。结果系统随机死机,查了三天才发现是中断栈溢出了。
我的原则:中断里只做「标记」和「搬运」。标记事件发生,把数据搬到缓冲区,然后唤醒任务去处理。绝对不在中断里执行业务逻辑。
1.3.3 DMA:硬件帮你搬砖
DMA 是真正的「零 CPU 开销」。配置好源地址、目的地址、数据长度,DMA 控制器自己把数据搬完,然后给你发个中断说「搞定了」。
// DMA 配置示例:从 ADC 搬运数据到内存缓冲区
DMA_InitTypeDef dma_config;
dma_config.PeriphBaseAddr = (uint32_t)&ADC1->DR;
dma_config.MemoryBaseAddr = (uint32_t)adc_buffer;
dma_config.Direction = DMA_DIR_PeripheralToMemory;
dma_config.BufferSize = 1024; // 一次搬运 1024 个采样点
DMA_Init(DMA1_Channel1, &dma_config);
DMA_Cmd(DMA1_Channel1, ENABLE);
DMA 的坑在于:缓存一致性问题。特别是带 Cache 的高性能 MCU,DMA 写内存的时候,CPU 读到的可能是 Cache 里的旧数据。我踩过这个坑,后来老老实实在 DMA 传输前后加了数据同步指令。
1.3.4 消息队列:解耦利器
消息队列是我个人最喜欢的通信方式。它让发送方和接收方完全解耦:发送方只管往队列里丢消息,接收方只管从队列里取消息。双方不需要知道对方是谁、在哪个任务里运行。
// 消息队列使用示例(基于 FreeRTOS)
// 发送任务
void sensor_task(void *param) {
sensor_data_t data;
while (1) {
data = read_sensor();
xQueueSend(sensor_queue, &data, portMAX_DELAY);
vTaskDelay(pdMS_TO_TICKS(100));
}
}
// 接收任务
void display_task(void *param) {
sensor_data_t data;
while (1) {
if (xQueueReceive(sensor_queue, &data, pdMS_TO_TICKS(500))) {
update_display(&data);
}
}
}
消息队列的代价是:内存开销和调度延迟。每个消息都要复制一份数据(或者传递指针),队列本身也要占用 RAM。在资源紧张的 MCU 上,要精打细算。
选型建议:
- 按键、开关等低频事件 → 中断
- ADC 连续采样、音频数据流 → DMA
- 简单状态查询、超时检测 → 轮询(配合定时器)
- 多任务间数据传递、事件通知 → 消息队列
1.4 本章知识体系总览
下面这张图,把本章的核心逻辑串起来了。你可以把它当作整个课程的「导航地图」。
这张图你看懂了吗?从上到下,就是我们的设计流程:先确立低耦合原则,再按组件化思想分层,最后根据具体场景选择通信模式。后面 29 章,每一章都会围绕这张图里的某个节点展开。
给新人的建议:刚开始做架构设计,别想着一步到位。先画图,把模块和通信关系画清楚。画着画着,你就知道哪里耦合紧了,哪里需要解耦了。我每个项目开始前,都会花至少一天时间画架构图。这个习惯帮我避免了很多后期返工。