从轮询到回调再到观察者——嵌入式应用层感知底层变化的三种姿势

摘自:一枚嵌入式码农

链接:https://mp.weixin.qq.com/s/uNIOvRU5Aqwq8-cqt_ppAg

目录

做嵌入式开发这些年,有个问题几乎每个项目都会遇到:底层硬件状态变了,应用层怎么知道?

一个真实的场景

假设你在做一个温控系统。底层有个温度传感器驱动,每隔一段时间采集一次温度值。应用层需要根据温度来控制风扇转速------温度高了加速,温度低了减速。

问题来了:应用层怎么拿到最新的温度值?

你可能会说,这还不简单,直接读就行了。没错,但"怎么读"、"什么时候读",这里面的门道其实不少。处理得粗糙,代码能跑但难维护;处理得讲究,代码不仅清晰,后面改需求也不怕。

今天就来聊聊嵌入式开发中,应用层监听底层变化的几种常见做法,从最朴素的到比较优雅的,一步步来。

方式一:轮询------最直觉的笨办法

轮询(Polling),说白了就是应用层主动、反复地去问底层:"数据变了没?变了没?"

就像你等快递,每隔五分钟就打开手机查一次物流。能不能拿到快递?能。累不累?累。

在嵌入式里,轮询大概长这样:

c 复制代码
/* 应用层 - 轮询方式 */
void app_task(void)
{
    static int last_temp = 0;

    while (1) {
        int cur_temp = drv_temp_read();  // 直接调用底层驱动读温度

        if (cur_temp != last_temp) {
            last_temp = cur_temp;
            fan_adjust(cur_temp);         // 温度变了,调整风扇
        }

        delay_ms(100);  // 每100ms查一次
    }
}

这段代码能用吗?当然能用。但问题也很明显:

  • 应用层和驱动层紧耦合------app_task 直接调用了 drv_temp_read(),如果换个传感器,应用层也得改。
  • CPU在空转------大部分时间温度根本没变,但你还是在不停地读。
  • 实时性靠缘分------如果轮询间隔是100ms,那最坏情况下你要等99ms才能发现变化。

用一张图来看轮询的工作方式:

轮询适合什么场景? 数据变化频率高、对实时性要求不高、系统简单的情况。比如一个只有几个外设的裸机系统,轮询完全够用,没必要搞复杂。

但如果你的系统有十几个底层模块都需要监听,全用轮询,主循环就会变成一坨"查询大杂烩",维护起来头疼。

方式二:回调函数------"别找我,有事我叫你"

既然轮询是"应用层主动去问",那能不能反过来?底层数据变了,主动通知应用层。

这就是回调(Callback)的思路。换成快递的例子:你不用一直刷手机了,快递到了快递员直接给你打电话。

实现上,应用层把一个函数(回调函数)注册给底层驱动,当底层检测到数据变化时,直接调用这个函数。

c 复制代码
/* ---------- 驱动层 ---------- */
typedef void (*temp_callback_t)(int temp);

static temp_callback_t g_cb = NULL;

// 应用层调用此函数注册回调
void drv_temp_register_cb(temp_callback_t cb)
{
    g_cb = cb;
}

// 驱动内部:中断或定时采集后调用
void drv_temp_isr(void)
{
    int temp = read_sensor_hw();
    if (g_cb) {
        g_cb(temp);  // 数据变了,通知应用层
    }
}
c 复制代码
/* ---------- 应用层 ---------- */
void on_temp_changed(int temp)
{
    fan_adjust(temp);  // 收到通知,直接处理
}

void app_init(void)
{
    drv_temp_register_cb(on_temp_changed);  // 注册回调
}

看一下回调方式的交互流程:

和轮询相比,回调有几个明显的好处:

  • 实时性好------数据一变就通知,不用等轮询间隔。
  • CPU不白忙------没有变化时,应用层可以安心做别的事。
  • 耦合降低了一些------应用层不需要知道驱动内部怎么采集数据。

但回调也不是没毛病:

  • 驱动层要"认识"应用层的函数签名------虽然用了函数指针解耦,但驱动层还是得定义回调类型,双方要约定好接口。
  • 只能注册一个回调------上面的例子只存了一个 g_cb,如果风扇模块和报警模块都想监听温度变化呢?你得改成数组,管理起来就复杂了。
  • 中断上下文问题------如果回调在中断里执行,处理逻辑不能太重,否则影响系统实时性。

回调函数在实际项目中用得非常多,尤其是驱动和中间件之间的交互。但当"一个事件需要通知多个模块"的需求出现时,简单的回调就显得力不从心了。

方式三:观察者模式------一处变化,多方响应

继续用快递的比喻:这次不是你一个人等快递,而是你、你室友、你女朋友都在等同一个包裹。快递员不可能一个个打电话,最好的办法是大家都"订阅"了物流通知,包裹一到,所有人同时收到短信。

这就是**观察者模式(Observer Pattern)**的核心思想:被观察的对象维护一个订阅者列表,状态变化时遍历列表逐个通知。

在嵌入式里,我们可以用C语言这样实现:

c 复制代码
/* ---------- 观察者框架 ---------- */
typedef void (*observer_func_t)(int value);

#define MAX_OBSERVERS  8

typedef struct {
    observer_func_t observers[MAX_OBSERVERS];
    int count;
} subject_t;

void subject_init(subject_t *sub)
{
    sub->count = 0;
}

// 订阅:把自己的处理函数加入列表
void subject_attach(subject_t *sub, observer_func_t func)
{
    if (sub->count < MAX_OBSERVERS) {
        sub->observers[sub->count++] = func;
    }
}

// 通知:遍历列表,逐个调用
void subject_notify(subject_t *sub, int value)
{
    for (int i = 0; i < sub->count; i++) {
        sub->observers[i](value);
    }
}
c 复制代码
/* ---------- 驱动层 ---------- */
static subject_t temp_subject;

void drv_temp_init(void)
{
    subject_init(&temp_subject);
}

// 提供给应用层的订阅接口
void drv_temp_subscribe(observer_func_t func)
{
    subject_attach(&temp_subject, func);
}

void drv_temp_isr(void)
{
    int temp = read_sensor_hw();
    subject_notify(&temp_subject, temp);  // 通知所有订阅者
}
c 复制代码
/* ---------- 应用层 ---------- */
// 风扇模块
void fan_on_temp_changed(int temp) {
    fan_adjust(temp);
}

// 报警模块
void alarm_on_temp_changed(int temp) {
    if (temp > 80) alarm_trigger();
}

// 显示模块
void display_on_temp_changed(int temp) {
    lcd_show_temp(temp);
}

void app_init(void)
{
    drv_temp_subscribe(fan_on_temp_changed);
    drv_temp_subscribe(alarm_on_temp_changed);
    drv_temp_subscribe(display_on_temp_changed);
}

来看看观察者模式下的交互流程:

观察者模式的优势非常明显:

  • 一对多通知------一个事件源可以同时通知任意多个模块,新增模块只需 subscribe 一行代码。
  • 彻底解耦------驱动层完全不知道谁订阅了自己,应用层各模块之间也互不干扰。
  • 扩展性强------后面如果要加个"数据记录模块",只需要写个新函数然后 subscribe,其他代码一行不用动。

当然,在嵌入式场景下也有需要注意的地方:

  • 订阅者数组大小是固定的(MAX_OBSERVERS),要根据实际需求设定。
  • 通知的执行顺序就是订阅顺序,如果对顺序有要求需要额外处理。
  • 同样要注意中断上下文的问题,必要时在通知函数中只做标记,把真正的处理放到任务里。

三种方式横向对比

说了这么多,到底该用哪种?没有银弹,看场景。

画个整体的架构对比图:

我在实际项目中的经验是:别一上来就用最复杂的方案。如果你的系统只有两三个模块需要通信,回调就足够了。但如果你发现自己在不同地方写了好几个回调注册函数,而且它们监听的是同一个事件,那就是该考虑观察者模式的时候了。

再往前一步:消息队列与事件总线

其实如果你用的是RTOS,还有一种更"正式"的方式------消息队列。底层把数据变化封装成一条消息丢进队列,应用层从队列里取消息处理。这样做的好处是天然支持异步处理,不用担心中断上下文的问题。

c 复制代码
/* RTOS消息队列方式(伪代码) */

// 驱动层:发送消息
void drv_temp_isr(void)
{
    msg_t msg = { .type = MSG_TEMP, .value = read_sensor_hw() };
    queue_send(&app_queue, &msg);  // 丢进队列就完事
}

// 应用层:等待消息
void app_task(void *arg)
{
    msg_t msg;
    while (1) {
        queue_recv(&app_queue, &msg, WAIT_FOREVER);  // 阻塞等待
        switch (msg.type) {
            case MSG_TEMP:  fan_adjust(msg.value); break;
            case MSG_KEY:   handle_key(msg.value);  break;
            // ...
        }
    }
}

再进一步抽象,你甚至可以实现一个事件总线(Event Bus),所有模块都通过总线发布和订阅事件,彼此完全不知道对方的存在。不过这就超出今天的讨论范围了。

最后

回顾一下,从轮询到回调再到观察者,本质上是一个解耦的过程:

  1. 轮询------应用层主动拉取,简单直接,但耦合度高。
  2. 回调------底层主动推送,实时性好,但扩展性有限。
  3. 观察者------订阅-通知机制,解耦彻底,支持一对多。

你会发现,这些方案背后其实都有经典的软件设计思想在支撑。观察者模式本身就是 GoF 23种设计模式之一,而回调本质上是策略模式的一种简化形式,消息队列则和中介者模式有异曲同工之妙。

嵌入式开发不像互联网应用那样有成熟的框架帮你把设计模式"包好",很多时候你得自己动手搭。如果你能系统地掌握设计模式,很多看起来"棘手"的架构问题,其实前人早就给出了漂亮的解法。

相关推荐
知无不研2 小时前
中介者模式
c++·设计模式·中介者模式
crescent_悦2 小时前
PTA C++:正整数A+B
数据结构·c++·算法
YYYing.2 小时前
【Linux/C++多线程篇(一) 】多线程编程入门:从核心概念到常用函数详解
linux·开发语言·c++·笔记·ubuntu
一起搞IT吧2 小时前
Android功耗系列专题理论之十六:功耗不同阶段&不同模块分析说明
android·c++·智能手机·性能优化
荣光属于凯撒2 小时前
P15755 [JAG 2025 Summer Camp #1] JAG Box
c++·算法·贪心算法
郝学胜-神的一滴2 小时前
CMake:解锁C++跨平台工程构建的核心密钥
开发语言·c++·职场和发展
佑白雪乐3 小时前
C++标准总结+VSCode使用+MinGW
开发语言·c++·vscode
仰泳的熊猫3 小时前
题目2269:蓝桥杯2016年第七届真题-冰雹数
开发语言·数据结构·c++·算法·蓝桥杯
Yungoal3 小时前
C++流类继承关系
开发语言·c++