☰
Ubuntu内核模块源码修改与编译安装实战指南:从改驱动到稳定加载
2026/10/18 10:15:46 网站建设 项目流程

在 Ubuntu 上编译安装内核模块,听起来像老教程里一句话带过的事:装个linux-headers,跑一句make,然后insmod收工。可一旦你真的因为某个开源驱动不顺手、某个内核自带模块的行为不符合预期,打算自己修改源码再重新编译,就会撞上一连串课本上不会写的问题:版本魔数报错、Secure Boot 拦截、符号依赖对不上,甚至搞了半天modprobe加载的却是系统目录里的旧文件。

这篇文章把我自己从一个"能编译 hello world"的新手,到能改一个真实模块并稳定加载的完整过程捋一遍,包括每一步背后的原理和实际踩过的坑。适合谁看:想在 Ubuntu 上修改内核自带驱动或第三方内核模块源码、却卡在编译安装环节的人。全文以实操为主线,命令可以直接复制,例子是一个通用的模块骨架,不会绑定任何特定硬件。

1. 为什么放着现成的 .ko 不用,偏要自己改源码再编译

1.1 三种典型的"必须改源码"场景

第一种,硬件厂商只提供了开源驱动源码,系统里根本没有现成的.ko可以下载。Ubuntu 的官方内核仓库只收录了一部分硬件驱动,很多外设、开发板、特殊网卡的驱动要自己从源码编译,这是最直接的场景。

第二种,内核自带的某个模块行为不符合预期。比如某个驱动默认的采样间隔、某个虚拟网卡的默认队列数量、某个音频模块的默认采样率,虽然很多参数可以通过/sys或模块参数临时调整,但这些调整在重启后就失效了。你希望直接把默认值写死在源码里,让模块一加载就是这个值,这时候改源码重编才是根治。

第三种,排查内核问题。想搞懂某个模块到底在干什么,最简单的方式就是给它的入口函数和关键路径加几条printk,重新编译加载,观察日志。很多内核问题排查的第一刀就是加日志,加完再决定要不要继续深挖。

1.2 为什么不建议直接改二进制 .ko

有人会想:驱动就在/lib/modules/$(uname -r)/kernel/...下,能不能像改脚本一样用十六进制编辑器把某个常量改掉?结论是别尝试。内核模块不是独立程序,它和当前内核的版本魔数(vermagic)、导出符号的 CRC、内部结构体的内存布局严格绑定。你哪怕只想改一个默认参数,也得先定位它在.data段的具体偏移,改完还要处理符号校验、模块签名,稍有偏差内核直接拒绝加载。

这个过程比重新编译源码麻烦十倍,而且极易在不知不觉中破坏模块的校验信息。更关键的是,二进制层面的修改无法追溯到源码,等过几个月你自己都忘了改过哪里,维护起来全是坑。所以只要你有源码,老老实实走编译流程才是正路。

2. 编译环境准备:headers、build-essential 和一串易错检查

2.1 需要安装的包以及它们各自的角色

编译内核模块和编译普通用户态程序不一样,后者有编译器、标准库就能干,前者还必须有和当前运行内核精确对应的头文件与生成文件。Ubuntu 下最常见的做法是安装内核头文件包,它不需要完整的 Linux 源码树,因为普通模块编译用的是内核导出的构建系统(Kbuild),而不是整个内核树。

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

build-essential提供 gcc、make、ld 等基础工具,没有它什么都编不了。linux-headers-$(uname -r)提供/usr/src/linux-headers-$(uname -r)目录,里面有include、scripts、Makefile以及编译时生成的auto.conf等关键文件。装完之后,/lib/modules/$(uname -r)/build这个软链接会指向那个目录,Kbuild 就是通过它找到内核构建系统的。

如果你打算后面处理模块签名,或者有跨内核版本自动重编的需求,可以顺手把mokutil和dkms也装上。不是必须,但用到的时候不用再回头补。

2.2 验证头文件是否真的对齐

装完一定要验证,我吃过亏:uname -r显示某个-generic版本,但 headers 包因为依赖问题没装上,/lib/modules/$(uname -r)/build是一个断掉的软链接,编译第一句就四处找include/generated/autoconf.h。验证命令很简单:

uname -r ls -l /lib/modules/$(uname -r)/build dpkg -l | grep linux-headers-$(uname -r)

三条输出的内核版本必须一致,build软链接必须指向一个存在的目录。如果你是在自己编译的内核上操作,情况略有不同:/lib/modules/$(uname -r)/build会指向你的内核源码树,这时编译模块前最好先去那个目录里执行一次make modules_prepare,确保generated等文件是最新的。这个步骤很多第一次自编译内核的人会漏,漏了之后编译模块时经常报一些莫名其妙的头文件缺失错误。

环境就绪后,下一步就是动手写或改模块源码。

3. 动手改源码前必须搞清楚的内核模块骨架

3.1 一个能编译的最小模块长什么样

为了让你后面修改别人源码时心里有底,先从最小可编译模块说起。新建一个目录,比如~/kernel-module-demo/,放一个demo_module.c:

#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> static int __init demo_init(void) { printk(KERN_INFO "demo: module loaded\n"); return 0; } static void __exit demo_exit(void) { printk(KERN_INFO "demo: module unloaded\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL"); MODULE_DESCRIPTION("A demo kernel module for self-compile practice"); MODULE_VERSION("0.1");

这个文件虽然短,但每个部分都有讲究。module_init和module_exit是内核模块的入口和出口,分别对应加载和卸载时执行的函数。MODULE_LICENSE("GPL")不只是声明,很多内核符号只对 GPL 兼容模块导出,如果你不写或者写非 GPL,链接时可能直接报Unknown symbol。__init和__exit标记告诉内核:这个函数只在初始化/退出阶段使用,用完之后相应内存可以被回收。

3.2 Makefile 为什么要写成 Kbuild 的"外包"形式

内核模块的编译不能简单gcc -c demo_module.c,必须借助内核的 Kbuild 系统。普通用户态程序的 Makefile 是自己定规则,内核模块的 Makefile 更像是给 Kbuild 提交一份"目标清单"。新建一个Makefile:

obj-m := demo_module.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(CURDIR) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean

obj-m := demo_module.o表示要把这个目标编译成可加载模块,obj-y则是编入内核。-C $(KDIR)让 make 先切换到内核构建目录,读取内核的顶层 Makefile;M=$(PWD)告诉它"我自己的模块源码在这个目录,请到这里帮我处理"。这样编译时用的 include 路径、CFLAGS、版本魔数标记都来自当前运行内核,而不是本机 gcc 的默认路径。

如果模块由多个 C 文件组成,比如main.c和helper.c,目标文件名和源文件名对不上,要写成:

obj-m := demo_module.o demo_module-objs := main.o helper.o

这是 Kbuild 的特殊约定:目标名和对象名不一致时,用xxx-objs列出实际参与链接的对象。这个知识点在改复杂开源模块时会频繁用到,很多新手在 Makefile 里直接写obj-m := main.o helper.o,结果只会编出一个模块,另一个文件完全没有被链接进去。

4. 编译安装加载全流程:从 make 到 modprobe 的每一步

4.1 一个具体的"改源码"实例

为了贴合"自己修改源码"的主题,我模拟一个很常见的改动。假设你拿到的模块源码里有一段默认参数配置:

static int interval = 5; module_param(interval, int, 0644); static int demo_config(void) { pr_info("demo: interval=%d\n", interval); return 0; }

你嫌默认 5 秒太慢,想改成 1 秒,并且加一条更明显的启动日志:

static int interval = 1; module_param(interval, int, 0644); static int demo_config(void) { pr_info("demo: custom interval=%d\n", interval); return 0; }

改动很简单,但完整走一遍编译安装流程,你就能体会到"改源码 -> 编译 -> 加载 -> 验证"这个闭环是怎么运作的。

4.2 编译命令与常见输出

在源码目录执行:

make clean make

实际调用的是 Makefile 里那一长串命令。正常输出会经历make -C /lib/modules/... build M=... modules,中间出现Building modules, stage 2.和MODPOST,最后在当前目录生成demo_module.ko。看到.ko文件后,先别急着加载,用modinfo看一眼它的元信息:

modinfo demo_module.ko

重点看vermagic是否和当前内核匹配,description和version是否是你改完后的版本。很多人忽略这一步,直接insmod,报错了才回头看。其实modinfo在加载前就能筛掉一大部分环境问题。

4.3 安装到系统目录并使用 modprobe 管理依赖

编译出的.ko可以直接用绝对路径加载,但更规范的做法是安装到系统模块目录,让modprobe能按名字查找。执行:

sudo make modules_install sudo depmod -a sudo modprobe demo_module

make modules_install会把当前目录下obj-m对应的.ko拷贝到/lib/modules/$(uname -r)/extra/。depmod -a扫描所有模块,生成modules.dep等依赖索引文件,这样modprobe才知道模块在哪个路径、依赖谁。

这里有个特别容易踩的误区:insmod和modprobe不是同一个命令的两种写法。insmod直接加载指定路径的.ko,不做任何依赖解析;modprobe会在系统模块目录里按名字查找,并自动加载依赖模块。所以如果你有一个模块依赖另一个模块,用insmod加载主模块经常报Unknown symbol,而modprobe通常能一次搞定。

4.4 模块参数的临时覆盖

如果你只是为了实验某个参数值,不想每次改默认值都重新编译,加载时可以传参覆盖:

sudo modprobe demo_module interval=1 # 或者 sudo insmod ./demo_module.ko interval=1

这个操作在调试阶段非常有用,但要注意:它只是运行时覆盖,不会改变模块二进制里的默认值。想从根上改,还是得回到源码。我在实际开发中更推荐一个循环:调试期用make && sudo insmod ./demo_module.ko,逻辑简单、路径明确、出问题好回滚;确认行为稳定后,再make modules_install+depmod把它变成系统模块。

有人喜欢一上来就modprobe,结果加载的其实是/lib/modules/$(uname -r)/extra/里残留的旧版本,改了半天源码感觉"没生效",最后才发现装了双份模块。这个坑我在后面还会专门提。

5. 改完源码最容易翻车的三个坑:vermagic、Secure Boot、符号依赖

5.1 报 Exec format error,先查版本魔数

modprobe demo_module报Exec format error,或者用 insmod 报Invalid module format,这是内核模块加载失败最常见的一种。别盯着报错猜,直接看内核日志:

sudo dmesg | tail -n 30

典型的输出是:

demo_module: version magic '6.5.0-xx-generic SMP preempt mod_unload ...' should be '6.5.0-xx-generic SMP preempt mod_unload ...'

vermagic是编译时由内核头文件里的版本信息和配置生成的标记,运行内核会严格检查它。原因通常是两个:第一,headers 包版本和运行内核不一致,比如apt upgrade升级了内核但你还没重启,或者重启了但 headers 没跟上;第二,/lib/modules/$(uname -r)/build软链接断了。解决办法回到第 2.2 节的验证步骤,安装对应版本的 headers 后重新编译。还有个少见情况是自己编译的内核,源码树里配置和实际运行的配置不一致,这种情况要重新make modules_prepare。

5.2 Operation not permitted,多半是签名或 lockdown 在拦

如果 dmesg 里出现类似Lockdown: insmod: module verification failed或者module verification failed: signature not found,而你的机器又是 UEFI 引导、启用了 Secure Boot,那基本就是安全启动校验在拦截未签名的.ko。

处理有两条路线。一是针对个人学习机,如果确实不需要 Secure Boot 校验,可以通过 UEFI 固件设置或 MOK 管理工具关闭它,这是最省事的路径。二是在生产或需要保留安全启动的机器上,给模块做签名:安装mokutil和shim-signed,用内核源码里的sign-file工具给自己的密钥对和.ko签名,再通过sudo mokutil --import导入公钥,重启后在蓝色 MOK 管理界面确认 Enroll。

从安全角度讲,生产环境建议走签名流程而不是简单关闭校验。不过如果你只是在开发机上快速验证源码改动,关掉校验通常能省下一个小时。这个决策取决于机器用途,别在个人学习机上教条式地搭一套签名体系。

5.3 Unknown symbol 与 module_layout 版本不一致

当你改的模块依赖了另一个模块或内核导出的符号,还会遇到符号版本不一致的问题。比如 dmesg 报disagrees about version of symbol ...,这是因为内核开启了 CONFIG_MODVERSIONS,每个导出的内核符号都带有一个 CRC 值,你的模块在编译时记录下来的符号 CRC 和当前运行内核提供的符号 CRC 对不上。

这种情况不是重编一遍当前模块就能解决的。先确认依赖链:

modinfo demo_module.ko modprobe --dump-modversions demo_module.ko

modinfo的depends字段告诉你它依赖谁;--dump-modversions列出它引用的符号版本。如果依赖的是另一个第三方模块,那个模块往往也需要同步重新编译。加载顺序也常出问题:先加载依赖模块,再加载目标模块,modprobe会自动处理顺序,insmod不会,所以能上modprobe尽量modprobe。

5.4 一个最小可复用的排查链路

结合我的实际经验,加载失败时别盯着报错干猜,按下面顺序走一遍,九成问题能定位:

  • 先记录uname -r,确认自己当前到底在哪个内核;
  • 用sudo insmod ./demo_module.ko拿到最直接的报错;
  • 立刻sudo dmesg | tail -n 30,看内核给出的具体原因;
  • 用modinfo demo_module.ko检查 vermagic、depends、version;
  • 根据 dmesg 里的关键词决定是补 headers、处理签名,还是重编依赖模块。

这套链路我从内核调试转到模块开发以后一直在用。它把"报错-原因"之间的黑盒拆开了,尤其是 dmesg 那条,很多新手只看 insmod 的返回值就放弃了,实际上真正的信息都在内核日志里。

6. 怎么确认自己改的那几行真的生效了

6.1 printk 不是你想象的 printf

很多第一次写内核模块的人以为printk("hello\n")就能在终端看到输出,其实默认控制台可能只显示KERN_WARNING以上级别的日志,KERN_INFO不会直接打到屏幕上,它只会进入内核环形缓冲区,要用dmesg查看。我建议在模块日志里加上有辨识度的前缀,比如demo:,否则在几万条内核日志里找自己那一行会非常痛苦。

给一段实际的验证流程:

sudo dmesg -C sudo insmod ./demo_module.ko dmesg | tail -n 10

先清空环形缓冲区,加载后只读取末尾的日志,干净利落。我这些年排查内核模块,几乎每次都这么干,比在海量日志里 grep 高效得多。如果你想要更精确的过滤,dmesg --level=info也能用,但大多数情况下tail足够。

6.2 用 modinfo 和 /sys/module 验证运行时状态

如果修改的是module_param默认值,加载后可以直接读参数节点:

cat /sys/module/demo_module/parameters/interval

这个文件的值就是当前模块实际使用的参数。如果我改的默认值从 5 变 1,这里就会显示 1,证明改动确实进入了内核。/sys/module/demo_module/目录下还有refcnt(引用计数)、holders(谁占用了它)等信息,分析模块为什么卸载不掉的时候非常有用。

如果模块代码里生成了文件节点或者导出了什么接口,modinfo demo_module.ko的对应字段也会显示出来,可以和/sys下的实际情况对上。

这里还要提一个进阶技巧:把调试日志写成pr_debug(),默认不会输出。在 Ubuntu 默认开启 CONFIG_DYNAMIC_DEBUG 的前提下,可以动态打开特定文件的调试输出:

echo 'file demo_module.c +p' | sudo tee /sys/kernel/debug/dynamic_debug/control

之后dmesg里就会出那些pr_debug日志。这意味着你可以在不重新编译的情况下开关部分日志,对内核模块开发来说非常实用。改完代码不用反复重启,这个能力能省下大量时间。

7. 内核一升级模块就失效:重新编译与 DKMS 的边界

7.1 为什么不能跨内核版本复用 .ko

很多人的疑问是:我昨天编译的.ko今天还能用吗?大多数情况下不能。模块与内核的 vermagic、导出符号 CRC、内部结构体布局都强绑定,Ubuntu 一升级内核(比如从 6.5 升到 6.8),旧.ko就会被版本校验拦下来。所以每当你apt upgrade之后发现模块加载报 version magic 错误,第一件事是看uname -r是不是变了;变了就去装对应的linux-headers-$(uname -r),重新编译一遍模块。

这其实是 Linux 内核模块机制的一个特点:动态加载模块要求模块和内核保持同步,否则内存里的结构体如果错位,一个字段偏移错误都可能让系统崩溃。版本校验虽然烦,但它是保护机制,不是故意卡你。

7.2 DKMS 能省事,但不是开发期的银弹

DKMS 的思路是把模块源码注册给系统,每次内核变更后自动重新编译安装。它很适合发布给用户、希望"一次装好,内核升级自动跟随"的场景。但 DKMS 也有代价:你要写dkms.conf,把源码放到约定目录,调试时看编译输出、加载新版本都隔了一层,迭代体验远不如本地 Makefile 直接。

我自己的建议是:开发迭代阶段用本地目录和 Makefile,等模块稳定之后,再考虑迁到 DKMS 长期管理,两条路不冲突。对大多数人来说,先跑通本地编译加载,比一开始就搭 DKMS 基础设施重要得多。

最后再分享一个让我记忆深刻的小坑:开发时尽量把模块源码工程放在一个干净的独立目录,比如~/module-dev/,避免/lib/modules/$(uname -r)/extra/下面残留旧.ko。我遇到过一次,本地make clean了,但系统目录里还有一个同名旧模块,modprobe加载的始终是旧文件。后来养成一个习惯:每次重新编译安装前,先modprobe -r 模块名卸载旧模块,再安装加载,从此再没被这个双份文件的坑耽误过。这套工作流对 Ubuntu 上改源码编译内核模块的人来说,越早建立越好。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询