第4章:GPIO驱动开发:GPIO控制器驱动、API实现、中断处理、实际案例:LED控制

各位同学,欢迎来到第四章。前面几章我们把Zephyr的外设框架聊了个大概,今天咱们来点实在的——GPIO驱动开发。

GPIO,说白了就是芯片的“手脚”。你要控制LED、读取按键、驱动蜂鸣器,都离不开它。在Zephyr里,GPIO驱动看似简单,但里面的门道可不少。我刚开始接触Zephyr时,以为GPIO就是调几个API,结果在中断处理上栽了个大跟头。嗯,今天咱们就把这些坑一个个填上。

4.1 GPIO控制器驱动:底层是怎么工作的?

先说说GPIO控制器驱动。在Zephyr里,每个GPIO外设都对应一个驱动实例。这个实例负责管理硬件寄存器、配置引脚方向、设置上下拉等。

我个人习惯把GPIO驱动分成三层:

  • 硬件抽象层:直接操作寄存器,比如写GPIOx_MODER、GPIOx_ODR
  • API实现层:封装成Zephyr标准的gpio_pin_configure()、gpio_pin_set()等
  • 应用层:你写的业务代码,比如点个灯、读个按键

来看一个典型的GPIO控制器驱动初始化代码:

// 以STM32为例,GPIO控制器驱动初始化
static int gpio_stm32_init(const struct device *dev)
{
    struct gpio_stm32_data *data = dev->data;
    const struct gpio_stm32_config *cfg = dev->config;

    // 使能GPIO时钟
    clock_control_on(cfg->clock_dev, cfg->clock_subsys);

    // 配置中断(如果有)
    if (cfg->irq_num > 0) {
        irq_connect_dynamic(cfg->irq_num, cfg->irq_prio,
                            gpio_stm32_isr, (void *)dev, 0);
        irq_enable(cfg->irq_num);
    }

    return 0;
}

这里有个细节:时钟使能必须在配置引脚之前。我在项目中遇到过有人把顺序搞反,结果寄存器写不进去,查了半天才发现是时钟没开。

警告:GPIO初始化时,务必先使能外设时钟,再配置寄存器。否则写操作会被硬件忽略,而且不会报错——这种bug最难查。

4.2 API实现:Zephyr标准接口怎么封装?

Zephyr定义了一套统一的GPIO API,所有芯片的驱动都要实现这些接口。你想想看,这样上层应用就不用关心底层是STM32还是NXP了,多方便。

核心API就这几个:

API函数 功能 我踩过的坑
gpio_pin_configure() 配置引脚方向、上下拉、驱动强度 参数flags的位域容易搞混
gpio_pin_set() 输出高/低电平 注意逻辑电平与硬件电平的映射
gpio_pin_get() 读取引脚电平 开漏输出时读到的可能不准
gpio_pin_interrupt_configure() 配置中断触发方式 中断使能顺序有讲究

来看一个实际的API实现片段:

static int gpio_stm32_pin_configure(const struct device *dev,
                                    gpio_pin_t pin,
                                    gpio_flags_t flags)
{
    struct gpio_stm32_data *data = dev->data;
    const struct gpio_stm32_config *cfg = dev->config;
    uint32_t mode = 0, otype = 0, pupd = 0, speed = 0;

    // 解析flags,设置寄存器
    if (flags & GPIO_OUTPUT) {
        mode = 0x01;  // 输出模式
    } else {
        mode = 0x00;  // 输入模式
    }

    // 配置上下拉
    if (flags & GPIO_PULL_UP) {
        pupd = 0x01;
    } else if (flags & GPIO_PULL_DOWN) {
        pupd = 0x02;
    }

    // 写寄存器
    MODIFY_REG(cfg->base->MODER, 0x03 << (pin * 2), mode << (pin * 2));
    MODIFY_REG(cfg->base->PUPDR, 0x03 << (pin * 2), pupd << (pin * 2));

    return 0;
}

这里要注意:flags参数是Zephyr定义的位掩码,不同芯片的寄存器映射可能不同。我曾经在移植驱动时,直接拿STM32的代码改给GD32用,结果上下拉配置反了——因为两家芯片的PUPDR寄存器编码不一样。

小技巧:写GPIO驱动时,建议先对照芯片参考手册,把寄存器位定义列成宏。比如:#define GPIO_MODER_OUTPUT(x) (0x01 << ((x) * 2))。这样代码可读性高,也不容易出错。

4.3 中断处理:别让中断把你搞懵

GPIO中断,是嵌入式开发里最容易出问题的地方之一。我记得刚入行时,写一个按键中断,结果一按下去系统就死机——后来发现是没清中断标志位。

Zephyr的GPIO中断处理流程是这样的:

  1. 硬件检测到电平变化,触发中断
  2. CPU跳转到中断向量表,执行ISR
  3. ISR里调用回调函数(如果有注册)
  4. 清中断标志位,退出

来看一个完整的中断配置示例:

// 定义回调函数
void button_callback(const struct device *dev,
                     struct gpio_callback *cb,
                     uint32_t pins)
{
    printk("Button pressed! Pin mask: 0x%x\n", pins);
    // 这里可以做消抖处理
}

// 配置中断
void setup_button_interrupt(void)
{
    const struct device *gpio_dev;
    struct gpio_callback button_cb_data;

    gpio_dev = DEVICE_DT_GET(DT_NODELABEL(gpio0));
    if (!device_is_ready(gpio_dev)) {
        return;
    }

    // 配置引脚为输入,上拉
    gpio_pin_configure(gpio_dev, 5, GPIO_INPUT | GPIO_PULL_UP);

    // 配置中断:下降沿触发
    gpio_pin_interrupt_configure(gpio_dev, 5, GPIO_INT_EDGE_FALLING);

    // 注册回调
    gpio_init_callback(&button_cb_data, button_callback, BIT(5));
    gpio_add_callback(gpio_dev, &button_cb_data);
}

这里有几个关键点:

  • 中断触发方式:边沿触发还是电平触发?按键一般用下降沿或低电平
  • 消抖:机械按键有抖动,硬件消抖或软件延时都行
  • 回调函数里别做耗时操作:ISR里尽量只设标志位,具体处理放到线程里
避坑指南:我曾经在回调函数里直接调用printk,结果中断频率一高,系统直接卡死。因为printk本身可能触发其他中断,造成嵌套中断。记住:ISR里只做最必要的事,复杂逻辑交给线程。

4.4 实际案例:用GPIO控制LED

好了,理论说完了,咱们来点实战。控制LED是GPIO最经典的应用,没有之一。

先看硬件连接:LED正极接GPIO引脚,负极通过电阻接地。这样GPIO输出高电平时LED亮,低电平时灭。

在Zephyr里,我们通常用设备树来定义LED引脚:

// dts文件中的LED节点
/ {
    leds {
        compatible = "gpio-leds";
        led0: led_0 {
            gpios = &gpio0, 13, GPIO_ACTIVE_HIGH;
            label = "LED0";
        };
    };
};

然后代码里这样用:

#include <zephyr.h>
#include <device.h>
#include <devicetree.h>
#include <drivers/gpio.h>

// 获取LED对应的GPIO设备
#define LED0_NODE DT_ALIAS(led0)
#define LED0_GPIO_LABEL DT_GPIO_LABEL(LED0_NODE, gpios)
#define LED0_PIN DT_GPIO_PIN(LED0_NODE, gpios)
#define LED0_FLAGS DT_GPIO_FLAGS(LED0_NODE, gpios)

void main(void)
{
    const struct device *dev;
    int ret;

    dev = device_get_binding(LED0_GPIO_LABEL);
    if (!dev) {
        printk("Failed to get GPIO device\n");
        return;
    }

    // 配置为输出
    ret = gpio_pin_configure(dev, LED0_PIN,
                             GPIO_OUTPUT_ACTIVE | LED0_FLAGS);
    if (ret < 0) {
        return;
    }

    // 开始闪烁
    while (1) {
        gpio_pin_set(dev, LED0_PIN, 1);
        k_sleep(K_MSEC(500));
        gpio_pin_set(dev, LED0_PIN, 0);
        k_sleep(K_MSEC(500));
    }
}

这段代码很简单,但有几个细节值得注意:

  • GPIO_OUTPUT_ACTIVE:这个宏会根据设备树里的GPIO_ACTIVE_HIGH/LOW自动处理电平极性
  • device_get_binding:获取设备实例,如果返回NULL,说明设备没初始化或不存在
  • k_sleep:延时用内核API,别用空循环——那会浪费CPU
扩展一下:如果你想控制多个LED,可以用gpio_port_set_masked()一次性操作多个引脚。比如控制8个LED组成流水灯,用端口操作比逐个引脚设置快得多。

4.5 进阶:用GPIO模拟时序

最后聊个进阶话题。有时候你需要用GPIO模拟一些简单的通信协议,比如单总线、PWM(如果硬件不支持)。

我做过一个项目,需要驱动一个WS2812B灯带。这个灯带对时序要求很严格,必须用GPIO精确控制高低电平的持续时间。Zephyr的GPIO API本身不提供纳秒级延时,所以得用硬件定时器或者CPU空转。

这里给个思路:

// 用GPIO模拟WS2812B时序
#define T0H 3   // 0码高电平时间,单位:CPU周期
#define T0L 9   // 0码低电平时间
#define T1H 7   // 1码高电平时间
#define T1L 5   // 1码低电平时间

static inline void ws2812_send_bit(uint8_t bit)
{
    if (bit) {
        gpio_pin_set(dev, PIN, 1);
        __asm__("nop");  // 延时T1H
        gpio_pin_set(dev, PIN, 0);
        __asm__("nop");  // 延时T1L
    } else {
        gpio_pin_set(dev, PIN, 1);
        __asm__("nop");  // 延时T0H
        gpio_pin_set(dev, PIN, 0);
        __asm__("nop");  // 延时T0L
    }
}

注意:这种用空指令延时的方法依赖CPU频率,换芯片就得重新调参数。我建议用硬件定时器或者DWT(数据观察点跟踪)来做精确延时,更可靠。

重要提醒:用GPIO模拟时序时,一定要关中断!否则中断一来,时序就乱了。我吃过这个亏,灯带颜色怎么调都不对,最后发现是系统滴答中断干扰了时序。

好了,这一章的内容就到这。GPIO驱动开发,说白了就是三板斧:配置、读写、中断。但每一板斧都有它的门道。下一章咱们聊I2C驱动,那个更复杂,但更有意思。

记住:写驱动不是写应用,你得时刻想着硬件是怎么工作的。多读芯片手册,多动手调试,慢慢就有感觉了。