第三讲: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关键字。我见过不少新手忘了加,结果编译器优化后,寄存器读写全乱套了。为什么?因为编译器觉得这个变量没变过,直接用了缓存的值。但寄存器是硬件,随时可能变啊!

警告: 寄存器映射必须使用 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,结果芯片过热保护了。

小技巧: 如果驱动能力不够,可以加三极管或MOS管做开关。汽车电子里,驱动继电器必须加外部驱动,千万别直接用GPIO去推。

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)
重点: 位带操作是原子操作,不会被中断打断。这在实时性要求高的场合特别有用,比如PWM模拟、时序要求严格的通信协议。

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