应用程序和驱动的交互原理
在 linux 中,驱动可以获取外设(传感器)数据、控制外设。Linux 驱动编译既要写一个驱动,也要写一个简单的测试应用程序,在 Linux 中,驱动和应用是完全分开的。
Linux 中分用户空间和内核空间,Linux操作系统的内核和驱动程序运行在内核空间,应用程序运行在用户空间。应用程序想要访问内核资源,有三种方法:系统调用、异常(中断)、陷入。
应用程序不会直接调用系统调用,而是通过 API 函数来间接调用系统,unix 类操作系统中最常用的编程接口是 POSIX。应用程序使用 open 函数打开一个设备文件。每个系统调用有一个调用号。

字符设备驱动框架
在 Linux 中一切皆文件,驱动设备表现就是一个 /dev/ 下的文件,比如 /dev/led,可以通过 open 打开设备,通过write函数向 /dev/led 写数据。编写设备驱动的时候也需要编写驱动对应的 open、close、write 函数。 (驱动最终是要被应用调用的,在写驱动的时候需要考虑应用开发的便利性。驱动是分驱动框架的,要按照驱动框架来编写。)
字符设备是 Linux 驱动中最基本的一类驱动,字符设备就是一个一个字节,按照字节流进行读写操作的设备,读写数据十分先后顺序的,比如我们常见的点灯、按键、IIC都是字符设备,这些设备的驱动就叫做字符设备驱动。
字符设备驱动的编写主要就是驱动对应的 open、close、write 等函数,比如应用程序中调用了 open 这个函数,那么在驱动程序中也得有一个名为 open 的函数。每一个系统调用,在驱动中都有与之对应的一个驱动函数,在 Linux 内核文件 include/linux/fs.h 中有个叫做 file_operations 的结构体,此结构体就是 Linux 内核驱动操作函数集合
1 | struct file_operations { |
在字符设备驱动开发中最常用的就是上面这些函数,关于其他的函数大家可以查阅相关文档。我们在字符设备驱动开发中最主要的工作就是实现上面这些函数,不一定全部都要实现,但是像 open、release、write、read 等都是需要实现的,当然了,具体需要实现哪些函数还是要看具体的驱动要求。
字符设备驱动开发步骤
驱动模块的加载和卸载
Linux 驱动有两种运行方式,第一种就是将驱动编译进 Linux 内核中,这样当 Linux 内核启动的时候就会自动运行驱动程序。第二种就是将驱动编译成模块(Linux 下模块扩展名为.ko),在 Linux 内核启动以后使用“insmod”命令加载驱动模块。在调试驱动的时候一般都选择将其编译为模块,这样我们修改驱动以后只需要编译一下驱动代码即可,不需要编译整个 Linux 代码。而且在调试的时候只需要加载或者卸载驱动模块即可,不需要重启整个系统。总之,将驱动编译为模块最大的好处就是方便开发,当驱动开发完成,确定没有问题以后就可以将驱动编译进 Linux 内核中,当然也可以不编译进 Linux 内核中,具体看自己的需求。
模块有加载和卸载两种操作,我们在编写驱动的时候需要注册这两种操作函数,模块的加载和 卸载注册函数如下:
module_init(xxx_init); //注册模块加载函数
module_exit(xxx_exit); //注册模块卸载函数
module_init 函数用来向 Linux 内核注册一个模块加载函数,参数 xxx_init 就是需要注册的具体函数,当使用“insmod”命令加载驱动的时候,xxx_init 这个函数就会被调用。module_exit()函数用来向 Linux 内核注册一个模块卸载函数,参数 xxx_exit 就是需要注册的具体函数,当 使用“rmmod”命令卸载具体驱动的时候 xxx_exit 函数就会被调用。字符设备驱动模块加载和卸载模板如下所示:
1 | static int __init xxx_init(void) |
第 1 行,定义了个名为 xxx_init 的驱动入口函数,并且使用了“__init”来修饰。
第 7 行,定义了个名为 xxx_exit 的驱动出口函数,并且使用了“__exit”来修饰。
第 12 行,调用函数 module_init 来声明 xxx_init 为驱动入口函数,当加载驱动的时候 xxx_init 函数就会被调用。
第 13 行,调用函数 module_exit 来声明 xxx_exit 为驱动出口函数,当卸载驱动的时候 xxx_exit 函数就会被调用。
驱动编译完成以后扩展名为.ko,有两种命令可以加载驱动模块:insmod和modprobe,insmod是最简单的模块加载命令,此命令用于加载指定的.ko模块,比如加载drv.ko这个驱动模块,命令如下:
insmod drv.ko
insmod 命令不能解决模块的依赖关系,比如 drv.ko 依赖 first.ko 这个模块,就必须先使用 insmod 命令加载 first.ko 这个模块,然后再加载 drv.ko 这个模块。但是 modprobe 就不会存在这 个问题,modprobe 会分析模块的依赖关系,然后会将所有的依赖模块都加载到内核中,因此 modprobe 命令相比 insmod 要智能一些。modprobe 命令主要智能在提供了模块的依赖性分析、错 误检查、错误报告等功能,推荐使用 modprobe 命令来加载驱动。modprobe 命令默认会去/lib/m odules/<kernel-version>目录中查找模块,比如本书使用的 Linux kernel 的版本号为 4.1.15,因此 modprobe 命令默认会到/lib/modules/4.1.15 这个目录中查找相应的驱动模块,一般自己制作的根文件系统中是不会有这个目录的,所以需要自己手动创建。
驱动模块的卸载使用命令“rmmod”即可,比如要卸载drv.ko,使用如下命令即可:
rmmod drv.ko
字符设备注册与注销
对于字符设备驱动而言,当驱动模块加载成功以后需要注册字符设备,同样,卸载驱动模块的时候也需要注销掉字符设备。字符设备的注册和注销函数原型如下所示:
static inline int register_chrdev(unsigned int major, const char *name, const struct file_operations *fops)
static inline void unregister_chrdev(unsigned int major, const char *name)
register_chrdev 函数用于注册字符设备,此函数一共有三个参数,这三个参数的含义如下:
major:主设备号,Linux 下每个设备都有一个设备号,设备号分为主设备号和次设备号两部分,关于设备号后面会详细讲解。
name:设备名字,指向一串字符串。
fops:结构体 file_operations 类型指针,指向设备的操作函数集合变量。
unregister_chrdev 函数用户注销字符设备,此函数有两个参数,这两个参数含义如下:
major:要注销的设备对应的主设备号。
name:要注销的设备对应的设备名。
一般字符设备的注册在驱动模块的入口函数xxx_init中进行,字符设备的注销在驱动模块的出口函数xxx_exit中进行。在上节示例代码中字符设备的注册和注销,内容如下所示:
1 | static struct file_operations test_fops; |
先定义一个 file_operations 结构体的变量 test_fops,就是设备的操作函数集合,只是此时我们还没有初始化 test_fops 中的 open、release 等这些成员变量,所以这个操作函数集合还是空的。然后调用函数 register_chrdev 注册字符设备,主设备号为 200,设备名字为“chrtest”, 设备操作函数集合就是第 1 行定义的 test_fops。要注意的一点就是,选择没有被使用的主设备号,输入命令“cat /proc/devices”可以查看当前已经被使用掉的设备号。
实现设备的具体操作函数
file_operations 结构体就是设备的具体操作函数,在上述代码中我们定义了 file_operations 结构体类型的变量 test_fops,但是还没对其进行初始化,也就是初始化其中的 open、release、read 和 write 等具体的设备操作函数。本小节我们就完成变量 test_fops 的初始化,设置好针对 chrtest 设备的操作函数。在初始化 test_fops 之前我们要分析一下需求,也就是要对 chrtest 这个设备进行哪些操作,只有确定了需求以后才知道我们应该实现哪些操作函数。假设对 chrtest 这个设备有如下两个要求:
1、能够对 chrtest 进行打开和关闭操作设备打开和关闭是最基本的要求,几乎所有的设备都得提供打开和关闭的功能。因此我们需要实现 file_operations 中的 open 和 release 这两个函数。
2、对 chrtest 进行读写操作 假设 chrtest 这个设备控制着一段缓冲区(内存),应用程序需要通过 read chrtest 的缓冲区进行读写操作。所以需要实现 file_operations 中的 read 和 write 这两个函数。
1 | // 打开设备 |
添加 LICENSE 和作者信息
最后我们需要在驱动中加入 LICENSE 信息和作者信息,其中 LICENSE 是必须添加的,否 则的话编译的时候会报错,作者信息可以添加也可以不添加。LICENSE 和作者信息的添加使用如下两个函数:
MODULE_LICENSE("GPL")
MODULE_AUTHOR("HCW")
编写程序
编写设备驱动
新建chrdevbase.c,写入以下内容
1 |
|
编写Makefile
1 | KERNELDIR := /home/hcw/learn/linux-6.1.161 |
参数 filp 有个叫做 private_data 的成员变量,private_data 是个 void 指针,一般在驱动中将 private_data 指向设备结构体,设备结构体会存放设备的一些属性。
chrdevbase read函数,应用程序调用read函数从设备中读取数据的时候此函数会执行。参数buf是用户空间的内存,读取到的数据存储在buf中,参数cnt是要读取的字节数,参数oft是相对于文件首地址的偏移。kerneldata里面保存着用户空间要读取的数据,先将kerneldata数组中的数据拷贝到读缓冲区readbuf 中,通过函数copy_to_user将readbuf 中的数据复制到参数buf中。因为内核空间不能直接操作用户空间的内存,因此需要借助copy_to_user函数来完成内核空间的数据到用户空间的复制。copy_to_user函数原型如下:
static inline long copy_to_user(void __user *to, const void *from, unsigned long n)
chrdevbase_write函数,应用程序调用write函数向设备写数据的时候此函数就会执行。参数buf就是应用程序要写入设备的数据,也是用户空间的内存,参数cnt是要写入的数据长度,参数offt是相对文件首地址的偏移。通过函数copy_from_user将 buf 中的数据复制到写缓冲区 writebuf 中,因为用户空间内存不能直接访问内核空间的内存,所以需要借助函数 copy_from_user 将用户空间的数据复制到writebuf这个内核空间中。
在 Linux 内核中没有 printf 这个函数。printk 相当于 printf 的孪生兄妹,printf 运行在用户态,printk 运行在内核态。在内核中想要向控制台输出或显示一些内容,必须使用 printk 这个函数。不同之处在于,printk 可以根据日志级别对消息进行分类,一共有 8 个消息级别,这 8 个消息级别定义在文件 include/linux/kern_levels.h 里面,定义如下:
#define KERN_SOH "\001"
#define KERN_EMERG KERN_SOH "0" /* 紧急事件,一般是内核崩溃 */
#define KERN_ALERT KERN_SOH "1" /* 必须立即采取行动 */
#define KERN_CRIT KERN_SOH "2" /* 临界条件,比如严重的软件或硬件错误*/
#define KERN_ERR KERN_SOH "3" /* 错误状态,一般设备驱动程序中使用 KERN_ERR 报告硬件错误 */
#define KERN_WARNING KERN_SOH "4"/* 警告信息,不会对系统造成严重影响 */
#define KERN_NOTICE KERN_SOH "5" /* 有必要进行提示的一些信息 */
#define KERN_INFO KERN_SOH "6" /* 提示性的信息 */
#define KERN_DEBUG KERN_SOH "7" /* 调试信息 */
printk(KERN_EMERG "gsmi: Log Shutdown Reason\n");
上述代码就是设置“gsmi: Log Shutdown Reason\n”这行消息的级别为 KERN_EMERG。在具体的消息前面加上 KERN_EMERG 就可以将这条消息的级别设置为 KERN_EMERG。
编写测试 APP
新建 chrdevtestapp.c,输入以下内容
1 |
|
编译后运行测试
加载驱动模块
驱动模块chrdevbase.ko和测试软件chrdevbaseAPP都已经准备好了,接下来就是运行测试。设置好以后启动 Linux 系统,检查开发板根文件系统中有没有“/lib/modules/6.1.161”这个 目录,如果没有的话自行创建。注意,“/lib/modules/4.1.15”这个目录用来存放驱动模块,使用 modprobe 命令加载驱动模块的时候,驱动模块要存放在此目录下。“/lib/modules”是通用的, 不管你用的什么板子、什么内核,这部分是一样的。不一样的是后面的“6.1.161”,这里要根据 你所使用的 Linux 内核版本来设置,比如开发板现在用的是 6.1.161 版本的 Linux 内核, 因此就是“/lib/modules/6.1.161”。如果你使用的其他版本内核,比如 5.14.31,那么就应该创建“/ lib/modules/5.14.31”目录,否则 modprobe 命令无法加载驱动模块。将刚刚编译好的驱动模块和可执行文件复制进此文件夹。
使用 insmod 命令加载驱动,然后输入 cat /proc/devices 命令检查有无 chrdevbase,可以看到有。
接下来创建设备节点文件,驱动加载成功需要在/dev 目录下创建一个与之对应的设备节点文件,应用程序就是通过操作这个设备节点文件来完成对具体设备的操作。输入如下命令创建 /dev/chrdevbase 这个设备节点文件:
mknod /dev/chrdevbase c 200 0
其中“mknod”是创建节点命令,“/dev/chrdevbase”是要创建的节点文件,“c”表示这是个字符设备,“200”是设备的主设备号,“0”是设备的次设备号。创建完成以后就会存在/dev/chrdevbase这个文件,可以使用“1s /dev/chrdevbase -1”命令查看,结果如下图

现在我们输入如下命令运行测试 APP
./chrdevtestapp /dev/chrdevbase 1
可以看到首先输出“kernel senddata ok!”这一行信息,这是驱动程序中 chrdevbase_read 函数输出的信息,因为 chrdevbaseAPP 使用 read 函数从 chrdevbase 设备读取数据,因此 chrdevbase_read 函数就会执行。chrdevbase_read 函数向 chrdevbaseAPP 发送“kernel data!”数据,chrdevbaseAPP 接收到以后就打印出来,“read data:kernel data!”就是 chrdevbaseAPP 打印出来的接收到的数据。说明对 chrdevbase 的读操作正常。
./chrdevtestapp /dev/chrdevbase 2
可以看到只有一行“kernel recevdata:usr data!”,这个是驱动程序中的chrdevbase_write 函数输出的。chrdevbaseAPP 使用 write 函数向 chrdevbase 设备写入数据 “usr data!”。 chrdevbase write 函数接收到以后将其打印出来。说明对chrdevbase的写操作正常,既然读写都没问题,说明我们编写的 chrdevbase 驱动是没有问题的。

接下来可以卸载驱动模块,输入如下指令
rmmod chrdevbase.ko
rmmod 只会卸载内核里的驱动代码,不会自动删除 /dev 里的设备节点,需要手动删除。
结语
至此,chrdevbase 这个设备的整个驱动就验证完成了,驱动工作正常,以后的字符设备驱动实验基本都以此为蓝本。