4. GPIO实战:点亮LED、按键输入检测、位带操作实现、GPIO模拟I2C时序
GPIO,说白了就是单片机最基础的“手脚”。你让它输出高电平,它就推高;你让它读引脚,它就知道外面是0还是1。这一章我们直接上手,把CMS32的GPIO玩透。
我个人习惯,学一个新外设,先点亮一颗LED。灯亮了,说明你的时钟、引脚模式、输出寄存器都配置对了。这比看一百页手册都实在。
4.1 点亮一颗LED:从寄存器到代码
先看硬件连接。我手头的板子上,LED接在PB0引脚,高电平点亮。那我们要做三件事:
- 开启GPIOB的时钟
- 把PB0设为推挽输出模式
- 往PB0的ODR寄存器写1
CMS32的时钟使能在CRM模块里。我记得第一次用这个芯片时,找了半天时钟寄存器,后来发现它叫CRM_APB2ENR。代码很简单:
// 1. 使能GPIOB时钟
CRM->APB2ENR |= CRM_APB2ENR_GPIOBEN;
// 2. 配置PB0为推挽输出,速度50MHz
GPIOB->CRL &= ~(0x0F << (0 * 4)); // 先清空配置位
GPIOB->CRL |= (0x03 << (0 * 4)); // 0x03: 推挽输出, 50MHz
// 3. 点亮LED
GPIOB->ODR |= (1 << 0);
你看,三步走完,灯就亮了。但这里有个细节——CRL寄存器每4位控制一个引脚。PB0对应CRL的bit0~bit3。0x03代表输出模式、50MHz速度。如果你想要开漏输出,那就改成0x07。
4.2 按键输入检测:去抖是门手艺
按键检测,听起来简单,做起来坑不少。我刚开始做项目时,直接读引脚电平,结果按下一次触发了好几次。为什么?因为按键是机械结构,按下和松开时会有抖动。
硬件上,按键通常接在PB1,外部上拉到3.3V,按下时接地。所以读到的电平是:
- 未按下:高电平(1)
- 按下:低电平(0)
配置输入模式时,要注意设置上拉或下拉。CMS32的GPIO输入模式默认是浮空,需要手动使能内部上拉:
// 配置PB1为输入模式
GPIOB->CRL &= ~(0x0F << (1 * 4)); // 清空
GPIOB->CRL |= (0x08 << (1 * 4)); // 0x08: 输入模式
// 使能内部上拉
GPIOB->ODR |= (1 << 1); // ODR对应位写1 = 上拉
然后就是去抖。我常用的方法是延时去抖,简单可靠:
uint8_t Key_Scan(void)
{
if ((GPIOB->IDR & (1 << 1)) == 0) // 检测到按下
{
Delay_ms(10); // 延时10ms,避开抖动区
if ((GPIOB->IDR & (1 << 1)) == 0) // 再次确认
{
while ((GPIOB->IDR & (1 << 1)) == 0); // 等待松开
return 1; // 有效按键
}
}
return 0;
}
4.3 位带操作:让GPIO像51一样快
你想想看,操作一个引脚,要读-改-写整个ODR寄存器,是不是有点麻烦?CMS32支持位带操作,可以把一个bit映射到一个32位的地址空间。直接写那个地址,就等于操作对应的引脚。
位带操作的原理是这样的:
- 位带区:SRAM区(0x2000_0000 ~ 0x200F_FFFF)和外设区(0x4000_0000 ~ 0x400F_FFFF)
- 位带别名区:每个bit膨胀成32位地址
计算公式:
别名地址 = 位带基址 + (字节偏移 * 32) + (位号 * 4)
我封装了两个宏,用起来很方便:
// GPIOB的基址是0x40010C00,ODR偏移是0x0C
#define GPIOB_ODR_ADDR (0x40010C00 + 0x0C)
// 位带操作宏
#define BITBAND(addr, bit) (*(volatile uint32_t *)(0x42000000 + ((uint32_t)(addr) - 0x40000000) * 32 + (bit) * 4))
// 使用示例
#define LED_PIN BITBAND(GPIOB_ODR_ADDR, 0) // PB0
#define KEY_PIN BITBAND(GPIOB_IDR_ADDR, 1) // PB1
// 直接操作
LED_PIN = 1; // 点亮
LED_PIN = 0; // 熄灭
if (KEY_PIN == 0) // 检测按键
{
// ...
}
4.4 GPIO模拟I2C时序:软核才是真功夫
I2C总线,两根线:SCL(时钟)和SDA(数据)。CMS32虽然有硬件I2C,但有时候引脚不够用,或者要接的器件协议有点特殊,就得用GPIO模拟。
模拟I2C的核心是时序。我画了一张流程图,帮你理清逻辑:
看明白流程后,我们直接写代码。我习惯用延时来产生时序,因为简单直观:
// 定义引脚
#define I2C_SCL_PIN BITBAND(GPIOB_ODR_ADDR, 6) // PB6
#define I2C_SDA_PIN BITBAND(GPIOB_ODR_ADDR, 7) // PB7
// 延时函数(粗略延时,具体值根据主频调整)
static void I2C_Delay(void)
{
uint8_t i = 5;
while (i--);
}
// 起始条件
void I2C_Start(void)
{
I2C_SDA_PIN = 1;
I2C_SCL_PIN = 1;
I2C_Delay();
I2C_SDA_PIN = 0; // SCL高时,SDA下降沿
I2C_Delay();
I2C_SCL_PIN = 0; // 拉低SCL,准备传输数据
}
// 停止条件
void I2C_Stop(void)
{
I2C_SDA_PIN = 0;
I2C_SCL_PIN = 1;
I2C_Delay();
I2C_SDA_PIN = 1; // SCL高时,SDA上升沿
I2C_Delay();
}
// 发送一个字节
void I2C_SendByte(uint8_t data)
{
uint8_t i;
for (i = 0; i < 8; i++)
{
I2C_SCL_PIN = 0; // 拉低SCL,准备设置SDA
if (data & 0x80)
I2C_SDA_PIN = 1;
else
I2C_SDA_PIN = 0;
I2C_Delay();
I2C_SCL_PIN = 1; // 拉高SCL,从机采样
I2C_Delay();
data <<= 1;
}
I2C_SCL_PIN = 0; // 释放SCL
}
// 读取一个字节
uint8_t I2C_ReadByte(void)
{
uint8_t i, data = 0;
I2C_SDA_PIN = 1; // 释放SDA,让从机控制
for (i = 0; i < 8; i++)
{
I2C_SCL_PIN = 0;
I2C_Delay();
I2C_SCL_PIN = 1; // 拉高SCL,读取SDA
I2C_Delay();
data <<= 1;
if (I2C_SDA_PIN) // 读取SDA电平
data |= 0x01;
}
I2C_SCL_PIN = 0;
return data;
}
4.5 实战:用模拟I2C驱动OLED屏
纸上谈兵没意思。我们拿一个SSD1306的OLED屏来练手。这个屏很常见,0.96寸,128x64像素,I2C接口。
初始化序列我就不贴全了,重点看写命令和写数据的函数:
void OLED_WriteCmd(uint8_t cmd)
{
I2C_Start();
I2C_SendByte(0x78); // 设备地址 + 写位
I2C_WaitAck();
I2C_SendByte(0x00); // 控制字节:命令
I2C_WaitAck();
I2C_SendByte(cmd);
I2C_WaitAck();
I2C_Stop();
}
void OLED_WriteData(uint8_t data)
{
I2C_Start();
I2C_SendByte(0x78);
I2C_WaitAck();
I2C_SendByte(0x40); // 控制字节:数据
I2C_WaitAck();
I2C_SendByte(data);
I2C_WaitAck();
I2C_Stop();
}
你看,核心就是I2C的起始、发送、等待应答、停止。只要时序对了,剩下的就是填充数据。
4.6 本章小结
这一章我们干了四件事:
| 知识点 | 核心要点 | 常见坑 |
|---|---|---|
| 点亮LED | 时钟使能 → 模式配置 → ODR写值 | 忘记使能时钟,CRL未清空 |
| 按键检测 | 输入模式 + 上拉 + 去抖 | 去抖时间太短或太长 |
| 位带操作 | 地址映射,原子操作 | 地址计算错误 |
| 模拟I2C | 时序准确,开漏输出 | 引脚模式配置错误 |
GPIO是基础,但基础不牢,地动山摇。我见过太多工程师在GPIO上翻车——不是时钟没开,就是模式配错。你把这些练熟了,后面的UART、SPI、定时器,上手会快很多。