2. GPIO 外设驱动基础:从寄存器到实战
GPIO,说白了就是芯片的「手脚」。你要让 LED 亮、检测按键、驱动小电机,都得通过它。我刚开始用 Rust 写嵌入式驱动时,第一件事就是把 GPIO 的寄存器模型搞清楚——寄存器看不懂,后面全是瞎蒙。
2.1 GPIO 寄存器模型
每个 GPIO 端口背后都有一组寄存器。以 STM32 为例,一个 GPIO 端口通常包含以下寄存器:
| 寄存器 | 偏移 | 作用 |
|---|---|---|
| MODER | 0x00 | 模式选择(输入/输出/复用/模拟) |
| OTYPER | 0x04 | 输出类型(推挽/开漏) |
| OSPEEDR | 0x08 | 输出速度 |
| PUPDR | 0x0C | 上下拉配置 |
| IDR | 0x10 | 输入数据寄存器(只读) |
| ODR | 0x14 | 输出数据寄存器 |
| BSRR | 0x18 | 位设置/复位寄存器 |
嗯,这里要注意:BSRR 是写 1 有效的寄存器。你往低 16 位写 1,对应引脚输出高电平;往高 16 位写 1,对应引脚输出低电平。这个设计很巧妙——一次写入就能同时控制多个引脚,不需要读-改-写。
核心思路:Rust 里操作寄存器,我习惯用 volatile 读写 + 指针偏移。别用全局变量,编译器优化会坑你。
2.2 输出模式实现
输出模式分两步:配模式、写数据。
先看 MODER 寄存器。每个引脚占 2 位:
- 00:输入模式
- 01:输出模式
- 10:复用功能
- 11:模拟模式
举个例子,让 PA5 变成输出:
// 假设 GPIOA 基地址为 0x40020000
let gpioa = 0x40020000 as *mut u32;
let moder = gpioa as *mut u32; // MODER 偏移 0x00
// 读取当前 MODER 值
let mut val = unsafe { moder.read_volatile() };
// 清除 PA5 对应的 2 位(位 10-11)
val &= !(0b11 << 10);
// 设置为输出模式(01)
val |= 0b01 << 10;
unsafe { moder.write_volatile(val); }
写数据就更直接了。往 ODR 对应位写 1 或 0:
let odr = (gpioa as usize + 0x14) as *mut u32;
// PA5 输出高电平
unsafe { odr.write_volatile(1 << 5); }
// PA5 输出低电平
unsafe { odr.write_volatile(0 << 5); }
我的习惯:写输出用 BSRR 代替 ODR。BSRR 写 1 有效,不会影响其他引脚状态。ODR 是读-改-写,多线程或中断里容易出问题。
2.3 输入模式实现
输入模式更简单——配好模式,读 IDR 就行。
把引脚配成输入:
// 清除对应位,设为 00(输入模式)
val &= !(0b11 << 10);
unsafe { moder.write_volatile(val); }
读输入电平:
let idr = (gpioa as usize + 0x10) as *mut u32;
let bit = unsafe { idr.read_volatile() } & (1 << 5);
if bit != 0 {
// 高电平
} else {
// 低电平
}
你想想看,读输入其实就这一行代码。但坑往往不在读本身,而在外部信号的质量。按键按下会有抖动,机械开关弹跳能持续 5-20ms。我曾经在一个项目里直接读按键电平做触发,结果一次按下触发了七八次中断……
避坑:读输入前一定要确认引脚电平与 MCU 逻辑电平匹配。3.3V 的芯片读 5V 信号,不处理会烧引脚。加电平转换或分压电阻是常规操作。
2.4 上下拉配置
上下拉解决的是「浮空」问题。引脚不接任何东西时,电平不确定——外界一点噪声就能让读值乱跳。
PUPDR 寄存器每个引脚占 2 位:
- 00:无上下拉
- 01:上拉
- 10:下拉
- 11:保留
配置上拉:
let pupdr = (gpioa as usize + 0x0C) as *mut u32;
let mut val = unsafe { pupdr.read_volatile() };
val &= !(0b11 << 10); // 清除 PA5 的上下拉位
val |= 0b01 << 10; // 上拉
unsafe { pupdr.write_volatile(val); }
下拉同理,改成 0b10 就行。
经验之谈:按键电路我习惯用外部上拉 + 内部下拉关闭。外部电阻值稳定,不受温度影响。内部上拉电阻一般在 30-50kΩ,驱动能力弱,长线场景容易受干扰。
2.5 开漏输出与推挽输出
这两个概念,我当年学的时候也绕了一阵。说白了:
- 推挽输出:能主动拉高、拉低。像开关电源,要么接 VCC,要么接 GND。
- 开漏输出:只能拉低,不能主动拉高。要输出高电平,得靠外部上拉电阻。
OTYPER 寄存器每个引脚只占 1 位:
- 0:推挽输出
- 1:开漏输出
配置开漏:
let otyper = (gpioa as usize + 0x04) as *mut u32;
let mut val = unsafe { otyper.read_volatile() };
val |= 1 << 5; // PA5 开漏
unsafe { otyper.write_volatile(val); }
什么时候用开漏?
- I2C 总线:必须开漏,多个设备才能共用一根线
- 电平转换:开漏 + 外部上拉到目标电压,3.3V 芯片就能驱动 5V 设备
- 线与逻辑:多个开漏输出接一起,任何一个拉低,总线就是低
我踩过的坑:开漏输出忘了加上拉电阻。示波器一看,波形上升沿慢得像蜗牛。加上 4.7kΩ 上拉,瞬间正常。上拉电阻值别太小,否则功耗大;也别太大,否则信号爬不上去。一般 1kΩ-10kΩ 之间选。
2.6 知识结构总览
下面这张图把 GPIO 的核心知识点串起来了。我画图时习惯把「配置」和「数据」分开——配置是一次性的,数据是实时读写的。
2.7 实战要点总结
写 GPIO 驱动,记住这几条:
- 先配模式,再读写数据——顺序反了,读出来全是 0
- 输出用 BSRR,别用 ODR——避免读-改-写竞争
- 输入要配上拉或下拉——浮空引脚是万恶之源
- 开漏输出必须加外部上拉——不然高电平出不来
- Rust 里用 volatile 操作寄存器——编译器不会优化掉你的读写
最后提醒一句:别在驱动层写死引脚号。用 Rust 的泛型或 const generics 把引脚抽象出来,上层代码才能复用。我早期项目里每个外设都硬编码引脚,后来换板子改到吐血……