如果你刚开始学内核模块开发,可能会被编译过程搞得一头雾水 —— 为什么不能像编译普通 C 程序那样用 gcc 直接编译?内核模块的编译到底特殊在哪里?今天咱们就从最基础的 Makefile 写起,一步步掌握从代码到可加载模块的 "变身术"。
普通 C 程序编译很简单,gcc hello.c -o hello就行,但内核模块可不行。这就像做面包和做蛋糕的区别 —— 虽然都是面粉做的,但烤箱温度、配料比例完全不同。
内核模块不是独立程序,而是要嵌入到内核中的 "插件",意味着:
如果你用系统默认的 gcc 编译模块,会得到类似这样的错误:
error: 'printk' undeclared (first use in this function)这不是因为 printk 不存在,而是因为没有正确引入内核头文件和编译选项。

在开始编译前,需要准备好必要的工具和环境,就像做蛋糕前要准备好烤箱和原料。
最关键的是要安装与当前内核版本匹配的内核源码或开发包:
Ubuntu/Debian 系统:
# 查看当前内核版本
uname -r
# 安装对应版本的内核开发包
sudo apt-get install linux-headers-$(uname -r)CentOS/RHEL 系统:
sudo yum install kernel-devel-$(uname -r)这些包会安装编译模块所需的头文件和配置文件,通常放在/lib/modules/$(uname -r)/build目录下。
创建一个简单的测试模块hello.c:
#include <linux/init.h>
#include <linux/module.h>
static int __init hello_init(void) {
printk(KERN_INFO "Hello, 内核模块世界!\n");
return 0;
}
static void __exit hello_exit(void) {
printk(KERN_INFO "Goodbye, 内核模块世界!\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("一个简单的测试模块");这个模块加载时会打印一句欢迎语,卸载时打印告别语。
内核模块的编译完全依赖 Makefile,这是整个过程的 "指挥中心"。一个最简单的内核模块 Makefile 只有几行,但每一行都有特殊含义。
创建文件Makefile(注意首字母大写):
# 声明要编译的模块
obj-m += hello.o
# 内核源码树路径(自动获取当前系统内核)
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
# 当前目录路径
PWD := $(shell pwd)
# 默认编译目标
default:
# 进入内核源码树,执行编译
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules
# 清理编译生成的文件
clean:
$(MAKE) -C $(KERNELDIR) M=$(PWD) cleanobj-m += hello.o:告诉内核构建系统,要编译一个名为hello.ko的模块(.o文件会被链接成.ko模块)KERNELDIR:指定内核源码树的位置,/lib/modules/$(uname -r)/build是标准位置PWD:记录当前目录路径,让内核构建系统知道模块代码在哪里在终端执行make命令:
make如果一切顺利,会看到类似这样的输出:
make -C /lib/modules/5.4.0-100-generic/build M=/home/user/kernel-modules modules
make[1]: Entering directory '/usr/src/linux-headers-5.4.0-100-generic'
CC [M] /home/user/kernel-modules/hello.o
Building modules, stage 2.
MODPOST 1 modules
CC [M] /home/user/kernel-modules/hello.mod.o
LD [M] /home/user/kernel-modules/hello.ko
make[1]: Leaving directory '/usr/src/linux-headers-5.4.0-100-generic'编译成功后,目录下会生成几个文件:
hello.ko:最终可加载的内核模块(最重要的文件)hello.o:编译产生的目标文件hello.mod.c:模块依赖信息(自动生成)hello.mod.o:模块依赖目标文件modules.order和Module.symvers:模块顺序和符号信息当模块代码复杂时,通常会分成多个文件。比如我们有main.c和helper.c两个文件,该如何编译呢?
只需将多个文件合并成一个模块名:
# 将main.o和helper.o合并成一个demo.ko模块
obj-m += demo.o
demo-objs := main.o helper.o # 指定组成demo.ko的目标文件
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
default:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KERNELDIR) M=$(PWD) clean关键是demo-objs := main.o helper.o,它告诉构建系统:demo.ko由main.o和helper.o链接而成。
main.c(主文件):
#include <linux/init.h>
#include <linux/module.h>
#include "helper.h" // 包含辅助函数头文件
static int __init demo_init(void) {
printk(KERN_INFO "主模块初始化\n");
helper_function(); // 调用辅助函数
return 0;
}
static void __exit demo_exit(void) {
printk(KERN_INFO "主模块退出\n");
}
module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");helper.c(辅助文件):
#include <linux/kernel.h>
#include "helper.h"
void helper_function(void) {
printk(KERN_INFO "这是辅助函数\n");
}helper.h(头文件):
#ifndef _HELPER_H_
#define _HELPER_H_
void helper_function(void);
#endif执行make后,会生成demo.ko模块,包含了两个文件的功能。
有时需要添加额外的编译选项,比如警告级别、宏定义等,这可以通过EXTRA_CFLAGS实现。
让编译器更严格地检查代码:
obj-m += hello.o
EXTRA_CFLAGS += -Wall -Wextra # 开启更多警告
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
default:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KERNELDIR) M=$(PWD) clean-Wall开启基本警告,-Wextra开启额外警告,帮助发现潜在问题。
在编译时传递宏定义,控制代码条件编译:
obj-m += debug_module.o
EXTRA_CFLAGS += -DDEBUG=1 # 定义DEBUG宏,值为1
# 或者根据条件定义
ifdef DEBUG
EXTRA_CFLAGS += -DDEBUG=1
endif在代码中可以这样使用:
#ifdef DEBUG
printk(KERN_DEBUG "调试信息:变量x的值为%d\n", x);
#endif编译时如果定义了DEBUG=1,就会输出调试信息。
如果模块需要引用其他目录的头文件:
obj-m += mymodule.o
EXTRA_CFLAGS += -I$(PWD)/include # 包含当前目录下的include子目录
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
default:
$(MAKE) -C $(KERNELDIR) M=$(PWD) modules有时需要为嵌入式设备编译模块(比如 ARM 架构),这就需要交叉编译。
1. 安装交叉编译工具链
以 ARM 架构为例,在 Ubuntu 上安装:
sudo apt-get install gcc-arm-linux-gnueabihf2. 交叉编译的 Makefile
obj-m += hello_arm.o
# 目标架构的内核源码路径
KERNELDIR := /path/to/arm-linux-kernel
# 交叉编译器前缀
CROSS_COMPILE := arm-linux-gnueabihf-
# 当前目录
PWD := $(shell pwd)
default:
$(MAKE) -C $(KERNELDIR) ARCH=arm CROSS_COMPILE=$(CROSS_COMPILE) M=$(PWD) modules
clean:
$(MAKE) -C $(KERNELDIR) ARCH=arm CROSS_COMPILE=$(CROSS_COMPILE) M=$(PWD) cleanARCH=arm:指定目标架构为 ARMCROSS_COMPILE=arm-linux-gnueabihf-:指定交叉编译器前缀执行make后,会生成能在 ARM 设备上运行的hello_arm.ko模块。
即使最简单的模块,也可能遇到编译错误。这里列举几个最常见的问题及解决办法。
make: *** 没有规则可制作目标“modules”。 停止。原因:内核源码树路径错误或未安装内核开发包。 解决:
KERNELDIR是否指向正确的内核源码目录linux-headers-$(uname -r)包error: implicit declaration of function ‘printk’ [-Werror=implicit-function-declaration]原因:忘记包含必要的头文件。
解决:在代码开头添加#include <linux/kernel.h>(printk 的声明在这个头文件中)。
error: ‘KERN_INFO’ undeclared (first use in this function)原因:KERN_INFO等日志级别宏定义在linux/kern_levels.h中,而该文件通常通过linux/kernel.h间接包含。
解决:添加#include <linux/kernel.h>。
WARNING: modpost: missing MODULE_LICENSE() in /home/user/hello.o原因:模块未声明许可证。
解决:在代码中添加MODULE_LICENSE("GPL");(或其他合法许可证)。
WARNING: "some_function" [/home/user/mymodule.ko] undefined!原因:模块使用的内核函数在当前内核版本中不存在或未导出。 解决:
EXPORT_SYMBOL导出编译出.ko文件后,还需要验证模块是否可以正常加载到内核中。
1. 加载模块
sudo insmod hello.ko2. 检查模块是否加载成功
lsmod | grep hello如果输出类似hello 16384 0,说明模块加载成功。
3. 查看模块输出日志
dmesg | tail会看到模块初始化函数打印的信息:Hello, 内核模块世界!
4. 卸载模块
sudo rmmod hello再次查看日志:dmesg | tail,会看到退出函数的输出:Goodbye, 内核模块世界!
如果这几步都没问题,说明编译的模块是正常可用的。
内核模块的编译虽然看起来复杂,但核心原理很简单:遵循内核的编译规则,使用内核提供的工具链,确保与内核版本匹配。
掌握模块编译需要记住三个关键点:
刚开始可能会被各种错误信息搞得头疼,但只要多练习几次,熟悉了常见问题的解决方法,你会发现 —— 编译内核模块其实就那么几步固定操作。就像学骑自行车,一开始觉得难,熟练后就成了本能。
编译过程:

附录:万能Makefile模板
# 目标模块名
obj-m += ultimate_module.o
# 多文件支持
ultimate_module-objs := main.o utils.o
# 自定义源文件目录
src_dir := src
obj_dir := build
# 自动收集源文件
srcs := $(wildcard $(src_dir)/*.c)
objs := $(patsubst $(src_dir)/%.c,$(obj_dir)/%.o,$(srcs))
# 内核目录
KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build
# 编译规则
all:
@mkdir -p $(obj_dir)
make -C $(KERNEL_DIR) M=$(PWD) modules
clean:
make -C $(KERNEL_DIR) M=$(PWD) clean
rm -rf $(obj_dir)
# 版本信息注入
EXTRA_CFLAGS += -DBUILD_TIMESTAMP=\"$(shell date +%Y-%m-%dT%H:%M:%S%z)\"下一篇文章,我们将学习内核模块的调试技巧,看看如何解决模块加载后的运行时问题。如果你在编译过程中遇到了其他问题,欢迎在评论区留言讨论!