freeCodeCamp.org

How to Write a Linux Kernel Module That Actually Builds

8.5内容质量
How to Write a Linux Kernel Module That Actually Builds

TL;DR · AI 摘要

本文详解Linux内核模块构建全过程,揭示Makefile复杂性与.ko文件组成,提供2014年后仍有效的实践方法。

核心要点

  • 内核模块构建后95%体积来自调试信息而非代码
  • Makefile需匹配当前内核版本和编译器
  • 模块加载失败常因vermagic版本不匹配

结构提纲

按章节快速跳转。

  1. 揭示内核模块构建的隐藏复杂性,对比传统教程的不足

  2. 详细说明Debian/Ubuntu/Fedora/Arch系统的依赖安装方法

  3. ›Makefile解析

    解释Makefile中OBJMAPPING和模块签名的特殊规则

  4. 展示从源码到.ko文件的完整构建链路和文件结构

  5. 分析.ko文件中导出符号与未解析符号的关联关系

  6. 列举4类典型构建错误及其对应的解决方案

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • Linux内核模块构建
    • 代码结构
      • 模块初始化函数
      • 模块退出函数
    • 构建过程
      • Makefile配置
      • 符号表生成
      • 调试信息处理
    • 关键概念
      • vermagic验证
      • .ko文件结构

金句 / Highlights

值得收藏与分享的关键句。

#Linux#内核模块#Makefile#系统编程
打开原文

如何编写一个真正能构建的Linux内核模块

2026年9月15日

/

#Kernel

Chris Roy

Linux内核模块是一段可以加载到正在运行的内核中的小代码,而无需重新构建整个内核。

这听起来似乎很简单,但即使是最小的模块也会围绕它产生大量机制:目标文件、元数据、导出和未解决的符号,以及一个与普通可执行文件截然不同的最终.ko文件。

以下是一个完整且可工作的Linux内核模块。它只有22行,其中7行是包含文件和元数据:

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

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Chris Roy");
MODULE_DESCRIPTION("A minimal loadable kernel module");
MODULE_VERSION("0.1");

static int __init hello_init(void)
{
    pr_info("hello: loaded, module at %pS\n", hello_init);
    return 0;
}

static void __exit hello_exit(void)
{
    pr_info("hello: unloaded\n");
}

module_init(hello_init);
module_exit(hello_exit);

在我编写此内容的机器上编译,这会生成一个约106,000字节的文件。剥离调试信息后,相同模块的大小为4,864字节。构建结果中95%的内容都不是代码。

你的结果会与我的不同,且差异不可预测。其中一部分原因是构建位置:调试信息记录了你编译的目录,因此深层嵌套路径会增加几百字节,而短路径则不会。你的编译器版本和内核配置会进一步影响结果。保持比例不变,确切的字节数仅取决于当前机器。

这个差距是一个很好的起点,因为大多数内核模块教程会向你展示上面的代码列表,告诉你运行make,然后就停止了。

本文将遵循实际构建生成的内容,说明在你编写任何有用代码之前模块已经依赖的内容,以及解释你从2014年找到的教程为何不再能编译。

目录

  • 你需要的准备
  • 能工作的最小模块
  • Makefile看起来比它实际更奇怪
  • 构建实际做了什么
  • .ko文件内部结构
  • 你的"Hello World"已经依赖了三件事
  • vermagic以及你的模块为何拒绝加载
  • 加载时传递参数
  • 加载模块及输出去向
  • 四个构建错误及其含义
  • 你找到的教程为何无法编译
  • 结论
  • 尾声

你需要的准备

要跟随本文的步骤,你需要一台愿意加载代码的Linux机器、你正在运行的内核头文件以及一个编译器。

在Debian或Ubuntu上:

code
sudo apt install build-essential linux-headers-$(uname -r)

在Fedora上,等效命令是安装kernel-devel和kernel-headers包,在Arch上则是与你的内核匹配的linux-headers包。

检查头文件是否出现在构建期望的位置:

code
ls -d /lib/modules/$(uname -r)/build

该路径是头文件包的符号链接,它的缺失是模块构建失败时最常见原因(错误信息中未提及头文件)。

即使模块构建成功,仍有两件事会阻止你加载它。Secure Boot会拒绝未签名的模块,而内核锁定机制会在保密模式下阻止加载。检查这两项:

code
mokutil --sb-state
cat /sys/kernel/security/lockdown

在当前这台机器上,Secure Boot 已被禁用,lockdown 报告显示 [none] 完整性和机密性,方括号标记了活动模式。如果你的系统显示 Secure Boot 已启用,需要在固件中对模块进行签名或禁用 Secure Boot,否则模块无法加载。

我正在使用 Ubuntu 22.04,内核版本为 5.15.0-190-generic,gcc 版本为 11.4。你的版本会有所不同,文章中会在相关位置说明这一点。

能正常工作的最小模块

将本文开头的代码保存为 hello.c。再次提供参考:

其中四项内容正在执行实际工作。

module_init 和 module_exit 用于注册内核在加载和卸载模块时调用的函数。它们不是 main 函数。模块没有单一的入口点来运行并返回,而是通过两个特定事件触发的钩子函数来执行操作,除非有其他代码调用它,否则中间不会执行任何操作。

__init 和 __exit 是节标记。__init 告诉内核这段代码只运行一次,之后其内存可以被释放,这就是为什么你的启动日志中会出现 "Freeing unused kernel memory"。__exit 告诉构建系统这个函数仅在模块可以被卸载时才需要。

MODULE_LICENSE("GPL") 不是形式主义。内核在加载时会检查它,声明非 GPL 许可证的模块将无法访问标记为 EXPORT_SYMBOL_GPL 的符号,而这些符号大多是有趣的接口。完全省略这个宏会导致内核污染自身并记录警告信息。

pr_info 是 printk(KERN_INFO ...) 的现代写法。它将信息写入内核环形缓冲区,而不是终端,这会让几乎所有人在第一次使用时感到困惑。

MODULE_AUTHOR、MODULE_DESCRIPTION 和 MODULE_VERSION 宏是元数据而非行为,它们最终会出现在 modinfo 可读取的文件中。省略它们不会导致任何问题,但近期内核在构建时会警告缺少 MODULE_DESCRIPTION,这已经足够理由从一开始就编写这三个宏。

Makefile 的真实面貌

code
obj-m += hello.o

all:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

这看起来像一个 Makefile,但本质上并不是。obj-m += hello.o 不是你定义的 Make 变量,而是被 kbuild(内核自己的构建系统)读取的声明。

make -C 这一行会进入内核头文件目录并运行内核构建系统,通过传递 M=$(PWD) 参数来说明 "模块源代码在这里"。你的 Makefile 只是一个薄包装器,将任务委托给你未编写且难以替换的构建系统。

这种间接性就是为什么模块构建会以看似与你的代码无关的方式失败。你不是像编译 libc 头文件那样直接编译内核头文件,而是在使用内核的构建系统,用它的标志和规则来构建你的文件。

构建过程实际执行的操作

运行 make 并阅读输出,而不是跳过它:

code
make -C /lib/modules/5.15.0-190-generic/build M=/home/chris/lkm modules
make[1]: Entering directory '/usr/src/linux-headers-5.15.0-190-generic'
  CC [M]  /home/chris/lkm/hello.o
  MODPOST /home/chris/lkm/Module.symvers
  CC [M]  /home/chris/lkm/hello.mod.o
  LD [M]  /home/chris/lkm/hello.ko
  BTF [M] /home/chris/lkm/hello.ko
Skipping BTF generation for /home/chris/lkm/hello.ko due to unavailability of vmlinux

总共五个步骤,只有第一个是你预期的编译过程。

CC [M] hello.o 编译你的源代码。这是普通流程。

MODPOST 是一个值得关注的步骤。它会扫描你的目标文件,查找你引用但未定义的符号,然后将每个符号与内核的导出符号表进行比对。如果使用了内核未提供的功能,构建过程将失败。此外,它还会生成 hello.mod.c 文件,这是一个包含模块元数据的小型胶合文件。

CC [M] hello.mod.o 会编译这个生成的胶合文件,而 LD [M] 则会将其与你的目标文件链接,最终生成 .ko 文件。

BTF [M] 会附加用于追踪工具的类型信息。在此案例中被跳过,因为生成 BTF 需要未压缩的 vmlinux 镜像,而 Ubuntu 默认不提供该文件。构建过程会发出警告并继续执行,这是正确的做法:BTF 有其用途,但并非必需。

.ko 文件内部结构

.ko 是一个普通的 ELF 对象文件,但附加了内核专用的段。查看其元数据:

code
modinfo ./hello.ko
code
version:        0.1
description:    A minimal loadable kernel module
author:         Chris Roy
license:        GPL
srcversion:     39D86510C9FF65D797EAF90
depends:        
retpoline:      Y
name:           hello
vermagic:       5.15.0-190-generic SMP mod_unload modversions

所有这些信息都存储在一个 ELF 段中,以空字符分隔的字符串形式保存。你可以直接从文件中读取:

code
objcopy -O binary --only-section=.modinfo hello.ko /dev/stdout | tr '\0' '\n'

这让我们回到开头提到的数字。模块在磁盘上的大小约为 106,000 字节:

code
ls -l hello.ko
cp hello.ko /tmp/ && strip --strip-debug /tmp/hello.ko && ls -l /tmp/hello.ko
code
105984  hello.ko
  4864  /tmp/hello.ko

实际的模块大小不足 5KB。其余部分是构建过程中保留的 DWARF 调试信息,以便 crash 和 gdb 等工具在代码崩溃时能解析你的代码。当你加载模块时,内核不会加载调试段,因此内存消耗是较小的数字而非较大的数字。

你的 Hello World 已经依赖了三件事

这是我最希望有人首先向我展示的部分。询问目标文件需要内核提供什么:

code
nm -u hello.ko
code
U __fentry__
U _printk
U __x86_return_thunk

U 表示未定义:这些是你模块引用而内核必须在加载时提供的符号。

_printk 你可以理解,因为 pr_info 会扩展为它。

__fentry__ 是编译器在每个函数顶部插入的调用,因为内核启用了函数追踪功能。无论你是否要求,模块中每个函数都会带有这个钩子,这也是 ftrace 后续无需重新编译即可追踪代码的原因。

__x86_return_thunk 是一种 Spectre 缓解措施。你的编译器将普通返回指令替换为对跳转 thunk 的调用,以避免漏洞依赖的推测执行路径。即使这个模块只打印一行,该缓解措施仍会生效,因为它适用于机器上的所有内核代码,无论是否为模块。

你 Hello World 中的三个符号中有两个是机器强加给你的基础设施。这很好地展现了编写内核代码的现实情况。

MODPOST 在链接成功前验证了这三个符号确实存在。如果你调用了一个内核未导出的函数,构建过程会在该处失败,报出“未定义符号”错误,而不是生成一个加载时失败的模块。

vermagic 以及你的模块为何拒绝加载

再次查看 modinfo 的那行输出:

code
vermagic: 5.15.0-190-generic SMP mod_unload modversions

内核在加载任何内容之前会将该字符串与自身的进行比较,如果发现不匹配则拒绝加载。它涵盖了发布版本、内核是否为SMP、是否编译了模块卸载功能,以及符号版本控制是否启用。

这就是为什么在一台机器上编译的模块通常无法在另一台机器上加载,以及为什么升级内核意味着需要重新编译模块的原因。

Linux内核内部没有ABI稳定性保证。内部结构在不同版本之间会发生变化,使用一个布局编译的模块如果运行在另一个布局上,会导致内存损坏而不是干净地失败。拒绝加载是内核在保持谨慎。

这也是DKMS存在的原因。如果你安装了VirtualBox、ZFS或NVIDIA驱动,你已经拥有一个以这种方式重新编译的模块。在本机上:

code
modinfo vboxdrv | head -3
code
filename:       /lib/modules/5.15.0-190-generic/updates/dkms/vboxdrv.ko
version:        6.1.50_Ubuntu r161033 (0x00320000)
license:        GPL

注意路径中的updates/dkms/。DKMS会保留源代码,并在每次安装新内核时重新编译模块,这正是vermagic检查所导致的维护成本。

加载时传递参数

一个总是执行相同操作的模块很少是你想要的。module_param暴露了一个变量,使其值可以在加载时设置。将以下内容保存为param.c并与hello.c并存:

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

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("A module that takes parameters");

static char *who = "world";
static int times = 1;

module_param(who, charp, 0444);
MODULE_PARM_DESC(who, "who to greet");
module_param(times, int, 0644);
MODULE_PARM_DESC(times, "how many times to greet");

static int __init param_init(void)
{
    int i;

    for (i = 0; i < times; i++)
        pr_info("param: hello, %s\n", who);
    return 0;
}

static void __exit param_exit(void)
{
    pr_info("param: unloaded\n");
}

module_init(param_init);
module_exit(param_exit);

将其添加到Makefile中,该文件接受一个列表:

code
obj-m += hello.o param.o

三个参数分别是变量、类型以及将在/sys/module/<name>/parameters/下表示该变量的文件的权限。权限模式0444使其在加载后可读且不可更改。0644允许root写入该文件并在模块运行时更改值,这很有用。这也是当你在模块读取变量时未预期其变化时引入竞态条件的方式。

charp是字符指针,其他常见类型包括int、bool、long以及通过module_param_array实现的charp数组。传递与变量不匹配的类型会导致构建失败,而不是在之后出现错误行为。

MODULE_PARM_DESC将描述信息写入模块元数据,modinfo会从中找到这些信息:

code
name:           param
parm:           who:who to greet (charp)
parm:           times:how many times to greet (int)

以name=value的形式在加载时设置它们:

code
sudo insmod ./param.ko who=kernel times=3

任何人都可以在加载模块前查看它接受的参数,这也是使用MODULE_PARM_DESC的主要原因。

加载模块及输出位置

code
sudo insmod ./hello.ko
sudo dmesg | tail -2
lsmod | grep hello
sudo rmmod hello

pr_info输出会发送到内核环形缓冲区,因此会显示在dmesg中而不是终端上。如果dmesg在没有root权限时拒绝访问,那是由于kernel.dmesg_restrict的限制,此时可以通过sudo journalctl -k | tail通过日志系统读取相同的消息。

格式字符串中的 %pS 会将内核指针以符号名称和偏移量的形式打印,而不是原始地址。这样可以从日志行中得到可读内容,而不是十六进制数字。

lsmod 会读取 /proc/modules 并显示三列:模块名称、内存中的大小,以及使用计数和依赖它的任何内容的名称。使用计数非零的模块无法卸载,这也是 rmmod 拒绝卸载的最常见原因。

在加载任何模块之前,请注意一个警告。用户空间的错误只会导致你的程序崩溃。但此处的错误可能会导致机器宕机或文件系统损坏。首次几次操作建议在虚拟机中进行。快照的成本远低于损坏磁盘的成本。

四种构建错误及其含义

这四种错误通常会消耗人们大量时间,每种错误在了解如何解读后都会给出明确的提示。

No rule to make target 'modules'. Stop.:缺少内核头文件,或 /lib/modules/$(uname -r)/build 的符号链接指向无效位置。安装与当前运行的内核完全匹配的头文件包,通常你运行的内核可能不是最新安装的(如果你在更新后没有重启过)。

ERROR: modpost: "some_function" [hello.ko] undefined!:你引用了内核未导出的符号。以下是真实构建中的示例:

code
ERROR: modpost: "this_symbol_does_not_exist" [bad.ko] undefined!
make[2]: *** [scripts/Makefile.modpost:133: Module.symvers] Error 1

重试不会解决问题。要么该函数是内核内部函数且从未导出,要么它使用 EXPORT_SYMBOL_GPL 导出,而你的模块声明了非 GPL 许可证。使用 grep the_symbol /proc/kallsyms 检查,如果第二列显示大写 T,则表示这是一个全局文本符号。

insmod: ERROR: could not insert module: Invalid module format:构建成功但 vermagic 与运行中的内核不匹配。将 modinfo ./hello.ko | grep vermagic 与 uname -r 对比。使用正确的头文件重新构建即可解决。

insmod: ERROR: could not insert module: Operation not permitted:通常是安全启动拒绝了未签名模块,或保密模式下的锁定机制阻止了加载。在假设代码有问题之前,请先检查 mokutil --sb-state 和 cat /sys/kernel/security/lockdown。

还有一个需要注意的事项,现在学习比之后调试更容易。加载任何树外模块都会在内核上设置污染标志,该标志会在后续的 oops 或 panic 中被记录和报告:

code
cat /proc/sys/kernel/tainted

该值是一个位掩码。在此机器上显示为 4096,即第 12 位 TAINT_OOT_MODULE,表示曾经加载过树外模块。

第 13 位是人们常混淆的相邻位 TAINT_UNSIGNED_MODULE,这是安全启动关注的内容。

完整列表位于内核源码的 include/linux/panic.h 中。内核开发者在查看问题之前,会要求你在未污染的内核上复现错误,而这个文件会告诉他们你是否做到了。

MODULE_LICENSE 曾经在实践中是可选的。现在它已成为关键性参数,因为它控制着对仅限 GPL 的导出符号的访问权限。

M= 参数过去曾被拼写为 SUBDIRS=。这种拼写方式已被移除,而使用该方式的教程在出错时不会提及 SUBDIRS。

头文件路径已移动。任何引用 /usr/src/linux 的内容都早于按内核拆分的头文件包,且足够老旧,需要检查其余部分。另一个较小的线索是:<linux/module.h> 已包含 <linux/moduleparam.h> 多年,因此教程若刻意包含两者,实际上是在复制过时的内容,尽管同时包含两者并无害处。

如果教程在你的内核上构建时没有警告,说明它足够新。如果构建失败,通常第一个错误会显示它所针对的内核版本。

结论

你现在可以构建一个内核模块,阅读构建结果,并解释它依赖的每个符号。

更有用的是,你知道它为何以特定方式失败。缺失的 /lib/modules/$(uname -r)/build 是头文件问题。vermagic 不匹配需要重新构建。MODPOST 阶段出现未定义符号意味着内核未导出你请求的内容,无论重试多少次都无法改变这一事实。

接下来有几个方向可以选择。使用 proc_create 注册一个 /proc 条目并从中读取,这是模块能执行的最小有用操作。

阅读位于 /usr/src/linux-headers-$(uname -r)/ 下的内核自身 Module.symvers 文件,可以查看 MODPOST 检查的符号表。在此机器上该表包含 26,420 个导出符号,每个符号都标记为 EXPORT_SYMBOL 或 EXPORT_SYMBOL_GPL。

或者跟踪你自己的模块函数,这得益于从首次构建起就存在的 __fentry__ 钩子。没有需要运行的 ftrace 命令。它是一个位于 /sys/kernel/tracing 下的接口,因此你可以通过向文件写入内容来操作:

code
sudo sh -c 'echo hello_init > /sys/kernel/tracing/set_ftrace_filter'
sudo sh -c 'echo function > /sys/kernel/tracing/current_tracer'
sudo cat /sys/kernel/tracing/trace

在较旧的系统上,该路径是 /sys/kernel/debug/tracing。如果你不想手动向文件写入内容,可以使用 trace-cmd 包装相同的接口。

尾声

以上所有内容都假设你被允许这么做,而这一假设正是我感兴趣的点。加载的模块与内核本身拥有相同的权限。它可以读取任何内存、修补任何函数,并忽略系统认为正在执行的任何策略,因为在它运行时已没有比它更高的权限来拒绝它。

这使得模块加载成为权限模型无法包含的唯一操作,这也是内核通过签名和锁定机制而非权限来保护它的原因。

我在开发一个基于能力的桌面操作系统时遇到了这一限制,该系统中程序的清单即为它所能执行的全部内容,而 Debian 生态系统仍需在其下运行。模块是这一模型无法继续表达的地方,因此弄清楚内核在接受模块前具体检查了哪些内容,不再是一个细节,而成为设计约束。

我在 thechris.in 上撰写关于系统及其奥秘的文章。

我过去二十年专注于 VoIP、实时系统和后端架构,构建可扩展的系统并探索其背后的权衡。我重视代码的清晰性、设计的克制性以及不自我张扬的深度技术。我在 thechris.in 上撰写文章。

如果你读到了这里,请感谢作者以表明你对他们的关注。说声谢谢

免费学习编程。freeCodeCamp的开源课程已帮助超过40,000人成为开发者。立即开始

广告 /think