第4章 GPIO实战:按键输入检测、LED流水灯、位带操作实现原子化GPIO控制
GPIO,说白了就是芯片的“手脚”。你让它输出高电平,它就推个1出去;你读它的电平,就知道外部是按下还是松开。这一章我们直接上手,把CMS32的GPIO玩透。
我个人习惯,学一个新MCU,先点灯,再读按键。这两个动作能验证寄存器配置、时钟使能、引脚复用这些基础环节有没有搞对。搞不对?灯不亮,按键没反应,问题出在哪一目了然。
4.1 GPIO模块整体结构
先看一张总图,把GPIO的“骨架”搭起来。
这张图里,寄存器组是“大脑”,功能模块是“肌肉”,引脚Pad是“手指”。你写寄存器,就是在指挥大脑,让手指去干活。
4.2 按键输入检测实战
按键检测,看起来简单,坑其实不少。我刚开始做嵌入式那会儿,以为读个IO电平就完事了。结果产品一上电,按键乱跳,按一下触发十次。后来才明白,机械按键有抖动,必须做消抖处理。
4.2.1 硬件连接
| 引脚 | 方向 | 外部电路 | 默认电平 |
|---|---|---|---|
| PA0 | 输入 | 按键 → GND | 高电平 (VDD) |
| PA1 | 输入 | 按键 → GND | 高电平 (VDD) |
按键按下,引脚被拉到GND,读回来是0。松开,内部上拉电阻把它拉回1。嗯,这里要注意:CMS32内部上拉电阻默认是关闭的,必须手动使能。
4.2.2 寄存器配置步骤
- 使能GPIO时钟:RCC->AHBENR 置位对应位
- 配置模式:MODER 设为输入模式 (00)
- 使能内部上拉:PUPDR 设为上拉 (01)
- 读取电平:读 IDR 寄存器对应位
关键寄存器速查
- RCC->AHBENR:GPIOA使能位在bit 0
- GPIOA->MODER:每2位控制一个引脚,00=输入
- GPIOA->PUPDR:每2位控制一个引脚,01=上拉
- GPIOA->IDR:每1位对应一个引脚的电平
4.2.3 代码实现:带消抖的按键检测
/* 按键初始化 */
void Key_Init(void)
{
RCC->AHBENR |= (1 << 0); /* 使能GPIOA时钟 */
GPIOA->MODER &= ~(0x03 << 0); /* PA0: 输入模式 */
GPIOA->MODER &= ~(0x03 << 2); /* PA1: 输入模式 */
GPIOA->PUPDR |= (0x01 << 0); /* PA0: 上拉 */
GPIOA->PUPDR |= (0x01 << 2); /* PA1: 上拉 */
}
/* 带软件消抖的按键读取 */
uint8_t Key_Read(GPIO_TypeDef *GPIOx, uint16_t pin)
{
if (GPIOx->IDR & pin) {
return 1; /* 未按下 */
}
/* 延时10ms消抖 */
Delay_ms(10);
if (GPIOx->IDR & pin) {
return 1; /* 抖动后恢复,视为未按下 */
}
return 0; /* 确实按下了 */
}
我的经验:消抖延时10ms是个经验值。按键品质好,5ms也够;品质差的,20ms才稳。我一般留个宏定义,方便调试时改。
4.3 LED流水灯实战
流水灯,GPIO输出的“Hello World”。但别小看它,它能帮你验证引脚映射、时钟树、输出驱动能力这些基础环节。
4.3.1 硬件连接
| 引脚 | 方向 | 外部电路 | 驱动方式 |
|---|---|---|---|
| PB0 | 输出 | LED → 电阻 → GND | 推挽输出 |
| PB1 | 输出 | LED → 电阻 → GND | 推挽输出 |
| PB2 | 输出 | LED → 电阻 → GND | 推挽输出 |
| PB3 | 输出 | LED → 电阻 → GND | 推挽输出 |
4.3.2 寄存器配置步骤
- 使能GPIOB时钟:RCC->AHBENR bit 1
- 配置模式:MODER 设为输出模式 (01)
- 配置输出类型:OTYPER 设为推挽 (0)
- 配置输出速度:OSPEEDR 设为低速 (00)
- 写ODR寄存器:控制LED亮灭
4.3.3 代码实现:流水灯
/* LED初始化 */
void LED_Init(void)
{
RCC->AHBENR |= (1 << 1); /* 使能GPIOB时钟 */
GPIOB->MODER &= ~(0xFF << 0); /* 清除PB0~PB3模式位 */
GPIOB->MODER |= (0x55 << 0); /* PB0~PB3: 输出模式 */
GPIOB->OTYPER &= ~(0x0F << 0); /* 推挽输出 */
GPIOB->OSPEEDR &= ~(0xFF << 0); /* 低速 */
}
/* 流水灯主循环 */
void LED_Flow(void)
{
uint8_t i;
uint16_t led_pattern = 0x01; /* 从PB0开始 */
while (1) {
GPIOB->ODR = (GPIOB->ODR & ~0x0F) | led_pattern;
Delay_ms(200); /* 每200ms切换一次 */
led_pattern <<= 1; /* 左移一位 */
if (led_pattern > 0x08) {
led_pattern = 0x01; /* 回到起点 */
}
}
}
注意:写ODR时,一定要用“读-改-写”操作,不要直接赋值覆盖其他引脚的状态。否则,其他引脚的电平会被意外改变。
4.4 位带操作:原子化GPIO控制
为什么需要位带操作?你想想看,在多任务环境下,一个“读-改-写”操作可能被中断打断。比如你在读ODR,准备改bit 3,结果中断里改了bit 5,等你写回去,bit 5的状态就被覆盖了。这就是竞态条件。
位带操作,说白了就是把一个32位的寄存器,映射到一段“位带别名区”。你往别名区写一个字,硬件自动完成“读-改-写”,而且这个过程是原子的,不会被中断打断。
4.4.1 位带映射公式
CMS32的位带映射和Cortex-M内核一致:
别名区地址 = 位带基址 + (字节偏移 × 32) + (位号 × 4)
具体到GPIO:
| 区域 | 位带区基址 | 别名区基址 |
|---|---|---|
| SRAM | 0x2000_0000 | 0x2200_0000 |
| 外设 (含GPIO) | 0x4000_0000 | 0x4200_0000 |
4.4.2 位带操作宏定义
/* 位带操作宏:将外设地址的某个位映射到别名区 */
#define BITBAND_PERIPH(addr, bit) \
(*(volatile uint32_t *)(0x42000000 + ((uint32_t)(addr) - 0x40000000) * 32 + (bit) * 4))
/* GPIO ODR 位带别名 */
#define PAout(n) BITBAND_PERIPH(&GPIOA->ODR, n)
#define PBout(n) BITBAND_PERIPH(&GPIOB->ODR, n)
/* GPIO IDR 位带别名 */
#define PAin(n) BITBAND_PERIPH(&GPIOA->IDR, n)
#define PBin(n) BITBAND_PERIPH(&GPIOB->IDR, n)
4.4.3 使用位带操作改写流水灯
void LED_Flow_Bitband(void)
{
uint8_t i;
while (1) {
/* 逐个点亮,原子操作,无需读-改-写 */
for (i = 0; i < 4; i++) {
PBout(i) = 1; /* 点亮 */
Delay_ms(200);
PBout(i) = 0; /* 熄灭 */
}
}
}
位带操作的优势
- 原子性:单次总线访问,不会被中断打断
- 代码简洁:PBout(3) = 1,比 GPIOB->ODR |= (1<<3) 直观得多
- 性能:省去了读-改-写的三条指令,只需一条STR
我的习惯:只要项目里用到了RTOS或者有多个中断源,GPIO输出我一定用位带操作。曾经在一个电机驱动项目里,就是因为中断里改了另一个引脚的状态,导致电机抖动。换成位带操作后,问题立刻消失。
4.5 本章核心知识速查表
| 知识点 | 关键寄存器/操作 | 注意事项 |
|---|---|---|
| 按键输入 | MODER=00, PUPDR=01, 读IDR | 必须消抖,10ms延时 |
| LED输出 | MODER=01, OTYPER=0, 写ODR | 读-改-写,避免覆盖 |
| 位带操作 | 别名区地址 = 基址 + 偏移×32 + 位×4 | 仅适用于SRAM和外设区 |
| 原子性 | 单次总线写操作 | 多任务/中断环境必用 |