【Linux】内核学习笔记(九)——模块、调试和系统编程基础

本篇是《Linux内核学习笔记》系列的第九篇。

从现在开始,我们(其实是我)已经看够了源码,迫不及待地希望能够利用已经学到的东西大展身手。一边学一边敲代码其实是很重要的。本文会介绍模块这一概念,比较添加模块和直接向内核修改代码的异同,并介绍常用的调试技巧。

1. 模块

一个非常好的模块编程教程:https://sysprog21.github.io/lkmpg

1.1. 要不要用模块?

模块显然是有必要存在的。例如,有些代码有用,但注定不是通用代码。例如声卡、显卡等,它们有各种各样的硬件型号,其驱动程序也相当多样,若是将它们全部加入内核源码树中,其会变得极其庞大。但是,大部分计算机可能只用其中的一种或几种。这种情况下,设备驱动程序往往以模块的形式呈现。

还有一种情况,即像我这样的内核菜鸟想要写点小demo来训练基本的API、数据结构等。这样的代码没必要放在源码树,只是作为一个模块就够了。

不过有时的需求,模块注定不能完成。例如静态链接的代码,它已经位于RAM,这时就不能通过外部模块来将其替换。有不少核心组件是静态链接到内核的,例如伙伴系统等。

1.2. Hello模块

接下来的内容就是系统编程的Hello, world。

#include <linux/module.h>   // header for all modules
#include <linux/kernel.h>   // pr_info
#include <linux/init.h>     // about macros of init and exit

static int start(void)
{
    pr_info("Hello, world\n");
    return 0;
}

static void finish(void)
{
    pr_info("Goodbye, world\n");
}

module_init(start);
module_exit(finish);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("SuHaitao");
MODULE_DESCRIPTION("A Hello, World Module");

首先,需要包括一些头文件。这些文件的用途在上面给出。

然后,模块需要定义入口和出口,它们用module_initmodule_exit宏进行注册。初始化函数必须满足int func(void)的形式,退出函数必须满足void func(void)的形式,少一个void是不行的!初始化函数返回0表示模块正常加载,否则内核会给出报错信息(尽管初始化函数确实已经执行完了,模块已经被加载到了内核)。

最后三行用于给出模块的一些信息。特别要注意的是,MODULE_LICENSE给出了模块的许可证信息。如果一个模块有GPL许可证,它就是开源的,意味着不是一个二进制黑盒模块。反之它有可能是闭源的、不易调试的黑盒模块,内核开发者对其缺乏信任,认为这种模块会污染内核。因此,非GPL许可证的模块加入内核后,内核会设置一个污染标识。

下面写Makefile。

obj-m += hello.o

PWD := $(CURDIR)

all:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

首先,我们要向obj-m列表加入hello.o,与源代码文件同名。

make语句的含义是,先跳转至-C选项指示的路径下,然后make modules,最后移动到M参数的值指示的路径下。PWD一般来说就是当前目录,但这里给它赋以CURDIR的值,是防止使用sudo执行命令时PWD不能正确地继承环境变量值,详见这里的解释。

-C指示的是什么路径?其实就是内核源代码的位置,此处是一个软链接。当内核编译好后,使用sudo make modules_install就可以在/lib/modules建立一系列内核模块信息,该路径也就可以使用了。

写好Makefile之后,先不着急编译安装,我们来监听一些内核信息:

$ cat /dev/kmsg

好了,再打开一个Shell,然后编译安装:

$ make && sudo insmod hello.ko

编译会产生.ko文件,然后用insmod就能安装它。这时就会看到内核的输出:

6,1088,23460050244,-;Hello, world

6是信息等级,这里是info级,和pr_info对应。1088是信息的序列号。最后的一串数字是以微秒为单位的时间戳。

然后使用如下命令卸载模块:

$ sudo rmmod hello.ko

内核输出如下(使用cat /dev/kmsg查看):

6,1089,24988083413,-;Goodbye, world

2. 更复杂一点的模块

接下来我们会运用定时器实现一个热情的模块,它每秒钟都要说一次hello。一个很简单的思路是,在模块初始化函数中启动一个定时器,在定时器的回调函数中输出hello,然后重新启动定时器,从而形成无限循环。

第一步是声明定时器。我们把它定义成全局变量,让整个模块,包括回调函数的多次调用,都共用一个计时器。计时器起码得存活到回调函数结束为止,如果草率地定义在模块初始化函数中,add_timer之后就返回掉,那么真到执行回调函数的时候计时器已经不在了,内核就会崩溃。

static struct timer_list timer;

接下来在模块初始化函数中进行计时器初始化,并添加计时器:

timer_setup(&timer, hello, 0);
timer.expires = jiffies + 100;
add_timer(&timer);

hello是回调函数,每次调用它时,它会输出hello,并给内置的计数器加1(这里偷懒了,没有使用锁保护!它不是可重入的函数!)。输出完之后,重新添加计时器,这样一段时间之后hello又会被调用。

static void hello(struct timer_list* _timer)
{
    static int cnt = 0;
    printk(KERN_INFO "Hello %d times\n", cnt++);
    timer.expires = jiffies + 100;
    add_timer(&timer);
}

还不能忘了在模块清理函数中进行定时器的注销。模块可能在任何时候被卸载,此时定时器的指针还保留在队列中,如果没有将其注销,定时器和回调函数的指针都会悬空,等执行回调函数的时候就寄了。

del_timer_sync(&timer);

然后编译、安装、卸载模块,观察输出。

6,565,727893501,-;Hello, world 2
6,566,728962333,-;Hello 0 times
6,567,730002324,-;Hello 1 times
6,568,731042331,-;Hello 2 times
6,569,732082310,-;Hello 3 times
6,570,733122305,-;Hello 4 times
6,571,734162700,-;Hello 5 times
6,572,735202459,-;Hello 6 times
6,573,736242368,-;Hello 7 times
6,574,737282300,-;Hello 8 times
6,575,737935404,-;Goodbye, world 2

发表评论

您的电子邮箱地址不会被公开。