内核模块可以动态地被安装到内核,从而扩展内核的功能,使用内核模块时不需要重新编译内核。内核模块常用的场景是驱动,随着芯片种类的增加,硬件种类的增加,这些芯片或者硬件(比如网卡) 的驱动可以以模块的方式进行开发,这样可以在需要的时候加载对应的模块,不使用的时候卸载模块,不至于因为芯片或硬件的种类的快速增加而导致内核镜像的膨胀。
使用 lsmod 可以看到当前系统中安装的内核模块。字符设备,块设备,网络设备,文件系统等都可以通过内核模块的方式来安装。
(1)e1000 是网卡驱动模块,网卡驱动经常以内核模块的方式安装
(2)vsock 是一个字符设备,也是通过内核模块的方式来安装
cpp
root@wyl-virtual-machine:/home/wyl/mod/hello2# lsmod
Module Size Used by
isofs 49152 1
rfcomm 81920 4
bnep 24576 2
vmw_vsock_vmci_transport 32768 2
vsock 40960 3 vmw_vsock_vmci_transport
nls_iso8859_1 16384 1
binfmt_misc 24576 1
snd_ens1371 28672 2
snd_ac97_codec 131072 1 snd_ens1371
gameport 20480 1 snd_ens1371
ac97_bus 16384 1 snd_ac97_codec
snd_pcm 110592 2 snd_ac97_codec,snd_ens1371
btusb 57344 0
btrtl 24576 1 btusb
btbcm 16384 1 btusb
snd_seq_midi 20480 0
snd_seq_midi_event 16384 1 snd_seq_midi
snd_rawmidi 36864 2 snd_seq_midi,snd_ens1371
btintel 24576 1 btusb
intel_rapl_msr 20480 0
intel_rapl_common 24576 1 intel_rapl_msr
crct10dif_pclmul 16384 1
ghash_clmulni_intel 16384 0
aesni_intel 372736 0
bluetooth 557056 27 btrtl,btintel,btbcm,bnep,btusb,rfcomm
crypto_simd 16384 1 aesni_intel
cryptd 24576 2 crypto_simd,ghash_clmulni_intel
snd_seq 73728 2 snd_seq_midi,snd_seq_midi_event
ecdh_generic 16384 1 bluetooth
ecc 28672 1 ecdh_generic
glue_helper 16384 1 aesni_intel
snd_seq_device 16384 3 snd_seq,snd_seq_midi,snd_rawmidi
snd_timer 36864 2 snd_seq,snd_pcm
rapl 20480 0
snd 90112 11 snd_seq,snd_seq_device,snd_timer,snd_ac97_codec,snd_pcm,snd_rawmidi,snd_ens1371
soundcore 16384 1 snd
vmw_balloon 24576 0
joydev 24576 0
input_leds 16384 0
serio_raw 20480 0
vmw_vmci 69632 2 vmw_balloon,vmw_vsock_vmci_transport
mac_hid 16384 0
sch_fq_codel 20480 2
vmwgfx 299008 5
ttm 106496 1 vmwgfx
drm_kms_helper 184320 1 vmwgfx
fb_sys_fops 16384 1 drm_kms_helper
syscopyarea 16384 1 drm_kms_helper
sysfillrect 16384 1 drm_kms_helper
sysimgblt 16384 1 drm_kms_helper
parport_pc 40960 0
ppdev 24576 0
lp 20480 0
parport 53248 3 parport_pc,lp,ppdev
ramoops 28672 0
drm 495616 8 vmwgfx,drm_kms_helper,ttm
efi_pstore 16384 0
reed_solomon 24576 1 ramoops
ip_tables 32768 0
x_tables 40960 1 ip_tables
autofs4 45056 2
crc32_pclmul 16384 0
psmouse 155648 0
e1000 147456 0
mptspi 24576 2
mptscsih 40960 1 mptspi
ahci 40960 1
mptbase 94208 2 mptspi,mptscsih
libahci 36864 1 ahci
scsi_transport_spi 32768 1 mptspi
i2c_piix4 28672 0
pata_acpi 16384 0
hid_generic 16384 0
usbhid 57344 0
hid 131072 2 usbhid,hid_generic
内核模块也是学习内核的一个工具,linux 内核中使用 EXPORT_SYMBOL 导出的符号,在内核模块中都可以使用,包括进程调度相关的,线程间同步相关,文件系统相关的很多函数,都可以在内核模块中使用,基于此可以通过内核模块来对这些内核子系统进行学习。比如你想学习内核中怎么创建一个内核线程,内核中的自旋锁怎么使用,都可以通过内核模块的方式来学习。
1 最简内核模块
如下是一个简单的内核模块,源代码 hello.c 和编译脚本 Makefile,将两者放在同一个文件夹下,执行 make 即可编译出内核模块。
在 hello_init() 和 hello_exit() 函数中都调用了 dump_stack() 函数。dump_stack() 是内核中很好使用的函数,可以用来打印当前的调用栈。
hello.c
cpp
// 定义了 MODULE_AUTHOR(), MODULE_LICENSE(), MODULE_DESCRIPTION() 这些宏
// 以及 module_init() 和 module_exit()
#include <linux/module.h>
// 定义了 __init 和 __exit
#include <linux/init.h>
// 模块初始化函数
static int __init hello_init(void){
printk("hello init\n");
dump_stack();
return 0;
}
// 模块退出函数
static void __exit hello_exit(void){
printk("hello exit\n");
dump_stack();
}
// 声明模块的初始化函数
module_init(hello_init);
// 声明模块的退出函数
// 入口函数主要做一些资源的申请和初始化的工作
// 出口函数主要做一些资源的释放工作
// 入口和出口是软件设计中经常会涉及到的思想
// 比如 c++ 对象中的构造函数和析构函数,也可以称作一个对象的入口和出口
module_exit(hello_exit);
MODULE_AUTHOR("wx2");
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("hello kernel module");
MODULE_ALIAS("hello kernel module");
Makefile:
bash
# 指定编译的目标文件
# 目标文件是 hello.o,那么编译过程自动找到源文件 hello.c 并编译
obj-m += hello.o
# 内核源码头文件目录
# 编译的时候进入内核源码头文件目录,调用该目录下的 Makefile
# 最终会调用我们自己的模块源码目录,用 M 来表示
# 下文中的 M=$(shell pwd)
KDIR = /lib/modules/$(shell uname -r)/build
all:
make -C $(KDIR) M=$(shell pwd) modules
clean:
make -C $(KDIR) M=$(shell pwd) clean
编译之后生成的 hello.ko 即内核模块,执行 insmod hello.ko 可以加载内核模块,执行 rmmod hello 可以卸载模块。通过 dmesg 可以查看模块函数中的打印。内核函数 dump_stack() 可以打印调用栈,在函数 hello_init() 和 hello_exit() 中都可以调用 dump_stack() 来查看调用栈。加载内核模块时,调用系统调用 init_module(),卸载内核模块时调用 delete_module(),在这两个系统调用中分别调用内核初始化函数 hello_init() 和内核退出函数 hello_exit()。
cpp
加载内核模块,调用栈:
[ 1096.663388] hello init
[ 1096.663391] CPU: 1 PID: 3992 Comm: insmod Tainted: G OE 5.4.0-174-generic #193-Ubuntu
[ 1096.663392] Hardware name: VMware, Inc. VMware Virtual Platform/440BX Desktop Reference Platform, BIOS 6.00 07/22/2020
[ 1096.663393] Call Trace:
[ 1096.663398] dump_stack+0x6d/0x8b
[ 1096.663400] ? 0xffffffffc0798000
[ 1096.663402] hello_init+0x1a/0x1000 [hello]
[ 1096.663405] do_one_initcall+0x4a/0x200
[ 1096.663407] ? _cond_resched+0x19/0x30
[ 1096.663409] ? kmem_cache_alloc_trace+0x177/0x240
[ 1096.663411] do_init_module+0x52/0x240
[ 1096.663412] load_module+0x128d/0x13d0
[ 1096.663416] __do_sys_finit_module+0xbe/0x120
[ 1096.663418] ? __do_sys_finit_module+0xbe/0x120
[ 1096.663420] __x64_sys_finit_module+0x1a/0x20
[ 1096.663422] do_syscall_64+0x57/0x190
[ 1096.663423] entry_SYSCALL_64_after_hwframe+0x5c/0xc1
[ 1096.663425] RIP: 0033:0x7f5fe720195d
[ 1096.663427] Code: 00 c3 66 2e 0f 1f 84 00 00 00 00 00 90 f3 0f 1e fa 48 89 f8 48 89 f7 48 89 d6 48 89 ca 4d 89 c2 4d 89 c8 4c 8b 4c 24 08 0f 05 <48> 3d 01 f0 ff ff 73 01 c3 48 8b 0d 03 35 0d 00 f7 d8 64 89 01 48
[ 1096.663427] RSP: 002b:00007ffcd09b7498 EFLAGS: 00000246 ORIG_RAX: 0000000000000139
[ 1096.663429] RAX: ffffffffffffffda RBX: 000055e84e680790 RCX: 00007f5fe720195d
[ 1096.663430] RDX: 0000000000000000 RSI: 000055e84e1d9358 RDI: 0000000000000003
[ 1096.663430] RBP: 0000000000000000 R08: 0000000000000000 R09: 00007f5fe72d8580
[ 1096.663431] R10: 0000000000000003 R11: 0000000000000246 R12: 000055e84e1d9358
[ 1096.663431] R13: 0000000000000000 R14: 000055e84e680760 R15: 0000000000000000
卸载内核模块,调用栈:
[ 1100.852877] hello exit
[ 1100.852881] CPU: 2 PID: 3994 Comm: rmmod Tainted: G OE 5.4.0-174-generic #193-Ubuntu
[ 1100.852882] Hardware name: VMware, Inc. VMware Virtual Platform/440BX Desktop Reference Platform, BIOS 6.00 07/22/2020
[ 1100.852882] Call Trace:
[ 1100.852888] dump_stack+0x6d/0x8b
[ 1100.852892] hello_exit+0x15/0x1000 [hello]
[ 1100.852895] __x64_sys_delete_module+0x147/0x2b0
[ 1100.852897] ? exit_to_usermode_loop+0xea/0x160
[ 1100.852899] do_syscall_64+0x57/0x190
[ 1100.852901] entry_SYSCALL_64_after_hwframe+0x5c/0xc1
[ 1100.852903] RIP: 0033:0x7fdca32a0c8b
[ 1100.852904] Code: 73 01 c3 48 8b 0d 05 c2 0c 00 f7 d8 64 89 01 48 83 c8 ff c3 66 2e 0f 1f 84 00 00 00 00 00 90 f3 0f 1e fa b8 b0 00 00 00 0f 05 <48> 3d 01 f0 ff ff 73 01 c3 48 8b 0d d5 c1 0c 00 f7 d8 64 89 01 48
[ 1100.852905] RSP: 002b:00007ffcd7b4af08 EFLAGS: 00000206 ORIG_RAX: 00000000000000b0
[ 1100.852906] RAX: ffffffffffffffda RBX: 00005556c8282760 RCX: 00007fdca32a0c8b
[ 1100.852907] RDX: 000000000000000a RSI: 0000000000000800 RDI: 00005556c82827c8
[ 1100.852908] RBP: 00007ffcd7b4af68 R08: 0000000000000000 R09: 0000000000000000
[ 1100.852908] R10: 00007fdca331cac0 R11: 0000000000000206 R12: 00007ffcd7b4b140
[ 1100.852909] R13: 00007ffcd7b4c79b R14: 00005556c82822a0 R15: 00005556c8282760
2 带参内核模块
内核模块也可以带参数,参数是用户和内核模块通信的一种方式。内核模块中的参数,会在模块文件夹中生成对应的文件,用户可以通过 echo xxx > param_path 改变模块中的参数,进而改变模块的行为。
对于一个可执行的单元来说,往往都是可以传入参数的,通过参数来指定执行体中的行为。函数调用有入参;对于一个可执行程序来说,main() 函数可以有入参;我们在创建一个线程的时候,也可以有入参。内核模块作为一个执行体,也可以指定参数,其它的传参方式本质上都是在函数调用的时候传参,内核模块中的参数使用方式与函数传参是有区别的。
模块参数相关的宏声明在文件 include/linux/moduleparam.h,主要包括以下 3 个, 分别声明一个普通参数,一个数组参数,一个带回调的参数。
module_param(name, type, perm)
module_param_array(name, type, nump, perm)
module_param_cb(name, ops, arg, perm)
**name:**变量名
**type:**变量类型,支持常见的 short,int, long 等常见的基本数据类型,另外还支持以下几种
**bool:**可以赋值为 0 或者非 0 的数,如果赋值为 0,则 cat 这个变量会显示 N,赋值为非 0,则 cat 这个变量会显示 Y;另外, bool 还可以赋值为 y/n, Y/N
invbool: 这种类型也可以出现在 module_param 的第二个形参中,只不过使用 cat 查看值的时候显示的值是和初始化的值相反的,如果初始化赋值为 0 则显示 Y,初始化赋值为 1,则显示 N
hexint: 当在 module_param 中传入这个数据类型时,则变量显示为十六进制
参数类型和实际类型不一致时,编译会报错
**perm:**参数的读写以及可执行权限,如果传入 0,那么该参数在模块参数路径下看不到
模块参数路径是 /sys/module/参数名/parameters/,安装模块之后,在这个路径下就能看到模块中定义的参数。常见的权限组合是 S_IWUSR | S_IRUSR,即用户可读可写,参数不能设置可执行权限,如果设置了可执行权限,在加载模块时会打印错误信息,提示参数权限无效。
#define S_IRUSR 00400 文件所有者可读
#define S_IWUSR 00200 文件所有者可写
#define S_IXUSR 00100 文件所有者可执行
#define S_IRGRP 00040 与文件所有者同组的用户可读
#define S_IWGRP 00020
#define S_IXGRP 00010
**nump:**这个参数指定数组的元素个数, 对于已经声明了数组长度的数组,该配置无效,即如果声明了数组长度是 4,但是这个参数是 2,那么仍然可以向数组中写 4 个数据
**ops:**结构体,主要两个成员是 set 和 get,分别是设置变量的值或者获取变量的值,这两个函数可以使用内核默认的,也可以自己定义,一般 set 使用自己定义的函数,这样方便在回调函数里实现自己的逻辑,get 使用内核默认的函数。cb 参数可以给我们开发者更大的灵活性和自由度。
cpp
struct kernel_param_ops {
/* How the ops should behave */
unsigned int flags;
/* Returns 0, or -errno. arg is in kp->arg. */
int (*set)(const char *val, const struct kernel_param *kp);
/* Returns length written or -errno. Buffer is 4k (ie. be short!) */
int (*get)(char *buffer, const struct kernel_param *kp);
/* Optional function to free kp->arg when module unloaded. */
void (*free)(void *arg);
};
arg: 变量的地址
2.1 示例
如下模块代码中有 4 个参数,这 4 个参数都有用户读写权限。在模块中创建了一个线程,该线程每隔 1s 打印一次 4 个参数的值。模块使用 insmod 安装之后,可以使用 echo 命令修改变量的值,参数修改的结果,可以通过 dmesg 查看线程的打印来确认。
echo 10 > /sys/module/hello/parameters/param_int
echo "hello" > /sys/module/hello/parameters/param_charp
echo 10 > /sys/module/hello/parameters/param_cb_int
echo 1,2,3,4 > /sys/module/hello/parameters/param_array_int
cpp
#include <linux/delay.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/kthread.h>
#include <linux/module.h>
#include <linux/moduleparam.h>
// int 类型的参数
int param_int;
// 数组参数
int param_array_int[4];
// 字符指针参数
char *param_charp;
// 回调参数
int param_cb_int = 0;
// 声明参数 param_int
module_param(param_int, int, S_IWUSR | S_IRUSR);
// 声明字符指针参数,param_charp
module_param(param_charp, charp, S_IWUSR | S_IRUSR);
// 声明数组参数 param_array_init
module_param_array(param_array_int, int, NULL, S_IWUSR | S_IRUSR);
static struct task_struct *test_kthread = NULL;
int notify_param(const char *val, const struct kernel_param *kp) {
int res = param_set_int(val, kp);
printk("cb param addr: %p, arg in kp: %p\n", ¶m_cb_int, kp->arg);
if (res == 0) {
printk(KERN_INFO "call back function called...\n");
printk(KERN_INFO "new value of param_cb_int = %d\n", param_cb_int);
return 0;
}
return -1;
}
// call back 参数的 set 和 get 函数
// get 函数使用系统定义的,set 函数使用自定义的函数
// 当然对于 int 类型的参数, set 函数也可以使用系统定义的函数 param_set_int
const struct kernel_param_ops my_param_ops = {
.set = ¬ify_param,
.get = ¶m_get_int,
};
module_param_cb(param_cb_int, &my_param_ops, ¶m_cb_int, S_IWUSR | S_IRUSR);
static int thread_hello(void *data) {
int i = 0;
int print_seq = 0;
while (!kthread_should_stop()) {
msleep(1000);
printk("---------------- %d ----------------\n", print_seq++);
printk(KERN_INFO "param_int = %d\n", param_int);
printk(KERN_INFO "param_cb_int = %d\n", param_cb_int);
printk(KERN_INFO "param_charp = %s, string addr = %p\n", param_charp, (void *)param_charp);
for (i = 0; i < (sizeof param_array_int / sizeof(int)); i++) {
printk(KERN_INFO "param_array_int[%d] = %d\n", i, param_array_int[i]);
}
}
return 0;
}
static int __init hello_init(void) {
int i = 0;
test_kthread = kthread_run(thread_hello, NULL, "hello_test");
printk(KERN_INFO "param_int = %d \n", param_int);
printk(KERN_INFO "param_cb_int = %d \n", param_cb_int);
printk(KERN_INFO "param_charp = %s \n", param_charp);
for (i = 0; i < (sizeof param_array_int / sizeof(int)); i++) {
printk(KERN_INFO "param_array_int[%d] = %d\n", i, param_array_int[i]);
}
printk(KERN_INFO "kernel module inserted successfully...\n");
return 0;
}
static void __exit hello_exit(void) {
if (test_kthread) {
kthread_stop(test_kthread);
}
printk(KERN_INFO "Kernel Module Removed Successfully...\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("wx2");
MODULE_DESCRIPTION("test module param");
MODULE_VERSION("1.0");
bash
obj-m += hello.o
KDIR = /lib/modules/$(shell uname -r)/build
all:
make -C $(KDIR) M=$(shell pwd) modules
clean:
make -C $(KDIR) M=$(shell pwd) clean
dmesg 查看打印结果如下,内核模块安装之后,参数默认都是 0;设置参数之后,可以看到打印的参数与设置的参数是一致的。
3 模块间依赖
假如有两个模块 A 和 B,B 中调用了 A 中的函数,或者使用了 A 中的全局变量,那么就认为 B 依赖 A。
3.1 手动指定
如下两个模块 A 和 B,A 中定义了函数 func1,并使用 EXPORT_SYMBOL() 将符号导出,B 中使用 extern 声明了 func1(),并且调用了 func1(),所以说 B 依赖 A。这种情况下,想要安装模块 B,需要先安装模块 A。如果没有安装 A 的情况下就安装 B,那么 B 会安装失败,错误信息 "Unknown symbol in module"。
相同的符号只能导出一次,假如有两个模块都导出了符号 func1, 那么加载第二个模块的时候会加载失败,dmesg 中错误信息 "exports duplicate symbol func1 (owned by dep2)"。
内核中导出的符号可以在 /proc/kallsyms 中看到,模块中导出的符号,也可以在其中查看到。
cpp
// mod_a.c
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/moduleparam.h>
int param_int = 0;
module_param(param_int, int, S_IWUSR | S_IRUSR);
int func1(void)
{
printk("In Func: %s...\n", __func__);
return 0;
}
// 导出函数符号,在较老的内核版本上,导出的符号可以声明为 static 类型
// 从内核的某个版本之后,被导出的符号不能声明为 static 类型
EXPORT_SYMBOL(func1);
static int __init hello_init(void)
{
printk("module a init, param_int: %d\n", param_int);
return 0;
}
static void __exit hello_exit(void)
{
printk("module a exit\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("wx2");
MODULE_DESCRIPTION("module a");
MODULE_VERSION("1.0");
Makefile:
obj-m += mod_a.o
KDIR = /lib/modules/$(shell uname -r)/build
all:
make -C (KDIR) M=(shell pwd) modules
clean:
make -C (KDIR) M=(shell pwd) clean
cpp
// mod_b.c
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>
extern int func1(void);
static int func2(void)
{
func1();
printk("in func: %s...\n", __func__);
return 0;
}
static int __init hello_init(void)
{
printk("module b init\n");
func2();
return 0;
}
static void __exit hello_exit(void)
{
printk("module b exit\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("wx2");
MODULE_DESCRIPTION("module b");
MODULE_VERSION("1.0");
Makefile:
obj-m += mod_b.o
KDIR = /lib/modules/$(shell uname -r)/build
mod_b 依赖 mod_a 中的符号,那么需要配置 KBUILD_EXTRA_SYMBOLS
如果不指定,那么在编译的时候会报错,提示符号找不到
KBUILD_EXTRA_SYMBOLS=/home/wyl/mod/a/Module.symvers
all:
make -C (KDIR) M=(shell pwd) modules
clean:
make -C (KDIR) M=(shell pwd) clean
除了上边声明 KBUILD_EXTRA_SYMBOLS 的方式,也可以将 mod_a.c 和 mod_b.c 放到一个文件夹下,然后 Makefile 如下,这样可以一次编译出来两个模块。
obj-m += mod_a.o mod_b.o
KDIR = /lib/modules/$(shell uname -r)/build
all:
make -C (KDIR) M=(shell pwd) modules
clean:
make -C (KDIR) M=(shell pwd) clean
3.1 depmod 和 modprobe
depmod 可以自动发现模块之间的依赖关系。使用 depmod 之前,需要把内核模块拷贝到 /lib/modules/$(uname -r) 目录下或者这个目录的子目录下。拷贝结果截图如下:
拷贝内核模块之后执行 depmod,便会自动生成模块之间的依赖关系,模块依赖关系保存到了 /lib/modules/$(uname -r)/modules.dep 中。mod_b 依赖 mod_a,在文件中使用冒号来表示,被依赖的模块放在冒号后边。
内核模块的的 .ko 文件也是 elf 文件格式,在 elf 文件中可以确定一个函数符号是在本文件中定义的还是在其它文件中定义的。depmod 就是通过函数符号是不是在本 .ko 文件中定义来确定模块之间的依赖。
使用 objdump -tT xxx 可以查看一个二进制文件中的符号。如下图所示,在 mod_a.ko 中 func1 显示为 F,表示函数;在 mod_b.ko 中 func1 显示为 UND,表示没有在这个模块中定义。
执行 depmod 之后,就可以使用 modprobe 直接安装 mod_b,modprobe 安装 mod_b 的时候会首先检查依赖关系,发现 mod_b 依赖 mod_a,便会先安装 mod_a 再安装 mod_b。
使用 modprobe 安装模块时,只需要指定模块名即可,不需要指定模块文件的全路径,也不需要在模块名后边带后缀 .ko;而使用 insmod 安装模块时,需要指定模块的全路径,也需要带着模块的后缀 .ko。
使用 depmod 和 modprobe 来管理模块之间的依赖,类似于在 ubuntu 上使用 apt 来安装软件,apt 安装软件的时候也会自动去判断软件的依赖,先安装这个软件包的依赖,最后再安装这个软件包。
3.2 有依赖关系的模块参数
如上边例子所示,mod_b 依赖 mod_a,在使用 modprobe 安装 mod_b 的时候,怎么给 mod_a 传递参数呢。
执行 modprobe mod_b param_int=100,这种方式无法将参数传递给 mod_a。
通过如下方式给 mod_a 传递参数:
① 在目录 /etc/modprobe.d 中添加一个文件,比如 mod_a.conf
② 在 mod_a.conf 中添加如下内容,在执行 moprobe mod_a 的时候便会将 mod_a 中的 param_int 初始化成 10。
options mod_a param_int=10
模块参数配置文件 xxx.conf 可以放在 /etc/modprobe.d 或者 /usr/lib/modprobe.d 中,当两个目录中存在相同的参数时,优先选择 /etc/modprobe.d 中的配置。