02-GPIO寄存器详解与三步进化法.md 15 KB


tags: [source-summary] type: source source: "尚硅谷嵌入式技术之STM32单片机(基础篇)V1.0.2 — 第4~5章:点亮LED案例 + GPIO详解" author: "尚硅谷研究院" date: 2026-07-15

created: 2026-07-15

GPIO寄存器详解与三步进化法

用生活理解:GPIO 就像一排开关(每个引脚一个),你可以用程序控制每个开关输出高/低电平(控制 LED),也可以读取每个开关的状态(读取按键)。"三步进化法"就是操作这些开关的方式从"直接掰电线"进化到"用遥控器按按钮"再到"声控"的过程。


GPIO概述

GPIO(General-purpose input/output,通用型输入输出)是 STM32 芯片与外界交互的最基本途径。

总体说明

  • STM32F103ZET6 共有 7 组 GPIO 端口:GPIOA~GPIOG,每组 16 个引脚,共 112 个 GPIO
  • 每个引脚电平范围:0~3.3V,部分引脚可兼容 5V
  • 最快翻转速度:每 2 个时钟周期翻转一次(最高 50MHz)
  • 每个 GPIO 均可作为外部中断输入
  • 每个引脚可配置为 8 种工作模式之一

参考:STM32F103ZET6 数据手册 §3(引脚功能定义表) 参考:开发板原理图(LED/KEY 电路部分)

GPIO的主要特点

特性 说明
引脚电平 0~3.3V(FT 引脚兼容 5V)
翻转速度 最快 50MHz(2 个时钟周期)
上下拉 内置可配置上下拉电阻(约 40KΩ)
驱动能力 最大 ±25mA 拉/灌电流
中断能力 每个引脚均可作为 EXTI 中断输入

GPIO内部结构详解

GPIO 内部结构图

保护二极管

GPIO 引脚内部有两个保护二极管,分别连接到 VDD(3.3V)和 VSS(0V):

  • 当输入电压高于 VDD+0.3V 时,上方二极管导通,将电压钳位到 VDD
  • 当输入电压低于 VSS-0.3V 时,下方二极管导通,将电压拉高到 VSS

实际意义:防止静电或意外的高/低电压损坏芯片。但这不是绝对的,超过极限电压仍会烧毁。

上下拉电阻

两个开关控制引脚内部的上下拉电阻(典型值 40KΩ):

  • 上拉:上开关闭合 → 引脚默认高电平
  • 下拉:下开关闭合 → 引脚默认低电平
  • 浮空:两个开关都断开 → 引脚电平不确定(高阻态)
  • 两个同时闭合:VDD 到 VSS 短路 → 费电且无意义,不会这么用

施密特触发器(Schmitt Trigger)

施密特触发器是一个带迟滞特性的比较器电路:

  • 输入信号从低到高:阈值约为 1.8V
  • 输入信号从高到低:阈值约为 1.2V
  • 这种滞回特性可以滤除信号上的噪声抖动,实现波形整形

施密特触发器的输出进入输入数据寄存器(IDR),供 CPU 读取。

注意:在模拟输入模式下,施密特触发器被关闭以降低功耗。

输出驱动电路

  • P-MOS(上管):导通时输出高电平(VDD)
  • N-MOS(下管):导通时输出低电平(VSS)
  • 推挽输出:P-MOS 和 N-MOS 交替导通,可输出高或低电平
  • 开漏输出:P-MOS 始终关断,只有 N-MOS 工作(只能输出低电平,高电平靠外部上拉电阻)

GPIO的8种工作模式

模式 类型 用途 P-MOS N-MOS 施密特触发器 上下拉
输入浮空 输入 按键、通信引脚 关闭 关闭 禁止
输入上拉 输入 按键(默认高电平) 关闭 关闭 上拉
输入下拉 输入 按键(默认低电平) 关闭 关闭 下拉
模拟输入 输入 ADC 采集 关闭 关闭 关闭 禁止
通用推挽输出 输出 LED、普通信号 交替 交替 禁止
通用开漏输出 输出 I2C 总线、电平转换 关闭 交替 禁止
复用推挽输出 输出 USART_TX、SPI_SCK 交替 交替 禁止
复用开漏输出 输出 I2C_SCL/SDA 关闭 交替 禁止

常见模式选择指导

应用场景 推荐模式
驱动 LED 推挽输出
读取按键 上拉输入 或 下拉输入
I2C 通信 复用开漏输出(需要外部上拉)
串口 TX 复用推挽输出
串口 RX 浮空输入 或 上拉输入
ADC 采样 模拟输入
SPI 通信 复用推挽输出
PWM 输出 复用推挽输出

GPIO的7个关键寄存器

每个 GPIO 端口有 7 个 32 位寄存器,基地址如下:

  • GPIOA: 0x4001 0800
  • GPIOB: 0x4001 0C00
  • GPIOC: 0x4001 1000
  • GPIOD: 0x4001 1400
  • GPIOE: 0x4001 1800
  • GPIOF: 0x4001 1C00
  • GPIOG: 0x4001 2000

参考:参考手册 §1(存储器映射)+ §8(GPIO 寄存器描述)

1. GPIOx_CRL — 端口配置低寄存器(Address: 0x00)

控制 Pin0~Pin7 的配置模式。

位域 名称 说明
31:30 CNF7[1:0] Pin7 配置位
29:28 MODE7[1:0] Pin7 模式位
... ... ...
3:2 CNF0[1:0] Pin0 配置位
1:0 MODE0[1:0] Pin0 模式位

MODE 位含义: | MODE[1:0] | 模式 | |-----------|------| | 00 | 输入模式(复位状态) | | 01 | 输出,最大速度 10MHz | | 10 | 输出,最大速度 2MHz | | 11 | 输出,最大速度 50MHz |

CNF 位含义(输入模式下,即 MODE=00): | CNF[1:0] | 模式 | |----------|------| | 00 | 模拟输入 | | 01 | 浮空输入(复位状态) | | 10 | 上拉/下拉输入 | | 11 | 保留 |

CNF 位含义(输出模式下,即 MODE>00): | CNF[1:0] | 模式 | |----------|------| | 00 | 通用推挽输出 | | 01 | 通用开漏输出 | | 10 | 复用功能推挽输出 | | 11 | 复用功能开漏输出 |

配置示例:PA0 推挽输出 50MHz → MODE0[1:0]=11, CNF0[1:0]=00 → CRL 低 4 位 = 0011 = 0x3

2. GPIOx_CRH — 端口配置高寄存器(Address: 0x04)

与 CRL 结构完全相同,控制 Pin8~Pin15

3. GPIOx_IDR — 端口输入数据寄存器(Address: 0x08)

说明
31:16 保留
15:0 IDRy:读取 Pin y 的输入电平(0=低,1=高)

4. GPIOx_ODR — 端口输出数据寄存器(Address: 0x0C)

说明
31:16 保留
15:0 ODRy:写入 Pin y 的输出电平(0=低,1=高)

5. GPIOx_BSRR — 端口位设置/清除寄存器(Address: 0x10)

名称 功能
31:16 BRy 清除 ODR 的对应位(写 1 清 0)
15:0 BSy 设置 ODR 的对应位(写 1 置 1)

特点:BSRR 是原子操作,同时写低 16 位和高 16 位不会冲突。 对比GPIOA->BSRR = (1<<0) 相当于 GPIOA->ODR |= (1<<0),但 BSRR 不需要"读-改-写"三步,不会被打断。

6. GPIOx_BRR — 端口位清除寄存器(Address: 0x14)

说明
31:16 保留
15:0 BRy:清除 ODR 的对应位(写 1 清 0)

相当于 BSRR 高 16 位的简化版。

7. GPIOx_LCKR — 端口配置锁定寄存器(Address: 0x18)

用于锁定 CRL/CRH 的配置,锁定后下次复位前不可更改

锁定时序:写 1 → 写 0 → 写 1 → 读 0 → 读 1


操作寄存器方式的"三步进化法"

尚硅谷的教学特色:用同一个功能(点亮 LED)展示三种逐渐优化的写法。

硬件电路

LED1 连接 PA0,低电平点亮。

LED 电路原理图

原理

PA0=1(3.3V) → LED 灭(两端电压差接近 0)
PA0=0(0V)   → LED 亮(电流经 VCC→限流电阻→LED→PA0→GND)

限流电阻计算:R = (VCC - V_LED) / I_LED,LED 工作电流约 5~10mA。

进化 0:裸地址操作(直接操作内存)

文件:stm32/01_led_register/User/main.c

#include "stm32f10x.h"

int main(void)
{
    /* 1. 开启GPIOA的时钟 */
    // RCC_APB2ENR 寄存器的地址 = RCC基地址(0x40021000) + 偏移(0x18) = 0x40021018
    // 第2位置1表示开启GPIOA时钟
    // 0x00000004 = 二进制 0000 0000 0000 0000 0000 0000 0000 0100
    //                                         ↑第2位=1
    *(uint32_t *)(0x40021000 + 0x18) = 4;

    /* 2. 配置PA0为推挽输出模式(最大速度50MHz) */
    // GPIOA_CRL 寄存器的地址 = GPIOA基地址(0x40010800) + 偏移(0x00) = 0x40010800
    // PA0对应CRL的最低4位:
    //   MODE0[1:0] = 11 (50MHz输出)
    //   CNF0[1:0]  = 00 (通用推挽输出)
    // 所以低4位 = 0011 = 0x3
    // 注意:这会覆盖PA1~PA7的配置(其他位变成0),是粗暴的做法
    *(uint32_t *)(0x40010800 + 0x00) = 3;

    /* 3. PA0输出低电平(点亮LED) */
    // GPIOA_ODR 寄存器的地址 = GPIOA基地址(0x40010800) + 偏移(0x0C) = 0x4001080C
    // 第0位置0 = 输出低电平;其他位置1 = 不影响其他引脚
    // 0x0000FFFE = 二进制 ... 1111 1111 1111 1110
    //                                          ↑第0位=0
    *(uint32_t *)(0x40010800 + 0x0C) = 0xFFFE;

    while (1)
    {
    }
}

问题:每次查手册计算地址,烦琐;直接赋值覆盖了其他位(不安全)。

进化 1:使用结构体宏定义

ST 官方在 stm32f10x.h 中用结构体封装了所有寄存器:

// stm32f10x.h 中的定义(简化版)

#define PERIPH_BASE           ((uint32_t)0x40000000)   // 外设基地址
#define AHBPERIPH_BASE        (PERIPH_BASE + 0x20000)  // AHB外设基地址
#define RCC_BASE              (AHBPERIPH_BASE + 0x1000)// RCC基地址

#define RCC                   ((RCC_TypeDef *) RCC_BASE)// RCC结构体指针

typedef struct
{
  __IO uint32_t CR;         // 偏移 0x00
  __IO uint32_t CFGR;       // 偏移 0x04
  __IO uint32_t CIR;        // 偏移 0x08
  __IO uint32_t APB2RSTR;   // 偏移 0x0C
  __IO uint32_t APB1RSTR;   // 偏移 0x10
  __IO uint32_t AHBENR;     // 偏移 0x14
  __IO uint32_t APB2ENR;    // 偏移 0x18  ← 我们用的寄存器
  __IO uint32_t APB1ENR;    // 偏移 0x1C
  /* ... */
} RCC_TypeDef;

巧妙之处:结构体成员地址连续,CR 偏移 0x00,CFGR 偏移 0x04,APB2ENR 偏移 0x18(6×4=24=0x18),与查手册结果一致。

利用这个封装,代码进化为:

文件:stm32/01_led_register/User/main.c

#include "stm32f10x.h"

int main(void)
{
    RCC->APB2ENR = 4;       // RCC->APB2ENR 等价于 *(uint32_t*)(0x40021018)
    GPIOA->CRL = 3;          // GPIOA->CRL 等价于 *(uint32_t*)(0x40010800)
    GPIOA->ODR = 0xFFFE;     // GPIOA->ODR 等价于 *(uint32_t*)(0x4001080C)

    while (1)
    {
    }
}

进步:不再手动计算地址,代码可读性大幅提升。 仍存在的问题:直接写值覆盖了整个寄存器,只操作特定某位才安全。

进化 2:使用位运算实现只改变某一位

文件:stm32/01_led_register/User/main.c

#include "stm32f10x.h"

int main(void)
{
    // 1. 开启GPIOA时钟
    // RCC->APB2ENR 的第2位置1,保持其他位不变
    RCC->APB2ENR |= (1 << 2);
    // |= (1<<2) = 按位或: 将第2位置1,其他位不变
    // 相当于: RCC->APB2ENR = RCC->APB2ENR | 0x00000004

    // 2. 配置PA0为推挽输出50MHz
    // 先清除CNF0[1:0]=00 (清3,2位)
    GPIOA->CRL &= ~(1 << 3);   // &= ~(1<<3): 按位与+取反: 保留其他位,将第3位清0
    GPIOA->CRL &= ~(1 << 2);   // 将第2位清0
    // 再设置MODE0[1:0]=11 (置1,0位)
    GPIOA->CRL |= (1 << 1);    // |= (1<<1): 按位或: 将第1位置1
    GPIOA->CRL |= (1 << 0);    // 将第0位置1

    // 3. PA0输出低电平
    GPIOA->ODR &= ~(1 << 0);   // 将第0位清0,输出低电平

    while (1)
    {
    }
}

进步:位运算只修改目标位,不影响其他位的原有配置。 仍存在的问题1<<2 这种写法需要查手册知道要移位几。

进化 3:使用 ST 预定义的位宏(最终形态)

ST 在 stm32f10x.h 中把每个位域的值也预先定义好了宏:

// stm32f10x.h 中的宏定义示例
#define RCC_APB2ENR_IOPAEN    ((uint32_t)0x00000004)  // = 1<<2
#define GPIO_CRL_MODE0        ((uint32_t)0x00000003)  // MODE0的掩码 = 11B
#define GPIO_CRL_MODE0_1      ((uint32_t)0x00000002)  // MODE0的第1位
#define GPIO_CRL_MODE0_0      ((uint32_t)0x00000001)  // MODE0的第0位
#define GPIO_CRL_CNF0         ((uint32_t)0x0000000C)  // CNF0的掩码 = 1100B
#define GPIO_CRL_CNF0_1       ((uint32_t)0x00000008)  // CNF0的第3位
#define GPIO_CRL_CNF0_0       ((uint32_t)0x00000004)  // CNF0的第2位
#define GPIO_ODR_ODR0         ((uint32_t)0x00000001)  // ODR的第0位

利用这些宏,最终版代码:

文件:stm32/01_led_register/User/main.c

#include "stm32f10x.h"

int main(void)
{
    // 1. 开启GPIOA时钟
    // RCC_APB2ENR_IOPAEN = 0x00000004 = 1<<2
    // |= 按位或: 将IOPAEN位置1,保持其他时钟位不变
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;

    // 2. 配置PA0为推挽输出
    // GPIO_CRL_CNF0 = 0xC,清除CNF0两位(CNF0_1和CNF0_0)
    GPIOA->CRL &= ~GPIO_CRL_CNF0;
    // GPIO_CRL_MODE0 = 0x3,设置MODE0[1:0] = 11(50MHz输出)
    GPIOA->CRL |= GPIO_CRL_MODE0;

    // 3. PA0输出低电平
    // GPIO_ODR_ODR0 = 0x1,将ODR0位清0
    GPIOA->ODR &= ~GPIO_ODR_ODR0;

    while (1)
    {
    }
}

最终形态:代码简洁、可读性强、安全、易于维护。这就是实际项目中推荐的方式。

三步进化法总结

阶段 写法 优点 缺点
进化0 裸地址 理解硬件本质 烦琐、不安全
进化1 结构体宏 可读性好 仍可能覆盖其他位
进化2 位运算 只改需要的位 需查手册知移位次数
进化3 位宏 最安全、最可读 需要了解宏名含义

核心位操作速查表

操作 语法 解释
位置1 `REG = BITMASK;`
位清0 REG &= ~BITMASK; 按位与+取反:其他位不变,指定位清0
位翻转 REG ^= BITMASK; 按位异或:指定位置1变0,0变1
位移位 x << N 左移N位(相当于乘 2^N)
位移位 x >> N 右移N位(相当于除 2^N)
测试位 if (REG & BITMASK) 按位与:测试指定位是否为1
连续位置1 `REG = (MASK << N)`
连续位清0 REG &= ~(MASK << N) 将 N 位开始的 MASK 位清0

典型技巧先清除后设置 是嵌入式位操作的黄金法则。对连续位域(如 MODE[1:0])总是先 &= ~MASK 清空,再 |= VALUE 设置。


核心函数速查表

(本笔记以寄存器操作为主,无标准库/HAL 库函数)

操作 寄存器方式 说明
设置输出高 GPIOx->BSRR = (1<<n) 原子操作,推荐
设置输出低 GPIOx->BRR = (1<<n) 原子操作,推荐
读取输入 uint8_t val = (GPIOx->IDR >> n) & 1 提取第n位值
翻转输出 GPIOx->ODR ^= (1<<n) 异或翻转

常见问题与避坑

  1. GPIO 配置后不工作 → 检查 RCC 时钟是否已开启(最常见错误!)
  2. 输出电平不对 → 检查推挽/开漏模式选择是否正确、ODR/BSRR 操作是否正确
  3. 开漏输出高电平不对 → 检查外部上拉电阻是否焊接
  4. 输入值一直为0或1 → 检查上下拉配置是否合适、施密特触发器是否被关闭(模拟模式)
  5. 引脚翻转速度太慢 → 检查 MODE 位速度配置(50MHz/10MHz/2MHz)
  6. 同时配置多个引脚互相干扰 → 使用 CRL/CRH 位操作,不要整体赋值