Linux 驱动程序基础开发详解

前言

在当今的技术世界中,嵌入式系统无处不在,从智能手机到工业自动化设备,几乎每一台电子设备都依赖于它们的高效运行。而在这些系统中,操作系统与硬件之间的桥梁——驱动程序——起着至关重要的作用。作为开源操作系统的典范,Linux不仅在服务器和桌面环境中占据一席之地,更在嵌入式系统中广泛应用。

这篇文章将探讨Linux驱动程序的核心概念,并以一个LED驱动程序讲解驱动程序的开发实践。

驱动程序相关概念梳理

驱动程序定义

驱动程序(Device Driver)是一种特殊的软件组件,用于操作系统和硬件设备之间的通信和控制。它充当硬件抽象层,使操作系统无需了解具体硬件的细节即可与之交互。驱动程序的主要职责是接收操作系统的请求,将其转化为设备能够理解的指令,并将设备的响应传回操作系统。

用户空间与内核空间

图灵社区

驱动程序运行在内核空间,而应用程序运行在用户空间,搞懂用户空间和内核空间,才能搞懂驱动程序的工作原理。

用户空间(User Space)

用户空间是指普通应用程序运行的内存区域。用户空间代码运行在较低的特权级别,受到操作系统的严格保护,不能直接访问硬件和内核数据结构。

特点:

  1. 安全性:用户空间与内核空间隔离,防止用户程序直接修改内核数据,保障系统稳定性和安全性。
  2. 限制:用户空间代码不能直接执行特权操作(如访问硬件、管理内存等),需要通过系统调用(System Call)与内核交互。
  3. 可移植性:由于受到严格的系统调用接口约束,用户空间程序在不同硬件和操作系统之间的移植性较好。

任何在操作系统上运行的应用程序(如文本编辑器、浏览器、数据库管理系统)都在用户空间中执行。

内核空间(Kernel Space)

内核空间是操作系统内核代码运行的内存区域,运行在最高特权级别,直接管理硬件资源和系统核心数据结构。

特点:

  1. 特权级别高:内核空间代码拥有最高特权,可以直接访问硬件和内存。
  2. 关键任务:负责内存管理、进程调度、文件系统管理、网络堆栈等核心任务。
  3. 单一地址空间:内核代码通常在一个统一的地址空间内运行,所有内核代码和数据共享相同的地址空间。

内核空间包括操作系统内核、设备驱动程序、内核扩展模块等。

用户空间与内核空间的交互

  1. 系统调用(System Call): 用户空间程序通过系统调用接口请求内核提供服务,例如文件操作、进程控制、网络通信等。系统调用是一种受控方式,确保用户程序不能直接破坏系统安全。
  2. 设备文件(Device File): 用户空间通过设备文件与驱动程序交互。例如,读取设备数据、写入配置参数等。设备文件通常位于/dev目录下。
  3. 共享内存(Shared Memory): 用户空间与内核空间可以通过共享内存进行高效的数据交换。
  4. IOCTL(Input/Output Control): 用户空间程序可以使用IOCTL系统调用向驱动程序发送控制命令,执行特定的设备操作。

copy_from_user函数,将用户空间的数据复制到内核空间,copy_to_user函数,将内核空间的数据复制到用户空间。

设备树

要通过驱动程序来操控硬件,就需要有人来提供硬件的相关信息,而这个人就是设备树。

设备树就像是一个详细的“硬件地图”或“硬件说明书”。它告诉操作系统你的设备有哪些硬件组件,这些组件如何连接,以及如何配置它们。

为什么需要设备树?

在嵌入式系统中,有很多不同类型的硬件。为了让操作系统能够正确识别和使用这些硬件,需要有一个标准化的方法来描述硬件配置。设备树就是这样一种方法。它可以避免在每次硬件变化时都修改驱动程序,从而简化开发和维护工作。

设备树的结构

设备树主要是以一个树形结构来描述硬件设备,它由三个成分组成:节点、属性、包含。

节点(Node)

节点是设备树的基本构建单元,每个节点代表一个硬件设备或一个逻辑设备。

属性(Property)

属性是节点的配置信息,用于描述节点的具体参数和配置。

每个属性由一个键(名称)和一个值(数据)组成,值可以是字符串、整数或其他数据类型。

常见属性有:

  • compatible:描述设备的类型和兼容性,驱动程序通过匹配这个属性来找到对应的设备。
  • reg:描述设备寄存器地址范围。
  • status:描述设备状态(如“okay”、“disabled”)。
  • interrupts:描述设备使用的中断资源。
包含(Include)

包含用于复用设备树文件,通过引入外部文件,减少重复定义,提高设备树文件的可维护性和组织性。

设备树源码有两种格式,一种是dts,另外一种是dtsi,dtsi用于给其他dts文件包含用的,相当于复用的效果。

下面是一个节点、属性、包含的书写格式:

// 引入外部设备树文件
#include "filename.dtsi"

// 根节点
/ {
    property_name = value;             // 属性:键值对
    property_name = <value>;           // 属性:键值对(数值)
    property_name = "string_value";    // 属性:键值对(字符串)
    property_name;                     // 布尔属性:存在即为true
    // 其他根节点的属性...

    label: node_name@unit_address {    // 节点:带有标签(label)和单元地址
        property_name = value;         // 属性:键值对
        property_name = <value>;       // 属性:键值对(数值)
        property_name = "string_value";// 属性:键值对(字符串)
        property_name;                 // 布尔属性:存在即为true
        // 其他属性...

        child_label: child_node_name@unit_address { // 子节点:带有标签和单元地址
            property_name = value;     // 属性:键值对
            property_name = <value>;   // 属性:键值对(数值)
            property_name = "string_value"; // 属性:键值对(字符串)
            property_name;             // 布尔属性:存在即为true
            // 子节点的属性...
        };
        // 其他子节点...
    };

    another_label: another_node_name { // 节点:带有标签但没有单元地址
        property_name = value;         // 属性:键值对
        property_name = <value>;       // 属性:键值对(数值)
        property_name = "string_value";// 属性:键值对(字符串)
        property_name;                 // 布尔属性:存在即为true
        // 其他属性...

        // 其他子节点...
    };
};

下面是一个含有节点、属性、包含的具体设备树例子:

// 包含另一个设备树文件
#include "common.dtsi"

/ {
    // 定义根节点
    compatible = "example,embedded-system";
    model = "Example Embedded System";

    // 定义内存节点
    memory {
        device_type = "memory";
        reg = <0x80000000 0x2000000>; // 内存起始地址和大小
    };

    // 定义SoC节点
    soc {
        compatible = "simple-bus";
        #address-cells = <1>;
        #size-cells = <1>;

        // 定义串口节点
        uart0: uart@101f1000 {
            compatible = "ns16550a";
            reg = <0x101f1000 0x1000>; // 寄存器地址范围
            interrupts = <5 0>;       // 中断号和触发方式
            clock-frequency = <24000000>; // 时钟频率
        };

        // 定义I2C控制器节点
        i2c0: i2c@40003000 {
            compatible = "nordic,nrf-twi";
            reg = <0x40003000 0x1000>; // 寄存器地址范围
            clock-frequency = <100000>; // 时钟频率

            // 定义连接在I2C总线上的温度传感器
            temp_sensor: temperature_sensor@48 {
                compatible = "ti,tmp102";
                reg = <0x48>; // I2C地址
            };
        };
    };
};

设备树的工作原理

5d13a81affa1ecd603a888f2526d6638472b81a0a8ffdafe88ec4c9ae856105f

设备树源文件(dts)会先被编译成二进制设备树文件(dtb),当我们把dtb文件放到/boot目录下后,内核在启动时会解析该文件。解析后,内核会生成一个表示硬件拓扑结构的设备树节点(device_node)树,每个节点代表一个device_node,但是,并不是每个节点都会转化成 platform_device 结构体,只有满足下面这两个条件之一才会被转化:

  1. 根节点下含有 compatile 属性的子节点
  2. 含有特定 compatile 属性的节点的子节点:如果一个节点的 compatile 属性,它的值是这 4 者之一:"simple-bus","simple-mfd","isa","arm,amba-bus", 那 么 它 的 子 结 点 ( 需含 compatile 属性)也可以转换为 platform_device。

被转化的 platform_device 节点,内核会将其节点对应的 compatile 属性值与驱动程序(platform_driver 结构体)一一匹配,如果匹配成功,就会调用驱动程序的 platform_driver 结构体的 probe函数,我们可以在驱动程序的 probe 函数处理驱动程序初始化操作。

设备树的编译

编译设备树需要用到内核源码,这里以 Linux4.9 内核为例,我使用的是 imx6ull 开发板,使用了韦东山老师提供的设备树文件,首先进入 linux 内核目录,执行如下命令:

# 生成对应的Makefile配置
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- 100ask_imx6ull_defconfig

# 编译设备树
make dtbs

# 编译成功后,会生成dtb文件,将dtb 文件拷贝到开发板上的/boot目录下
cp /mnt/100ask_imx6ull-14x14.dtb /boot

# 重启开发板
reboot

驱动程序框架

36ecc077a5a63b0dced01ace36313cb5adf3f555ffd3c668d31bb91bdea59c78

一个驱动程序的框架主要有以下几部分:

  1. 入口 module_init 和 出口函数 module_exit

  2. 在入口函数中注册 platform_driver 结构体

    实现 probe、remove 函数,实现 driver 结构体

  3. 在 probe 函数中注册 platform_operations 结构体

    实现 open、read、write 等函数操作硬件

一般我们学习一个驱动程序,会先从它的入口函数开始探索,然后顺着入口函数不断深挖,最终摸清楚整个驱动程序的大致工作流程。

驱动程序工作流程

驱动程序工作流程分为三部分:加载驱动程序、设备文件操作和卸载驱动程序。

加载驱动程序

驱动程序通常以内核模块的形式存在(.ko文件),当我们编译好驱动程序之后,将其放到开发板后,可以通过insmod命令将其动态加载到内核中。加载时会执行驱动程序的初始化函数(module_init 函数),设置硬件并注册设备文件。

设备文件操作

用户空间的程序通过打开设备文件(通常是/dev/xx)与驱动程序交互,例如 open(), read(), write(), ioctl() 等系统调用会被驱动程序拦截并处理。驱动程序通过这些操作函数与硬件进行通信,并完成相应的功能。

卸载驱动程序

当驱动程序不再需要时,可以使用 rmmod 命令将其卸载,卸载时会调用驱动程序的清理函数(module_exit 函数),释放资源并注销设备文件。

驱动程序关联的结构体

驱动程序最重要的两个结构体,file_operationsplatform_driver

file_operations

file_operations 结构体用于将用户空间的操作映射到内核中的具体实现。每个成员函数指针对应于设备驱动程序中的一个操作函数,例如打开、读取、写入、关闭等。内核通过调用这些函数指针,来实现对设备的各种操作。

99217e46ef334635b77b0c7247b54de10e3249a0ec72ad5b56c01b5f76ade41e

  • owner:指向拥有此 file_operations 结构的模块,通常设置为 THIS_MODULE 以确保模块在使用这些函数时不会被卸载。
  • open:打开设备文件时调用,通常用于初始化设备或分配资源。
  • read:从设备读取数据到用户空间的缓冲区。
  • write:从用户空间的缓冲区写数据到设备。
  • release:关闭设备文件时调用,通常用于释放资源。
  • poll:用于实现 pollselect 系统调用,检查设备是否可以进行读写操作。
  • mmap:将设备内存映射到用户空间,通常用于直接访问设备内存。

platform_driver

platform_driver 结构体是用来定义和注册一个平台驱动程序的关键组成部分。它定义了驱动程序如何初始化设备,如何响应系统中的设备匹配,以及如何处理设备的移除或关闭等事件。

a758d63704c25f7d105545709cd816b74fdeb59bb19ff0f4a198f5e520ced6c3

  • probe:当一个匹配的设备被发现时,这个函数被调用。这是驱动初始化硬件的地方,通常涉及到资源的申请、中断的注册、硬件的初始化等。
  • remove:当设备需要移除或驱动卸载时,这个函数被调用。用于清理在probe函数中分配的所有资源。
  • shutdown:在系统关机时调用此函数,用于关闭设备。
  • suspend 和 resume:这两个函数用于管理设备的电源状态,实现低功耗模式的转换。
  • driver:这是一个device_driver结构,包括驱动程序的名称、模块拥有者、匹配表等信息。这是链接到设备模型和总线类型的关键结构。

编写驱动程序用到的相关函数和宏

module_init、module_exit 和 MODULE_LICENSE 宏

  • module_init:用于指定一个模块的初始化函数,当模块被加载时自动调用这个函数。
  • module_exit:用于指定一个模块的退出函数,当模块被卸载时自动调用这个函数。
  • MODULE_LICENSE:这个宏定义模块的许可证类型。内核用这些信息来确保使用许可证合规的模块,并可能基于许可证类型启用或禁用某些功能。例如,如果一个模块没有正确标记为“GPL”并尝试使用仅限GPL许可的内核符号,加载该模块时内核会拒绝这一操作并在日志中记录一个警告。

platform_driver_register、platform_driver_unregister

  • platform_driver_register:
  • platform_driver_unregister:

register_chrdev、unregister_chrdev

  • register_chrdev:用于注册一个字符设备。通过这个调用,你可以指定设备的主设备号(如果为0,将自动分配一个),设备名称及与设备相关的文件操作。
  • unregister_chrdev:用于注销一个已注册的字符设备。

class_create、class_destroy

  • class_create:用于创建一个类,通常与设备文件(device files)相关。类(class)在 /sys/class 目录下为设备创建一个目录,使得设备可以与 udev 或 mdev 等用户空间的设备管理器集成。
  • class_destroy:用于销毁通过 class_create 创建的类。

device_create、device_destory

  • device_create:用于在特定的类下创建一个设备。这通常是在设备驱动初始化时调用,为设备在 /sys/class/<classname> 下创建对应的节点。
  • device_destory:用于销毁通过 device_create 创建的设备。

驱动程序示例代码

下面是一个驱动程序框架示例代码,展示了一个驱动程序开发的基本框架。

#include <linux/module.h>       // 包含模块相关的头文件
#include <linux/platform_device.h> // 包含平台设备相关的头文件
#include <linux/fs.h>           // 包含文件系统相关的头文件
#include <linux/uaccess.h>      // 包含用户空间访问函数的头文件
#include <linux/device.h>       // 包含设备模型相关的头文件

#define DEVICE_NAME "mychardev" // 设备名称
#define CLASS_NAME  "myclass"   // 设备类名称
#define BUF_LEN 80              // 缓冲区大小

static int major;               // 主设备号
static char msg[BUF_LEN];       // 设备数据缓冲区
static char *msg_ptr;           // 指向缓冲区的指针
static struct class *myclass = NULL;  // 设备类指针
static struct device *mydevice = NULL;// 设备指针

// 设备打开函数
static int device_open(struct inode *inode, struct file *file) {
    msg_ptr = msg;                // 初始化消息指针
    try_module_get(THIS_MODULE);  // 增加模块引用计数
    return 0;
}

// 设备释放函数
static int device_release(struct inode *inode, struct file *file) {
    module_put(THIS_MODULE);  // 减少模块引用计数
    return 0;
}

// 设备读取函数
static ssize_t device_read(struct file *file, char __user *buffer, size_t length, loff_t *offset) {
    int bytes_read = 0;  // 已读取的字节数
    if (*msg_ptr == 0)   // 如果消息指针指向的内容为空,返回0表示读取结束
        return 0;
    while (length && *msg_ptr) {  // 读取缓冲区中的数据
        put_user(*(msg_ptr++), buffer++);  // 将数据从内核空间复制到用户空间
        length--;         // 减少要读取的长度
        bytes_read++;     // 增加已读取的字节数
    }
    return bytes_read;    // 返回读取的字节数
}

// 设备写入函数
static ssize_t device_write(struct file *file, const char __user *buffer, size_t length, loff_t *offset) {
    int i;
    for (i = 0; i < length && i < BUF_LEN; i++) {
        get_user(msg[i], buffer + i);  // 将数据从用户空间复制到内核空间
    }
    msg_ptr = msg;  // 重置消息指针
    return i;       // 返回写入的字节数
}

// 文件操作结构体,定义了设备文件的操作函数
static struct file_operations fops = {
    .read = device_read,      // 读取函数
    .write = device_write,    // 写入函数
    .open = device_open,      // 打开函数
    .release = device_release,// 释放函数
};

// 平台设备驱动的探测函数
static int mydriver_probe(struct platform_device *pdev) {
    printk(KERN_INFO "mychardev: Device has been probed\n");

    // 注册字符设备
    major = register_chrdev(0, DEVICE_NAME, &fops);
    if (major < 0) {
        printk(KERN_ALERT "Registering char device failed with %d\n", major);
        return major;
    }

    // 创建设备类
    myclass = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(myclass)) {
        unregister_chrdev(major, DEVICE_NAME);
        printk(KERN_ALERT "Failed to register device class\n");
        return PTR_ERR(myclass);
    }

    // 创建设备
    mydevice = device_create(myclass, NULL, MKDEV(major, 0), NULL, DEVICE_NAME);
    if (IS_ERR(mydevice)) {
        class_destroy(myclass);
        unregister_chrdev(major, DEVICE_NAME);
        printk(KERN_ALERT "Failed to create the device\n");
        return PTR_ERR(mydevice);
    }

    printk(KERN_INFO "mychardev: Device registered correctly with major number %d\n", major);
    return 0;
}

// 平台设备驱动的移除函数
static int mydriver_remove(struct platform_device *pdev) {
    device_destroy(myclass, MKDEV(major, 0)); // 销毁设备
    class_unregister(myclass);                // 注销设备类
    class_destroy(myclass);                   // 销毁设备类
    unregister_chrdev(major, DEVICE_NAME);    // 注销字符设备
    printk(KERN_INFO "mychardev: Device has been removed\n");
    return 0;
}

// 定义平台驱动结构体
static struct platform_driver mydriver = {
    .probe = mydriver_probe,   // 设备探测函数
    .remove = mydriver_remove, // 设备移除函数
    .driver = {
        .name = "myplatform",  // 平台设备名称
        .owner = THIS_MODULE,
    },
};

// 模块初始化函数
static int __init mychardev_init(void) {
    int ret;

    // 注册平台驱动
    ret = platform_driver_register(&mydriver);
    if (ret != 0) {
        printk(KERN_ALERT "Failed to register platform driver\n");
    }
    return ret;
}

// 模块退出函数
static void __exit mychardev_exit(void) {
    // 注销平台驱动
    platform_driver_unregister(&mydriver);
}

// 指定模块初始化和退出函数
module_init(mychardev_init);
module_exit(mychardev_exit);

MODULE_LICENSE("GPL");              // 模块许可证
MODULE_AUTHOR("Your Name");         // 模块作者
MODULE_DESCRIPTION("A Simple Character Device Driver with Platform Driver");  // 模块描述

LED 驱动程序开发步骤

有了上面的知识梳理,我相信大家此时此刻心里应该对驱动程序有了一个初步的了解了。接下来我将以LED驱动程序开发为例,讲解一个驱动程序的开发过程。我使用的是imx6ull开发板,其他开发板同样适用下面的开发步骤。

看原理图和芯片手册

要操控硬件,我们就要先去了解该硬件的相关知识,我们需要先从原理图入手,了解它的引脚情况。

image-20240604214524811

从上图可以得出如下信息:

  1. 要让LED亮,需要右边引脚为低电平,因为左边VDD是高电平,只有产生电势差,才会有电压,也才会有电流。
  2. LED2是一个GPIO功能,连接的是第五组GPIO的第3个引脚。
  3. 要配置该引脚为GPIO引脚,需要修改IOMUX的SNVS_TAMPER3寄存器。

要操作GPIO引脚,有如下步骤:

  1. 使能GPIO时钟模块(CCM)
  2. 使能IOMUX配置引脚为GPIO功能
  3. 配置GPIO引脚为输入或输出
  4. 操作GPIO引脚输入或输出数据

有了这些信息之后,我们就可以查阅imx6ull的芯片手册,了解如何操作寄存器。

image-20240604215719134

从上图可以看出,GPIO5时钟模块默认是使能的,并且CCGR1寄存器控制GPIO5的时钟功能,接着我们继续找到CCGR1寄存器。

image-20240604220138016

上面这个就是CCM时钟控制模块CCGR1的内存地址,imx6ull是32位处理器,使用4字节=32位作为地址。既然我们不需要操作CCM,那么我们接着看IOMUX,也就是IO复用模块。

image-20240604220658930

上图可以得知SNVS_TAMPER3寄存器地址为0x02290014,我们要修改这个地址的低3位为101,就能使能IOMUX。之后,我们就可以来查看GPIO5的相关寄存器了。

image-20240604221017434

从上图我们得出GPIO5的寄存器基地址和尾地址,GPIO5为第5组GPIO,我们要控制的是第3个引脚,接着往下看,我们找到GPIO这个章节。我们要配置引脚为输出引脚(输出电平),并且输出高电平或低电平,那么我们需要操作这两个寄存器,GPIOx_GDIR和GPIOx_DR寄存器。

image-20240604221605290

从上图可以得知,我们配置为1就是输出引脚。

image-20240604221802088

下面给出操作寄存器的示例代码:

volatile static unsigned int *IOMUX_SNVS_TAMPER3;
volatile static unsigned int *GPIO5_GDIR;
volatile static unsigned int *GPIO5_DR;

// 在open函数中初始化
static int led_open(struct inode *inode, struct file *file) {
  // GPIO enable IOMUX、GPIO5_GDIR
  *IOMUX_SNVS_TAMPER3 &= ~0xf;
  *IOMUX_SNVS_TAMPER3 |= 0x5;
  *GPIO5_GDIR |= (1 << 3);
  return 0;
}

// 在write函数中输出高低电平
ssize_t led_write(struct file *file, const char __user *buf, size_t size,
                  loff_t *ppos) {
  char val;
  if (val) {
    // 开灯
    *GPIO5_DR &= ~(1 << 3);
  } else {
    // 关灯
    *GPIO5_DR |= (1 << 3);
  }
  return 0;
}

// 在驱动程序init函数中映射物理地址位虚拟地址
static int __init led_drv_init(void) {
  // 每个进程的地址都是通过MMU管理的虚拟地址,我们需要将寄存器物理地址映射成虚拟地址
  // 4代表4字节,也就是32位,刚好是一个寄存器的位数
  IOMUX_SNVS_TAMPER3 = ioremap(0x02290000 + 0x14, 4);
  GPIO5_GDIR = ioremap(0x020AC004, 4);
  GPIO5_DR = ioremap(0x020AC000, 4);
}

// 接触映射
static void led_drv_exit(void) {
  iounmap(IOMUX_SNVS_TAMPER3);
  iounmap(GPIO5_GDIR);
  iounmap(GPIO5_DR);
}

从上面的步骤可以看出,操作寄存器是一件很麻烦的事情,好在,linux给我们提供了GPIO子系统和Pinctrl子系统,我们无需去操作寄存器就可以实现相同的功能,下面介绍下什么是GPIO子系统和Pinctrl子系统。

Pinctrl子系统

Pinctrl(Pin Control)子系统是Linux内核中的一个子系统,用于管理和配置处理器上的引脚(pins)。在嵌入式系统中,处理器的引脚可以有多种功能,例如GPIO(通用输入/输出)、I2C、SPI、UART等。Pinctrl子系统的主要作用是协调这些引脚的多功能性,并确保在系统运行时引脚的配置符合预期。

主要功能:

  • 引脚复用(Pin Multiplexing)
    • 引脚复用允许一个物理引脚根据需要配置为不同的功能。例如,一个引脚可以配置为GPIO、I2C时钟线或UART接收线。Pinctrl子系统通过复用设置(muxing settings)来管理这些配置。
  • 引脚配置(Pin Configuration)
    • 除了复用,引脚还需要其他配置,比如电平、上拉/下拉电阻、驱动能力等。Pinctrl子系统提供接口来设置这些参数。

Pinctrl的配置主要是由BSP工程师来实现,而驱动开发工程师的职责是使用这个Pinctrl配置。

GPIO子系统

GPIO(General Purpose Input/Output)子系统是Linux内核中的一个关键子系统,用于管理和控制处理器上的通用输入/输出引脚。GPIO引脚是可编程的,可以配置为输入或输出,并用于与其他硬件组件进行简单的信号交互。GPIO子系统为开发者提供了一套标准化的接口,以便在不同的硬件平台上进行引脚操作。

主要功能:

  • 引脚配置
    • 可以将引脚配置为输入或输出。输入引脚用于读取外部设备的状态,而输出引脚则用于控制外部设备。
  • 电平控制
    • 输出引脚可以设置高电平或低电平,以控制连接到引脚的外部设备。输入引脚可以读取引脚上的电平状态。
  • 中断处理
    • GPIO引脚可以配置为触发中断,在引脚状态发生变化时通知处理器。常见的触发类型包括上升沿、下降沿和电平触发。
  • 多功能引脚支持
    • 与Pinctrl子系统配合,GPIO子系统支持引脚的多功能配置,通过设置引脚复用,允许同一个物理引脚具备多种功能。

GPIO子系统有两套接口,一套是老的Sysfs接口,一套是更现代的Libgpiod接口,相比于Sysfs接口,提供了更强大的功能和更友好的接口。

修改设备树文件

了解了这两个系统之后,我们就可以使用设备树来配置Pinctrl子系统和GPIO子系统了。这里我们可以使用NXP公司提供的Pins_Tool_for_i.MX_Processors软件,它可以帮助我们生成我们相应的Pinctrl数据。

d4d634b7e85214bc0e5ff49915e48f64234a935fbe80e3dd17dfce62db43a69c

将生成的这段Pinctrl放到内核目录的设备树文件imx6ull-14x14.dts(我使用的是imx6ull开发板)的iomuxc_snvs节点下,并修改节点的标签名和节点名:

// 当设备树被加载并应用时,pinctrl 会将 MX6ULL_PAD_SNVS_TAMPER3 引脚配置为 GPIO5_IO03,并应用 0x000110A0 指定的电气特性和复用设置。
pinctrl_leds: ledgrp {
    fsl,pins = <
          MX6ULL_PAD_SNVS_TAMPER3__GPIO5_IO03        0x000110A0
    >;
};

之后,在根节点定义一个子节点,来使用Pinctrl和GPIO子系统:

/ {
    myled {
        compatible = "leon,leddrv";
        
        // 定义引脚控制的名称,可以有多个
        pinctrl-names = "default";
        
        // 引用引脚控制设置
        pinctrl-0 = <&pinctrl_leds>;
        
        // 定义该设备使用的GPIO信息
        // &gpio5: 引用GPIO控制器5
        // 3: 使用GPIO5的第3个引脚
        // GPIO_ACTIVE_LOW: 该GPIO引脚是低电平有效
        gpios = <&gpio5 3 GPIO_ACTIVE_LOW>;
    };
}

上述就是led节点,它拥有compatible属性,因此会生成platform_device结构体。在这个节点里面,使用了定义的Pinctrl节点,这样就使用了这个Pinctrl节点的电气特性。

在内核目录下,执行make dtbs命令编译设备树,将编译的产物.dtb放到开发板的/boot目录下,reboot重启开发板,至此,设备树的修改搞定。

编写代码、编译并上机测试

创建一个文件夹,里面创建三个文件,一个是驱动程序源码led_drv.c文件,一个是Makefile,用于编译驱动程序,另外一个是led_drv_test.c,用于测试驱动程序。目录如下:

├── my_led
│   ├── Makefile
│   ├── led_drv.c
│   └── led_test.c

先编写led_drv_test.c测试程序,这个程序比较简单,首先我们用open函数打开我们的驱动设备,然后根据参数调用write函数向驱动程序传递数据,最后调用close函数关闭文件描述符。代码如下:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>

/*
 * ./led_drv_test /dev/led on
 * ./led_drv_test /dev/led off
 */
int main(int argc, char **argv)
{
	int fd;
	char status;
	
	/* 1. 判断参数 */
	if (argc != 3) 
	{
		printf("Usage: %s <dev> <on | off>\n", argv[0]);
		return -1;
	}

	/* 2. 打开文件 */
	fd = open(argv[1], O_RDWR);
	if (fd == -1)
	{
		printf("can not open file %s\n", argv[1]);
		return -1;
	}

	/* 3. 写文件 */
	if (0 == strcmp(argv[2], "on"))
	{
		status = 1;
		write(fd, &status, 1);
	}
	else
	{
		status = 0;
		write(fd, &status, 1);
	}
	
	close(fd);
	
	return 0;
}

接着开始写驱动程序led_drv.c,编写思路是:编写platform_driver结构体,在入口函数调用platform_driver_register注册platform_driver结构体,当设备树的节点与platform_driverdriver成员的of_match_table成员匹配时,就会调用其probe函数,可以在这个函数做一些初始化操作。首先是调用gpiod_get也就是GPIO子系统的函数获取一个gpio_desc结构体,它表示一个GPIO引脚,之后调用register_chrdev、class_create、device_create创建字符设备。

以下是led_drv.c的代码:

#include "linux/err.h"
#include "linux/gpio/consumer.h"
#include "linux/kdev_t.h"
#include "linux/mod_devicetable.h"
#include "linux/platform_device.h"
#include <linux/device.h>
#include <linux/errno.h>
#include <linux/fs.h>
#include <linux/gfp.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/kmod.h>
#include <linux/major.h>
#include <linux/miscdevice.h>
#include <linux/module.h>
#include <linux/mutex.h>
#include <linux/proc_fs.h>
#include <linux/seq_file.h>
#include <linux/stat.h>
#include <linux/tty.h>

// 主设备号
static int major;
// 设备类
static struct class *class;
// GPIO描述符,用于控制LED
static struct gpio_desc *led_gpio;

// 打开设备文件的操作函数
static int led_open(struct inode *inode, struct file *file) {
  printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);

  // 初始化LED GPIO引脚为输出模式,并将其初始值设为0
  gpiod_direction_output(led_gpio, 0);

  return 0;
}

// 写设备文件的操作函数,用于控制LED的状态
static ssize_t led_write(struct file *file, const char __user *buf, size_t size,
                         loff_t *ppos) {
  char val;
  // 从用户空间读取数据
  if (copy_from_user(&val, buf, 1) != 0) {
    return -1;
  }

  // 设置LED GPIO的值
  gpiod_set_value(led_gpio, val);

  return 0;
}

// 文件操作结构体
static const struct file_operations led_fops = {
    .owner = THIS_MODULE,
    .open = led_open,
    .write = led_write,
};

// platform_driver的probe函数,在设备绑定驱动时调用
int probe(struct platform_device *pd) {
  printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);

  // 获取设备树中定义的LED GPIO
  led_gpio = gpiod_get(&pd->dev, "led", 0);
  if (IS_ERR(led_gpio)) {
    dev_err(&pd->dev, "Failed to get GPIO for led\n");
    return PTR_ERR(led_gpio);
  }

  // 注册字符设备,并获取主设备号
  major = register_chrdev(0, "led", &led_fops);

  // 创建设备类
  class = class_create(THIS_MODULE, "led_class");

  if (IS_ERR(class)) {
    printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
    unregister_chrdev(major, "led");
    gpiod_put(led_gpio);
    return -1;
  }

  // 创建设备节点 /dev/led
  device_create(class, NULL, MKDEV(major, 0), NULL, "led");
  return 0;
}

// platform_driver的remove函数,在设备解绑驱动时调用
int remove(struct platform_device *pd) {
  // 销毁设备节点
  device_destroy(class, MKDEV(major, 0));
  // 注销字符设备
  unregister_chrdev(major, "led");
  // 销毁设备类
  class_destroy(class);
  // 释放GPIO资源
  gpiod_put(led_gpio);
  return 0;
}

// 设备树匹配表
static struct of_device_id of_device_ids[] = {
    {.compatible = "leon,leddrv"},
    {}};

// platform_driver结构体
static struct platform_driver platform_driver = {
    .probe = probe,
    .remove = remove,
    .driver = {
        .name = "leon-led",
        .of_match_table = of_device_ids,
    }};

// 模块初始化函数
static int __init led_drv_init(void) {
  int err;
  printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);

  // 注册平台驱动
  err = platform_driver_register(&platform_driver);

  return 0;
}

// 模块退出函数
static void led_drv_exit(void) {
  printk("%s %s line %d\n", __FILE__, __FUNCTION__, __LINE__);
  // 卸载平台驱动
  platform_driver_unregister(&platform_driver);
}

module_init(led_drv_init);
module_exit(led_drv_exit);

MODULE_LICENSE("GPL");

最后一个是Makefile,Makefile的作用是提高编译源码的效率,做到自动化编译。

# 指定编译器前缀,用于交叉编译
CROSS_COMPILE = arm-linux-gnueabihf-
# 指定内核目录路径
KERN_DIR = /home/Linux-4.9.88

# 主目标,每个Makefile只有一个主目标
all:
	make -C $(KERN_DIR) M=`pwd` modules 
	$(CROSS_COMPILE)gcc -o led_test led_test.c 

clean:
	make -C $(KERN_DIR) M=`pwd` modules clean
	rm -rf modules.order
	rm -f led_test

# 要编译的驱动程序文件名
obj-m += led_drv.o

准备好之后,就可以进行编译了,运行make命令,编译成功后的产物如下:

image-20240611074336650

将编译后的led_drv.koled_test放到开发板,运行insmod led_drv.ko加载驱动程序,运行测试程序,./led_drv_test /dev/led on,可以发现led灯变亮了,./led_drv_test /dev/led off灯灭,说明测试成功。

总结

本篇文章从介绍驱动程序的相关概念到实现一个简单的led驱动程序,可以看出,相比裸机、RTOS,Linux的驱动程序抽象复杂许多,但这样做的好处是高内聚、低耦合,提升了项目的可维护性,这也是软件编程设计思想重要的概念之一,特别是在越大型的项目,它的优点就越突出。在下一篇文章,我将介绍驱动程序基石,介绍阻塞、非阻塞、POLL、异步、中断等相关概念,并实现一个按键驱动程序,这些也是驱动程序开发过程中的重要一环,我们下期再见。

参考资料

  • 韦东山Linux
  • EmbeTronicX网站