回调与设计模式:策略模式、观察者模式、命令模式中的回调
说实话,很多嵌入式工程师学设计模式时,总觉得这东西是Java、C++那帮搞大型软件的人玩的。我当年也这么想。直到有一次,我在一个智能家居网关项目里,被一堆if-else和状态切换搞得焦头烂额……嗯,从那以后我才明白,设计模式不是花架子,它解决的是代码组织的问题。而回调,恰恰是这些模式在C语言里的“灵魂粘合剂”。
今天咱们就聊聊三个经典模式——策略模式、观察者模式、命令模式。看看回调是怎么在它们里面扮演核心角色的。
1. 策略模式:把“算法”变成回调
策略模式,说白了就是“把不同的做法封装起来,让它们可以互相替换”。你想想看,你的设备可能支持多种通信协议:UART、SPI、I2C。每种协议的收发逻辑都不一样,但你不想在业务代码里写一堆switch-case。
这时候回调就派上用场了。我们把每个协议的“发送”和“接收”函数,注册成回调。业务层只管调用回调,不管底层是谁在干活。
// 定义策略接口(函数指针类型)
typedef void (*send_func_t)(const uint8_t* data, uint32_t len);
typedef void (*recv_func_t)(uint8_t* buf, uint32_t len);
// 策略结构体
typedef struct {
send_func_t send;
recv_func_t recv;
} comm_strategy_t;
// 具体策略:UART
void uart_send(const uint8_t* data, uint32_t len) {
// UART硬件发送逻辑
}
void uart_recv(uint8_t* buf, uint32_t len) {
// UART硬件接收逻辑
}
comm_strategy_t uart_strategy = { uart_send, uart_recv };
// 具体策略:SPI
void spi_send(const uint8_t* data, uint32_t len) {
// SPI硬件发送逻辑
}
void spi_recv(uint8_t* buf, uint32_t len) {
// SPI硬件接收逻辑
}
comm_strategy_t spi_strategy = { spi_send, spi_recv };
// 业务层:只管调用回调
void process_data(comm_strategy_t* strategy, const uint8_t* data, uint32_t len) {
strategy->send(data, len);
uint8_t response[64];
strategy->recv(response, sizeof(response));
// 处理响应...
}
我的经验: 我在一个多协议传感器采集项目里用过这个模式。当时要支持Modbus RTU(串口)和Modbus TCP(以太网)。如果不用策略模式,业务代码里到处都是if(protocol == UART)……那代码维护起来,简直是一场噩梦。
2. 观察者模式:回调就是“通知”本身
观察者模式,核心就是“一对多”的通知机制。一个事件发生了,多个订阅者要收到通知。在C语言里,这个“通知”就是回调函数。
我习惯用一个简单的“事件总线”来实现。每个观察者注册一个回调,事件发生时,遍历回调列表,挨个调用。
#define MAX_OBSERVERS 10
typedef void (*observer_cb_t)(uint32_t event_id, void* context);
typedef struct {
observer_cb_t callbacks[MAX_OBSERVERS];
void* contexts[MAX_OBSERVERS];
uint8_t count;
} event_bus_t;
void event_bus_init(event_bus_t* bus) {
bus->count = 0;
}
bool event_bus_register(event_bus_t* bus, observer_cb_t cb, void* ctx) {
if (bus->count >= MAX_OBSERVERS) return false;
bus->callbacks[bus->count] = cb;
bus->contexts[bus->count] = ctx;
bus->count++;
return true;
}
void event_bus_notify(event_bus_t* bus, uint32_t event_id) {
for (uint8_t i = 0; i < bus->count; i++) {
bus->callbacks[i](event_id, bus->contexts[i]);
}
}
// 使用示例
void temperature_alarm(uint32_t event_id, void* context) {
if (event_id == EVENT_TEMP_OVER_LIMIT) {
// 触发报警
}
}
event_bus_t bus;
event_bus_init(&bus);
event_bus_register(&bus, temperature_alarm, NULL);
避坑指南: 我曾经在一个实时性要求很高的系统里,让观察者回调里做了大量计算。结果事件通知一多,系统响应就卡顿了。记住:回调里不要做耗时操作。如果必须做,用任务队列把工作丢给后台处理。
3. 命令模式:把“操作”封装成回调
命令模式,就是把一个请求或操作,封装成一个对象。在C语言里,这个“对象”就是一个函数指针加上它的参数。说白了,就是把“你要干什么”和“什么时候干”分离开。
我最常用的场景是“命令队列”。比如一个按键处理模块,按键按下时不是立刻执行操作,而是把操作封装成一个命令,丢到队列里。主循环再从队列里取出来执行。
typedef void (*command_func_t)(void* param);
typedef struct {
command_func_t func;
void* param;
} command_t;
#define CMD_QUEUE_SIZE 16
typedef struct {
command_t buffer[CMD_QUEUE_SIZE];
uint8_t head;
uint8_t tail;
uint8_t count;
} cmd_queue_t;
bool cmd_queue_push(cmd_queue_t* q, command_func_t func, void* param) {
if (q->count >= CMD_QUEUE_SIZE) return false;
q->buffer[q->head].func = func;
q->buffer[q->head].param = param;
q->head = (q->head + 1) % CMD_QUEUE_SIZE;
q->count++;
return true;
}
bool cmd_queue_pop(cmd_queue_t* q, command_t* cmd) {
if (q->count == 0) return false;
*cmd = q->buffer[q->tail];
q->tail = (q->tail + 1) % CMD_QUEUE_SIZE;
q->count--;
return true;
}
// 主循环中执行
void main_loop(cmd_queue_t* q) {
command_t cmd;
while (cmd_queue_pop(q, &cmd)) {
cmd.func(cmd.param);
}
}
核心思想: 命令模式把“调用者”和“执行者”解耦了。调用者只管生成命令(回调+参数),执行者只管按顺序执行。这在多任务系统里特别有用——你可以从一个中断里push命令,在主循环里pop执行。
4. 三种模式的对比
| 模式 | 回调的角色 | 典型场景 | 我的建议 |
|---|---|---|---|
| 策略模式 | 封装算法/行为 | 多协议通信、多种加密算法 | 适合“横向替换”的场景 |
| 观察者模式 | 事件通知 | 按键事件、传感器数据上报 | 注意回调执行时间,别阻塞 |
| 命令模式 | 封装操作请求 | 命令队列、撤销/重做 | 适合“异步执行”或“延迟执行” |
5. 一张图看懂:回调与三种设计模式的关系
下面这张SVG图,展示了回调在这三种模式中的核心位置。你看,回调就像一根线,把不同的设计思想串了起来。
个人习惯: 我一般在项目初期,先画出“哪些地方需要回调”,然后看这些回调适合套用哪种模式。策略模式适合“替换”,观察者模式适合“通知”,命令模式适合“排队”。选对了模式,代码结构会清晰很多。
好了,今天的内容就到这里。这三种模式,说白了都是“回调”在不同场景下的应用变体。你写嵌入式代码时,如果发现某个模块的if-else越来越多,或者事件处理越来越乱,不妨想想:是不是该用个设计模式来整理一下了?