第 4 篇:Embassy 异步运行时与事件驱动架构

📅 2026/7/23 3:24:15
第 4 篇:Embassy 异步运行时与事件驱动架构
第 4 篇Embassy 异步运行时与事件驱动架构用 Rust 构建ESP32-C3 电子墨水屏阅读器 · 系列文章https://github.com/longxiangam/epd-reader引言本项目要同时做很多事刷电子墨水屏、读按键、收发网络包、计时、采样电池。这些并发由 Embassy 组织——一个基于 Rust async/await 的异步运行时。显示渲染、事件检测、网络通信、时间同步各自是一个 Embassy 任务。本文讲 Embassy 在单核 ESP32-C3 上怎么调度这些任务以及按键事件怎么从 GPIO 电平变化一步步变成页面响应。1. Embassy 是什么1.1 核心思想Embassy 的全称是 “Embedded Async”它用 Rust 的async/await语法实现了一个轻量级的任务调度器。核心思想很简单每个async fn是一个任务任务通过.await主动让出 CPU协作式调度没有抢占没有上下文切换的开销不需要互斥量保护临界区1.2 启动 Embassy在 ESP32-C3 上Embassy 通过esp-rtos集成// src/main.rs:86-89lettimg0TimerGroup::new(peripherals.TIMG0);letsw_intSoftwareInterruptControl::new(peripherals.SW_INTERRUPT);esp_rtos::start(timg0.timer0,sw_int.software_interrupt0);esp_rtos::start配置了 Embassy 执行器使用的硬件定时器和软件中断。ESP32-C3 的 Timer Group 0 (TIMG0) 提供精确的时间基准软件中断用于唤醒挂起的任务。之后入口宏#[esp_rtos::main]会创建 Embassy 执行器并将main函数作为第一个任务运行// src/main.rs:78-79#[esp_rtos::main]asyncfnmain(spawner:Spawner)-!{Spawner是任务的启动器——通过它来创建新的异步任务。1.3 任务创建Embassy 的任务用#[embassy_executor::task]宏标注// src/display.rs:80-86#[embassy_executor::task]pubasyncfnrender(mutspi_device:staticmutActualSpistatic,busy:esp_hal::peripherals::GPIO6static,rst:esp_hal::peripherals::GPIO7static,dc:esp_hal::peripherals::GPIO20static,){// ...}在main中通过spawner.spawn启动// src/main.rs:173spawner.spawn(display::render(spi_bus_epd,epd_busy,epd_rst,epd_dc)).unwrap();注意任务参数必须拥有static生命周期。这是因为任务一旦启动它可能在任何时候运行它的参数必须比整个程序的运行时间活得更长。在嵌入式#![no_std]环境中static是唯一的永久生命周期。1.4 本项目的 Embassy 任务全景任务文件职责display::renderdisplay.rsEPD 渲染循环等待 Channel驱动 SPIevent::runevent.rs按键检测与事件分发battery::test_bat_adcbattery.rs电池 ADC 采样60 秒周期worldtime::ntp_workerworldtime.rsNTP 同步 定时拉取天气/节假日pages::main_taskpages/mod.rs主界面事件循环connection_wifiwifi.rsWiFi STA 连接状态管理net_taskwifi.rs网络协议栈 Runnerdo_stopwifi.rsWiFi 自动关闭30 秒超时dhcp_servicewifi.rsAP 模式 DHCP 服务dns_servicewifi.rsAP 模式 DNS 劫持2. 协作式调度如何工作2.1 .await 的本质在 Embassy 中每个.await都是一个让出点。当任务执行到.await时如果等待的条件不满足任务会保存自己的状态栈帧将控制权交还给执行器。执行器会选择下一个就绪的任务运行。// 等待 100 毫秒Timer::after(Duration::from_millis(100)).await;// 让出 CPU100ms 后恢复// 等待 Channel 消息letmsgreceiver.receive().await;// 让出 CPU有消息时恢复// 等待 GPIO 下降沿key1.wait_for_falling_edge().await;// 让出 CPU电平变化时恢复2.2 select多事件竞争embassy_futures::select::select允许同时等待多个 Future谁先完成就处理谁// src/event.rs:104-116loop{letkey1_edgekey1.wait_for_falling_edge();letkey2_edgekey2.wait_for_falling_edge();matchselect(key1_edge,key2_edge).await{First(_){key_detection::_,1(mutkey1).await;}Second(_){key_detection::_,2(mutkey2).await;}}refresh_active_time().await;Timer::after(Duration::from_millis(10)).await;}两个按键谁被按下就处理谁。select不会阻塞在第一个等待上——它同时监听两个事件。同样在渲染任务中// src/display.rs:113-115letrender_signreceiver.receive();// 等待渲染请求letquickly_lutquickly_lut_receiver.receive();// 等待刷新模式切换matchselect(render_sign,quickly_lut).await{// 处理先到的那个}2.3 协作式调度的特点Embassy 是协作式调度任务只在.await让出时才会切换不会被中途抢占。由此带来两个特点两段不.await的代码天然是原子的访问普通共享数据不需要加锁访问static mut仍需CriticalSectionRawMutex。任务不分配独立栈共享一个栈每个任务自身只占很少内存。代价是任务必须自觉.await。如果某个任务跑死循环不让出其它任务就永远得不到执行机会——所以每个任务循环里都得有.await。3. 事件系统设计3.1 事件类型本项目定义了丰富的事件类型覆盖所有按键交互模式// src/event.rs:18-27pubenumEventType{KeyShort(u32),// 短按参数是按键编号KeyLongStart(u32),// 长按开始KeyLongIng(u32),// 长按持续中节流为每 100ms 触发一次KeyLongEnd(u32),// 长按释放KeyDouble(u32),// 双击WheelBack,// 滚轮后退预留WheelFront,// 滚轮前进预留}事件携带的信息// src/event.rs:30-32pubstructEventInfo{pubptr:Optionusize,// 注册时绑定的对象指针}3.2 发布-订阅机制事件系统的核心是一个全局监听器列表// src/event.rs:38-45structListener{callback:BoxdynFnMut(EventInfo)-PinBoxdynFutureOutput()staticSendSyncstatic,event_type:EventType,ptr:Optionusize,// 绑定的对象指针fixed:bool,// 是否常驻}staticLISTENER:MutexCriticalSectionRawMutex,VecListener,20Mutex::new(Vec::new());这个类型签名看起来很复杂但拆解后逻辑很清晰callback一个闭包接收EventInfo返回一个Futureevent_type监听的事件类型ptr可选的对象指针用于事件与对象的绑定fixed标记是否在clear()时保留3.3 三种注册方式// 一次性监听clear() 时会被清除pubasyncfnonF(event_type:EventType,callback:F)// 绑定到特定对象的监听pubasyncfnon_targetF(event_type:EventType,target_ptr:usize,callback:F)// 常驻监听clear() 时不会被清除pubasyncfnon_fixedF(event_type:EventType,target_ptr:usize,callback:F)为什么需要三种这与页面系统的生命周期有关on()页面进入时注册页面退出时通过clear()统一清除。大多数事件监听用这个。on_target()同上但绑定了特定对象的指针。在事件回调中可以通过ptr找到对应的对象。on_fixed()常驻监听不受clear()影响。用于全局性的功能如睡眠检测。3.4 事件分发当事件触发时遍历所有监听器匹配事件类型并调用回调// src/event.rs:83-94pubasyncfntoggle_event(event_type:EventType,_ms:u64){letmutvecLISTENER.lock().await;forlistenerinvec.iter_mut(){iflistener.event_typeevent_type{(listener.callback)(EventInfo{ptr:listener.ptr}).await;}}}注意事件分发是同步的——所有匹配的回调会依次执行完毕后才返回。这在协作式调度下是安全的因为没有抢占。3.5 页面中的事件绑定以主页面为例展示事件绑定的实际使用// src/pages/main_page.rs:126-168asyncfnbind_event(mutself){event::clear().await;// 清除上一个页面的事件监听// Key2 短按 → 下移光标event::on(EventType::KeyShort(2),move|_info|{Box::pin(async{Self::get_mut().await.unwrap().increase();})}).await;// Key1 短按 → 上移光标event::on(EventType::KeyShort(1),|_info|{Box::pin(async{Self::get_mut().await.unwrap().decrease();})}).await;// Key3 短按 → 进入子页面event::on(EventType::KeyShort(3),|_info|{Box::pin(async{letmut_refSelf::get_mut().await.unwrap();mut_ref.current_pageSome(mut_ref.choose_index);})}).await;}这里有一个特殊的模式闭包内部通过Self::get_mut().await获取页面的可变引用。这是因为 Rust 闭包不能直接捕获mut self生命周期问题所以通过全局静态变量间接访问// src/pages/main_page.rs:77-82pubasyncfnget_mut()-OptionstaticmutMainPage{unsafe{letptr:*mutMainPageMAIN_PAGE.lock().await.as_mut().unwrap()as*mutMainPage;Some(mut*ptr)}}这是一个unsafe操作但在本项目的协作式调度下是安全的——因为同一时间只有一个任务在处理事件不会出现并发访问。4. 按键检测从电平到事件4.1 多按键检测本项目的三个物理按键中Key2 和 Key3 通过 ADC 分压共用 GPIO2。硬件上两个按键串联不同阻值的电阻到地GPIO2 ──┬──[R1]── Key2 ── GND └──[R2]── Key3 ── GND按下 Key2 时 ADC 读到低电压按下 Key3 时读到较高电压都不按时读到最高电压。4.2 按键状态机key_detection函数实现了一个状态机区分短按、长按、双击// src/event.rs:129-200简化流程asyncfnkey_detectionP,constNUM:usize(key:mutP){constLONG_ING_INTERVAL_MS:u64100;// KeyLongIng 节流间隔letbegin_msInstant::now().as_millis();letmutlast_long_ing_msbegin_ms;letmutis_longfalse;letmutkey_numNUM;// Key2 需要通过 ADC 判断是 Key2 还是 Key3ifNUM2{key_numjudge_adc_num().await;ifkey_num0{return;}// ADC 值太高不是按键}loop{// 采样 100 次判断电平letis_low_times/* 采样 */;ifis_low_times80{// 按键处于按下状态letcurrentInstant::now().as_millis();ifcurrent-begin_ms500{if!is_long{is_longtrue;last_long_ing_mscurrent;toggle_event(EventType::KeyLongStart(key_num)).await;}elseifcurrent-last_long_ing_msLONG_ING_INTERVAL_MS{last_long_ing_mscurrent;// 每 100ms 才派发一次避免 ~1kHz 空转耗电toggle_event(EventType::KeyLongIng(key_num)).await;}}}elseifis_low_times2{// 按键释放ifis_long{toggle_event(EventType::KeyLongEnd(key_num)).await;return;}else{// 短按等待看是否双击ifenable_double_click{// 等待 400ms如果再次按下则触发双击if/* 再次按下 */{toggle_event(EventType::KeyDouble(key_num)).await;}else{toggle_event(EventType::KeyShort(key_num)).await;}}else{toggle_event(EventType::KeyShort(key_num)).await;}return;}}Timer::after(Duration::from_millis(1)).await;}}状态机可以图示为按下 [空闲] ─────────────► [按下中] ▲ │ │ ├── 500ms 释放 ──► [短按判定] │ │ ├── 400ms 内再按 ──► KeyDouble │ │ └── 超时 ──► KeyShort │ │ │ └── 500ms ──► [长按中] │ │ │ ├── KeyLongStart首次 │ ├── KeyLongIng持续 │ └── 释放 ──► KeyLongEnd │ │ └──────────────────────────────────────────────────────┘采样 100 次取多数的消抖策略80/100 为阈值比传统的延时消抖更可靠。每次循环通过Timer::after(1ms).await让出 CPU不会阻塞其他任务。4.3 ADC 按键识别ESP32-C3 的read_oneshot在冷启动 / 通道切换时经常返回Err必须容忍——只采到Ok的样本计入平均但仅在总尝试次数超限时才放弃避免卡死事件任务// src/event.rs简化asyncfnjudge_adc_num()-usize{constSAMPLES:usize20;constMAX_TRIES:usize200;constAVG_KEY2:u32200;// 200 → Key2constAVG_NONE:u321000;// 1000 → 无按键上拉到 VCCletmutneedSAMPLES;letmutsum:u320;letmuttries0usize;whileneed0{tries1;iftriesMAX_TRIES{return0;}// 持续 Err 超限放弃避免卡死ifletSome(pin)ADC_PIN.lock().await.as_mut(){ifletSome(adc)ADC_PER.lock().await.as_mut(){matchadc.read_oneshot(pin){Ok(v){sumvasu32;need-1;}Err(_){Timer::after_millis(2).await;}// 偶发 Err等 2ms 重试}}}}letavgsum/SAMPLESasu32;ifavgAVG_NONE{0}elseifavgAVG_KEY2{2}else{3}}20 次有效采样取平均值阈值 200 区分 Key2低电压和 Key3较高电压超过 1000 视为无按键返回 0。电池采样battery.rs采用同样的出错重试、超限放弃策略上限 50 次保证 ADC 偶发错误不会让按键或电量检测整体失效。5. Embassy 同步原语本项目使用了 Embassy 提供的三种同步原语各有适用场景5.1 Channel异步消息传递用于生产者-消费者模式pubstaticRENDER_CHANNEL:ChannelCriticalSectionRawMutex,RenderInfo,64Channel::new();容量 64足够缓冲多个渲染请求发送端不会阻塞除非满了接收端通过.receive().await等待新消息5.2 Signal一次性信号用于简单的通知场景pubstaticSTOP_WIFI_SIGNAL:SignalCriticalSectionRawMutex,()Signal::new();Signal 只保留最新的值适合开关式的通知启动 WiFi、停止 WiFi、重新连接等。5.3 Mutex异步互斥锁用于保护共享数据pubstaticWIFI_INFO:MutexCriticalSectionRawMutex,OptionWifiStorageMutex::new(None);lock().await在锁可用时立即返回否则让出 CPU 等待。由于是协作式调度不存在死锁的风险——只要每个任务在持有锁时不忘记.await或者不在持锁期间做长时间操作。6. 完整的事件流从按键到屏幕更新的完整数据流物理按键按下 │ GPIO 电平变化 │ event::run() — wait_for_falling_edge().await │ key_detection() — ADC 采样 → 判断按键编号 │ toggle_event() — 遍历 LISTENER匹配事件类型 │ Listener callback — 修改页面状态如 choose_index 1 │ 页面 render() — 绘制到帧缓冲区 │ RENDER_CHANNEL.send() — 发送渲染请求 │ display::render() — 接收请求驱动 SPI 刷新屏幕 │ EPD 显示新画面这条链路贯穿了本项目的三个核心层次事件层 → 页面层 → 显示层每一层都通过 Embassy 的异步机制解耦。小结技术点要点Embassy 执行器esp_rtos::start#[esp_rtos::main]任务创建#[embassy_executor::task]spawner.spawn()协作式调度.await让出 CPU无抢占无临界区select同时等待多个 Future谁先就绪处理谁事件系统发布-订阅 三种注册模式on/on_target/on_fixed按键检测100 次采样消抖 状态机 ADC 多按键同步原语Channel消息、Signal通知、Mutex共享数据Embassy 的设计哲学是用 Rust 的类型系统和 async/await 语法在编译期保证并发安全在运行时实现高效的协作式调度。这让嵌入式开发既安全又高效。在下一篇文章中我们将进入 WiFi 网络的世界看看 ESP32-C3 如何在 400KB SRAM 的限制下跑起完整的 TCP/TLS 协议栈。