去年给一块 RK3588 板子调驱动,凌晨两点,内核 panic 日志刷屏。我盯着 call trace 看了十分钟,发现不是硬件问题——是我自己的 module_exit 里忘了清理 cdev。
这事后来成为我判断一个内核模块教程好不好的标准:如果教程里的代码能编译、能加载、但 rmmod 就 panic ,那不是教程,那是地雷。
所以这篇文章从一个能加载也能卸载的模块开始,走到一个真正的字符设备驱动。每一行代码你都可以复制、编译、跑起来。
环境: Linux 6.6 LTS , x86-64 或 ARM64 均可。我用的是 Ubuntu 24.04 上的 6.6 内核。
先跑起来:一个能加载也能卸载的模块
内核模块不是你熟悉的 main() 入口程序。它没有 main,只有两个回调:一个叫 init,一个叫 exit。内核加载模块时调 init,卸载时调 exit。
这是最小版本,把它存成 hello.c:
// hello.c — Linux 6.6 LTS
#include<linux/module.h>
#include<linux/kernel.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("LabHub");
MODULE_DESCRIPTION("First kernel module — hello world");
staticint __init hello_init(void)
{
pr_info("Hello: module loaded into kernel space\n");
return0;
}
staticvoid __exit hello_exit(void)
{
pr_info("Goodbye: module unloaded, resources freed\n");
}
module_init(hello_init);
module_exit(hello_exit);
三点值得讲。
__init 和 __exit 标记。这两个宏告诉编译器把函数放到特殊的 ELF 段里。__init 函数在内核初始化完成后会被内核释放掉——因为初始化代码只跑一次,留着纯占内存。你如果试着在模块加载后通过函数指针调用一个 __init 函数,内核直接 Oops 。
pr_info 而非 printk。printk 当然能用,但 pr_info 等价于 printk(KERN_INFO ...),少打几个字,而且内核社区推荐用它。日志等级从 pr_emerg(系统挂了)到 pr_debug(开了 DEBUG 才输出),你用 dmesg -H 看的时候能按等级过滤。
MODULE_LICENSE("GPL")。不写这行也行,但内核会给你贴一个 (E) 的污点标记( taint flag ),并且拒绝让你访问 GPL-only 的内核符号——实用上等于自断一臂。
Makefile :内核构建系统不是 make 那么简单
内核模块的 Makefile 跟用户态程序完全是两套逻辑。它不是直接用 gcc -c hello.c,而是通过内核的 Kbuild 系统编译:
obj-m +=hello.o
KDIR :=/lib/modules/$(shelluname-r)/build
PWD :=$(shell pwd)
all:
$(MAKE)-C$(KDIR) M=$(PWD)modules
clean:
$(MAKE)-C$(KDIR) M=$(PWD)clean
核心在 -C $(KDIR) —— make 先 cd 到内核源码树(或者说内核头文件目录),然后用 M=$(PWD) 告诉内核构建系统"你的模块源码在这个目录"。内核的顶层 Makefile 会处理剩下的——找到你的 obj-m,调用正确的编译器、链接器、使用正确的 CFLAGS。
编译和验证:
make
modinfohello.ko
sudoinsmodhello.ko
dmesg | tail-3
lsmod | grephello
sudormmodhello
dmesg | tail-3
如果 insmod 报 Required key not available,说明你的内核开了 Secure Boot——用 sudo mokutil --disable-validation 临时关掉,或者给模块签名。
字符设备驱动:让用户态能 open/read/write 你的模块
能加载能卸载只是热身。真正的设备驱动要让用户态程序通过文件接口跟内核交互——open("/dev/mylog", O_RDWR)、read()、write()。
字符设备驱动围绕一个核心结构体展开:struct file_operations。它本质上是一个函数指针表,用户态的每个系统调用( open / read / write / close )最终都会调到这张表里对应的函数。
先看完整骨架,保存为 mylog.c:
// mylog.c — 第一个字符设备驱动
// Linux 6.6 LTS,x86-64 / ARM64 通用
#include<linux/module.h>
#include<linux/kernel.h>
#include<linux/fs.h> // file_operations, register_chrdev_region
#include<linux/cdev.h> // cdev_init, cdev_add, cdev_del
#include<linux/uaccess.h> // copy_to_user, copy_from_user
#include<linux/slab.h> // kmalloc, kfree
#define DEVICE_NAME "mylog"
#define CLASS_NAME "mylog_class"
#define BUFFER_SIZE 1024
MODULE_LICENSE("GPL");
MODULE_AUTHOR("LabHub");
MODULE_DESCRIPTION("Simple character device driver — /dev/mylog");
staticint major_number;
staticstructclass*mylog_class = NULL;
staticstructdevice*mylog_device = NULL;
staticstructcdev mylog_cdev;
staticchar*log_buffer = NULL;
staticsize_t log_len =0;
staticintmylog_open (structinode*, structfile*);
staticintmylog_release (structinode*, structfile*);
staticssize_tmylog_read (structfile*, char __user *, size_t, loff_t *);
staticssize_tmylog_write (structfile*, constchar __user *, size_t, loff_t *);
staticstructfile_operations fops = {
.owner = THIS_MODULE,
.open = mylog_open,
.release = mylog_release,
.read = mylog_read,
.write = mylog_write,
};
注意 fops 的初始化方式——用 C99 的 designated initializer (.open = mylog_open),不为 NULL 的成员自动归零。这在内核里是标准写法,比你用 memset 安全得多。你不需要实现的函数(比如 llseek)保持 NULL ,内核会用默认实现。
接下来逐个实现。
open 和 release :资源获取与释放
staticintmylog_open(structinode*inodep, structfile*filep)
{
pr_info("mylog: device opened\n");
return0;
}
staticintmylog_release(structinode*inodep, structfile*filep)
{
pr_info("mylog: device closed\n");
return0;
}
这个版本里 open/close 什么都不做。在实际驱动里,你通常在 open 里分配 per-instance 数据结构,在 release 里释放。私有数据放 filep->private_data,这是内核为每个 struct file 预留的一个 void *。
read :从内核空间拷到用户空间
staticssize_tmylog_read(structfile*filep, char __user *buf,
size_t len, loff_t *offset)
{
size_t bytes_to_read;
int ret;
if (*offset >= log_len)
return0; // EOF
bytes_to_read = min(len, log_len - (size_t)(*offset));
ret = copy_to_user(buf, log_buffer +*offset, bytes_to_read);
if (ret !=0) {
pr_err("mylog: copy_to_user failed, %d bytes not copied\n", ret);
return-EFAULT;
}
*offset += bytes_to_read;
pr_info("mylog: read %zu bytes from offset %lld\n",
bytes_to_read, *offset - bytes_to_read);
return bytes_to_read;
}
这里有两个内核独有的概念。
__user 标记。buf 参数声明为 char __user *,告诉编译器和稀疏检查工具( sparse )"这个指针指向用户空间地址"。内核不能直接解引用用户空间指针——如果直接 memcpy(buf, ...),在最好的情况下触发 page fault 导致进程被杀,最坏的情况下成为一个提权漏洞。
copy_to_user。内核提供的安全拷贝函数,内部会做完整的地址校验和 page fault 处理。返回值是未能拷贝的字节数, 0 表示全部成功。注意错误处理:如果返回非零,返回 -EFAULT( Bad address ),用户态程序会收到 errno = EFAULT。
*offset 的维护完全由驱动负责。用户态的 read() 和 pread() 行为不同——read() 会自动推进文件偏移,pread() 不会。但到了内核层, VFS 已经把这两种调用统一传了 *offset,你只需按它读写并更新即可。
write :从用户空间拷进内核空间
staticssize_tmylog_write(structfile*filep, constchar __user *buf,
size_t len, loff_t *offset)
{
size_t bytes_to_write;
int ret;
bytes_to_write = min(len, (size_t)(BUFFER_SIZE -1- log_len));
if (bytes_to_write ==0) {
pr_warn("mylog: buffer full, write rejected\n");
return-ENOSPC; // No space left
}
ret = copy_from_user(log_buffer + log_len, buf, bytes_to_write);
if (ret !=0) {
pr_err("mylog: copy_from_user failed, %d bytes not copied\n", ret);
return-EFAULT;
}
log_len += bytes_to_write;
log_buffer[log_len] ='\0';
pr_info("mylog: wrote %zu bytes, buffer now %zu bytes\n",
bytes_to_write, log_len);
return bytes_to_write;
}
copy_from_user 是 copy_to_user 的镜像——从用户空间拷到内核空间。同样,返回值是未能拷贝的字节数。
缓冲区管理采用的是最简单的环形方案——满了就返 -ENOSPC。实际驱动里你可能想要环形缓冲或者动态扩容,但这个版本的目标是让你看完能跑通,不是做一个生产级的日志系统。
组装: init 和 exit
staticint __init mylog_init(void)
{
dev_t dev_num;
pr_info("mylog: initializing\n");
// 1. 分配设备号
if (alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME) <0) {
pr_err("mylog: failed to allocate device number\n");
return-ENODEV;
}
major_number = MAJOR(dev_num);
pr_info("mylog: major number = %d\n", major_number);
// 2. 初始化 & 注册 cdev
cdev_init(&mylog_cdev, &fops);
mylog_cdev.owner = THIS_MODULE;
if (cdev_add(&mylog_cdev, dev_num, 1) <0) {
pr_err("mylog: cdev_add failed\n");
unregister_chrdev_region(dev_num, 1);
return-ENODEV;
}
// 3. 创建设备类(让 udev 自动创建 /dev/mylog)
mylog_class = class_create(CLASS_NAME);
if (IS_ERR(mylog_class)) {
pr_err("mylog: class_create failed\n");
cdev_del(&mylog_cdev);
unregister_chrdev_region(dev_num, 1);
return PTR_ERR(mylog_class);
}
// 4. 在 /dev 下创建设备节点
mylog_device = device_create(mylog_class, NULL, dev_num,
NULL, DEVICE_NAME);
if (IS_ERR(mylog_device)) {
pr_err("mylog: device_create failed\n");
class_destroy(mylog_class);
cdev_del(&mylog_cdev);
unregister_chrdev_region(dev_num, 1);
return PTR_ERR(mylog_device);
}
// 5. 分配内部缓冲区
log_buffer = kmalloc(BUFFER_SIZE, GFP_KERNEL);
if (!log_buffer) {
pr_err("mylog: kmalloc failed\n");
device_destroy(mylog_class, dev_num);
class_destroy(mylog_class);
cdev_del(&mylog_cdev);
unregister_chrdev_region(dev_num, 1);
return-ENOMEM;
}
memset(log_buffer, 0, BUFFER_SIZE);
log_len =0;
pr_info("mylog: /dev/%s ready\n", DEVICE_NAME);
return0;
}
staticvoid __exit mylog_exit(void)
{
kfree(log_buffer);
device_destroy(mylog_class, MKDEV(major_number, 0));
class_destroy(mylog_class);
cdev_del(&mylog_cdev);
unregister_chrdev_region(MKDEV(major_number, 0), 1);
pr_info("mylog: unloaded, all resources freed\n");
}
module_init(mylog_init);
module_exit(mylog_exit);
有四件事你必须知道——它们决定了你的模块在生产环境里是"能用"还是"时间炸弹"。
alloc_chrdev_region 动态分配设备号。主设备号( major number )是内核用来把 /dev 下的文件路由到对应驱动的索引。过去大家写死一个数字(比如 #define MAJOR 240),但在现代内核里这是禁止的——你写死的数字可能跟其他驱动冲突。alloc_chrdev_region 让内核自己找一个空闲的,你通过 MAJOR(dev_num) 取出。
goto 风格的错误处理。init 里每一步失败都要撤销之前已经成功的步骤,顺序必须跟 init 相反。内核社区的规矩是用 goto 标签做级联清理,而不是逐层 if (error) 嵌套——20 个 if 嵌套 vs 5 个 goto 标签,前者根本读不懂。这里为了让你看清每一步的依赖关系,我展开写了;实际内核代码里全用的 goto 模式。
kmalloc 用 GFP_KERNEL。这是进程上下文中分配内存的标准 flag——允许睡眠(如果内存紧张,内核可能会把当前进程挂起等待 swap / 回收)。在中断上下文或持有自旋锁时不能用 GFP_KERNEL,必须用 GFP_ATOMIC。字符设备驱动的 init 在进程上下文中,所以 GFP_KERNEL 是对的。
device_create 自动触发 udev。你不需要手动 mknod /dev/mylog c <major> 0,内核对 device_create 的调用会发送 uevent 给 udev ,让它自动创建 /dev/mylog。如果你的 /dev 下没出现设备节点,先检查 CONFIG_UEVENT_HELPER 和 udevd 是否在跑。
Makefile 更新
obj-m +=mylog.o
KDIR :=/lib/modules/$(shelluname-r)/build
PWD :=$(shell pwd)
all:
$(MAKE)-C$(KDIR) M=$(PWD)modules
clean:
$(MAKE)-C$(KDIR) M=$(PWD)clean
测试
make
sudoinsmodmylog.ko
dmesg | tail-5
ls-l/dev/mylog
sudochmod666/dev/mylog
echo "hello from userspace">/dev/mylog
cat/dev/mylog
dmesg | tail-3
sudormmodmylog
dmesg | tail-1
如果 cat /dev/mylog 什么都没输出,先确认 echo 那条命令返回了 0 (没报错),然后 sudo cat /dev/mylog(权限问题)。
从能用到能用好
这篇文章给了你一个能跑的字符设备驱动。下一篇文章我们会做一个真正有用的版本:加并发控制( mutex )、实现 ioctl 自定义命令、支持多个进程同时打开。
在这之前,先把这份代码在你的环境里跑通。如果你的是 ARM 板子(树莓派 / RK3588 ),上面所有代码不用改,直接 make 就行——内核头文件路径会自动适配。
别急着改代码。先 insmod,先 cat /dev/mylog,先 rmmod。亲眼看到它活一遍,比自己闷头写十遍有用得多。
夜雨聆风