1. 课程导论:为什么需要设备模型?从字符设备驱动的痛点说起。

大家好,我是你们这门课的主讲。在正式开始之前,我想先聊聊一个很基础的问题:我们为什么需要设备模型?

很多刚入行的朋友,包括我当年刚接触Linux驱动时,都会觉得设备模型这东西有点虚。不就是注册个设备、创建设备节点吗?直接写个字符设备驱动不就行了?

嗯,说实话,我刚开始也是这么想的。直到我在一个项目中,被一个简单的“热插拔”问题折磨了整整三天……

从最简单的字符设备驱动说起

我们先看一个最典型的字符设备驱动框架。你肯定见过类似这样的代码:

static int __init my_driver_init(void)
{
    // 1. 分配设备号
    alloc_chrdev_region(&dev_num, 0, 1, "my_device");
    
    // 2. 初始化cdev结构体
    cdev_init(&my_cdev, &my_fops);
    
    // 3. 添加cdev到内核
    cdev_add(&my_cdev, dev_num, 1);
    
    // 4. 创建设备类
    my_class = class_create(THIS_MODULE, "my_class");
    
    // 5. 创建设备节点
    device_create(my_class, NULL, dev_num, NULL, "my_device");
    
    return 0;
}

这段代码看起来挺规整的,对吧?我当年刚学会写这个的时候,还挺得意。但实际项目中,问题很快就来了。

痛点一:设备与驱动的“硬绑定”

你想想看,上面的代码里,设备号和设备名都是写死的。如果系统里有两个一模一样的硬件呢?比如两个串口芯片,或者两个温度传感器。

我遇到过这样一个场景:一个嵌入式板子上有两个I2C温度传感器,型号完全一样。按照上面的写法,我只能手动给它们分配不同的次设备号。但问题是——哪个传感器对应哪个设备节点?

你可能会说:“我可以在代码里判断总线地址啊。”没错,但这就意味着你的驱动代码里要写死硬件信息。一旦硬件改版,或者换了个传感器型号,整个驱动就得重写。

核心问题:传统字符设备驱动把“设备”和“驱动”混在一起。驱动代码里既包含了操作硬件的逻辑,又包含了设备的具体信息(如地址、中断号、资源等)。这就像把菜谱和食材清单写在同一张纸上——换个食材,整张纸都得重写。

痛点二:热插拔怎么办?

USB设备、SD卡、PCIe设备……这些硬件都是可以随时插拔的。但上面的字符设备驱动,是在模块加载时一次性注册好的。如果设备在系统启动后才插入,怎么办?

我记得有一次调试一个USB转串口模块。模块插入后,系统确实检测到了,但/dev/下没有出现对应的设备节点。为什么?因为驱动里没有处理热插拔事件。设备来了,内核不知道该怎么通知用户空间。

说白了,传统字符设备驱动是“静态”的——它假设设备永远存在,且位置固定。但在现代系统中,这个假设根本不成立。

痛点三:资源管理混乱

再来看一个更隐蔽的问题。假设你的驱动里用到了GPIO、中断、DMA、时钟……这些资源。在传统字符设备驱动里,你可能会这样写:

static int my_probe(void)
{
    // 申请GPIO
    gpio_request(100, "my_gpio");
    
    // 申请中断
    request_irq(IRQ_NUM, my_irq_handler, 0, "my_dev", NULL);
    
    // 映射寄存器地址
    ioremap(0x01C20800, 0x1000);
    
    // ... 更多资源
}

问题来了:如果GPIO申请失败,中断已经申请了怎么办?如果中断申请失败,GPIO已经申请了,谁来释放?

我见过一个项目,因为资源释放顺序写错了,导致系统在驱动卸载时直接panic。排查了整整两天,最后发现是中断释放时GPIO已经被其他驱动占用了

避坑指南:我曾经在一个项目中,因为资源申请和释放的顺序不一致,导致系统在反复加载/卸载驱动后,内核内存泄漏。后来我养成了一个习惯:资源申请的顺序,必须和释放的顺序完全相反。但即便如此,手动管理这些资源依然容易出错。

痛点四:设备信息散落各处

在传统字符设备驱动里,设备信息(如寄存器地址、中断号、时钟频率)通常写在驱动代码里,或者通过模块参数传递。但这样会带来几个问题:

  • 代码不可移植:换个板子,所有地址都得改
  • 信息不统一:同一个设备,不同驱动里可能写不同的地址
  • 难以维护:硬件改版后,要找到所有相关的驱动代码逐一修改

你想想看,一个成熟的嵌入式系统里,可能有几十个外设。每个外设的地址、中断、时钟都不一样。如果这些信息都散落在各个驱动文件里,那维护起来简直就是噩梦。

设备模型要解决什么?

好了,说了这么多痛点,我们来看看设备模型到底要解决什么问题。说白了,就是把“设备”和“驱动”解耦,让它们各自独立,再通过一个统一的机制把它们匹配起来。

具体来说,设备模型要解决三个核心问题:

  1. 设备与驱动的匹配:让驱动能够自动识别它所支持的设备,而不是靠写死设备信息
  2. 热插拔支持:设备来了,驱动自动加载;设备走了,驱动自动卸载
  3. 资源统一管理:设备占用的资源(GPIO、中断、内存等)由内核统一管理,避免冲突

而实现这一切的基础,就是设备树(Device Tree)内核设备模型(Driver Model)

设备模型的核心思想

我画了一张图,帮你理解设备模型的核心逻辑:

Linux设备模型核心架构 设备树 (Device Tree) 描述硬件信息 寄存器地址、中断号 时钟频率、GPIO等 解析 内核设备模型 设备注册与匹配 资源管理 热插拔事件 绑定 设备驱动 probe/remove 操作硬件 提供文件操作接口 设备树描述硬件 → 内核设备模型管理设备 → 驱动操作硬件 关键变化 传统驱动:驱动代码里写死硬件信息 → 不可移植、难以维护 设备模型:硬件信息由设备树描述,驱动只关心操作逻辑 → 解耦、可复用

这张图展示了设备模型的核心流程:设备树描述硬件 → 内核设备模型解析并管理 → 驱动绑定并操作硬件。三者各司其职,互不干扰。

设备树的作用

设备树(Device Tree)就是用来描述硬件信息的。它把寄存器地址、中断号、时钟频率、GPIO等信息,从驱动代码里剥离出来,放到一个独立的.dts文件中。

举个例子,一个I2C温度传感器的设备树节点可能是这样的:

temp_sensor: temperature-sensor@48 {
    compatible = "ti,tmp102";
    reg = <0x48>;
    interrupt-parent = <&gpio1>;
    interrupts = <5 IRQ_TYPE_EDGE_FALLING>;
};

看到没?compatible字段告诉内核:“我是TI公司的TMP102温度传感器”。reg字段告诉内核:“我的I2C地址是0x48”。interrupts字段告诉内核:“我用的中断是GPIO1的第5号引脚,下降沿触发”。

这些信息,以前都是写在驱动代码里的。现在,它们被放到了设备树里。驱动只需要说:“我支持compatible为'ti,tmp102'的设备”,内核就会自动帮它匹配。

个人经验:我习惯在设备树里把每个外设的“身份信息”写清楚。比如compatible字段,一定要用“厂商,型号”的格式。这样不仅内核能识别,人也看得懂。我曾经见过有人写“my_device”这种名字,结果后来换了芯片厂商,根本不知道这个设备对应哪个硬件。

设备模型带来的好处

有了设备模型,我们再看之前的痛点:

痛点 传统字符驱动 设备模型解决方案
设备与驱动绑定 驱动代码里写死设备信息 设备树描述设备,驱动通过compatible匹配
热插拔 不支持,需手动处理 内核自动检测设备插入/移除,调用probe/remove
资源管理 手动申请/释放,容易出错 内核统一管理,自动释放
代码可移植性 换板子需修改驱动代码 只需修改设备树,驱动代码不变

说白了,设备模型就是给Linux内核的驱动开发立了一套规矩。这套规矩让驱动开发变得规范、可复用、易维护。虽然刚开始学的时候会觉得有点复杂,但一旦你习惯了,就会发现它真的能帮你省下大量调试时间。

小结

这一节我们聊了传统字符设备驱动的几个痛点:设备与驱动硬绑定、不支持热插拔、资源管理混乱、设备信息散落各处。这些问题,说白了都是因为驱动代码里混入了太多硬件信息

设备模型的核心思想,就是把硬件信息剥离出来,交给设备树去描述。驱动只负责操作逻辑,内核负责匹配和管理。这样,驱动变得通用,硬件信息变得清晰,整个系统也更容易维护。

下一节,我们会深入设备树的具体语法和解析过程。不过在那之前,我建议你先想想:你手头的项目里,有没有因为设备信息写死在驱动里而导致的维护问题?如果有,那设备模型就是你要找的答案。


公众号:蓝海资料掘金营,微信deep3321