3. GPIO 基础与 LED 驱动:从寄存器到流水灯

GPIO,全称 General-Purpose Input/Output,通用输入输出口。说白了,它就是单片机跟外界打交道最基础、最常用的方式。你想想看,一个芯片要控制外部设备,要么输出高低电平,要么读取外部信号的高低电平,这些都得靠 GPIO。

我刚开始学单片机那会儿,第一个实验就是点亮一个 LED。别看它简单,这里面涉及的知识点——GPIO 模式配置、推挽与开漏的区别、上拉下拉电阻的作用——几乎贯穿了整个嵌入式开发生涯。今天我们就把它彻底讲透。

本章核心脉络: GPIO 工作模式 → 寄存器配置 → 点亮单个 LED → 流水灯实现 → 位带操作优化
GPIO 与 LED 驱动知识体系 GPIO 核心概念 推挽输出 开漏输出 上拉输入 下拉输入 寄存器操作:CRL/CRH(模式) + ODR(输出) + IDR(输入) 点亮单个 LED 流水灯实现 位带操作优化 💡 学习路径建议: 先理解四种 GPIO 模式的区别 → 掌握寄存器配置方法 → 动手实现 LED 控制 → 用位带操作优化代码

3.1 GPIO 的四种工作模式

很多初学者一上来就写代码,却忽略了 GPIO 模式配置。结果 LED 不亮,查半天发现是模式配错了。我个人习惯是,写任何 GPIO 代码前,先想清楚:这个引脚要干什么?输出还是输入?驱动能力够不够?

GPIO 模式可以分成两大类:输出模式和输入模式。输出模式里又有推挽和开漏两种;输入模式里又有上拉、下拉和浮空三种。我们重点讲最常用的四种。

模式 内部结构 典型应用 注意事项
推挽输出 PMOS + NMOS 互补结构 驱动 LED、普通数字信号输出 输出高电平时直接接 VCC,驱动能力强
开漏输出 只有 NMOS,高阻需外接上拉 I²C 总线、多设备共用线路 必须外接上拉电阻才能输出高电平
上拉输入 内部弱上拉电阻 (~40kΩ) 按键检测、开关信号读取 引脚悬空时默认高电平
下拉输入 内部弱下拉电阻 (~40kΩ) 防止引脚浮空误触发 引脚悬空时默认低电平
我的经验: 驱动 LED 时,99% 的情况用推挽输出就够了。开漏输出主要用于需要"线与"功能的场景,比如 I²C。如果你不确定用哪种,先选推挽,出不了大问题。

3.2 推挽输出 vs 开漏输出:到底怎么选?

这两个概念,我当年也迷糊过一阵子。推挽输出,内部有两只管子:一只负责拉高(PMOS),一只负责拉低(NMOS)。输出高电平时,PMOS 导通,引脚直接连到 VCC;输出低电平时,NMOS 导通,引脚连到 GND。所以推挽输出的高低电平都很"硬",驱动能力强。

开漏输出就不一样了。它只有一只 NMOS 管,负责拉低。输出低电平时,NMOS 导通,引脚到 GND;输出高电平时,NMOS 截止,引脚处于高阻态——这时候如果没有外部上拉电阻,引脚电平就是"悬空"的,不确定。

为什么会有人用开漏?我举个例子:I²C 总线上挂了好几个设备,如果都用推挽输出,两个设备同时输出不同电平,一个拉高一个拉低,那就短路了。开漏输出配合上拉电阻,可以实现"线与"——任何一个设备拉低,总线就是低电平。这个特性在总线通信中非常有用。

避坑指南: 我曾经在一个项目里,用开漏输出直接驱动 LED,结果 LED 死活不亮。查了半天才发现,开漏输出高电平时引脚是高阻态,LED 根本得不到电流。记住:开漏输出必须外接上拉电阻才能输出高电平!

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 接了其他外设,就不会被误操作。

优化建议: 延时用定时器实现,别用纯软件延时。软件延时浪费 CPU,而且不精确。后面章节我们会讲定时器的用法。

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。这就是位带操作带来的可读性提升。

注意: 位带别名地址的计算依赖于具体的寄存器地址。不同型号的 MCU,GPIO 基地址可能不同。换芯片时一定要重新计算,别直接复制粘贴。我见过有人把 STM32F103 的代码直接搬到 F407 上,结果位带地址全错了。

3.9 本章小结

这一章内容不少,从 GPIO 的四种工作模式,到寄存器配置,再到 LED 驱动和流水灯实现,最后用位带操作做了优化。核心就几点:

  • 推挽输出驱动 LED,开漏输出用于总线
  • 上拉/下拉解决引脚浮空问题
  • 寄存器配置三步走:开时钟 → 配模式 → 写数据
  • 位带操作让代码更简洁、更高效