Kernel Linux学习——驱动编写
2020-08-08 14:46:20 hawkJW
前面已经完成了Kernel Linux的环境配置部分,下面我们就熟悉一下Kernel Linux重要的部分,即驱动的编写
总体分析
实际上,Linux系统分为内核态和用户态。其中,只有内核态才能访问到硬件设备,而驱动可以当作从内核态中提供出来的API,供用户态的代码访问到硬件设备上。
因为,我们这篇文档主要简单介绍一下驱动的例子,并且说明一下驱动的工作原理。
简单驱动模块介绍
根据一些惯例,我们实现一个最简单的内核模块——HelloWorld模块,其功能也非常简单:加载模块和卸载模块的时候输出相关的提示信息即可。
其中和普通的计算机方面知识的学习一样,我们首先给出例子,并且模仿例子中的规定,然后熟悉后可以自己探索为什么,其中该模块的源代码如下所示
#include#include MODULE_LICENSE("GPL"); MODULE_AUTHOR("hawk"); int helloInit(void) { printk(KERN_INFO "Hello, world\n"); return 0; } void helloExit(void) { printk(KERN_INFO "Goodbye, world\n"); } module_init(helloInit); module_exit(helloExit);
可以看到,Linux下驱动仍然使用C语言进行开发,但是由于驱动主要运行在内核中,而内核中不存在libc库,因此使用内核中的库函数——比如printk,可以看到,其和glibc中的printf函数名称十分相似,实际上,这两个函数的功能也很相近。
对于这个驱动源代码来说,可以看到其包含有一些MODULE_*的函数,这个基本上是一些描述性质的字符串,对于驱动运行来说,仅仅是一个可选项——有了这些可以让其他人对于这个模块有更好地了解,但是没有并不影响运行。而其中module_*函数就和模块的运行息息相关了。
我们思考一个问题——由于编写模块管理机制的人并不知道之后会添加什么模块,但是其还需要通过一些机制调用添加的模块,因此一个合理的机制就是通过预先定义一个入口函数和一个出口函数——类似于正常C语言的main,当我要使用模块时,直接调用预先定义的入口函数,当要卸载模块时,调用出口函数就可以。而这里,明显的,module_init就是入口函数:当内核加载这个模块时,就调用这个helloInit入口函数即可;而module_exit就是出口函数:当内核卸载这个模块时,就调用这个helloExit模块即可。
我们如果有一些计算机的基础的话,应该知道,要想要程序可以执行,单纯的源代码计算机的CPU是不认识的,我们需要进行编译,最后生成可执行代码。这里对于驱动的编译通常使用make命令进行编译,其指导文件Makefile内容如下所示
ifneq ($(KERNELRELEASE),) obj-m := hello.o else KDIR ?= /home/hawk/Desktop/linux PWD := $(shell pwd) default: $(MAKE) -C $(KDIR) M=$(PWD) modules endif clean: rm -rf *.o *.ko *.order *.symvers *.mod.c
其中,KDIR表示Kernel Linux源代码的目录,在我的环境中,我将下载的源代码放入了/home/hawk/Desktop/linux。M表示其是make编译时的变量,具体作用可以自己在进行查询。因此具体到我的环境中,其Makefile内容如下所示
ifneq ($(KERNELRELEASE),) obj-m := driver.o else KDIR ?= /home/hawk/Desktop/linux PWD := $(shell pwd) default: $(MAKE) -C $(KDIR) M=$(PWD) modules endif clean: rm -rf *.o *.ko *.order *.symvers *.mod.c
需要特殊说明一下,Makefile的格式要求比较严格,如果执行命令错误的话,可以看一下tab格式是否正确。然后我们执行如下命令进行编译
make
最后在当前目录下,会生成对应的驱动文件*.ko文件,这就是最后编译出来的驱动文件。然后我们将其放入我们前面所说的根文件系统中,准备进入启动Linux系统后分别进行载入和卸载,命令如下所示
mv hello.ko /path/to/rootfs make clean cd /path/to/rootfs find . | cpio -o --format=newc > ../rootfs.img
然后通过qemu启动Kernel Linux,然后在其根目录下可以看到对应的文件,如图所示
然后我们将该驱动装载入Linux内核中,命令如下所示
insmod hello.ko
其命令的结果如图所示
可以看到,其输出了Hello, world字符字样,和我们之间分析的一样;然后我们观察一下当前Linux内核中的相关驱动,命令如下所示
lsmod
其命令的结果如图所示
可以看到,确实正常加载了hello驱动,并且内核中目前只有这一个驱动(因为这仅仅是Linux内核,没有安装其他相关的配置)。最后,我们尝试从Linux内核中卸载该模块,命令如下所示
rmmod
其命令的结果如图所示
可以看到,其输出了Goodbye, world,和我们之前分析的一样,其成功从Linux内核中卸载了该驱动。这样,我们就完成了一个简单的驱动样例。
实际上,正如我们在前面分析到的一样,驱动实际上是用来操作设备的,因此我们前面给到的驱动的样例,实际上是一个驱动,但实际上却并没有牵扯到相关的设备,下面我们就在举一个虚拟字符设备驱动相关的例子——一个简单的char设备驱动,其内容如下所示
#include#include #include #include static struct cdev chr_dev; static dev_t ndev; static int chr_open(struct inode* nd, struct file* filp) { int major, minor; major = MAJOR(nd->i_rdev); minor = MINOR(nd->i_rdev); printk("chr_open, major = %d, minor = %d\n", major, minor); return 0; } static ssize_t chr_read(struct file* filp, char __user* u, size_t sz, loff_t *off) { printk("chr_read process!\n"); return 0; } struct file_operations chr_ops = { .owner = THIS_MODULE, .open = chr_open, .read = chr_read }; static int helloInit(void) { int ret; cdev_init(&chr_dev, &chr_ops); ret = alloc_chrdev_region(&ndev, 0, 1, "hawkDev"); if(ret < 0) {return ret;} printk("helloInit(): major = %d, minor = %d\n", MAJOR(ndev), MINOR(ndev)); ret = cdev_add(&chr_dev, ndev, 1); if(ret < 0) {return ret;} return 0; } static void helloExit(void) { printk("helloExit process!\n"); cdev_del(&chr_dev); unregister_chrdev_region(ndev, 1); } module_init(helloInit); module_exit(helloExit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("hawk");
可能代码看起来稍微有一些多,但是实际上大部分都是代码细节,主要的思路和前面介绍的一样。设备号分为主设备号和次设备号——其中主设备号和驱动一一对应,而次设备号用来区分使用相同主设备号的设备。这里alloc_chrdev_region方法用来申请一个动态主设备号以及一系列次设备号,其中最后一个参数表示该主设备号,也就是一一对应的驱动名称·;而申请到后还不能使用,我们需要将其注册入Linux内核中,也就是通过cdev_add方法最终完成设备的配置。如果还是不明白,不要紧,我认为就和一开始学C语言一样,为什么要#include
其余对于驱动编译和加载的方法和上面的例子是一样的,我们把命令列出来即可
insmod hello.ko
然后结果如图所示
可以看到,其应该确实注册了相关的设备。下面我们前往/dev目录下构建该虚拟设备,根据其major和minor进行构建即可,命令如下所示
mknod /dev/hawkDev c 248 0
其中,这里设备名称应该和上面驱动中申请注册的设备名称一样的,结果如图所示
然后我们尝试对该设备进行读操作,结果如图所示
可以看到,对于该设备的打开、读操作都使用了我们在驱动中缩写的功能,也就是我们确实通过驱动操作了该虚拟设备。