3. STM32硬件基础回顾

各位同学,欢迎来到第三章。从这章开始,我们终于要跟硬件打交道了。

GPIO,全称是General-Purpose Input/Output,通用输入输出口。说白了,它就是芯片上那些可以编程控制的引脚。你可以让它们输出高电平或低电平,也可以读取外部信号是0还是1。

我刚开始学嵌入式那会儿,觉得GPIO不就是点个灯嘛,有啥好学的?后来做项目才发现,GPIO的配置细节直接决定了整个系统的稳定性。嗯,咱们今天就从最基础的开始,一步步来。

3.1 GPIO工作原理

先看一张图,了解一下GPIO的内部结构。

GPIO 内部结构框图 GPIO 引脚 输出数据寄存器 输出驱动器 输入数据寄存器 施密特触发器 模式配置寄存器 GPIO 核心:模式配置寄存器决定引脚是输入还是输出,数据寄存器负责读写电平值

这张图展示了GPIO的核心结构。左边是输出路径,右边是输入路径,中间是模式配置。

STM32的GPIO有8种工作模式,但咱们做LED控制,主要用两种:

模式 说明 典型用途
推挽输出 引脚可以输出高电平或低电平,驱动能力强 LED控制、蜂鸣器
浮空输入 引脚电平由外部决定,内部不上拉也不下拉 按键检测
上拉输入 内部接上拉电阻,默认高电平 防抖动的按键
开漏输出 只能输出低电平,高电平靠外部上拉 I2C总线

我的经验:做LED控制,用推挽输出就够了。我曾经见过有人用开漏输出点灯,结果灯死活不亮——因为忘了加外部上拉电阻。你想想看,一个电阻引发的血案。

3.2 Rust中GPIO的配置与操作

在Rust嵌入式HAL里,GPIO的操作被抽象成了几个trait。咱们用的是stm32f4xx-hal这个crate,它把底层的寄存器操作都封装好了。

先看一个完整的LED闪烁程序:

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _;
use stm32f4xx_hal as hal;

use hal::{
    pac,
    prelude::*,
    timer::Timer,
};

#[entry]
fn main() -> ! {
    // 1. 获取外设实例
    let dp = pac::Peripherals::take().unwrap();
    let cp = cortex_m::Peripherals::take().unwrap();

    // 2. 初始化GPIO
    let gpioc = dp.GPIOC.split();
    let mut led = gpioc.pc13.into_push_pull_output();

    // 3. 初始化定时器
    let mut timer = Timer::syst(cp.SYST, 1.hz(), &dp.CLOCK);

    // 4. 主循环
    loop {
        led.set_high();
        timer.wait();
        led.set_low();
        timer.wait();
    }
}

这段代码看着简单,但背后有几个关键点,我得跟你聊聊。

3.2.1 获取外设

pac::Peripherals::take()这个调用,在Rust里只能执行一次。为什么?

因为硬件寄存器是全局共享的资源。如果两个地方都能拿到同一个GPIO的寄存器,同时去写,那不乱套了?Rust的所有权模型在这里发挥了作用——take()返回一个Option,第一次调用拿到Some,第二次就是None了。

注意:如果你在多个函数里都调用了take(),只有第一个能成功。我见过有人把take()写在两个不同的模块里,结果程序跑起来莫名其妙——其实就是第二个调用返回了None,然后unwrap()直接panic了。

3.2.2 拆分GPIO

gpioc.split()这个操作,把整个GPIOC端口拆成了独立的引脚。每个引脚都是一个独立的结构体,有自己的类型。

比如pc13,它的类型是PC13<Analog>。调用into_push_pull_output()之后,类型变成了PC13<Output<PushPull>>。

这就是Rust的类型系统在帮你做硬件配置的静态检查。如果你忘了配置引脚模式,编译器直接报错,根本不会让你编译通过。

核心思想:Rust嵌入式HAL用类型状态模式(Type-State Pattern)来保证硬件配置的正确性。每个引脚的类型都携带了它的当前状态信息,编译器在编译时就能检查出错误。

3.3 理解所有权与借用模型在硬件操作中的应用

这部分可能是很多Rust初学者最头疼的地方。我尽量用大白话讲清楚。

先看一个会编译失败的例子:

// 这段代码编译不通过!
let mut led = gpioc.pc13.into_push_pull_output();

let led_ref1 = &led;
let led_ref2 = &led;

led.set_high();  // 错误!led已经被不可变借用
println!("{:?}", led_ref1);
println!("{:?}", led_ref2);

为什么会这样?

因为led代表的是一个真实的硬件引脚。如果你同时持有多个引用,Rust无法保证你不会通过其中一个引用去修改引脚状态,而另一个引用还蒙在鼓里。

在嵌入式场景下,这个规则尤其重要。你想想看,如果两个中断服务函数都引用了同一个引脚,一个在置高,一个在置低,那引脚到底输出什么?

正确的做法是:要么转移所有权,要么用可变引用:

// 方式一:转移所有权
fn toggle_led(mut led: PC13<Output<PushPull>>) {
    led.set_high();
    // led 在这里被drop,所有权转移出去了
}

// 方式二:可变借用
fn toggle_led_ref(led: &mut PC13<Output<PushPull>>) {
    led.set_high();
}

// 调用
toggle_led(led);  // led的所有权转移给了函数
// 这里不能再使用led

// 或者
toggle_led_ref(&mut led);  // 可变借用
led.set_low();  // 借用结束后,可以继续使用

我的建议:在写驱动库的时候,优先用所有权转移。这样调用者拿到引脚后,可以自由决定怎么用。在写应用层代码的时候,用可变借用更灵活,因为一个引脚可以在多个地方被操作。

3.4 实现LED闪烁——完整实战

好了,理论讲完了,咱们来写一个完整的LED闪烁程序。这次用PB0引脚,接一个LED。

硬件连接:

STM32引脚 LED
PB0 阳极(正极)
GND 阴极(负极)

代码实现:

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _;
use stm32f4xx_hal as hal;

use hal::{
    pac,
    prelude::*,
    timer::Timer,
};

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

    // 初始化GPIOB
    let gpiob = dp.GPIOB.split();
    let mut led = gpiob.pb0.into_push_pull_output();

    // 配置定时器,1Hz = 1秒闪烁一次
    let mut timer = Timer::syst(cp.SYST, 1.hz(), &dp.CLOCK);

    loop {
        // 点亮LED
        led.set_high();
        timer.wait();

        // 熄灭LED
        led.set_low();
        timer.wait();
    }
}

这段代码跑起来,LED就会以1秒的频率闪烁。

关键点回顾:

  • into_push_pull_output() 把引脚配置为推挽输出模式
  • set_high() 和 set_low() 控制引脚电平
  • Timer::syst() 使用系统滴答定时器产生精确延时
  • 所有权模型保证了引脚不会被多个地方同时操作

3.5 避坑指南

最后,分享几个我踩过的坑:

  • 引脚复用冲突:STM32的很多引脚有多个功能。比如PB0也可能是I2C的SCL。如果你在别的地方初始化了I2C,这里再用PB0做GPIO,就会冲突。解决办法是仔细看数据手册,确认引脚没有被占用。
  • 时钟未使能:在HAL里,split()会自动使能GPIO时钟。但如果你直接操作寄存器,别忘了手动使能。我曾经因为这个,折腾了整整一个下午。
  • LED极性:有的开发板LED是低电平点亮,高电平熄灭。如果你的LED不亮,试试把set_high()换成set_low()。

重要:千万不要在中断服务函数里长时间操作GPIO。中断服务应该快速执行,GPIO的置高置低操作虽然快,但如果配合了延时,就会阻塞中断。正确的做法是在中断里只设置一个标志位,在主循环里处理GPIO操作。

好了,这一章的内容就到这里。GPIO看似简单,但它是所有外设操作的基础。把这一章吃透了,后面的UART、SPI、I2C都会轻松很多。