3. GPIO驱动移植与实战(LED/按键)

各位同学,欢迎来到第三章。这一章我们开始真正动手——把GPIO驱动移植到RT-Thread上。说白了,就是让板子上的LED能亮,按键能响应中断。我刚开始学RT-Thread时,觉得GPIO驱动是最简单的,结果踩了不少坑。今天我把这些经验都揉进这节课里,你跟着走一遍,后面再写其他外设驱动就顺了。

3.1 STM32 HAL库GPIO基础

先回顾一下HAL库的GPIO操作。STM32的GPIO,每个端口有16个引脚,每个引脚可以配置成输入、输出、复用功能或模拟模式。HAL库封装了这些操作,我们常用的就几个函数:

函数 功能 典型用法
HAL_GPIO_WritePin 设置引脚输出电平 点亮/熄灭LED
HAL_GPIO_ReadPin 读取引脚输入电平 读取按键状态
HAL_GPIO_TogglePin 翻转引脚输出电平 LED闪烁
HAL_GPIO_EXTI_Callback 外部中断回调函数 按键中断处理

嗯,这里要注意:HAL库的GPIO初始化是通过MX_GPIO_Init()完成的,CubeMX会帮我们生成配置代码。但RT-Thread的PIN设备驱动,需要我们自己把HAL库的底层操作封装成统一的接口。

我的习惯:在写驱动前,先用CubeMX把GPIO的时钟、模式、上下拉、中断优先级配好。生成代码后,再手动添加RT-Thread的驱动层。这样硬件初始化交给CubeMX,我们只关心驱动框架的对接。

3.2 RT-Thread PIN设备驱动框架解析

RT-Thread的PIN设备驱动,核心是一个结构体:struct rt_pin_ops。它定义了所有GPIO操作的标准接口。你想想看,不管是什么芯片,最终都要实现这组操作函数。

struct rt_pin_ops
{
    void (*pin_mode)(rt_device_t dev, rt_base_t pin, rt_base_t mode);
    void (*pin_write)(rt_device_t dev, rt_base_t pin, rt_base_t value);
    rt_int32_t (*pin_read)(rt_device_t dev, rt_base_t pin);
    rt_err_t (*pin_attach_irq)(rt_device_t dev, rt_base_t pin,
                               rt_uint32_t mode, void (*hdr)(void *args), void *args);
    rt_err_t (*pin_detach_irq)(rt_device_t dev, rt_base_t pin);
    rt_err_t (*pin_irq_enable)(rt_device_t dev, rt_base_t pin, rt_uint32_t enabled);
};

这个结构体就是驱动框架的“契约”。你只要实现了这6个函数,RT-Thread就能通过统一的API操作你的GPIO。比如上层调用rt_pin_write(),最终会调用到你写的pin_write函数。

我画了一张图,帮你理清整个调用链路:

RT-Thread PIN设备驱动调用链路 应用层 rt_pin_write() / rt_pin_read() 设备管理层 rt_device_write() 分发 驱动框架层 struct rt_pin_ops 回调 BSP驱动层 HAL_GPIO_WritePin() 等 我们编写的部分 实现 pin_ops 结构体 注册 PIN 设备 处理中断回调 RT-Thread 内核提供 设备管理、调度、IPC

3.3 编写GPIO驱动:注册PIN设备、实现ops

好,理论讲完了,我们直接上代码。我会一步步带你实现一个完整的GPIO驱动。

3.3.1 定义引脚编号映射

首先,我们需要把STM32的引脚编号映射到RT-Thread的引脚编号。RT-Thread用0~15表示每个端口的引脚,但我们需要区分不同端口。我习惯用(端口号 * 16 + 引脚号)的方式编码。

#define STM32_PIN(port, pin)    ((port * 16) + pin)

/* 举例:PA0 = 0*16 + 0 = 0, PB5 = 1*16 + 5 = 21 */
#define LED_PIN     STM32_PIN(0, 5)   /* PA5 */
#define KEY_PIN     STM32_PIN(1, 12)  /* PB12 */
我曾经踩过的坑:一开始我直接用HAL库的GPIO_Pin编号(比如GPIO_PIN_5),结果发现RT-Thread的pin_read传进来的参数是0~15,跟HAL库的位掩码对不上。后来我统一用引脚序号(0~15)做中间层,在ops函数里再转成HAL库的GPIO_Pin_x掩码。这个转换层一定要做,不然调试起来很痛苦。

3.3.2 实现pin_mode函数

这个函数负责配置引脚模式。RT-Thread定义了PIN_MODE_OUTPUT、PIN_MODE_INPUT、PIN_MODE_INPUT_PULLUP等模式,我们需要映射到HAL库的GPIO初始化结构体。

static void stm32_pin_mode(rt_device_t dev, rt_base_t pin, rt_base_t mode)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    uint16_t hal_pin = 1 << (pin & 0x0F);  /* 取低4位作为引脚号 */
    GPIO_TypeDef *gpio_port = get_gpio_port(pin >> 4);  /* 取高4位作为端口号 */

    GPIO_InitStruct.Pin = hal_pin;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;

    switch (mode)
    {
    case PIN_MODE_OUTPUT:
        GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
        GPIO_InitStruct.Pull = GPIO_NOPULL;
        break;
    case PIN_MODE_INPUT:
        GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
        GPIO_InitStruct.Pull = GPIO_NOPULL;
        break;
    case PIN_MODE_INPUT_PULLUP:
        GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
        GPIO_InitStruct.Pull = GPIO_PULLUP;
        break;
    case PIN_MODE_INPUT_PULLDOWN:
        GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
        GPIO_InitStruct.Pull = GPIO_PULLDOWN;
        break;
    default:
        return;
    }

    HAL_GPIO_Init(gpio_port, &GPIO_InitStruct);
}

3.3.3 实现pin_write和pin_read

这两个函数相对简单,直接调用HAL库的接口就行。

static void stm32_pin_write(rt_device_t dev, rt_base_t pin, rt_base_t value)
{
    uint16_t hal_pin = 1 << (pin & 0x0F);
    GPIO_TypeDef *gpio_port = get_gpio_port(pin >> 4);

    HAL_GPIO_WritePin(gpio_port, hal_pin, (GPIO_PinState)value);
}

static rt_int32_t stm32_pin_read(rt_device_t dev, rt_base_t pin)
{
    uint16_t hal_pin = 1 << (pin & 0x0F);
    GPIO_TypeDef *gpio_port = get_gpio_port(pin >> 4);

    return (rt_int32_t)HAL_GPIO_ReadPin(gpio_port, hal_pin);
}

3.3.4 实现中断相关函数

中断处理是GPIO驱动里最绕的部分。RT-Thread要求我们实现pin_attach_irq、pin_detach_irq和pin_irq_enable。核心思路是:用一个数组保存每个引脚的中断回调函数,然后在HAL库的HAL_GPIO_EXTI_Callback里统一分发。

/* 中断回调函数表 */
static struct rt_pin_irq_hdr pin_irq_hdr_tab[] =
{
    {-1, 0, RT_NULL, RT_NULL},
    /* ... 共16个,对应EXTI0~EXTI15 */
};

static rt_err_t stm32_pin_attach_irq(rt_device_t dev, rt_base_t pin,
                                     rt_uint32_t mode, void (*hdr)(void *args), void *args)
{
    rt_uint8_t irq_index = pin & 0x0F;

    pin_irq_hdr_tab[irq_index].pin = pin;
    pin_irq_hdr_tab[irq_index].mode = mode;
    pin_irq_hdr_tab[irq_index].hdr = hdr;
    pin_irq_hdr_tab[irq_index].args = args;

    return RT_EOK;
}

/* HAL库中断回调 */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    rt_uint8_t irq_index;
    /* 根据GPIO_Pin找到对应的irq_index */
    for (int i = 0; i < 16; i++)
    {
        if (GPIO_Pin & (1 << i))
        {
            irq_index = i;
            break;
        }
    }

    if (pin_irq_hdr_tab[irq_index].hdr != RT_NULL)
    {
        pin_irq_hdr_tab[irq_index].hdr(pin_irq_hdr_tab[irq_index].args);
    }
}

3.3.5 注册PIN设备

最后一步,把ops结构体实例化,并注册到RT-Thread设备管理器中。

const static 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,
};

int rt_hw_pin_init(void)
{
    rt_device_pin_register("pin", &stm32_pin_ops, RT_NULL);
    return 0;
}
INIT_BOARD_EXPORT(rt_hw_pin_init);  /* 板级初始化时自动调用 */
关键点:使用INIT_BOARD_EXPORT宏,确保PIN设备在系统启动早期就被注册。这样后面的线程和设备才能正常使用GPIO。我见过有人用INIT_APP_EXPORT,结果驱动还没注册好,应用就开始调用了,直接崩溃。

3.4 实战:LED闪烁与按键中断

驱动写好了,我们来验证一下。写两个简单的线程:一个让LED以500ms间隔闪烁,另一个等待按键中断并打印消息。

3.4.1 LED闪烁线程

static void led_thread_entry(void *parameter)
{
    rt_pin_mode(LED_PIN, PIN_MODE_OUTPUT);

    while (1)
    {
        rt_pin_write(LED_PIN, PIN_HIGH);
        rt_thread_mdelay(500);
        rt_pin_write(LED_PIN, PIN_LOW);
        rt_thread_mdelay(500);
    }
}

3.4.2 按键中断线程

static void key_irq_callback(void *args)
{
    /* 中断回调中只做标记,不要做耗时操作 */
    rt_kprintf("Key pressed!\n");
}

static void key_thread_entry(void *parameter)
{
    rt_pin_mode(KEY_PIN, PIN_MODE_INPUT_PULLUP);
    rt_pin_attach_irq(KEY_PIN, PIN_IRQ_MODE_FALLING, key_irq_callback, RT_NULL);
    rt_pin_irq_enable(KEY_PIN, PIN_IRQ_ENABLE);

    while (1)
    {
        rt_thread_mdelay(1000);  /* 线程主要做其他任务,中断由回调处理 */
    }
}
我的经验:中断回调函数里不要做复杂操作,比如打印、延时、内存分配。我一般只在回调里发一个信号量或事件,让线程去处理。上面为了演示简单,直接打印了,实际项目中别这么干。

3.5 常见问题与避坑指南

  • 引脚编号混乱:确保你的STM32_PIN宏和HAL库的端口号一致。我建议在头文件里用枚举把所有用到的引脚定义好,避免硬编码。
  • 中断优先级配置:RT-Thread需要中断优先级分组为4(即4位抢占优先级,0位子优先级)。如果CubeMX配置不对,会导致系统调度异常。
  • 中断回调未注册:记得在HAL_GPIO_EXTI_Callback里调用RT-Thread的回调分发。我一开始忘了这步,按键按下去一点反应都没有。
  • GPIO时钟未使能:在stm32_pin_mode里,如果端口时钟没开,配置会无效。建议在驱动初始化时统一使能所有可能用到的端口时钟。