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 位里。这带来三个直接后果:

  1. 配置一个引脚 = 一次 4 位的读改写(HAL 的 write_pin_config 就是这么做的),配置 16 个引脚的端口要动整个 32 位寄存器——跨引脚的读改写竞态窗口比 F4 大。
  2. "输入/输出方向"不是独立概念,它编码在 MODE 位里(MODE=00 是输入,非 0 是输出且值即速度)。所以 HAL 没有 set_direction——set_output_mode(mode, speed) 写完就是输出。
  3. 上下拉是输入模式的子模式(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 配置。

系列目录

  1. 系列总览:Zig 裸机开发的定位、生态与平台全景
  2. 开发环境搭建:Zig 0.17、microzig 与 probe-rs
  3. 工程结构剖析:build.zig、MicroBuild 与固件产物
  4. 第一个固件:启动流程、向量表与 GPIO 点灯
  5. 寄存器级编程:PAC、MMIO 与 volatile 语义
  6. GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
  7. 时钟树配置:HSE、PLL 与总线分频
  8. 中断系统:NVIC、优先级分组与向量注册
  9. USART 串口通信:轮询收发与日志重定向
  10. 定时器基础:时基、PWM 与呼吸灯
  11. 输入捕获:频率与占空比测量
  12. 中断驱动串口:环形缓冲区与临界区设计
  13. DMA 传输:从外设搬运到内存
  14. I2C 总线:SSD1306 OLED 显示实战
  15. SPI 总线:W25Q64 Flash 读写与 ID 识别
  16. ADC 采样:单次、扫描与内部温度传感器
  17. 内部 Flash 编程:参数持久化与页擦除
  18. 独立看门狗:复位诊断与可靠性设计
  19. 低功耗模式:Sleep、Stop、Standby 与唤醒
  20. 内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
  21. Zig 语言进阶:comptime、错误处理与零成本抽象
  22. 并发模型:中断与主循环的协作与临界区
  23. 协作式调度器:状态机与时间片轮转
  24. 实时性分析:中断延迟、抖动与 WCET
  25. RTOS 内核实现(上):任务控制块与上下文切换
  26. RTOS 内核实现(下):信号量、消息队列与优先级继承
  27. USB 设备开发:CDC 虚拟串口
  28. CAN 总线:bxCAN 过滤器与环回自测
  29. 调试与测试:probe-rs、RTT 与硬件在环
  30. 收官:分层架构、可移植性与 Zig 生态展望
posted @ 2026-10-27 08:52  navysummer_hardware  阅读(6)  评论(0)    收藏  举报