1. 项目概述:为什么内核模块编译是驱动开发的基石

搞Linux驱动开发,编译内核模块是你绕不开的第一道坎。很多新手朋友拿到一个驱动源码,或者自己写了几行代码,面对一堆.c和.h文件,第一反应往往是“怎么把它变成系统能加载的.ko文件?”。这时候, make modules 这个命令就是你的核心工具。它不仅仅是GNU Make的一个目标,更是连接你的源代码与Linux内核这座庞大建筑的桥梁。简单来说, make modules 指挥着整个编译工具链,将你编写的、符合内核编程规范的C代码,编译成可动态加载到运行中内核的二进制模块。

这个过程解决了什么问题呢?最直接的就是 解耦与迭代效率 。想象一下,如果你每次修改驱动代码,都需要重新编译整个几百万行代码的内核并重启系统,那开发调试的体验将是灾难性的。内核模块机制允许你将特定硬件(如网卡、声卡)或功能(如文件系统、加密算法)的驱动作为独立组件编译和加载,实现了“热插拔”式的开发。这特别适合驱动开发者、嵌入式系统工程师以及任何需要与内核深度交互但又希望保持灵活性的场景。无论是为一块新的PCIe网卡编写驱动,还是为一个自定义的硬件加速器开发内核支持,掌握 make modules 的精准使用,都能让你从“源码编辑”顺畅地走到“功能验证”,是驱动开发链路中承上启下的关键一环。

2. 编译环境搭建与内核头文件解析

在挥舞 make modules 这把“锤子”之前,你得先准备好“砧台”和“铁料”——也就是编译环境和内核头文件。很多人卡在这一步,不是因为命令复杂,而是环境没配对。

2.1 基础编译工具链安装

首先,你需要一套完整的编译工具。在不同的Linux发行版上,安装命令略有不同。以常见的Ubuntu/Debian和CentOS/RHEL为例:

  • Ubuntu/Debian系列 :

    sudo apt update
    sudo apt install build-essential libncurses-dev flex bison libssl-dev libelf-dev
    

    这里的 build-essential 包含了gcc, g++, make等核心工具。 libncurses-dev 和 flex 、 bison 是配置内核时可能需要的。 libssl-dev 和 libelf-dev 则是编译和签名模块所必须的库。

  • CentOS/RHEL系列 :

    sudo yum groupinstall "Development Tools"
    sudo yum install ncurses-devel flex bison openssl-devel elfutils-libelf-devel
    

    功能与上述Debian包对应。

注意 :务必使用发行版自带的包管理器安装。从源码编译GCC等工具链极其耗时且容易引入兼容性问题,对于驱动开发来说完全是得不偿失。

2.2 获取与准备内核头文件/源码

这是最核心也最容易出错的一步。你的驱动模块必须针对 当前运行的内核版本 进行编译,否则即使编译成功,加载时也会因为符号(函数、变量)版本不匹配而失败。

方法一:使用发行版提供的头文件包(推荐给初学者和针对标准内核的驱动) 这是最简单的方法,它提供的是当前运行内核对应的头文件,而非完整源码。

  • Ubuntu/Debian : sudo apt install linux-headers-$(uname -r)
  • CentOS/RHEL : sudo yum install kernel-devel-$(uname -r)

安装后,头文件通常位于 /lib/modules/$(uname -r)/build ,这个路径实际上是一个指向 /usr/src 下某个目录的符号链接。你可以通过 ls -l /lib/modules/$(uname -r)/build 来确认。

方法二:获取完整的内核源码 如果你需要修改内核本身,或者你的驱动依赖尚未发布的内核新特性,就需要完整源码。

  1. 从 kernel.org 下载稳定版源码,例如: wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.10.tar.xz
  2. 解压: tar -xf linux-5.10.tar.xz
  3. 进入目录并配置(这步很关键):
    cd linux-5.10
    # 拷贝当前运行内核的配置作为基础,这能最大程度保证兼容性
    cp /boot/config-$(uname -r) .config
    # 运行旧配置检查,处理因版本差异带来的新选项
    make olddefconfig
    # 准备头文件和构建脚本,这会在源码根目录生成必要的Makefile和头文件链接
    make prepare
    make scripts
    
    完成 make prepare 和 make scripts 后,这个源码目录就可以作为 make modules 的“构建目录”来使用了。

关键区别与选择 :

  • 头文件包 :体积小,安装快,只包含编译模块所需的最小文件集。适合绝大多数“仅编译驱动”的场景。但如果你需要 make menuconfig 来调整内核配置以启用某些依赖选项,则不行。
  • 完整源码 :体积庞大(>1GB),配置编译耗时。但它是一个完整的内核工作树,你可以进行全内核编译、修改配置、并使用内核源码树内的所有工具。当你需要深度定制或为特定内核版本(非当前运行版本)编译驱动时,这是唯一选择。

实操心得 :我个人的习惯是,在开发机上使用 头文件包 ,快速迭代驱动代码;而在构建服务器或需要为多个不同内核版本构建驱动时,使用 完整源码 并配合版本控制。务必记住:驱动模块的编译环境(头文件/源码版本)必须与目标运行环境的内核版本严格一致, modinfo 命令输出的 vermagic 字段就是这道“防火墙”。

3. 单模块编译实战:从零构建一个“Hello World”驱动

让我们从一个最简单的例子开始,亲手编译一个独立的内核模块。这个过程会清晰地展示 make modules 的工作流程和必要的文件结构。

3.1 编写最简单的内核模块源码

创建一个工作目录,例如 ~/hello_mod ,并在其中创建两个文件:

1. hello.c (模块主体)

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

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple Hello World kernel module");
MODULE_VERSION("0.1");

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, world! Kernel module loaded.\n");
    return 0; // 返回0表示初始化成功
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, world! Kernel module unloaded.\n");
}

module_init(hello_init);
module_exit(hello_exit);

代码解析 :

  • module_init 和 module_exit 是宏,它们将你定义的 hello_init 和 hello_exit 函数分别注册为模块加载和卸载时的入口点。
  • printk 是内核的“printf”, KERN_INFO 是日志级别。输出不会到终端,需要用 dmesg 命令查看。
  • MODULE_* 宏用于添加模块元信息, LICENSE 是必须的(如GPL),否则加载时会有警告。

2. Makefile (构建规则) 这是驱动编译的灵魂,告诉 make 命令如何工作。

# 指定内核源码树的位置。如果使用头文件包,通常就是 /lib/modules/$(shell uname -r)/build
KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build

# 指定当前模块源码所在目录
PWD := $(shell pwd)

# 定义要编译的模块目标文件(.o文件),最终会生成同名.ko文件
obj-m := hello.o

# 默认的make目标
all:
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules

# 清理编译生成的文件
clean:
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean

Makefile关键点解析 :

  • obj-m := hello.o :这是最重要的行。 obj-m 表示编译为可加载模块。等号右边列出所有需要编译成模块的 .o 文件。如果模块由多个 .c 文件组成(如 hello-main.c 和 hello-helper.c ),则写为 obj-m := hello.o ,并额外添加 hello-objs := hello-main.o hello-helper.o 。
  • $(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules :这是编译命令的核心。
    • -C $(KERNEL_DIR) :告诉 make 先切换到内核构建目录(即我们准备好的头文件或源码目录)。
    • M=$(PWD) :告诉内核的顶层Makefile,模块的源码位于当前目录( PWD )。
    • modules :指定要执行的目标,即编译模块。

3.2 执行编译与结果分析

在 ~/hello_mod 目录下,直接执行 make 命令(它会找到Makefile并执行 all 目标):

make

如果一切顺利,你将看到类似以下的输出,这是内核构建系统(kbuild)在工作:

make -C /lib/modules/5.15.0-91-generic/build M=/home/user/hello_mod modules
make[1]: Entering directory '/usr/src/linux-headers-5.15.0-91-generic'
  CC [M]  /home/user/hello_mod/hello.o
  MODPOST /home/user/hello_mod/Module.symvers
  CC [M]  /home/user/hello_mod/hello.mod.o
  LD [M]  /home/user/hello_mod/hello.ko
  BTF [M] /home/user/hello_mod/hello.ko
make[1]: Leaving directory '/usr/src/linux-headers-5.15.0-91-generic'

编译完成后,目录下会生成多个文件,我们关注的核心产出是:

  • hello.ko :这就是最终的可加载内核模块文件。
  • hello.o :模块的主目标文件。
  • hello.mod.o , hello.mod.c , Module.symvers 等:是kbuild系统在 MODPOST 阶段生成的中间文件,用于解决模块依赖和版本控制。

使用 modinfo 命令可以查看模块的详细信息:

modinfo hello.ko

输出会显示我们在代码中用 MODULE_* 宏定义的作者、描述、许可证,以及最重要的 vermagic ,它必须与当前内核的版本魔法字符串匹配才能加载。

3.3 模块的加载、卸载与调试

编译成功只是第一步,让模块跑起来才是目的。

加载模块 :

sudo insmod hello.ko

使用 dmesg | tail 查看内核日志,你应该能看到 Hello, world! Kernel module loaded. 这条信息。

查看已加载模块 :

lsmod | grep hello

这会显示 hello 模块及其占用内存的大小。

卸载模块 :

sudo rmmod hello

再次查看 dmesg ,会看到卸载时的告别信息。

注意事项与避坑指南 :

  1. 权限问题 :加载卸载模块需要 root 权限,务必使用 sudo 。
  2. 版本魔术(vermagic)不匹配 :这是最常见的错误。如果 modinfo 显示的 vermagic 与 uname -r 不一致,模块将无法加载,并报错 Invalid module format 。确保 KERNEL_DIR 指向正确的、与运行内核匹配的构建目录。
  3. 缺失内核配置选项 :如果你的模块依赖某个内核功能(如特定的API或子系统),而该功能在当前内核配置( .config )中被编译为 =n (未启用)或 =m (编译为模块但未加载),可能会导致编译失败或运行时错误。这时需要进入内核源码目录,使用 make menuconfig 启用相关选项,并重新 make prepare 。
  4. 打印信息看不到 : printk 默认的日志级别可能高于控制台打印阈值。除了用 dmesg 查看,你也可以通过 echo 8 > /proc/sys/kernel/printk 临时降低打印级别,或者在你的 printk 中使用更高的优先级如 KERN_ALERT 。

4. 多模块编译:复杂驱动项目的工程化管理

真实的Linux驱动很少是单个文件。一个完整的驱动可能包含核心驱动模块、多个设备支持模块、公共库代码等。这就需要用到多模块编译。kbuild系统对此有很好的支持,关键在于Makefile的编写。

4.1 多模块项目的目录结构与Makefile组织

假设我们有一个稍微复杂点的虚拟字符设备驱动项目 my_driver ,结构如下:

my_driver/
├── core/           # 核心逻辑
│   ├── main.c
│   └── internal.h
├── devices/        # 不同设备的支持
│   ├── dev_a.c
│   └── dev_b.c
├── common/         # 公共函数
│   └── utils.c
├── include/        # 对外的头文件
│   └── my_driver.h
└── Makefile        # 顶层Makefile

我们的目标是编译出两个内核模块: my_driver_core.ko (核心模块)和 my_driver_devs.ko (设备集合模块)。

顶层 Makefile (my_driver/Makefile)

KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

# 要编译的模块列表,对应两个.ko文件
obj-m := my_driver_core.o my_driver_devs.o

# 定义第一个模块 my_driver_core.ko 的组成
my_driver_core-objs := core/main.o common/utils.o

# 定义第二个模块 my_driver_devs.ko 的组成
my_driver_devs-objs := devices/dev_a.o devices/dev_b.o

# 指定头文件的查找路径。对于驱动内部头文件,通常用 -I$(src)/include
ccflags-y := -I$(src)/include -I$(src)/common

all:
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules

clean:
	$(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean

关键解析 :

  • obj-m := my_driver_core.o my_driver_devs.o :声明本项目将生成两个模块。注意,这里的 .o 文件名 直接对应 最终生成的 .ko 文件名( my_driver_core.ko 和 my_driver_devs.ko )。
  • <module_name>-objs :这是多文件模块的 核心语法 。它告诉kbuild系统,名为 <module_name>.ko 的模块是由后面列出的多个 .o 文件链接而成的。例如, my_driver_core.ko 由 core/main.o 和 common/utils.o 链接生成。
  • ccflags-y :用于向编译所有模块的gcc命令传递额外的标志。 $(src) 是一个kbuild变量,指向当前Makefile所在的源码根目录(即 my_driver/ )。这里我们添加了头文件搜索路径。

4.2 模块间的符号导出与依赖关系

在多模块项目中,一个模块(如核心模块)提供的函数或变量,可能需要被另一个模块(如设备模块)使用。这就涉及到 符号导出 。

1. 在提供符号的模块中导出函数 在 core/main.c 中,定义一个函数并导出:

// core/main.c
#include <linux/export.h>
#include "internal.h"

int my_driver_register_device(struct device_info *info)
{
    // ... 注册逻辑
}
// 使用EXPORT_SYMBOL宏导出该函数,使其对其他模块可见
EXPORT_SYMBOL(my_driver_register_device);

2. 在使用符号的模块中声明外部函数 在 devices/dev_a.c 中,使用这个函数:

// devices/dev_a.c
#include <linux/module.h>
#include "../include/my_driver.h" // 假设声明在这里

extern int my_driver_register_device(struct device_info *info); // 外部声明

static int __init dev_a_init(void)
{
    struct device_info dev_info = { ... };
    int ret = my_driver_register_device(&dev_info); // 调用核心模块的函数
    if (ret)
        pr_err("Failed to register dev A\n");
    return ret;
}

3. 处理模块加载依赖 由于 my_driver_devs.ko 依赖 my_driver_core.ko 中的符号,加载时必须 先加载核心模块 :

sudo insmod my_driver_core.ko
sudo insmod my_driver_devs.ko

卸载时顺序则相反:

sudo rmmod my_driver_devs.ko
sudo rmmod my_driver_core.ko

你可以使用 modprobe 命令,它能够自动处理模块依赖(需要先运行 sudo depmod 生成依赖关系)。但更常见的做法是在模块的 MODULE_INIT 代码中动态探测依赖,或者将多个模块打包成一个“元模块”。

实操心得 :管理多模块依赖时,我强烈建议在核心模块的初始化函数中创建一个 class (使用 class_create )或 bus (如 platform_bus ),然后设备模块通过这个标准的内核对象进行注册和通信。这比直接使用 EXPORT_SYMBOL 更加规范和安全,也符合Linux设备模型。 EXPORT_SYMBOL 应仅用于确实需要跨模块共享的、稳定的底层接口。

4.3 使用Kbuild递归构建大型项目

对于非常庞大的驱动项目(如一些GPU驱动),源码可能分布在多层子目录中。此时,可以在每个子目录放置一个 Kbuild 或 Makefile 文件,顶层Makefile通过 obj-y 或 obj-m 变量“包含”子目录。

例如,修改上面的项目结构,让 core 和 devices 子目录管理自己的构建: 顶层 Makefile :

obj-m := my_driver_core.o my_driver_devs.o

# 告诉kbuild,my_driver_core.o的源码在core/子目录下寻找
my_driver_core-y := core/

# 告诉kbuild,my_driver_devs.o的源码在devices/子目录下寻找
my_driver_devs-y := devices/

ccflags-y := -I$(src)/include

core/Kbuild :

# 此目录下的文件将参与构建my_driver_core.o
obj-y := main.o utils.o

devices/Kbuild :

# 此目录下的文件将参与构建my_driver_devs.o
obj-y := dev_a.o dev_b.o

这种方式将构建规则分散到各个子目录,更利于模块化管理和团队协作。kbuild系统会自动进入这些子目录进行编译。

5. 高级技巧与疑难问题排查

掌握了基础的单模块和多模块编译后,在实际开发中你还会遇到一些更复杂的情况和棘手的错误。这里分享一些进阶技巧和排查思路。

5.1 为不同内核版本交叉编译模块

你的开发机内核版本是5.15,但目标设备(比如一个嵌入式板卡)运行的是4.19内核。你需要进行交叉编译。

  1. 获取目标内核源码 :下载或获取目标设备使用的4.19内核完整源码。
  2. 配置编译工具链 :如果目标设备是ARM架构,你需要安装对应的交叉编译器,如 gcc-arm-linux-gnueabihf 。
  3. 配置内核源码 :在目标内核源码目录中,使用目标设备的配置文件(通常由芯片厂商提供)进行配置。可能是 make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- xxx_defconfig 。
  4. 准备构建环境 :同样需要执行 make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- prepare scripts 。
  5. 编译驱动 :在你的驱动目录Makefile中,或者通过命令行覆盖变量:
    make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- KERNEL_DIR=/path/to/target/kernel/source modules
    
    关键点在于 ARCH 和 CROSS_COMPILE 这两个变量,它们告诉kbuild系统使用目标架构和交叉编译器。

5.2 编译错误与警告深度解读

编译出错时,不要只看最后一行。从错误信息的顶部开始看。

  • “implicit declaration of function ‘xxx’” :这通常是最常见的警告,但可能升级为错误。意味着你调用了一个函数,但编译器在当前包含的头文件中没有找到它的声明。 排查 :检查是否包含了正确的头文件( #include <linux/xxx.h> ),或者该函数是否在你当前编译的内核版本中存在(有时新版本添加的函数在老版本内核中不可用)。
  • “dereferencing pointer to incomplete type ‘struct xxx’” :你试图访问一个结构体的成员,但编译器只看到了该结构体的前向声明( struct xxx; ),没看到完整定义。 排查 :确保包含了定义该结构体的头文件。有时结构体定义在头文件的条件编译( #ifdef )中,检查你的内核配置是否满足了条件。
  • “error: expected ‘;’ before ‘xxx’” :语法错误。但有时根源在于之前的某行,比如宏定义错误、缺少括号等。检查出错行附近的代码。
  • 模块加载时“Unknown symbol” :这是运行时错误。说明模块A使用了模块B导出的一个符号,但模块B没有加载,或者模块B没有用 EXPORT_SYMBOL 导出该符号。使用 sudo cat /proc/kallsyms | grep function_name 可以查看该符号是否在内核(或其他已加载模块)的符号表中。如果没有,检查导出和依赖关系。

5.3 内核模块签名与安全引导(Secure Boot)

在现代开启了UEFI安全启动(Secure Boot)的系统上,内核会拒绝加载未经验签名的模块。这会给开发和调试带来麻烦。

开发环境下的变通方案 :

  1. 关闭安全启动 :在BIOS/UEFI设置中临时关闭Secure Boot(最简单,但不安全)。
  2. 使用本地签名 :
    • 生成自己的密钥对: openssl req -new -x509 -newkey rsa:2048 -keyout my_key.priv -outform DER -out my_key.x509 -nodes -days 36500 -subj "/CN=My Local Key/"
    • 将公钥注册到内核:这需要你 重新编译内核 ,在配置中 CONFIG_MODULE_SIG=y ,并指定你的公钥路径 CONFIG_MODULE_SIG_KEY="certs/my_key.x509" 。然后编译安装新内核。
    • 用私钥给模块签名: /lib/modules/$(uname -r)/build/scripts/sign-file sha512 my_key.priv my_key.x509 hello.ko 。 这个过程相当繁琐,仅适用于深度定制的环境。

更实用的开发流程 :在开发阶段,通常是在关闭Secure Boot的测试机上进行的。待驱动稳定后,再在开启Secure Boot的生产环境中,使用由系统厂商或发行版提供的正式密钥进行签名和集成。

5.4 性能优化与调试信息控制

  • 减少模块体积 :编译时默认会包含调试信息( -g ),使得 .ko 文件很大。在最终发布时,可以在Makefile中添加:

    ccflags-y += -DNDEBUG # 禁用assert
    # 或者更激进地,使用内核的发布配置,它通常会传递 -O2 并减少调试信息
    

    但注意,去掉调试信息会使问题排查变得困难。

  • 选择性开启调试 :在代码中使用 #ifdef DEBUG 宏包裹详细的调试打印信息。在Makefile中可以通过 ccflags-y += -DDEBUG 来全局开启。这样在开发时能获得详细日志,发布时只需移除该编译选项即可获得干净的模块。

  • 使用动态调试(Dynamic Debug) :这是更强大的机制。在代码中使用 pr_debug() 或 dev_dbg() 代替 printk 。模块加载后,你可以通过 echo 'file hello.c +p' > /sys/kernel/debug/dynamic_debug/control 来动态开启 hello.c 文件中所有 pr_debug() 的输出,无需重新编译。这需要内核配置 CONFIG_DYNAMIC_DEBUG=y 。

从最简单的 hello.ko 到管理一个多模块的复杂驱动项目, make modules 始终是那个核心命令。它的背后是Linux庞大而精巧的内核构建系统(kbuild)。理解它,不仅仅是记住命令,更是理解内核模块如何被组织、链接和管理。我个人的体会是,每次解决一个诡异的编译或链接错误,对内核的理解就会加深一层。最好的学习方式,就是从一个实际的小驱动项目开始,亲手去编译、加载、卸载,观察输出,修改代码,再重复这个过程。当你能够熟练地为不同版本的内核、甚至不同架构的处理器交叉编译驱动时,你会发现,曾经看似神秘的Linux驱动世界,已经向你敞开了大门。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐