第三讲:GPIO驱动开发——从寄存器到实战
各位同学,欢迎来到第三讲。今天我们要啃的这块骨头,是嵌入式开发里最基础、也最绕不开的——GPIO。说白了,就是芯片的“手脚”,用来输出高低电平,或者读取外部信号。
我刚开始做汽车电子那会儿,第一个任务就是点亮板子上的LED。当时觉得这有什么难的?结果一上手,发现寄存器配置错了,灯死活不亮。后来查了半天,原来是上下拉没设对。嗯,从那以后,我养成了一个习惯:写GPIO驱动前,先把芯片手册翻烂。
3.1 GPIO模块的寄存器映射
每个MCU的GPIO外设,都有一组寄存器来控制它。这些寄存器被映射到芯片的地址空间里。你想想看,CPU要操作GPIO,总得知道往哪个地址写数据吧?
以STM32F103为例,GPIOA的基地址是0x40010800。这个地址怎么来的?芯片手册里写得清清楚楚。我个人习惯,在代码里用宏定义把它写死:
#define GPIOA_BASE (0x40010800UL)
#define GPIOA_CRL (*(volatile uint32_t *)(GPIOA_BASE + 0x00))
#define GPIOA_CRH (*(volatile uint32_t *)(GPIOA_BASE + 0x04))
#define GPIOA_IDR (*(volatile uint32_t *)(GPIOA_BASE + 0x08))
#define GPIOA_ODR (*(volatile uint32_t *)(GPIOA_BASE + 0x0C))
#define GPIOA_BSRR (*(volatile uint32_t *)(GPIOA_BASE + 0x10))
#define GPIOA_BRR (*(volatile uint32_t *)(GPIOA_BASE + 0x14))
#define GPIOA_LCKR (*(volatile uint32_t *)(GPIOA_BASE + 0x18))
注意那个volatile关键字。我见过不少新手忘了加,结果编译器优化后,寄存器读写全乱套了。为什么?因为编译器觉得这个变量没变过,直接用了缓存的值。但寄存器是硬件,随时可能变啊!
3.2 输入输出模式配置
GPIO的模式,远不止“输入”和“输出”两种。以STM32为例,每个引脚可以配置成8种模式之一。我整理了一张表,你收藏好:
| 模式 | CNF[1:0] | MODE[1:0] | 说明 |
|---|---|---|---|
| 模拟输入 | 00 | 00 | ADC专用,不接数字电路 |
| 浮空输入 | 01 | 00 | 无上下拉,高阻态 |
| 上拉/下拉输入 | 10 | 00 | 由PxODR寄存器决定 |
| 推挽输出 | 00 | xx | 最常用,能输出高低电平 |
| 开漏输出 | 01 | xx | 只能拉低,不能拉高 |
| 复用推挽 | 10 | xx | 由外设控制 |
| 复用开漏 | 11 | xx | I2C等总线用 |
配置代码其实很简单,就是往CRL或CRH寄存器里写值。比如把PA0设为推挽输出,50MHz:
// PA0 推挽输出,50MHz
GPIOA_CRL &= ~(0x0F << 0); // 先清零
GPIOA_CRL |= (0x03 << 0); // MODE=11, CNF=00
我建议你写一个通用的配置函数,把引脚号、模式、速度作为参数传进去。这样代码复用性高,也容易维护。
3.3 上下拉与驱动能力
上下拉电阻,是GPIO里最容易踩坑的地方。我曾经在一个项目里,按键检测老是误触发。查了两天,发现是内部上拉没使能,引脚浮空,噪声一过来就误判。
内部上下拉怎么配?以STM32为例,通过ODR寄存器来控制:
- 配置成输入模式时,ODR=0 表示下拉,ODR=1 表示上拉
- 配置成输出模式时,ODR就是输出数据寄存器
驱动能力呢?说白了就是引脚能输出多大电流。STM32的GPIO典型驱动能力是±8mA(源电流/灌电流)。但注意,所有引脚的总电流不能超过150mA。我见过有人一口气点亮20个LED,结果芯片过热保护了。
3.4 位带操作与原子访问
位带操作,是Cortex-M3内核的一个“黑科技”。它把每个bit映射到一个32位的地址空间。你往那个地址写1,对应的bit就置1;写0,就清0。而且这个操作是原子的——不会被中断打断。
为什么需要原子操作?你想想看,如果主循环里要置位一个引脚,突然来了个中断,中断里也操作同一个引脚,那不乱套了?
位带区的计算公式:
bit_word_addr = bit_band_base + (byte_offset * 32) + (bit_number * 4)
以GPIOA的ODR寄存器为例,要操作第0位:
#define BITBAND_PERIPH_BASE 0x42000000
#define GPIOA_ODR_ADDR 0x4001080C
#define PA0_BIT_ADDR (BITBAND_PERIPH_BASE + ((GPIOA_ODR_ADDR - 0x40000000) * 32) + (0 * 4))
// 原子置位PA0
*(volatile uint32_t *)PA0_BIT_ADDR = 1;
// 原子清零PA0
*(volatile uint32_t *)PA0_BIT_ADDR = 0;
我个人习惯,把位带操作封装成宏:
#define GPIO_BIT_SET(port, pin) (*(volatile uint32_t *)(BITBAND_PERIPH_BASE + ((port##_ODR_ADDR - 0x40000000) * 32) + ((pin) * 4)) = 1)
#define GPIO_BIT_CLR(port, pin) (*(volatile uint32_t *)(BITBAND_PERIPH_BASE + ((port##_ODR_ADDR - 0x40000000) * 32) + ((pin) * 4)) = 0)
3.5 实战:点亮板载LED与读取按键状态
理论说完了,咱们来点实际的。假设板子上有个LED接在PA0上(高电平点亮),有个按键接在PA1上(按下为低电平)。
第一步,配置GPIO:
void GPIO_Init(void)
{
// PA0: 推挽输出, 50MHz
GPIOA_CRL &= ~(0x0F << 0);
GPIOA_CRL |= (0x03 << 0);
// PA1: 上拉输入
GPIOA_CRL &= ~(0x0F << 4);
GPIOA_CRL |= (0x08 << 4); // CNF=10, MODE=00
GPIOA_ODR |= (1 << 1); // 使能上拉
}
第二步,点亮LED:
// 用位带操作点亮
GPIO_BIT_SET(GPIOA, 0);
// 或者用BSRR寄存器(也是原子的)
GPIOA_BSRR = (1 << 0); // 置位
第三步,读取按键:
uint8_t Key_Read(void)
{
// 读取IDR寄存器
if(GPIOA_IDR & (1 << 1))
{
return 1; // 未按下
}
else
{
return 0; // 按下
}
}
第四步,主循环里做点事:
int main(void)
{
GPIO_Init();
while(1)
{
if(Key_Read() == 0) // 按键按下
{
GPIO_BIT_SET(GPIOA, 0); // 点亮LED
}
else
{
GPIO_BIT_CLR(GPIOA, 0); // 熄灭LED
}
}
}
嗯,代码很简单。但实际项目中,按键要加消抖处理。我一般用定时器中断,每10ms采样一次,连续采样3次一致才认为有效。这个后面讲定时器时会细说。
好了,这一讲的内容就到这里。GPIO看似简单,但它是所有外设驱动的基础。寄存器映射、模式配置、上下拉、位带操作——这些基本功打扎实了,后面学SPI、I2C、UART都会轻松很多。
下一讲,我们会进入定时器模块。到时候我会分享一个我在汽车项目里遇到的“定时器配置错误导致电机抖动”的案例,保证让你印象深刻。
公众号:蓝海资料掘金营,微信deep3321