本文介绍了Linux内核模块的机制,包括内核模块的特点、构建过程、模块操作命令、模块参数、导出符号、模块声明与描述以及模块引用计数。

内核模块允许动态加载功能到内核中,避免了内核重编译的需要。

文章通过示例代码详细解释了如何编写、编译和操作内核模块,以及如何使用模块参数和导出符号。此外,还讨论了模块的许可证声明、作者信息、描述等元数据,以及如何使用模块引用计数来防止模块在使用中被卸载。

内核模块简介

hello world示例

/*
 * a simple kernel module: hello
 *
 * Copyright (C) 2014 Barry Song  (baohua@kernel.org)
 *
 * Licensed under GPLv2 or later.
 */

#include <linux/init.h>
#include <linux/module.h>

static int __init hello_init(void)
{
        printk(KERN_INFO "Hello World enter\n");
        return 0;
}
module_init(hello_init);

static void __exit hello_exit(void)
{
        printk(KERN_INFO "Hello World exit\n");
}
module_exit(hello_exit);

MODULE_AUTHOR("Barry Song <21cnbao@gmail.com>");
MODULE_LICENSE("GPL v2");
MODULE_DESCRIPTION("A simple Hello World Module");
MODULE_ALIAS("a simplest module");

该示例中包含三部分,分别是内核模块加载函数(module_init),卸载函数(module_exit)、GPLv2的许可声明(MODULE_LICENSE(GPL v2))和其他的描述信息。

内核模块构建

内核模块的编译流程如下:

KVERS = $(shell uname -r)

# Kernel modules
obj-m += hello.o

# Specify flags for the module compilation.
#EXTRA_CFLAGS=-g -O0

build: kernel_modules

kernel_modules:
        make -C /lib/modules/$(KVERS)/build M=$(CURDIR) modules

clean:
        make -C /lib/modules/$(KVERS)/build M=$(CURDIR) clean

其中-C /lib/modules/$(KVERS)/build 用于切换到内核构建目录,内核的顶层Makefile接管编译流程,M=$(CURDIR)指定模块源码目录,内核构建系统调用当前目录下的Makefile,编译完成之后生成.ko模块文件。

上面Makefile文件的编译输出如下:

make -C /lib/modules/4.0.0-040000-generic/build M=/home/baohua/develop/training/kernel/drivers/hello modules
make[1]: Entering directory `/usr/src/linux-headers-4.0.0-040000-generic'
  CC [M]  /home/baohua/develop/training/kernel/drivers/hello/hello.o
  Building modules, stage 2.
  MODPOST 1 modules
  CC      /home/baohua/develop/training/kernel/drivers/hello/hello.mod.o
  LD [M]  /home/baohua/develop/training/kernel/drivers/hello/hello.ko
make[1]: Leaving directory `/usr/src/linux-headers-4.0.0-040000-generic'

为什么要使用-C指定切换到内核构建目录呢?

RE:指定目录的原因在于Linux内核的构建系统(kbuild)是集中管理的,其中内核的顶层的Makefile定义了完整的编译规则和依赖关系,内核模块需要和当前运行的内核版本保持ABI一致,内核顶层构建目录中有Module.sysvers.config等文件,可以确保编译出的.ko模块能正确链接到内核符号。

如果为其他版本系统构建内核模块呢? RE:如果不是为当前系统构建内核模块,就会出现不匹配的问题,因为-C /lib/modules/$(KVERS)/build指向的是当前运行内核的构建目录。如果要为某个特定版本的内核编译模块,就需要下载或准备该版本的内核源码,并运行make -C /path/to/that/kernel/source M=$PWD,此时会进入目标内核源码目录,而非当前系统的/lib/modules/.../build。如果目标内核运行在不同架构,则需要设置交叉编译工具链:make -C /path/to/that/kernel/source ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- M=$PID

本质上模块必须和目标内核的符号表一致。通常需要目标内核源码目录里有 Module.symvers 文件,否则可能出现符号解析失败。

模块相关操作

命令说明示例
insmod xxx.ko加载模块insmod hello.ko
rmmod xxx卸载模块rmmod hello
lsmod列出系统中已加载的所有模块,实际读取/proc/modules文件
modprobe xxx使用modprobe加载模块需要先将模块安装到内核模块目录,然后使用depmod -a创建依赖安装到/lib/modules/$(uname -r)/kernel/drivers/hello,然后调用depmod -a; modprobe hello
modprobe -r xxx卸载对应模块modprobe -r hello
modinfo xxx.ko获取模块信息modinfo ./hello.ko

hello.ko模块加载和卸载时会通过printk()输出对应信息,可以使用dmesg -c命令查看,如下所示:

$ sudo insmod ./hello.ko
$ sudo rmmod hello
$ sudo dmesg -c
[20163.408354] Hello World enter
[20168.194467] Hello World exit

同时内核中会将已加载的模块信息记录在/proc/modules文件中,并在/sys/module目录下创建对应模块目录(例如/sys/module/hello),该目录中会有一个refcnt文件(该模块的引用计数)和sections目录,如下所示:

$ cat /proc/modules | head -n 5
hello 16384 0 - Live 0x00000000 (OE)
vmw_vsock_vmci_transport 32768 2 - Live 0x00000000
vsock 32768 3 vmw_vsock_vmci_transport, Live 0x00000000
xt_addrtype 16384 2 - Live 0x00000000
xt_conntrack 16384 1 - Live 0x00000000
$ tree /sys/module/hello/ -a
/sys/module/hello/
├── coresize
├── holders
├── initsize
├── initstate
├── notes
│   └── .note.gnu.build-id
├── refcnt
├── sections
│   ├── .exit.text
│   ├── .gnu.linkonce.this_module
│   ├── .init.text
│   ├── .note.gnu.build-id
│   ├── .rodata.str1.1
│   ├── .strtab
│   └── .symtab
├── srcversion
├── taint
└── uevent

3 directories, 15 files

modprobe命令在加载内核时会同时加载该内核所依赖的其他模块,在卸载模块时也会同步卸载依赖的模块。模块之间的依赖关系存放在/lib/modules/$(uname -r)/module.dep中,该文件是在编译内核时由depmod命令生成的。

$ cat modules.dep | head -n 10
kernel/arch/x86/kernel/cpu/mcheck/mce-inject.ko:
kernel/arch/x86/kernel/msr.ko:
kernel/arch/x86/kernel/cpuid.ko:
kernel/arch/x86/kernel/apm.ko:
kernel/arch/x86/kernel/iosf_mbi.ko:
kernel/arch/x86/crypto/glue_helper.ko:
kernel/arch/x86/crypto/aes-i586.ko:
kernel/arch/x86/crypto/twofish-i586.ko: kernel/crypto/twofish_common.ko
kernel/arch/x86/crypto/salsa20-i586.ko:
kernel/arch/x86/crypto/serpent-sse2-i586.ko: kernel/crypto/xts.ko kernel/crypto/serpent_generic.ko kernel/crypto/lrw.ko kernel/crypto/gf128mul.ko kernel/arch/x86/crypto/glue_helper.ko kernel/crypto/ablk_helper.ko kernel/crypto/cryptd.ko

modinfo命令可以查看模块的具体信息:

$ modinfo ./hello.ko
filename:       /lib/modules/4.0.0-040000-generic/kernel/drivers/hello/./hello.ko
alias:          a simplest module
description:    A simple Hello World Module
license:        GPL v2
author:         Barry Song <21cnbao@gmail.com>
srcversion:     56C45B019D5D953B115F1D4
depends:
vermagic:       4.0.0-040000-generic SMP mod_unload modversions 686

内核模块说明

内核模块组成部分是否必须说明
模块加载函数当通过insmodmodprobe命令加载模块时,该函数会自动被内核执行,完成初始化工作
模块卸载函数当通过rmmod卸载模块时,该函数会自动被内核执行,完成模块资源清理动作
模块许可证声明描述内核模块的许可权限,如果不声明LICENSE,模块被加载时会受到Kernel Tainted警告,示例[21930.971407] hello: module license 'unspecified' taints kernel.
模块参数模块被加载时转递给它的值,对应模块内部的全局变量
模块导出符号内核模块可以导出的符号,可供其他模块使用
模块作者等信息声明

模块加载函数

Linux内核模块加载函数一般以__init标识声明,典型的模块加载函数的形式如下所示:

static int __init initialization_function(void)
{
    // 初始化代码
}
module_init(initialization_function);

模块加载函数以module_init(function name)的形式被指定。

模块加载函数返回整型值,初始化成功返回0,失败返回错误码。

在Linux内核中,可以使用request_module(const char *fmt,...)函数加载内核模块,驱动开发人员可以通过调用request_module(module_name)灵活地加载其他内核模块。

示例如下:

#include <linux/init.h>
#include <linux/kmod.h>  // 提供 request_module()
#include <linux/module.h>

static int __init my_init(void)
{
    int ret;

    pr_info("my_module: init, requesting ipv6...\n");

    // 调用 request_module,参数是模块别名或名字
    ret = request_module("ipv6");
    if (ret != 0)
    {
        pr_err("my_module: failed to load ipv6, ret=%d\n", ret);
    }
    else
    {
        pr_info("my_module: ipv6 module requested successfully\n");
    }

    return 0;
}

static void __exit my_exit(void)
{
    pr_info("my_module: exit\n");
}

module_init(my_init);
module_exit(my_exit);

MODULE_LICENSE("GPL");
// [91496.789095] my_module: init, requesting ipv6...
// [91496.793324] my_module: ipv6 module requested successfully

在Linux中,所有标识__init的函数如果直接编译进入内核,成为内核镜像的一部分,它的初始化函数指针会被编译器和链接脚本收集到.initcall.init段中,在系统初始化时内核通过这些函数指针调用这些__init函数加载模块,并在初始化完成之后,释放这些区段占用的内存。

对于可加载模块,在调用insmod为模块分配内存并映射各个段时,初始化函数地址存放在.init.text段中,同时保存在文件/sys/module/<module_name>/sections/.init.text中,内核会调用.init.text段中的初始刷函数完成初始化动作,之后将该段释放,但未见持久存在,用于调试。

除了初始化函数,初始化数据也可以定义为__initdata,在模块完成初始化之后也会释放其内存,在文件/sys/module/<module_name>/sections/.init.data中会显示该数据的地址信息。

模块卸载函数

Linux内核模块卸载函数一般以__exit标识声明,示例如下所示:

static void __exit cleanup_function(void)
{
        //
}

module_exit(cleanup_function);

模块卸载函数在模块卸载的时候执行,不返回任何值,且必须以module_exit(function_name)的形式指定。

__exit修饰模块卸载函数,可以告诉内核如果该模块被直接编译进内核,则cleanup_function函数会被省略,不会被链接进最后的映像。因为模块既然被内置,则不会被卸载,卸载函数就不必要了。

除了函数之外,只在模块退出阶段使用的数据也可以使用__exitdata来修饰。

模块参数

使用module_param(param_name, param_type, param_perm)来给内核模块定义参数,如下所示:

// 字符串参数
static char *book_name = "dissecting Linux Device Driver";
module_param(book_name, charp, S_IRUGO);

// 整型参数
static int book_num = 4000;
module_param(book_num, int, S_IRUGO);

// 布尔参数
static bool is_en_book = false;
module_param(is_en_book, bool, S_IRUGO);

// 数组参数
static int book_no[4] = {0, 1, 2, 3};
static int book_no_num = 0;
module_param_array(book_no, int, &book_no_num, S_IRUGO); // 第3个参数用来保存实际传入的参数个数

在装载内核模块时,用户可以向模块传递参数,形式为insmod [module_name] [param_name]=[param_value],如果不传递,参数将使用模块中定义的缺省值。

如果模块被内置,无法使用insmod的情况下,可以通过bootloaderbootargs中设置module_name.param_name=param_value的形式给内置的模块的传递参数。

grub为例:

menuentry 'Ubuntu' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-simple-dd120e13-1808-47e0-b15b-0aaaa5449d3d' {
        recordfail
        load_video
        gfxmode $linux_gfx_mode
        insmod gzio
        insmod part_msdos
        insmod ext2
        set root='hd0,msdos1'
        if [ x$feature_platform_search_hint = xy ]; then
          search --no-floppy --fs-uuid --set=root --hint-bios=hd0,msdos1 --hint-efi=hd0,msdos1 --hint-baremetal=ahci0,msdos1  dd120e13-1808-47e0-b15b-0aaaa5449d3d
        else
          search --no-floppy --fs-uuid --set=root dd120e13-1808-47e0-b15b-0aaaa5449d3d
        fi
        linux   /boot/vmlinuz-4.0.0-040000-generic root=UUID=dd120e13-1808-47e0-b15b-0aaaa5449d3d ro  quiet splash $vt_handoff book.book_no=1000,1001,1002,1003
        initrd  /boot/initrd.img-4.0.0-040000-generic
}

模块被加载后,在/sys/module目录下会出现以此模块命名的目录。

如果该模块存在权限不为0的参数,在此模块的目录下会出现parameters目录,其中包含一系列以参数名命名的文件节点,这些文件的权限值是传入module_param()的参数读写权限,文件的内容则是参数的值。

示例如下:

#include <linux/init.h>
#include <linux/module.h>

// 字符串参数
static char *book_name = "dissecting Linux Device Driver";
module_param(book_name, charp, S_IRUGO);

// 整型参数
static int book_num = 4000;
module_param(book_num, int, S_IRUGO);

// 布尔参数
static bool is_en_book = false;
module_param(is_en_book, bool, S_IRUGO);

// 数组参数
static int book_no[4] = {0, 1, 2, 3};
static int book_no_num = 0;
module_param_array(book_no, int, &book_no_num, S_IRUGO);
// MODULE_PARAM_DESC(book_no, "book no");

static int __init book_init(void)
{
        int i = 0;
        printk(KERN_INFO "book name:%s\n", book_name);
        printk(KERN_INFO "book num:%d\n", book_num);
        printk(KERN_INFO "book is_en_book:%d\n", is_en_book);
        printk(KERN_INFO "book no_num:%d\n", book_no_num);
        for(i = 0; i < book_no_num; i++)
        {
                printk(KERN_INFO "book no:%d]\n", book_no[i]);
        }
        return 0;
}
module_init(book_init);

static void __exit book_exit(void)
{
        printk(KERN_INFO "book module exit\n ");
}
module_exit(book_exit);

运行结果如下:

$ sudo insmod book.ko book_name="AAA" book_num=4 is_en_book=false book_no=0001,0002,0003,0004
insmod: ERROR: could not insert module book.ko: Invalid parameters
$ sudo insmod book.ko book_name="AAA" book_num=4 is_en_book=0 book_no=0001,0002,0003,0004
$ ls /sys/module/book/
coresize  holders  initsize  initstate  notes  parameters  refcnt  sections  srcversion  taint  uevent  version
$ ls /sys/module/book/parameters/
book_name  book_no  book_num  is_en_book
$ cat /sys/module/book/parameters/book_name
AAA
$ cat /sys/module/book/parameters/book_no
1,2,3,4
$ cat /sys/module/book/parameters/book_num
4
$ cat /sys/module/book/parameters/is_en_book
N

[45557.280796] book name:AAA
[45557.280800] book num:4
[45557.280801] book is_en_book:0
[45557.280803] book no_num:4
[45557.280804] book no:1]
[45557.280805] book no:2]
[45557.280806] book no:3]
[45557.280807] book no:4]

导出符号 Linux的/proc/kallsyms文件记录了内核符号表,其中包含符合以及符号所在的内存地址。

$ sudo cat /proc/kallsyms | grep integar
f9cc703c r __kstrtab_sub_integar        [export_symb]
f9cc7038 r __kcrctab_sub_integar        [export_symb]
f9cc7048 r __kstrtab_add_integar        [export_symb]
f9cc7034 r __kcrctab_add_integar        [export_symb]
f9cc6000 t add_integar  [export_symb]
f9cc7024 r __ksymtab_add_integar        [export_symb]
f9cc6010 t sub_integar  [export_symb]
f9cc702c r __ksymtab_sub_integar        [export_symb]

模块可以使用以下宏导出符号到内核符号表中:

EXPORT_SYMBOL(sym_name);
EXPORT_SYMBOL_GPL(sym_name);

编译模块时,编译器会生成特殊的段:

  1. __kstrtab_xxx:保存符号名字符串
  2. __kcrctab_xxx:保存符号版本校验值
  3. __ksymtab_xxx:符号表项,指向函数或变量地址

当调用insmodmodprobe加载模块时,内核会调用register_module(),把__ksymtab中的符号插入到全局符号表(kernel_symbol链表),之后就可以在/proc/kallsyms中看到。

示例如下:

#include <linux/init.h>
#include <linux/module.h>

int add_integar(int a, int b)
{
        return a + b;
}
EXPORT_SYMBOL_GPL(add_integar);

int sub_integar(int a, int b)
{
        return a - b;
}
EXPORT_SYMBOL_GPL(sub_integar);

MODULE_LICENSE("GPL v2");

在另一个模块中使用符号时,需要满足以下3点:

使用extern进行声明
许可证匹配
符号版本校验通过(使用同一个Module.symvers
使用示例如下:

#include <linux/init.h>
#include <linux/module.h>

// 符号声明
extern int add_integar(int a, int b);
static int __init hello_init(void)
{
        int a = 1;
        int b = 2;
        printk(KERN_INFO "Hello World enter\n");
        printk(KERN_INFO "a+b=%d\n", add_integar(a, b));
        return 0;
}
module_init(hello_init);

static void __exit hello_exit(void)
{
        printk(KERN_INFO "Hello World exit\n");
}
module_exit(hello_exit);

MODULE_AUTHOR("Barry Song <21cnbao@gmail.com>");
// GPL版本
MODULE_LICENSE("GPL v2");
MODULE_DESCRIPTION("A simple Hello World Module");
MODULE_ALIAS("a simplest module");
// 符号版本一致
// $ md5sum Module.symvers ../export/Module.symvers
// 86fdc3d51068bbc40f5178dd08f2801c  Module.symvers
// 86fdc3d51068bbc40f5178dd08f2801c  ../export/Module.symvers

// 结果
// [82990.116249] Hello World enter
// [82990.116254] a+b=3

模块声明与描述

作用示例
MODULE_LICENSE指定许可证,决定符号可用性MODULE_LICENSE("GPL")
MODULE_AUTHOR开发者信息MODULE_AUTHOR("BL")
MODULE_DESCRIPTION功能描述MODULE_DESCRIPTION("Driver for XYZ")
MODULE_VERSION模块版本号MODULE_VERSION("1.2.3")
MODULE_ALIAS模块别名MODULE_ALIAS("usb:v1234p5678")
MODULE_DEVICE_TABLE设备匹配表MODULE_DEVICE_TABLE(usb, table)
MODULE_INFO自定义信息MODULE_INFO(release_date, “2026-07-12”)

对于USB、PCI等设备驱动,会创建一个MODULE_DEVICE_TABLE表明该驱动模块所支持的设备,用于把内核驱动支持的设备 ID 暴露给用户空间工具,从而实现设备即插即用时自动加载对应的驱动模块(设备插入 -> udev 识别 -> modprobe 自动加载驱动)。

static const struct usb_device_id my_usb_id_table[] = {
    { USB_DEVICE(0x1234, 0x5678) }, // Vendor ID + Product ID
    {}
};
MODULE_DEVICE_TABLE(usb, my_usb_id_table);

模块引用计数

模块引用计数用于跟踪该模块是否正在使用中,防止模块仍在被依赖时被卸载。比如当设备正在使用时,管理该设备的模块不能被卸载。

Linux2.6提供了两个函数用来管理模块的引用计数:

int try_module_get(struct module* module);
void module_put(struct module* module);

try_module_get用于增加模块的实用计数;返回为0表示调用失败。常用于驱动在打开设备时调用,确保模块不会在设备使用过程中被卸载。

module_put用于减少模块使用计数。常用于驱动在关闭设备时调用,释放对模块的引用。

内核为不同类型的设备定义了指向管理此设备的模块字段struct module* owner,当用户空间调用设备操作(如open()read()ioctl())时,内核会检查 owner指针:

  1. 如果owner非空,内核自动会调用try_module_get(owner)来增加模块引用计数。
  2. 当操作结束后,引用计数会通过module_put(owner)递减。

无需手动调用这两个函数来处理模块的引用计数。

示例如下:

static struct file_operations my_fops = {
    .owner = THIS_MODULE, // 定义owner字段,内核自动处理引用计数
    .open  = my_open,
    .release = my_release,
};