GPIO 与基础外设
类型状态模式(typestate)如何把引脚配置错误变成编译错误:split() 所有权拆分、引脚模式在类型里编码、点灯与读按键、Delay 的使用。
类型状态模式(typestate)如何把引脚配置错误变成编译错误:split() 所有权拆分、引脚模式在类型里编码、点灯与读按键、Delay 的使用。
PC13<Output<PushPull>> 与 PC13<Input<PullUp>> 是不同类型。对输入引脚调用 set_high() 会因为该方法不存在而编译失败——非法操作在编译期就被禁止,不需要运行时检查。gpioc.split() 消耗整个 GPIOC 外设,返回一个包含 pc0..pc15 各引脚独立所有权的结构体。此后每个引脚可以被移动到不同函数/驱动,互不干扰——所有权系统把"端口"安全地分成了"引脚"。into_push_pull_output()、into_pull_up_input()、into_alternate() 等方法在引脚状态间转换。这些方法消耗旧状态的引脚(按值 self),返回新状态类型的引脚。因为是类型转换,编译器自动阻止你用旧引脚(它已被移动),保证同一物理引脚不会同时以两种模式存在。PushPull/OpenDrain 区分二者。into_pull_up_input() 等方法在类型里记录上下拉状态。into_alternate::<7>() 把引脚切到编号为 7 的复用功能。HAL 常用类型状态确保只有正确复用号的引脚才能传给对应外设的构造函数,接错引脚编译报错。.erase() 或 into_dyn_pin() 把具体引脚"擦除"成统一的动态类型 ErasedPin,代价是丢失部分编译期信息、改为运行期存储引脚号。用于需要同构集合的场景。cp.SYST.delay(&clocks) 或 dp.TIM2.delay(&clocks),返回实现了 DelayNs trait 的对象。它知道系统时钟频率,因此 delay_ms(500) 能延时准确的 500 毫秒。一个引脚的一生,就是在不同类型之间转换。每次 into_xxx 都消耗旧类型、产出新类型,编译器全程追踪它现在"是什么":
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 的驱动后,原处无法再用它,从根源杜绝引脚争用。