一、字符设备驱动简介
字符设备是 Linux 驱动中最基本的一类设备驱动,字符设备就是一个一个字节,按照字节
流进行读写操作的设备,读写数据是分先后顺序的。比如我们最常见的点灯、按键、 IIC、 SPI,
LCD 等等都是字符设备,这些设备的驱动就叫做字符设备驱动。
Linux应用程序对驱动的调用:
在Linux中一切皆文件。驱动加载成功以后会在“/dev”目录下生成一个相应的文件。应用程序通过对这个名为“/dev/xxx” (xxx 是具体的驱动文件名字)的文件进行相应的操作即可实现对硬件的操作。
应用程序运行在用户空间,而 Linux 驱动属于内核的一部分,因此驱动运行于内核空间。当我们在用户空间想要实现对内核的操作,比如使用 open 函数打开/dev/led 这个驱动,因为用户空间不能直接对内核进行操作,因此必须使用一个叫做“系统调用”的方法来实现从用户空间“陷入” 到内核空间,这样才能实现对底层驱动的操作。
例如我门调用应用程序的open函数:
我们重点关注的是应用程序和具体的驱动,应用程序使用到的函数在具体驱动程序中都有与之对应的函数,比如应用程序中调用了 open 这个函数,那么在驱动程序中也得有一个名为 open 的函数。每一个系统调用,在驱动中都有与之对应的一个驱动函数,在 Linux 内核文件 include/linux/fs.h 中有个叫做 file_operations 的结构体,此结构体就是 Linux 内核驱动操作函数集合:
owner:拥有改结构体的模块的指针,一般设置为THIS_MODULE。
llseek :用与修改文件当前的读写位置的函数。
read :用于读取设备文件函数。
write :用与向设备文件写入(发送)数据函数。
poll :轮询函数(轮询函数是在一定时间间隔内反复执行某个函数的过程),用于查询设备是否可以进行非阻塞的读写。
unlocked_ioctl :提供对于设备的控制功能,与应用函数中的ioctl函数对应。
compat_ioctl :与unlocked_ioctl函数功能一样,于区别在于在 64 位系统上,32 位的应用程序调用将会使用此函数。在 32 位的系统上运行 32 位的应用程序调用的是unlocked_ioctl
mmap :将设备的内存映射到进程空间中(用户空间),一般帧缓冲设备会使用此函数,比如LCD驱动的显存,将LCD 显存映射到用户空间中以后应用程序就可以直接操作显存了,这样就不用在用户空间和内核空间之间来回复制。
open :打开设备文件函数。
release :释放(关闭)设备文件,与应用程序中的close函数对应。
fasync :用于刷新待处理的数据,用于将缓存区中的数据刷新磁盘中。
aio_fsync :与fasync函数功能一样,aio_fsync是异步刷新。
以上是比较常用的函数,当然具体需要实现哪些函数还是得看具体的驱动要求
二、字符设备驱动开发步骤
1、驱动模块的加载和卸载
Linux驱动有两种运行方式,第一种是将驱动编译Linux内核中,这样当Linux内核启动的时候就会自动运行驱动程序。第二种是将驱动编译成模块(Linux下模块扩展名为".ko"),在Linux内核启动后使用命令“insmod”或者“modprobe”命令加载驱动模块。在调式驱动的时候一般都选择将其编译为模块,这样我门就不需要重新烧写Linux内核,非常方便。将驱动编译成模块是最方便开发的。
模块有加载和卸载两种操作,在编写驱动时需要先注册这两个操作函数:
module_init(xxx_init); //注册模块加载函数
module_exit(xxx_exit); //注册模块卸载函数
我们使用vscode创建一个工程创建一个字符设备驱动的.c文件因为是编写 Linux 驱动,因此会用到 Linux 源码中的函数。我们需要在 VSCode 中添加 Linux源码中的头文件路径。打开VSCode,按下“Crtl+Shift+P”打开 VSCode 的控制台,然后输入“C/C++: Edit configurations(JSON) ”,打开 C/C++编辑配置文件,
includePath表示头文件路径,我们需要将前面我们移植NXPLinux内核编译过的Linux内核源码的头文件加进来:
{
"configurations": [
{
"name": "Linux",
"includePath": [
"${workspaceFolder}/**",
"/home/zxg/linux/IMX6ULL/linux/linux-imx-rel_imx_4.1.15_2.1.0_ga/include",
"/home/zxg/linux/IMX6ULL/linux/linux-imx-rel_imx_4.1.15_2.1.0_ga/arch/arm/include",
"/home/zxg/linux/IMX6ULL/linux/linux-imx-rel_imx_4.1.15_2.1.0_ga/arch/arm/include/generated/"
],
"defines": [],
"compilerPath": "/usr/bin/clang",
"cStandard": "c11",
"cppStandard": "c++17",
"intelliSenseMode": "clang-x64"
}
],
"version": 4
}
在Linux源码种也有其他模块的注册加载和卸载,我们可以参考写出我们的注册加载和卸载模块函数
注册模块加载函数和注册模块卸载函数可以完成跳转到Linux内核源码。
module_init :向Linux内核注册一个模块加载函数,参数chrdevbase_init就是要注册的具体函数。当我们使用“insmod”命令或“modprobe”命令加载驱动时候,chrdevbase_init函数就会被调用。
module_exit :向Linux内核注册一个模块卸载函数,参数chrdevbase_exit就是具体的函数,当使用“rmmod”命令卸载具体驱动时候,chrdevbase_exit函数就会被调用。
编写Makefile编译chrdevbase.c文件为.ko模块:
KERNELDIR := /home/zxg/linux/IMX6ULL/linux/linux-imx-rel_imx_4.1.15_2.1.0_ga
CURRENT_PATH := $(shell pwd)
obj-m := chrdevbase.o
build: kernel_modules
kernel_modules:
$(MAKE) - C $(KERNELDIR) M=$(CURRENT_PATH) modules
clean:
$(MAKE) - C $(KERNELDIR) M=$(CURRENT_PATH) modules
KERNELDIR :表示开发板所使用的Linux内核源码目录,使用绝对路径。
CURRENT_PATH :表示当前路径,直接通过pwd命令获取当前路径。
obj-m :表示chrdevbase.c这个文件编译为chrdevbase.ko模块。
后面的 modules 表示编译模块, -C 表示将当前的工作目录切换到指定目录中,也就是 KERNERLDIR 目录。 M 表示模块源码目录,“make modules”命令中加入 M=dir 以后程序会自动到指定的 dir 目录中读取模块的源码并将其编译为.ko 文件。
驱动编译完成后扩展名为.ko,可以使用“insmod”和“modprobe”命令来加载驱动,“insmod”时最简单的模块加载命令,但是不能解决依赖关系,比如说有一个驱动lcd.ko时以来与frist.ko这个模块的,就必须使用“insmod”加载frist.ko模块,然后在加载lcd.ko模块。“modprobe”命令不存在这个问题,它会先分析加载的模块的依赖关系,然后都会将所有依赖的模块都加载到内核种,因此我们使用“modprobe”命令比较多 。
注:第一此在Linux内核使用“modprobe”命令时,“modprobe”命令会默认到/lib/modules/<kernel-version>目录下查找编译后的驱动模块,所以需要自己在根文件系统种创建这样一个目录,比如我使用的Linux kernel使用的时4.1.15版本,所以我需要在根文件/lib下创建modules目录,在/modules目录下在创建一个4.1.15的目录。
将编译好的驱动模块(.ko)拷贝到根文件系统广告费创建的4.1.15目录下。
在serialCRT中使用“modprobe”加载驱动模块:
第一此使用modprobe命令,需要先使用depmod命令来添加modprobe命令的依赖。到这里字符设备驱动模块的加载和卸载我们就已经完成了。
2、支付设备的注册与注销
对于字符设备驱动,当驱动模块加载成功后,需要注册字符设备,同时卸载驱动模块时候也需要注销掉字符设备。
头文件
#include <linux/fs.h>
/* 注册字符设备函数 */
static inline int register_chrdev(unsigned int major, const char *name,
const struct file_operations *fops)
{
return __register_chrdev(major, 0, 256, name, fops);
}
/* 销毁字符设备函数 */
static inline void unregister_chrdev(unsigned int major, const char *name)
{
__unregister_chrdev(major, 0, 256, name);
}
major :主设备号。Linux下每一个设备都会有设备号,设备号分为主设备号和次设备号。后面有详细的讲。
name :设备的名字。指向遗传字符串。
fops :它时file_operations结构体的指针, file_operations前面我们也说过,就是 Linux 内核驱动操作函数集合。它指向设备的操作函数集合变量。
一般字符设备的注册都是在驱动模块的入口函数中进行,字符串设备的注销也是在出口函数进行。
在我们vscode工程中,在chrdevbase.c中,在入口函数添加注册驱动模块,在出口函数添加注销驱动模块。根据file_operations结构体,具体实现出字符设备模块打开,关闭,读和写函数:
添加LICENSE和作者信息:
MODULE_LICENSE() //添加模块 LICENSE 信息
MODULE_AUTHOR() //添加模块作者信息
LICENSE: 许可证,我们使用GPL协议。GPL协议一般指GNU通用公共许可证。 GNU通用公共许可证简称为GPL,是由自由软件基金会发行的用于计算机软件的协议证书,使用该证书的软件被称为自由软件。
AUTHOR:作者的名字。
三、Linux设备号
1、设备号的组成
在Linux中,为了方便管理,每个设备都有一个设备号,设备号由主设备号和次设备号组成。主设备号表示某一个具体的驱动,次设备号表示使用这个驱动的各个设备。Linux提供一个dev_t的数据类型表示设备号,在include/linux/types.h里面。
可以看出dev_t其实就是unsigned int类型,无符号32位 。其中高12位表示主设备号,低20位表示次设备号。所以主设备号范围0~4095。
2、设备号的分配
1、静态分配设备号
注册字符设备的时候需要给设备指定一个设备号,这个设备号可以是驱动开发者静态的指定一个设备号,比如选择 200 这个主设备号。有一些常用的设备号已经被 Linux 内核开发者给分配掉了,我们可以使用命令“cat /proc/devices”查看
2、动态分配设备号
静态分配设备号需要我们检查当前系统中所有被使用了的设备号,然后挑选一个没有使用
的。而且静态分配设备号很容易带来冲突问题, Linux 社区推荐使用动态分配设备号,在注册字
符设备之前先申请一个设备号,系统会自动给你一个没有被使用的设备号,这样就避免了冲突。
卸载驱动的时候释放掉这个设备号即可:
int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)
dev :保存申请到的设备号。
baseminor :次设备号起始地址,alloc_chrdev_region 可以申请一段连续的多个设备号,这
些设备号的主设备号一样,但是次设备号不同,次设备号以 baseminor 为起始地址地址开始递
增。一般 baseminor 为 0,也就是说次设备号从 0 开始。
count :要申请的设备号数量。
name :设备名字。
void unregister_chrdev_region(dev_t from, unsigned count)
from :要释放的设备号。
count :表示从from开始,要释放的设备号数量 。
四、chrdevbase字符设备驱动实验
1、编写驱动程序
当字符串驱动文件中,当我们加载驱动程序时,会调用一次具体的驱动入口函数,会执行chrdevbase_init函数,chrdevbase_init函数中我们注册了字符设备,字符设备有,打开设备,关闭设备,向设备写数据和读数据的功能:
writebuf:就是用户区要向设备写入的数据缓存区。
readbuf: 用户从设备读取数据的缓存区。
kerneldata[] :保存用户区要读取的数据。
printk :函数原型:const char * fmt(…)。和C语言中的printf一样,只不过printk是在内核使用的,printf是在用户区使用的,他们都是在控制台打印信息,方便调式。printk可以根据日志级别对消息进行分类。在Linux内核源码的/include/linux/kern_levels.h中:
一共定义8个级别,0的优先级最高,如果要设置消息级别的参数:
printk(KERN_EMERG "gsmi: Log Shutdown Reason\n");
上述代码就是设置“gsmi: Log Shutdown Reason\n”这行消息的级别为 KERN_EMERG。在
具体的消息前面加上 KERN_EMERG 就可以将这条消息的级别设置为 KERN_EMERG。如果不使用消息级别的话,printk会使用KERN_DEBUG这个级别。
copy_to_user :内核空间向用户空间拷贝数据。
函数原型:
static inline long copy_to_user(void __user *to, const void *from, unsigned long n)
to:表示目的,from:表示源头。n:表示要复制的数据长度。long:返回值,如果复制成功返回0,如果失败,返回没有拷贝成功的数据字节数。
copy_from_user :用户空间向内核空间拷贝数据。
函数原型:
unsigned long copy_from_user(void * to, const void __user * from, unsigned long n)
to:表示目的。from:表示源头。n :表示要复制的数据长度。ulong:返回值 如果数据拷贝成功,则返回零;否则,返回没有拷贝成功的数据字节数。
memcpy:
函数原型:
void *memcpy(void*dest, const void *src, size_t n);
于C语言中的用法一样。
2、编写应用程序APP
应用程序主要是对驱动程序设备进行读写功能的测试。编写应用程序APP也就是编写Linux应用。在Linux应用要使用C库和一些文件相关的函数。
在ubuntu下使用man命令查看man:翻译过来就是手册的意思,一般用来查看Linux系统中的函数信息。
比如在应用程序中,我们要使用open函数来打开驱动文件,可以使用man 2 open查看:
编写chrdevbaseAPP.c文件。
main函数的参数位三个参数,判断运行测试APP输入参数如果不是三个的话,则使用错误。比如现在要向chrdevbase设备中读数据,需要命令如下:
./chrdevbaseApp /dev/chrdevbase 1
./chrdevbaseApp:对应argv[0],表示运行这个软件。
/dev/chrdevbase:对应argv[1],表示测试的软件要打开/dev/chrdevbase这个设备。
1:表示对这个设备进行扫描操作通过程序指定。
在程序中使用到Linux的一些函数:
open():系统调用打开由pathname指定的文件。如果指定文件不存在,它可以选择性的由open()函数创建(如果指定了O_CREATE)。
头文件:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
函数原型:
int open(const char *pathname, int flags);
pathname:要打开的设备或文件名。
flags:文件打开模式。有三种打开模式:O_RDONLE(只读)、O_WRONLY(只写)、0_RDWR(读写)。除了这三种必须的模式外,还可以使用逻辑或来添加其他模式
O_APPEND | 每次写操作都写入文件的末尾 |
O_CREAT | 如果指定文件不存在,则创建这个文件 |
O_EXCL | 如果要创建的文件已存在,则返回 -1,并且修改 errno 的值 |
O_TRUNC | 如果文件存在,并且以只写/读写方式打开,则清空文件全部内容 |
O_NOCTTY | 如果路径名指向终端设备,不要把这个设备用作控制终端 |
O_NONBLOC | 如果路径名指向 FIFO/块文件/字符文件,则把文件的打开和后继I/O 设置为非阻塞 |
DSYNC | 等待物理 I/O 结束后再 write。在不影响读取新写入的数据的前提下,不等待文件属性更新 |
O_RSYNC | read 等待所有写入同一区域的写操作完成后再进行 |
O_SYNC | 等待物理 I/O 结束后再 write,包括更新文件属性的 I/O。 |
返回值:如果文件打开成功的话返回文件的文件描述符。
read():尝试从文件描述符fd读取到的字节数,从buf开始的缓冲区。
头文件:#include <unistd.h>
函数原型:
ssize_t read(int fd, void *buf, size_t count);
fd:要读取的文件描述符。一般打开文件后会返回一个文件描述符。
buf:数据读取存放的位置。
count:要读取的数据长度。
返回值:读取成功的话返回读取到的字节数;如果返回 0 表示读取到了文件末尾;如果返
回负值,表示读取失败。
write():从缓冲区的buf开始写入到文件描述符fd引用的文件。
头文件:#include <unistd.h
函数原型:
ssize_t write(int fd, const void *buf, size_t count);
fd:要写入的文件描述符。
buf:要写入的数据。
count:要写入的数据长度。
返回值: 写入成功的话返回写入的字节数;如果返回 0 表示没有写入任何数据;如果返回
负值,表示写入失败。
close():关闭一个文件的文件描述符,使其不再引用任何描述符文件,可以重复使用。
头文件:#include <unistd.h>
函数原型:
int close(int fd);
fd:要关闭的文件描述符。
返回值:0 表示关闭成功,负值表示关闭失败。
3、编译驱动程序和测试APP
驱动程序我门使用Makefile编译,前面我们已经介绍过了驱动的Makefile。测试APP的编译我们不适用Makefile编译,因为就一个文件,所以我们采用直接编译。
驱动程序:
测试APP:
4、加载运行
将编译好的驱动模块(.ko)和测试APP文件拷贝到nfs的根文件系统的/lib/modules/4.1.15目录下。需要加上权限。
再serialCRT上,先加载驱动模块:
查看当前系统中有没有chrdevbase这个设备:
可以看出这个设备加载成功,设备号就是我们驱动代码中给的200。
创建设备节点文件:
驱动加载成功需要再/dev目录下创建一个与之对应的设备节点文件,应用程序就是通过操作这个设备节点文件来完成对具体设备的操作。
mknod /dev/chrdevbase c 200 0
/dev/chrdevbase :创建的节点文件。
c:表示这个是一个字符设备
200:主设备号。
0:次设备号。
设备操作测试:
非常完美!