4. 外设驱动开发(GPIO与UART):寄存器映射、GPIO输入输出驱动、UART轮询与中断模式驱动

好,咱们今天聊点实在的。外设驱动开发,说白了就是让芯片的“手脚”动起来。GPIO和UART,这是嵌入式开发里最基础、也最常用的两个外设。你想想看,一个芯片如果连灯都点不亮、数据都发不出去,那跟一块石头有什么区别?

我个人习惯,拿到一个新芯片,第一件事就是先把GPIO和UART调通。为什么?因为GPIO能帮你验证芯片是否正常工作,UART则是你调试的“眼睛”和“嘴巴”。今天我就把这两个外设的驱动开发,从寄存器映射到中断模式,掰开了揉碎了讲给你听。

4.1 寄存器映射:让C语言直接操作硬件

寄存器映射,这是驱动开发的基石。说白了,就是把芯片手册里那些物理地址,用C语言的指针或者结构体给“映射”过来。这样你写代码的时候,就能像操作普通变量一样去读写硬件寄存器了。

我在项目中遇到过不少新手,上来就写 *(volatile uint32_t *)0x40020C00 = 0x01; 这种代码。嗯,能跑,但可读性太差了。你过两周再看,自己都看不懂写了啥。

我建议的做法,是用结构体把一组寄存器封装起来。比如GPIO,通常会有MODER、OTYPER、OSPEEDR、PUPDR、IDR、ODR、BSRR、AFRL、AFRH这些寄存器。你可以这样定义:

typedef struct {
    volatile uint32_t MODER;    // 模式寄存器
    volatile uint32_t OTYPER;   // 输出类型寄存器
    volatile uint32_t OSPEEDR;  // 输出速度寄存器
    volatile uint32_t PUPDR;    // 上下拉寄存器
    volatile uint32_t IDR;      // 输入数据寄存器
    volatile uint32_t ODR;      // 输出数据寄存器
    volatile uint32_t BSRR;     // 位设置/复位寄存器
    volatile uint32_t AFRL;     // 复用功能低寄存器
    volatile uint32_t AFRH;     // 复用功能高寄存器
} GPIO_TypeDef;

然后,把基地址映射成指针:

#define GPIOA_BASE      0x40020000
#define GPIOA           ((GPIO_TypeDef *)GPIOA_BASE)
#define GPIOB_BASE      0x40020400
#define GPIOB           ((GPIO_TypeDef *)GPIOB_BASE)

这样,你写 GPIOA->MODER = 0x01; 是不是就清晰多了?

小技巧: 记得加 volatile 关键字。编译器有时候会自作聪明地优化掉你对寄存器的读写,加了 volatile 就是告诉编译器:“别动我的寄存器,每次都要老老实实地去读去写。”

4.2 GPIO输入输出驱动:从点灯到按键检测

GPIO驱动,最经典的两个场景就是:输出控制LED,输入检测按键。咱们一个一个说。

4.2.1 GPIO输出驱动——点灯

点灯,嵌入式界的“Hello World”。但你别小看它,这里面门道不少。

第一步,使能时钟。很多新手会栽在这里——寄存器配了半天,灯就是不亮。为什么?因为GPIO模块的时钟没开!在STM32这类芯片上,外设的时钟默认是关闭的,你得先打开它:

RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;  // 使能GPIOA时钟

第二步,配置模式。GPIO有输入、输出、复用、模拟四种模式。输出的话,还要选择推挽还是开漏。推挽输出能主动拉高拉低,开漏则需要外部上拉电阻。我一般用推挽输出:

GPIOA->MODER &= ~(0x3 << (5 * 2));   // 先清零
GPIOA->MODER |= (0x1 << (5 * 2));    // 设置为输出模式
GPIOA->OTYPER &= ~(0x1 << 5);        // 推挽输出
GPIOA->OSPEEDR |= (0x2 << (5 * 2));  // 高速

第三步,输出电平。你可以直接写ODR寄存器,但我更推荐用BSRR寄存器。BSRR是“原子操作”,写一次就能同时设置和复位多个引脚,不会被打断:

GPIOA->BSRR = (1 << 5);   // 拉高PA5,灯亮
// 或者
GPIOA->BSRR = (1 << (5 + 16)); // 拉低PA5,灯灭
避坑指南: 我曾经在一个项目里,用ODR寄存器连续操作两个引脚,结果因为中断的干扰,两个引脚的状态出现了不一致。后来换成BSRR,问题就解决了。记住,BSRR的低16位是置位,高16位是复位,同时写同一个引脚,复位优先。

4.2.2 GPIO输入驱动——按键检测

按键检测,看起来简单,但要做好不容易。核心问题就是:机械抖动。

你按下一个按键,实际上在几毫秒内会反复弹跳好几次。如果不做处理,一次按键可能会被误判成十几次。我常用的消抖方法有两种:

  • 硬件消抖: 加RC滤波电路,简单粗暴,但会增加BOM成本。
  • 软件消抖: 检测到电平变化后,延时10-20ms再读一次。如果状态一致,就认为是有效按键。

配置输入模式也很简单:

GPIOA->MODER &= ~(0x3 << (0 * 2));   // 输入模式
GPIOA->PUPDR |= (0x1 << (0 * 2));    // 上拉,默认高电平

读取按键状态:

uint8_t key_state = (GPIOA->IDR >> 0) & 0x01;  // 读PA0

嗯,这里要注意,按键按下时,如果配置了上拉,读到的就是低电平。别搞反了。

4.3 UART轮询模式驱动:简单可靠的数据收发

UART,串口,嵌入式调试的“生命线”。轮询模式是最简单的实现方式,CPU不停地检查状态寄存器,看看数据有没有收到、能不能发送。

先看初始化。UART的配置参数不少:波特率、数据位、停止位、校验位、流控。我一般用115200-8-N-1,也就是115200波特率、8位数据、无校验、1位停止位:

void UART_Init(void) {
    // 1. 使能时钟
    RCC->APB1ENR |= RCC_APB1ENR_USART2EN;
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    
    // 2. 配置GPIO复用功能
    GPIOA->MODER &= ~(0x3 << (2*2) | 0x3 << (3*2));
    GPIOA->MODER |= (0x2 << (2*2) | 0x2 << (3*2));  // 复用功能
    GPIOA->AFRL |= (0x7 << (2*4) | 0x7 << (3*4));   // AF7 = USART2
    
    // 3. 配置UART参数
    USART2->BRR = 84000000 / 115200;  // 波特率
    USART2->CR1 = USART_CR1_TE | USART_CR1_RE | USART_CR1_UE;  // 使能发送、接收、UART
}

轮询发送一个字节:

void UART_SendByte(uint8_t data) {
    while(!(USART2->SR & USART_SR_TXE));  // 等待发送缓冲区空
    USART2->DR = data;
}

轮询接收一个字节:

uint8_t UART_ReceiveByte(void) {
    while(!(USART2->SR & USART_SR_RXNE));  // 等待接收数据
    return (uint8_t)(USART2->DR & 0xFF);
}
注意: 轮询模式有个致命缺点——CPU被“锁死”了。在等待数据的时候,CPU什么都干不了。如果数据一直不来,程序就卡死在那里。所以轮询模式只适合简单的调试场景,或者数据量极小的应用。

4.4 UART中断模式驱动:高效的数据处理

中断模式,才是真正实用的UART驱动方式。CPU不用傻等,数据来了,硬件会自动触发中断,你只需要在中断服务函数里处理数据就行。

配置中断的步骤:

// 在初始化中增加中断使能
USART2->CR1 |= USART_CR1_RXNEIE;  // 使能接收中断
NVIC_EnableIRQ(USART2_IRQn);      // 使能NVIC中断
NVIC_SetPriority(USART2_IRQn, 1); // 设置优先级

中断服务函数:

volatile uint8_t rx_buffer[256];
volatile uint16_t rx_index = 0;

void USART2_IRQHandler(void) {
    if(USART2->SR & USART_SR_RXNE) {
        uint8_t data = (uint8_t)(USART2->DR & 0xFF);
        rx_buffer[rx_index++] = data;
        if(rx_index >= 256) rx_index = 0;  // 环形缓冲区,溢出回绕
    }
}

这里有个坑,我踩过好几次。中断服务函数里,一定要先判断中断标志位,再读数据。因为UART可能有多个中断源(接收、发送、错误等),你不判断就一股脑读DR,可能会读到错误数据。

另外,中断服务函数要尽量短。别在里面做复杂的处理,比如解析协议、调用printf。正确的做法是:中断里只收数据,放到缓冲区,然后设置一个标志位。主循环里检测到标志位,再去处理数据。

我的经验: 实际项目中,我一般会实现一个环形缓冲区(Ring Buffer),配合中断使用。这样既能保证数据不丢失,又能让主循环有足够的时间去处理。环形缓冲区的实现不复杂,但要注意读写指针的同步问题,防止出现“数据覆盖”或“重复读取”的bug。

4.5 总结与避坑清单

好了,GPIO和UART的驱动开发,核心内容就这些。我最后给你列一个避坑清单,都是我曾经血泪教训换来的:

常见问题 原因 解决方案
GPIO不工作 时钟没使能 检查RCC寄存器,确保外设时钟已开启
按键误触发 机械抖动 软件延时消抖,或硬件RC滤波
UART数据乱码 波特率不匹配 核对时钟源频率和BRR寄存器计算值
中断不触发 NVIC未使能 检查NVIC_EnableIRQ和中断优先级设置
中断服务函数卡死 未清除中断标志 读DR寄存器会自动清除RXNE标志,但其他标志需手动清除

驱动开发,说白了就是“读手册、配寄存器、测功能”这三板斧。但每一板斧都有它的门道。你多写几次,多踩几个坑,自然就熟练了。下一章,咱们聊聊定时器和PWM,那又是另一番天地了。