深入浅出:从零开始编写 Linux 可加载内核模块 (LKM) 与设备驱动入门

在 Linux 内核开发的浩瀚宇宙中,我们经常听到这样一个既定事实:对于 Linux 设备驱动程序的开发,我们实际上只能选择两种语言——汇编和 C。汇编语言虽然强大,但它主要用于实现 Linux 内核中最底层的、与硬件直接相关的部分;而 C 语言,凭借其高效性和对底层内存操作的灵活性,承担了内核中绝大部分依赖于特定体系结构的逻辑实现。

然而,随着系统复杂度的增加,将所有的驱动代码都静态编译进内核镜像已经不再现实。这时,“可加载内核模块”便登上了舞台。在本文中,我们将深入探讨 LKM 的奥秘,纠正一些常见的概念误区,并带你一步步编写、构建和运行属于你的第一个内核模块。准备好深入内核空间了吗?让我们开始吧。

什么是内核模块?

许多初学者容易混淆“内核模块”与普通应用程序的“模块”,或者将其与静态编译进内核的“模块”混为一谈。为了准确起见,我们通常将动态加载的部分称为“可加载内核模块”或简称 LKM。

这里有一个常见的误解:有些人认为 LKM 运行在内核之外,仅仅是连接到内核的外部插件。这是一个错误的观念。实际上,一旦 LKM 被加载,它就成为了内核内存映像不可分割的一部分,拥有与核心内核相同的运行权限。为了区分,我们将启动时加载的那个基础内核镜像称为“基础内核”。LKM 的作用,就是在不重新编译整个内核的情况下,动态地向这个基础内核“注入”新的功能。

步骤 1:编写代码——你的第一个“Hello World”

在用户空间编程时,我们习惯使用 INLINECODE21e7dcc1 输出信息。但在内核空间,我们不能使用标准 C 库。相反,我们必须使用内核提供的 INLINECODEbab613f4 函数。让我们通过经典的“Hello World”程序来揭开内核开发的神秘面纱。

代码示例:Learn.c

/* 必要的头文件 */
#include    // 用于初始化和清理宏的宏定义
#include  // 包含内核常用的类型、函数和宏定义
#include  // 加载模块所需的核心头文件

/* 模块元数据 */
// 告诉内核该模块受 GPL 协定保护,这是开源内核模块的标准做法
MODULE_LICENSE("GPL");
// 模块版本号描述
MODULE_VERSION("4.15.0-133-generic");

/**
 * start - 模块加载时的入口函数
 * 返回值:0 表示成功,非 0 表示失败
 */
static int __init start(void)
{
    // KERN_INFO 是日志级别,表示这是一条信息性日志
    printk(KERN_INFO "Loading module....");
    printk(KERN_INFO "Hello World....");
    return 0;
}

/**
 * end - 模块卸载时的出口函数
 * 无返回值
 */
static void __exit end(void)
{
    printk(KERN_INFO "Bye World....");
}

// 注册初始化和清理函数
module_init(start);
module_exit(end);

深入解析代码

让我们详细剖析一下上面的代码,因为理解这些细节对于编写稳定的驱动至关重要。

  • 头文件引用

* module.h 是编写任何模块都必须包含的,它定义了加载和卸载模块所需的函数接口。

* INLINECODE70356c50 包含了 INLINECODEee001e6f 和 module_exit 宏,用于指定模块的入口和出口点。

  • 模块许可证

* MODULE_LICENSE("GPL") 至关重要。如果不声明 GPL 许可证,内核会认为你的模块是“专有”的,从而拒绝调用某些仅对 GPL 开放的内核符号。这在实际开发中常常导致模块加载时出现“Unknown symbol”错误。

  • 初始化函数 start

* 我们使用 __init 宏修饰这个函数。这告诉内核,该函数仅在初始化时使用,一旦初始化完成,内核可以将这部分内存释放掉,从而节省宝贵的系统内存。

* 函数返回 int。记住,内核模块的初始化必须返回状态码。如果这里返回非零值,内核会认为模块加载失败,并终止加载过程。

  • 退出函数 end

* 使用 __exit 宏修饰。当模块被卸载时,这个函数会被调用。

* 它没有返回值,也不能被静态编译进内核的驱动调用。

步骤 2:构建内核模块——编写 Makefile

与用户空间的 C 程序不同,我们不能简单地使用 gcc -o learn learn.c 来编译内核模块。Linux 内核使用了一套极其复杂的构建系统。为了构建模块,我们需要编写一个特殊的 Makefile,它会调用内核的构建系统。

Makefile 示例

# obj-m 告诉内核构建系统我们需要构建一个名为 learn.o 的目标文件
# 最终会生成 learn.ko (Kernel Object)
obj-m += learn.o

all:
	# 调用内核构建系统
	# make -C 切换到内核构建目录 (通常是 /lib/modules/$(shell uname -r)/build)
	# M=$(PWD) 告诉内核构建系统回到当前目录查找模块源码
	# modules 是目标,表示构建模块
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

# 清理生成的文件
clean:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

构建过程发生了什么?

  • 编译:当你运行 make 命令时,构建系统首先预处理你的代码,处理内核头文件复杂的依赖关系。
  • 生成 .ko 文件:生成的 learn.ko 文件不仅包含了编译后的二进制代码,还包含了我们在代码中定义的模块信息(作者、许可证等)。
  • 生成 .mod 文件:这是一个中间文件,列出了模块所依赖的其他模块,这对于解决依赖关系至关重要。

实用提示:在开发过程中,你可能会频繁修改代码。务必在 Makefile 中包含 INLINECODEb973e413 目标,以便快速清理之前生成的 INLINECODE2cd7f6b5 和 .ko 文件,确保重新编译的干净性。

步骤 3:加载与卸载——与内核交互

现在我们手头上有了 INLINECODE3babb731 文件,是时候把它加载到内核中去了。我们需要使用几个关键命令:INLINECODE7abd6ade、INLINECODEe607b915 和 INLINECODE3e32bda3。

1. 加载模块:insmod

INLINECODE35b444d0 命令将模块的代码和数据段加载到内核内存中。这不仅仅是复制文件,它还会解析模块的符号表,并执行我们在 INLINECODE0ef59709 中定义的 start 函数。

sudo insmod learn.ko

如果一切顺利,命令执行后应该没有任何输出。但这并不代表什么都没发生。内核已经默默地在后台执行了我们的 INLINECODE9e1f71a8 函数。INLINECODEf886d87e 输出的信息不会直接显示在终端,而是被写入内核环形缓冲区。

2. 查看内核日志:dmesg

为了验证我们的模块是否真的加载成功,我们需要查看内核日志。dmesg 命令是查看内核日志缓冲区的标准工具。

sudo dmesg | tail -10

实战经验:有时候,你的模块加载失败可能是因为内核版本不匹配,或者是缺少特定的函数符号。INLINECODEe91340d1 通常会给出具体的错误信息,例如 “Unknown symbol” 或 “disagrees about version of symbol modulelayout”。当你遇到问题时,第一时间查看 dmesg 是解决问题的最佳途径。

3. 卸载模块:rmmod

当我们完成了模块的测试工作,或者需要重新加载更新后的版本时,我们需要将其从内核中移除。

sudo rmmod learn

执行 INLINECODEb5fea14c 后,内核会触发我们在代码中定义的 INLINECODE8018a776 函数(即 INLINECODEe65f4fb3)。此时,INLINECODE4178ae6c 会打印 “Bye World….”。同样,我们可以通过 dmesg 来确认这一消息。

进阶实战:添加更多功能

仅仅打印“Hello World”显然不够过瘾。让我们优化一下代码,增加一些实用性,并展示模块参数的用法。

进阶示例:带参数的模块

在真实场景中,我们通常希望在模块加载时传入参数。例如,指定设备名称或调试级别。

#include 
#include 
#include 

// 定义模块变量和权限
static int my_int_param = 0;
static char *my_string_param = "default";

// 注册模块参数:参数名, 变量指针, 权限
// S_IRUGO: 只有 root 可以读取
module_param(my_int_param, int, S_IRUGO);
MODULE_PARM_DESC(my_int_param, "An integer parameter");

module_param(my_string_param, charp, S_IRUGO);
MODULE_PARM_DESC(my_string_param, "A string parameter");

static int __init init_func(void)
{
    printk(KERN_INFO "Advanced Module Loaded!
");
    printk(KERN_INFO "Integer value: %d
", my_int_param);
    printk(KERN_INFO "String value: %s
", my_string_param);
    return 0;
}

static void __exit exit_func(void)
{
    printk(KERN_INFO "Advanced Module Unloaded.
");
}

module_init(init_func);
module_exit(exit_func);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple parameterized module.");

测试方法:

# 加载模块并传入参数
sudo insmod advanced.ko my_int_param=42 my_string_param="Geeks"

# 查看结果
sudo dmesg | tail -5

性能优化与最佳实践

作为一名内核开发者,你需要比写应用代码更加谨慎,因为内核中的一个错误就可能导致系统崩溃。以下是一些实战中的建议:

  • 谨慎使用全局变量:内核空间是共享的。全局变量如果不加锁保护,在多核处理器上极易引发竞态条件。
  • 不要使用浮点运算:在内核中进行浮点运算需要手动保存和恢复浮点寄存器,这不仅麻烦而且性能极差,除非你必须,否则尽量避免。
  • 内存分配限制:不要使用用户空间的 INLINECODEfe94d570。在内核中,我们使用 INLINECODEf86a037d 和 kfree。更重要的是,永远不要在持有自旋锁的情况下调用可能引起睡眠的函数,否则可能导致内核死锁。
  • 模块通信:如果你想从用户空间与你的模块交互(例如发送控制指令),你可以使用 INLINECODEe5bb7543 文件系统或 INLINECODE6a038135,而不是仅限于 dmesg

总结

在这篇文章中,我们一起从零开始,编写了第一个 Linux 可加载内核模块。我们纠正了关于 LKM 的概念误区,分析了 INLINECODE35ed0bea 和 INLINECODEe40a3e87 的机制,并通过 Makefile 将其编译为 .ko 文件。最后,我们还进阶学习了如何传递参数并验证了模块的运行。

掌握 LKM 只是 Linux 设备驱动开发的起点。从这里出发,你将探索字符设备、并发控制、中断处理以及更复杂的硬件驱动。记住,内核开发是一门需要耐心和细心的艺术,但一旦看到你的代码在操作系统最底层运行,那种成就感是无与伦比的。

接下来,你可以尝试编写一个字符设备驱动,尝试通过 /dev 接口与用户空间进行数据交换,开启你的内核编程之旅吧。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。如需转载,请注明文章出处豆丁博客和来源网址。https://shluqu.cn/37932.html
点赞
0.00 平均评分 (0% 分数) - 0