tags: [source-summary] type: source source: "尚硅谷嵌入式技术之STM32单片机(基础篇)V1.0.2 — 第4~5章:点亮LED案例 + GPIO详解" author: "尚硅谷研究院" date: 2026-07-15
用生活理解:GPIO 就像一排开关(每个引脚一个),你可以用程序控制每个开关输出高/低电平(控制 LED),也可以读取每个开关的状态(读取按键)。"三步进化法"就是操作这些开关的方式从"直接掰电线"进化到"用遥控器按按钮"再到"声控"的过程。
GPIO(General-purpose input/output,通用型输入输出)是 STM32 芯片与外界交互的最基本途径。
参考:STM32F103ZET6 数据手册 §3(引脚功能定义表) 参考:开发板原理图(LED/KEY 电路部分)
| 特性 | 说明 |
|---|---|
| 引脚电平 | 0~3.3V(FT 引脚兼容 5V) |
| 翻转速度 | 最快 50MHz(2 个时钟周期) |
| 上下拉 | 内置可配置上下拉电阻(约 40KΩ) |
| 驱动能力 | 最大 ±25mA 拉/灌电流 |
| 中断能力 | 每个引脚均可作为 EXTI 中断输入 |
GPIO 引脚内部有两个保护二极管,分别连接到 VDD(3.3V)和 VSS(0V):
实际意义:防止静电或意外的高/低电压损坏芯片。但这不是绝对的,超过极限电压仍会烧毁。
两个开关控制引脚内部的上下拉电阻(典型值 40KΩ):
施密特触发器是一个带迟滞特性的比较器电路:
施密特触发器的输出进入输入数据寄存器(IDR),供 CPU 读取。
注意:在模拟输入模式下,施密特触发器被关闭以降低功耗。
| 模式 | 类型 | 用途 | P-MOS | N-MOS | 施密特触发器 | 上下拉 |
|---|---|---|---|---|---|---|
| 输入浮空 | 输入 | 按键、通信引脚 | 关闭 | 关闭 | 开 | 禁止 |
| 输入上拉 | 输入 | 按键(默认高电平) | 关闭 | 关闭 | 开 | 上拉 |
| 输入下拉 | 输入 | 按键(默认低电平) | 关闭 | 关闭 | 开 | 下拉 |
| 模拟输入 | 输入 | ADC 采集 | 关闭 | 关闭 | 关闭 | 禁止 |
| 通用推挽输出 | 输出 | LED、普通信号 | 交替 | 交替 | 开 | 禁止 |
| 通用开漏输出 | 输出 | I2C 总线、电平转换 | 关闭 | 交替 | 开 | 禁止 |
| 复用推挽输出 | 输出 | USART_TX、SPI_SCK | 交替 | 交替 | 开 | 禁止 |
| 复用开漏输出 | 输出 | I2C_SCL/SDA | 关闭 | 交替 | 开 | 禁止 |
| 应用场景 | 推荐模式 |
|---|---|
| 驱动 LED | 推挽输出 |
| 读取按键 | 上拉输入 或 下拉输入 |
| I2C 通信 | 复用开漏输出(需要外部上拉) |
| 串口 TX | 复用推挽输出 |
| 串口 RX | 浮空输入 或 上拉输入 |
| ADC 采样 | 模拟输入 |
| SPI 通信 | 复用推挽输出 |
| PWM 输出 | 复用推挽输出 |
每个 GPIO 端口有 7 个 32 位寄存器,基地址如下:
0x4001 08000x4001 0C000x4001 10000x4001 14000x4001 18000x4001 1C000x4001 2000参考:参考手册 §1(存储器映射)+ §8(GPIO 寄存器描述)
控制 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
与 CRL 结构完全相同,控制 Pin8~Pin15。
| 位 | 说明 |
|---|---|
| 31:16 | 保留 |
| 15:0 | IDRy:读取 Pin y 的输入电平(0=低,1=高) |
| 位 | 说明 |
|---|---|
| 31:16 | 保留 |
| 15:0 | ODRy:写入 Pin y 的输出电平(0=低,1=高) |
| 位 | 名称 | 功能 |
|---|---|---|
| 31:16 | BRy | 清除 ODR 的对应位(写 1 清 0) |
| 15:0 | BSy | 设置 ODR 的对应位(写 1 置 1) |
特点:BSRR 是原子操作,同时写低 16 位和高 16 位不会冲突。 对比:
GPIOA->BSRR = (1<<0)相当于GPIOA->ODR |= (1<<0),但 BSRR 不需要"读-改-写"三步,不会被打断。
| 位 | 说明 |
|---|---|
| 31:16 | 保留 |
| 15:0 | BRy:清除 ODR 的对应位(写 1 清 0) |
相当于 BSRR 高 16 位的简化版。
用于锁定 CRL/CRH 的配置,锁定后下次复位前不可更改。
锁定时序:写 1 → 写 0 → 写 1 → 读 0 → 读 1
尚硅谷的教学特色:用同一个功能(点亮 LED)展示三种逐渐优化的写法。
LED1 连接 PA0,低电平点亮。
原理:
PA0=1(3.3V) → LED 灭(两端电压差接近 0)
PA0=0(0V) → LED 亮(电流经 VCC→限流电阻→LED→PA0→GND)
限流电阻计算:R = (VCC - V_LED) / I_LED,LED 工作电流约 5~10mA。
文件: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)
{
}
}
问题:每次查手册计算地址,烦琐;直接赋值覆盖了其他位(不安全)。
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)
{
}
}
进步:不再手动计算地址,代码可读性大幅提升。 仍存在的问题:直接写值覆盖了整个寄存器,只操作特定某位才安全。
文件: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这种写法需要查手册知道要移位几。
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) |
异或翻转 |