ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux内核模块编程从入门到实战:编译、加载与调试全解析

Linux内核模块编程从入门到实战:编译、加载与调试全解析 Linux 内核模块编程是我觉得所有玩 Linux 的人迟早都会想碰一下的东西。你用了很久的 Linux敲过一堆命令写过不少 shell 脚本或者 C 程序有一天你会突然好奇内核到底是怎么工作的我能不能往里面加点东西这时候内核模块就是最顺手的入口。它不像改内核源码那样要重新编译整个内核、重启机器才能验证效果模块可以随时加载、随时卸载改一处编译一下就能看到结果开发迭代快得多。这篇文章我会从零开始把一个内核模块从写出来到跑起来的全过程拆开讲清楚适合已经会写 C 语言、但对内核没什么概念的读者当然如果你已经有点基础里面关于编译链接、符号处理、内存屏障踩坑的部分应该也能帮你把很多模糊的点串起来。很多教程上来就给一个 hello.c然后 insmod 一下打印个 Hello, World 就完事了。但你要是真的照着做大概率会遇到一堆让你抓狂的问题比如版本信息不匹配、权限不够、编译报错找不到头文件、模块加载进去后 dmesg 里啥也没有。这篇文章我尽量把这些坑都给你填上同时把内核模块背后那套编译、加载、链接的逻辑讲明白这样你遇到问题的时候不是靠瞎试而是真的知道该往哪个方向排查。1. 先搞明白内核模块到底解决什么问题1.1 一个模块的本质运行在内核态的动态链接库最直白的理解方式是把内核模块看成“运行在内核态的动态库”。你平时写程序调用的函数是 libc 提供的程序运行在用户态有自己独立的虚拟地址空间崩溃了最多就是 core dump。内核模块不是这样的它运行在内核态和内核共享同一个地址空间它犯的每一个错都不是段错误那么简单直接就是 panic整台机器死给你看。这也是为什么我强调学内核模块编程心态要先摆正。你不是在写一个普通的应用程序你是在往一个正在运行的、管理着所有进程、内存、文件系统、网络栈的操作系统核心里面插代码。权限最大责任也最大。那为什么要用模块呢直接编进内核里不行吗行但模块最大的价值是可插拔。你的 Linux 发行版内核里已经加载了几十个模块了你用lsmod看一眼就知道大部分驱动都是以模块形式存在的。好处是内核本身可以保持精简需要哪个硬件、哪个文件系统支持的时候再加载进去不需要的时候卸载掉内存也省了。1.2 模块机制和应用程序编译之间的核心区别初次接触内核模块开发最容易懵的地方就是同样是 C 代码为什么我普通程序那套编译方式行不通普通程序编译你用的是 gcc 加一堆-l参数链接库链接的是用户态的 glibc。内核模块编译链接的是内核本身这套链接规则由内核的构建系统Kbuild来定。你在模块源码里调用的printk实际上是一个内核符号不是 libc 的printf。你在模块源码里用的头文件也基本是/usr/src/linux-headers-xxx/include里的内核头文件而不是/usr/include里那套用户态头文件。这个差别会体现在编译命令上。你会发现编译内核模块并不是直接敲gcc -c hello.c而是通过 make 调用内核的 Makefile传入M当前目录这个参数让内核的构建系统帮你把模块编译出来。这背后的原因后面会详细说你现在只需要先记住编译内核模块你实际上是在和内核源码树打交道你的代码最终会被链接进内核的符号表体系里。2. 开发环境准备别急着写代码先把工具链和内核头文件备齐2.1 必要的软件包和内核头文件不同发行版的安装方式不太一样但核心就两样东西编译工具链和当前内核版本的头文件。Debian/Ubuntu 系列sudo apt update sudo apt install build-essential linux-headers-$(uname -r)RHEL/CentOS/Fedora 系列sudo yum install gcc make kernel-devel-$(uname -r) # 或者如果你是 Fedora用 dnf sudo dnf install gcc make kernel-devel-$(uname -r)注意这里的$(uname -r)这个命令是获取你当前正在运行的内核版本号。内核模块有个很关键的特性它和内核版本是强绑定的模块编译时候依赖的头文件必须和你运行的内核匹配否则加载的时候会报 module verification failed 之类的错误。还有一点如果你用的内核是发行版自带的那linux-headers-$(uname -r)包会包含编译模块所需的大部分头文件和一个 Makefile 框架。但如果你的内核是自己手动编译安装的那你需要确保/lib/modules/$(uname -r)/build这个软链接指向你编译内核的源码目录。2.2 一个小坑内核头文件和用户态头文件别混用这是我见过新手最容易犯的错源码里#include stdio.h或者#include string.h编译直接报错说找不到头文件。内核模块不是普通用户态程序它不能链接 glibc也不能随便包含用户态头文件。内核源码树里只有内核自己的头文件你在模块里能用的函数都是内核导出的那一批内核 API比如kmalloc、kfree、printk、copy_to_user、mutex_lock这些。像malloc、printf、strcpy这类用户态函数在内核模块里是不存在的。内核模块编程相当于你处于一个“没有标准库”的世界。很多功能都要依赖内核提供的接口来实现这跟应用程序开发写起来的手感差别很大。我觉得这也正是内核模块编程有意思的地方在限制之下你会对计算机底层的内存管理、并发控制、系统调用机制有更深的体会。验证一下你的环境有没有准备好执行下面的命令ls -l /lib/modules/$(uname -r)/build如果这个目录存在且不是空的说明内核头文件基本就位了。再执行which make which gcc能输出路径就说明工具链也在。3. 第一个内核模块源码逐行拆解3.1 Hello World 模块的完整代码环境准备好了我们来写第一个模块文件名就叫hello.c#include linux/init.h #include linux/module.h #include linux/kernel.h static int __init hello_init(void) { printk(KERN_INFO Hello, Linux kernel module!\n); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO Goodbye, Linux kernel module!\n); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple hello world kernel module);这段代码看着跟普通 C 程序差不多但每一处都有讲究。我一一拆开给你讲。3.2 头文件和初始化/退出函数linux/init.h里面定义了__init、__exit这些宏。__init标记的函数在模块加载完成后内核会把这部分代码占用的内存释放掉。因为初始化代码只用一次留着也是浪费内存。__exit标记的函数如果这个模块被编进内核不是模块方式就会被忽略掉因为内核对不需要卸载的代码是不会保留退出函数的。linux/module.h是模块编程必须包含的头文件里面定义了module_init、module_exit这些宏以及MODULE_*系列宏的声明。linux/kernel.h提供了printk等内核工具函数的原型以及KERN_INFO等日志级别的定义。hello_init是模块的入口函数。注意它只是被注册为入口并不是 C 语言的main函数。模块加载时内核会调用它它返回 0 表示初始化成功返回负数表示失败。hello_exit是模块被卸载时要执行的清理函数释放资源的重活都在这干。3.3 module_init 和 module_exit 宏的原理这是个很重要的概念理解了它你就理解了内核模块的加载机制。module_init(hello_init)这个宏其实并不是简单地把函数地址存到一个变量里那么简单。展开后它会在你的模块目标文件的特殊段.init段里创建一个结构体这个结构体包含了初始化函数的地址、模块名等信息。内核在加载模块时会解析模块的 ELF 文件里的这些特殊段找到初始化函数的地址然后调用它。这其实就是一种注册机制。你在用户态写的动态库用的是构造函数constructor attribute来执行一些初始化工作内核模块本质上也是一样的套路只不过它的加载器是内核本身约定的构造函数就是module_init注册的那个函数。rmmod卸载模块时内核会检查有没有谁还在引用这个模块如果没有就调用module_exit注册的清理函数然后把模块占用的内存释放掉。3.4 printk 和日志级别的那些事printk是内核态的打印函数相当于用户态的printf但它有日志级别。KERN_INFO是其中一种表示这条消息属于信息级别。常见日志级别从低到高有这些宏数值含义KERN_EMERG0紧急情况系统可能崩溃KERN_ALERT1必须立即处理KERN_CRIT2严重情况KERN_ERR3错误情况KERN_WARNING4警告KERN_NOTICE5正常但重要的情况KERN_INFO6信息性消息KERN_DEBUG7调试级别消息默认情况下内核会把日志输出到内核环形缓冲区里你可以通过dmesg命令查看。注意printk不会把消息打印到你的终端上除非你设置了控制台的日志级别。一个常见的困惑是insmod 加载模块后屏幕上什么都不显示然后你误以为模块没加载成功。实际模块已经加载了消息只是进了 dmesg 缓冲区。3.5 MODULE_LICENSE 为什么要写 GPLMODULE_LICENSE(GPL)这个声明很多人以为是可选的不写也能编译通过确实能编译但加载的时候内核会提示这个模块没有声明许可证并且会标记为“污染”tainted。这个标记会影响你后面调试的准确性因为你无法确认这个模块是不是导致系统异常的根源。更重要的是如果你声明的许可证不是 GPL 兼容协议那么一些原本导出的 GPL-only 符号你的模块就不能用了。特别是涉及一些核心内核 API 的时候比如某些锁机制、一些调试接口它们明确标记为只在 GPL 模块下可用。所以只要你不是在写闭源商业驱动建议还是写 GPL省得给自己挖坑。4. 编译和加载Makefile 的写法与 insmod/rmmod 实战4.1 一个标准的内核模块 Makefile在hello.c同目录下新建一个 Makefileobj-m : hello.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这个 Makefile 看起来有点绕但它的逻辑其实很清晰它是把真正的编译工作委托给内核源码树的 Makefile 去做的。KDIR是内核构建目录M$(PWD)告诉内核构建系统请在当前目录编译这个模块。obj-m : hello.o这行的意思是要把hello.c编译成一个内核模块目标文件。然后执行make如果一切顺利目录下会生成hello.ko这个.ko文件就是最终的内核模块全称是 kernel object。注意它不是一个可执行文件它更像是一个符合特殊格式要求的、包含了大量元信息的二进制文件。你可以用modinfo hello.ko查看它的信息modinfo hello.ko会输出模块的描述、作者、许可证还有一个很重要信息vermagic它记录了编译这个模块时的内核版本、编译器版本、SMP 选项等模块加载时内核会拿这个去和当前内核做比对。4.2 加载、查看、卸载模块的基本操作加载模块sudo insmod hello.ko查看模块是否加载成功lsmod | grep hello查看内核日志dmesg | tail -5如果一切正常你会看到Hello, Linux kernel module!这行输出。卸载模块sudo rmmod hello然后再看 dmesg会看到Goodbye, Linux kernel module!。这里注意insmod和rmmod需要 root 权限因为加载或卸载模块意味着在内核空间里添加或者删除代码这属于对系统核心的修改必须有特权。4.3 内核模块版本校验机制刚才提到 vermagic这里展开讲讲。模块加载时内核会做一系列检查具体包括模块的 vermagic 是否和当前内核匹配不匹配的话直接拒绝加载报错invalid module format。模块依赖的符号是否存在以及这些符号的版本是否一致。如果某个符号的 CRC 校验值不匹配也会报错。这种机制是有意设计的“防火墙”防止你用不同内核版本编译出的模块强行加载到当前内核造成不可预测的崩溃。毕竟内核态的代码一旦出错连错误排除的机会都没有直接重启机器。所以如果你在 A 机器上编译了一个模块想拿到 B 机器上去加载除非两台机器的内核版本完全一致包括编译选项否则大概率会失败。这是内核模块和普通程序在部署上一个很大的不同普通程序拷贝过去就能跑内核模块必须绑定内核版本。4.4 打印模块信息的两种方式除了printk之外如果你想让模块加载时打印的信息更完整可以用dev_info、dev_err这类设备相关的打印函数。但作为入门printk(KERN_INFO ...)已经够用了。有一个小技巧是加载模块前先执行sudo dmesg -C清空一下缓冲区这样加载后只dmesg不会被一堆历史日志淹没。sudo dmesg -C sudo insmod hello.ko dmesg这样你会非常清晰地看到模块加载产生的输出。5. 模块的进阶能力参数传递和符号导出5.1 模块参数让用户态控制模块行为一个只有固定逻辑的模块是很无聊的实际驱动和工具模块几乎都要支持通过参数来配置行为。比如你写一个网络相关的模块可能需要让用户指定使用的端口号写一个调试模块可能需要让用户指定调试级别。内核模块通过module_param和module_param_array两个宏来支持参数传递。来看个例子#include linux/init.h #include linux/module.h #include linux/moduleparam.h #include linux/kernel.h static int count 1; static char *name world; static int arr[3] {0, 0, 0}; module_param(count, int, 0644); MODULE_PARM_DESC(count, Number of times to print); module_param(name, charp, 0644); MODULE_PARM_DESC(name, Name to print); module_param_array(arr, int, NULL, 0644); MODULE_PARM_DESC(arr, An array of integers); static int __init param_init(void) { int i; for (i 0; i count; i) { pr_info(Hello, %s! (arr[0]%d, arr[1]%d, arr[2]%d)\n, name, arr[0], arr[1], arr[2]); } return 0; } static void __exit param_exit(void) { pr_info(param module unloaded\n); } module_init(param_init); module_exit(param_exit); MODULE_LICENSE(GPL);module_param(count, int, 0644)这里的三个参数分别是参数名、参数类型、文件权限。第三个参数如果设置为 0参数不会出现在 sysfs 文件系统里设置为 0644 的话模块加载后在/sys/module/你的模块名/parameters/count这个路径下会出现一个文件你可以通过读写这个文件来查看和修改参数值。加载的时候这样传参sudo insmod param.ko count5 namekernel arr1,2,3这样输出就会打印 5 次并且名字为 kernel数组也接收了传入的值。这个机制对于调试太有用了。我经常是模块里暴露几个调试开关参数加载的时候指定开启哪些日志不需要改代码重新编译。5.2 导出符号让模块间可以互相调用内核模块之间是可以调用彼此的函数和变量的但前提是这些符号要被明确地导出。导出的宏有两个EXPORT_SYMBOL和EXPORT_SYMBOL_GPL后者表示被导出的符号只对声明为 GPL 的模块可见。举个例子假设你有两个模块mod_a.ko和mod_b.komod_a导出了一个函数int add(int a, int b)mod_b可以声明这个函数的外来原型然后直接用因为它是一个外部符号。mod_a.c 里这样写#include linux/module.h int add(int a, int b) { return a b; } EXPORT_SYMBOL(add); MODULE_LICENSE(GPL);mod_b.c 里这样写#include linux/module.h extern int add(int a, int b); static int __init mod_b_init(void) { pr_info(3 4 %d\n, add(3, 4)); return 0; } module_init(mod_b_init); MODULE_LICENSE(GPL);编译时mod_a 要先加载mod_b 后加载否则 mod_b 会因为找不到符号add而加载失败。这也是模块依赖关系的一种体现可以用modinfo查看模块的依赖关系。不过实际开发中模块之间互相调用符号要慎重。因为模块加载顺序一旦出错系统直接起不来。大家更常用的做法是把公共逻辑放到底层模块里上层的功能模块依赖它或者干脆就做一个模块内部用文件、proc、sysfs 等方式对外提供接口。6. 用户态和内核态的交互不只是打印日志6.1 为什么直接文件操作不行前面的 hello world 和参数模块本质上都是单向输出模块自己在里面折腾用户态只能通过 dmesg 被动查看。但在现实场景里你的用户态程序需要主动跟内核模块对话读写配置、发送命令、接收数据。内核模块运行在内核态用户态程序运行在用户态两者的地址空间是隔离的指针不能直接互传。你写一个用户态程序把一块缓冲区的地址传给内核模块让内核模块直接往这个地址写数据这是行不通的。内核模块如果直接写用户态的指针结果通常是触发一个内核保护错误系统直接 Oops。这就引出了内核模块交互最核心的一套机制文件操作接口。也就是说你的模块可以在/dev下创建一个设备文件用户态程序像读写普通文件一样去读写这个设备文件内核模块通过 file_operations 结构体里注册的 read、write、ioctl 等回调函数来响应这些操作。6.2 一个极简的 misc 设备示例完整地写一个字符设备驱动涉及的东西很多主设备号、次设备号、设备类、缓冲区管理。这里我展示一个简化版用 misc 设备框架这个框架帮我们处理了主设备号的分配等杂务适合入门和快速开发工具型模块。#include linux/init.h #include linux/module.h #include linux/miscdevice.h #include linux/fs.h #include linux/uaccess.h #define MISC_DEV_NAME mymisc static ssize_t my_read(struct file *file, char __user *buf, size_t count, loff_t *off) { const char *msg hello from kernel!\n; size_t len strlen(msg); if (*off len) return 0; if (count len - *off) count len - *off; if (copy_to_user(buf, msg *off, count)) return -EFAULT; *off count; return count; } static ssize_t my_write(struct file *file, const char __user *buf, size_t count, loff_t *off) { char kbuf[128]; size_t len count sizeof(kbuf) - 1 ? count : sizeof(kbuf) - 1; if (copy_from_user(kbuf, buf, len)) return -EFAULT; kbuf[len] \0; pr_info(received from user: %s\n, kbuf); return len; } static const struct file_operations my_fops { .owner THIS_MODULE, .read my_read, .write my_write, }; static struct miscdevice my_misc { .minor MISC_DYNAMIC_MINOR, .name MISC_DEV_NAME, .fops my_fops, }; static int __init misc_init(void) { return misc_register(my_misc); } static void __exit misc_exit(void) { misc_deregister(my_misc); } module_init(misc_init); module_exit(misc_exit); MODULE_LICENSE(GPL);编译加载后执行sudo insmod misc.ko ls -l /dev/mymisc如果看到设备节点mymisc出现了说明注册成功。然后你可以用 echo 和 cat 测试echo hello user /dev/mymisc cat /dev/mymisc注意cat会读取我们设备的内容也就是内核模块打印的那句话。在 read 和 write 回调里我用到了copy_to_user和copy_from_user。这两个函数是内核态和用户态数据拷贝的“正规军”千万不能用memcpy直接拷贝因为用户态指针在内核态是危险的轻则访问被拒绝重则系统崩溃。这也是内核模块编程和用户态编程差异最明显的地方之一。6.3 ioctl更高效的控制面交互除了 read/write还有一个非常常用但经常被新手忽略的接口ioctl。它本质上是一个命令通道专门用于设备控制指令的传输比如 nvme 工具管理硬盘、以太网卡配置速率在你自己的模块中也能定义自定义命令。ioctl 函数的原型是static long my_ioctl(struct file *file, unsigned int cmd, unsigned long arg)cmd是你定义的命令号arg是参数指针本质上也是一块用户态地址处理方式和 read/write 里一样需要通过copy_from_user/copy_to_user来访问。ioctl 命令号有个构造规则_IOW、_IOR、_IO这些宏分别表示写方向、读方向、无数据方向的命令。用户态程序用ioctl(fd, cmd, arg)调用内核模块在回调里响应。如果你要做的模块涉及复杂的控制逻辑ioctl 会比 read/write 更友好因为你可以在一个入口里处理所有控制命令代码结构清晰得多。7. 排查问题的实战思路版本、符号、权限三大关7.1 insmod 报错 invalid module format这个错误几乎所有人都遇到过原因就是模块 vermagic 和当前内核不匹配。可能是你在 A 内核版本上编译的模块拿到 B 内核版本上加载了也可能是编译模块时系统里有多套内核头文件Makefile 指向了错误的那套。排查步骤执行uname -r确认当前内核版本。执行modinfo hello.ko查看模块的 vermagic 行。两者对比如果不一致清理重新编译make clean make。还有一种隐蔽的情况编译模块时KDIR指向的目录是/lib/modules/$(uname -r)/build但这个软链接有可能指向了错误的环境尤其是当你用容器或者 chroot 编译的时候。确认一下ls -l /lib/modules/$(uname -r)/build | readlink -f指向的是不是你预期的内核源码树。7.2 insmod 报错 Unknown symbol in module这类报错一般是模块依赖的某个内核符号缺失或者符号本身没有导出。你用EXPORT_SYMBOL导出的符号其他模块才能用。如果某个符号是 GPL-only 的而你的模块没有声明 GPL 许可证也会报这个错。用nm hello.ko可以查看模块的符号表看看U标记的未定义符号有哪些nm hello.ko | grep U 然后到内核符号表里查一下grep 符号名 /proc/kallsyms如果/proc/kallsyms里查不到这个符号说明内核根本没有导出它或者你需要重新配置内核编译选项。我遇到过一个典型情况使用了某个内核版本后原本导出的符号在新版本里被移到了另一套接口下面。这类问题最好的解决办法是查阅内核源码树里对应的头文件直接用新接口。7.3 dmesg 里看不到模块打印的消息有时候 insmod 后模块加载成功lsmod 也能看到但 dmesg 里就是没有输出。原因有几个你没有用 root 权限执行 dmesg。某些发行版限制非 root 用户读取内核日志此时用sudo dmesg。printk 的日志级别低于当前控制台的日志级别导致消息没有渲染到终端。这种情况下 dmesg 里其实已经记录了只是没显示检查是不是dmesg | tail没刷够行数。缓冲区被刷掉了设置更大的内核环形缓冲区或者尽早查看日志。最简单靠谱的验证方法是直接看/dev/kmsgsudo cat /dev/kmsg它会把内核日志实时打印出来比 dmesg 更实时。7.4 模块卸载时提示 Device or resource busy如果你在模块的 exit 函数里没有正确释放资源比如设备节点还被进程占用着赶紧先确认还有没有程序在使用这个设备lsof /dev/mymisc fuser -v /dev/mymisc把这些进程结束了再 rmmod。如果确认没有进程占用还是报 busy检查模块的 fops 里owner字段是不是设置了THIS_MODULE如果你没设置内核没法正确追踪谁在使用这个模块卸载时也可能出问题。8. 调试技巧和心态如何提升内核模块开发的效率8.1 用动态调试开关减少重复编译新手写内核模块最容易沮丧的一点是改一行代码重新编译、重新 insmod、重新 rmmod、再 dmesg这个循环非常耗时。我自己后来习惯的做法是在模块里留一个 module_param 作为调试开关代码里大量使用pr_debug或者在关键路径上判断 debug 变量来决定是否打印。比如static bool debug false; module_param(debug, bool, 0644); #define LOG(fmt, ...) \ do { \ if (debug) \ printk(KERN_INFO [%s] fmt \n, __func__, ##__VA_ARGS__); \ } while (0)这样上线时加载参数debug1就能看到详细日志平时不传参就静默运行完全不用改代码重编译。这对于排查问题效率提升特别明显。8.2 善于使用 ftrace 和 tracepoint如果你的模块涉及对某个内核函数的调用或者你想了解模块加载之后它自己内部发生了什么可以借助ftrace工具来追踪函数调用。最常见的方式是echo function_graph /sys/kernel/tracing/current_tracer echo my_module_func /sys/kernel/tracing/set_ftrace_filter echo 1 /sys/kernel/tracing/tracing_on当然这个需要内核开启了 ftrace 支持大多数发行版默认都开启。不过说实话这些工具在内核模块开发的初期并不常用等你自己遇到实际性能问题或逻辑追踪需求的时候再回头学也不迟。8.3 内核崩溃了怎么办Oops 和 Panic内核模块运行时如果出现空指针解引用、非法地址访问往往会导致内核 Oops严重的话直接 Panic。这时候屏幕上一堆十六进制寄存器和调用栈信息看起来非常吓人。其实遇到 Oops 信息不要慌最有价值的两块信息是出错的函数名和偏移量。比如Call Trace: [ffffffffa0000c3e] my_func0x1e/0x50 [my_module]说明模块my_module里的my_func函数偏移 0x1e 处出了问题。出错地址的访问类型。如果是 NULL 指针解引用错误信息里通常会直接指出。如果你是用虚拟机开发建议在写内核模块之前先给这台机器做一次快照。内核模块开发经常会导致系统无响应有一个快照能随时回滚效率会高很多。我自己的开发流程基本都是在虚拟机里跑宿主机不受影响。9. 和主流 Linux 系统的兼容性国产发行版环境下的模块编译差异现在国内很多服务器和桌面环境已经切换到国产 Linux 发行版比如麒麟、统信 UOS、欧拉 openEuler 这些。在内核模块开发上它们和标准 Ubuntu/CentOS 大体相同但有几个实测下来的差异点值得提前提醒。头文件包名不同有的叫kernel-devel有的叫linux-headers还有的可能需要从 ISO 源里单独找。内核版本较新或带有厂商补丁编译模块时KKDIR 指向的内核源码树必须来自你当前运行的内核单是版本号相同还不够如果有厂商补丁导致某些头文件和官方内核不同你在官方内核环境下编译出来的模块照样可能加载失败。最稳妥的办法是加载模块的机器上直接用 uname -r 对应的头文件包来编译。部分国产发行版默认开启了内核模块签名校验加载的模块如果未签名会被拒绝。遇到这种情况你可能需要在 BIOS/引导配置里关闭安全启动或者给模块签名。如果你打算长期做内核模块相关的工作不只是写 hello world 练手那么花点时间研究一下发行版的签名机制和模块加载策略是值得的。10. 踩过几次坑后我建议你先做这几件事再深入如果你是一个刚接触 Linux 内核模块编程的读者我强烈建议你不要急着把文章里的代码直接复制进你的项目里而是按这个顺序来在一台虚拟机里准备一个干净的环境装好内核头文件和编译工具链。把 hello.c 从头到尾手动敲一遍理解每一行的作用编译、加载、卸载完整跑通。然后改一改他的行为比如多打印几次、加一个模块参数、尝试在 init 函数里分配一块内存然后在 exit 里释放它。这样你会对模块的生命周期有更直观的感受。接着写一个简单的 misc 设备模块用 echo/cat 和它交互体会内核态和用户态的边界。遇到不懂的内核 API直接去读内核源码树里的头文件和示例代码内核源代码是最好的文档。我在实际操作中还发现一件事很多人觉得内核模块编程难难在并发处理和内存管理上。一个模块要同时被多个进程访问、要处理中断上下文这些都会牵扯到锁、原子操作、等待队列等复杂概念。入门阶段先不用急着啃那些老老实实把我上面这些基础动作练透等你有真实业务需求了再逐步上手并发会自然很多。另外说个工具上的小建议编辑内核模块代码现代 IDE 和代码补全体验确实好但内核开发里 gdb 对内核模块的支持比较有限。很多人以为能像普通程序那样 gdb实际上不是的。你更多时候是靠 printk、加日志、读崩溃栈来定位问题所以我建议早点习惯那种打印日志——加载——看日志的开发循环。内核模块编程是一条有意思的路它把操作系统从黑盒变成了可以通过代码触摸的东西。我第一次成功加载一个能交互的设备模块时那种感觉不是写普通程序能比的。希望这篇文章能帮你少绕弯路尽快上手。
返回列表