尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

嵌入式应用层感知底层变化:轮询、回调、观察者三种姿势对比

嵌入式应用层感知底层变化:轮询、回调、观察者三种姿势对比 上一篇讲工厂模式把创建驱动和使用驱动分开这篇接着聊驱动和应用之间的另一条线底层硬件状态变了应用层怎么知道做嵌入式这些年这个问题几乎每个项目都绕不开。温度传感器读到新值了按键被按下了串口收到一帧数据了电池电压跌了底层一直在变应用层得想办法跟上。跟上得不好要么反应慢半拍要么 CPU 白忙活要么代码改一处崩三处。我见过三种典型写法从最直觉的轮询到回调再到观察者本质是一个逐步解耦的过程。这篇把这三种姿势掰开揉碎讲清楚每种适合什么场景、坑在哪、什么时候该升级。方式一轮询最直觉的笨办法轮询说白了就是应用层主动、反复地去问底层数据变了没变了没/* 应用层 - 轮询方式 */ 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查一次 */ } }这种写法我刚入行时写了一堆因为它太符合直觉了我要温度就读一下变了就处理没变就等会儿再读。逻辑直白调试也简单断点打在if里就能看到每次读到的值。但它的代价也实在。第一应用层和驱动层紧耦合app_task直接调drv_temp_read换驱动或换芯片应用层跟着改。第二CPU 在空转就算温度一整天没变每 100ms 也得醒一次读一遍低功耗场景下这是致命的MCU 本来可以睡到几十微安被轮询活活拉到毫安级。第三实时性靠缘分温度在第 101ms 变了你得等到第 200ms 那次轮询才发现最坏延迟一个轮询周期。轮询适合什么数据变化频率高、对实时性要求不高、系统简单的场景。比如一个简单的工业仪表就显示个温度湿度没什么复杂逻辑轮询完全够用简单可靠。但系统一旦复杂起来轮询就开始捉襟见肘。轮询还有个经典坑去抖。温度在阈值附近抖动比如设定 80 度开风扇实际温度在 79.5 和 80.5 之间晃轮询就会让风扇忽开忽关继电器咔咔响寿命大打折扣。光靠轮询没法解决得加滞回区间或者滤波代码复杂度立刻上来。很多人骂轮询其实一半是骂它本身一半是骂它逼着你后面补一堆补丁。方式二回调别找我有事我叫你轮询是应用层主动拉回调是底层主动推。底层数据变了主动通知应用层。/* ---------- 驱动层 ---------- */ 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); /* 数据变了通知应用层 */ } } /* ---------- 应用层 ---------- */ void on_temp_changed(int temp) { fan_adjust(temp); /* 收到通知直接处理 */ } void app_init(void) { drv_temp_register_cb(on_temp_changed); /* 注册回调 */ }回调的好处立竿见影。实时性好数据一变就通知不用等下一个轮询周期CPU 不白忙没事就睡有事被中断唤醒耦合也降低了一些应用层不再直接调驱动读数据而是注册一个函数等通知。但回调有两个硬伤。第一只能注册一个回调。g_cb是个单变量后注册的会覆盖先注册的。温度变了既要调风扇、又要触发报警、还要刷新显示三个模块都要响应单回调就抓瞎了。你当然可以写一个总回调在里面手动分发但那本质就是在应用层手搓观察者不如直接用观察者。第二中断上下文问题。drv_temp_isr是中断g_cb(temp)在中断里被调用意味着on_temp_changed也在中断里跑。如果on_temp_changed里调了fan_adjust而fan_adjust内部有延时、有阻塞、有锁、有耗时计算整个系统就埋了雷。中断本该快进快出在里面干重活是大忌。正确的做法是中断里只做最轻的事把值存下来、发个信号、投个消息真正的处理放到任务上下文。这就引出了异步事件机制中断里只投递不干活。回调本身没错错的是把重活塞进回调里跑。回调适合单订阅者、处理轻量、对实时性敏感的场景。比如一个传感器数据就一个模块消费回调函数里就更新个变量或发个信号这种场景回调干净利落。方式三观察者一处变化多方响应观察者模式解决了回调只能一个的局限。被观察的对象维护一个订阅者列表状态变化时遍历列表逐个通知。/* ---------- 观察者框架 ---------- */ 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); } } /* ---------- 驱动层 ---------- */ 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); /* 通知所有订阅者 */ } /* ---------- 应用层 ---------- */ 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 一个log_on_temp_changed就行驱动层一行不用改。这就是开闭原则在事件通知上的体现。但观察者也不是没坑嵌入式里有两个坑特别容易踩。第一订阅了忘记取消。模块销毁时如果没从订阅者列表里移除自己下次 notify 还会调到那个已经失效的函数指针轻则访问已释放资源重则 HardFault。上面这套简化框架甚至没提供detach接口生产代码里必须补上而且模块销毁路径里必须调。这跟上一篇讲工厂模式的 Mock 替换是同一类问题留了接口就得管生命周期。第二notify 时的迭代安全。subject_notify遍历数组调每个 observer如果某个 observer 在执行时把自己从列表里 detach 了或者 attach 了新的count和数组内容在遍历中变化循环就会越界或漏调。解决办法要么遍历前拷贝一份快照要么用标记删除detach 只置 NULLnotify 跳过 NULL下次整理时压缩后者在嵌入式里更省内存。观察者适合多模块响应同一事件、模块动态增减、需要彻底解耦的场景。温度变化触发多个子系统是典型按键事件、网络状态变化、电量变化也都适合。三种方式横向对比维度轮询回调观察者实时性差依赖轮询间隔好事件驱动好事件驱动CPU效率低空转高高耦合度高中低一对多不支持不支持支持实现复杂度低中中高适用场景简单原型单订阅者多模块响应这张表不是用来背的是用来做选型决策的。我的经验是项目早期、原型阶段轮询先跑起来别一上来就上观察者过度设计比没有设计更坑等明确只有一个消费者且处理很轻回调最省事一旦出现同一个事件要多个模块响应或者预见到后面会加消费者直接上观察者别用总回调手动分发凑合凑合的代码后面重构成本更高。进阶消息队列与事件总线上了 RTOS 之后还有一把更顺手的刀消息队列。底层把数据变化封装成消息丢进队列应用层从队列取消息处理天然支持异步。/* 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; } } }消息队列的好处是把通知和处理彻底分开。中断只管投消息瞬间返回处理在任务上下文慢慢来可以阻塞、可以延时、可以持锁都不影响中断实时性。这正好解决了回调那个中断上下文不能干重活的硬伤。消息队列还能统一多种事件源。温度、按键、串口、网络全封装成msg_t丢进同一个队列应用层一个循环统一处理这就是事件总线的雏形。再进一步抽象把消息类型 处理函数做成注册表就是一套完整的事件驱动框架第 9 篇讲的 while(1) 死循环换活法核心就是这套。消息队列也有它的坑队列满了怎么办中断投消息时队列已满消息就丢了。生产代码里要么用带超时的queue_send配合溢出计数告警要么关键消息用覆盖最旧策略FreeRTOS 的xQueueOverwrite要么队列开大点但占内存。嵌入式没有银弹每个选择都是 RAM 和可靠性的权衡。怎么选给个判断框架聊了这么多落到实际怎么选我给个判断框架按顺序问自己三个问题。第一问系统有 RTOS 吗没有且逻辑简单轮询够用别折腾。有 RTOS直接考虑消息队列异步处理是 RTOS 的本分。第二问同一个事件有几个模块要响应只有一个回调最省事但记住回调里别干重活。有两个以上或者预见到会加观察者。第三问实时性要求多高轮询的延迟下限是轮询周期要求毫秒级响应的轮询基本出局得事件驱动。这三个问题答完基本就能定方案。但还有一条经验别一步到位。从轮询起步遇到痛点再升级到回调再遇到多模块响应的痛点再升级到观察者或消息队列。每一步升级都有明确的触发条件不是拍脑袋。过早引入复杂机制调试和理解的成本会吃掉它带来的好处。最后说一句这三种姿势不是非此即彼。一个稍复杂的系统里轮询、回调、观察者、消息队列往往并存低频的状态用轮询省事单点通知用回调多方响应用观察者跨任务异步用消息队列。知道每种的长处和短处在合适的场景用合适的工具这才是架构能力的体现。下一篇讲嵌入式 C 为什么绕不开面向对象把结构体当类、函数指针当方法这套手法的根再挖深一层。有用的话点个在看让更多被底层变了应用层不知道折磨的嵌入式工程师看到。标签嵌入式 观察者模式 事件驱动 回调 解耦
返回列表