Linux 驱动程序基础开发详解
文章目录
前言
在当今的技术世界中,嵌入式系统无处不在,从智能手机到工业自动化设备,几乎每一台电子设备都依赖于它们的高效运行。而在这些系统中,操作系统与硬件之间的桥梁——驱动程序——起着至关重要的作用。作为开源操作系统的典范,Linux不仅在服务器和桌面环境中占据一席之地,更在嵌入式系统中广泛应用。
这篇文章将探讨Linux驱动程序的核心概念,并以一个LED驱动程序讲解驱动程序的开发实践。
驱动程序相关概念梳理
驱动程序定义
驱动程序(Device Driver)是一种特殊的软件组件,用于操作系统和硬件设备之间的通信和控制。它充当硬件抽象层,使操作系统无需了解具体硬件的细节即可与之交互。驱动程序的主要职责是接收操作系统的请求,将其转化为设备能够理解的指令,并将设备的响应传回操作系统。
用户空间与内核空间
驱动程序运行在内核空间,而应用程序运行在用户空间,搞懂用户空间和内核空间,才能搞懂驱动程序的工作原理。
用户空间(User Space)
用户空间是指普通应用程序运行的内存区域。用户空间代码运行在较低的特权级别,受到操作系统的严格保护,不能直接访问硬件和内核数据结构。
特点:
- 安全性:用户空间与内核空间隔离,防止用户程序直接修改内核数据,保障系统稳定性和安全性。
- 限制:用户空间代码不能直接执行特权操作(如访问硬件、管理内存等),需要通过系统调用(System Call)与内核交互。
- 可移植性:由于受到严格的系统调用接口约束,用户空间程序在不同硬件和操作系统之间的移植性较好。
任何在操作系统上运行的应用程序(如文本编辑器、浏览器、数据库管理系统)都在用户空间中执行。
内核空间(Kernel Space)
内核空间是操作系统内核代码运行的内存区域,运行在最高特权级别,直接管理硬件资源和系统核心数据结构。
特点:
- 特权级别高:内核空间代码拥有最高特权,可以直接访问硬件和内存。
- 关键任务:负责内存管理、进程调度、文件系统管理、网络堆栈等核心任务。
- 单一地址空间:内核代码通常在一个统一的地址空间内运行,所有内核代码和数据共享相同的地址空间。
内核空间包括操作系统内核、设备驱动程序、内核扩展模块等。
用户空间与内核空间的交互
- 系统调用(System Call): 用户空间程序通过系统调用接口请求内核提供服务,例如文件操作、进程控制、网络通信等。系统调用是一种受控方式,确保用户程序不能直接破坏系统安全。
-
设备文件(Device File): 用户空间通过设备文件与驱动程序交互。例如,读取设备数据、写入配置参数等。设备文件通常位于
/dev
目录下。 - 共享内存(Shared Memory): 用户空间与内核空间可以通过共享内存进行高效的数据交换。
- 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地址
};
};
};
};
设备树的工作原理
设备树源文件(dts)会先被编译成二进制设备树文件(dtb),当我们把dtb文件放到/boot目录下后,内核在启动时会解析该文件。解析后,内核会生成一个表示硬件拓扑结构的设备树节点(device_node
)树,每个节点代表一个device_node
,但是,并不是每个节点都会转化成 platform_device
结构体,只有满足下面这两个条件之一才会被转化:
- 根节点下含有
compatile
属性的子节点 - 含有特定
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
驱动程序框架
一个驱动程序的框架主要有以下几部分:
-
入口
module_init
和 出口函数module_exit
-
在入口函数中注册 platform_driver 结构体
实现 probe、remove 函数,实现 driver 结构体
-
在 probe 函数中注册 platform_operations 结构体
实现 open、read、write 等函数操作硬件
一般我们学习一个驱动程序,会先从它的入口函数开始探索,然后顺着入口函数不断深挖,最终摸清楚整个驱动程序的大致工作流程。
驱动程序工作流程
驱动程序工作流程分为三部分:加载驱动程序、设备文件操作和卸载驱动程序。
加载驱动程序
驱动程序通常以内核模块的形式存在(.ko
文件),当我们编译好驱动程序之后,将其放到开发板后,可以通过insmod
命令将其动态加载到内核中。加载时会执行驱动程序的初始化函数(module_init
函数),设置硬件并注册设备文件。
设备文件操作
用户空间的程序通过打开设备文件(通常是/dev/xx
)与驱动程序交互,例如 open()
, read()
, write()
, ioctl()
等系统调用会被驱动程序拦截并处理。驱动程序通过这些操作函数与硬件进行通信,并完成相应的功能。
卸载驱动程序
当驱动程序不再需要时,可以使用 rmmod
命令将其卸载,卸载时会调用驱动程序的清理函数(module_exit
函数),释放资源并注销设备文件。
驱动程序关联的结构体
驱动程序最重要的两个结构体,file_operations
和platform_driver
。
file_operations
file_operations
结构体用于将用户空间的操作映射到内核中的具体实现。每个成员函数指针对应于设备驱动程序中的一个操作函数,例如打开、读取、写入、关闭等。内核通过调用这些函数指针,来实现对设备的各种操作。
- owner:指向拥有此
file_operations
结构的模块,通常设置为THIS_MODULE
以确保模块在使用这些函数时不会被卸载。 - open:打开设备文件时调用,通常用于初始化设备或分配资源。
- read:从设备读取数据到用户空间的缓冲区。
- write:从用户空间的缓冲区写数据到设备。
- release:关闭设备文件时调用,通常用于释放资源。
- poll:用于实现
poll
或select
系统调用,检查设备是否可以进行读写操作。 - mmap:将设备内存映射到用户空间,通常用于直接访问设备内存。
platform_driver
platform_driver
结构体是用来定义和注册一个平台驱动程序的关键组成部分。它定义了驱动程序如何初始化设备,如何响应系统中的设备匹配,以及如何处理设备的移除或关闭等事件。
- 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开发板,其他开发板同样适用下面的开发步骤。
看原理图和芯片手册
要操控硬件,我们就要先去了解该硬件的相关知识,我们需要先从原理图入手,了解它的引脚情况。
从上图可以得出如下信息:
- 要让LED亮,需要右边引脚为低电平,因为左边VDD是高电平,只有产生电势差,才会有电压,也才会有电流。
- LED2是一个GPIO功能,连接的是第五组GPIO的第3个引脚。
- 要配置该引脚为GPIO引脚,需要修改IOMUX的SNVS_TAMPER3寄存器。
要操作GPIO引脚,有如下步骤:
- 使能GPIO时钟模块(CCM)
- 使能IOMUX配置引脚为GPIO功能
- 配置GPIO引脚为输入或输出
- 操作GPIO引脚输入或输出数据
有了这些信息之后,我们就可以查阅imx6ull的芯片手册,了解如何操作寄存器。
从上图可以看出,GPIO5时钟模块默认是使能的,并且CCGR1寄存器控制GPIO5的时钟功能,接着我们继续找到CCGR1寄存器。
上面这个就是CCM时钟控制模块CCGR1的内存地址,imx6ull是32位处理器,使用4字节=32位作为地址。既然我们不需要操作CCM,那么我们接着看IOMUX,也就是IO复用模块。
上图可以得知SNVS_TAMPER3寄存器地址为0x02290014,我们要修改这个地址的低3位为101,就能使能IOMUX。之后,我们就可以来查看GPIO5的相关寄存器了。
从上图我们得出GPIO5的寄存器基地址和尾地址,GPIO5为第5组GPIO,我们要控制的是第3个引脚,接着往下看,我们找到GPIO这个章节。我们要配置引脚为输出引脚(输出电平),并且输出高电平或低电平,那么我们需要操作这两个寄存器,GPIOx_GDIR和GPIOx_DR寄存器。
从上图可以得知,我们配置为1就是输出引脚。
下面给出操作寄存器的示例代码:
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数据。
将生成的这段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_driver
的driver
成员的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
命令,编译成功后的产物如下:
将编译后的led_drv.ko
和led_test
放到开发板,运行insmod led_drv.ko
加载驱动程序,运行测试程序,./led_drv_test /dev/led on
,可以发现led灯变亮了,./led_drv_test /dev/led off
灯灭,说明测试成功。
总结
本篇文章从介绍驱动程序的相关概念到实现一个简单的led驱动程序,可以看出,相比裸机、RTOS,Linux的驱动程序抽象复杂许多,但这样做的好处是高内聚、低耦合,提升了项目的可维护性,这也是软件编程设计思想重要的概念之一,特别是在越大型的项目,它的优点就越突出。在下一篇文章,我将介绍驱动程序基石,介绍阻塞、非阻塞、POLL、异步、中断等相关概念,并实现一个按键驱动程序,这些也是驱动程序开发过程中的重要一环,我们下期再见。
参考资料
- 韦东山Linux
- EmbeTronicX网站