2. GPIO 基础:从寄存器到点亮第一盏灯

GPIO,全称 General-Purpose Input/Output,通用输入输出口。说白了,就是芯片上那些可以编程控制的引脚。你可以让它输出高电平(3.3V)或低电平(0V),也可以读取外部信号是高还是低。

我刚开始学 STM32 的时候,觉得 GPIO 不就是个开关嘛,有啥好学的?后来做项目才发现,GPIO 的工作模式选不对,轻则功能异常,重则烧芯片。嗯,这一节我们就把 GPIO 彻底讲透。

2.1 GPIO 的四种工作模式

STM32 的 GPIO 引脚,可以配置成四种基本模式。我习惯把它们分成两组:输出模式和输入模式。

模式 方向 典型应用
推挽输出 输出 驱动 LED、普通数字信号输出
开漏输出 输出 I2C 总线、电平转换、多路信号线与
浮空输入 输入 读取外部数字信号(按键、传感器)
上拉/下拉输入 输入 防止引脚悬空、按键检测(默认电平)

推挽输出(Push-Pull)

推挽输出,内部有两个 MOS 管:一个负责「推」(输出高电平),一个负责「挽」(输出低电平)。你写 1,引脚就是 3.3V;你写 0,引脚就是 0V。速度快,驱动能力强。

我在项目中用推挽输出驱动 LED 和继电器,从来没出过问题。但要注意,推挽输出不能把两个引脚直接连在一起,否则一个输出高、一个输出低,那就是短路。

开漏输出(Open-Drain)

开漏输出,内部只有下拉的 MOS 管。输出 0 时,引脚拉低到 GND;输出 1 时,引脚处于高阻态(相当于断开)。所以,开漏输出必须外接上拉电阻到 VCC,才能输出高电平。

为什么要用这么麻烦的模式?因为开漏输出可以实现「线与」功能——多个开漏输出可以共用一个上拉电阻,任何一个输出 0,总线就被拉低。I2C 总线就是靠这个原理工作的。

我的经验: 开漏输出做电平转换特别好用。比如你的 STM32 是 3.3V,但外设需要 5V 信号,开漏输出加上拉到 5V,就能输出 5V 电平。

浮空输入与上拉/下拉输入

浮空输入,引脚内部没有任何上拉或下拉电阻。引脚电平完全由外部电路决定。如果外部什么都没接,引脚电平就是不确定的,会乱跳。

上拉输入,内部接了一个几十千欧的电阻到 VCC。引脚默认是高电平,外部拉低才能读到 0。下拉输入则相反,默认是低电平。

我曾经在一个按键检测项目里,用了浮空输入,结果按键没按下时,引脚电平随机跳变,导致系统误触发。后来改成上拉输入,问题立刻解决。

避坑指南: 浮空输入一定要慎用。如果引脚外部没有明确的驱动源,必须配置上拉或下拉,否则读到的电平是随机的。

2.2 寄存器映射与位操作

STM32 的 GPIO 控制,本质就是读写寄存器。每个 GPIO 端口(如 GPIOA、GPIOB)都有一组寄存器,映射到固定的内存地址。

举个例子,GPIOA 的基地址是 0x40020000。它的控制寄存器都在这个基地址上偏移。常用的几个寄存器:

寄存器 偏移 功能
GPIOx_MODER 0x00 模式选择(输入、输出、复用、模拟)
GPIOx_OTYPER 0x04 输出类型(推挽、开漏)
GPIOx_PUPDR 0x0C 上拉/下拉配置
GPIOx_ODR 0x14 输出数据寄存器
GPIOx_IDR 0x10 输入数据寄存器
GPIOx_BSRR 0x18 位设置/复位寄存器

操作寄存器,最常用的就是位操作。C 语言里,我们用 &(与)、|(或)、~(取反)、<<(左移)来操作特定位。

比如,要把 GPIOA 的第 5 脚设为输出模式:

// MODER 每两位控制一个引脚:00=输入,01=输出,10=复用,11=模拟
// 先清除第 5 脚对应的两位(位 10 和位 11)
GPIOA->MODER &= ~(0x3 << (5 * 2));
// 再设置第 5 脚为输出模式(01)
GPIOA->MODER |= (0x1 << (5 * 2));

我个人习惯用 BSRR 寄存器来设置或复位输出。BSRR 的低 16 位写 1 对应置位,高 16 位写 1 对应复位。这样一条指令就能完成,而且不会影响其他引脚。

// 置位 PA5(输出高电平)
GPIOA->BSRR = (1 << 5);
// 复位 PA5(输出低电平)
GPIOA->BSRR = (1 << (5 + 16));
关键点: 操作寄存器时,一定要用「读-改-写」的方式,避免影响其他引脚。BSRR 寄存器是例外,它只写不读,直接操作。

2.3 点亮 LED 灯实战

理论说完了,我们来点一盏灯。这是嵌入式开发的「Hello World」。我当年第一次点亮 LED 的时候,那种成就感,到现在还记得。

假设 LED 接在 PA5 引脚,正极通过电阻连到 PA5,负极接地。那么 PA5 输出高电平时,LED 亮;输出低电平时,LED 灭。

完整代码(基于寄存器操作,不使用 HAL 库):

#include "stm32f4xx.h"

void delay(volatile uint32_t count) {
    while (count--) {
        __NOP();  // 空操作,消耗一个指令周期
    }
}

int main(void) {
    // 1. 使能 GPIOA 时钟
    // RCC_AHB1ENR 的位 0 控制 GPIOA 时钟
    RCC->AHB1ENR |= (1 << 0);

    // 2. 配置 PA5 为输出模式
    GPIOA->MODER &= ~(0x3 << (5 * 2));  // 先清除
    GPIOA->MODER |= (0x1 << (5 * 2));   // 设为输出

    // 3. 配置 PA5 为推挽输出(默认就是推挽,这一步可省略)
    GPIOA->OTYPER &= ~(1 << 5);

    // 4. 配置 PA5 无上拉/下拉(输出模式不需要)
    GPIOA->PUPDR &= ~(0x3 << (5 * 2));

    while (1) {
        // 点亮 LED
        GPIOA->BSRR = (1 << 5);
        delay(500000);

        // 熄灭 LED
        GPIOA->BSRR = (1 << (5 + 16));
        delay(500000);
    }
}

代码逻辑很清晰:

  1. 使能时钟:STM32 的外设默认时钟是关闭的,必须先打开 GPIOA 的时钟。
  2. 配置模式:MODER 寄存器设 PA5 为输出。
  3. 配置类型:OTYPER 设推挽(默认值,可省略)。
  4. 配置上下拉:输出模式不需要上下拉。
  5. 循环闪烁:用 BSRR 置位和复位,中间加延时。
小技巧: 延时函数用 volatile 修饰参数,防止编译器优化掉循环。实际项目中,建议用定时器做精确延时。

2.4 GPIO 知识体系总览

下面这张图,把 GPIO 的核心知识点串起来了。你可以把它当作一个快速参考。

GPIO 知识体系 GPIO 核心 工作模式 推挽输出 开漏输出 浮空输入 上拉/下拉输入 寄存器映射 MODER OTYPER PUPDR BSRR 点亮 LED 实战 使能时钟 配置模式 循环闪烁 位操作 核心思路:时钟 → 模式 → 类型 → 上下拉 → 输出/输入 位操作是灵魂,BSRR 是效率之王

GPIO 看似简单,但它是所有外设控制的基础。推挽输出驱动能力强,开漏输出适合总线通信,上拉输入防悬空。寄存器操作虽然比 HAL 库繁琐,但能让你真正理解芯片的工作原理。

我建议你亲手敲一遍上面的代码,用示波器或者逻辑分析仪看看波形。当你看到 LED 按照你的代码闪烁时,那种感觉,嗯,就是嵌入式开发的开始。