3. 开发环境搭建

各位同学,这一章咱们来啃一块硬骨头——GPIO驱动移植。说实话,GPIO是嵌入式开发里最基础、最常用的外设,但恰恰是这种基础模块,移植起来反而容易出各种小毛病。我在做第一个RT-Thread项目时,就因为在GPIO中断配置上少写了一行代码,排查了整整一个下午。

这一章的目标很明确:把STM32的GPIO操作,封装成RT-Thread标准的PIN设备驱动。咱们分四步走——先分析STM32的GPIO库,再看RT-Thread的PIN框架,然后动手写驱动,最后用按键中断来验证。

3.1 STM32 GPIO标准库与HAL库分析

STM32的GPIO操作,说白了就是三件事:配置模式、读写电平、管理中断。标准库和HAL库的实现思路不太一样,但底层寄存器操作是相通的。

3.1.1 标准库核心流程

标准库的GPIO初始化,核心就两个结构体:GPIO_InitTypeDef 和 GPIO_TypeDef。前者描述引脚配置,后者映射到硬件寄存器。

// 标准库的典型GPIO初始化
GPIO_InitTypeDef GPIO_InitStructure;

RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;   // 上拉输入
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);

这里有个细节:标准库的 GPIO_Mode 是枚举值,直接对应CRL/CRH寄存器的配置位。HAL库则多了一层抽象,用 GPIO_InitTypeDef.Mode 组合了方向、上下拉、输出类型。

对比项 标准库 HAL库
初始化结构体 GPIO_InitTypeDef GPIO_InitTypeDef(同名但字段不同)
模式定义 GPIO_Mode_IPU/GPIO_Mode_Out_PP GPIO_MODE_INPUT/GPIO_MODE_OUTPUT_PP
时钟使能 RCC_APB2PeriphClockCmd() __HAL_RCC_GPIOA_CLK_ENABLE()
中断配置 需手动配置EXTI和NVIC HAL_GPIO_EXTI_Init()封装

我个人习惯:在RT-Thread驱动层,我更倾向于用HAL库。因为HAL库的抽象层次更接近RT-Thread的设备模型,而且HAL库的延时函数、中断回调机制,跟RT-Thread的线程调度能更好地配合。

3.1.2 HAL库的GPIO中断机制

HAL库的GPIO中断,走的是EXTI(外部中断/事件控制器)路线。每个GPIO引脚都可以映射到16个EXTI线之一。这里有个坑:同一EXTI线不能同时映射多个引脚。比如PA0和PB0不能同时使用EXTI0。

// HAL库的GPIO中断配置示例
GPIO_InitTypeDef GPIO_InitStruct = {0};

__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;  // 下降沿触发
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

// 配置NVIC
HAL_NVIC_SetPriority(EXTI0_IRQn, 5, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);

中断响应流程:引脚电平变化 → EXTI检测 → NVIC仲裁 → 执行中断服务函数 → 调用HAL库回调。嗯,这里要注意,HAL库的回调函数 HAL_GPIO_EXTI_Callback() 是弱定义,你需要自己重写。

我曾经踩过的坑: 在HAL库中,如果多个引脚共用同一个EXTI线,中断标志位会互相干扰。解决办法是:在回调函数里读取具体引脚的电平状态,或者干脆避免共用EXTI线。

3.2 RT-Thread PIN设备驱动框架

RT-Thread的PIN设备框架,说白了就是一套标准化的GPIO操作接口。它把不同芯片的GPIO差异,封装在 struct rt_pin_ops 这个结构体里。

3.2.1 框架核心结构

PIN设备框架分为三层:

  • 应用层:用户调用 rt_pin_mode()、rt_pin_write()、rt_pin_read()、rt_pin_attach_irq() 等API。
  • 框架层:维护引脚号到硬件引脚的映射表,统一中断回调注册机制。
  • 驱动层:实现 rt_pin_ops 中的函数指针,对接具体芯片的寄存器操作。
// rt_pin_ops 结构体定义(简化版)
struct rt_pin_ops
{
    void (*pin_mode)(rt_base_t pin, rt_base_t mode);
    void (*pin_write)(rt_base_t pin, rt_base_t value);
    rt_int32_t (*pin_read)(rt_base_t pin);
    rt_err_t (*pin_attach_irq)(rt_base_t pin, rt_uint32_t mode,
                               void (*hdr)(void *args), void *args);
    rt_err_t (*pin_detach_irq)(rt_base_t pin);
    rt_err_t (*pin_irq_enable)(rt_base_t pin, rt_uint32_t enabled);
};

你想想看,这个设计妙在哪里?它把硬件相关的操作全部隔离在驱动层。上层应用写一次代码,换芯片时只需要换底层驱动。我在做产品迭代时,从STM32F1换到F4,应用层代码一行没改。

3.2.2 引脚号映射规则

RT-Thread使用统一的引脚编号:PIN_NUM(port, pin)。比如PA0就是 PIN_NUM(0, 0),PB5就是 PIN_NUM(1, 5)。这个宏定义通常在 pin_map.h 里。

// 引脚号映射示例
#define PIN_NUM(port, pin)  ((port) * 16 + (pin))

// 使用示例
rt_pin_mode(PIN_NUM(0, 0), PIN_MODE_INPUT_PULLUP);  // PA0上拉输入
rt_pin_write(PIN_NUM(1, 5), PIN_HIGH);               // PB5输出高电平
小技巧: 在编写驱动时,建议在 pin_map.h 里给每个实际使用的引脚起个语义化的宏名,比如 #define LED_PIN PIN_NUM(0, 1)。这样代码可读性会好很多。

3.3 编写GPIO驱动

好了,理论说完了,咱们动手写代码。这一节我会带着你,一步步把STM32的GPIO封装成RT-Thread的PIN设备。

3.3.1 驱动初始化

驱动初始化要做两件事:注册 rt_pin_ops 到框架,以及初始化必要的硬件资源。

// drv_gpio.c 核心初始化代码
static struct rt_pin_ops stm32_pin_ops =
{
    .pin_mode = stm32_pin_mode,
    .pin_write = stm32_pin_write,
    .pin_read = stm32_pin_read,
    .pin_attach_irq = stm32_pin_attach_irq,
    .pin_detach_irq = stm32_pin_detach_irq,
    .pin_irq_enable = stm32_pin_irq_enable,
};

int rt_hw_pin_init(void)
{
    // 注册PIN设备
    rt_device_pin_register("pin", &stm32_pin_ops, RT_NULL);
    return RT_EOK;
}
INIT_BOARD_EXPORT(rt_hw_pin_init);

这里用了 INIT_BOARD_EXPORT 宏,确保驱动在板级初始化阶段就被加载。我建议所有外设驱动都遵循这个初始化顺序,避免出现依赖问题。

3.3.2 核心函数实现

咱们挑两个最典型的函数来讲解:pin_mode 和 pin_attach_irq。

// pin_mode 实现:将RT-Thread模式映射到HAL库模式
static void stm32_pin_mode(rt_base_t pin, rt_base_t mode)
{
    uint16_t gpio_pin = 1 << (pin % 16);
    GPIO_TypeDef *gpio_port = get_gpio_port(pin / 16);
    GPIO_InitTypeDef GPIO_InitStruct = {0};

    GPIO_InitStruct.Pin = gpio_pin;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;

    switch (mode)
    {
    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_OUTPUT:
        GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
        GPIO_InitStruct.Pull = GPIO_NOPULL;
        break;
    // ... 其他模式
    }
    HAL_GPIO_Init(gpio_port, &GPIO_InitStruct);
}
// pin_attach_irq 实现:注册中断回调
static rt_err_t stm32_pin_attach_irq(rt_base_t pin, rt_uint32_t mode,
                                     void (*hdr)(void *args), void *args)
{
    uint8_t irq_line = pin % 16;

    // 保存回调函数到全局表
    irq_callback_table[irq_line].callback = hdr;
    irq_callback_table[irq_line].args = args;

    // 配置EXTI触发模式
    // ... 这里省略HAL库的EXTI配置代码

    return RT_EOK;
}
关键点: 中断回调函数必须保存在一个全局表中,因为硬件中断服务函数是固定的,需要通过查表来调用用户注册的回调。这个表的大小取决于芯片支持的EXTI线数量,通常是16。

3.4 测试按键中断

驱动写完了,得验证一下。咱们用按键中断来测试——按下按键,LED翻转。这是嵌入式开发的"Hello World"。

3.4.1 硬件连接

信号 引脚 配置
按键 PA0 上拉输入,下降沿触发
LED PB1 推挽输出

3.4.2 应用层代码

// main.c 测试代码
#include <rtthread.h>
#include <rtdevice.h>

#define KEY_PIN    PIN_NUM(0, 0)   // PA0
#define LED_PIN    PIN_NUM(1, 1)   // PB1

static void key_irq_callback(void *args)
{
    rt_base_t pin = (rt_base_t)args;
    rt_pin_write(LED_PIN, !rt_pin_read(LED_PIN));
}

int key_irq_test(void)
{
    // 配置LED引脚
    rt_pin_mode(LED_PIN, PIN_MODE_OUTPUT);
    rt_pin_write(LED_PIN, PIN_LOW);

    // 配置按键引脚
    rt_pin_mode(KEY_PIN, PIN_MODE_INPUT_PULLUP);

    // 注册中断回调
    rt_pin_attach_irq(KEY_PIN, PIN_IRQ_MODE_FALLING,
                      key_irq_callback, (void *)KEY_PIN);

    // 使能中断
    rt_pin_irq_enable(KEY_PIN, PIN_IRQ_ENABLE);

    return RT_EOK;
}
MSH_CMD_EXPORT(key_irq_test, test key interrupt);

这段代码在MSH命令行输入 key_irq_test 就能运行。按下按键,LED状态翻转。如果没反应,先检查中断是否使能,再检查回调函数是否注册成功。

我曾经遇到的典型问题: 中断回调函数里调用了 rt_pin_write,但这个函数内部可能有关键区保护。如果在中断上下文里调用会导致死锁。解决办法是:在回调函数里只发送一个信号量或事件,真正的处理放在线程里做。

3.5 本章知识体系

下面这张图,把GPIO驱动移植的完整脉络画出来了。从硬件寄存器到应用API,每一层都有明确的职责。

GPIO驱动移植知识体系 应用层:rt_pin_mode/read/write/attach_irq 框架层:rt_pin_ops 结构体 + 引脚映射表 驱动层:stm32_pin_mode/write/read/attach_irq 实现 HAL库层:HAL_GPIO_Init/EXTI_Config/NVIC_SetPriority 寄存器层:GPIOx_CRL/CRH/IDR/ODR/EXTI_IMR 关键设计点 • 引脚号统一映射 • 中断回调全局表 • 模式转换适配 每一层只依赖下一层,上层不关心底层实现细节

从这张图可以看得很清楚:应用层只调用RT-Thread的标准API,完全不关心底层是STM32还是GD32。驱动层负责把标准API翻译成HAL库调用,而HAL库再操作寄存器。这就是分层设计的威力。

好了,这一章的内容就到这里。GPIO驱动移植是RT-Thread外设驱动的基础,把这个搞懂了,后面UART、I2C、SPI的移植思路都是类似的。记住:先分析硬件库,再理解框架,最后动手写代码——这个顺序别搞反了。