3. STM32硬件基础回顾
各位同学,欢迎来到第三章。从这章开始,我们终于要跟硬件打交道了。
GPIO,全称是General-Purpose Input/Output,通用输入输出口。说白了,它就是芯片上那些可以编程控制的引脚。你可以让它们输出高电平或低电平,也可以读取外部信号是0还是1。
我刚开始学嵌入式那会儿,觉得GPIO不就是点个灯嘛,有啥好学的?后来做项目才发现,GPIO的配置细节直接决定了整个系统的稳定性。嗯,咱们今天就从最基础的开始,一步步来。
3.1 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都会轻松很多。