Linux 内核编译与模块开发入门

Linux 内核模块(Kernel Module)是可动态加载到内核空间的代码单元,它是驱动开发、文件系统扩展、网络协议栈增量的核心手段。与微内核架构不同,Linux 采用宏内核设计,所有内核代码共享同一地址空间,模块的运行效率极高,但也意味着一个不合格的模块可能导致整个系统崩溃。

Linux 内核模块(Kernel Module)是可动态加载到内核空间的代码单元,它是驱动开发、文件系统扩展、网络协议栈增量的核心手段。与微内核架构不同,Linux 采用宏内核设计,所有内核代码共享同一地址空间,模块的运行效率极高,但也意味着一个不合格的模块可能导致整个系统崩溃。本文从环境搭建到字符设备驱动,再到调试技巧,系统性地介绍内核模块开发的全流程。

一、为什么要使用内核模块

在传统的操作系统开发中,任何功能的扩展都需要修改内核源码并重新编译整个内核映像。这种方式存在两个明显弊端:一是编译时间随着内核代码膨胀而急剧增加,主流桌面级硬件上完整编译一次 Linux 内核仍需数十分钟;二是每次更新都需要重启系统,这对服务器场景几乎不可接受。

内核模块机制解决了这一问题。模块以 .ko(Kernel Object)文件的形式存在,通过 insmodmodprobe 命令加载到运行中的内核,通过 rmmod 命令卸载。内核模块广泛应用于三大场景:设备驱动程序(如网卡、显卡驱动)、文件系统实现(如 ext4、Btrfs 的辅助模块)以及网络协议扩展(如 Netfilter 钩子)。

动态加载的另一个优势是按需加载。系统启动时仅加载必要的核心模块,其余模块在检测到硬件或显式请求时才加载,降低了内存占用。当模块不再需要时,可以安全卸载并释放资源。

不过,模块运行在内核态,拥有最高特权级别,一个野指针或死锁可能立即触发内核恐慌(Kernel Panic),导致数据丢失。因此,内核模块开发必须在隔离环境中进行,绝对禁止在生产服务器上直接编译和测试模块。

二、环境搭建

2.1 安装内核头文件

编译内核模块需要与当前运行内核版本严格匹配的头文件和构建脚本。在 Debian/Ubuntu 系发行版中,使用以下命令安装:

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

安装完成后,内核源码树中的关键脚本和头文件位于 /lib/modules/$(uname -r)/build/ 目录下。其中 Makefile 是 Kbuild 系统的入口,include/ 子目录包含内核头文件,scripts/ 存放各种构建辅助脚本。

使用 lsmod 查看当前已加载的模块,modinfo <模块名> 查看模块的元信息,确认环境是否正常。

2.2 使用 QEMU 或虚拟机进行隔离测试

初学者最常见的错误就是在物理机上直接加载自己编写的模块。一旦模块存在严重 Bug,系统可能瞬间挂死,键盘和鼠标失去响应,只能强制断电重启,而且未保存的工作将全部丢失。

正确的做法是使用 QEMU、VMware 或 VirtualBox 搭建虚拟机进行测试。QEMU 尤其适合内核开发,因为它支持 KGDB(内核级别的 GDB)调试,且可以通过 -s -S 参数在启动时暂停 CPU,等待调试器连接。建议为测试虚拟机单独编译一个开启调试符号的内核:

CONFIG_DEBUG_INFO=y          # 包含调试符号
CONFIG_DEBUG_KERNEL=y        # 内核调试支持
CONFIG_KGDB=y                # KGDB 远程调试

三、最简内核模块:Hello, Kernel

3.1 核心要素

每个内核模块至少包含两个函数:初始化函数和卸载函数,并需要使用特定的宏进行注册。

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

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, Kernel!\n");
    return 0;
}

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

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Leeting Yan");
MODULE_DESCRIPTION("A simple Hello World kernel module");

module_initmodule_exit 分别将函数注册为模块的入口和出口。__init__exit 是编译器属性宏,标记这些函数仅在加载或卸载期间使用,之后内核可以回收其占用的内存。printk 是内核态的日志输出函数,与 printf 类似,但本身不支持浮点数,且第一个参数必须是日志级别。

内核定义了八个日志级别:KERN_EMERGKERN_ALERTKERN_CRITKERN_ERRKERN_WARNINGKERN_NOTICEKERN_INFOKERN_DEBUGprintk(KERN_INFO "...") 展开后在消息前插入 <6> 优先级标记,内核根据当前控制台日志级别决定是否输出。dmesg 命令用于查看内核环形缓冲区中的所有日志,包括已经滚动的历史记录。

3.2 加载与卸载命令

命令作用
insmod hello.ko加载指定模块
rmmod hello卸载指定模块
modprobe hello自动解决依赖后加载
lsmod列出所有已加载模块
modinfo hello.ko查看模块信息

modprobe 优于 insmod 的地方在于它会读取 /lib/modules/$(uname -r)/modules.dep 文件,自动处理模块间的依赖关系。

3.3 Kbuild Makefile

内核模块不使用普通 Makefile,而是基于 Kbuild 系统。在与 hello.c 同目录下创建 Makefile:

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

obj-m 表示将 hello.o 作为模块目标(m 是 module 的缩写),而非编译进内核(对应 obj-y)。-C 指定内核源码树目录,M=$(PWD) 表示当前目录是外部模块路径。执行 make 后,会生成 hello.ko

四、内核模块结构进阶

4.1 许可证声明

Linux 内核采用 GPL 许可证,如果模块未声明兼容的许可证,内核会在加载时发出污染(taint)警告:

MODULE_LICENSE("GPL");
MODULE_LICENSE("GPL v2");
MODULE_LICENSE("Dual BSD/GPL");

非 GPL 模块仍然可以加载,但某些内核函数和符号将被拒绝访问,部分调试功能也会受限。

4.2 模块参数

内核模块可以接受命令行参数,语法与用户空间程序类似:

static int my_int = 10;
static char *my_string = "default";

module_param(my_int, int, 0644);
module_param(my_string, charp, 0644);
MODULE_PARM_DESC(my_int, "An integer parameter");
MODULE_PARM_DESC(my_string, "A string parameter");

加载时传入参数:sudo insmod mymod.ko my_int=42 my_string="hello"

4.3 导出符号

一个模块可以导出函数或变量供其他模块使用:

int my_exported_function(int x)
{
    return x * 2;
}
EXPORT_SYMBOL(my_exported_function);
/* 或 EXPORT_SYMBOL_GPL,仅对 GPL 模块可见 */

导出的符号会进入内核的全局符号表,其他模块通过 extern 声明即可调用。

五、字符设备驱动

字符设备是 Linux 中最基础的设备抽象,数据以字节流方式顺序读写。本节实现一个简单的 “echo 设备”:用户写入的字符串可以通过读操作原样返回。

5.1 注册设备号

Linux 通过主次设备号标识设备。推荐使用动态分配:

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#include <linux/slab.h>

#define DEVICE_NAME "echo_device"
#define CLASS_NAME  "echo_class"

static int major;
static struct class *echo_class;
static struct device *echo_device;
static struct cdev echo_cdev;

#define BUFFER_SIZE 1024
static char *device_buffer;
static size_t buffer_len;

static int echo_open(struct inode *inode, struct file *file)
{
    printk(KERN_INFO "echo_device: opened\n");
    return 0;
}

static int echo_release(struct inode *inode, struct file *file)
{
    printk(KERN_INFO "echo_device: closed\n");
    return 0;
}

static ssize_t echo_read(struct file *file, char __user *user_buffer,
                         size_t len, loff_t *offset)
{
    size_t to_copy = min(len, buffer_len - *offset);

    if (to_copy == 0)
        return 0;

    if (copy_to_user(user_buffer, device_buffer + *offset, to_copy))
        return -EFAULT;

    *offset += to_copy;
    return to_copy;
}

static ssize_t echo_write(struct file *file, const char __user *user_buffer,
                          size_t len, loff_t *offset)
{
    size_t to_copy = min(len, (size_t)BUFFER_SIZE - 1);

    if (copy_from_user(device_buffer, user_buffer, to_copy))
        return -EFAULT;

    device_buffer[to_copy] = '\0';
    buffer_len = to_copy;
    *offset = 0;

    printk(KERN_INFO "echo_device: received %zu bytes\n", to_copy);
    return to_copy;
}

static struct file_operations fops = {
    .owner   = THIS_MODULE,
    .open    = echo_open,
    .release = echo_release,
    .read    = echo_read,
    .write   = echo_write,
};

copy_to_usercopy_from_user 是内核与用户空间之间的安全数据传输函数。直接使用指针解引用访问用户空间地址会导致页错误,因为用户态和内核态拥有不同的页表映射。

5.2 初始化与清理

static int __init echo_init(void)
{
    dev_t dev;

    device_buffer = kzalloc(BUFFER_SIZE, GFP_KERNEL);
    if (!device_buffer)
        return -ENOMEM;

    if (alloc_chrdev_region(&dev, 0, 1, DEVICE_NAME) < 0) {
        kfree(device_buffer);
        return -1;
    }

    major = MAJOR(dev);
    cdev_init(&echo_cdev, &fops);

    if (cdev_add(&echo_cdev, dev, 1) < 0) {
        unregister_chrdev_region(dev, 1);
        kfree(device_buffer);
        return -1;
    }

    echo_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(echo_class)) {
        cdev_del(&echo_cdev);
        unregister_chrdev_region(dev, 1);
        kfree(device_buffer);
        return PTR_ERR(echo_class);
    }

    echo_device = device_create(echo_class, NULL, dev, NULL, DEVICE_NAME);
    if (IS_ERR(echo_device)) {
        class_destroy(echo_class);
        cdev_del(&echo_cdev);
        unregister_chrdev_region(dev, 1);
        kfree(device_buffer);
        return PTR_ERR(echo_device);
    }

    printk(KERN_INFO "echo_device: registered with major %d\n", major);
    return 0;
}

static void __exit echo_exit(void)
{
    dev_t dev = MKDEV(major, 0);

    device_destroy(echo_class, dev);
    class_destroy(echo_class);
    cdev_del(&echo_cdev);
    unregister_chrdev_region(dev, 1);
    kfree(device_buffer);

    printk(KERN_INFO "echo_device: unregistered\n");
}

module_init(echo_init);
module_exit(echo_exit);
MODULE_LICENSE("GPL");

alloc_chrdev_region 向内核申请一个未被占用的主设备号,class_createdevice_create 在 sysfs 中创建设备节点信息,现代 Linux 系统结合 udev 规则会自动在 /dev/ 目录下生成对应的设备文件。加载模块后,即可通过 /dev/echo_device 进行读写测试:

echo "Hello Kernel" > /dev/echo_device
cat /dev/echo_device

六、内核内存与同步机制

6.1 内存分配

内核提供多套内存分配接口,最常用的是 kmalloc 家族:

函数说明
kmalloc(size, flags)分配物理连续的内存,上限约 128 KB
kzalloc(size, flags)等同于 kmalloc + memset(0)
kfree(ptr)释放 kmalloc/z 分配的内存
vmalloc(size)分配逻辑连续但物理可能不连续的内存,上限更大
vfree(ptr)释放 vmalloc 分配的内存

kmallocflags 参数最关键的值是 GFP_KERNEL(标准内核空间分配,可能休眠)和 GFP_ATOMIC(原子分配,用于中断上下文,不会休眠但可能失败)。

kmalloc 内部基于 slab 分配器实现,不同大小的请求会被归入预定义的 slab 缓存(kmalloc-8、kmalloc-16、kmalloc-32 … kmalloc-8192)。kmem 中的缓存名称对调试内存泄漏非常有用。

6.2 同步原语

内核态不能直接使用用户空间的 pthread_mutex,而应使用内核提供的同步机制:

自旋锁(spinlock):忙等待锁,适用于多核 CPU 之间的短期临界区保护,不可用于可能休眠的代码路径(如持有自旋锁时不能调用 kmalloc(GFP_KERNEL))。

static DEFINE_SPINLOCK(my_lock);
unsigned long flags;

spin_lock_irqsave(&my_lock, flags);
/* 临界区 */
spin_unlock_irqrestore(&my_lock, flags);

互斥锁(mutex):进程级睡眠锁,适用于可能长时间持有的临界区,可以休眠。

static DEFINE_MUTEX(my_mutex);

mutex_lock(&my_mutex);
/* 临界区 */
mutex_unlock(&my_mutex);

在中断处理函数、软中断或 tasklet 中只能使用自旋锁;在进程上下文中如果临界区可能较长,优先使用 mutex。

七、调试技巧

7.1 printk 调试

printk 调试是最直接且最常用的方法。为了控制输出粒度,内核支持动态调试(dyndbg):

echo 'file mymodule.c +p' > /sys/kernel/debug/dynamic_debug/control

上述命令开启 mymodule.c 文件中所有 pr_debug 调用的输出,无需重新编译。查看动态调试状态:

cat /sys/kernel/debug/dynamic_debug/control | grep mymodule

7.2 Oops 消息分析

当内核模块触发空指针解引用、非法指令或栈溢出时,内核会打印 Oops 消息,其中包含程序计数器(PC)、寄存器状态和调用栈。通过 Oops 中的地址,结合 /proc/modules 中模块的加载基址,可以定位出错的指令在源代码中的位置:

./scripts/faddr2line vmlinux <模块名>+<偏移地址>

如果开启了 CONFIG_DEBUG_INFO,addr2line 可以直接将地址映射到具体的源文件和行号。

7.3 KGDB 源码级调试

对于复杂的逻辑错误,printk 调试效率很低。KGDB 允许使用 GDB 通过网络或串口连接到正在运行的内核,设置断点、单步执行、检查变量。

KGDB 的基本工作流:在目标机配置好 KGDB 并启动到调试模式后,在开发机上运行:

gdb ./vmlinux
(gdb) target remote /dev/ttyS0   # 或网络连接
(gdb) add-symbol-file mymod.ko 0x<加载地址>

之后便可像调试普通用户程序一样调试内核模块。需要注意:内核中的断点会冻结整个系统,因此必须确保 KGDB 连接通道(串口或网络)可用。

八、实战项目:proc 接口模块

Linux proc 文件系统是内核与用户空间之间最轻量的交互通道。本节实现一个 proc 文件 /proc/my_kstats,读取时返回当前系统运行时间、内存页统计和 CPU 数量。

8.1 完整代码

#include <linux/module.h>
#include <linux/proc_fs.h>
#include <linux/seq_file.h>
#include <linux/mm.h>
#include <linux/sched.h>
#include <linux/uptime.h>

#define PROC_NAME "my_kstats"

static void *my_seq_start(struct seq_file *s, loff_t *pos)
{
    if (*pos > 0)
        return NULL;
    return pos;
}

static void *my_seq_next(struct seq_file *s, void *v, loff_t *pos)
{
    (*pos)++;
    return NULL;
}

static void my_seq_stop(struct seq_file *s, void *v)
{
}

static int my_seq_show(struct seq_file *s, void *v)
{
    struct sysinfo si;
    struct timespec64 uptime;

    si_meminfo(&si);
    ktime_get_boottime_ts64(&uptime);

    seq_printf(s, "=== Kernel Stats ===\n");
    seq_printf(s, "Uptime: %lld seconds\n", (long long)uptime.tv_sec);
    seq_printf(s, "Total RAM pages: %lu\n", si.totalram);
    seq_printf(s, "Free RAM pages:  %lu\n", si.freeram);
    seq_printf(s, "Number of CPUs:  %d\n", num_online_cpus());

    return 0;
}

static const struct seq_operations my_seq_ops = {
    .start = my_seq_start,
    .next  = my_seq_next,
    .stop  = my_seq_stop,
    .show  = my_seq_show,
};

static int my_proc_open(struct inode *inode, struct file *file)
{
    return seq_open(file, &my_seq_ops);
}

static const struct proc_ops my_proc_fops = {
    .proc_open    = my_proc_open,
    .proc_read    = seq_read,
    .proc_lseek   = seq_lseek,
    .proc_release = seq_release,
};

static int __init mystats_init(void)
{
    if (!proc_create(PROC_NAME, 0444, NULL, &my_proc_fops)) {
        printk(KERN_ERR "Failed to create /proc/%s\n", PROC_NAME);
        return -ENOMEM;
    }

    printk(KERN_INFO "/proc/%s created\n", PROC_NAME);
    return 0;
}

static void __exit mystats_exit(void)
{
    remove_proc_entry(PROC_NAME, NULL);
    printk(KERN_INFO "/proc/%s removed\n", PROC_NAME);
}

module_init(mystats_init);
module_exit(mystats_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Proc interface for kernel statistics");

8.2 Makefile

obj-m += my_kstats.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

8.3 编译与测试

make
sudo insmod my_kstats.ko
cat /proc/my_kstats
sudo rmmod my_kstats
dmesg | tail

seq_file 接口是 proc 文件操作的标准范式,它封装了缓冲区管理和多次读取的拆分逻辑,开发者只需实现数据的生成逻辑,无需手动处理 read 系统调用的复杂性。

九、总结

内核模块开发是深入理解操作系统的最佳路径之一。本文覆盖了从模块的基础概念、Hello World 原型,到字符设备驱动实现,再到 proc 接口实战的完整链路。掌握 printkdmesginsmodrmmod 的基本用法后,应重点理解 copy_to_user/copy_from_user 在内核与用户空间通信中的必要性,以及 kmallocvmalloc 在内存连续性和使用场景上的差异。

调试能力决定了开发效率。printk 调试解决 80% 的问题,Oops 分析解决剩余的 15%,而 KGDB 则用于攻克最难的 5%。初学者应始终在虚拟机中反复练习加载、卸载、修改、再加载的循环,直到可以熟练地对模块进行热更新测试。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 进程与线程:从 PCB 到内核调度实体
  2. 虚拟内存与分页机制:从 MMU 到 TLB
  3. 系统性能诊断与调优:strace、perf、bpftrace