Chapter 05 · Rust 嵌入式开发

GPIO 与基础外设

类型状态模式(typestate)如何把引脚配置错误变成编译错误:split() 所有权拆分、引脚模式在类型里编码、点灯与读按键、Delay 的使用。

课程进度42%

关键概念

类型状态模式 (Typestate Pattern)
把对象的"状态"编码进它的类型参数,让状态在编译期可追踪。例如引脚 PC13<Output<PushPull>>PC13<Input<PullUp>> 是不同类型。对输入引脚调用 set_high() 会因为该方法不存在而编译失败——非法操作在编译期就被禁止,不需要运行时检查。
split() 与所有权拆分
GPIO 端口寄存器块(如 GPIOC)是整体,但我们想让每个引脚独立拥有、独立传递。gpioc.split() 消耗整个 GPIOC 外设,返回一个包含 pc0..pc15 各引脚独立所有权的结构体。此后每个引脚可以被移动到不同函数/驱动,互不干扰——所有权系统把"端口"安全地分成了"引脚"。
into_xxx 模式转换
HAL 用 into_push_pull_output()into_pull_up_input()into_alternate() 等方法在引脚状态间转换。这些方法消耗旧状态的引脚(按值 self),返回新状态类型的引脚。因为是类型转换,编译器自动阻止你用旧引脚(它已被移动),保证同一物理引脚不会同时以两种模式存在。
推挽输出 (Push-Pull) vs 开漏输出 (Open-Drain)
推挽输出能主动输出高电平(接到 VDD)和低电平(接到 GND),驱动 LED 等常规负载。开漏输出只能拉低或高阻,高电平需要外部上拉电阻——用于 I2C 总线、多设备"线与"、电平转换等场景。HAL 用不同类型 PushPull/OpenDrain 区分二者。
上拉 / 下拉 (Pull-Up / Pull-Down)
输入引脚悬空时电平不确定(浮空),易受干扰误触发。内部上拉电阻把默认电平拉到高,下拉拉到低。按键接地 + 上拉是最常见接法:不按时读到高,按下时接通 GND 读到低。HAL 用 into_pull_up_input() 等方法在类型里记录上下拉状态。
复用功能 (Alternate Function)
同一物理引脚除了普通 GPIO,还能连到内部外设(如 UART 的 TX、SPI 的 SCK)。into_alternate::<7>() 把引脚切到编号为 7 的复用功能。HAL 常用类型状态确保只有正确复用号的引脚才能传给对应外设的构造函数,接错引脚编译报错。
Erased / Dyn Pin(类型擦除引脚)
类型状态让每个引脚类型都不同,有时不便(如想把不同引脚放进同一个数组)。HAL 提供 .erase()into_dyn_pin() 把具体引脚"擦除"成统一的动态类型 ErasedPin,代价是丢失部分编译期信息、改为运行期存储引脚号。用于需要同构集合的场景。
Delay 提供者
阻塞式延时的实现,通常基于 SysTick 或某个定时器。HAL 提供 cp.SYST.delay(&clocks)dp.TIM2.delay(&clocks),返回实现了 DelayNs trait 的对象。它知道系统时钟频率,因此 delay_ms(500) 能延时准确的 500 毫秒。

类型状态:状态机在类型里

一个引脚的一生,就是在不同类型之间转换。每次 into_xxx 都消耗旧类型、产出新类型,编译器全程追踪它现在"是什么":

GPIO 引脚的类型状态转换 ═══════════════════════════════════════════════════ PC13<Input<Floating>> (split 后的初始状态:浮空输入) │ ├── .into_push_pull_output() │ ▼ │ PC13<Output<PushPull>> ← 有 set_high/set_low/toggle │ 无 is_high(不是输入) │ ├── .into_pull_up_input() │ ▼ │ PC13<Input<PullUp>> ← 有 is_high/is_low │ 无 set_high(不是输出) │ └── .into_alternate::<7>() ▼ PC13<Alternate<7>> ← 只能传给 UART/SPI 等外设 关键:对输出引脚调 is_high(),或对输入引脚调 set_high(), 都会因方法不存在而"编译失败",而非运行期出错。
类型状态是零成本的

Output<PushPull> 这些类型参数只在编译期存在,运行时不占任何内存、不产生任何指令。它们纯粹是给编译器看的"标签",编译后完全消失。所以类型状态提供的安全保证是彻底免费的——这正是"零成本抽象"在 GPIO 上的体现。

点灯 + 读按键完整示例

按键接 PA0 到 GND(内部上拉,按下读低),LED 在 PC13。按一下翻转一次 LED,带软件消抖:

#![no_std]
#![no_main]
use panic_halt as _;
use cortex_m_rt::entry;
use stm32f4xx_hal::{pac, prelude::*};

#[entry]
fn main() -> ! {
    let dp = pac::Peripherals::take().unwrap();
    let cp = cortex_m::Peripherals::take().unwrap();

    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.use_hse(25.MHz()).sysclk(84.MHz()).freeze();

    // 拆分端口,各引脚获得独立所有权
    let gpioa = dp.GPIOA.split();
    let gpioc = dp.GPIOC.split();

    // LED:推挽输出;按键:上拉输入
    let mut led = gpioc.pc13.into_push_pull_output();
    let button = gpioa.pa0.into_pull_up_input();

    let mut delay = cp.SYST.delay(&clocks);

    let mut last_pressed = false;

    loop {
        // 上拉输入:按下 = 低电平 = is_low() 为真
        let pressed = button.is_low();

        // 检测下降沿(松→按),并做 20ms 消抖
        if pressed && !last_pressed {
            delay.delay_ms(20_u32);       // 消抖:再确认一次
            if button.is_low() {
                led.toggle();            // StatefulOutputPin::toggle
            }
        }
        last_pressed = pressed;

        delay.delay_ms(5_u32);           // 轮询间隔
    }
}
按键消抖为什么必要

机械按键在按下/松开瞬间,触点会在几毫秒内多次通断(抖动),若不处理,一次按压会被读成几十次。软件消抖的思路:检测到状态变化后延时 10~20ms 再次确认。轮询消抖简单但占用 CPU;更优雅的做法是用外部中断 + 定时器(第 6 章)或去抖 crate(如 debouncr)。

常见引脚配置方法

方法结果类型典型用途
into_push_pull_output()Output<PushPull>LED、蜂鸣器、CS 片选
into_open_drain_output()Output<OpenDrain>I2C 手动位操作、线与
into_pull_up_input()Input<PullUp>接地按键、低有效信号
into_pull_down_input()Input<PullDown>接 VDD 按键、高有效信号
into_floating_input()Input<Floating>已有外部上下拉的信号
into_alternate::<N>()Alternate<N>接 UART/SPI/I2C/TIM

把引脚交给驱动:所有权流动

由于引脚有独立所有权,可以把它按值移动给某个抽象层或驱动,之后原地就不能再用它了——这从根本上防止了"两处代码同时控制同一引脚":

use embedded_hal::digital::OutputPin;

// 一个只依赖 OutputPin trait 的通用"闪灯器"
struct Blinker<P> { pin: P }

impl<P: OutputPin> Blinker<P> {
    fn new(pin: P) -> Self { Self { pin } }
    fn on(&mut self) { let _ = self.pin.set_high(); }
    fn off(&mut self) { let _ = self.pin.set_low(); }
}

// 用法:led 的所有权移动进 Blinker
let led = gpioc.pc13.into_push_pull_output();
let mut blinker = Blinker::new(led);
blinker.on();
// led.set_low();  // ❌ 编译错误:led 已被移动到 blinker
本章小结

GPIO 端口通过 split() 消耗整体外设、拆分出每个引脚的独立所有权,此后引脚可各自移动传递。HAL 用类型状态模式把引脚模式编码进类型(PC13<Output<PushPull>> vs PC13<Input<PullUp>>),into_push_pull_output()/into_pull_up_input()/into_alternate::<N>() 等方法消耗旧状态引脚、返回新状态类型,因此对输出引脚调 is_high()、对输入引脚调 set_high() 都是编译错误而非运行期问题。这套类型标签是零成本的,运行时完全消失。推挽输出主动驱动高低电平,开漏输出配外部上拉用于 I2C/线与;输入引脚配上拉/下拉防浮空,接地按键 + 上拉时按下读 is_low()。Delay 提供者(SysTick/TIM)实现 DelayNs 提供精确阻塞延时。把引脚按值交给只依赖 OutputPin trait 的驱动后,原处无法再用它,从根源杜绝引脚争用。