C++ 事件总线(EventBus)实现嵌入式系统解耦
摘要:在嵌入式软件开发中,随着功能模块的增加,模块间的'相互调用'往往会导致代码耦合度指数级上升。修改 A 模块的代码,B、C、D 模块都要跟着改,牵一发而动全身。本文将抛弃传统的直接调用方式,基于 C++11 实现一个轻量级的发布 - 订阅(Publish-Subscribe)系统,彻底斩断模块间的依赖链。
一、架构之殇:为什么你的代码越来越难改?
假设我们要开发一个智能环境控制器,它有四个独立模块:
- 采集模块:读取温度传感器。
- 显示模块:LCD 屏幕显示数据。
- 网络模块:WiFi 上传数据。
- 控制模块:温度过高开启风扇。
1. 传统写法(高耦合)
在传统的面向过程编程中,采集模块的代码通常长这样:
// SensorTask.cpp
#include "LcdDriver.h" // 依赖 LCD
#include "WifiModule.h" // 依赖 WiFi
#include "FanControl.h" // 依赖 风扇
void Sensor_Read_Loop() {
float temp = HAL_ADC_Read(); // 噩梦的开始:采集者必须知道所有消费者的存在
LCD_ShowTemp(temp); // 直接调用
WiFi_Upload(temp); // 直接调用
if (temp > 30) Fan_On(); // 直接调用
}
2. 痛点分析
- 依赖地狱:采集模块依赖了其他所有模块的头文件。如果你想把这个采集算法移植到另一个没有 WiFi 的项目中,你必须手动删除所有 WiFi 相关的代码。
- 扩展性差:如果老板说:'加一个蜂鸣器,温度高了报警'。你得去改
SensorTask.cpp,重新编译采集模块。这违背了'开闭原则'(对扩展开放,对修改关闭)。
二、破局之道:引入'事件总线'
我们要引入一个中间人(Broker)。
- 采集者只管喊一声:'温度变了,现在是 35 度!'(发布事件)
- 显示/网络/风扇只管告诉中间人:'如果温度变了,记得叫醒我。'(订阅事件)
结果:采集者根本不知道显示器、WiFi 的存在。大家老死不相往来,却配合得天衣无缝。
三、核心实现:极简 C++ EventBus
为了适配嵌入式资源,我们不使用复杂的模板元编程,而是利用 C++11 的 std::function 和 std::map 实现一个通用版本。
1. 通用头文件 (EventBus.h)
#pragma once
#include <vector>
#include <map>
#include <functional>
// 1. 定义事件类型(这是唯一的公共依赖)
enum class EventID {
TEMP_UPDATED, // 温度更新
WIFI_CONNECTED, // WiFi 连上
BUTTON_PRESSED, // 按键按下
ERROR_OCCURED // 系统故障
};
// 2. 定义回调函数原型:接收一个 void* 指针,允许传输任意数据
using EventCallback = std::function<void(void* payload)>;
class EventBus {
public:
// 单例模式:全局只有一个总线
static EventBus& Get() {
static EventBus instance;
return instance;
}
// [订阅]:谁关心某个事件,就来登记
void Subscribe(EventID id, EventCallback cb) {
subscribers[id].push_back(cb);
}
// [发布]:发生什么事了,通知大家
void Publish(EventID id, void* payload = nullptr) {
auto it = subscribers.find(id);
if (it != subscribers.end()) {
for (auto& callback : it->second) {
(callback) (payload);
}
}
}
:
std::map<EventID, std::vector<EventCallback>> subscribers;
() = ;
};
四、彻底解耦实战
让我们看看重构后的代码是如何工作的。
1. 公共数据定义
为了安全传输,我们定义传输的数据结构。
// AppData.h
struct TempData {
float temperature;
float humidity;
};
2. 发布者:采集模块 (SensorTask)
注意: 这里没有任何 include "Lcd.h" 或 include "Wifi.h"。它只依赖 EventBus。
#include "EventBus.h"
#include "AppData.h"
void Sensor_Loop() {
// 1. 获取硬件数据
TempData currentData;
currentData.temperature = 35.5f;
currentData.humidity = 60.0f;
// 2. 广播事件:我任务完成了,剩下的你们随意
// 就像发朋友圈一样,不指定谁看
EventBus::Get().Publish(EventID::TEMP_UPDATED, ¤tData);
}
3. 订阅者 A:显示模块 (LcdTask)
#include "EventBus.h"
#include "AppData.h"
class LcdModule {
public:
void Init() {
// 注册监听:当温度更新时,自动调用 UpdateScreen
EventBus::Get().Subscribe(EventID::TEMP_UPDATED, [this](void* data) {
this->UpdateScreen(data);
});
}
private:
void UpdateScreen(void* rawData) {
// 1. 数据还原(Type Casting)
TempData* data = static_cast<TempData*>(rawData);
// 2. 业务逻辑
printf("LCD Display: Temp = %.1f\n", data->temperature);
}
};
4. 订阅者 B:风扇控制 (FanTask)
完全独立的另一个文件,逻辑互不干扰。
void InitFanControl() {
EventBus::Get().Subscribe(EventID::TEMP_UPDATED, [](void* rawData) {
TempData* data = static_cast<TempData*>(rawData);
if (data->temperature > 30.0f) {
HAL_GPIO_WritePin(FAN_PORT, FAN_PIN, 1); // 开风扇
}
});
}
五、扩展性演示:突发需求
需求:老板突然要求,温度过高时,蜂鸣器也要响。
旧架构做法:打开 SensorTask.cpp,添加蜂鸣器头文件,修改采集逻辑,重新测试采集功能(容易引入 Bug)。
新架构做法:
- 新建
BuzzerTask.cpp。 - 订阅
TEMP_UPDATED事件。 - 判断温度并报警。
SensorTask.cpp 一个字都不用改! 这就是架构设计的魅力。
六、架构师的'避坑指南'
在嵌入式环境使用 EventBus,有三个关键点必须注意:
- 空指针风险:
void* payload提供了灵活性,但也带来了风险。- 规范:必须在项目文档或头文件中明确规定:
TEMP_UPDATED事件携带的数据类型必须是struct TempData。 - 进阶:在资源允许的情况下,可以使用
std::any(C++17) 或自定义的Variant类来增强类型安全。
- 规范:必须在项目文档或头文件中明确规定:
- 生命周期管理:
Publish传递的是指针。- 如果数据是局部变量(栈内存),必须保证在
Publish函数返回前,所有订阅者都已经处理完数据(同步调用模式,本文采用的就是这种)。 - 如果要在不同线程/任务间异步传递,必须使用深拷贝或智能指针,或者通过 RTOS 的消息队列发送。
- 如果数据是局部变量(栈内存),必须保证在
- 中断上下文 (ISR)
std::map和std::vector的内存分配不是重入安全的。- 严禁在 ISR(硬件中断)中调用
Subscribe。 - 如果在 ISR 中调用
Publish,需确保总线实现不涉及动态内存分配(Pre-allocated),或者仅在 ISR 中置标志位,在主循环中分发事件。
七、总结
代码的耦合度是衡量架构质量的核心指标。
通过引入 EventBus,我们将 N 对 N 的网状依赖,转化为了 1 对 N 的星型依赖。这不仅让代码逻辑更加清晰,更极大地提升了系统的可测试性和可维护性。
对于 STM32 这样的单片机项目,只要合理控制内存,C++11 的这一特性绝对是提升代码逼格和质量的神器。

