嵌入式Linux中内核模块的基本框架
在Linux系统中,驱动程序属于内核态程序,可以认为它是介于操作系统和硬件实体之间的一层,对上负责与操作系统交流,对下负责控制硬件设备。 即,驱动程序对操作系统通过软件接口进行沟通,对芯片硬件通过读写寄存器进行控制。Linux系统的驱动由内核模块(Loadable Kernel Module,简称LKM)的形式来呈现,它实现了一种在内核运行期间动态加载一组目标代码来实现某个特定功能的机制。
模块是具有独立功能的程序,它可以被单独编译,但不能独立运行,在运行时它被链接到内核作为内核的一部分在内核空间运行,这与运行在用户空间的进程是不一样的。模块由一组函数和数据结构组成,用来实现一种文件系统、一个驱动程序和其他内核上层功能。内核模块经过编译,最终形成以.ko为后缀的ELF(Excutable And Linking Format)格式文件。
ELF是一种普通的可重定位目标文件,其中包含了代码和数据,可以用来链接成为可执行文件、共享目标文件或静态链接库文件等。内核模块文件通过insmod命令插入到内核中,通过rmmod命令移除。
下面看一个简单的内核模块程序示例,其功能为,当把它插入内核时,会在终端打印一句Hello World;当把它从内核移除时,会打印一句GoodBye。虽然简单,但它却是内核态程序的一个基本框架,可在野火STM32MP157开发板上实现。下面的代码保存为一个名为hello.c的文件。
#include <linux/module.h> #include <linux/init.h> #include <linux/kernel.h> static int __init hello_init(void) { printk(KERN_EMERG"Hello World!\n"); return 0; } static void __exit hello_exit(void) { printk("Goodbye!\n"); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Delphi"); MODULE_ALIAS("Alias"); MODULE_DESCRIPTION("Hello World Module");
内核模块属于内核态程序,即它是Linux内核的一部分,所以应该把它放入到Linux内核中去编译,因此,一方面需要提供目标系统的Linux源码(此处使用野火STM32MP157开发板的内核源码),另一方面,还需要写一个配套的Makefile文件参与编译,其内容如下。
KERNEL_DIR=/opt/ebf_linux_kernel_mp157_depth1/build_image/build/ ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- export ARCH CROSS_COMPILE obj-m := hello.o all: $(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) modules
上面第一行中的KERNEL_DIR即为指向内核源码的目录。注意最后一行要以Tab键起头,因为它是一条命令。执行make命令进行编译,完成后会在当前目录下产生出很多文件,找到一个名为hello.ko的文件,即为需要的内核模块文件。把该文件拷贝到NFS共享目录下,在开发板的挂载目录找到它,并执行insmod hello.ko,会在终端上打印出一句Hello World!。再执行lsmod命令查看一下当前插入的内核模块,会发现有hello的名称。最后执行rmmod hello,会在终端上打印出一句GoodBye!。全部过程如下图所示。
上图中先使用命令modinfo查看了hello.ko文件的信息,其中可见模块的用途说明,别名,作者,所遵循的协议等信息。接下来使用file命令查看了该文件的属性,从中可以看出,hello.ko文件属于32位的ELF格式文件,为ARM格式文件。
下面就来详细解释一下以上内核模块代码的工作原理。
首先是包含内核模块所需要的头文件,他们位于Linux内核源码的include目录下。其中:
#include <linux/module.h>:包含内核模块信息声明的相关函数及宏定义,如MODULE_LICENSE()等。
#include <linux/init.h>:包含了module_init()和module_exit()函数的声明。
#include <linux/kernel.h>:包含内核提供的各种函数,如printk()等。
前面说过,内核模块属于Linux内核的一部分,当把它动态地插入到正在运行的Linux内核中去时,Linux系统就增加了该模块提供的某种功能,当Linux内核不再需要它时,又可以动态地从正在运行的Linux内核中移除,此时Linux系统就散失了模块提供的功能。这就是Linux的内核模块机制,在插入内核模块时,使用Linux提供的insmod命令,移除时使用rmmod命令。同时,在Linux系统中,模块之间可能存在一些依赖,即在插入模块A时,可能还会需要模块B的支持。为了解决这些依赖问题,Linux还提供了modprobe命令来代替insmod命令进行插入操作,modprobe是一个处理层叠模块的工具,它相当于多次执行了insmod命令,只不过在执行它之前,需要先执行depmod命令进行模块间依赖关系的建立。
内核模块程序其本身并不会执行,在它内部一般都会定义一个入口函数(如上例中的hello_init函数)和一个出口函数(如上例中的hello_exit函数),通过某种条件来触发这些函数执行。为了指定触发的条件,Linux系统中规定,当执行模块插入命令(insmod或modprobe)时,会触发一个名为module_init的宏。而当执行移除命令(rmmod)时,会触发一个名为module_exit的宏。在这两个宏中,可以指定触发后要执行的函数。对比来看,上面内核模块代码中的module_init(hello_init)一句,就指明了当Linux执行insmod(或modprobe)命令时,会去调用hello_init函数,所以一般称其为入口函数。同样,上面内核模块代码中的module_exit(hello_exit)一句,就指明了当Linux执行rmmod命令时,会去调用hello_exit函数,所以一般称其为出口函数。
明白了这一层,再来看上面的内核模块代码就清爽多了。在执行insmod命令时要打印Hello world,只需要把打印语句放在入口函数中就可以了。同样,在执行rmmod命令时要打印GoodBye,只需要把打印语句放在出口函数中就可以了。只不过,所定义的入口函数和出口函数还需要专门“修饰”一下,此外,打印语句也要换成printk。
由于内核模块的代码是内核代码的一部分,如果在内核模块中定义的函数名称和内核源代码中的某个函数重复了,那在编译时就会因冲突而报错。因此,一般会给内核模块中的函数加上static的前缀进行修饰,以指明该函数的作用域被限制在本模块中,这样就可以有效避免名称冲突而产生错误。
此外,函数中带有__init的修饰符,表示该函数将被放到ELF文件的__init节区中,该节区的内容只适用于模块的初始化阶段,初始化阶段执行完毕之后,这部分内容就会从内存中释放掉。同样,函数中带有__exit的修饰符,表示将该函数放在ELF文件的__exit节区,当执行完模块卸载阶段之后,就会自动释放该区域的内存空间。一般地,__init和__exit用于修饰函数,__initdata和__exitdata用于修饰变量,这部分定义位于内核源码/linux下的init.h头文件中。被__init修饰过的入口函数在内核模块中做与初始化相关的工作,返回值为0表示模块初始化成功,并在/sys/module目录下会自动建立一个以模块名为名的目录(见上面的图片),返回值为非0时表示模块初始化失败。而被__exit修饰过的出口函数的返回值则是void类型,即不需要返回。
上面的代码中,在入口函数和出口函数中都调用了printk函数。由于在内核模块运行的过程不依赖于C库支持,所以用不了printf函数,因此在需要使用单独的内核打印输出时,换成了printk函数,它的用法与printf函数类似,只不过加入了打印的优先级别。查看当前系统printk打印等级可在开发板上执行cat /proc/sys/kernel/printk,从左到右依次对应当前控制台日志级别、默认消息日志级别、最小的控制台级别、默认控制台日志级别。
最后,内核模块程序还必须声明本模块所遵循的许可证协议,这里设置为GPL协议。除此以外,内核模块许可证还有“GPL v2”,“GPL and additional rights”,“Dual SD/GPL”,“DualMPL/GPL”,“Proprietary”等。另外,还可以在模块中加入作者、别名、用途等信息(非必要)。特别注意一点,如果要给模块起别名(如代码中的MODULE_ALIAS(“Alias”);),在使用该模块的别名时,需要将该模块复制到/lib/modules/内核源码/目录下(本例为/lib/modules/4.19.94-stm-r1/),并使用depmod命令更新模块的依赖关系方才可以。