3. GPIO 基础与 LED 驱动:从寄存器到流水灯
GPIO,全称 General-Purpose Input/Output,通用输入输出口。说白了,它就是单片机跟外界打交道最基础、最常用的方式。你想想看,一个芯片要控制外部设备,要么输出高低电平,要么读取外部信号的高低电平,这些都得靠 GPIO。
我刚开始学单片机那会儿,第一个实验就是点亮一个 LED。别看它简单,这里面涉及的知识点——GPIO 模式配置、推挽与开漏的区别、上拉下拉电阻的作用——几乎贯穿了整个嵌入式开发生涯。今天我们就把它彻底讲透。
3.1 GPIO 的四种工作模式
很多初学者一上来就写代码,却忽略了 GPIO 模式配置。结果 LED 不亮,查半天发现是模式配错了。我个人习惯是,写任何 GPIO 代码前,先想清楚:这个引脚要干什么?输出还是输入?驱动能力够不够?
GPIO 模式可以分成两大类:输出模式和输入模式。输出模式里又有推挽和开漏两种;输入模式里又有上拉、下拉和浮空三种。我们重点讲最常用的四种。
| 模式 | 内部结构 | 典型应用 | 注意事项 |
|---|---|---|---|
| 推挽输出 | PMOS + NMOS 互补结构 | 驱动 LED、普通数字信号输出 | 输出高电平时直接接 VCC,驱动能力强 |
| 开漏输出 | 只有 NMOS,高阻需外接上拉 | I²C 总线、多设备共用线路 | 必须外接上拉电阻才能输出高电平 |
| 上拉输入 | 内部弱上拉电阻 (~40kΩ) | 按键检测、开关信号读取 | 引脚悬空时默认高电平 |
| 下拉输入 | 内部弱下拉电阻 (~40kΩ) | 防止引脚浮空误触发 | 引脚悬空时默认低电平 |
3.2 推挽输出 vs 开漏输出:到底怎么选?
这两个概念,我当年也迷糊过一阵子。推挽输出,内部有两只管子:一只负责拉高(PMOS),一只负责拉低(NMOS)。输出高电平时,PMOS 导通,引脚直接连到 VCC;输出低电平时,NMOS 导通,引脚连到 GND。所以推挽输出的高低电平都很"硬",驱动能力强。
开漏输出就不一样了。它只有一只 NMOS 管,负责拉低。输出低电平时,NMOS 导通,引脚到 GND;输出高电平时,NMOS 截止,引脚处于高阻态——这时候如果没有外部上拉电阻,引脚电平就是"悬空"的,不确定。
为什么会有人用开漏?我举个例子:I²C 总线上挂了好几个设备,如果都用推挽输出,两个设备同时输出不同电平,一个拉高一个拉低,那就短路了。开漏输出配合上拉电阻,可以实现"线与"——任何一个设备拉低,总线就是低电平。这个特性在总线通信中非常有用。
3.3 上拉与下拉:输入模式的"默认值"
输入模式下,引脚是读取外部信号的高低电平。但如果外部信号没接,引脚就处于"浮空"状态,电平不确定,读到的值可能是 0 也可能是 1,完全随机。这就是为什么需要上拉或下拉电阻。
上拉输入:内部有个电阻把引脚拉到 VCC,所以引脚悬空时默认是高电平。按下按键接地,引脚变成低电平。我习惯用这种方式检测按键——平时高电平,按下变低,逻辑清晰。
下拉输入:内部电阻把引脚拉到 GND,悬空时默认低电平。外部信号拉高时读到高电平。这种方式用得少一些,但在某些场景下更合适。
你想想看,如果按键接的是 VCC,按下时输出高电平,那就应该用下拉输入,让引脚默认低电平。所以上拉还是下拉,取决于你的外部电路怎么接。
3.4 寄存器配置:从底层控制 GPIO
用库函数配置 GPIO 很方便,但理解寄存器层面的操作更重要。以 STM32F103 为例,每个 GPIO 端口有 7 个寄存器,我们最常用的是这三个:
- CRL / CRH(配置寄存器低位/高位):每个引脚占 4 位,配置模式(输入/输出/复用)和速度
- ODR(输出数据寄存器):写 1 输出高电平,写 0 输出低电平
- IDR(输入数据寄存器):读取引脚当前电平
每个引脚用 4 位配置,其中 CNF[1:0] 决定模式,MODE[1:0] 决定速度。具体对应关系如下:
| MODE[1:0] | 模式 | CNF[1:0] | 配置 |
|---|---|---|---|
| 00 | 输入模式 | 00 | 模拟输入 |
| 01 / 10 / 11 | 输出模式(10MHz/2MHz/50MHz) | 00 | 推挽输出 |
| 01 / 10 / 11 | 输出模式 | 01 | 开漏输出 |
| 00 | 输入模式 | 10 | 上拉/下拉输入 |
| 00 | 输入模式 | 11 | 保留 |
举个例子,配置 PA0 为推挽输出,速度 50MHz:
// 配置 PA0 为推挽输出,50MHz
GPIOA->CRL &= ~(0xF << 0); // 先清除低 4 位
GPIOA->CRL |= (0x3 << 0); // MODE=11 (50MHz), CNF=00 (推挽)
// 输出高电平
GPIOA->ODR |= (1 << 0);
// 输出低电平
GPIOA->ODR &= ~(1 << 0);
3.5 点亮第一个 LED:从原理图到代码
好,理论讲完了,我们动手。假设 LED 接在 PA0 上,正极通过电阻连到 VCC,负极连到 PA0。这种接法叫"低电平点亮"——PA0 输出低电平时,电流从 VCC 流过 LED 和电阻到 PA0,LED 亮。
为什么用低电平点亮?因为单片机的引脚拉低能力通常比拉高能力强。而且很多 MCU 上电后引脚默认是高电平,如果用高电平点亮,上电瞬间 LED 会闪一下。我吃过这个亏,后来一律用低电平点亮。
// 点亮 PA0 上的 LED
void LED_Init(void)
{
// 1. 使能 GPIOA 时钟
RCC->APB2ENR |= (1 << 2);
// 2. 配置 PA0 为推挽输出,50MHz
GPIOA->CRL &= ~(0xF << 0);
GPIOA->CRL |= (0x3 << 0);
// 3. 初始状态:熄灭(输出高电平)
GPIOA->ODR |= (1 << 0);
}
// 点亮
void LED_On(void)
{
GPIOA->ODR &= ~(1 << 0); // 输出低电平
}
// 熄灭
void LED_Off(void)
{
GPIOA->ODR |= (1 << 0); // 输出高电平
}
// 翻转
void LED_Toggle(void)
{
GPIOA->ODR ^= (1 << 0);
}
三步走:开时钟、配模式、写数据。记住这个顺序,缺一不可。我见过有人忘了开时钟,折腾半天引脚没反应——时钟没开,寄存器写进去的值根本到不了硬件。
3.6 流水灯实现:让 LED 动起来
单个 LED 点亮了,接下来做流水灯。假设 8 个 LED 接在 PA0~PA7 上,我们要让它们依次点亮,形成流水效果。
最直接的做法:循环移位。每次把 ODR 的值左移一位,最低位补 0,这样亮的 LED 就依次移动了。
// 流水灯实现
void FlowLED(void)
{
uint8_t i;
uint16_t led_data = 0xFE; // 初始:PA0 低电平(亮),其余高电平(灭)
while(1)
{
GPIOA->ODR = (GPIOA->ODR & 0xFF00) | (led_data & 0x00FF);
Delay_ms(200); // 延时 200ms
// 左移一位,最低位补 0
led_data = (led_data << 1) | 0x01;
// 如果移到了第 8 位,重新开始
if(led_data == 0xFF)
{
led_data = 0xFE;
}
}
}
这里有个细节:GPIOA->ODR = (GPIOA->ODR & 0xFF00) | (led_data & 0x00FF); 这行代码只修改低 8 位,不影响高 8 位。如果 PA8~PA15 接了其他外设,就不会被误操作。
3.7 位带操作:用 C 语言实现"位寻址"
写 GPIOA->ODR |= (1 << 0); 这种代码,每次都要读-改-写,效率不高。而且代码可读性差,一堆移位操作看着就头疼。
Cortex-M3 内核提供了一个神器:位带操作(Bit Banding)。它把每个 bit 映射到一个 32 位的地址空间。你往这个地址写 1,对应的 bit 就置 1;写 0,bit 就清 0。相当于用 C 语言实现了 8051 的位寻址。
位带映射公式:
位带别名地址 = 位带基址 + (字节偏移 × 32) + (位号 × 4)
以 STM32F103 为例,SRAM 位带区从 0x20000000 开始,外设位带区从 0x40000000 开始。GPIOA 的 ODR 寄存器地址是 0x4001080C,我们要操作第 0 位:
// 计算 PA0 的位带别名地址
// 外设位带基址: 0x42000000
// GPIOA->ODR 地址: 0x4001080C
// 字节偏移: 0x4001080C - 0x40000000 = 0x1080C
// 位号: 0
#define PA0_OUT (*(volatile uint32_t *)(0x42000000 + (0x1080C * 32) + (0 * 4)))
// 使用
PA0_OUT = 1; // PA0 输出高电平
PA0_OUT = 0; // PA0 输出低电平
是不是清爽多了?而且位带操作是原子操作,不会被中断打断,在多任务环境下特别有用。
- 代码简洁,可读性强
- 读-改-写操作是原子的,无需关中断
- 效率高,一条指令完成
不过要注意,位带操作只适用于 Cortex-M3/M4/M7 等内核。如果你用 8051 或者 PIC,那还是老老实实用移位吧。
3.8 实战:用位带操作实现流水灯
我们把前面的流水灯用位带操作重写一遍。先定义好每个引脚的位带别名:
// 定义 PA0~PA7 的位带别名
#define PA0 (*(volatile uint32_t *)(0x42000000 + (0x1080C * 32) + (0 * 4)))
#define PA1 (*(volatile uint32_t *)(0x42000000 + (0x1080C * 32) + (1 * 4)))
#define PA2 (*(volatile uint32_t *)(0x42000000 + (0x1080C * 32) + (2 * 4)))
#define PA3 (*(volatile uint32_t *)(0x42000000 + (0x1080C * 32) + (3 * 4)))
#define PA4 (*(volatile uint32_t *)(0x42000000 + (0x1080C * 32) + (4 * 4)))
#define PA5 (*(volatile uint32_t *)(0x42000000 + (0x1080C * 32) + (5 * 4)))
#define PA6 (*(volatile uint32_t *)(0x42000000 + (0x1080C * 32) + (6 * 4)))
#define PA7 (*(volatile uint32_t *)(0x42000000 + (0x1080C * 32) + (7 * 4)))
// 流水灯
void FlowLED_BitBand(void)
{
while(1)
{
PA0 = 0; Delay_ms(200); PA0 = 1;
PA1 = 0; Delay_ms(200); PA1 = 1;
PA2 = 0; Delay_ms(200); PA2 = 1;
PA3 = 0; Delay_ms(200); PA3 = 1;
PA4 = 0; Delay_ms(200); PA4 = 1;
PA5 = 0; Delay_ms(200); PA5 = 1;
PA6 = 0; Delay_ms(200); PA6 = 1;
PA7 = 0; Delay_ms(200); PA7 = 1;
}
}
这段代码,就算没学过嵌入式的人也能看懂——PA0 输出低电平,延时,输出高电平,然后轮到 PA1。这就是位带操作带来的可读性提升。
3.9 本章小结
这一章内容不少,从 GPIO 的四种工作模式,到寄存器配置,再到 LED 驱动和流水灯实现,最后用位带操作做了优化。核心就几点:
- 推挽输出驱动 LED,开漏输出用于总线
- 上拉/下拉解决引脚浮空问题
- 寄存器配置三步走:开时钟 → 配模式 → 写数据
- 位带操作让代码更简洁、更高效