深度探索Linux操作系统 —— 编译过程分析
深度探索Linux操作系统 —— 构建工具链
深度探索Linux操作系统 —— 构建内核
深度探索Linux操作系统 —— 构建initramfs
深度探索Linux操作系统 —— 从内核空间到用户空间
深度探索Linux操作系统 —— 构建根文件系统
深度探索Linux操作系统 —— 构建桌面环境
深度探索Linux操作系统 —— Linux图形原理探讨
一般而言,桌面、服务器等通用系统都使用 initramfs。部分嵌入式系统中,也会使用 initramfs,甚至有的使用 initramfs 作为最终的根文件系统。那么什么是 initramfs 呢?很难用一句话将 initramfs 的作用描述清楚,或许可以将 initramfs 定位为内核通往根文件系统的桥梁。
鸡和蛋的问题:内核要加载这些模块或者运行这些程序才能正确识别根文件系统所在的设备,但是保存这些模块或者程序的根文件系统又存储在这些设备上。
内核开发者们设计了 initramfs 机制。initramfs 是一个临时的文件系统,其中包含了必要的设备如硬盘、网卡、文件系统等的驱动以及加载驱动的工具及其运行环境,比如基本的 C 库,动态库的链接加载器等等。同时,那些处理根文件系统在 RAID 、网络设备上的程序也存放在 initramfs 中。由第三方程序(如 Bootloader )负责将 initramfs 从硬盘装载进内存。以驱动硬盘为例,内核就不必再从硬盘,而是从已经加载到内存的 initramfs 中获取硬盘控制器等相关驱动了,继而可以驱动硬盘,访问硬盘上的根文件系统,从而解决了前面提到的鸡和蛋的矛盾。
在初始化的最后,内核运行 initramfs 中的 init 程序,该程序将探测硬件设备、加载驱动,挂载真正的文件系统,执行文件系统上的 /sbin/init,进而切换到真正的用户空间。真正的文件系统挂载后,initramfs 即完成了使命,其占用的内存也会被释放。
在 2.4 以及更早版本的内核中,内核使用的是 initrd。initrd 是基于ramdisk技术的,而 ramdisk 就是一个基于内存的块设备,因此 initrd 也具有块设备的一切属性。比如 initrd 容量是固定的,一旦创建 initrd 时设定了一个大小,就不能再进行动态调整。
ramfs 与 ramdisk 有着本质的区别,ramdisk 本质上是基于内存的一个块设备,而 ramfs 是基于缓存的一个文件系统。因此,ramfs 去除了前述块设备的一些限制。比如,ramfs 根据其中包含的文件大小可自由伸缩;增加文件时,自动分配内存;删除文件时,自动释放内存。更重要的是,ramfs 是基于已有的缓存机制,因此不必再像 ramdisk 那样需要和缓存之间进行多余的复制一环。
从 2.6 开始,内核开发人员基于 ramfs 开发了 initramfs 替代 initrd 。
当 2.6 版本的内核引导时,在挂载真正的根文件系统之前,首先将挂载一个名为 rootfs 的文件系统,并将 rootfs 的根作为虚拟文件系统目录树的总根。那么为什么要使用 rootfs 这么一个中间过程呢?原因之一还是为了解决鸡和蛋的问题。内核需要根文件系统上的驱动以及程序来驱动和挂载根文件系统,但是这些驱动和程序有可能没有编译进内核,而在根文件系统上。如果不借助第三方,内核是没有办法挂载真正的根文件系统的。而 rootfs 虽然名称为 rootfs ,但是并不是什么新的文件系统,事实上,其就是一个 ramfs,只不过换了一个名称。换句话说,rootfs 是在内存中的,内核不需要特殊的驱动就可以挂载 rootfs,所以内核使用 rootfs 作为一个过渡的桥梁。
在挂载了 rootfs 后,内核将 Bootloader 加载到内存中的 initramfs 中打包的文件解压到 rootfs 中,而这些文件中包含了驱动以及挂载真正的根文件系统的工具,内核通过加载这些驱动、使用这些工具,实现了挂载真正的根文件系统。此后,rootfs 也完成了历史使命,被真正的根文件系统覆盖(overmount)。但是 rootfs 作为虚拟文件系统目录树的总根,并不能被卸载。但是这没有关系,前面我们已经谈到了,rootfs 基于 ramfs,删除其中的文件即可释放其占用的空间。
# 1
mkdir initramfs
cd initramfs
# 2
# /vita/initramfs/init
#!/bin/bash
echo "Hello Linux!"
exec /bin/bash
# 3
mkdir bin
cp ../sysroot/bin/bash bin/
bash 依赖
vita@baisheng:/vita/initramfs$ ldd bin/bash
libdl.so.2 => /vita/sysroot/lib/libdl.so.2
libgcc_s.so.1 =>/vita/cross-tool/i686-none-linux-gnu/lib/libgcc_s.so.1
libc.so.6 => /vita/sysroot/lib/libc.so.6
vita@baisheng:/vita/initramfs$ ldd lib/libdl.so.2
libc.so.6 => /vita/sysroot/lib/libc.so.6
ld-linux.so.2 => /vita/sysroot/lib/ld-linux.so.2
vita@baisheng:/vita/initramfs$ ldd lib/libc.so.6
ld-linux.so.2 => /vita/sysroot/lib/ld-linux.so.2
vita@baisheng:/vita/initramfs$ ldd lib/ld-linux.so.2
vita@baisheng:/vita/initramfs$ ldd lib/libgcc_s.so.1
libc.so.6 => /vita/sysroot/lib/libc.so.6
bash 依赖于libc、libdl 以及 libgcc_s.so.1,因此,我们需要在 initramfs 中安装这三个库,以及安装加载动态库的动态加载/链接器。
根据依赖关系可见,libdl 依赖 libc 和动态链接器,libgcc 只依赖 libc,libc 仅依赖动态链接器,而动态链接器不依赖其他任何库,因此,我们不再需要安装其他库到 initramfs 中。
Linux 从 2.6.18 开始采用 udev,/dev 目录使用了基于内存的文件系统 tmpfs 管理设备文件。
2009 年初,开发人员又提出了 devtmpfs ,并在同年年底被 Linux 2.6.32 正式收录。内核引导时,devtmpfs 将所有注册的设备在 devtmpfs 中建立相应的设备文件,一旦进入用户空间,在启动 udev 前,就可以将 devtmpfs 挂载到 /dev 目录下。
也就是说,在启动 udev 前,devtmpfs 中已经建立了初步的设备文件,一般启动程序不必再等待 udev 建立设备节点,甚至在某些嵌入式系统上,不再需要 udev 创建设备节点,因为这个基本的 /dev 已经足够,从而缩短了系统的启动时间。同 rootfs 类似,devtmpfs 也不是新设计的文件系统,如果内核配置支持 tmpfs ,那么其就是 tmpfs;否则,devtmpfs 就是 ramfs,只不过换了一个名字而已。
接下来重新编译内核和模块。内核和模块可以使用单独的命令分开编译,也可以使用一条 make 命令同时编译内核和模块。编译完成后,将模块暂时安装在 “/vita/sysroot/lib/modules” 目录下。
vita@baisheng:/vita/build/linux-3.7.4$ make bzImage
vita@baisheng:/vita/build/linux-3.7.4$ make modules
vita@baisheng:/vita/build/linux-3.7.4$ make \
INSTALL_MOD_PATH=$SYSROOT modules_install
最终安装的硬盘控制器驱动模块包括:
vita@baisheng:/vita$ ls sysroot/lib/modules/3.7.4/kernel/drivers/ata/
ahci.ko ata_piix.ko libahci.ko
我们将其复制到 initramfs 中。
从 2.6 版内核开始,Linux 采用 udev 管理驱动模块的加载以及设备节点的管理。每当内核发现新的设备,便通过 NETLINK 向用户空间发送新设备事件,该事件中记录了设备的相关信息。用户空间的 udev 服务进程收到内核事件后,根据事件中携带的信息,首先判断该设备的驱动是否已经加载,如果没有,则加载驱动。驱动加载后,内核会再次向用户空间报告发现新设备事件,这时设备已经成功驱动了,并且主次设备号等信息也已经准备好了,udev 收到事件后,或者为设备建立节点,或者执行某些特定的操作。整个过程如图4-19所示。
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”。
pci_uevent 又向 uevent 中追加了 pci class 、 vendor id 、 device id 以及 MODALIAS 等变量,其中 MODALIAS 需要重点关注,其是由设备所在总线、vendor ID、device ID 等相关参数连接而成的一个字符串。在接下来的章节中,读者将看到,用户空间的 udev 恰恰就是根据这个变量为设备匹配驱动模块的。
除了总线外,如果硬盘控制器所属的 class 或者 type 也需要继续向 uevent 中追加变量,则继续调用硬盘控制器所属的 class 或者 type 中的相应的函数,这里不再继续分析了。最终,内核向用户空间发送的 uevent 事件包含的大致的内容如下,其中不同变量之间使用 “\0” 进行分隔。
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 进一步细分。而在驱动代码中,恰恰使用这些设备信息明确声明了其可以支持的设备。
内核将 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 等安装脚本已经替我们调用了这个命令。
前面我们讨论了动态加载驱动的整个过程。但是不知道读者想过没有,对于磁盘这种非热插拔设备,如果驱动没有编译进内核,那么当内核引导枚举设备时,系统运行在内核空间,尚未进入用户空间,更谈不上启动用户空间的 udev 服务了,因此内核发送到用户空间的 uevent 自然会被丢掉,更别提加载硬盘驱动模块和建立设备节点了。
为了解决这个问题,开发人员基于 sys 文件系统设计了一种巧妙的机制。在 Linux 操作系统进入用户空间,udevd 启动后,通过 sys 文件系统请求内核重新发出 uevent 。此时 udevd 已经启动了,就会收到 uevent ,然后结合这些事件和规则,完成驱动的加载、设备节点的建立等。我们可以将这个过程看作是内核和 udev 导演的一出戏,对于冷插拔的设备,模拟了一遍热插拔的过程。
下面我们简单探讨一下这个机制的原理。
当新设备注册时,内核将调用 device_create_file 在 sys 文件系统中为设备注册一个名字为 uevent 的文件,当用户空间的程序读取该文件时,内核将调用函数 show_uevent 处理用户的读操作,而当用户空间的程序向该文件写入时,内核将调用函数 store_uevent 处理用户的写操作。我们以函数 store_uevent 为例,看看内
核是如何处理用户的写操作的。函数 store_uevent 代码如下:
也就是说,当用户空间的程序向该属性文件写入字符串 “add” 时,函数 kobject_action_type 认为用户空间的程序要求 KOBJ_ADD 类型的事件,于是调用 kobject_uevent 向用户空间发送 KOBJ_ADD 类型的 uevent 。
利用这种机制,我们可以在用户空间的 udev 服务程序启动后,向所有设备的属性文件 uevent 写入 “add” 字符串,请求内核重新发送一遍 KOBJ_ADD 事件,模拟一遍热插拔动作。如此,udevd 就可以收到这些事件,完成驱动加载、设备节点创建等工作。
为此,udev 提供了一个管理工具 udevadm,我们可以使用这个工具请求内核重新发送设备相关事件。假设请求内核对全部设备模拟一遍热插拔,即重新发送事件 KOBJ_ADD ,则使用如下命令:
udevadm trigger --action=add