LV10-02-字符设备驱动-01-基础知识
本文主要是字符设备驱动基础的相关笔记,若笔记中有错误或者不合适的地方,欢迎批评指正😃。
点击查看使用工具及版本
Windows | windows11 |
Ubuntu | Ubuntu16.04的64位版本 |
VMware® Workstation 16 Pro | 16.2.3 build-19376536 |
SecureCRT | Version 8.7.2 (x64 build 2214) - 正式版-2020年5月14日 |
Linux开发板 | 华清远见 底板: FS4412_DEV_V5 核心板: FS4412 V2 |
u-boot | 2013.01 |
点击查看本文参考资料
参考方向 | 参考原文 |
--- | --- |
点击查看相关文件下载
文件 | 下载链接 |
--- | --- |
一、字符设备驱动简介
1. 字符设备?
字符设备是Linux
驱动中最基本的一类设备驱动,字符设备就是一个一个字节,按照字节流进行读写操作的设备,读写数据是分先后顺序的。比如我们最常见的LED
灯、按键、 IIC
、 SPI
,LCD
等等都是字符设备,这些设备的驱动就叫做字符设备驱动。
2. 如何调用驱动?
驱动加载成功以后会在/dev
目录下生成一个相应的文件,应用程序通过对这个名为/dev/xxx
(xxx
是具体的驱动文件名字)的文件进行相应的操作即可实现对硬件的操作。
比如现在有个叫做/dev/led
的驱动文件,此文件是led
灯的驱动文件。应用程序使用open
函数来打开文件/dev/led
,使用完成以后使用close
函数关闭/dev/led
这个文件。 open
和close
就是打开和关闭 led
驱动的函数,如果要点亮或关闭 led
,那么就使用 write
函数来操作,也就是向此驱动写入数据,这个数据就是要关闭还是要打开led
的控制参数。如果要获取led
灯的状态,就用 read
函数从驱动中读取相应的状态。
应用程序运行在用户空间,而Linux
驱动属于内核的一部分,因此驱动运行于内核空间。当我们在用户空间想要实现对内核的操作,比如使用open
函数打开/dev/led
这个驱动,因为用户空间不能直接对内核进行操作,因此必须使用一个叫做系统调用的方法来实现从用户空间进入到内核空间,这样才能实现对底层驱动的操作。 open
、 close
、write
和 read
等这些函数是由 C
库提供的,在 Linux
系统中,系统调用作为 C
库的一部分。
如上图所示,在应用中调用了open
函数,然后对应一个关于open
的系统调用,在这个系统调用中,再调用我们编写的驱动函数,应用程序中怎么找到这个系统调用,以及如何进入内核空间的,暂时不用关心,我们需要关心的是这个open
系统调用怎么知道要调用驱动中的myopen
呢?
3. struct file_operations
每一个系统调用,在驱动中都有与之对应的一个驱动函数,他们之间是通过一个结构体联系起来的,在Linux
内核文件 include/linux/fs.h
中有一个名为file_operations
的结构体,此结构体就是 Linux
内核驱动操作函数集合。
点击查看 struct file_operations 结构体成员
1 | struct file_operations |
【常用成员说明】
owner
:填THIS_MODULE
,表示该结构体对象从属于哪个内核模块。llseek
:llseek
函数用于修改文件当前的读写位置。read
:read
函数用于读取设备文件,与应用程序中的read()
函数对应。write
:write
函数用于向设备文件写入(发送)数据,与应用程序中的write()
函数对应。poll
:poll
是个轮询函数,用于查询设备是否可以进行非阻塞的读写,实现多路复用的支持。unlocked_ioctl
:unlocked_ioctl
函数提供对于设备的控制功能,如读写设备参数,读设备状态等,与应用程序中的ioctl()
函数对应。compat_ioctl
与unlocked_ioctl
函数功能一样,区别在于在64
位系统上,32
位的应用程序调用将会使用此函数。在32
位的系统上运行32
位的应用程序调用的是unlocked_ioctl
。mmap
:用于将将设备的内存映射到进程空间中(也就是用户空间,简单来说就是映射内核空间到用户空间),一般帧缓冲设备会使用此函数,比如LCD
驱动的显存,将帧缓冲(LCD
显存)映射到用户空间中以后应用程序就可以直接操作显存了,这样就不用在用户空间和内核空间之间来回复制。open
:用于打开设备文件,与应用程序中的open()
函数对应。release
:用于释放(关闭)设备文件,与应用程序中的close()
函数对应。fasync
:用于常用于异步通知,如信号驱动。
一般我们会定义一个struct file_operations
类型的全局变量并用自己实现的各种操作函数名对其进行初始化,如myopen()
。初始化后,由于该结构体对象各个函数指针成员都对应相应的系统调用函数,应用层通过调用系统函数来间接调用这些函数指针成员指向的设备驱动函数,这样当我们在应用程序中调用open
的时候,系统调用就会在内部调用相应的myopen
驱动函数了。
二、字符设备加载与卸载
1. 加载与卸载模板
这是驱动开发的最基本的步骤,模块有加载和卸载两种操作,我们在编写驱动的时候需要注册这两种操作函数,模块的加载和
卸载注册函数如下:
1 | module_init(xxx_init); //注册模块加载函数 |
module_init
函数用来向Linux
内核注册一个模块加载函数,参数xxx_init
就是需要注册的具体函数,当使用insmod
命令加载驱动的时候,xxx_init
这个函数就会被调用。module_exit()
函数用来向Linux
内核注册一个模块卸载函数,参数xxx_exit
就是需要注册的具体函数,当使用rmmod
命令卸载具体驱动的时候xxx_exit
函数就会被调用。
- 字符设备加载与卸载模板
点击查看详情
1 | /* 模块入口函数 */ |
2. 添加LICENSE
前边我们已经了解过模块信息宏的相关概念,模块信息中这个LICENSE
是必须要添加的,还可以添加上作者名字,一般为:
1 | /* 模块信息(通过 modinfo dev_name 查看) */ |
三、设备号分配
1. 设备号的组成
Linux
中每个设备都有一个设备号,设备号由主设备号和次设备号两部分组成,主设备号表示某一个具体的驱动,次设备号表示使用这个驱动的各个设备,设备号主要是用于区分内核中同类设备。 当应用程序打开一个设备文件时,通过设备号来查找定位内核中管理的设备。
Linux
提供了一个名为dev_t
的数据类型表示设备号,dev_t
定义在文件 include/linux/types.h
里面,定义如下:
1 | typedef __u32 __kernel_dev_t; |
dev_t
是__u32
类型的,而__u32
定义在文件include/uapi/asm-generic/int-ll64.h
里面,定义如下:
1 | typedef unsigned int __u32; |
所以dev_t
其实就是 unsigned int
类型,是一个 32
位的数据类型。 这32
位包含了主设备号和次设备号:
- 主设备号:占高
12
位,用来表示驱动程序相同的一类设备,因此Linux
系统中主设备号范围为0~4095
。 - 次设备号:占低
20
位,用来表示被操作的哪个具体设备。
2. 相关函数
对于设备号,linux
内核中的include/linux/kdev_t.h
文件中为我们提供了如下几个函数(本质是宏)来操作设备号:
1 |
MINORBITS
:表示次设备号位数,一共是20
位。MINORMASK
:表示次设备号掩码。MAJOR
: 用于从dev_t
中获取主设备号,将dev_t
右移20
位即可。例如,
1 | dev_t devno = MKDEV(999,1); |
MINOR
:用于从dev_t
中获取次设备号,取dev_t
的低20
位的值即可。例如,
1 | dev_t devno = MKDEV(999,1); |
MKDEV
:用于将给定的主设备号和次设备号的值组合成dev_t
类型的设备号。 例如,
1 | dev_t devno; |
3. 设备号的分配
设备号的分配有两种方式,一种是静态分配,一种是动态分配。
3.1 静态分配设备号
设备号可以是驱动开发者静态的指定一个设备号,比如选择200
这个主设备号。有一些常用的设备号已经被Linux
内核开发者给分配掉了,具体分配的内容可以查看文档Documentation/devices.txt
。并不是说内核开发者已经分配掉的主设备号我们就不能用了,具体能不能用还得看我们的硬件平台运行过程中有没有使用这个主设备号。
3.1.1 查看已使用的主设备号
我们可以使用下边的命令查看当前系统中所有已经使用了的主设备号:
1 | cat /proc/devices |
我们想要查询我们加载的模块的设备号的话,可以加上grep
搜索:
1 | cat /proc/devices | grep 申请设备号时用的名字 |
3.1.2 注册静态分配的设备号
当我们给定了主设备号和次设备号的时候需要进行设备号的注册,我们可以使用register_chrdev_region
函数进行静态分配的设备号的注册,我们使用以下命令查询一下函数所在头文件:
1 | grep register_chrdev_region -r -n ~/5linux/linux-3.14/include |
经过查找,我们可以得到如下信息:
1 | /* 需包含的头文件 */ |
【函数说明】该函数用于注册静态分配的设备号,先验证设备号是否被占用,如果没有则申请占用该设备号,分配成功后可以在/proc/devices
查看到申请到主设备号和对应的设备名。
【函数参数】
from
:dev_t
类型,要申请的设备号,也就是给定的设备号。count
:unsigned
类型,需要申请的设备数量。name
:char *
类型,传入的是一个字符串,表示/proc/devices
文件中与该设备对应的名字(就是设备名称),方便用户层查询主设备号。
【返回值】int
类型,成功返回0
,失败返回一个负数,负数的绝对值表示错误码。
【使用格式】一般情况下基本使用格式如下:
1 | /* 需要包含的头文件 */ |
【注意事项】none
3.2 动态分配设备号
3.2.1 为什么要动态分配
静态分配设备号需要我们检查当前系统中所有被使用了的设备号,然后挑选一个没有使用的。而且静态分配设备号很容易带来冲突问题, Linux
社区推荐使用动态分配设备号,在注册字符设备之前先申请一个设备号,系统会自动给我们一个没有被使用的设备号,这样就避免了冲突,卸载驱动的时候释放掉这个设备号即可。
3.2.2 动态分配函数
我们可以使用alloc_chrdev_region
函数进行静态分配的设备号的注册,我们使用以下命令查询一下函数所在头文件:
1 | grep alloc_chrdev_region -r -n ~/5linux/linux-3.14/include |
经过查找,我们可以得到如下信息:
1 | /* 需包含的头文件 */ |
【函数说明】该函数用于动态分配设备号,查询内核里未被占用的设备号,如果找到则占用该设备号,分配成功后可以在/proc/devices
查看到申请到主设备号和对应的设备名。
【函数参数】
dev
:dev_t *
类型,传入的是一个地址,保存的是申请到的设备号。baseminor
:unsigned
类型,次设备号起始地址, 该函数可以申请一段连续的多个设备号,这些设备号的主设备号一样,但是次设备号不同,次设备号以baseminor
为起始地址地址开始递增。一般baseminor
为0
,也就是说次设备号从0
开始。count
:unsigned
类型,要申请的设备号数量。name
:char *
类型,传入的是一个字符串,表示/proc/devices
文件中与该设备对应的名字(就是设备名称),方便用户层查询主设备号。
【返回值】int
类型,成功返回0
,失败返回一个负数,负数的绝对值表示错误码。
【使用格式】一般情况下基本使用格式如下:
1 | /* 需要包含的头文件 */ |
【注意事项】none
4. 设备号释放
4.1 相关函数
我们申请的设备号不再使用的时候就需要释放掉这个设备号,以便于其他设备使用,静态分配或者是动态分配的设备号我们统一使用函数unregister_chrdev_region
完成,我们使用以下命令查询一下函数所在头文件:
1 | grep unregister_chrdev_region -r -n ~/5linux/linux-3.14/include |
经过查找,我们可以得到如下信息:
1 | /* 需包含的头文件 */ |
【函数说明】该函数用于释放设备号,释放成功后/proc/devices
文件对应的记录的主设备号和设备名称消失。
【函数参数】
from
:dev_t
类型,已成功分配的设备号,就是我们需要释放掉的设备号。count
:unsigned
类型,申请成功的设备数量。
【返回值】none
【使用格式】一般情况下基本使用格式如下:
1 | /* 需要包含的头文件 */ |
【注意事项】none
四、设备注册
1. 字符设备结构
在 Linux
中使用 cdev
结构体表示一个字符设备, cdev
结构体在 include/linux/cdev.h
文件中的定义如下 :
1 | struct cdev { |
【成员介绍】
kobj
:struct kobject
类型,表示该类型实体是一种内核对象。owner
:struct module *
类型,一般填THIS_MODULE
,表示该字符设备从属于哪个内核模块。ops
:struct file_operations *
类型,指向空间存放着针对该设备的各种操作函数地址,这样建立起来了设备驱动的函数与系统调用的对应关系,如应用程序调用open()
函数的时候,对应的系统调用就会找到我们驱动程序中实现的myopen()
函数。list
:struct list_head
类型,表示链表指针域。dev
:dev_t
类型,表示设备号。count
:unsigned int
类型,表示设备数量。
编写字符设备驱动之前需要定义一个 cdev
结构体变量,这个变量就表示一个字符设备,如下所示:
1 | struct cdev mydev; |
其实我们还有一种方式定义一个设备,就是动态申请(暂时没用过,先写在这里,知道有这么一个函数):
1 | struct cdev * cdev_alloc(); |
【说明】目前按我自己的理解,就是这个结构体对象是用于负责建立系统调用与设备驱动中函数集关系,并且向内核注册这个字符设备。
2. 字符设备操作函数集
我们需要给设备指定操作函数集,这样内核在进行系统调用的时候才知道去调用驱动中的哪一个函数,所以我们需要使用struct file_operations
定义一个操作函数集,之后再赋值给cdev
变量,我们常用的成员有下边这几个:
1 | struct file_operations |
所以我们定义的函数集如下:
1 | /* 操作函数集定义 */ |
3. 初始化字符设备变量
定义好 cdev
变量以后就要使用 cdev_init
函数对其进行初始化 ,其实主要为它设置操作函数集。
3.1 owner
成员
初始化的时候,该成员也需要初始化,一般设置为THIS_MODULE
:
1 | mydev.owner = THIS_MODULE; |
3.2 cdev_init()
我们使用以下命令查询一下函数所在头文件:
1 | grep cdev_init -r -n ~/5linux/linux-3.14/include |
经过查找,我们可以得到如下信息:
1 | /* 需包含的头文件 */ |
【函数说明】该函数用于初始化cdev
变量,主要是为该设备添加操作函数集。
【函数参数】
cdev
:struct cdev *
类型,要初始化的cdev
结构体变量。fops
:struct file_operations *
类型,字符设备文件操作函数集合。
【返回值】none
类型。
【使用格式】一般情况下基本使用格式如下:
1 | /* 需要包含的头文件 */ |
【注意事项】none
3.3 cdev_add()
我们使用以下命令查询一下函数所在头文件:
1 | grep cdev_add -r -n ~/5linux/linux-3.14/include |
经过查找,我们可以得到如下信息:
1 | /* 需包含的头文件 */ |
【函数说明】该函数用于将指定字符设备添加到Linux
内核,添加成功后会在 /proc/devices
文件中创建一个包含主设备号和对应的设备名称记录。
【函数参数】
p
:struct cdev *
类型,指向要添加的字符设备(cdev
结构体变量) 。dev
:dev_t
类型,设备所使用的设备号。count
:unsigned
类型,要添加的设备数量,一般填1
。
【返回值】int
类型,添加成功返回 0
,失败返回错误码。
【使用格式】一般情况下基本使用格式如下:
1 | /* 需要包含的头文件 */ |
【注意事项】none
3.4 cdev_del()
我们使用以下命令查询一下函数所在头文件:
1 | grep cdev_del -r -n ~/5linux/linux-3.14/include |
经过查找,我们可以得到如下信息:
1 | /* 需包含的头文件 */ |
【函数说明】该函数用于将指定字符设备从Linux
内核移除,移除成功后会删除在 /proc/devices
文件中创建的包含主设备号和对应设备名称的记录。
【函数参数】
p
:struct cdev *
类型,指向要移除的字符设备(cdev
结构体变量) 。
【返回值】none
类型。
【使用格式】一般情况下基本使用格式如下:
1 | /* 需要包含的头文件 */ |
【注意事项】none
五、设备节点创建
前边的介绍中,我们了解到加载完驱动后,我们后边操作的是/dev/
下的设备文件,而这个文件则需要我们来进行创建,所以在申请完设备号之后,我们还需要在/dev
目录下创建一个与之对应的设备节点文件, 应用程序将会通过操作这个设备节点文件来完成对具体设备的操作。
1. 手动创建设备节点
我们可以通过mknod
命令完成设备节点的创建,使用格式如下:
1 | mknod /dev/设备文件名 设备种类(c为字符设备,b为块设备) 主设备号 次设备号 # ubuntu下需加sudo执行 |
2. 应用程序创建设备节点
2.1 mknod()
我们还可以通过应用程序来创建设备节点,我们在应用程序中使用mknod
系统调用函数来完成设备节点的创建,我们可以使用man 2 mknode
来查看函数的帮助手册:
1 | /* 需包含的头文件 */ |
【函数说明】该函数用于创建设备节点,详情可以查看帮助手册,这个函数我没怎么用过,这里做一个简单的笔记。
【函数参数】
pathname
:char *
类型,带路径的设备文件名,无路径默认为当前目录,一般都创建在/dev
下。mode
:mode_t
类型,文件权限。dev
:dev_t
类型,32
位设备号。
【返回值】int
类型,成功返回0
,失败返回-1
。
【使用格式】none
【注意事项】none
3. 自动创建设备节点
前边两种不免比较麻烦,增加了应用程序开发人员的负担,我们其实是可以在加载驱动模块成功的时候自动在/dev
目录下
创建对应的设备文件。
3.1 mdev
机制
udev
是一个用户程序,在 Linux
下通过 udev
来实现设备文件的创建与删除, udev
可以检测系统中硬件设备状态,可以根据系统中硬件设备状态来创建或者删除设备文件。比如使用insmod
命令成功加载驱动模块以后就自动在/dev
目录下创建对应的设备节点文件,使用rmmod
命令卸载驱动模块以后就删除掉/dev
目录下的设备节点文件。 使用busybox
构建根文件系统的时候,busybox
会创建一个 udev
的简化版本——mdev
。
内核中定义了struct class
结构体,一个struct class
结构体类型变量对应一个类, 内核同时提供了class_create()
函数,可以用它来创建一个类,这个类存放于/sys/class
下面,一旦创建好了这个类,再调用device_create()
函数来在/dev
目录下创建相应的设备节点。这样,加载模块的时候,用户空间中的udev
会自动响应device_create()
函数,去/sys/class
下寻找对应的类从而创建设备节点,需要注意的是,当只有一个主设备号,多个次设备号的时候,这些次设备的类只有一个。
所以在嵌入式 Linux
中我们使用mdev
来实现设备节点文件的自动创建与删除,Linux
系统中的热插拔事件也由mdev
管理,在/etc/init.d/rcS
文件中添加如下语句:
1 | echo /sbin/mdev > /proc/sys/kernel/hotplug # 命令设置热插拔事件由 mdev 来管理 |
3.2 创建和删除类
3.2.1 class_create()
我们使用以下命令查询一下函数所在头文件:
1 | grep class_create -r -n ~/5linux/linux-3.14/include |
经过查找,我们可以得到如下信息:
1 | /* 需包含的头文件 */ |
【函数说明】该函数用于创建一个类,在/sys/class
生成一个目录,目录名由name
指定(会得到一个/sys/class/name
目录)。它其实是个宏定义,展开后内容如下:
1 | struct class *class_create(struct module *owner, const char *name); |
【函数参数】
owner
:struct module *
类型,一般为THIS_MODULE
。name
:char *
类型,类的名字。
【返回值】struct class *
类型,是一个指向结构体class
的指针,也就是创建的类,失败的时候返回NULL
。
【使用格式】一般情况下基本使用格式如下:
1 | /* 需要包含的头文件 */ |
【注意事项】
(1)辅助接口:可以定义一个struct class
的指针变量class
来接受返回值,然后通过IS_ERR(class)
判断是否失败;
1 | IS_ERR(device);/* 成功-->0,失败-->非0 */ |
(2)主设备号相同,次设备号不同的几个次设备,他们属于同一个类,只需要创建一个类即可。
3.2.2 class_destroy()
我们使用以下命令查询一下函数所在头文件:
1 | grep class_destroy -r -n ~/5linux/linux-3.14/include |
经过查找,我们可以得到如下信息:
1 | /* 需包含的头文件 */ |
【函数说明】该函数用于删除一个类,会删除创建类时在/sys/class
生成的那个目录(会删除/sys/class/name
目录)。
【函数参数】
cls
:struct class *
类型,要删除的类。
【返回值】none
【使用格式】一般情况下基本使用格式如下:
1 | /* 需要包含的头文件 */ |
【注意事项】
(1)主设备号相同,次设备号不同的几个次设备,他们属于同一个类,只需要创建一个类,删除的时候也只需要删除创建的那一个类。
3.3创建和删除设备
创建完了类之后,我们还需要在/dev/
下创建我们的设备节点。
3.3.1 device_create()
我们使用以下命令查询一下函数所在头文件:
1 | grep device_create -r -n ~/5linux/linux-3.14/include |
经过查找,我们可以得到如下信息:
1 | /* 需包含的头文件 */ |
【函数说明】该函数用于在/sys/class
目录下class_create
生成目录再生成一个子目录与该设备相对应,uevent
让应用程序udevd
创建设备文件,最终会创建/dev/fmt
作为我们的设备节点。该函数最终创建的文件有:
1 | /sys/class/class_name/fmt # 目录 |
【函数参数】
cls
:struct class *
类型,创建的类名,表示设备要创建哪个类下面。parent
:struct device *
类型,表示父设备,一般为NULL
,也就是没有父设备。devt
:dev_t
类型,已经分配成功的设备号。drvdata
:void *
类型,驱动私有数据,一般为NULL
。fmt
:char *
类型,表示设备名字 ,是一个格式化字符串,类似printf
,可以是一个固定的字符串,也可以是格式化字符串,若fmt=xxx
,就会生成/dev/xxx
这个设备文件。 若为格式化字符串%s%d
,则需要后边的vargs
参数。...
:不定参数列表,前边的fmt
若为"%s%d"
,这里就需要一个字符串和一个整型变量,就类似于printf
。
【返回值】struct device *
类型,是一个指向结构体device
的指针,也就是表示创建好的设备,失败的时候返回NULL
。
【使用格式】一般情况下基本使用格式如下:
1 | /* 需要包含的头文件 */ |
【注意事项】
(1)辅助接口:可以定义一个struct device
的指针变量device
来接受返回值,然后通过IS_ERR(device)
判断是否失败;
1 | IS_ERR(device);/* 成功-->0,失败-->非0 */ |
(2)主设备号相同,次设备号不同的几个次设备,他们属于同一个类,只需要创建一个类,需要创建多个设备对应不同的次设备。
3.2.2 device_destroy()
我们使用以下命令查询一下函数所在头文件:
1 | grep device_destroy -r -n ~/5linux/linux-3.14/include |
经过查找,我们可以得到如下信息:
1 | /* 需包含的头文件 */ |
【函数说明】该函数用于删除一个设备,会删除创建设备时在/sys/class
生成的那个目录。就是说下边目录和文件会被删除:
1 | /sys/class/class_name/fmt # 目录 |
【函数参数】
cls
:struct class *
类型,要删除的设备所处的类。devt
:dev_t
类型,表示要删除的设备号。
【返回值】none
【使用格式】一般情况下基本使用格式如下:
1 | /* 需要包含的头文件 */ |
【注意事项】
(1)主设备号相同,次设备号不同的几个次设备,他们属于同一个类,只需要创建一个类,需要创建多个设备对应不同的次设备,删除的时候就需要将这些设备逐个删除。
(2)删除的时候可以先删除设备,再删除类,按理说可以直接删除类就完事了,毕竟类目录都没了,子目录应该也就一起删除了。