从EXTI实现看Embassy: 异步Rust嵌入式框架

从EXTI实现看Embassy: 异步Rust嵌入式框架

原文链接:https://decaday.github.io/blog/embassy-exti/

Embassy是一个基于Rust的异步嵌入式开发框架:

Embassy: The next-generation framework for embedded applications

Embassy不仅包含了异步运行时,还提供了STM32、RP2xxx,NRF等芯片的异步HAL实现、usb、[蓝牙(trouble)](embassy-rs/trouble: A Rust Host BLE stack with a future goal of qualification.)等,乐鑫官方的esp-rs也是将embassy作为默认框架使用。

最近研究了embassy-stm32的部分实现,写在博客里作为记录吧。Exti最简单也有点Async味,就先写这个吧。

注意:本文撰写时,Embassy尚未1.0 release,此文可能在您读的时候已经过时。为了博客的清晰,部分代码被简化。

EXTI

EXTI 是 Extended Interrupts and Events Controller 的缩写,即"扩展中断和事件控制器"。

它的核心作用可以概括为一句话:让STM32能够响应来自外部(或内部通道)的异步信号,如IO上升沿、IO高电平,并在这些事件发生时触发中断或事件请求,从而执行特定的任务,尤其擅长将MCU从低功耗模式中唤醒。

embassy-stm32的exti驱动,我们从顶向下看。

源码链接:embassy/embassy-stm32/src · embassy-rs/embassy

整个代码的逻辑如下:

ExtiInput<'d>

rust 复制代码
/// EXTI input driver.
///
/// This driver augments a GPIO `Input` with EXTI functionality. EXTI is not
/// built into `Input` itself because it needs to take ownership of the corresponding
/// EXTI channel, which is a limited resource.
///
/// Pins PA5, PB5, PC5... all use EXTI channel 5, so you can't use EXTI on, say, PA5 and PC5 at the same time.
pub struct ExtiInput<'d> {
  pin: Input<'d>,
}

这是可被用户直接使用的ExtiInput类型。

其内部包含了一个Input类型(其实Input类型内部也是包含了一个FlexPin类型)

构造函数

rust 复制代码
impl<'d> ExtiInput<'d> {
    /// Create an EXTI input.
    pub fn new<T: GpioPin>(
        pin: impl Peripheral<P = T> + 'd,
        ch: impl Peripheral<P = T::ExtiChannel> + 'd,
        pull: Pull,
    ) -> Self {
        into_ref!(pin, ch);

        // Needed if using AnyPin+AnyChannel.
        assert_eq!(pin.pin(), ch.number());

        Self {
            pin: Input::new(pin, pull),
        }
    }
    ...

new函数我们主要说一下 impl Peripheral<P = T::ExtiChannel>

  • impl Peripheral<...>: 表明 pin 必须是一个实现了 Peripheral trait 的类型。 Peripheral 用来标记硬件外设所有权,来自embassy-hal-internal
  • <P = T>: 这是一个关联类型约束,意味着这个外设的实体类型就是泛型 T(比如 peripherals::PA4)。
  • <P = T::ExtiChannel>T::ExtiChannel是Trait T的关联类型,这个我们将在下面看到。它意味着这个外设的实体类型要与 "与T对应的ExtiChannel" 的类型匹配。
  • + 'd: 这是一个生命周期约束,确保传入的外设引用至少和 ExtiInput 实例活得一样长。这在处理外设的可变借用时非常重要。

这个类型限制是这样的:

T是GpioPin,是某个引脚的类型(比如PA4,PA5,都是单独的类型,都可以是T

pin 参数要走了 T 的所有权,目的是使得用户无法直接将PA4再用作I2C。其形式通常是单例Singleton,也就是传统rust hal库结构的let p = Peripheral.take() 所获得的外设的所有权(以后可能单独写博客讲单例)。

ch 参数限定了其自身必须是T的关联类型ExtiChannelP = T::ExtiChannel),我们在下面细说,这要求了channel必须与pin对应,比如PA4必须提供EXTI4。

类型系统

EXTI单例(Singleton)类型的定义在_generated.rs(由build.rs生成的)中的embassy_hal_internal::peripherals_definition!宏中。

rust 复制代码
// (embassy-stm32/target/thumbv7em-none-eabi/.../out/_generated.rs)
embassy_hal_internal::peripherals_definition!(
    ADC1,
    ...
    EXTI0,
    EXTI1,
    EXTI2,
    EXTI3,
    ...
)

这些外设信息来自芯片的CubeMX数据库。经过stm32-data和embassy-stm32宏的层层处理,实现了完善的类型限制和不同型号间高度的代码复用。

Channel Trait

Exit的Channel Trait使用了密封(Sealed)Trait,这样可以保证Channel Trait在包外可见,但是不能在外部被实现(因为外部实现privite trait SealedChannel

rust 复制代码
trait SealedChannel {}
#[allow(private_bounds)]
pub trait Channel: SealedChannel + Sized {
    /// Get the EXTI channel number.
    fn number(&self) -> u8;

    /// Type-erase (degrade) this channel into an `AnyChannel`.
    ///
    /// This converts EXTI channel singletons (`EXTI0`, `EXTI1`, ...), which
    /// are all different types, into the same type. It is useful for
    /// creating arrays of channels, or avoiding generics.
    fn degrade(self) -> AnyChannel {
        AnyChannel { number: self.number() as u8, }
    }
}

在实现上比较简单,embassy-stm32使用宏来简化了代码。

rust 复制代码
macro_rules! impl_exti {
    ($type:ident, $number:expr) => {
        impl SealedChannel for peripherals::$type {}
        impl Channel for peripherals::$type {
            fn number(&self) -> u8 {
                $number
            }
        }
    };
}

impl_exti!(EXTI0, 0);
impl_exti!(EXTI1, 1);
impl_exti!(EXTI2, 2);
impl_exti!(EXTI3, 3);
// ...

Pin Trait

Pin Trait同样使用了Sealed Trait。AnyPin部分我们先不研究,我们只看Exti部分:Pin Trait设置了一个关联类型,指向exti::Channel Trait。

rust 复制代码
// embassy-stm32/src/gpio.rs

pub trait Pin: Peripheral<P = Self> + Into<AnyPin> + SealedPin + Sized + 'static {
    /// EXTI channel assigned to this pin. For example, PC4 uses EXTI4.
    #[cfg(feature = "exti")]
    type ExtiChannel: crate::exti::Channel;

    
    #[inline] // Number of the pin within the port (0..31)
    fn pin(&self) -> u8 { self._pin() }

    #[inline] // Port of the pin
    fn port(&self) -> u8 { self._port() }

    /// Type-erase (degrade) this pin into an `AnyPin`.
    ///
    /// This converts pin singletons (`PA5`, `PB6`, ...), which
    /// are all different types, into the same type. It is useful for
    /// creating arrays of pins, or avoiding generics.
    #[inline]
    fn degrade(self) -> AnyPin {
        AnyPin {
            pin_port: self.pin_port(),
        }
    }
}

在Impl上也是用了大量的codegen和宏,其最终是 foreach_pin 这个宏:(foreach_pin的原型在build.rs生成的_macro.rs内,稍微有点绕,不再详细叙述)

rust 复制代码
// (embassy-stm32/src/gpio.rs)
foreach_pin!(
    ($pin_name:ident, $port_name:ident, $port_num:expr, $pin_num:expr, $exti_ch:ident) => {
        impl Pin for peripherals::$pin_name {
            #[cfg(feature = "exti")]
            type ExtiChannel = peripherals::$exti_ch;
        }
        impl SealedPin for peripherals::$pin_name { /* ... */} 
        impl From<peripherals::$pin_name> for AnyPin { /* ... */} 
    };
);

其它IO复用也是通过codegen和宏实现的。比如,经过数据处理后,可能生成这样的代码:

rust 复制代码
// (_generated.rs)
impl_adc_pin!(ADC3, PC2, 12u8);
impl_adc_pin!(ADC3, PC3, 13u8);
pin_trait_impl!(crate::can::RxPin, CAN1, PA11, 9u8);
pin_trait_impl!(crate::can::TxPin, CAN1, PA12, 9u8);

这种情况下就限制死了alternate function,从而在编译期就能发现问题,而且通过代码提示就能获知可用的IO而不用翻手册。不得不说,这就是人们希望类型系统所做到的!

wait_for_high

rust 复制代码
/// Asynchronously wait until the pin is high.
///
/// This returns immediately if the pin is already high.
pub async fn wait_for_high(&mut self) {
    let fut = ExtiInputFuture::new(self.pin.pin.pin.pin(), self.pin.pin.pin.port(), true, false);
    if self.is_high() {
        return;
    }
    fut.await
}
...
/// Asynchronously wait until the pin sees a rising edge.
///
/// If the pin is already high, it will wait for it to go low then back high.
pub async fn wait_for_rising_edge(&mut self) {
    ExtiInputFuture::new(self.pin.pin.pin.pin(), self.pin.pin.pin.port(), true, false).await
}
...

这个self.pin.pin.pin.pin()有够吐槽的。解释起来是这样的: ExtiInput.Input.FlexPin.PeripheralRef<AnyPin>.pin()

我们看见的wait_for_high或是wait_for_rising_edge新建了一个ExtiInputFuture,我们来看看:

ExtiInputFuture<'a>

复制代码
#[must_use = "futures do nothing unless you `.await` or poll them"]
struct ExtiInputFuture<'a> {
    pin: u8,
    phantom: PhantomData<&'a mut AnyPin>,
}

ExtiInputFuture并不存储外设实例,而只存一个pin_num,这有利于所有权的编写和更加灵活。实际上,STM32也只有16个Channel嘛,我们可以用一些全局标志位。

new和drop

rust 复制代码
    fn new(pin: u8, port: u8, rising: bool, falling: bool) -> Self {
        critical_section::with(|_| {
            let pin = pin as usize;
            exticr_regs().exticr(pin / 4).modify(|w| w.set_exti(pin % 4, port));
            EXTI.rtsr(0).modify(|w| w.set_line(pin, rising));
            EXTI.ftsr(0).modify(|w| w.set_line(pin, falling));

            // clear pending bit
            #[cfg(not(any(exti_c0, exti_g0, exti_u0, exti_l5, exti_u5, exti_h5, exti_h50)))]
            EXTI.pr(0).write(|w| w.set_line(pin, true));
            #[cfg(any(exti_c0, exti_g0, exti_u0, exti_l5, exti_u5, exti_h5, exti_h50))]
            {
                EXTI.rpr(0).write(|w| w.set_line(pin, true));
                EXTI.fpr(0).write(|w| w.set_line(pin, true));
            }

            cpu_regs().imr(0).modify(|w| w.set_line(pin, true));
        });

        Self {
            pin,
            phantom: PhantomData,
        }
    }
}

impl<'a> Drop for ExtiInputFuture<'a> {
    fn drop(&mut self) {
        critical_section::with(|_| {
            let pin = self.pin as _;
            cpu_regs().imr(0).modify(|w| w.set_line(pin, false));
        });
    }
}

new函数使用了一个critical_section。"critical_section::with 创建了一个临界区。在嵌入式系统中,临界区是一段在执行期间不会被中断打断的代码。对于单核微控制器,最简单的实现方式就是临时禁用所有中断(这也是默认实现)。这确保了在配置 EXTI 寄存器这种需要多个步骤的操作时,不会被一个突如其来的中断打乱,从而保证了操作的原子性。

new函数初始化了选择引脚端口、设置触发边沿等与EXTI相关的寄存器(就不展开细看了),最后一行设置了IMR(Interrupt mask register)寄存器,表示取消屏蔽(Mask)该位,此时该通道可产生中断。

impl Future (poll)

rust 复制代码
const EXTI_COUNT: usize = 16;
const NEW_AW: AtomicWaker = AtomicWaker::new();
static EXTI_WAKERS: [AtomicWaker; EXTI_COUNT] = [NEW_AW; EXTI_COUNT];
...
...
impl<'a> Future for ExtiInputFuture<'a> {
    type Output = ();

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        EXTI_WAKERS[self.pin as usize].register(cx.waker());

        let imr = cpu_regs().imr(0).read();
        if !imr.line(self.pin as _) {
            Poll::Ready(())
        } else {
            Poll::Pending
        }
    }
}

在这里我们实现了 Future trait。使得 ExtiInputFuture 可以用于 async/await 机制。

Future trait 代表一个异步计算/运行的结果,可以被执行器(executor)轮询(poll)以检查是否完成。 在 poll 方法中,我们做了以下几件事:

  1. 注册 waker : waker是唤醒器。因为持续的轮询会消耗大量的cpu资源(如果持续poll,那就是nb模式)。所以,一个聪明的executor仅第一次和被waker唤醒后,才会执行一次poll。这里的唤醒者是中断函数。

    EXTI_WAKERS 是一个全局的 AtomicWaker 数组,每个 pin 对应一个 AtomicWaker,用于存储 wakerpoll 调用时会将 waker 存入 EXTI_WAKERS[self.pine],这样当中断发生时,可以使用这个 waker 唤醒 Future

  2. 检查中断是否发生 :它通过检查IMR寄存器判断中断是否发生。因为我们的中断函数(on_irq)在触发后会立刻通过imr(0).modify(|w| w.0 &= !bits)来屏蔽该中断线 。所以,如果在poll时发现IMR位被清零了(即被屏蔽了),就说明在我们await的这段时间里,中断已经来过了。这时就可以返回Poll::Ready了。如果IMR位仍然是1(未屏蔽),则说明中断还没来,返回Poll::Pending继续等待。" 这样就把pollon_irq的行为联系起来了,逻辑更清晰。

提一下,AtomicWaker这个底层实现在embassy-sync中,平台有Atomic的情况下用AtomicPtr实现,没有的话用Mutex实现。

中断

on_irq

rust 复制代码
unsafe fn on_irq() {
    #[cfg(not(any(exti_c0, exti_g0, exti_u0, exti_l5, exti_u5, exti_h5, exti_h50)))]
    let bits = EXTI.pr(0).read().0;
    #[cfg(any(exti_c0, exti_g0, exti_u0, exti_l5, exti_u5, exti_h5, exti_h50))]
    let bits = EXTI.rpr(0).read().0 | EXTI.fpr(0).read().0;
    // ...

    // Mask all the channels that fired.
    cpu_regs().imr(0).modify(|w| w.0 &= !bits);

    // Wake the tasks
    for pin in BitIter(bits) {
        EXTI_WAKERS[pin as usize].wake();
    }

    // Clear pending
    EXTI.pr(0).write_value(Lines(bits));
    ...
}

on_irq 函数的主要作用是在外部中断发生时,处理触发的 ExtiChannel 并唤醒相应的 Future

  1. 读取PR(Pending Register)或者 RPR/FPR(Rising/Falling Edge Pending Register)因为多个EXTI线可能共用一个中断向量,所以on_irq首先读取PR来确定具体是哪些线触发了中断。
  2. 通过修改 IMR(Interrupt Mask Register),屏蔽已触发的中断通道,以防止重复触发。
  3. 为了处理多个Channel都触发的情况,Embassy通过 BitIter(bits) 遍历所有触发的 pin,并调用 EXTI_WAKERS[pin as usize].wake() 唤醒相应的 Future。这个BitIter会在下面讲到。
  4. EXTI.prEXTI.rpr/EXTI.fpr 中清除对应的位,以便后续的中断可以正确触发。

绑定

Embassy通过一系列宏将EXTI中断绑定到on_irq上。

rust 复制代码
macro_rules! foreach_exti_irq {
    ($action:ident) => {
        foreach_interrupt!(
            (EXTI0)  => { $action!(EXTI0); };
            (EXTI1)  => { $action!(EXTI1); };
			...
            // plus the weird ones
            (EXTI0_1)   => { $action!( EXTI0_1 ); };
            (EXTI15_10) => { $action!(EXTI15_10); };
			...
        );
    };
}

macro_rules! impl_irq {
    ($e:ident) => {
        #[allow(non_snake_case)]
        #[cfg(feature = "rt")]
        #[interrupt]
        unsafe fn $e() {
            on_irq()
        }
    };
}

因为EXTI中断比较复杂,有多个外设共用一个中断向量的情况,而且不同的系列共用中断向量的情况还不一样,在exti上难以使用bind_irqs!这样的模式、embassy_stm32的其它外设,以及embassy_rp等hal都是使用的bind_irqs!。这其实是将更多的中断访问权交给了用户。

但是exti就不行了,想要让hal不占用中断向量,就只能关闭exti feature来关闭整个模块,或者关闭rt feature,自行管理启动和所有中断。

BitIter

rust 复制代码
struct BitIter(u32);

impl Iterator for BitIter {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        match self.0.trailing_zeros() {
            32 => None,
            b => {
                self.0 &= !(1 << b);
                Some(b)
            }
        }
    }
}

BitIter 是一个简单的位迭代器,用于遍历 bits 中的所有 1 位。

trailing_zeros() 返回最低有效位(LSB)之前 0 的个数。然后self.0 &= !(1 << b) 清除该位,以便在下一次 next() 调用时继续遍历。

这种方式确保了 on_irq 处理多个 EXTI 事件时能够逐一唤醒对应的 Future

embedded_hal

exti.rs还提供了embedded_hal(略) 和 embedded_hal_async Trait的实现:

rust 复制代码
impl<'d> embedded_hal_async::digital::Wait for ExtiInput<'d> {
    async fn wait_for_high(&mut self) -> Result<(), Self::Error> {
        self.wait_for_high().await;
        Ok(())
    }

    async fn wait_for_low(&mut self) -> Result<(), Self::Error> {
        self.wait_for_low().await;
        Ok(())
    }

    async fn wait_for_rising_edge(&mut self) -> Result<(), Self::Error> {
        self.wait_for_rising_edge().await;
        Ok(())
    }

    async fn wait_for_falling_edge(&mut self) -> Result<(), Self::Error> {
        self.wait_for_falling_edge().await;
        Ok(())
    }

    async fn wait_for_any_edge(&mut self) -> Result<(), Self::Error> {
        self.wait_for_any_edge().await;
        Ok(())
    }
}

然后我们就可以愉快地使用:

button.wait_for_low().await啦!

总结

这个EXTI模块复杂性比较低,主要用于EXTI最低级也是最常用的用法:等待上升沿、等待高电平等。

但是由于stm32系列太多,又有很多EXTI15_10这种共用向量情况,embassy-stm32直接接管了所有EXTI中断(对于普通向量则一般使用bind_interrupts的模式),所以如果用户想用EXTI完成更加复杂和即时的操作,就只能关闭exti feature来关闭整个模块,或者关闭rt feature,自行管理启动和所有中断。

Embassy HAL设计了一套优秀的类型系统和HAL范式,为社区提供了学习榜样。其类型系统一部分在embassy-hal-internal中完成,一部分在HAL内部完成。通过这套类型系统和约束,我们可以避免很多恼人的错误,也能很大程度上简化代码(比如,永远不会设置错、忘设置IO AF,也不用再去查AF表)。

embassy-stm32 的创新主要是其codegen和metapac:使用了复杂的数据预处理和codegen实现了对stm32外设的包罗万象。stm32-data 通过来自CubeMX等的数据,生成带有元数据的PAC:stm32-metapac,避免了像stm32-rs 一样的重复和分散、不统一的代码。

当然,包罗万象是有代价的。我们日后可以详细聊聊。

在Embassy范式的影响下,我编写和维护了py32-halsifli-rs ,包含了对embassy大量的直接 Copy 借鉴,这两套hal分别针对Puya的低成本MCU如PY32F002和SiFli的M33蓝牙MCU SF32LB52。了解一下?

原文链接:https://decaday.github.io/blog/embassy-exti/

我的github: https://github.com/decaday

本文以CC-BY-NC许可发布,当您转载该文章时,需要保留署名,且不能用于商业用途。特别地,不能转载到C**N平台。