第三章 GPIO高级应用:位带操作、多引脚控制与模拟I2C
各位做嵌入式开发的同行,今天我们来聊聊GPIO那些“进阶”玩法。说实话,很多工程师用STM32好几年,GPIO还停留在最基础的推挽输出、上拉输入阶段。但真正到了项目里——比如你要同时控制8个LED实现流水灯,或者用普通IO口模拟I2C总线——你就会发现,光靠标准库函数一个个操作,效率低得让人抓狂。
这一章,我把个人项目中沉淀下来的几个GPIO高级技巧分享给你。包括位带操作、多引脚同时控制、外部中断线映射,以及用GPIO模拟I2C时序。每个技巧背后都有我踩过的坑,咱们边聊边看。
本章核心知识点一览
- 位带操作原理与实现——把GPIO当“位变量”用
- 多引脚同时控制——一次写操作搞定多个IO
- 外部中断线映射——16条中断线如何分配给多个IO
- GPIO模拟I2C时序——用普通IO实现标准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外设的各种“坑”。