原文:
annas-archive.org/md5/3a82583c6e34135584cfbe4fe278ea74译者:飞龙
第十章:现场软件更新
在前几章中,我们讨论了为 Linux 设备构建软件的各种方法,以及如何为各种类型的大容量存储设备创建系统镜像。当你进入生产阶段时,你只需要将系统镜像复制到闪存中,它就可以准备好部署了。现在,我想考虑设备在首次发货后的生命周期。
随着我们进入 物联网 时代,我们创建的设备很可能会连接到互联网。同时,软件变得越来越复杂。更多的软件意味着更多的漏洞。连接到互联网意味着这些漏洞可能会被远程利用。因此,我们有一个共同的需求,即能够在 现场 更新软件。所谓“现场”是指“工厂之外”。软件更新带来的好处不仅仅是修复漏洞。它们为现有硬件增加新功能并随着时间的推移提高系统性能,从而带来了更多的价值。
本章将涵盖以下主题:
-
更新从哪里来源?
-
更新内容
-
软件更新基础
-
更新机制的类型
-
OTA 更新
-
使用 Mender 进行本地更新
-
使用 Mender 进行 OTA 更新
技术要求
为了跟上示例,请确保你拥有以下内容:
-
一台至少有 90 GB 空闲磁盘空间的 Ubuntu 24.04 或更高版本的 LTS 主机系统
-
Yocto 5.0(scarthgap)LTS 版本
你应该已经在 第六章 中构建了 Yocto 5.0(scarthgap)LTS 版本。如果没有,请在根据 第六章 中的说明在 Linux 主机上构建 Yocto 之前,参考 Yocto 项目快速构建 指南中的 兼容的 Linux 发行版 和 构建主机包 部分 (docs.yoctoproject.org/brief-yoctoprojectqs/))。
本章中使用的代码可以在本书 GitHub 仓库中的章节文件夹找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development/tree/main/Chapter10。
更新从哪里来源?
有许多方法可以进行软件更新。大体上,我将它们归类为以下几种:
-
本地更新:由技术人员执行,通过便携介质如 USB 闪存驱动器或 SD 卡携带更新,并需要逐一访问每个系统。
-
远程更新:由用户或技术人员在本地发起,但从远程服务器下载。
-
空中下载更新(OTA)更新:完全远程推送和管理,无需任何本地输入。
我将首先描述几种软件更新的方法,然后展示一个使用 Mender 的示例。
更新内容
嵌入式 Linux 设备在设计和实现上有很大的多样性。然而,它们都包含这些基本组件:
-
引导加载程序
-
内核
-
根文件系统
-
系统应用程序
-
特定设备的数据
有些组件比其他组件更难更新,如下图所示:
图 10.1 – 更新的组件
让我们逐个查看这些组件。
引导加载程序
引导加载程序是处理器上电后运行的第一段代码。处理器找到引导加载程序的方式非常依赖于设备,但在大多数情况下,只有一个这样的地点,因此只能有一个引导加载程序。如果没有备份,更新引导加载程序是有风险的:如果系统在过程中断电会发生什么?因此,大多数更新解决方案都不会更改引导加载程序。这并不是一个大问题,因为引导加载程序在开机时只运行很短的时间,通常不是导致运行时错误的主要源头。
内核
Linux 内核是一个关键组件,肯定需要不时进行更新。
内核有多个部分:
-
由引导加载程序加载的二进制映像,通常存储在根文件系统中。
-
许多设备还拥有一个设备树二进制文件(DTB),它向内核描述硬件,因此必须与内核一起更新。DTB 通常与内核二进制文件一起存储。
-
根文件系统中可能包含内核模块。
内核和 DTB 可以存储在根文件系统中(如果引导加载程序能读取该文件系统格式),也可以存储在专用分区中。在任何一种情况下,拥有冗余副本都是可能的并且更安全。
根文件系统
根文件系统包含使系统正常工作的基本系统库、工具和脚本。能够替换和升级所有这些是非常期望的。机制依赖于文件系统的实现。
嵌入式根文件系统的常见格式如下:
-
RAM 磁盘:从原始闪存内存或磁盘映像加载。要更新它,只需覆盖 RAM 磁盘映像并重启系统。
-
只读压缩文件系统(squashfs):存储在闪存分区中。由于这些文件系统没有写功能,因此更新它们的唯一方法是将完整的文件系统映像写入分区。
-
常见文件系统类型:JFFS2 和 UBIFS 格式通常用于原始闪存内存。对于如 eMMC 和 SD 卡这样的管理型闪存内存,格式可能是 ext4 或 F2FS。由于这些文件系统在运行时可写,因此可以逐文件更新它们。
系统应用程序
系统应用程序是设备的主要载荷;它们实现了设备的主要功能。因此,它们可能会频繁更新以修复错误和添加功能。它们可能与根文件系统捆绑在一起,但也常常被放置在单独的文件系统中,以便更容易更新,并且可以保持系统文件(通常是开源的)与应用程序文件(通常是专有的)之间的分离。
特定设备的数据
这是运行时修改的文件组合。设备特定的数据包括配置设置、日志、用户提供的数据以及类似的文件。这些数据通常不需要更新,但在更新过程中需要被保留。这些数据需要存储在专用的分区中。
需要更新的组件
总结来说,更新可能包括内核、新版本的根文件系统和系统应用。设备会有其他分区,更新时不应该受到干扰,像设备运行时数据一样。
软件更新失败的代价可能是灾难性的。安全的软件更新在企业和家庭互联网环境中都是一个重要问题。在我们能够发货任何硬件之前,我们必须能够自信地更新软件。
软件更新基础
更新软件乍一看似乎是一个简单的任务:你只需要用新的文件覆盖旧的文件。但随着你工程师培训的展开,你开始意识到可能出错的地方。假如在更新过程中断电了怎么办?假如在更新测试中漏掉了一个 bug,导致部分设备无法启动怎么办?假如第三方发送了一个假更新,把你的设备纳入了僵尸网络怎么办?至少,软件更新机制必须是:
-
健壮,确保更新不会导致设备无法使用。
-
故障安全,确保在所有失败时仍有备份模式。
-
安全,以防止设备被安装未经授权的更新而被劫持。
换句话说,我们需要一个不容易受到墨菲定律影响的系统。墨菲定律表明,如果某件事有可能出错,那么它最终一定会出错。有些问题并非小事。将软件部署到现场设备与将软件部署到云端是不同的。嵌入式 Linux 系统需要在没有任何人工干预的情况下,检测并应对如内核崩溃或启动循环等意外情况。
提高更新的健壮性
你可能认为 Linux 系统更新的问题早已解决——我们都有定期更新的 Linux 桌面(不是吗?)。此外,数据中心里有大量的 Linux 服务器,也同样保持最新。然而,服务器和设备之间是有区别的。前者运行在一个受保护的环境中,不太可能突然失去电源或网络连接。如果更新确实失败了,仍然可以访问服务器,并使用外部机制重新安装。
另一方面,设备往往部署在远程站点,电力不稳定且网络连接差,这使得更新被中断的可能性大大增加。因此,考虑到在更新失败后,获取设备进行修复可能非常昂贵。比如,如果设备是一座山顶的环境监测站,或者是位于海底的油井阀门控制系统怎么办?因此,对于嵌入式设备来说,拥有一个健壮的更新机制尤为重要,以防系统无法使用。
这里的关键词是原子性。为了确保原子性,更新过程中不应有任何阶段是系统部分更新的。必须有一个单一且不可中断的更改,来将系统切换到新版本的软件。
这排除了最明显的更新机制:通过在文件系统的部分区域上提取归档文件来单独更新文件。若系统在更新过程中被重置,就无法确保文件的一致性。即使使用apt、dnf或pacman等包管理器也无法解决问题。如果你查看这些包管理器的内部工作机制,会发现它们的确是通过在文件系统上提取归档并运行脚本来配置软件包,既在更新之前,也在更新之后。包管理器在数据中心的受保护环境中,或者在你的桌面上是没问题的,但在设备上却不可行。
为了实现原子性,更新必须与正在运行的系统并行安装,然后切换到新版本的软件。在接下来的章节中,我们将描述实现原子性的两种不同方法。第一种方法是拥有两个根文件系统和其他主要组件的副本。一个副本是活动的,而另一个可以接收更新。当更新完成后,通过切换,重启时引导程序选择更新后的副本。这被称为对称镜像更新或A/B 镜像更新。这种方法的变种是使用一个特殊的恢复模式操作系统,负责更新主操作系统。原子性的保证由引导程序和恢复操作系统共同承担。这被称为非对称镜像更新。这是 Android 在 Nougat 7.x 版本之前采用的方法。第二种方法是,在系统分区的不同子目录中拥有两个或多个根文件系统副本,然后在启动时使用chroot(8)来选择其中一个副本。一旦 Linux 系统运行,更新客户端可以将更新安装到另一个根文件系统中,完成并检查所有内容后,可以切换并重启。这被称为原子文件更新,以OSTree为例。
使更新具备故障安全性
接下来需要考虑的问题是,如何从一个已正确安装但包含使系统无法启动的代码的更新中恢复。理想情况下,我们希望系统能够检测到这种情况,并回滚到先前的工作镜像。
有几种故障模式可能导致系统无法操作。第一个是内核 panic,通常由内核设备驱动程序中的 bug 或无法运行init程序引起。一个合理的起点是通过配置内核,在 panic 后的一定时间内重启。
你可以在构建内核时通过设置CONFIG_PANIC_TIMEOUT来实现,或者通过将内核命令行设置为panic来实现。例如,要在 panic 后 5 秒重启,可以将panic=5添加到内核命令行。
你可能希望进一步配置内核,使其在发生 Oops 错误时触发panic。请记住,Oops 是当内核遇到致命错误时生成的。在某些情况下,内核能够从错误中恢复,而在其他情况下则无法恢复。但无论如何,肯定是出现了问题,系统无法正常工作。要在内核配置中启用 Oops 触发 panic,请设置CONFIG_PANIC_ON_OOPS=y,或者在内核命令行中设置oops=panic。
第二种故障模式发生在内核成功启动init后,但由于某种原因,主应用程序无法运行。对此,你需要一个看门狗。看门狗是一个硬件或软件定时器,如果定时器未在过期前重置,则会重新启动系统。如果你使用的是systemd,你可以使用内置的看门狗功能,我将在第十三章中描述。如果没有,你可能需要启用内核源代码中Documentation/watchdog描述的 Linux 内置看门狗支持。
这两种故障都会导致启动循环:无论是内核 panic 还是看门狗超时,都会导致系统重启。如果问题持续存在,系统将不断重启。要打破启动循环,我们需要在引导加载程序中添加一些代码,检测这种情况并回滚到先前已知的正常版本。一个典型的方法是使用启动计数,每次启动时引导加载程序都会递增该计数器,并且一旦系统启动并运行,计数器会在用户空间中被重置为零。如果系统进入启动循环,计数器不会被重置,从而继续增加。然后,配置引导加载程序,当计数器超过阈值时采取补救措施。
在 U-Boot 中,这通过三个变量来处理:
-
bootcount:每次处理器启动时递增。 -
bootlimit:如果bootcount超过bootlimit,U-Boot 将执行altbootcmd中的命令,而不是bootcmd。 -
altbootcmd:包含备用启动命令,例如回滚到先前的版本或启动恢复模式操作系统。
为了实现这一功能,必须有一种方法允许用户空间程序重置引导计数。我们可以使用 U-Boot 工具,通过它在运行时访问 U-Boot 环境:
-
fw_printenv:打印 U-Boot 变量的值 -
fw_setenv:设置 U-Boot 变量的值
这两个命令需要知道 U-Boot 环境块存储的位置,相关的配置文件位于 /etc/fw_env.config。例如,如果 U-Boot 环境存储在 eMMC 内存的 0x800000 偏移位置,并且有一个备份副本在 0x1000000,那么配置文件会如下所示:
# cat /etc/fw_env.config
/dev/mmcblk0 0x800000 0x40000
/dev/mmcblk0 0x1000000 0x40000
本节最后需要讨论的一个问题是:每次启动时递增引导计数,并在应用程序启动时重置它,这会导致不必要的写入环境块,从而加速闪存的损耗。为了防止在每次重启时发生这种情况,U-Boot 引入了一个名为 upgrade_available 的附加变量。如果 upgrade_available 为 0,则 bootcount 不会递增,因为没有未验证的升级需要防范。在安装更新后,upgrade_available 会被设置为 1,这样只有在需要时才会启用引导计数保护。
让更新变得安全
最后一个问题涉及更新机制本身的潜在滥用。当你实现更新机制时,主要目的是提供一种可靠的自动化或半自动化的方法来安装安全补丁和新功能。然而,其他人可能利用同样的机制安装未经授权的软件版本,并劫持设备。我们需要确保这种情况不会发生。
最大的安全漏洞是伪造的远程更新。为了防止这种情况发生,我们需要在下载开始之前验证更新服务器的身份。同时,我们还需要一个安全的传输通道,例如 HTTPS,以防止下载流的篡改。校验和提供了第二道防线。每个更新都会生成一个校验和,并发布到服务器上。只有在校验和与下载内容匹配时,更新才会被应用。当我描述 OTA 更新时,我会回到服务器身份验证的话题。
还有一个关于镜像真实性的问题。检测伪造更新的一种方法是在引导加载程序中使用安全启动协议。如果内核镜像在工厂时已经用数字密钥签名,引导加载程序可以在加载内核之前检查签名,并在验证失败时拒绝加载。如果制造商保持密钥的私密性,那么就无法加载未经授权的内核。U-Boot 实现了这样的机制,相关内容可以在在线文档中查看:docs.u-boot.org/en/latest/usage/fit/verified-boot.html
重要提示
安全启动:是好是坏?
如果我购买了一台具有软件更新功能的设备,那么我就是在信任该设备的供应商提供有用的更新。我绝对不希望一个恶意的第三方在我不知情的情况下安装软件。但是,我是否应该被允许自己安装软件呢?如果我完全拥有该设备,难道我不应有权修改它,包括加载新的软件吗?想想 TiVo 机顶盒,它最终促成了 GPL v3 许可证的诞生。记得 Linksys WRT54G Wi-Fi 路由器吗?当硬件访问变得容易时,它催生了一个全新的产业,包括 OpenWrt 项目。这是一个复杂的问题,位于自由与控制的交汇点。我的观点是,一些设备制造商将安全性作为借口,来保护他们(有时是劣质的)软件。
更新软件可能看起来平凡,但一个坏的更新可能会对你的业务造成灾难性的损害。2024 年 7 月的 CrowdStrike 宕机就是一个完美的例子。出于这个原因,使用蓝绿部署等安全技术逐步推出更新是非常重要的。这样,如果出了问题,你可以回滚软件发布,而不会影响到很多用户。那么,既然我们知道了所需的条件,我们如何在嵌入式 Linux 系统上更新软件呢?
更新机制的类型
在本节中,我将介绍三种应用软件更新的方法:对称或 A/B 镜像更新;不对称镜像更新,也称为恢复模式更新;最后是原子文件更新。
对称镜像更新
在此方案中,有两个操作系统副本,每个副本包括 Linux 内核、根文件系统和系统应用程序。它们在下面的图中标记为A和B:
图 10.2 – 对称镜像更新
对称镜像更新的工作原理如下:
-
启动加载程序有一个标志,指示它应该加载哪个镜像。最初,标志被设置为A,因此启动加载程序加载操作系统镜像A。
-
要安装更新,更新程序应用程序(操作系统的一部分)会覆盖操作系统镜像B。
-
完成后,更新程序将引导标志更改为B并重新启动。
-
现在,启动加载程序将加载新的操作系统。
-
当安装进一步更新时,更新程序会覆盖镜像A并将引导标志更改为A,这样你就会在两个副本之间来回切换。
-
如果更新在引导标志更改之前失败,启动加载程序将继续加载正常的操作系统。
有几个开源项目实现了对称镜像更新。其中之一是 Mender 客户端在独立模式下运行,我将在 使用 Mender 进行本地更新 部分中描述。另一个是 SWUpdate (github.com/sbabic/swupdate),它可以接收多个镜像更新的 CPIO 格式包,然后将这些更新部署到系统的不同部分。它允许你使用 Lua 语言编写插件进行自定义处理。
SWUpdate 还支持原始闪存内存,作为 MTD 闪存分区访问的文件系统,支持组织为 UBI 卷的存储,以及支持具有磁盘分区表的 SD/eMMC 存储。第三个例子是 RAUC,即 稳健的自动更新控制器 (github.com/rauc/rauc)。它也支持原始闪存存储、UBI 卷和 SD/eMMC 设备。图像可以使用 OpenSSL 密钥进行签名和验证。第四个例子是 fwup (github.com/fwup-home/fwup)),由长期的 Buildroot 贡献者 Frank Hunleth 提供。
这种方案有一些缺点。其中之一是通过更新整个文件系统镜像,更新包的大小较大,这可能会对连接设备的网络基础设施造成压力。可以通过仅发送已经更改的文件系统块来缓解这一问题,这需要通过对比新旧文件系统的二进制 diff 来完成。SWUpdate、RAUC 和 fwup 都支持这种 增量更新。Mender 的商业版也支持这一功能。
第二个缺点是需要为根文件系统及其他组件保留冗余副本的存储空间。如果根文件系统是最大的组件,它几乎会让你需要的闪存内存翻倍,以容纳两个副本。因此,采用非对称更新方案。
非对称镜像更新
你可以通过仅保留一个最小化的恢复操作系统用于更新主操作系统,如下所示,从而减少存储需求:
图 10.3 – 非对称镜像更新
要安装非对称更新,请执行以下操作:
-
设置启动标志指向恢复操作系统并重新启动。
-
一旦恢复操作系统启动,它可以将更新流式传输到主操作系统镜像。
-
如果更新被中断,启动加载程序将再次启动到恢复操作系统,这样可以继续更新。
-
只有当更新完成并经过验证后,恢复操作系统才会清除启动标志并重新启动——这时,将加载新的主操作系统。
-
在正确但存在漏洞的更新情况下,回退的做法是将系统恢复到恢复模式,系统可以尝试进行修复,可能通过请求较早的更新版本来解决问题。
恢复操作系统通常比主操作系统小得多,可能只有几兆字节,因此存储开销并不大。值得一提的是,这是 Android 在 Nougat 版本之前采用的方案。对于非对称镜像更新的开源实现,请考虑 SWUpdate 或 RAUC。
这种方案的一个主要缺点是,在运行恢复操作系统时,设备无法操作。这样的方案也不允许更新恢复操作系统本身。这将需要类似 A/B 镜像更新的东西,从而败坏了整个目的。
原子文件更新
另一种方法是在单个文件系统的多个目录中具有根文件系统的冗余副本,然后在引导时使用 chroot(8) 命令选择其中一个。这允许一个目录树在另一个作为根目录被挂载时进行更新。此外,而不是复制在根文件系统版本之间未更改的文件,您可以使用链接。这将节省大量磁盘空间并减少更新包中要下载的数据量。这些是原子文件更新的基本思想。
重要提示
chroot 命令在现有目录中运行程序。该程序将此目录视为其根目录,因此无法访问更高级别的任何文件或目录。它经常用于在受限环境中运行程序,有时称为 chroot 监狱。
libostree 项目 (github.com/ostreedev/ostree),前身为 OSTree,是这一理念最流行的实现。 OSTree 大约在 2011 年开始,作为向 GNOME 桌面开发者部署更新和改进其持续集成测试的手段。
它后来被采用为嵌入式设备的更新解决方案。它是 Automotive Grade Linux (AGL) 中的一种更新方法,并且通过 meta-updater 层在 Yocto Project 中可用,该层由 Advanced Telematic Systems (ATS) 支持。
使用 OSTree,在目标上的文件存储在 /ostree/repo/objects 目录中。它们被命名,使得同一文件的多个版本可以存在于存储库中。然后,一组给定的文件被链接到一个部署目录中,该目录的名称类似于 /ostree/deploy/os/29ff9…/。这被称为 checking out,因为它与从 Git 存储库中检出分支的方式有一些相似之处。每个部署目录包含组成根文件系统的文件。它们可以有任意数量,但默认情况下只有两个。例如,这里有两个 deploy 目录,每个目录都链接回 repo 目录:
/ostree/repo/objects/...
/ostree/deploy/os/a3c83.../
/usr/bin/bash
/usr/bin/echo
/ostree/deploy/os/29ff9.../
/usr/bin/bash
/usr/bin/echo
要从 OSTree 目录引导:
-
引导加载程序使用
initramfs引导内核,并在内核命令行中传递要使用的部署路径:bootargs=ostree=/ostree/deploy/os/deploy/29ff9... -
initramfs包含一个名为ostree-init的init程序,它读取命令行并执行chroot到指定的路径。 -
当安装系统更新时,已更改的文件会由 OSTree 安装代理下载到
repo目录中。 -
完成后,将创建一个新的
deploy目录,其中包含指向将构成新根文件系统的文件集合的链接。其中一些文件是新的,另一些则与之前相同。 -
最后,OSTree 安装代理将更改启动加载程序的启动标志,以便在下次重启时,它将
chroot到新的deploy目录。 -
启动加载程序实现了对启动次数的检查,如果检测到启动循环,它将回退到先前的根目录。
尽管开发人员可以手动在目标设备上操作更新程序或安装客户端,但最终软件更新需要通过 OTA 自动进行。
OTA 更新
更新 OTA 意味着能够通过网络将软件推送到设备或设备组,通常不需要终端用户与设备的互动。为了实现这一点,我们需要一个中央服务器来控制更新过程,并且需要一种协议来将更新下载到更新客户端。在典型的实现中,客户端会定期向更新服务器发送请求,检查是否有待处理的更新。轮询间隔需要足够长,以避免轮询流量占用网络带宽的显著部分,但又要足够短,以便及时传送更新。通常,几十分钟到几个小时的间隔是一个很好的折衷。来自设备的轮询消息包含某种唯一标识符,如序列号或 MAC 地址,以及当前的软件版本。通过这些信息,更新服务器可以判断是否需要更新。轮询消息还可以包含其他状态信息,如运行时间、环境参数或任何对设备的中央管理有用的信息。
更新服务器通常与一个管理系统连接,该系统将为其控制下的各个设备群体分配新的软件版本。如果设备群体很大,可能会分批发送更新,以避免过载网络。通常会有某种状态显示,展示设备的当前状态,并突出显示问题。
当然,更新机制必须是安全的,以防止虚假的更新被发送到终端设备。这涉及到客户端和服务器通过交换证书相互认证。然后,客户端可以验证下载的包是否由预期的密钥签名。
这里有三个开源项目的示例,你可以用来进行 OTA 更新:
-
管理模式下的 Mender
-
balena
-
Eclipse hawkBit (
github.com/eclipse/hawkbit)与像 SWUpdate 或 RAUC 这样的更新客户端配合使用
我们将详细介绍 Mender。
使用 Mender 进行本地更新
说到理论,接下来我会展示这些原理在实践中的应用。第一组示例涉及 Mender。Mender 使用对称的 A/B 镜像更新机制,并在更新失败时提供回退功能。它可以在独立模式下进行本地更新,也可以在托管模式下进行 OTA 更新。我将从独立模式开始。
Mender 是由 Northern.tech 编写和支持的。关于该软件的更多信息可以在官网的文档部分找到(mender.io)。我不会深入讲解软件的配置,因为我的目的是阐述软件更新的原理。我们从 Mender 客户端开始。
构建 Mender 客户端
Mender 客户端作为一个 Yocto 元层可用。这里的示例使用的是 The Yocto Project 的 scarthgap 版本,和我们在第六章中使用的版本相同。
首先通过以下方式获取meta-mender层:
$ git clone -b scarthgap https://github.com/mendersoftware/meta-mender
在克隆meta-mender层之前,你需要先导航到poky目录的上一级,这样两个目录就会在同一层级并排放置。
Mender 客户端需要对 U-Boot 配置进行一些更改,以处理引导标志和引导计数变量。标准的 Mender 客户端层有一些子层,提供了 U-Boot 集成的示例实现,我们可以直接使用,例如meta-mender-qemu和meta-mender-raspberrypi。我们将使用 QEMU。
下一步是创建构建目录,并为此配置添加相关层:
$ source poky/oe-init-build-env build-mender-qemu
$ bitbake-layers add-layer ../meta-openembedded/meta-oe
$ bitbake-layers add-layer ../meta-mender/meta-mender-core
$ bitbake-layers add-layer ../meta-mender/meta-mender-demo
$ bitbake-layers add-layer ../meta-mender/meta-mender-qemu
然后,我们需要通过向conf/local.conf添加一些设置来配置环境:
1 MENDER_ARTIFACT_NAME = "release-1"
2 INHERIT += "mender-full"
3 MACHINE = "vexpress-qemu"
4 INIT_MANAGER = "systemd"
5 IMAGE_FSTYPES = "ext4"
从conf/local.conf中省略行号(1 到 5)。第 2 行包含一个名为mender-full的 BitBake 类,负责处理生成 A/B 镜像格式所需的特殊图像处理。第 3 行选择了一个名为vexpress-qemu的机器,它使用 QEMU 来模拟 Arm Versatile Express 开发板,而不是 Yocto 项目的默认开发板 Versatile PB。第 4 行选择了systemd作为初始化守护进程,替代默认的 System V init。我将在第十三章中更详细地描述init守护进程。第 5 行使得根文件系统镜像生成ext4格式。
现在我们可以构建镜像:
$ bitbake core-image-full-cmdline
和往常一样,构建的结果保存在 tmp/deploy/images/vexpress-qemu 中。你会注意到与我们之前做过的 Yocto 项目构建相比,这里有一些新变化。这里有一个名为 core-image-full-cmdline-vexpress-qemu-grub-<timestamp>.mender 的文件,另一个文件名类似,但以 .uefiimg 结尾。.mender 文件是下一个小节中所需的:使用 Mender 安装更新。.uefiimg 文件是通过 Yocto 项目中的工具 wic 创建的。输出的是一个包含分区表的镜像,准备好可以直接复制到 SD 卡或 eMMC 芯片上。
我们可以使用 Mender 层提供的脚本运行 QEMU 目标,脚本会先启动 U-Boot,然后加载 Linux 内核:
$ ../meta-mender/meta-mender-qemu/scripts/mender-qemu
<…>
[ OK ] Started Boot script to demo Mender OTA updates.
[ OK ] Started Periodic Command Scheduler.
Starting D-Bus System Message Bus...
[ OK ] Started Getty on tty1.
Starting IPv6 Packet Filtering Framework...
Starting IPv4 Packet Filtering Framework...
Starting Mender-configure device configuration...
[ OK ] Started Serial Getty on ttyAMA0.
<…>
[ OK ] Finished Wait for Network to be Configured.
[ OK ] Started Time & Date Service.
[ OK ] Finished Mender-configure device configuration.
Poky (Yocto Project Reference Distro) 5.0.7 vexpress-qemu ttyAMA0
vexpress-qemu login:
如果不是登录提示符,而是出现类似这样的错误:
mender-qemu: 117: qemu-system-arm: not found
然后在系统上安装 qemu-system-arm 并重新运行脚本:
$ sudo apt install qemu-system-arm
以 root 用户身份登录,无需密码。查看目标上的分区布局,我们可以看到以下内容:
# fdisk -l /dev/mmcblk0
Disk /dev/mmcblk0: 1 GiB, 1073741824 bytes, 2097152 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: gpt
Disk identifier: 00000000-0000-0000-0000-00004D9B9EF0
Device Start End Sectors Size Type
/dev/mmcblk0p1 16384 49151 32768 16M EFI System
/dev/mmcblk0p2 49152 933887 884736 432M Linux filesystem
/dev/mmcblk0p3 933888 1818623 884736 432M Linux filesystem
/dev/mmcblk0p4 1818624 2097118 278495 136M Linux filesystem
总共有四个分区:
-
分区 1 包含 U-Boot 启动文件。
-
分区 2 和 3 包含 A/B 根文件系统(此时相同)。
-
分区 4 只是一个扩展分区,包含剩余的空间。
运行 mount 命令可以看到,第二个分区被用作根文件系统,第三个分区用来接收更新:
# mount | head -1
/dev/mmcblk0p2 on / type ext4 (rw,relatime)
现在 Mender 客户端已经安装,我们可以开始安装更新。
使用 Mender 安装更新
现在我们想对根文件系统进行更改,然后将其安装为更新:
-
打开另一个终端,并进入工作构建目录:
$ source poky/oe-init-build-env build-mender-qemu -
复制我们刚刚构建的镜像。这将是我们要更新的实时镜像:
$ cd tmp/deploy/images/vexpress-qemu $ cp core-image-full-cmdline-vexpress-qemu-grub.uefiimg \ core-image-live-vexpress-qemu-grub.uefiimg $ cd -
如果我们不这么做,QEMU 脚本将只加载 BitBake 生成的最新镜像,包括更新内容,这样就达不到演示的目的。
-
接下来,修改目标的主机名,这样安装时会很容易看到。为此,编辑
conf/local.conf并添加这一行:hostname:pn-base-files = "vexpress-qemu-release2" -
现在我们可以像之前一样构建镜像:
$ bitbake core-image-full-cmdline
这次我们不关心 .uefiimg 文件,它包含一个全新的镜像。相反,我们只关心新的根文件系统,它位于 core-image-full-cmdline-vexpress-qemu-grub.mender 中。.mender 文件的格式是 Mender 客户端可以识别的。.mender 文件格式包括版本信息、头部和捆绑在一起的根文件系统镜像,并以压缩 .tar 格式打包。
-
下一步是将新生成的工件部署到目标设备,并在设备上本地启动更新,但从服务器接收更新。通过按 Ctrl + A 然后 x 停止之前终端会话中启动的仿真器。这一步确保 QEMU 启动时使用的是之前的镜像,而不是最新的镜像。要用之前的镜像启动 QEMU:
$ ../meta-mender/meta-mender-qemu/scripts/mender-qemu \ core-image-live -
检查网络配置,QEMU 的 IP 地址为
10.0.2.15,主机的 IP 地址为10.0.2.2:# ping 10.0.2.2 PING 10.0.2.2 (10.0.2.2) 56(84) bytes of data. 64 bytes from 10.0.2.2: icmp_seq=1 ttl=255 time=0.842 ms ^C --- 10.0.2.2 ping statistics --- 1 packets transmitted, 1 received, 0% packet loss, time 0ms rtt min/avg/max/mdev = 0.842/0.842/0.842/0.000 ms -
现在,在另一个终端会话中,启动一个主机上的 Web 服务器,能够提供更新:
$ cd tmp/deploy/images/vexpress-qemu $ python3 -m http.server Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ... -
它正在监听
8000端口。完成 Web 服务器的操作后,按 Ctrl + C 来终止它。 -
回到目标设备,执行此命令以获取更新:
# mender-update --log-level info install \ > http://10.0.2.2:8000/core-image-full-cmdline-vexpress-qemu-grub.mender Installing artifact... 100% <…> Installed, but not committed. Use 'commit' to update, or 'rollback' to roll back the update. At least one payload requested a reboot of the device it updated.
更新已写入第三个分区(/dev/mmcblk0p3),而我们的根文件系统仍然位于第二个分区(/dev/mmcblk0p2)。
-
通过在 QEMU 命令行输入
reboot重启 QEMU。注意,现在根文件系统已挂载在分区 3 上,且主机名已更改:# mount /dev/mmcblk0p3 on / type ext4 (rw,relatime) <…> # hostname vexpress-qemu-release2
成功!
-
还有一件事需要做。我们需要考虑启动循环的问题。使用
grub-mender-grubenv-print查看相关的 U-Boot 变量:# grub-mender-grubenv-print upgrade_available upgrade_available=1 # grub-mender-grubenv-print bootcount bootcount=1
如果系统在重启时没有清除 bootcount,U-Boot 应该检测到并回退到先前的安装版本。
让我们测试 U-Boot 的回退行为:
- 立即重启 QEMU 目标设备。
当目标设备再次启动时,我们看到 U-Boot 已回退到先前的安装版本:
# mount
/dev/mmcblk0p2 on / type ext4 (rw,relatime)
<…>
# hostname
vexpress-qemu
-
现在,让我们重复更新过程:
# mender-update rollback Rolled back. # mender-update --log-level info install \ > http://10.0.2.2:8000/core-image-full-cmdline-vexpress-qemu-grub.mender # reboot -
这次,在重启后,提交更改:
# mender-update commit Committed. # grub-mender-grubenv-print upgrade_available upgrade_available=0 # grub-mender-grubenv-print bootcount bootcount=1
一旦 upgrade_available 被清除,U-Boot 将不再检查 bootcount,因此设备将继续挂载此更新后的根文件系统。当加载进一步的更新时,Mender 客户端将清除 bootcount 并重新设置 upgrade_available。
这个例子使用了来自命令行的 Mender 客户端来本地启动更新。更新本身来自服务器,但也可以通过 USB 闪存驱动器或 SD 卡提供。我们本可以使用提到的其他镜像更新客户端:SWUpdate、RAUC 或 fwup。它们各有优点,但基本的技术方法是相同的。
使用 Mender 进行 OTA 更新
我们将再次使用设备上的 Mender 客户端,但这次以托管模式运行。此外,我们将配置一个服务器来部署更新,以便无需本地交互。Mender 提供了一个开源服务器用于此目的。有关如何设置此演示服务器的文档,请参见 docs.mender.io/2.4/getting-started/on-premise-installation.
安装需要 Docker Engine 版本 19.03 或更高版本,还需要 Docker Compose 版本 1.25 或更高版本。有关每个版本的详细信息,请参见 Docker 官网 docs.docker.com/engine/install/ 和 docs.docker.com/compose/install/。
要验证系统中安装的 Docker 和 Docker Compose 的版本,请使用以下命令:
$ docker --version
Docker version 26.1.3, build 26.1.3-0ubuntu1~24.04.1
$ docker-compose --version
docker-compose version 1.29.2, build unknown
Docker Compose 从 2022 年开始与 Docker 一起捆绑。如果第二个命令失败,请尝试不带短横线调用 Docker Compose:
$ docker compose
Mender 服务器还需要一个名为 jq 的命令行 JSON 解析器:
$ sudo apt install jq
一旦安装了这三者,按照如下步骤安装 Mender 集成环境:
$ git clone -b \
3.7.9 https://github.com/mendersoftware/integration.git integration-3.7.9
$ cd integration-3.7.9
$ ./demo up
Starting the Mender demo environment...
<…>
Creating a new user...
****************************************
Username: mender-demo@example.com
Login password: F26E0B14587A
****************************************
Please keep the password available, it will not be cached by the login script.
Mender demo server ready and running in the background. Copy credentials above and log in at https://localhost
Press Enter to show the logs.
Press Ctrl-C to stop the backend and quit.
当你运行./demo up时,你会看到脚本下载几百兆字节的 Docker 镜像,具体时间取决于你的网络连接速度。过一会儿,你会看到它创建了一个新的演示用户和密码。这意味着服务器已经启动并运行。
现在,Mender 网络界面已在https://localhost/上运行,打开浏览器访问该网址并接受弹出的证书警告。该警告出现是因为 Web 服务使用了一个浏览器无法识别的自签名证书。输入 Mender 服务器生成的用户名和密码登录页面。
现在我们需要对目标的配置进行更改,以便它可以轮询我们本地的服务器以获取更新。为了演示,我们通过在hosts文件中添加一行,将docker.mender.io和s3.docker.mender.io的服务器 URL 映射到10.0.2.2本地主机地址。使用 Yocto 项目进行此更改,请按照以下步骤操作:
-
首先,导航到 Yocto 克隆目录的上一层目录。
-
接下来,创建一个层,文件内容为追加创建
hosts文件的配方,即recipes-core/base-files/base-files_%.bbappend。 -
在
MELD/Chapter10/meta-ota中已经有一个合适的层,你可以复制它:$ cp -a MELD/Chapter10/meta-ota . -
源代码工作构建目录:
$ source poky/oe-init-build-env build-mender-qemu -
添加
meta-ota层:$ bitbake-layers add-layer ../meta-ota
现在你的层结构应该包含八个层,包括meta-oe、meta-mender-core、meta-mender-demo、meta-mender-qemu和meta-ota。
-
使用以下命令构建新的镜像:
$ bitbake core-image-full-cmdline -
然后,制作一份副本。这将是我们这次会话的实时镜像:
$ cd tmp/deploy/images/vexpress-qemu $ cp core-image-full-cmdline-vexpress-qemu-grub.uefiimg \ core-image-live-ota-vexpress-qemu-grub.uefiimg $ cd - -
停止任何你可能已启动的模拟器,方法是在该终端会话中按 Ctrl + A 然后按 x。
-
启动实时镜像:
$ ../meta-mender/meta-mender-qemu/scripts/mender-qemu \ core-image-live-ota -
几秒钟后,你会在 Web 界面的仪表板上看到一个新设备。这发生得非常快,因为 Mender 客户端被配置为每 5 秒轮询一次服务器,以演示系统。生产环境中会使用更长的轮询间隔——推荐使用 30 分钟。
-
查看如何配置轮询间隔,可以通过查看目标上的
/etc/mender/mender.conf文件来了解:# cat /etc/mender/mender.conf { "InventoryPollIntervalSeconds": 5, "RetryPollIntervalSeconds": 30, "ServerURL": "https://docker.mender.io", "TenantToken": "dummy", "UpdatePollIntervalSeconds": 5 }
注意文件中也有服务器 URL。
- 返回 Web UI,点击绿色勾选按钮以授权新设备:
图 10.4 – 接受设备
- 然后,点击设备条目查看详细信息。
再次创建一个更新并进行部署——这次是 OTA:
-
更新
conf/local.conf中的以下行:MENDER_ARTIFACT_NAME = "OTA-update1" -
再次构建镜像:
$ bitbake core-image-full-cmdline
这将在tmp/deploy/images/vexpress-qemu目录下生成一个新的core-image-full-cmdline-vexpress-qemu-grub.mender文件。
-
通过打开发布标签并点击紫色的上传按钮,将其导入到 Web 界面中。
-
浏览
tmp/deploy/images/vexpress-qemu中的core-image-full-cmdline-vexpress-qemu-grub.mender文件并上传它:
图 10.5 – 上传一个工件
Mender 服务器应该将文件复制到服务器数据存储中,并且一个名为OTA-update1的新工件应该出现在发布下。
要将更新部署到我们的 QEMU 设备,请执行以下操作:
-
点击设备标签并选择设备。
-
点击设备信息右下角的为此设备创建部署选项。
-
选择OTA-update1工件并点击创建部署按钮:
图 10.6 – 创建一个部署
部署应该很快从待定转为进行中。
- 点击查看详情按钮。
图 10.7 – 进行中
- 大约 13 分钟后,Mender 客户端应该完成将更新写入备用文件系统镜像的操作。此时,QEMU 将重新启动并提交更新。网页 UI 应该显示完成,此时客户端正在运行OTA-update1。
Mender 很简洁,并被广泛应用于许多商业产品中,但有时我们只希望尽可能快速地将一个软件项目部署到少量流行的开发板上。
提示
在进行几次 Mender 服务器实验后,您可能想要清除状态并重新开始。您可以通过在integration-3.7.9目录下执行这两个命令来实现:
./demo down
./demo up
容器是将软件部署到边缘设备的最快方式。我们将在第十六章中再次讨论容器化的软件更新。
使用 SWUpdate 进行本地更新
与 Mender 类似,SWUpdate 使用对称的 A/B 镜像更新机制,如果更新失败,则会回滚。SWUpdate 可以接收多个 CPIO 格式的镜像更新包,并将这些更新部署到系统的不同部分。它允许您用 Lua 语言编写插件进行自定义处理。Lua 是一种功能强大的脚本语言,容易嵌入到应用程序中。SWUpdate 是一个客户端解决方案,因此与 Mender 不同,它没有相应的企业托管计划需要支付费用。相反,您可以使用像 hawkBit 这样的工具部署自己的 OTA 服务器。
SWUpdate 项目 (github.com/sbabic/swupdate) 由 Stefano Babic 发起并且持续维护,Stefano 是 DENX Software Engineering 的员工,该公司也是 U-Boot 背后的团队。该项目有丰富的文档(sbabic.github.io/swupdate/),从稳健和故障安全更新的动机开始,随后清晰地解释了各种更新策略。
总结
能够在现场设备上更新软件至少是一个有用的特性。如果设备连接到互联网,那么在现场更新软件就绝对是必须的。然而,往往这个特性被推到项目的最后部分,因为人们认为这不是一个难以解决的问题。在这一章中,我希望我已经阐明了设计一个有效且稳健的更新机制所涉及的各种问题。此外,还有多个开源选项可以直接使用。现在,你不必再重新发明轮子了。
最常用的两种方法是对称镜像(A/B)更新或它的变种,非对称(恢复)镜像更新。在这里,你可以选择 SWUpdate、RAUC、Mender 和 fwup。最近的一项创新是以 OSTree 形式出现的原子文件更新。原子文件更新减少了需要下载的数据量以及目标设备上需要安装的冗余存储空间。最后,随着 Docker 的普及,人们开始渴望容器化的软件更新。这就是 balena 所采用的方法。
通过访问每个站点并从 USB 存储器或 SD 卡上应用更新,在小范围内部署更新是很常见的。然而,如果你想部署到远程地点,或进行大规模部署,那么就需要一个 OTA 更新选项。
下一章将描述如何通过设备驱动程序控制系统的硬件组件,既包括作为内核一部分的传统驱动程序,也包括你如何在用户空间中控制硬件的程度。
第十一章:与设备驱动程序接口
内核设备驱动程序是将底层硬件暴露给系统其余部分的机制。作为嵌入式系统开发人员,你需要了解这些设备驱动程序如何融入整体架构,以及如何从用户空间程序访问它们。你的系统可能会有一些新颖的硬件,你需要找出一种访问它们的方法。在许多情况下,你会发现有现成的设备驱动程序可以使用,而你无需编写任何内核代码就能实现你想要的功能。例如,你可以通过sysfs中的文件操作 GPIO 引脚和 LED,并且可以使用库来访问包括SPI(串行外设接口)和I2C(集成电路之间接口)在内的串行总线。
有许多地方可以了解如何编写设备驱动程序,但很少有地方会告诉你为什么要编写设备驱动程序,以及你在编写时的选择。这正是我想在这里讨论的内容。然而,请记住,这不是一本专门讲解编写内核设备驱动程序的书籍,这里提供的信息是帮助你了解相关领域,但不一定能让你在其中扎根。很多优秀的书籍和博客文章可以帮助你编写设备驱动程序,其中一些将在本章末的进一步学习部分列出。
本章将涵盖以下主题:
-
设备驱动程序的作用
-
字符设备
-
块设备
-
网络设备
-
在运行时了解驱动程序
-
寻找合适的设备驱动程序
-
用户空间中的设备驱动程序
-
编写内核设备驱动程序
-
发现硬件配置
技术要求
要跟随示例进行操作,请确保你有以下设备:
-
基于 Linux 的主机系统
-
一张 microSD 卡读卡器和卡
-
BeaglePlay
-
一种能够提供 3A 电流的 5V USB-C 电源
-
一根以太网电缆和一个有可用端口的路由器,用于网络连接
本章使用的代码可以在本书 GitHub 仓库的章节文件夹中找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development/tree/main/Chapter11。
设备驱动程序的作用
正如我在第四章中提到的,内核的一个功能是封装计算机系统的众多硬件接口,并以一致的方式向用户空间程序呈现它们。内核有一些框架,旨在简化设备驱动程序的编写,设备驱动程序是连接上层内核和下层硬件的代码。设备驱动程序可以控制物理设备,例如 UART 或 MMC 控制器,或者它可以代表虚拟设备,如空设备(/dev/null)或 RAM 磁盘。一个驱动程序可能控制多个相同类型的设备。
内核设备驱动代码以较高的特权级别运行,和其余的内核一样。它可以完全访问处理器地址空间和硬件寄存器。它能够处理中断和 DMA 传输。它还可以利用复杂的内核基础设施来进行同步和内存管理。然而,你应该注意,这也有缺点;如果驱动程序有问题,可能会导致系统崩溃。
因此,有一个原则,设备驱动程序应该尽可能简单,仅仅为应用程序提供信息(实际决策由应用程序做出)。你经常会听到这个被表达为内核中没有策略。制定管理系统整体行为的策略是用户空间的责任。例如,响应外部事件(如插入新 USB 设备)来加载内核模块的任务是udev用户空间程序的责任,而不是内核的。内核只是提供了一种加载内核模块的手段。
在 Linux 中,设备驱动程序主要有三种类型:
-
字符设备:这是为无缓冲 I/O 提供的,具有丰富的功能范围,且在应用代码和驱动程序之间有一个薄层。在实现自定义设备驱动时,它是首选。
-
块设备:这是为大容量存储设备的块 I/O 设计的接口。它有一层厚厚的缓冲层,旨在使磁盘读写尽可能快,这使得它不适用于其他用途。
-
网络设备:这类似于块设备,但用于传输和接收网络数据包,而不是磁盘块。
还有第四种类型,它以伪文件系统中的一组文件呈现。例如,你可能通过/sys/class/gpio中的一组文件访问 GPIO 驱动程序,正如我将在本章后面描述的那样。让我们从更详细地了解这三种基本设备类型开始。
字符设备
字符设备在用户空间通过一个叫做设备节点的特殊文件来标识。这个文件名通过与之关联的主次设备号映射到一个设备驱动程序。广义上说,主设备号将设备节点映射到特定的设备驱动,而次设备号则告诉驱动程序访问的是哪个接口。例如,Arm Versatile PB 的第一个串口设备节点命名为/dev/ttyAMA0,主设备号为204,次设备号为64。第二个串口的设备节点主设备号相同,但次设备号是65。我们可以在目录列表中看到所有四个串口的设备号:
# ls -l /dev/ttyAMA*
crw-rw---- 1 root root 204, 64 Jan 1 1970 /dev/ttyAMA0
crw-rw---- 1 root root 204, 65 Jan 1 1970 /dev/ttyAMA1
crw-rw---- 1 root root 204, 66 Jan 1 1970 /dev/ttyAMA2
crw-rw---- 1 root root 204, 67 Jan 1 1970 /dev/ttyAMA3
标准主次编号的列表可以在内核文档 Documentation/admin-guide/devices.txt 中找到。这个列表更新不频繁,且不包括前面提到的 ttyAMA 设备。然而,如果你查看内核源代码中的 drivers/tty/serial/amba-pl011.c,你将看到主次编号的声明位置:
#define SERIAL_AMBA_MAJOR 204
#define SERIAL_AMBA_MINOR 64
如果设备有多个实例,例如 ttyAMA 驱动程序,设备节点的命名约定是使用基准名称(ttyAMA)并附加实例号,范围从 0 到 3。
正如我在 第五章 中提到的,设备节点可以通过几种方式创建:
-
devtmpfs:当设备驱动程序使用驱动程序提供的基准名称(ttyAMA)和实例号注册一个新设备接口时,会创建设备节点。 -
udev或mdev(没有devtmpfs):这些与devtmpfs基本相同,只是用户空间的守护程序需要从sysfs中提取设备名称并创建节点。 -
mknod:如果你使用静态设备节点,它们是通过mknod手动创建的。
你可能从我这里使用的数字中得到这样的印象:主次编号都是 8 位数字,范围从 0 到 255。事实上,主编号是 12 位的,允许有效的主编号范围从 1 到 4095,而次编号是 20 位的,范围从 0 到 1,048,575。
当你打开一个字符设备节点时,内核会检查主次编号是否属于字符设备驱动程序已注册的范围。如果是,内核将调用驱动程序;否则,open(2) 调用会失败。设备驱动程序可以提取次编号以确定使用哪个硬件接口。
要编写一个访问设备驱动程序的程序,你需要了解它的工作原理。换句话说,设备驱动程序不同于文件:你对它所做的操作会改变设备的状态。一个简单的例子是 urandom 伪随机数生成器,每次读取时都会返回一组随机数据字节。
这是一个实现此功能的程序:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(void)
{
int f;
unsigned int rnd;
int n;
f = open("/dev/urandom", O_RDONLY);
if (f < 0) {
perror("Failed to open urandom");
return 1;
}
n = read(f, &rnd, sizeof(rnd));
if (n != sizeof(rnd)) {
perror("Problem reading urandom");
return 1;
}
printf("Random number = 0x%x\n", rnd);
close(f);
return 0;
}
你可以在 MELD/Chapter11/meta-device-drivers/recipes-local/read-urandom 目录下找到该程序的完整源代码和 BitBake 配方。
Unix 驱动程序模型的优点是,一旦我们知道有一个名为 urandom 的设备,每次从中读取时,它都会返回一组新的伪随机数据,因此我们无需了解关于它的其他信息。我们可以直接使用标准函数,如 open(2)、read(2) 和 close(2)。
提示
你也可以使用流 I/O 函数,如 fopen(3)、fread(3) 和 fclose(3),但这些函数中隐含的缓冲机制往往会导致意外的行为。例如,fwrite(3) 通常只写入用户空间缓冲区,而不是设备。你需要调用 fflush(3) 强制将缓冲区写出。因此,最好不要在调用设备驱动程序时使用流 I/O 函数。
大多数设备驱动程序使用字符接口。大容量存储设备是一个显著的例外。读取和写入磁盘需要使用块接口以获得最大速度。
块设备
块设备还与一个设备节点相关联,该节点也具有主设备号和次设备号。
提示
尽管字符设备和块设备是通过主设备号和次设备号来标识的,但它们位于不同的命名空间中。主设备号为 4 的字符驱动程序与主设备号为 4 的块驱动程序没有任何关系。
对于块设备,主设备号用于标识设备驱动程序,次设备号用于标识分区。让我们来看一下 BeaglePlay 上的 MMC 驱动程序:
# ls -l /dev/mmcblk*
brw-rw---- 1 root disk 179, 0 Aug 7 13:25 /dev/mmcblk0
brw-rw---- 1 root disk 179, 256 Aug 7 13:25 /dev/mmcblk0boot0
brw-rw---- 1 root disk 179, 512 Aug 7 13:25 /dev/mmcblk0boot1
brw-rw---- 1 root disk 179, 1 Aug 7 13:25 /dev/mmcblk0p1
brw-rw---- 1 root disk 179, 2 Aug 7 13:25 /dev/mmcblk0p2
brw-rw---- 1 root disk 236, 0 Aug 7 13:25 /dev/mmcblk0rpmb
brw-rw---- 1 root disk 179, 768 Feb 4 09:42 /dev/mmcblk1
brw-rw---- 1 root disk 179, 769 Feb 4 09:42 /dev/mmcblk1p1
brw-rw---- 1 root disk 179, 770 Feb 4 09:42 /dev/mmcblk1p2
在这里,mmcblk0 是 eMMC 芯片,它有两个分区,而 mmcblk1 是 microSD 卡槽,卡槽上也有一张带有两个分区的卡。MMC 块设备驱动的主设备号是 179(你可以在 devices.txt 文件中查找)。次设备号用于标识不同的物理 MMC 设备以及该设备上存储介质的分区。在 MMC 驱动程序的情况下,每个设备的次设备号范围为八个:0 到 7 的次设备号用于第一个设备,8 到 15 用于第二个设备,以此类推。在每个范围内,第一个次设备号表示整个设备的原始扇区,其他次设备号表示最多七个分区。在 BeaglePlay 的 eMMC 芯片上,有两个 4 MB 的内存区域被保留用于引导加载程序。这两个区域分别表示为 mmcblk0boot0 和 mmcblk0boot1,其次设备号分别为 256 和 512。
另一个例子是,你可能熟悉名为 sd 的 SCSI 磁盘驱动程序,它用于控制使用 SCSI 命令集的一系列磁盘,包括 SCSI、SATA、USB 大容量存储和 通用闪存存储 (UFS) 。它的主设备号是 8,每个接口或磁盘有 16 个次设备号范围。
从 0 到 15 的次设备号用于第一个接口,设备节点名称为 sda 到 sda15;从 16 到 31 的次设备号用于第二个磁盘,设备节点名称为 sdb 到 sdb15;以此类推。这一过程一直持续到第 16 个磁盘,从 240 到 255,设备节点名称为 sdp。由于 SCSI 磁盘非常流行,因此为它们保留了其他主设备号,但在这里我们不需要担心这个。
MMC 和 SCSI 块驱动程序都期望在磁盘的开始处找到分区表。分区表可以通过 fdisk、sfidsk 和 parted 等工具创建。
用户空间程序可以通过设备节点直接打开和与块设备交互。尽管如此,这并不是一个常见的操作,通常只用于执行管理操作,例如创建分区、使用文件系统格式化分区和挂载。一旦文件系统被挂载,您通过该文件系统中的文件间接与块设备交互。
大多数块设备都将有一个有效的内核驱动程序,因此我们很少需要编写自己的驱动程序。网络设备也是如此。就像文件系统抽象了块设备的细节一样,网络堆栈消除了直接与网络设备交互的需求。
网络设备
网络设备不通过设备节点访问,也没有主要和次要编号。相反,内核基于字符串和实例号为网络设备分配名称。以下是网络驱动程序注册接口的示例方式:
my_netdev = alloc_netdev(0, "net%d", NET_NAME_UNKNOWN, netdev_setup);
ret = register_netdev(my_netdev);
这将在第一次调用时创建名为net0的网络设备,第二次调用时创建net1,依此类推。更常见的名称包括lo、eth0、enp2s0、wlan0和wlp1s0。请注意,这是它启动时的名称;设备管理器如udev可能会稍后将其更改为其他名称。
通常,网络接口名称仅在使用诸如ip等实用程序配置网络地址和路由时使用。之后,您通过打开套接字间接与网络驱动程序交互,让网络层决定如何将其路由到正确的接口。
然而,通过创建套接字并使用include/linux/sockios.h中列出的ioctl命令,可以从用户空间直接访问网络设备。以下是使用SIOCGIFHWADDR查询网络驱动程序硬件(MAC)地址的程序示例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/sockios.h>
#include <net/if.h>
int main(int argc, char *argv[])
{
int s;
int ret;
struct ifreq ifr;
if (argc != 2) {
printf("Usage %s [network interface]\n", argv[0]);
return 1;
}
s = socket(PF_INET, SOCK_DGRAM, 0);
if (s < 0) {
perror("socket");
return 1;
}
strcpy(ifr.ifr_name, argv[1]);
ret = ioctl(s, SIOCGIFHWADDR, &ifr);
if (ret < 0) {
perror("ioctl");
return 1;
}
for (int i = 0; i < 6; i++) {
printf("%02x:", (unsigned char)ifr.ifr_hwaddr.sa_data[i]);
}
printf("\n");
close(s);
return 0;
}
您可以通过读取MELD/Chapter11/meta-device-drivers/recipes-local/show-mac-address目录中的完整源代码和 BitBake 配方来查找此程序的完整源代码和 BitBake 配方。show-mac-address程序将网络接口名称作为参数。打开套接字后,我们将接口名称复制到结构体中,并将该结构体传递到套接字上的ioctl调用中,然后打印出结果的 MAC 地址。
现在我们知道设备驱动程序的三个类别,如何列出系统中使用的不同驱动程序呢?
在运行时查找驱动程序
一旦您有一个运行中的 Linux 系统,了解已加载的设备驱动程序及其状态非常有用。通过阅读/proc和/sys中的文件,您可以获取很多信息。
通过读取/proc/devices列出当前加载和活动的字符和块设备驱动程序:
$ cat /proc/devices
Character devices:
1 mem
4 /dev/vc/0
4 tty
4 ttyS
5 /dev/tty
5 /dev/console
<…>
对于每个驱动程序,你可以看到主设备号和基本名称。然而,这并不能告诉你每个驱动程序连接了多少设备。它只显示了ttyAMA,但并未给出它是否连接了四个实际的串口。我会在后面讨论sysfs时再回到这个问题。
网络设备不出现在这个列表中,因为它们没有设备节点。相反,你可以使用ip工具获取网络设备的列表:
# ip link show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq qlen 1000
link/ether 34:08:e1:85:07:d9 brd ff:ff:ff:ff:ff:ff
3: eth1: <BROADCAST,MULTICAST> mtu 1500 qdisc noop qlen 1000
link/ether 7a:1f:d8:46:36:b1 brd ff:ff:ff:ff:ff:ff
你还可以通过著名的lsusb和lspci命令分别查看连接到 USB 或 PCI 总线的设备。关于它们的信息可以在各自的手册页中找到,也有大量在线指南,因此我在这里就不再详细描述了。
真正有趣的信息在sysfs中,这也是我们接下来要讨论的话题。
从 sysfs 获取信息
你可以从细致的角度定义sysfs为内核对象、属性和关系的表示。一个内核对象是一个目录,一个属性是一个文件,而一个关系是一个从一个对象到另一个对象的符号链接。从更实用的角度来看,由于 Linux 设备驱动模型将所有设备和驱动程序表示为内核对象,你可以通过查看/sys来看到内核对系统的视图:
# ls /sys
block class devices fs module
bus dev firmware kernel power
在探索设备和驱动程序信息的过程中,我将关注这三个目录:devices、class和block。
设备 – /sys/devices
这是内核在启动后发现的设备以及它们如何相互连接的视图。它按系统总线在顶层进行组织,因此你看到的内容会因系统而异。以下是 QEMU 仿真中的 Arm Versatile 板的示例:
# ls /sys/devices
platform software system tracepoint virtual
所有系统中都有三个目录:
-
system/:包含系统核心的设备,包括 CPU 和时钟。 -
virtual/:包含基于内存的设备。你会在virtual/mem中找到像/dev/null、/dev/random和/dev/zero这样的内存设备。你还会在virtual/net中找到lo回环设备。 -
platform/:这是一个包含所有未通过常规硬件总线连接的设备的目录。在嵌入式设备上,几乎所有的东西都可能属于这个目录。
其他设备出现在与实际系统总线相对应的目录中。例如,如果存在 PCI 根总线,它会显示为pci0000:00。
浏览这个层次结构相当困难,因为它需要一些对系统拓扑的了解,并且路径名变得相当长且难以记住。为了简化操作,/sys/class和/sys/block提供了两种不同的设备视图。
驱动程序 – /sys/class
这是按照设备驱动类型展示的视图。换句话说,这是一个软件视图,而非硬件视图。每个子目录代表一种驱动类,并由驱动框架中的一个组件实现。例如,UART 设备由 tty 层管理,因此你会在 /sys/class/tty 目录下找到它们。同样,你可以在 /sys/class/net 找到网络设备,在 /sys/class/input 找到输入设备,如键盘、触摸屏和鼠标,等等。
每个子目录中都有一个符号链接,指向该类型设备在 /sys/device 中的表示。
让我们来看一下 Versatile PB 上的串行端口。我们可以看到它们共有四个:
# ls -d /sys/class/tty/ttyAMA*
/sys/class/tty/ttyAMA0 /sys/class/tty/ttyAMA2
/sys/class/tty/ttyAMA1 /sys/class/tty/ttyAMA3
每个目录都是与设备接口实例关联的内核对象的表示。查看这些目录中的某一个,我们可以看到该对象的属性(以文件形式表示)以及与其他对象的关系(通过链接表示):
# ls /sys/class/tty/ttyAMA0
close_delay flags line uartclk
closing_wait io_type port uevent
custom_divisor iomem_base power xmit_fifo_size
dev iomem_reg_shift subsystem
device irq type
名为 device 的链接指向该设备的硬件对象。名为 subsystem 的链接指向 /sys/class/tty 中的父子系统。其余的目录项是属性。某些属性特定于串行端口,如 xmit_fifo_size,而其他一些则适用于多种类型的设备,例如 irq(中断号)和 dev(设备号)。有些属性文件是可写的,允许你在运行时调整驱动程序的参数。
dev 属性尤其有趣。如果你查看它的值,你会发现以下内容:
# cat /sys/class/tty/ttyAMA0/dev
204:64
这些是设备的主次号。这个属性是在驱动程序注册接口时创建的。正是通过这个文件,udev 和 mdev 才能找到设备驱动的主次号。
块设备驱动 – /sys/block
还有一个关于设备模型的重要视图:你可以在 /sys/block 中找到的块设备驱动视图。这里有每个块设备的子目录。以下是来自 BeaglePlay 的一部分:
# ls /sys/block
loop0 loop4 mmcblk0 ram0 ram12 ram2 ram6
loop1 loop5 mmcblk1 ram1 ram13 ram3 ram7
loop2 loop6 mmcblk0boot0 ram10 ram14 ram4 ram8
loop3 loop7 mmcblk0boot1 ram11 ram15 ram5 ram9
如果你查看 mmcblk0 目录,这是该开发板上的 eMMC 芯片,你将看到接口的属性和其中的分区信息:
# ls /sys/block/mmcblk0
alignment_offset events holders mmcblk0p2 ro
bdi events_async inflight mq size
capability events_poll_msecs integrity power slaves
dev ext_range mmcblk0boot0 queue stat
device force_ro mmcblk0boot1 range subsystem
discard_alignment hidden mmcblk0p1 removable uevent
总结来说,你可以通过阅读 sysfs 来了解很多关于系统中设备(硬件)和驱动程序(软件)的信息。
寻找合适的设备驱动
一个典型的嵌入式开发板通常基于制造商提供的参考设计,并做出一些更改以使其适用于特定的应用。随参考板一起提供的 BSP 应该支持该板上的所有外设。然后你可以定制设计,可能是通过 I2C 连接的温度传感器、通过 GPIO 引脚连接的灯光和按钮、通过 MIPI 接口连接的显示面板,或者其他许多东西。你的任务是创建一个定制的内核来控制所有这些外设,但你该从哪里开始寻找支持这些外设的设备驱动呢?
最明显的查询途径是查看制造商网站上的驱动支持页面,或者直接向他们询问。根据我的经验,这种方式很少能得到你想要的结果。硬件制造商通常对 Linux 不太熟悉,他们往往会提供误导性的信息。可能他们提供的是二进制格式的专有驱动,或者是与你当前内核版本不匹配的源代码。因此,尽管如此,你可以尝试这种方式。就我个人而言,我总是会尽量寻找适合当前任务的开源驱动程序。
你的内核中可能已经有支持:主线 Linux 中有成千上万的驱动程序,厂商内核中也有许多厂商特定的驱动程序。首先运行make menuconfig(或xconfig),并搜索产品名称或编号。如果没有找到完全匹配的项,尝试进行更通用的搜索,考虑到大多数驱动程序支持同一家族的多个产品。接下来,尝试在drivers目录中查找代码(grep是你的好帮手)。
如果你仍然没有找到驱动程序,可以尝试在线搜索并在相关论坛中询问,看是否有适用于较新版本 Linux 的驱动程序。如果找到了,应该认真考虑更新 BSP,以使用更新的内核。有时,这样做并不实际,因此你可能需要考虑将驱动程序回移植到当前内核。如果内核版本相似,移植可能比较简单,但如果版本相差超过 12 到 18 个月,那么代码变化可能非常大,你可能需要重写驱动程序的一部分来将其与当前内核集成。如果这些方法都失败了,你将不得不自己编写缺失的内核驱动程序。然而,这并不总是必要的。我们将在下一节中探讨一种替代方法。
用户空间中的设备驱动程序
在开始编写设备驱动程序之前,先停下来思考一下它是否真的必要。对于许多常见类型的设备,已经有通用的设备驱动程序,允许你直接在用户空间与硬件交互,而无需编写一行内核代码。用户空间代码当然更容易编写和调试。而且它不受 GPL 的约束,尽管我认为这并不是采取这种方式的好理由。
这些驱动程序大致分为两类:一类是通过sysfs中的文件进行控制的驱动,包括 GPIO 和 LED,另一类是通过设备节点暴露通用接口的串行总线,如 I2C。
让我们为 BeaglePlay 构建一个包含一些示例的 Yocto 镜像:
-
导航到你克隆 Yocto 的目录的上一层:
$ cd ~ -
从本书的 Git 仓库中复制 meta-device-driver 层:
$ cp -a MELD/Chapter11/meta-device-drivers . -
为 BeaglePlay 设置你的 BitBake 工作环境:
$ source poky/oe-init-build-env build-beagleplay -
这将设置一系列环境变量,并将你带回到
build-beagleplay目录,这是你在第六章的层部分中填充的目录。如果你已经删除了之前的工作,可以重复该练习,添加自己的meta-nova层,并为 BeaglePlay 构建core-image-minimal。 -
移除
meta-nova层:$ bitbake-layers remove-layer ../meta-nova -
添加
meta-device-drivers层:$ bitbake-layers add-layer ../meta-device-drivers -
确认你的层结构是否设置正确:
$ bitbake-layers show-layers NOTE: Starting bitbake server... layer path priority ========================================================================= core /home/frank/poky/meta 5 yocto /home/frank/poky/meta-poky 5 yoctobsp /home/frank/poky/meta-yocto-bsp 5 arm-toolchain /home/frank/meta-arm/meta-arm-toolchain 5 meta-arm /home/frank/meta-arm/meta-arm 5 meta-ti-bsp /home/frank/meta-ti/meta-ti-bsp 6 device-drivers /home/frank/meta-device-drivers 6 -
修改
conf/local.conf,使示例程序和虚拟驱动程序被安装:IMAGE_INSTALL:append = " read-urandom show-mac-address gpio-int i2c-eeprom-read dummy-driver" -
在内核中启用传统的
/sys/class/gpio接口:$ bitbake -c menuconfig virtual/kernel -
确保启用了
CONFIG_EXPERT、CONFIG_GPIO_SYSFS、CONFIG_DEBUG_FS和CONFIG_DEBUG_FS_ALLOW_ALL选项。确保禁用了CONFIG_KEYBOARD_GPIO选项。 -
在内核中启用
/sys/class/leds接口,确保启用了CONFIG_LEDS_CLASS、CONFIG_LEDS_GPIO和CONFIG_LEDS_TRIGGER_TIMER选项。 -
保存修改后的内核
.config并退出menuconfig。 -
构建
core-image-minimal:$ bitbake core-image-minimal
使用 balenaEtcher 将完成的镜像写入 microSD 卡,将 microSD 插入 BeaglePlay 并启动,如第六章中的运行 BeaglePlay 目标部分所述。
GPIO
通用输入/输出(GPIO)是最简单的数字接口形式,因为它为你提供对单个硬件引脚的直接访问,每个引脚可以处于两种状态之一:高或低。在大多数情况下,你可以将 GPIO 引脚配置为输入或输出。你甚至可以使用一组 GPIO 引脚,通过软件操作每个比特,创建更高级别的接口,如 I2C 或 SPI,这种技术称为位接入。主要的限制是软件循环的速度和准确性,以及你愿意为此分配的 CPU 周期数。通常,除非你配置实时内核,否则很难达到毫秒级别以下的定时器准确度,正如我们将在第二十一章中看到的那样。GPIO 的更常见用例是读取按键和数字传感器,以及控制 LED、马达和继电器。
大多数 SoC 将大量 GPIO 位组装在一起,通常每个寄存器 32 位。在芯片上的 GPIO 位通过一个称为引脚复用(pin mux)的多路复用器路由到芯片封装上的 GPIO 引脚。通过 I2C 或 SPI 总线连接的电源管理芯片和专用 GPIO 扩展器上,可能还会有额外的 GPIO 引脚。所有这些多样性都由一个内核子系统gpiolib处理,gpiolib实际上不是一个库,而是 GPIO 驱动程序用来以一致方式公开 I/O 的基础设施。有关gpiolib实现的详细信息,可以在内核源代码的Documentation/driver-api/gpio/中找到,驱动程序本身的代码位于drivers/gpio/。
应用程序可以通过 /sys/class/gpio/ 目录中的文件与 gpiolib 进行交互。以下是在像 BeaglePlay 这样的典型嵌入式板上看到的内容:
# ls /sys/class/gpio
export gpiochip512 gpiochip515 gpiochip539 gpiochip631 unexport
从 gpiochip512 到 gpiochip631 命名的目录表示四个 GPIO 寄存器,每个寄存器有一个可变数量的 GPIO 位。如果你查看其中一个 gpiochip 目录,你将看到以下内容:
# ls /sys/class/gpio/gpiochip512
base device label ngpio power subsystem uevent
名为 base 的文件包含寄存器中第一个 GPIO 引脚的编号,而 ngpio 包含寄存器中的位数。在这个例子中,gpiochip512/base 是 512,gpiochip512/ngpio 是 3,这告诉你它包含 GPIO 位 512 到 514。一个寄存器中的最后一个 GPIO 和下一个寄存器中的第一个 GPIO 之间可能会有间隙。
要从用户空间控制 GPIO 位,首先需要从内核空间导出它,你可以通过将 GPIO 编号写入 /sys/class/gpio/export 来实现。这个示例展示了 GPIO 640 的过程,它与 BeaglePlay 上的 mikroBUS 连接器的 INT 引脚相连:
# echo 640 > /sys/class/gpio/export
# ls /sys/class/gpio
export gpiochip512 gpiochip539 unexport
gpio640 gpiochip515 gpiochip631
现在,有一个新的 gpio640 目录,其中包含你需要控制引脚的文件。
重要提示
如果 GPIO 位已被内核占用,你将无法以这种方式导出它:
# echo 640 > /sys/class/gpio/export
bash: echo: write error: Device or resource busy
gpio640 目录包含以下文件:
# ls /sys/class/gpio/gpio640
active_low direction power uevent
device edge subsystem value
引脚开始时是一个有效的输入,用于 mikroBUS 连接器的 INT(中断)引脚。要将 GPIO 转换为输出,请将 out 写入 direction 文件。value 文件包含引脚的当前状态,0 表示低电平,1 表示高电平。如果它是输出,你可以通过写入 0 或 1 来改变状态。有时硬件中的低电平和高电平意义会被反转(硬件工程师喜欢做这种事情),因此写入 1 到 active_low 会反转 value 的意义,使低电压显示为 1,高电压显示为 0。
相反,你可以通过将 GPIO 编号写入 /sys/class/gpio/unexport 来从用户空间移除 GPIO 控制,就像你为导出所做的那样。
处理来自 GPIO 的中断
在许多情况下,GPIO 输入可以配置为在状态变化时生成中断。这使你可以等待中断,而不是在低效的软件循环中轮询。如果 GPIO 位能够生成中断,则会存在一个名为 edge 的文件。它的初始值为 none,表示不生成中断。要启用中断,你可以将其设置为以下值之一:
-
rising:在上升沿触发中断。 -
falling:在下降沿触发中断。 -
both:在上升沿和下降沿触发中断。 -
none:没有中断(默认)。
要确定 BeaglePlay 上的 USR 按钮分配到哪个 GPIO:
# cat /sys/kernel/debug/gpio | grep USR_BUTTON
gpio-557 (USR_BUTTON |sysfs ) in hi IRQ
如果你想等待 GPIO 557(USR 按钮)上的下降沿,你必须首先启用中断:
# echo 557 > /sys/class/gpio/export
# echo falling > /sys/class/gpio/gpio557/edge
这是一个等待来自 GPIO 的中断的程序:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/types.h>
int main(int argc, char *argv[])
{
int ep;
int f;
struct epoll_event ev, events;
char value[4];
int ret;
int n;
ep = epoll_create(1);
if (ep == -1) {
perror("Can't create epoll");
return 1;
}
f = open("/sys/class/gpio/gpio557/value", O_RDONLY | O_NONBLOCK);
if (f == -1) {
perror("Can't open gpio557");
return 1;
}
n = read(f, &value, sizeof(value));
if (n > 0) {
printf("Initial value value=%c\n", value[0]);
lseek(f, 0, SEEK_SET);
}
ev.events = EPOLLPRI;
ev.data.fd = f;
ret = epoll_ctl(ep, EPOLL_CTL_ADD, f, &ev);
while (1) {
printf("Waiting\n");
ret = epoll_wait(ep, &events, 1, -1);
if (ret > 0) {
n = read(f, &value, sizeof(value));
printf("Button pressed: value=%c\n", value[0]);
lseek(f, 0, SEEK_SET);
}
}
return 0;
}
下面是gpio-int程序的工作原理。首先,调用epoll_create创建epoll通知功能。接下来,打开 GPIO 并读取其初始值。调用epoll_ctl将 GPIO 的文件描述符注册为POLLPRI事件。最后,使用epoll_wait函数等待中断。当你按下 BeaglePlay 上的 USR 按钮时,程序将打印出Button pressed:,后跟从 GPIO 读取的字节数和值。
虽然我们本可以使用select和poll来处理中断,但与其他两个系统调用不同,epoll的性能在监视的文件描述符数量增加时不会迅速下降。
这个程序的完整源代码,以及 BitBake 配方和 GPIO 配置脚本,可以在MELD/Chapter11/meta-device-drivers/recipes-local/gpio-int目录下找到。
与 GPIO 一样,LED 通过sysfs访问。然而,它的接口有明显不同。
LED 灯
LED 通常通过 GPIO 引脚进行控制,但还有一个内核子系统提供了更专门的控制,特别是为此目的。leds内核子系统增加了设置亮度的功能(如果 LED 具备此能力),并且能够处理以其他方式连接的 LED,而不仅仅是简单的 GPIO 引脚。它可以配置为在事件发生时触发 LED 点亮,例如块设备访问或心跳,用以显示设备正在工作。你需要在内核中启用CONFIG_LEDS_CLASS选项,并配置适合你的 LED 触发动作。更多信息请参阅Documentation/leds/,驱动程序位于drivers/leds/。
与 GPIO 一样,LED 通过/sys/class/leds/目录下的sysfs接口进行控制。在 BeaglePlay 中,用户 LED 的名称以:function的形式编码在设备树中,如下所示:
# ls /sys/class/leds
:cpu :heartbeat :wlan mmc1::
:disk-activity :lan mmc0:: mmc2::
现在,我们可以查看其中一个 LED 的属性:
# cd /sys/class/leds/\:heartbeat
# ls
brightness invert power trigger
device max_brightness subsystem uevent
请注意,路径中的冒号需要通过反斜杠进行转义,这是 shell 的要求。
brightness文件控制 LED 的亮度,值可以在0(关闭)和max_brightness(完全开启)之间。如果 LED 不支持中间亮度,则任何非零值都将其点亮。名为trigger的文件列出了触发 LED 点亮的事件。触发器的列表依赖于实现。以下是一个示例:
# cat trigger
none kbd-scrolllock kbd-numlock <…> disk-write [heartbeat] cpu <…>
当前选择的触发器显示在方括号中。你可以通过将其他触发器写入文件来更改它。如果你想完全通过亮度控制 LED,请选择none。如果设置触发器为timer,将会出现两个额外的文件,允许你设置开关时间,单位为毫秒:
# echo timer > trigger
# ls
brightness delay_on max_brightness subsystem uevent
delay_off device power trigger
# cat delay_on
500
# cat delay_off
500
如果 LED 具有芯片内定时器硬件,闪烁操作将在不打断 CPU 的情况下进行。
I2C
I2C 是一种简单的低速两线总线,常见于嵌入式板上。它通常用于访问不在 SoC 上的外设,如显示控制器、摄像头传感器、GPIO 扩展器等。还有一个相关标准叫做 系统管理总线(SMBus),它通常用于 PC 上访问温度和电压传感器。SMBus 是 I2C 的一个子集。
I2C 是一种主从协议,主设备是 SoC 上的一个或多个主控制器。每个从设备有一个由制造商分配的 7 位地址(请参考数据手册),允许每条总线最多有 128 个节点,但其中 16 个已被保留,因此实际允许最多 112 个节点。主设备可以与其中一个从设备发起读写事务。通常,第一个字节用于指定从设备上的寄存器,而剩余的字节是从该寄存器读取或写入的数据。
每个主控制器有一个设备节点。该 SoC 有五个设备节点:
# ls -l /dev/i2c*
crw-rw---- 1 root gpio 89, 0 Aug 7 13:25 /dev/i2c-0
crw-rw---- 1 root gpio 89, 1 Aug 7 13:25 /dev/i2c-1
crw-rw---- 1 root gpio 89, 2 Aug 7 13:25 /dev/i2c-2
crw-rw---- 1 root gpio 89, 3 Aug 7 13:25 /dev/i2c-3
crw-rw---- 1 root gpio 89, 5 Aug 7 13:25 /dev/i2c-5
设备接口提供了一系列 ioctl 命令,用于查询主控制器并将读写命令发送到 I2C 从设备。有一个名为 i2c-tools 的软件包,使用该接口提供基本的命令行工具与 I2C 设备进行交互。以下是这些工具:
-
i2cdetect:列出 I2C 适配器并探测总线。 -
i2cdump:从 I2C 外设的所有寄存器中转储数据。 -
i2cget:从 I2C 从设备读取数据。 -
i2cset:向 I2C 从设备写入数据。
i2c-tools 包在 Buildroot 和 Yocto 项目中都有提供,也在大多数主流发行版中可用。编写一个用户空间程序与设备通信是直接的,只要你知道从设备的地址和协议。以下示例展示了如何从 FT24C32A-ELR-T EEPROM 中读取前四个字节,该 EEPROM 被安装在 BeaglePlay 上的 I2C 总线 0。该 EEPROM 的从设备地址是 0x50。
以下是一个程序代码,它从 I2C 地址读取前四个字节:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <linux/i2c-dev.h>
#define I2C_ADDRESS 0x50
int main(void)
{
int f;
int n;
char buf[10];
/* Open the adapter and set the address of the I2C device */
f = open("/dev/i2c-0", O_RDWR);
/* Set the address of the i2c slave device */
ioctl(f, I2C_SLAVE, I2C_ADDRESS);
/* Set the 16-bit address to read from to 0 */
buf[0] = 0; /* address byte 1 */
buf[1] = 0; /* address byte 2 */
n = write(f, buf, 2);
/* Now read 4 bytes from that address */
n = read(f, buf, 4);
printf("0x%x 0x%x 0x%x 0x%x\n", buf[0], buf[1], buf[2], buf[3]);
close(f);
return 0;
}
这个 i2c-eeprom-read 程序在 BeaglePlay 上执行时会打印 0xaa 0x55 0x33 0x33。这四个字节的序列是 EEPROM 的魔术数字。该程序的完整源代码和 BitBake 配方可以在 MELD/Chapter11/meta-device-drivers/recipes-local/i2c-eeprom-read 目录下找到。
请注意,I2C 总线另一端的设备可能是小端(little-endian)或大端(big-endian)。小端和大端指的是数据字内字节的顺序。一个 32 位的数据字包含四个字节。小端表示最低有效字节位于索引 0,最高有效字节位于索引 3。相反,大端表示最高有效字节位于索引 0,最低有效字节位于索引 3。大端也被称为 网络字节序,对应于网络协议中字节通过网络传输的顺序。
此程序类似于i2cget,但要读取的地址和寄存器字节都是硬编码的,而不是作为参数传递。我们可以使用i2cdetect来发现 I2C 总线上任何外围设备的地址。使用i2cdetect可能会使 I2C 外围设备处于不良状态或锁定总线,因此使用后最好重启。外围设备的数据手册告诉我们寄存器映射的内容。有了这些信息,我们可以使用i2cset通过 I2C 写入其寄存器。这些 I2C 命令可以轻松地转换为用于与外围设备交互的 C 函数库。
重要提示
有关 Linux 实现的更多 I2C 信息,请参阅Documentation/i2c/dev-interface.rst。主控制器驱动程序位于drivers/i2c/busses/中。
另一种流行的通信协议是SPI,它使用 4 线总线。
SPI
SPI 总线类似于 I2C,但速度更快,可高达数十 MHz。该接口使用四根线分别发送和接收线,允许全双工操作。总线上的每个芯片都通过专用的芯片选择线选中。它通常用于连接触摸屏传感器、显示控制器和串行 NOR 闪存设备。
与 I2C 类似,SPI 也是一种主从协议,大多数 SoC 都实现了一个或多个主机控制器。可以通过CONFIG_SPI_SPIDEV内核配置启用通用 SPI 设备驱动程序。这会为每个 SPI 控制器创建一个设备节点,允许您从用户空间访问 SPI 芯片。设备节点命名为spidev<bus>.<chip select>:
# ls -l /dev/spi*
crw-rw---- 1 root root 153, 0 Jan 1 00:29 /dev/spidev1.0
有关使用spidev接口的示例,请参阅Documentation/spi/中的示例代码。
到目前为止,我们看到的设备驱动程序都在 Linux 内核中得到了长期的上游支持。因为所有这些设备驱动程序都是通用的(GPIO、LED、I2C 和 SPI),所以从用户空间访问它们很简单。在某些情况下,您可能会遇到一个硬件设备缺乏兼容的内核设备驱动程序。这种硬件可能是您产品的核心(如 LiDAR、SDR 等)。在 SoC 和该硬件之间可能还有一个 FPGA。在这种情况下,您可能别无选择,只能编写自己的内核模块。
编写内核设备驱动程序
最终,当你耗尽所有先前的用户空间选项时,你会发现自己不得不编写一个设备驱动程序来访问附加到设备的硬件。字符驱动是最灵活的,应该能满足你 90%的需求;如果你正在处理网络接口,则使用网络驱动;块设备驱动则用于大容量存储。编写内核驱动的任务非常复杂,超出了本书的范围。书末有一些参考资料,能帮助你继续前进。在这一部分,我将概述与驱动程序交互的选项——这是一个通常不被涵盖的话题——并向你展示字符设备驱动的基本骨架。
设计字符驱动接口
主要的字符驱动接口基于一个字节流,就像你在串行端口上看到的那样。然而,许多设备并不符合这个描述:例如,机器人臂的控制器需要能够移动和旋转每个关节的功能。幸运的是,除了 read 和 write 之外,还有其他方式与设备驱动程序进行通信:
-
ioctl:ioctl函数允许你向驱动程序传递两个参数。这些参数可以有任何你想要的意义。根据惯例,第一个参数是一个命令,用来选择驱动程序中的某个功能,而第二个参数是指向一个结构体的指针,这个结构体用作输入和输出参数的容器。这就像一张空白画布,允许你设计任何你喜欢的程序接口。当驱动和应用程序紧密关联并由同一团队编写时,这种做法非常常见。然而,ioctl在内核中已经被弃用,你会发现很难让任何新使用ioctl的驱动被上游接受。内核维护者不喜欢ioctl,因为它让内核代码和应用代码过于依赖,且很难确保两者在不同的内核版本和架构间保持同步。 -
sysfs:这是当前首选的方法,一个很好的例子就是之前提到的 LED 接口。它的优势在于,文件命名只要具有描述性,便可以部分自我文档化。它还可以脚本化,因为文件的内容通常是文本字符串。另一方面,每个文件必须包含一个单一值的要求,使得如果你需要一次性更改多个值时,很难实现原子性。相对地,ioctl通过一个函数调用将所有参数打包在一个结构体中传递。 -
mmap:你可以通过将内核内存映射到用户空间,直接访问内核缓冲区和硬件寄存器,从而绕过内核。你可能仍然需要一些内核代码来处理中断和 DMA。有一个封装了这一思想的子系统,称为uio,即用户 I/O。更多的文档可以在Documentation/driver-api/uio-howto.rst中找到,示例驱动程序则在drivers/uio/目录下。 -
sigio:您可以使用名为kill_fasync()的内核函数从驱动程序发送信号,以通知应用程序某个事件,如输入准备就绪或接收到中断。按照惯例,使用名为SIGIO的信号,但也可以是任何信号。您可以在drivers/uio/uio.c和drivers/char/rtc.c中看到一些示例。主要问题是,在用户空间编写可靠的信号处理程序很困难,因此它仍然是一个不常使用的功能。 -
debugfs:这是另一个伪文件系统,将内核数据表示为文件和目录,类似于proc和sysfs。主要区别在于,debugfs不得包含系统正常运行所需的信息;它仅用于调试和追踪信息。它通过mount -t debugfs debug /sys/kernel/debug进行挂载。在Documentation/filesystems/debugfs.rst中有关于debugfs的详细描述。 -
proc:proc文件系统对于所有新代码已被弃用,除非它与进程有关,这是该文件系统最初的用途。然而,您可以使用proc发布您选择的任何信息。而且,与sysfs和debugfs不同,它对非 GPL 模块也可用。 -
netlink:这是一个套接字协议族。AF_NETLINK创建一个套接字,将内核空间与用户空间连接起来。最初创建它是为了让网络工具能够与 Linux 网络代码进行通信,以访问路由表和其他细节。它也被udev用来将事件从内核传递到udev守护进程。在一般的设备驱动程序中很少使用。
在内核源代码中有许多前述文件系统的示例,您可以为驱动程序代码设计非常有趣的接口。唯一的通用规则是最小惊讶原则。换句话说,使用您驱动程序的应用程序编写者应该发现一切都以逻辑的方式工作,没有任何奇怪或异常的地方。
设备驱动程序的构成
现在是时候通过查看一个简单设备驱动程序的代码来将一些线程联系起来了。
这是一个名为dummy的设备驱动程序的开头,它创建了四个可以通过/dev/dummy0到/dev/dummy3访问的设备:
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/device.h>
#define DEVICE_NAME "dummy"
#define MAJOR_NUM 42
#define NUM_DEVICES 4
static struct class *dummy_class;
接下来,我们将定义字符设备接口的dummy_open()、dummy_release()、dummy_read()和dummy_write()函数:
static int dummy_open(struct inode *inode, struct file *file)
{
pr_info("%s\n", __func__);
return 0;
}
static int dummy_release(struct inode *inode, struct file *file)
{
pr_info("%s\n", __func__);
return 0;
}
static ssize_t dummy_read(struct file *file, char *buffer, size_t length, loff_t * offset)
{
pr_info("%s %u\n", __func__, length);
return 0;
}
static ssize_t dummy_write(struct file *file, const char *buffer, size_t length, loff_t * offset)
{
pr_info("%s %u\n", __func__, length);
return length;
}
之后,我们需要初始化一个file_operations结构,并定义dummy_init()和dummy_exit()函数,这些函数在驱动程序加载和卸载时调用:
struct file_operations dummy_fops = {
.open = dummy_open,
.release = dummy_release,
.read = dummy_read,
.write = dummy_write,
};
int __init dummy_init(void)
{
int ret;
int i;
printk("Dummy loaded\n");
ret = register_chrdev(MAJOR_NUM, DEVICE_NAME, &dummy_fops);
if (ret != 0){
return ret;
}
dummy_class = class_create(DEVICE_NAME);
for (int i = 0; i < NUM_DEVICES; i++) {
device_create(dummy_class, NULL, MKDEV(MAJOR_NUM, i), NULL, "dummy%d", i);
}
return 0;
}
void __exit dummy_exit(void)
{
int i;
for (int i = 0; i < NUM_DEVICES; i++) {
device_destroy(dummy_class, MKDEV(MAJOR_NUM, i));
}
class_destroy(dummy_class);
unregister_chrdev(MAJOR_NUM, DEVICE_NAME);
printk("Dummy unloaded\n");
}
在代码的末尾,名为module_init和module_exit的宏指定了在模块加载和卸载时要调用的函数:
module_init(dummy_init);
module_exit(dummy_exit);
最后的三个宏,命名为MODULE_*,添加了一些关于模块的基本信息:
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Chris Simmonds");
MODULE_DESCRIPTION("A dummy driver");
这些信息可以通过modinfo命令从编译后的内核模块中获取。完整的源代码以及该驱动程序的Makefile可以在MELD/Chapter11/meta-device-drivers/recipes-kernel/dummy-driver目录中找到。
当模块被加载时,会调用dummy_init()函数。它成为字符设备的时刻,是它调用register_chrdev并传递指向struct file_operations的指针,struct file_operations中包含了驱动程序实现的四个函数的指针。虽然register_chrdev告诉内核有一个主设备号为42的驱动程序,但它没有说明驱动程序的类别,因此不会在/sys/class/中创建条目。
如果/sys/class/中没有条目,设备管理器将无法创建设备节点。因此,接下来的几行代码创建了一个名为dummy的设备类,并创建了四个该类的设备,分别叫做dummy0到dummy3。结果是,在驱动程序初始化时,会创建/sys/class/dummy/目录,并包含子目录dummy0到dummy3。每个子目录中都有一个dev文件,包含设备的主设备号和次设备号。这就是设备管理器创建设备节点/dev/dummy0到/dev/dummy3所需要的全部内容。
dummy_exit()函数必须通过释放设备类和主设备号来释放dummy_init()所申请的资源。
该驱动程序的文件操作由dummy_open()、dummy_read()、dummy_write()和dummy_release()实现。当用户空间程序调用open(2)、read(2)、write(2)和close(2)时,会分别调用这些函数。它们仅打印内核消息,以便你可以看到它们是否被调用。你可以通过命令行使用echo命令来演示这一点:
# echo hello > /dev/dummy0
dummy_open
dummy_write 6
dummy_release
在这种情况下,消息会出现是因为我已经登录到控制台,而内核消息默认会打印到控制台。如果你没有登录到控制台,仍然可以通过使用dmesg命令查看内核消息。
这个驱动程序的完整源代码不到 100 行,但足以说明设备节点与驱动代码之间的关联、设备类的创建以及数据在用户空间和内核空间之间的传输。接下来,你需要构建它。
编译内核模块
此时,你已经有了一些驱动程序代码,想要在目标系统上进行编译和测试。你可以将其复制到内核源代码树中,并修改 makefile 进行构建,或者你也可以将其作为模块进行树外编译。让我们从树外编译开始。
你将需要一个简单的Makefile,它利用内核构建系统来完成所有繁重的工作:
obj-m := dummy.o
SRC := $(shell pwd)
all:
$(MAKE) -C $(KERNEL_SRC) M=$(SRC)
modules_install:
$(MAKE) -C $(KERNEL_SRC) M=$(SRC) modules_install
clean:
rm -f *.o *~ core .depend .*.cmd *.ko *.mod.c
rm -f Module.markers Module.symvers modules.order
rm -rf .tmp_versions Modules.symvers
Yocto 将 KERNEL_SRC 设置为目标设备的内核目录,你将在该设备上运行该模块。obj-m := dummy.o 代码将调用内核构建规则,将 dummy.c 源文件转换为 dummy.ko 内核模块。我将在下一节向你展示如何加载内核模块。
重要提示
内核模块在内核发布版本和配置之间不具有二进制兼容性:该模块只能在它编译的内核上加载。
如果你想在内核源码树中构建一个驱动程序,过程非常简单。选择一个适合你驱动程序类型的目录。这个驱动程序是一个基本的字符设备,所以我会把dummy.c放在drivers/char/目录下。接着,编辑目录中的 makefile,并添加一行代码,无条件地构建这个驱动程序作为一个模块,像这样:
obj-m += dummy.o
或者,你可以添加以下行来无条件地将其构建为内置模块:
obj-y += dummy.o
如果你希望驱动程序是可选的,你可以在 Kconfig 文件中添加一个菜单选项,并且根据配置选项进行条件编译,就像我在 第四章 的 理解内核配置 部分描述的那样。
加载内核模块
你可以使用简单的 modprobe、lsmod 和 rmmod 命令来加载、列出和卸载模块。这里,它们正在加载和卸载虚拟驱动程序:
# modprobe dummy
# lsmod
Module Size Used by
dummy 12288 0
# rmmod dummy
如果模块被放置在 /lib/modules/<kernel release> 的子目录中,你可以使用 depmod -a 命令创建一个模块依赖数据库,像这样:
# depmod -a
# ls /lib/modules/6.12.9-ti-g8906665ace32
modules.alias modules.builtin.modinfo modules.softdep
modules.alias.bin modules.dep modules.symbols
modules.builtin modules.dep.bin modules.symbols.bin
modules.builtin.alias.bin modules.devname updates
modules.builtin.bin modules.order
modules.* 文件中的信息由 modprobe 命令用于按名称而非完整路径定位模块。modprobe 还有许多其他功能,所有这些功能都在 modprobe(8) 手册页中有描述。
现在我们已经编写并加载了虚拟内核模块,那么我们如何让它与真实硬件交流?我们需要通过设备树或平台数据将我们的驱动程序绑定到该硬件上。发现硬件并将其与设备驱动程序绑定是下一节讨论的主题。
发现硬件配置
虚拟驱动程序展示了设备驱动程序的结构,但它缺乏与真实硬件的交互,因为它只操纵内存结构。设备驱动程序通常被编写来与硬件交互。其中一部分是能够首先发现硬件,记住它在不同配置中可能在不同地址。
在某些情况下,硬件本身提供信息。在可发现总线上的设备(如 PCI 或 USB)具有查询模式,返回资源需求和唯一标识符。内核将标识符及可能的其他特征与设备驱动程序进行匹配。
然而,大多数嵌入式板上的硬件模块没有这样的标识符。你必须以 设备树 的形式或称为 平台数据 的 C 结构形式提供信息。
在 Linux 的标准驱动模型中,设备驱动会在适当的子系统中注册自己:PCI、USB、开放固件(设备树)、平台设备等。注册包括一个标识符和一个回调函数,即 probe 函数,只有当硬件的 ID 与驱动的 ID 匹配时,probe 函数才会被调用。对于 PCI 和 USB,ID 是基于设备的厂商和产品 ID。而对于设备树和平台设备,ID 是一个名称(文本字符串)。
设备树
我在第三章中给你介绍了设备树。在这里,我想向你展示 Linux 设备驱动如何与这些信息连接。
举个例子,我将使用 Arm Versatile 板(arch/arm/boot/dts/versatile-ab.dts),该板定义了以太网适配器:
net@10010000 {
compatible = "smsc,lan91c111";
reg = <0x10010000 0x10000>;
interrupts = <25>;
};
特别注意此节点的 compatible 属性。这个字符串值稍后会在以太网适配器的源代码中再次出现。我们将在第十二章中进一步学习设备树。
平台数据
在没有设备树支持的情况下,有一种回退方法,通过使用 C 结构体描述硬件,这种方法称为平台数据。
每个硬件组件都通过struct platform_device进行描述,该结构体包含一个名称和指向资源数组的指针。资源的类型由标志决定,标志包括以下内容:
-
IORESOURCE_MEM:这是一个内存区域的物理地址。 -
IORESOURCE_IO:这是 I/O 寄存器的物理地址或端口号。 -
IORESOURCE_IRQ:这是中断号。
这里是来自 arch/arm/machversatile/core.c 的以太网控制器平台数据示例,已进行编辑以提高可读性:
#define VERSATILE_ETH_BASE 0x10010000
#define IRQ_ETH 25
static struct resource smc91x_resources[] = {
[0] = {
.start = VERSATILE_ETH_BASE,
.end = VERSATILE_ETH_BASE + SZ_64K - 1,
.flags = IORESOURCE_MEM,
},
[1] = {
.start = IRQ_ETH,
.end = IRQ_ETH,
.flags = IORESOURCE_IRQ,
},
};
static struct platform_device smc91x_device = {
.name = "smc91x",
.id = 0,
.num_resources = ARRAY_SIZE(smc91x_resources),
.resource = smc91x_resources,
};
它有一个 64 KB 的内存区域和一个中断。平台数据通常会在板卡初始化时注册到内核:
void __init versatile_init(void)
{
platform_device_register(&versatile_flash_device);
platform_device_register(&versatile_i2c_device);
platform_device_register(&smc91x_device);
<…>
这里展示的的平台数据在功能上与之前的设备树源代码等效,唯一不同的是 name 字段,它取代了 compatible 属性的位置。
硬件与设备驱动的连接
在前面的部分中,你已经看到如何使用设备树或平台数据描述以太网适配器。对应的驱动代码在 drivers/net/ethernet/smsc/smc91x.c 中,它同时支持设备树和平台数据。
这里是初始化代码,再次进行了编辑以提高可读性:
static const struct of_device_id smc91x_match[] = {
{ .compatible = "smsc,lan91c94", },
{ .compatible = "smsc,lan91c111", },
{},
};
MODULE_DEVICE_TABLE(of, smc91x_match);
static struct platform_driver smc_driver = {
.probe = smc_drv_probe,
.remove = smc_drv_remove,
.driver = {
.name = "smc91x",
.of_match_table = of_match_ptr(smc91x_match),
},
};
static int __init smc_driver_init(void)
{
return platform_driver_register(&smc_driver);
}
static void __exit smc_driver_exit(void)
{
platform_driver_unregister(&smc_driver);
}
module_init(smc_driver_init);
module_exit(smc_driver_exit);
当驱动程序初始化时,它会调用 platform_driver_register(),并指向 struct platform_driver,其中包含一个指向 probe 函数的回调、驱动名称 smc91x 和指向 struct of_device_id 的指针。
如果该驱动已经由设备树配置,内核将查找设备树节点中的 compatible 属性与 compatible 结构元素指向的字符串之间的匹配。对于每个匹配,它都会调用 probe 函数。
另一方面,如果它是通过平台数据配置的,则probe函数会针对driver.name指向的字符串中的每个匹配项进行调用。
probe函数提取有关接口的信息:
static int smc_drv_probe(struct platform_device *pdev)
{
struct smc91x_platdata *pd = dev_get_platdata(&pdev->dev);
const struct of_device_id *match = NULL;
struct resource *res, *ires;
int irq;
res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
ires = platform_get_resource(pdev, IORESOURCE_IRQ, 0);
<…>
addr = ioremap(res->start, SMC_IO_EXTENT);
irq = ires->start;
<…>
}
调用platform_get_resource()可以从设备树或平台数据中提取内存和irq信息。由驱动程序负责映射内存并安装中断处理程序。第三个参数(前两种情况中的0)在存在多个该类型资源时发挥作用。
register-io-width property:
match = of_match_device(of_match_ptr(smc91x_match), &pdev->dev);
if (match) {
struct device_node *np = pdev->dev.of_node;
u32 val;
<…>
of_property_read_u32(np, "reg-io-width", &val);
<…>
}
对于大多数驱动程序,具体的绑定信息可以在Documentation/devicetree/bindings/中找到。对于这个特定的驱动程序,信息存放在Documentation/devicetree/bindings/net/smsc,lan9115.yaml中。
这里需要记住的主要内容是,驱动程序应该注册一个probe函数,并提供足够的信息,以便内核在找到与已知硬件匹配的设备时能够调用probe函数。设备树描述的硬件与设备驱动程序之间的关联是通过compatible属性完成的。平台数据与驱动程序之间的关联则是通过名称完成的。
总结
设备驱动程序负责处理设备,通常是物理硬件,但有时也包括虚拟接口,并以一致且有用的方式将其呈现给用户空间。Linux 设备驱动程序分为三大类:字符设备、块设备和网络设备。三者中,字符设备接口最为灵活,因此也是最常见的。Linux 驱动程序适配于一个名为驱动模型(driver model)的框架,该框架通过sysfs对外暴露。几乎所有设备和驱动程序的状态都可以在/sys/中看到。
每个嵌入式系统都有其独特的硬件接口和要求。Linux 为大多数标准接口提供了驱动程序,通过选择正确的内核配置,您可以非常快速地获得一个可用的目标板。这时,剩下的就是那些非标准组件,您需要为它们添加自己的设备支持。
在某些情况下,您可以通过使用通用驱动程序来避开这个问题,例如 GPIO、I2C 和 SPI,然后编写用户空间代码来完成工作。我推荐将此作为起点,因为它可以让您在不编写内核代码的情况下熟悉硬件。编写内核驱动程序并不特别困难,但您需要小心编写代码,以免破坏系统的稳定性。
我已经讲解了如何编写内核驱动代码:如果您选择这条路,您不可避免地想知道如何检查它是否正常工作以及如何检测错误。我将在第十九章中讲解这个话题。
下一章展示了如何使用单板计算机和附加板进行快速原型设计的技巧。
进一步学习
-
Linux 内核开发,第 3 版,作者:Robert Love
-
Linux 每周新闻 –
lwn.net/Kernel -
《Linux 上的异步 IO:select、poll 和 epoll》,作者:Julia Evans –
jvns.ca/blog/2017/06/03/async-io-on-linux--select--poll--and-epoll/ -
《Linux 必备设备驱动程序,第 1 版》,作者:Sreekrishnan Venkateswaran
加入我们的社区,参与 Discord 讨论
加入我们社区的 Discord 空间,和作者及其他读者一起讨论: https://packt.link/embeddedsystems
第十二章:使用扩展板进行原型设计
定制板的启用是嵌入式 Linux 工程师一遍又一遍需要做的事情。假设一个消费电子产品制造商想要构建一款新设备,并且该设备需要运行 Linux。在硬件准备就绪之前,Linux 镜像的组装过程就已经开始,并且通过将 SBC 和扩展板拼接在一起的原型来完成。验证了概念验证后,初步的原型 PCB 将与外设一起制作。没有什么比看到定制板首次启动到 Linux 系统中更令人满意的经历了。
BeaglePlay 在单板计算机(SBC)中独树一帜,因为它具有一个 mikroBUS 插槽,可以快速实现即插即用的外设扩展。几乎任何硬件外设,都可以找到相应的 MikroE Click 扩展板。在本章中,我们将集成 GNSS 接收器、环境传感器模块和 OLED 显示屏与 BeaglePlay。利用 mikroBUS 消除了阅读原理图和布线面包板的需要,这样你就能将更多时间花在编写应用程序上,而不是在硬件调试上。
使用真实硬件进行快速原型设计涉及大量的试错。借助完整的 Debian Linux 发行版,我们可以使用主流工具,如 git、pip3 和 python3,直接在 BeaglePlay 上开发软件。
本章将涵盖以下主题:
-
将原理图映射到引脚
-
使用扩展板进行原型设计
-
测试硬件外设
技术要求
为了跟随示例,确保你拥有以下设备:
-
一台 Ubuntu 24.04 或更高版本的 LTS 主机系统
-
一个 microSD 卡读卡器和卡
-
适用于 Linux 的 balenaEtcher
-
一个 BeaglePlay
-
一个能够提供 3A 电流的 5V USB-C 电源
-
一个带有 3.3V 逻辑电平的 USB 转 TTL 串口电缆
-
一根以太网线和一个带有可用端口的路由器,用于网络连接
-
一个 MikroE-5764 GNSS 7 Click 扩展板
-
一个外部有源 GNSS 天线
-
一个 MikroE-5546 环境 Click 扩展板
-
一个 MikroE-5545 OLED C Click 扩展板
本章使用的代码可以在本书 GitHub 仓库的章节文件夹中找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development/tree/main/Chapter12。
将原理图映射到引脚
因为 BeaglePlay 的 物料清单(BOM)、PCB 设计文件和原理图都是开源的,任何人都可以将 BeaglePlay 制作为他们的消费产品的一部分。由于 BeaglePlay 主要用于开发,它包含了一些生产中可能不需要的组件,例如以太网端口、USB 端口和 microSD 卡槽。作为开发板,BeaglePlay 也可能缺少一个或多个应用所需的外设,例如传感器、LTE 调制解调器或 OLED 显示屏。
BeaglePlay 采用的是德州仪器的 AM6254 处理器,这是一款四核 64 位 Arm Cortex-A53 SoC,配备了可编程实时单元(PRU)和 M4 微控制器。与 Raspberry Pi 4 相似,BeaglePlay 具有内置 Wi-Fi 和蓝牙。与其他 SBC 不同,它还具有一个可编程无线电,支持亚 GHz 和 2.4 GHz 低功耗无线通信。虽然 BeaglePlay 非常多功能,但在某些情况下,你可能想围绕 AM6254 设计自己的定制 PCB,以降低最终产品的成本。
在 第十一章 中,我们讨论了如何将以太网适配器绑定到 Linux 设备驱动的示例。外设绑定通过设备树源代码或 C 结构体(即平台数据)进行。多年来,设备树源代码已成为绑定到 Linux 设备驱动的首选方式,特别是在 Arm SoC 上。与 U-Boot 一样,将设备树源代码编译为 DTB 也是 Linux 内核构建过程的一部分。
如果你需要从本地网络到云端传输大量数据包,那么运行 Linux 是一个明智的选择,因为它具有非常成熟的 TCP/IP 网络栈。BeaglePlay 的 Arm Cortex-A53 CPU 满足运行主流 Linux 的要求(足够的可寻址内存和内存管理单元)。这意味着你的产品可以受益于 Linux 内核的安全性和错误修复。
现在我们已经选择了 SBC,接下来让我们看看 BeaglePlay 的原理图。
阅读原理图
BeaglePlay 配备了 mikroBUS 插座以及 Grove 和 QWIIC 接口,用于连接附加板。在这三种标准中,mikroBUS 是唯一一个具有 UART、I2C 和 SPI 通信端口,以及模拟到数字转换器(ADC)、脉冲宽度调制(PWM)和 GPIO 功能的标准。在选择 SBC 进行开发时,可以考虑 I/O 扩展选项。更多选项意味着在原型设计时可以选择更多外设模块。
在有选择的情况下,我通常在生产中选择 SPI 而不是 UART 和 I2C。许多 SoC 上的 UART 数量有限,通常保留用于蓝牙和/或串行控制台。I2C 驱动程序和硬件可能存在严重的 Bug。有些 I2C 内核驱动实现得非常糟糕,当连接过多外设同时通信时,总线可能会锁死。其他时候,Bug 可能出现在硬件上。广受诟病的 Broadcom SoC 中的 I2C 控制器(例如 Raspberry Pi 4 中的控制器)在外设尝试执行时钟拉伸时容易出现故障。时钟拉伸是指 I2C 子节点设备临时减慢或停止总线时钟。
每个 mikroBUS 插座由两对 1x8 母头排针组成。我们可以在 BeaglePlay 的原理图第 22 页找到这两个排针条(github.com/beagleboard/beagleplay/blob/main/BeaglePlay_sch.pdf)。
这是 BeaglePlay 的 mikroBUS 插座的右侧排针:
图 12.1 – mikroBUS 插槽(右侧接头条带)
引脚 1 连接地,引脚 2 输出 5V。引脚 3(I2C3_SDA)和引脚 4(I2C3_CL)连接到 BeaglePlay 的 I2C3 总线。引脚 5(UART5_TXD)和引脚 6(UART5_RXD)连接到 BeaglePlay 的 UART5。引脚 7(GPIO1_9)和引脚 8(GPIO1_11)是 GPIO,其中引脚 7 作为中断使用,引脚 8 作为 PWM 使用。
这是 BeaglePlay 的 mikroBUS 插槽左侧接头条带:
图 12.2 – mikroBUS 插槽(左侧接头条带)
引脚 9(GPIO1_10)和引脚 10(GPIO1_12)是 GPIO,其中引脚 9 作为模拟输入,引脚 10 作为复位功能使用。引脚 11(SPI2_CS0)、12(SPI2_CLK)、13(SPI2_D0)和 14(SPI2_D1)连接到 BeaglePlay 的 SPI2 总线。最后,引脚 15 输出 3.3V,引脚 16 连接地。
请注意,SPI2 总线有 CS0、CLK、D0 和 D1 线路。CS 代表芯片选择。由于每个 SPI 总线都是主从节点接口,拉低 CS 信号线通常用于选择总线上要传输的外设。此种负逻辑被称为低有效。CLK 代表时钟,并且总是由总线主设备生成,在此案例中是 AM6254。通过 SPI 总线传输的数据与此 CLK 信号同步。SPI 支持比 I2C 更高的时钟频率。D0 数据线对应主设备输入,从设备输出(MISO)。
D1 数据线对应主设备输出,从设备输入(MOSI)。SPI 是一个全双工接口,这意味着主设备和选中的从设备可以同时发送数据。
这里是一个块图,展示了四个 SPI 信号的方向:
图 12.3 – SPI 信号
现在让我们启用 BeaglePlay 上的 mikroBUS。最快的方法是从 BeagleBoard.org 安装一个预构建的 Debian 镜像。
在 BeaglePlay 上安装 Debian
BeagleBoard.org 提供适用于其各种开发板的 Debian 镜像。Debian 是一个流行的 Linux 发行版,包含了一个全面的开源软件包集合。它是一个庞大的项目,全球各地的贡献者共同参与。为各种 BeagleBoard 构建 Debian 的方式不同于嵌入式 Linux 的标准做法,因为这个过程不依赖于交叉编译。与其尝试为 BeaglePlay 自己构建 Debian 镜像,不如直接从 BeagleBoard.org 下载一个已经完成的镜像。
要下载并解压 BeaglePlay 的 Debian Bookworm minimal eMMC flasher 镜像,请使用以下命令:
$ wget https://files.beagle.cc/file/beagleboard-public-2021/images/beagleplay-emmc-flasher-debian-12.7-minimal-arm64-2024-09-04-8gb.img.xz
$ xz -d beagleplay-emmc-flasher-debian-12.7-minimal-arm64-2024-09-04-8gb.img.xz
如果上述链接损坏,请访问beagleboard.org/distros获取当前可供下载的 Debian 镜像列表。BeagleBoard.org可能会删除一些过时的 Debian 镜像链接,因为长期维护 Debian 发布版本需要大量成本和劳动力。
在撰写时,基于 AM6254 的 BeaglePlay 板的最新 Debian 映像为 12.7。主版本号 12 表明 12.7 是 Debian Bookworm LTS 版本。由于 Debian 12.0 最初发布于 2023 年 6 月 10 日,Bookworm 应该从该日期起获得长达 5 年的更新。
重要提示
如果可能,请在本章的练习中下载版本 12.7(也称为 Bookworm),而不是从BeagleBoard.org获取最新的 Debian 映像。BeaglePlay 的引导加载程序、内核、DTB 和命令行工具经常变化,因此后续说明可能无法与较新的 Debian 版本一起使用。
现在,您有了 BeaglePlay 的 Debian 闪存映像,请将其写入 microSD 卡:
-
将 microSD 卡插入 Linux 主机。
-
启动 balenaEtcher。
-
从Etcher中点击从文件闪存。
-
找到您从 BeagleBoard.org 下载并打开的
img文件。 -
从Etcher中点击选择目标。
-
选择您在步骤 1中插入的 microSD 卡。
-
点击从 Etcher 闪存以写入映像。
-
当 Etcher 完成烧录后,请弹出 microSD 卡。
接下来,从 microSD 引导闪存映像,并将 Debian 闪存到 BeaglePlay 的 eMMC。在继续之前,请确保您的 USB 到 TTL 串行电缆具有 3.3 V 逻辑电平。三针 UART 连接器位于 BeaglePlay 的 USB-C 连接器旁边。不要连接电缆的任何第四根红线。红线通常表示电源,在此情况下是不必要的,并可能损坏板子。
要将 Debian 映像从 microSD 复制到 BeaglePlay 的 eMMC:
-
拔掉 BeaglePlay 的 USB-C 电源。
-
将串行电缆的 USB 端插入主机。
-
将串行电缆的 TX 线连接到 BeaglePlay 的 RX 引脚。
-
将串行电缆的 RX 线连接到 BeaglePlay 的 TX 引脚。
-
将串行电缆的 GND(黑色)线连接到 BeaglePlay 的 GND 引脚。
-
启动适当的终端程序,如
gtkterm、minicom或picocom,并以每秒 115,200 比特率(bps)无流控制的方式连接到端口。gtkterm可能是设置和使用最简单的:$ sudo gtkterm -p /dev/ttyUSB0 -s 115200 -
将 microSD 卡插入 BeaglePlay。
-
在 BeaglePlay 上按住 USR 按钮。
-
通过 USB-C 端口为 BeaglePlay 供电。
-
一旦 BeaglePlay 开始从 microSD 卡引导,请释放 USR 按钮。
-
等待以下提示:
BeaglePlay microSD (extlinux.conf) (swap enabled) 1: microSD disable BCFSERIAL 2: copy microSD to eMMC (default) 3: microSD (debug) 4: microSD Enter choice: 2 -
输入
2。
镜像复制需要几分钟时间。进度会在串行控制台上报告。如果串行控制台上出现乱码或没有输出,请交换 BeaglePlay 上 RX 和 TX 引脚连接的电缆。一旦 eMMC 刷写完成,关闭 BeaglePlay 电源并取出 microSD 卡。通过 USB-C 端口为 BeaglePlay 供电。将以太网电缆从 BeaglePlay 插入路由器的空闲端口。当板载以太网灯开始闪烁时,BeaglePlay 应该已经联网。互联网连接让我们可以安装包并从 Debian 内部获取 Git 仓库中的代码。
从你的 Linux 主机通过 SSH 连接到 BeaglePlay:
$ ssh debian@beaglebone.local
在 debian 用户的密码提示符下输入 temppwd。按照提示更改密码。连接关闭后,使用新密码重新登录。
现在 Debian 已在你的目标设备上运行,我们来将 Linux 内核降级到带有必要 mikroBUS 驱动程序的版本。
使用扩展板进行原型设计
ClickID 是 MikroE 为 MikroE Click 扩展板提供的即插即用解决方案。ClickID 使 Linux 能够自动识别 Click 扩展板,并指示 mikroBUS 驱动程序加载正确的接口驱动程序(UART、I2C、SPI、ADC 或 PWM)以便与外设通信。关于外设的所有信息都存储在一个焊接在扩展板右下角的 EEPROM 芯片中。Linux 在启动时通过 1-Wire 与这个 EEPROM 通信,从而执行即插即用过程。并非所有 Click 扩展板都具有这个 EEPROM,因此并非所有板卡都支持 ClickID。
Debian 会自动升级包括 Linux 内核在内的包,而不会提示用户。这是一个问题,因为我们将使用较旧的 Linux 5.10 内核与 MikroE Click 扩展板进行通信。
要禁用 Debian 中的自动升级:
debian@BeagleBone:~$ sudo apt remove unattended-upgrades
将 Linux 内核从 6.6 降级到 5.10:
debian@BeagleBone:~$ sudo apt update
debian@BeagleBone:~$ sudo apt install bbb.io-kernel-5.10-ti-k3-am62
debian@BeagleBone:~$ sudo apt remove bbb.io-kernel-6.6-ti
debian@BeagleBone:~$ sudo shutdown -r now
一旦 BeaglePlay 恢复联网,重新 SSH 连接到 BeaglePlay。
要确认 BeaglePlay 上的 Linux 内核已构建了必要的 mikroBUS 驱动程序:
debian@BeagleBone:~$ dmesg | grep mikrobus
[ 1.952311] mikrobus:mikrobus_port_register: registering port mikrobus-0
[ 1.952373] mikrobus mikrobus-0: mikrobus port 0 eeprom empty probing default eeprom
每个 ClickID EEPROM 都有一个清单部分,包含板卡特定信息,如引脚排列、接口或 Linux 驱动程序。即使你的 Click 扩展板没有 ClickID,可能也已经存在清单。
要在 BeaglePlay 上安装最新的清单:
debian@BeagleBone:~$ sudo apt update
debian@BeagleBone:~$ sudo apt install bbb.io-clickid-manifests
要查看安装在 BeaglePlay 上的完整清单文件列表:
debian@BeagleBone:~$ ls /lib/firmware/mikrobus/
要加载带有 mikroBUS 驱动程序的清单,将该清单写入 mikrobus-0/new_device 条目:
debian@BeagleBone:~$ sudo su root
# cd /lib/firmware/mikrobus
# cat GNSS-7-CLICK.mnfb > /sys/bus/mikrobus/devices/mikrobus-0/new_device
# exit
清单不会持久化,因此每次重启 BeaglePlay 时,你必须重新加载它。
即使你找不到 Click 扩展板的清单,也不必担心。BeagleBoard.org 创建了一个简单的 Python 工具,名为 Manifesto,用于创建新的 Click 扩展板清单(github.com/beagleboard/manifesto)。
重要提示
如图所示手动加载 GNSS Click 7 清单完全没有必要,因为 GNSS Click 7 内置了 ClickID EEPROM。
许多 Click 附加板显示为 Linux 工业 I/O(IIO)设备。iio_info 工具可用于发现启用 IIO 驱动的设备。
安装 iio_info 工具:
debian@BeaglePlay:~$ sudo apt install libiio-utils
本书的代码仓库中有外设测试脚本。Debian 系统自带 Git,因此你可以克隆本书的仓库以获取代码:
debian@BeagleBone:~$ cd ~
debian@BeagleBone:~$ git clone https://github.com/PacktPublishing/Mastering-Embedded-Linux-Development MELD
现在,我们准备好测试每个 Click 附加板。
测试硬件外设
我们将把三个外设连接到 BeaglePlay:一个 u-blox NEO-M9N GNSS 接收器,一个 Bosch BME680 环境传感器和一个深圳 Boxing World Technology PSP27801 OLED 显示器。在本书的代码仓库中,Chapter12 下有三个测试程序。parse_nmea.py 程序测试 NEO-M9N;sensors.py 程序测试 BME680;display.py 程序测试 PSP27801。虽然可以在单个 mikroBUS 插槽上堆叠多个 Click 附加板,但我们将逐个测试每个外设。
连接 GNSS Click 7 附加板。
全球导航卫星系统(GNSS)接收器通过 UART(串口)、I2C 或 SPI 发送 国家海洋电子协会(NMEA)数据。许多 GNSS 用户空间工具(如 gpsd)仅支持与串口连接的模块。
从 u-blox 产品页面下载 NEO-M9N 系列数据表:www.u-blox.com/en/product/neo-m9n-module。跳转到描述 SPI 的部分。该部分指出,由于 SPI 引脚与 UART 和 I2C 接口共享,SPI 默认为禁用。要启用 NEO-M9N 上的 SPI,我们必须将 D_SEL 引脚连接到 GND。拉低 D_SEL 将两个 UART 和两个 I2C 引脚转换为四个 SPI 引脚。这也解释了为什么 GNSS 7 Click 附加板默认通过 I2C 和 UART 进行操作。要选择 SPI 通信,必须插入跳线。
将 GNSS Click 7 附加板连接到 BeaglePlay:
-
从 USB-C 电源断开 BeaglePlay。
-
将 GNSS Click 7 附加板插入 BeaglePlay 上的 mikroBUS 插槽。
-
将外部有源 GNSS 天线拧到 GNSS SMA 连接器上。
-
通过 USB-C 端口为 BeaglePlay 供电。
-
如果以太网电缆与 BeaglePlay 断开,请重新连接至路由器的空闲端口。
一旦 BeaglePlay 重新上线,通过 SSH 重新连接至 BeaglePlay。
确认 GNSS Click 7 附加板正确连接和识别:
debian@BeagleBone:~$ dmesg | grep mikrobus
[ 1.969019] mikrobus:mikrobus_port_register: registering port mikrobus-0
[ 1.969093] mikrobus mikrobus-0: mikrobus port 0 eeprom empty probing default eeprom
[ 2.734524] mikrobus_manifest:mikrobus_manifest_attach_device: parsed device 1, driver=neo-8, protocol=4, reg=0
[ 2.739995] mikrobus_manifest:mikrobus_manifest_attach_device: device 1, number of properties=1
[ 2.740005] mikrobus_manifest:mikrobus_manifest_parse: GNSS 7 Click manifest parsed with 1 devices
[ 2.740073] mikrobus mikrobus-0: registering device : neo-8
如果 dmesg 的输出看起来像上面所示,那么你已经成功将附加板连接到 BeaglePlay。
检查新连接的 GNSS 设备:
debian@BeagleBone:~$ ls /sys/class/gnss/gnss0/
dev device power subsystem type uevent
这意味着 GNSS 设备现在可以在 /dev/gnss0 中使用。
接收 NMEA 消息
最后,我们将安装 Python 测试程序并在目标设备上运行。该程序仅仅将 GNSS 模块的实时消息流输出到控制台。
NMEA 是大多数 GNSS 接收器支持的数据消息格式。NEO-M9N 默认输出 NMEA 语句。这些语句是以 $ 字符开头,后跟逗号分隔的字段的 ASCII 文本。我们首先要做的是从 /dev/gnss0 接口读取 NMEA 语句流。原始的 NMEA 消息不容易阅读,因此我们将使用解析器为数据字段添加有用的注释。
将 GNSS 模块的 ASCII 输入流传输到 stdout:
debian@BeagleBone:~$ sudo cat /dev/gnss0
$GNRMC,201929.00,A,3723.40927,N,12204.29313,W,0.159,,181224,,,A,V*04
$GNVTG,,T,,M,0.159,N,0.294,K,A*3F
$GNGGA,201929.00,3723.40927,N,12204.29313,W,1,09,1.16,43.4,M,-30.0,M,,*41
$GNGSA,A,3,30,08,14,07,20,,,,,,,,2.10,1.16,1.75,1*0C
$GNGSA,A,3,,,,,,,,,,,,,2.10,1.16,1.75,2*04
$GNGSA,A,3,03,,,,,,,,,,,,2.10,1.16,1.75,3*06
$GNGSA,A,3,36,20,19,,,,,,,,,,2.10,1.16,1.75,4*0D
$GPGSV,3,1,11,04,13,142,,07,63,045,35,08,36,068,25,09,39,150,,1*61
$GPGSV,3,2,11,13,11,316,07,14,46,233,21,17,04,184,,20,19,269,19,1*62
$GPGSV,3,3,11,22,25,228,32,27,13,041,,30,60,318,28,1*5D
$GLGSV,1,1,00,1*78
$GAGSV,1,1,04,02,22,228,23,03,60,310,30,05,63,148,,16,77,040,33,7*76
$GBGSV,1,1,03,19,47,204,31,20,10,168,25,36,62,293,35,1*4E
$GNGLL,3723.40927,N,12204.29313,W,201929.00,A,A*66
<…>
每秒钟您应该能看到一段 NMEA 语句。按 Ctrl + C 取消流并返回命令行提示符。
GitHub 仓库中包含一个 NMEA 解析器脚本。parse_nmea.py 脚本依赖于 pynmea2 库。
在 BeaglePlay 上安装 pynmea2:
debian@BeagleBone:~$ sudo apt install python3.11-venv
debian@BeagleBone:~$ python3 -m venv gnss-click
debian@BeagleBone:~$ source gnss-click/bin/activate
(gnss-click) $ pip3 install pynmea2
将 /dev/gnss0 的输出通过管道传输到 NMEA 解析器:
(gnss-click) $ cd ~/MELD/Chapter12
(gnss-click) $ sudo cat /dev/gnss0 | ./parse_nmea.py
解析后的 NMEA 输出如下:
<RMC(timestamp=datetime.time(20, 33, 31, tzinfo=datetime.timezone.utc), status='A', lat='3723.40678', lat_dir='N', lon='12204.28976', lon_dir='W', spd_over_grnd=0.389, true_course=None, datestamp=datetime.date(2024, 12, 18), mag_variation='', mag_var_dir='', mode_indicator='A', nav_status='V')>
<VTG(true_track=None, true_track_sym='T', mag_track=None, mag_track_sym='M', spd_over_grnd_kts=Decimal('0.389'), spd_over_grnd_kts_sym='N', spd_over_grnd_kmph=0.72, spd_over_grnd_kmph_sym='K', faa_mode='A')>
<GGA(timestamp=datetime.time(20, 33, 31, tzinfo=datetime.timezone.utc), lat='3723.40678', lat_dir='N', lon='12204.28976', lon_dir='W', gps_qual=1, num_sats='11', horizontal_dil='1.10', altitude=50.1, altitude_units='M', geo_sep='-30.0', geo_sep_units='M', age_gps_data='', ref_station_id='')>
<…>
如果您的 GNSS 模块无法接收到卫星信号或获得固定位置,不要灰心。这可能有多种原因,例如选择了错误的 GNSS 天线,或者没有清晰的视距通向天空。射频很复杂,本章的目标只是证明我们能够让 GNSS 模块的通信正常工作。现在,我们可以尝试使用其他 GNSS 天线,并探索 NEO-M9N 的更多高级功能,如更丰富的 UBX 消息协议。
现在,NMEA 数据已经流入终端,我们的第一个项目完成了。我们成功验证了 AM6254 可以通过 I2C 和 UART 的组合与 NEO-M9N 进行通信。
连接 Environment Click 附加板
BME680 环境传感器测量温度、相对湿度、压力和气体。它通过 SPI 或 I2C 从 Environment Click 附加板与 AM6254 SoC 通信。与 GNSS 7 Click 相似,Environment Click 默认使用 I2C。要选择 SPI 通信,需要插入跳线。
将 Environment Click 附加板连接到 BeaglePlay:
-
拔掉 BeaglePlay 的 USB-C 电源。
-
将 Environment Click 附加板插入 BeaglePlay 上的 mikroBUS 插槽。
-
通过 USB-C 端口为 BeaglePlay 供电。
-
如果已断开,重新连接 BeaglePlay 的以太网电缆到路由器上的空闲端口。
BeaglePlay 恢复在线后,重新 SSH 连接到 BeaglePlay。
确认您的 Environment Click 附加板是否已正确连接和识别:
debian@BeagleBone:~$ dmesg | grep mikrobus
[ 1.962765] mikrobus:mikrobus_port_register: registering port mikrobus-0
[ 1.962829] mikrobus mikrobus-0: mikrobus port 0 eeprom empty probing default eeprom
[ 2.413200] mikrobus_manifest:mikrobus_manifest_attach_device: parsed device 1, driver=bme680, protocol=3, reg=77
[ 2.413212] mikrobus_manifest:mikrobus_manifest_parse: Environment Click manifest parsed with 1 devices
[ 2.413281] mikrobus mikrobus-0: registering device : bme680
如果 dmesg 输出与上面显示的相似,则说明您已成功将附加板连接到 BeaglePlay。
检查您新连接的环境传感器:
debian@BeagleBone:~$ iio_info
Library version: 0.24 (git tag: v0.24)
Compiled with backends: local xml ip usb
IIO context created with local backend.
Backend version: 0.24 (git tag: v0.24)
Backend description string: Linux BeagleBone 5.10.168-ti-arm64-r118 #1bookworm SMP Thu Feb 6 01:00:48 UTC 2025 aarch64
IIO context has 2 attributes:
local,kernel: 5.10.168-ti-arm64-r118
uri: local:
IIO context has 2 devices:
iio:device0: bme680
4 channels found:
temp: (input)
2 channel-specific attributes found:
attr 0: input value: 25020
attr 1: oversampling_ratio value: 8
pressure: (input)
2 channel-specific attributes found:
attr 0: input value: 1014.370000000
attr 1: oversampling_ratio value: 4
resistance: (input)
1 channel-specific attributes found:
attr 0: input value: 1183
humidityrelative: (input)
2 channel-specific attributes found:
attr 0: input value: 42.810000000
attr 1: oversampling_ratio value: 2
1 device-specific attributes found:
attr 0: oversampling_ratio_available value: 1 2 4 8 16
No trigger on this device
<…>
注意 bme680 会显示为 iio:device0。
读取传感器值
与其他 Linux IIO 设备一样,BME680 的寄存器值可以通过 sysfs 访问。
从 BME680 读取湿度、压力、气体和温度值:
$ cd /sys/bus/iio/devices/iio\:device0
$ cat in_humidityrelative_input
41.074000000
$ cat in_pressure_input
1014.350000000
$ cat in_resistance_input
3966
$ cat in_temp_input
24540
一个持续轮询所有四个通道的脚本已包含在 GitHub 仓库中。该 sensors.py 脚本除了 Python 标准库外,没有其他依赖项。
要运行脚本,请执行以下操作:
$ cd ~/MELD/Chapter12
$ ./sensors.py
随着传感器值流向终端,我们的第二个项目已经完成。我们成功验证了 AM6254 可以通过 I2C 与 BME680 通信。
连接 OLED C Click 扩展板
OLED C Click 配备了 Solomon Systech SSD1351 控制器,用于驱动 PSP27801 OLED 显示屏。你通过 SPI 将数据写入 SSD1351 内部的 128x128 像素 SRAM 显示缓冲区。SSD1351 支持两种颜色模式:65K(6:5:6)和 262K(6:6:6)。(r:g:b) 三元组表示每个像素的 RGB 组件使用了多少位。PSP27801 的分辨率为 96x96 像素,明显低于 SD1351 显示缓冲区的分辨率。
要将 OLED C Click 扩展板连接到 BeaglePlay,请按照以下步骤操作:
-
从 USB-C 电源断开 BeaglePlay。
-
将 OLED C Click 扩展板插入 BeaglePlay 的 mikroBUS 插槽。
-
通过 USB-C 端口为 BeaglePlay 供电。
-
如果以太网线从 BeaglePlay 断开,请将其重新连接到路由器的空闲端口。
一旦 BeaglePlay 重新上线,通过 SSH 连接回 BeaglePlay。
要确认 OLED C Click 扩展板已正确连接并被识别,请执行以下操作:
debian@BeagleBone:~$ dmesg | grep mikrobus
[ 1.946050] mikrobus:mikrobus_port_register: registering port mikrobus-0
[ 1.946117] mikrobus mikrobus-0: mikrobus port 0 eeprom empty probing default eeprom
[ 3.553403] mikrobus_manifest:mikrobus_manifest_attach_device: parsed device 1, driver=fb_ssd1351, protocol=11, reg=0
[ 3.553416] mikrobus_manifest:mikrobus_manifest_attach_device: device 1, number of properties=7
[ 3.553430] mikrobus_manifest:mikrobus_manifest_attach_device: device 1, number of gpio resource=2
[ 3.553437] mikrobus_manifest:mikrobus_manifest_parse: OLEDC Click manifest parsed with 1 devices
[ 3.553513] mikrobus mikrobus-0: registering device : fb_ssd1351
[ 3.553520] mikrobus mikrobus-0: adding lookup table : spi1.0
如果 dmesg 输出的内容与上述相似,那么你已经成功将扩展板连接到 BeaglePlay。
要检查新连接的 OLED 显示屏,请按照以下步骤操作:
$ ls /sys/class/graphics/fb0
bits_per_pixel console dev mode pan state uevent
bl_curve coursor device modes power stride virtual_size
blank debug gamma name rotate subsystem
$ cd /sys/class/graphics/fb0
$ cat name
fb_ssd1351
$ cat bits_per_pixel
16
$ cat virtual_size
128,128
将 SSD1351 显示为 Linux 帧缓冲区,大大简化了我们与 OLED 显示屏的交互方式。你无需链接 mikroSDK 库并处理其笨重的 C API。只需直接以任何方式写入 fb0 设备即可。
显示动画
一个 OLED 显示屏测试脚本已包含在 GitHub 仓库中。该 display.py 脚本依赖于 luma.core 和 numpy 库:
要在 BeaglePlay 上安装 luma.core 和 numpy,请执行以下操作:
debian@BeagleBone:~$ python3 -m venv ./oledc-click
debian@BeagleBone:~$ source ./oledc-click/bin/activate
(oledc-click) $ pip install luma.core numpy
要运行测试脚本,请执行以下操作:
(oledc-click) $ cd ~/MELD/Chapter12
(oledc-click) $ ./display.py
OLED 显示屏上会显示一个连续的动画,涉及一个红色、一个绿色和一个蓝色的方块。当三个方块相互靠近时,它们重叠在一起形成一个位于中央的白色方块。然后,方块们分开并回到它们的起始位置,动画重复播放。
我们的第三个也是最后一个项目已经完成。我们成功地验证了 AM6254 可以通过 SPI 在 PSP27801 上显示动态图像。
总结
在这一章中,我们学习了如何将外设与 SoC 集成。为了做到这一点,我们首先需要从原理图和数据手册中获取知识。没有现成的硬件时,我们还必须选择并插入扩展板。最后,我们编写了简单的 Python 测试程序并运行,以验证外设功能。现在硬件已经正常工作,我们可以开始开发嵌入式应用程序了。
接下来的两章将讲解系统启动及你可以选择的不同init程序,从简单的 BusyBox init到更复杂的系统如 System V init和systemd。你选择的init程序会对产品的用户体验产生重大影响,包括启动时间和故障容错能力。
进一步学习
-
SPI 接口简介,作者:Piyu Dhaker –
www.analog.com/en/analog-dialogue/articles/introduction-to-spi-interface.html -
焊接很简单,作者:Mitch Altman、Andie Nordgren 和 Jeff Keyzer –
mightyohm.com/blog/2011/04/soldering-is-easy-comic-book
第十三章:启动 – init程序
我们在第四章中已经了解了内核如何启动并启动第一个程序init。在第五章和第六章中,我们创建了不同复杂度的根文件系统,这些文件系统都包含了init程序。现在,是时候更详细地了解init程序,并探索它对系统其他部分的重要性。
init有很多实现版本。在本章中,我将描述三种主要的实现:BusyBox init、System V init 和 systemd。我将解释它们是如何工作的,以及哪些类型的系统最适合使用每种实现。部分内容涉及在大小、复杂性和灵活性之间做出权衡。我们将学习如何使用 BusyBox init和 System V init启动一个守护进程。同时,我们还将学习如何向systemd添加一个服务。
在本章中,我们将讨论以下主题:
-
在内核启动后
-
介绍
init程序 -
BusyBox
init -
System V
init -
systemd
技术要求
要跟随示例操作,确保你具备以下条件:
-
一台至少有 90GB 空闲磁盘空间的 Ubuntu 24.04 或更高版本的 LTS 主机系统
-
Buildroot 2024.02.6 LTS 版
-
Yocto 5.0(scarthgap)LTS 版
你应该已经为第六章安装了 Buildroot 2024.02.6 LTS 版。如果还没有,请参考Buildroot 用户手册中的系统要求部分,按照第六章的说明,在 Linux 主机上安装 Buildroot。
你应该已经在第六章构建了 5.0(scarthgap)LTS 版的 Yocto。如果还没有,请参考兼容的 Linux 发行版和构建主机包部分,按照Yocto 项目快速构建指南的说明,在 Linux 主机上构建 Yocto。
本章中使用的代码可以在本书 GitHub 仓库的章节文件夹中找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development。
在内核启动后
在第四章中,我们看到内核启动代码会寻找根文件系统,可能是 initramfs 或内核命令行中由 root= 指定的文件系统。内核启动代码接着执行一个程序,默认情况下,对于 initramfs 是 /init,对于常规文件系统则是 /sbin/init。init 程序具有 root 权限,并且由于它是第一个运行的进程,因此它的 进程 ID (PID) 是 1。如果因为某些原因无法启动 init,内核将会 panic,系统无法启动。
init 程序是所有其他进程的祖先,如下面 pstree 命令在一个简单的嵌入式 Linux 系统上显示的那样:
# pstree -gn
init(1)-+-syslogd(63)
|-klogd(66)
|-dropbear(99)
`-sh(100)---pstree(109)
init 程序的工作是接管用户空间中的引导过程并使其运行。它可能像是一个运行 shell 脚本的简单 shell 命令——在第五章的开始有一个例子——但在大多数情况下,你将使用一个专门的 init 守护进程来执行以下任务:
-
启动其他守护进程并配置系统参数以及将系统配置到工作状态所需的其他任务。
-
可选地,在允许登录 shell 的终端上启动登录守护进程,如
getty。 -
采用因其直接父进程终止且线程组中没有其他进程而成为孤儿的进程。
-
响应其任何直接子进程终止,捕获
SIGCHLD信号并收集返回值,以防止它们成为僵尸进程。我将在第十七章中详细讨论僵尸进程。 -
可选地,重启其他已终止的守护进程。
-
处理系统关机。
换句话说,init 管理系统从启动到关机的生命周期。有一种观点认为,init 很适合处理其他运行时事件,如新硬件的加入和模块的加载与卸载。这正是 systemd 所做的工作。
引入 init 程序
你最有可能在嵌入式设备中遇到的三种 init 程序是 BusyBox init、System V init 和 systemd。Buildroot 提供这三种程序,其中 BusyBox init 是默认选项。Yocto 项目允许你在 System V init 和 systemd 之间选择,默认是 System V init。虽然 Yocto 的小型发行版包含 BusyBox init,但大多数其他发行版层并不包含它。
以下表格列出了一些指标,用于比较三者:
| 指标 | BusyBox init | System V init | systemd |
|---|---|---|---|
| 复杂度 | 低 | 中 | 高 |
| 启动速度 | 快 | 慢 | 中等 |
| 必需的 shell | ash | dash 或 bash | 无 |
| 可执行文件数量 | 1(*) | 4 | 50 |
| libc | 任何 | 任何 | glibc |
| 大小(MB) | < 0.1(*) | 0.1 | 34(**) |
表 13.1 – BusyBox init、System V init 和 systemd 的比较
(*) BusyBox init 是 BusyBox 单一可执行文件的一部分,经过优化以减少磁盘空间占用。
(**) 基于 systemd 的 Buildroot 配置。
从广义上讲,从 BusyBox init 到 systemd,灵活性和复杂性都有所增加。
BusyBox init
BusyBox 有一个最小化的 init 程序,它使用 /etc/inittab 配置文件在启动时启动程序,在关机时停止程序。实际的工作由 shell 脚本完成,按照约定,这些脚本放在 /etc/init.d 目录中。
init 从读取 /etc/inittab 开始。该文件包含一系列程序,每行一个,格式如下:
<id>::<action>:<program>
这些参数的作用是:
-
id:命令的控制终端 -
action:何时以及如何运行程序 -
program:要运行的程序及其所有命令行参数
这些操作是:
-
sysinit:在init启动时,先于其他类型的操作运行该程序。 -
respawn:运行该程序,并在其终止时重新启动。用于将程序作为守护进程运行。 -
askfirst:与respawn相同,但它会在控制台上显示请按 Enter 键激活此控制台的消息,按下 Enter 后运行程序。用于在终端启动交互式外壳,而无需提示输入用户名或密码。 -
once:运行该程序一次,但如果它终止,不会尝试重新启动它。 -
wait:运行该程序并等待其完成。 -
restart:当init接收到SIGHUP信号时运行该程序,表示它应该重新加载inittab文件。 -
ctrlaltdel:当init接收到SIGINT信号时运行该程序,通常是因为在控制台上按下 Ctrl + Alt + Del。 -
shutdown:当init关闭时运行该程序。
这里有一个小例子,它挂载了 proc 和 sysfs,然后在串口接口上运行一个外壳:
null::sysinit:/bin/mount -t proc proc /proc
null::sysinit:/bin/mount -t sysfs sysfs /sys
console::askfirst:-/bin/sh
对于一些简单的项目,如果你只需要启动少量守护进程并在串口终端上启动登录外壳,手动编写脚本是非常容易的。如果你正在创建一个自主定制(RYO)的嵌入式 Linux,这种方法是适当的。然而,你会发现,随着需要配置的内容增多,手写的 init 脚本会迅速变得不可维护。它们不是很模块化,每次添加或删除新组件时都需要更新。
Buildroot init 脚本
Buildroot 多年来有效地使用 BusyBox init。Buildroot 在 /etc/init.d/ 中有两个脚本,分别是 rcS 和 rcK(rc 代表“运行命令”)。rcS 脚本在启动时运行。它遍历 /etc/init.d/ 中所有名称以大写字母 S 开头并跟随两位数字的脚本,并按数字顺序运行它们。这些是启动脚本。rcK 脚本在关机时运行。它遍历所有以大写字母 K 开头并跟随两位数字的脚本,并按数字顺序运行它们。这些是停止脚本。
有了这个结构,Buildroot 软件包可以提供自己的启动和停止脚本,使得系统变得可扩展。两位数字控制 init 脚本的执行顺序。如果你正在使用 Buildroot,这个结构是透明的。如果没有使用,你可以将其作为编写自己 BusyBox init 脚本的模型。
像 BusyBox init 一样,System V init 依赖于 /etc/init.d 中的 shell 脚本和 /etc/inittab 配置文件。虽然这两种 init 系统在许多方面相似,但 System V init 拥有更多功能和更长的历史。
System V init
这个 init 程序的灵感来源于 Unix System V,追溯到上世纪 80 年代中期。大多数 Linux 发行版中常见的版本最初由 Miquel van Smoorenburg 编写。直到最近,它一直是几乎所有桌面和服务器发行版以及许多嵌入式系统的 init 守护进程。然而,近年来它已被 systemd 替代,我们将在下一节中介绍它。
BusyBox 的 init 守护进程只是一个简化版的 System V init。与 BusyBox init 相比,System V init 有两个优势:
-
首先,启动脚本采用众所周知的模块化格式编写,便于在构建时或运行时添加新软件包。
-
其次,它具有 运行级别 的概念,允许在从一个运行级别切换到另一个运行级别时,一次性启动或停止一组程序。
有八个运行级别,从 0 到 6,再加上 S:
-
S:执行启动任务 -
0:停止系统 -
1到5:可供一般使用 -
6:重新启动系统
级别 1 到 5 可以根据需要使用。在大多数桌面 Linux 发行版中,它们被分配为:
-
1:单用户模式 -
2:没有网络配置的多用户模式 -
3:具有网络配置的多用户模式 -
4:未使用 -
5:具有图形登录的多用户模式
init 程序启动 /etc/inittab 文件中 initdefault 行指定的默认运行级别:
id:3:initdefault:
你可以使用 telinit <runlevel> 命令在运行时更改运行级别,这会向 init 发送一条消息。你可以使用 runlevel 命令查找当前运行级别和先前的运行级别。以下是一个示例:
# runlevel
N 5
# telinit 3
INIT: Switching to runlevel: 3
# runlevel
5 3
runlevel 命令的初始输出是 N 5。N 表示没有先前的运行级别,因为自启动以来运行级别没有变化。当前的运行级别是 5。在更改运行级别后,输出为 5 3,表示从 5 过渡到 3。
halt 和 reboot 命令分别切换到运行级别 0 和 6。你可以通过在内核命令行中指定不同的运行级别(从 0 到 6 的单个数字)来覆盖默认的运行级别。例如,要强制默认运行级别为单用户模式,你可以在内核命令行中添加 1,如下所示:
console=ttyAMA0 root=/dev/mmcblk1p2 1
每个运行级别都有一组杀死脚本,用于停止进程,还有一组启动脚本,用于启动进程。当进入新运行级别时,init首先运行杀死脚本,然后是新级别的启动脚本。当前正在运行的守护进程,如果在新运行级别中既没有启动脚本也没有杀死脚本,将会收到SIGTERM信号。换句话说,切换运行级别时的默认操作是终止守护进程,除非另有指示。
事实上,嵌入式 Linux 中并不常用运行级别(runlevel)。大多数设备直接启动到默认运行级别并保持在那里。我觉得这部分原因是大多数人并不了解它们。
提示
运行级别是一种简单便捷的方式,用于在不同模式之间切换,例如从生产模式切换到维护模式。
系统 V init是 Buildroot 和 Yocto 项目中的一种选项。在这两种情况下,init脚本都已去除任何bash shell 的特定内容,因此它们能够在 BusyBox 的ash shell 中工作。然而,Buildroot 通过将 BusyBox 的init程序替换为系统 V 的init并添加一个模拟 BusyBox 行为的inittab,稍微作弊了一下。Buildroot 除了0和6运行级别(它们用于停止或重启系统)外,不实现其他运行级别。
接下来,我们来看一些细节。以下示例取自 Yocto 项目 5.0 版本。其他 Linux 发行版可能会略有不同地实现init脚本。
inittab
init程序首先通过读取/etc/inittab配置文件中的条目来定义每个运行级别发生的操作。其格式是前面章节中描述的 BusyBox inittab的扩展版。因为 BusyBox 本来就从系统 V 借用了这一格式,所以这并不令人惊讶。
每个inittab条目的格式如下:
<id>:<runlevels>:<action>:<process>
这些字段包括:
-
id:最多四个字符的唯一标识符 -
runlevels:此条目所属的运行级别 -
action:命令的运行时机和方式 -
process:要运行的命令
这些操作与 BusyBox 的init相同:sysinit、respawn、once、wait、restart、ctrlaltdel和shutdown。然而,系统 V 的init没有askfirst,这是 BusyBox 特有的。
以下是 Yocto 项目在为qemuarm机器构建core-image-minimal时提供的完整inittab:
# /etc/inittab: init(8) configuration.
# $Id: inittab,v 1.91 2002/01/25 13:35:21 miquels Exp $
# The default runlevel.
id:5:initdefault:
# Boot-time system configuration/initialization script.
# This is run first except when booting in emergency (-b) mode.
si::sysinit:/etc/init.d/rcS
# What to do in single-user mode.
~~:S:wait:/sbin/sulogin
# /etc/init.d executes the S and K scripts upon change
# of runlevel.
#
# Runlevel 0 is halt.
# Runlevel 1 is single-user.
# Runlevels 2-5 are multi-user.
# Runlevel 6 is reboot.
l0:0:wait:/etc/init.d/rc 0
l1:1:wait:/etc/init.d/rc 1
l2:2:wait:/etc/init.d/rc 2
l3:3:wait:/etc/init.d/rc 3
l4:4:wait:/etc/init.d/rc 4
l5:5:wait:/etc/init.d/rc 5
l6:6:wait:/etc/init.d/rc 6
# Normally not reached, but fallthrough in case of emergency.
z6:6:respawn:/sbin/sulogin
AMA0:12345:respawn:/sbin/getty 115200 ttyAMA0
# /sbin/getty invocations for the runlevels
#
# The "id" field MUST be the same as the last
# characters of the device (after "tty").
#
# Format:
# <id>:<runlevels>:<action>:<process>
#
1:2345:respawn:/sbin/getty 38400 tty1
第一个id:5:initdefault条目将默认运行级别设置为5。接下来的si::sysinit条目在启动时运行/etc/init.d/rcS脚本。rcS脚本所做的就是进入S运行级别:
#!/bin/sh
<…>
exec /etc/init.d/rc S
因此,第一次进入的运行级别是S,接着是默认运行级别5。请注意,运行级别S不会被记录,也不会在runlevel命令中作为之前的运行级别显示出来。
从l0到l6的七个条目,在运行级别发生变化时会执行/etc/init.d/rc脚本。rc脚本负责处理启动和杀死脚本。
再往下查看,找到一个运行getty守护进程的条目:
AMA0:12345:respawn:/sbin/getty 115200 ttyAMA0
该条目会在进入运行级别1到5时,在/dev/ttyAMA0上生成一个登录提示,允许你登录并获得一个交互式终端。ttyAMA0设备是 QEMU 仿真中的 Arm Versatile 开发板的串行控制台。其他开发板的串行控制台可能会有不同的设备名称。
最后一项会在/dev/tty1上运行另一个getty守护进程:
1:2345:respawn:/sbin/getty 38400 tty1
该条目会在进入运行级别2到5时触发。tty1设备是一个虚拟控制台,当你在构建内核时启用了CONFIG_FRAMEBUFFER_CONSOLE或VGA_CONSOLE选项,它会映射到一个图形屏幕。
桌面 Linux 发行版通常会在虚拟终端 1 到 6 上启动六个getty守护进程,tty7则保留用于图形屏幕。Ubuntu 和 Arch Linux 是值得注意的例外,因为它们使用tty1来显示图形界面。你可以通过组合键Ctrl + Alt + F1到Ctrl + Alt + F6在虚拟终端之间切换。嵌入式设备很少使用虚拟终端。
init.d 脚本
每个需要响应运行级别变化的组件,在/etc/init.d中都有一个脚本来执行该变化。脚本应该接收两个参数:start和stop。我会在添加一个新的守护进程部分给出每个的示例。
/etc/init.d/rc运行级别处理脚本接收要切换的运行级别作为参数。每个运行级别都有一个名为rc<runlevel>.d的目录:
# ls -d /etc/rc*
/etc/rc0.d /etc/rc2.d /etc/rc4.d /etc/rc6.d
/etc/rc1.d /etc/rc3.d /etc/rc5.d /etc/rcS.d
你会找到一组脚本,这些脚本以大写字母S开头,后面跟着两个数字。你也可能会找到以大写字母K开头的脚本。这些是启动和终止脚本。以下是5级别运行时的脚本示例:
# ls /etc/rc5.d
S01networking S20hwclock.sh S99rmnologin.sh S99stop-bootlogd
S15mountnfs.sh S20syslog
这些实际上是指向init.d中对应脚本的符号链接。rc脚本首先运行所有以K开头的脚本,传入stop参数。然后,它运行所有以S开头的脚本,传入start参数。再次强调,两个数字的代码是用来表示脚本执行的顺序的。
添加一个新的守护进程
假设你有一个名为simpleserver的程序,它作为传统的 Unix 守护进程运行;换句话说,它会分叉并在后台运行。这个程序的代码位于MELD/Chapter13/simpleserver。对应的init.d脚本(见下文)位于MELD/Chapter13/simpleserver-sysvinit/init.d:
#! /bin/sh
case "$1" in
start)
echo "Starting simpelserver"
start-stop-daemon -S -n simpleserver -a /usr/bin/simpleserver
;;
stop)
echo "Stopping simpleserver"
start-stop-daemon -K -n simpleserver
;;
*)
echo "Usage: $0 {start|stop}"
exit 1
esac
exit 0
start-stop-daemon是一个简化后台进程操作的程序。它最初来自 Debian 安装包(dpkg),但大多数嵌入式系统使用的是来自 BusyBox 的版本。使用-S参数运行start-stop-daemon时,会启动守护进程,确保每次只有一个实例在运行。使用-K参数运行start-stop-daemon时,会通过发送SIGTERM信号(默认)来停止守护进程,通知守护进程是时候终止了。
要使simpleserver正常工作,请将init.d脚本复制到/etc/init.d并使其可执行。然后,从你希望该程序运行的每个运行级别添加链接——在此情况下,只有默认运行级别5:
# cd /etc/init.d/rc5.d
# ln -s ../init.d/simpleserver S99simpleserver
数字99表示这是最后启动的程序之一。
重要说明
请记住,可能还会有其他以S99开头的链接,在这种情况下,rc脚本会按字母顺序运行它们。
在嵌入式设备中,通常不需要过多担心关机操作,但如果有需要处理的事项,可以在0级和6级添加 kill 链接:
# cd /etc/init.d/rc0.d
# ln -s ../init.d/simpleserver K01simpleserver
# cd /etc/init.d/rc6.d
# ln -s ../init.d/simpleserver K01simpleserver
我们可以绕过运行级别和顺序,直接测试和调试init.d脚本。
启动和停止服务
你可以通过直接调用脚本与/etc/init.d中的脚本交互。以下是一个使用syslog脚本的示例,该脚本控制syslogd和klogd守护进程:
# /etc/init.d/syslog --help
Usage: syslog { start | stop | restart }
# /etc/init.d/syslog stop
Stopping syslogd/klogd: stopped syslogd (pid 198)
stopped klogd (pid 201)
done
# /etc/init.d/syslog start
Starting syslogd/klogd: done
所有脚本都实现了start和stop,它们还应该实现help。有些脚本还实现了status,可以告诉你服务是否正在运行。仍然使用 System V init的主流发行版有一个名为service的命令,用于启动和停止服务,该命令隐藏了直接调用脚本的细节。
System V init是一个简单的init守护进程,已经为 Linux 管理员服务了数十年。虽然运行级别提供了比 BusyBox init更高的复杂性,但 System V init仍然无法监控服务并在需要时重新启动它们。随着 System V init逐渐显得过时,最受欢迎的 Linux 发行版已经转向了systemd。
systemd
systemd (systemd.io/) 自我定义为系统和服务管理器。该项目由 Lennart Poettering 和 Kay Sievers 于 2010 年发起,旨在创建一套集成的工具,用于管理基于init守护进程的 Linux 系统。它还包括设备管理(udev)和日志记录等多个功能。systemd是最新的技术,并且仍在快速发展中。它在桌面和服务器 Linux 发行版中很常见,且在嵌入式 Linux 系统中越来越受欢迎。那么,它比 System V init更好在哪里呢?
-
配置更简单且更有逻辑性(理解后即可)。与复杂的 Shell 脚本不同,
systemd使用单位配置文件,这些文件采用一种明确定义的格式编写。 -
服务之间有明确的依赖关系。这比只能控制脚本执行顺序的两位数字系统有了巨大的改进。
-
在安全性方面,为每个服务设置权限和资源限制是很容易的。
-
它可以监控服务并在需要时重新启动它们。
-
服务是并行启动的,从而减少了启动时间。
这里无法提供关于systemd的完整描述。与 System V init一样,我将重点介绍基于systemd版本 255 的 The Yocto Project 5.0 发布版的嵌入式用例示例。
使用 The Yocto Project 和 Buildroot 构建 systemd
The Yocto Project 中的默认init守护进程是 System V。要选择systemd,请在conf/local.conf中添加以下行:
INIT_MANAGER = "systemd"
默认情况下,Buildroot 使用 BusyBox 的init。你可以通过menuconfig选择systemd,方法是进入系统配置 | 初始化系统菜单。你还需要将工具链配置为使用glibc作为 C 库,因为systemd官方不支持uClibc-ng或musl。此外,内核的版本和配置也有限制。systemd源代码顶层的README文件中列出了库和内核的所有依赖关系。
介绍目标、服务和单元
在描述systemd如何工作之前,我需要介绍三个关键概念:
-
单元:一个描述目标、服务或其他几个事物的配置文件。单元是包含属性和值的文本文件。
-
服务:一个可以启动和停止的守护进程,类似于 System V 的
init服务。 -
目标:一组服务,类似于 System V 的
init运行级别。默认目标由所有在启动时启动的服务组成。
你可以使用systemctl命令来更改状态并了解当前发生了什么。
单元
配置的基本项是单元文件。单元文件位于四个不同的位置:
-
/etc/systemd/system:本地配置 -
/run/systemd/system:运行时配置 -
/usr/lib/systemd/system:分发级别的配置(默认位置) -
/lib/systemd/system:分发级别的配置(传统默认位置)
在查找单元时,systemd会按前述顺序搜索这些目录,找到匹配项后停止搜索。你可以通过在/etc/systemd/system中放置同名的单元来覆盖分发级别单元的行为。你还可以通过创建一个空文件或链接到/dev/null来完全禁用一个单元。
所有单元文件都以[Unit]标记的部分开始,其中包含基本信息和依赖关系。例如,以下是 D-Bus 服务/lib/systemd/system/dbus.service的Unit部分:
[Unit]
Description=D-Bus System Message Bus
Documentation=man:dbus-daemon(1)
Requires=dbus.socket
除了描述和文档引用外,还有一个通过Requires关键字表达的对dbus.socket单元的依赖关系。这告诉systemd在启动 D-Bus 服务时创建一个本地套接字。
依赖关系通过Requires、Wants和Conflicts关键字表达:
-
Requires:此单元依赖的单元列表;这些单元会在该单元启动时启动。 -
Wants:一种比Requires更弱的形式;即使这些依赖项中的任何一个未能启动,该单元仍然会继续运行。 -
Conflicts:一种负依赖关系;当此单元启动时,这些单元会被停止,反之,如果其中一个单元随后重新启动,则此单元会被停止。
这三个关键字定义了外向依赖。它们用于在 目标 之间创建依赖关系。还有一组依赖关系称为内向依赖,用于在 服务 和 目标 之间创建链接。换句话说,外向依赖用于创建在系统从一个状态切换到另一个状态时需要启动的目标列表,内向依赖用于确定在进入任何状态时应启动或停止的服务。内向依赖通过 WantedBy 关键字创建,我将在接下来的章节 添加自定义服务 中描述。
处理依赖关系会生成一个应启动或停止的单元列表。Before 和 After 关键字决定它们的启动顺序。停止顺序只是启动顺序的反向:
-
Before:在列出的单元之前启动该单元。 -
After:在列出的单元之后启动该单元。
例如,After 指令确保在网络子系统启动后启动以下 Web 服务器:
[Unit]
Description=Lighttpd Web Server
After=network.target
在没有 Before 或 After 指令的情况下,单元会并行启动或停止,没有特定顺序。
服务
服务 是一个守护进程,可以像 System V init 服务一样启动和停止。一个服务有一个以 .service 结尾的单元文件。
服务单元有一个 [Service] 部分,描述了服务如何运行。以下是 lighttpd.service 中的 [Service] 部分:
[Service]
ExecStart=/usr/sbin/lighttpd -f /etc/lighttpd/lighttpd.conf -D
ExecReload=/bin/kill -HUP $MAINPID
这些是在启动和重启服务时运行的命令。你可以在这里添加更多配置项,详情请参考 systemd.service(5) 手册页。
目标
目标 是一个将服务或其他类型的单元组合在一起的单元。目标在这方面是一个元服务,起到同步点的作用。目标只有依赖关系。目标的名称以 .target 结尾,例如 multi-user.target。目标是一个期望的状态,扮演着 System V init 运行级别的角色。以下是完整的 multi-user.target:
[Unit]
Description=Multi-User System
Documentation=man:systemd.special(7)
Requires=basic.target
Conflicts=rescue.service rescue.target
After=basic.target rescue.service rescue.target
AllowIsolate=yes
这意味着基本目标必须在多用户目标之前启动。这还意味着,由于它与救援目标冲突,启动救援目标将首先停止多用户目标。救援目标和多用户目标不能同时运行,因为救援目标启动的是单用户模式。只有在系统恢复时,激活救援目标才有意义。
如何 systemd 启动系统
让我们看看 systemd 如何实现启动过程。内核启动 systemd,因为 /sbin/init 被符号链接到 /lib/systemd/systemd。systemd 运行 default.target,它始终是指向目标的链接:如果是文本登录,指向 multi-user.target,如果是图形环境,指向 graphical.target。如果默认目标是 multi-user.target,你将看到这个符号链接:
/etc/systemd/system/default.target -> /lib/systemd/system/multi-user.target
通过在内核命令行中传递system.unit=<new target>来覆盖默认目标。
查找默认目标:
# systemctl get-default
multi-user.target
启动类似multi-user.target的目标会创建一个依赖树,将系统带入工作状态。在典型的系统中,multi-user.target依赖于basic.target,后者依赖于sysinit.target,而sysinit.target又依赖于需要早期启动的服务。
打印系统依赖的文本图:
# systemctl list-dependencies
列出所有服务及其当前状态:
# systemctl list-units --type service
列出所有目标:
# systemctl list-units --type target
现在我们已经看到了系统的依赖树,那么如何插入一个额外的服务呢?
添加你自己的服务
这是我们的simpleserver服务的单元:
[Unit]
Description=Simple server
[Service]
Type=forking
ExecStart=/usr/bin/simpleserver
[Install]
WantedBy=multi-user.target
你可以在MELD/Chapter13/simpleserver-systemd中找到这个simpleserver.service文件。
[Unit]部分只包含一个描述,这个描述会出现在systemctl下。没有依赖项,因为这个服务非常简单。
[Service]部分指向可执行文件,并有一个标志表示它会分叉。如果simpleserver更简单,并在前台运行,systemd会为我们进行守护进程化,因此不需要Type=forking。
[Install]部分创建了一个到multi-user.target的传入依赖,这样当系统进入多用户模式时,我们的服务器会被启动。
一旦你将simpleserver.service文件放入/etc/systemd/system目录,你就可以使用systemctl start simpleserver和sytemctl stop simpleserver命令来启动和停止该服务。你还可以使用systemctl获取它的当前状态:
# systemctl status simpleserver
simpleserver.service - Simple server
Loaded: loaded (/etc/systemd/system/simpleserver.service; disabled)
Active: active (running) since Thu 1970-01-01 02:20:50 UTC; 8s ago
Main PID: 180 (simpleserver)
CGroup: /system.slice/simpleserver.service
└─180 /usr/bin/simpleserver -n
Jan 01 02:20:50 qemuarm systemd[1]: Started Simple server.
此时,该服务仅在命令下启动和停止。为了使其持久化,你需要添加一个指向目标的永久依赖。[Install]部分表示,当启用该服务时,它会依赖multi-user.target,以便在启动时启动。
启用该服务:
# systemctl enable simpleserver
Created symlink from /etc/systemd/system/multiuser.target.wants/simpleserver.service to /etc/systemd/system/simpleserver.service.
更新systemd依赖树而无需重启:
# systemctl daemon-reload
你也可以为服务添加依赖,而无需编辑目标单元文件。一个目标可以有一个名为<target_name>.target.wants的目录,其中包含指向服务的链接。在此目录中创建一个链接就相当于将单元添加到目标的[Wants]列表中。systemctl enable simpleserver命令创建了以下链接:
/etc/systemd/system/multi-user.target.wants/simpleserver.service -> /etc/systemd/system/simpleserver.service
如果一个重要服务崩溃,你可能希望重启它。为此,在[Service]部分添加以下标志:
Restart=on-abort
其他Restart选项包括on-success、on-failure、on-abnormal、on-watchdog和always。
添加看门狗
许多嵌入式系统需要看门狗:如果一个关键服务停止工作,你需要采取行动。这通常意味着需要重启系统。大多数嵌入式 SoC 都有一个硬件看门狗,可以通过/dev/watchdog设备节点访问。看门狗在启动时会初始化一个超时时间。如果在超时时间内没有重置这个计时器,看门狗将会被触发,系统将重启。与看门狗驱动程序的接口在内核源代码的Documentation/watchdog/下进行了描述,驱动程序的代码位于drivers/watchdog/。
当有两个或更多关键服务需要通过看门狗进行保护时,就会出现一个问题。systemd提供了一个有用的功能,可以在多个服务之间分配看门狗。可以配置systemd期望服务定期发送保持活跃信号,并在未收到该信号时采取行动,从而创建一个软件看门狗。为了实现这一功能,你需要在守护进程中添加代码,发送保持活跃信号。守护进程读取WATCHDOG_USEC环境变量的值,并在此时间段内调用sd_notify(false, "WATCHDOG=1")。该时间段应设置为看门狗超时的约一半。systemd源代码中有相关示例。
要在服务单元中启用软件看门狗,可以在[Service]部分添加如下内容:
WatchdogSec=30s
Restart=on-watchdog
StartLimitInterval=5min
StartLimitBurst=4
StartLimitAction=reboot-force
在这个例子中,服务每 30 秒需要接收到一次保持活跃的信号。如果保持活跃信号未能发送,服务会被重启,但如果在 5 分钟内重启超过四次,systemd会立即重启整个系统。关于这些设置的详细描述,可以参考systemd.service(5)手册页。
软件看门狗负责监控单个服务,但如果systemd本身失败、内核崩溃或硬件死锁该怎么办?在这些情况下,我们需要告诉systemd使用硬件看门狗。将RuntimeWatchdogSec=<N>添加到/etc/systemd/system.conf中。这将会在给定的N时间内重置看门狗,从而在systemd因某种原因失败时重启系统。这将是一个立即的硬重启或系统“重置”,没有任何优雅的关机过程。
嵌入式 Linux 的影响
systemd有许多对嵌入式 Linux 有用的功能。本章仅提到了其中的一些。其他功能包括资源控制(可以参考systemd.slice(5)和systemd.resource-control(5)手册页)、设备管理(udev(7))、系统日志功能(journald(5))、自动挂载文件系统的挂载单元以及cron作业的定时器单元。
你需要平衡这些功能与systemd的体积。即使是仅包含核心组件(systemd、udevd和journald)的最小构建,其存储空间也接近 10 MB,包括共享库。
你还需要记住,systemd的开发与内核和glibc紧密跟随,因此systemd的版本无法在比其发布版本早一到两年的内核和glibc上运行。
总结
每个 Linux 设备都需要某种形式的 init 程序。如果你设计的系统只需要在启动时启动少量守护进程,那么 BusyBox init 足够用了。如果你使用 Buildroot 作为构建系统,BusyBox init 通常也是一个不错的选择。
另一方面,如果你的系统在启动或运行时具有复杂的服务依赖关系,那么 systemd 是最佳选择。即便没有这样的复杂性,systemd 也有一些有用的功能,比如看门狗、远程日志等。如果你的存储空间足够,应该认真考虑使用 systemd。
与此同时,System V init 仍然存在。它已经被广泛理解,并且每个对我们来说重要的组件都有对应的 init 脚本。System V 仍然是 Yocto 项目参考发行版(Poky)的默认 init。在启动时间方面,systemd 对于类似的工作负载来说更快。然而,如果你追求的是最快的启动速度,简单的 BusyBox init 配合最小化的启动脚本则更胜一筹。
进一步学习
- systemd 系统与服务管理器 –
systemd.io/
146万+

被折叠的 条评论
为什么被折叠?



