3. STM32硬件基础回顾

这一章,我们来啃一块硬骨头——GPIO。别觉得它简单,GPIO是嵌入式世界里最基础也最容易被忽视的外设。我见过不少工程师,GPIO点灯溜得很,但一遇到中断回调、设备模型集成就翻车。说白了,寄存器操作是基本功,框架理解才是分水岭。

3.1 STM32 GPIO寄存器,你真的看懂了吗?

先看寄存器。STM32的GPIO,每个端口有16个引脚,控制寄存器就那么几个。我习惯把它们分成三类:模式配置、数据读写、复用与中断。

寄存器 功能 我常用的位域
MODER 模式选择(输入/输出/复用/模拟) 每引脚2位,00=输入,01=输出,10=复用,11=模拟
OTYPER 输出类型(推挽/开漏) 每引脚1位,0=推挽,1=开漏
OSPEEDR 输出速度 每引脚2位,建议外设用high,GPIO用medium
PUPDR 上下拉配置 每引脚2位,01=上拉,10=下拉
IDR / ODR 输入/输出数据 读IDR,写ODR
BSRR 位设置/复位 低16位置位,高16位复位,原子操作
AFRL / AFRH 复用功能选择 每引脚4位,查数据手册对应AF编号

这里有个坑,我刚开始做项目时踩过——BSRR寄存器。很多人写ODR来操作引脚,但ODR不是原子操作,在多任务环境下可能被中断打断。BSRR是原子操作,写1置位,写1复位,不会受其他操作影响。嗯,这点在RT-Thread里尤其重要。

核心原则:多任务环境下操作GPIO输出,优先用BSRR而不是ODR。BSRR的原子性可以避免“读-改-写”带来的竞态问题。

3.2 RT-Thread PIN设备驱动框架

RT-Thread把GPIO抽象成了PIN设备。说白了,就是不管你用STM32还是GD32,上层API都一样。框架分三层:

  • 设备驱动层:实现rt_pin_ops结构体,包含mode、write、read、attach_irq等函数指针
  • 设备管理层:rt_device_pin_register注册设备,提供统一接口
  • 应用层:rt_pin_mode、rt_pin_write、rt_pin_read、rt_pin_attach_irq

我个人的习惯是,先看驱动层的实现,再理解上层API。驱动层核心就是下面这个结构体:

static const struct rt_pin_ops stm32_pin_ops =
{
    stm32_pin_mode,
    stm32_pin_write,
    stm32_pin_read,
    stm32_pin_attach_irq,
    stm32_pin_detach_irq,
    stm32_pin_irq_enable,
    stm32_pin_get,
};

每个函数对应一个寄存器操作。比如stm32_pin_write,最终调用的就是BSRR或ODR。你想想看,框架把底层差异全封装了,应用层根本不用关心寄存器地址。

小技巧:RT-Thread的PIN编号是线性映射的。比如PA0=0,PA1=1,...,PB0=16。这个映射关系在board.h里定义,移植时一定要核对。

3.3 编写GPIO按键驱动

好,理论讲完了,咱们动手写一个按键驱动。这个驱动要支持:

  • 按键初始化(配置为输入、上拉)
  • 读取按键状态
  • 注册中断回调
  • 消抖处理

先看初始化代码:

#include <rtthread.h>
#include <rtdevice.h>

#define KEY_PIN            GET_PIN(C, 13)  // PC13,板载按键
#define KEY_IRQ_MODE       PIN_IRQ_MODE_FALLING

static struct rt_semaphore key_sem;

static void key_irq_callback(void *args)
{
    rt_sem_release(&key_sem);
}

static void key_thread_entry(void *parameter)
{
    while (1)
    {
        if (rt_sem_take(&key_sem, RT_WAITING_FOREVER) == RT_EOK)
        {
            // 消抖处理:延时20ms再读一次
            rt_thread_mdelay(20);
            if (rt_pin_read(KEY_PIN) == PIN_LOW)
            {
                rt_kprintf("Key pressed!\n");
                // 这里可以触发你的业务逻辑
            }
        }
    }
}

int key_driver_init(void)
{
    rt_pin_mode(KEY_PIN, PIN_MODE_INPUT_PULLUP);
    rt_pin_attach_irq(KEY_PIN, KEY_IRQ_MODE, key_irq_callback, RT_NULL);
    rt_pin_irq_enable(KEY_PIN, PIN_IRQ_ENABLE);

    rt_sem_init(&key_sem, "key_sem", 0, RT_IPC_FLAG_FIFO);

    rt_thread_t tid = rt_thread_create("key", key_thread_entry, RT_NULL, 1024, 5, 10);
    if (tid != RT_NULL)
        rt_thread_startup(tid);

    return RT_EOK;
}
INIT_APP_EXPORT(key_driver_init);

这段代码有几个关键点:

  1. 中断回调里只做信号量释放,不做耗时操作。这是RT-Thread的硬性要求,中断上下文不能阻塞。
  2. 消抖放在线程里做,用rt_thread_mdelay延时,不占用中断时间。
  3. 使用INIT_APP_EXPORT自动初始化,省去手动调用的麻烦。

我曾经踩过的坑:中断回调里直接调用了rt_kprintf,结果系统卡死。原因是rt_kprintf可能触发调度,而中断里不允许调度。记住:中断回调只做最轻量的事,比如发信号量、发消息、置标志位。

3.4 中断处理与回调机制

RT-Thread的中断处理,说白了就是三层跳转:

  • 硬件中断:CPU响应中断,跳转到中断向量表
  • 内核中断处理:rt_hw_interrupt_handler,保存上下文,调用注册的中断服务函数
  • 驱动回调:stm32_pin_irq_handler遍历引脚中断状态,调用用户注册的回调函数

我画了一张图,帮你理清这个流程:

RT-Thread GPIO中断处理流程 硬件中断触发 中断向量表跳转 rt_hw_interrupt_handler stm32_pin_irq_handler 用户回调函数 关键点 • 中断回调不能阻塞 • 使用信号量/消息队列 • 消抖在线程中处理

回调机制的核心,是函数指针。驱动层维护一个回调函数表,每个引脚可以注册一个回调。当硬件中断发生时,驱动遍历所有引脚,找到触发中断的引脚,调用对应的回调函数。

// 驱动内部实现(简化版)
static void (*pin_irq_handler[16])(void *args);

void stm32_pin_irq_handler(int pin)
{
    if (pin_irq_handler[pin] != RT_NULL)
    {
        pin_irq_handler[pin](RT_NULL);  // 调用用户回调
    }
}

这里有个设计细节:回调函数的参数是void *args,你可以传入任意结构体指针。我习惯传入一个结构体,包含引脚号、事件类型、用户数据,这样回调里能拿到完整的上下文信息。

最佳实践:回调函数里只做“通知”操作,不做“处理”操作。通知线程去处理,线程里做消抖、滤波、业务逻辑。这样中断响应快,系统稳定。

3.5 本章小结

这一章我们走了三个层次:寄存器层、框架层、应用层。寄存器是基础,框架是工具,应用是目标。我个人觉得,理解这三层的关系,比背下每个寄存器的地址更重要。你想想看,不管芯片怎么换,只要框架在,应用代码几乎不用改——这就是设备模型的价值。