内核模块开发:从Hello World到参数传递

说实话,很多初学者一上来就被内核源码的庞大体积吓住了。几千万行代码,怎么看?我的建议是——别急着看全部,先写一个内核模块。通过模块开发,你能快速理解内核的运作机制,而且每次修改只需要编译模块,不用重新编译整个内核。

这一章,我们就从零开始,写一个完整的内核模块。包括怎么编译、怎么加载卸载,以及怎么给模块传递参数。

2.1 内核模块的基本结构

一个最简单的内核模块,至少需要两个函数:模块初始化函数和模块退出函数。前者在模块加载时执行,后者在卸载时执行。

来看一个经典的Hello World模块:

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

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, Kernel World!\n");
    return 0;
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, Kernel World!\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple Hello World module");

这里有几个关键点:

  • __init 和 __exit 是内核的宏,告诉编译器这些函数只在初始化/退出时使用,之后可以释放内存
  • module_init() 和 module_exit() 注册入口和出口函数
  • printk() 是内核版的printf,输出到内核日志
  • MODULE_LICENSE 必须声明,否则内核会认为模块是“脏”的
我的习惯:每次写模块,我都会先写好MODULE_LICENSE和MODULE_DESCRIPTION。这不仅是规范,更是一种好习惯——万一模块出问题,你能快速知道它是干什么的。

2.2 编译内核模块

内核模块的编译和普通程序不一样。你不能直接用gcc,而是要用内核的构建系统——Kbuild。说白了,就是写一个Makefile,让内核源码树的Makefile来帮你编译。

标准的Makefile长这样:

obj-m := hello.o

KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

all:
    $(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
    $(MAKE) -C $(KDIR) M=$(PWD) clean

解释一下:

  • obj-m := hello.o 告诉内核,我要编译一个名为hello.ko的模块
  • KDIR 指向当前运行内核的源码树
  • -C $(KDIR) 切换到内核源码目录执行make
  • M=$(PWD) 指定模块源码在当前目录

然后执行 make,就会生成 hello.ko 文件。

我曾经踩过的坑:有一次我忘了安装内核头文件,结果编译报错说找不到<linux/module.h>。记住,/lib/modules/$(uname -r)/build 这个目录必须存在。如果没有,执行 sudo apt-get install linux-headers-$(uname -r)。

2.3 加载与卸载模块

编译出 .ko 文件后,就可以加载到内核了。常用的命令就三个:

命令 作用 示例
insmod 加载模块 sudo insmod hello.ko
rmmod 卸载模块 sudo rmmod hello
lsmod 列出已加载模块 lsmod | grep hello
modinfo 查看模块信息 modinfo hello.ko

加载后,用 dmesg | tail 就能看到我们打印的 "Hello, Kernel World!"。

这里有个细节:insmod 和 modprobe 有什么区别?insmod 是直接加载,不处理依赖;modprobe 会自动解决依赖关系。我一般调试时用 insmod,部署时用 modprobe。

2.4 模块参数传递

写模块时,你肯定不希望把参数写死在代码里。内核提供了 module_param() 宏,让你可以在加载时传递参数。

来看一个带参数的模块:

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

static int count = 1;
static char *name = "world";

module_param(count, int, 0644);
module_param(name, charp, 0644);

MODULE_PARM_DESC(count, "Number of times to print");
MODULE_PARM_DESC(name, "Name to greet");

static int __init param_init(void)
{
    int i;
    for (i = 0; i < count; i++)
        printk(KERN_INFO "Hello, %s!\n", name);
    return 0;
}

static void __exit param_exit(void)
{
    printk(KERN_INFO "Goodbye, %s!\n", name);
}

module_init(param_init);
module_exit(param_exit);

MODULE_LICENSE("GPL");

加载时这样传参:

sudo insmod param.ko count=3 name="Kernel"

参数说明:

  • module_param(name, type, perm):name是参数名,type是类型(int, charp, bool等),perm是文件权限
  • perm 设为0表示不在sysfs中暴露,设为0644表示可以在 /sys/module/param/parameters/ 下查看和修改
  • MODULE_PARM_DESC 为参数添加描述,modinfo 时能看到
为什么perm很重要?如果你把perm设为0644,那么模块加载后,你可以在 /sys/module/<模块名>/parameters/<参数名> 下动态修改参数值。这在调试时非常方便,不用卸载重载模块。

2.5 内核模块开发完整流程

我把整个流程总结成一张图,方便你对照操作:

内核模块开发完整流程 内核模块开发完整流程 编写模块源码 编写Makefile 执行 make 编译 insmod 加载模块 dmesg 验证输出 常见问题 • 缺少内核头文件 • 模块许可证未声明 • 符号版本不匹配 • 参数类型错误

2.6 调试技巧与避坑指南

写模块和写用户态程序不一样,出错了可能就是内核崩溃。我总结几个实用技巧:

我曾经犯过的错:有一次我在模块里写了个死循环,加载后系统直接卡死。后来我学乖了,调试时先用 printk 打日志,确认逻辑没问题再上复杂操作。记住,内核没有保护,一个野指针就能让系统重启。
  • 多用printk:它是内核调试的瑞士军刀。不同优先级用 KERN_EMERG 到 KERN_DEBUG,默认是 KERN_WARNING
  • 查看日志:dmesg -w 可以实时查看内核日志,比反复敲 dmesg | tail 方便多了
  • 模块依赖:用 modinfo 查看模块的 depends 字段,确保依赖模块已加载
  • 安全卸载:如果 rmmod 报错说模块正忙,用 lsmod | grep <模块名> 看看谁在用
我的调试三板斧:第一,printk 打关键路径;第二,modinfo 检查模块信息;第三,dmesg 看错误日志。这三招能解决90%的问题。

2.7 模块参数的高级用法

除了基本类型,内核还支持数组参数和字符串参数。来看一个数组参数的例子:

static int arr[3] = {1, 2, 3};
static int arr_argc = 0;

module_param_array(arr, int, &arr_argc, 0644);
MODULE_PARM_DESC(arr, "An array of integers");

加载时这样传:

sudo insmod array.ko arr=10,20,30

这里 arr_argc 会记录实际传入的元素个数。这个技巧在需要动态配置的模块中非常实用。

嗯,这一章的内容就到这里。模块开发是理解内核的第一步,也是后续分析内核源码的基础。你写几个模块练练手,自然就熟悉了。