GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
本篇是《Zig 裸机开发实战:STM32F103ZET6 全栈解析》系列第 6 篇。上一篇:寄存器级编程:PAC、MMIO 与 volatile 语义,下一篇:时钟树配置:HSE、PLL 与总线分频,目录见文末。
GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
导读
F103 的 GPIO 是"看起来最简单、坑却最密"的外设:8 种模式挤在每引脚 4 位里,Speed 枚举的取值顺序反直觉,put 与 toggle 的原子性不同,上下拉只在输入模式有效,还有一个 E/F/G 端口静默映射到 A 的上游 bug。这一篇把 microzig 的 gpio.Pin、pins.GlobalConfiguration 两条路径全部讲透,并给出按键消抖与双 LED 交替闪烁的完整例子。
为什么需要理解 GPIO 的编码
F1 系列的 GPIO 与后来所有 STM32 不同:没有独立的"模式寄存器 + 输出类型 + 速度"分层,每个引脚的所有配置压缩在 CRL/CRH 的 4 位里。这带来三个直接后果:
- 配置一个引脚 = 一次 4 位的读改写(HAL 的
write_pin_config就是这么做的),配置 16 个引脚的端口要动整个 32 位寄存器——跨引脚的读改写竞态窗口比 F4 大。 - "输入/输出方向"不是独立概念,它编码在 MODE 位里(MODE=00 是输入,非 0 是输出且值即速度)。所以 HAL 没有
set_direction——set_output_mode(mode, speed)写完就是输出。 - 上下拉是输入模式的子模式(CNF=10 + ODR 位决定上拉/下拉),输出模式配上下拉在硬件上不存在。HAL 里
set_pull是通过写 BSRR/BRR(即 ODR)实现的,这也解释了它的实现方式。
理解编码还有一个现实动机:第 05 篇的 PE5 bug 让我们知道 E/F/G 口只能走寄存器层,而寄存器层配置 GPIO 就是在写这 4 位——不懂编码表寸步难行。
代码案例:双 LED 交替闪烁 + 按键读取
const microzig = @import("microzig");
const stm32 = microzig.hal;
const rcc = stm32.rcc;
const gpio = stm32.gpio;
const time = stm32.time;
pub const panic = microzig.panic;
pub const std_options = microzig.std_options(.{});
comptime {
_ = microzig.export_startup();
}
pub fn main() !void {
_ = try rcc.apply(.{
.SYSCLKSource = .PLL1_P,
.PLLSourceVirtual = .HSE_Div_PREDIV,
.PLLMUL = .Mul9,
.APB1CLKDivider = .Div2,
.RTCClockSelection = .LSI,
.flags = .{
.RTCUsed_ForRCC = true,
.HSEOscillator = true,
},
});
rcc.enable_clock(.GPIOB);
rcc.enable_clock(.GPIOC);
rcc.enable_clock(.GPIOA);
// 输出:LED1=PB5、LED2=PC0,推挽 50MHz
const led1 = gpio.Pin.from_port(.B, 5);
const led2 = gpio.Pin.from_port(.C, 0);
led1.set_output_mode(.general_purpose_push_pull, .max_50MHz);
led2.set_output_mode(.general_purpose_push_pull, .max_50MHz);
// 输入:KEY1=PA0,按接线表外接 3.3V,配下拉输入
//(接线:按键一端 3.3V、一端 PA0;按下=高电平)
const key = gpio.Pin.from_port(.A, 0);
key.set_input_mode(.pull);
key.set_pull(.down);
// 交替闪烁:put 是原子写,两个灯互不干扰
while (true) {
led1.put(1);
led2.put(0);
time.sleep_ms(250);
led1.put(0);
led2.put(1);
time.sleep_ms(250);
// 顺带读一次按键(简单消抖:连续两次读到高才算按下)
if (key.read() == 1) {
time.sleep_us(2000); // 2ms 跨过抖动期
if (key.read() == 1) {
// 按键有效,这里可以翻转一个状态
}
}
}
}
逐段解析
CRL/CRH 的 4 位编码表
每个端口两个配置寄存器:CRL 管引脚 0–7,CRH 管引脚 8–15。每引脚 4 位 = MODE[1:0](低 2 位)+ CNF[1:0](高 2 位)。完整编码:
输入模式(MODE=00):
| CNF[1:0] | 含义 | HAL 对应 |
|---|---|---|
| 00 | 模拟输入 | set_input_mode(.analog) |
| 01 | 浮空输入(复位默认) | set_input_mode(.floating) |
| 10 | 上拉/下拉输入(由 ODR 同位决定) | set_input_mode(.pull) + set_pull(.up/.down) |
| 11 | 保留 | — |
输出模式(MODE 非 00):
| CNF[1:0] | MODE=01(10 MHz) | MODE=10(2 MHz) | MODE=11(50 MHz) |
|---|---|---|---|
| 00 | 通用推挽输出 | 同左 | 同左 |
| 01 | 通用开漏输出 | 同左 | 同左 |
| 10 | 复用推挽输出 | 同左 | 同左 |
| 11 | 复用开漏输出 | 同左 | 同左 |
注意 MODE 的值与速度的对应:01=10 MHz、10=2 MHz、11=50 MHz——这就是 Speed 枚举反直觉的来源。microzig 的定义(hals/STM32F103/gpio.zig):
pub const Speed = enum(u2) {
reserved, // 0
max_10MHz, // 1
max_2MHz, // 2
max_50MHz, // 3
};
枚举值 max_10MHz = 1, max_2MHz = 2, max_50MHz = 3,顺序与名字感觉完全相反(按数值看"2 在 10 和 50 中间")。这是 ST 硬件编码的直接映射。写错不会报错,只是速度不对——低速档驱动长线或高频信号时边沿变缓,一般应用无感,但做 PWM 或长线驱动时要意识到。
HAL 的 set_output_mode 实现是 config = s_speed + (m_mode << 2),与编码表完全一致:OutputMode 枚举 general_purpose_push_pull / general_purpose_open_drain / alternate_function_push_pull / alternate_function_open_drain 四个值对应 CNF 00/01/10/11。
InputMode 只有三种有用值
pub const InputMode = enum(u2) {
analog, // ADC 用
floating, // 外部已有确定电平(如 UART RX 由对方驱动)
pull, // 内部上下拉(弱,约 30-50 kOhm),配合 set_pull
reserved,
};
set_pull(.up) / set_pull(.down) 的实现是写 BSRR/BRR(设置/清除 ODR 同位)——它只在上拉/下拉输入模式下有意义(CNF=10 时 ODR 位选择上拉或下拉)。对浮空输入调 set_pull 无效果;对输出引脚调 set_pull 实际是在改输出电平。microzig 的 pins.GlobalConfiguration 对"输出引脚配上下拉"直接 @compileError("Only input pins can have pull up/down enabled"),gpio.Pin 这条路则靠你自己保证。
按键电路与本例配置的对应关系:按键接 3.3 V 与 PA0 之间 → 平时需要 PA0 是低、按下是高 → 配下拉输入;反过来按键接 GND 就配上拉输入、按下为低。用内部上下拉(约 30–50 kΩ)驱动按键没问题;长线或强干扰环境建议外部 10 kΩ 上拉 + 100 nF 滤波,内部拉只作保险。
put 与 toggle 的原子性(量化)
// put(1) 的实现:一次写 BSRR,原子
port.BSRR.raw = gpio.mask();
// toggle 的实现:读-改-写 ODR,非原子
port.ODR.raw ^= gpio.mask();
put 的另一个细节:put(0) 写的是 BSRR = mask << 16(高 16 位是复位通道)。BSRR 低 16 位写 1 置位、高 16 位写 1 复位、写 0 无动作,所以"一次写同时置位一些引脚、复位另一些引脚"是可能的——BSRR.raw = (1<<5) | (1<<(16+0)) 一条 store 让 PB5 高、PC0 低,这是双 LED 交替闪烁的最快写法(省一次翻转的中间态)。
toggle 的竞态窗口:读 ODR → 异或 → 写回,三步之间任何对同端口 ODR 的其他修改(另一个 ISR、DMA 触发的 GPIO 无此功能但中断可以)都会被覆盖。经验法则:中断与主循环共享的端口,全部用 put;确认独占的引脚才用 toggle。
GlobalConfiguration:声明式配置及其局限
hal.pins.GlobalConfiguration 是"编译期配置表"风格(全仓库只有官方 blinky 示例用它):
const pins = (stm32.pins.GlobalConfiguration{
.GPIOB = .{
.PIN5 = .{ .name = "led1", .mode = .{ .output = .general_purpose_push_pull } },
},
.GPIOC = .{
.PIN0 = .{ .name = "led2", .mode = .{ .output = .general_purpose_push_pull } },
},
}).apply();
// pins.led1 / pins.led2 可直接用
三个要点与两个局限。要点一:端口字段名是 GPIOA..GPIOG(pins.Port 枚举),不是 gpio.Port 的 .A...G——两套枚举并存,混写报 no field named 'A'。要点二:apply() 自动使能用到的端口时钟(这是它比手动 enable_clock 省事的地方)。要点三:返回的匿名结构体字段名来自 .name。局限一:对输出引脚配 .pull 直接编译错误。局限二:它救不了 E/F/G——内部生成逻辑最终走 gpio.Pin.from_port,E/F/G 仍然静默打到 GPIOA。结论不变:E/F/G 只有寄存器层一条路。
按键消抖的两种写法
机械按键闭合/断开时有 1–20 ms 的电平抖动。两种主流软件消抖:
延时重读法(本例用的):读到按键电平变化后延时 2–10 ms 再读一次,两次一致才确认。优点是 3 行代码;缺点是阻塞(本例阻塞 2 ms),不适合实时性敏感的主循环。
状态机法(第 23 篇正式展开):用周期性采样(比如 1 ms 一次)+ 计数器,连续 N 次相同电平才切换状态。不阻塞、可同时管多个按键,代价是要一个时基(time.get_time_since_boot() 或定时器中断)。microzig.drivers 里还有现成的 DebouncedButton 驱动(vtable 风格、可在 host 上单测),第 14 篇之后会用。
本系列后续用 EXTI 中断处理按键(第 08 篇),消抖在中断侧同样适用:中断触发后重读引脚确认电平,或干脆禁用中断 + 定时器延迟后再使能。
运行现象
烧录后 PB5 与 PC0 上的两个 LED 每 250 ms 交替:LED1 亮时 LED2 灭,反之亦然,整个周期 500 ms。用逻辑分析仪同时抓两路:两路方波互为反相,周期 500 ms,翻转沿对齐(都在 put 那条 store 的时刻,亚微秒级)。
按键部分:串个 LED 或在调试器里观察变量,按下 KEY1(PA0 拉高)2 ms 后确认到高电平。未按下时 PA0 被内部下拉稳定在低,万用表量约 0 V;如果未按下时电平漂浮(读到随机值),检查 set_input_mode(.pull) 与 set_pull(.down) 是否都写了——只写其一,引脚处于浮空态。
常见问题
Q1:set_pull(.up) 写了但读到的还是随机电平。
成因:上下拉只存在于"上拉/下拉输入"模式。如果引脚模式是浮空输入(.floating)或输出模式,set_pull 要么无效要么改的是输出电平。另一个可能:模式与 pull 的调用顺序——先 set_input_mode(.pull) 再 set_pull(.up)(本例顺序),反过来虽然多数情况也能工作(都在改同一组寄存器位),但语义上先定模式再定拉更清晰。解决方案:确保 set_input_mode(.pull) + set_pull(.up/.down) 成对出现;浮空输入需要外部电阻;确认引脚没被板上的其他电路强制电平。
Q2:用了 .max_50MHz 但波形边沿很缓,或者长线驱动时信号变形。
成因:Speed 枚举是 max_10MHz=1, max_2MHz=2, max_50MHz=3,写错名字不报错只是档位不对。另外 50 MHz 档边沿陡但功耗与 EMI 也大,驱动长线时过冲反而更严重。解决方案:确认写的是 .max_50MHz;长线驱动考虑串联 22–100 ohm 电阻或降速到 2 MHz 档换边沿质量;PWM 输出(第 10 篇)用 50 MHz 档保证边沿时间只占周期的极小比例。
Q3:给 PE3 配了输出模式,结果 PA3 上的设备被误触发了。
成因:就是那个上游 bug——Pin.get_port() 对端口 4/5/6(E/F/G)返回 GPIOA,任何通过 gpio.Pin 或 pins.GlobalConfiguration 对 E/F/G 的操作都会写到 PA 的同号引脚。解决方案:E/F/G 一律寄存器层(第 05 篇的 CRL/CRH 位操作 + BSRR/BRR),或自写一个修正版辅助函数(复制 write_pin_config 逻辑但用正确的端口指针)。本系列 HAL 示例固定只用 PA/PB/PC 就是为了绕开它。
Q4:toggle() 偶尔出现"两个中断各翻转一次但灯只变了状态一次"或丢翻转。
成因:读改写竞态——两个执行流(嵌套中断、或中断与主循环)对同一端口的 ODR 做 ^=,后写回者覆盖先写回者的修改。解决方案:共享端口改用 put(BSRR 原子写);或翻转操作包进临界区(microzig.interrupt.enter_critical_section(),退出时 leave() 恢复中断状态);或按功能分端口,让每个 ISR 独占一个 GPIO 端口。第 22 篇会用逻辑分析仪把这个竞态拍下来。
Q5:pins.GlobalConfiguration 写 .A = ... 报 no field named 'A'。
成因:microzig 有两套端口枚举:gpio.Port 成员是 .A...G(给 Pin.from_port 用),pins.Port 成员是 GPIOA..GPIOG(给 GlobalConfiguration 的字段名用)。GlobalConfiguration 的字段名走后者。解决方案:GlobalConfiguration 里写 .GPIOB = .{ .PIN5 = ... };Pin.from_port 里写 .B。同时注意引脚字段名是 PIN0..PIN15 全大写。
Q6:读按键偶尔把一次按键读成两次(双触发)。
成因:抖动窗口超过了单次重读的间隔,或者主循环恰好两次轮询都落在抖动期之外。延时重读法的 2 ms 对多数按键够用,但对弹簧差的按键 20 ms 抖动也常见。解决方案:把重读延时提到 5–10 ms(代价是阻塞);换状态机消抖(连续 N 个采样点一致才确认);或硬件上并联 100 nF 到 GND + 10 kΩ 上拉,把 RC 时间常数做到 1 ms 级,软件只做一次重读确认。
面试考点
Q1:画出 F103 GPIO 一个引脚的 4 位配置编码,说明为什么"输出模式下没有上下拉"。
参考答案:4 位 = MODE[1:0](低)+ CNF[1:0](高)。MODE=00 输入:CNF 00=模拟、01=浮空、10=上/下拉(由 ODR 同位选择)、11 保留。MODE 非 0 输出(01=10MHz、10=2MHz、11=50MHz):CNF 00=通用推挽、01=通用开漏、10=复用推挽、11=复用开漏。"输出模式下没有上下拉"是因为硬件结构:输入模式的上下拉是输入缓冲器前的弱电阻,由 ODR 位在 CNF=10 时选择;输出模式下 ODR 位直接驱动输出级,不存在"再叠加弱上拉"的通路——输出电平由输出级强驱动决定。所以 HAL 的 set_pull 只对 pull 输入有意义,pins.GlobalConfiguration 对输出+pull 直接编译错误。
Q2:Speed 枚举 max_10MHz=1, max_2MHz=2, max_50MHz=3 为什么是这个顺序?有什么工程含义?
参考答案:它是 ST 硬件 MODE 编码的直接映射(01/10/11 分别是 10/2/50 MHz),microzig 选择与硬件一一对应而不是按语义排序,代价是反直觉。工程含义有三:写错枚举名不报编译错(都是合法成员),只是速度档位错;MODE=00 与输入复用(00 不是速度 0 而是输入模式),所以速度枚举里 reserved 占 0;速度档影响输出驱动强度与边沿速率,EMI 敏感设计反而用低档。面试延伸点:这类"枚举值=硬件编码"的设计在寄存器层是惯例(@backingInt(speed) 直接塞进位域),microzig 的 set_output_mode 正是 speed + (mode << 2) 拼出 4 位。
Q3:BSRR 为什么是原子置位/复位的正确工具?给出一条 store 同时置位 PB5 和复位 PC0 的写法。
参考答案:BSRR 低 16 位写 1 置位对应引脚、高 16 位写 1 复位、写 0 无动作;一次 store 且无读参与,所以原子;多个执行流写不同位互不影响。同时置位 PB5、复位 PC0:BSRR.raw = (1 << 5) | (1 << (16 + 0));。对比 ODR 读改写:三步窗口内 ISR 插入会丢更新。HAL 的 put(value) 内部就是这两个通道:value=0 写 mask<<16,value=1 写 mask;而 toggle 是 ODR.raw ^= mask 非原子——这是"中断与主循环共享 GPIO 端口时只用 put"这一规则的底层依据。
Q4:设计一个非阻塞的按键消抖方案,说明需要的硬件与软件要素。
参考答案:要素:时基(1 ms 系统节拍,来自 SysTick 或 TIM 中断;或主循环用 time.get_time_since_boot() 轮询)、每按键一个状态机(stable_state、last_reading、counter)、采样函数每 1 ms 读一次 IDR。算法:采样值与 last_reading 不同则 counter=0、last_reading=新值;相同则 counter++,counter 达到 N(如 20 ms/N 次)且不等于 stable_state 时更新 stable_state 并发事件。优点:零阻塞、多按键共享一个时基、抖动阈值可调;可host 单测(喂假电平序列)。microzig.drivers 的 DebouncedButton 就是这个模式。对比延时重读法:实现 3 行但阻塞主循环,抖动期不可调。
Q5:为什么 microzig 的 gpio.Pin 用 enum(usize) 存"端口*16+引脚号"而不是存端口指针?这个设计在 E/F/G bug 中扮演什么角色?
参考答案:Pin 的值是纯编号(pin + 16 * @backingInt(port)),类型小、可比较、可作为数组下标或配置表键值。代价是"编号到寄存器"的映射要靠 get_port() 的 switch 运行时翻译——bug 就藏在这个翻译层:case 4/5/6 的 @hasDecl 检查对了但返回值全写成 GPIOA。这个设计让 bug 只影响特定端口区间且完全静默(类型系统无法发现"返回了错误的端口指针"),如果 Pin 直接持有端口指针常量,生成时写错会在更早的检查点暴露。教训:抽象层的间接翻译点是静默 bug 的高发区,对这类层要有对照测试(本系列第 05 篇的 PA5/PE5 对照实验就是)。
小结
- CRL/CRH 每引脚 4 位 = MODE[1:0] + CNF[1:0];输入 4 种(模拟/浮空/上下拉/保留)、输出 4 种(通用/复用 × 推挽/开漏)× 3 档速度。
Speed枚举反直觉:max_10MHz=1、max_2MHz=2、max_50MHz=3,写错不报错。InputMode只有.floating/.pull常用;set_pull只在上拉输入模式有效,实现是写 BSRR/BRR(ODR 位)。put写 BSRR 原子;toggle读改写 ODR 非原子;共享端口只用 put。GlobalConfiguration端口名是GPIOA形式(不是.A),自动开时钟,但同样救不了 E/F/G。- E/F/G 的唯一安全路径是寄存器层;本系列 HAL 示例固定 PA/PB/PC。
- 下一篇时钟树:HSE、PLL、总线分频与那套逐字段的
rcc.apply配置。
系列目录
- 系列总览:Zig 裸机开发的定位、生态与平台全景
- 开发环境搭建:Zig 0.17、microzig 与 probe-rs
- 工程结构剖析:build.zig、MicroBuild 与固件产物
- 第一个固件:启动流程、向量表与 GPIO 点灯
- 寄存器级编程:PAC、MMIO 与 volatile 语义
- GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
- 时钟树配置:HSE、PLL 与总线分频
- 中断系统:NVIC、优先级分组与向量注册
- USART 串口通信:轮询收发与日志重定向
- 定时器基础:时基、PWM 与呼吸灯
- 输入捕获:频率与占空比测量
- 中断驱动串口:环形缓冲区与临界区设计
- DMA 传输:从外设搬运到内存
- I2C 总线:SSD1306 OLED 显示实战
- SPI 总线:W25Q64 Flash 读写与 ID 识别
- ADC 采样:单次、扫描与内部温度传感器
- 内部 Flash 编程:参数持久化与页擦除
- 独立看门狗:复位诊断与可靠性设计
- 低功耗模式:Sleep、Stop、Standby 与唤醒
- 内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
- Zig 语言进阶:comptime、错误处理与零成本抽象
- 并发模型:中断与主循环的协作与临界区
- 协作式调度器:状态机与时间片轮转
- 实时性分析:中断延迟、抖动与 WCET
- RTOS 内核实现(上):任务控制块与上下文切换
- RTOS 内核实现(下):信号量、消息队列与优先级继承
- USB 设备开发:CDC 虚拟串口
- CAN 总线:bxCAN 过滤器与环回自测
- 调试与测试:probe-rs、RTT 与硬件在环
- 收官:分层架构、可移植性与 Zig 生态展望

浙公网安备 33010602011771号