Rust 究竟有多安全? 写个MCU串口驱动让你信服

很多人都在吹 Rust 的安全性,如所有权和借用机制、零成本抽象、安全并发等等,对于传统 C 单片机开发者而言,这些概念都非常陌生,很难直接理解 Rust 究竟有多安全和高效,很多传统 C 开发者也经常怀疑 Rust 的性能和安全,认为这么多高级语法、高抽象化的编程方式,联想到 C++ 的坑,就会认为 Rust 一定和 C++的毛病差不多,编译的代码效率和安全未必有多优秀。

本文则基于国产芯片 PY32F030 的串口驱动来介绍 Rust 有多安全。

串口驱动框架

Rust 的外设驱动与 C 驱动的编程模式完全不一样,Rust 外设驱动的代码设计更加巧妙,分层更细,也保证提供给用户的接口都一致。只要串口的顶层接口遵循 Rust 对于串口设备的接口的规范,无论什么单片机,无论什么外设,则用户的操作接口(读写)都能轻松保持一致,非常适合移植和简化外设操作。通常串口的驱动框架如下:

各层介绍

从上图可只,串口驱动可细分为 4 层。

  1. 串口外设/GPIO外设/时钟外设:这些都是 MCU 的外设,属于硬件层
  2. PAC(Peripheral access control):为 Rust 嵌入式中独特的一层,Rust 为各外设包装了一层读写层,使用统一的读写接口,来操作各寄存器,保证能准确、高效的操作寄存器,无需像 C 驱动中需要使用移位,与或等操作来操作寄存器。如设置 GPIOA.10 引脚功能模式时,则可以使用以下代码直接指定,无需知道 PA10 的模式设置位域在寄存器中的索引位置。
gpioa_reg.moder.modify(|_, w| {
    unsafe {
        w.mode10().bits(0b001)
    }
})
  1. 串口驱动层:这层则使用了非常多的 Rust 函数式编程模式,巧妙地实现了安全、高效的接口,部分代码如下:
pub struct UsartRx<'d, T: Instance, M: Mode> {
    // _p: PeripheralRef<'d, T>,
    _p: PhantomData<(T, M)>,
    _rxd: Option'd, AnyPin>>,
    _rts: Option'd, AnyPin>>,
}

pub struct UsartTx<'d, T: Instance, M: Mode> {
    _p: PhantomData<(T, M)>,
    _txd: Option'd, AnyPin>>,
    _cts: Option'd, AnyPin>>,
}

pub struct FlexUsart<'d, T: Instance, M: Mode> {
    pub rx: UsartRx<'d, T, M>,
    pub tx: UsartTx<'d, T, M>,
}

// use crate::gpio::sealed::Pin;

impl<'d, T: Instance, M: Mode> FlexUsart<'d, T, M> {
    pub fn split(self) -> (UsartRx<'d, T, M>, UsartTx<'d, T, M>) {
        (self.rx, self.tx)
    }

    pub fn new(
        usart: impl Peripheral + 'd,
        rxd: Option<impl Peripheralimpl RxPin> + 'd>,
        txd: Option<impl Peripheralimpl TxPin> + 'd>,
        config: Config,
    ) -> Self {
        // 初始化 rxd 引脚
        let rxd = rxd.map_or_else(
            || None,
            |rxd| {
                into_ref!(rxd);
                rxd.set_instance_af(gpio::PinSpeed::VeryHigh, gpio::PinIoType::Pullup);
                // defmt::info!("rxd: {} ", Debug2Format(&(rxd.af())));
                Some(rxd.map_into())
            },
        );
        // 初始化 txd 引脚
        let txd = txd.map_or_else(
            || None,
            |txd| {
                into_ref!(txd);
                txd.set_instance_af(gpio::PinSpeed::VeryHigh, gpio::PinIoType::Pullup);
                // defmt::info!("txd: {} ", Debug2Format(&(txd.af())));
                Some(txd.map_into())
            },
        );

        into_ref!(usart);

        Self::new_inner(usart, rxd, txd, NoneNone, config)
    }

    fn new_inner(
        _usart: PeripheralRef<'d, T>,
        rxd: Option'd, AnyPin>>,
        txd: Option'd, AnyPin>>,
        cts: Option'd, AnyPin>>,
        rts: Option'd, AnyPin>>,
        config: Config,
    ) -> Self {
        // let block = T::block();
        T::enable(true);
        T::config(config);

        Self {
            rx: UsartRx::::new(rxd, rts),
            tx: UsartTx::::new(txd, cts),
        }
    }
}

impl<'d, T: Instance> UsartRx<'d, T, Blocking> {
    pub fn read_blocking(&self, buf: &mut [u8]) {
        T::read_bytes_blocking(buf)
    }
}

如果你不熟悉 Rust 代码,那么看到这些代码后一定非常迷惑,感觉到处都是模板,不用担心看不懂,会用就行。你也许认为这一定会消耗非常多的 Ram 和 Flash 资源。实际上并非此,下文将会告诉你这些代码占用了多少 Flash和内存。

  1. 嵌入式外设抽象层:Rust 官方社区发布了嵌入式外设的标准抽象embedded-hal,embedded-io,这些 crate 定义了常用外设如 GPIO、USART、IIC、SPI 、Timer 等外设的标准必要接口标准和读写接口。只要厂商的 Rust 驱动实现了该标准的 trait,则外设能轻松被用户使用,即使用户并不清楚外设驱动是怎么实现的,也无需一大堆配置。本层驱动代码如下:
impl<'d, T: Instance> embedded_io::Read for UsartRx<'d, T, Blocking> {
    fn read(&mut self, buf: &mut [u8]) -> Result<usize, Self::Error> {
        Ok(T::read_bytes_blocking(buf))
    }
}

impl<'d, T: Instance> embedded_io::Write for UsartTx<'d, T, Blocking> {
    fn write(&mut self, buf: &[u8]) -> Result<usize, Self::Error> {
        Ok(T::write_bytes_blocking(buf))
    }

    fn flush(&mut self) -> Result<(), Self::Error> {
        todo!()
    }
}
  1. 用户层:用户使用代码则非常简单了,示例代码如下:
#![no_std]
#![no_main]

use embedded_io::Write;
use hal::usart::FlexUsart;
use heapless::String;
use py32f030_hal as hal;

use {defmt_rtt as _, panic_probe as _};

#[cortex_m_rt::entry]
fn main() -> ! {
    let p = hal::init(Default::default());
    let gpioa = p.GPIOA.split();
    let rx = gpioa.PA9;
    let tx = gpioa.PA10;

    let usart = FlexUsart::new(p.USART1, Some(rx), Some(tx), Default::default());

    let (_, mut tx) = usart.split();

    defmt::info!("usart start...");
    let buf: String<20> = "hello rust\r\n".into();
    loop {
        // 使用标准接口来发送串口数据
        let _ = write!(tx, "example for usart\r\n");

        // 使用自定义的驱动接口发送串口数据
        tx.write_bytes_blocking(buf.as_bytes());

        defmt::info!("send: {} ", buf.as_bytes());
        cortex_m::asm::delay(1000 * 1000 * 10);
    }
}

从示例代码可以看出串口的接口创建和使用非常简单。用户可以使用 Rust 标准接口,即用write!宏输出到串口,无论串口号和串口类型都能统一接口的使用。用户也可使用串口驱动内部的接口操作,如tx.write_bytes_blocking(buf.as_bytes());多么简单优雅!

同时你无需担心串口缓存长度问题,永远不用担心缓存索引超出总长度。

也无需担心接口参数被放错!

测试效果如下同时示例代码中有个彩蛋!请继续阅读。

类型安全

通常串口的 GPIO 引脚只能是指定的某些引脚才能作为 TX 和 RX 引脚,且引脚的配置时钟和上下拉,开漏等模式也需要配置,如 PY32F030手册所示,PA9 和 PA10 可以作为串口 1 的 TX 和 RX 引脚。其他引脚如 PA11 则不能作为串口 1 的 RX 引脚,我们在代码中尝试指定 PA11 作为 RX 引脚试试,修改如下:可以看到编译器立即报错,报告 PA11 没有实现 RxPin 的 trait, 编译器不满意!因此无法继续编译。这个小小的安全提示非常有用,对于这种小错误如果在 C 驱动中则不太容易快速发现。

也许这是嵌入式菜鸟们的福利, Rust 保证让你不会再犯一些小错误,它确保你的代码在逻辑正确的情况下一定会正常运行,一些小错误在编译期间立即发现,你无需再使用烧录运行的方式来 Debug 各种隐形的问题,效率真的翻倍!

这就是 Rust 的安全特性之一!在保证安全的情况下,这个小示例代码并没有占用 RAM 和 Flash 太多资源。编译固件信息如下:

$ arm-none-eabi-size target/thumbv6m-none-eabi/release/examples/embassy_uart
   text    data     bss     dec     hex filename
   9616      72    1064   10752    2a00 target/thumbv6m-none-eabi/release/examples/embassy_uart

大约 9K 的 flash 占用,同时 ram 数据占用非常小。

总结

Rust 的安全不仅仅在内存中可以广泛体现,在驱动安全中也有非常多的亮点,欢迎留言讨论。


请使用浏览器的分享功能分享到微信等