Appearance
GPIO 与最小系统
概念
上一章讲的都是 CPU 内部的事(寄存器、调用约定、流水线)。GPIO 是 CPU 第一次把手伸到芯片外面:一根引脚,能被程序拉高或读低。
一句话说清它是什么:GPIO(General Purpose Input/Output,通用输入输出)是一个可以被程序控制的引脚——你写 1 它变高电平,你读它得到当前电平。
它的"通用"体现在两个方向都能用:
| 方向 | 谁驱动这根线 | 程序做什么 | 典型用途 |
|---|---|---|---|
| 输出 | 芯片自己 | 写 ODR / BSRR | 点灯、驱动继电器、片选 |
| 输入 | 外部电路 | 读 IDR | 按键、传感器开关量、跳线 |
为什么"最小系统"要和 GPIO 写在一起:芯片要能跑起来、引脚要能稳定工作,光有 GPIO 不够——还需要电源、时钟、复位这些"让它活着"的东西。最小系统 = 让一颗 MCU 能独立工作的最少外围。
这一层回答了上一层什么问题:上一章说清了处理器内部有哪些寄存器,但寄存器是抽象的。GPIO 把"寄存器"落成了一根看得见的引脚:写 ODR 的第 5 位,PA5 引脚上的电压就真的变了。从这一章起,代码和物理世界第一次真正连上。
原理
一、一个引脚能做什么:GPIO 的四种模式
STM32 的每个引脚用 2 位配置模式(MODER 寄存器),共四种:
| MODER 值 | 模式 | 说明 |
|---|---|---|
00 | 输入 | 引脚只读,电平由外部决定 |
01 | 通用输出 | 最常用,程序完全控制电平 |
10 | 复用功能 | 交给片上外设(UART/SPI/定时器)控制 |
11 | 模拟 | 关闭数字电路,接 ADC/DAC |
"复用功能"这一项是后面所有外设的基础:UART 的 TX 引脚、SPI 的 SCK 引脚,本质上都是"把某个引脚从 GPIO 切给外设"。配置外设的第一步永远是先把引脚设为复用模式,再选择复用编号(AFR 寄存器)。
二、输出:推挽与开漏
输出级有两种电路结构,差别在"能不能主动拉高":
text
推挽(Push-Pull) 开漏(Open-Drain)
Vcc Vcc
| |
[P-MOS] (无上管)
| |
--+-- 引脚 --+-- 引脚
| |
[N-MOS] [N-MOS]
| |
GND GND| 对比 | 推挽 | 开漏 |
|---|---|---|
| 高电平来源 | 芯片内部 P-MOS 拉高 | 外部上拉电阻 |
| 输出高电平 | 接近 Vcc | 由外部上拉决定 |
| 能否线与 | 不能(两个推挽同时输出会短路) | 能(一起拉低起"线与"作用) |
| 典型用途 | 点灯、驱动单根信号 | I2C 总线、共享中断线、电平转换 |
开漏的两个关键点:① 只能"拉低"或"放开",拉高靠别人;② 因为不会互相短路,多个设备可以挂同一根线——这正是 I2C 只用两根线就能挂一堆从机的原因。漏极开路必须配上拉电阻,否则高电平永远是悬空的。
三、输入:上拉、下拉与施密特触发器
引脚作输入时,如果外部不驱动,它处在悬空状态,读回来的值是随机的(还会因为浮空而耗电、被干扰)。所以要有上下拉电阻把它"钉"在一个确定电平上:
| PUPDR 值 | 配置 | 空闲电平 | 典型场景 |
|---|---|---|---|
00 | 浮空 | 不确定 | 外部已有驱动 |
01 | 上拉 | 高 | 按键接 GND(按下变低) |
10 | 下拉 | 低 | 按键接 Vcc(按下变高) |
输入通路里还有一个不起眼但很关键的东西:施密特触发器。它有两个阈值(上阈值、下阈值),电平要越过上阈值才算 1,跌到下阈值以下才算 0。
高阈值 --+-------------------
| <-- 迟滞带(噪声在这里不会翻转)
低阈值 --+-------------------好处是抗噪声:信号在阈值附近抖动时不会来回翻转。代价是输入电平必须足够"干净"——如果是缓慢变化的模拟量,施密特触发器只会给出一个"某时刻翻转"的数字结果。
四、寄存器模型:五个寄存器管一个端口
STM32 一个 GPIO 端口(GPIOA/GPIOB…)有 16 个引脚,用一组寄存器统一管理:
| 寄存器 | 作用 | 每引脚占几位 |
|---|---|---|
| MODER | 设置输入/输出/复用/模拟 | 2 位 |
| OTYPER | 推挽 / 开漏 | 1 位 |
| OSPEEDR | 输出速度(压摆率) | 2 位 |
| PUPDR | 上拉 / 下拉 | 2 位 |
| IDR | 读入的引脚电平(只读) | 1 位 |
| ODR | 要输出的电平(可读可写) | 1 位 |
| BSRR | 原子的置位 / 复位 | 16 + 16 位 |
"每引脚占 2 位"直接决定了位运算的写法:设置 PA5 为输出,是要把第 5×2 = 10、11 两位写成 01:
text
MODER &= ~(3 << (5 x 2)); // 先清零这 2 位
MODER |= (1 << (5 x 2)); // 再写 01先清零再置位是配置寄存器的通用套路:直接用 |= 会在原来已经是 10 的位上留下错误组合。
五、为什么要 BSRR 而不是 ODR
改一个引脚的电平,两种写法:
| 写法 | 展开成什么 | 风险 |
|---|---|---|
对 ODR 读-改-写(按位或上 1 << 5) | 读 ODR → 改 bit5 → 写回 ODR | 读-改-写三步,中间可被中断打断 |
BSRR = (1 << 5) | 一次写,硬件只改 bit5 | 原子,不可打断 |
ODR |= (1<<5) 的危险在于:如果读到 ODR 之后、写回之前来了一个中断,而 ISR 改了同一个端口的另一个引脚,那么主程序写回时会把 ISR 的修改覆盖掉,那一脚的电平就丢了。
BSRR 把"置位"和"复位"塞进同一个 32 位字:
text
BSRR 低 16 位:写 1 -> 对应引脚置高(其余位写 0 无影响)
BSRR 高 16 位:写 1 -> 对应引脚置低于是置位 PA5 就是 BSRR = 0x00000020,复位 PA5 就是 BSRR = 0x00200000——一次写、只影响目标位,天生原子。
六、最小系统的五要素
让一颗 MCU 能独立工作,除了芯片本身,至少还要五样:
| 要素 | 作用 | 少了的后果 |
|---|---|---|
| 电源 | 供电 + 去耦电容(每个电源脚配 100 nF) | 复位或跑飞 |
| 时钟 | 晶振(外接 HSE)或内部 RC | 跑不起来或频率不准 |
| 复位 | 复位电路(上拉 + 电容,或专用复位芯片) | 上电状态不定 |
| 启动模式 | BOOT 引脚选择从哪启动(Flash / 系统存储器 / SRAM) | 进不了自己的程序 |
| 调试接口 | SWD(两根线:SWCLK + SWDIO) | 无法烧写/调试 |
晶振的作用是把频率"乘"上去:常见做法是外接 8 MHz 晶振,经 PLL 倍频到 72 MHz 给内核用,再分频给各个外设总线。频率越高越费电,所以外设总线通常跑得比内核慢。
七、LED 限流电阻与按键去抖
两个最基础的"接口常识",但考试和实操都会考。
LED 必须串限流电阻,否则电流只受 LED 与引脚的内阻限制,会烧掉其中之一。公式来自欧姆定律:
text
R = (Vcc - Vf) / I其中 Vf 是 LED 的正向压降(红光约 2.0 V,蓝白光约 3.2 V),I 是想要的电流(指示灯一般 2~10 mA)。算完要向上取标准电阻值——取小了电流超标,取大了灯偏暗。
机械按键必须去抖:金属触点闭合时会在 5~20 ms 内反复通断,程序如果直接读,一次按下会被识别成好几次。两种做法:
| 做法 | 原理 | 代价 |
|---|---|---|
| 硬件去抖 | 上拉电阻 + 并联电容,用 RC 把抖动"滤平" | 占板子面积,恢复慢 |
| 软件去抖 | 检测到变化后延时 20 ms 再读一次 | 占 CPU 时间(或用定时器扫描) |
RC 时间常数决定硬件去抖的效果:τ = R × C,电容电压充到约 0.7 τ 附近就进入可判定区间。
示例
例 1:LED 限流电阻的完整计算
三种常见组合,先算理论值:
Vcc=3.3 V, Vf=2.0 V, I=5.0 mA -> R = (3.3-2.0)/0.005 = 260.0 Ω
Vcc=3.3 V, Vf=3.2 V, I=5.0 mA -> R = (3.3-3.2)/0.005 = 20.0 Ω
Vcc=5.0 V, Vf=2.0 V, I=10.0 mA -> R = (5.0-2.0)/0.010 = 300.0 Ω第二行值得注意:3.3 V 供电点一颗蓝光 LED,只剩 0.1 V 留给电阻,20 Ω 这么小的电阻意味着电流对电压极其敏感——电源波动 0.1 V,电流就翻倍。所以蓝光 LED 要么降低电流,要么用更高的供电电压。
再看取标准值之后实际电流是多少(Vcc=3.3 V, Vf=2.0 V):
R=150 Ω -> I = 1.3/150 = 8.6667 mA
R=220 Ω -> I = 1.3/220 = 5.9091 mA
R=270 Ω -> I = 1.3/270 = 4.8148 mA
R=330 Ω -> I = 1.3/330 = 3.9394 mA
R=470 Ω -> I = 1.3/470 = 2.7660 mA理论值 260 Ω 取标准值 270 Ω,实际 4.8148 mA,仍在引脚 20 mA 的上限内。"算完取标准值再回算电流"是这道题的固定两步。
例 2:BSRR 的位运算
置位和复位分别写在低 16 位与高 16 位:
置位 P5 : BSRR = 0x00000020 复位 P5 : BSRR = 0x00200000
置位 P13 : BSRR = 0x00002000 复位 P13 : BSRR = 0x20000000对比一下"普通写"的写法:
PA5 输出模式:MODER = 0b01 << 10 = 0x00000400同一件事在不同寄存器里用的是不同的位偏移:MODER 每脚 2 位所以偏移 10,BSRR 每脚 1 位所以偏移 5 与 5+16。这是 GPIO 编程最容易写错的地方——记住"MODER 乘 2,BSRR 加上 16"。
例 3:时钟树与 SysTick
从 8 MHz 晶振到 72 MHz 内核:
HSE 8 MHz x PLL 9 = SYSCLK 72 MHz
SysTick 1 ms 重装值 = 72000000 / 1000 - 1 = 71999
AHB = 72 MHz, APB1 (/2) = 36.0 MHz, APB2 (/1) = 72 MHz减 1 是因为计数器从重装值倒数到 0 才是一次中断(共 重装值 + 1 个计数)。写 SysTick 时忘掉这个 -1,定时就是"多一个计数周期"的偏差。
例 4:C 实现——GPIO 寄存器与 BSRR 原子位操作
用结构体模拟寄存器,跑一遍"初始化 + 闪三次"。
/* gpio.c —— 用 C 模拟 GPIO 寄存器与 BSRR 原子位操作 */
#include <stdio.h>
#include <stdint.h>
typedef struct {
uint32_t MODER; /* 模式:每引脚 2 位 */
uint32_t OTYPER; /* 输出类型:推挽 / 开漏 */
uint32_t ODR; /* 输出数据 */
uint32_t IDR; /* 输入数据 */
uint32_t BSRR; /* 置位 / 复位(写 1 生效) */
} GPIO_TypeDef;
static GPIO_TypeDef gpioa;
static void gpio_init_out(int pin)
{
gpioa.MODER &= ~(3u << (pin * 2)); /* 先清掉这 2 位 */
gpioa.MODER |= (1u << (pin * 2)); /* 01 = 通用输出 */
}
static void gpio_set(int pin)
{
gpioa.BSRR = (1u << pin); /* 低 16 位:置位 */
gpioa.ODR |= (1u << pin); /* 硬件把置位作用到 ODR */
}
static void gpio_reset(int pin)
{
gpioa.BSRR = (1u << (pin + 16)); /* 高 16 位:复位 */
gpioa.ODR &= ~(1u << pin);
}
static int gpio_read(int pin)
{
return (int)((gpioa.ODR >> pin) & 1u);
}
int main(void)
{
int k;
printf("=== init PA5 as push-pull output ===\n");
gpio_init_out(5);
printf("MODER = 0x%08X (bits 11:10 = 01)\n", gpioa.MODER);
printf("\n=== blink 3 times (BSRR set/reset) ===\n");
for (k = 0; k < 3; k++) {
gpio_set(5);
printf("set #%d: BSRR=0x%08X -> PA5=%d\n", k + 1, gpioa.BSRR, gpio_read(5));
gpio_reset(5);
printf("reset #%d: BSRR=0x%08X -> PA5=%d\n", k + 1, gpioa.BSRR, gpio_read(5));
}
printf("\n=== current-limiting resistor ===\n");
printf("Vcc=3.3 V, Vf=2.0 V, target 5.0 mA -> R = %.1f ohm, use 270 ohm\n",
(3.3 - 2.0) / 5e-3);
printf("actual current = (3.3-2.0)/270 = %.4f mA (< 20 mA limit)\n",
(3.3 - 2.0) / 270 * 1000);
printf("\n=== why BSRR is atomic ===\n");
printf("set word 0x00000020 and reset word 0x00200000 live in low/high 16 bits\n");
printf("ODR ^= (1<<5) is read-modify-write and can lose a bit if interrupted\n");
return 0;
}
c 本站为静态站,不提供在线运行;可复制到本地用 gcc / python 执行
预期输出:
=== init PA5 as push-pull output ===
MODER = 0x00000400 (bits 11:10 = 01)
=== blink 3 times (BSRR set/reset) ===
set #1: BSRR=0x00000020 -> PA5=1
reset #1: BSRR=0x00200000 -> PA5=0
set #2: BSRR=0x00000020 -> PA5=1
reset #2: BSRR=0x00200000 -> PA5=0
set #3: BSRR=0x00000020 -> PA5=1
reset #3: BSRR=0x00200000 -> PA5=0
=== current-limiting resistor ===
Vcc=3.3 V, Vf=2.0 V, target 5.0 mA -> R = 260.0 ohm, use 270 ohm
actual current = (3.3-2.0)/270 = 4.8148 mA (< 20 mA limit)
=== why BSRR is atomic ===
set word 0x00000020 and reset word 0x00200000 live in low/high 16 bits
ODR ^= (1<<5) is read-modify-write and can lose a bit if interrupted注意 set #1 与 reset #1 打印的 BSRR 值不同(0x20 与 0x00200000),但引脚状态都正确——这正是 BSRR 的设计意图:同一个寄存器,低半区管置位、高半区管复位。
例 5:Python——电阻、位操作与去抖验算
# GPIO 与最小系统:限流电阻、位操作、时钟与去抖
print("=== 一、LED 限流电阻 R = (Vcc - Vf) / I ===")
for vcc, vf, i_ma in [(3.3, 2.0, 5.0), (3.3, 3.2, 5.0), (5.0, 2.0, 10.0)]:
print(f"Vcc={vcc} V, Vf={vf} V, I={i_ma} mA -> R = {(vcc - vf) / (i_ma / 1000):.1f} Ω")
print("\n=== 二、取标准电阻后的实际电流 ===")
vcc, vf = 3.3, 2.0
for r_std in (150, 220, 270, 330, 470):
print(f"R={r_std:3d} Ω -> I = {(vcc - vf) / r_std * 1000:.4f} mA")
print("\n=== 三、BSRR 原子置位 / 复位 ===")
for b in (5, 13):
print(f"置位 P{b}: BSRR = 0x{(1 << b):08X} 复位 P{b}: BSRR = 0x{(1 << (b + 16)):08X}")
print("\n=== 四、MODER:每引脚 2 位(01 = 通用输出)===")
for pin in (5, 13):
print(f"PA{pin} 输出:MODER = 0b01 << {pin * 2} = 0x{(1 << (pin * 2)):08X}")
print("\n=== 五、时钟树:8 MHz 晶振 -> 72 MHz ===")
hse, pll = 8_000_000, 9
sysclk = hse * pll
print(f"HSE {hse / 1e6:.0f} MHz x PLL {pll} = SYSCLK {sysclk / 1e6:.0f} MHz")
print(f"SysTick 1 ms 重装值 = {sysclk} / 1000 - 1 = {sysclk // 1000 - 1}")
print(f"AHB = {sysclk / 1e6:.0f} MHz, APB1 (/2) = {sysclk / 2e6:.1f} MHz, APB2 (/1) = {sysclk / 1e6:.0f} MHz")
print("\n=== 六、按键 RC 去抖 ===")
for r_pull, c in [(10000, 10e-9), (4700, 100e-9), (10000, 100e-9)]:
tau = r_pull * c
print(f"R={r_pull / 1000:4.1f} kΩ, C={c * 1e9:3.0f} nF -> tau = {tau * 1e6:7.1f} us, 0.7*tau = {0.7 * tau * 1e6:7.1f} us")
print("机械按键抖动 5~20 ms -> 软件延时 20 ms 即可覆盖")
python 本站为静态站,不提供在线运行;可复制到本地用 gcc / python 执行
预期输出:
=== 一、LED 限流电阻 R = (Vcc - Vf) / I ===
Vcc=3.3 V, Vf=2.0 V, I=5.0 mA -> R = 260.0 Ω
Vcc=3.3 V, Vf=3.2 V, I=5.0 mA -> R = 20.0 Ω
Vcc=5.0 V, Vf=2.0 V, I=10.0 mA -> R = 300.0 Ω
=== 二、取标准电阻后的实际电流 ===
R=150 Ω -> I = 8.6667 mA
R=220 Ω -> I = 5.9091 mA
R=270 Ω -> I = 4.8148 mA
R=330 Ω -> I = 3.9394 mA
R=470 Ω -> I = 2.7660 mA
=== 三、BSRR 原子置位 / 复位 ===
置位 P5: BSRR = 0x00000020 复位 P5: BSRR = 0x00200000
置位 P13: BSRR = 0x00002000 复位 P13: BSRR = 0x20000000
=== 四、MODER:每引脚 2 位(01 = 通用输出)===
PA5 输出:MODER = 0b01 << 10 = 0x00000400
PA13 输出:MODER = 0b01 << 26 = 0x04000000
=== 五、时钟树:8 MHz 晶振 -> 72 MHz ===
HSE 8 MHz x PLL 9 = SYSCLK 72 MHz
SysTick 1 ms 重装值 = 72000000 / 1000 - 1 = 71999
AHB = 72 MHz, APB1 (/2) = 36.0 MHz, APB2 (/1) = 72 MHz
=== 六、按键 RC 去抖 ===
R=10.0 kΩ, C= 10 nF -> tau = 100.0 us, 0.7*tau = 70.0 us
R= 4.7 kΩ, C=100 nF -> tau = 470.0 us, 0.7*tau = 329.0 us
R=10.0 kΩ, C=100 nF -> tau = 1000.0 us, 0.7*tau = 700.0 us
机械按键抖动 5~20 ms -> 软件延时 20 ms 即可覆盖考点
- MODER 每脚 2 位、BSRR 每脚 1 位:设置 PA5 输出的位偏移是
5×2 = 10;置位 PA5 是1 << 5,复位是1 << (5+16)。这两套偏移混用是最高频的实操错误。 - 推挽 vs 开漏:推挽能主动拉高,开漏只能拉低(高电平靠外部上拉),开漏才能实现"线与"和挂总线——I2C 必须是开漏。
- 上下拉的选择跟着按键接法走:按键另一端接 GND → 用上拉(按下读到 0);接 Vcc → 用下拉(按下读到 1)。
- LED 限流电阻
R = (Vcc - Vf) / I,两步走:先算理论值,再取标准值后回算实际电流。别忘了 LED 有正向压降Vf(这是最常见的漏项)。 - 引脚电流上限约 20 mA:算出来的电流超了就必须加三极管/MOS 驱动,不能硬接。
- BSRR 的意义在"原子":
ODR |= (1<<n)是读-改-写,可能在中断里丢位;BSRR 一次写、只影响目标位。 - SysTick 重装值要减 1:
(f_clk / f_tick) - 1,因为是从重装值倒数到 0。 - 软件去抖的常数是 20 ms:机械按键抖动 5~20 ms,延时取上限。
- 易错:把"复用功能"忘掉(配 UART 引脚时只设了输出模式,没切复用);以为上拉电阻随便取(
4.7 kΩ到10 kΩ是常规区间,太小费电、太大抗扰差)。
小结
- GPIO 是 CPU 伸到芯片外的那根手:写寄存器就改电平,读寄存器就知道外面是高是低。
- 四种模式:输入、通用输出、复用、模拟;后两种分别是外设与 ADC 的入口。
- 输出分推挽与开漏:推挽主动拉高;开漏只能拉低,靠外部上拉,换来"线与"能力(I2C 的基础)。
- 输入要上下拉把电平钉住,施密特触发器给噪声留迟滞带。
- 寄存器分工:MODER 每脚 2 位管模式,BSRR 每脚 1 位管原子置位/复位,ODR 存输出电平,IDR 存输入电平。
- 最小系统五要素:电源、时钟、复位、启动模式、调试接口——少一样就跑不起来。
- 两个必算的数:LED 限流电阻
(Vcc-Vf)/I与 SysTick 重装值f_clk/f_tick - 1。
回到主线:这一章把 silicon 里的 CMOS 互补结构 第一次用到了引脚上——推挽输出就是一对互补 MOS,开漏就是"只用下管";也把 arch 里的 I/O 系统 从"抽象端口"变成了"具体的寄存器地址"。它交出去的东西是一根能被程序控制的线,下一章在这根线上跑起一套协议。
这一章只答了"怎么让一根引脚变高变低",还没答"怎么把一串字节从 A 传到 B"。下一章给这根线定规矩——起始位、数据位、停止位。
下一篇:UART 串口通信
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。