第2章:内核模块编程入门

大家好,我是你们的Linux内核驱动讲师。今天我们来聊聊内核模块编程的入门知识。说实话,我刚开始学内核模块时,也觉得这东西挺神秘的——一个能在系统运行时动态加载的代码片段,想想就觉得很酷。

2.1 模块的加载与卸载

内核模块,说白了就是一段可以在系统运行时插入内核的代码。你不需要重新编译整个内核,也不需要重启系统。我个人觉得,这是Linux内核设计中最优雅的特性之一。

加载和卸载模块的命令很简单:

# 加载模块
insmod hello.ko

# 卸载模块
rmmod hello

# 查看已加载的模块
lsmod

嗯,这里要注意一点:insmod后面跟的是文件名(带.ko后缀),而rmmod后面跟的是模块名(不带后缀)。我在项目中遇到过好几次,同事把rmmod hello.ko写上去,结果报错说找不到模块。

避坑指南:我曾经因为忘记先卸载旧模块就加载新版本,导致内核崩溃。记住:加载新模块前,一定要先rmmod卸载旧的。

2.2 module_init / module_exit 宏

每个内核模块都必须有两个基本函数:初始化和退出函数。这两个函数通过module_initmodule_exit宏来注册。

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

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

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

module_init(hello_init);
module_exit(hello_exit);

你想想看,为什么用__init__exit标记?其实这两个宏告诉内核:初始化函数在运行完后可以释放掉,退出函数在模块卸载时才会用到。这是内核的一种优化手段。

小技巧:我个人习惯在初始化函数中打印一条"module loaded"的消息,这样调试时能清楚知道模块是否加载成功。

2.3 LICENSE与作者信息

内核模块必须声明许可证,否则内核会认为你使用了非GPL兼容的许可证,从而限制你使用某些内核符号。

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name <your@email.com>");
MODULE_DESCRIPTION("A simple hello world module");
MODULE_VERSION("1.0");

为什么非要声明GPL?说白了,内核核心代码都是GPL协议的,如果你的模块不声明GPL兼容许可证,内核就不让你调用那些标记为EXPORT_SYMBOL_GPL的函数。我在项目中遇到过,有个第三方驱动没加GPL声明,结果编译通过但加载时一堆警告,功能也不正常。

作用
MODULE_LICENSE 声明许可证类型,推荐使用"GPL"
MODULE_AUTHOR 作者信息,方便维护
MODULE_DESCRIPTION 模块功能描述
MODULE_VERSION 版本号,方便追踪

2.4 printk调试技巧

printk是内核版的printf,但它比printf复杂得多。为什么?因为printk的输出可以有不同的日志级别。

printk(KERN_EMERG   "系统崩溃级别\n");
printk(KERN_ALERT   "需要立即处理\n");
printk(KERN_CRIT    "严重情况\n");
printk(KERN_ERR     "错误情况\n");
printk(KERN_WARNING "警告\n");
printk(KERN_NOTICE  "正常但重要\n");
printk(KERN_INFO    "信息性消息\n");
printk(KERN_DEBUG   "调试消息\n");

我个人最常用的就是KERN_INFOKERN_ERR。调试时用KERN_DEBUG,但要注意:默认情况下,KERN_DEBUG级别的消息不会显示在控制台上。

核心技巧:查看printk输出的命令是dmesg。我习惯在调试时开两个终端:一个运行模块,另一个用dmesg -w实时查看内核日志。

为什么会这样?因为printk的输出是写到内核环形缓冲区,而不是直接写到终端。所以你需要用dmesg来查看。嗯,这里有个坑:环形缓冲区大小有限,如果打印太多消息,旧消息会被覆盖。

2.5 模块参数传递

有时候你希望模块加载时能传递一些参数,比如设备数量、调试级别等。内核模块支持通过module_param宏来声明参数。

#include <linux/moduleparam.h>

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

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

MODULE_PARM_DESC(count, "设备数量");
MODULE_PARM_DESC(name, "设备名称");

加载时传递参数:

insmod hello.ko count=5 name="mydevice"

你想想看,为什么要有0644这个权限值?它表示这个参数在/sys/module/hello/parameters/目录下对应的文件权限。0644表示所有者可读写,其他人只读。这样你可以在运行时通过读写这些文件来修改参数值。

实用建议:我在项目中经常用模块参数来控制调试信息的输出级别。比如定义一个debug参数,值为0时只打印错误,值为1时打印信息,值为2时打印所有调试信息。这样不用重新编译模块,就能灵活控制日志输出。

2.6 完整示例:一个带参数的可加载模块

好了,我们把今天学到的知识整合起来,写一个完整的模块:

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

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

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

MODULE_PARM_DESC(count, "设备实例数量");
MODULE_PARM_DESC(name, "设备名称前缀");

static int __init mymodule_init(void)
{
    int i;
    
    printk(KERN_INFO "模块加载开始\n");
    printk(KERN_INFO "设备名称: %s, 数量: %d\n", name, count);
    
    for (i = 0; i < count; i++) {
        printk(KERN_DEBUG "创建设备实例 %d\n", i);
    }
    
    return 0;
}

static void __exit mymodule_exit(void)
{
    printk(KERN_INFO "模块卸载,再见!\n");
}

module_init(mymodule_init);
module_exit(mymodule_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("一个带参数的内核模块示例");
MODULE_VERSION("1.0");

编译这个模块,然后试试:

insmod mymodule.ko count=3 name="sensor"
dmesg | tail -10

你应该能看到模块打印的信息。然后卸载:

rmmod mymodule
dmesg | tail -5

嗯,到这里,你已经掌握了内核模块编程的基本功。说实话,这些内容虽然基础,但我在实际项目中每天都在用。下一章我们会深入探讨字符设备驱动的实现,到时候这些知识都会派上用场。

本章小结:
  • 模块加载用insmod,卸载用rmmod,查看用lsmod
  • module_init和module_exit是模块的入口和出口
  • 必须声明MODULE_LICENSE,推荐使用"GPL"
  • printk有8个日志级别,用dmesg查看输出
  • module_param可以在加载时传递参数

记住:写内核模块不像写用户态程序,一个空指针就可能让整个系统崩溃。所以,小心谨慎,多打印调试信息,慢慢来。