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 整体框架图

下面这张图把字符设备驱动、模块生命周期、设备树绑定的关系串起来了。你看一遍就能理解整个框架怎么运转。

TRNG字符设备驱动框架 用户空间 应用程序 → open() / read() / ioctl() / close() VFS + 系统调用层 file_operations 结构体 .open = trng_open | .read = trng_read .release = trng_release | .unlocked_ioctl = trng_ioctl 模块生命周期 module_init(trng_init) → alloc_chrdev_region → cdev_init / cdev_add 设备树绑定 of_match_table → "vendor,trng-1.0" platform_driver → probe / remove reg / interrupts / clocks 三者配合:设备树描述硬件 → 模块加载注册驱动 → file_operations处理用户请求

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寄存器、处理中断、管理熵源缓冲区。