万书网 > 文学作品 > 深度探索Linux操作系统 > 4.6 自动加载硬盘控制器驱动

4.6 自动加载硬盘控制器驱动



在前面,我们以Intel的工作在AHCI模式下的SATA硬盘控制器为例,展示了如何加载硬盘控制器的驱动。但是,除非是为一款特定的嵌入式设备定制的系统,否则,对于一个通用设备来说,比如PC,我们是不能假定硬件使用的硬盘控制器的。因此,合理的方法应该是根据具体的硬盘控制器加载对应的驱动模块。但是依靠用户自己手动来完成吗?姑且不提是否方便易用,除非专业用户,否则普通用户如何知道应该加载哪些驱动模块呢?

从2.6版内核开始,Linux采用udev管理驱动模块的加载以及设备节点的管理。每当内核发现新的设备,便通过NETLINK向用户空间发送新设备事件,该事件中记录了设备的相关信息。用户空间的udev服务进程收到内核事件后,根据事件中携带的信息,首先判断该设备的驱动是否已经加载,如果没有,则加载驱动。驱动加载后,内核会再次向用户空间报告发现新设备事件,这时设备已经成功驱动了,并且主次设备号等信息也已经准备好了,udev收到事件后,或者为设备建立节点,或者执行某些特定的操作。整个过程如图4-19所示。

图 4-19 自动加载设备驱动过程

读者可能会有一点疑问:既然有了devtmpfs,为什么还需要udev?

首先,也是最重要的一点,devtmpfs仅是记录了设备驱动注册的节点。udev除了创建设备节点外,还要负责加载设备驱动。后者是devtmpfs所不能实现的,devtmpfs仅是一个被动的记录数据的文件系统而已。

其次,使用udev,在发现新设备或者设备发生了更新时,可以有机会执行某些特定的动作。比如在建立新设备时,为设备节点建立额外的符号链接。

下面我们就分别讨论一下上面描述的各个过程。



4.6.1 内核向用户空间发送事件


PC机上的硬盘控制器,无论是IDE接口的,还是SATA接口的,一般都是通过PCI总线连接到计算机上的。内核在引导时,PCI子系统将进行初始化,枚举总线上的设备,并尝试为设备匹配驱动;然后将收集到的设备相关信息组织为uevent事件;接着调用kobject_uevent,通过NETLINK将组织好的uevent发送到用户空间,通知udev有新设备了。简单地讲,内核的工作就是探测并收集设备信息,将其包装到uevent事件中,然后发送到用户空间。

事实上,无论是发现新的设备,还是有新的驱动载入,抑或是用户向sysfs中的uevent写入字符串,内核都将调用函数kobject_uevent向用户空间发送事件,其代码如下所示:



结构体kobj_uevent_env用来保存收集到的设备相关信息,所以在函数kobject_uevent_env中,首先为kobj_uevent_env申请了一块内存,即变量env指向的内存,用来临时存放准备发送到用户空间的设备相关信息。

然后向该内存中添加了三个默认的变量,包括ACTION、DEVPATH和SUBSYSTEM。其中ACTION指的是热插拔的动作,如"add","remove","change"等。DEVPATH指的是设备在sysfs文件系统中注册的设备路径,比如笔者的硬盘sda的DEVPATH是"/devices/pci0000:00/0000:00:1f.2/ata1/host0/target0:0:0/0:0:0:0/block/sda"。SUBSYSTEM一般是指设备所在的总线,比如笔者的硬盘是挂在PCI总线上的,因此该变量的值是"pci"。

在Linux的设备模型中,除了总线、设备以及驱动这些对象外,还定义了集合,有某些相似特性的kobject将被组织到一个集合中。所以我们看到,在函数kobject_uevent_env的开头,寻找硬盘控制器所属的集合,并在向uevent中添加了三个默认的变量后,调用硬盘控制器所属的集合的uevent_ops中的函数uevent继续向uevent中追加变量。对于硬盘控制器来说,其所属的集合是devices_kset,这是PCI总线在初始化设备时设定的,相关定义如下:



dev_uevent向uevent中继续增加一些设备相关的变量,包括设备节点的主次设备号、名称、设备节点的读、写和执行权限、设备的类型以及驱动模块的名称等准备发送到用户空间的变量。在设备枚举阶段,因为设备还没有被驱动,所以这些信息是没有的。只有当设备被正确地驱动后,内核向用户空间发送的uevent中才包含这些信息。

除了设备信息外,设备所属的总线也可能需要向用户空间报告一些设备所在的总线的相关信息。因此,如果设备属于某个总线,函数dev_uevent则还要调用设备所属总线的event函数。PCI的设备总线类型pci_bus_type及其中的uevent函数代码如下:



pci_uevent又向uevent中追加了pci  class、vendor  id、device  id以及MODALIAS等变量,其中MODALIAS需要重点关注,其是由设备所在总线、vendor  ID、device  ID等相关参数连接而成的一个字符串。在接下来的章节中,读者将看到,用户空间的udev恰恰就是根据这个变量为设备匹配驱动模块的。

除了总线外,如果硬盘控制器所属的class或者type也需要继续向uevent中追加变量,则继续调用硬盘控制器所属的class或者type中的相应的函数,这里不再继续分析了。最终,内核向用户空间发送的uevent事件包含的大致的内容如下,其中不同变量之间使用“\0”进行分隔。



当伴随着热插拔事件一同发往用户空间的变量准备完毕后,kobject_uevent_env使用内核和用户空间的通信协议NETLINK向用户空间报告事件,代码如下:



kobject_uevent_env申请了一个结构体sk_buff类型的变量skb,这个skb就是用来封装报文的。报文以形如"ACTION@DEVPATH"(如"add@/devices/pci0000:00/0000:00:1f.2")的格式开头,紧接着的消息体中封装的就是前面收集到的存储在变量env中的变量。

至此,对于加载硬盘控制器驱动这个任务,内核已经完成了它的使命:PCI子系统获取硬盘控制器的信息,并将其通过NETLINK抛到了用户空间。接下来,该用户空间的udev出场了。



4.6.2 udev加载驱动和建立设备节点

前面我们探讨了内核向用户空间报告uevent事件的过程。这一节,我们来讨论udev是如何根据内核报告的uevent事件加载硬盘控制器驱动以及建立设备节点的。

udev是用户空间动态管理设备的机制,包括加载驱动、管理设备节点等。udev机制的核心是其服务进程udevd。当启动过程进入用户空间阶段后,udevd将被启动。udevd启动后,首先读取并分析所有的规则文件,并将其缓存在内存中。一般情况下,系统默认的规则文件存放在/lib/udev/rules.d目录下,用户自定义的规则存放在/etc/udev/rules.d目录下。每当动态地增加、删除或者改变某个规则文件时,udevd将更新其缓存在内存中的规则。然后,udevd通过NETLINK协议,监听并处理来自内核的uevent事件。每当udevd收到一个内核的uevent,udevd均创建一个单独的子进程处理uevent。

对于每个内核报告的uevent,udevd根据uevent中的变量逐个匹配规则。规则文件通常以数字开头,数字小的先进行匹配。若每个规则文件中包含若干个规则,同一规则不允许断行,每个规则至少包含一个key-value对,每个key-value对之间使用逗号分隔。可以将规则理解为由匹配条件和赋值动作组成,当所有的匹配条件都满足后,赋值动作就会发生。规则中可以加载驱动模块;规定如何给设备接点命名、建立符号连接;设备连接和断开时分别执行指定的程序等。

前面我们看到内核在发现新设备时会将设备的一些信息通过NETLINK发送到用户空间,udev接收到事件后,如果发现设备尚未被驱动,将尝试加载驱动模块。那么udev如何确定设备对应的驱动模块呢?一般而言,根据设备的vender  ID和device  ID就可以标识一类设备,当然有的也需要根据subvendor  ID和subdevice  ID进一步细分。而在驱动代码中,恰恰使用这些设备信息明确声明了其可以支持的设备。以驱动AHCI模式的SATA硬盘控制器驱动为例:



ID  table中的每一项表示该驱动支持的一类设备,根据PCI_VDEVICE的定义:



以ahci_pci_tbl中的第一项为例,该项声明了该驱动支持vender  ID为PCI_VENDOR_ID_INTEL(0x8086),device  ID为0x2652,subvendor  ID、subdevice  ID为任意的Intel  SATA控制器。

内核将ID  table中的每一项中的信息按照一定的格式组合起来,作为驱动的一个别名。这些别名存储在编译好的驱动模块中,模块安装后,需要使用工具depmod将其提取出来并存储在/lib/modules/'uname-r'目录下的modules.alias.bin/modules.alias中,如同前面讨论的modules.dep和modules.dep.bin的关系一样,modules.alias.bin与modules.alias完全相同,只不过modules.alias.bin是为了加快搜索速度采用Trie树存储的。很多读者可能会说,编译安装模块时从来没有显示执行depmod啊,那是因为make等安装脚本已经替我们调用了这个命令。

我们可以使用工具modinfo来查看驱动模块的相关信息,下面是查看驱动模块ahci的别名信息。



上述输出表示驱动模块ahci可以驱动别名为"pci:v*d*sv*sd*bc01sc06i01*"、"pci:v00001  B21d00000612sv*sd*bc*sc*i*"等的设备,其中“*”表示可以匹配任意ID。

通过depmod生成的典型的modules.alias文件如下所示:



显然,这个文件就是简单地将别名和驱动名称对应起来。

前面讨论内核向用户空间发送uevent时,我们看到,内核将在uevent的消息体中封装一个变量MODALIAS,其值形如"pci:v00008086d00001C03sv000017AAsd000021CEbc01s  c06i01"。看上去是不是与驱动的别名一致?没错,内核的设计者们设计了这个机制,内核创建变量MODALIAS和模块创建别名采用相同的算法。当udevd收到内核uevent后,从uevent中提取这个字符串,然后将这个字符串作为modprobe的参数。modprobe首先查找文件modules.alias.bin,将该别名对应的模块找到。以该别名为例,显然其会与上面modules.alias文件片断中的第一行匹配成功,而该行明确表明该别名对应的驱动模块是ahci,因此,modprobe将加载模块ahci。

udev设计了规则文件80-drivers.rules用来描述如何加载驱动模块,以v173版本的udev的80-drivers.rules为例:



我们先来看第一个规则,该规则表示如果uevent的动作是删除设备(remove),则忽略下面所有规则,什么也不用做。

第二个规则包含两个匹配条件,一个赋值动作。其中“?”匹配一个字符,“*”匹配0或多个字符。这个规则表达的含义是:当设备还没有加载驱动,即环境变量DRIVER的值为空,并且环境变量MODALIAS的值非空,那么调用modprobe加载驱动。我们看到这里加载模块的方式就是采用我们前面讨论的别名的方式。这里追加到环境变量RUN中的程序,如果不给出绝对路径,将在/lib/udev目录下寻找,如果这个程序不在/lib/udev目录下,必须给出绝对路径。

80-drivers.rules也会包含对个别特殊subsystem类型的设备的特殊处理,我们这里不作过多讨论。

一旦驱动被正确加载,并且设备需要在用户空间建立设备节点,那么内核向用户空间再次报告的uevent中会包含创建设备节点需要的主次设备号以及节点的名称等环境变量,类似于下面的这个示例uevent事件。事实上,在发现设备、加载驱动过程中,内核一般会多次向用户空间报告uevent事件,只有设备和驱动匹配成功后发送的事件中才会包含主次设备号等变量。



该消息中,内核为udev创建设备节点提供了必要的变量,包括主设备号为8,次设备号为1,内核提供的该设备节点的名字为sda1。当udevd收到的uevent消息中,如果uevent的变量中包含设备号,则使用系统调用mknod创建设备节点。



4.6.3 处理冷插拔设备

前面我们讨论了动态加载驱动的整个过程。但是不知道读者想过没有,对于磁盘这种非热插拔设备,如果驱动没有编译进内核,那么当内核引导枚举设备时,系统运行在内核空间,尚未进入用户空间,更谈不上启动用户空间的udev服务了,因此内核发送到用户空间的uevent自然会被丢掉,更别提加载硬盘驱动模块和建立设备节点了。

为了解决这个问题,开发人员基于sys文件系统设计了一种巧妙的机制。在Linux操作系统进入用户空间,udevd启动后,通过sys文件系统请求内核重新发出uevent。此时udevd已经启动了,就会收到uevent,然后结合这些事件和规则,完成驱动的加载、设备节点的建立等。我们可以将这个过程看作是内核和udev导演的一出戏,对于冷插拔的设备,模拟了一遍热插拔的过程。

下面我们简单探讨一下这个机制的原理。

当新设备注册时,内核将调用device_create_file在sys文件系统中为设备注册一个名字为uevent的文件,当用户空间的程序读取该文件时,内核将调用函数show_uevent处理用户的读操作,而当用户空间的程序向该文件写入时,内核将调用函数store_uevent处理用户的写操作。我们以函数store_uevent为例,看看内核是如何处理用户的写操作的。函数store_uevent代码如下:



store_uevent的参数buf指向复制自用户空间的用户写入的字符串。函数kobject_action_type根据buf中的字符串,来决定发送给用户空间的uevent的类型。写入的字符串和发送的事件类型间的对应关系的代码如下所示。



也就是说,当用户空间的程序向该属性文件写入字符串"add"时,函数kobject_action_type认为用户空间的程序要求KOBJ_ADD类型的事件,于是调用kobject_uevent向用户空间发送KOBJ_ADD类型的uevent。

利用这种机制,我们可以在用户空间的udev服务程序启动后,向所有设备的属性文件uevent写入"add"字符串,请求内核重新发送一遍KOBJ_ADD事件,模拟一遍热插拔动作。如此,udevd就可以收到这些事件,完成驱动加载、设备节点创建等工作。

为此,udev提供了一个管理工具udevadm,我们可以使用这个工具请求内核重新发送设备相关事件。假设请求内核对全部设备模拟一遍热插拔,即重新发送事件KOBJ_ADD,则使用如下命令:



我们来简单地看一下这个命令背后的代码:



根据上面代码可见,udevadm的trigger命令对应的函数是adm_trigger。当用户请求内核重新发送设备相关的事件时,adm_trigger首先调用udev_enumerate_scan_devices在sys文件系统中寻找设备,使用udevadm的trigger命令时我们可以指定一些属性,匹配特定的设备。但是无论如何,会有多个设备满足匹配条件的情况,比如我们上面的命令,没有任何限制条件,那么内核将匹配所有设备。于是udev在结构体udev_enumerate中设计了一个链表,udev_enumerate_scan_devices将找到的所有设备连接到结构体udev_enumerate中的设备链表中。

然后,adm_trigger调用函数exec_list遍历这个链表,向这些设备在sys文件系统中注册的属性文件uevent写入用户请求内核重新发送的事件类型对应的字符串。比如,如果请求内核发送KOBJ_ADD类型的uevent,则写入字符串"add";如果请求内核发送KOBJ_CHANGE类型的uevent,则写入字符串"change",等等。



4.6.4 编译安装udev

前面几节我们探讨了相关的工作原理,从本节开始,我们开始动手实践驱动模块的自动加载过程。

因为系统启动程序systemd和udev之间的依赖关系,为了方便开发编译,所以社区中已经将udev和systemd合并了。但是本书中我们不讨论systemd,为了减少干扰,本书中使用尚未合并前的udev。合并前后,udev本质上并没有什么差别。

使用如下命令编译安装udev(我们采用的版本是udev  173):



指定--libexecdir的目的是告诉安装脚本将udev的规则文件以及一些helper程序安装在/lib/udev目录下。我们使用--disable选项禁掉udev不必要的一些特性,也减少了udev对其他库的依赖和系统的复杂性。

接下来将udevd、udevadm以及相关的规则文件安装到initramfs中:



udevd和udevadm依赖的库在前面已经复制到initramfs中了,所以只需将udevd、udevadm和加载驱动的规则,即80-drivers.rules,复制到initramfs即可。



4.6.5 配置内核支持NETLINK

内核与udevd通过Unix  Domain  Sockets使用NETLINK协议进行通信,因此,我们需要配置内核支持Unix  Domain  Sockets与NETLINK协议。配置步骤如下:

1)执行make  menuconfig,出现如图4-20所示的界面。

图 4-20 配置内核支持Unix  domain  sockets和NETLINK协议(1)

2)在图4-20中,选择"Networking  support",出现如图4-21所示的界面。

图 4-21 配置内核支持Unix  domain  sockets和NETLINK协议(2)

3)在图4-21中,选择"Networking  options",出现如图4-22所示的界面。

图 4-22 配置内核支持Unix  domain  sockets和NETLINK协议(3)

4)在图4-22中,选中"Unix  domain  sockets"。
对于,NETLINK协议,只要配置内核支持网络,NETLINK协议默认就被支持。通过net目录下的Makefile可以清楚地看到这一点:



4.6.6 配置内核支持inotify

因为udev使用inotify机制监测udev的规则文件是否发生变化,所以配置内核使其支持inotify机制,否则udevd将因为初始化inotify失败而退出。配置过程如下:

1)执行make  menuconfig,出现如图4-23所示的界面。

图 4-23 配置内核支持inotify(1)

2)在图4-23中,选择"File  systems",出现如图4-24所示的界面。

图 4-24 配置内核支持inotify(2)

3)在图4-24中,选中"Inotify  support  for  userspace"。



4.6.7 安装modules.alias.bin文件

在安装内核模块时,安装脚本最后会自动调用depmod创建modules.alias.bin/modules.alias文件。我们直接将其复制到initramfs即可:



如果你在某些特殊情况下,需要用手动执行depmod创建modules.alias.bin、modules.dep.bin等文件,相应命令如下:



下面我们验证一下modules.alias.bin是否可以正确工作。我们需要安装两个工具:一个是lspci,这个工具用来运行在目标系统上,查看硬盘设备在PCI总线上的位置,包括设备所在的总线、设备号等,这个工具在软件包pciutils中;另外一个是coreutils中的工具cat,其已经编译并且安装在/vita/sysroot下了,我们将其直接复制到initramfs即可。

我们首先编译安装lspci:



将lspci及其依赖的库安装到initramfs,命令如下:



lspci将依次尝试通过sysfs文件系统、proc文件系统以及直接访问端口的方式列出PCI总线上的设备。为了便于人们理解,社区中维护了一个pci数据库pci.ids,该数据库中记录了ID到设备信息的映射。当lspci查找到设备ID时,其使用设备ID到pci.ids中去匹配设备信息。因此除了安装lspci及依赖的库外,我们还需要安装pci数据库pci.ids,pci.ids已经被包含在软件包pciutils中,并且在安装lspci时已经安装到目标系统的根文件系统下,我们将其复制到initramfs中。



coreutils中的cat已经编译并且安装在/vita/sysroot下了,我们直接复制到initramfs即可。



使用支持NETLINK和inotity的内核以及新的initramfs更新vita系统,重启后运行lspci,运行结果如图4-25所示。根据lspci的输出可见,SATA控制器挂在总线号为0x00的PCI总线上,设备号为0x0d。

图 4-25 硬盘控制器的uevent中的环境变量

根据总线号和设备号就可以确定SATA控制器在sysfs文件系统中的路径。我们使用命令cat将uevent中的相关变量读出,根据输出结果可见,变量MODALIAS的值为"pci:v000080  86d00002829sv00000000sd00000000bc01sc06i01"。我们使用这个MODALIAS的值加载模块,如图4-26所示。

图 4-26 通过环境变量MODALIAS加载驱动

根据输出的信息,我们清楚地看到,使用模块的别名,模块也被正确加载了,说明modules.alias.bin文件工作正常。事实上,通过文件modules.alias.bin中的别名和MODALIAS的对应关系,modprobe将如下命令:



转换为了:



4.6.8 启动udevd和模拟热插拔

现在对于自动加载硬盘控制器驱动来说,是万事俱备,只欠东风了,让我们来扣响扳机。修改init,在其中启动udevd,并使用udevadm对冷插拔设备模拟热插拔。另外,udevd需要保存某些运行时的信息,因此,我们需要建立run目录:



因为这个目录也是保存运行时信息的,关机后不再需要保存,因此我们也使用相对高效的基于内存的文件系统。修改后的init文件如下:



init启动了udev的服务进程udevd,然后使用命令udevadm遍历sysfs中的设备,向这些设备在sysfs文件系统中的文件uevent写入"add"字符串,请求内核重新发送KOBJ_ADD事件,相当于模拟了一次热插拔。

udevd收到硬盘控制器的uevent后,将加载硬盘控制器驱动,并创建设备节点。当然devtmpfs也会创建设备节点,但是udevd与devtmpfs并不矛盾,udevd可以在devtmpfs上进行用户空间的各种修饰。

命令"udevadm  settle"的目的是等待udevd处理完内核向用户空间发送的uevent后再继续向下执行。否则,如果这里不进行等待,后续的操作有可能发生错误。举个例子,假如在udevd正在调用modprobe加载硬盘驱动模块时,init后续的脚本可能已经并行地开始挂载根文件系统了,但是此时设备尚未被驱动,更别提设备节点了,所以挂载将会失败。

重新压缩initramfs,更新到vita系统,重启系统。我们来检查一下硬盘控制器是否正确加载,如图4-27所示。通过lsmod和查看设备节点,显然硬盘控制器驱动已经成功自动加载。

图 4-27 查看加载模块以及建立的硬盘设备节点