资讯动态

Linux内核模块开发实战:从环境搭建到文件拦截与透明加密

发布时间:2026/10/2 8:59:10 来源:尧图企业网站定制
2025年了写个业务接口、搭个前端页面这些活儿的门槛已经被AI拉得越来越低。但你去翻招聘网站Linux内核研发、驱动开发、安全加固的岗位薪资一直居高不下候选人却始终稀缺。原因不复杂用户态代码出问题重启能解决内核态代码一出问题轻则panic重则整台物理机直接挂掉连个保存现场的机会都不给你。这篇文章我不会去重复网上那些抄来抄去的原理长文而是按我实际开发的顺序带你完整走一遍Linux内核模块从环境搭建、编码、加载、调试到发布的流程。你会亲手写出一个能动态加载的内核模块实现file_operations里的read/write拦截逻辑并在此基础上搭出透明加密模块的框架。只要你有C语言基础、熟悉常用Linux命令并且愿意在虚拟机里折腾就能跟上。1. 为什么到了2025年内核模块开发依然值得亲手写一遍1.1 用户态解决不了的问题最后都会落到内核态很多人觉得现在的Linux生态已经足够丰富用户态工具什么都能干何必去碰内核。这个说法对了一半。像文件透明加密、目录级实时审计、进程行为拦截、网络数据包深度管控这类场景用户态方案要么性能扛不住要么存在时间窗口漏洞。举个最常见的例子你要拦截某个应用程序对配置文件的read/write操作。用户态用inotify只能拿到“文件被打开了”“被修改了”这种事后通知拿不到具体改了哪些字节更没法在写入落盘之前插入一层处理逻辑。而eBPF虽然能挂载到内核函数上做观测但在VFS层深度定制读写路径、修改返回数据依然不是它的主场。这一类需求最终都要落到一个内核模块里通过自定义file_operations或者挂接VFS回调来实现。2025年这个格局并没有根本改变。eBPF解决的是可观测性和部分安全策略问题内核模块解决的是深度定制问题。两者是共存关系不是替代关系。搜索引擎里常年挂着“linux 内核 动态加载 file_operations 拦截 read write”和“linux 内核 透明加密”这类热搜词也说明实际项目中这类需求非常普遍。1.2 写内核模块到底在锻炼什么能力我的体会是内核模块开发是理解整个Linux系统最佳的一扇门。你写用户态程序看到的是系统调用封装好的返回值写内核模块你会被迫去理解open()背后发生了什么、VFS层怎么找到inode、struct file是怎么被创建的、page cache在读写路径里扮演什么角色。这些知识不是孤立的技术细节它会反向提升你在用户态的排障能力。比如你处理一个“文件内容被篡改”的问题时如果懂内核态的文件读写流程就能判断出篡改发生在用户态、VFS层、页缓存还是块设备层排查范围瞬间缩小。而且内核模块的开发范式对工程素养要求极高并发、引用计数、内存生命周期、错误路径处理每一项都比用户态严格得多。你写用户态程序时一个内存泄漏可能运行几个月才暴露同样的错误放在内核模块里可能几分钟就把整台机器打挂。这种被迫养成的严谨习惯对任何方向的开发都有价值。1.3 这篇实战指南覆盖的路线我会从一张空白的Ubuntu 24.04桌面开始依次做这几件事搭建一套靠谱的内核模块开发环境包括编译工具链、内核头文件、调试虚拟机写第一个可加载、可卸载的hello_kmod模块摸清insmod、rmmod、dmesg的完整工作流实现一个真正有业务含义的模块通过file_operations拦截read/write调用把它扩展成透明加密模块的骨架结合内核crypto API做文件加解密最后讲清楚调试排障和生产发布环节那些文档里通常不写的坑这套流程走完你手里的不再是一段只能printk的示例代码而是一个具备实际开发深度的模块雏形。它可以直接作为你后续做安全审计、透明加密、设备驱动等方向项目的起点。2. 开发环境准备从内核版本选型到虚拟机隔离2.1 版本选型Ubuntu 24.04 内核6.12 LTS2025年做内核模块开发我推荐的基础环境是Ubuntu 24.04 LTS搭配Linux 6.12 LTS内核。6.12是2024年12月发布的长支持版本维护周期会持续到2026年底现在这个时间点刚好是它最稳定、第三方软件兼容性最完整的阶段。先确认你当前内核版本uname -r如果是6.12系列直接往下走。如果你用的是其他发行版也没关系只要内核版本大于等于5.15本文的代码和API基本都能用个别地方我会标注版本差异。这里提醒一句内核模块和内核版本是强绑定的。模块编译时会把当前内核的vermagic写进.ko文件里加载时内核会校验这个魔术字是否匹配。所以你在一台机器上编译的模块不能直接拷到另一台内核版本不同的机器上加载。后面第7章我会讲怎么用DKMS解决这个问题。2.2 安装编译内核模块的完整依赖编译模块不需要完整的内核源码树只需要linux-headers包。但这个依赖链比想象中多几个包一次性装齐省得后面缺啥补啥sudo apt update sudo apt install -y build-essential linux-headers-$(uname -r) \ libncurses-dev flex bison bc libelf-dev dwarves每个包都有它存在的理由。build-essential提供gcc和makelinux-headers包提供内核编译时生成的头文件和Makefile片段这些是编译模块必须的flex和bison是内核构建系统用来处理语法解析器的libelf-dev是为了支持elf格式检查dwarves提供pahole工具新版内核用它生成BTF信息。装完后验证一下头文件是否完整ls /lib/modules/$(uname -r)/build这个目录是一个软链接指向/usr/src目录下对应的linux-headers-xxx目录。能看到Makefile和include文件夹就说明环境没问题。2.3 强烈建议在虚拟机里练手内核模块开发有个残酷的现实你写用户态程序崩了最多段错误写内核模块一个野指针就能把整个系统搞挂。我刚入行那年在一个模块里忘记判空直接把当时正在用的主力开发机给panic了写了一半的代码全没保存。所以现在的标准做法是所有内核模块开发调试都在虚拟机里做。推荐用QEMU/KVM或者VirtualBox按快照随便折腾崩了回滚就是。我自己常用QEMU因为它支持串口输出内核panic的完整日志能直接重定向到宿主机终端比看虚拟机图形界面里的滚动日志舒服得多。如果你在Windows上开发WSL2也可以做内核模块开发因为它跑的是真实Linux内核。但WSL2的内核是微软定制版默认没有开部分模块编译选项你可能会在加载某些模块时遇到权限或功能缺失的问题。建议优先用完整虚拟机而不是WSL2。还要检查一下Secure Boot状态mokutil --sb-state如果输出是SecureBoot enabled那你编译出来的模块加载时会被拒因为内核处于lockdown模式只允许加载有有效签名的模块。两个解决办法一是进BIOS临时关闭Secure Boot二是给模块做签名。开发阶段为了方便很多人的选择是先关掉生产发布时再走签名流程这也是第7章的内容。3. hello_kmod第一个可加载模块的完整生命周期3.1 模块源码入口、出口与printk调试直接进入正题。新建一个工作目录比如~/kmod/hello_kmod创建hello_kmod.c文件#include linux/init.h #include linux/module.h #include linux/moduleparam.h #include linux/kernel.h MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name youexample.com); MODULE_DESCRIPTION(A simple hello world kernel module); MODULE_VERSION(1.0); static int repeat 1; module_param(repeat, int, 0644); MODULE_PARM_DESC(repeat, Number of times to print hello (default: 1)); static int __init hello_init(void) { int i; for (i 0; i repeat; i) pr_info(hello_kmod: hello, kernel! (%d/%d)\n, i 1, repeat); return 0; } static void __exit hello_exit(void) { pr_info(hello_kmod: goodbye, kernel!\n); } module_init(hello_init); module_exit(hello_exit);几个关键点解释一下。MODULE_LICENSE(GPL)不只是一个声明。如果你的模块声明为非GPL许可证内核里大量EXPORT_SYMBOL_GPL导出的符号对你就是不可见的很多核心API根本调不了。这几年有些厂商因为许可证问题吃了大亏开发阶段直接写GPL最省事。__init和__exit是内核的特殊属性宏。__init标记的函数在模块加载完成后会释放占用的内存__exit标记的函数在模块卸载时才会被链接进去这样能减小模块常驻内存。如果你看到“init_module: Unknown symbol”这类报错往往就是函数属性写错了。pr_info是新内核推荐的printk简化宏等价于printk(KERN_INFO ...)。在内核日志里信息级日志用pr_info调试用pr_debug错误用pr_err不要一上来全都用printk裸宏。3.2 Makefile与编译在同一个目录下创建Makefileobj-m : hello_kmod.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean这里的核心是obj-m : hello_kmod.o它告诉内核构建系统“把hello_kmod.c编译成内核模块”。-C $(KDIR)表示切换到内核头文件目录执行MakefileM$(PWD)表示编译完成后回到当前目录生成模块。执行编译make编译成功后会生成hello_kmod.ko。用file命令看一下file hello_kmod.ko输出里包含ELF 64-bit LSB relocatable和内核版本信息说明模块已经生成。3.3 加载、查看、卸载的完整命令链加载模块前先用modinfo查看模块元数据modinfo hello_kmod.ko会显示description、author、license、vermagic等信息。在Linux运维中modinfo是排查模块加载问题的第一步它能在加载前就发现vermagic不匹配的问题。加载模块sudo insmod hello_kmod.ko repeat3insmod是直接加载模块的命令参数格式是模块名值这里传入repeat3模块就会打印三条日志。查看加载结果lsmod | grep hello_kmod dmesg | tail -n 10dmesg输出里能看到三条hello, kernel!日志。如果加载后没看到日志多半是printk级别被dmesg的级别过滤了可以用dmesg -H查看全部或调整loglevel。卸载模块sudo rmmod hello_kmod dmesg | tail -n 3能看到goodbye, kernel!。此时再执行lsmod模块已经不在列表里。模块参数还有一个入口可以查看。加载模块后/sys/module/hello_kmod/parameters目录下会生成repeat这个文件用cat查看它当前值就是3。如果参数权限允许0644root用户还可以通过echo往这个文件写入新值运行时动态修改参数。3.4 加载失败最常见的三个错误我把新手最常见的加载错误列出来这些我在学习阶段都遇到过报错信息原因解决方案Invalid module format模块的vermagic与当前内核不匹配或者模块编译架构错误确认linux-headers版本和uname -r一致重新make clean后编译Operation not permittedSecure Boot开启导致内核不接受未签名模块或者权限不足检查mokutil --sb-state开发环境临时关闭Secure BootUnknown symbol模块引用了内核没有导出的符号或者符号存在于GPL限制区用nm hello_kmod.ko查看未定义符号确认对应内核符号已EXPORT遇到问题先dmesg看尾部日志内核加载模块失败时通常会给出具体的失败原因这些信息比用户态程序的报错要精准得多。4. 核心实战file_operations拦截read/write的两种思路4.1 先搞清楚VFS的调用路径要做file_operations层面的拦截必须先理解VFS虚拟文件系统层的调用机制。当用户态调用read()时内核走的是系统调用入口 → VFS层的ksys_read() → 根据文件对应的struct file找到f_op → 调用f_op-read()。这个f_op是一个struct file_operations指针它指向一个包含了open、read、write、release等函数指针的结构体。每个打开的文件都有一个f_op它决定了对这个文件进行的各种操作具体落到哪个函数。在内核6.x版本中file_operations结构体的定义已经发生了两个重要变化第一绝大多数文件系统的f_op实例被定义为const放在只读内存段第二read/write等接口的签名里增加了更多标志位参数。这意味着“直接改写一个文件的f_op指针指向”这种操作在新内核里会受到内存保护强行写会触发page fault。这给传统的“劫持f_op”方案增加了难度也让它变得更加不推荐。我建议把拦截需求拆成两种路线来考虑一种是自己创建字符设备在设备初始化时指定自己的f_op这样读写请求天然会进入你的回调函数另一种是修改已有文件的f_op这种做法在旧内核里常见在新内核里风险极高。4.2 路线一注册自己的字符设备并接管read/write这条路线适合做加密设备、审计网关、虚拟设备这类场景。它的核心是通过miscdevice框架注册一个杂项设备然后把read/write回调指向你自己的实现。#include linux/init.h #include linux/module.h #include linux/miscdevice.h #include linux/fs.h #include linux/uaccess.h #define DEV_NAME kmod_demo static ssize_t demo_read(struct file *file, char __user *buf, size_t len, loff_t *offset) { char msg[] hello from kernel module\n; size_t msg_len sizeof(msg) - 1; pr_info(kmod_demo: read intercepted, len%zu\n, len); if (*offset msg_len) return 0; if (len msg_len - *offset) len msg_len - *offset; if (copy_to_user(buf, msg *offset, len)) return -EFAULT; *offset len; return len; } static ssize_t demo_write(struct file *file, const char __user *buf, size_t len, loff_t *offset) { char kbuf[256]; pr_info(kmod_demo: write intercepted, len%zu\n, len); if (len sizeof(kbuf)) len sizeof(kbuf) - 1; if (copy_from_user(kbuf, buf, len)) return -EFAULT; kbuf[len] \0; pr_info(kmod_demo: received from user: %s\n, kbuf); return len; } static const struct file_operations demo_fops { .owner THIS_MODULE, .read demo_read, .write demo_write, }; static struct miscdevice demo_misc { .minor MISC_DYNAMIC_MINOR, .name DEV_NAME, .fops demo_fops, }; static int __init demo_init(void) { int ret misc_register(demo_misc); if (ret) pr_err(kmod_demo: failed to register misc device, ret%d\n, ret); else pr_info(kmod_demo: device /dev/%s registered\n, DEV_NAME); return ret; } static void __exit demo_exit(void) { misc_deregister(demo_misc); pr_info(kmod_demo: device unregistered\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL);这段代码里值得注意的细节有三个copy_to_user/copy_from_user是内核态与用户态数据交换的专用接口。内核不能直接用memcpy去读写用户态指针因为用户态的内存可能被换出直接访问会触发缺页异常。这两个函数会处理这些情况并在失败时返回未拷贝的字节数你需要检查返回值并返回-EFAULT。misc_register注册后系统会自动在/dev下创建设备节点。不需要手动mknod对新手友好。它适合那些简单、不需要申请主设备号的字符设备。MISC_DYNAMIC_MINOR让内核动态分配次设备号避免手动分配造成的冲突。编译加载后可以直接用echo和cat测试sudo insmod kmod_demo.ko echo hello user space | sudo tee /dev/kmod_demo sudo cat /dev/kmod_demo dmesg | tail你会看到内核日志里打印出了write和read被拦截的信息。这一套就是file_operations拦截最稳定的落地方式适用于你能控制设备节点创建的场景。4.3 路线二动态替换已有文件的f_op到底能不能做这是热搜词“linux 内核 动态加载 file_operations 拦截 read write”里隐含的一条技术路线加载模块后找到目标文件的struct file把它指向的f_op换成你自己写的file_operations这样对任意已有文件的读写操作都能被拦截。从原理上说在内核5.x之前的版本这种操作是可行的通过task_struct遍历进程的files_struct找到fd对应的struct file然后file-f_op fake_fops;完事。但到了6.x内核这条路基本被堵死了文件系统普遍把f_op声明为const替换时相当于往只读内存区域写数据会触发内核保护机制同一个文件系统内大量文件共享同一个f_op你替换一个文件的f_op会影响这个文件系统其他文件的后续操作污染范围完全不可控替换f_op会破坏文件系统的引用计数和状态一致性极容易触发panic或数据损坏我的建议是除非你在做安全研究、在内核调试环境里做验证否则不要在生产环境用这种方案。它不是一个工程上可持续的设计。如果确实需要“针对现有文件的读写做动态拦截”2025年更务实的方案是用Linux安全模块LSM的钩子或者直接在VFS层挂接fanotify进行监控配合用户态策略做处理。需要修改读写内容时优先考虑在第5章讲的透明加密框架而不是直接替换f_op。4.4 2025年更务实的替代方案对比我整理了一张表按不同拦截需求推荐不同技术需求推荐方案理由监控文件读取、打开事件fanotify / inotify用户态就能做成本最低支持目录级监控在VFS读写路径上做策略拦截如EDRLSM hooks官方支持的拦截点不破坏文件系统结构深度定制某类设备的读写行为自定义字符设备 自定义f_op设备节点可控不影响其他文件对现有文件做透明加密内核态加密文件系统、块设备加密层生命周期和并发处理被系统性解决观测内核函数内部调用ftrace / kprobe无需修改目标函数观察成本低这几种方案结合使用几乎可以覆盖2025年你遇到的所有VFS层拦截需求而且比直接替换f_op稳定得多。5. 从拦截走向透明加密结合内核crypto API的模块框架5.1 透明加密要解决的核心问题“透明加密”这四个字的含义是用户态进程感觉不到加密的存在打开文件读到的是明文写入文件时内核自动在落盘前完成加密。对用户态完全透明但磁盘上保存的是密文。思路拆开就两条write路径上先对用户态传来的buf做加密再交给底层写盘read路径上先读到底层的密文再解密后返回给用户态。第4章的file_operations拦截只是插了一个日志在这里它的真正价值才体现出来——你可以在回调函数里插入加解密逻辑。但直接理解成“在f_op-write里调一下crypto_encrypt就完事”就天真了。透明加密的工程难点在于同一文件可能被多个进程同时打开读写mmap方式的文件访问会绕过read/write回调页缓存里的明文页何时淘汰、何时加密落盘这些问题都需要系统性的设计。5.2 内核crypto API快速上手先快速掌握内核crypto API的核心调用方式。这里用AES-XTS作为示例它是磁盘加密场景的推荐算法因为XTS模式专门解决扇区加密时的可随机读写问题。#include crypto/skcipher.h struct crypto_skcipher *tfm; struct skcipher_request *req; struct scatterlist sg_in, sg_out; int ret; // 分配算法实例 tfm crypto_alloc_skcipher(xts(aes), 0, 0); if (IS_ERR(tfm)) { pr_err(failed to alloc cipher: %ld\n, PTR_ERR(tfm)); return PTR_ERR(tfm); } // 设置密钥AES-XTS需要32字节两个16字节的AES密钥 ret crypto_skcipher_setkey(tfm, key, 32); if (ret) { pr_err(setkey failed: %d\n, ret); crypto_free_skcipher(tfm); return ret; } // 分配请求对象 req skcipher_request_alloc(tfm, GFP_KERNEL); if (!req) { crypto_free_skcipher(tfm); return -ENOMEM; } // 准备scatterlist描述输入输出缓冲区 sg_init_one(sg_in, plaintext, len); sg_init_one(sg_out, ciphertext, len); // 设置加密请求输入、输出、长度、IV(tweak) skcipher_request_set_crypt(req, sg_in, sg_out, len, iv); // 执行加密 ret crypto_skcipher_encrypt(req); if (ret) pr_err(encrypt failed: %d\n, ret); // 执行解密则调用 crypto_skcipher_decrypt(req, ...) skcipher_request_free(req); crypto_free_skcipher(tfm);几个坑说一下AES-XTS的IVtweak不是传统意义的随机IV它通常是对应扇区的编号。这样设计的好处是加解密可以随机定位到任意扇区不需要像CBC那样必须按块顺序处理。你加密第100个扇区时只需要知道第100个扇区的编号就能独立完成加解密。crypto_skcipher_encrypt可能异步完成不一定会同步执行完毕。对于块设备加密场景通常用crypto_wait_req搭配completion机制等待完成。如果你在write回调这种不允许睡眠的上下文里调用就要考虑用异步回调的方式处理。新手最容易在这里翻车。5.3 一个最小可行的加密模块框架下面这个框架展示了如何在字符设备write路径上先加密再“落盘”。注意这里我把落盘简化为打印日志实际项目中你需要把密文交给真正的存储后端比如写入一个普通文件或者块设备。static ssize_t enc_write(struct file *file, const char __user *buf, size_t len, loff_t *offset) { char *plaintext; char *ciphertext; u8 iv[16] {0}; int ret; // 限制单次写入大小避免在内核态申请过大内存 if (len 4096) return -EINVAL; plaintext kmalloc(len, GFP_KERNEL); ciphertext kmalloc(len, GFP_KERNEL); if (!plaintext || !ciphertext) { ret -ENOMEM; goto out; } if (copy_from_user(plaintext, buf, len)) { ret -EFAULT; goto out; } // 使用文件偏移量或扇区号作为tweak这里简化处理 *(u64 *)iv *offset; // 加密plaintext - ciphertext ret do_aes_xts_encrypt(plaintext, ciphertext, len, iv); if (ret) goto out; // 这里把ciphertext写入真实的存储后端 // 真实项目中调用原f_op-write、写入块设备、或写入page cache pr_info(enc_write: %zu bytes encrypted at offset %lld\n, len, *offset); out: kfree(plaintext); kfree(ciphertext); return ret ? ret : len; }这套逻辑对应读路径就是先读出密文解密后再copy_to_user。框架本身不复杂复杂的是密钥管理。密钥不能写死在代码里通常做法是模块加载时从内核keyring读取或者由用户态通过ioctl注入。这样才符合加密审计和密钥轮换的要求。5.4 为什么真实产品往往不自己做文件级透明加密这里我必须泼一盆冷水。上面这个框架能跑但它只是教学意义上的“最小实现”。真实产品的文件级透明加密需要考虑页缓存一致性进程A写入了明文页缓存里存的是明文还是密文进程B再读时该怎么办mmap路径映射文件后通过内存访问不经过read/write回调并发写入同一文件多个f_op同时执行时的锁设计崩溃一致性加密过程中断电文件会不会损坏需不需要journal所以2025年你能看到的成熟产品普遍选择两条路一种是用dm-crypt这类块设备层加密方案对整个块设备加密不管上面是什么文件系统另一种是用内核自带的加密文件系统如ext4的encrypt特性、fscrypt框架。它们把页缓存、并发、崩溃恢复这些复杂问题都系统性地解决了。那为什么还要学这个模块框架因为它是理解这些成熟方案内部原理的最好路径。你理解了write路径加密和read路径解密的基本模型再去读fscrypt源码时会轻松很多同时它也是你为特殊场景设计定制方案的起点——比如嵌入式Linux里你用不上完整加密文件系统但需要一个轻量的专用加密设备这时候这个框架就能直接改造成产品代码。6. 调试排障实录怎么把内核态崩溃锁死在虚拟机里6.1 调试环境QEMU串口日志与nokaslr内核模块调试和用户态调试完全是两回事。用户态你可以用gdb设断点、单步执行内核态一旦panic你需要在有限的日志里找到问题所在。第一件事就是准备一个能导出完整内核日志的调试环境。我用QEMU时会这样启动虚拟机qemu-system-x86_64 \ -m 4096 -smp 4 \ -kernel /boot/vmlinuz-$(uname -r) \ -initrd /boot/initrd.img-$(uname -r) \ -append root/dev/vda1 consolettyS0 nokaslr \ -drive fileubuntu.qcow2,formatqcow2 \ -nographic核心参数是consolettyS0它让内核把日志输出重定向到串口然后-nographic把串口直接接到当前终端。这样即使内核panic完整日志会保留在终端滚动缓冲区里你可以直接截图或者复制分析。nokaslr是我调试时的习惯。KASLR内核地址空间布局随机化会让内核每次启动地址都不同导致你看到的Oops地址无法直接用addr2line对应到源码行号。关掉它地址就能稳定对应到vmlinux镜像文件。6.2 从Oops栈回溯到定位代码行当内核模块触发异常时日志里会出现一长串以“Oops:”开头的信息。里面的关键字段BUG: unable to handle page fault for address: ffff888012345678 RIP: 0010:my_function0x15/0x50 [my_module] Call Trace: TASK entry_SYSCALL_64_after_hwframe0x74/0x80 /TASKRIP行告诉你出错的函数和偏移量my_function0x15/0x50表示出错位置在my_function符号内偏移0x15字节处这个函数总长度0x50字节。Call Trace是函数的调用链。要精确定位到源码行把RIP里的地址去掉偏移量用模块加载基址加偏移的实际地址换算后用addr2line处理addr2line -e /usr/lib/debug/boot/vmlinux-$(uname -r) ffffffff81001234如果模块本身有调试信息也可以用同样的方式分析模块文件。前提是编译时给Makefile加EXTRA_CFLAGS-g保留DWARF调试信息。内核社区常用的是Crash工具配合kdump来分析离线内核转储但日常开发阶段上面这套“串口日志addr2line”已经能覆盖90%的问题定位需求。6.3 我踩过的三个典型内核态坑第一个坑忽略引用计数导致模块无法卸载。我写第一个字符设备模块时忘记了在f_op的.open里调用模块的引用计数增加接口结果用户态进程open了设备之后模块直接rmmod卸载。然后用户态再往设备文件写入内核访问了已经被释放的f_op指针当场panic。正确做法是在open回调里调用try_module_get(THIS_MODULE)在release回调里调用module_put(THIS_MODULE)保证模块正在被使用时不会被卸载。第二个坑持锁睡眠。内核里有大量自旋锁它设计为只允许短暂持有持锁期间不能睡眠。我当时在一个自旋锁保护的区域里调用了copy_to_user而这个函数可能因为缺页而睡眠结果导致死锁。排查时表现为主机完全卡死鼠标键盘都不响应。后来用CONFIG_PROVE_LOCKING重新编译内核启动后检测直接报出警告定位就很快。如果你遇到莫名死锁先检查锁上下文里有没有睡眠操作。第三个坑kmalloc之后忘了kfree。用户态内存泄漏你可能很久才发现内核态kmalloc一次次分配最终会触发系统内存耗尽。最麻烦的是这种问题不会立即崩溃而是过一段时间随机性出现OOM让人摸不着头脑。我的排查习惯是每次kmalloc立刻写对应的kfree路径先保证错误路径也会释放再填充业务逻辑。分配和释放配套写是内核模块开发的铁律。6.4 用好内核自带的观测手段除了崩溃后分析日志我们还能主动观测模块运行状态。最常用的是/proc和/sys伪文件系统。lsmod显示已加载模块列表cat /proc/modules能看到模块占用内存大小和引用计数模块参数可以动态修改/sys/module/模块名/目录下还能看到模块的很多状态信息。更进阶的手段是ftrace。先查看当前内核支持跟踪哪些函数cat /sys/kernel/tracing/available_filter_functions | grep vfs_read开启跟踪echo vfs_read /sys/kernel/tracing/set_ftrace_filter echo function /sys/kernel/tracing/current_tracer cat /sys/kernel/tracing/trace这样你能看到完整的读写调用栈对理解f_op的调用路径和排查“为什么我的回调没被调用”这类问题都非常有效。kprobe则可以在任意函数入口处插入探测点打印参数和返回值不需要重新编译内核。新版内核里eBPF工具链bpftrace提供了更简洁的语法很多场景可以替代手写kprobe模块。但kprobe模块本身依然值得掌握它是理解eBPF底层机制的一块奠基石。7. 上生产前必须搞定的事模块签名、DKMS与发布红线7.1 元信息与许可证modinfo背后的门道开发环境里能跑的模块离生产标准还有一段距离。第一步是补齐元信息。前面hello_kmod里已经写了MODULE_AUTHOR、MODULE_DESCRIPTION、MODULE_VERSION这些不是装饰modinfo里显示的每一项都来自这些宏。生产模块还应该加上MODULE_ALIAS让modprobe能通过别名找到模块。MODULE_LICENSE的影响在静态检查阶段就存在。除了前面说的导出符号可见性问题在内核社区审核流程里许可证不兼容的模块基本不可能被上游接受。如果你未来有把模块提交到内核主线或者某个发行版仓库的计划从一开始就用GPL兼容许可证是最省事的选择。7.2 与发行版内核的绑定问题内核模块不是跨发行版通用的。即使同为6.12内核Ubuntu编译时打开了某些配置项、CentOS/Rocky打开了另外一些配置项模块里引用的部分符号可能存在或不存在加载结果完全不同。国产Linux发行版麒麟、统信等虽然使用标准内核接口但同样需要对应用户环境的内核版本和配置来编译模块。这也解释了为什么很多厂商在发布内核模块产品时要同时提供多套预编译版本或者干脆用DKMS让用户在目标机器上现场编译。检查模块与当前内核匹配情况最简单的方法是modinfo hello_kmod.ko | grep vermagic uname -r两者不一致加载时几乎必然报Invalid module format。7.3 Secure Boot下的模块签名生产环境普遍开启了Secure Boot这时你的模块必须以被信任的密钥签名才能加载。开发阶段的临时解决办法是关闭Secure Boot生产环境不行。给模块签名需要一对MOKMachine Owner Key。如果系统里已经用shim-signed管理了MOK可以这样操作# 检查MOK是否已存在 ls /var/lib/shim-signed/mok/ # 如果还没有MOK创建一个 sudo mokutil --import /path/to/your/mok.der # 对模块签名 sudo /usr/src/linux-headers-$(uname -r)/scripts/sign-file \ sha256 \ /var/lib/shim-signed/mok/MOK.priv \ /var/lib/shim-signed/mok/MOK.der \ hello_kmod.ko签完名后用modinfo查看会多出一行signer信息。重启后如果提示“Verifying MOK”在MOK管理界面里确认导入之后签名模块就能正常加载。7.4 用DKMS自动适配内核更新生产环境最大的麻烦点是“内核升级后模块失效”。用户升级系统内核你预编译的模块在旧内核上还能用但新内核加载不了。DKMS就是解决这个问题的标准方案它会在每次内核更新时自动重编模块。在模块源码目录下创建一个dkms.confPACKAGE_NAMEhello_kmod PACKAGE_VERSION1.0 BUILT_MODULE_NAME[0]hello_kmod DEST_MODULE_LOCATION[0]/kernel/misc/ AUTOINSTALLyes MAKE[0]make KDIR/lib/modules/${kernelver}/build CLEANmake clean然后注册安装sudo dkms add . sudo dkms build hello_kmod/1.0 sudo dkms install hello_kmod/1.0之后系统每次安装新内核DKMS都会自动为它编译好对应版本的模块。这一步做完你的模块才具备对外发布的基础条件。7.5 写在最后的几条红线内核模块开发的工程红线我总结下来是这几条不要污染共享的file_operations。文件系统的f_op是全局共享资源除非你有完整的设计和异常处理方案否则不要动它。不要在自旋锁、原子上下文里调用可能睡眠的函数包括copy_to_user、kmalloc(GFP_KERNEL)、mutex_lock等。每次申请资源内存、密钥、设备节点都要有对等的释放路径错误分支也要释放不能只在成功路径清理。模块卸载时必须确保所有引用计数归零open中的try_module_get和release中的module_put必须成对。常驻线程要显式退出Do not rely on module_exit的隐式清理。最后说一点个人体会。这几年折腾内核模块我最大的感受是内核编程真正难的不是语法而是思维方式的转变——用户态你可以假设自己独享资源内核态随时可能被多核并发、中断上下文、持锁状态打断。写每一行代码前先问自己如果这里被并发调用怎么办如果持锁后睡眠了怎么办如果资源申请了一半失败怎么办养成这个习惯比记住任何API都有用。把这个习惯带到你之后的每一次开发里内核模块会是你进步最快的技术训练场。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑