发新话题
打印

NetBSD LKM简单分析

NetBSD LKM简单分析

由于需要内核模块的相关知识,打算在Linux和BSD之中选取一个系统作为分析之用。翻了翻<Linux内核分析及编程>,觉得代码有点混乱(当然,我也不知道这本书是用的哪个Linux内核版本作为例子)。于是昨天花了一下午把NetBSD的LKM结构梳理了一下,发现LKM这个东西其实很简单。下面我把我整理的东西开放出来,供大家参考。

我不清楚NetBSD的LKM机制和FreeBSD、Linux之间的实现差异,希望有熟悉FreeBSD、Linux的朋友可以做出比较。

首先以一个实例说明LKM怎样使用,接着讲述LKM的实现机理,最后结合代码看看具体实现。

1 lkm的简单示例
我们首先以一个简单示例说明LKM怎样使用,所以这个例子越简单越好。因此我用sys/lkm/syscall/example中的例子作为讲解之用。(为了方便起见,本文把这个路径简称为$exam)

为了生成可供操作的二进制代码,可以在$exam目录中直接执行make(需要管理员权限,下面除特殊说明外,都是以管理员权限操作)。该目录中有 example_syscall.c 和lkminit_syscall.c 两个文件。Lkminit_syscall.c是LKM的入口,而example_syscall.c当然是我们期望的系统调用本身。先把 lkminit_syscall的实现细节放在一边,看看example_syscall.c中的内容:


[Copy to clipboard] [ - ]CODE:
Int example_syscall(struct lwp *l, void *uap, register_t retval[])
{
         printf( "I am a loaded system call using the kernel printf!\n");
         printf( "I will print this message each time I am called!\n");
         return (0);     /* success (or error code from errno.h)*/
}

简单可以认为,只要执行了我们期望的该系统调用,它就会在控制台上打印出两行话。系统调用本身又可以另开一篇讲解,因此这里不打算深入系统调用的实质。

Ok,看看LKM是否如我们所预期的那样工作:


QUOTE:
gvim# cd $exam
gvim# make   # make之后会将连接出一个名为syscall_example.o的目标文件
gvim# modload syscall_example.o # 注意最后的.o要一起提供

Sample Loaded system call
Copyright (c) 1993 Terrence R. Lambert
All rights reserved
Module loaded as ID 0

这个时候,模块就已经被插入内核,并且告诉你模块号是0。以上输出很有意思,如果你的控制台有彩色的话,可以看见下面的颜色。可见,前三句是内核的输出 (因此用彩色表示,该色彩可以在conf/GENERIC中调节),最后一句是输出在用户层,所以是常色显示。如果你现在不能猜到原因,我会在后面解释。






QUOTE:
gvim# cd $exam/test
gvim# cat Makefile

load:
……
modstat -n syscall_example
@./testsyscall
……  # 原来make load的作用是:用模块名作为参数,取得模块在内核中的详细参数
      # 然后直接调用已经生成的用户层例子来检验我们的系统调用。




QUOTE:
gvim# modstat -n syscall_example #我把Makefile的内容手动实现,便于讲解
                                 #modstat当然猜名字也知道是取得模块状态
Type    Id   Offset Loadaddr Size Info     Rev Module Name
SYSCALL   0     210 c6caf000 0004 c6caf1c0   2 syscall_example

modstat返回了syscall_example模块的内部表示:Type类型字段表明该模块是一个系统调用,Id号是0,系统调用号偏移是210 (下一段有简述),加载地址c6caf000(大于C000000,因此可以知道是内核地址,这里,你应该知道为什么前面的printf会产生颜色的原因了吧:因为printf是内核的printf,输出在内核控制台,而不是我们常用的lib中的printf),大小4K(一页),Info和Rev我没有去了解,Module Name就是我们插入模块的名称。


QUOTE:
gvim# ./testsyscall

Table offset as reported by modstat: 210

询问我们的系统调用号,也就是在内核系统调用表中的偏移号,我们将上面得到的210给它。于是在内核控制台,得到内核消息(也就是带颜色的输出):


QUOTE:
I am a loaded system call using the kernel printf!
I will print this message each time I am called!

通过上面的步骤,我们确定syscall_example系统调用确实可以运行。那么testsyscall怎么用的这个系统调用? testsyscall.c中的代码syscall(atoi(buf))告诉我们,它其实就是直接用syscall(210)来调用我们的系统调用。

说到系统调用号,简单看看系统调用号的分布。在sys/kern/syscalls.c的syscallnames数组中记载,找到209,210,211三个号,可以看出210到219一共10个号是预留给LKM的。而我们的例子中,使用的是210。

2 LKM的实现机理
LKM的全称是Loadable Kernel Module,也就是说在系统运行的时候,可以在内核空间里插入我们想执行的代码,以达到不用重启内核就扩充内核功能的目的。首先由Sun引入,具体可以参考man 4 lkm(或者assiss写的 初识NETBSD LKM )。通过上面的示例操作,知道syscall_example.o不过就是普通的目标文件,这个目标文件和未经连接的目标文件是一样的。系统可执行文件的知识告诉我们,在目标二进制代码需要成为可执行之前,要进行链接,以决断出目标文件.o中的未决符号引用。简单说来,普通的链接过程是和用户层的库连接,如- lm,-lkvm;而LKM的连接过程是和内核链接。链接过程简述如下:




一旦链接之后,内核地址空间中的这段代码就是实际可运行代码。接下来的问题是怎样才能使用这段代码?

NetBSD的解决办法是用设备的方式访问,即对/dev/lkm伪设备的操作。由于是对设备的操作,可以预见正如man 4 lkm中所说的那样,需要通过ioctl(2)接口。(FreeBSD、Linux是怎样操作的?)

3 实现细节
3.1 modload.c
现在用代码来详细说明LKM是如何运作的。内核部分代码主要分布在sys/kern/kern_lkm.c中,我们需要分析的是这份代码文件中的一部分。下面的分析需要可执行文件格式的知识,可以在google上找到不少说明,我假设大家都具备这个基本知识。一个很好用的工具是modload的-d参数,会打印不少有用信息。

我们从modload命令入手,剖析LKM。 modload命令的代码可以在/usr/src/sbin/modload中找到。依照main()的顺序分析,可以一窥究竟。解说顺序是先引用代码,然后在引用下面解释。


[Copy to clipboard] [ - ]CODE:
/usr/src/sbin/modload/modload.c
……
main()
{
        ……
p = strrchr(modout, '.');
                if (!p || strcmp(p, ".o"))
                        errx(2, "module object must end in .o");
                ……

上面我提醒说modload命令所插入的模块名,必须以.o结尾,这段代码就是作这个用。


[Copy to clipboard] [ - ]CODE:
       /*
         * Verify that the entry point for the module exists.
         */
        if (verify_entry(entry, modobj)) {
                /*
                 * Try <modobj>_init if entry is DFLT_ENTRY.
                 */
                if (strcmp(entry, DFLT_ENTRY) == 0) {
                        char *nentry;
                        if ((p = strrchr(modout, '/')))
                                p++;
                        else
                                p = modout;
                        asprintf(&nentry, "%s%s", p, DFLT_ENTRYEXT);
                        if (!nentry)
                                err(1, "malloc");
                        entry = nentry;
                        if (verify_entry(entry, modobj))
                                errx(1, "entry point _%s not found in %s",
                                    entry, modobj);

verify_entry (const char *entry, char *filename) 是一个在modload.c中实现的函数,主要作用是在模块filename中查找entry入口。在main的声明处,可以看见entry的初始值 const char *entry = DFLT_ENTRY; 而在modload.c文件中定义#define DFLT_ENTRY xxxinit ,同时,也可以看见DFLT_ENTRYEXT的定义 #define DFLT_ENTRYEXT  "_lkmentry"。因此,这段代码的作用是显而易见:在syscall_example.o文件中,查找是否存在名为xxxinit或者syscall_example_lkmentry的入口地址。该地址作为编译器的符号表而保留。那么,我们的 syscall_example例子中的入口地址是什么?在$exam/lkminit_syscall.c中我们找到int syscall_example_lkmentry __P((struct lkm_table *, int, int));的声明。可见示例代码syscall_example的入口符合LKM的使用规范。


[Copy to clipboard] [ - ]CODE:
        if (kname == NULL) {
                int fd = open(_PATH_KSYMS, O_RDONLY);
                if (fd < 0) {
                        warn("%s", _PATH_KSYMS);
                } else {
                        close(fd);
                        kname = _PATH_KSYMS;
                }
        }

接着这段代码的作用是打开参数_PATH_KSYMS 指定的设备。在/usr/src/include/paths.h中找到#define _PATH_KSYMS "/dev/ksyms"。也就是要求打开/dev/ksyms设备,该设备的作用是提供内核中的符号名及其地址。具体参看man 4 ksyms。


[Copy to clipboard] [ - ]CODE:
        if (kname == NULL) {
#ifdef CPU_BOOTED_KERNEL
                ……
                rc = sysctl(……);
                ……
#endif /* CPU_BOOTED_KERNEL */
                        kname = _PATH_UNIX;
        }

上面说过,syscall_example.o的未决符号的链接是和内核进行的,因此,这段代码作用是找到引导内核(如果不是用引导内核与.o做连接,那么执行时会出现core dump,详见man 4 lkm)。启动内核的默认情况是/netbsd(也就是_PATH_UNIX 所指的情况,在src/include/paths.h中定义#define _PATH_UNIX "/netbsd"),但是在系统引导阶段可以指定内核,所以用sysctl接口取得引导内核。(本文假设默认情况,即假设/netbsd)。


[Copy to clipboard] [ - ]CODE:
if (prelink(kname, entry, out, 0, modobj, ldscript))

这段代码很有意思,下面的代码还会用到。prelink是modload.c中实现的函数,作用是生成用于命令行的ld命令。去到prelink()中看看,其实是一个linkcmd(&cmd, kernel, entry, outfile, address, object, ldscript);的wrapper。linkcmd()是一个在/usr/src/sbin/modload/elf.c中实现的函数(这里还有 a.out格式,就不再讨论了),具体函数是elf_linkcmd(),一些asprintf(cmdp, LINKCMD…)函数按照LINKCMD的格式生成字符串。


QUOTE:
#define LINKCMD         "ld -R %s -e %s -o %s -Ttext %p %s"
#define LINKCMD2        "ld -R %s -e %s -o %s -Ttext %p -Tdata %p %s"
#define LINKSCRIPTCMD   "ld -T %s -R %s -e %s -o %s -Ttext %p %s"
#define LINKSCRIPTCMD2  "ld -T %s -R %s -e %s -o %s -Ttext %p -Tdata %p %s"

是asprintf生成的命令格式。现在我们用modload的-d参数装载syscall_example.o,可以看到下面的有趣情景:


QUOTE:
gvim# modload –d syscall_example.o
ld -R /dev/ksyms -e syscall_example_lkmentry -o syscall_example -Ttext 0x0 syscall_example.o
……

原来,这一次调用prelink之后构造出来的命令是ld -R /dev/ksyms -e syscall_example_lkmentry -o syscall_example -Ttext 0x0 syscall_example.o 。ld的详细参数请查阅man ld。


[Copy to clipboard] [ - ]CODE:
if (mod_sizes(modfd, &modsize, &strtablen, &resrv, &stb) != 0)

mod_sizes也是实现在/usr/src/sbin/modload/elf.c的函数,作用是取得.o中各段的偏移以及大小,分别放在 modsize,strtablen等参数变量中。这里用的手段和((struct *str)0->offset)一样,在结构体的例子中相对起始地址是0,同样,模块中的相对起始地址也是0。这在上面ld命令中的参数- Ttext 0x0可以知道。

同样由modload 的-d参数,观察到下面的信息:


QUOTE:
.text: addr = 0x0 size = 0xd8 align = 0x4
.shstrtab: addr = 0x0 size = 0x44 align = 0x1
.symtab: addr = 0x0 size = 0x2fec0 align = 0x4
.strtab: addr = 0x0 size = 0x2ad22 align = 0x1
.rodata: addr = 0xd8 size = 0xce align = 0x4
.data: addr = 0x11c0 size = 0x34 align = 0x20
.data section forced to offset 0x1c0 (was 0x11c0)

这些信息也可以用objdump查证。


[Copy to clipboard] [ - ]CODE:
if (ioctl(devfd, LMRESERV, &resrv) == -1)

这里操作/dev/lkm伪设备,在内核里分配一部分内存,作为存放代码只用。(后面再解释详情)。分配得到的内存,记录在参量resrv中。


[Copy to clipboard] [ - ]CODE:
if (prelink(kname, entry, out, (void *)resrv.addr, modobj, ldscript))
……
modentry = mod_load(modfd);

前面已经取得了各段的大小,取得了内存空间,现在,就用ioctl取得的内存地址作为偏移,重定位.o中的未决符号。mod_load在elf.c中实现,作用是通过/dev/lkm接口,用ioctl()进行拷贝,比较简单。

用modload –d观察如下:


QUOTE:
ld -R /dev/ksyms -e syscall_example_lkmentry -o syscall_example -Ttext 0xc6caf000 -Tdata 0xc6caf1c0 syscall_example.o
loading `.text': addr = 0xc6caf000, size = 0xd8
loading `.rodata': addr = 0xc6caf0d8, size = 0xce
loading `.data': addr = 0xc6caf1c0, size = 0x34
modentry = 0xc6caf000

由参数-Ttext 0xc6caf000我们知道,ioctl分配得到的内存起始地址是0xc6caf000。现在我们的syscall_example.o所包含的代码、数据就已经拷贝到内核空间中。由后面的printf("Module loaded as ID %d\n", resrv.slot);返回ID号,也就是”Module loaded as ID 0”这句输出。

到此为止,我们就完成了syscall_example.o在内核中的装载。

3.2 kern_lkm.c
要了解ioctl到底对/dev/lkm做了些什么,需要进入kern_lkm.c中查看。

在kern_lkm.c中有如下几行:


[Copy to clipboard] [ - ]CODE:
dev_type_open(lkmopen);
dev_type_close(lkmclose);
dev_type_ioctl(lkmioctl);

const struct cdevsw lkm_cdevsw = {
        lkmopen, lkmclose, noread, nowrite, lkmioctl,
        nostop, notty, nopoll, nommap, nokqfilter, D_OTHER,
};

简单说来就是设备操作的入口,lkmopen、lkmclose和lkmioctl分别对应打开,关闭,和上面提到的ioctl操作。Lkm{open, close}都比较简单,lkmopen()的功能集中在避免相同模块多次初始化,lkmclose主要是收回之前已经分配的内存。

lkmioctl()是一个比较复杂的函数,我们只拣相关片断,其余的比较容易举一反三。


[Copy to clipboard] [ - ]CODE:
case LMRESERV:
……
case LMLOADBUF:
……
case LMREADY:
……

lkmioctl()的LMRESERV命令在前面提及过,该命令由modload.c中的ioctl(devfd, LMRESERV, &resrv)一句发出。作用是分配内核内存空间。这条命令中主要流程是 a) curp = lkmalloc();分配一个表示lkm自身的数据结构,b) 接着curp->area = LKM_SPACE_ALLOC(curp->size, 1);分配curp->size大小的空间。c) resrvp->addr = curp->area;也就是我们上面得到的起始地址0xc6caf000。

LKM_SPACE_ALLOC在kern_lkm.c最开始处定义:


[Copy to clipboard] [ - ]CODE:
#define LKM_SPACE_ALLOC(size, exec) \
        uvm_km_alloc(lkm_map, (size), 0, \
                UVM_KMF_WIRED | ((exec) ? UVM_KMF_EXEC : 0))

可以看见内存是在内核区分配的,是不可交换的(wired),并且是可执行段(UVM_KMF_EXEC)。

LMLOADBUF命令也提到过,在modload.c的loadbuf()函数中,用来将数据从用户区缓冲区加载到内核缓冲区。LMLOADBUF命令的主要作用在error = copyin(loadbufp->data, (caddr_t)curp->area + curp->offset, i);这句上。Copyin的分析可以在google中找到。

LMREADY命令在modload.c的最后,加载完之后是否继续执行则由modload 的-n参数保护(详见man modload)。LMREADY命令的主要作用在error = (*(curp->entry))(curp, LKM_E_LOAD, LKM_VERSION);这句上,调用一次load函数,本例中为lkminit_syscall.c中的syscall_load()。
lkmioctl()的其余命令都相对简单,就不再详诉。

3.3 lkminit_syscall.c
要完整的使用LKM还需要用户定义的入口,也就是xxxinit()或者modulename_lkmentry()。在本例中,syscall_example_lkmentry()在lkminit_syscall.c中实现:


[Copy to clipboard] [ - ]CODE:
MOD_SYSCALL( "syscall_example", -1, &newent)
……
int     syscall_example_lkmentry __P((struct lkm_table *, int, int));
syscall_example_lkmentry(struct lkm_table *lkmtp, int cmd, int ver)
{
        DISPATCH(lkmtp,cmd,ver,syscall_load,lkm_nofunc,lkm_nofunc)
}

按照syscall_example_lkmentry()上的注释,入口函数中应当只包含DISPATCH这一个函数。

MOD_SYSCALL声明一个对应的数据结构,说明该模块是一个系统调用,模块名称是”syscall_example”,新的系统调用入口是”newent”。结构sysent的定义可以在sys/kern/init_sysent.c中找到。

LKM_DISPATCH宏(DISPATCH宏是它的一个wrapper)的定义在sys/sys/lkm.h 中 #define LKM_DISPATCH(lkmtp, cmd, envdep, load, unload, stat)。


[Copy to clipboard] [ - ]CODE:
#define LKM_DISPATCH(lkmtp, cmd, envdep, load, unload, stat)\

case LKM_E_LOAD:         \

case LKM_E_UNLOAD:        \

case LKM_E_STAT:\


分别执行宏变量load,unload,stat传递的函数,本例中为syscall_load(),lkm_nofunc(),lkm_nofunc(),然后将cmd命令传递到lkmdispatch()中。

lkmdispatch()在kern_lwp.c中。判断模块的类型,然后执行模块的命令:


[Copy to clipboard] [ - ]CODE:
switch(lkmtp->private.lkm_any->lkm_type) {
        case LM_SYSCALL:
                error = _lkm_syscall(lkmtp, cmd);
                break;
                …
        case LM_VFS:
        …
}

本例中是LM_SYSCALL,因此调用_lkm_syscall()。


[Copy to clipboard] [ - ]CODE:
_lkm_syscall(struct lkm_table *lkmtp, int cmd)
{
……
        switch(cmd) {
        case LKM_E_LOAD:
            ……
            memcpy(&args->lkm_oldent, &sysent[ i], sizeof(struct sysent));
            /* replace with new */
            memcpy(&sysent[ i], args->lkm_sysent, sizeof(struct sysent));
            /* done! */
            args->mod.lkm_offset = i;       /* slot in sysent[] */
        case LKM_E_UNLOAD:
            ……
            memcpy(&sysent, &args->lkm_oldent, sizeof(struct sysent));
            ……
}

在第一次加载模块时,关键作用在这两个memcpy上。这两句的作用是替换i号系统调用的函数指针,指向我们提供的地址。在本例中,也就是将i=210的系统调用号所指地址替换成example_syscall()的地址(虽然在系统里210号是预留给LKM的)。最后返回lkm_offset,这也就是我们用modstat取得的系统调用号(即相对于系统调用表的偏移)。而在UNLOAD的时候进行恢复。

到此,基本向大家阐明了LKM机制的过程。

TOP

发新话题