Linux内核模块介绍
- 系统版本为:Ubuntu 18.04.1 LTS
- 内核版本为:4.15.0-43-generic
为什么Linux系统会提供模块?
随着OS的演化,功能越来越多,就会有一个问题:Linux本身是一个单内核,它的效率高但是它的可扩展性和可维护性差,如果把所有的功能都放到OS的内核中,那么内核就会非常庞大并且笨拙,如果不放在内核中,那么这些功能要么不能运行,要么运行非常缓慢。
所以聪明的工程师们就发明了模块,来解决这个问题。
但是内核模块也是有缺点的,首先,加载卸载需要时间,会降低系统性能;模块需要管理结构和额外的代码,因此需要更多的内存;模块只能间接地访问内核资源,所以效率较低;最重要的是模块一旦被加载,就成了内核的一部分,就有可能使内核崩溃,也有安全隐患;最后还会有版本问题。
在开始模块的插入之前,我们需要先了解一些基本的内容,就是我们和Linux交互的方式,在用户态我们都是直接printf打印出来就好了,但是在Linux中,我们都是通过日志信息来了解系统状况的,Linux有专门的文件系统来给用户提供信息(/proc,之后会有专门的文章来介绍),在这里我们仅简单介绍一下。
1 从printf到printk
我们都知道,printf可以帮助我们在屏幕上打印出文字信息,但是这仅仅是在linux的用户态中使用的,不幸的是在内核中不能使用printf了。当linux运行起来以后,内核本身就会记录一些信息,并提供整个操作系统的状态信息,printk是最常见的linux内核向终端用户传递消息的方式。
printk和printf的区别就是printk可以指定消息的打印级别,内核根据这个级别来决定是否将消息打印到终端上:
内核使用printk()而不是printf()的原因是内核没有链接标准C函数库。但其实printk和printf函数的接口完全一样,可以在控制台显示1024个字符。
printk函数在运行的时候,首先设法获取控制台信号量,然后将要输出的字符存入控制台的日志缓冲区,再调用控制台驱动程序来刷新缓冲区。如果控制台无法获取控制台信号量,就只能把要输出的字符存储到日志缓冲区,并依赖拥有控制台信号量的进程来刷新这个缓冲区。注意在printk存储任何数据到日志缓冲区之前,必须使用日志缓冲区锁,这样才能保证并发调用printk时不会相互影响。如果已经获得了控制台信号量,那么刷新日志缓冲区之前,可以多次调用printk()函数,所以,printk不能来表明任何程序的运行时间。
2 dmesg
首先,linux内核有多种方式可以用于存储日志和信息。linux内核通过klogd()发送消息,并给它标上适当的警告级。所有级别的消息都存储在/proc/kmsg中。
dmesg是一个命令行工具,可以用于显示存储在/proc/kmsg中的缓冲区内容,并能够根据消息级别来选择是否要过滤这个缓冲区。
3 /var/log/meaasga
linux系统的/var/log/message下存储的是大多数已经记录的系统的信息。对于某些存储了已接收消息的特定位置,可以借助syslogd程序来读取/etc/syslogd.conf的内容。根据syslogd.conf中项的不同,日志信息可以存储到许多不同的文件中去,但是该文件在不同版本的linux系统中有所不同,不过一般是在/var/log/meaasga下。
4 编写模块
了解了以上内容之后,我们就可以来编写内核模块了,和每种编程语言类似,内核模块的编写也是有基本的框架的:c文件+Makefile文件。
C文件
我们在自己的文件夹下新建c文件,然后编写代码。
直接来看代码,我将所有需要知道的东西都写在注释中了:
# include <linux/module.h> //对应使用module_init/module_exit,所有模块都要使用,这个文件必须被包含进来
# include <linux/kernel.h> //包含常用的内核函数
# include <linux/init.h> //包含了宏_init和_exit,告诉编译程序相关的函数和变量仅用于初始化,编译程序将标有_init的所有代码存储到特殊的内存段中,初始化结束后就释放这段内存
/*
* 模块的初始化函数lkp_init():向内核注册模块提供新功能
* __init是用于初始化的修饰符
* printk()函数的使用:由内核定义,它把要打印的东西输出到终端或者日志里去,
* */
static int __init lkp_init(void)
{
printk("hello world\n");
return 0;
}
/*
*模块的退出和清理函数lkp_exit():注销由模块提供所有的功能
* */
static void __exit lkp_exit(void)
{
printk("<good googbye\n");
}
//模块初始化的入口点,对于内置模块,内核在引导时调用该入口点;对于可加载模块则在该模块插入内核时才调用。
module_init(lkp_init);
//对于可加载模块,内核在此处调用cleanup_module函数,而对于内置的模块,它没有什么用。
module_exit(lkp_exit);
/*
* 模块的许可证声明GPL,提示可能没有GNU公共许可证
* */
MODULE_LICENSE("GPL");
Makefile文件
注意,Makefile文件的M必须是大写,而且得和你要编译的c文件放在同一个目录。
接下来是代码:
obj-m:=first.o
#这一行的-C的作用是告诉make程序读取Makefile或者做其他事情之前,先要改变Linux的源目录。
make -C /usr/src/linux-2.6.7 SUBDIRS=$PWD modules
- 上面的obj-m :=这个赋值语句,意思是要使用目标文件first.o建立一个模块,最后生成的模块名是first.ko
- 注意,Makefile的编写有一套专用的语法,如果有兴趣的话,可以去学习下。这里仅仅是简单的应用
来看这段代码,我们可以发现我们每次需要编译文件的时候,都需要确定一下我们自己的Linux系统中内核的版本,首先,查看内核的版本在终端中使用uname -r命令即可,但是我们并不需要这样子写,可以写成如下形式,移植性很强,以后只需要复制这个文档修改目标文件.o的名字即可使用:
#Makefile文件注意:假如前面的.c文件起名为first.c,那么这里的Makefile文件中的.o文件就要起名为first.o 只有root用户才能加载和卸载模块
obj-m:=first.o #产生first模块的目标文件
#目标文件 文件 要与模块名字相同
CURRENT_PATH:=$(shell pwd) #模块所在的当前路径
LINUX_KERNEL:=$(shell uname -r) #linux内核代码的当前版本
LINUX_KERNEL_PATH:=/usr/src/linux-headers-$(LINUX_KERNEL)
all:
make -C $(LINUX_KERNEL_PATH) M=$(CURRENT_PATH) modules #编译模块
#[Tab] 内核的路径 当前目录编译完放哪 表明编译的是内核模块
clean:
make -C $(LINUX_KERNEL_PATH) M=$(CURRENT_PATH) clean #清理模块
插入/检查/删除模块
在编写好c文件和Makefile文件之后,在终端中使用make命令,即可编译。编译后会生成一个.ko文件,这个就是模块。
在Linux中,插入删除模块要有超级用户权限,所以要在命令前加上sudo。插入模块:
sudo insmod first.ko
之后可以查看模块是否正确插入了:
lsmod
之后使用dmesg命令查看日志信息:
dmesg
删除模块只用写模块名字即可,不用写扩展名
sudo rmmod first