我参加的 “基于协程异步机制的操作系统/驱动” 项目分为 3 个阶段, 在一阶段, 我主要阅读了 tokio-tutorial, 了解了 Rust 中的异步编程常见结构和异步执行方式, 产出了一个爬虫程序, 该爬虫主要对 进程, 线程, 协程 三种实现的并发程序进行相互比较, 进一步让我理解了协程的作用以及和进程/线程的区别。
在第二阶段, 我主要阅读了 embassy 的源码和文档, embassy 有点类似于 tokio, 区别是 embassy 是一个运行在嵌入式裸机上的异步运行时, 而 tokio 则大量使用了标准库(即操作系统)提供的一些方法。我的产出是对 mini-tokio - 一个 tokio-tutorial 里教学用的最小化异步运行时进行了优先级支持, 即每个任务拥有各自的优先级, 优先级高的任务在调度时会被优先执行。
来到了第三阶段, 我主要阅读了 embassy_preempt 的源码, 了解其执行流。 embassy_preempt 实现了协程之间的抢占, 即在定时器发出中断后保存其上下文并切换任务, 由于抢占这件事在现实中发生的次数一般较少, 我们认为这样操作带来的性能损失是可以接受的; 同时, 这也在某种程度上实现了进程和协程的统一: 协程在被抢占时退化为线程, 在再次执行并让权时自然的恢复为协程。我主要的贡献为将 embassy_preempt 从 stm32f401re 移植到 stm32f103c8 开发板上, 并为 embassy_preempt 实现了 Future 的 FFI 化, 即我们可以在手动构造一个 Future 并通过 FFI 传给 embassy_preempt 执行, 给异步任务的构造提供了新的选择。
在从 stm32f401re 到 stm32f103c8 的移植过程中, 我主要做了以下改动:
F103C8 仅有 20KB SRAM 和 64KB Flash,而 F401RE 拥有 96KB SRAM 和 512KB Flash。
1 2 3 4 5 MEMORY { RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K FLASH (rx) : ORIGIN = 0x8000000, LENGTH = 64K }
OS_ARENA_SIZE 定义任务控制块分配器的容量。F401 设置为 10240 字节,可容纳约 80 个任务;F103 的 20KB 总 RAM 使其必须缩小至 4096 字节(约 30 个任务)。APB_HZ 是定时器的输入时钟频率。STM32F103 的最高系统时钟为 72MHz(F401 为 84MHz),该值直接用于计算 TIM3 的预分频器: psc = APB_HZ / TICK_HZ - 1。
1 2 3 4 5 6 7 8 9 #[cfg(feature = "stm32f401re" )] pub const OS_ARENA_SIZE: USIZE = 10240 ;#[cfg(feature = "stm32f103c8" )] pub const OS_ARENA_SIZE: USIZE = 4096 ;#[cfg(feature = "stm32f401re" )] pub const APB_HZ: INT64U = 84000000 ;#[cfg(feature = "stm32f103c8" )] pub const APB_HZ: INT64U = 72000000 ;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 #[cfg(feature = "stm32f103c8" )] pub const STACK_START: *mut u8 = 0x20001400 as *mut u8 ;#[cfg(feature = "stm32f103c8" )] pub const STACK_SIZE: usize = 6 * 1024 ;#[cfg(feature = "stm32f103c8" )] pub const PROGRAM_STACK_SIZE: usize = 1024 ;#[cfg(feature = "stm32f103c8" )] pub const INTERRUPT_STACK_SIZE: usize = 1024 ;#[cfg(feature = "stm32f103c8" )] pub const HEAP_START: *mut u8 = 0x20002C00 as *mut u8 ;#[cfg(feature = "stm32f103c8" )] pub const HEAP_SIZE: usize = 2 * 1024 ;
一开始我编译运行后 BSS 段实际占用 0x20000038-0x200011EC(约 4.5KB), 而 STACK_START 设于 0x20001000,正处于 BSS 范围内。init_stack_allocator() 初始化时通过 FixedSizeBlockAllocator 在 0x20001000 处建立链表元数据, 随后 __aeabi_memclr8 运行 BSS 清零,将已分配的元数据结构覆盖为全零, 导致后续 alloc_stack() 返回空指针或访问损坏的链表, 触发 BusFault → HardFault。移至 0x20001400 后解决。
STACK_SIZE 影响系统可支持的最大并发抢占数, PROGRAM_STACK(1KB) + INTERRUPT_STACK(1KB) 固定消耗 2KB, 剩余 (STACK_SIZE - 2KB) / TASK_STACK_SIZE 为最大同时被抢占的任务数。以 6KB 栈池和 1KB 每任务计算,可支持 4 个任务同时处于被抢占状态。preempt_test 需要 3 个抢占栈,最初的 4KB 设置仅余 2KB(2 个栈), 在分配第 3 个时栈池返回空指针导致 panic, 需扩容至 6KB。
HEAP_START 和 HEAP_SIZE 需紧随 STACK 末尾,避免中间产生间隙浪费 SRAM。2KB 足以满足 lazy_static 和 arena 之外的零星 alloc 需求。
F103 与 F401 的 RCC 外设属于完全不同的两代设计, F401 将 PLL 配置置于独立寄存器 RCC.pllcfgr(),包含 PLLM/PLLN/PLLP/PLLQ 四个分频系数,源自 F4 系列为 USB/SDIO/I2S 等多种外设提供独立时钟的需求。F103 的 PLL 参数直接集成在 RCC.cfgr() 中,仅有一个倍频系数 PLLMUL, 因为 F1 系列只需为 CPU 和简单外设提供时钟。此外 F401 拥有 PLLI2S 和 PLLSAI 且需在启动时显式禁用, F103 则完全没有这些外设。Flash 等待周期也不同: F401 使用 set_prften() 使能预取缓冲, F103 对应方法名为 set_prftbe()。
TIM3 定时器在两种芯片上使用了相同的 timer_v1.rs PAC 模块和 TIM_GP16 类型,因此 enable_Timer() 及所有定时器操作代码无需修改。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 #[cfg(feature = "stm32f103c8" )] fn rcc_init () { use stm32_metapac::rcc::vals::{Pllmul, Pllsrc, Pllxtpre}; RCC.cr().modify(|v| { v.set_hseon(true ); }); while !RCC.cr().read().hserdy() {} RCC.cfgr().modify(|v| { v.set_pllsrc(Pllsrc::HSE_DIV_PREDIV); v.set_pllxtpre(Pllxtpre::DIV1); v.set_pllmul(Pllmul::MUL9); }); FLASH.acr().modify(|v| { v.set_latency(Latency::WS2); v.set_prftbe(true ); }); RCC.cr().modify(|v| { v.set_pllon(true ); }); while !RCC.cr().read().pllrdy() {} RCC.cfgr().modify(|v| { v.set_hpre(Hpre::DIV1); v.set_ppre1(Ppre::DIV2); v.set_ppre2(Ppre::DIV1); }); RCC.cfgr().modify(|v| v.set_sw(Sw::PLL1_P)); while RCC.cfgr().read().sws() != Sw::PLL1_P {} }
F401 和 F103 的 GPIO 是两代完全不同的外设设计。F401 使用 4 个独立寄存器分别配置每个引脚的模式 MODER、输出类型 OTYPER、速度 OSPEEDR和上下拉 PUPDR, 每个寄存器为 16 个引脚各分配 2 位。F103 将所有配置压缩在 CRL/CRH 两个寄存器中: 每个引脚占 4 位, 低 2 位为 MODE(输入/2MHz/10MHz/50MHz), 高 2 位为 CNF(推挽/开漏/复用功能)。CRL 管理引脚 0-7, CRH 管理引脚 8-15。
此外, 时钟总线也不同: F401 的 GPIO 挂在 AHB1 总线上(RCC.ahb1enr()), F103 的 GPIO 挂在 APB2 上(RCC.apb2enr())。
F103 实现使用 core::ptr::write_volatile 直接操作硬件地址而非 PAC 抽象, 原因之一是调试 LED 问题时需要绕过 PAC 逐层验证寄存器写入是否生效, 二是在两个芯片的 PAC 类型系统不兼容的情况下, 直接内存访问反而更清晰。
LED 极性也值得注意: Blue Pill 板载 LED 连接 PC13 且为低电平有效(输出 LOW=灯亮,HIGH=灯灭)。Embassy 官方 blinky 示例使用 Output::new(p.PC13, Level::High, Speed::Low) 将初始电平设为 HIGH(灯灭),与我们的 LED_OFF() 逻辑一致。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 #[cfg(feature = "stm32f401re" )] pub fn LED_Init () { RCC.ahb1enr().modify(|v| v.set_gpioaen(true )); GPIOA.moder().modify(|v| v.set_moder(5 , vals::Moder::OUTPUT)); GPIOA.otyper().modify(|v| v.set_ot(5 , vals::Ot::PUSHPULL)); GPIOA.ospeedr().modify(|v| v.set_ospeedr(5 , vals::Ospeedr::HIGHSPEED)); GPIOA.pupdr().modify(|v| v.set_pupdr(5 , vals::Pupdr::FLOATING)); } #[cfg(feature = "stm32f103c8" )] pub fn LED_Init () { unsafe { let r = core::ptr::read_volatile(0x40021018 as *const u32 ); core::ptr::write_volatile(0x40021018 as *mut u32 , r | (1 << 4 )); asm!("dsb" ); asm!("isb" ); let crh = core::ptr::read_volatile(0x40011004 as *const u32 ); core::ptr::write_volatile(0x40011004 as *mut u32 , (crh & !(0xF << 20 )) | (0x2 << 20 )); } }
bottom_driver 模块是一个按键中断驱动,用于测试外部中断触发的任务抢占。它操作 F401 的 SYSCFG 外设来选择 EXTI 线的映射引脚。F103 没有 SYSCFG 外设,替代方案是 AFIO(Alternate Function I/O), 但两者寄存器布局、字段名称、使能位全部不同。该驱动不是 RTOS 核心调度功能的前提——任务的创建、定时器、优先级调度、PendSV 上下文切换均不依赖它, 因此直接对 F103 禁用。测试中使用 bottom_driver 的 bin(如 bottom_test, time_performance 等)在 F103 上无法编译, 这是预期行为。
1 2 3 4 5 6 7 8 9 10 #[cfg(feature = "stm32f401re" )] pub mod bottom_driver; #[cfg(feature = "stm32f401re" )] use bottom_driver::BOT_DRIVER;#[cfg(feature = "stm32f401re" )] BOT_DRIVER.init();
而对于第二个任务, 一个 dyn Trait 显然无法通过 FFI 传递, 需要做以下修改:
FfiWaker 用 C 函数指针作为 vtable,把 Rust 的 Waker 包装成 C 可以克隆/唤醒/销毁的指针。FfiContext 把这个指针重新包装回 Rust 的 Context,让 C 侧能通过它唤醒任务。
标准库的 Poll 没有 #[repr(C)],不能跨 FFI 边界传递。所以我们需要另一个 Poll
1 2 3 4 5 #[repr(C, u8)] pub enum FfiPoll <T> { Ready(T), Pending, }
C 侧的对应类型
1 2 3 4 5 typedef struct { uint8_t _tag; uint8_t _pad[3 ]; uint32_t value; } FfiPoll_u32;
内存布局
1 2 3 4 5 Ready(42): 00 00 00 00 2a 00 00 00 ^tag=0 ^value=42 Pending: 01 00 00 00 00 00 00 00 ^tag=1 ^未使用
同时, 我们将 Waker 视为一个不透明的结构体提供给 C, 我在 Task2 里面描述过 Waker 的内部结构(https://gitlab.eduxiji.net/T2026106149910763/project3136859-388282/-/blob/task2/docs/waker.md ), 这里不再赘述。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #[repr(C)] struct FfiWakerBase { vtable: *const FfiWakerVTable, } #[repr(C)] struct FfiWaker { base: FfiWakerBase, waker: ManuallyDrop<Waker>, } #[repr(C)] struct FfiWakerVTable { clone: unsafe extern "C" fn (*const FfiWakerBase) -> *const FfiWakerBase, wake: unsafe extern "C" fn (*const FfiWakerBase), wake_by_ref: unsafe extern "C" fn (*const FfiWakerBase), drop : unsafe extern "C" fn (*const FfiWakerBase), }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 static REF_VTABLE: FfiWakerVTable = { unsafe extern "C" fn clone (data) -> *const FfiWakerBase { let cloned = Waker::clone(&(*w).waker); Box ::into_raw(Box ::new(FfiWaker { base: FfiWakerBase { vtable: &OWNED_VTABLE }, waker: ManuallyDrop::new(cloned), })) } unsafe extern "C" fn wake (data) { unreachable! (); } unsafe extern "C" fn wake_by_ref (data) { w.waker.wake_by_ref(); } unsafe extern "C" fn drop (data) { unreachable! (); } FfiWakerVTable { clone, wake: unreachable, wake_by_ref, drop : unreachable } };
C 端不能 wake() 或 drop()。这两个操作会消费所有权,但 borrowed waker 没有所有权。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 static OWNED_VTABLE: FfiWakerVTable = { unsafe extern "C" fn clone (data) -> *const FfiWakerBase { let cloned = Waker::clone(&(*w).waker); Box ::into_raw(Box ::new(FfiWaker { })) } unsafe extern "C" fn wake (data) { let b = Box ::from_raw(data as *mut FfiWaker); ManuallyDrop::into_inner(b.waker).wake(); } unsafe extern "C" fn drop (data) { let mut b = Box ::from_raw(data as *mut FfiWaker); ManuallyDrop::drop (&mut b.waker); } FfiWakerVTable { clone, wake, wake_by_ref, drop } };
C 端通过 clone() 得到的 waker 是完全拥有的, 可以 wake()、drop()、或 clone()。
Waker 的上层 Context 同样需要处理。
1 2 3 4 5 6 #[repr(C)] pub struct FfiContext <'a > { task: OS_TCB_REF, waker: *const FfiWakerBase, _marker: PhantomData<&'a FfiWakerBase>, }
C 的 poll 函数接收 *mut FfiContext,通过 with_context() 方法获取标准 &mut Context
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 pub fn with_context <T>(&mut self , f: impl FnOnce (&mut Context) -> T) -> T { static RUST_VTABLE: RawWakerVTable = { }; let raw = RawWaker::new(self .waker.cast(), &RUST_VTABLE); let waker = unsafe { Waker::from_raw(raw) }; let waker = ManuallyDrop::new(waker); let mut cx = Context::from_waker(&waker); f(&mut cx) }
Future 同理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 #[repr(C)] pub struct FfiFuture <T> { pub fut_ptr: *mut (), pub poll_fn: unsafe extern "C" fn (*mut (), *mut FfiContext) -> FfiPoll<T>, pub drop_fn: unsafe extern "C" fn (*mut ()), } impl <T> Future for FfiFuture<T> { fn poll (self : Pin<&mut Self >, cx: &mut Context<'_ >) -> Poll<T> { let task_ref = executor::task_from_waker(cx.waker()); let rust_waker = cx.waker().clone(); let ffi_waker = ManuallyDrop::new(FfiWaker { base: FfiWakerBase { vtable: &REF_VTABLE }, waker: ManuallyDrop::new(rust_waker), }); let mut ffi_ctx = FfiContext::new(task_ref, &*ffi_waker); let ret = (self .poll_fn)(self .fut_ptr, &mut ffi_ctx); Poll::from(ret) } }
与 async-ffi 的主要区别是, async-ffi 基于 std 环境, 做了一些额外的包装, 包括捕获 unwind panic 等问题。我在嵌入式领域做了相应的改造, 去除了一些不必要的功能, 同时可以与 no-std 环境适配。
训练营到这里就结束了, 我接下来的想法是继续沿着 “同步与异步统一” 这个问题继续走下去, 先前的 FFIFuture 也是我在这个问题上的初步尝试, 最终形成成果。