5、Linux内核驱动开发:字符设备驱动模型、GPIO控制、设备树(DTS)编写

说实话,嵌入式Linux开发里,驱动开发是绕不开的坎儿。很多初学者一听到「内核驱动」四个字就头大,觉得那是大神才碰的东西。其实不然。你想想看,驱动说白了就是让内核认识你的硬件,然后给上层应用开个口子去用。今天我们就来聊聊最基础的字符设备驱动、GPIO怎么控制,以及设备树到底是个什么玩意儿。

核心要点:字符设备驱动是Linux驱动中最基础、最常用的模型。GPIO控制是嵌入式开发的必修课。设备树则是现代ARM Linux下描述硬件的标准方式。这三者构成了嵌入式驱动开发的铁三角。

5.1 字符设备驱动模型

字符设备,说白了就是像串口、LED、按键这种,数据是按字节流一个个读写的外设。跟块设备(比如硬盘)不一样,字符设备不支持随机访问,你没法「跳到第100个字节去读」。我刚开始做驱动时,第一个练手的项目就是写一个虚拟字符设备——不控制任何硬件,纯粹在内核里开个缓冲区,上层应用能读写就行。

字符设备驱动的核心结构,其实就三个东西:

  • file_operations 结构体——这是驱动的「接口表」,告诉内核这个设备支持哪些操作(open、read、write、ioctl等)
  • 设备号——主设备号标识驱动类型,次设备号标识具体设备
  • cdev 结构体——内核里代表字符设备的数据结构

来看一个最简单的框架代码:

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>

#define DEVICE_NAME "my_demo"
#define CLASS_NAME  "demo_class"

static int major;
static struct cdev my_cdev;
static struct class *demo_class;

static int demo_open(struct inode *inode, struct file *file) {
    pr_info("demo device opened\n");
    return 0;
}

static ssize_t demo_read(struct file *file, char __user *buf, 
                         size_t count, loff_t *offset) {
    // 这里实现从内核拷贝数据到用户空间
    return 0;
}

static ssize_t demo_write(struct file *file, const char __user *buf,
                          size_t count, loff_t *offset) {
    // 这里实现从用户空间接收数据
    return count;
}

static struct file_operations fops = {
    .owner = THIS_MODULE,
    .open  = demo_open,
    .read  = demo_read,
    .write = demo_write,
};

static int __init demo_init(void) {
    // 动态分配设备号
    alloc_chrdev_region(&dev, 0, 1, DEVICE_NAME);
    major = MAJOR(dev);
    
    // 初始化cdev并注册
    cdev_init(&my_cdev, &fops);
    cdev_add(&my_cdev, dev, 1);
    
    // 创建设备类
    demo_class = class_create(THIS_MODULE, CLASS_NAME);
    device_create(demo_class, NULL, dev, NULL, DEVICE_NAME);
    
    pr_info("Demo driver loaded, major=%d\n", major);
    return 0;
}

static void __exit demo_exit(void) {
    device_destroy(demo_class, dev);
    class_destroy(demo_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev, 1);
    pr_info("Demo driver unloaded\n");
}

module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");

这段代码我建议你亲手敲一遍。为什么?因为字符设备驱动的骨架就是这样的,不管你是控制GPIO、操作I2C还是SPI,底层的注册流程都大同小异。我曾经在项目里犯过一个低级错误——忘了调用 device_create,结果驱动加载成功了,但 /dev 下死活看不到设备节点。嗯,这种坑踩过一次就记住了。

小技巧:调试驱动时,多用 pr_info 打印日志。内核日志用 dmesg 查看。我习惯在 open、release 函数里都加一句打印,这样能清楚知道用户态程序有没有正确打开设备。

5.2 GPIO控制

GPIO控制是嵌入式驱动里最频繁的操作。在Linux内核里,控制GPIO有两种主流方式:

  1. 旧接口(gpio_xxx 系列)——基于整数编号,简单直接
  2. 新接口(gpiod_xxx 系列)——基于描述符,配合设备树使用

我个人强烈建议你用新接口。为什么?因为新接口跟设备树绑定,代码可移植性更好。你换个板子,只要改设备树,驱动代码不用动。我曾经维护过一个项目,旧接口的驱动换了三次板子改了三次代码,后来全部重构为gpiod接口,一劳永逸。

来看一个GPIO输出的例子——控制一个LED:

#include <linux/gpio/consumer.h>

struct gpio_desc *led_gpio;

static int led_probe(struct platform_device *pdev) {
    // 从设备树获取GPIO
    led_gpio = devm_gpiod_get(&pdev->dev, "led", GPIOD_OUT_LOW);
    if (IS_ERR(led_gpio)) {
        pr_err("Failed to get LED GPIO\n");
        return PTR_ERR(led_gpio);
    }
    
    // 设置高电平,点亮LED
    gpiod_set_value(led_gpio, 1);
    return 0;
}

static int led_remove(struct platform_device *pdev) {
    gpiod_set_value(led_gpio, 0);
    return 0;
}

这里有个细节要注意:devm_gpiod_get 里的第二个参数 "led" 对应设备树里GPIO的 led-gpios 属性名。命名规则是去掉 -gpios 后缀。嗯,这个对应关系我一开始也搞混过。

避坑指南:我曾经在中断上下文里调用 gpiod_set_value,结果系统直接死锁了。原因是GPIO操作可能引起休眠。记住:中断上下文里只能用 gpiod_set_value_cansleep 这类带 cansleep 后缀的函数,而且调用前要确保GPIO芯片支持原子操作。

5.3 设备树(DTS)编写

设备树,英文叫 Device Tree,是描述硬件信息的文本文件。编译后变成 .dtb 文件,内核启动时解析它来知道板子上有哪些外设、地址是多少、中断号是多少。

我刚开始接触设备树时,觉得这东西就是一堆乱七八糟的嵌套括号。后来看多了才发现,它的结构其实很清晰:

  • 根节点——用 / 表示,整棵树的起点
  • CPU节点——描述处理器信息
  • 内存节点——描述DDR大小和地址
  • 外设节点——描述各个外设,挂在对应的总线下面

来看一个简单的设备树片段:

/dts-v1/;

/ {
    model = "MyEmbedded Board";
    compatible = "mycompany,myboard";

    chosen {
        bootargs = "console=ttyS0,115200";
    };

    memory@80000000 {
        device_type = "memory";
        reg = <0x80000000 0x10000000>;  // 256MB
    };

    soc {
        compatible = "simple-bus";
        #address-cells = <1>;
        #size-cells = <1>;

        gpio_controller: gpio@ff710000 {
            compatible = "mycompany,gpio";
            reg = <0xff710000 0x1000>;
            interrupts = <0 32 4>;
            gpio-controller;
            #gpio-cells = <2>;
        };

        my_led {
            compatible = "mycompany,led";
            led-gpios = <&gpio_controller 15 0>;
            status = "okay";
        };
    };
};

这里我解释几个关键点:

  • reg 属性:第一个数字是基地址,第二个是地址范围长度。比如 0xff710000 0x1000 表示从0xff710000开始的4KB空间
  • interrupts:三个数字分别表示中断控制器类型、中断号、触发方式。具体含义取决于芯片
  • gpio-cells = <2>:表示引用GPIO时需要两个参数——引脚号和标志位
  • status = "okay":使能这个设备。改成 "disabled" 就禁用了

下面这张图展示了设备树、驱动和硬件之间的关系:

设备树、驱动与硬件的关系 设备树(.dts) 描述硬件信息 内核解析 生成 platform_device 驱动模块 probe 回调执行 硬件寄存器操作 GPIO、I2C、SPI、UART等 匹配过程 compatible 属性比对 关键流程 设备树 → 内核解析为 platform_device → 驱动根据 compatible 匹配 → probe 执行 → 操作硬件

5.4 综合实战:一个完整的GPIO字符设备驱动

把前面三个知识点串起来,我们来写一个完整的驱动:通过字符设备接口控制GPIO。用户态程序可以 open 设备,然后 write 一个 '1' 或 '0' 来控制GPIO电平。

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/gpio/consumer.h>
#include <linux/platform_device.h>
#include <linux/of.h>

#define DRIVER_NAME "gpio_led"

static struct gpio_desc *led_desc;
static int major;
static struct cdev led_cdev;
static struct class *led_class;

static ssize_t led_write(struct file *file, const char __user *buf,
                         size_t count, loff_t *offset) {
    char kbuf[4];
    if (count < 1) return -EINVAL;
    
    if (copy_from_user(kbuf, buf, 1)) return -EFAULT;
    
    if (kbuf[0] == '1') {
        gpiod_set_value(led_desc, 1);
    } else if (kbuf[0] == '0') {
        gpiod_set_value(led_desc, 0);
    }
    
    return count;
}

static struct file_operations led_fops = {
    .owner = THIS_MODULE,
    .write = led_write,
};

static int led_probe(struct platform_device *pdev) {
    dev_t dev;
    
    // 从设备树获取GPIO
    led_desc = devm_gpiod_get(&pdev->dev, "led", GPIOD_OUT_LOW);
    if (IS_ERR(led_desc)) {
        dev_err(&pdev->dev, "Failed to get GPIO\n");
        return PTR_ERR(led_desc);
    }
    
    // 注册字符设备
    alloc_chrdev_region(&dev, 0, 1, DRIVER_NAME);
    major = MAJOR(dev);
    cdev_init(&led_cdev, &led_fops);
    cdev_add(&led_cdev, dev, 1);
    
    led_class = class_create(THIS_MODULE, "led_class");
    device_create(led_class, NULL, dev, NULL, DRIVER_NAME);
    
    dev_info(&pdev->dev, "GPIO LED driver loaded\n");
    return 0;
}

static int led_remove(struct platform_device *pdev) {
    dev_t dev = MKDEV(major, 0);
    device_destroy(led_class, dev);
    class_destroy(led_class);
    cdev_del(&led_cdev);
    unregister_chrdev_region(dev, 1);
    return 0;
}

static const struct of_device_id led_of_match[] = {
    { .compatible = "mycompany,gpio-led" },
    { }
};
MODULE_DEVICE_TABLE(of, led_of_match);

static struct platform_driver led_driver = {
    .probe  = led_probe,
    .remove = led_remove,
    .driver = {
        .name = DRIVER_NAME,
        .of_match_table = led_of_match,
    },
};

module_platform_driver(led_driver);
MODULE_LICENSE("GPL");

对应的设备树节点:

my_led {
    compatible = "mycompany,gpio-led";
    led-gpios = <&gpio0 15 0>;
    status = "okay";
};

用户态测试程序:

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main() {
    int fd = open("/dev/gpio_led", O_WRONLY);
    if (fd < 0) {
        perror("open");
        return -1;
    }
    
    write(fd, "1", 1);  // 点亮
    sleep(1);
    write(fd, "0", 1);  // 熄灭
    
    close(fd);
    return 0;
}

总结一下:字符设备驱动是基础框架,GPIO是具体操作对象,设备树是硬件描述语言。三者结合,你就能写出结构清晰、可移植性强的嵌入式驱动。我个人的经验是,先写一个虚拟字符设备练手,再逐步加入GPIO控制,最后配合设备树做平台驱动。一步一个脚印,驱动开发没那么神秘。


公众号:蓝海资料掘金营,微信deep3321