很多人都在吹 Rust 的安全性,如所有权和借用机制、零成本抽象、安全并发等等,对于传统 C 单片机开发者而言,这些概念都非常陌生,很难直接理解 Rust 究竟有多安全和高效,很多传统 C 开发者也经常怀疑 Rust 的性能和安全,认为这么多高级语法、高抽象化的编程方式,联想到 C++ 的坑,就会认为 Rust 一定和 C++的毛病差不多,编译的代码效率和安全未必有多优秀。
本文则基于国产芯片 PY32F030 的串口驱动来介绍 Rust 有多安全。
串口驱动框架
Rust 的外设驱动与 C 驱动的编程模式完全不一样,Rust 外设驱动的代码设计更加巧妙,分层更细,也保证提供给用户的接口都一致。只要串口的顶层接口遵循 Rust 对于串口设备的接口的规范,无论什么单片机,无论什么外设,则用户的操作接口(读写)都能轻松保持一致,非常适合移植和简化外设操作。通常串口的驱动框架如下:

各层介绍
从上图可只,串口驱动可细分为 4 层。
串口外设/GPIO外设/时钟外设:这些都是 MCU 的外设,属于硬件层 PAC(Peripheral access control):为 Rust 嵌入式中独特的一层,Rust 为各外设包装了一层读写层,使用统一的读写接口,来操作各寄存器,保证能准确、高效的操作寄存器,无需像 C 驱动中需要使用移位,与或等操作来操作寄存器。如设置 GPIOA.10 引脚功能模式时,则可以使用以下代码直接指定,无需知道 PA10 的模式设置位域在寄存器中的索引位置。
gpioa_reg.moder.modify(|_, w| {
unsafe {
w.mode10().bits(0b001)
}
})
串口驱动层:这层则使用了非常多的 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 Peripheral
impl 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, None, None, 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和内存。
嵌入式外设抽象层: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!()
}
}
用户层:用户使用代码则非常简单了,示例代码如下:
#![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 的安全不仅仅在内存中可以广泛体现,在驱动安全中也有非常多的亮点,欢迎留言讨论。