Linux 设备模型学习笔记(1)
前言
最近在学习 Linux 驱动开发,从简单的字符设备再到平台总线,深刻体会到了 Linux 内核的博大精深,期间阅读了不少前辈们写的文章,资料等等,再结合我自己的一些思考和理解,整理成这篇笔记。
1. 为什么需要设备模型?
在 Linux 发展的早期,驱动开发可以算得上是非常简单粗暴,写驱动就是申请设备号,操作硬件等等。Linux 是支持所有不同功能的硬件设备的,这是它的优点,但这也导致 Linux 内核中有相当大一部分代码都是设备驱动相关的,并且随着后来嵌入式系统的爆发(特别是ARM SoC),这种情况愈发严重。早期那种简单粗暴的驱动开发方式已经不再适用了,因为它会带来下面几个问题:
第一,代码的低复用导致的代码规模爆发式增长 。在早期的那种驱动开发方式中,硬件信息与驱动代码是强耦合的,也就是我们常说的硬编码,这种方式的特点是驱动代码里面不仅包含了控制硬件的方法 ,还包含了硬件设备的详细信息 。比如,我们需要控制板子上的一个 LED ,在这个板子上 LED 在 GPIO 3 上面,而另一块板子上 LED 在 GPIO 4 上面,那么相同逻辑的 led 驱动代码由于硬件信息的不同,就要写两遍。硬件信息的硬编码导致驱动代码无法跨板子复用,这就是代码规模爆发式增长的原因,且其中大部分代码都是重复的。
第二,电源管理的问题 。当系统要休眠时,Linux 就需要按顺序关闭设备。关闭设备的原则是先关闭子设备,在关闭父设备 。那我们常见的鼠标来举例,系统必须先关闭挂载在 USB 控制器上的鼠标,然后才能断开 USB 控制器的电源。但是系统里面有那么多设备,如果没有一个树状结构来记录设备的父子关系,内核根本就不知道关闭电源的正确顺序,容易导致系统崩溃。
第三,用户空间无法知道设备的具体信息。如果用户想知道系统里面究竟挂了几个鼠标,一个 I2C 总线上究竟连接了几个芯片。没有统一的模型,内核就无法向用户空间展示完整的设备拓扑图。
这三个问题,是早期的驱动开发方式带来的问题,也正是设备模型出现后解决的问题。
Linux 设备模型的出现,就是为了将硬件的描述 与硬件的驱动 分离,并通过总线来管理他们,最终形成一棵有序的树。
2. Linux 设备模型的基本概念
2.1 总线 Bus
在物理概念中,总线是连接 CPU 和外设的导线,比如USB,I2C,SPI等。但在 Linux 软件模型中,Bus是一种管理机制。
我们来看看内核源码中对于总线的解释,相关内容可以在下面目录中找到:
bash
include/linux/device.h

这段注释是内核源码中对于总线的描述,大致意思如下:总线是处理器与一个或多个设备之间的通信通道 。在设备模型的框架下,所有设备都通过总线连接,即使这是一个内部的、虚拟的"平台"总线 。总线之间可以互相连接,例如,一个 USB 控制器通常就是一个 PCI 设备。设备模型反映了总线与其所控制设备之间的实际连接关系。总线由 bus_type 结构体表示。该结构体包含了总线的名称、默认属性、总线方法、电源管理操作以及驱动程序核心的私有数据。
对这段注释进行一个总结,其实可以分为两点:
第一,在 Linux 中,总线是 CPU 和一个或多个设备直接交流的通道。
第二,为了将设备模型抽象出来,所有的设备都要连接到总线上,这个总线可以是 CPU 内部总线,也可以是虚拟的平台总线。
在 CPU 内部的众多总线中,每个总线上都维护着两个链表,分别是 Devices 链表 和 Drivers 链表 。Devices 链表中存放着所有挂在这条总线上的设备 。Drivers 链表中存放着所有这条总线上的驱动。
而总线最重要的功能就是将设备和驱动进行匹配。每当有一个新设备插入或者有一个新驱动被加载,总线就会开始执行匹配程序。
下面是内核源码中bus_type结构体的部分内容:

里面成员很多,我们挑几个重要的看看。
第 123 行是总线名称,比如 "Platform" , "I2C" 等等。
第 130 行是匹配函数,当有新设备或新驱动加入总线时,内核就是通过自动调用这个函数,来判断两者是否配对的。
第 132 行是初始化函数 ,匹配成功后,内核会调用这个函数,进而调用驱动程序里 的 probe初始化函数。
2.2 设备 Device
Device 结构体描述的是硬件设备信息,在现代 ARM Linux 中,这些信息主要来源于设备树。
设备树里面包含的信息有下面几种:
第一种是资源信息,比如寄存器地址,中断号,DMA 通道等等。
第二种是拓扑信息,描述该设备在设备树中的位置,比如它是谁的父节点,又是谁的子节点。
第三种是状态信息,描述设备现在处于开启状态还是关闭状态。
struct device 就像一个基类,实际开发中,我们通常使用它的派生类,如 struct platform_device。
核心思想是将硬件变动隔离起来,就是将所有变动的硬件信息封装在 Device 里面,通过 DTS 传给内核,从而实现硬件信息和驱动程序的解耦。
2.3 驱动 Driver
Driver 结构体描述的是对应的硬件应该怎么使用。里面的信息主要是包含操作逻辑,比如怎么初始化,怎么读写,怎么处理中断等等。驱动代码里面不应该出现具体的物理地址,地址应该从传入的 Device 指针里面取出来。
在代码层面,驱动由 struct device_driver 表示,对于使用设备树的 ARM Linux,我们需要特别关注 of_match_table 成员:

这里同样只截取了一部分内容,我们看看最重要的几个成员。
第 299 行是驱动名称。
第 300 行指明该驱动属于哪条总线。
第 308 行,是驱动用来与设备匹配的关键成员。
2.4 类 Class
到这里,我们大概已经知道 Bus 是按照物理连接对设备进行分类,比如一个设备到底是属于 USB 设备还是 I2C 设备。
而类 Class 是按照功能属性对设备进行分类的。
举个例子:
从总线角度来看:一个 USB 鼠标属于 USB 总线,蓝牙鼠标属于蓝牙总线。
而从类的角度来看:他们二者都是属于 input 类,也就是输入类的。
从这个例子中不难看出,Class 向用户空间提供了一个标准的接口,不管底层的物理总线是什么,只要属于同一个 Class ,应用程序就可以用相同的方式来操作设备,屏蔽了底层的差异。
我们可以在 /sys/class/目录下看到所有的类:

2.5 它们是怎么协同工作的
在理解了上面的概念之后,我们来梳理一下内核管理硬件的整个流程:
上面我们提到过,Bus 维护着两个链表。一个是 klist_devices 设备链表,里面是等待配对的硬件设备。另一个是 klist_drivers驱动链表,里面是等待干活的驱动。
当内核解析设备树时,会将节点转化为对应的 Device 结构体并注册到总线上,这时 Devices 链表中的设备加一。
当用户加载内核模块(使用insmod命令)时,Driver 结构体被注册到总线上,这时 Drivers 链表中的驱动加一。
每当 Devices 链表或者 Drivers 链表中增加新成员,总线都会调用.match函数,拿着新来的成员去和对方链表的每一个成员进行比对。常用的平台总线中通常是通过比对compatible属性字符串来决定配对是否成功。
一旦匹配成功,总线就会调用.probe函数进行初始化,这个总线的.probe函数最终会调用驱动的.probe函数,把具体的 Device 结构体传给 Driver。注意:总线先调用自己的 probe , 进而调用驱动提供的 probe。
在 .probe 函数中,驱动通常会调用子系统的接口,将自己注册到对应的 Class 中,从而在 /dev 或 /sys 下生成文件,供用户层操作。
至此,驱动程序正式拿到了硬件的控制权,然后开始干活。