4. GPIO实战:点亮LED、按键输入检测、位带操作实现、GPIO模拟I2C时序

GPIO,说白了就是单片机最基础的“手脚”。你让它输出高电平,它就推高;你让它读引脚,它就知道外面是0还是1。这一章我们直接上手,把CMS32的GPIO玩透。

我个人习惯,学一个新外设,先点亮一颗LED。灯亮了,说明你的时钟、引脚模式、输出寄存器都配置对了。这比看一百页手册都实在。

4.1 点亮一颗LED:从寄存器到代码

先看硬件连接。我手头的板子上,LED接在PB0引脚,高电平点亮。那我们要做三件事:

  1. 开启GPIOB的时钟
  2. 把PB0设为推挽输出模式
  3. 往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/CRH寄存器时,一定要先清空再赋值。我曾经在项目里直接赋值,结果把其他引脚的模式改乱了,排查了半天。

你看,三步走完,灯就亮了。但这里有个细节——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;
}
注意: 延时去抖的10ms不是随便写的。我实测过,大部分按键的抖动时间在5~15ms之间。10ms是个安全值。如果你用的按键质量差,可能需要加到20ms。

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)  // 检测按键
{
    // ...
}
核心优势: 位带操作是原子操作,不会被中断打断。这在多中断场景下特别有用。我曾经在一个项目中用位带操作控制LED,中断服务函数里直接赋值,再也不用关中断了。

4.4 GPIO模拟I2C时序:软核才是真功夫

I2C总线,两根线:SCL(时钟)和SDA(数据)。CMS32虽然有硬件I2C,但有时候引脚不够用,或者要接的器件协议有点特殊,就得用GPIO模拟。

模拟I2C的核心是时序。我画了一张流程图,帮你理清逻辑:

GPIO模拟I2C时序流程图 GPIO模拟I2C主流程 I2C通信开始 发送起始条件 (S) 发送7位地址 + R/W(0) 等待从机ACK 发送8位数据 发送停止条件 (P) 通信结束 时序要点 • SCL高时采样SDA • 起始: SCL高, SDA下降 • 停止: SCL高, SDA上升

看明白流程后,我们直接写代码。我习惯用延时来产生时序,因为简单直观:

// 定义引脚
#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;
}
经验之谈: 模拟I2C的延时很关键。太快了从机跟不上,太慢了影响效率。我一般先用100kHz的速率调试,稳定后再调快。CMS32主频72MHz时,5个NOP的延时大概对应100kHz。

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的起始、发送、等待应答、停止。只要时序对了,剩下的就是填充数据。

避坑指南: 我曾经在模拟I2C时,忘了把SDA引脚配置为开漏输出。结果拉低没问题,拉高时因为推挽输出和从机的开漏输出冲突,导致通信不稳定。记住:模拟I2C的SDA和SCL都要配成开漏输出,并外接上拉电阻。

4.6 本章小结

这一章我们干了四件事:

知识点 核心要点 常见坑
点亮LED 时钟使能 → 模式配置 → ODR写值 忘记使能时钟,CRL未清空
按键检测 输入模式 + 上拉 + 去抖 去抖时间太短或太长
位带操作 地址映射,原子操作 地址计算错误
模拟I2C 时序准确,开漏输出 引脚模式配置错误

GPIO是基础,但基础不牢,地动山摇。我见过太多工程师在GPIO上翻车——不是时钟没开,就是模式配错。你把这些练熟了,后面的UART、SPI、定时器,上手会快很多。