9、回调函数与库设计:设计一个简单的观察者模式库,使用回调解耦
观察者模式,说白了就是「你盯着我,我变了就通知你」。在嵌入式世界里,这种场景太常见了——按键按下要通知上层、传感器数据更新要分发出去、系统状态切换要广播给各个模块。
我早期做项目时,习惯把所有逻辑写在一个大循环里。结果呢?每次加一个新功能,就要改主循环代码。改着改着,自己都看不懂了。后来我意识到,解耦才是嵌入式软件设计的核心。而回调函数,就是实现解耦最锋利的工具。
9.1 观察者模式的核心思想
观察者模式包含两个角色:
- 主题(Subject):维护一个观察者列表,状态变化时通知所有观察者
- 观察者(Observer):注册到主题上,收到通知后执行自己的逻辑
用回调来实现,就是主题里存一批函数指针。状态变了,挨个调用这些指针。观察者只需要提供自己的回调函数,完全不用关心主题内部怎么运作。
核心价值:主题和观察者之间没有直接依赖。主题不知道观察者是谁,观察者也不知道主题怎么实现。全靠回调这座「桥」来通信。
9.2 设计一个轻量级观察者库
我习惯把这种库设计成极简风格,不依赖动态内存分配,适合资源受限的MCU。下面是我常用的一个模板:
/* observer.h */
#ifndef OBSERVER_H
#define OBSERVER_H
#include <stdint.h>
/* 事件类型定义 */
typedef enum {
EVENT_BUTTON_PRESS,
EVENT_SENSOR_UPDATE,
EVENT_SYSTEM_ERROR,
EVENT_MAX
} event_t;
/* 回调函数类型:参数是事件ID和用户自定义数据 */
typedef void (*observer_cb_t)(event_t event, void *arg);
/* 观察者节点 */
typedef struct {
observer_cb_t callback;
void *arg;
struct observer_node *next;
} observer_node_t;
/* 主题结构体 */
typedef struct {
observer_node_t *head[EVENT_MAX];
} subject_t;
/* 接口函数 */
void subject_init(subject_t *sub);
void subject_register(subject_t *sub, event_t event,
observer_cb_t cb, void *arg);
void subject_unregister(subject_t *sub, event_t event,
observer_cb_t cb, void *arg);
void subject_notify(subject_t *sub, event_t event);
#endif /* OBSERVER_H */
嗯,这里要注意:observer_node_t 里的 next 指针我用了单链表。为什么不用数组?因为嵌入式场景下,观察者数量经常动态变化,链表插入删除更灵活。而且我故意把 arg 设计成 void *,这样观察者可以携带任意上下文。
9.3 实现细节与避坑
实现部分其实不复杂,但有几个坑我踩过,必须说一下。
/* observer.c */
#include "observer.h"
#include <stdlib.h>
void subject_init(subject_t *sub) {
for (int i = 0; i < EVENT_MAX; i++) {
sub->head[i] = NULL;
}
}
void subject_register(subject_t *sub, event_t event,
observer_cb_t cb, void *arg) {
if (event >= EVENT_MAX || cb == NULL) return;
/* 防止重复注册 */
observer_node_t *cur = sub->head[event];
while (cur) {
if (cur->callback == cb && cur->arg == arg) return;
cur = cur->next;
}
/* 头插法,简单高效 */
observer_node_t *node = malloc(sizeof(observer_node_t));
if (node == NULL) return;
node->callback = cb;
node->arg = arg;
node->next = sub->head[event];
sub->head[event] = node;
}
void subject_notify(subject_t *sub, event_t event) {
if (event >= EVENT_MAX) return;
observer_node_t *cur = sub->head[event];
while (cur) {
cur->callback(event, cur->arg);
cur = cur->next;
}
}
我曾经踩过的坑:在回调函数里又去注册或注销观察者,导致链表被修改,遍历指针变成野指针。解决方案有两种:一是用「延迟操作」队列,把注册注销请求攒到通知结束后再处理;二是用双缓冲链表。我个人偏向第一种,实现简单,不容易出bug。
9.4 使用示例:按键事件分发
来看看实际怎么用。假设我们有一个按键模块,按下时触发事件:
/* main.c */
#include "observer.h"
#include <stdio.h>
/* 两个观察者:一个记录日志,一个控制LED */
void log_callback(event_t event, void *arg) {
printf("按键按下,时间戳: %lu\n", *(uint32_t*)arg);
}
void led_callback(event_t event, void *arg) {
/* 假设arg指向LED状态 */
uint8_t *state = (uint8_t*)arg;
*state = !(*state);
printf("LED状态切换为: %d\n", *state);
}
int main() {
subject_t subject;
subject_init(&subject);
uint32_t timestamp = 0;
uint8_t led_state = 0;
/* 注册两个观察者 */
subject_register(&subject, EVENT_BUTTON_PRESS,
log_callback, ×tamp);
subject_register(&subject, EVENT_BUTTON_PRESS,
led_callback, &led_state);
/* 模拟按键按下 */
while (1) {
/* 假设这里检测到按键 */
timestamp = get_system_tick();
subject_notify(&subject, EVENT_BUTTON_PRESS);
/* 延时防抖 */
delay_ms(100);
}
return 0;
}
你看,主循环里只需要调用 subject_notify,具体谁处理、怎么处理,完全由注册的回调决定。想加一个新功能?写个回调函数,注册上去就行。主循环一行代码都不用改。
9.5 观察者模式库的架构图
下面这张图展示了整个库的核心流程。我画的时候特意把「主题」和「观察者」分在两侧,中间用回调函数连接,这样解耦关系一目了然。
9.6 进阶:静态分配版本
有些场景下不能用 malloc,比如在中断里注册观察者,或者系统对实时性要求极高。这时候可以用静态数组代替链表:
/* 静态分配版本 */
#define MAX_OBSERVERS_PER_EVENT 8
typedef struct {
observer_cb_t callbacks[MAX_OBSERVERS_PER_EVENT];
void *args[MAX_OBSERVERS_PER_EVENT];
uint8_t count[EVENT_MAX];
} subject_static_t;
void subject_static_register(subject_static_t *sub,
event_t event,
observer_cb_t cb,
void *arg) {
if (event >= EVENT_MAX || cb == NULL) return;
if (sub->count[event] >= MAX_OBSERVERS_PER_EVENT) return;
uint8_t idx = sub->count[event]++;
sub->callbacks[idx] = cb;
sub->args[idx] = arg;
}
我的建议:如果观察者数量固定且不多(比如不超过8个),用静态数组。代码更简单,没有内存碎片问题。如果观察者数量动态变化,或者需要频繁注册注销,用链表。我个人在量产项目中,80%的情况用静态数组就够了。
9.7 回调机制在库设计中的价值
你想想看,如果没有回调,库和用户代码之间怎么通信?要么库提供注册函数让用户轮询,要么库直接调用用户定义的全局函数。前者增加CPU负担,后者导致强耦合。
回调机制让库设计者可以写出「不知道用户是谁,但知道怎么通知用户」的代码。这就是解耦的精髓。我参与过一个传感器融合库的设计,底层驱动用观察者模式上报原始数据,上层算法模块注册回调接收处理后的结果。整个架构清晰得像搭积木。
| 方案 | 耦合度 | 灵活性 | 内存开销 | 适用场景 |
|---|---|---|---|---|
| 全局函数调用 | 高 | 低 | 极低 | 简单裸机程序 |
| 轮询查询 | 中 | 中 | 低 | 状态机驱动 |
| 回调函数(观察者) | 极低 | 高 | 中 | 模块化设计、RTOS |
| 消息队列 | 极低 | 极高 | 高 | 多任务、复杂系统 |
从表格能看出来,回调方案在灵活性和开销之间取得了很好的平衡。这也是为什么它在嵌入式领域如此流行。
好了,关于观察者模式库的设计就聊到这里。记住一句话:回调是解耦的钥匙,观察者是回调的舞台。下次你写库的时候,不妨试试这个模式,你会发现代码的可维护性提升一个档次。