Linux内核模块
本文介绍了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
内核模块说明
| 内核模块组成部分 | 是否必须 | 说明 |
|---|---|---|
| 模块加载函数 | 是 | 当通过insmod或modprobe命令加载模块时,该函数会自动被内核执行,完成初始化工作 |
| 模块卸载函数 | 是 | 当通过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的情况下,可以通过bootloader在bootargs中设置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);
编译模块时,编译器会生成特殊的段:
__kstrtab_xxx:保存符号名字符串__kcrctab_xxx:保存符号版本校验值__ksymtab_xxx:符号表项,指向函数或变量地址
当调用insmod或modprobe加载模块时,内核会调用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指针:
- 如果
owner非空,内核自动会调用try_module_get(owner)来增加模块引用计数。 - 当操作结束后,引用计数会通过
module_put(owner)递减。
无需手动调用这两个函数来处理模块的引用计数。
示例如下:
static struct file_operations my_fops = {
.owner = THIS_MODULE, // 定义owner字段,内核自动处理引用计数
.open = my_open,
.release = my_release,
};
- 原文作者:生如夏花
- 原文链接:https://DBL2017.github.io/post/%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B0/ldd/linux%E5%86%85%E6%A0%B8%E6%A8%A1%E5%9D%97/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。