2. 总线基础

做嵌入式开发,GPIO 是最基础也最绕不开的外设。说白了,它就是芯片伸出来的那些引脚,你可以让它们输出高电平或低电平,也可以读取外部信号是 0 还是 1。今天我们就从 GPIO 入手,用 Rust 和 embedded-hal 来操作 LED,先让它闪烁,再玩出呼吸灯的效果。

本章核心目标:掌握 embedded-hal 的 GPIO 抽象模型,理解输出引脚与 PWM 的工作原理,能独立实现 LED 闪烁与呼吸灯程序。

1. embedded-hal 的 GPIO 抽象

embedded-hal 是 Rust 嵌入式生态里的硬件抽象层。它定义了一组 trait,比如 OutputPin、InputPin、PwmPin。不同的芯片厂商(STM32、nRF、ESP32 等)只要实现了这些 trait,你的代码就能跨平台跑。

我个人习惯把 GPIO 操作分成三层来看:

层次 说明 示例
硬件层 芯片寄存器,控制引脚模式、上下拉、速度 GPIOx_MODER, GPIOx_ODR
HAL 层 embedded-hal trait 的具体实现 stm32f4xx_hal::gpio::PB5
应用层 你的业务逻辑,只调用 trait 方法 pin.set_high() / pin.set_low()

你想想看,如果直接操作寄存器,换个芯片就得重写。但用 embedded-hal,应用层代码几乎不用改。我在项目中遇到过好几次芯片换型,只要 HAL 实现没变,LED 闪烁的代码一行都没动。

2. 点亮 LED:从闪烁开始

先来一个最简单的例子。假设我们有一个 STM32F4 开发板,LED 接在 PB5 上,低电平点亮。

use embedded_hal::digital::v2::OutputPin;
use stm32f4xx_hal::{
    pac,
    prelude::*,
    gpio::GpioExt,
};

fn main() -> ! {
    let dp = pac::Peripherals::take().unwrap();
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.freeze();

    let gpiob = dp.GPIOB.split();
    let mut led = gpiob.pb5.into_push_pull_output();

    loop {
        led.set_low().unwrap();   // 点亮
        cortex_m::asm::delay(8_000_000);
        led.set_high().unwrap();  // 熄灭
        cortex_m::asm::delay(8_000_000);
    }
}

嗯,这里要注意:set_low() 点亮是因为硬件上 LED 的阴极接 GPIO,阳极接 VCC。如果你的板子是高电平点亮,那就反过来。我曾经因为没看原理图,调了半小时才发现是电平反了。

调试小技巧:用万用表量一下 GPIO 引脚电压。set_high 时应该是 3.3V,set_low 时接近 0V。如果电压不对,先检查引脚模式配置。

3. 从闪烁到呼吸灯:PWM 原理

闪烁太简单了,呼吸灯才有点意思。呼吸灯的本质是让 LED 的亮度随时间缓慢变化。怎么控制亮度?靠 PWM(脉冲宽度调制)。

PWM 的核心思想:在一个固定周期内,高电平占的比例(占空比)决定了平均电压。占空比 0% 就是全灭,100% 就是全亮,中间值对应不同亮度。

PWM 占空比与亮度关系 10% 占空比 很暗 50% 占空比 中等亮度 90% 占空比 很亮 红色 = 高电平(亮) 蓝色 = 低电平(灭)

人眼有视觉暂留效应。只要 PWM 频率足够高(通常 1kHz 以上),你就不会看到闪烁,只会感觉到亮度的变化。呼吸灯就是让占空比从 0% 慢慢升到 100%,再慢慢降回来。

4. 用 embedded-hal 实现 PWM 呼吸灯

embedded-hal 里定义了 PwmPin trait,它提供了 set_duty() 方法来设置占空比。不同的芯片,PWM 的时钟源、定时器配置都不一样,但应用层的接口是统一的。

下面是一个基于 STM32F4 的呼吸灯示例:

use embedded_hal::PwmPin;
use stm32f4xx_hal::{
    pac,
    prelude::*,
    timer::Timer,
};

fn main() -> ! {
    let dp = pac::Peripherals::take().unwrap();
    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.freeze();

    let gpiob = dp.GPIOB.split();
    let led = gpiob.pb5.into_alternate();

    let timer = Timer::new(dp.TIM3, &clocks);
    let mut pwm = timer.pwm(led, 1.khz());
    let max_duty = pwm.get_max_duty();

    pwm.enable();

    loop {
        // 从暗到亮
        for duty in 0..=max_duty {
            pwm.set_duty(duty);
            delay_ms(1);
        }
        // 从亮到暗
        for duty in (0..=max_duty).rev() {
            pwm.set_duty(duty);
            delay_ms(1);
        }
    }
}

这里 get_max_duty() 返回的是 PWM 计数器的最大值。比如 16 位定时器,最大值就是 65535。你传 0 就是全灭,传 max_duty 就是全亮。

常见坑:PWM 频率不能太低。低于 50Hz 人眼就能看到闪烁。我建议至少设到 1kHz 以上。另外,有些芯片的 PWM 引脚有复用功能,记得在 datasheet 里确认 Alternate Function 编号。

5. 调试经验:LED 不亮怎么办?

做硬件调试,LED 不亮是最常见的问题。我总结了一个排查清单:

  1. 检查引脚模式:是 push-pull 还是 open-drain?LED 一般用 push-pull。
  2. 检查电平极性:set_high 是亮还是灭?看原理图。
  3. 检查时钟:GPIO 外设的时钟开了吗?RCC 里要 enable。
  4. 检查限流电阻:LED 没串电阻?那可能已经烧了。
  5. 用逻辑分析仪:看引脚波形,确认 PWM 频率和占空比对不对。

我曾经有一次调呼吸灯,LED 死活不亮。折腾了半天,发现是定时器的时钟源没配好,PWM 根本没输出。用逻辑分析仪一看,引脚一直是低电平。嗯,从那以后我每次都会先看波形。

6. 本章知识结构

GPIO 与 LED 控制知识体系 embedded-hal GPIO OutputPin trait PwmPin trait InputPin trait LED 闪烁 呼吸灯 按键输入 本章重点:OutputPin 和 PwmPin,下一章会用到 InputPin 做按键检测

这一章我们只用了 OutputPin 和 PwmPin。下一章会用到 InputPin 来做按键检测。但别急,先把 LED 玩熟了再说。

课后练习:试着把呼吸灯的频率调快或调慢,看看效果。再试试用两个 LED 做交替呼吸,像苹果电脑的睡眠指示灯那样。