5. 内核模块

关于内核模块的概念,在上个章节中 《Linux驱动编译方法》 中就有提及。

5.1. 内核模块的基本概念和特点

内核模块全称Loadable Kernel Module(LKM),是一种在内核运行时加载一组目标代码来实现某个特定功能的机制。

模块是具有独立功能的程序,它可以被单独编译,但不能独立运行, 在运行时它被链接到内核作为内核的一部分在内核空间运行,这与运行在用户空间的进程是不一样的。

模块由一组函数和数据结构组成,用来实现一种文件系统、一个驱动程序和其他内核上层功能。内核模块具备如下特点:

  • 模块本身不被编译入内核映像,这控制了内核的大小

  • 模块一旦被加载,它就和内核中的其它部分完全一样

5.2. 内核模块常用命令

在讲内核模块的编译、加载/卸载内容之前,先介绍一下内核模块常用命令

5.2.1. lsmod命令

查看目前内核加载了多少模块,包含名称、大小、是否被其他模块所用

5.2.2. insmod命令

insmod命令(英文全拼:install module)用于将给定的模块加载到内核中。 关于该命令更多内容,可参考 《Linux insmod 命令》

格式: insmod 模块完整路径名
示例: insmod /helloworld/helloworld.ko

5.2.3. rmmod命令

rmmod命令用于将已加载模块从内核中移除。

格式: rmmod [选项] 模块名
示例: rmmod helloworld.ko

选项

  • -v:显示指令执行的详细信息;

  • -f:强制移除模块,不论是否正在被使用,使用此选项比较危险;

  • -s:向系统日志(syslog)发送错误信息。

5.2.4. dmesg命令

用于查看内核打印的数据。

#查看内核打印的所有信息
sudo dmesg
#查看包含xxx内容的内核打印信息
sudo dmesg | grep xxx

5.2.5. modprobe命令

自动加载所需的模块依赖,并加载一个或多个模块。想了解更多可参考 《Linux modprobe命令》。这里不作详细介绍

5.2.6. depmod命令

depmod命令更新模块依赖信息。通常在安装新模块或更新内核后运行 depmod -a 来更新所有模块的依赖关系。想了解更多可参考 《Linux depmod命令》。这里不作详细介绍

5.2.7. modinfo命令

显示模块的详细信息,包括它的作者、版权、依赖项、符号等。想了解更多可参考 《Linux modinfo命令》

5.2.8. kmod命令

内核模块管理工具,可以用于加载、卸载、刷新和列出模块。它是 insmod、rmmod、modprobe 和 lsmod 的现代替代品。kmod 构建在 libkmod 库之上,这个库提供了内核模块管理的功能。kmod 命令通常在 Linux 发行版中预装,或者可以通过包管理器安装。

注意

kmod 工具集的实际命令可能会根据您的系统和安装的 kmod 版本而有所不同。在大多数情况下,kmod 会接管传统的内核模块管理命令,因此您可以直接使用 insmod, modprobe, lsmod 等命令,它们通常会通过别名指向 kmod 对应的函数。

5.3. 内核模块的编译、加载和卸载

在上一章节我们已经写了一个最简单的 helloworld驱动,那么这个章节将来学习怎么将我们写的驱动编译成驱动文件,并加载到内核中。

5.3.1. 内核模块的编译

首先分析一下Makefile文件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
KERNEL_DIR=../../kernel/
# KERNEL_DIR的路径得是内核源码路径

#声明编译的架构为arm64,编译器前缀为aarch64-linux-gnu-
ARCH=arm64
CROSS_COMPILE=aarch64-linux-gnu-
export  ARCH  CROSS_COMPILE
#obj-m:编译成模块
obj-m := helloworld.o
all:
	$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) modules

#伪目标
.PHONE:clean
#指当make命令后紧跟clean时(即make clean),执行以下伪目标clean对应的指令
clean:
	$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) clean

注解

  • 第1行: 指定内核目录,根据自己编译内核时指定的输出目录,可以相对路径或者绝对路径,如果编译内核时没有指定特定输出目录,那么就将这个变量指定到内核源码的根目录。

  • 第5行: arm64体系结构。

  • 第6行: 指定交叉编译工具链。

  • 第7行: 导出环境变量。

  • 第9行: 表示以模块进行编译。

  • 第10行: all只是个标号,可以自行定义,是make的默认执行目标。

  • 第11行: 调用make编译时执行的语句

  • 第14行: clean 就是删除后面这些由make生成的文件。

对11行语句单独分析:

$(MAKE) -C $(KERNEL_DIR) M=$(CURDIR) modules
  • $(MAKE) :MAKE是Makefile中的宏变量,要引用宏变量要使用符号。这里实际上就是指向make程序,所以这里也可以把$(MAKE)换成make。

  • make -C $(KERNEL_DIR):是make命令的一个选项,-C作用是changedirectory,-C $(KERNEL_DIR)指明跳转到内核源码目录下去执行那里的Makefile。

  • M=$(CURDIR):返回当前目录。

  • modules:make modules指单独编译模块

  • 这句话的意思是:当make执行默认的目标all时,-C $(KERNEL_DIR)指明跳转到内核源码目录下去执行那里的Makefile,M=$(CURDIR)表示又返回到当前目录来执行当前的Makefile。

注意

编译内核模块前一定要确认内核是否成功编译,否则会报错。

下面开始编译:

cd linux_driver/helloworld/
make

新增的helloworld.ko文件,这就是编译生成的内核驱动模块。

../../../_images/make_helloworld.png

5.3.2. 内核模块的加载和卸载

编译好内核驱动模块,可以通过多种方式将helloworld.ko拷贝到开发板,我们可以使用 NFS网络文件系统scp命令sftp命令 等。

其中NFS环境请搭建请参考开发环境搭建章节的 《搭建文件传输环境》 内容。

scp 命令用于 Linux 之间复制文件和目录,该命令基于ssh,需要搭建好ssh环境。

#SCP传输
scp helloworld.ko cat@192.168.103.147:/home/cat/
#NFS文件系统
cp helloworld.ko /mnt/
../../../_images/cp_module.png

打开鲁班猫终端,输入以下命令将helloworld驱动模块加载到内核。

insmod helloworld.ko

我们也可以输入以下命令,将 helloworld 驱动拆卸。

rmmod helloworld
rmmod helloworld.ko

具体演示如下:

../../../_images/insmod_module.png

5.4. 内核模块的工作机制

我们编写的内核模块,经过编译,最终形成.ko为后缀的ELF文件。我们可以使用 file 命令来查看它。

../../../_images/file_module.png

那么这样的文件是如何被内核一步一步拿到并且很好的工作的呢?

为了便于我们更好的理解内核模块的加载/卸载过程,可以先跟我一起学习ELF文件格式,了解ko究竟是怎么一回事儿。

再一同去看看内核源码,探究内核模块加载/卸载,以及符号导出的经过。

5.4.1. ko文件的文件格式

ko文件在数据组织形式上是ELF(Excutable And Linking Format)格式,是一种普通的可重定位目标文件。这类文件包含了代码和数据,可以被用来链接成可执行文件或共享目标文件,静态链接库也可以归为这一类。

ELF 文件格式的可能布局如下图。

ELF 文件格式的可能布局

5.4.1.1. ELF文件头

文件开始处是一个ELF头部(ELF Header),用来描述整个文件的组织,这些信息独立于处理器,也独立于文件中的其余内容。文件头描述了ELF文件很多重要信息,例如它运行的平台,支持的CPU类型等。

Linux可执行文件采用ELF(Executable and Linkable Format)格式来存储数据。每个ELF文件都以以下的数据来开头:

#define EI_NIDENT (16)

typedef struct
{
    unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
    Elf32_Half    e_type;         /* Object file type */
    Elf32_Half    e_machine;      /* Architecture */
    Elf32_Word    e_version;      /* Object file version */
    Elf32_Addr    e_entry;        /* Entry point virtual address */
    Elf32_Off       e_phoff;        /* Program header table file offset */
    Elf32_Off       e_shoff;        /* Section header table file offset */
    Elf32_Word    e_flags;        /* Processor-specific flags */
    Elf32_Half    e_ehsize;       /* ELF header size in bytes */
    Elf32_Half    e_phentsize;    /* Program header table entry size */
    Elf32_Half    e_phnum;        /* Program header table entry count */
    Elf32_Half    e_shentsize;    /* Section header table entry size */
    Elf32_Half    e_shnum;        /* Section header table entry count */
    Elf32_Half    e_shstrndx;     /* Section header string table index */
} Elf32_Ehdr;

从中可知,ELF文件中的主要内容为 program headersection header ,两者的大小、在ELF文件中的位置和数量都能通过文件头来获取。而后又可以通过program header来获得每个segment的属性,通过section header来获得每个section的属性。

我们可以使用 readelf -h 工具查看elf文件的头部详细信息。

头部详细信息

注意

注意此处program header的大小为0。

5.4.1.2. 程序头部表

头部详细信息,又叫程序头部表(Program Header Table),它是个数组结构。

程序头部表关注的是ELF文件加载时会有几个不同的属性的段,关心如何将相同属性的段合并为一个segment,以优化时间和空间消耗(只有dll/exe文件拥有程序头部表,relocatable文件没有程序头部表)

程序头部表格式如下:

typedef struct elf64_phdr {
    Elf64_Word p_type;
    Elf64_Word p_flags;
    Elf64_Off p_offset;        /* Segment file offset */
    Elf64_Addr p_vaddr;        /* Segment virtual address */
    Elf64_Addr p_paddr;        /* Segment physical address */
    Elf64_Xword p_filesz;        /* Segment size in file */
    Elf64_Xword p_memsz;        /* Segment size in memory */
    Elf64_Xword p_align;        /* Segment alignment, file & memory */
} Elf64_Phdr;

可以通过 readelf -l 查看程序头部表,如下:

程序头部表

可以看出这个文件没有程序头部表,这也符合了上面ELF文件头部分“program header的大小为0”的描述。

5.4.1.3. 节区头部表

节区头部表实际上是一个Elf_Shdr[m]数组,其中的每一个元素(表项)记录系统中一个节区的信息,包括如节区名,类型,flag,内存/文件起始地址,大小,对齐等信息,如:

typedef struct elf64_shdr {
    Elf64_Word sh_name;        /* Section name, index in string tbl */
    Elf64_Word sh_type;        /* Type of section */
    Elf64_Xword sh_flags;        /* Miscellaneous section attributes */
    Elf64_Addr sh_addr;        /* Section virtual addr at execution */
    Elf64_Off sh_offset;        /* Section file offset */
    Elf64_Xword sh_size;        /* Size of section in bytes */
    Elf64_Word sh_link;        /* Index of another section */
    Elf64_Word sh_info;        /* Additional section information */
    Elf64_Xword sh_addralign;    /* Section alignment */
    Elf64_Xword sh_entsize;    /* Entry size if section holds table */
} Elf64_Shdr;

我们可以使用 readelf -S 读取elf文件的节区头部表的详细信息。

节区头部表

其中每一行是节区头部表中的一个表项,也代表着此elf文件中的一个节区信息(ELF文件中定位节区的方式是: ELF头–>节区头部表–>节区首地址及其他信息)

节区头部表中又包含了很多子表的信息,我们这里就简单介绍一个字符串表。

5.4.1.4. 字符串表

字符串表,严格来说应该是节区头部表字符串表,该表是给节区头部表专门准备的字符串表,ELF文件中通常存在两个字符串表:

  • 一个是代码中所有使用到的字符串的表,名称为.strtab

  • 一个是记录所有节区名的字符串表,名称为.shstrtab

二者通常是没有关系的,.shstrtab只记录节区名称,而.strtab则记录符号表中符号相关的字符串信息。

我们可以使用 readelf -p .shstrtab 读取.shstrtab字符串表的详细信息,使用 readelf -p .strtab 读取.strtab字符串表的详细信息。

演示如下:

字符串表

5.4.2. 内核模块加载过程

在前面我们了解了ko内核模块文件的一些格式内容之后,我们可以知道内核模块其实也是一段经过特殊加工的代码,那么既然是加工过的代码,内核就可以利用到加工时留在内核模块里的信息,对内核模块进行利用。

所以我们就可以接着了解内核模块的加载过程了。

  1. 首先 insmod 会通过文件系统将 .ko模块 读到用户空间的一块内存中;

  2. 然后执行系统调用 init_module() 解析模块,这时,内核在 vmalloc 区分配与ko文件大小相同的内存来暂存ko文件;

  3. 暂存好之后解析ko文件,将文件中的各个section 分配到init 段和core 段;

  4. 在modules区为init段和core段分配内存,并把对应的section copy到modules区最终的运行地址,经过relocate函数地址等操作后,就可以执行ko的init操作了。

  5. 这样一个ko的加载流程就结束了。同时,init段会被释放掉,仅留下core段来运行。

init_module() (内核源码(kernel)/kernel/module.c)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/*************************************************************************************************
*  @brief     init_module()   (SYSCALL_DEFINE3为Linux 内核中用于定义一个系统调用的宏)
*  @note      这个系统调用名为 init_module,它允许用户空间的程序向内核加载一个内核模块。
*  @param     umod         指向用户空间中包含模块数据的内存区域的指针
*  @param     len          模块数据的长度,以字节为单位
*  @param     uargs        指向用户空间中的字符串的指针,该字符串包含了传递给模块的参数
*  @return    成功时返回0,失败时返回负数(ret:错误码)
*************************************************************************************************/
SYSCALL_DEFINE3(init_module, void __user *, umod,
        unsigned long, len, const char __user *, uargs)
{
    int err;
    struct load_info info = { };

    err = may_init_module();
    if (err)
        return err;

    pr_debug("init_module: umod=%p, len=%lu, uargs=%p\n",
        umod, len, uargs);

    err = copy_module_from_user(umod, len, &info);
    if (err)
        return err;

    return load_module(&info, uargs, 0);
}

注解

  • 第22行:通过vmalloc在vmalloc区分配内存空间,将内核模块copy到此空间,info->hdr 直接指向此空间首地址,也就是ko的elf header 。

  • 第26行:然后通过load_module()进行模块加载的核心处理,在这里完成了模块的搬移,重定向等艰苦的过程。

下面是load_module()的详细过程,代码已经被我简化,主要包含setup_load_info()和layout_and_allocate()。

load_module函数(内核源码/kernel/module.c)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
 /*************************************************************************************************
 *  @brief     load_module
 *  @note      用于加载内核模块的内部函数。通常由用户空间的系统调用接口通过 init_module 系统调用触发
 *  @param     info         指向 load_info 结构体的指针,该结构体包含了模块加载所需的信息
 *  @param     uargs        指向用户空间字符串的指针,该字符串包含了传递给模块的参数。
 *  @param     flags        用于指定加载模块时的标志,这些标志可以控制模块加载的行为。
 *  @return    成功时返回0,失败时返回负数(ret:错误码)
 *************************************************************************************************/
static int load_module(struct load_info *info, const char __user *uargs,
            int flags)
{
   struct module *mod;
   long err = 0;
   char *after_dashes;
   ...
   err = setup_load_info(info, flags);
   ...
   mod = layout_and_allocate(info, flags);
   ...
}

注解

  • 第16行:setup_load_info()加载struct load_info 和 struct module, rewrite_section_headers,将每个section的sh_addr修改为当前镜像所在的内存地址, section 名称字符串表地址的获取方式是从ELF头中的e_shstrndx获取到节区头部字符串表的标号,找到对应section在ELF文件中的偏移,再加上ELF文件起始地址就得到了字符串表在内存中的地址。

  • 第18行:在layout_and_allocate()中,layout_sections() 负责将section 归类为core和init这两大类,为ko的第二次搬移做准备。move_module()把ko搬移到最终的运行地址。内核模块加载代码搬运过程到此就结束了。

注意

但此时内核模块要工作起来还得进行符号导出,内核模块导出符号我们放到后面小节详细讲解。

5.4.3. 内核模块卸载过程

卸载过程相对加载比较简单,我们输入指令rmmod,最终在系统内核中需要调用delete_module进行实现。

具体过程如下:先从用户空间传入需要卸载的模块名称,根据名称找到要卸载的模块指针,确保我们要卸载的模块没有被其他模块依赖,然后找到模块本身的exit函数实现卸载。代码如下。

  • 代码文件路径:内核源码(kernel)/kernel/module.c

delete_module()(内核源码/kernel/module.c)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/*************************************************************************************************
*  @brief     delete_module()   (SYSCALL_DEFINE2为Linux 内核中用于定义一个系统调用的宏)
*  @note      这个系统调用名为 delete_module,它用于从内核中移除一个已经加载的内核模块。
*  @param     name_user    指向用户空间的字符串的指针,该字符串包含了要删除的模块的名称
*  @param     uargs        这是一个标志值,用于指定删除模块时的行为。
*  @return    成功时返回0,失败时返回负数(ret:错误码)
*************************************************************************************************/
SYSCALL_DEFINE2(delete_module, const char __user *, name_user,
        unsigned int, flags)
{
    struct module *mod;
    char name[MODULE_NAME_LEN];
    int ret, forced = 0;

    if (!capable(CAP_SYS_MODULE) || modules_disabled)
        return -EPERM;

    if (strncpy_from_user(name, name_user, MODULE_NAME_LEN-1) < 0)
        return -EFAULT;
    name[MODULE_NAME_LEN-1] = '\0';

    audit_log_kern_module(name);

    if (mutex_lock_interruptible(&module_mutex) != 0)
        return -EINTR;

    mod = find_module(name);
    if (!mod) {
        ret = -ENOENT;
        goto out;
    }

    if (!list_empty(&mod->source_list)) {
        /* Other modules depend on us: get rid of them first. */
        ret = -EWOULDBLOCK;
        goto out;
    }

    /* Doing init or already dying? */
    if (mod->state != MODULE_STATE_LIVE) {
        /* FIXME: if (force), slam module count damn the torpedoes */
        pr_debug("%s already dying\n", mod->name);
        ret = -EBUSY;
        goto out;
    }

    /* If it has an init func, it must have an exit func to unload */
    if (mod->init && !mod->exit) {
        forced = try_force_unload(flags);
        if (!forced) {
            /* This module can't be removed */
            ret = -EBUSY;
            goto out;
        }
    }

    /* Stop the machine so refcounts can't move and disable module. */
    ret = try_stop_module(mod, flags, &forced);
    if (ret != 0)
        goto out;

    mutex_unlock(&module_mutex);
    /* Final destruction now no one is using it. */
    if (mod->exit != NULL)
        mod->exit();
    blocking_notifier_call_chain(&module_notify_list,
                    MODULE_STATE_GOING, mod);
    klp_module_going(mod);
    ftrace_release_mod(mod);

    async_synchronize_full();

    /* Store the name of the last unloaded module for diagnostic purposes */
    strlcpy(last_unloaded_module, mod->name, sizeof(last_unloaded_module));

    free_module(mod);
    /* someone could wait for the module in add_unformed_module() */
    wake_up_all(&module_wq);
    return 0;
out:
    mutex_unlock(&module_mutex);
    return ret;
}

注解

  • 第15行:确保有插入和删除模块不受限制的权利,并且模块没有被禁止插入或删除

  • 第18行:获得模块名字

  • 第27行:找到要卸载的模块指针

  • 第33行:有依赖的模块,需要先卸载它们

  • 第48行:检查模块的退出函数

  • 第58行:停止机器,使参考计数不能移动并禁用模块

  • 第66-67行:告诉通知链module_notify_list上的监听者,模块状态变为MODULE_STATE_GOING

  • 第71行:等待所有异步函数调用完成

注意

从 Linux 内核版本 5.10 开始,delete_module 系统调用已被弃用,并在后续版本中被移除。取而代之的是 request_module 系统调用,它可以用来请求内核卸载一个模块。这是因为 delete_module 可能会导致系统不稳定,特别是在强制删除正在使用的模块时。request_module 系统调用提供了更安全的方式来管理内核模块的生命周期。

5.4.4. 内核是如何导出符号的

符号是什么东西?我们为什么需要导出符号呢?内核模块如何导出符号呢?其他模块又是如何找到这些符号的呢?

这是这一小节讨论的知识,实际上,符号指的就是内核模块中使用EXPORT_SYMBOL声明的函数和变量。当模块被装入内核后,它所导出的符号都会记录在公共内核符号表中。在使用命令insmod加载模块后,模块就被连接到了内核,因此可以访问内核的共用符号。

通常情况下我们无需导出任何符号,但是如果其他模块想要从我们这个模块中获取某些方便的时候, 就可以考虑使用导出符号为其提供服务。这被称为模块层叠技术。 例如msdos文件系统依赖于由fat模块导出的符号;USB输入设备模块层叠在usbcore和input模块之上。 也就是我们可以将模块分为多个层,通过简化每一层来实现复杂的项目。

modprobe是一个处理层叠模块的工具,它的功能相当于多次使用insmod,除了装入指定模块外还同时装入指定模块所依赖的其他模块。该命令在前面的“内核模块常用命令”中也有提及。

当我们要导出模块的时候,可以使用下面的宏

EXPORT_SYMBOL(name)
EXPORT_SYMBOL_GPL(name) //name为我们要导出的标志

符号必须在模块文件的全局部分导出,不能在函数中使用,_GPL使得导出的模块只能被GPL许可的模块使用。

编译我们的模块时,这两个宏会被拓展为一个特殊变量的声明,存放在ELF文件中。 具体也就是存放在ELF文件的符号表中:

  • st_name: 是符号名称在符号名称字符串表中的索引值

  • st_value: 是符号所在的内存地址

  • st_size: 是符号大小

  • st_info: 是符号类型和绑定信息

  • st_shndx: 表示符号所在section

当ELF的符号表被加载到内核后,会执行simplify_symbols来遍历整个ELF文件符号表。 根据st_shndx找到符号所在的section和st_value中符号在section中的偏移得到真正的内存地址。并最终将符号内存地址,符号名称指针存储到内核符号表中。

simplify_symbols函数原型如下:

simplify_symbols函数 (内核源码/kernel/module.c)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/********************************************************************************************
*  @brief     simplify_symbols()
*  @note      在模块加载过程中简化模块的符号表(内部函数)
*  @param     mod         指向 module 结构体的指针,该结构体代表了一个内核模块
*  @param     info        指向 load_info 结构体的指针,该结构体包含了模块加载时的详细信息
*  @return    成功时返回0,失败时返回负数(ret:错误码)
********************************************************************************************/
static int simplify_symbols(struct module *mod, const struct load_info *info)
{
    Elf_Shdr *symsec = &info->sechdrs[info->index.sym];
    Elf_Sym *sym = (void *)symsec->sh_addr;
    unsigned long secbase;
    unsigned int i;
    int ret = 0;
    const struct kernel_symbol *ksym;

    for (i = 1; i < symsec->sh_size / sizeof(Elf_Sym); i++) {
        const char *name = info->strtab + sym[i].st_name;

        switch (sym[i].st_shndx) {
        case SHN_COMMON:
            /* Ignore common symbols */
            if (!strncmp(name, "__gnu_lto", 9))
                break;

            /* We compiled with -fno-common.  These are not
            supposed to happen.  */
            pr_debug("Common symbol: %s\n", name);
            pr_warn("%s: please compile with -fno-common\n",
                mod->name);
            ret = -ENOEXEC;
            break;

        case SHN_ABS:
            /* Don't need to do anything */
            pr_debug("Absolute symbol: 0x%08lx\n",
                (long)sym[i].st_value);
            break;

        case SHN_LIVEPATCH:
            /* Livepatch symbols are resolved by livepatch */
            break;

        case SHN_UNDEF:
            ksym = resolve_symbol_wait(mod, info, name);
            /* Ok if resolved.  */
            if (ksym && !IS_ERR(ksym)) {
                sym[i].st_value = kernel_symbol_value(ksym);
                break;
            }

            /* Ok if weak or ignored.  */
            if (!ksym &&
                (ELF_ST_BIND(sym[i].st_info) == STB_WEAK ||
                ignore_undef_symbol(info->hdr->e_machine, name)))
                break;

            ret = PTR_ERR(ksym) ?: -ENOENT;
            pr_warn("%s: Unknown symbol %s (err %d)\n",
                mod->name, name, ret);
            break;

        default:
            /* Divert to percpu allocation if a percpu var. */
            if (sym[i].st_shndx == info->index.pcpu)
                secbase = (unsigned long)mod_percpu(mod);
            else
                secbase = info->sechdrs[sym[i].st_shndx].sh_addr;
            sym[i].st_value += secbase;
            break;
        }
    }

    return ret;
}

内核导出的符号表结构有两个字段,一个是符号在内存中的地址,一个是符号名称指针, 符号名称被放在了 __ksymtab_strings 这个section中,以 EXPORT_SYMBOL 举例,符号会被放到名为 ___ksymtab 的section中。这个结构体我们要注意,它构成的表是导出符号表而不是通常意义上的符号表 。

kernel_symbol结构体(内核源码/include/linux/export.h)
1
2
3
4
5
struct kernel_symbol {
    unsigned long value;
    const char *name;
    const char *namespace;
};
kernel_symbol结构体成员

成员

描述

value

符号的虚拟地址。在内核中,这通常是函数或变量在内存中的地址。对于内核符号,这个地址是相对于内核的虚拟地址空间的。

name

符号的名称

namespace

符号的命名空间。在内核中,命名空间用于区分不同类型的符号。

其他的内核模块在寻找符号的时候会调用resolve_symbol_wait去内核和其他模块中通过符号名称寻址目标符号,resolve_symbol_wait会调用resolve_symbol,进而调用 find_symbol。找到了符号之后,把符号的实际地址赋值给符号表 sym[i].st_value = ksym->value。

注意

find_symbol 函数是 Linux 内核中用于搜索特定符号的函数。这个函数在内核模块加载、符号解析和依赖管理中扮演着重要角色。当你需要找到一个符号的地址、所有者、CRC 校验和、许可证信息等时,可以使用这个函数。

find_symbol函数(内核源码/kernel/module.c)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/************************************************************************************
*  @brief     find_symbol()
*  @note      用于搜索特定符号的函数
*  @param     name       要搜索的符号名称,这个名称应该与内核中的一个符号完全匹配
*  @param     owner      指向指向 module 结构体的指针的指针
*  @param     crc        设置为指向该符号的 CRC 校验和,如果不关心CRC校验和,设置为 NULL
*  @param     license    设置为指向该符号的许可证信息,如果你不关心许可证信息,设置为 NULL
*  @param     gplok      指定是否允许在 GPL 兼容的模块中搜索符号
*  @param     warn       指定如果找不到符号是否应该发出警告
*  @return    成功找到返回该符号的地址,没有找到返回 NULL
************************************************************************************/
static const struct kernel_symbol *find_symbol(const char *name,
                    struct module **owner,
                    const s32 **crc,
                    enum mod_license *license,
                    bool gplok,
                    bool warn)
{
    struct find_symbol_arg fsa;

    fsa.name = name;
    fsa.gplok = gplok;
    fsa.warn = warn;

    if (each_symbol_section(find_exported_symbol_in_section, &fsa)) {
        if (owner)
            *owner = fsa.owner;
        if (crc)
            *crc = fsa.crc;
        if (license)
            *license = fsa.license;
        return fsa.sym;
    }

    pr_debug("Failed to find symbol %s\n", name);
    return NULL;
}
/************************************************************************************
*  @brief     __symbol_get()
*  @note      用于根据符号名称获取其对应的内核地址(内部函数)
*  @param     symbol     要查找的符号的名称。这个名称应该与内核中的一个符号完全匹配。
*  @return    返回值是一个 void 指针,指向找到的符号的地址。符号不存在返回 NULL
************************************************************************************/
void *__symbol_get(const char *symbol)
{
    struct module *owner;
    const struct kernel_symbol *sym;

    preempt_disable();
    sym = find_symbol(symbol, &owner, NULL, NULL, true, true);
    if (sym && strong_try_module_get(owner))
        sym = NULL;
    preempt_enable();

    return sym ? (void *)kernel_symbol_value(sym) : NULL;
}
EXPORT_SYMBOL_GPL(__symbol_get);

注解

  • 第25行:在each_symbol_section中,去查找了两个地方,一个是内核的导出符号表,即我们在将内核符号是如何导出的时候定义的全局变量,一个是遍历已经加载的内核模块,查找动作是在each_symbol_in_section中完成的。

  • 第50行:__symbol_get函数调用了find_symbol函数。

  • 第57行:导出符号标志。

至此符号查找完毕,最后将所有section借助ELF文件的重定向表进行重定向,就能使用该符号了。

到这里内核就完成了内核模块的加载/卸载以及符号导出,感兴趣的读者可以查阅内核源码目录下/kernel/module.c文件。

5.5. 本章命令/函数汇总

5.5.1. 命令汇总

注意

关于上述内核模块的常用命令,这里仅列举1~2个例子,如 depmod 命令举例为 depmod -a

# 查看目前内核加载的模块
lsmod
# 加载模块到内核
insmod xxx.ko
# 卸载模块
rmmod xxx.ko
rmmod xxx
# 查看内核打印的所有信息
sudo dmesg
# 查看包含xxx内容的内核打印信息
sudo dmesg | grep xxx
# 自动加载所需的模块依赖,并加载一个或多个模块。(下一个章节实验会用到)
modprobe xxx
# 递归地更新所有模块的依赖关系,更新 modules.dep 文件。
depmod -a
# 显示特点模块的简要信息,包括它的作者、版权、依赖项、符号等。
modinfo xxx
# 显示crct10dif_ce模块的简要信息,该模块为lsmod列出来随便选的
modinfo crct10dif_ce
# 列出所有已安装的内核模块
kmod list

# 通过scp将xxx.ko文件传输到鲁班猫的/home/cat/目录下
scp xxx.ko cat@192.168.103.147:/home/cat/

# 查看elf文件的头部详细信息。
readelf -h xxx.ko
# 查看程序头部表
readelf -l xxx.ko
# 读取elf文件的节区头部表的详细信息。
readelf -S xxx.ko
# 读取.shstrtab字符串表的详细信息
readelf -p .shstrtab xxx.ko
# 读取.strtab字符串表的详细信息。
readelf -p .strtab xxx.ko

5.5.2. 函数汇总

以下内容不仅包含函数,还有系统调用的宏

// 内核模块加载系统调用名为init_module的宏
SYSCALL_DEFINE3(init_module, void __user *, umod, unsigned long, len, const char __user *, uargs)

// 用于加载内核模块的内部函数。通常由用户空间的系统调用接口通过 init_module 系统调用触发
static int load_module(struct load_info *info, const char __user *uargs, int flags)

// 内核模块卸载系统调用名为delete_module的宏
SYSCALL_DEFINE2(delete_module, const char __user *, name_user, unsigned int, flags)

// 在模块加载过程中简化模块的符号表(内部函数)
static int simplify_symbols(struct module *mod, const struct load_info *info)

// 用于搜索特定符号的函数
static const struct kernel_symbol *find_symbol(const char *name, struct module **owner, const s32 **crc, enum mod_license *license, bool gplok, bool warn)

// 根据符号名称获取其对应的内核地址(内部函数)
void *__symbol_get(const char *symbol)