2. GPIO 输入输出基础:从寄存器到 HAL 库

GPIO,说白了就是单片机跟外界打交道的最基本通道。我刚开始学 STM32 的时候,觉得不就是个引脚嘛,能有多复杂?后来做项目才发现,光是一个推挽和开漏的选择,就能让板子工作不正常。这一章,咱们就把 GPIO 的底裤扒干净。

2.1 GPIO 模式详解

STM32 的每个 GPIO 引脚都可以配置成多种模式。你想想看,一个引脚既要能输出高电平点亮 LED,又要能检测按键按下,还要能模拟通信协议,它内部得有多复杂?

2.1.1 推挽输出 vs 开漏输出

这是最常用的两种输出模式,也是面试最爱问的。

特性 推挽输出 (Push-Pull) 开漏输出 (Open-Drain)
输出高电平 由内部 PMOS 直接驱动到 VDD 必须外接上拉电阻
输出低电平 由内部 NMOS 驱动到 GND 由内部 NMOS 驱动到 GND
驱动能力 强,可同时提供和吸收电流 只能吸收电流(灌电流)
典型应用 驱动 LED、普通数字信号 I2C 总线、电平转换、多路复用

推挽输出:内部有两个管子(一个推,一个挽)。输出 1 时,上管导通,直接怼到 VDD;输出 0 时,下管导通,拉到 GND。速度快,驱动强。我习惯用它来驱动 LED,简单粗暴。

开漏输出:只有下管,上管被砍掉了。输出 0 时,下管导通拉低;输出 1 时,下管断开,引脚处于高阻态。所以必须外接一个上拉电阻到 VDD,才能输出高电平。为什么要这么麻烦?因为多个开漏输出的引脚可以“线与”在一起,任何一个拉低,总线就是低电平。I2C 协议就是靠这个工作的。

我的经验: 驱动 LED 用推挽模式,直接灌电流或者拉电流都行。但如果你要驱动一个 5V 的器件,而你的 STM32 是 3.3V 供电,开漏输出加上拉到 5V,就能实现电平转换。这个技巧我在一个传感器接口项目里用过,省掉了一个电平转换芯片。

2.1.2 上拉与下拉

输入模式下,引脚不能悬空。悬空意味着电平不确定,读到的值可能是 0 也可能是 1,完全看心情。STM32 内部集成了上拉和下拉电阻,省掉了外部电阻。

  • 上拉输入:内部接一个约 40kΩ 的电阻到 VDD。默认读到的就是高电平。按键按下,引脚接地,读到低电平。
  • 下拉输入:内部接一个约 40kΩ 的电阻到 GND。默认读到的就是低电平。按键按下,引脚接 VDD,读到高电平。
  • 浮空输入:内部不上拉也不下拉。引脚电平完全由外部决定。适合外部已经有驱动电路的场景。
避坑指南: 我曾经在一个产品上用了浮空输入检测按键,结果在电磁干扰强的环境下,按键没按下,程序却误判了无数次。后来全部改成上拉输入,问题解决。所以,除非你明确知道外部有强驱动,否则尽量用上拉或下拉。

2.2 GPIO 寄存器详解

用 HAL 库之前,我建议你先看看寄存器。不是为了炫技,而是为了理解底层到底发生了什么。STM32 的 GPIO 寄存器不多,但每个都很关键。

2.2.1 端口配置寄存器 (GPIOx_CRL / CRH)

这两个寄存器决定了每个引脚的模式和速度。CRL 控制低 8 位(Pin 0-7),CRH 控制高 8 位(Pin 8-15)。每个引脚占用 4 个 bit。

CNF[1:0] MODE[1:0] 配置模式
00 00 模拟输入
01 00 浮空输入
10 00 上拉/下拉输入
00 01/10/11 推挽输出 (10MHz/2MHz/50MHz)
10 01/10/11 复用功能推挽输出
11 01/10/11 复用功能开漏输出

MODE 位除了配置模式,还配置了输出速度。速度越高,功耗越大,EMI 也越强。我一般 LED 用 2MHz,SPI 用 50MHz,够用就行,别盲目开最高速。

2.2.2 端口输出数据寄存器 (GPIOx_ODR)

写这个寄存器,就能控制引脚输出高电平还是低电平。比如 ODR 的第 0 位写 1,PA0 就输出高电平;写 0,就输出低电平。

2.2.3 端口输入数据寄存器 (GPIOx_IDR)

读这个寄存器,就能知道引脚当前的电平状态。IDR 是只读的,你只能读,不能写。

2.3 实战:使用寄存器点亮 LED

咱们来点个灯。假设 LED 接在 PA0 上,高电平点亮。用寄存器操作,代码非常直接。

// 开启 GPIOA 时钟
RCC->APB2ENR |= (1 << 2);   // RCC_APB2ENR 的第 2 位是 IOPAEN

// 配置 PA0 为推挽输出,50MHz
GPIOA->CRL &= ~(0xF << 0);  // 先清零 CNF0 和 MODE0
GPIOA->CRL |= (0x3 << 0);   // MODE0 = 11 (50MHz), CNF0 = 00 (推挽)

// 点亮 LED
GPIOA->ODR |= (1 << 0);     // PA0 输出高电平

// 熄灭 LED
GPIOA->ODR &= ~(1 << 0);    // PA0 输出低电平

你看,就这么几行。但要注意,操作 CRL 时一定要先清零再赋值,否则会影响到其他引脚。我见过有人直接赋值,结果把其他引脚的模式改乱了,查了半天。

2.4 实战:使用 HAL 库点亮 LED

HAL 库封装了上面的寄存器操作,用起来更安全,也更可读。但代价是代码体积变大,执行速度变慢。对于 LED 这种低速操作,完全没问题。

// 定义 GPIO 初始化结构体
GPIO_InitTypeDef GPIO_InitStruct = {0};

// 开启 GPIOA 时钟
__HAL_RCC_GPIOA_CLK_ENABLE();

// 配置 PA0
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;   // 推挽输出
GPIO_InitStruct.Pull = GPIO_NOPULL;           // 不上拉也不下拉
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;  // 低速
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

// 点亮 LED
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);

// 熄灭 LED
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET);

// 翻转 LED
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_0);
核心对比: 寄存器方式直接、高效,适合对时序要求严格的场景。HAL 库方式可移植性好、代码清晰,适合快速开发和团队协作。我个人习惯是:原型阶段用 HAL 库,量产优化时再考虑寄存器。

2.5 实战:按键输入检测

按键检测比 LED 麻烦一点,因为存在机械抖动。按键按下和释放的瞬间,电平会跳变多次,如果不做处理,程序会误判为多次按下。

2.5.1 硬件去抖 vs 软件去抖

硬件去抖:在按键两端并联一个 0.1μF 的电容。简单有效,但增加 BOM 成本。

软件去抖:检测到电平变化后,延时 10-20ms 再读一次。如果电平一致,就确认是有效动作。不花钱,但占用 CPU 时间。

我一般用软件去抖,因为省成本。但要注意,延时不能用 HAL_Delay() 这种阻塞延时,否则会影响系统实时性。可以用定时器或者状态机来实现非阻塞去抖。

2.5.2 寄存器方式实现按键检测

假设按键接在 PA1,按下为低电平(外部上拉)。

// 配置 PA1 为上拉输入
GPIOA->CRL &= ~(0xF << 4);  // 清零 CNF1 和 MODE1
GPIOA->CRL |= (0x8 << 4);   // CNF1 = 10 (上拉/下拉), MODE1 = 00 (输入)

// 读取按键状态
if ((GPIOA->IDR & (1 << 1)) == 0) {
    // 按键按下
    // 延时去抖
    for (volatile uint32_t i = 0; i < 100000; i++);
    if ((GPIOA->IDR & (1 << 1)) == 0) {
        // 确认按键按下
    }
}

2.5.3 HAL 库方式实现按键检测

// 配置 PA1 为上拉输入
GPIO_InitStruct.Pin = GPIO_PIN_1;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

// 读取按键状态
if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET) {
    // 按键按下
    HAL_Delay(20);  // 延时去抖
    if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET) {
        // 确认按键按下
    }
}
避坑指南: 我曾经在一个项目中,按键检测用了 HAL_Delay(20) 去抖,结果系统里还有其他任务需要实时响应,20ms 的阻塞导致系统卡顿。后来改成用定时器轮询 + 状态机,问题解决。所以,在 RTOS 或者复杂应用中,别用阻塞延时去抖。

2.6 本章知识结构图

下面这张图把 GPIO 的核心知识点串起来了,你可以对照着回顾一下。

GPIO 输入输出知识体系 GPIO 核心 输出模式 输入模式 推挽输出 开漏输出 上拉输入 下拉输入 核心寄存器:CRL / CRH / ODR / IDR 点亮 LED(输出) 按键检测(输入) 寄存器操作是底层基础,HAL 库是上层封装。理解寄存器,才能用好 HAL 库。

嗯,这一章的内容就到这儿。GPIO 看似简单,但它是所有外设的基础。推挽和开漏的区别、上拉和下拉的应用、寄存器和 HAL 库的对比,这些搞清楚了,后面的章节你会轻松很多。