4. GPIO 编程:从寄存器到灯亮

GPIO,说白了就是芯片的「手脚」。你要让芯片对外输出信号,或者读取外部电平,都得靠它。我刚开始学嵌入式那会儿,觉得 GPIO 不就是置 1 清 0 嘛,有啥好学的?后来踩了几个坑才明白——模式配不对,灯都不带亮的。

4.1 GPIO 模式配置:推挽、开漏、上拉、下拉

每个 GPIO 引脚,都可以配置成好几种模式。选错了,信号就出不来。咱们一个一个说。

模式 典型用途 内部结构
推挽输出 驱动 LED、普通数字信号输出 PMOS + NMOS,交替导通
开漏输出 I²C 总线、多设备共享线路 只有 NMOS,高阻需外接上拉
上拉输入 按键检测(默认高电平) 内部弱上拉电阻
下拉输入 按键检测(默认低电平) 内部弱下拉电阻

推挽输出

推挽输出,就是芯片内部有两个管子:一个推上去输出高电平,一个拉下来输出低电平。速度快,驱动能力强。我一般驱动 LED 或者给其他芯片发控制信号时,都用推挽。

开漏输出

开漏输出就只有一个下拉管。输出 0 的时候能拉低,输出 1 的时候……嗯,它不管了,引脚悬空。所以必须外部接一个上拉电阻到 VCC。为什么要有这种奇怪模式?你想想看,I²C 总线上挂了好几个设备,如果都用推挽,两个设备同时输出一个高一个低,那就短路了。开漏模式配合「线与」逻辑,谁拉低谁说了算。

我曾经踩过的坑: 用开漏输出驱动 LED,忘了接外部上拉电阻。结果灯死活不亮,量电压只有 0.3V。折腾了半天才发现——开漏输出高电平时引脚是高阻,LED 根本没电流回路。

上拉/下拉输入

按键检测最常用。按键没按下时,引脚电平不确定,必须靠内部上拉或下拉电阻给它一个确定的电平。我个人习惯:按键接 GND,引脚配内部上拉。这样按键按下就是低电平,代码里检测下降沿很方便。

4.2 点亮 LED 与按键输入

理论说完了,咱们直接上手。下面是一个典型的 LED + 按键例程。硬件连接:LED 接 PA0(推挽输出),按键接 PA1(上拉输入,按下为低)。

#include "stm32f10x.h"

void GPIO_Init(void)
{
    RCC->APB2ENR |= (1 << 2);   // 开启 GPIOA 时钟
    
    // PA0: 推挽输出,50MHz
    GPIOA->CRL &= ~(0xF << 0);  // 先清空配置位
    GPIOA->CRL |= (0x3 << 0);   // 0x3 = 推挽输出 50MHz
    
    // PA1: 上拉输入
    GPIOA->CRL &= ~(0xF << 4);  // 清空
    GPIOA->CRL |= (0x8 << 4);   // 0x8 = 上拉/下拉输入
    GPIOA->ODR |= (1 << 1);      // 使能内部上拉
}

int main(void)
{
    GPIO_Init();
    
    while(1)
    {
        if((GPIOA->IDR & (1 << 1)) == 0)  // 按键按下
        {
            GPIOA->BSRR = (1 << 0);        // PA0 输出高,LED 亮
        }
        else
        {
            GPIOA->BRR = (1 << 0);         // PA0 输出低,LED 灭
        }
    }
}
小技巧: 配置 GPIO 时,一定要先清空再赋值。CRL 寄存器复位值不一定是 0,不清空直接赋值会保留旧配置,导致模式混乱。我见过有人在这上面卡了两天。

4.3 位带操作:让 GPIO 像 51 一样爽

用寄存器操作 GPIO,代码写起来有点啰嗦。每次都要读-改-写。有没有更直接的办法?有,位带操作。

位带操作,就是把一个 32 位的字,映射到 1 个 bit 上。你往这个地址写 1,对应的 GPIO 引脚就输出高;写 0 就输出低。不需要读寄存器,直接写就行。

STM32 的位带区域在 0x20000000 ~ 0x200FFFFF(SRAM)和 0x40000000 ~ 0x400FFFFF(外设)。GPIO 的 ODR 寄存器就在外设位带区。

计算公式:

bit_word_addr = bit_band_base + (byte_offset * 32) + (bit_number * 4)

举个例子,PA0 的 ODR 位:

// GPIOA->ODR 地址: 0x4001080C
// PA0 对应 bit 0
// 位带别名地址 = 0x42000000 + (0x0001080C * 32) + (0 * 4)
//              = 0x42000000 + 0x00210180
//              = 0x42210180

#define PA0_OUT  (*(volatile uint32_t *)0x42210180)

// 使用
PA0_OUT = 1;  // PA0 输出高
PA0_OUT = 0;  // PA0 输出低

是不是简洁多了?我个人习惯把常用的 GPIO 位带地址都定义成宏,代码可读性高很多。

位带操作的优势:
  • 原子操作:读-改-写不会被中断打断
  • 代码简洁:一行搞定,不需要 &= 和 |=
  • 速度快:直接写内存,比读-改-写少一条指令

4.4 寄存器映射:从物理地址到 C 语言结构体

为什么我们可以用 GPIOA->ODR = 0x01 这种写法?这背后就是寄存器映射。

芯片厂商在头文件里,把外设的基地址定义好,然后用结构体把寄存器排列起来。比如:

// GPIO 寄存器结构体
typedef struct
{
    volatile uint32_t CRL;    // 0x00
    volatile uint32_t CRH;    // 0x04
    volatile uint32_t IDR;    // 0x08
    volatile uint32_t ODR;    // 0x0C
    volatile uint32_t BSRR;   // 0x10
    volatile uint32_t BRR;    // 0x14
    volatile uint32_t LCKR;   // 0x18
} GPIO_TypeDef;

// GPIOA 基地址: 0x40010800
#define GPIOA  ((GPIO_TypeDef *)0x40010800)

这样,GPIOA->ODR 就等价于 *(uint32_t *)(0x40010800 + 0x0C)。结构体成员的偏移量必须和硬件寄存器地址完全对齐,中间不能有空洞。所以 volatile 关键字不能少——告诉编译器,这个变量可能被硬件修改,别优化掉了。

我曾经遇到过一个问题:调试时发现 GPIO 输出不对,单步执行没问题,全速跑就不行。后来发现是编译器把 GPIOA->ODR = 0 优化掉了,因为后面没再读它。加上 volatile 就好了。

4.5 本章知识结构图

下面这张图,把 GPIO 编程的核心知识点串起来了。从模式配置到寄存器操作,再到位带优化,一条线走到底。

GPIO 编程知识体系 GPIO 核心 模式配置 输入/输出 寄存器操作 推挽 / 开漏 上拉 / 下拉 点亮 LED 按键检测 位带操作 寄存器映射 💡 学习路径建议 先理解模式配置 → 再动手写 LED 和按键代码 → 最后用位带操作优化代码,理解寄存器映射原理

4.6 避坑指南

  • 时钟没开: 操作 GPIO 之前,一定要先开启对应端口的时钟。我见过有人配了半天寄存器,结果 GPIO 纹丝不动——RCC 没使能。
  • 模式配错: 推挽输出配成了开漏,LED 不亮;输入配成了输出,按键读不到。配置前先想清楚:这个引脚到底是输入还是输出?
  • 位带地址算错: 位带别名地址的计算公式容易出错。我建议你算完后,用调试器读一下那个地址的值,确认和 ODR 寄存器一致。
  • volatile 漏掉: 寄存器映射的结构体成员,必须加 volatile。否则编译器优化后,你可能读到的是缓存值,不是真实硬件状态。