第三章 GPIO高级应用:位带操作、多引脚控制与模拟I2C

各位做嵌入式开发的同行,今天我们来聊聊GPIO那些“进阶”玩法。说实话,很多工程师用STM32好几年,GPIO还停留在最基础的推挽输出、上拉输入阶段。但真正到了项目里——比如你要同时控制8个LED实现流水灯,或者用普通IO口模拟I2C总线——你就会发现,光靠标准库函数一个个操作,效率低得让人抓狂。

这一章,我把个人项目中沉淀下来的几个GPIO高级技巧分享给你。包括位带操作、多引脚同时控制、外部中断线映射,以及用GPIO模拟I2C时序。每个技巧背后都有我踩过的坑,咱们边聊边看。

本章核心知识点一览

  • 位带操作原理与实现——把GPIO当“位变量”用
  • 多引脚同时控制——一次写操作搞定多个IO
  • 外部中断线映射——16条中断线如何分配给多个IO
  • GPIO模拟I2C时序——用普通IO实现标准I2C通信
GPIO高级应用知识体系 GPIO高级应用 位带操作原理 多引脚同时控制 外部中断线映射 GPIO模拟I2C 别名区映射 原子操作 BSRR/BRR ODR并行写 AFIO配置 中断优先级 SCL/SDA 时序 💡 核心思路:用最少的CPU开销,实现最灵活的IO控制 位带操作→单周期位寻址 | 多引脚控制→一次写16位 | 中断映射→灵活分配 | 模拟I2C→节省外设资源

3.1 位带操作:把GPIO当“位变量”用

先问一个问题:在C语言里,你想单独控制一个bit,通常怎么做?用位运算——&、|、~、<<。但每次操作都要读-改-写三步,而且不是原子操作。如果中间被中断打断,很容易出问题。

位带操作就是来解决这个痛点的。Cortex-M3内核提供了一个“位带区”,把每个bit映射到一个32位的别名地址。你直接往这个地址写0或1,就能单独控制那个bit,而且是原子操作,不会被中断打断。

位带映射公式(记住这个)

别名地址 = 位带基址 + (字节偏移 × 32) + (位号 × 4)

对于SRAM区:位带基址 = 0x22000000

对于外设区:位带基址 = 0x42000000

拿GPIO来举例。GPIOB的ODR寄存器地址是0x40010C0C。我想单独控制PB5,按公式算一下:

// 字节偏移 = 0x40010C0C - 0x40000000 = 0x10C0C
// 别名地址 = 0x42000000 + (0x10C0C × 32) + (5 × 4)
//         = 0x42000000 + 0x218180 + 0x14
//         = 0x42218194

#define PB5_OUT  (*(volatile uint32_t *)0x42218194)

// 使用
PB5_OUT = 1;  // PB5输出高电平
PB5_OUT = 0;  // PB5输出低电平

我在项目中用过位带操作做LED指示灯控制。8个LED分别接在PB0~PB7,用位带定义后,每个LED就是一个独立的“变量”。代码可读性极高,而且没有读-改-写的风险。

注意:位带操作只适用于Cortex-M3/M4/M7内核。M0/M0+不支持位带,别搞混了。另外,位带别名地址的访问速度比普通寄存器访问稍慢一点点,但在绝大多数场景下可以忽略。

3.2 多引脚同时控制:BSRR与BRR的妙用

你想想看,如果我想同时把PB0、PB2、PB5、PB7置高,其他引脚不变,用标准库怎么写?

GPIO_SetBits(GPIOB, GPIO_Pin_0 | GPIO_Pin_2 | GPIO_Pin_5 | GPIO_Pin_7);

这行代码背后,库函数其实在读ODR、做或运算、再写回ODR。如果中间来了个中断,刚好也操作了GPIOB,那就乱套了。

STM32的GPIO模块设计了两个“原子操作”寄存器:BSRR和BRR。BSRR的低16位是置位寄存器,写1对应的位就置高;高16位是复位寄存器,写1对应的位就置低。BRR只有低16位,功能同BSRR的高16位。

寄存器 位范围 功能 操作
BSRR [15:0] 置位(写1对应引脚输出高) 原子操作
BSRR [31:16] 复位(写1对应引脚输出低) 原子操作
BRR [15:0] 复位(写1对应引脚输出低) 原子操作

一次写BSRR,可以同时置高某些引脚、置低某些引脚。比如:

// PB0、PB2置高,PB5、PB7置低,其他不变
GPIOB->BSRR = (1 << 0) | (1 << 2) | (1 << (5 + 16)) | (1 << (7 + 16));

这一条指令,硬件自动完成,中间不可能被打断。我在做步进电机驱动时,经常需要同时控制方向引脚和使能引脚,用BSRR一次搞定,省心多了。

实战技巧:如果你需要同时控制多个引脚输出相同电平,可以直接写ODR寄存器。比如让PB0~PB7全部输出0x55:

GPIOB->ODR = (GPIOB->ODR & 0xFF00) | 0x0055;

但注意这不是原子操作,如果中断里也改了ODR,会有冲突。所以关键场合还是用BSRR/BRR更安全。

3.3 外部中断线映射:16条线如何分配给多个IO

STM32的外部中断控制器(EXTI)支持16条中断线(EXTI0~EXTI15),但每个GPIO端口有16个引脚。问题来了:PA0、PB0、PC0……这些引脚都对应EXTI0,怎么选?

答案是AFIO(Alternate Function IO)寄存器。通过配置AFIO_EXTICR1~AFIO_EXTICR4这四个寄存器,你可以把每个EXTI线映射到不同端口的对应引脚上。

EXTI线 AFIO寄存器 可选端口
EXTI0 AFIO_EXTICR1[3:0] PA0、PB0、PC0、PD0、PE0、PF0、PG0
EXTI1 AFIO_EXTICR1[7:4] PA1、PB1、PC1、PD1、PE1、PF1、PG1
EXTI2 AFIO_EXTICR1[11:8] PA2、PB2、PC2、PD2、PE2、PF2、PG2
… … …
EXTI15 AFIO_EXTICR4[15:12] PA15、PB15、PC15、PD15、PE15、PF15、PG15

举个例子,我想用PB1作为外部中断触发引脚:

// 1. 使能AFIO时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);

// 2. 将EXTI1映射到PB1
GPIO_EXTILineConfig(GPIO_PortSourceGPIOB, GPIO_PinSource1);

// 3. 配置EXTI1为下降沿触发
EXTI_InitTypeDef EXTI_InitStructure;
EXTI_InitStructure.EXTI_Line = EXTI_Line1;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling;
EXTI_InitStructure.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStructure);

// 4. 配置NVIC
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = EXTI1_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);

我曾经踩过的坑:同一个EXTI线不能同时映射到两个不同的引脚。比如你不能同时用PA0和PB0都触发EXTI0中断。如果项目需要多个引脚触发同一个中断服务函数,你得用“线与”的方式——把多个引脚通过外部电路连到一起,或者用软件轮询的方式在中断里判断。

3.4 GPIO模拟I2C时序:用普通IO实现标准I2C

I2C总线是嵌入式系统里最常用的通信协议之一。但有时候,芯片的I2C外设不够用,或者引脚被其他功能占用了。这时候,用GPIO模拟I2C时序就成了必备技能。

标准I2C协议的核心就两条线:SCL(时钟)和SDA(数据)。时序上分几个阶段:起始信号、数据传输、应答信号、停止信号。

I2C时序关键点:

  • 起始信号:SCL高电平时,SDA从高变低
  • 数据位:SCL低电平时改变SDA,SCL高电平时采样SDA
  • 应答信号:第9个SCL时钟,从机拉低SDA表示应答
  • 停止信号:SCL高电平时,SDA从低变高

下面是我在项目中用过的模拟I2C代码框架,去掉了具体延时,只保留核心逻辑:

// 宏定义SCL和SDA引脚
#define I2C_SCL_H()    GPIO_SetBits(GPIOB, GPIO_Pin_6)
#define I2C_SCL_L()    GPIO_ResetBits(GPIOB, GPIO_Pin_6)
#define I2C_SDA_H()    GPIO_SetBits(GPIOB, GPIO_Pin_7)
#define I2C_SDA_L()    GPIO_ResetBits(GPIOB, GPIO_Pin_7)
#define I2C_SDA_READ() GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7)

// 起始信号
void I2C_Start(void)
{
    I2C_SDA_H();
    I2C_SCL_H();
    delay_us(5);
    I2C_SDA_L();  // SCL高时,SDA下降沿
    delay_us(5);
    I2C_SCL_L();  // 拉低SCL,准备传输数据
}

// 停止信号
void I2C_Stop(void)
{
    I2C_SDA_L();
    I2C_SCL_H();
    delay_us(5);
    I2C_SDA_H();  // SCL高时,SDA上升沿
    delay_us(5);
}

// 发送一个字节
void I2C_SendByte(uint8_t data)
{
    for(uint8_t i = 0; i < 8; i++)
    {
        if(data & 0x80)
            I2C_SDA_H();
        else
            I2C_SDA_L();
        delay_us(2);
        I2C_SCL_H();  // 拉高SCL,从机采样
        delay_us(5);
        I2C_SCL_L();  // 拉低SCL,准备下一位
        delay_us(2);
        data <<= 1;
    }
    // 释放SDA,等待应答
    I2C_SDA_H();
    delay_us(2);
    I2C_SCL_H();
    delay_us(5);
    // 读取应答位
    uint8_t ack = I2C_SDA_READ();
    I2C_SCL_L();
    // ack == 0 表示应答
}

// 接收一个字节
uint8_t I2C_ReceiveByte(void)
{
    uint8_t data = 0;
    I2C_SDA_H();  // 释放SDA,让从机控制
    for(uint8_t i = 0; i < 8; i++)
    {
        data <<= 1;
        I2C_SCL_H();
        delay_us(5);
        if(I2C_SDA_READ())
            data |= 0x01;
        I2C_SCL_L();
        delay_us(5);
    }
    return data;
}

延时怎么定?标准I2C模式最高400kHz,对应SCL周期2.5μs。我一般把SCL高/低电平各延时2~5μs,这样实际频率在100~200kHz之间,兼容性好。如果从机支持高速模式(1MHz),可以适当减小延时。

模拟I2C的好处是灵活——你可以把SCL和SDA放在任意两个GPIO上,不受硬件I2C外设的引脚限制。我在一个项目里同时用了3路模拟I2C,分别接不同的传感器,硬件I2C外设留给了更高速的通信。

注意:模拟I2C是纯软件实现的,会占用CPU时间。如果系统里有很多中断,或者主频很低,建议用硬件I2C+DMA的方式。另外,模拟I2C的时序对延时敏感,延时函数最好用定时器或SysTick实现,不要用简单的for循环空转——不同优化等级下,for循环的延时差异很大。

小结

这一章的内容,说白了就是让你把GPIO用“透”。位带操作让你能像操作变量一样操作单个引脚;BSRR/BRR让你一次控制多个引脚且保证原子性;外部中断映射让你灵活分配中断资源;模拟I2C则是在硬件资源不够时的“救命稻草”。

这些技巧我在实际项目中反复用过。说实话,位带操作和BSRR用得最多,尤其是做实时性要求高的控制时。模拟I2C虽然看起来“土”,但胜在灵活,有时候比硬件I2C还好用——至少你不用纠结于I2C外设的各种“坑”。