3. Linux内核驱动框架:字符设备驱动基础
做TRNG驱动,说白了就是写一个字符设备驱动。为什么?因为随机数生成器本质上就是个数据源——用户态程序打开它,读它,拿随机数走人。字符设备模型正好干这个。
我刚开始接触内核驱动时,总觉得字符设备框架很绕。后来发现,你只要抓住三个东西:设备号、file_operations、模块生命周期。今天就掰开揉碎讲清楚。
3.1 字符设备驱动的基本骨架
一个字符设备驱动,在Linux里就是一块内核模块。它向用户态暴露一个文件接口。用户用open()、read()、write()、release()来操作硬件。
嗯,这里要注意:TRNG设备通常是只读的。你不可能往硬件熵源里写随机数。所以我们的驱动里,read是核心,write可以返回错误。
核心概念:字符设备 = 设备号 + file_operations + 注册到内核
3.2 file_operations结构体——驱动的灵魂
这个结构体定义了驱动能干什么。每个成员都是一个函数指针。内核在用户态发起系统调用时,会通过这个结构体找到你的驱动函数。
我见过不少新手把file_operations里所有函数都实现了,其实没必要。TRNG驱动只需要实现几个关键接口:
| 成员 | 函数原型 | TRNG中的用途 |
|---|---|---|
open |
int (*open)(struct inode *, struct file *) |
打开设备,初始化硬件熵源 |
release |
int (*release)(struct inode *, struct file *) |
关闭设备,释放资源 |
read |
ssize_t (*read)(struct file *, char __user *, size_t, loff_t *) |
从硬件读取随机数,拷贝到用户空间 |
unlocked_ioctl |
long (*unlocked_ioctl)(struct file *, unsigned int, unsigned long) |
控制命令,比如启动熵源测试、获取状态 |
看一个实际例子。这是我之前做的一个TRNG驱动的file_operations定义:
static const struct file_operations trng_fops = {
.owner = THIS_MODULE,
.open = trng_open,
.release = trng_release,
.read = trng_read,
.unlocked_ioctl = trng_ioctl,
};
你可能会问:为什么没有write?我刚才说了,TRNG是只读设备。如果用户试图写,内核会返回-EINVAL。当然,你也可以显式实现一个write函数,直接返回-EPERM,给用户一个明确的错误提示。
个人习惯:我通常会在open里做硬件初始化检查。如果熵源没准备好,返回-EBUSY。这样用户态程序可以优雅地处理,而不是读到一堆0。
3.3 模块加载与卸载——驱动的生命周期
驱动写好了,怎么装进内核?靠module_init和module_exit。这两个宏告诉内核:加载时调用哪个函数,卸载时调用哪个函数。
我曾经踩过一个坑:卸载模块时忘了释放设备号,结果下次加载时注册失败。后来我养成了一个习惯——在exit函数里,严格按照init的逆序释放资源。
看一个标准的TRNG驱动加载/卸载模板:
static int __init trng_init(void)
{
int ret;
// 1. 分配设备号
ret = alloc_chrdev_region(&trng_dev_num, 0, 1, "trng");
if (ret < 0) {
pr_err("Failed to allocate device number\n");
return ret;
}
// 2. 初始化cdev结构体
cdev_init(&trng_cdev, &trng_fops);
trng_cdev.owner = THIS_MODULE;
// 3. 添加字符设备到内核
ret = cdev_add(&trng_cdev, trng_dev_num, 1);
if (ret < 0) {
pr_err("Failed to add cdev\n");
unregister_chrdev_region(trng_dev_num, 1);
return ret;
}
// 4. 创建设备类和设备节点(配合udev)
trng_class = class_create(THIS_MODULE, "trng_class");
device_create(trng_class, NULL, trng_dev_num, NULL, "trng");
pr_info("TRNG driver loaded, major=%d minor=%d\n",
MAJOR(trng_dev_num), MINOR(trng_dev_num));
return 0;
}
static void __exit trng_exit(void)
{
// 逆序释放:设备节点 -> 类 -> cdev -> 设备号
device_destroy(trng_class, trng_dev_num);
class_destroy(trng_class);
cdev_del(&trng_cdev);
unregister_chrdev_region(trng_dev_num, 1);
pr_info("TRNG driver unloaded\n");
}
module_init(trng_init);
module_exit(trng_exit);
避坑指南:cdev_add之后,设备就暴露给用户态了。如果硬件初始化还没完成,用户可能读到垃圾数据。我建议在open里做硬件就绪检查,而不是在init里。
3.4 设备树绑定——让内核找到你的硬件
在嵌入式系统里,硬件信息通过设备树传递给内核。TRNG作为一个硬件外设,需要在设备树里描述它的地址、中断、时钟等信息。
设备树绑定的核心是compatible字符串。驱动通过这个字符串匹配到对应的设备节点。我习惯用"vendor,device"的命名方式,比如"arm,true-random"。
看一个典型的TRNG设备树节点:
trng: trng@ff100000 {
compatible = "vendor,trng-1.0";
reg = <0x0 0xff100000 0x0 0x1000>;
interrupts = <0 42 4>;
clocks = <&clkc 15>;
clock-names = "trng_clk";
status = "okay";
};
在驱动里,你需要用of_match_table来声明支持的设备:
static const struct of_device_id trng_of_match[] = {
{ .compatible = "vendor,trng-1.0", },
{ /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, trng_of_match);
static struct platform_driver trng_platform_driver = {
.driver = {
.name = "trng",
.of_match_table = trng_of_match,
},
.probe = trng_probe,
.remove = trng_remove,
};
你想想看,设备树的好处是什么?硬件配置和驱动代码解耦了。换一块板子,只要改设备树,驱动不用动。我在一个项目里,同一套驱动跑了三个硬件版本,全靠设备树差异化配置。
3.5 整体框架图
下面这张图把字符设备驱动、模块生命周期、设备树绑定的关系串起来了。你看一遍就能理解整个框架怎么运转。
3.6 驱动开发中的几个关键点
最后,我总结几个实际开发中容易忽略的地方:
- 设备号管理:用
alloc_chrdev_region动态分配,别用静态的。静态号容易冲突,我吃过这个亏。 - 并发访问:TRNG可能被多个进程同时打开。
read里要做好互斥,用mutex或spinlock保护硬件寄存器。 - 休眠与阻塞:如果熵源暂时没有数据,
read可以休眠等待。用wait_event_interruptible实现,别忙等。 - 调试信息:多用
dev_dbg和dev_info,别用printk。前者能告诉你哪个设备在打印。
我的经验:第一次写TRNG驱动时,我忘了在open里做引用计数。结果用户态程序崩溃后,驱动资源没释放,第二次打开直接死锁。后来我老老实实加了try_module_get和module_put。
字符设备驱动框架,说白了就是这三板斧:设备号、操作函数、模块加载。设备树绑定是嵌入式Linux的特色,让驱动更灵活。掌握了这些,TRNG驱动的骨架就搭起来了。下一层,我们要往这个骨架里填充真正的硬件操作——读写TRNG寄存器、处理中断、管理熵源缓冲区。