2026/10/18 10:51:13

Linux内核模块编译加载:Kbuild、Makefile与常见问题

Linux内核模块编译加载:Kbuild、Makefile与常见问题 1. 环境准备先确认内核版本和编译链1.1 写驱动前先分清楚你要碰的源码是哪一类自己编译安装模块而且是自己改过源码的模块在 Ubuntu 这种发行版上看起来有点吓人其实拆开看就是三件事准备一套和当前内核匹配的编译环境按 Kbuild 的规则写好源码和 Makefile最后把生成的 .ko 文件装到系统模块目录里。这里的模块特指 Linux 内核模块比如你自己写的一个测试驱动或者把内核源码里某个驱动改了一行后重新编译出来的驱动不是 pip 里那种 Python 包。动手之前先想清楚你的源码属于哪一种。第一种是你自己从零写的独立模块这种最简单只要一个 .c 文件和几行 Makefile 就能编译。第二种是内核树里的某个模块你想修改它的源码后重新生成 .ko这种情况需要先拿到对应版本的内核源码或者完整驱动源码。第三种是完整内核源码树你想改的驱动散落在 drivers 子目录中依赖很多内部头文件你需要用内核源码树的方式去构建。这三种情况有一个共同前提编译环境必须和当前运行内核版本一致。内核模块不是普通的用户态程序普通 C 程序链接的是 glibc模块链接的是内核导出的符号和内部接口。同一个源码在 5.x 内核上能编译换到 6.x 上可能直接报错。我常用一个类比模块像一块乐高积木内核就是底座积木的卡扣尺寸必须和底座一致硬塞进去不是松了就是卡死。你改的只是积木上图案没关系但卡扣尺寸改错了系统一定不认。1.2 安装编译依赖并确认 build 目录存在在 Ubuntu 上最稳妥的做法是先更新软件包索引然后安装编译工具链和内核头文件包。命令如下sudo apt update sudo apt install build-essential linux-headers-$(uname -r)build-essential 会带进来 gcc、make 这些基础工具linux-headers-$(uname -r) 则提供与当前内核版本对应的头文件和顶层 Kbuild 文件。注意这里的 $(uname -r) 必须在 shell 里展开成你当前正在运行的内核版本号所以安装前可以先单独执行 uname -r 看一眼。装完之后检查一个关键目录ls -ld /lib/modules/$(uname -r)/build正常情况下会看到一个指向 /usr/src/linux-headers-$(uname -r) 的符号链接。如果这个目录不存在说明头文件包没有装对后面编译时 Kbuild 会直接报“No such file or directory”而不是代码本身的错误。还有一个容易被忽略的情况如果你升级了内核但还没重启uname -r 显示的仍然是旧内核此时安装的 linux-headers-$(uname -r) 也是旧版本。等你重启进新内核后要么再装一次对应新内核的头文件包要么直接回到旧内核下编译否则模块版本会对不上。提示不要试图在内核头文件目录里手工执行 make 来编译模块。这个 build 目录只是给 Kbuild 提供环境真正的业务源码应该放在你自己的工程目录里用 make -C 指向它。这一点后面会详细讲。2. 编写最小模块先把“你好内核”跑通2.1 一个能编译能加载的最小模块源码我建议每次在新环境下做模块开发都先写一个最小模块跑通整个链路。不是说 hello world 幼稚而是它能帮你把环境问题、编译问题、加载问题分开排查。先建一个目录再写一个 hello.cmkdir -p ~/hello-mod cd ~/hello-modhello.c 的内容如下#include linux/init.h #include linux/module.h #include linux/kernel.h static int __init hello_init(void) { printk(KERN_INFO hello module loaded\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO hello module unloaded\n); } MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A minimal hello module); module_init(hello_init); module_exit(hello_exit);这段代码虽然短但已经包含了模块必须的全部骨架。module_init(hello_init) 不是普通函数调用而是告诉内核当 insmod 加载这个 .ko 时应该调用哪个函数作为入口module_exit(hello_exit) 则告诉内核当 rmmod 卸载时应该调用哪个函数作为出口。hello_init 里 return 0 表示初始化成功如果返回负数insmod 会认为加载失败并把对应的错误码报告给用户。printk 是内核自己用的日志输出函数不等价于 printf它往内核日志缓冲区写内容用户态要用 dmesg 看。hello_init 和 hello_exit 前面的 __init、__exit 也不只是装饰。__init 标记的函数内核会在模块加载完成之后把这段初始化代码所占的内存释放掉__exit 标记的代码在模块被静态编进内核时会被直接忽略。这种机制对运行时内存很敏感的设备驱动尤其重要虽然 hello 模块感受不到但写正式驱动时能帮你省内存。2.2 源码里常见的几个坑第一MODULE_LICENSE 的声明不能省。如果你不写或者声明成 Proprietary模块虽然还能编出来但加载时内核会把它标记为 tainted部分只对 GPL 模块开放的内核符号你在编译阶段用不了运行时也可能出现莫名其妙的行为。我自己调试时习惯一律写“GPL”除非有商业授权上的特殊考虑。第二不要在模块里调用 glibc 函数。很多人写惯了用户态程序会在模块里用 printf、malloc、strcpy 这些函数编译时偶尔能过链接或加载时就会失败。内核模块只能使用内核导出的 API字符串、内存分配都有对应的内核版本格式也完全不同。第三printk 的日志级别和格式要谨慎。最常用的是 KERN_INFO、KERN_ERR、KERN_DEBUG调试时可以在编译时给源码加 -DDEBUG 来控制是否输出调试信息。别图省事把所有日志都写成 KERN_INFO等到 dmesg 被刷屏时你会发现真正想看的那一行早被冲走了。第四内核 API 在不同版本之间变化非常频繁。你从网上抄来的代码如果报“unknown type name”或者“struct xxx has no member named yyy”先别怀疑编译器去对应的内核头文件里查一下这个接口在新版本里长什么样。改内核自带模块源码的时候这个问题尤其常见后面第 4 节会再展开。3. 编写 Kbuild Makefile理解 M 和 obj-m3.1 为什么模块编译不能直接用 gcc很多第一次接触模块编译的人会想我写了个 .c 文件为什么不能像普通程序那样 gcc hello.c -o hello原因在于内核模块不是独立可执行文件它需要和内核的构建系统配合使用一套非常特殊的编译参数。Kbuild 会根据当前内核的 .config 配置自动加上 -D__KERNEL__、架构相关选项、栈保护、重定位、modpost 符号检查等参数。这些选项少了一个编出来的 .ko 轻则加载失败重则把内核搞得不稳定。模块编译的最小单元是 obj-m。在 Kbuild 的规则里obj-m : hello.o 意思是你想以可加载模块的方式生成 hello.ko。Kbuild 看到 hello.o 之后会自动去找 hello.c然后依次完成普通编译、modpost 检查、生成 hello.mod.c、最后链接成 hello.ko。写错了写成 obj-m : hello.koKbuild 反而会找不到目标依赖。3.2 一份可以直接抄的 Makefile在 ~/hello-mod 目录下创建 Makefile内容如下obj-m : hello.o KERNEL_DIR : /lib/modules/$(shell uname -r)/build PWD : $(CURDIR) all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) clean install: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules_install INSTALL_MOD_DIRextra depmod -a这里的命令都要求缩进是 Tab 字符不是四个空格。如果你从网页复制代码很可能复制进来的是空格make 会报“missing separator”这是新手最容易踩的坑之一。KERNEL_DIR 指向当前内核的 build 目录M$(PWD) 告诉 Kbuild 你要编译的外部模块源码在哪里。all 目标执行 make -C $(KERNEL_DIR) M$(PWD) modules意思是切到内核构建目录用它的构建规则来处理 M 指定的外部模块。这样你不需要理解内核顶层 Makefile 的所有细节Kbuild 会自动把你的源码纳入整个内核模块构建流程。执行编译make看到 CC [M] hello.o、MODPOST 这些输出基本就说明编译链路是通的。成功后目录下会多出 hello.ko、hello.o、hello.mod.o、hello.mod.c、modules.order、Module.symvers 这些文件。hello.ko 是最终要用的成品hello.mod.c 是 Kbuild 自动生成的模块元数据文件里面记录了 vermagic、依赖关系、模块参数等信息不用手工维护。你可以用 file hello.ko 和 modinfo hello.ko 快速查看它的架构、版本信息、作者、描述和依赖。3.3 多个源码文件怎么组织如果你的模块不是单文件而是由 main.c、helper.c 等多个文件组成Makefile 要稍微改一下obj-m : hello.o hello-y : main.o helper.o目标名 hello.o 表示最终生成 hello.kohello-y 列出组成这个模块的所有目标文件。模块名可以和你主源码文件名不一样比如主源码叫 module_main.c 也完全没问题只要 obj-m 后面的名字和模块名一致hello-y 的列表正确地写进去就行。Kbuild 会把这些对象链接成一个 hello.ko。如果某个文件需要特定的编译宏可以单独写 CFLAGS_module_main.o : -DDEBUG 这样的行Kbuild 允许你按目标文件设置编译参数。注意每次改完源码重新编译之前不一定非得先执行 make clean。Kbuild 会依靠文件的 mtime 判断哪些需要重编但如果你改了 Makefile 里 obj-m 的组成或者换了内核版本最好先 make clean 一次免得旧的目标文件残留导致链接出问题。4. 修改内核自带模块两种更推荐的编译方式4.1 先说清楚源码去哪里找很多人在 Ubuntu 上想改内核自带的某个驱动第一反应是跑到 /usr/src/linux-headers-$(uname -r) 目录下面翻 drivers。实际上linux-headers 包只包含编译模块必需的头文件和顶层 Kbuild 脚本完整的驱动源码并不在里面。你在那个目录下找不到 drivers/net 下的完整驱动源码就算有零星几个文件也不能代表内核源码树的全部依赖。要改真正的内核模块源码要么从发行版提供的 linux-source 包或内核官网获取一份对应版本的内核源码压缩包要么只把你关心的那个驱动目录整体提取到自己的工作目录。我的建议是除非你确实需要改很多内核自带模块否则尽量别把整个内核源码树拖下水。内核源码树编译一次要承担的上下文比单个模块大得多出问题之后排查范围也会扩大。如果只是改一个驱动把它抽取成独立模块工程更清爽。4.2 方式 A把驱动源码抽出来做成独立模块假设内核源码里有个驱动目录 drivers/net/mydrv里面有 mydrv_main.c 和 mydrv_aux.c 两个文件。你只需要把这两个文件和一个必要的头文件目录拷出来放到自己的工作目录mkdir -p ~/mydrv-build cd ~/mydrv-build cp /path/to/kernel-source/drivers/net/mydrv/mydrv_main.c . cp /path/to/kernel-source/drivers/net/mydrv/mydrv_aux.c .然后在当前目录写一个全新的 Makefile备份原来的那个obj-m : mydrv.o mydrv-y : mydrv_main.o mydrv_aux.o ccflags-y -I$(src)/include KERNEL_DIR : /lib/modules/$(shell uname -r)/build PWD : $(CURDIR) all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) clean这里 ccflags-y 是常见的补充项。驱动在源码树里时Kbuild 可能已经通过全局的 include 路径找到了它依赖的内部头文件但你把它抽出来之后那些头文件不会自动被找到。你需要把依赖的头文件目录也一起拷出来然后通过 -I$(src)/include 告诉编译器去哪里找。具体路径取决于驱动代码里的 #include 写法没有固定模板。如果抽取出来的文件引用了很多驱动目录之外的头文件这种方式会比较痛苦那可以考虑方式 B。修改完源码后直接 make得到一个独立的 mydrv.ko。这种方式的好处是非常轻量不依赖内核源码树编译速度快适合只改一个驱动且依赖不复杂的场景。坏处是驱动如果依赖 Kconfig 生成的头文件配置项可能需要额外处理。4.3 方式 B使用完整内核源码树编译如果驱动依赖比较复杂或者你希望保持它原有的目录结构那就用完整源码树。先解压一份和当前运行内核相同大版本的内核源码然后依次执行cd linux-xxx cp /boot/config-$(uname -r) .config make olddefconfig make modules_preparecp 是把你当前内核的配置复制成源码树的 .config保证编译选项和运行内核一致。make olddefconfig 会在旧配置基础上把新增选项补成默认值。make modules_prepare 会生成一批编译模块必需的文件比如 include/generated/autoconf.h 和编译目标目录。做完这三步你不需要 make 整个内核可以直接进入你想改的模块目录编译make Mdrivers/net/mydrv modules改源码之前建议先不要动手直接编一次原版确认能在当前内核下顺利生成模块。如果原版就编译失败先解决环境问题再改代码否则你很难分清错误是环境引起的还是改动引起的。编出 mydrv.ko 之后安装可以直接拷贝到系统模块目录sudo cp drivers/net/mydrv/mydrv.ko /lib/modules/$(uname -r)/extra/ sudo depmod -a放到 extra 目录是为了和发行版内核自带的模块区分depmod -a 会重新扫描整个模块目录生成新的依赖关系。重新编译后重复这套动作即可。5. 从 insmod 到 modprobe模块加载与开机自启5.1 insmod、rmmod、dmesg 三件套模块编译出来之后最直接的加载方式是 insmod它需要你给出 .ko 文件的完整路径加载时不检查依赖关系。对 hello 这种没有依赖的模块用起来很顺手sudo insmod ./hello.ko dmesg | tail -5 lsmod | grep hello sudo rmmod helloinsmod 成功后不会打印任何提示所以要用 dmesg 看内核日志确认 hello_init 是不是真的跑了。如果 dmesg 里什么都没出现先看 insmod 有没有报错。rmmod 卸载模块时会触发 hello_exit同样去 dmesg 确认。lsmod 是看当前内核加载了哪些模块有没有带上你的名字。insmod 的缺点是它不会处理模块之间的依赖。假设你的模块调用了另一个模块导出的符号你没有先加载被依赖的模块insmod 就会报 Unknown symbol。排查手段是用 nm hello.ko | grep U 看未定义符号再去找这些符号属于哪个内核模块。真正的模块管理日常应该交给 modprobe它会根据 /lib/modules/$(uname -r)/modules.dep 里的依赖关系自动把依赖模块按顺序加载。5.2 把模块安装到系统目录并设置开机自启如果每次调试都手工 insmod 指定路径那只是临时验证。真正想让自己改过的模块像系统模块一样随用随取需要安装到标准目录。前面我们写好的 Makefile 里已经带了 install 目标执行sudo make install它做的事相当于 make modules_install INSTALL_MOD_DIRextra把生成的 hello.ko 拷贝到 /lib/modules/$(uname -r)/extra/然后自动执行 depmod -a。之后你就可以不写完整路径直接用模块名加载sudo modprobe hello lsmod | grep hello sudo modprobe -r hellomodprobe 会去标准模块目录里按名字找 hello.ko。如果你还想让模块开机自动加载最简单的做法是在 /etc/modules-load.d/ 下新建一个配置文件里面只写模块名echo hello | sudo tee /etc/modules-load.d/hello.conf系统启动时会读取该目录下的配置再调用 modprobe 把列出的模块加载起来。如果你的模块在运行时依赖其他模块modprobe 会自动处理所以开机自启推荐用 modprobe 链路而不是在 rc.local 里写 insmod 路径。有一点必须记住如果你后来重新编译了 hello.ko需要再次执行 sudo make install 和 sudo depmod -a否则系统里存的还是旧版本。6. 编译和加载报错问题排查速查表6.1 编译阶段的常见问题报错信息可能原因处理方法/lib/modules/x/build: No such file or directory未安装对应版本的内核头文件安装 linux-headers-$(uname -r)确认 /lib/modules/$(uname -r)/build 存在missing separatorMakefile 里用了空格代替 Tab把命令行的缩进改成真正的 Tab 字符No rule to make target modulesKERNEL_DIR 指向错误或参数写错检查 make -C 路径和 M 参数error: unknown type name缺少头文件或内核版本 API 变化查看新版头文件原型补 include 或改用新接口struct xxx has no member named yyy结构体字段在新内核被改名或移除搜索对应内核头文件确认新名字modpost: undefined symbol模块引用了一个没有导出的符号检查自身的 .c 文件是否漏编译或需要先加载依赖模块编译阶段最常见的并不是代码逻辑错误而是环境版本错配。我处理这类问题有一个习惯先跑 uname -r 确认当前内核再跑 ls -ld /lib/modules/$(uname -r)/build 确认头文件目录两个一致再谈其他。如果源码是从旧内核教程里抄的报错集中在“字段不存在”“函数参数个数不对”你就要做好适配新版 API 的准备了。新版内核往往把旧的注册函数拆分成多步例如在某个 register 函数前先要初始化一个 ops 结构体或者把原来一个函数改成带版本参数的扩展版本。6.2 加载阶段的常见问题报错信息可能原因处理方法invalid module format模块编译时用的内核配置或版本和当前运行内核不一致用当前 uname -r 对应的头文件重新编译检查 modinfo vermagicUnknown symbol依赖模块未加载或符号未导出用 modprobe 代替 insmod或先加载依赖模块用 nm 查看未定义符号Operation not permitted权限不足或系统启用了安全启动校验且模块未签名确认使用 root 执行若启用了安全启动需要完成模块签名流程module is already loaded模块名冲突或已加载未卸载lsmod 查看rmmod 后重试FATAL: Module not found模块没安装到标准目录或 depmod 没跑确认 .ko 在 /lib/modules/$(uname -r) 下执行 depmod -a 后重试你可以用 modinfo hello.ko 看模块的 vermagic 字符串它记录了编译时内核版本、SMP 配置、preempt 配置等信息。加载时内核会拿这个字符串和当前运行内核比对对不上就报 invalid module format。解决思路只有一个用当前运行内核对应的头文件和配置重新编译不要再纠结为什么旧模块能装上。用在加载阶段的另一个实用技巧是看 dmesg它往往比 insmod 的终态报错更详细尤其是 Unknown symbol 后面会列出具体符号名。根据符号名去找哪个模块导出了它再用 modprobe 把依赖加载好。我自己调试时会把 dmesg 过滤一下dmesg | tail -n 30 | grep -E hello|mydrv|Unknown|module至于安全启动的问题不同发行版处理方式不一样。有些环境默认开启固件安全校验未签名的 .ko 加载会被拒绝。普通自用模块要么完成系统要求的签名流程要么在确认安全的环境里按官方文档处理不要绕过校验机制这个边界需要你自己把握。最后分享一个小习惯。我每次写模块工程都会保留 Module.symvers 文件它是一个符号版本表记录了当前编译环境下哪些符号被导出了。当你的模块依赖另一个外部模块的符号时编译阶段需要把被依赖模块的 Module.symvers 放到当前目录或通过 KBUILD_EXTRA_SYMBOLS 指定。这个文件很小但丢了之后排查依赖问题会非常痛苦。还有改完内核源码重编模块记得先 make clean 再 make尤其是你升级过内核头文件包之后这一步能规避大量“明明改了代码却不生效”的灵异问题。