精通嵌入式-Linux-开发第四版-全-
精通嵌入式 Linux 开发第四版(全)
原文:
annas-archive.org/md5/3a82583c6e34135584cfbe4fe278ea74
译者:飞龙
序言
Linux 多年来一直是嵌入式计算的主力军。然而,关于这一主题的书籍却少之又少:本书旨在填补这一空白。嵌入式 Linux 这个术语定义不明确,可以应用于各种设备中的操作系统,从恒温器、Wi-Fi 路由器到工业控制单元。然而,它们都是建立在相同的基本开源软件之上。这些技术就是本书所描述的内容,基于我作为工程师的经验。
技术发展永不停歇。围绕嵌入式计算的行业和主流计算一样,受摩尔定律的影响。这意味着指数级增长,自本书第一版出版以来,令人惊讶的许多事物发生了变化。本书第四版已经全面修订,采用了最新版本的主要开源组件,包括 Linux 6.6、Yocto 项目 5.0 Scarthgap 和 Buildroot 2024.02 LTS。除了 Autotools,本书现在还涵盖了 CMake,这是一种近年来被广泛采用的现代构建系统。
本书适用对象
本书面向有兴趣深入了解嵌入式计算和 Linux 的开发者,旨在扩展他们对该主题各个分支的知识。在写作本书时,我假设读者对 Linux 命令行有基本的了解,且在编程示例中具备 C 和 Python 语言的工作知识。若读者熟悉硬件及硬件接口,将在一些章节中占据优势,因为几个章节重点讲解了嵌入式目标板所涉及的硬件。
本书内容
第一章,入门,通过描述嵌入式 Linux 生态系统以及你在开始项目时可以选择的选项来为你定下基调。
第二章,了解工具链,介绍了工具链的组成部分以及如何为目标板获取用于交叉编译代码的工具链。
第三章,关于引导加载程序的一切,解释了引导加载程序在将 Linux 内核加载到内存中的作用,并以 U-Boot 为例。它还介绍了设备树,作为编码几乎所有嵌入式 Linux 系统硬件细节的机制。
第四章,配置和构建内核,提供了如何为嵌入式系统选择 Linux 内核并根据设备硬件配置它的信息。还涵盖了如何将 Linux 移植到新硬件上。
第五章,构建根文件系统,通过逐步指导如何配置根文件系统,介绍了嵌入式 Linux 实现中用户空间部分的概念。
第六章,选择构建系统,介绍了两种常用的嵌入式 Linux 构建系统,Buildroot 和 The Yocto Project,它们自动化了前四章中描述的步骤。
第七章,使用 Yocto 开发,展示了如何在现有 BSP 层之上构建系统映像,如何使用 Yocto 的可扩展 SDK 开发板载软件包,并构建自己的嵌入式 Linux 发行版,配备运行时包管理。
第八章,Yocto 背后的秘密,是对 Yocto 构建工作流和架构的介绍,包括解释 Yocto 独特的多层方法。它还通过实际的配方文件示例,讲解了 BitBake 语法和语义的基础。
第九章,创建存储策略,讨论了管理闪存时面临的挑战,包括原始闪存芯片和嵌入式 MMC(eMMC)包。它描述了适用于每种技术的文件系统。
第十章,现场软件更新,检查了设备部署后更新软件的各种方式,并包括完全管理的空中下载(OTA)更新。讨论的关键主题是可靠性和安全性。
第十一章,与设备驱动程序接口,描述了内核设备驱动程序如何通过实现简单的驱动程序与硬件进行交互。它还描述了从用户空间调用设备驱动程序的各种方式。
第十二章,使用附加板进行原型设计,演示了如何使用预构建的 Debian 镜像和 MikroElektronika 外围附加板,快速进行硬件和软件的原型设计。
第十三章,启动——init 程序,解释了第一个用户空间程序init
如何启动其余的系统。它描述了三种不同版本的init
程序,每种适用于不同类型的嵌入式系统,从简单的 BusyBox init
到 System V init
,再到当前的先进技术systemd
。
第十四章,电源管理,考虑了 Linux 可以如何调优以减少功耗,包括动态频率和电压调整、选择更深的空闲状态和系统挂起。其目的是让设备在电池充电后使用更长时间,同时运行得更凉爽。
第十五章,打包 Python,解释了将 Python 模块捆绑在一起以便部署的各种选择,并阐述了何时使用一种方法而非另一种方法。内容涵盖了pip
、虚拟环境和conda
。
第十六章,部署容器镜像,介绍了 DevOps 运动的原则,并展示了如何将这些原则应用于嵌入式 Linux。首先,我们使用 Docker 将一个 Python 应用程序及其用户空间环境打包到容器镜像中。然后,我们使用 GitHub Actions 为我们的容器镜像设置 CI/CD 管道。最后,我们使用 Docker 在 Raspberry Pi 4 上执行容器化的软件更新。
第十七章,学习进程和线程,从应用程序程序员的角度描述了嵌入式系统。本章讨论了进程和线程、进程间通信以及调度策略。
第十八章,内存管理,研究了虚拟内存背后的概念以及如何将地址空间划分为内存映射。它还描述了如何准确测量内存使用情况以及如何检测内存泄漏。
第十九章,使用 GDB 进行调试,向您展示如何结合 GNU 调试器 GDB 和调试代理gdbserver
,调试在目标设备上远程运行的应用程序。接着,本章展示了如何扩展该模型以调试内核代码,利用内核调试存根 KGDB。
第二十章,性能分析与追踪,介绍了可用的系统性能测量技术,从整体系统概况开始,然后集中分析导致性能瓶颈的特定区域。本章还描述了如何使用 Valgrind 检查应用程序在线程同步和内存分配方面的正确性。
第二十一章,实时编程,提供了一个详细的实时编程指南,讲解如何在 Linux 上使用最近合并的PREEMPT_RT
实时内核补丁进行实时编程。
最大化本书的学习效果
本书中使用的软件完全是开源的。在几乎所有情况下,我使用的是写作时可用的最新稳定版本。虽然我尽力以非版本特定的方式描述主要特性,但不可避免地,某些示例需要适配才能在后续软件版本中工作。以下是本书中使用的主要硬件和软件:
-
QEMU(64 位 Arm)
-
Raspberry Pi 4
-
BeaglePlay
-
Yocto 项目 5.0 Scarthgap
-
Buildroot 2024.02
-
Bootlin aarch64 glibc 稳定工具链 2024.02-1
-
Arm GNU AArch32 裸机目标(arm-none-eabi)工具链 13.2.Rel1
-
U-Boot v2024.04
-
Linux 内核 6.6
嵌入式开发涉及两个系统:主机系统用于开发程序,目标系统用于运行这些程序。对于主机系统,我选择了 Ubuntu 24.04 LTS,因为它广泛使用且有长期的维护保障。你也可以选择在 Docker、虚拟机或 Windows Subsystem for Linux 上运行 Linux,但要注意,有些任务,如使用 Yocto 项目构建分发版,比较复杂,最好在 Linux 的原生安装环境中运行。
对于目标设备,我选择了 QEMU 模拟器、Raspberry Pi 4 和 BeaglePlay。使用 QEMU 意味着你可以在不额外投资硬件的情况下尝试大部分示例。另一方面,某些事情在真实硬件上运行得更好。为此,我选择了 Raspberry Pi 4,因为它价格低廉、普及度高,并且有很好的社区支持。BeaglePlay 取代了本书早期版本中的 BeagleBone Black。当然,你并不仅仅局限于这三种目标设备。本书的核心思想是为你提供一般性的问题解决方案,使你能够将其应用于多种目标开发板。
下载示例代码文件
本书的代码包托管在 GitHub 上,网址是:github.com/PacktPublishing/Mastering-Embedded-Linux-Development
。我们还有其他书籍和视频的代码包,全部可以在github.com/PacktPublishing
找到。快去看看吧!
下载彩色图像
我们还提供了一个 PDF 文件,里面包含了本书中使用的截图/图表的彩色图像。你可以在这里下载:packt.link/gbp/9781803232591
。
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 用户名。例如:“执行make menuconfig
命令:”
代码块如下所示:
require recipes-core/images/core-image-minimal.bb
IMAGE_INSTALL:append = " helloworld strace"
所有命令行输入或输出都按照以下方式书写:
$ bitbake -c populate_sdk nova-image
粗体:表示新术语、重要单词或屏幕上出现的词汇。例如,菜单或对话框中的单词会像这样出现在文本中。例如:“退出外部工具链,并打开工具链子菜单。”
警告或重要提示以这样的方式出现。
提示和技巧以这样的方式出现。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请通过电子邮件发送到feedback@packtpub.com
,并在邮件主题中提及书名。如果你有任何关于本书的疑问,请通过电子邮件联系我们,邮箱地址是questions@packtpub.com
。
勘误:虽然我们已尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在本书中发现了错误,我们将非常感激你向我们报告。请访问www.packtpub.com/submit-errata
,点击提交勘误并填写表单。
盗版:如果你在互联网上遇到我们作品的任何非法副本,我们将非常感激你提供该副本的网址或网站名称。请通过copyright@packtpub.com
联系我们,并提供该材料的链接。
如果你有兴趣成为作者:如果你在某个领域有专长并且有兴趣写书或为书籍贡献内容,请访问authors.packtpub.com/
。
分享你的想法
一旦你阅读完《掌握嵌入式 Linux 开发,第 4 版》,我们很想听听你的想法!请点击这里直接进入亚马逊评论页面分享你的反馈。
你的评价对我们和技术社区都非常重要,将帮助我们确保提供高质量的内容。
下载本书的免费 PDF 副本
感谢购买本书!
你喜欢在移动中阅读,但又无法随身携带纸质书籍吗?
你的电子书购买是否与所选设备不兼容?
不用担心,现在每本 Packt 书籍都附赠免费的无 DRM PDF 版本。
随时随地、任何设备上阅读。直接从你最喜爱的技术书籍中搜索、复制并粘贴代码到你的应用程序中。
好处还不止这些,你还可以获得独家折扣、新闻通讯以及每天通过邮件获得的优质免费内容。
按照以下简单步骤获得福利:
- 扫描二维码或访问以下链接:
packt.link/free-ebook/9781803232591
-
提交你的购买凭证。
-
就是这样!我们会直接将免费的 PDF 和其他福利发送到你的邮箱。
第一部分
嵌入式 Linux 的要素
在这一部分,你将探索任何嵌入式 Linux 项目的四个关键要素。你将学习如何选择工具链、构建引导加载程序,并为目标设备构建内核。第五章要求你从零开始一步步构建根文件系统。这些手动操作较为困难,但在本节结束时,你将对嵌入式 Linux 的工作原理有更深入的理解,并更好地认识到那些能够自动化此开发板启动阶段的工具。
本部分包括以下章节:
-
第一章,开始入门
-
第二章,了解工具链
-
第三章,引导加载程序全解
-
第四章,配置和构建内核
-
第五章,构建根文件系统
第一章:开始
你即将开始你的下一个项目,这次,它将运行 Linux。在你动手之前,你应该考虑什么?让我们首先从高层次上了解嵌入式 Linux,看看它为何受欢迎,开源许可证的含义是什么,以及你需要什么样的硬件来运行它。
Linux 在 1999 年左右首次成为嵌入式设备的可行选择。那时,AXIS 发布了 2100 网络摄像头,TiVo 发布了他们的首个数字视频录制器(DVR)。这两款设备都是各自类别中首批使用 Linux 的设备。从 1999 年起,Linux 逐渐流行起来,直到今天,它成为了许多产品类别的操作系统(OS)首选。到 2024 年,运行 Linux 的设备已超过三十亿台。这包括所有运行 Android 系统的智能手机,Android 使用的是 Linux 内核,以及数亿台机顶盒、智能电视和 Wi-Fi 路由器。我们不能忽视其他设备,例如车辆诊断设备、工业设备和医疗监测设备,这些设备的出货量较小。
本章将涵盖以下主题:
-
选择 Linux
-
什么时候不选择 Linux
-
了解相关参与者
-
项目生命周期的推进
-
探索开源
-
为嵌入式 Linux 选择硬件
-
获取本书所需的硬件
-
配置开发环境
选择 Linux
为什么 Linux 如此普及?为什么像电视机这样简单的设备需要运行如此复杂的 Linux,只是为了在屏幕上显示流媒体视频?
简单的答案是摩尔定律。英特尔的联合创始人戈登·摩尔在 1965 年观察到,芯片上元件的密度大约每两年翻一番。这一规律适用于我们日常生活中设计和使用的设备,就像它适用于台式机、笔记本电脑和服务器一样。大多数嵌入式设备的核心是一个高度集成的芯片,芯片包含一个或多个处理器核心,并与主内存、大容量存储器和各种外设进行接口。这被称为系统级芯片(SoC)。SoC 的复杂性随着摩尔定律的推进不断增加。一个典型的 SoC 有一个技术参考手册,内容可能达到几千页。
你的电视机不再像旧款模拟电视那样只显示视频流。这个视频流是数字的,可能是加密的,并且需要处理才能生成图像。你的电视机(或者很快会)连接到互联网。它可以接收来自智能手机、平板电脑、笔记本电脑、台式机和家庭媒体服务器的内容。它可以用来玩游戏、播放视频流和显示来自安全摄像头的实时画面。你需要一个完整的操作系统来管理这种复杂度。
这里是一些推动 Linux 采用的要点:
-
Linux 具备所需的功能。它有一个良好的调度器、优秀的网络栈、对 USB、Wi-Fi、蓝牙、多种存储介质、多媒体设备等的支持,能够满足所有需求。
-
Linux 已经被移植到广泛的处理器架构上,包括在 SoC 设计中非常常见的一些架构——Arm、RISC-V、x86、PowerPC 和 MIPS。
-
Linux 是开源的,因此你有自由获取源代码并根据需求进行修改。你或代表你工作的人可以为你的设备创建板级支持包。你可以添加缺失的协议、特性和技术,也可以删除不需要的功能,以减少内存和存储需求。Linux 非常灵活。
-
Linux 拥有一个活跃的社区(以 Linux 内核为例,社区非常活跃)。每 8 到 10 周就会发布一次新内核版本,每个版本都包含来自 1,000 多位开发者的代码。活跃的社区意味着 Linux 能保持最新,并支持当前的硬件、协议和标准。Linux 基金会是一个非盈利组织,得到了大科技公司的支持。
除了 Linux,Linux 基金会还为多个主要的开源项目提供支持,包括 Kubernetes 和 PyTorch。它还在全球举办年度活动,如开源峰会和 Linux Plumbers 大会。
- 开源许可证保证你可以访问源代码。没有供应商锁定。
正因如此,Linux 是复杂设备的理想选择。但在这里我需要提几个注意事项。复杂性使得它更难理解。加上快速发展的开发进程和开源的去中心化结构,你需要投入一些精力学习如何使用它,并且随着它的变化不断学习。我希望这本书能帮助你这一过程。
何时不选择 Linux
Linux 适合你的项目吗?当要解决的问题足够复杂时,Linux 非常适用。特别是在需要连接性、鲁棒性和复杂用户界面的场合,Linux 表现得尤为出色。然而,它并不能解决所有问题,因此在你深入之前需要考虑以下几个方面:
-
你的硬件能胜任工作吗?与传统的实时操作系统(RTOS),如 VxWorks 或 QNX 相比,Linux 需要更多的资源。它至少需要一个 32 位处理器和更多的内存。我将在为嵌入式 Linux 选择硬件部分中详细讲解。
-
你拥有合适的技能吗?项目的早期阶段,尤其是板级启动,需要对 Linux 以及它如何与硬件关联有详细了解。同样,在调试和优化应用时,你需要能够解读结果。如果你的团队没有相关技能,可能需要外包一些工作。当然,阅读本书会有所帮助!
-
你的系统是实时的吗?只要你注意一些细节,Linux 可以处理许多实时活动,我在第二十一章中深入讨论了这些内容。
-
你的代码是否需要监管批准(医疗、汽车、航空航天等)?合规验证和确认的负担可能使得选择其他操作系统成为更好的选择。即使你选择在这些环境中使用 Linux,购买一款已经为类似产品提供 Linux 发行版的商业公司提供的发行版可能也更有意义。这些商业 Linux 厂商包括西门子、Timesys 和 Wind River。
仔细考虑这些要点。成功的最佳指示器可能是寻找类似的 Linux 运行产品,看看它们是如何做的,并遵循最佳实践。
了解各方参与者
开源软件从哪里来?谁编写它?特别是,它如何与嵌入式开发的关键组件——工具链、引导加载程序、内核以及根文件系统中的基本实用程序相关联?
-
开源社区:毕竟,这是生成你将使用的软件的引擎。这个社区是一个松散的开发者联盟,其中许多人通过非营利组织、学术机构或商业公司获得资助。他们共同合作推动各个项目的目标。这个社区非常庞大,有很多不同规模的项目。我们将使用的一些项目包括 Linux 本身、U-Boot、BusyBox、Buildroot、Yocto 项目以及许多 GNU 旗下的项目。
-
CPU 架构师:这些是设计我们使用的 CPU 的组织。这里重要的有 Arm/Linaro(Arm Cortex-A)、英特尔(x86 和 x86-64)、SiFive(RISC-V)和 IBM(PowerPC)。它们实现或至少影响对基本 CPU 架构的支持。
-
SoC 厂商:这些包括博通、英特尔、Microchip、NXP、高通、TI 等公司。它们从 CPU 架构师处获取内核和工具链,并修改它们以支持自己的芯片。它们还创建参考板:用于下一层级创建开发板和工作产品的设计。
-
板卡厂商和 OEM:这些公司从 SoC 厂商那里获取参考设计,并将其构建成特定的产品,如机顶盒或摄像头。它们还创建更多通用的开发板,如 Advantech 和 Kontron 的产品。一个重要类别是便宜的 单板计算机 (SBC) 如 BeagleBoard 和 Raspberry Pi,它们已经创建了自己的软件和硬件附加组件生态系统。
-
商业 Linux 厂商:如西门子、Timesys 和 Wind River 等公司提供经过严格合规验证的商业 Linux 发行版,这些发行版已在多个行业(医疗、汽车、航空航天等)中得到验证和确认。
这些形成了一条链条,通常你的项目处于链条的末端,这意味着你没有自由选择组件的余地。除非在极少数情况下,否则你不能直接从 kernel.org 获取最新的内核,因为它不支持你正在使用的芯片或板卡。
这是嵌入式开发中的一个持续性问题。理想情况下,每个链条中的开发人员都会推动他们的更改上游,但实际情况并非如此。开发人员时刻面临时间压力,将补丁合并到 Linux 内核中需要付出极大的努力。不少内核存在许多尚未合并的补丁。此外,SoC 供应商通常仅对其最新芯片积极开发开源组件,这意味着对任何两年以上的芯片的支持将被冻结,并且不会接收任何更新。
结果是大多数嵌入式设计基于旧版软件。它们不会接收安全补丁、性能增强或者新版本中的功能。像 Heartbleed(OpenSSL 库的漏洞)和 Shellshock(Bash shell 的漏洞)这样的问题未得到修复。
你能做些什么?首先,请向你的供应商(如 NXP、TI 和 Xilinx 等)提问:他们的更新政策是什么,他们多久更新一次内核版本,当前内核版本是什么,之前的版本是什么,以及他们的上游合并政策是什么?一些供应商在这方面取得了显著进展。你应该偏爱他们的芯片。
其次,您可以采取措施提高自给自足能力。第一部分的章节详细说明了依赖关系,并展示了您可以自助的地方。不要仅仅接受 SoC 或板卡供应商提供的包并盲目使用,而不考虑其他选择。
项目生命周期的推进
本书分为五个部分,反映了项目的各个阶段。这些阶段不一定是顺序的。通常它们是重叠的,你需要回头重新审视先前完成的事情。然而,它们代表了开发者在项目进展过程中关注的重点。
-
嵌入式 Linux 要素(第 1 到 5 章)将帮助您设置开发环境,并为后续阶段创建一个工作平台。这通常被称为板级启动阶段。
-
构建嵌入式 Linux 镜像(第 6 到 8 章)展示了如何通过利用 Buildroot 或 The Yocto Project 等构建系统自动化构建嵌入式 Linux 镜像的过程。自动化复杂的构建任务可以加速项目生命周期,使团队能够在更短的时间内交付更高质量的产品。
-
系统架构与设计选择(第 9 到 14 章)将为您提供一些关于程序和数据存储、如何在内核设备驱动程序和应用程序之间分工,以及如何初始化系统的设计决策。
-
开发应用程序(第 15 到 18 章) 向你展示如何打包和部署 Python 应用程序,如何有效利用 Linux 的进程和线程模型,并在资源受限的设备上管理内存。打包和部署 Python 应用程序与嵌入式 Linux 有什么关系呢?答案是“关系不大”,但请记住,本书的标题中也恰好有“开发”这个词。而且第 15 和 16 章完全与现代软件开发有关。
-
调试与性能优化(第 19 到 21 章) 描述了如何在应用程序和内核中跟踪、分析和调试你的代码。最后一章解释了当需要时如何为实时行为进行设计。
现在,让我们集中讨论本书第一部分中涉及的嵌入式 Linux 四个基本元素。
嵌入式 Linux 的四个元素
每个项目都始于获取、定制和部署这四个元素:工具链、引导加载程序、内核和根文件系统。这是本书第一部分的内容。
-
工具链:这是交叉编译器和其他创建目标设备代码所需的工具。交叉编译器在不同的主机 CPU 架构上运行时,生成目标 CPU 架构的机器码。
-
引导加载程序:这是一个裸机程序,用于初始化板卡和 Linux 内核。“裸机”一词意味着该程序直接运行在 CPU 上,而不是在操作系统之上。
-
内核:这是系统的核心,管理系统资源并与硬件进行交互。
-
根文件系统:它包含了内核初始化完成后运行的库和程序。
这里还有一个未提到的第五个元素,那就是特定于嵌入式应用程序的程序集合,它使得设备能够执行预定的功能,无论是称重商品、播放电影、控制机器人还是飞行无人机。
通常,当你购买 SoC 或板卡时,你会获得这些元素中的部分或全部作为一个软件包。但由于前面提到的原因,这些可能不是最适合你的选择。在前八章中,我将为你提供背景知识,以帮助你做出正确的选择,并介绍两种能够自动化整个过程的工具:Buildroot 和 Yocto 项目。
浏览开源
嵌入式 Linux 的组件是开源的,所以现在是时候考虑一下开源意味着什么,开源许可证为什么会这样运作,以及这将如何影响你将要基于它创建的、通常是专有的嵌入式设备。
许可证
在谈论开源时,“免费”这个词经常被提及。对于新接触这个话题的人来说,他们通常认为它意味着“无需付费”,而开源软件许可证确实保证你可以免费使用软件来开发和部署系统。然而,这里的更重要的含义是自由,因为你可以自由地获取源代码,按照自己的需要进行修改,并在其他系统中重新部署它。开源许可证赋予你这种权利,但一些许可证还要求你将这些修改与公众共享。
与免费软件许可证进行比较,这类许可证允许你免费复制二进制文件,但不提供源代码。其他许可证允许你在特定条件下免费使用软件,例如用于个人用途,但不允许商业用途。这些都不是开源。
为了帮助你理解与开源许可证相关的影响,我将提供以下评论,但我要指出的是,我是一名工程师,不是律师。接下来是我对这些许可证的理解以及它们的解释方式。
开源许可证大致分为两类:
-
Copyleft 许可证,如 GNU 通用公共许可证(GPL)
-
宽松许可证,如 BSD 和 MIT 许可证
宽松许可证本质上说,你可以修改源代码并在你选择的系统中使用它,只要你不以任何方式修改许可证的条款。换句话说,除了这一条限制之外,你可以随意使用它,包括将其构建到可能的专有系统中。
GPL 许可证相似,但有一些条款要求你将获取和修改软件的权利传递给最终用户。换句话说,你需要共享源代码。一种选择是通过将其上传到公共服务器使其完全公开。另一种选择是通过书面方式向最终用户提供代码,按照要求提供。
GPL 进一步规定,你不能将 GPL 代码并入专有程序。任何尝试这样做的行为都会使整个程序适用 GPL。换句话说,你不能在同一个程序中将 GPL 代码和专有代码结合起来。除了 Linux 内核,GNU 编译器集合(GCC)和 GNU 调试器(GDB),以及与 GNU 项目相关的许多其他自由工具,都是 GPL 许可证下的内容。
那么,库文件如何呢?如果它们采用 GPL 许可证,则与它们链接的任何程序也会成为 GPL 程序。然而,大多数库是采用 GNU 较宽松通用公共许可证(LGPL)进行授权的。如果是这种情况,你可以从专有程序中与它们进行链接。
重要提示
上述描述全部与 GPL v2 和 LGPL v2 相关。我应该提到最新版本的 GPL v3 和 LGPL v3。这些版本有争议,我承认我并不完全理解其含义。然而,GPL v3 和 LGPL v3 的目的是确保系统中的 GPL v3 和 LGPL v3 组件可以被最终用户替换,这符合开放源代码软件的精神,适用于每个人。
然而,GPL v3 和 LGPL v3 也存在一些问题。存在安全隐患。如果设备的拥有者可以访问系统代码,那么不受欢迎的入侵者也可能访问。通常的防御措施是通过诸如厂商等权威机构对内核映像进行签名,以避免未经授权的更新。这是否侵犯了我修改设备的权利?意见不一。
重要说明
TiVo 机顶盒是这场辩论中的一个重要部分。它使用一个 Linux 内核,该内核根据 GPL v2 许可发布。为了遵守许可,TiVo 已经发布了他们版本的内核源代码。TiVo 还有一个启动加载程序,该加载程序只会加载由他们签名的内核二进制文件。因此,你可以为 TiVo 盒子构建一个修改版的内核,但无法在硬件上加载它。
自由软件基金会(FSF)的立场是,这种做法不符合开放源代码软件的精神,并将其称为 TiVo 化。GPL v3 和 LGPL v3 的编写目的就是为了防止这种情况的发生。一些项目,特别是 Linux 内核,一直不愿意采用 GPL v3 许可证,因为它对设备制造商施加了限制。
为嵌入式 Linux 选择硬件
如果你正在为嵌入式 Linux 项目设计或选择硬件,你应该注意什么?
-
首先,选择一个被内核支持的 CPU 架构——除非你计划自己添加一个新的架构!查看 Linux 5.15 的源代码,你会看到有 23 个架构,每个架构都在
arch/
目录中有一个子目录。它们都是 32 位或 64 位架构,大多数有 MMU,有些则没有。在嵌入式设备中最常见的架构是 Arm、RISC-V、PowerPC、MIPS 和 x86,它们都有 32 位和 64 位版本,且都配有 内存管理单元(MMU)。 -
本书大部分内容是以这类处理器为基础编写的。还有另一类处理器没有 MMU,并运行一种被称为 微控制器 Linux 或 uClinux 的 Linux 子集。这些处理器架构包括 ARC(阿戈诺特 RISC 核心)、Blackfin、MicroBlaze 和 Nios。我会不时提到 uClinux,但不会深入讨论,因为它是一个相对专门化的类型。
-
第二,你将需要适量的 RAM。16 MB 是一个不错的最低值,尽管实际上用一半的内存也完全可以运行 Linux。如果你愿意为优化系统的每个部分付出努力,甚至可以用 4 MB 来运行 Linux。甚至可能能更低,但到了某个临界点,它就不再是 Linux 了。
-
第三,存在非易失性存储,通常是闪存。8 MB 足以满足简单设备的需求,如网络摄像头或基础路由器。与 RAM 类似,如果你真的愿意,你可以在较小的存储空间下创建一个可用的 Linux 系统,但存储越小,操作就越困难。Linux 对闪存存储设备有广泛的支持,包括原始的 NOR 和 NAND 闪存芯片,以及以 SD 卡、eMMC 芯片、USB 闪存等形式存在的管理闪存。
-
第四,串口非常有用,最好是基于 UART 的串口。它不一定要安装在生产板上,但会使板子的启动、调试和开发变得更加容易。
-
第五,你需要某种方式来加载软件,从零开始时尤其如此。许多微控制器板上配备了 联合测试行动组(JTAG)接口,专门用于此目的。现代 SoC 也可以直接从可移动介质加载启动代码,特别是 SD 卡和 microSD 卡,或像 QSPI 或 USB 这样的串行接口。
除了这些基本要素外,设备还需要与其工作所需的特定硬件进行接口。主线 Linux 提供了许多不同设备的开源驱动程序,并且来自 SoC 制造商和第三方芯片 OEM 的驱动程序(质量不同)也可供使用,后者可能会在设计中包含。
记住我关于某些厂商承诺和能力的评论。作为嵌入式系统的开发者,你会发现自己花了很多时间评估和适配第三方代码(如果有的话),或者如果没有,你需要与厂商沟通。最后,你必须为设备特有的接口编写设备支持,或者找人来做这件事。
获取本书所需硬件
本书中的示例旨在具有通用性。为了使它们更具相关性和易于跟随,我不得不选择特定的硬件。我选择了三种典型设备:树莓派 4、BeaglePlay 和 QEMU。第一种设备无疑是市场上最流行的基于 Arm 的单板计算机。第二种设备是广泛可用的单板计算机,也可以用于严肃的嵌入式硬件。第三种设备是机器仿真器,可以用于创建典型的嵌入式硬件系统。
曾一度想仅使用 QEMU,但像所有仿真一样,它与真实硬件有所不同。使用 Raspberry Pi 4 和 BeaglePlay,你能够与真实硬件互动,看到真实的 LED 闪烁。与 Raspberry Pi 4 不同,BeaglePlay 和之前的 BeagleBone Black 一样,都是 开源硬件。这意味着板卡设计材料是公开的,任何人都可以将 BeaglePlay 或其衍生品嵌入到他们的产品中。
无论如何,我鼓励你尽可能多地尝试这些示例,无论是使用这三种平台中的任何一种,还是使用你手头的任何嵌入式硬件。
Raspberry Pi 4
从 2019 年 6 月到 2023 年 10 月,Raspberry Pi 4 Model B 一直是由 Raspberry Pi 基金会生产的旗舰 SBC。Raspberry Pi 4 的技术规格包括以下内容:
-
Broadcom BCM2711 1.5 GHz 四核 Cortex-A72(Arm v8)64 位 SoC
-
2, 4 或 8 GB DDR4 RAM
-
2.4 GHz 和 5 GHz 802.11ac 无线,蓝牙 5.0,BLE
-
用于调试和开发的串口
-
一个 microSD 卡槽,可以用作启动设备
-
一个 USB-C 连接器,用于为开发板供电
-
两个全尺寸 USB 3.0 和两个全尺寸 USB 2.0 主机端口
-
一个千兆以太网端口
-
两个 micro HDMI 端口,用于视频和音频输出
此外,还提供一个 40 针扩展头,可以连接多种被称为 硬件附加在顶部(HATs)的子板,允许你将开发板适配成许多不同的用途。然而,在本书中的示例中,你不需要任何 HATs。
除了开发板本身,你还需要以下设备:
-
一张 microSD 卡以及一种从开发 PC 或笔记本电脑写入它的方式
-
一根带有 3.3 V 逻辑电平的 USB 到 TTL 串口线
-
一个 5 V USB-C 电源,能够提供 3 A 电流
-
一根以太网线和一个路由器来连接,因为一些示例需要网络连接
BeaglePlay
BeaglePlay 是由 BeagleBoard.org 基金会生产的一款开源硬件设计的 SBC(单板计算机)。该规格的主要特点如下:
-
TI AM6254 1.4 GHz 四核 Cortex-A53(Arm v8)64 位 Sitara SoC
-
2 GB DDR4 RAM
-
板载 16 GB eMMC 闪存
-
2.4 GHz 和 5 GHz MIMO Wi-Fi, BLE, Zigbee
-
用于调试和开发的串口
-
一个 microSD 卡槽,可以用作启动设备
-
一个 USB-C 连接器,用于为开发板供电
-
一个全尺寸 USB 2.0 主机端口
-
一个千兆以太网端口
-
一个全尺寸 HDMI 端口,用于视频和音频输出
BeaglePlay 采用 mikroBUS、Grove 和 Qwiic 接口来连接附加板,而不是采用大型扩展头。
除了开发板本身,你还需要以下设备:
-
一张 microSD 卡以及一种从开发 PC 或笔记本电脑写入它的方式
-
一根带有 3.3 V 逻辑电平的 USB 到 TTL 串口线
-
一个 5 V USB-C 电源,能够提供 3 A 电流
-
一根以太网线和一个路由器来连接,因为一些示例需要网络连接
除了上述内容,第十二章还要求以下内容:
-
一块 MikroE-5764 GNSS 7 Click 扩展板
-
一个带有 SMA 连接器的外部主动 GNSS 天线
-
一块 MikroE-5546 Environment Click 扩展板
-
一块 MikroE-5545 OLED C Click 扩展板
QEMU
QEMU 是一个机器模拟器。它有不同的版本,每个版本可以模拟一种处理器架构以及使用该架构构建的各种板子。例如,我们有以下几种:
-
qemu-system-arm
:32 位 Arm -
qemu-system-aarch64
:64 位 Arm -
qemu-system-mips
:MIPS -
qemu-system-ppc
:Power PC -
qemu-system-x86
:x86 和 x86-64
对于每种架构,QEMU 模拟了一系列硬件,你可以通过使用-machine help
选项查看。每种架构模拟了该板上通常会找到的大多数硬件。还有选项将硬件链接到本地资源,例如使用本地文件作为模拟的磁盘驱动器。这里是一个具体的例子:
$ qemu-system-arm -machine vexpress-a9 -m 256M -drive file=rootfs.ext4,sd -net nic -net use -kernel zImage -dtb vexpress-v2p-ca9.dtb -append "console=ttyAMA0,115200 root=/dev/mmcblk0" -serial stdio -net nic,model=lan9118 -net tap,ifname=tap0
重要提示
上述命令不打算执行,并且会失败,因为qemu-system-arm
未安装,并且rootfs.ext4.sd
、zImage
和vexpress-v2p-ca9.dtb
文件在你的主机系统中不存在。这只是一个供我们展开讨论的示例。
上述命令行中使用的选项如下:
-
-machine vexpress -a9
:创建一个具有 Cortex-A9 处理器的 Arm Versatile Express 开发板的仿真。 -
-m 256M
:分配 256 MB 的 RAM。 -
-drive file=rootfs.ext4,sd
:将 SD 接口连接到本地的rootfs.ext4
文件,该文件包含文件系统映像。 -
-kernel zImage
:从本地名为zImage
的文件加载 Linux 内核。 -
-dtb vexpress-v2p-ca9.dtb
:从本地的vexpress-v2p-ca9.dtb
文件加载设备树。 -
-append "…"
:将引号中的字符串追加为内核命令行。 -
-serial stdio
:将串口连接到启动 QEMU 的终端,这样你就可以通过串口控制台登录到模拟的机器。 -
-net nic,model=lan9118
:创建一个网络接口。 -
-net tap,ifname=tap0
:将网络接口连接到虚拟网络接口tap0
。
要配置主机端的网络,你需要 User Mode Linux (UML) 项目中的 tunctl
命令。在 Debian 和 Ubuntu 上,软件包名为 uml-utilities
:
$ sudo tunctl -u $(whoami) -t tap0
这会创建一个名为tap0
的网络接口,并将其连接到模拟 QEMU 机器中的网络控制器。你可以像配置任何其他网络接口一样配置tap0
。
所有这些选项将在接下来的章节中进行描述。我将在大多数示例中使用 Versatile Express,但应该很容易使用不同的机器或架构。
配置你的开发环境
我只使用了开源软件作为开发工具和目标操作系统/应用程序。我假设你将在开发系统上使用 Linux。
我使用 Ubuntu 24.04 LTS 测试了所有的主机命令,因此我建议在整本书中使用该版本,以避免出现意外问题。
除了 Ubuntu,The Yocto Project 只支持少数几个 Linux 发行版:Fedora、Debian、openSUSE、AlmaLinux 和 Rocky。如果你无法使用 Ubuntu,请确保选择这些支持的发行版来进行 The Yocto Project 的练习。
总结
嵌入式硬件继续按照摩尔定律的发展趋势变得越来越复杂。Linux 拥有高效利用硬件的能力和灵活性。我们将共同学习如何利用这一力量,从而构建出令用户满意的强大产品。本书将带领你经历嵌入式项目生命周期的五个阶段,从嵌入式 Linux 的四个元素开始。
嵌入式平台的种类繁多,并且开发进度迅速,这导致了软件的孤立池。在许多情况下,你将依赖这些软件,特别是由 SoC 或主板厂商提供的 Linux 内核,以及在较小程度上,工具链。
一些 SoC 制造商在推动他们的更改上游时变得更加高效,且这些更改的维护也变得更加容易。尽管有这些改进,选择适合的硬件来进行嵌入式 Linux 项目仍然是一项充满风险的挑战。开源许可证合规性是你在构建嵌入式 Linux 生态系统中的产品时需要关注的另一个话题。
在本章中,你将了解本书中将使用的硬件和一些软件(即 QEMU)。随后,我们将探讨一些强大的工具,帮助你为设备创建和维护软件。我们将介绍 Buildroot,并深入讲解 The Yocto Project。在我们深入研究这些构建工具之前,我们将拆解嵌入式 Linux 的四个元素,你可以将其应用于所有嵌入式 Linux 项目,无论它们是如何构建的。
加入我们社区的 Discord 频道
加入我们社区的 Discord 频道,与作者和其他读者讨论: packt.link/embeddedsystems
第二章:了解工具链
工具链是嵌入式 Linux 的第一个元素,也是你项目的起点。你将使用它来编译将在设备上运行的所有代码。你在这个早期阶段做出的选择将对最终结果产生深远的影响。
你的工具链应能够通过使用适合你处理器的最佳指令集,充分发挥硬件的效能。它应支持你所需的语言,并且具备可移植操作系统接口(POSIX)和其他系统接口的稳固实现。
你的工具链在整个项目中应该保持不变。换句话说,一旦选择了工具链,重要的是要坚持使用它。在项目中不一致地更改编译器和开发库将导致微妙的错误。尽管如此,还是建议在发现安全漏洞或错误时更新工具链。
获取工具链可以像下载并安装一个 TAR 文件那么简单,或者像从源代码构建整个工具链那样复杂。在本章中,我们采用第一种方法。稍后,在第六章中,我们将切换到使用构建系统生成的工具链。这是获取工具链的更常见方法。
本章将涵盖以下主题:
-
介绍工具链
-
寻找工具链
-
工具链的构成
-
与库的链接 ‒ 静态和动态链接
-
交叉编译的艺术
技术要求
我推荐使用 Ubuntu 24.04 或更高版本的 LTS 版本,因为本章中的练习是在撰写时对该 Linux 发行版进行过测试的。
这是在 Ubuntu 24.04 LTS 上安装本章所需的所有软件包的命令:
$ sudo apt-get install autoconf automake bison bzip2 cmake flex g++ gawk gcc gettext git gperf help2man libstdc++6 libtool libtool-bin make patch texinfo unzip wget xz-utils
本章中使用的代码可以在本书的 GitHub 仓库的章节文件夹中找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development/tree/main/Chapter02
。
介绍工具链
工具链是一组将源代码编译成可在目标设备上运行的可执行文件的工具。它包括一个编译器、一个链接器和运行时库。你需要一个工具链来构建嵌入式 Linux 系统的其他三个元素:
-
启动加载程序
-
内核
-
根文件系统
它必须能够编译用 C、C++ 和汇编语言编写的代码,因为这些是基础开源包中使用的语言。
通常,Linux 的工具链是基于 GNU 项目中的组件的,到目前为止仍然如此。然而,在过去几年里,Clang 编译器和相关的 低级虚拟机(LLVM)项目已经取得了长足的进展,LLVM 现在已成为 GNU 工具链的可行替代方案。LLVM 和基于 GNU 的工具链之间的一个主要区别在于许可证;LLVM 采用的是带有 LLVM 异常条款的 Apache 2.0 许可证,而 GNU 则使用 GPL。
Clang 也有一些技术优势,比如更快的编译速度、更好的诊断信息以及对最新 C 和 C++ 标准的更好支持。但GCC(GNU C 编译器)在与现有代码库的兼容性以及对更广泛架构和操作系统(OS)的支持方面具有优势。虽然 Clang 花费了几年时间才发展到现在的水平,但它现在可以编译嵌入式 Linux 所需的所有组件,并且是 GCC 的可行替代方案。欲了解更多信息,请参见docs.kernel.org/kbuild/llvm.html
。
关于如何使用 Clang 进行交叉编译的详细说明可以在clang.llvm.org/docs/CrossCompilation.html
找到。如果你希望将其作为嵌入式 Linux 构建系统的一部分,很多人正在研究如何将 Clang 与 Buildroot 和 Yocto 项目结合使用。我将在第六章中介绍嵌入式构建系统。与此同时,本章专注于 GNU 工具链,因为它仍然是 Linux 中最流行和成熟的工具链。
标准的 GNU 工具链包含三个主要组件:
-
Binutils:一组二进制工具,包括汇编器和连接器。
-
GCC:用于 C 和其他语言的编译器,包括 C++、Objective-C、Objective-C++、Java、Fortran、Ada、Go 和 D。它们都使用一个通用的后端生成汇编代码,并将其交给 GNU 汇编器处理。
-
C 库:基于 POSIX 规范的标准化 应用程序接口(API),它是应用程序访问操作系统内核的主要接口。我们将在本章稍后讨论几种需要考虑的 C 库。
除此之外,你还需要一份 Linux 内核头文件。内核头文件包含了访问内核时所需的定义和常量。你需要这些内核头文件来编译 C 库、程序和库。这些用户空间代码通过 Linux 帧缓冲驱动与 Linux 设备间接交互,例如用来显示图形。这与直接访问外设硬件的内核空间中的内核模块/驱动程序形成鲜明对比。
这不仅仅是将内核源代码的 include 目录中的头文件复制一遍的问题。这些头文件仅用于内核,它们包含的定义如果直接用于编译常规的 Linux 应用程序,会导致冲突。因此,你需要生成一组经过清理的内核头文件,我在第五章中做了说明。
在为用户空间编译时,内核头文件不需要来自你将要运行的 Linux 的确切版本。由于内核接口始终向后兼容,只要头文件来自与目标上运行的内核版本相同或更早的内核版本,就足够了。
大多数人认为GNU 调试器(GDB)也是工具链的一部分,因为它通常也是在这一点上构建的。当构建交叉编译器时,你还需要构建一个相应的交叉调试器,以便从主机机器远程调试目标上的代码。我将在第十九章中讲解 GDB。
现在我们已经讨论了内核头文件并了解了工具链的组成部分,让我们来看一下不同类型的工具链。
工具链的类型
对我们来说,工具链有两种类型:
-
本地:一种在与其生成的程序相同类型的系统(甚至是实际系统)上运行的工具链。这通常适用于桌面和服务器,并且在某些类型的嵌入式设备上越来越受欢迎。例如,运行 Debian ARM 版本的 Raspberry Pi 4 就有自托管的本地编译器。
-
交叉:一种在不同类型的系统上运行的工具链,它允许在快速的桌面 PC 上进行开发,然后将代码加载到嵌入式目标上执行。
几乎所有嵌入式 Linux 开发都是使用交叉开发工具链完成的。这部分是因为大多数嵌入式设备不适合开发,因为它们缺乏计算能力、内存和存储空间,但也因为它可以保持主机和目标环境的分离。当主机和目标使用相同的架构时(例如,x86_64),这一点尤其重要。在这种情况下,很容易在主机上本地编译并直接将二进制文件复制到目标上。
这种方法有一定的效果。然而,主机操作系统的分发版可能会比目标系统更频繁地收到更新,或者为目标系统构建代码的不同工程师可能会使用稍微不同版本的主机开发库。随着时间的推移,开发系统和目标系统将会出现分歧。如果你确保主机和目标的构建环境保持一致,你可以升级工具链。然而,更好的方法是将主机和目标分开,并使用交叉工具链来实现这一点。
有一种反对本地开发的观点。交叉开发需要跨平台编译所有你需要的库和工具。这一点我们将在稍后的章节中讨论,名为交叉编译艺术,交叉开发并不总是简单的,因为许多开源包并不是为这种方式的构建而设计的。
像 Buildroot 和 Yocto 项目这样的集成构建工具通过封装交叉编译大多数嵌入式系统所需的软件包规则来提供帮助。但是,如果你想编译大量的附加包,那么最好是本地编译它们。例如,使用交叉编译器为 Raspberry Pi 4 或 BeaglePlay 构建 Debian 发行版非常困难。相反,它们是本地编译的。
从零开始创建本地构建环境并不容易。你仍然需要首先使用交叉编译器在目标上创建本地构建环境,然后用它来构建软件包。接着,为了在合理的时间内完成本地构建,你需要一组配置良好的目标板或快速模拟器(QEMU)来模拟目标。
在本章中,我们将重点关注一个相对容易设置和管理的预构建交叉编译器环境。我们将首先看看是什么区分了不同的目标 CPU 架构。
CPU 架构
工具链必须根据目标 CPU 的能力进行构建,这包括:
-
CPU 架构:ARM、RISC-V、PowerPC、无互锁流水线阶段的微处理器(MIPS)或 x86_64。
-
大端或小端操作:一些 CPU 可以在两种模式下操作,但机器代码对于每种模式是不同的。
-
浮点支持:并不是所有版本的嵌入式处理器都实现了硬件浮点单元。在这些情况下,工具链必须配置为调用软件浮点库。
-
应用二进制接口(ABI):用于在函数调用之间传递参数的调用约定。
对于许多架构,ABI 在处理器家族中是常见的。一个显著的例外是 ARM。ARM 架构在 2000 年代后期过渡到扩展应用二进制接口(EABI),这导致了之前的 ABI 被称为旧应用二进制接口(OABI)。尽管 OABI 现在已经过时,但你仍然会看到对 EABI 的引用。从那时起,EABI 根据浮点参数的传递方式分为三种:softfloat、softfp 和 hardfp。
原始的 EABI 使用软件仿真(softfloat)或通用整数寄存器(softfp),而较新的 扩展应用程序二进制接口硬浮点(EABIHF)使用浮点寄存器(hardfp)。原始的 EABI 的 softfloat 和 softfp 模式是 ABI 兼容的。在 softfloat 模式下,编译器不会生成 浮点单元(FPU)指令。所有浮点运算都在软件中完成,这会导致性能不佳。在 softfp 模式下,浮动值通过栈或整数寄存器传递,以提高性能。由于 hardfp 模式省去了整数和浮点寄存器之间的拷贝,EABIHF 在浮点运算方面明显更快。
EABIHF 的缺点是 hardfp 模式与没有浮点单元的 CPU 不兼容。此时,你只能在两个不兼容的 ABI 之间进行选择。不能混合使用这两种模式,所以你必须在此时做出决定。
GNU 为工具链中每个工具的名称添加一个前缀,用于标识可以生成的各种组合。这个前缀由三到四个由短横线分隔的组件组成,如下所示:
-
CPU:例如 ARM、RISC-V、PowerPC、MIPS 或 x86_64 等 CPU 架构。如果 CPU 支持两种字节序模式,它们可能通过添加
el
来表示小端(little-endian)或eb
来表示大端(big-endian)。例如小端 MIPS(mipsel
)和大端 ARM(armeb
)就是很好的例子。 -
厂商:标识工具链的提供者。例如,
buildroot
、poky
或者简单的unknown
。有时,它可能完全不显示。 -
操作系统:就我们而言,它总是
linux
。 -
用户空间:用户空间组件的名称,可能是
gnu
或musl
。这里也可以附加 ABI。所以,对于 ARM 工具链,你可能会看到gnueabi
、gnueabihf
、musleabi
或musleabihf
。
你可以通过使用 gcc
的 -dumpmachine
选项来找到构建工具链时使用的元组。例如,在主机计算机上,你可能会看到以下内容:
$ gcc -dumpmachine
x86_64-linux-gnu
这个元组表示的是一个 x86_64
CPU,一个 linux
内核和一个 gnu
用户空间。
重要提示
当在机器上安装了本地编译器时,通常会创建工具链中每个工具的链接,且不带前缀,这样你就可以通过 gcc
命令调用 C 编译器。
下面是使用交叉编译器的一个示例:
$ mipsel-unknown-linux-gnu-gcc -dumpmachine
mipsel-unknown-linux-gnu
这个元组表示的是一个小端 MIPS CPU,unknown
的厂商,一个 linux
内核和 gnu
用户空间。你选择的用户空间(gnu
或 musl
)决定了程序链接的 C 库(glibc 或 musl)。
选择 C 库
Unix 操作系统的编程接口是用 C 语言定义的,遵循 POSIX 标准。C 库 就是该接口的实现。它是 Linux 程序通向内核的门道。即使你用 Go 或 Python 等其他语言编写程序,相应的运行时支持库最终也会调用 C 库,如下所示:
图 2.1 – C 库
每当 C 库需要内核服务时,它将使用内核系统调用接口在用户空间和内核空间之间进行切换。虽然可以通过直接进行内核系统调用来绕过 C 库,但那样做既麻烦又几乎没有必要。
有几种 C 库可供选择。主要选项如下:
-
glibc:这是标准的 GNU C 库,网址是
gnu.org/software/libc/
。它较大,直到最近才变得更加可配置,但它是 POSIX API 最完整的实现。许可证是 LGPL 2.1。 -
musl libc:这是一个相对较新的库,但作为一个小巧且符合标准的 glibc 替代品,它已经获得了很多关注。它是内存和存储有限的系统的好选择。它采用 MIT 许可证,并且可以在
musl.libc.org
获得。 -
uClibc-ng:u 实际上是希腊字母 mu,表示这是微控制器 C 库。uClibc-ng 可在
uclibc-ng.org
获取。它最初是为 uClinux(没有内存管理单元的微控制器上的 Linux)开发的,但后来已被改编为可以与完整的 Linux 一起使用。uClibc-ng 库是原 uClibc 项目的一个分支,而原项目已经停止维护。两者的许可证都是 LGPL 2.1。
那么,选择哪个呢?我的建议是,只有在使用 uClinux 时才使用 uClibc-ng。如果你的存储或内存非常有限,那么 musl libc 是一个不错的选择。否则,请使用 glibc,如下图所示:
图 2.2 – 选择 C 库
你选择的 C 库可能会限制你选择的工具链,因为并非所有预构建的工具链都支持所有 C 库。一旦你确定了工具链的需求,你该去哪里找呢?
寻找工具链
你有三个选择来获得交叉开发工具链:你可以找到一个已经构建好的工具链,与你的需求匹配;你可以使用一个由嵌入式构建工具生成的工具链(这部分内容在 第六章 中介绍);或者你可以自己创建一个。
使用预构建的交叉工具链是一个有吸引力的选择,因为你只需要下载并安装它。但你会受到该工具链配置的限制,并且依赖于提供该工具链的人或组织。它很可能是以下之一:
-
一个 SoC 或板卡供应商。大多数供应商提供 Linux 工具链。
-
一个致力于为特定架构提供系统级支持的联盟。例如,Linaro(
www.linaro.org
)为 ARM 架构提供了预构建的工具链。 -
一个第三方 Linux 工具供应商,如西门子、Timesys 或 Wind River。
-
您的桌面 Linux 发行版的交叉工具包。例如,基于 Debian 的发行版提供用于交叉编译 ARM、PowerPC 和 MIPS 目标的工具包。
-
由集成嵌入式构建工具生成的二进制 SDK。Yocto 项目提供了可以下载的工具链,下载链接为
downloads.yoctoproject.org/releases/yocto/yocto-<version>/toolchain/
。 (请将<version>
替换为有效的 Yocto 项目版本,如5.0
)。 -
来自一个论坛的链接,您现在已经找不到了。
重要提示
在所有这些情况下,您必须决定所提供的预构建工具链是否符合您的要求。它是否使用您偏好的 C 库?供应商是否会为您提供安全修复和错误修复的更新?请记住我在第一章中的评论,关于支持和更新。如果您的回答是否定的,那么您应该考虑自己构建工具链。
不幸的是,构建一个工具链并不是一件容易的事。如果您真的想自己做所有事情,可以查看 Cross Linux From Scratch(trac.clfs.org
)。在这里,您将找到逐步指导,教您如何创建每个组件。
一个更简单的替代方法是使用 crosstool-NG,它将构建过程封装成一组脚本,并具有菜单驱动的前端界面。然而,仅仅做出正确选择,您仍然需要一定程度的知识。
使用像 Buildroot 或 Yocto 项目这样的构建系统会更简单,因为它们在构建过程中生成工具链。这是我首选的解决方案,正如我们将在第六章中看到的那样。
您将需要一个可用的交叉工具链才能完成下一节中的练习。我们将使用来自 Bootlin 的预构建工具链。Bootlin 的工具链是使用 Buildroot 构建的。
下载预构建的交叉工具链,以便用于第二章至第五章:
$ wget https://toolchains.bootlin.com/downloads/releases/toolchains/aarch64/tarballs/aarch64--glibc--stable-2024.02-1.tar.bz2
要下载此工具链的最新版本,请访问toolchains.bootlin.com
。选择架构为aarch64,选择 libc 为glibc。完成这些选择后,下载工具链的稳定版本。
通过将预构建的工具链提取并解压到您的主目录中,在您的 Linux 主机上安装工具链:
$ bzip2 -d aarch64--glibc--stable-2024.02-1.tar.bz2
$ tar -xvf aarch64--glibc--stable-2024.02-1.tar
<…>
您将使用此工具链完成本章的其余部分。让我们首先看看它的内部结构。
工具链的结构
为了了解一个典型的工具链中包含了什么,让我们看看你从 Bootlin 下载的工具链。示例中使用的是 aarch64 工具链,它的前缀是aarch64-buildroot-linux-gnu
。
aarch64 工具链位于~/aarch64--glibc--stable-2024.02-1/bin
目录下。在这个目录中,你会找到交叉编译器aarch64-buildroot-linux-gnu-gcc
。要使用它,你需要通过以下命令将该目录添加到你的路径中:
$ PATH=~/aarch64--glibc--stable-2024.02-1/bin:$PATH
如果你下载了不同的版本,请确保将2024.02-1
替换为稳定工具链的实际版本。
现在你可以编写一个简单的helloworld
程序,在 C 语言中,它是这样的:
#include <stdio.h>
#include <stdlib.h>
int main (int argc, char *argv[])
{
printf ("Hello, World!\n");
return 0;
}
然后像这样编译:
$ aarch64-buildroot-linux-gnu-gcc helloworld.c -o helloworld
通过使用file
命令打印文件类型,确认它是否已被交叉编译:
$ file helloworld
helloworld: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0, not stripped
现在你已经验证了交叉编译器的工作情况,让我们更仔细地看一下它。
了解你的交叉编译器
假设你刚刚收到了一个工具链,并且你想了解它是如何配置的。你可以通过查询gcc
来获取很多信息。例如,要查看版本,可以使用--version
:
$ aarch64-buildroot-linux-gnu-gcc --version
aarch64-buildroot-linux-gnu-gcc.br_real (Buildroot 2021.11-11272-ge2962af) 12.3.0
Copyright (C) 2022 Free Software Foundation, Inc.
This is free software; see the source for copying conditions. There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
要查看它是如何配置的,请使用-v
:
$ aarch64-buildroot-linux-gnu-gcc -v
Using built-in specs.
COLLECT_GCC=/home/frank/aarch64--glibc--stable-2024.02-1/bin/aarch64-buildroot-linux-gnu-gcc.br_real
COLLECT_LTO_WRAPPER=/home/frank/aarch64--glibc--stable-2024.02-1/bin/../libexec/gcc/aarch64-buildroot-linux-gnu/12.3.0/lto-wrapper
Target: aarch64-buildroot-linux-gnu
Configured with: ./configure --prefix=/builds/buildroot.org/toolchains-builder/build/aarch64--glibc--stable-2024.02-1 --sysconfdir=/builds/buildroot.org/toolchains-builder/build/aarch64--glibc--stable-2024.02-1/etc --enable-static --target=aarch64-buildroot-linux-gnu --with-sysroot=/builds/buildroot.org/toolchains-builder/build/aarch64--glibc--stable-2024.02-1/aarch64-buildroot-linux-gnu/sysroot --enable-__cxa_atexit --with-gnu-ld --disable-libssp --disable-multilib --disable-decimal-float --enable-plugins --enable-lto --with-gmp=/builds/buildroot.org/toolchains-builder/build/aarch64--glibc--stable-2024.02-1 --with-mpc=/builds/buildroot.org/toolchains-builder/build/aarch64--glibc--stable-2024.02-1 --with-mpfr=/builds/buildroot.org/toolchains-builder/build/aarch64--glibc--stable-2024.02-1 --with-pkgversion='Buildroot 2021.11-11272-ge2962af' --with-bugurl=http://bugs.buildroot.net/ --without-zstd --disable-libquadmath --disable-libquadmath-support --enable-tls --enable-threads --without-isl --without-cloog --with-abi=lp64 --with-cpu=cortex-a53 --enable-languages=c,c++,fortran --with-build-time-tools=/builds/buildroot.org/toolchains-builder/build/aarch64--glibc--stable-2024.02-1/aarch64-buildroot-linux-gnu/bin --enable-shared --enable-libgomp
Thread model: posix
Supported LTO compression algorithms: zlib
gcc version 12.3.0 (Buildroot 2021.11-11272-ge2962af)
<…>
输出内容很多,但需要注意的有以下几点:
-
--with-sysroot
=/builds/buildroot.org/toolchains-builder/build/aarch64--glibc--stable-2024.02-1/aarch64-buildroot-linux-gnu/sysroot
:构建时sysroot
目录的位置。请参阅以下部分以了解详细说明。 -
--enable-languages=c,c++,fortran
:启用 C 语言、C++语言和 Fortran 语言。 -
--with-cpu=cortex-a53
:为 ARM Cortex-A53 核心生成代码。 -
--enable-threads
:启用 POSIX 线程。
这些是编译器的默认设置。你可以在gcc
命令行上覆盖其中的大部分。例如,如果你想为不同的 CPU 进行编译,可以通过在命令行添加-mcpu=cortex-a72
来覆盖已配置的--with-cpu
设置,如下所示:
$ aarch64-buildroot-linux-gnu-gcc -mcpu=cortex-a72 helloworld.c -o helloworld
你可以使用--target-help
打印出可用的架构特定选项,方法如下:
$ aarch64-buildroot-linux-gnu-gcc --target-help
你可能会想,是否在此时完全正确地配置它很重要,因为你总是可以稍后更改它。答案取决于你预计如何使用它。如果你计划为每个目标创建一个新的工具链,那么一开始就设置好所有内容是有意义的,因为这样可以减少以后出错的风险。
我称之为 Buildroot 哲学,我们将在第六章中再次讨论。如果你想构建一个通用的工具链,并且准备在为特定目标构建时提供正确的设置,那么你应该使基础工具链通用,这也是 Yocto 项目处理事务的方式。
现在我们已经看到在构建时sysroot
目录的位置,让我们来看一下安装在主机上的默认sysroot
目录的内容。
sysroot、库和头文件
工具链的sysroot
目录包含库、头文件和其他配置文件的子目录。它可以在配置工具链时通过--with-sysroot=
设置,或者可以在命令行中使用--sysroot=
设置。你可以通过使用-print-sysroot
查看默认sysroot
的位置:
$ aarch64-buildroot-linux-gnu-gcc -print-sysroot
/home/frank/aarch64--glibc--stable-2024.02-1/aarch64-buildroot-linux-gnu/sysroot
你将在sysroot
中找到以下子目录:
-
lib
:包含 C 库的共享对象和动态链接器/加载器ld-linux
。 -
usr/lib
:包含 C 库的静态库归档文件以及可能随后安装的其他库。 -
usr/include
:包含所有库的头文件。 -
usr/bin
:包含在目标设备上运行的实用程序,如ldd
命令。 -
usr/share
:用于本地化和国际化。 -
sbin
:提供用于优化库加载路径的ldconfig
工具。
这些工具中,有些在开发主机上用于编译程序,而其他工具,如共享库和ld-linux
,在目标设备运行时需要。
工具链中的其他工具
以下是调用 GNU 工具链中各个其他组件的命令列表。像aarch64-buildroot-linux-gnu-gcc
,这些工具都位于你添加到PATH
中的~/aarch64--glibc--stable-2024.02-1/bin/
目录中。以下是这些工具的简短描述:
-
addr2line
:通过读取可执行文件中的调试符号表,将程序地址转换为源代码文件名和行号。在解码系统崩溃报告中打印的地址时非常有用。 -
ar
:用于创建静态库的归档工具。 -
as
:GNU 汇编器。 -
c++filt
:解码 C++和 Java 符号。 -
cpp
:C 预处理器,用于展开#define
、#include
和其他类似指令。你很少需要单独使用它。 -
elfedit
:更新 ELF 文件的 ELF 头。 -
g++
:GNU C++前端,假设源文件包含 C++代码。 -
gcc
:GNU C 前端,假设源文件包含 C 代码。 -
gcov
:代码覆盖工具。 -
gdb
:GNU 调试器。 -
gprof
:程序分析工具。 -
ld
:GNU 链接器。 -
nm
:列出目标文件中的符号。 -
objcopy
:复制和转换目标文件。 -
objdump
:显示目标文件中的信息。 -
ranlib
:在静态库中创建或修改索引,使链接阶段更快。 -
readelf
:显示 ELF 对象格式文件的信息。 -
size
:列出各个段的大小以及总大小。 -
strings
:显示文件中可打印字符的字符串。 -
strip
:去除目标文件中的调试符号表,使其更小。通常,你会去除所有放置到目标上的可执行代码。
现在我们将从命令行工具切换话题,回到 C 库的主题。
查看 C 库的组件
C 库不是单一的库文件。它由四个主要部分组成,这些部分共同实现了 POSIX API:
-
libc
:主要的 C 库,包含众所周知的 POSIX 函数,如printf
、open
、close
、read
、write
等 -
libm
:包含数学函数,如cos
、exp
、log
-
libpthread
:包含所有以pthread_
开头的 POSIX 线程函数 -
librt
:具有 POSIX 的实时扩展,包括共享内存和异步 I/O
第一个库libc
总是被链接的,但其他库必须显式地使用-l
选项进行链接。-l
的参数是库名称,去掉lib
前缀。例如,一个通过调用sin()
来计算正弦函数的程序将使用-lm
链接libm
:
$ aarch64-buildroot-linux-gnu-gcc myprog.c -o myprog -lm
你可以通过使用readelf
命令来验证该程序或任何其他程序已链接的库:
$ aarch64-buildroot-linux-gnu-readelf -a myprog | grep "Shared library"
0x0000000000000001 (NEEDED) Shared library: [libm.so.6]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
共享库需要一个运行时链接器,你可以通过以下方式暴露它:
$ aarch64-buildroot-linux-gnu-readelf -a myprog | grep "program interpreter"
[Requesting program interpreter: /lib/ld-linux-aarch64.so.1]
这非常有用,以至于我有一个名为list-libs
的脚本文件,你可以在书籍代码档案中的MELD/list-libs
找到它。它包含以下命令:
${CROSS_COMPILE}readelf -a $1 | grep "program interpreter"
${CROSS_COMPILE}readelf -a $1 | grep "Shared library"
除了 C 库的四个组成部分外,我们还可以链接其他库文件。我们将在下一节中讨论如何做到这一点。
与库链接——静态链接和动态链接
你为 Linux 编写的任何应用程序,无论是 C 语言还是 C++,都会与 C 库libc
链接。这是如此基本,以至于你甚至不需要告诉gcc
或g++
去做,因为它总是会链接libc
。而你可能希望链接的其他库,则需要通过-l
选项显式指定。
库代码可以通过两种不同的方式进行链接:
-
静态链接:这意味着所有应用程序调用的库函数及其依赖项都从库文件中提取,并绑定到你的可执行文件中。
-
动态链接:这意味着代码中会生成对库文件和其中函数的引用,但实际的链接是在加载时动态完成的。
你可以在书籍代码档案中的MELD/Chapter02/library
找到接下来示例的代码。
静态库
静态链接在某些情况下非常有用。例如,如果你正在构建一个仅包含 BusyBox 和一些脚本文件的小型系统,将 BusyBox 静态链接会更简单,这样就避免了复制运行时库文件和链接器。由于你只链接应用程序使用的代码,而不是提供整个 C 库,二进制文件的体积也会更小。如果你需要在文件系统尚未可用时运行程序,静态链接也非常有用。
你可以通过在命令行中添加-static
来静态链接所有的库:
$ aarch64-buildroot-linux-gnu-gcc -static helloworld.c -o helloworld-static
你会注意到,二进制文件的大小显著增加:
$ ls -l helloworld*
-rwxrwxr-x 1 frank frank 8928 Apr 28 23:34 helloworld
-rw-rw-r-- 1 frank frank 123 Apr 28 23:30 helloworld.c
-rwxrwxr-x 1 frank frank 718472 Apr 28 23:33 helloworld-static
静态链接会从一个通常命名为 lib
$ export SYSROOT=$(aarch64-buildroot-linux-gnu-gcc -print-sysroot)
$ cd $SYSROOT
$ ls -l usr/lib/libc.a
-rw-r--r-- 1 frank frank 5551484 Mar 3 2024 usr/lib/libc.a
注意,语法export SYSROOT=$(aarch64-buildroot-linux-gnu-gcc -print-sysroot)
将sysroot
的路径放入 shell 变量SYSROOT
,这使得示例更加清晰。
创建静态库就像使用ar
命令创建目标文件的归档一样简单。如果我有两个源文件test1.c
和test2.c
(此练习没有 Git 示例——你需要自己生成test1.c
和test2.c
文件),并且我想创建一个名为libtest.a
的静态库,那么我会做以下操作:
$ aarch64-buildroot-linux-gnu-gcc -c test1.c
$ aarch64-buildroot-linux-gnu-gcc -c test2.c
$ aarch64-buildroot-linux-gnu-ar rc libtest.a test1.o test2.o
$ ls -l
total 24
-rw-rw-r-- 1 frank frank 2392 Nov 9 09:28 libtest.a
-rw-rw-r-- 1 frank frank 116 Nov 9 09:26 test1.c
-rw-rw-r-- 1 frank frank 1080 Nov 9 09:27 test1.o
-rw-rw-r-- 1 frank frank 121 Nov 9 09:26 test2.c
-rw-rw-r-- 1 frank frank 1088 Nov 9 09:27 test2.o
本书的 Git 仓库包含源代码和 makefile,帮助进行后续的链接练习:
$ cd MELD/Chapter02/library
$ tree
.
├── hello-arm
│ ├── hello-arm.c
│ └── Makefile
├── inc
│ └── testlib.h
├── shared
│ ├── Makefile
│ └── testlib.c
└── static
├── Makefile
└── testlib.c
编译静态libtest.a
库:
$ cd static
$ CC=aarch64-buildroot-linux-gnu-gcc make
aarch64-buildroot-linux-gnu-gcc -Wall -g -I../inc -c testlib.c
ar rc libtest.a testlib.o
编译hello-arm.c
并将其与libtest.a
链接,生成hello-arm-static
可执行文件:
$ cd ../hello-arm
$ CC=aarch64-buildroot-linux-gnu-gcc make hello-arm-static
aarch64-buildroot-linux-gnu-gcc -c -Wall -I../inc -o hello-arm.o hello-arm.c
aarch64-buildroot-linux-gnu-gcc -o hello-arm-static hello-arm.o -L../static -ltest
现在,让我们使用动态链接重新构建相同的程序。
共享库
部署库的更常见方法是将其作为共享对象在运行时链接,这样可以更高效地利用存储和系统内存,因为只需要加载一份代码副本。它还使得更新库文件变得更加容易,无需重新链接所有使用这些库的程序。
共享库的目标代码必须是位置无关的,以便运行时链接器可以在内存中的下一个空闲地址处找到它。为此,向gcc
添加-fPIC
参数,然后使用-shared
选项进行链接:
$ aarch64-buildroot-linux-gnu-gcc -fPIC -c test1.c
$ aarch64-buildroot-linux-gnu-gcc -fPIC -c test2.c
$ aarch64-buildroot-linux-gnu-gcc -shared -o libtest.so test1.o test2.o
这将生成共享库libtest.so
。要将应用程序与此库链接,你需要像静态链接一样添加-ltest
,但这次代码不会被包含在可执行文件中。相反,会有一个对库的引用,运行时链接器需要解析该引用。
编译共享的libtest.so
库:
$ cd MELD/Chapter02/library
$ cd shared
$ CC=aarch64-buildroot-linux-gnu-gcc make
aarch64-buildroot-linux-gnu-gcc -Wall -g -fPIC -I../inc -c testlib.c
aarch64-buildroot-linux-gnu-gcc -shared -o libtest.so testlib.o
编译hello-arm.c
并将其与libtest.so
链接,生成hello-arm-shared
可执行文件:
$ cd ../hello-arm
$ CC=aarch64-buildroot-linux-gnu-gcc make hello-arm-shared
aarch64-buildroot-linux-gnu-gcc -c -Wall -I../inc -o hello-arm.o hello-arm.c
aarch64-buildroot-linux-gnu-gcc -o hello-arm-shared hello-arm.o -L../shared -ltest
$ ~/MELD/list-libs hello-arm-shared
[Requesting program interpreter: /lib/ld-linux-aarch64.so.1]
0x0000000000000001 (NEEDED) Shared library: [libtest.so]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
本程序的运行时链接器是/lib/ld-linux-aarch64.so.1
,必须在目标系统的文件系统中存在。链接器将在默认的搜索路径/lib
和/usr/lib
中查找libtest.so
。如果你希望它在其他目录中查找库,可以将一个以冒号分隔的路径列表放入 shell 变量LD_LIBRARY_PATH
中:
$ export LD_LIBRARY_PATH=/opt/lib:/opt/usr/lib
由于共享库与可执行文件分开,你需要确保目标系统上安装了正确版本的共享库,以免遇到运行时错误。
理解共享库版本号
共享库的一个优点是它们可以独立于使用它们的程序进行更新。库更新有两种类型:
-
修复错误或以向后兼容的方式添加新功能的更新
-
破坏与现有应用程序兼容性的更新
GNU/Linux 有一个版本管理方案来处理这两种情况。
每个库都有一个发布版本和一个接口编号。发布版本仅仅是附加在库名称后的字符串。例如,JPEG 图像库libjpeg
目前的版本是 8.2.2,因此库文件名为libjpeg.so.8.2.2
。同时有一个名为libjpeg.so
的符号链接指向libjpeg.so.8.2.2
,这样在用-ljpeg
编译程序时,链接的是当前版本。如果你安装了 8.2.3 版本,链接会被更新,你将链接到那个版本。
假设现在有了 9.0.0 版本,并且它破坏了向后兼容性。此时,libjpeg.so
的链接指向libjpeg.so.9.0.0
,这样所有新的程序都会链接到新版本。当libjpeg
的接口发生变化时,结果将是编译错误,开发者可以修复这些错误。
在目标系统上没有重新编译的程序会以某种方式失败,因为它们仍然使用旧的接口。这时,名为soname的对象就派上用场了。soname 编码了库构建时的接口编号,运行时链接器在加载库时会用到它。它的格式为<库名称>.so.<接口编号>
。对于libjpeg.so.8.2.2
,soname 为libjpeg.so.8
,因为在构建该libjpeg
共享库时的接口编号是 8:
$ readelf -a /usr/lib/x86_64-linux-gnu/libjpeg.so.8.2.2 | grep SONAME
0x000000000000000e (SONAME) Library soname: [libjpeg.so.8]
用它编译的任何程序都会在运行时请求libjpeg.so.8
,而这将是目标上的一个符号链接,指向libjpeg.so.8.2.2
。当安装了libjpeg
的 9.0.0 版本时,它的 soname 是libjpeg.so.9
,因此同一个系统上可以安装两个不兼容的版本。用libjpeg.so.8.*.*
链接的程序会加载libjpeg.so.8
,而用libjpeg.so.9.*.*
链接的程序会加载libjpeg.so.9
。
这就是为什么当你查看/usr/lib/x86_64-linux-gnu/libjpeg*
目录时,会看到这四个文件:
-
libjpeg.a
:用于静态链接的库文件 -
libjpeg.so -> libjpeg.so.8.2.2
:用于动态链接的符号链接 -
libjpeg.so.8 -> libjpeg.so.8.2.2
:在运行时加载库时使用的符号链接 -
libjpeg.so.8.2.2
:在编译和运行时使用的实际共享库
前两个仅在主机计算机上用于构建,而最后两个则在目标计算机上用于运行时。
虽然你可以直接从命令行调用各种 GNU 跨编译工具,但这种方法在像helloworld
这样的小示例之外无法扩展。要真正有效地进行跨编译,我们需要将跨工具链与构建系统结合使用。
跨编译的艺术
拥有一个有效的跨工具链是开始这段旅程的起点,而不是终点。在某个时刻,你会希望开始为目标进行跨编译,编译你需要的各种工具、应用程序和库。其中许多将是开源软件包,每个包都有自己独特的编译方法和特殊性。
一些常见的构建系统包括:
-
纯 makefile,其中工具链通常由
make
变量CROSS_COMPILE
控制 -
GNU Autotools 构建系统
-
CMake
无论是 Autotools 还是 makefile,都需要构建即使是一个基础的嵌入式 Linux 系统。CMake 是跨平台的,且在过去几年里得到了更多的采用,尤其在 C++ 社区中。在本节中,我们将介绍这三种构建工具。
简单的 makefile
一些重要的包非常容易进行交叉编译,包括 Linux 内核、U-Boot 启动加载程序和 BusyBox。对于这些,您只需将工具链前缀放入 CROSS_COMPILE
变量中,例如 aarch64-buildroot-linux-gnu-
。注意末尾的连字符。
要编译 BusyBox,您需要输入:
$ make CROSS_COMPILE=aarch64-buildroot-linux-gnu-
或者您可以将其设置为 shell 变量:
$ export CROSS_COMPILE=aarch64-buildroot-linux-gnu-
$ make
在 U-Boot 和 Linux 的情况下,您还需要将 make
变量 ARCH
设置为它们支持的某个机器架构,我将在 第三章 和 第四章 中详细讲解。
Autotools 和 CMake 都可以生成 makefile。Autotools 仅生成 makefile,而 CMake 根据您要针对的目标平台(在我们的例子中严格是 Linux)支持其他构建项目的方式。我们先来看看使用 Autotools 进行交叉编译。
Autotools
Autotools 这个名字指的是一组用于许多开源项目构建系统的工具。这些组件及其对应的项目页面如下:
-
GNU autoconf (
www.gnu.org/software/autoconf/
) -
GNU automake (https://www.gnu.org/software/automake/)
-
GNU libtool (
www.gnu.org/software/libtool/)
) -
gnulib (
www.gnu.org/software/gnulib/)
)
Autotools 的作用是弥合包可能需要编译的不同类型系统之间的差异,考虑到不同版本的编译器、不同版本的库、头文件的位置差异以及与其他包的依赖关系。
使用 Autotools 的包附带一个名为 configure
的脚本,该脚本检查依赖关系并根据其查找的内容生成 makefile。configure
脚本还可能让您有机会启用或禁用某些功能。您可以通过运行 ./configure --help
查找可用的选项。
要配置、构建并安装适用于本地操作系统的包,您通常需要运行以下三个命令:
$ ./configure
$ make
$ sudo make install
Autotools 也可以处理交叉开发。您可以通过设置这些 shell 变量来影响配置脚本的行为:
-
CC
:C 编译器命令 -
CFLAGS
:额外的 C 编译器标志 -
CXX
:C++ 编译器命令 -
CXXFLAGS
:额外的 C++ 编译器标志 -
LDFLAGS
:附加的链接器标志;例如,如果您的库位于非标准目录<lib dir>
中,您可以通过添加-L<lib dir>
将其加入库搜索路径。 -
LIBS
:传递给链接器的附加库列表;例如,-lm
用于数学库 -
CPPFLAGS
:C/C++ 预处理器标志;例如,你可以添加-I<include dir>
来搜索非标准目录<include dir>
中的头文件。 -
CPP
:使用的 C 预处理器
有时,仅设置 CC
变量就足够了,如下所示:
$ CC=aarch64-buildroot-linux-gnu-gcc ./configure
其他时候,可能会导致如下错误:
<…>
checking for suffix of executables...
checking whether we are cross compiling... configure: error: in '/home/frank/sqlite-autoconf-3440000':
configure: error: cannot run C compiled programs.
If you meant to cross compile, use '--host'.
See 'config.log' for more details
失败的原因是,configure
通常会尝试通过编译代码片段并运行它们来发现工具链的能力,而如果程序是交叉编译的,这种方式无法正常工作。
重要提示
在交叉编译时,传递 --host=<host>
给 configure
,以便 configure
搜索你的系统,找到目标 <host>
平台的交叉编译工具链。这样,configure
就不会尝试将非本地代码片段作为配置步骤的一部分来执行。
Autotools 理解在编译包时可能涉及的三种不同类型的机器:
-
build:构建包的计算机,默认为当前机器。
-
host:程序运行的计算机。对于本地编译,这一项留空,默认为与 build 相同的计算机。当你进行交叉编译时,设置为工具链的元组。
-
target:程序生成代码的计算机。在构建交叉编译器时,你需要设置此项。
要进行交叉编译,你只需要按如下方式覆盖 host:
$ CC=aarch64-buildroot-linux-gnu-gcc ./configure --host=aarch64-buildroot-linux-gnu
最后要注意的一点是,默认的安装目录是 <sysroot>/usr/local
。你通常会将其安装到 <sysroot>/usr
,这样头文件和库就能从默认位置被提取。
配置典型 Autotools 包的完整命令如下:
$ CC=aarch64-buildroot-linux-gnu-gcc ./configure --host=aarch64-buildroot-linux-gnu --prefix=/usr
让我们更深入地了解 Autotools,并使用它交叉编译一个流行的库。
一个例子:SQLite
SQLite 库实现了一个简单的关系型数据库,并且在嵌入式设备上非常流行。
首先,开始获取 SQLite 的副本:
$ wget http://www.sqlite.org/2023/sqlite-autoconf-3440000.tar.gz
$ tar xf sqlite-autoconf-3440000.tar.gz
$ cd sqlite-autoconf-3440000
SQLite 版本 3.44.0 可能不再可用。如果是这样,请从 SQLite 下载页面下载一个更新版本的源代码:www.sqlite.org/download.html.
修改前面的 tar
和 cd
命令,以匹配新 tarball 的文件名。
接下来,运行 configure
脚本:
$ CC=aarch64-buildroot-linux-gnu-gcc ./configure --host=aarch64-buildroot-linux-gnu --prefix=/usr
似乎可行!如果失败,终端会打印错误信息,并记录在 config.log
文件中。注意,已经创建了多个 makefile,现在你可以进行编译:
$ make
最后,通过设置 make 变量 DESTDIR
,你将其安装到工具链目录。如果不这样做,它会尝试将其安装到主机计算机的 /usr
目录中,而这不是你想要的:
$ make DESTDIR=$(aarch64-buildroot-linux-gnu-gcc -print-sysroot) install
你可能会发现最终命令因为文件权限错误而失败,因为工具链安装在像 /opt
或 /usr/local
这样的系统目录中。在这种情况下,运行安装时你需要 root 权限。
安装后,你应该会发现各种文件已经被添加到你的工具链中:
-
<sysroot>/usr/bin
:sqlite3
:一个可以在目标上安装并运行的 SQLite 命令行界面 -
<sysroot>/usr/lib
:libsqlite3.so.0.8.6
,libsqlite3.so.0
,libsqlite3.so
,libsqlite3.la
,libsqlite3.a
:共享库和静态库 -
<sysroot>/usr/lib/pkgconfig
:sqlite3.pc
:如下一节所述的包配置文件 -
<sysroot>/usr/include
:sqlite3.h
,sqlite3ext.h
:头文件 -
<sysroot>/usr/share/man/man1
:sqlite3.1
:手册页
现在,你可以通过在链接阶段添加 -lsqlite3
来编译使用 sqlite3 的程序:
$ aarch64-buildroot-linux-gnu-gcc -lsqlite3 sqlite-test.c -o sqlite-test
在这里,sqlite-test.c
是一个假设的程序,它调用了 SQLite 函数。由于 sqlite3
已经安装到 sysroot
中,编译器将毫不费力地找到头文件和库文件。如果它们安装在其他位置,你需要添加 -L<lib dir>
和 -I<include dir>
。
自然地,也会有运行时依赖项,你需要将适当的文件安装到目标目录,具体请参考 第五章。
要交叉编译一个库或包,首先需要交叉编译它的依赖项。Autotools 依赖于一个名为 pkg-config
的工具来收集 Autotools 交叉编译的包的关键信息。
包配置
跟踪包依赖关系相当复杂。包配置工具 pkg-config
帮助跟踪哪些包已安装以及每个包需要的编译标志,它通过在 <sysroot>/usr/lib/pkgconfig
中保持 Autotools 包的数据库来实现。例如,SQLite3 的配置文件名为 sqlite3.pc
,其中包含其他依赖于它的包所需的关键信息:
cat $(aarch64-buildroot-linux-gnu-gcc -print-sysroot)/usr/lib/pkgconfig/sqlite3.pc
# Package Information for pkg-config
prefix=/usr
exec_prefix=${prefix}
libdir=${exec_prefix}/lib
includedir=${prefix}/include
Name: SQLite
Description: SQL database engine
Version: 3.44.0
Libs: -L${libdir} -lsqlite3
Libs.private: -lm -ldl -lpthread
Cflags: -I${includedir}
你可以使用 pkg-config
提取你可以直接传递给 gcc
的信息。对于像 libsqlite3
这样的库,你需要知道库名(--libs
)和任何特殊的 C 标志(--cflags
):
$ pkg-config sqlite3 --libs --cflags
Package sqlite3 was not found in the pkg-config search path.
Perhaps you should add the directory containing 'sqlite3.pc' to the PKG_CONFIG_PATH environment variable
No package 'sqlite3' found
哎呀!失败了,因为它在查找主机的 sysroot
,并且主机上没有安装 libsqlite3
的开发包。你需要通过设置 shell 变量 PKG_CONFIG_LIBDIR
来将其指向目标工具链的 sysroot
:
$ export PKG_CONFIG_LIBDIR=$(aarch64-buildroot-linux-gnu-gcc -print-sysroot)/usr/lib/pkgconfig
$ pkg-config sqlite3 --libs --cflags
-lsqlite3
现在输出是 -lsqlite3
。在这种情况下,你已经知道了这一点,但通常来说你并不知道,所以这是一个非常有价值的技巧。最终的编译命令是:
$ export PKG_CONFIG_LIBDIR=$(aarch64-buildroot-linux-gnu-gcc -print-sysroot)/usr/lib/pkgconfig
$ aarch64-buildroot-linux-gnu-gcc $(pkg-config sqlite3 --cflags --libs) sqlite-test.c -o sqlite-test
许多 configure
脚本会读取 pkg-config
生成的信息。正如我们接下来会看到的,这可能会导致交叉编译时出现错误。
交叉编译问题
sqlite3
是一个表现良好的包,能够顺利进行交叉编译,但并非所有包都是如此。典型的痛点包括:
-
自制的构建系统,例如
zlib
,它有一个configure
脚本,行为与前面所述的 Autotoolsconfigure
不同 -
configure
脚本读取pkg-config
信息、头文件和来自主机的其他文件,而忽略--host
覆盖选项 -
脚本强制尝试运行交叉编译的代码
每种情况都需要仔细分析错误。我们可以通过向 configure
脚本传递额外的参数来提供正确的信息,或者对代码应用补丁以完全避免该问题。请记住,一个软件包可能有许多依赖关系。对于有图形界面或处理多媒体内容的程序尤其如此。例如,MPlayer 有超过 100 个库的依赖关系。编译它们所有需要几周的时间。
因此,除非没有其他选择或要构建的软件包数量较少,否则我不推荐以这种方式手动为目标进行交叉编译。
更好的方法是使用像 Buildroot 或 The Yocto Project 这样的构建工具,或者通过为目标架构设置本地构建环境来完全避免这个问题。现在你可以明白为什么像 Debian 这样的发行版总是进行本地编译。
CMake
CMake 更像是一个元构建系统,因为它依赖于底层平台的本地工具来构建软件。在 Windows 上,CMake 可以为 Microsoft Visual Studio 生成项目文件,在 macOS 上,它可以为 Xcode 生成项目文件。与各大平台的主要集成开发环境(IDE)整合不是一项简单的任务,这也解释了 CMake 作为领先的跨平台构建系统解决方案的成功。CMake 也可以在 Linux 上运行,在这里它可以与您选择的交叉编译工具链一起使用。
要为本地 Linux 操作系统配置、构建和安装包,请运行以下命令:
$ cmake .
$ make
$ sudo make install
在 Linux 上,本地构建工具是 GNU make
,因此 CMake 默认为我们生成 makefile 来进行构建。通常,我们希望执行外部构建,以便对象文件和其他构建产物与源文件分开。
要在名为build
的子目录中配置一个外部构建,请运行以下命令:
$ mkdir build
$ cd build
$ cmake ..
这将在项目目录中生成一个名为 build
的子目录,其中包含 CMakeLists.txt
文件。CMakeLists.txt
文件是 CMake 等价于 Autotools 项目的 configure
脚本。
然后我们可以从 build
目录内在源外构建项目,并像以前一样安装该包:
$ make
$ sudo make install
CMake 使用绝对路径,因此一旦生成了 makefile,build
子目录就无法被复制或移动,否则任何后续的 make
步骤可能会失败。请注意,即使是外部构建,CMake 也默认将包安装到像 /usr/bin
这样的系统目录中。
要生成 makefile,以便 CMake 将包安装到 build
子目录中,请将之前的 cmake
命令替换为以下命令:
$ cmake .. -D CMAKE_INSTALL_PREFIX=../build
我们不再需要在make install
前加上sudo
,因为我们不需要提升权限就可以将包文件复制到build
目录。
同样,我们可以使用另一个 CMake 命令行选项来为交叉编译生成 makefile:
$ cmake .. -D CMAKE_C_COMPILER="/home/frank/aarch64--glibc--stable-<version>/bin/aarch64-buildroot-linux-gnu-gcc"
但是,交叉编译时使用 CMake 的最佳实践是创建一个工具链文件,除了设置CMAKE_C_COMPILER
和CMAKE_CXX_COMPILER
外,还要为嵌入式 Linux 目标设置其他相关变量。
当我们以模块化的方式设计软件,并在库和组件之间强制定义良好的 API 边界时,CMake 效果最好。
这里列出了一些在 CMake 中反复出现的关键术语:
-
target
:一个软件组件,如库或可执行文件。 -
properties
:构建目标所需的源文件、编译器选项和链接库。 -
package
:一个 CMake 文件,用于配置外部目标的构建,就像它在你的CMakeLists.txt
中定义一样。
例如,如果我们有一个基于 CMake 的可执行文件dummy
,并且它需要依赖 SQLite,我们可以定义以下的CMakeLists.txt
:
cmake_minimum_required (VERSION 3.0)
project (Dummy)
add_executable(dummy dummy.c)
find_package (SQLite3)
target_include_directories(dummy PRIVATE ${SQLITE3_INCLUDE_DIRS})
target_link_libraries (dummy PRIVATE ${SQLITE3_LIBRARIES})
find_package
命令查找一个包(在此例中为 SQLite3)并将其导入,这样外部目标就可以作为依赖项添加到dummy
可执行文件的target_link_libraries
链接列表中。
CMake 提供了许多用于流行 C 和 C++包的查找器,包括 OpenSSL、Boost 和 protobuf,使得本地开发比单纯使用 makefile 要高效得多。
PRIVATE
限定符可以防止诸如头文件和标志等细节泄漏到dummy
目标之外。当构建的目标是库而非可执行文件时,使用PRIVATE
更有意义。把目标视作模块,并在使用 CMake 定义自己目标时尽量减少暴露的表面区域。只有在绝对必要时才使用PUBLIC
限定符,并对仅包含头文件的库使用INTERFACE
限定符。
将你的应用程序建模为一个依赖关系图,图中的边连接目标。这个图不仅应包含你的应用程序直接链接的库,还应包括任何传递依赖项。为了获得最佳效果,移除图中的任何循环或其他不必要的独立性。通常最好在开始编码之前执行这个步骤。适当的规划可以让你拥有一个干净、易于维护的CMakeLists.txt
,而不是一个没人愿意触碰的复杂混乱的文件。
摘要
工具链始终是你的起点。之后的一切都依赖于有一个工作正常且可靠的工具链。
你可以从 Bootlin 或 Linaro 下载一个工具链,开始时什么都没有,并用它来编译目标上所需的所有包。或者,你也可以将工具链作为从源代码生成的发行版的一部分,使用 Buildroot 或 Yocto 项目等构建系统获取。
小心那些作为硬件包的一部分免费提供给你的工具链或发行版。它们通常配置不当且没有得到维护。
一旦你拥有了工具链,你就可以用它来构建嵌入式 Linux 系统的其他组件。在下一章中,你将了解引导加载程序,它将激活你的设备并开始启动过程。我们将在本章中构建的工具链上,构建一个适用于 BeaglePlay 的工作引导加载程序。
深入学习
-
2023 年的工具链选项:编译器和库的新变化,由 Bernhard “Bero” Rosenkränzer 主讲 –
www.youtube.com/watch?v=Vgm3GJ2ItDA
-
现代 CMake 与模块化设计,由 Mathieu Ropert 主讲 –
www.youtube.com/watch?v=eC9-iRN2b04
第三章:所有关于引导加载程序的内容
引导加载程序是嵌入式 Linux 的第二个元素。它是启动系统并加载操作系统内核的部分。在本章中,我们将探讨引导加载程序的作用,以及它如何通过一个名为设备树的数据结构将控制权从自己传递给内核,设备树也称为扁平设备树或FDT。
我将介绍设备树的基础知识,以便您能够理解设备树中描述的连接并将其与实际硬件关联起来。我将重点介绍一个流行的开源引导加载程序 U-Boot,并展示如何使用它启动目标设备。我还将展示如何使用 BeaglePlay 作为示例,自定义 U-Boot 以便在新设备上运行。
本章将涵盖以下内容:
-
引导加载程序的作用是什么?
-
启动顺序
-
从引导加载程序到内核的过渡
-
介绍设备树
-
U-Boot
技术要求
要进行示例操作,请确保您拥有以下内容:
-
一台安装有
device-tree-compiler
、git
、make
、patch
和u-boot-tools
的 Ubuntu 24.04 或更高版本 LTS 主机系统 -
一个来自第二章的 BeaglePlay Bootlin 工具链
-
一个 microSD 卡读卡器和卡片
-
一条 3.3 V 逻辑电平的 USB 到 TTL 串口电缆
-
BeaglePlay
-
一款能够提供 3 A 电流的 5 V USB-C 电源
本章的所有代码可以在书籍的 GitHub 仓库中的 Chapter03
文件夹中找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development/tree/main/Chapter03
。
引导加载程序的作用是什么?
在嵌入式 Linux 系统中,引导加载程序有两个主要任务:将系统初始化到基本状态并加载内核。实际上,第一个任务在某种程度上是从属于第二个任务的,因为它只需要将系统启动到足以加载内核的程度。
当引导加载程序的第一行代码在开机或重启后执行时,系统处于非常简化的状态。动态随机存取内存(DRAM)控制器尚未设置,因此主内存不可访问。同样,其他接口也没有配置,因此通过NAND(非与)闪存控制器、多媒体卡(MMC)控制器等访问的存储也不可用。通常,开始时唯一可用的资源是一个 CPU 核心、一些片上静态随机存取内存(SRAM)和引导只读存储器(ROM)。
系统启动包括几个阶段的代码,每个阶段使系统的更多部分投入运行。引导加载程序的最后一步是将内核加载到 RAM 中,并为其创建一个执行环境。引导加载程序与内核之间的接口细节是架构特定的,但在每种情况下,它需要做两件事。首先,引导加载程序必须传递一个指针,指向包含硬件配置相关信息的结构。其次,它必须传递一个指针,指向内核命令行。
内核命令行是一个文本字符串,用于控制 Linux 的行为。一旦内核开始执行,引导加载程序就不再需要,所有它所占用的内存可以被回收。
引导加载程序的一个辅助任务是提供一个维护模式,用于更新引导配置、将新的引导映像加载到内存中,甚至可能执行诊断。通常通过一个简单的命令行用户界面来控制,通常是通过串行控制台。
启动序列
几年前,我们只需要将引导加载程序放置在处理器的复位矢量处的非易失性存储器中。当时NOR(非或)闪存存储器很常见,并且由于它可以直接映射到地址空间,因此是理想的存储方法。以下图示展示了这样一种配置,其中复位矢量位于0xfffffffc
,位于闪存区域的顶部:
图 3.1 – NOR 闪存
引导加载程序被链接,以便在该位置有一个跳转指令,指向引导加载程序代码的起始位置。从那时起,运行在 NOR 闪存中的引导加载程序代码可以初始化 DRAM 控制器,使得主内存——DRAM——变得可用,然后它将自己复制到 DRAM 中。引导加载程序完全运行后,可以将内核从闪存加载到 DRAM 中,并将控制权转交给内核。
然而,一旦你远离像 NOR 闪存这样简单的线性可寻址存储介质,启动序列就会变成一个复杂的多阶段过程。具体细节因每个 SoC 而异,但它们通常会经过以下几个阶段。
阶段 1 – ROM 代码
在没有可靠外部存储器的情况下,复位或开机后立即运行的代码存储在 SoC 芯片上。这被称为ROM 代码。它在芯片制造时就被加载,因此 ROM 代码是专有的,不能被开源等效物替代。
ROM 代码不包括初始化内存控制器的代码,因为 DRAM 的配置高度依赖于设备,因此只能使用不需要内存控制器的 SRAM。大多数嵌入式 SoC 设计在芯片上都有少量的 SRAM,大小从最小的 4 KB 到几百 KB 不等。
图 3.2 – 阶段 1 – ROM 代码
ROM 代码可以从多个预编程位置之一加载一小段代码到 SRAM 中。例如,TI Sitara 芯片尝试从 NAND 闪存的前几页或通过串行外设接口(SPI)连接的闪存加载代码。它们还尝试从像 eMMC 芯片或 SD 卡这样的 MMC 设备的第一个分区中名为MLO(Memory Loader)的文件中加载代码。如果从所有这些存储设备读取失败,则尝试从以太网、USB 或 UART 读取字节流。后者主要用于在生产中将代码加载到闪存中,而不是用于正常操作。
大多数嵌入式 SoC 都有类似方式工作的 ROM 代码。在 SRAM 不足以加载诸如 U-Boot 之类的完整引导加载程序的 SoC 中,需要有一个称为次级程序加载器(SPL)的中间加载器。在 ROM 代码阶段结束时,SPL 存在于 SRAM 中,并且 ROM 代码跳转到该代码的开头。
第 2 阶段 – 次级程序加载器
SPL 必须设置内存控制器和系统的其他重要部分,以准备将第三级程序加载器(TPL)加载到 DRAM 中。SPL 的功能受 SRAM 大小的限制。它可以像 ROM 代码一样从存储设备列表中读取程序,再次使用从闪存设备起始处的预编程偏移量。
如果 SPL 内置文件系统驱动程序,它可以从磁盘分区读取像u-boot.img
这样的众所周知的文件名。通常 SPL 不允许用户交互,但它可能会打印版本信息和进度消息,您可以在控制台上看到。以下图显示了第 2 阶段的架构:
图 3.3 – 第 2 阶段 – SPL
前面的图显示了从 ROM 代码到 SPL 的跳转。当 SPL 在 SRAM 中执行时,它将 TPL 加载到 DRAM 中。第二阶段结束时,TPL 存在于 DRAM 中,SPL 可以跳转到该区域。
SPL 可能是开源的,例如 Atmel AT91Bootstrap 就是如此,但它通常包含供应商提供的作为二进制 blob 的专有代码。
第 3 阶段 – 第三级程序加载器
此时,我们正在运行一个完整的引导加载程序,例如 U-Boot,稍后我们将在本章中详细了解。通常,有一个简单的命令行用户界面,可以让您执行维护任务,例如将新的引导和内核映像加载到闪存中,以及自动加载内核而无需用户干预。以下图解释了第 3 阶段的架构:
图 3.4 – 第 3 阶段 – TPL
上面的图示展示了从 SRAM 中的 SPL 到 DRAM 中的 TPL 的跳转。当 TPL 执行时,它将内核加载到 DRAM 中。如果需要,我们还可以选择将 FDT 和/或初始 RAM 磁盘附加到 DRAM 中的镜像中。无论哪种方式,在第三阶段结束时,内存中都有一个等待启动的内核。
嵌入式引导加载程序通常在内核运行后从内存中消失,不再参与系统的操作。在此之前,TPL 需要将引导过程的控制权交给内核。
从引导加载程序到内核的过渡
当引导加载程序将控制权交给内核时,它必须传递一些基本信息,包括以下内容:
-
机器号,在没有设备树支持的 PowerPC 和 Arm 平台上,用来标识 SoC 的类型
-
目前已检测到的硬件的基本信息,包括(至少)物理 RAM 的大小和位置以及 CPU 的时钟速度
-
内核命令行
-
可选地,设备树二进制文件的位置和大小
-
可选地,初始 RAM 磁盘的位置和大小,称为初始 RAM 文件系统(initramfs)
内核命令行是一个纯 ASCII 字符串,通过它可以控制 Linux 的行为,例如,指定包含根文件系统的设备名称。我们将在下一章详细介绍内核命令行。通常会提供根文件系统作为 RAM 磁盘,在这种情况下,引导加载程序的责任是将 RAM 磁盘镜像加载到内存中。我们将在第五章中介绍如何创建初始 RAM 磁盘。
这种信息传递方式取决于架构,并且近年来发生了变化。例如,在 PowerPC 中,引导加载程序通常只会传递指向板级信息结构的指针,而在 Arm 中,它传递指向 A 标签列表的指针。关于 A 标签格式的详细描述可以在内核源代码树中的Documentation/arch/arm/booting.rst
找到。你可以浏览内核源代码树:github.com/torvalds/linux
。
在这两种情况下,传递的信息量都非常有限,剩下的部分要么在运行时发现,要么作为平台数据硬编码到内核中。平台数据的广泛使用意味着每块开发板都必须有一个为该平台配置和修改过的内核。需要一种更好的方法,而这种方法就是设备树。
在 Arm 领域,摆脱 A 标签的转变始于 2013 年 2 月,当时 Linux 3.8 发布。今天,几乎所有 Arm 系统都使用设备树来收集硬件平台的具体信息。这使得单一内核二进制文件可以在多种 Arm 平台上运行。
现在我们已经了解了引导加载程序的作用、引导序列的各个阶段以及它如何将控制权交给内核,接下来让我们学习如何配置一个引导加载程序,以便它能够在流行的嵌入式 SoC 上运行。
介绍设备树
如果您正在使用 Arm 或 PowerPC SoC,您几乎可以肯定在某个时刻会遇到设备树。本节旨在为您提供一个快速概述,了解设备树是什么以及它们如何工作。我们将在本书的后续章节中反复讨论设备树的话题。
设备树是一种灵活的定义计算机系统硬件组件的方式。请记住,设备树只是静态数据,而不是可执行代码。通常,设备树由引导加载程序加载并传递给内核,尽管也可以将设备树与内核镜像捆绑在一起,以便支持那些无法单独加载设备树的引导加载程序。
该格式源自 Sun Microsystems 的引导加载程序 OpenBoot,并被正式化为 Open Firmware 规范(IEEE 标准 IEEE1275-1994)。它曾被用于基于 PowerPC 的 Macintosh 计算机,因此是 PowerPC Linux 移植的一个合乎逻辑的选择。从那时起,它已被大量采用,广泛用于许多 Arm Linux 实现,并在较小范围内应用于 MIPS、MicroBlaze、ARC 和其他架构。
我建议访问 www.devicetree.org
获取更多信息。
设备树基础
Linux 内核包含大量的设备树源文件,位于 arch/$ARCH/boot/dts
目录下,这里是学习设备树的一个很好的起点。此外,U-Boot 源代码包含较少的源文件,位于 arch/$ARCH/dts
目录下。如果您从第三方获得了硬件,dts
文件是板卡支持包的一部分,因此您应该预期会收到它以及其他源文件。
设备树将计算机系统表示为一个层次结构中连接在一起的组件集合。每个设备树以一个根节点开始,该根节点由一个斜杠(/
)表示,后续的子节点描述了系统的硬件。每个节点都有一个名称,并包含多个以 name = "value"
形式的属性。以下是一个简单的示例:
/dts-v1/;
/{
model = "TI AM335x BeagleBone";
compatible = "ti,am33xx";
#address-cells = <1>;
#size-cells = <1>;
cpus {
#address-cells = <1>;
#size-cells = <0>;
cpu@0 {
compatible = "arm,cortex-a8";
device_type = "cpu";
reg = <0>;
};
};
memory@80000000 {
device_type = "memory";
reg = <0x80000000 0x20000000>; /* 512 MB */
};
};
在这里,我们有一个根节点,它包含一个 cpus
节点和一个 memory
节点。cpus
节点包含一个名为 cpu@0
的单一 CPU 节点。这些节点的名称通常包括一个 @
,后面跟着一个地址,用以区分同类型的其他节点。如果节点有 reg
属性,则必须使用 @
。
根节点和 CPU 节点都有一个 compatible
属性。Linux 内核使用这个属性通过将其与每个设备驱动程序在 of_device_id
结构中导出的字符串进行比较,从而找到匹配的设备驱动程序(更多内容请参见 第十一章)。
重要提示
传统上,compatible
属性的值由制造商名称和组件名称组成,以减少由不同制造商制造的类似设备之间的混淆,因此有 ti,am33xx
和 arm,cortex-a8
。当有多个驱动程序可以处理此设备时,compatible
属性具有多个值是非常常见的。它们按照最适合的顺序列出。
CPU 节点和内存节点都有一个 device_type
属性,用于描述设备的类别。节点名称通常来源于 device_type
。
reg
属性
先前显示的 memory
和 cpu
节点具有 reg
属性,该属性引用寄存器空间中的一系列单元。reg
属性由两个值组成,表示实际物理地址和范围的大小(长度)。两者都写成零或多个称为单元的 32 位整数。因此,先前的 memory
节点引用了从 0x80000000
开始、长度为 0x20000000
字节的内存单元。
当地址或大小值无法用 32 位表示时,理解 reg
属性变得更加复杂。例如,在具有 64 位寻址的设备上,每个地址或大小值需要两个单元:
/{
#address-cells = <2>;
#size-cells = <2>;
memory@80000000 {
device_type = "memory";
reg = <0x00000000 0x80000000 0 0x80000000>;
};
};
关于所需单元数的信息存储在祖先节点的 #address-cells
和 #size_cells
属性中。换句话说,要理解 reg
属性,你必须向节点层次结构向后查找,直到找到 #address-cells
和 #size_cells
。如果没有,则默认值为每个都是 1
—— 但依赖默认值是设备树编写者的不良实践。默认值可能明显,也可能不明显,因此明确表示可以避免任何误解。
现在,让我们回到 cpu
和 cpus
节点。CPU 也有地址。在四核设备中,它们可能被寻址为 0
、1
、2
和 3
。可以将其视为一个没有深度的一维数组,因此大小为零。因此,你可以看到在 cpus
节点中我们有 #address-cells = <1>
和 #size-cells = <0>
。在子节点 cpu@0
中,我们通过 reg = <0>
为 reg
属性分配了一个单一值。
标签和中断
到目前为止描述的设备树结构假设存在单一的组件层次结构,实际上可能有几个。除了组件与系统其他部分之间的明显数据连接外,节点还可能连接到中断控制器、时钟源和电压调节器。
为了表达这些连接,我们可以向节点添加标签,并从其他节点引用该标签。这些标签有时被称为phandles,因为在编译设备树时,具有从另一个节点引用的节点会在称为 phandle
的属性中被分配一个唯一的数值。
如果你反编译设备树二进制文件,你可以看到 phandle。以包含一个可以生成interrupts
和interrupt-controller
的 LCD 控制器的系统为例:
/dts-v1/;
{
intc: interrupt-controller@48200000 {
compatible = "ti,am33xx-intc";
interrupt-controller;
#interrupt-cells = <1>;
reg = <0x48200000 0x1000>;
};
lcdc: lcdc@4830e000 {
compatible = "ti,am33xx-tilcdc";
reg = <0x4830e000 0x1000>;
interrupt-parent = <&intc>;
interrupts = <36>;
ti,hwmods = "lcdc";
status = "disabled";
};
};
这里,我们有一个interrupt-controller@48200000
节点,标签为intc
。interrupt-controller
属性将其标识为一个中断控制器。像所有中断控制器一样,它有一个#interrupt-cells
属性,告诉我们表示中断源需要多少个单元。在这个例子中,只有一个单元表示中断请求(IRQ)编号。
其他中断控制器可能会使用附加的单元来表征中断,例如,指示它是边沿触发还是电平触发。中断单元的数量及其含义在每个中断控制器的绑定文件中有所描述。设备树绑定可以在 Linux 内核源代码中的Documentation/devicetree/bindings
目录下找到。
查看lcdc@4830e000
节点,它有一个interrupt-parent
属性,引用了它所连接的中断控制器,并使用标签。它还有一个interrupts
属性,这里为36
。注意,这个节点有自己的标签lcdc
,该标签在其他地方也会使用。任何节点都可以有标签。
设备树包含文件
vexpress-v2p-ca9.dts:
/include/ "vexpress-v2m.dtsi"
浏览内核中的.dts
文件,你会发现一个借用自 C 语言的替代include
语句;例如,这在am335x-boneblack.dts
中:
#include "am33xx.dtsi"
#include "am335x-bone-common.dtsi"
这是来自am33xx.dtsi
的另一个例子:
#include <dt-bindings/gpio/gpio.h>
#include <dt-bindings/pinctrl/am33xx.h>
#include <dt-bindings/clock/am3.h>
最后,include/dt-bindings/pinctrl/am33xx.h
包含了普通的 C 宏:
#define PULL_DISABLE (1 << 3)
#define INPUT_EN (1 << 5)
#define SLEWCTRL_SLOW (1 << 6)
#define SLEWCTRL_FAST 0
如果使用 Kbuild 系统构建设备树源文件,所有这些问题都可以得到解决,Kbuild 系统会通过 C 预处理器(CPP)处理它们。CPP 将#include
和#define
语句处理成适合设备树编译器的文本。动机通过之前的例子进行了说明。这意味着设备树源文件可以使用与内核代码相同的常量定义。
当我们使用任何语法包含文件时,节点会彼此重叠,创建一个复合树,其中外层扩展或修改内层。例如,am33xx.dtsi
是针对所有am33xx
SoC 的通用文件,它像这样定义了第一个 MMC 控制器接口:
mmc1: mmc@48060000 {
compatible = "ti,omap4-hsmmc";
ti,hwmods = "mmc1";
ti,dual-volt;
ti,needs-special-reset;
ti,needs-special-hs-handling;
dmas = <&edma_xbar 24 0 0
&edma_xbar 25 0 0>;
dma-names = "tx", "rx";
interrupts = <64>;
reg = <0x48060000 0x1000>;
status = "disabled";
};
请注意,status
为disabled
,这意味着没有设备驱动程序应该绑定到它,而且它的标签为mmc1
。
BeagleBone 和 BeagleBone Black 都有一个连接到mmc1
的 microSD 卡接口。这就是为什么在am335x-bone-common.dtsi
中,通过与符号&
一起使用它的标签&mmc1
来引用相同的节点:
&mmc1 {
status = "okay";
bus-width = <0x4>;
pinctrl-names = "default";
pinctrl-0 = <&mmc1_pins>;
cd-gpios = <&gpio0 6 GPIO_ACTIVE_LOW>;
};
通过使用与标签关联的符号(&)来引用节点,可以覆盖之前 mmc1
条目的属性。在这里,status
属性被设置为 okay
,这使得 MMC 设备驱动程序在 BeagleBone 的两个变种上都在运行时与该接口绑定。此外,还在引脚控制配置 mmc1_pins
中添加了对标签的引用。遗憾的是,本文没有足够的篇幅来描述引脚控制和引脚复用。有关更多信息,可以参考 Linux 内核源代码中 Documentation/devicetree/bindings/pinctrl
目录下的文档。
然而,BeagleBone Black 上的 mmc1
接口连接到一个不同的电压调节器。这在 am335x-boneblack.dts
文件中有所体现,你会看到另一个对 mmc1
的引用,它通过 vmmcsd_fixed
标签将其与电压调节器关联:
&mmc1 {
vmmc-supply = <&vmmcsd_fixed>;
};
因此,以这种方式分层设备树源文件为我们提供了灵活性,并减少了重复代码的需要。
编译设备树
引导加载程序和内核需要设备树的二进制表示形式,因此必须使用设备树编译器 dtc
来编译。编译后的结果是一个以 .dtb
结尾的文件,这被称为设备树二进制文件或设备树 Blob。
在 Linux 源代码中的 scripts/dtc/dtc
目录里有 dtc
的副本,而且它也可以作为包在许多 Linux 发行版中使用。你可以用它来编译一个简单的设备树(不使用 #include
):
$ dtc simpledts-1.dts -o simpledts-1.dtb
DTC: dts->dts on file "simpledts-1.dts"
请注意,dtc
并不会提供有用的错误信息,只会检查语言的基本语法。因此,调试设备树源文件中的输入错误可能是一个漫长的过程。
要构建更复杂的示例,您必须使用 Kbuild 内核,如 第四章 所示。
与内核类似,引导加载程序可以使用设备树来初始化嵌入式 SoC 及其外设。当你从如 QSPI 闪存等大容量存储设备加载内核时,设备树至关重要。虽然嵌入式 Linux 提供了多种引导加载程序的选择,但我们这里只讨论其中一个。接下来我们将深入研究这个引导加载程序。
U-Boot
我们将专注于 U-Boot,因为它支持多种处理器架构以及大多数单板和设备。U-Boot,或其全名 Das U-Boot,最初作为一个开源的嵌入式 PowerPC 板的引导加载程序诞生。随后,它被移植到基于 Arm 的板子,后来又移植到其他架构,包括 无锁管道阶段的微处理器(MIPS)和 SuperH(SH)。
U-Boot 已经存在很长时间,并且有一个活跃的社区。该项目由 DENX Software Engineering 主办并维护。有大量关于 U-Boot 的信息,好的入门点是 u-boot.readthedocs.io
。此外,还有一个邮件列表 u-boot@lists.denx.de,你可以通过填写并提交 lists.denx.de/listinfo/u-boot
上的表格来订阅。
构建 U-Boot
首先获取源代码。像大多数项目一样,推荐的方式是克隆 Git 仓库,并检查你打算使用的标签:
$ git clone git://git.denx.de/u-boot.git u-boot-mainline
$ cd u-boot-mainline
$ git checkout v2024.04
或者,你可以从 ftp.denx.de/pub/u-boot/
下载一个 tarball。
configs
目录中有超过 1,000 个用于常见开发板和设备的配置文件。在大多数情况下,你可以根据文件名大致猜测使用哪个配置文件。但你可以通过查看 doc/board
目录下的 .rst
文件获取更详细的信息。或者,你也可以在适当的网页教程或论坛中查找相关信息。
以 BeaglePlay 为例,我们会发现 configs
目录下有一个名为 am62x_evm_a53_defconfig
的配置文件。在同一目录中,还有一个名为 am62x_evm_r5_defconfig
的配置文件,它是针对 BeaglePlay 的 Arm Cortex-R5F 微控制器的。ROM 代码运行在 Arm Cortex-R5F 微控制器上,而 TPL 运行在主 Arm Cortex-A53 CPU 上。这里有两个 U-Boot SPL:一个运行在 R5 上,另一个运行在主 CPU 上。在 doc/board/beagle/am62x_beagleplay.rst
文件中有一个顺序图,详细解释了 BeaglePlay 独特的启动流程。仔细查看这个顺序图,确保你理解它。在本章的后续内容中,遇到不明确的地方,可以参考这个图进行澄清。
为 BeaglePlay 构建 U-Boot 是一个多阶段的过程。BeaglePlay 的 am62x
SoC 中的 Arm Cortex-M4F 和 Cortex-R5F 是 32 位处理器,因此它们需要一个 32 位的工具链。一个名为 TI Foundational Security(TIFS)的软件组件运行在 M4 上。TIFS 启动 R5,并要求其加载一个固件映像到 TIFS 核心中。这意味着,在为 R5 生成启动加载器映像时,我们需要将 TIFS 二进制固件映像与 U-Boot SPL 一起捆绑。接下来,我们需要使用 64 位工具链为主 A53 CPU 构建 Trusted Firmware-A(TF-A)。最后,我们为主 CPU 配置并构建 U-Boot SPL 和 TPL。
获取 32 位工具链
打开您的浏览器,访问developer.arm.com/downloads/-/arm-gnu-toolchain-downloads
。搜索Downloads: 13.2.Rel1,点击前面的加号展开该部分。然后点击arm-gnu-toolchain-13.2.rel1-x86_64-arm-none-eabi.tar.xz
文件,在 x86_64 Linux 宿主的交叉工具链AArch32 裸机目标(arm-none-eabi)下下载工具链。
以下 R5 练习已使用该版本的 Arm GNU 工具链成功执行。我建议从该网页下载相同版本的工具链(如果它仍然可用),以避免任何问题。
在您的主目录中安装 32 位工具链:
$ cd ~
$ tar -xvf ~/Downloads/arm-gnu-toolchain-13.2.rel1-x86_64-arm-none-eabi.tar.xz
将 32 位工具链添加到您的PATH
环境变量中:
$ export PATH=${HOME}/arm-gnu-toolchain-13.2.Rel1-x86_64-arm-none-eabi/bin/:$PATH
现在,您可以为 R5 构建 U-Boot 了。确保将前述命令中的13.2.rel1
和13.2.Rel1
替换为您下载的 32 位工具链的实际版本。
为 R5 构建 U-Boot SPL
当我在 2023 年 11 月编写这篇文章时,主线 U-Boot 对 BeaglePlay 的支持还很新。因此,我选择使用 BeagleBoard.org 为 BeaglePlay 提供的 U-Boot 分支。我建议从相同的 Git 仓库(如果仍然可用)构建 U-Boot 源代码,以避免任何问题。
将 U-Boot 分支克隆到您的主目录,并检出一个稳定的提交:
$ git clone https://github.com/beagleboard/u-boot u-boot-beagleplay
$ cd u-boot-beagleplay
$ git checkout f036fb
安装为 BeaglePlay 构建 U-Boot 所需的包:
$ sudo apt install bison device-tree-compiler flex libncurses-dev libssl-dev python3-dev python3-setuptools swig
为 R5 配置并构建 U-Boot:
-
首先,为 R5 创建一个构建目录,向上一层共享构建产物:
$ mkdir -p ../build_uboot/r5
-
接下来,为 32 位 Arm 设置
ARCH
和CROSS_COMPILE
环境变量:$ export ARCH=arm $ export CROSS_COMPILE=arm-none-eabi-
-
选择
am62x_evm_r5_defconfig
进行构建:$ make am62x_evm_r5_defconfig O=../build_uboot/r5
-
运行
make menuconfig
进一步配置 U-Boot 以进行构建:$ make menuconfig O=../build_uboot/r5
-
进入环境子菜单。
-
选择环境位于 EXT4 文件系统中。
-
图 3.5 – 选择环境位于 EXT4 文件系统中
-
在该菜单页面上取消选择任何其他选项(例如,MMC、NAND 和 SPI)作为环境存储。
-
在环境的块设备名称文本字段中输入
mmc
。
图 3.6 – 环境的块设备名称
- 在EXT4 中存储环境的设备和分区文本字段中输入
1:2
。
图 3.7 – EXT4 中存储环境的设备和分区
- 确保
/uboot.env
是用于环境的 EXT4 文件名文本字段。
图 3.8 – 环境
-
退出环境子菜单。
-
进入SPL/TPL子菜单。
-
选择支持 EXT 文件系统。
图 3.9 – 选择支持 EXT4 文件系统
-
退出SPL/TPL子菜单。
-
进入Boot Options子菜单。
-
选择启用 bootcmd 的默认值。
图 3.10 – 选择启用 bootcmd 的默认值。
- 在bootcmd value文本字段中输入
echo 'no bootcmd yet'
。
图 3.11 – bootcmd value。
图 3.12 – 启动选项。
-
退出
menuconfig
,并在询问是否保存新配置时选择是。 -
最后,为 R5 构建 U-Boot:
$ make O=../build_uboot/r5
当 U-Boot 构建完成时,应该在../build_uboot/r5/spl
目录下找到 R5 的 SPL 二进制文件。
请参考doc/board/beagle/am62x_beagleplay.rst
中的启动流程序列图。
为 R5 生成镜像。
回顾一下,加载到 M4 中的 TIFS 固件镜像需要与 R5 的 U-Boot SPL 一起打包。我们直接从 TI 获取二进制 TIFS 固件镜像。
将 TI 固件仓库克隆到你的主目录:
$ cd ~
$ git clone https://github.com/TexasInstruments-Sandbox/ti-linux-firmware.git
$ cd ti-linux-firmware
$ git checkout c126d386
将 M4 的 TIFS 固件镜像与 R5 的 U-Boot SPL 一起打包,需要一个名为k3-image-gen
的工具。
将k3-image-gen
仓库克隆到你的主目录:
$ cd ~
$ git clone git://git.ti.com/k3-image-gen/k3-image-gen.git
$ cd k3-image-gen
$ git checkout 150f195
通过在k3-image-gen
目录中运行make
并将 U-Boot SPL 和 TIFS 固件镜像的路径作为参数传递,生成 R5 的合并镜像:
$ make SOC=am62x SBL=../build_uboot/r5/spl/u-boot-spl.bin SYSFW_PATH=../ti-linux-firmware/ti-sysfw/ti-fs-firmware-am62x-gp.bin
现在,k3-image-gen
目录中应该有一个tiboot3.bin
文件。
请参考doc/board/beagle/am62x_beagleplay.rst
中的启动流程序列图。
为主 A53 CPU 构建 TF-A。
BeaglePlay 中am62x
SoC 的 A53 是 64 位 CPU,因此我们必须切换到 64 位工具链,以交叉编译源代码。我们将使用你在主目录中安装的第二章中的相同 64 位 Bootlin 工具链。
将该 64 位 Bootlin 工具链添加到你的PATH
环境变量中:
$ export PATH=${HOME}/aarch64--glibc--stable-2024.02-1/bin/:$PATH
确保将前述命令中的2024.02-1
替换为你下载的实际 64 位工具链版本。
将 TF-A 源代码克隆到你的主目录,并检出一个稳定的发布标签:
$ cd ~
$ git clone https://github.com/ARM-software/arm-trusted-firmware.git
$ cd arm-trusted-firmware
$ git checkout v2.9
为 A53 配置并构建 TF-A:
-
为 64 位 Arm 设置
ARCH
和CROSS_COMPILE
环境变量:$ export ARCH=aarch64 $ export CROSS_COMPILE=aarch64-buildroot-linux-gnu-
-
为 A53 构建 TF-A,并指定
k3
作为平台,lite
作为目标板:$ make PLAT=k3 TARGET_BOARD=lite
当 TF-A 构建完成时,应该在./build/k3/lite/release
目录下找到 A53 的bl31.bin
文件。
请参考doc/board/beagle/am62x_beagleplay.rst
中的启动流程序列图。
为主 A53 CPU 构建 U-Boot。
到目前为止执行的所有构建步骤都是 BeaglePlay 中am62x
SoC 特有的。为大多数目标构建 U-Boot 只需要为主 CPU 编译 SPL 和 TPL。我们将使用你在主目录中安装的第二章中的相同 64 位 Bootlin 工具链。
将该 64 位 Bootlin 工具链添加到你的PATH
环境变量中:
$ export PATH=${HOME}/aarch64--glibc--stable-2024.02-1/bin/:$PATH
确保在上述命令中将2024.02-1
替换为你下载的 64 位工具链的实际版本。
配置并为 A53 构建 U-Boot:
-
首先,导航回 BeaglePlay 的
u-boot
源代码树:$ cd ~ $ cd u-boot-beagleplay
-
接下来,在上一层级创建 A53 的构建目录:
$ mkdir -p ../build_uboot/a53
-
设置
ARCH
和CROSS_COMPILE
环境变量为 64 位 Arm:$ export ARCH=aarch64 $ export CROSS_COMPILE=aarch64-buildroot-linux-gnu-
-
选择
am62x_evm_a53_defconfig
进行构建:$ make am62x_evm_a53_defconfig O=../build_uboot/a53
-
运行
make menuconfig
进一步配置 U-Boot 以进行构建:$ make menuconfig O=../build_uboot/a53
-
进入环境子菜单。
-
选择环境在 EXT4 文件系统中。
-
取消选择该菜单页面上的其他选项(例如 MMC、NAND 和 SPI)作为环境存储。
-
在环境的块设备名称文本框中输入
mmc
。 -
在为 EXT4 存储环境的设备和分区文本框中输入
1:2
。 -
确保在用于环境的 EXT4 文件名称文本框中输入
/uboot.env
。 -
退出环境子菜单。
-
进入SPL/TPL子菜单。
-
选择支持 EXT 文件系统。
-
退出SPL/TPL子菜单。
-
进入启动选项子菜单。
-
选择启用默认的 bootcmd 值。
-
在bootcmd 值文本框中输入
echo 'no bootcmd yet'
。 -
退出
menuconfig
并在被询问是否保存新配置时选择是。 -
最后,构建 U-Boot 以 A53 为目标,并将 TI 的 TF-A 和 DM 固件路径作为参数传递给
make
:$ make ATF=$HOME/arm-trusted-firmware/build/k3/lite/release/bl31.bin DM=$HOME/ti-linux-firmware/ti-dm/am62xx/ipc_echo_testb_mcu1_0_release_strip.xer5f O=../build_uboot/a53
重要说明
在
make
命令中始终使用绝对路径,而非相对路径./
,指向ATF
和DM
固件。否则,生成的 SPL 和 U-Boot 二进制文件在大小和内容上都会不正确。
编译结果如下:
-
u-boot
:U-Boot 的 ELF 格式,适用于调试器 -
u-boot.map
:符号表 -
u-boot.bin
:U-Boot 的原始二进制格式,适合在你的设备上运行 -
u-boot.img
:u-boot.bin
加上 U-Boot 头部,适合上传到运行中的 U-Boot 副本 -
u-boot.srec
:U-Boot 的摩托罗拉 S 记录格式(SRECORD或SRE),适合通过串行连接传输
BeaglePlay 还需要一个 SPL,如前所述。它与 U-Boot 同时构建,并命名为tispl.bin
(在doc/board/beagle/am62x_beagleplay.rst
中的启动流程图):
$ cd ~
$ cd build_uboot/a53
$ ls -l tispl.bin
-rw-rw-r-- 1 frank frank 549508 Jun 29 20:31 tispl.bin
$ ls -l u-boot*
-rwxrwxr-x 1 frank frank 6779128 Jun 29 20:31 u-boot
-rw-rw-r-- 1 frank frank 1098236 Jun 29 20:31 u-boot.bin
-rw-rw-r-- 1 frank frank 18246 Jun 29 20:30 u-boot.cfg
-rw-rw-r-- 1 frank frank 11563 Jun 29 20:31 u-boot.cfg.configs
-rw-rw-r-- 1 frank frank 37485 Jun 29 20:31 u-boot.dtb
-rw-rw-r-- 1 frank frank 1060788 Jun 29 20:31 u-boot-dtb.img
-rw-rw-r-- 1 frank frank 1098236 Jun 29 20:31 u-boot-fit-dtb.bin
-rw-rw-r-- 1 frank frank 1060788 Jun 29 20:31 u-boot.img
-rw-rw-r-- 1 frank frank 1060788 Jun 29 20:31 u-boot.img_HS
-rw-rw-r-- 1 frank frank 1348 Jun 29 20:31 u-boot.lds
-rw-rw-r-- 1 frank frank 765615 Jun 29 20:31 u-boot.map
-rwxrwxr-x 1 frank frank 993104 Jun 29 20:31 u-boot-nodtb.bin
-rwxrwxr-x 1 frank frank 993104 Jun 29 20:31 u-boot-nodtb.bin_HS
-rw-rw-r-- 1 frank frank 1836 Jun 29 20:31 u-boot-spl-k3_HS.its
-rwxrwxr-x 1 frank frank 2979442 Jun 29 20:31 u-boot.srec
-rw-rw-r-- 1 frank frank 342412 Jun 29 20:31 u-boot.sym
其他目标的过程类似。
安装 U-Boot
第一次在板上安装引导加载程序需要一些手动干预。如果板子有硬件调试接口,比如联合测试动作组(JTAG),通常可以直接将 U-Boot 的副本加载到 RAM 中并使其运行。之后,你可以使用 U-Boot 命令将其复制到闪存中。具体细节因板子而异,超出了本书的范围。
许多 SoC 设计内置了一个启动 ROM,可以用来从各种外部源读取启动代码,例如 SD 卡、串行接口或 USB 大容量存储。这正是 Beagle 上的 am62x
芯片的情况,它使得尝试新软件变得非常容易。
您需要一个 microSD 卡读卡器来将镜像写入卡中。读卡器有两种类型:外接读卡器,通过 USB 端口连接,以及许多笔记本电脑上内置的 SD 读卡器。Linux 会在插入卡片时为其分配一个设备名称。lsblk
命令是一个有用的工具,可以帮助您查找分配到哪个设备。例如,当我将一张名义上是 32 GB 的 microSD 卡插入读卡器时,看到的输出如下:
$ lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
sda 8:0 1 29.8G 0 disk
└─sda1 8:1 1 29.8G 0 part /media/frank/6662-6262
nvme0n1 259:0 0 465.8G 0 disk
├─nvme0n1p1 259:1 0 512M 0 part /boot/efi
├─nvme0n1p2 259:2 0 16M 0 part
├─nvme0n1p3 259:3 0 232.9G 0 part
└─nvme0n1p4 259:4 0 232.4G 0 part /
在这种情况下,nvme0n1
是我的 512 GB 硬盘,sda
是 microSD 卡。它只有一个分区,sda1
,挂载为 /media/frank/6662-6262
目录。
如果我使用内置的 SD 卡槽,我会看到如下:
$ lsblk
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
mmcblk0 179:0 1 29.8G 0 disk
└─mmcblk0p1 179:1 1 29.8G 0 part /media/frank/6662-6262
nvme0n1 259:0 0 465.8G 0 disk
├─nvme0n1p1 259:1 0 512M 0 part /boot/efi
├─nvme0n1p2 259:2 0 16M 0 part
├─nvme0n1p3 259:3 0 232.9G 0 part
└─nvme0n1p4 259:4 0 232.4G 0 part /
在这种情况下,microSD 卡显示为 mmcblk0
,分区为 mmcblk0p1
。请注意,您使用的 microSD 卡可能与此卡格式不同,因此可能会看到不同数量的分区以及不同的挂载点。在格式化 SD 卡时,非常重要的一点是要确保它的设备名称。你绝对不想把硬盘当作 SD 卡误格式化了。
这种情况我不止一次遇到过。所以,在本书的代码仓库中,我提供了一个名为 MELD/format-sdcard.sh
的 shell 脚本,其中包含了足够的检查,以防止您(和我)使用错误的设备名称。参数是 microSD 卡的设备名称,第一个例子中的设备名称是 sda
,第二个例子中的设备名称是 mmcblk0
。以下是它的使用示例:
$ MELD/format-sdcard.sh mmcblk0
该脚本创建了两个分区。第一个分区为 128 MB,格式化为 FAT32,用来存放引导加载程序;第二个分区为 1 GB,格式化为 ext4,您将在 第五章 中使用。该脚本会在应用到任何大于 128 GB 的驱动器时中止,因此如果您使用的是更大的 microSD 卡,请准备修改脚本。
格式化完 microSD 卡后,将其从读卡器中取出,然后重新插入。将鼠标移动到并点击显示在 Ubuntu 桌面左侧的 USB 驱动器图标之一,以便打开 boot
分区的窗口。在当前版本的 Ubuntu 中,这两个分区会挂载为 /media/<user>/boot
和 /media/<user>/rootfs
。
将 tiboot3.bin
、tispl.bin
和 u-boot.img
复制到 boot
分区,像这样:
$ cd ~
$ cd k3-image-gen
$ cp tiboot3.bin /media/$USER/boot/.
$ cd ~
$ cd build_uboot/a53
$ cp tispl.bin u-boot.img /media/$USER/boot/.
右键点击两个 USB 驱动器图标中的任意一个,选择 弹出 来卸载 microSD 卡。最后,从主机的读卡器中取出 microSD 卡。
要从新准备的 microSD 卡启动 BeaglePlay:
-
在 BeaglePlay 没有通电的情况下,将 microSD 卡插入 BeaglePlay 的读卡器。
-
将 USB 到 TTL 串行线的 USB 端插入您的主机。确保您的电缆具有 3.3 V 的逻辑电平。
-
三针 UART 连接器位于 BeaglePlay 的 USB-C 连接器旁边。不要将电缆的第四根红线连接上。红线通常表示电源,而在这种情况下不需要,且可能会损坏板子。
-
将电缆中的 TX 线连接到 BeaglePlay 的 RX 引脚。
-
将电缆中的 RX 线连接到 BeaglePlay 的 TX 引脚。
-
将电缆中的 GND(黑色)线连接到 BeaglePlay 的 GND 引脚。
-
一个串口应该会在你的 PC 上显示为
/dev/ttyUSB0
。 -
启动一个合适的终端程序,如
gtkterm
、minicom
或picocom
,并将其连接到 115,200 比特每秒 (bps) 的端口,且不使用流控制。gtkterm
可能是最容易设置和使用的:$ gtkterm -p /dev/ttyUSB0 -s 115200
-
如果你遇到权限错误,可能需要将自己添加到
dialout
组中才能使用该端口,或者用sudo
启动gtkterm
。如果串口控制台上没有输出或出现乱码,可以交换 BeaglePlay 上 RX 和 TX 引脚连接的线。 -
按住 BeaglePlay 上的 USR 按钮。
-
使用 USB-C 启动开发板。
-
大约 5 秒后松开按钮。
你应该在串口控制台上看到一些输出,后跟一个 U-Boot 提示符:
U-Boot SPL 2021.01-gf036fbdc25 (Jun 29 2024 - 18:54:55 -0700)
SYSFW ABI: 3.1 (firmware rev 0x0009 '9.0.4--v09.00.04 (Kool Koala)')
SPL initial stack usage: 13384 bytes
Trying to boot from MMC2
spl_load_fit_image: Skip load 'tee': image size is 0!
Loading Environment from EXT4... ** File not found /uboot.env **
** Unable to read "/uboot.env" from mmc1:2 **
Starting ATF on ARM64 core...
NOTICE: BL31: v2.9(release):v2.9.0
NOTICE: BL31: Built : 19:01:43, Jun 29 2024
U-Boot SPL 2021.01-gf036fbdc25 (Jun 29 2024 - 20:30:20 -0700)
SYSFW ABI: 3.1 (firmware rev 0x0009 '9.0.4--v09.00.04 (Kool Koala)')
Trying to boot from MMC2
U-Boot 2021.01-gf036fbdc25 (Jun 29 2024 - 20:30:20 -0700)
SoC: AM62X SR1.0 GP
Model: BeagleBoard.org BeaglePlay
Board: BEAGLEPLAY-A0- rev 02
DRAM: 2 GiB
MMC: mmc@fa10000: 0, mmc@fa00000: 1, mmc@fa20000: 2
Loading Environment from EXT4... ** File not found /uboot.env **
** Unable to read "/uboot.env" from mmc1:2 **
In: serial@2800000
Out: serial@2800000
Err: serial@2800000
Error: Can't set serial# to SSSS
Net: Could not get PHY for ethernet@8000000port@1: addr 0
am65_cpsw_nuss_port ethernet@8000000port@1: phy_connect() failed
No ethernet found.
Press SPACE to abort autoboot in 2 seconds
no bootcmd yet
=>
按下键盘上的任意键以停止 U-Boot 在默认环境下自动启动。现在我们面前有了一个 U-Boot 提示符,让我们开始使用 U-Boot。
使用 U-Boot
在本节中,我将描述一些可以使用 U-Boot 执行的常见任务。
U-Boot 提供一个串口命令行界面,提供一个为每个板定制的命令提示符。在这些示例中,我使用=>
作为命令提示符。输入help
会列出当前版本的 U-Boot 中配置的所有命令。输入help <command>
会列出关于某个特定命令的更多信息。
BeaglePlay 的默认命令解释器相当简单。你无法通过按左箭头或右箭头键来编辑命令行。按Tab键也无法完成命令输入,按上箭头键也无法查看命令历史。按下这些键中的任何一个会打断你当前输入的命令,你需要按Ctrl + C并重新开始。你唯一可以安全使用的行编辑键是退格键。
作为可选项,你可以配置一个不同的命令外壳程序叫做Hush,它具有更复杂的交互支持,包括命令行编辑。
默认的数字格式是十六进制。考虑以下命令:
=> nand read 82000000 400000 200000
这将从 NAND 闪存的 0x400000
偏移处读取 0x200000
字节数据到 RAM 地址 0x82000000
。
环境变量
U-Boot 广泛使用环境变量来存储并传递信息,甚至用于创建脚本。环境变量是简单的name=value
对,存储在内存区域中。变量的初始值可能在板级配置头文件中这样编写:
#define CONFIG_EXTRA_ENV_SETTINGS
"myvar1=value1"
"myvar2=value2"
你可以通过 U-Boot 命令行使用 setenv
创建和修改变量。例如,setenv foo bar
创建名为 foo
的变量,并赋值为 bar
。请注意,变量名和值之间没有 =
符号。你可以通过设置变量为空字符串来删除该变量,例如使用 setenv foo
。你可以使用 printenv
命令打印所有变量到控制台,或使用 printenv foo
打印单个变量。
如果 U-Boot 配置了存储环境的空间,你可以使用 saveenv
命令保存环境变量。如果是原始的 NAND 或 NOR 闪存,那么可以为此目的保留一个擦除块,通常还会使用另一个擦除块作为冗余副本,以防止损坏。如果有 eMMC 或 SD 卡存储,可以将其存储在保留的扇区阵列中,或存储在磁盘分区中的名为 uboot.env
的文件中。其他选项包括通过 I2C 或 SPI 接口连接的串行电可擦可编程只读存储器(EEPROM)或非易失性 RAM。
启动镜像格式
U-Boot 没有文件系统。相反,它通过一个 64 字节的头部标记信息块,以便跟踪内容。我们使用 mkimage
命令行工具为 U-Boot 准备文件,该工具随 u-boot-tools
包一起提供,适用于 Ubuntu。你也可以通过在 U-Boot 源代码树中运行 make tools
来获取 mkimage
,然后通过 tools/mkimage
来调用它。以下是该命令的用法概述:
$ mkimage
Error: Missing output filename
Usage: mkimage -l image
-l ==> list image header information
mkimage [-x] -A arch -O os -T type -C comp -a addr -e ep -n name -d data_file[:data_file...] image
-A ==> set architecture to 'arch'
-O ==> set operating system to 'os'
-T ==> set image type to 'type'
-C ==> set compression type 'comp'
-a ==> set load address to 'addr' (hex)
-e ==> set entry point to 'ep' (hex)
-n ==> set image name to 'name'
-d ==> use image data from 'datafile'
-x ==> set XIP (execute in place)
mkimage [-D dtc_options] [-f fit-image.its|-f auto|-F] [-b <dtb> [-b <dtb>]] [-E] [-B size] [-i <ramdisk.cpio.gz>] fit-image
<dtb> file is used with -f auto, it may occur multiple times.
-D => set all options for device tree compiler
-f => input filename for FIT source
-i => input filename for ramdisk file
-E => place data outside of the FIT structure
-B => align size in hex for FIT structure and header
Signing / verified boot options: [-k keydir] [-K dtb] [ -c <comment>] [-p addr] [-r] [-N engine]
-k => set directory containing private keys
-K => write public keys to this .dtb file
-G => use this signing key (in lieu of -k)
-c => add comment in signature node
-F => re-sign existing FIT image
-p => place external data at a static position
-r => mark keys used as 'required' in dtb
-N => openssl engine to use for signing
mkimage -V ==> print version information and exit
Use '-T list' to see a list of available image types
例如,要为 32 位 Arm 处理器准备内核镜像,你可以使用以下命令:
$ mkimage -A arm -O linux -T kernel -C gzip -a 0x80008000 -e 0x80008000 -n 'Linux' -d zImage uImage
在这个示例中,架构是 arm
,操作系统是 linux
,镜像类型是 kernel
。此外,压缩方案是 gzip
,加载地址是 0x80008000
,入口点与加载地址相同。最后,镜像名称是 Linux
,镜像数据文件名为 zImage
,正在生成的镜像名为 uImage
。
加载镜像
通常,你从可移动存储设备(如 SD 卡)或通过网络加载镜像。SD 卡在 U-Boot 中由 MMC 驱动程序处理。以下是从 microSD 卡加载文件到内存的示例:
=> mmc rescan
=> mmc list
mmc@fa10000: 0 (eMMC)
mmc@fa00000: 1 (SD)
mmc@fa20000: 2
=> fatload mmc 1:1 80000000 tiboot3.bin
329021 bytes read in 19 ms (16.5 MiB/s)
mmc rescan
命令会重新初始化 MMC 驱动程序,可能是为了检测最近插入的 SD 卡。接下来,使用 fatload
从 SD 卡的 FAT 格式分区读取文件。请注意,tiboot3.bin
是 R5 的固件镜像,而不是 Linux 内核镜像,因此在此启动序列阶段无法执行它。fatload
命令的格式如下:
fatload <interface> [<dev[:part]> [<addr> [<filename> [bytes [pos]]]]]
如果 <interface>
是 mmc
,如我们的例子,那么 <dev:part>
是从零开始计数的 MMC 接口设备号和从一开始计数的分区号。因此,<1:1>
是第二个设备的第一个分区,对于 BeaglePlay 来说,mmc 1
是 microSD 卡(板载 eMMC 是 mmc 0
)。选择的内存位置 0x80000000
位于当前未使用的 RAM 区域。
要通过网络加载内核镜像文件,必须使用简单文件传输协议(TFTP)。这需要你在开发系统上安装tftpd
(TFTP 守护进程)并启动它。你还需要配置 PC 与目标开发板之间的任何防火墙,允许 TFTP 协议通过 UDP 端口69
。
TFTP 的默认配置只允许访问/var/lib/tftpboot
目录。接下来的步骤是将你要传输到目标的文件复制到该目录中。然后,假设你使用了一对静态 IP 地址,这样就不需要进一步的网络管理,加载内核镜像文件的命令序列如下所示:
=> setenv ipaddr 192.168.159.42
=> setenv serverip 192.168.159.99
=> tftp 82000000 uImage
link up on port 0, speed 100, full duplex
Using cpsw device
TFTP from server 192.168.159.99; our IP address is 192.168.159.42
Filename 'uImage'.
Load address: 0x82000000
Loading:
######################################################################################################################################################################################################################################################################################################################
3 MiB/s
done
Bytes transferred = 4605000 (464448 hex)
最后,让我们看看如何将镜像写入 NAND 闪存并读取它们。这是通过nand
命令来处理的。以下示例通过 TFTP 加载内核镜像并将其写入闪存:
=> tftpboot 82000000 uImage
=> nandecc hw
=> nand erase 280000 400000
NAND erase: device 0 offset 0x280000, size 0x400000
Erasing at 0x660000 -- 100% complete.
OK
=> nand write 82000000 280000 400000
NAND write: device 0 offset 0x280000, size 0x400000
4194304 bytes written: OK
现在,你可以使用nand read
命令从闪存加载内核:
=> nand read 82000000 280000 400000
一旦内核加载到 RAM 中,我们就可以启动它。
启动 Linux
bootm
命令启动内核镜像。其语法如下:
bootm <address of kernel> <address of ramdisk> <address of dtb>
内核镜像的地址是必要的,但如果内核配置不需要ramdisk
和dtb
,则可以省略它们的地址。如果有dtb
但没有initramfs
,则第二个地址可以用破折号代替。示例如下:
=> bootm 82000000 – 83000000
每次开机时输入一长串命令来启动开发板显然是不可接受的。让我们来看看如何自动化启动过程。
使用 U-Boot 脚本自动化启动
U-Boot 将一系列命令存储在环境变量中。如果名为bootcmd
的特殊变量包含脚本,那么它将在开机后经过bootdelay
秒的延迟后运行。如果你在串口控制台上观察,你会看到延迟倒计时到零。你可以在此期间按下任意键来终止倒计时,并进入 U-Boot 的交互式会话。
创建脚本的方式很简单,尽管不易阅读。你只需要将命令通过分号连接,每个命令前都必须加上一个\转义字符。例如,要从闪存中的某个偏移位置加载内核镜像并启动,你可以使用如下命令:
setenv bootcmd nand read 82000000 400000 200000\;bootm 82000000
现在我们知道如何在 BeaglePlay 上使用 U-Boot 启动内核。那么如何将 U-Boot 移植到一个没有 BSP 的新开发板上呢?我们将在本章剩余部分讨论这个问题。
将 U-Boot 移植到新开发板
假设你的硬件部门已经创建了一个名为Nova的新开发板,该板基于 BeaglePlay,并且你需要将 U-Boot 移植到该板上。你需要了解 U-Boot 代码的布局以及板卡配置机制的工作原理。在本节中,我将向你展示如何创建一个现有开发板的变体——BeaglePlay——并将其作为进一步定制的基础。
有相当多的文件需要修改。我已经将它们汇总成一个补丁文件,存放在书本代码库中的 MELD/Chapter03/0001-BSP-for-Nova.patch
。你只需将该补丁应用到 BeaglePlay 的干净 U-Boot 分支副本上,并像下面这样重新构建:
$ cd ~
$ cd u-boot-beagleplay
$ patch -p1 < ~/MELD/Chapter03/0001-BSP-for-Nova.patch
$ rm -rf ../build_uboot/a53
$ mkdir ../build_uboot/a53
$ export PATH=${HOME}/aarch64--glibc--stable-2024.02-1/bin/:$PATH
$ export ARCH=aarch64
$ export CROSS_COMPILE=aarch64-buildroot-linux-gnu-
$ make nova_defconfig O=../build_uboot/a53
$ make ATF=$HOME/arm-trusted-firmware/build/k3/lite/release/bl31.bin DM=$HOME/ti-linux-firmware/ti-dm/am62xx/ipc_echo_testb_mcu1_0_release_strip.xer5f O=../build_uboot/a53
如果你想使用不同版本的 U-Boot,你需要自己重新生成补丁,以确保它能够顺利应用。本节的其余部分描述了补丁是如何创建的。如果你想跳过这些细节,可以直接运行前面的命令,然后跳到本节 构建与测试 部分的结尾。若想按步骤跟着做,你需要一个没有应用 Nova BSP 补丁的干净 U-Boot 分支副本。我们将要处理的主要目录如下:
-
arch
:包含特定于每个支持的架构的代码,这些代码位于arm
、mips
和powerpc
目录中。在每个架构下,都有一个子目录对应架构家族中的每个成员。例如,在arch/arm/cpu
中,有多个架构变体的目录,包括arm926ejs
、armv7
和armv8
。 -
board
:包含特定于某个开发板的代码。如果有多个来自同一厂商的开发板,它们会被收集到一个子目录中。因此,基于 BeaglePlay 的am62x
EVM 开发板的代码位于board/ti/am62x
。 -
common
:包含核心功能,包括命令外壳以及可以从中调用的每个命令,每个命令都在一个名为cmd_<command name>.c
的文件中。 -
doc
:包含多个.rst
文件,描述了 U-Boot 的各个方面。如果你不知道如何继续进行 U-Boot 移植,这是一个很好的起点。 -
include
:除了许多共享的头文件外,还包含一个重要的include/configs
子目录,在这里你会找到大多数开发板的配置设置。
Kconfig
如何从 Kconfig
文件中提取配置信息,并将整个系统配置存储在一个名为 .config
的文件中,将在 第四章 中详细描述。每个开发板都有一个默认的配置,存储在 configs/<board name>_defconfig
中。对于 Nova 开发板,我们可以从 EVM 的配置开始进行复制:
$ cp configs/am62x_evm_a53_defconfig configs/nova_defconfig
现在,编辑 configs/nova_defconfig
,将 CONFIG_TARGET_AM625_A53_EVM=y
替换为 CONFIG_TARGET_NOVA=y
,如下面所示:
CONFIG_ARM=y
CONFIG_ARCH_K3=y
CONFIG_TI_SECURE_DEVICE=y
CONFIG_TI_COMMON_CMD_OPTIONS=y
CONFIG_SPL_GPIO_SUPPORT=y
CONFIG_SPL_LIBCOMMON_SUPPORT=y
CONFIG_SPL_LIBGENERIC_SUPPORT=y
CONFIG_SYS_MALLOC_F_LEN=0x8000
CONFIG_NR_DRAM_BANKS=2
CONFIG_SOC_K3_AM625=y
CONFIG_K3_ATF_LOAD_ADDR=0x9e780000
CONFIG_TARGET_NOVA=y
CONFIG_ENV_SIZE=0x20000
<…>
请注意,CONFIG_ARM=y
会导致包含 arch/arm/Kconfig
的内容。
我们现在完成了对 configs/nova_defconfig
的修改。
开发板特定文件
每个开发板都有一个名为 board/<board name>
或 board/<vendor>/<board name>
的子目录,应该包含以下内容:
-
Kconfig
:包含开发板的配置选项。 -
MAINTAINERS
:记录开发板是否目前正在维护,以及由谁维护。 -
Makefile
:用于构建特定于开发板的代码。
此外,可能还会有一些用于开发板特定功能的源文件。
我们的 Nova 开发板基于 BeaglePlay,而 BeaglePlay 又基于 TI 的 am62x
EVM。因此,我们应该复制 am62x
开发板的文件:
$ mkdir board/ti/nova
$ cp -a board/ti/am62x/* board/ti/nova
$ cd board/ti/nova
$ mv evm.c nova.c
首先,修改 board/ti/nova/Makefile
,使得编译的是 nova.c
而不是 evm.c
:
<…>
obj-y += nova.o
将 evm.c
复制为 nova.c
让你可以改变 U-Boot 如何与自定义开发板交互。
接下来,编辑 board/ti/nova/Kconfig
:
-
将
prompt
下的"TI K3 AM62x based boards"
字符串更改为"TI K3 AM62x based Nova! board"
。 -
将
TARGET_AM625_A53_EVM
重命名为TARGET_NOVA
。 -
删除
TARGET_AM625_R5_EVM
及其所有项。 -
将
SYS_BOARD
设置为"nova"
,这样它就会构建board/ti/nova
中的文件。 -
将
SYS_CONFIG_NAME
设置为"nova"
,以便它使用include/configs/nova.h
作为配置文件。
修改后的 board/ti/nova/Konfig
应该如下所示:
<…>
if TARGET_NOVA
config SYS_BOARD
default "nova"
config SYS_VENDOR
default "ti"
config SYS_CONFIG_NAME
default "nova"
source "board/ti/common/Kconfig"
endif
<…>
现在,我们需要将 Nova 的 Kconfig
文件链接到 Kconfig
文件链中。首先,编辑 arch/arm/Kconfig
,并在 source "board/tcl/sl50/Kconfig"
后插入 source "board/ti/nova/Kconfig"
,如下所示:
<…>
source "board/st/stv0991/Kconfig"
source "board/tcl/sl50/Kconfig"
source "board/ti/nova/Kconfig"
source "board/toradex/colibri_pxa270/Kconfig"
source "board/variscite/dart_6ul/Kconfig"
<…>
现在我们已经复制并修改了 Nova 开发板的特定文件,接下来处理头文件。
配置头文件
每个开发板在 include/configs
中都有一个头文件,包含了大部分的配置资料。该文件的名称由开发板的 Kconfig
文件中的 SYS_CONFIG_NAME
标识符决定。该文件的格式在 U-Boot 源码树顶层的 README
文件中有详细描述。对于我们的 Nova 开发板,只需将 include/configs/am62x_evm.h
复制到 include/configs/nova.h
并做一些修改,如下所示:
<…>
#ifndef __CONFIG_NOVA_H
#define __CONFIG_NOVA_H
#include <linux/sizes.h>
#include <config_distro_bootcmd.h>
#include <environment/ti/mmc.h>
#include <environment/ti/k3_dfu.h>
#undef CONFIG_SYS_PROMPT
#define CONFIG_SYS_PROMPT "nova!> "
/* DDR Configuration */
#define CONFIG_SYS_SDRAM_BASE1 0x880000000
#define CONFIG_SYS_BOOTM_LEN SZ_64M
#ifdef CONFIG_SYS_K3_SPL_ATF
#define CONFIG_SPL_FS_LOAD_PAYLOAD_NAME "tispl.bin"
#endif
#if defined(CONFIG_TARGET_NOVA)
#define CONFIG_SPL_MAX_SIZE SZ_1M
#define CONFIG_SYS_INIT_SP_ADDR (CONFIG_SPL_TEXT_BASE + SZ_4M)
#else
<…>
#endif /* __CONFIG_NOVA_H */
首先,将 __CONFIG_AM625_EVM_H
替换为 __CONFIG_NOVA_H
。接下来,重新定义 CONFIG_SYS_PROMPT
,以便我们能够在运行时识别该引导加载程序。最后,将 CONFIG_TARGET_AM625_A53_EVM
替换为 CONFIG_TARGET_NOVA
,确保 CONFIG_SPL_MAX_SIZE
和 CONFIG_SYS_INIT_SP_ADDR
被正确地定义。
在源树完全修改后,我们现在准备为自定义开发板构建 U-Boot。
构建和测试
为了为 Nova 开发板构建 U-Boot:
-
首先,返回到 BeaglePlay 的 U-Boot 源代码树:
$ cd ~ $ cd u-boot-beagleplay
-
接下来,为 64 位 Arm 设置
ARCH
和CROSS_COMPILE
环境变量:$ export ARCH=aarch64 $ export CROSS_COMPILE=aarch64-buildroot-linux-gnu-
-
清除任何先前的构建产物:
$ rm -rf ../build_uboot/a53 $ mkdir ../build_uboot/a53
-
选择
nova_defconfig
来进行构建:$ make nova_defconfig O=../build_uboot/a53
-
运行
make menuconfig
来进一步配置 U-Boot,以便进行构建:$ make menuconfig O=../build_uboot/a53
-
进入 Environment 子菜单。
-
选择 环境位于 EXT4 文件系统中。
-
在该菜单页中,取消选择任何其他环境存储选项(例如,MMC、NAND 和 SPI)。
-
在 环境的块设备名称 文本框中输入
mmc
。 -
在 存储环境的 EXT4 设备和分区 文本框中输入
1:2
。 -
确保在 要用于环境的 EXT4 文件的名称 文本框中输入
/uboot.env
。 -
退出 Environment 子菜单。
-
进入 SPL/TPL 子菜单。
-
选择 支持 EXT 文件系统。
-
退出 SPL/TPL 子菜单。
-
进入 启动选项 子菜单。
-
选择启用 bootcmd 的默认值。
-
在 bootcmd 值文本字段中输入
echo 'no bootcmd yet'
。 -
退出
menuconfig
,并在被问及是否保存新配置时选择是。 -
保存修改后的
defconfig
:$ make savedefconfig O=../build_uboot/a53
-
使用你的更改更新
nova_defconfig
:$ cp ../build_uboot/a53/defconfig configs/nova_defconfig
-
最后,为 A53 构建 U-Boot,并将 TI 的 TF-A 和 DM 固件路径作为参数传递给
make
:$ make ATF=$HOME/arm-trusted-firmware/build/k3/lite/release/bl31.bin DM=$HOME/ti-linux-firmware/ti-dm/am62xx/ipc_echo_testb_mcu1_0_release_strip.xer5f O=../build_uboot/a53
-
将
tispl.bin
和u-boot.img
复制到你之前创建的 microSD 卡的boot
分区:$ cd ~ $ cd build_uboot/a53 $ cp tispl.bin u-boot.img /media/$USER/boot/.
将 microSD 卡重新插入 BeaglePlay,并在按住 USR 按钮的同时重新供电。你应该在串口控制台看到类似这样的输出(注意自定义命令提示符):
<…>
U-Boot SPL 2021.01-gf036fbdc25-dirty (Jun 30 2024 - 18:37:39 -0700)
SYSFW ABI: 3.1 (firmware rev 0x0009 '9.0.4--v09.00.04 (Kool Koala)')
Trying to boot from MMC2
U-Boot 2021.01-gf036fbdc25-dirty (Jun 30 2024 - 18:37:39 -0700)
SoC: AM62X SR1.0 GP
Model: BeagleBoard.org BeaglePlay
Board: BEAGLEPLAY-A0- rev 02
DRAM: 2 GiB
MMC: mmc@fa10000: 0, mmc@fa00000: 1, mmc@fa20000: 2
Loading Environment from EXT4... ** File not found /uboot.env **
** Unable to read "/uboot.env" from mmc1:2 **
In: serial@2800000
Out: serial@2800000
Err: serial@2800000
Error: Can't set serial# to SSSS
Net: Could not get PHY for ethernet@8000000port@1: addr 0
am65_cpsw_nuss_port ethernet@8000000port@1: phy_connect() failed
No ethernet found.
Press SPACE to abort autoboot in 2 seconds
no bootcmd yet
nova!>
你可以通过将这些更改提交到 Git 并使用 git format-patch
命令来创建一个补丁:
$ git add .
$ git commit -m "BSP for Nova"
<…>
$ git format-patch -1
0001-BSP-for-Nova.patch
生成这个补丁完成了我们对 U-Boot 作为 TPL 的覆盖。U-Boot 也可以配置为完全跳过启动过程中的 TPL 阶段。接下来,让我们看看这种替代的 Linux 启动方式。
Falcon 模式
我们已经习惯了现代嵌入式处理器启动的概念,即启动 ROM 加载 SPL,接着加载 u-boot.bin
,然后再加载 Linux 内核。你可能在想,是否有办法减少步骤,从而简化并加速启动过程。答案是 U-Boot 的 Falcon 模式。
这个想法很简单:让 SPL 直接加载内核镜像,跳过 u-boot.bin
。没有用户交互,也没有脚本。它只是从闪存或 eMMC 中的已知位置加载内核到内存,传递一个预先准备好的参数块,并开始运行。配置 Falcon 模式的细节超出了本书的范围。
重要提示
Falcon 模式的名称来源于游隼,它是所有鸟类中最快的,能够在俯冲时达到每小时超过 200 英里的速度。
总结
每个系统都需要一个引导加载程序来激活硬件并加载内核。U-Boot 受到了许多开发者的青睐,因为它支持多种有用的硬件,并且相对容易移植到新设备。
在这一章节中,我们学习了如何通过串口控制台从命令行交互式地检查和驱动 U-Boot。这些命令行练习包括使用 TFTP 通过网络加载内核,以便进行快速迭代。最后,我们学习了如何通过为我们的 Nova 板生成补丁,将 U-Boot 移植到新设备上。
近年来,嵌入式硬件的复杂性和种类不断增加,这导致了设备树的引入,作为描述硬件的一种方式。设备树仅仅是系统的文本表示,经过编译成设备树二进制文件,并在内核加载时传递给内核。由内核来解释设备树,并加载和初始化它在设备树中找到的设备驱动程序。
U-Boot 非常灵活,可以从大容量存储、闪存或网络加载映像并进行引导。经过了 Linux 引导过程的一些复杂细节后,在下一章,我们将讨论该过程的下一阶段。这是你嵌入式项目的第三个元素——内核。
第四章:配置和构建内核
内核是嵌入式 Linux 的第三个组成部分。它是负责管理资源和与硬件接口的组件。因此,它几乎影响你最终软件构建的方方面面。每个完成的内核通常会为某些特定硬件进行配置。然而,设备树使我们能够使用通用内核,并通过 DTB 的内容将其定制为我们的硬件,就像我们在第三章中看到的那样。
在本章中,我们将探讨如何为一个开发板获取内核,如何配置和编译内核。我们将再次回顾引导过程,这次重点讨论内核的角色。我们还将探讨设备驱动程序以及它们如何从设备树中获取信息。
在本章中,我们将覆盖以下主题:
-
内核的作用是什么?
-
选择一个内核
-
配置内核
-
使用
Kbuild
进行编译 -
构建和启动内核
-
观察内核启动过程
-
将 Linux 移植到新板子上
技术要求
为了跟随示例,请确保你具备以下设备:
-
一个基于 Ubuntu 24.04 或更高版本 LTS 的主机系统
-
一个来自第二章的 Bootlin
aarch64
工具链 -
一个 microSD 卡读卡器和卡
-
一个安装了 U-Boot 的 microSD 卡,来自第三章
-
一条具有 3.3V 逻辑电平的 USB 至 TTL 串口电缆
-
Raspberry Pi 4
-
BeaglePlay
-
一个能够提供 3A 电流的 5V USB-C 电源供应器
本章使用的代码可以在本书 GitHub 仓库中的章节文件夹中找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development/tree/main/Chapter04
。
内核的作用是什么?
Linux 始于 1991 年,当时 Linus Torvalds 开始为 Intel 386 和 486 架构的个人计算机编写操作系统。他受到四年前 Andrew S. Tanenbaum 编写的 MINIX 操作系统的启发。Linux 与 MINIX 在许多方面有所不同;主要的区别是,Linux 是一个 32 位虚拟内存内核,而且它的代码是开源的,后来以 GPL v2 许可证发布。他在 1991 年 8 月 25 日通过comp.os.minix
新闻组发表了一篇著名的帖子,内容如下:
大家好,正在使用 minix 的朋友们——我正在为 386(486) AT 克隆机编写一个(免费的)操作系统(只是一个爱好,不会像 GNU 那样大而专业)。这个项目自四月以来一直在酝酿,现已开始准备就绪。我希望能得到关于 minix 中大家喜欢/不喜欢的反馈,因为我的操作系统在某些方面与它相似(例如相同的文件系统物理布局(出于实际原因)等)。
严格来说,Linus 并没有编写一个操作系统。他编写了一个内核,而内核只是操作系统的一个组成部分。为了创建一个完整的操作系统,其中包含用户空间命令和一个 shell 命令解释器,他使用了来自 GNU 项目的组件,特别是工具链、C 库和基本的命令行工具。这一区别至今仍然存在,并且赋予了 Linux 在使用方式上的极大灵活性。
伯克利软件分发版(BSD)比 Linux 早了很多年。BSD 起源于 1970 年代末期,加利福尼亚大学伯克利分校著名的计算机系统研究小组的一个研究项目。最初称为伯克利 Unix,BSD 基于贝尔实验室开发的原始 Unix 源代码。如今,BSD 已成为一个废弃的操作系统,但它的开源后代,如 FreeBSD、OpenBSD 和 NetBSD 仍在延续。最著名的例子是,苹果公司 macOS 和 iOS 操作系统中的开源系统 Darwin 就是 BSD 的衍生版本。
Linux 内核可以与 GNU 用户空间结合,创建一个完整的 Linux 发行版,运行在桌面和服务器上,这通常被称为 GNU/Linux。它还可以与 Android 用户空间结合,创建著名的移动操作系统,或者它可以与一个基于 BusyBox 的小型用户空间结合,创建一个紧凑的嵌入式系统。
与此对比的是 BSD 操作系统(FreeBSD、OpenBSD 和 NetBSD),在这些系统中,内核、工具链和用户空间被组合成一个统一的代码库。通过去除工具链,你可以部署更简洁的运行时镜像,而不需要编译器或头文件。通过将用户空间与内核解耦,你可以在初始化系统(runit
与 systemd
)、C 库(musl
与 glibc
)以及软件包格式(.apk
与 .deb
)上获得更多选择。
内核有三个主要功能——管理资源、与硬件交互,并提供一个 API,为用户空间程序提供有用的抽象层,概述如下图:
图 4.1 − 用户空间、内核空间和硬件
运行在用户空间的应用程序运行在较低的 CPU 特权级别。它们几乎只能做一些库调用。用户空间和内核空间之间的主要接口是C 库,它将用户级函数(如 POSIX 定义的函数)转换为内核系统调用。系统调用接口使用架构特定的方法,如陷阱或软件中断,将 CPU 从低特权用户模式切换到高特权内核模式。运行在内核模式下的 CPU 可以访问所有内存地址和 CPU 寄存器。
系统调用处理程序将调用分派给适当的内核子系统。内存分配调用会交给内存管理器,文件系统调用会交给文件系统代码,依此类推。某些调用需要底层硬件的输入,并会传递给设备驱动程序。在某些情况下,硬件本身通过触发中断来调用内核函数。
重要提示
图 4.1中的图示表明,内核代码有第二个入口点:硬件中断。中断只能在设备驱动程序中处理,用户空间应用程序无法处理。
换句话说,你的应用程序所做的所有有用的事情,都是通过内核来完成的。因此,内核是系统中最重要的元素之一。所以,理解如何选择一个内核非常重要。
选择内核
下一步是为你的项目选择内核。需要平衡你总是使用最新软件版本的愿望与对供应商特定补丁的需求,以及对代码库长期支持的兴趣。
内核开发周期
Linux 的开发速度非常快,每 8 到 12 周就会发布一个新版本。版本号的构建方式多年来发生了变化。在 2011 年 7 月之前,使用三位数版本方案,版本号像 2.6.39。中间的数字表示它是开发者版本还是稳定版本。奇数(2.1.x、2.3.x、2.5.x)是给开发者的,偶数版本是给最终用户的。
从 2.6 版本开始,Linux 放弃了长生命周期的开发分支(奇数版本),因为它减缓了新功能对用户的发布速度。2011 年 7 月,从 2.6.39 到 3.0 的版本号变更,纯粹是因为 Linus 觉得版本号变得太大了。
在这两个版本之间,Linux 的功能或架构没有发生巨大变化。他还借此机会去掉了中间的数字。从那时起,Linus 已经将主版本号提升了三次:2015 年 4 月(从 3 升级到 4),2019 年 3 月(从 4 升级到 5),以及最近的 2022 年 10 月(从 5 升级到 6)。每次版本号的提升都是出于整洁的考虑,而不是因为有大的架构变化。
Linus 管理着开发内核树。你可以通过克隆 Git 树来关注他,方法如下:
$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
这将把源代码检出到一个名为linux
的子目录。你可以通过时不时在该目录下运行git pull
命令来保持最新。
内核开发的完整周期始于为期两周的合并窗口,在此期间,Linus 会接受用于新特性的补丁。合并窗口结束时,进入稳定化阶段。一旦合并窗口关闭,Linus 会发布带有版本号 -rc1
、-rc2
等的每周发布候选版本,通常会发布到 -rc7
或 -rc8
。在此期间,人们会测试候选版本并提交错误报告和修复。当所有重大错误都被修复后,内核正式发布。
在合并窗口期间合并的代码通常已经相当成熟。通常,它是从许多子系统和内核架构维护者的仓库中拉取的。通过保持短周期的开发,特性可以在准备好时进行合并。如果内核维护者认为某个特性不够稳定或不够完善,它可以被推迟到下一个版本。
跟踪每个版本之间的变化并不容易。你可以阅读 Linus 的 Git 仓库中的提交日志,但由于条目过多,很难获得整体概览。幸运的是,Linux 有一个Kernel Newbies网站(kernelnewbies.org
),你可以在 kernelnewbies.org/LinuxVersions
上找到每个版本的简明概览。
稳定版和长期支持版发布
Linux 的快速变化速度是一件好事,因为它将新特性引入主线代码库,但它与嵌入式项目较长的生命周期并不完全匹配。内核开发者通过两种方式来解决这个问题:稳定版发布和长期支持版发布。在主线内核(由 Linus Torvalds 维护)发布后,它会被移到稳定树(由 Greg Kroah-Hartman 维护)。错误修复会应用到稳定版内核,而主线内核则进入下一个开发周期。
稳定版内核的点版本通过第三个数字来标记(例如 3.18.1、3.18.2 等)。在版本 3 之前,有四个版本号(例如 2.6.29.1、2.6.39.2 等)。
你可以通过以下命令获取稳定树:
$ cd ~
$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/stable/linux-stable.git
你可以使用 git
checkout
来获取特定版本,例如 6.6.46:
$ cd linux-stable
$ git checkout v6.6.46
稳定版内核通常只会更新到下一个主线版本(大约 8 到 12 周后),因此你会看到 www.kernel.org/
上通常只有一个或两个稳定版内核。为了满足那些需要更长时间更新的用户,一些内核被标记为长期支持,并维持两年或更长时间。长期支持内核保证会发现并修复所有错误。每年至少会发布一个长期支持的内核版本。
以 2024 年 8 月查看www.kernel.org/
时,共有六个长期维护的内核版本:6.6、6.1、5.15、5.10、5.4 和 4.19。最老的版本已经维护了近六年,当前版本为 4.19.319。如果你正在构建一个需要长期维护的产品,那么最新的长期维护内核可能是一个不错的选择。
供应商支持
在理想的情况下,你应该能够从www.kernel.org/
下载内核,并为任何声称支持 Linux 的设备配置它。然而,现实中并非总是如此。事实上,主线 Linux 只对能运行 Linux 的众多设备中的一小部分提供了稳定支持。你可能会在一些独立的开源项目中找到对你的开发板或 SoC 的支持,比如 Linaro (www.linaro.org/
) 或 Yocto 项目 (www.yoctoproject.org/
)。有些公司提供嵌入式 Linux 的付费第三方支持。但在许多情况下,你将不得不依赖于 SoC 或开发板供应商提供的可用内核。
正如我们所知,一些供应商比其他供应商更擅长支持 Linux。在此阶段,我的建议是选择那些提供良好支持的供应商,或者更好的是,选择那些努力将其内核更改提交到主线的供应商。可以通过搜索 Linux 内核邮件列表或提交历史,查看候选的 SoC 或开发板是否有最近的活动。当主线内核中没有上游更改时,判断一个供应商是否提供良好支持,往往依赖于口碑。有些供应商因只发布一个内核代码版本后,就将所有精力转向更新的 SoC,声名狼藉。
许可证
Linux 源代码是按照 GPL v2 许可证授权的。这意味着你必须以许可证中规定的某种方式提供你的内核源代码。
内核的实际许可证文本位于文件 COPYING
中。它以 Linus 撰写的附录开始,声明通过系统调用接口从用户空间调用内核的代码不被视为内核的衍生作品,因此不受许可证约束。因此,专有应用程序运行在 Linux 上并没有问题。
然而,Linux 许可证中有一个领域引起了无休止的混淆和争议:内核模块。内核模块只是一个在运行时与内核动态链接的代码片段,从而扩展了内核的功能。通用公共许可证(GPL)并未区分静态链接和动态链接,因此,内核模块的源代码似乎受 GPL 的覆盖。在 Linux 的早期,关于这个规则的例外存在争议,例如与 安德鲁文件系统(AFS)相关的争论。因为这个代码早于 Linux,因此(有人辩称)它不是衍生作品,因此不受许可证的约束。
多年来,就其他代码片段进行过类似的讨论,结果现在已接受的做法是,GPL 不一定适用于内核模块。这一点通过内核中的MODULE_LICENSE
宏进行了规定,该宏可以设置为Proprietary
,表示该模块没有遵循 GPL 许可证。如果你打算使用相同的论据,可能需要阅读一封经常被引用的邮件线程,标题为Linux GPL 和二进制模块例外条款?,该邮件已存档于yarchive.net/comp/linux/gpl_modules.html
。
GPL 应该被视为一件好事,因为它确保了我们在进行嵌入式项目时,总是能够获取内核的源码。没有它,嵌入式 Linux 将变得更加难以使用且更加支离破碎。
最佳实践
话虽如此,选择内核时,你需要权衡使用最新版本的好处与厂商特定增强功能和驱动程序稳定性的需求。此外,快速发展的 Linux 开发周期使得新特性能够迅速集成,并且有稳定的长期支持版本可供扩展维护。长期支持内核会获得超过两年的更新,非常适合长期项目。厂商支持也至关重要,因此,确保选择那些积极支持 Linux 并为主线内核做出贡献的厂商。最后,GPL v2 许可证确保了内核源码的获取,这使得在嵌入式项目中使用和维护内核变得更加容易。
配置内核
一旦决定了基于哪个内核来构建镜像,接下来的步骤是配置内核。
获取源码
本书中使用的三个目标(Raspberry Pi 4、BeaglePlay 和 QEMU)都得到了主线内核的良好支持。因此,使用来自www.kernel.org/
的最新长期支持内核是有意义的,在写作时,该内核版本为 6.6.46。当你自己操作时,应该检查是否有 6.6 内核的更新版本,并使用该版本,因为它会修复 6.6.46 发布后发现的错误。
重要说明
如果有更晚的长期发布版本,你可能希望考虑使用那个版本。但要注意,可能已经有一些变化,意味着以下命令序列无法完全按给定的方式工作。
要获取并提取 6.6.46 版本的 Linux 内核发布 tarball,可以使用以下命令:
$ cd ~
$ wget https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.6.46.tar.xz
$ tar xf linux-6.6.46.tar.xz
$ mv linux-6.6.46 linux-stable
要获取更新版本,只需将linux-
后的6.6.46
替换为所需的长期支持版本。
这里有大量代码。6.6 内核中有超过 81,000 个文件,包含 C 源代码、头文件和汇编代码,总计超过 2400 万行代码,根据 SLOCCount 工具的测量结果。尽管如此,了解代码的基本布局以及大概在哪个地方查找特定组件是很有价值的。主要的关注目录有:
-
arch
:包含特定于体系结构的文件。每种体系结构都有一个子目录。 -
Documentation
:包含内核文档。如果想找到有关 Linux 某方面更多信息,总是首先查看此处。 -
drivers
:包含设备驱动程序,数以千计。每种类型的驱动程序都有一个子目录。 -
fs
:包含文件系统代码。 -
include
:包含内核头文件,包括在构建工具链时需要的文件。 -
init
:包含内核启动代码。 -
kernel
:包含核心功能,包括调度、锁定、定时器、电源管理以及调试/跟踪代码。 -
mm
:包含内存管理。 -
net
:包含网络协议。 -
scripts
:包含许多有用的脚本,包括设备树编译器,在第三章中有描述。 -
tools
:包含许多有用的工具,包括 Linux 性能计数器工具(perf
),我将在第二十章中描述。
随着时间的推移,你会熟悉这种结构,并意识到,如果你要找特定 SoC 的串口代码,你会在drivers/tty/serial
找到它,而不是在arch/$ARCH/mach-foo
中,因为它是设备驱动,而不是特定于 CPU 架构的内容。
理解内核配置– Kconfig
Linux 的一个优点是可以根据不同的需求配置内核,从小型的专用设备(如智能恒温器)到复杂的移动电话。在当前版本中,有成千上万的配置选项。正确配置配置本身就是一项任务,但在深入讨论之前,我想先向你展示它是如何工作的,这样你可以更好地理解正在发生的事情。
配置机制被称为Kconfig
,与之集成的构建系统被称为Kbuild
。这两者的文档位于Documentation/kbuild
中。Kconfig
/Kbuild
不仅在内核项目中使用,还包括 Crosstool-NG、U-Boot、Barebox 和 BusyBox 等其他项目。
配置选项在名为Kconfig
的一系列文件中声明,使用的语法在Documentation/kbuild/kconfig-language.rst
中描述。
在 Linux 中,顶层的Kconfig
看起来像这样:
mainmenu "Linux/$(ARCH) $(KERNELVERSION) Kernel Configuration"
comment "Compiler: $(CC_VERSION_TEXT)"
source "scripts/Kconfig.include"
<…>
arch/Kconfig
的第一行是:
source "arch/$(SRCARCH)/Kconfig"
该行包括依赖于启用了哪些选项的其他Kconfig
文件的体系结构相关配置文件。
让体系结构发挥如此重要作用有三个含义:
-
首先,在配置 Linux 时必须指定一个体系结构,设置
ARCH=<architecture>
;否则,它将默认为本地机器体系结构。 -
其次,通常情况下,你设置的
ARCH
的值决定了SRCARCH
的值,因此你很少需要显式设置SRCARCH
。 -
第三,每种体系结构的顶级菜单布局都不同。
你在ARCH
中设置的值是arch
目录中找到的子目录之一,唯一的特殊情况是ARCH=i386
和ARCH=x86_64
都会引用arch/x86/Kconfig
。
Kconfig
文件大部分由menu
和endmenu
关键字划定的菜单组成。菜单项由config
关键字标记。
下面是一个来自drivers/char/Kconfig
的示例:
menu "Character devices"
<…>
config DEVMEM
bool "/dev/mem virtual device support"
default y
help
Say Y here if you want to support the /dev/mem device.
The /dev/mem device is used to access areas of physical memory.
When in doubt, say "Y".
<…>
endmenu
紧跟在config
后面的参数指定了一个变量,这里是DEVMEM
。由于此选项是bool
(布尔值),它只能有两个值:如果启用,它的值为y
,如果未启用,则该变量根本不定义。屏幕上显示的菜单项名称是bool
关键字后面的字符串。
此配置项及其他所有配置项存储在名为.config
的文件中。
提示
.config
中的前导点(.
)表示它是一个隐藏文件,默认情况下,ls
命令不会显示,除非你使用ls -a
来显示所有文件。
与此配置项对应的行是:
CONFIG_DEVMEM=y
除了bool
之外,还有几种其他数据类型。以下是完整的列表:
-
bool
:要么是y
,要么未定义。 -
tristate
:用于某个功能可以作为内核模块或内核映像的一部分进行构建的情况。其值为m
表示作为模块,y
表示构建为内核的一部分,若未启用该功能,则未定义。 -
int
:使用十进制表示法的整数值。 -
hex
:使用十六进制表示法的无符号整数值。 -
string
:字符串值。
项目之间可能存在依赖关系,依赖关系通过depends on
构造来表示,如下所示:
config MTD_CMDLINE_PARTS
tristate "Command line partition table parsing"
depends on MTD
如果CONFIG_MTD
在其他地方未启用,则该菜单选项不会显示,因此无法选择。
还有反向依赖关系。select
关键字在启用某个选项时,会启用其他选项。arch/$ARCH
中的Kconfig
文件包含了许多select
语句,启用了特定于架构的功能,下面是 Arm 架构的示例:
config ARM
bool
default y
select ARCH_CLOCKSOURCE_DATA
select ARCH_HAS_DEVMEM_IS_ALLOWED
<…>
通过选择ARCH_CLOCKSOURCE_DATA
和ARCH_HAS_DEVMEM_IS_ALLOWED
,我们将这两个变量的值设置为y
,以便将这些功能静态构建到内核中。
有几种配置工具可以读取Kconfig
文件并生成.config
文件。它们中的一些会在屏幕上显示菜单并允许你进行交互式选择。menuconfig
可能是大多数人熟悉的工具,但也有xconfig
和gconfig
。
使用menuconfig
之前,你需要先安装ncurses
、flex
和bison
。以下命令在 Ubuntu 上安装所有这些依赖项:
$ sudo apt install libncurses5-dev flex bison
你可以通过make
命令启动menuconfig
,请记住,在内核的情况下,你需要提供一个架构,如下所示:
$ cd ~
$ export PATH=${HOME}/aarch64--glibc--stable-2024.02-1/bin/:$PATH
$ export CROSS_COMPILE=aarch64-buildroot-linux-gnu-
$ cd linux-stable
$ mkdir ../build_arm64
$ make ARCH=arm64 menuconfig O=../build_arm64
确保你的PATH
变量指向你在第二章中下载的 64 位工具链。
这里你可以看到带有先前突出显示的DEVMEM
配置选项的menuconfig
:
图 4.2 − 选择 DEVMEM
位于项目前的星号(*
)表示该驱动已被选择为静态编译到内核中。如果是M
,则表示它已被选择为内核模块,以便在运行时插入内核中。
提示
你经常会看到类似于启用CONFIG_BLK_DEV_INITRD
的指令,但由于有很多菜单可供浏览,找到设置该配置项的地方可能需要一段时间。所有配置编辑器都有一个搜索功能。你可以在menuconfig
中通过按下斜杠键*/
来访问它。在xconfig
中,它位于编辑菜单下,但请确保在搜索配置项时不包括CONFIG_
部分。
由于需要配置的内容太多,每次构建内核时从头开始是不现实的,因此在arch/$ARCH/configs
中有一组已知的工作配置文件,每个文件包含适用于单个 SoC 或一组 SoC 的配置值。
你可以使用make <配置文件名称>
命令来选择一个配置。例如,要配置 Linux 以支持广泛的 64 位 Arm SoC,你需要输入:
$ make ARCH=arm64 defconfig O=../build_arm64
这是一个通用内核,适用于各种开发板。对于更专业的应用,比如使用厂商提供的内核,默认配置文件是板级支持包的一部分。在你开始构建内核之前,你需要确定使用哪个配置文件。
还有一个有用的配置目标叫做oldconfig
。当你将配置迁移到新内核版本时使用它。该目标会采用一个现有的.config
文件,并向你提问有关新配置选项的问题。将旧内核的.config
文件复制到新源目录并运行make ARCH=arm64 oldconfig
命令,以使其与新版本同步。
oldconfig
目标也可以用来验证你手动编辑过的.config
文件(忽略顶部出现的文本自动生成的文件;请勿编辑)。
如果你对配置进行了更改,那么修改后的.config
文件将成为你的板级支持包的一部分,并需要放置在源代码管理之下。
当你开始构建内核时,会生成一个名为include/generated/autoconf.h
的头文件。这个头文件包含每个配置值的#define
,以便它可以包含在内核源代码中。
现在我们已经确定了内核并学会了如何配置它,我们将进行标识。
使用 LOCALVERSION 来标识你的内核
你可以通过使用make kernelversion
和make kernelrelease
目标来发现你构建的内核版本和发布版本:
$ make ARCH=arm64 kernelversion
6.6.46
$ make ARCH=arm64 kernelrelease O=../build_arm64
6.6.46
这可以通过uname
命令在运行时报告,并且也用于命名存储内核模块的目录。
如果你更改了默认配置,建议你附加版本信息,可以通过在menuconfig
中设置CONFIG_LOCALVERSION
来配置:
$ make ARCH=arm64 menuconfig O=../build_arm64
例如,如果我想标记我正在构建的内核,使用标识符meld
和版本1.0
,那么我会在menuconfig
中这样定义本地版本:
图 4.3 – 追加到内核发布版本
退出menuconfig
并在被询问是否保存新配置时选择是。
运行make prepare
以使用新的kernelrelease
版本刷新Makefile
:
$ make ARCH=arm64 prepare O=../build_arm64
运行make kernelversion
会产生与之前相同的输出,但如果我现在运行make kernelrelease
,我会看到:
$ make ARCH=arm64 kernelrelease O=../build_arm64
6.6.46-meld-v1.0
这是一个愉快的绕道,关于内核版本管理,但现在让我们回到配置内核以进行编译的正题。
何时使用内核模块
我已经多次提到内核模块了。桌面 Linux 发行版广泛使用它们,以便根据检测到的硬件和所需的功能在运行时加载正确的设备和内核功能。如果没有内核模块,每个驱动程序和功能都必须静态链接到内核中,这会使内核变得异常庞大。
另一方面,对于嵌入式设备,硬件和内核配置通常在构建内核时就已知,因此模块不是那么有用。事实上,它们会造成一个问题,因为它们在内核和根文件系统之间创建了版本依赖关系,如果其中一个更新而另一个没有更新,就可能导致引导失败。因此,嵌入式内核通常是构建时没有任何模块的。
以下是内核模块在嵌入式系统中是一个好主意的几个场景:
-
当你有专有模块时,出于前面部分提到的许可原因。
-
通过推迟加载非必要的驱动程序来减少启动时间。
-
当有多个驱动程序可能需要加载,而静态编译它们会占用太多内存时。例如,你有一个支持多种设备的 USB 接口。这基本上与桌面发行版中的相同论点。
-
接下来,让我们学习如何使用或不使用内核模块通过
Kbuild
编译内核映像。
使用 Kbuild 进行编译
内核构建系统(Kbuild
)是一组make
脚本,它从.config
文件中获取配置信息,计算依赖关系,并编译所有必要的内容以生成内核映像。该内核映像包含所有静态链接的组件、可选的设备树二进制文件以及任何内核模块。依赖关系通过每个目录中的 Makefile 表示,包含可构建组件。例如,以下两行来自drivers/char/Makefile
:
obj-y += mem.o random.o
obj-$(CONFIG_TTY_PRINTK) += ttyprintk.o
obj-y
规则无条件地编译文件以生成目标,因此mem.c
和random.c
始终是内核的一部分。在第二行中,ttyprintk.c
依赖于一个配置参数。如果CONFIG_TTY_PRINTK
为y
,则它作为内置模块编译。如果为m
,则编译为模块。如果该参数未定义,则完全不编译。
对于大多数目标,只需键入make
(并设置合适的ARCH
和CROSS_COMPILE
)即可完成工作,但逐步执行是有教育意义的。请参见第二章的最后部分,了解CROSS_COMPILE
的make
变量的含义。
确定要构建的内核目标
要构建内核镜像,您需要了解引导加载程序的要求。以下是一个粗略的指南:
-
U-Boot:可以为 64 位 Arm 加载压缩的
Image.gz
文件。也可以使用bootz
命令为 32 位 Arm 加载自解压的zImage
文件。 -
x86 目标:需要一个
bzImage
文件。 -
大多数其他引导加载程序:需要一个
zImage
文件。
这是为 64 位 Arm 构建Image.gz
文件的示例:
$ sudo apt install libssl-dev
$ PATH=~/aarch64--glibc--stable-2024.02-1/bin/:$PATH
$ cd ~
$ cd linux-stable
$ make -j<n> ARCH=arm64 CROSS_COMPILE=aarch64-buildroot-linux-gnu- Image.gz O=../build_arm64
确保您的PATH
变量指向您在第二章中下载的 64 位工具链。
重要提示
当您第一次在内核源树上运行make
时,可能会提示您包括或省略各种功能、选项和插件。这些功能和选项大多数提供了更高的安全性,因此添加它们是没有坏处的。有一个显著的例外。在提示选择 GCC 插件时,确保选择n
表示否,如下所示:
*
* GCC plugins
*
GCC plugins (GCC_PLUGINS) [Y/n/?] (NEW) n
否则,构建将失败,因为make
找不到g++
。
请记住,在make -j
后替换<n>
为您主机上可用的 CPU 核心数,以加速构建过程。
提示
-j<n>
选项告诉make
并行运行多少个作业,这样可以减少构建所需的时间。make -j4
将运行四个作业。一个粗略的指南是,运行的作业数应该与您主机上的 CPU 核心数相等。
目前,AArch64 内核不提供解压器,因此,如果使用压缩的Image
目标(例如Image.gz
),需要由引导加载程序执行解压(gzip
等)。
无论我们目标的是哪种内核镜像格式,在生成可引导镜像之前,始终先生成这两个构建产物(vmlinux
和System.map
)。
构建构件
内核构建会在顶级目录中生成两个文件:vmlinux
和System.map
。第一个文件vmlinux
是作为 ELF 二进制文件的内核。如果您在编译内核时启用了调试(CONFIG_DEBUG_INFO=y
),它将包含可用于调试器(如kgdb
)的调试符号。您还可以使用其他 ELF 二进制工具,例如size
,来衡量每个段(text
、data
和bss
)的长度,这些段组成了vmlinux
可执行文件:
$ cd ~
$ cd build_arm64
$ aarch64-buildroot-linux-gnu-size vmlinux
text data bss dec hex filename
25923719 15631632 620032 42175383 2838b97 vmlinux
像内核这样的程序在内存中被划分为多个段。text
段包含可执行指令(代码)。data
段包含初始化的全局和静态变量。bss
段包含未初始化的全局和静态变量。dec
和 hex
值分别是文件大小的十进制和十六进制表示。
System.map
包含符号表,以人类可读的形式展示。
大多数引导加载程序无法直接处理 ELF 代码。因此,需要进一步的处理步骤,将 vmlinux
转换为适合各种引导加载程序的二进制文件,并放置在 arch/$ARCH/boot
目录中:
-
Image
:将vmlinux
转换为原始二进制格式。 -
zImage
:对于 PowerPC 架构,这只是Image
的压缩版本,意味着引导加载程序必须进行解压缩。对于其他所有架构,压缩后的Image
会附加在一个解压缩并重定位它的代码存根上。 -
uImage
:zImage
加上一个 64 字节的 U-Boot 头。
在构建过程中,你将看到正在执行的命令摘要:
$ make -j<n> ARCH=arm64 CROSS_COMPILE=aarch64-buildroot-linux-gnu- Image.gz O=../build_arm64
<…>
CC scripts/mod/empty.o
HOSTCC scripts/mod/mk_elfconfig
CC scripts/mod/devicetable-offsets.s
UPD scripts/mod/devicetable-offsets.h
MKELF scripts/mod/elfconfig.h
HOSTCC scripts/mod/modpost.o
HOSTCC scripts/mod/file2alias.o
HOSTCC scripts/mod/sumversion.o
<…>
当内核构建失败时,有时查看实际执行的命令会很有用。为此,可以在命令行中添加 V=1
:
$ make -j<n> ARCH=arm64 CROSS_COMPILE=aarch64-buildroot-linux-gnu- V=1 Image.gz O=../build_arm64
在这一部分,我们了解了 Kbuild
如何将预编译的 vmlinux
ELF 二进制文件转换为可引导的内核镜像。接下来,我们将探讨如何编译设备树。
编译设备树
dtbs target for arch/arm64/configs/defconfig:
$ make ARCH=arm64 dtbs CROSS_COMPILE=aarch64-buildroot-linux-gnu- O=../build_arm64
<…>
DTC arch/arm64/boot/dts/ti/k3-am625-beagleplay.dtb
DTC arch/arm64/boot/dts/ti/k3-am625-phyboard-lyra-rdk.dtb
DTC arch/arm64/boot/dts/ti/k3-am625-sk.dtb
DTC arch/arm64/boot/dts/ti/k3-am625-verdin-nonwifi-dahlia.dtb
DTC arch/arm64/boot/dts/ti/k3-am625-verdin-nonwifi-dev.dtb
DTC arch/arm64/boot/dts/ti/k3-am625-verdin-nonwifi-yavia.dtb
DTC arch/arm64/boot/dts/ti/k3-am625-verdin-wifi-dahlia.dtb
DTC arch/arm64/boot/dts/ti/k3-am625-verdin-wifi-dev.dtb
DTC arch/arm64/boot/dts/ti/k3-am625-verdin-wifi-yavia.dtb
<…>
编译后的 .dtb
文件会生成在 ../build_arm64
输出目录中。
编译模块
如果你配置了一些功能作为模块进行构建,那么你可以使用 modules
目标单独构建它们:
$ make -j<n> ARCH=arm64 CROSS_COMPILE=aarch64-buildroot-linux-gnu- modules O=../build_arm64
将 make -j
后面的 <n>
替换为主机机器上可用的 CPU 核心数,以加快构建速度。
编译后的模块具有 .ko
后缀,并生成在与源代码相同的目录中,这意味着它们散布在整个内核源代码树中。找到它们有点棘手,但你可以使用 modules_install
目标将它们安装到正确的位置。
默认位置是开发系统中的 /lib/modules
,这几乎肯定不是你想要的位置。要将它们安装到根文件系统的暂存区,使用 INSTALL_MOD_PATH
提供路径:
$ mkdir ~/rootfs
$ make -j<n> ARCH=arm64 CROSS_COMPILE=aarch64-buildroot-linux-gnu- INSTALL_MOD_PATH=$HOME/rootfs modules_install O=../build_arm64
内核模块被放置在相对于文件系统根目录的 /lib/modules/<kernel version>
目录下。
清理内核源代码
有三个用于清理内核源代码树的 make
目标:
-
clean:删除目标文件和大多数中间文件。
-
mrproper:删除所有中间文件,包括
.config
文件。使用此目标可以将源代码树恢复到克隆或提取源代码后立即的状态。Mr. Proper 是一种清洁产品,在一些地区很常见。make mrproper
的目的是对内核源代码进行彻底清理。 -
distclean:与
mrproper
相同,但还会删除编辑器备份文件、补丁文件和其他软件开发的产物。
构建和启动内核
构建和启动 Linux 高度依赖于设备。在本节中,我将展示如何在树莓派 4、BeaglePlay 和 QEMU 上实现。对于其他目标板,您必须咨询供应商或社区项目的相关信息(如果有的话)。
为树莓派 4 构建内核
尽管主线内核已支持树莓派 4,我更倾向于使用树莓派基金会的 Linux 分支(github.com/raspberrypi/linux
)以保证稳定性。2024 年 8 月,该分支的最新长期支持内核版本为 6.6,因此我们将构建该版本。
由于树莓派 4 使用 64 位四核 Arm Cortex-A72 CPU,我们将使用第二章**,中的 Bootlin 工具链为其交叉编译一个 64 位内核。
安装构建内核所需的包:
$ sudo apt install libssl-dev
现在你已经安装了所需的工具链和软件包,克隆内核仓库的6.6.y
分支(一层深度)到一个名为linux-rpi
的目录,并将一些预构建的二进制文件导出到boot
子目录:
$ cd ~
$ git clone --depth=1 -b rpi-6.6.y https://github.com/raspberrypi/linux.git linux-rpi
$ git clone --depth=1 -b 1.20240529 https://github.com/raspberrypi/firmware.git firmware-rpi
$ mv firmware-rpi/boot .
$ rm -rf firmware-rpi
$ rm boot/kernel*
$ rm boot/*.dtb
$ rm boot/overlays/*.dtbo
--depth=n
参数指示 Git 在克隆时仅获取最后n
个提交。
导航到新克隆的linux-rpi
目录并构建内核:
$ PATH=~/aarch64--glibc--stable-2024.02-1/bin/:$PATH
$ cd ~
$ cd linux-rpi
$ make ARCH=arm64 CROSS_COMPILE=aarch64-buildroot-linux-gnu- bcm2711_defconfig O=../build_rpi
$ make -j<n> ARCH=arm64 CROSS_COMPILE=aarch64-buildroot-linux-gnu- O=../build_rpi
将make -j
后的<n>
替换为主机机器上可用的 CPU 核心数,以加速构建过程。
构建完成后,将内核镜像、设备树二进制文件和启动参数复制到 boot 子目录:
$ cp ../build_rpi/arch/arm64/boot/Image ../boot/kernel8.img
$ cp ../build_rpi/arch/arm64/boot/dts/overlays/*.dtbo ../boot/overlays/
$ cp ../build_rpi/arch/arm64/boot/dts/broadcom/*.dtb ../boot/
$ cat << EOF > ../boot/config.txt
enable_uart=1
arm_64bit=1
EOF
$ cat << EOF > ../boot/cmdline.txt
console=serial0,115200 console=tty1 root=/dev/mmcblk0p2 rootwait
EOF
上述命令都可以在脚本MELD/Chapter04/build-linux-rpi4.sh
中找到。请注意,写入cmdline.txt
的内核命令行必须全部在一行内。让我们将这些步骤分解为几个阶段:
-
将树莓派基金会的
rpi-6.6.y
分支克隆到linux-rpi
目录。 -
将树莓派基金会的
1.20240529
标签的固件仓库克隆到firmware-rpi
目录。 -
将树莓派基金会的
firmware
仓库中的boot
子目录移动到boot
目录中。 -
从
boot
目录删除现有的内核镜像、设备树二进制文件和设备树覆盖。 -
从
linux-rpi
目录,构建树莓派 4 的 64 位内核、模块和设备树。 -
将新构建的内核镜像、设备树二进制文件和设备树覆盖从./build_rpi/
arch/arm64/boot
复制到boot
目录。 -
将
config.txt
和cmdline.txt
文件写入boot
目录,以供树莓派 4 的引导加载程序读取并传递给内核。
让我们看一下config.txt
中的设置。enable_uart=1
这一行在启动过程中启用串口控制台,而默认情况下是禁用的。arm_64bit=1
这一行指示树莓派 4 的引导加载程序以 64 位模式启动 CPU,并从名为kernel8.img
的文件中加载内核镜像。
现在,我们来看一下cmdline.txt
。console=serial0,115200
和console=tty1
内核命令行参数指示内核在启动过程中将日志消息输出到串口控制台。
启动 Raspberry Pi 4
Raspberry Pi 设备使用 Broadcom 提供的专有引导加载程序,而不是 U-Boot。与以前的 Raspberry Pi 型号不同,Raspberry Pi 4 的引导加载程序驻留在板载 SPI EEPROM 中,而不是在 microSD 卡上。我们仍然需要将 Raspberry Pi 4 的内核镜像和设备树 blob 放到 microSD 卡上,以便启动我们的 64 位内核。
在继续之前,你需要一张带有 FAT32 boot
分区的 microSD 卡,分区足够大以容纳必要的内核构建文件。boot
分区需要是 microSD 卡的第一个分区,1 GB 的分区大小足够。
有关如何将 USB 到 TTL 串口电缆连接到 Raspberry Pi 4 的指导,请参见learn.adafruit.com/adafruits-raspberry-pi-lesson-5-using-a-console-cable/connect-the-lead
。
要准备一张 microSD 卡,加载你新构建的内核镜像并在 Raspberry Pi 4 上启动:
-
首先,进入
boot
目录的上一级:$ cd ~
-
接下来,将 microSD 卡插入卡读卡器,并将
boot
目录的所有内容复制到boot
分区。 -
卸载卡并将其插入 Raspberry Pi 4。
-
将你的 USB 到 TTL 串口电缆连接到 40 针 GPIO 头的 GND、TXD 和 RXD 引脚。
-
启动一个终端仿真器,如
gtkterm
。 -
最后,启动 Raspberry Pi 4。
你应该能在串口控制台看到以下输出:
[ 0.000000] Booting Linux on physical CPU 0x0000000000 [0x410fd083]
[ 0.000000] Linux version 6.6.45-v8+ (frank@frank-nuc) (aarch64-buildroot-linux-gnu-gcc.br_real (Buildroot 2021.11-11272-ge2962af) 12.3.0, GNU ld (GNU Binutils) 2.41) #1 SMP PREEMPT Mon Aug 19 08:51:43 PDT 2024
[ 0.000000] KASLR enabled
[ 0.000000] random: crng init done
[ 0.000000] Machine model: Raspberry Pi 4 Model B Rev 1.1
[ 0.000000] efi: UEFI not found.
[ 0.000000] Reserved memory: created CMA memory pool at 0x000000002ac00000, size 64 MiB
<…>
该序列将以内核恐慌结束,因为内核无法在 microSD 卡上找到根文件系统。我将在本章后面解释什么是内核恐慌。
为 BeaglePlay 构建内核
下面是为 BeaglePlay 构建内核、模块和设备树的命令序列:
-
首先,如果尚未添加,将 64 位 Arm 工具链添加到你的
PATH
中:$ PATH=~/aarch64--glibc--stable-2024.02-1/bin/:$PATH
-
接下来,返回主线 Linux 源树:
$ cd ~ $ cd linux-stable $ mkdir ../build_beagleplay
-
为 64 位 Arm 设置
ARCH
和CROSS_COMPILE
环境变量:$ export ARCH=arm64 $ export CROSS_COMPILE=aarch64-buildroot-linux-gnu-
-
运行
make defconfig
来配置一个适用于大多数 64 位 Arm SoC 的内核:$ make defconfig O=../build_beagleplay
-
运行
make menuconfig
继续配置内核:$ make menuconfig O=../build_beagleplay
-
进入通用架构相关选项子菜单。
-
如果已选择GCC 插件,请取消选择。
-
退出通用架构相关选项子菜单。
-
进入平台选择子菜单。
-
取消选择所有 SoC 的支持,除了德州仪器公司 K3 多核 SoC 架构。
-
退出平台选择子菜单。
-
进入设备驱动程序 | 图形支持子菜单
-
取消选择直接渲染管理器。
-
退出图形支持和设备驱动程序子菜单。
-
退出
menuconfig
并在询问是否保存新配置时选择是。 -
最后,为 BeaglePlay 构建内核、模块和设备树:
$ make -j<n> O=../build_beagleplay
将make -j
后面的<n>
替换为你主机上可用的 CPU 核心数,以加快构建速度。
启动 BeaglePlay
在继续之前,你需要一张已经安装了 U-Boot 的 microSD 卡,如第三章中的安装 U-Boot部分所描述:
-
首先,进入
build_beagleplay
目录的上一层:$ cd ~
-
接下来,将 microSD 卡插入卡读卡器,并将
build_beagleplay/arch/arm64/boot/Image.gz
和build_beagleplay/arch/arm64/boot/dts/ti/k3-am625-beagleplay.dtb
文件复制到 FAT32 的boot
分区中。 -
卸载卡并将其插入 BeaglePlay。
-
启动终端模拟器,如
gtkterm
,并准备在看到 U-Boot 消息出现时立即按下空格键。 -
按住 USR 按钮并按下空格键启动 BeaglePlay。
-
最后,在 U-Boot 提示符下输入以下命令:
nova!> setenv bootargs console=ttyS2,115200n8 nova!> fatload mmc 1 0x80000000 Image.gz nova!> fatload mmc 1 0x82000000 k3-am625-beagleplay.dtb nova!> setenv kernel_comp_addr_r 0x85000000 nova!> setenv kernel_comp_size 0x2000000 nova!> booti 0x80000000 - 0x82000000
你应该在串行控制台看到以下输出:
Starting kernel ...
[ 0.000000] Booting Linux on physical CPU 0x0000000000 [0x410fd034]
[ 0.000000] Linux version 6.6.46 (frank@frank-nuc) (aarch64-buildroot-linux-gnu-gcc.br_real (Buildroot 2021.11-11272-ge2962af)
12.3.0, GNU ld (GNU Binutils) 2.41) #1 SMP PREEMPT Mon Aug 19 11:24:56 PDT 2024
[ 0.000000] KASLR disabled due to lack of seed
[ 0.000000] Machine model: BeagleBoard.org BeaglePlay
[ 0.000000] efi: UEFI not found.
<…>
请注意,我们将内核命令行设置为console=ttyS2
。这告诉 Linux 使用哪个 UART 设备来输出控制台信息。如果没有这个设置,我们将无法看到Starting the kernel...
之后的任何信息,也无法知道系统是否正常工作。序列会以内核崩溃结束,正如 Raspberry Pi 4 所表现的一样。
为 QEMU 构建内核
这是为 QEMU 模拟的virt
通用虚拟平台构建 Linux 的命令序列:
-
如果你还没有添加 64 位 Arm 工具链到
PATH
,请首先添加:$ PATH=~/aarch64--glibc--stable-2024.02-1/bin/:$PATH
-
接下来,返回到主线 Linux 源码树:
$ cd ~ $ cd linux-stable $ mkdir ../build_qemu
-
为 64 位 Arm 设置
ARCH
和CROSS_COMPILE
环境变量:$ export ARCH=arm64 $ export CROSS_COMPILE=aarch64-buildroot-linux-gnu-
-
运行
make defconfig
来配置适用于大多数 64 位 Arm SoC 的内核:$ make defconfig O=../build_qemu
-
运行
make menuconfig
继续配置内核:$ make menuconfig O=../build_qemu
-
进入平台选择子菜单。
-
取消选择除ARMv8 软件模型(Versatile Express)外的所有 SoC 支持。
-
退出平台选择子菜单。
-
选择ACPI(高级配置和电源接口)支持。
-
退出
menuconfig
,并在被询问是否保存新配置时选择Yes。 -
最后,为 QEMU 构建内核、模块和设备树:
$ make -j<n> O=../build_qemu
将make -j
后面的<n>
替换为你主机上可用的 CPU 核心数,以加快构建速度。
启动 QEMU
假设你已经安装了qemu-system-aarch64
,你可以通过以下命令从主线内核源码树启动 QEMU:
$ qemu-system-aarch64 -M virt -cpu cortex-a53 -nographic -smp 1 -kernel ../build_qemu/arch/arm64/boot/Image -append "console=ttyAMA0"
与 Raspberry Pi 4 和 BeaglePlay 一样,这将以内核崩溃并导致系统停止。要退出 QEMU,按Ctrl + A然后按x(分别按下两个键)。
观察内核启动过程
到此为止,你应该已经拥有了 Raspberry Pi 4、BeaglePlay 和 QEMU 的内核映像文件和设备树文件。让我们首先看一下内核崩溃。
内核崩溃
尽管在 QEMU 上起步顺利,但最终结果却不如人意:
[ 0.393978] Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(0,0)
[ 0.394269] CPU: 0 PID: 1 Comm: swapper/0 Not tainted 6.6.46 #2
[ 0.394443] Hardware name: linux,dummy-virt (DT)
<…>
[ 0.396719] ---[ end Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(0,0) ]---
这是一个内核恐慌的好例子。当内核遇到无法恢复的错误时,就会发生恐慌。默认情况下,它会在控制台打印一条消息,然后停止。你可以设置恐慌命令行参数,允许在重启前等待几秒钟。在这种情况下,无法恢复的错误是没有根文件系统,这说明没有用户空间来控制内核时,内核是无用的。你可以通过提供根文件系统来提供用户空间,根文件系统可以是一个 RAM 磁盘或可挂载的外部存储设备。我们将在下一章讨论如何创建根文件系统,但首先,我想描述一下导致恐慌的事件顺序。
早期用户空间
为了从内核初始化过渡到用户空间,内核必须挂载根文件系统并执行该根文件系统中的程序。这可以通过 RAM 磁盘或将真实的文件系统挂载到块设备上来实现。所有这些代码都位于init/main.c
中,从rest_init()
函数开始,rest_init()
创建了第一个线程,PID 为 1,并运行kernel_init()
中的代码。如果有 RAM 磁盘,它将尝试执行程序/init
,该程序将负责设置用户空间。
如果内核无法找到并运行/init
,它会尝试通过调用init/do_mounts.c
中的prepare_namespace()
函数来挂载文件系统。这需要一个root=
命令行来提供用于挂载的块设备的名称,通常是以下形式:
root=/dev/<disk name><partition number>
或者对于 SD 卡和 eMMC:
root=/dev/<disk name>p<partition number>
例如,对于 SD 卡的第一个分区,应该是root=/dev/mmcblk0p1
。如果挂载成功,它会尝试执行/sbin/init
,然后是/etc/init
、/bin/init
,最后是/bin/sh
,并在第一个成功的程序处停止。这个程序可以在命令行上被覆盖。对于 RAM 磁盘,使用rdinit=
。对于文件系统,使用init=
。
内核消息
内核开发人员喜欢通过广泛使用printk()
和类似的函数打印有用的信息。这些消息按重要性进行分类,0
为最高级别:
级别 | 值 | 含义 |
---|---|---|
KERN_EMERG |
0 |
系统不可用 |
KERN_ALERT |
1 |
必须立即采取行动 |
KERN_CRIT |
2 |
严重条件 |
KERN_ERR |
3 |
错误条件 |
KERN_WARNING |
4 |
警告条件 |
KERN_NOTICE |
5 |
正常但重要的条件 |
KERN_INFO |
6 |
信息性 |
KERN_DEBUG |
7 |
调试级别的消息 |
表 4.1 – 内核消息列表
它们首先被写入名为__log_buf
的缓冲区,其大小是CONFIG_LOG_BUF_SHIFT
的 2 的幂次方。例如,如果CONFIG_LOG_BUF_SHIFT
是16
,则__log_buf
为 64KB。你可以使用dmesg
命令转储整个缓冲区。
如果消息的级别低于控制台日志级别,则该消息会显示在控制台上并被放入 __log_buf
中。默认的控制台日志级别是 7
。这意味着级别为 6
及以下的消息会显示,而 KERN_DEBUG
级别(即级别 7
)的消息会被过滤掉。
你可以通过几种方式更改控制台日志级别,包括使用内核参数 loglevel=<level>
或命令 dmesg -n <level>
。
内核命令行
内核命令行是一个字符串,由引导加载程序通过 bootargs
变量传递给内核,在 U-Boot 的情况下就是这样。它也可以在设备树中定义,或作为内核配置的一部分在 CONFIG_CMDLINE
中设置。
我们已经看过一些内核命令行的例子,但还有很多其他的。完整的列表可以在 Documentation/admin-guide/kernel-parameters.txt
中找到。以下是一些最常用的参数:
-
debug
:将控制台日志级别设置为最高级别(8
),确保在控制台上看到所有内核消息。 -
init=
:从挂载的根文件系统中运行的init
程序。默认为/sbin/init
。 -
lpj=
:将loops_per_jiffy
设置为给定的常数。在此列表后的段落中有关于此的详细说明。 -
panic=
:当内核出现 panic 时的行为。如果该值大于零,则表示在重启前的等待秒数;如果为零,则表示永远等待(默认);如果小于零,则会在没有任何延迟的情况下重启。 -
quiet
:将控制台日志级别设置为静默,抑制所有非紧急信息。由于大多数设备具有串行控制台,因此输出所有这些字符串需要时间。因此,使用此选项减少消息数量会缩短启动时间。 -
rdinit=
:从 RAM 磁盘中运行的 init 程序。默认为/init
。 -
ro
:以只读模式挂载根设备。对 RAM 磁盘没有影响,RAM 磁盘始终是读写的。 -
root=
:挂载根文件系统的设备。 -
rootdelay=
:在尝试挂载根设备之前等待的秒数。默认为零。如果设备需要时间来探测硬件,则此参数非常有用。另见rootwait
。 -
rootfstype=
:根设备的文件系统类型。在许多情况下,它会在挂载过程中自动检测,尽管对于 jffs2 文件系统是必需的。 -
rootwait
:无限期等待根设备被检测到,通常在 MMC 设备中需要使用。 -
rw
:以读写模式挂载根设备(默认)。
lpj
参数常与减少内核启动时间相关提及。在初始化过程中,内核会循环大约 250 毫秒来校准延迟循环。该值存储在变量 loops_per_jiffy
中,并以如下方式报告:
Calibrating delay loop... 996.14 BogoMIPS (lpj=4980736)
如果内核始终运行在相同的硬件上,它将始终计算相同的值。通过在命令行中添加 lpj=4980736
,你可以将启动时间缩短 250 毫秒。
在下一节中,我们将学习如何基于 BeaglePlay(我们假设的 Nova 开发板)将 Linux 移植到新开发板上。
将 Linux 移植到新板卡
将 Linux 移植到新板卡可以简单也可以复杂,这取决于你的板卡与现有开发板的相似程度。在第三章中,我们将 U-Boot 移植到名为 Nova 的新板卡上,该板卡基于 BeaglePlay。对内核代码几乎不需要做任何修改,因此非常容易。如果你正在将其移植到全新且创新的硬件上,那就需要做更多的工作。我这里只考虑简单的情况。关于附加硬件外设的主题,我们将在第十二章中进行更深入的讨论。
arch/$ARCH
中架构特定代码的组织在不同系统之间有所不同。x86 架构相对简洁,因为大多数硬件细节在运行时被检测到。PowerPC 架构将 SoC 和板卡特定的文件组织到 platforms
下的子目录中。另一方面,32 位 Arm 架构相对混乱,因为许多基于 Arm 的 SoC 之间存在很大的差异。与平台相关的代码被放置在名为 mach-*
的目录中,几乎每个 SoC 都有一个对应的目录。还有其他名为 plat-*
的目录,其中包含多个版本的 SoC 公共代码。
在接下来的章节中,我将解释如何为新的 64 位 Arm 板卡创建设备树。
新的设备树
首先要做的是为板卡创建一个设备树,并修改它以描述 Nova 板卡的附加或更改硬件。在这个简单的例子中,我们只是将 k3-am625-beagleplay.dts
复制到 nova.dts
,并将模型名称更改为 Nova,如下所示:
/dts-v1/;
#include <dt-bindings/leds/common.h>
#include <dt-bindings/gpio/gpio.h>
#include <dt-bindings/input/input.h>
#include "k3-am625.dtsi"
/ {
compatible = "beagle,am625-beagleplay", "ti,am625";
model = "Nova";
<…>
完成为 BeaglePlay 构建内核中的所有步骤。
向 linux-stable/arch/arm64/boot/dts/ti/Makefile
添加以下依赖:
dtb-$(CONFIG_ARCH_K3) += nova.dtb
这个条目确保在选择 AM62x 目标时,Nova 的设备树会被编译。
如下所示构建 Nova 设备树二进制文件:
$ make ARCH=arm64 dtbs O=../build_beagleplay
DTC arch/arm64/boot/dts/ti/nova.dtb
我们可以通过启动 BeaglePlay 来看到使用 Nova 设备树的效果。按照与启动 BeaglePlay相同的步骤操作。将同一张 microSD 卡插入读卡器,并将 build_beagleplay/arch/arm64/boot/dts/ti/nova.dtb
文件复制到 FAT32 boot
分区中。使用与之前相同的 Image.gz
文件,但加载 nova.dtb
替代 k3-am625-beagleplay.dtb
。以下输出显示机器模型被打印出来的位置:
Starting kernel ...
[ 0.000000] Booting Linux on physical CPU 0x0000000000 [0x410fd034]
[ 0.000000] Linux version 6.6.46 (frank@frank-nuc) (aarch64-buildroot-linux-gnu-gcc.br_real (Buildroot 2021.11-11272-ge2962af)
12.3.0, GNU ld (GNU Binutils) 2.41) #1 SMP PREEMPT Mon Aug 19 11:24:56 PDT 2024
[ 0.000000] KASLR disabled due to lack of seed
[ 0.000000] Machine model: Nova
<…>
现在我们已经有了专门为 Nova 板卡准备的设备树,我们可以修改它,以描述 Nova 和 BeaglePlay 之间的硬件差异。内核配置也很可能需要做出相应的更改。在这种情况下,你需要基于 arch/arm64/configs/defconfig
的副本创建一个自定义配置文件。
总结
Linux 之所以强大,是因为它能够根据需求配置内核。获取内核源代码的权威来源是www.kernel.org/
,但你可能需要从设备厂商或支持该设备的第三方处获取特定 SoC 或开发板的源代码。为特定目标定制内核可能包括对核心内核代码的修改、为未包含在主线 Linux 中的设备添加驱动程序、一个默认的内核配置文件以及设备树源文件。
通常,你从目标开发板的默认配置开始,然后通过运行配置工具之一(如menuconfig
)进行调整。在这个阶段,你应该考虑的一件事是内核特性和驱动程序是否应该被禁用、作为模块编译,或是内建到内核中。对于嵌入式系统来说,内核模块通常没有太大优势,因为嵌入式系统的特性和硬件通常是明确规定的。然而,模块提供了一种将专有代码导入内核的方式,并通过在启动后加载非必要的驱动程序来减少启动时间。完全禁用不使用的内核特性和驱动程序可以减少编译时间以及启动时间。
构建内核会生成一个压缩的内核镜像文件,文件名为zImage
、Image.gz
或bzImage
,具体取决于你将使用的引导加载程序和目标架构。内核构建还会生成你配置的任何内核模块(.ko
文件)和设备树二进制文件(.dtb
文件),如果你的目标需要它们的话。
将 Linux 移植到一个新目标开发板可能非常简单,也可能非常困难,这取决于硬件与主线或厂商提供的内核有多大的差异。如果你的硬件基于一个众所周知的参考设计,那么可能只需要修改设备树或平台数据。你可能需要添加设备驱动程序,我们将在第十一章中讨论。不过,如果硬件与参考设计有很大不同,你可能需要额外的核心支持,这超出了本书的范围。
内核是基于 Linux 系统的核心,但它无法独立工作。它需要一个包含用户空间组件的根文件系统。根文件系统可以是 RAM 磁盘或通过块设备访问的文件系统,这将是下一章的主题。正如我们所见,没有根文件系统的内核启动会导致内核崩溃(kernel panic)。
深入学习
-
你想构建一个嵌入式 Linux 系统吗? 由 Jay Carlson 编写 –
jaycarlson.net/embedded-linux/
-
嵌入式 Linux 培训 –
bootlin.com/training/embedded-linux/
-
Linux 每周新闻 –
lwn.net/
-
树莓派论坛 –
forums.raspberrypi.com/
-
Linux Kernel Development, Third Edition, by Robert Love
第五章:构建根文件系统
根文件系统是嵌入式 Linux 的第四个要素。阅读完本章后,你将能够构建、启动并运行一个简单的嵌入式 Linux 系统。
这里描述的技术通常被称为 自己动手做,或 RYO。在嵌入式 Linux 的早期,这是一种创建根文件系统的唯一方式。至今,仍然有一些使用场景适用 RYO 根文件系统——例如,当内存或存储非常有限时,用于快速演示,或在某些需求无法轻松通过标准构建系统工具覆盖的情况下。然而,这些场景相当罕见。
本章的目的在于教育性展示,并非构建日常嵌入式系统的配方。对于此类构建,请使用下一章中描述的工具。
我们的第一个目标是创建一个最小的根文件系统,使其能提供一个 shell 提示符。然后,基于此,我们将添加脚本来启动其他程序并配置网络接口和用户权限。对于 BeaglePlay 和 QEMU 目标都有示例。知道如何从头开始构建根文件系统是一个有用的技能。它将帮助你理解在后续章节中我们查看更复杂示例时发生了什么。
本章将涵盖以下主题:
-
根文件系统中应该包含什么?
-
将根文件系统传输到目标设备
-
创建引导
initramfs
-
init
程序 -
配置用户账户
-
更好的设备节点管理方式
-
配置网络
-
使用设备表创建文件系统镜像
-
使用 NFS 挂载根文件系统
-
使用 TFTP 加载内核
技术要求
为了跟随示例,确保你拥有以下内容:
-
一个 Ubuntu 24.04 或更高版本的 LTS 主机系统
-
一台 microSD 卡读卡器和卡
-
为 BeaglePlay 准备的 microSD 卡,来自第四章
-
来自第四章的 QEMU
Image
文件 -
一条带有 3.3V 逻辑电平的 USB 到 TTL 串行电缆
-
一台 BeaglePlay
-
一款能够提供 3A 电流的 5V USB-C 电源
本章中使用的代码可以在书籍 GitHub 仓库的 Chapter05
文件夹中找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development/tree/main/Chapter05
。
根文件系统中应该包含什么?
内核通过引导加载程序传递的指针获得一个根文件系统,通常是一个initramfs
,或者通过挂载内核命令行中给出的块设备(使用root=
参数)。一旦它有了根文件系统,内核就会执行第一个程序——默认名为init
,正如在《第四章》中早期用户空间部分所描述的那样。然后,就内核而言,它的工作已经完成。由init
程序来启动其他程序并让系统运行起来。
要构建一个最小化的根文件系统,你需要以下组件:
-
init:这是启动一切的程序,通常通过运行一系列脚本来实现。我将在《第十三章》中更详细地描述
init
的工作原理。 -
shell:为你提供命令提示符,并(更重要的是)运行
init
和其他程序调用的 Shell 脚本。 -
守护进程:这些是后台程序,为其他程序提供服务。典型的例子包括系统日志守护进程(
syslogd
)和安全外壳守护进程(sshd
)。init
程序必须启动守护进程的初始集合,以支持主系统应用程序。实际上,init
本身就是一个守护进程。它是提供启动其他守护进程服务的守护进程。 -
共享库:大多数程序都与共享库链接,因此它们必须存在于根文件系统中。
-
配置文件:这些是一些文本文件,通常存储在
/etc
目录中,用于配置init
和其他守护进程。 -
设备节点:这些是特殊文件,用于访问各种设备驱动程序。
-
proc 和 sys:这两个伪文件系统将内核数据结构表示为一系列目录和文件。许多程序和库函数依赖于
/proc
和/sys
。 -
内核模块:需要安装在根文件系统中,通常位于
/lib/modules/<kernel version>
目录下。
此外,还有一些设备特定的应用程序,使设备能够完成其预定的工作,以及它们生成的运行时数据文件。
重要提示
在某些情况下,你可以将上述大多数程序合并为一个静态链接的程序,并启动该程序而不是init
。例如,如果你的程序名为/myprog
,你可以将以下命令添加到内核命令行:init=/myprog
。
我在一个安全系统中只遇到过一次这样的配置,其中禁用了fork
系统调用,从而使得无法启动任何其他程序。这种方法的缺点是你无法使用通常嵌入式系统中的许多工具。你必须自己做所有事情。
目录布局
Linux 内核并不关心文件和目录的布局,除非是存在名为init=
或rdinit=
的程序,因此你可以随意安排文件的存放位置。例如,比较运行 Android 的设备与桌面 Linux 发行版的文件布局,它们几乎完全不同。
然而,许多程序期望某些文件位于特定的位置,若设备使用类似的布局对开发人员有帮助。大多数 Linux 系统的基本布局在文件系统层次标准(FHS)中有所定义,详情请见refspecs.linuxfoundation.org/fhs.shtml
。FHS 涵盖了所有 Linux 操作系统的实现,从最大到最小。嵌入式设备通常根据其需求使用一个子集,但每个设备通常都包括以下内容:
-
/bin
:所有用户都需要的程序 -
/dev
:设备节点和其他特殊文件 -
/etc
:系统配置文件 -
/lib
:基本的共享库,包括构成 C 库的库文件 -
/proc
:以虚拟文件形式呈现的进程信息 -
/sbin
:系统管理员必需的程序 -
/sys
:以虚拟文件形式呈现的设备及其驱动信息 -
/tmp
:用于存放临时或易失性文件的地方 -
/usr
:附加程序、库文件和系统管理员工具,分别存放在/usr/bin
、/usr/lib
和/usr/sbin
目录中重要提示
/usr
目录包含所有系统范围内的只读文件,这些文件是由操作系统安装或提供的。过去,/bin
、/sbin
和/lib
仅包含启动所需的可执行文件和库文件,而/usr/bin
、/usr/sbin
和/usr/lib
包含所有其他可执行文件和二进制文件。这一区别后来模糊了,最终出现了将/bin
、/sbin
和/lib
合并到/usr/bin
、/usr/sbin
和/usr/lib
的现代趋势。如今,/bin
、/sbin
和/lib
中的文件仅仅是指向它们在/usr
中的对应文件的符号链接。/usr/sbin
目录像/sbin
目录一样,存放只能由root
用户执行的命令。 -
/var
:在运行时可以修改的文件和目录层次结构,例如日志信息,其中一些必须在启动后保留
这里有一些微妙的区别。/bin
和/sbin
的区别仅仅在于后者不需要包含在非 root 用户的搜索路径中。Red Hat 衍生的发行版用户对此应该比较熟悉。
临时目录
你应该首先在主机计算机上创建一个临时目录,用于汇总最终会被传输到目标设备的文件。在以下示例中,我使用了~/rootfs
。你需要在其中创建一个骨架目录结构。请看下面:
$ mkdir ~/rootfs
$ cd ~/rootfs
$ mkdir bin dev etc home lib proc sbin sys tmp usr var
$ mkdir usr/bin usr/lib usr/sbin
$ mkdir var/log
$ ln -s lib lib64
要更清晰地查看目录层次结构,可以使用便捷的tree
命令,带上-d
选项仅显示目录:
$ tree -d
.
├── bin
├── dev
├── etc
├── home
├── lib
├── lib64 -> lib
├── proc
├── sbin
├── sys
├── tmp
├── usr
│ ├── bin
│ ├── lib
│ └── sbin
└── var
└── log
并非所有目录具有相同的文件权限,目录中的个别文件可以比目录本身有更严格的权限。
POSIX 文件访问权限
每个进程或运行程序都属于一个用户和一个或多个组。用户由一个称为用户 ID或UID的 32 位数字表示。关于用户的信息,包括从 UID 到名称的映射,保存在/etc/passwd
中。同样,组由一个称为组 ID或GID的数字表示,信息保存在/etc/group
中。始终存在一个root
用户,其 UID 为 0,并且一个root
组,其 GID 为 0。root
用户也称为超级用户,因为在默认配置下,它可以绕过大部分权限检查并访问系统中的所有资源。Linux 系统中的安全性主要是关于限制对 root 账户的访问。
每个文件和目录还有一个所有者,并且属于恰好一个组。进程对文件或目录的访问级别由一组称为文件的模式的访问权限标志控制。有三组三位数:第一组适用于文件的所有者,第二组适用于与文件相同组的成员,最后一组适用于其他所有人 - 世界上的其余人。这些位用于文件的读(r
)、写(w
)和执行(x
)权限。三位数的组合产生 2³ = 8 个可能的值,表示为从 0 到 7 的八进制数字:
-
0: 没有权限
-
1: 仅执行 (--x)
-
2: 只写 (-w-)
-
3: 写和执行 (-wx)
-
4: 只读 (r--)
-
5: 读和执行 (r-x)
-
6: 读和写 (rw-)
-
7: 读、写和执行 (rwx)
由于三位数可以完美地放入八进制数字中,文件访问权限通常用八进制表示。
这里是一些常见的文件模式:
-
600: 所有者 - rw-, 组 - ---, 和其他人 - ---
-
644: 所有者 - rw-, 组 - r--, 和其他人 - r--
-
666: 所有者 - rw-, 组 - rw-, 和其他人 - rw-
-
700: 所有者 - rwx, 组 - ---, 和其他人 - ---
-
755: 所有者 - rwx, 组 - r-x, 和其他人 - r-x
-
775: 所有者 - rwx, 组 - rwx, 和其他人 - r-x
-
777: 所有者 - rwx, 组 - rwx, 和其他人 - rwx
八进制数字的第一个(最左边的)位是值为 4,第二个(中间的)位是值为 2,第三个(最右边的)位是值为 1,如下所示:
图 5.1 – 文件访问权限
如果集合中的三位数都设置了,则该集合的八进制值为 4 + 2 + 1 = 7。上述图表中的每一行包含 3 个集合,总共有 9 位。
有一个第四个前导的八进制数字,其值具有特殊意义:
-
SUID (4): 如果文件是可执行的,它会在程序运行时将进程的有效 UID 更改为文件所有者的 UID。
-
SGID (2):与 SUID 类似,这会将进程的有效 GID 更改为文件所属组的 GID。
-
Sticky (1):在一个目录中,它限制删除操作,使得一个用户不能删除另一个用户拥有的文件。通常这会设置在
/tmp
和/var/tmp
目录下。
SUID 位可能是最常用的。它赋予非 root 用户临时提升到超级用户的权限来执行任务。ping
程序是一个很好的例子:ping
打开一个原始套接字,这是一个特权操作。ping
可执行文件由root
用户拥有,并且设置了 SUID 位,这样当你运行ping
时,无论你的 UID 是什么,它都会以 UID 0 执行。
要设置这个前导八进制数字,可以使用chmod
命令的4
、2
和1
值。例如,要在暂存根目录中的/bin/ping
上设置 SUID,可以将4
加到模式755
前面,如下所示:
$ cd ~/rootfs
$ ls -l bin/ping
-rwxr-xr-x 1 root root 35712 Feb 6 09:15 bin/ping
$ sudo chmod 4755 bin/ping
$ ls -l bin/ping
-rwsr-xr-x 1 root root 35712 Feb 6 09:15 bin/ping
请注意,第二个ls
命令显示模式的前三个位为rws
,而之前是rwx
。那个s
表示 SUID 位已设置。
暂存目录中的文件所有权权限
出于安全性和稳定性的考虑,必须注意将要放置在目标设备上的文件的所有权和权限。通常,你需要将敏感资源的访问限制为只有root
用户才能访问,并尽可能减少非 root 用户运行程序的数量。最好是使用非 root 用户来运行程序,这样如果它们被外部攻击者攻破,它们提供给攻击者的系统资源将尽可能少。
例如,名为/dev/mem
的设备节点提供对系统内存的访问,这是某些程序所必需的。但是,如果它对所有人都可读写,那么就没有安全性,因为任何人都可以访问内存中的所有内容。所以,/dev/mem
应该由root
所有,属于root
组,并且设置模式为600
,以拒绝除所有者外的所有人读写访问。
然而,暂存目录存在一个问题。你在那里创建的文件将属于你。但是,当它们被安装到设备上时,它们应该属于特定的所有者和组,通常是root
用户。一个明显的解决方法是在此阶段将所有权更改为root
,可以使用下面的命令:
$ cd ~/rootfs
$ sudo chown -R root:root *
重要提示
不要运行前面的sudo chown -R root:root *
命令。你可能会不可逆转地损坏你的文件系统。
问题在于,你需要root
权限来运行chown
命令。从那时起,你将需要root
权限来修改暂存目录中的任何文件。不知不觉中,你可能就以root
身份登录进行开发,这并不是一个好主意。我们将在创建独立的initramfs
时重新审视这个问题。
根文件系统程序
现在,是时候开始为根文件系统填充必要的程序以及它们运行所需的支持库、配置文件和数据文件了。我将首先概述你需要的程序类型。
init 程序
init
是第一个运行的程序,因此它是根文件系统的重要组成部分。在本章中,我们将使用 BusyBox 提供的简单init
程序。
Shell
我们需要一个 Shell 来运行脚本并提供命令提示符,以便我们与系统进行交互。在生产设备中可能不需要交互式 Shell,但在开发、调试和维护中非常有用。嵌入式系统上常用的 Shell 有几种:
-
bash
:这是我们在桌面 Linux 中都熟知并喜爱的“大怪兽”。它是 Unix Bourne shell 的超集,具有许多扩展或 bash 特性。 -
ash
:这也是基于 Bourne shell 的,并且在 Unix 的 BSD 变种中有着悠久的历史。BusyBox 有一个版本的ash
,它经过扩展,使其更加兼容bash
。它比bash
小得多,因此它是嵌入式系统中非常流行的选择。 -
hush
:这是一款非常小的 shell,我们在第三章中简要地看过它。它在内存非常有限的设备上非常有用。BusyBox 中有一个版本的hush
。提示
如果你在目标设备上使用
ash
或hush
作为 shell,确保在目标设备上测试你的脚本。仅仅在主机上使用bash
测试它们,而在将它们复制到目标设备后却发现它们无法正常工作,这是很容易犯的错误。
实用工具
Shell 仅仅是启动其他程序的一种方式。Shell 脚本不过是一个程序列表,包含一些流程控制和在程序之间传递信息的方式。为了使一个 Shell 有用,你需要一些 Unix 命令行所依赖的实用程序。即使是一个基本的根文件系统,也需要大约 50 个实用程序。这带来了两个问题。首先,找到每个程序的源代码并交叉编译所有这些程序是一项大工程。其次,最终的程序集合会占用几十兆字节的存储空间。这在嵌入式 Linux 的早期是一个真正的问题,当时设备的存储容量只有几兆字节。BusyBox 正是为了应对这个问题而创建的。
BusyBox 来拯救我们了!
BusyBox 的诞生与嵌入式 Linux 无关。Bruce Perens 在 1996 年启动了这个项目,为了使 Debian 安装程序能够从 1.44 MB 的软盘启动 Linux。巧合的是,这个存储大小与当时设备的存储容量差不多,因此嵌入式 Linux 社区迅速接受了它。从那时起,BusyBox 就一直是嵌入式 Linux 的核心。
BusyBox 从零开始编写,旨在执行那些基本 Linux 工具的基本功能。开发者运用了 80/20 原则:一个程序最有用的 80% 功能是由 20% 的代码实现的。因此,BusyBox 工具实现了桌面版工具功能的一个子集,但足以在大多数情况下满足需求。
另一个 BusyBox 使用的技巧是将所有工具合并到一个单一的二进制文件中,从而方便它们之间共享代码。具体操作如下:BusyBox 是一系列小工具的集合,每个小工具都以 <applet>_main
的形式导出其 main
函数。例如,cat
命令在 coreutils/cat.c
中实现,并导出 cat_main
。BusyBox 的 main
函数根据命令行参数将调用调度到正确的小工具。
要读取文件,你可以通过启动 BusyBox 并指定你要运行的小工具的名称,后跟小工具需要的任何参数:
$ busybox cat my_file.txt
你也可以运行 BusyBox 而不带任何参数,以获取已编译的所有小工具的列表。
这样使用 BusyBox 有点笨拙。让 BusyBox 运行 cat
小工具的更好方法是从 /bin/cat
创建一个符号链接到 /bin/busybox
:
$ ls -l bin/cat bin/busybox
-rwxr-xr-x 1 root root 1137096 Aug 20 10:31 bin/busybox
lrwxrwxrwx 1 root root 7 Aug 20 10:31 bin/cat -> busybox
当你在命令行输入cat
时,实际运行的程序是 BusyBox。BusyBox 只需要检查传入的可执行文件路径(/bin/cat
),提取应用程序名称(cat
),然后进行表格查找,将cat
与cat_main
匹配。所有这些在 libbb/appletlib.c
中的这一段简化代码中有体现:
applet_name = argv[0];
applet_name = bb_basename(applet_name);
run_applet_and_exit(applet_name, argv);
BusyBox 包含超过 300 个小工具,包括一个 init
程序、几个具有不同复杂度的 shell 以及用于大多数管理任务的工具。甚至还有一个简单版本的 vi
编辑器,方便你在设备上修改文本文件。一个典型的 BusyBox 二进制文件将只启用几十个小工具。
总结来说,典型的 BusyBox 安装由一个单一程序组成,每个小工具都有一个符号链接,但其行为与多个独立应用程序完全一致。
构建 BusyBox
BusyBox 使用与内核相同的 Kconfig
和 Kbuild
系统,因此交叉编译非常简单。通过克隆 BusyBox Git 仓库并检查你想要的版本(2024 年 8 月时最新版本是 1_36_1
)来获取源码:
$ git clone git://busybox.net/busybox.git
$ cd busybox
$ git checkout 1_36_1
你还可以从 busybox.net/downloads/
下载相应的 TAR 文件。
使用默认配置来配置 BusyBox,这将启用几乎所有功能:
$ make distclean
$ make defconfig
到此为止,你可能需要运行 make menuconfig
来微调配置。例如,你几乎肯定需要在 设置 | 安装选项(“make install”行为)| ‘make install’ 的目标路径 中设置安装路径,指向暂存目录。然后,你就可以按常规方式进行交叉编译。如果你的目标平台是 BeaglePlay,可以使用以下命令:
$ PATH=~/aarch64--glibc--stable-2024.02-1/bin/:$PATH
$ make ARCH=arm64 CROSS_COMPILE=aarch64-buildroot-linux-gnu-
你可以以相同的方式为 QEMU 模拟的 64 位 Arm 通用虚拟平台交叉编译 BusyBox。
无论哪种情况,结果都是busybox
可执行文件。对于像这样的默认配置构建,大小约为 1,100KB。如果这个文件太大,你可以通过更改配置来删除不需要的工具,从而减小它的体积。
要将 BusyBox 安装到暂存区,请使用以下命令:
$ make ARCH=arm64 CROSS_COMPILE=aarch64-buildroot-linux-gnu- install
这将把二进制文件复制到“make install”目标路径中,并为其创建所有的符号链接。
现在,让我们来看看另一个替代品,ToyBox。
ToyBox——BusyBox 的替代品
BusyBox 并不是唯一的选择。还有 ToyBox,你可以在landley.net/toybox/
找到它。该项目由 Rob Landley 发起,他曾是 BusyBox 的维护者。ToyBox 的目标与 BusyBox 相同,但更加注重遵守标准(尤其是 POSIX-2008 和 LSB 4.1),而不太关注与 GNU 扩展兼容性。ToyBox 比 BusyBox 小,部分原因是它实现了更少的工具。
ToyBox 的许可证是 BSD,而不是 GPL v2,这使得它与那些采用 BSD 许可证用户空间的操作系统兼容,比如 Android。因此,ToyBox 与所有新的 Android 设备一起发布。从 0.8.3 版本开始,ToyBox 的Makefile
可以构建一个完整的 Linux 系统,当只提供 Linux 和 ToyBox 的源代码时,它可以启动并进入 Shell 提示符。
根文件系统的库
程序是与库链接的。你可以将它们全部静态链接,这样就不会在目标设备上驻留任何库。如果你有超过两到三个程序,这会占用不必要的大量存储空间。为了减小程序的大小,你需要将共享库从工具链复制到暂存目录。但是,你怎么知道该复制哪些库呢?
一种选择是从你的工具链的sysroot
目录复制所有的.so
文件。与其试图预测要包含哪些库,不如假设你的镜像最终会需要它们所有。这种做法无疑是合理的,如果你正在创建一个供他人使用的多种应用平台,那么这种方法是正确的。然而,要注意,完整的 glibc 是相当庞大的。在 Buildroot 构建的 glibc 的情况下,库、区域设置和其他支持文件总共达到了 22MB。通过使用 musl 或 uClibc-ng,你可以大大减少这个大小。
另一个选择是只挑选你需要的那些库。为此,你需要一种发现库依赖关系的方法。让我们使用来自第二章的readelf
命令来完成这项任务:
$ PATH=~/aarch64--glibc--stable-2024.02-1/bin/:$PATH
$ cd ~/rootfs
$ aarch64-buildroot-linux-gnu-readelf -a bin/busybox | grep "program interpreter"
[Requesting program interpreter: /lib/ld-linux-aarch64.so.1]
$ aarch64-buildroot-linux-gnu-readelf -a bin/busybox | grep "Shared library"
0x0000000000000001 (NEEDED) Shared library: [libm.so.6]
0x0000000000000001 (NEEDED) Shared library: [libresolv.so.2]
0x0000000000000001 (NEEDED) Shared library: [libc.so.6]
0x0000000000000001 (NEEDED) Shared library: [ld-linux-aarch64.so.1]
第一个readelf
命令搜索busybox
二进制文件中包含program interpreter
的行。第二个readelf
命令搜索busybox
二进制文件中包含Shared library
的行。现在,你需要在工具链的sysroot
目录中找到这些文件,并将它们复制到暂存目录。记住,你可以这样找到sysroot
:
$ aarch64-buildroot-linux-gnu-gcc -print-sysroot
/home/frank/aarch64--glibc--stable-2024.02-1/aarch64-buildroot-linux-gnu/sysroot
为了减少输入,可以将sysroot
路径保存在一个 shell 变量中:
$ export SYSROOT=$(aarch64-buildroot-linux-gnu-gcc -print-sysroot)
我们来看一下sysroot
中的/lib/ld-linux-aarch64.so.1
:
$ cd $SYSROOT
$ ls -l lib/ld-linux-aarch64.so.1
-rwxr-xr-x 1 frank frank 202248 Mar 3 00:48 lib/ld-linux-aarch64.so.1
对libc.so.6
、libm.so.6
和libresolv.so.2
进行相同的操作,这样你就会得到四个文件的列表。现在,将每个文件复制到rootfs
目录中:
$ cd ~/rootfs
$ cp $SYSROOT/lib/ld-linux-aarch64.so.1 lib
$ cp $SYSROOT/lib/libc.so.6 lib
$ cp $SYSROOT/lib/libm.so.6 lib
$ cp $SYSROOT/lib/libresolv.so.2 lib
这些是busybox
所需的共享库。对于每个你希望添加到rootfs
目录中的程序,重复此过程。
提示
只有在需要最小化嵌入式系统占用空间时,做这个操作才有意义。这样做的风险是,你可能会错过通过dlopen(3)
调用加载的库,主要是插件。我们将在本章稍后讨论配置网络接口时,探讨与名称服务切换(NSS)库的例子。
通过去除符号表减少文件大小
库和程序通常会在符号表中存储一些信息,用于调试和追踪。在生产系统中,你很少需要这些信息。节省空间的一种快速简便的方法是去除二进制文件中的符号表。这个例子展示了去除前的libc
:
$ file rootfs/lib/libc.so.6
rootfs/lib/libc.so.6: ELF 64-bit LSB shared object, ARM aarch64, version 1 (GNU/Linux), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0, with debug_info, not stripped
$ ls -og rootfs/lib/libc.so.6
-rwxr-xr-x 1 1925456 Dec 12 05:43 rootfs/lib/libc.so.6
现在,我们来看一下去除调试信息后的结果:
$ aarch64-buildroot-linux-gnu-strip rootfs/lib/libc.so.6
$ file rootfs/lib/libc.so.6
rootfs/lib/libc.so.6: ELF 64-bit LSB shared object, ARM aarch64, version 1 (GNU/Linux), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0, stripped
$ ls -og rootfs/lib/libc.so.6
-rwxr-xr-x 1 1392840 Dec 12 05:53 rootfs/lib/libc.so.6
在这种情况下,我们在去除调试信息之前节省了 532,616 字节,即文件大小的约 28%。
提示
注意不要去除内核模块的调试符号。某些符号是模块加载器在重新定位模块代码时所必需的,如果这些符号被去除,模块将无法加载。使用此命令移除调试符号,同时保留用于重定位的符号:strip --strip-unneeded <模块名>
。
设备节点
在 Linux 中,大多数设备通过设备节点来表示,符合 Unix 哲学中的一切皆文件(网络接口除外,网络接口是套接字)。设备节点可以指代块设备或字符设备。块设备是大容量存储设备,如 SD 卡或硬盘。字符设备几乎包括所有其他设备(同样,网络接口除外)。
设备节点的常规位置是/dev
目录。例如,串口可以通过名为/dev/ttyS0
的设备节点来表示。
设备节点是通过名为mknod
(即“创建节点”)的程序创建的:
mknod <name> <type> <major> <minor>
mknod
的参数如下:
-
名称是你要创建的设备节点的名称。
-
类型是字符设备(c)或块设备(b)。
-
主设备号和次设备号是一对数字,内核通过它们将文件请求路由到相应的设备驱动代码。内核源文件中有一个标准主设备号和次设备号的列表:
Documentation/admin-guide/devices.txt
。
你需要为系统中要访问的所有设备创建设备节点。你可以手动使用下面示范的 mknod
命令来创建,或者可以使用稍后提到的设备管理器在运行时自动创建它们。
在最小化根文件系统中,你只需要两个节点来使用 BusyBox 启动:console
和 null
。console
只需要对设备节点的所有者 root
可访问,因此其访问权限为 600
(rw-------
)。null
设备应该对所有人都可读可写,所以它的模式为 666
(rw-rw-rw-
)。使用 -m
选项设置 mknod
创建节点时的 mode
。你需要是 root
用户才能创建设备节点:
$ cd ~/rootfs
$ sudo mknod -m 666 dev/null c 1 3
$ sudo mknod -m 600 dev/console c 5 1
$ ls -l dev
total 0
crw------- 1 root root 5, 1 Aug 20 11:06 console
crw-rw-rw- 1 root root 1, 3 Aug 20 11:06 null
你可以使用标准的 rm
命令删除设备节点。没有 rmnod
命令,因为一旦创建,它们就只是文件。
Proc 和 sysfs 文件系统
proc
和 sysfs
是两个伪文件系统,它们提供了一个观察内核内部工作原理的窗口。它们都将内核数据以文件的形式表示在一个目录层次结构中。当你读取这些文件时,看到的内容并不是来自磁盘存储。相反,它是由内核中的一个函数即时格式化的。有些文件也可以写入,这意味着当你写入新的数据时,内核函数会被调用。如果数据格式正确,并且你具有足够的权限,那么该函数将修改内核内存中存储的值。换句话说,proc
和 sysfs
提供了与设备驱动程序和其他内核代码交互的另一种方式。
重要提示
以下的 mount
命令适用于嵌入式目标设备,如 BeaglePlay 或 Versatile Express(QEMU)。请勿在主机机器上运行这些命令。
proc
和 sysfs
文件系统应该挂载在 /proc
和 /sys
目录下:
# mount -t proc proc /proc
# mount -t sysfs sysfs /sys
虽然这两个文件系统的概念非常相似,但它们执行的是不同的功能。proc
从 Linux 初期就已经存在。它的最初目的是将关于进程的信息暴露给用户空间,因此得名。为了这个目的,系统为每个进程创建了一个名为 /proc/<PID>
的目录,目录中包含该进程的状态信息。进程列表命令(ps
)通过读取这些文件来生成输出。
还有一些文件提供了关于内核其他部分的信息。例如,/proc/cpuinfo
提供关于 CPU 的信息,/proc/interrupts
包含中断信息,等等。最后,/proc/sys
目录包含显示和控制内核子系统状态和行为的文件,特别是调度、内存管理和网络。手册页是查看 proc
目录中文件的最佳参考。你可以通过输入 man 5 proc
查看这些信息。
sysfs
的作用是将内核 驱动模型 显示给用户空间。它导出一个与设备相关的文件层次结构,表示设备及其连接关系。关于 Linux 驱动模型的更多细节,我将在描述与设备驱动交互时讲解,在 第十一章 中详细说明。
挂载文件系统
mount
命令允许我们将一个文件系统附加到另一个目录,从而形成文件系统的层次结构。最上层的文件系统是由内核在启动时挂载的,称为 根文件系统。mount
命令的格式如下:
mount [-t vfstype] [-o options] device directory
mount
的参数如下:
-
vfstype 是文件系统的类型。
-
options 是以逗号分隔的挂载选项列表。
-
device 是文件系统所在的块设备节点。
-
directory 是你希望将文件系统挂载到的目录。
在 -o
后有多种选项可供选择。查看 mount(8)
的手册页以获取更多信息。如果你想将包含 ext4 文件系统的 SD 卡的第一个分区挂载到名为 /mnt
的目录,请输入以下命令:
# mount -t ext4 /dev/mmcblk0p1 /mnt
假设挂载成功,你将会在 /mnt
目录下看到存储在 SD 卡上的文件。在某些情况下,你可以省略文件系统类型,让内核探测设备,找出存储的内容。如果挂载失败,你可能需要先卸载分区,如果你的 Linux 发行版配置为在插入 SD 卡时自动挂载所有分区。
你注意到以下挂载 proc
文件系统的例子有什么奇怪的吗?没有像 /dev/proc
这样的设备节点,因为它是一个伪文件系统,而非真实的文件系统。但 mount
命令需要一个设备参数。因此,我们必须提供一个字符串来代替设备的位置,但这个字符串的内容其实不重要。这两个命令的效果完全相同:
# mount -t proc procfs /proc
# mount -t proc nodevice /proc
procfs
和 nodevice
字符串会被 mount
命令忽略。挂载伪文件系统时,通常会使用文件系统类型代替设备。
内核模块
如果你有内核模块,它们需要使用 modules_install
内核构建目标安装到根文件系统中,正如我们在 第四章 中所看到的。这会将它们复制到 /lib/modules/<kernel version>
目录,并将 modprobe
命令所需的配置文件一并复制过去。
请注意,你刚刚在内核和根文件系统之间创建了一个依赖关系。如果你更新其中之一,你将需要更新另一个。
现在我们已经知道如何从 SD 卡挂载文件系统,接下来我们来看看挂载根文件系统的不同选项。替代方案(如 ramdisk 和 NFS)可能会让你感到惊讶,尤其是如果你对嵌入式 Linux 不熟悉的话。Ramdisk 可以保护原始源镜像不被损坏或磨损。我们将在第九章中了解更多关于闪存磨损的内容。网络文件系统允许更快速的开发,因为文件的更改会立即传播到目标设备。
将根文件系统传输到目标设备
在你的暂存目录中创建一个骨架根文件系统后,下一步是将其传输到目标设备。这里有三种可能性:
-
initramfs:这是一种由引导加载程序加载到 RAM 中的文件系统镜像。Ramdisks 容易创建,并且不依赖于大容量存储驱动程序。它们可以在主根文件系统需要更新时用作后备维护模式,甚至可以在较小的嵌入式设备中作为主根文件系统。Ramdisks 还常常作为主流 Linux 发行版中的早期用户空间使用。请记住,存储在 ramdisk 中的根文件系统内容是易失性的,因此在运行时对根文件系统所做的任何更改都会在系统重启时丢失。你需要其他存储类型来存储像配置参数这样的永久数据。
-
磁盘镜像:这是根文件系统的一个副本,已格式化并准备好加载到目标的存储设备上。它可以是一个 ext4 格式的镜像,准备复制到 SD 卡上,或者是 jffs2 格式,准备通过引导加载程序加载到闪存中。创建磁盘镜像可能是最常见的选择。关于不同类型的大容量存储,有更多的信息可以参考第九章。
-
网络文件系统:当暂存目录通过 NFS 服务器导出到网络,并在启动时由目标设备挂载时,就形成了网络文件系统。这通常在开发过程中使用,而不是通过反复创建磁盘镜像并将其重新加载到大容量存储设备上,因为后者会迅速变得繁琐。
我将从intiramfs
开始,利用它来说明如何对根文件系统进行一些细微调整,比如添加用户名和设备管理器,以便自动创建设备节点。然后,我会展示如何创建磁盘镜像,以及如何使用 NFS 通过网络挂载根文件系统。
创建一个启动 initramfs
初始 RAM 文件系统或initramfs
是一个压缩的 cpio 归档文件。cpio 是一种古老的 Unix 归档格式,类似于 TAR 和 ZIP,但它更容易解码,因此内核需要的代码较少。你需要在内核中配置CONFIG_BLK_DEV_INITRD
以支持initramfs
。
有三种不同的方式来创建引导 ramdisk:作为独立的 cpio 归档、作为嵌入在内核映像中的 cpio 归档,以及作为内核构建系统在构建过程中处理的设备表。第一种方式提供了最大的灵活性,因为我们可以随意混合和匹配内核和 ramdisk。然而,它意味着需要处理两个文件而不是一个,并且并非所有引导加载程序都有加载独立 ramdisk 的功能。
独立 initramfs
以下是创建归档、压缩它并添加一个 U-Boot 头以便加载到目标上的一系列指令:
$ cd ~/rootfs
$ find . | cpio -H newc -ov --owner root:root > ../initramfs.cpio
$ cd ..
$ gzip initramfs.cpio
$ mkimage -A arm64 -O linux -T ramdisk -d initramfs.cpio.gz uRamdisk
注意,我们运行cpio
时使用了--owner root:root
选项。这是解决前面在暂存目录中的文件所有权权限部分提到的文件所有权问题的快速修复。它使得 cpio 归档中的所有内容都具有 UID 和 GID 为 0。
最终的 uRamdisk 文件大小约为 1.9 MB,没有内核模块。再加上 9.8 MB 的内核Image.gz
文件和 1,061 KB 的 U-Boot。这样,我们总共需要 13 MB 的存储空间来引导这块板子。这个大小远远超过了最初的 1.44 MB 软盘。如果大小真的是一个问题,那么你可以使用以下某种方式:
-
通过去掉不需要的驱动程序和功能来使内核更小。
-
通过去掉不需要的工具来使 BusyBox 更小。
-
使用 musl libc 或 uClibc-ng 替代 glibc。
-
静态编译 BusyBox。
启动 initramfs
我们可以做的最简单的事情是通过控制台运行一个 shell,以便与目标进行交互。我们可以通过将rdinit=/bin/sh
添加到内核命令行来实现这一点。接下来的两个部分将演示如何在 QEMU 和 BeaglePlay 上进行操作。
使用 QEMU 启动
QEMU 有一个-initrd
选项,可以将initramfs
加载到内存中。你应该已经使用第四章中的aarch64-buildroot-linux-gnu
工具链编译了一个Image
文件。从本章开始,你应该已经创建了一个包含用相同工具链编译的 BusyBox 的initramfs
。现在,你可以使用MELD/Chapter05/run-qemu-initramfs.sh
脚本或以下命令启动 QEMU:
$ cd ~
$ cd build_qemu
$ qemu-system-aarch64 -M virt -cpu cortex-a53 -nographic -smp 1 -kernel arch/arm64/boot/Image -append "console=ttyAMA0 rdinit=/bin/sh" -initrd ~/initramfs.cpio.gz
你应该得到一个带有#
提示符的root
shell。
启动 BeaglePlay
对于 BeaglePlay,我们需要准备在第四章中使用的 microSD 卡,并使用aarch64-buildroot-linux-gnu
工具链构建根文件系统。将你之前在本节中创建的uRamdisk
复制到 microSD 卡的引导分区。启动 BeaglePlay 并进入 U-Boot 提示符。然后,输入以下命令:
nova!> fatload mmc 1 0x80000000 Image.gz
nova!> fatload mmc 1 0x82000000 k3-am625-beagleplay.dtb
nova!> setenv kernel_comp_addr_r 0x85000000
nova!> setenv kernel_comp_size 0x20000000
nova!> fatload mmc 1 0x83000000 uRamdisk
nova!> setenv bootargs console=ttyS2,115200n8 rdinit=/bin/sh
nova!> booti 0x80000000 0x83000000 0x82000000
如果一切顺利,你将在串口控制台上看到一个带有#
提示符的root
shell。完成此步骤后,我们需要在两个平台上都挂载proc
。
挂载 proc
你会发现ps
命令在两个平台上都无法工作。这是因为proc
文件系统尚未挂载。试着挂载它:
# mount -t proc proc /proc
再次运行ps
,你将看到进程列表。
作为改进,我们可以编写一个 Shell 脚本来挂载proc
以及其他启动时需要完成的操作。然后,你可以在启动时运行这个脚本,而不是/bin/sh
。以下代码片段演示了它是如何工作的:
#!/bin/sh
/bin/mount -t proc proc /proc
# Other boot-time commands go here
/bin/sh
最后一行的/bin/sh
启动一个新的 Shell,提供一个交互式的root
Shell 提示符。以这种方式将 Shell 作为init
程序非常方便,适用于快速修补,例如当你想要修复一个损坏的init
程序的系统时。然而,在大多数情况下,你会使用一个init
程序,我们将在本章的下一节中讨论。之前,我想先看一下加载initramfs
的另外两种方式。
将 initramfs 构建到内核镜像中
到目前为止,我们已经将压缩后的initramfs
作为单独的文件创建,并通过引导加载程序将其加载到内存中。一些引导加载程序可能不具备以这种方式加载initramfs
文件的能力。为了应对这些情况,Linux 可以配置将initramfs
合并到内核镜像中。为此,请更改内核配置,并将CONFIG_INITRAMFS_SOURCE
设置为你之前为独立initramfs
创建的压缩initramfs.cpio.gz
归档文件的完整路径。如果你使用的是menuconfig
,可以在General setup | Initramfs source file(s)中找到该字段。
完成这些更改后,构建内核。启动过程与之前相同,不同之处在于没有-initrd
选项和需要传入的 ramdisk 文件。
对于 QEMU,输入以下命令:
$ qemu-system-aarch64 -M virt -cpu cortex-a53 -nographic -smp 1 -kernel arch/arm64/boot/Image -append "console=ttyAMA0 rdinit=/bin/sh"
对于 BeaglePlay,在 U-Boot 提示符下输入以下命令:
nova!> fatload mmc 1 0x80000000 Image.gz
nova!> fatload mmc 1 0x82000000 k3-am625-beagleplay.dtb
nova!> setenv kernel_comp_addr_r 0x85000000
nova!> setenv kernel_comp_size 0x20000000
nova!> setenv bootargs console=ttyS2,115200n8 rdinit=/bin/sh
nova!> booti 0x80000000 - 0x82000000
每次更改暂存目录的内容并重新构建内核时,请记得重新生成initramfs.cpio
归档文件,并重新压缩initramfs.cpio.gz
文件:
提示
如果在启动时遇到以下内核恐慌:
[ 0.549725] Run /bin/sh as init process
[ 0.573389] Kernel panic - not syncing: Attempted to kill init! exitcode=0x00000000
[ 0.573688] CPU: 0 PID: 1 Comm: sh Not tainted 6.6.46 #13
…
[ 0.576075] ---[ end Kernel panic - not syncing: Attempted to kill init! exitcode=0x00000000 ]---
确保在你的暂存目录中存在dev/null
和dev/console
设备节点。
使用设备表构建 initramfs
设备表是一个文本文件,列出了归档文件或文件系统镜像中包含的文件、目录、设备节点和链接。其显著优势是,它允许你创建由root
用户或任何其他 UID 拥有的归档文件条目,而你自己不需要拥有root
权限。你甚至可以在不需要root
权限的情况下创建设备节点。所有这一切之所以可行,是因为归档文件只是一个数据文件,只有在 Linux 启动时展开时,才会使用你指定的属性创建实际的文件和目录。
内核有一个功能,允许我们在创建initramfs
时使用设备表。你编写设备表文件后,再将CONFIG_INITRAMFS_SOURCE
指向它。然后,当你构建内核时,它会根据设备表中的指令创建 cpio 归档文件。整个过程你都不需要root
访问权限。
这是我们简单根文件系统的设备表。为了便于管理,它缺少大部分指向 BusyBox 的符号链接:
dir /bin 775 0 0
dir /sys 775 0 0
dir /tmp 775 0 0
dir /dev 775 0 0
nod /dev/null 666 0 0 c 1 3
nod /dev/console 600 0 0 c 5 1
dir /home 775 0 0
dir /proc 775 0 0
dir /lib 775 0 0
file /lib/libm.so.6 /home/frank/rootfs/lib/libm.so.6 755 0 0
file /lib/libresolv.so.2 /home/frank/rootfs/lib/libresolv.so.2 755 0 0
file /lib/libc.so.6 /home/frank/rootfs/lib/libc.so.6 755 0 0
file /lib/ld-linux-aarch64.so.1 /home/frank/rootfs/lib/ld-linux-aarch64.so.1 755 0 0
语法非常直观:
-
dir <name> <mode> <uid> <gid>
-
file <name> <location> <mode> <uid> <gid>
-
nod <name> <mode> <uid> <gid> <dev_type> <maj> <min>
-
slink <name> <target> <mode> <uid> <gid>
命令dir
、nod
和slink
在 cpio 归档中创建一个文件系统对象,包含给定的名称、模式、用户 ID 和组 ID。file
命令将文件从源位置复制到归档中,并设置模式、用户 ID 和组 ID。
从头创建initramfs.cpio
归档的任务,通过内核源代码中的一个脚本gen_initramfs.sh
变得更加简单。首先,这个脚本从输入目录的内容生成一个设备表。然后,它将这个设备表转换成最终的 cpio 归档。
要从rootfs
目录生成initramfs.cpio
归档,并将所有由用户 ID1000
和组 ID1000
拥有的文件的所有权更改为用户 ID0
和组 ID0
,请输入以下命令:
$ cd ~
$ cp build_qemu/usr/gen_init_cpio linux-stable/usr/.
$ cd linux-stable
$ usr/gen_initramfs.sh -o ~/initramfs.cpio -u 1000 -g 1000 ~/rootfs
旧版 initrd 格式
有一种较旧的 Linux ramdisk 格式,称为initrd
。它是 Linux 2.6 之前唯一可用的格式,如果你使用的是 uClinux(没有 MMU 的 Linux 变种),仍然需要这种格式。它相当晦涩,因此我不会在这里介绍。
一旦我们的initramfs
启动,系统需要开始运行程序。首先运行的程序是init
程序。
init 程序
在启动时运行一个 shell 或甚至是一个 shell 脚本对于简单的情况是可以的,但实际上,你需要更灵活的东西。通常,Unix 系统运行一个叫做init
的程序,它负责启动和监控其他程序。多年来,有许多init
程序,其中一些我将在第十三章中描述。目前,我将简要介绍 BusyBox 的init
。
init
程序首先读取/etc/inittab
配置文件。这里是一个简单的例子,足以满足我们的需求:
::sysinit:/etc/init.d/rcS
::askfirst:-/bin/ash
第一行在init
启动时运行一个名为rcS
的 shell 脚本。第二行将消息Please press Enter to activate this console打印到控制台,并在按下Enter后启动一个 shell。/bin/ash
前的连字符-
意味着它将成为一个登录 shell,在显示 shell 提示符之前,首先会读取/etc/profile
和$HOME/.profile
。
通过这种方式启动 shell 的一个优势是启用了作业控制。最直接的效果是,你可以使用Ctrl + C终止当前程序。也许你之前没注意到这一点,但等你运行ping
程序时,你会发现你无法停止它!
如果根文件系统中没有inittab
,BusyBox 的init
会提供一个默认的inittab
。它比前面的那个稍微复杂一些。
脚本/etc/init.d/rcS
是放置需要在启动时执行的初始化命令的地方,例如挂载proc
和sysfs
文件系统:
#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sysfs /sys
确保你像这样使前面的rcS
脚本可执行:
$ cd ~/rootfs
$ chmod +x etc/init.d/rcS
你可以通过更改-append
参数来尝试在 QEMU 上运行init
,像这样:
-append "console=ttyAMA0 rdinit=/sbin/init"
对于 BeaglePlay,你需要在 U-Boot 中设置bootargs
变量,如下所示:
nova!> setenv bootargs console=ttyS2,115200n8 rdinit=/sbin/init
现在,让我们仔细看看init
在启动时读取的inittab
。
启动守护进程
通常,你希望在启动时运行某些后台进程。例如,syslogd
(日志守护进程)。syslogd
的目的是收集来自其他程序,主要是其他守护进程的日志信息。自然,BusyBox 为此提供了一个小程序!
启动守护进程的方法很简单,只需在etc/inittab
中添加一行,如下所示:
::respawn:/sbin/syslogd -n
respawn
意味着如果程序终止,它将被自动重启。-n
表示它应作为前台进程运行。日志写入/var/log/messages
。
重要提示
你可能还想以相同的方式启动klogd
。klogd
将内核日志信息发送到syslogd
,以便它们能够被记录到永久存储中。
迄今为止,我提到的所有进程都以root
身份运行,但这并不是理想的做法。
配置用户账户
正如我之前所说,将所有程序都以root
身份运行并不是一个好习惯,因为如果其中一个程序被外部攻击破坏,那么整个系统都会面临风险。最好创建没有特权的用户账户,并在不需要完整root
权限的地方使用它们。
用户名配置在/etc/passwd
中。每个用户有一行,其中包含七个由冒号分隔的信息字段。它们的顺序是:
-
登录名
-
用于验证密码的哈希码,或更常见的是
x
,表示密码存储在/etc/shadow
中 -
UID 或用户 ID
-
GID 或组 ID
-
注释字段(通常留空)
-
用户的主目录
-
用户将使用的 shell(可选)
这里是一个简单的示例,其中我们有root
用户,UID 为 0,以及daemon
用户,UID 为 1:
root:x:0:0:root:/root:/bin/sh
daemon:x:1:1:daemon:/usr/sbin:/bin/false
将用户daemon
的 shell 设置为/bin/false
,确保任何尝试使用该用户名登录的行为都会失败。
各种程序需要读取/etc/passwd
以查找用户名和 UID,因此该文件必须是全局可读的。如果密码哈希也存储在其中,就会产生问题,因为恶意程序可以复制该文件并通过各种破解程序发现实际的密码。为了减少敏感信息的暴露,密码存储在/etc/shadow
中,并在密码字段中放置x
,以表示这是情况。/etc/shadow
文件只需要root
访问,因此只要root
用户没有被破坏,密码就会是安全的。
shadow 密码文件包含每个用户的一个条目,由九个字段组成。以下是一个示例,镜像了前面提到的密码文件:
root::10933:0:99999:7:::
daemon:*:10933:0:99999:7:::
前两个字段是用户名和密码哈希。其余的七个字段与密码老化有关,这在嵌入式设备上通常不需要关注。如果你对详细信息感兴趣,可以参考shadow(5)
的手册页。
在这个例子中,root
的密码为空,意味着 root
可以在不输入密码的情况下登录。为 root
设置空密码在开发过程中非常有用,但不适合生产环境。你可以通过在目标设备上运行 passwd
命令来生成或更改密码哈希,它将把新的哈希写入 /etc/shadow
。如果你希望所有后续的根文件系统使用相同的密码,你可以将这个文件复制回暂存目录。
组名类似地存储在 /etc/group
中。每个组占一行,由四个字段组成,字段之间用冒号分隔。字段包括:
-
组名
-
组密码,或者通常是一个
x
,表示没有组密码 -
GID 或者组 ID
-
属于该组的用户的逗号分隔列表(可选)
这是一个例子:
root:x:0:
daemon:x:1:
向根文件系统添加用户帐户
首先,将 etc/passwd
、etc/shadow
和 etc/group
文件添加到你的暂存目录,如前面部分所示。确保 etc/shadow
的权限是 0600
。接下来,通过启动名为 getty
的程序来启动登录过程。BusyBox 中有一个 getty
版本,你可以通过在 inittab
文件中使用 respawn
关键字来启动它,getty
会在登录 shell 终止时重新启动。你的 inittab
文件应如下所示:
::sysinit:/etc/init.d/rcS
::respawn:/sbin/getty 115200 console
然后,重新构建 ramdisk,并像之前一样使用 QEMU 或 BeaglePlay 进行测试。
在本章前面,我们学习了如何使用 mknod
命令创建设备节点。现在,让我们来看一些更简单的创建设备节点的方法。
更好的设备节点管理方式
使用 mknod
静态地创建设备节点既困难又不灵活。然而,还有其他方法可以根据需求自动创建设备节点:
-
devtmpfs
:这是一种伪文件系统,你在启动时将其挂载到/dev
上。内核会将其填充为内核当前已知的所有设备的设备节点。内核还会为运行时检测到的新设备创建节点。这些节点由root
拥有,默认权限为0600
。一些知名的设备节点,如/dev/null
和/dev/random
,会将默认权限覆盖为0666
。要查看如何做到这一点,可以查看 Linux 源码树中的drivers/char/mem.c
文件,观察struct memdev
如何被初始化。 -
mdev
:这是一个 BusyBox 小程序,用于填充目录并根据需要创建设备节点。它有一个/etc/mdev.conf
配置文件,其中包含有关节点的所有权和模式的规则。 -
udev
:这是mdev
的主流等效版本。你会在桌面 Linux 和一些嵌入式设备上找到它。它非常灵活,并且适用于高端嵌入式设备。它现在是systemd
的一部分。重要提示
尽管
mdev
和udev
都会创建设备节点,但让devtmpfs
来做这项工作,并使用mdev/udev
作为上层实现设置所有权和权限的策略会更简单。devtmpfs
的方法是生成用户空间启动前设备节点的唯一可维护方式。
在介绍devtmpfs
之后,我将描述如何使用mdev
在启动时分配设备节点的所有权和权限。
使用 devtmpfs
devtmpfs
文件系统的支持由CONFIG_DEVTMPFS
内核配置变量控制。它在 64 位 Arm 通用虚拟平台的默认配置中没有启用,因此如果你想在 QEMU 上尝试devtmpfs
,你需要返回内核配置并启用此选项。
输入此命令以挂载devtmpfs
:
# mount -t devtmpfs devtmpfs /dev
你会注意到之后/dev
中有更多的设备节点。要在启动时挂载devtmpfs
,将上述命令添加到/etc/init.d/rcS
:
#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev
如果你在内核配置中启用了CONFIG_DEVTMPFS_MOUNT
,内核将在挂载根文件系统之后自动挂载devtmpfs
。然而,当启动initramfs
时,这个选项不起作用,就像我们在这里所做的那样。
使用 mdev
虽然mdev
的设置稍微复杂一些,但它确实允许你在设备节点创建时修改它们的权限。你可以通过运行带有-s
选项的mdev
来开始,这将导致它扫描/sys
目录,查找当前设备的信息。根据这些信息,它会将相应的节点填充到/dev
目录中。
如果你想跟踪新设备的上线并为它们创建节点,你需要通过写入/proc/sys/kernel/hotplug
来使mdev
成为一个热插拔客户端。将以下两行添加到/etc/init.d/rcS
:
#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev
echo /sbin/mdev > /proc/sys/kernel/hotplug
mdev -s
默认模式是660
,所有权是root:root
。你可以通过在/etc/mdev.conf
中添加规则来更改此设置。例如,为了给null
、random
和urandom
设备设置正确的模式,你可以将以下内容添加到/etc/mdev.conf
:
null root:root 666
random root:root 444
urandom root:root 444
格式在 BusyBox 源代码中的docs/mdev.txt
中有文档说明,examples
目录中还有更多示例。
静态设备节点到底有那么糟糕吗?
静态创建的设备节点相比于运行设备管理器有一个优势:它们在启动时不需要任何时间来创建。如果最小化启动时间是一个优先考虑的目标,那么使用静态创建的设备节点将节省可测量的时间。
配置网络
接下来,让我们看看一些基本的网络配置,以便我们能够与外界通信。我假设有一个以太网接口(eth0
),并且我们只需要一个简单的 IPv4 配置。
这些示例使用了 BusyBox 中的网络工具,这些工具足以满足我们的简单用例。我们需要的只是那些老旧但可靠的ifup
和ifdown
程序。你可以查看这两个程序的手册页以获取详细信息。主要的网络配置存储在/etc/network/interfaces
中。你需要在临时目录中创建这些目录:
etc/network
etc/network/if-pre-up.d
etc/network/if-up.d
var/run
这是用于静态 IP 地址的/etc/network/interfaces
:
auto lo
iface lo inet loopback
auto eth0
iface eth0 inet static
address 192.168.1.101
netmask 255.255.255.0
network 192.168.1.0
这是用于动态 IP 地址的/etc/network/interfaces
,通过 DHCP 分配:
auto lo
iface lo inet loopback
auto eth0
iface eth0 inet dhcp
你还需要配置一个 DHCP 客户端程序。BusyBox 提供了一个名为udchpcd
的程序。它需要一个 shell 脚本,该脚本位于/usr/share/udhcpc/default.script
。在 BusyBox 源代码的examples/udhcp/simple.script
中有一个适当的默认脚本。
glibc 的网络组件
glibc 使用一种称为名称服务切换(NSS)的机制来控制名称解析为数字的方式,以便进行网络和用户管理。用户名可以通过/etc/passwd
文件解析为 UID,网络服务(如 HTTP)可以通过/etc/services
解析为服务端口号。所有这些都由/etc/nsswitch.conf
配置;详细信息请参见nss(5)
手册页。以下是一个适用于大多数嵌入式 Linux 实现的简单示例:
passwd: files
group: files
shadow: files
hosts: files dns
networks: files
protocols: files
services: files
一切都由/etc
中相应命名的文件解析,除了主机名,如果它们不在/etc/hosts
中,则可以通过 DNS 查找解析。
为了使其正常工作,你需要将这些文件填充到/etc
目录中。网络、协议和服务在所有 Linux 系统中都是相同的,因此它们可以从开发机上的/etc
复制过来。至少,/etc/hosts
应包含回环地址:
127.0.0.1 localhost
其他文件(passwd
、group
和shadow
)在之前的配置用户账户部分中已有描述。
拼图的最后一块是执行名称解析的库。它们是按需加载的插件,基于nsswitch.conf
的内容。这意味着当你使用readelf
或ldd
时,它们不会显示为依赖项。你只需要从工具链的sysroot
中复制它们:
$ cd ~/rootfs
$ cp -a $SYSROOT/lib/libnss* lib
$ cp -a $SYSROOT/lib/libresolv* lib
最后,我们的临时目录已经完成。让我们从中生成文件系统。
使用设备表创建文件系统映像
我们在之前的创建引导 initramfs部分中看到,内核可以选择使用设备表来创建initramfs
。设备表非常有用,因为它允许非 root 用户创建设备节点,并为任何文件或目录分配任意的 UID 和 GID 值。相同的概念已经应用于创建其他文件系统映像格式的工具,如下所示,映射从文件系统格式到工具:
-
jffs2:
mkfs.jffs2
-
ubifs:
mkfs:ubifs
-
ext2:
genext2fs
我们将在 第九章 中介绍 jffs2 和 ubifs,届时我们将讨论用于闪存的文件系统。ext2 是一种常用于托管闪存的格式,包括 SD 卡。以下示例使用 ext2 创建一个可以复制到 SD 卡的磁盘镜像。
首先,你需要在主机上安装 genext2fs
工具。在 Ubuntu 上,安装的软件包名为 genext2fs
:
$ sudo apt install genext2fs
genext2fs
使用设备表文件,格式为 <name> <type> <mode> <uid> <gid> <major> <minor> <start> <inc> <count>
。各字段的含义如下:
-
name
-
type: 下面的某个类型:
-
f
: 常规文件 -
d
: 目录 -
c
: 字符设备文件 -
b
: 块设备文件 -
p
: FIFO(命名管道) -
uid: 文件的 UID
-
gid: 文件的 GID
-
major 和 minor: 设备编号(仅适用于设备节点)
-
start, inc 和 count: 允许你从 start 中的次要编号开始创建一组设备节点(仅适用于设备节点)
你不必像处理内核 initramfs
表那样为每个文件指定这些,只需要指向一个目录——暂存目录——并列出你需要在最终文件系统镜像中做出的更改和例外。
这里有一个简单的示例,它为我们填充了静态设备节点:
/dev d 755 0 0 - - - - -
/dev/null c 666 0 0 1 3 0 0 -
/dev/console c 600 0 0 5 1 0 0 -
/dev/ttyO0 c 600 0 0 252 0 0 0 -
然后,你可以使用 genext2fs
生成一个 8 MB 的文件系统镜像(默认大小为 1,024 字节的 8,192 块):
$ cd ~
$ genext2fs -b 8192 -d ~/rootfs -D ~/MELD/Chapter05/device-tables.txt -U rootfs.ext2
现在,你可以将生成的 rootfs.ext2
镜像复制到 SD 卡或类似设备上,正如我们接下来的操作一样。
启动 BeaglePlay
名为 MELD/format-sdcard.sh
的脚本在 microSD 卡上创建了两个分区:一个用于启动文件,另一个用于根文件系统。假设你已经按照上一节所示创建了根文件系统镜像,你可以使用 dd
命令将其写入第二个分区。
重要提示
和往常一样,当像这样将文件直接复制到存储设备时,务必确认你知道哪个设备是 microSD 卡。
在这种情况下,我使用的是内建的读卡器,该设备名为 /dev/mmcblk0
,因此命令是:
$ sudo dd if=rootfs.ext2 of=/dev/mmcblk0p2
请注意,主机系统上的读卡器可能有不同的名称。
将 microSD 卡插入 BeaglePlay,并设置内核命令行为 root=/dev/mmcblk1p2
。与之前的 Beagle 不同,BeaglePlay 上的 eMMC 是 mmcblk0
设备,而 microSD 是 mmcblk1
设备。以下是完整的 U-Boot 命令序列:
nova!> fatload mmc 1 0x80000000 Image.gz
nova!> fatload mmc 1 0x82000000 k3-am625-beagleplay.dtb
nova!> setenv kernel_comp_addr_r 0x85000000
nova!> setenv kernel_comp_size 0x20000000
nova!> setenv bootargs console=ttyS2,115200n8 root=/dev/mmcblk1p2 rootdelay=5 rootwait
nova!> booti 0x80000000 - 0x82000000
这是一个从普通块设备(如 SD 卡)挂载 ext2 文件系统的示例。相同的原则也适用于其他类型的文件系统。我们将在 第九章中更详细地讨论这些内容。现在,让我们转变思路,看看如何通过网络挂载文件系统。
使用 NFS 挂载根文件系统
如果您的设备有网络接口,您可以通过网络文件系统(NFS)通过网络挂载根文件系统,以加速开发。这使您可以访问主机机器几乎无限的存储空间,因此您可以添加调试工具和具有大型符号表的可执行文件。作为附加福利,开发机器上对根文件系统的更新会立即反映在目标上。您还可以从主机访问目标的所有日志文件。
首先,您需要在主机机器上安装并配置 NFS 服务器。在 Ubuntu 上安装的包名为 nfs-kernel-server
:
$ sudo apt install nfs-kernel-server
NFS 服务器需要知道哪些目录被导出到网络。这由 /etc/exports
文件控制。每个导出都有一行,格式在 exports(5)
手册页中描述。要导出根文件系统,我主机上的 exports
文件包含以下内容:
/home/frank/rootfs *(rw,sync,no_subtree_check,no_root_squash)
*
将目录导出到我的本地网络上的任何地址。如果需要,您可以在此时指定一个单一的 IP 地址或一个地址范围。后面跟着一系列括号内的选项。*
和开括号之间不能有任何空格。选项如下:
-
rw
: 将目录导出为读写模式。 -
sync
: 选择 NFS 协议的同步版本,它比异步版本更稳健,但速度稍慢。 -
no_subtree_check
: 禁用子树检查,这对安全性有轻微影响,但在某些情况下可以提高可靠性。 -
no_root_squash
: 允许来自用户 ID 0 的请求在不压缩为其他用户 ID 的情况下处理。这对于目标正确访问root
拥有的文件是必要的。
修改 /etc/exports
后,重启 NFS 服务器以使更改生效:
$ sudo systemctl restart nfs-kernel-server
现在,设置目标以通过 NFS 挂载根文件系统。为了使其生效,请在配置内核时启用 CONFIG_ROOT_NFS
。然后,通过将以下内容添加到内核命令行来配置 Linux 在启动时进行挂载:
root=/dev/nfs rw nfsroot=<host-ip>:<root-dir> ip=<target-ip>
选项如下:
-
rw
: 以读写模式挂载根文件系统。 -
nfsroot
: 指定主机的 IP 地址,后跟导出根文件系统的路径。 -
ip
: 这是将分配给目标的 IP 地址。通常,网络地址在运行时分配,就像我们在配置网络部分看到的那样。然而,在这种情况下,接口必须在根文件系统挂载并且init
启动之前进行配置。因此,它是在内核命令行中进行配置的。重要提示
有关 NFS 根挂载的更多信息,请参阅内核源代码中的
Documentation/admin-guide/nfs/nfsroot.rst
。
使用 BeaglePlay 进行测试
从 microSD 卡启动 BeaglePlay,并在 U-Boot 提示符下输入以下命令:
nova!> setenv serverip 192.168.1.119
nova!> setenv ipaddr 192.168.1.176
nova!> setenv npath <path to staging directory>
nova!> setenv bootargs console=ttyS2,115200n8 root=/dev/nfs ip=${ipaddr}:::::eth0 nfsroot=${serverip}:${npath},nfsvers=3,tcp rw
nova!> fatload mmc 1 0x80000000 Image.gz
nova!> fatload mmc 1 0x82000000 k3-am625-beagleplay.dtb
nova!> setenv kernel_comp_addr_r 0x85000000
nova!> setenv kernel_comp_size 0x20000000
nova!> booti 0x80000000 - 0x82000000
将 <path to staging directory>
替换为你的临时目录的完整路径,并将 serverip
和 ipaddr
值修改为与 Linux 主机和 BeaglePlay 的 IP 地址相匹配。确保 BeaglePlay 在进行此操作之前能够 ping 通 serverip
。
文件权限问题
你复制到临时目录中的文件将由你当前登录用户的 UID 所拥有(通常是 1000
)。然而,目标设备并不知道这个用户。而且,目标创建的任何文件都将由目标设备配置的用户(通常是 root 用户)拥有。整个情况非常混乱。不幸的是,这没有简单的解决办法。
最佳解决方案是创建临时目录的副本,并将所有权更改为 UID 和 GID 为 0
,使用命令 sudo chown -R 0:0 *
。然后,将该目录作为 NFS 挂载进行导出。这虽然取消了在开发和目标系统之间共享根文件系统单一副本的便利性,但至少文件所有权将是正确的。
在嵌入式 Linux 中,通常将设备驱动程序静态链接到内核,而不是像模块一样在运行时从根文件系统动态加载它们。那么,如何在修改内核源代码或设备树二进制文件(DTBs)时获得 NFS 提供的快速迭代好处呢?答案是 TFTP。
使用 TFTP 加载内核
现在我们知道如何通过网络使用 NFS 挂载根文件系统,你可能会想是否有办法通过网络加载内核、设备树和 initramfs
。如果可以做到这一点,那么唯一需要写入目标存储的组件就是引导加载程序。其他一切都可以从主机加载。这将节省时间,因为你不需要反复刷新目标。即使在闪存存储驱动程序仍在开发中时,你也可以继续工作(这种情况发生过)。
简单文件传输协议(TFTP)是解决方案。TFTP 是一种非常简单的文件传输协议,旨在与像 U-Boot 这样的引导加载程序易于实现。
首先,你需要在主机上安装 TFTP 守护进程。在 Ubuntu 上安装的包名为 tftpd-hpa
:
$ sudo apt install tftpd-hpa
按照如下所示修改 /etc/default/tftpd-hpa
的内容:
TFTP_USERNAME="tftp"
TFTP_DIRECTORY="/var/lib/tftpboot"
TFTP_ADDRESS="0.0.0.0:69"
TFTP_OPTIONS="--secure"
创建 /var/lib/tftpboot
目录,并设置必要的所有权和权限:
$ sudo mkdir -p /var/lib/tftpboot
$ sudo chown -R nobody:nogroup /var/lib/tftpboot
$ sudo chmod -R 777 /var/lib/tftpboot
在修改了 /etc/default/tftpd-hpa
后,重启 TFTP 服务器以使其生效:
$ sudo systemctl restart tftpd-hpa
安装并运行 tftpd-hpa
后,将你希望加载到目标上的文件复制到 /var/lib/tftpboot
。对于 BeaglePlay,这些文件将是 Image
和 k3-am625-beagleplay.dtb
:
$ cd ~
$ cp build_beagleplay/arch/arm64/boot/Image /var/lib/tftpboot/.
$ cp build_beagleplay/arch/arm64/boot/dts/ti/k3-am625-beagleplay.dtb /var/lib/tftpboot/.
然后,在 U-Boot 提示符下输入以下命令:
nova!> setenv serverip 192.168.1.119
nova!> setenv ipaddr 192.168.1.176
nova!> setenv npath <path to staging directory>
nova!> tftp 0x80000000 Image
nova!> tftp 0x82000000 k3-am625-beagleplay.dtb
nova!> setenv bootargs console=ttyS2,115200n8 root=/dev/nfs ip=${ipaddr}:::::eth0 nfsroot=${serverip}:${npath},nfsvers=3,tcp rw
nova!> booti 0x80000000 - 0x82000000
将<path to staging directory>
替换为你的临时目录的完整路径,并将serverip
和ipaddr
值更改为与你的 Linux 主机和 BeaglePlay 的 IP 地址匹配。你可能会发现 tftp
命令会无限期挂起,打印字母T
,这意味着 TFTP 请求超时。这种情况发生的原因有很多,最常见的原因是:
-
serverip
的 IP 地址不正确 -
TFTP 守护进程没有在服务器上运行。
-
服务器上的防火墙阻止了 TFTP 协议。大多数防火墙默认会阻止 TFTP 端口 69。
一旦你解决了连接问题,U-Boot 将从主机加载文件并以常规方式启动。
总结
Linux 的一个优点是它支持多种根文件系统,因此可以根据广泛的需求进行定制。我们已经看到,如何仅使用少量组件手动构建一个简单的根文件系统。BusyBox 在这方面特别有用。
通过一步步地执行这个过程,我们对 Linux 系统的一些基本工作原理有了了解,包括网络配置和用户账户。然而,随着设备变得越来越复杂,任务很快就变得难以管理。而且,始终存在一个担忧,那就是我们可能没有注意到实现中存在的安全漏洞。
在下一章中,我将向你展示如何使用嵌入式构建系统使创建嵌入式 Linux 系统的过程变得更加简单和可靠。我将从 Buildroot 开始,然后再介绍更复杂但更强大的 Yocto 项目。
进一步学习
-
文件系统层次标准,版本 3.0:
refspecs.linuxfoundation.org/fhs.shtml
-
Ramfs、rootfs 和 initramfs,由 Rob Landley 编写,Linux 源代码的一部分:Documentation/filesystems/ramfs-rootfs-initramfs.rst
加入我们在 Discord 上的社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:packt.link/embeddedsystems
第二部分
构建嵌入式 Linux 镜像
本部分旨在帮助你设置开发环境,并为后续阶段创建一个工作平台。你将学习如何通过利用嵌入式 Linux 构建系统(如 Buildroot 或 Yocto 项目)来自动化生成可启动镜像的过程。自动化复杂的构建任务能够加速项目生命周期,使团队能够在更短时间内交付更高质量的产品。本节的最后会深入介绍 Yocto 项目。
本部分包括以下章节:
-
第六章,选择构建系统
-
第七章,使用 Yocto 开发
-
第八章,Yocto 深入剖析
第六章:选择构建系统
前几章介绍了嵌入式 Linux 的前四个元素。你逐步构建了引导加载程序、内核和根文件系统,然后将它们组合成一个基本的嵌入式 Linux 系统。这是一个繁琐的过程!现在是时候通过尽可能地自动化来简化这个过程了。嵌入式 Linux 构建系统能够帮助实现这一点,我们将重点介绍两个:Buildroot 和 Yocto 项目。这两者都是复杂的工具,解释它们的工作原理需要整本书。
在本章中,我将仅介绍构建系统背后的基本思想,为第七章和第八章打下基础。首先,我将展示如何构建一个简单的镜像,以便对系统有一个整体的了解。然后,我将展示如何对前几章中的 Nova 开发板和 Raspberry Pi 4 示例做一些有用的修改。在接下来的章节中,我们将深入探讨 Yocto 项目,这是当前嵌入式 Linux 的主要构建系统。
本章我们将覆盖以下主题:
-
比较构建系统
-
分发二进制文件
-
介绍 Buildroot
-
介绍 Yocto 项目
技术要求
为了跟随示例,确保你拥有以下内容:
-
至少具有 90 GB 可用磁盘空间的 Ubuntu 24.04 或更高版本 LTS 主机系统
-
microSD 卡读卡器和卡片
-
适用于 Linux 的 balenaEtcher
-
一根以太网电缆和一个可用端口的路由器,用于网络连接
-
一根 3.3V 逻辑电平的 USB-to-TTL 串口线
-
Raspberry Pi 4
-
BeaglePlay
-
一款能够提供 3A 电流的 5V USB-C 电源
本章中使用的代码可以在本书 GitHub 仓库的本章文件夹中找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development/tree/main/Chapter06
。
比较构建系统
在第五章中,我描述了手动创建镜像的过程,这就是自定义构建(RYO)过程。这种方法让你完全掌控软件,以便按需进行定制。如果你想做一些真正奇特却具有创新性的事情,或者你想将内存占用减少到最小,那么自定义构建就是最佳选择。但在大多数情况下,手动构建是浪费时间的,并且会产生不稳定、不可维护的系统。
构建系统的理念是自动化完成到目前为止描述的所有步骤。构建系统应当能够从上游源代码构建以下某些或所有内容:
-
工具链
-
引导加载程序
-
内核
-
根文件系统
从上游源代码构建非常重要,原因有几个。它能让你放心地在任何时候都可以重新构建,而无需依赖外部组件。这也意味着你可以在源代码层面进行调试,并在必要时满足分发代码给用户的许可证要求。
为了完成任务,构建系统必须能够:
-
从上游下载源代码,既可以直接从版本控制系统获取,也可以作为归档文件下载,并将其缓存到本地。
-
应用补丁以启用交叉编译、修复架构相关的错误、应用本地配置策略等。
-
构建各种组件及其编译时和运行时的依赖关系。
-
创建一个暂存区并组装根文件系统。
-
创建各种格式的镜像文件,准备好加载到目标设备上。
其他一些有用的功能包括:
-
添加你自己的软件包,包含应用程序或内核更改。
-
选择各种根文件系统配置文件:大或小,有或没有图形界面,及其他功能。
-
创建一个独立的 SDK,你可以将其分发给其他开发者,这样他们就不需要安装完整的构建系统。
-
跟踪你选择的各种软件包使用了哪些开源许可证。
-
提供一个友好的用户界面。
在所有情况下,构建系统将系统的组件封装为软件包,一些是针对主机的,一些是针对目标的。每个软件包都定义了一组规则,用于获取源代码、编译它,并将结果安装到正确的位置。软件包之间有依赖关系,并且有一个机制来解决这些依赖关系并构建所需的软件包集。
开源构建系统在过去几年中已经成熟,市面上有很多这样的系统,包括:
-
Buildroot:是一个易于使用的系统,使用 GNU Make 和 Kconfig (
buildroot.org/
)。 -
OpenEmbedded:是一个强大的系统,也是 Yocto 的核心组件 (
openembedded.org
)。 -
OpenWrt:是一个面向构建无线路由器固件的构建工具 (
openwrt.org/
),开箱即用支持运行时软件包管理。它是 Buildroot 的衍生版本。 -
PTXdist:是由 Pengutronix 提供支持的开源构建系统 (
www.ptxdist.org/
)。 -
Yocto:通过元数据、工具和文档扩展了 OpenEmbedded 核心。它是目前最流行的嵌入式 Linux 构建系统 (
www.yoctoproject.org/
)。
我将专注于其中的两个:Buildroot 和 Yocto。它们以不同的方式解决问题,并且有不同的目标。
Buildroot 的主要目标是构建根文件系统镜像,因此得名。但它也可以构建引导加载程序、内核,甚至工具链。它易于安装和配置。最重要的是,Buildroot 可以快速生成目标镜像。
Yocto 在定义目标系统方面更为通用,因此它可以构建更复杂的嵌入式设备。默认情况下,每个组件都作为二进制包使用 RPM 格式生成。然后将这些包组合起来,制作成文件系统镜像。你可以在文件系统镜像中安装一个包管理器,这样就可以在运行时更新包。换句话说,当你使用 Yocto 构建时,你是在创建你自己的定制 Linux 发行版。请记住,启用运行时包管理也意味着你需要提供并运行自己对应的包源库。
分发二进制文件
主流 Linux 发行版通常是由 RPM 或 DEB 格式的二进制(预编译)包集合构建而成的。RPM 代表 Red Hat 包管理器,用于 Red Hat、SUSE、Fedora 以及其他基于 RPM 的发行版。Debian 及其衍生发行版,包括 Ubuntu 和 Mint,使用 Debian 包管理器(DEB)格式。此外,还有一种适用于嵌入式设备的轻量级格式,称为 Itsy 包(IPK)格式,它基于 DEB。
在设备上包含包管理器的能力是构建系统之间的一个重要区别。一旦在目标设备上安装了包管理器,就可以轻松部署新包并更新现有的包。我将在第十章中讨论这方面的影响。
现在进入重点。我们将从 Buildroot 开始。作为两种构建系统中更简单的一个,Buildroot 比 Yocto 更容易上手,并且是为我们的三个目标生成可引导镜像的最快方法。
介绍 Buildroot
目前的 Buildroot 版本可以构建工具链、引导加载程序、内核和根文件系统。Buildroot 使用 GNU Make 作为其主要构建工具。在buildroot.org/docs.html
上有很好的在线文档,以及在buildroot.org/downloads/manual/manual.html
上的Buildroot 用户手册。
背景
Buildroot 是最早的构建系统之一。它最初是因为 uClinux 和 uClibc 项目需要一种生成小型根文件系统进行测试的方法。Buildroot 于 2001 年底成为一个独立的项目,并在 2006 年继续发展,之后进入了一个休眠阶段。
然而,自从 Peter Korsgaard 在 2009 年接管管理工作以来,Buildroot 发展迅速,增加了对基于 glibc 的工具链的支持,并大幅增加了包和目标板的数量。Peter 目前仍然是 Buildroot 的首席维护者,并且在比利时 Barco 公司担任软件工程师时,拥有长久而辉煌的职业生涯。
Buildroot 是 OpenWrt 的祖先,OpenWrt 是另一个流行的构建系统,约在 2004 年从 Buildroot 分支出来。OpenWrt 的主要目标是为无线路由器生产软件,因此其软件包组合倾向于网络基础设施。它还具有一个运行时的 IPK 包管理器,允许设备在不完全重新刷写镜像的情况下进行更新或升级。Buildroot 和 OpenWrt 已经发生了如此大的分歧,以至于它们现在几乎是完全不同的构建系统。用一个构建的包无法与另一个系统兼容。
稳定版本和长期支持
Buildroot 开发者每年发布四次稳定版本,分别在二月、五月、八月和十一月。这些版本通过 Git 标签进行标记,形式为 <year>.02
、<year>.05
、<year>.08
和 <year>.11
。每个 <year>.02
版本都会被标记为 长期支持(LTS),意味着在初始发布后的 12 个月内会有补丁发布来修复安全性和其他重要的 bug。2017.02
版本是第一个获得 LTS 标签的版本。
安装
你可以通过克隆仓库或下载档案来安装 Buildroot。以下是获取 2024.02.6
版本的示例,这是本文写作时的最新稳定版本:
$ git clone git://git.buildroot.net/buildroot -b 2024.02.6
相应的 TAR 档案可以在 buildroot.org/downloads/
获取。
阅读《Buildroot 用户手册》中的 系统要求 部分,该手册可以在 buildroot.org/downloads/manual/manual.html
上找到,并确保安装手册中列出的所有软件包。
配置
Buildroot 使用我在理解内核配置部分中描述的内核 Kconfig/Kbuild 机制。你可以通过直接使用make menuconfig
(xconfig
或 gconfig
)从零开始配置 Buildroot。或者你也可以选择 configs
目录中存储的 100 多个不同开发板的配置之一。输入 make list-defconfigs
会列出所有默认配置。
让我们从构建一个默认配置开始,这个配置可以在 64 位 Arm QEMU 模拟器上运行:
$ cd buildroot
$ make qemu_aarch64_virt_defconfig
$ make
重要提示
不要使用 -j
选项告诉 GNU Make 要运行多少个并行任务。Buildroot 会自动充分利用你的 CPU 核心。如果你想限制任务数量,可以运行 make menuconfig
并在 构建选项下查找 同时运行的任务数。
构建过程可能需要一个小时,具体时间取决于你的主机系统有多少个 CPU 核心以及网络的速度。它将下载大约 502 MB 的代码,并消耗大约 12 GB 的磁盘空间。完成后,你会发现创建了两个新的目录:
-
dl
:包含 Buildroot 所构建的上游项目的档案。 -
output
:包含所有中间和最终编译的产物。
在 output
目录中,你会找到以下子目录:
-
build
:包含每个组件的构建目录。 -
host
:包含 Buildroot 在主机上所需的各种工具,包括工具链的可执行文件(位于output/host/usr/bin
)。 -
images
:包含构建的最终结果。根据你在配置时选择的内容,你将找到引导加载程序、内核以及一个或多个根文件系统映像。 -
staging
:是工具链sysroot
的符号链接。这个链接的名称可能有些令人困惑,因为它并没有指向如 第五章 中定义的暂存区域。 -
target
:是根目录的暂存区域。请注意,您不能将其用作根文件系统,因为文件的所有权和权限没有正确设置。Buildroot 使用前一章中描述的设备表,在创建image
目录中的文件系统映像时设置所有权和权限。
运行
一些示例目标在 board
目录中有一个子文件夹,包含自定义配置文件和安装结果的相关信息。
对于你刚刚构建的系统,相关的文件是 board/qemu/aarch64-virt/readme.txt
。这个 readme.txt
文件告诉你如何使用该目标启动 QEMU。假设你已经按照 第一章 中的描述安装了 qemu-system-aarch64
,你可以使用以下命令运行 QEMU:
$ qemu-system-aarch64 -M virt -cpu cortex-a53 -nographic -smp 1 -kernel output/images/Image -append "rootwait root=/dev/vda console=ttyAMA0" -netdev user,id=eth0 -device virtio-net-device,netdev=eth0 -drive file=output/images/rootfs.ext4,if=none,format=raw,id=hd0 -device virtio-blk-device,drive=hd0
在 output/images
中有一个名为 start-qemu.sh
的脚本,其中包含命令。当 QEMU 启动时,你应该能看到内核启动信息出现在启动 QEMU 的同一终端窗口中,随后是登录提示符:
Booting Linux on physical CPU 0x0000000000 [0x410fd034]
Linux version 6.1.44 (frank@frank-nuc) (aarch64-buildroot-linux-gnu-gcc.br_real (Buildroot 2024.02.6) 12.4.0, GNU ld (GNU Binutils) 2.40) #1 SMP Wed Oct 9 21:24:21 PDT 2024
random: crng init done
Machine model: linux,dummy-virt
efi: UEFI not found.
<…>
VFS: Mounted root (ext4 filesystem) readonly on device 254:0.
devtmpfs: mounted
Freeing unused kernel memory: 1280K
Run /sbin/init as init process
EXT4-fs (vda): re-mounted. Quota mode: disabled.
Saving 256 bits of creditable seed for next boot
Starting syslogd: OK
Starting klogd: OK
Running sysctl: OK
Starting network: udhcpc: started, v1.36.1
udhcpc: broadcasting discover
udhcpc: broadcasting select for 10.0.2.15, server 10.0.2.2
udhcpc: lease of 10.0.2.15 obtained from 10.0.2.2, lease time 86400
deleting routers
adding dns 10.0.2.3
OK
Welcome to Buildroot
buildroot login:
使用无密码的 root
用户登录。
要退出 QEMU,输入 Ctrl + A,然后按 x。
定位真实硬件
配置和构建 Raspberry Pi 4 可启动映像的步骤几乎与构建 64 位 Arm QEMU 时相同:
$ cd buildroot
$ make clean
$ make raspberrypi4_64_defconfig
$ make
最终的映像被写入名为 output/images/sdcard.img
的文件中。用于写入映像文件的 post-image.sh
脚本和 genimage.cfg.in
配置文件都位于 board/raspberrypi4-64
目录中。要将 sdcard.img
写入 microSD 卡并在 Raspberry Pi 4 上启动:
-
将 microSD 卡插入你的 Linux 主机。
-
启动 balenaEtcher。
-
在 Etcher 中点击 Flash from file 按钮。
-
定位并打开你为 Raspberry Pi 4 构建的
sdcard.img
映像。 -
在 Etcher 中点击 Select target 按钮。
-
选择你在 步骤 1 中插入的 microSD 卡。
-
在 Etcher 中点击 Flash 按钮以写入映像。
-
当 Etcher 完成闪存写入时,弹出 microSD 卡。
-
将 microSD 卡插入 Raspberry Pi 4。
-
通过 USB-C 端口为 Raspberry Pi 4 提供电源。
通过将树莓派 4 插入以太网并观察网络活动指示灯闪烁,确认树莓派 4 已成功启动。这个默认镜像非常简约,除了 BusyBox 几乎不包含其他内容。要通过 SSH 连接到树莓派 4,您需要在 Buildroot 镜像配置中添加一个 SSH 服务器,例如dropbear
或openssh
。
创建自定义 BSP
现在让我们使用 Buildroot 为我们的 Nova 开发板创建一个板级支持包(BSP),并使用前几章中相同版本的 U-Boot 和 Linux。您可以在本书本节中的MELD/Chapter06/buildroot
下查看我对 Buildroot 所做的更改。
推荐存储更改的位置是:
-
board/<organization>/<device>
:包含 Linux、U-Boot 以及其他组件的补丁、二进制文件、额外构建步骤和配置文件 -
configs/<device>_defconfig
:包含该开发板的默认配置 -
package/<organization>/<package_name>
:用于存放此开发板的任何附加包
创建一个目录来存储对 Nova 开发板的更改:
$ mkdir -p board/meld/nova
将nova_defconfig
从MELD/Chapter06/buildroot/configs
复制到buildroot
/configs
:
$ cp ../MELD/Chapter06/buildroot/configs/nova_defconfig configs/.
将MELD/Chapter06/buildroot/board/meld/nova
中的内容复制到buildroot/board/meld/nova
:
$ cp ../MELD/Chapter06/buildroot/board/meld/nova/* board/meld/nova/.
清理之前构建的所有产物(更改配置时总是要这么做):
$ make clean
选择 Nova 配置:
$ make nova_defconfig
make nova_defconfig
命令将配置 Buildroot,以便构建一个针对 BeaglePlay 的镜像。这个配置是一个良好的起点,但我们仍然需要为 Nova 开发板定制它。我们从选择为 Nova 创建的自定义 U-Boot 补丁开始。
U-Boot
在第三章中,我们为 Nova 创建了基于 TI 的 U-Boot 分支f036fb
版本的自定义引导加载程序,并为其创建了一个补丁文件,保存为MELD/Chapter03/0001-BSP-for-Nova.patch
。我们可以配置 Buildroot 来选择相同版本的 U-Boot 并应用我们的补丁。运行make nova_defconfig
已经将 U-Boot 版本设置为f036fb
。
将补丁文件复制到board/meld/nova
:
$ cp ../MELD/Chapter03/0001-BSP-for-Nova.patch board/meld/nova/.
现在运行make menuconfig
并进入引导加载程序页面。从该页面,进入自定义 U-Boot 补丁,并验证我们的补丁路径,如下所示:
图 6.1 – 选择自定义 U-Boot 补丁
现在我们已经为 Nova 开发板打了 U-Boot 补丁,接下来的步骤是为内核打补丁。
Linux
在第四章中,我们基于 Linux 6.6.46 版本的内核,并提供了一个新的设备树文件,来自MELD/Chapter04/nova.dts
。运行make nova_defconfig
已经将内核版本设置为 Linux 6.6.46,并将内核头文件使用的内核系列更改为与所构建内核匹配的版本。退出引导加载程序页面,并进入内核页面。确认非树设备树源文件路径的值已设置为board/meld/nova/nova.dts
:
图 6.2 – 选择设备树源
现在我们已经定义了设备树,让我们构建包含内核和根文件系统的系统映像。
构建
在构建的最后阶段,Buildroot 使用一个名为genimage
的工具来为 microSD 创建一个映像,我们可以直接将它复制到卡中。我们需要一个配置文件来以正确的方式布局映像。通过替换现有的board/meld/nova/genimage.cfg
文件中的"k3-am625-beagleplay.dtb"
为"nova.dtb"
,如下所示:
image boot.vfat {
vfat {
files = {
"tiboot3.bin",
"tispl.bin",
"u-boot.img",
"Image.gz",
"nova.dtb", // HERE
}
}
size = 16M
}
image sdcard.img {
hdimage {
}
partition u-boot {
partition-type = 0xC
bootable = "true"
image = "boot.vfat"
}
partition rootfs {
partition-type = 0x83
image = "rootfs.ext4"
}
}
这将创建一个名为sdcard.img
的文件,其中包含两个分区,分别为u-boot
和rootfs
。第一个分区包含boot.vfat
中列出的启动文件,第二个分区包含名为rootfs.ext4
的根文件系统映像,这将由 Buildroot 生成。
最后,我们需要一个post-image.sh
脚本来调用genimage
并创建 microSD 卡映像。请参见board/meld/nova/post-image.sh
:
#!/bin/sh
BOARD_DIR="$(dirname $0)"
cp ${BUILD_DIR}/ti-k3-r5-loader-2022.10/tiboot3.bin $BINARIES_DIR/tiboot3.bin
GENIMAGE_CFG="${BOARD_DIR}/genimage.cfg" GENIMAGE_TMP="${BUILD_DIR}/genimage.tmp"
rm -rf "${GENIMAGE_TMP}"
genimage \
--rootpath "${TARGET_DIR}" \
--tmppath "${GENIMAGE_TMP}" \
--inputpath "${BINARIES_DIR}" \
--outputpath "${BINARIES_DIR}" \
--config "${GENIMAGE_CFG}"
这个脚本将 R5 固件映像复制到output/images
目录,并使用我们的配置文件运行genimage
。
请注意,post-image.sh
需要具有可执行权限,否则构建将在最后失败:
$ chmod +x board/meld/nova/post-image.sh
现在,运行make menuconfig
并进入系统配置页面。在该页面,向下导航到创建文件系统映像前要运行的自定义脚本,注意到我们post-image.sh
脚本的路径:
图 6.3 – 选择在创建文件系统映像后要运行的自定义脚本
最后,你只需输入make
命令即可为 Nova 板构建 Linux。当构建完成后,你将在output/images
目录中看到这些文件:
bl31.bin rootfs.ext2 tee-header_v2.bin tispl.bin
boot.vfat rootfs.ext4 tee-pageable_v2.bin u-boot.img
Image.gz rootfs.tar tee-pager_v2.bin
nova.dtb sdcard.img tiboot3.bin
r5-u-boot-spl.bin tee.bin ti-connectivity
为了测试它,将 microSD 卡插入读卡器,并使用 balenaEtcher 将output/images/sdcard.img
写入 microSD 卡,就像我们之前为 Raspberry Pi 4 所做的那样。无需像上一章那样先格式化 microSD 卡,因为genimage
已创建了所需的磁盘布局。
我们已经展示了我们为 Nova 板创建的自定义配置有效,现在最好将我们的更改保存回nova_defconfig
文件,以便我们和其他人可以再次使用它。你可以通过以下命令来实现:
$ make savedefconfig BR2_DEFCONFIG=configs/nova_defconfig
现在你有了一个针对 Nova 板的自定义 Buildroot 配置。你可以通过输入以下命令来获取这个配置:
$ make nova_defconfig
至此,我们已成功配置了 Buildroot。在接下来的部分,我们将学习如何将自己的代码添加到 Buildroot 映像中。
添加你自己的代码
假设你开发了一个程序,并且想将它包含在构建中。你有两种选择。首先,可以单独构建它,使用它自己的构建系统,然后将二进制文件作为覆盖层放入最终构建中。其次,创建一个 Buildroot 包,可以从菜单中选择并像其他任何包一样构建它。
覆盖
覆盖层(overlay)只是一个目录结构,它会在构建过程中稍后的阶段覆盖到 Buildroot 根文件系统上。它可以包含可执行文件、库文件和你可能想要包含的任何其他内容。请注意,任何编译过的代码必须与运行时部署的库兼容,这意味着它必须使用与 Buildroot 相同的工具链进行编译。使用 Buildroot 工具链非常简单,只需将其添加到PATH
中:
$ PATH=<path_to_buildroot>/output/host/usr/bin:$PATH
工具链的前缀是<ARCH>-linux-
。因此,要编译一个简单的程序,你可以做类似下面的操作:
$ PATH=/home/frank/buildroot/output/host/usr/bin:$PATH
$ aarch64-linux-gcc helloworld.c -o helloworld
一旦你用正确的工具链编译了你的程序,将可执行文件和其他支持文件安装到临时区,并将其标记为 Buildroot 的覆盖层。对于helloworld
示例,你可以将其放在board/meld/nova
目录下:
$ mkdir -p board/meld/nova/overlay/usr/bin
$ cp helloworld board/meld/nova/overlay/usr/bin
最后,将BR2_ROOTFS_OVERLAY
设置为指向覆盖层的路径。可以在menuconfig
中通过系统配置 | 根文件系统覆盖目录选项进行配置。
添加一个包
<package_name>.mk.
重要提醒
请注意,Buildroot 包不包含代码,只是包含获取代码的指令,可能是下载一个 tar 包,执行git clone
,或任何获取上游源代码所需的操作。
Makefile 是按照 Buildroot 预期的格式编写的,包含指令,允许 Buildroot 下载、配置、编译并安装程序。编写一个新的包的 Makefile 是一个复杂的操作,详细内容可以参考Buildroot 用户手册。
下面是一个示例,展示如何为像helloworld
这样的简单程序创建一个包。首先,创建一个package/helloworld
子目录,并在其中创建一个类似如下的Config.in
文件:
config BR2_PACKAGE_HELLOWORLD
bool "helloworld"
help
A friendly program that prints Hello World! every 10s
第一行必须是BR2_PACKAGE_<大写包名>
的格式。接下来是bool
和包名,这将在配置菜单中显示。第二行是启用用户选择此包的部分。help
部分是可选的,但通常是一个好主意,因为它起到了自我文档的作用。
通过编辑package/Config.in
并引用配置文件,如下所示,将新包链接到目标包菜单中:
menu "My programs"
source "package/helloworld/Config.in"
endmenu
你可以将这个新的helloworld
包附加到现有的子菜单中,但创建一个只包含我们包的新子菜单,并将其插入到音频和视频应用菜单之前会更加简洁。
在将我的程序菜单插入到package/Config.in
后,创建一个package/helloworld/helloworld.mk
文件,以提供 Buildroot 所需的数据:
HELLOWORLD_VERSION = 1.0.0
HELLOWORLD_SITE = /home/frank/MELD/Chapter06/helloworld
HELLOWORLD_SITE_METHOD = local
define HELLOWORLD_BUILD_CMDS
$(MAKE) CC="$(TARGET_CC)" LD="$(TARGET_LD)" -C $(@D) all
endef
define HELLOWORLD_INSTALL_TARGET_CMDS
$(INSTALL) -D -m 0755 $(@D)/helloworld $(TARGET_DIR)/usr/bin/helloworld
endef
$(eval $(generic-package))
你可以在书籍的代码档案中找到我的helloworld
包,路径是MELD/Chapter06/buildroot/package/helloworld
,程序的源代码位于MELD/Chapter06/helloworld
。代码的位置是硬编码的本地路径名称。
在更现实的情况下,你会从源代码系统或某种中央服务器获取代码。有关如何操作的详细信息可以在Buildroot 用户手册中找到,其他包中也有大量示例。
许可证合规性
Buildroot 基于开源软件,所编译的包也是如此。在项目的某个阶段,你应该通过运行以下命令检查许可证:
$ make legal-info
许可信息会被收集到output/legal-info
目录中。用于编译主机工具的许可证摘要保存在host-manifest.csv
中,目标系统上的摘要保存在manifest.csv
中。更多信息请参考README
文件和Buildroot 用户手册。
现在,让我们切换构建系统,开始学习 Yocto 项目。
介绍 Yocto 项目
Yocto 项目比 Buildroot 更加复杂。它不仅可以构建工具链、引导加载程序、内核和根文件系统,还能为你生成一个完整的 Linux 发行版,其中的二进制包可以在运行时安装。构建过程围绕着一组使用 Python 和 Shell 脚本编写的配方进行。Yocto 项目包括一个名为BitBake的任务调度器,它根据配方生成你所配置的内容。有关更多在线文档,请访问www.yoctoproject.org/
。
背景
如果先了解背景,Yocto 项目的结构就更加清晰。它的根源在于OpenEmbedded(openembedded.org
),OpenEmbedded 源自多个项目,旨在将 Linux 移植到各种手持计算机上,包括 Sharp Zaurus 和 Compaq iPAQ。OpenEmbedded 于 2003 年诞生,作为这些手持计算机的构建系统。此后,其他开发者开始将它作为运行嵌入式 Linux 设备的通用构建系统。OpenEmbedded 由一个充满热情的程序员社区开发并持续发展。
OpenEmbedded 项目的目标是使用紧凑的 IPK 格式创建一组二进制包。这些包可以在运行时安装到目标系统上,以创建各种系统。它通过为每个包创建配方,并使用 BitBake 作为任务调度器来实现这一点。OpenEmbedded 非常灵活。通过提供正确的元数据,你可以根据自己的需求创建一个完整的 Linux 发行版。
2005 年,时任 OpenedHand 开发者的 Richard Purdie 创建了一个 OpenEmbedded 的分支,选择了更加保守的包,并创建了在一段时间内稳定的版本。他将其命名为Poky(发音类似 hockey),以一种日本小吃命名。虽然 Poky 是一个分支,但 OpenEmbedded 和 Poky 依然保持同步,分享更新并保持架构一致。英特尔在 2008 年收购了 OpenedHand,并于 2010 年将 Poky 移交给 Linux 基金会,成立了 Yocto 项目。
自 2010 年以来,OpenEmbedded 和 Poky 的通用组件已合并为一个名为 OpenEmbedded Core 或简称 OE-Core 的独立项目。
Yocto 项目汇集了多个组件,其中最重要的包括:
-
OE-Core:是与 OpenEmbedded 共享的核心元数据。
-
BitBake:是与 OpenEmbedded 和其他项目共享的任务调度器。
-
Poky:是参考发行版。Poky 的 Git 仓库还包括一个带有参考硬件机器的
meta-yocto-bsp
层。 -
文档:每个组件的用户手册和开发者指南。
-
Toaster:是一个基于 Web 的 BitBake 和其元数据的接口。
Yocto 提供了一个稳定的基础,可以按原样使用,也可以使用 meta 层 扩展,我将在本章稍后讨论。许多 SoC 厂商通过这种方式为其设备提供 BSP。Meta 层还可以用来创建扩展的或不同的构建系统。有些是开源的,比如 Poky,其他的是商业的,比如 Wind River Linux。Yocto 有一个品牌和兼容性测试方案,确保组件之间的互操作性。你会在各种网页上看到像“Yocto Project compatible”这样的声明。
因此,你应该将 Yocto 视为嵌入式 Linux 领域的基础,除了它本身作为一个完整的构建系统。
重要说明
你可能会好奇这个名字的由来。事实上,yocto 是 SI 前缀,表示 10^(-24),就像 micro 表示 10^(-6) 一样。为什么选择 Yocto 这个名字?这个名字部分是为了表示它能够构建非常小的 Linux 系统(尽管公平地说,其他构建系统也可以做到)。它也是对已经停用的 Ångström 分发版的一个讽刺,该分发版基于 OpenEmbedded。一个 Ångström 是 10¹⁰,与 yocto 相比,差距巨大!
稳定发布和支持
通常,Yocto 每六个月发布一次:分别在四月和十月。它们通常以代号为人所知,但了解它们的 Yocto 和 BitBake 版本号也很有用。以下是本文撰写时六个最新版本的表格:
代号 | 发布日期 | Yocto 版本 | BitBake 版本 |
---|---|---|---|
Scarthgap | 2024 年 4 月 | 5.0 | 2.8 |
Nanbield | 2023 年 11 月 | 4.3 | 2.6 |
Mickledore | 2023 年 5 月 | 4.2 | 2.4 |
Langdale | 2022 年 10 月 | 4.1 | 2.2 |
Kirkstone | 2022 年 5 月 | 4.0 | 2.0 |
Honister | 2021 年 10 月 | 3.4 | 1.52 |
表 6.1 – Yocto 的六个最新版本
稳定版本会在当前发布周期及下一个周期内提供安全性和关键性漏洞修复。换句话说,每个稳定版本会在发布后大约 12 个月内获得支持。除了稳定版本,Yocto 还提供 LTS(长期支持)版本。2020 年 4 月发布的 Yocto 3.1(dunfell)是第一个 LTS 版本。LTS 标识意味着该版本会获得缺陷修复和更新,支持周期为两年。因此,未来的计划是每两年选择一个 Yocto 的 LTS 版本。
与 Buildroot 类似,如果你希望继续获得支持,可以更新到下一个稳定版本,或者将更改回移植到当前版本。对于 Yocto,你还可以选择来自操作系统供应商(如西门子和 Wind River)的商业支持,通常支持几年。
安装 Yocto 项目
要获取 Yocto 项目的副本,克隆仓库时选择代号(此处为scarthgap
)作为分支:
$ git clone -b scarthgap git://git.yoctoproject.org/poky.git
由于我们要为 BeaglePlay 构建镜像,我们还需要克隆meta-ti
仓库:
$ git clone -b scarthgap https://github.com/TexasInstruments-Sandbox/meta-ti
由于meta-ti-bsp
层依赖于meta-arm
层,因此我们必须正确克隆该仓库:
$ git clone -b scarthgap git://git.yoctoproject.org/meta-arm
请注意,meta-ti
和meta-arm
分支名称必须与 Yocto 的代号匹配,以确保这些附加层与 scarthgap 版本的 Yocto 兼容。定期运行git pull
来获取所有远程分支的最新漏洞修复和安全补丁也是一个好习惯。
阅读兼容的 Linux 发行版和构建主机包部分,详见Yocto 项目快速构建指南(docs.yoctoproject.org/brief-yoctoprojectqs/
)。确保在主机计算机上安装了你的 Ubuntu 主机发行版所需的基本包。下一步是进行配置。
重要提示
在撰写本文时,Ubuntu 24.04 LTS(Noble Numbat)尚未得到 Yocto 项目的官方支持。在 Noble Numbat 发布后,用户遇到了与权限相关的许多 BitBake 错误。这些错误是由于 AppArmor 对操作系统施加了更严格的安全限制所致。要暂时禁用这些 AppArmor 保护:
$ echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns
请记住,每次重启 Ubuntu 主机后,在使用 Yocto 之前都需要重新运行此命令。
配置
让我们从构建 64 位 Arm QEMU 模拟器开始。首先通过运行脚本来设置环境:
$ source poky/oe-init-build-env
这将为你创建一个名为build
的工作目录,并将其设为当前目录。所有的配置文件、中间文件和目标镜像文件都将存放在此目录中。每次想要继续该项目时,必须重新运行该脚本。
要选择不同的工作目录,可以将其作为参数添加到oe-init-build-env
,如下所示:
$ source poky/oe-init-build-env build-qemu-arm64
这将把您带到 build-qemu-arm64
目录。通过这种方式,您可以拥有多个构建目录,每个目录用于不同的项目。您可以通过传递给 oe-init-build-env
的参数选择要使用的目录。
最初,build
目录只包含一个名为 conf
的子目录,其中包含此项目的配置文件:
-
local.conf
:包含您将要构建的设备的规格和构建环境。 -
bblayers.conf
:包含您将要使用的元层的路径。我稍后会描述这些层。
目前,我们只需要通过去掉这一行开头的注释字符 (#),在 conf/local.conf
中设置 MACHINE
变量为 qemuarm64
:
MACHINE ?= "qemuarm64"
现在我们已经准备好使用 Yocto 构建我们的第一个镜像。
构建中
要执行构建,您需要运行 BitBake 并告诉它您想要创建的根文件系统镜像。一些常见的镜像包括:
-
core-image-minimal
:是一个小型的基于控制台的系统,适用于测试以及作为自定义镜像的基础。 -
core-image-minimal-initramfs
:类似于core-image-minimal
,但构建为 RAM 磁盘。 -
core-image-x11
:是一个基本镜像,通过 X11 服务器和 XTerminal 终端应用提供图形支持。 -
core-image-full-cmdline
:是一个基于控制台的系统,提供标准的 CLI 体验,并完全支持目标硬件。
通过给 BitBake 最终目标,它将从后向前工作,并构建所有依赖项,从工具链开始。目前,我们只想创建一个最小的镜像,以查看它是如何工作的:
$ bitbake core-image-minimal
第一次构建可能需要一些时间(即使有多个 CPU 核心和大量内存,可能也超过一个小时)。它将下载大约 4.9 GB 的源代码,并消耗大约 49 GB 的磁盘空间。当构建完成时,您将在 build
目录下找到几个新目录。
这些包括 downloads
,它包含构建所需的所有源文件,以及 tmp
,它包含大部分构建产物。在 tmp
目录下,您会找到以下内容:
-
work
:包含构建目录和根文件系统的暂存区域。 -
deploy
:包含要部署到目标的最终二进制文件: -
deploy/images/<machine name>
:包含目标的引导加载程序、内核和根文件系统镜像。 -
deploy/rpm
:包含构成镜像的 RPM 软件包。 -
deploy/licenses
:包含从每个软件包中提取的许可证文件。
当构建完成后,我们可以在 QEMU 上启动完成的镜像。
运行 QEMU 目标
当您构建 QEMU 目标时,会生成一个 QEMU 的内部版本。这免去了您为您的发行版安装 QEMU 包的需求。还有一个名为 runqemu
的包装脚本,用来运行这个内部版本的 QEMU。
要运行 QEMU 仿真,确保首先运行 source oe-init-build-env build-qemu-arm64
,然后输入:
$ runqemu qemuarm64
在这种情况下,QEMU 已配置为使用图形控制台,因此登录提示将出现在黑色帧缓冲区中。以 root 身份登录,无需密码。关闭帧缓冲窗口以退出 QEMU。
要在没有图形窗口的情况下启动 QEMU,请在命令行中添加nographic
:
$ runqemu qemuarm64 nographic
在nographic
模式下,使用键序列Ctrl + A然后按x来关闭 QEMU。
runqemu
脚本有许多其他选项。键入runqemu help
以获取更多信息。
层
Yocto 元数据是按层结构组织的。每个层都是一个包含 BitBake 元数据的目录,元数据以食谱文件的形式存在。每个食谱文件用于构建一个单独的软件包。这些层叠加在一起,构建或“烘焙”所有软件食谱,最终生成一个完整的 Linux 镜像,就像烘焙一个蛋糕一样。根据约定,每个层的名称都以meta
开头。核心层包括:
-
meta
: 相当于一个未修改的 OpenEmbedded 核心。 -
meta-poky
: 是特定于 Poky 发行版的元数据。 -
meta-yocto-bsp
: 包含 Yocto 定期测试的参考机器的 BSP。
BitBake 搜索食谱的层列表存储在<your build directory>/conf/bblayers.conf
中,默认情况下包括前面列出的所有三个层。
以这种方式构建食谱和其他配置数据使得通过添加新层来扩展 Yocto 变得非常容易。额外的层可以从 SoC 制造商、Yocto 项目本身以及一大批希望为 Yocto 和 OpenEmbedded 增值的人们那里获得。这里有一个有用的层列表:layers.openembedded.org/layerindex/
。以下是一些示例:
-
meta-qt5
: Qt 5 库和工具。 -
meta-intel
: 为 Intel CPU 和 SoC 提供的 BSP。 -
meta-raspberrypi
: 为 Raspberry Pi 开发板提供的 BSP。 -
meta-ti
: 为 TI 基于 Arm 的 SoC 提供的 BSP。
添加一个层就像是将 meta 目录复制到合适的位置,并将其添加到bblayers.conf
中一样简单。确保阅读每个层应附带的README
文件,以了解该层对其他层的依赖关系以及与哪个版本的 Yocto 兼容。
为了说明层是如何工作的,假设我们为 Nova 开发板创建一个层,在本章后续部分中,我们将使用这个层来添加新特性。你可以在代码档案中的MELD/Chapter06/meta-nova
下看到该层的完整实现。
每个 meta 层必须至少有一个名为conf/layer.conf
的配置文件,并且还应该有一个README
文件和一个许可证。
要创建我们的meta-nova
层,请执行以下步骤:
$ source poky/oe-init-build-env build-nova
$ bitbake-layers create-layer nova
$ mv nova ../meta-nova
这将把你放入一个名为build-nova
的工作目录,并在../meta-nova
下创建一个名为meta-nova
的层,其中包含一个conf/layer.conf
文件、一个大致的README
文件和一个COPYING.MIT
许可证。layer.conf
文件如下所示:
# We have a conf and classes directory, add to BBPATH
BBPATH .= ":${LAYERDIR}"
# We have recipes-* directories, add to BBFILES
BBFILES += "${LAYERDIR}/recipes-*/*/*.bb \
${LAYERDIR}/recipes-*/*/*.bbappend"
BBFILE_COLLECTIONS += "nova"
BBFILE_PATTERN_nova = "^${LAYERDIR}/"
BBFILE_PRIORITY_nova = "6"
LAYERDEPENDS_nova = "core"
LAYERSERIES_COMPAT_nova = "scarthgap"
层将自身添加到 BBPATH
中,并将其中包含的配方添加到 BBFILES
中。从代码中可以看到,配方位于以 recipes-
开头、文件名以 .bb
(普通 BitBake 配方)或 .bbappend
(扩展现有配方,通过覆盖或添加指令的配方)结尾的目录中。这个层的名称是 nova
,并以优先级 6
被添加到 BBFILE_COLLECTIONS
的层列表中。层的优先级用于当同一配方出现在多个层时,优先级最高的层中的配方会生效。
在添加 Nova 层之前,我们必须先按照严格顺序添加 meta-arm-toolchain
、meta-arm
和 meta-ti-bsp
层:
$ bitbake-layers add-layer ../meta-arm/meta-arm-toolchain
$ bitbake-layers add-layer ../meta-arm/meta-arm
$ bitbake-layers add-layer ../meta-ti/meta-ti-bsp
现在将 Nova 层添加到你的构建配置中:
$ bitbake-layers add-layer ../meta-nova
在从环境中加载后,确保从你的 build-nova
工作目录运行所有这些 bitbake-layers add-layer
命令。
确认你的层结构已正确设置,如下所示:
$ 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
nova /home/frank/meta-nova 6
在这里,你可以看到新添加的层。由于它的优先级是 6
,它可以覆盖所有优先级较低的其他层中的配方。
使用这个空层进行构建。最终目标将是 Nova 板,但现在先通过在 conf/local.conf
中添加 MACHINE ?= "beagleplay-ti"
来为 BeaglePlay 构建。然后像之前一样使用 bitbake core-image-minimal
构建一个小镜像。
除了配方,层还可以包含 BitBake 类、配置文件、发行版等内容。接下来我将讲解配方,并展示如何创建自定义镜像和包。
BitBake 和配方
BitBake 处理几种不同类型的元数据:
-
配方(以
.bb
结尾的文件):包含有关构建软件单元的信息,包括如何获取源代码副本、对其他组件的依赖关系以及如何构建和安装它。 -
附加(以
.bbappend
结尾的文件):覆盖或扩展配方的某些细节。.bbappend
文件将其指令附加到与之具有相同根名称的配方(.bb
)文件的末尾。 -
包含(以
.inc
结尾的文件):包含多个配方共享的信息,使它们能够共享信息。可以使用 include 或 require 关键字包含这些文件。区别在于,如果文件不存在,require
会产生错误,而include
不会。 -
类(以
.bbclass
结尾的文件):包含一些常见的构建信息,例如如何构建内核或如何构建 Autotools 项目。类通过inherit
关键字被配方和其他类继承。类classes/base.bbclass
会被每个配方隐式继承。 -
配置(以
.conf
结尾的文件):定义控制项目构建过程的各种配置变量。
配方是一个任务集合,任务内容使用 Python 和 Shell 脚本的组合编写。这些任务有诸如do_fetch
、do_unpack
、do_patch
、do_configure
、do_compile
和do_install
等名称。你使用 BitBake 来执行这些任务。默认任务是do_build
,它执行构建配方所需的所有子任务。你可以使用bitbake -c listtasks <recipe>
列出配方中可用的任务。例如,要列出core-image-minimal
中的任务:
$ bitbake -c listtasks core-image-minimal
重要提示
-c
选项告诉 BitBake 运行配方中的特定任务,而无需在任务名称的开头包含do_
部分。
do_listtasks
是一个特殊的任务,用于列出配方中定义的所有任务。这里是fetch
任务,它用于下载配方的源代码:
$ bitbake -c fetch busybox
要获取目标及其所有依赖项的代码(当你想确保已下载了即将构建的镜像的所有代码时,这很有用),请使用以下命令:
$ bitbake core-image-minimal --runall=fetch
配方文件通常命名为<package-name>_<version>.bb
。它们可能依赖于其他配方,这样 BitBake 可以计算出完成顶级任务所需执行的所有子任务。
要为我们在meta-nova
中的helloworld
程序创建一个配方,你需要创建如下的目录结构:
meta-nova/recipes-local/helloworld
├── files
│ └── helloworld.c
└── helloworld_1.0.bb
配方是helloworld_1.0.bb
,源代码保存在配方所在目录的files
子目录中。该配方包含以下指令:
DESCRIPTION = "A friendly program that prints Hello World!"
SECTION = "examples"
LICENSE = "GPL-2.0-only"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/GPL-2.0-only;md5=801f80980d171dd6425610833a22dbe6"
SRC_URI = "file://helloworld.c"
S = "${WORKDIR}"
do_compile() {
${CC} ${CFLAGS} ${LDFLAGS} helloworld.c -o helloworld
}
do_install() {
install -d ${D}${bindir}
install -m 0755 helloworld ${D}${bindir}
}
源代码的位置由SRC_URI
设置。在这种情况下,file://
URI 意味着代码是本地的,位于配方目录中。BitBake 将相对于包含配方的目录搜索files
、helloworld
和helloworld-1.0
目录。需要定义的任务是do_compile
和do_install
,它们分别将源文件编译并安装到目标根文件系统中:${D}
表示配方的暂存区,而${bindir}
表示默认的/usr/bin
二进制目录。
每个配方都有一个由LICENSE
定义的许可证,这里设置为GPL-2.0-only
。包含许可证文本及其校验和的文件由LIC_FILES_CHKSUM
定义。如果校验和不匹配,BitBake 将终止构建,表示许可证已发生变化。请注意,MD5 校验和值和COMMON_LICENSE_DIR
在同一行,且由分号分隔。许可证文件可能是包的一部分,或者可能指向meta/files/common-licenses
中的标准许可证文本,正如这里所示。
商业许可证默认不允许,但可以很容易地启用它们。你需要在配方中指定许可证,如下所示:
LICENSE_FLAGS = "commercial"
然后,在你的conf/local.conf
中,明确允许此许可证,如下所示:
LICENSE_FLAGS_ACCEPTED = "commercial"
为了确保我们的helloworld
配方正确编译,可以让 BitBake 构建它:
$ bitbake helloworld
如果一切顺利,你应该会看到它在tmp/work/aarch64-poky-linux/helloworld
目录下创建了一个工作目录。你还应该能看到在tmp/deploy/rpm/aarch64/helloworld-1.0-r0.aarch64.rpm
中为它创建了一个 RPM 包。
包尚未成为目标镜像的一部分。待安装包的列表保存在一个名为IMAGE_INSTALL
的变量中。你可以通过将以下行添加到conf/local.conf
来将它追加到该列表的末尾:
IMAGE_INSTALL:append = " helloworld"
注意,开头的双引号和第一个包名之间需要有一个空格。现在该包将被添加到你bitbake
的任何镜像中:
$ bitbake core-image-minimal
如果你查看deploy-ti/images/beagleplay-ti/core-image-minimal-beagleplay-ti.rootfs.tar.xz
,你会看到/usr/bin/helloworld
确实已经被安装。
通过local.conf
自定义镜像
在开发过程中,你可能经常需要向镜像中添加包或以其他方式调整它。正如我们刚刚看到的,你可以通过添加类似以下的语句,将包简单地追加到安装包列表中:
IMAGE_INSTALL:append = " helloworld"
你可以通过EXTRA_IMAGE_FEATURES
做更多广泛的更改。以下是一个简短的列表,应该能给你提供启用功能的想法:
-
dbg-pkgs
:为镜像中安装的所有包安装调试符号包。 -
debug-tweaks
:允许root
用户无密码登录,并进行其他开发便利的更改。在生产镜像中绝对不要启用debug-tweaks
。 -
package-management
:安装包管理工具并保留包管理器数据库。 -
read-only-rootfs
:使根文件系统为只读。我们将在第九章中详细讲解这一点。 -
x11
:安装 X 服务器。 -
x11-base
:安装带有最小环境的 X 服务器。
你可以通过这种方式添加更多功能。我建议你查看Yocto 项目参考手册中的镜像特性部分:docs.yoctoproject.org/ref-manual/
,并阅读meta/classes-recipe/core-image.bbclass
中的代码。
编写镜像配方
修改local.conf
的问题在于它是局部的。如果你想创建一个可以与其他开发者共享或加载到生产系统上的镜像,那么你应该将更改放在镜像配方中。
一个镜像配方包含了如何为目标创建镜像文件的指令,包括引导加载程序、内核和根文件系统镜像。按照约定,镜像配方通常放在名为images
的目录中。你可以通过扫描poky
目录和任何你克隆的附加层来获取所有可用镜像的列表:
$ cd ~
$ ls poky/meta*/recipes*/images/*.bb
$ ls meta*/recipes*/images/*.bb
你会发现core-image-minimal
的配方位于poky
/meta/recipes-core/images/core-image-minimal.bb
。
一种简单的方法是采用现有的镜像配方,并使用像你在local.conf
中使用的语句进行修改。
假设你想要一个与 core-image-minimal
相同的镜像,但包括你的 helloworld
程序和 strace
工具。你可以通过一个两行的配方文件来实现这一点,文件中包含(使用 require
关键字)基本镜像并添加你需要的包。通常将镜像放在名为 images
的目录中,所以在 meta-nova/recipes-local/images
中添加配方 nova-image.bb
,内容如下:
require recipes-core/images/core-image-minimal.bb
IMAGE_INSTALL:append = " helloworld strace"
现在从你的 local.conf
文件中移除 IMAGE_INSTALL:append
行,并构建镜像:
$ bitbake nova-image
这次,构建应该会更快,因为 BitBake 重用了之前构建过程中生成的中间构建对象。
BitBake 不仅可以为目标设备构建镜像,还可以为主机机器构建 SDK,以进行交叉开发。
创建 SDK
创建一个可以供其他开发人员安装的独立工具链非常有用。这样可以避免团队中的每个人都必须安装完整的 Yocto。理想情况下,你希望工具链包含目标设备上安装的所有库的开发库和头文件。你可以使用populate_sdk
任务为任何镜像实现这一点,如下所示:
$ bitbake -c populate_sdk nova-image
结果是一个自安装的 shell 脚本,位于 deploy-ti/sdk
目录下:
poky-<c_library>-<host_machine>-<target_image>-<target_machine>-toolchain-<version>.sh
对于使用nova-image
配方构建的 SDK:
poky-glibc-x86_64-nova-image-aarch64-beagleplay-toolchain-<version>.sh
如果你只需要一个基本工具链,包含 C 和 C++ 交叉编译器、C 库和头文件,那么可以改用这个命令:
$ bitbake meta-toolchain
安装 SDK,只需运行该 shell 脚本。默认的安装目录是/opt/poky
,但安装脚本允许你更改此目录:
$ deploy-ti/sdk/poky-glibc-x86_64-nova-image-aarch64-beagleplay-toolchain-5.0.3.sh
Poky (Yocto Project Reference Distro) SDK installer version 5.0.3
=================================================================
Enter target directory for SDK (default: /opt/poky/5.0.3):
You are about to install the SDK to "/opt/poky/5.0.3". Proceed [Y/n]? Y
[sudo] password for frank:
Extracting SDK................................................................................................................done
Setting it up...done
SDK has been successfully set up and is ready to be used.
Each time you wish to use the SDK in a new shell session, you need to source the environment setup script e.g.
$ . /opt/poky/5.0.3/environment-setup-aarch64-poky-linux
为了使用工具链,首先源化环境并设置脚本:
$ source /opt/poky/<version>/environment-setup-aarch64-poky-linux
提示
设置 SDK 环境的environment-setup-*
脚本与在 Yocto 构建目录中使用的oe-init-build-env
脚本不兼容。在执行任何一个脚本之前,最好先开启一个新的终端会话。
Yocto 项目生成的工具链没有有效的 sysroot
目录。我们知道这一点,因为将-print-sysroot
选项传递给工具链的编译器时返回的是/not/exist
:
$ aarch64-poky-linux-gcc -print-sysroot
/not/exist
因此,如果你尝试交叉编译,它会像这样失败:
$ aarch64-poky-linux-gcc helloworld.c -o helloworld
helloworld.c:1:10: fatal error: stdio.h: No such file or directory
1 | #include <stdio.h>
| ^~~~~~~~~
compilation terminated.
这是因为编译器已经配置为支持多种 Arm 处理器,并且微调是在使用正确的标志启动编译器时完成的。相反,你应该使用在执行 environment-setup
脚本时创建的 shell 变量来进行交叉编译。这些变量包括:
-
CC
: C 编译器 -
CXX
: C++ 编译器 -
CPP
: C 预处理器 -
AS
: 汇编器 -
LD
: 链接器
这是我们发现 CC
被设置为的值:
$ echo $CC
aarch64-poky-linux-gcc -mbranch-protection=standard -fstack-protector-strong -O2 -D_FORTIFY_SOURCE=2 -Wformat -Wformat-security -Werror=format-security --sysroot=/opt/poky/5.0.3/sysroots/aarch64-poky-linux
只要使用$CC
进行编译,应该一切正常:
$ $CC -O helloworld.c -o helloworld
许可证审计
Yocto 项目要求每个软件包都有一个许可证。每个软件包在构建时都会将许可证副本放置在 tmp/deploy/licenses/<package name>
中。此外,镜像中使用的所有软件包和许可证的摘要会被放入目录:<image name>-<machine name>.rootfs-<date stamp>
。对于我们刚刚构建的 nova-image
,该目录将命名为类似以下的名称:
tmp/deploy/licenses/beagleplay/nova-image-beagleplay.rootfs-20241012221506
这完成了我们对两种主流嵌入式 Linux 构建系统的概述。Buildroot 简单且快速,非常适合用于简单的单一用途设备。Yocto 更加复杂和灵活。尽管 Yocto 在社区和行业中得到了良好的支持,但这个工具仍然有一个非常陡峭的学习曲线。你可以预期,成为 Yocto 的熟练用户可能需要几个月的时间,甚至即便如此,它有时也会做出一些让你吃惊的事情。
总结
在本章中,你学习了如何使用 Buildroot 和 Yocto 项目来配置、定制和构建嵌入式 Linux 镜像。我们使用 Buildroot 创建了一个 BSP,其中包括自定义的 U-Boot 补丁和针对假设基于 BeaglePlay 的板子的设备树规格。接着,我们学习了如何以 Buildroot 包的形式将自己的代码添加到镜像中。你还接触了 Yocto 项目,我们将在接下来的两章中深入探讨。特别是,你学习了一些基本的 BitBake 术语,如何编写镜像配方,以及如何创建 SDK。
不要忘记,使用这些工具创建的任何设备都需要在现场维护一段时间,通常是多年。Yocto 和 Buildroot 都会在初始发布后的大约一年内提供点发布版本,且 Yocto 现在至少提供四年的长期支持。在这两种情况下,你都会发现需要自己维护版本或支付商业支持费用。第三种可能性,忽视问题,绝不是一个选项!
进一步学习
-
Buildroot 用户手册,Buildroot 协会 –
buildroot.org/downloads/manual/manual.html
-
Yocto 项目文档,Yocto 项目 –
docs.yoctoproject.org/
第七章:使用 Yocto 开发
在不支持的硬件上启动 Linux 可能是一个繁琐的过程。幸运的是,Yocto 提供了板级支持包(BSPs),可以帮助我们在 BeaglePlay 和 Raspberry Pi 4 等流行单板计算机上快速启动嵌入式 Linux 开发。基于现有的 BSP 层进行构建,让我们能够迅速利用复杂的内建外设,如蓝牙和 Wi-Fi。在本章中,我们将创建一个自定义应用层来实现这一目标。
接下来,我们将查看 Yocto 可扩展 SDK 启用的开发工作流。在目标设备上修改软件通常意味着需要更换 microSD 卡。由于重新构建和重新部署完整的镜像太过耗时,我将向你展示如何使用 devtool
快速自动化并反复测试你的工作。在此过程中,你还将学习如何将你的工作保存到自己的层中,避免工作丢失。
Yocto 不仅构建 Linux 镜像,还能构建整个 Linux 发行版。在我们动手构建自己的 Linux 发行版之前,我们将先讨论这样做的原因。我们所做的许多选择包括是否添加运行时软件包管理,以便在目标设备上进行快速应用开发。这需要维护软件包数据库和远程软件包服务器,我将在最后提到这一点。
本章我们将涵盖以下主题:
-
在现有 BSP 上构建
-
使用
devtool
捕获变更 -
构建你自己的发行版
-
配置远程软件包服务器
技术要求
为了跟随示例进行操作,请确保你具备以下设备:
-
一台运行 Ubuntu 24.04 或更高版本 LTS 的主机系统,并且至少有 90 GB 的可用磁盘空间
-
Yocto 5.0 (scarthgap) LTS 版本
-
一台 microSD 卡读卡器和卡
-
适用于 Linux 的 balenaEtcher
-
一根以太网线和一台带有可用端口的路由器,用于网络连接
-
一台 Wi-Fi 路由器
-
一部支持蓝牙的智能手机
-
Raspberry Pi 4
-
一台能够提供 3A 电流的 5V USB-C 电源
你应该已经在 第六章 中构建了 Yocto 的 5.0 (scarthgap) LTS 版本。如果还没有,请先参考 兼容的 Linux 发行版 和 构建主机软件包 部分,查阅 Yocto 项目快速构建 指南 (docs.yoctoproject.org/brief-yoctoprojectqs/)
,然后按照 第六章 中的说明在你的 Linux 主机上构建 Yocto。
本章中使用的代码可以在本书的 GitHub 仓库的章节文件夹中找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development
。
在现有 BSP 上构建
一个 BSP 层为 Yocto 添加对特定硬件设备或设备系列的支持。这个支持通常包括引导加载程序、设备树二进制文件以及启动 Linux 所需的额外内核驱动程序。BSP 还可能包含任何附加的用户空间软件和外设固件,以充分启用和利用硬件的所有功能。按照惯例,BSP 层的名称以 meta-
前缀开始,后跟机器名称。找到适合你目标设备的最佳 BSP 是使用 Yocto 构建可启动镜像的第一步。
OpenEmbedded 层索引 (layers.openembedded.org/layerindex)
) 是开始查找优质 BSP 的最佳地方。你的板卡制造商或硅片供应商也可能提供 BSP 层。Yocto 项目为所有 Raspberry Pi 变种提供了一个 BSP。你可以在项目的源代码库中找到该 BSP 层及 Yocto 项目认可的所有其他层的 Git 仓库 (git.yoctoproject.org
)。
构建现有的 BSP
以下练习假设你已经将 Yocto 的 scarthgap 版本克隆或解压到主机环境中名为 poky
的目录。在继续之前,我们还需要从 poky
目录向上克隆以下依赖层,这样 layer
和 poky
目录就能并排放置:
$ git clone -b scarthgap git://git.openembedded.org/meta-openembedded
$ git clone -b scarthgap git://git.yoctoproject.org/meta-raspberrypi
请注意,依赖层的分支名称与 Yocto 版本匹配以确保兼容性。使用定期的 git pull
命令保持所有三个克隆与远程仓库同步并保持最新。meta-raspberrypi
层是所有 Raspberry Pi 的 BSP。一旦这些依赖关系就位,你可以构建一个为 Raspberry Pi 4 定制的镜像。但在此之前,让我们先看看 Yocto 的通用镜像配方:
-
首先,导航到你克隆 Yocto 的目录:
$ cd poky
-
接下来,进入包含标准镜像配方的目录:
$ cd meta/recipes-core/images
-
列出核心镜像配方:
$ ls -1 core* core-image-base.bb core-image-initramfs-boot.bb core-image-minimal.bb core-image-minimal-dev.bb core-image-minimal-initramfs.bb core-image-minimal-mtdutils.bb core-image-ptest-all.bb core-image-ptest.bb core-image-ptest-fast.bb core-image-tiny-initramfs.bb
-
显示
core-image-base
配方:$ cat core-image-base.bb SUMMARY = "A console-only image that fully supports the target device \ hardware." IMAGE_FEATURES += "splash" LICENSE = "MIT" inherit core-image
-
请注意,这个配方继承自
core-image
,因此它导入了core-image.bbclass
的内容,我们稍后将查看它。 -
显示
core-image-minimal
配方:$ cat core-image-minimal.bb SUMMARY = "A small image just capable of allowing a device to boot." IMAGE_INSTALL = "packagegroup-core-boot ${CORE_IMAGE_EXTRA_INSTALL}" IMAGE_LINGUAS = " " LICENSE = "MIT" inherit core-image IMAGE_ROOTFS_SIZE ?= "8192" IMAGE_ROOTFS_EXTRA_SPACE:append = "${@bb.utils.contains("DISTRO_FEATURES", "systemd", " + 4096", "", d)}"
-
与
core-image-base
一样,这个配方也继承自core-image
类文件。 -
显示
core-image-minimal-dev
配方:$ cat core-image-minimal-dev.bb require core-image-minimal.bb DESCRIPTION = "A small image just capable of allowing a device to boot and \ is suitable for development work." IMAGE_FEATURES += "dev-pkgs"
-
导航到 poky/meta 下的 classes 目录:
$ cd ../../classes-recipe
-
最后,显示
core-image
类文件:$ cat core-image.bbclass
-
请注意,在这个类文件的顶部列出了大量可用的
IMAGE_FEATURES
,包括前面提到的dev-pkgs
特性。
标准镜像,如 core-image-minimal
和 core-image-minimal-dev
,是机器无关的。在 第六章 中,我们为 QEMU Arm 模拟器和 BeaglePlay 构建了 core-image-minimal
。我们本可以同样为树莓派 4 构建一个 core-image-minimal
镜像。相比之下,BSP 层包括为特定板或一系列板设计的镜像配方。
现在,让我们看看 meta-rasberrypi
BSP 层中的 rpi-test-image
配方,了解如何将 Wi-Fi 和蓝牙的支持添加到树莓派 4 的 core-image-base
中:
-
首先,导航到克隆 Yocto 的目录上一级:
$ cd ../../..
-
接下来,进入
meta-raspberrypi
BSP 层中存放树莓派镜像配方的目录:$ cd meta-raspberrypi/recipes-core/images
-
列出树莓派镜像配方:
$ ls -1 rpi-test-image.bb
-
显示
rpi-test-image
配方:$ cat rpi-test-image.bb # Base this image on core-image-base include recipes-core/images/core-image-base.bb COMPATIBLE_MACHINE = "^rpi$" IMAGE_INSTALL:append = " packagegroup-rpi-test"
-
注意,
IMAGE_INSTALL
变量已被覆盖,以便它可以附加packagegroup-rpi-test
并将这些包包含在镜像中。 -
导航到
metaraspberrypi/recipes-core
下相邻的packagegroups
目录:$ cd ../packagegroups
-
最后,显示
packagegroup-rpi-test
配方:$ cat packagegroup-rpi-test.bb DESCRIPTION = "RaspberryPi Test Packagegroup" LICENSE = "MIT" LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302" PACKAGE_ARCH = "${MACHINE_ARCH}" inherit packagegroup COMPATIBLE_MACHINE = "^rpi$" OMXPLAYER = "${@bb.utils.contains('MACHINE_FEATURES', 'vc4graphics', '', 'omxplayer', d)}" RDEPENDS:${PN} = "\ ${OMXPLAYER} \ bcm2835-tests \ raspi-gpio \ rpio \ rpi-gpio \ pi-blaster \ python3-adafruit-circuitpython-register \ python3-adafruit-platformdetect \ python3-adafruit-pureio \ python3-rtimu \ connman \ connman-client \ wireless-regdb-static \ bluez5 \ " RRECOMMENDS:${PN} = "\ ${@bb.utils.contains("BBFILE_COLLECTIONS", "meta-multimedia", "bigbuckbunny-1080p bigbuckbunny-480p bigbuckbunny-720p", "", d)} \ ${MACHINE_EXTRA_RRECOMMENDS} \ "
-
注意,
connman
、connman-client
和bluez5
包已包含在运行时依赖项列表中,以便完全启用 Wi-Fi 和蓝牙功能。
最后,为树莓派 4 构建 rpi-test-image
:
-
首先,导航到克隆 Yocto 的目录上一级:
$ cd ../../..
-
接下来,设置你的 BitBake 工作环境:
$ source poky/oe-init-build-env build-rpi
-
这会设置一堆环境变量并将你带入新创建的
build-rpi
目录。 -
然后,向你的镜像中添加以下层:
$ bitbake-layers add-layer ../meta-openembedded/meta-oe $ bitbake-layers add-layer ../meta-openembedded/meta-python $ bitbake-layers add-layer ../meta-openembedded/meta-networking $ bitbake-layers add-layer ../meta-openembedded/meta-multimedia $ bitbake-layers add-layer ../meta-raspberrypi
重要提示
添加这些层的顺序很重要,因为
meta-networking
和meta-multimedia
层都依赖于meta-python
层。如果bitbake-layers add-layer
或bitbake-layers show-layers
因解析错误而失败,请删除build-rpi
目录,并从 步骤 1 重新开始此练习。 -
验证所有必要的层是否已添加到镜像中:
$ bitbake-layers show-layers
-
命令的输出应如下所示:
layer path priority ================================================================== core /home/frank/poky/meta 5 yocto /home/frank/poky/meta-poky 5 yoctobsp /home/frank/poky/meta-yocto-bsp 5 openembedded-layer /home/frank/meta-openembedded/meta-oe 5 meta-python /home/frank/meta-openembedded/meta-python 5 networking-layer /home/frank/meta-openembedded/meta-networking 5 multimedia-layer /home/frank/meta-openembedded/meta-multimedia 5 raspberrypi /home/frank/meta-raspberrypi 9
-
观察前面
bitbake-layers add-layer
命令对bblayers.conf
所做的更改:$ cat conf/bblayers.conf
-
上一步中的相同八个层应分配给
BBLAYERS
变量。 -
列出
meta-raspberrypi
BSP 层支持的机器:$ ls ../meta-raspberrypi/conf/machine
-
注意,存在
raspberrypi4
和raspberrypi4-64
机器配置。 -
将以下行添加到你的
conf/local.conf
文件中:MACHINE = "raspberrypi4-64"
-
这将覆盖你
conf/local.conf
文件中的以下默认设置:MACHINE ??= "qemux86-64"
-
设置
MACHINE
变量为raspberrypi4-64
确保我们即将构建的镜像适用于树莓派 4。 -
将以下行添加到你的
conf/local.conf
文件中:LICENSE_FLAGS_ACCEPTED = "synaptics-killswitch"
-
这将抑制以下构建错误:
ERROR: Nothing RPROVIDES 'linux-firmware-rpidistro-bcm43455'
-
现在,将
ssh-server-openssh
添加到你conf/local.conf
文件中EXTRA_IMAGE_FEATURES
列表中:EXTRA_IMAGE_FEATURES ?= "debug-tweaks ssh-server-openssh"
-
这会在我们的镜像中添加一个 SSH 服务器,用于本地网络访问。
-
最后,构建镜像:
$ bitbake rpi-test-image
第一次构建可能需要几分钟到几小时,具体取决于主机环境可用的 CPU 核心数量。
TARGET_SYS
应该为aarch64-poky-linux
,而MACHINE
应该为raspberrypi4-64
,因为该映像是为 Raspberry Pi 4 中的 Arm Cortex-A72 核心的 64 位目标架构构建的。
一旦映像构建完成,在tmp/deploy/images/raspberrypi4-64
目录下应该会有一个名为rpi-test-image-raspberrypi4-64.rootfs.wic.bz2
的文件:
$ ls -l tmp/deploy/images/raspberrypi4-64/rpi-test*wic.bz2
注意到rpi-test-image-raspberrypi4-64.rootfs.wic.bz2
是一个符号链接,指向同一目录下的实际映像文件。在wic.bz2
扩展名之前,附加了表示构建日期和时间的整数。
现在,使用 Etcher 将该映像写入 microSD 卡并在你的 Raspberry Pi 4 上启动它:
-
将 microSD 卡插入主机计算机。
-
启动 Etcher。
-
在 Etcher 中点击Flash from file。
-
找到你为 Raspberry Pi 4 构建的
wic.bz2
映像并打开它。 -
在 Etcher 中点击Select target。
-
选择你在步骤 1中插入的 microSD 卡。
-
在 Etcher 中点击Flash,将映像写入 microSD 卡。
-
等 Etcher 完成烧录后,弹出 microSD 卡。
-
将 microSD 卡插入你的 Raspberry Pi 4。
-
通过 Raspberry Pi 4 的 USB-C 端口为其供电。
通过将 Raspberry Pi 4 连接到以太网,并观察网络活动指示灯是否闪烁,确认你的 Raspberry Pi 4 是否成功启动。
控制 Wi-Fi
在上一个练习中,我们为 Raspberry Pi 4 构建了一个可启动映像,其中包含了工作中的以太网、Wi-Fi 和蓝牙。现在设备已经启动并通过以太网连接到本地网络,我们来连接附近的 Wi-Fi 网络。我们将在本练习中使用connman
,因为这是meta-raspberrypi
层默认提供的工具。其他 BSP 层则依赖于不同的网络接口配置守护进程,例如systemd-networkd
和NetworkManager
。请按照以下步骤操作:
-
我们构建的映像主机名为
raspberrypi4-64
,因此你应该能够以 root 用户通过 SSH 连接到设备:$ ssh root@raspberrypi4-64.local
-
当询问是否继续连接时,输入
yes
。你不会被提示输入密码。如果没有在raspberrypi4-64.local
找到主机,可以使用arp-scan
等工具定位你的 Raspberry Pi 4 的 IP 地址,然后通过该 IP 地址 SSH 连接,而不是通过主机名连接。 -
一旦进入,验证 Wi-Fi 驱动是否已加载:
root@raspberrypi4-64:~# lsmod | grep 80211 cfg80211 753664 1 brcmfmac rfkill 32768 6 nfc,bluetooth,cfg80211
-
启动
connman-client
:root@raspberrypi4-64:~# connmanctl connmanctl>
-
打开 Wi-Fi:
connmanctl> enable wifi Enabled wifi
-
如果 Wi-Fi 已经开启,忽略
"``Error wifi: Already enabled"
。 -
注册
connmanctl
作为连接代理:connmanctl> agent on Agent registered
-
扫描 Wi-Fi 网络:
connmanctl> scan wifi Scan completed for wifi
-
列出所有可用的 Wi-Fi 网络:
connmanctl> services *AO Wired ethernet_dca6320a8ead_cable RT-AC66U_B1_38_2G wifi_dca6320a8eae_52542d41433636555f42315f33385f3247_managed_psk RT-AC66U_B1_38_5G wifi_dca6320a8eae_52542d41433636555f42315f33385f3547_managed_psk
-
RT-AC66U_B1_38_2G
和RT-AC66U_B1_38_5G
是 ASUS 路由器的 Wi-Fi 网络 SSID。你的列表可能会有所不同。*AO
标记在Wired
前面表示设备当前通过以太网在线。 -
连接到 Wi-Fi 网络:
connmanctl> connect wifi_dca6320a8eae_52542d41433636555f42315f33385f3547_managed_psk Agent RequestInput wifi_dca6320a8eae_52542d41433636555f42315f33385f3547_managed_psk Passphrase = [ Type=psk, Requirement=mandatory ] Passphrase? somepassword Connected wifi_dca6320a8eae_52542d41433636555f42315f33385f3547_managed_psk
-
在
connect
后替换服务标识符为您在前一步中获得的服务标识符或目标网络。将您的 Wi-Fi 密码替换为somepassword
。 -
再次列出服务:
connmanctl> services *AO Wired ethernet_dca6320a8ead_cable *AR RT-AC66U_B1_38_5G wifi_dca6320a8eae_52542d41433636555f42315f33385f3547_managed_psk RT-AC66U_B1_38_2G wifi_ca6320a8eae_52542d41433636555f42315f33385f3247_managed_psk
-
这次,
*AR
出现在您刚刚连接的 SSID 前面,表示该网络连接已经准备好。以太网优先于 Wi-Fi,因此设备会通过Wired
保持在线。 -
退出
connman-client
:connmanctl> quit
-
将您的 Raspberry Pi 4 从以太网中拔出,从而关闭 SSH 会话:
root@raspberrypi4-64:~# client_loop: send disconnect: Broken pipe
-
重新连接到您的 Raspberry Pi 4:
$ ssh root@raspberrypi4-64.local
-
再次启动
connman-client
:root@raspberrypi4-64:~# connmanctl connmanctl>
-
再次列出服务:
connmanctl> services *AO RT-AC66U_B1_38_5G wifi_dca6320a8eae_52542d41433636555f42315f33385f3547_managed_psk
-
注意到
Wired
连接现在消失,而您之前连接的 Wi-Fi SSID 已被提升为在线状态。
connman
守护进程将您的 Wi-Fi 凭据保存在 /var/lib/connman
下的网络配置文件目录中,并且这些数据会在 microSD 卡上持久保存。这意味着 connman
会在您的 Raspberry Pi 4 启动时自动重新连接到您的 Wi-Fi 网络。电源重启后,您无需再次执行这些步骤。如果您愿意,可以保持以太网未插入。
控制蓝牙
除了 connman
和 connman-client
包之外,meta-raspberrypi
层还包括 bluez5
作为蓝牙栈。所有这些包以及所需的蓝牙驱动程序都包含在我们为 Raspberry Pi 4 构建的 rpi-test-image
中。让我们开始启用蓝牙并尝试与其他设备配对:
-
启动您的 Raspberry Pi 4 并通过 SSH 连接:
$ ssh root@raspberrypi4-64.local
-
验证蓝牙驱动程序是否已加载:
root@raspberrypi4-64:~# lsmod | grep bluetooth bluetooth 643072 29 hci_uart,btbcm,bnep,rfcomm ecdh_generic 16384 1 bluetooth rfkill 32768 7 nfc,bluetooth,cfg80211 libaes 12288 3 aes_arm64,bluetooth,aes_generic
-
初始化 HCI UART 驱动以实现蓝牙连接:
root@raspberrypi4-64:~# btuart
-
启动
connman-client
:root@raspberrypi4-64:~# connmanctl connmanctl>
-
打开蓝牙:
connmanctl> enable bluetooth Enabled Bluetooth
-
如果蓝牙已开启,忽略
"Error bluetooth: Already enabled"
错误信息。 -
退出
connman-client
:connmanctl> quit
-
启动蓝牙 CLI:
root@raspberrypi4-64:~# bluetoothctl Agent registered [CHG] Controller DC:A6:32:0A:8E:AF Pairable: yes
-
请求默认代理:
[bluetooth]# default-agent Default agent request successful
-
开启控制器:
[bluetooth]# power on Changing power on succeeded
-
显示控制器信息:
[bluetooth]# show Controller DC:A6:32:0A:8E:AF (public) Name: BlueZ 5.72 Alias: BlueZ 5.72 Class: 0x00200000 Powered: yes Discoverable: no DiscoverableTimeout: 0x000000b4 Pairable: yes
-
开始扫描蓝牙设备:
[bluetooth]# scan on Discovery started [CHG] Controller DC:A6:32:0A:8E:AF Discovering: yes … [NEW] Device DC:08:0F:03:52:CD Frank's iPhone …
-
如果您的智能手机在附近并且已启用蓝牙,它应当作为
[NEW]
设备出现在列表中。Frank's
iPhone
后面的DC:08:0F:03:52:CD
是我的智能手机的蓝牙 MAC 地址。 -
停止扫描蓝牙设备:
[bluetooth]# scan off … [CHG] Controller DC:A6:32:0A:8E:AF Discovering: no Discovery stopped
-
如果您有 iPhone 打开,请进入 设置 中的 蓝牙 以便接受来自您的 Raspberry Pi 4 的配对请求。
-
尝试与您的智能手机配对:
[bluetooth]# pair DC:08:0F:03:52:CD Attempting to pair with DC:08:0F:03:52:CD [CHG] Device DC:08:0F:03:52:CD Connected: yes Request confirmation [agent] Confirm passkey 936359 (yes/no):
-
将您的智能手机蓝牙 MAC 地址替换为
DC:08:0F:03:52:CD
。 -
在输入
yes
之前,接受来自智能手机的配对请求:
图 7.1 – 蓝牙配对请求
-
输入
yes
以确认密码:[agent] Confirm passkey 936359 (yes/no): yes [CHG] Device DC:08:0F:03:52:CD ServicesResolved: yes [CHG] Device DC:08:0F:03:52:CD Paired: yes Pairing successful [CHG] Device DC:08:0F:03:52:CD ServicesResolved: no [CHG] Device DC:08:0F:03:52:CD Connected: no
-
连接到您的智能手机:
[bluetooth]# connect DC:08:0F:03:52:CD Attempting to connect to DC:08:0F:03:52:CD [CHG] Device DC:08:0F:03:52:CD Connected: yes Connection successful [CHG] Device DC:08:0F:03:52:CD ServicesResolved: yes Authorize service
-
再次将您的智能手机蓝牙 MAC 地址替换为
DC:08:0F:03:52:CD
。 -
在提示授权服务时,输入
yes
:[agent] Authorize service 0000110e-0000-1000-8000- 00805f9b34fb (yes/no): yes [Frank's iPhone]#
你的 Raspberry Pi 4 现在已经通过蓝牙与智能手机配对并连接。它应该出现在你智能手机的蓝牙设备列表中,显示为BlueZ 5.72。bluetoothctl
程序有许多命令和子菜单,我们只是略微触及了表面。我建议输入help
并浏览自文档,以了解你可以通过命令行做些什么。像connman
一样,BlueZ 蓝牙栈是一个 D-Bus 服务,因此你可以通过 Python 或其他高级编程语言使用 D-Bus 绑定在 D-Bus 上与其进行编程通信。
添加自定义层
如果你使用的是 Raspberry Pi 4 来原型化新产品,那么你可以通过向conf/local.conf
中的IMAGE_INSTALL:append
变量分配的列表中添加包来快速生成自己的自定义镜像。虽然这种简单技巧有效,但在某个时候,你可能会想要开始开发自己的嵌入式应用。
如何构建这个额外的软件,以便将其包含在自定义镜像中?答案是创建一个自定义层,配合新配方来构建你的软件。
-
首先,导航到你克隆 Yocto 的目录上一级。
-
接下来,设置你的 BitBake 工作环境:
$ source poky/oe-init-build-env build-rpi
-
这会设置一堆环境变量,并将你带回
build-rpi
目录。 -
为你的应用创建一个新层:
$ bitbake-layers create-layer ../meta-gattd NOTE: Starting bitbake server... Add your new layer with 'bitbake-layers add-layer ../meta-gattd'
-
这个层的名称是
meta-gattd
,用于 GATT 守护进程。你可以随意命名你的层,但请遵循meta-
前缀约定。 -
导航到新的层目录:
$ cd ../meta-gattd
-
检查层的文件结构:
$ tree . ├── conf │ └── layer.conf ├── COPYING.MIT ├── README └── recipes-example └── example └── example_0.1.bb
-
重命名
recipes-examples
目录:$ mv recipes-example recipes-gattd
-
重命名
example
目录:$ cd recipes-gattd $ mv example gattd
-
重命名示例配方文件:
$ cd gattd $ mv example_0.1.bb gattd_0.1.bb
-
显示重命名后的配方文件:
$ cat gattd_0.1.bb
-
你需要为这个配方填充构建软件所需的元数据,包括
SRC_URI
和md5
校验和。 -
现在,暂时将 gattd_0.1.bb 替换为我在
MELD/Chapter07/meta-gattd/recipes-gattd/gattd/gattd_0.1.bb
中提供的完成配方。 -
为你的新层创建一个 Git 仓库,并将其推送到 GitHub。
-
在你的 Git 仓库中创建一个
scarthgap
分支,并将其推送到 GitHub。
现在我们有了一个用于应用的自定义层,让我们将其添加到你的工作镜像中:
-
首先,导航到你克隆 Yocto 的目录上一级:
$ cd ../../..
-
从 GitHub 克隆你的层或我的
meta-gattd
层:$ git clone -b scarthgap https://github.com/fvasquez/meta-gattd.git
-
将
fvasquez
替换为你的 GitHub 用户名,将meta-gattd
替换为你层的仓库名称。 -
接下来,设置你的 BitBake 工作环境:
$ source poky/oe-init-build-env build-rpi
-
这会设置一堆环境变量,并将你带回
build-rpi
目录。 -
然后,将新克隆的层添加到镜像中:
$ bitbake-layers add-layer ../meta-gattd
-
将
meta-gattd
替换为你层的名称。 -
验证所有必要的层是否已经添加到镜像中:
$ bitbake-layers show-layers
-
列表中应该有总共九个层,包括你新的层。
-
现在,将额外的软件包添加到你的
conf/local.conf
文件中:CORE_IMAGE_EXTRA_INSTALL += "gattd"
-
CORE_IMAGE_EXTRA_INSTALL
是一个便利变量,用于向从core-image
类继承的镜像中添加额外的软件包,像rpi-test-image
那样。IMAGE_INSTALL
是控制镜像中包含哪些软件包的变量。我们不能在conf/local.conf
中使用IMAGE_INSTALL += "gattd"
,因为它会替代core-image.bbclass
中默认的惰性赋值。请改用IMAGE_INSTALL:append = " gattd"
或CORE_IMAGE_EXTRA_INSTALL += " gattd"
。 -
最后,重新构建镜像:
$ bitbake rpi-test-image
如果你的软件成功构建并安装,它应该包含在完成的rpi-test-image-raspberrypi4-64.rootfs.wic.bz2
镜像中。将该镜像写入 microSD 卡,并在你的 Raspberry Pi 4 上启动它查看。应该可以在/usr/bin/gatt_server.py
找到一个 Python 脚本。
将软件包添加到conf/local.conf
中,在开发的最初阶段是有意义的。当你准备好与团队分享你的成果时,你应该创建一个镜像配方,并将你的软件包放在那里。在上一章的结尾,我们完成了并编写了一个nova-image
配方,将helloworld
包添加到core-image-minimal
中。
现在我们已经花了不少时间在实际硬件上测试新构建的镜像,是时候将注意力转回软件了。在接下来的部分,我们将介绍一个工具,它旨在简化我们在开发嵌入式软件时习惯的冗长编译、测试和调试周期。
使用 devtool 捕获更改
在上一章中,你学习了如何从头开始为helloworld
程序创建一个配方。最初,复制粘贴的方式可以工作,但随着项目的增长和你需要维护的配方数量的增加,这种方式很快就会变得令人沮丧。我将在这里展示一种更好的方法来处理软件包配方——无论是你自己的,还是第三方贡献给上游的。它叫做devtool
,是 Yocto 可扩展 SDK 的基石。
开发工作流程
在你开始使用devtool
之前,确保你在一个新的层中进行工作,而不是修改树中的配方。否则,你可能会轻易地覆盖并丢失数小时的工作:
-
首先,导航到克隆 Yocto 目录的上一级。
-
接下来,设置你的 BitBake 工作环境:
$ source poky/oe-init-build-env build-mine
-
这将设置一堆环境变量,并将你置于一个新的
build-mine
目录中。 -
在
conf/local.conf
中为 64 位 Arm 设置MACHINE
:MACHINE ?= "qemuarm64"
-
创建你的新层:
$ bitbake-layers create-layer ../meta-mine
-
现在,添加你的新层:
$ bitbake-layers add-layer ../meta-mine
-
检查你创建的新层是否位于你希望的位置:
$ bitbake-layers show-layers
bitbake-layers show-layers
的输出应该如下所示:
layer path priority
==================================================================
core /home/frank/poky/meta 5
yocto /home/frank/poky/meta-poky 5
yoctobsp /home/frank/poky/meta-yocto-bsp 5
meta-mine /home/frank/meta-mine 6
为了获得一些开发工作流程的第一手经验,你需要一个目标设备来进行部署。这意味着要构建一个镜像:
$ devtool build-image core-image-full-cmdline
第一次构建完整镜像通常需要几个小时。当完成时,继续启动它:
$ runqemu qemuarm64 nographic
<…>
Poky (Yocto Project Reference Distro) 5.0.4 qemuarm64 ttyAMA0
qemuarm64 login: root
root@qemuarm64:~#
通过指定nographic
选项,我们可以直接在一个独立的 shell 中运行 QEMU。这比应对模拟的图形输出更容易输入。以root
身份登录,密码为空。现在保持 QEMU 运行,因为我们接下来的练习需要它。你可以通过ssh root@192.168.7.2
登录到这个虚拟机。
devtool
支持三种常见的开发工作流:
-
添加一个新配方。
-
修补现有配方构建的源代码。
-
升级配方以获取上游源的更新版本。
当你启动任何这些工作流时,devtool
会为你创建一个临时工作区,以便你进行修改。这个沙盒包含配方文件和已获取的源代码。当你完成工作后,devtool
会将你的更改集成回你的层,以便销毁工作区。
创建一个新配方。
假设有一个开源软件你想要,但没有人提交过 BitBake 配方。而且假设这个软件是validator
文件签名、验证和安装工具。在这种情况下,你可以从 GitHub 下载validator
的源 tarball 发布包,并为其创建一个配方。这正是devtool add
所做的。
首先,devtool add
会创建一个带有本地 Git 仓库的工作区。在这个新的工作区目录中,它会创建一个recipes/validator
目录,并将 tarball 内容提取到sources/validator
目录。devtool
了解常见的构建系统,如 Autotools 和 CMake,并会尽力弄清楚这是什么类型的项目(在validator
的情况下是 Autotools)。然后,它使用解析后的元数据和从以前的 BitBake 构建中缓存的已构建包数据来确定DEPENDS
和RDEPENDS
的值,以及要继承和要求的文件:
-
首先,打开另一个 shell 并进入你克隆 Yocto 的目录上一级。
-
接下来,设置你的 BitBake 环境:
$ source poky/oe-init-build-env build-mine
-
这会设置一组环境变量,并将你带回
build-mine
工作目录。 -
然后,使用源 tarball 发布包的 URL 运行
devtool add
:$ devtool add https://github.com/containers/validator/releases/download/0.2.2/validator-0.2.2.tar.xz
-
devtool add
将生成一个配方,你可以用它来构建。 -
在你构建新的配方之前,让我们先看看它:
$ devtool edit-recipe validator
-
devtool
会在编辑器中打开recipes/validator/validator_0.2.2.bb
。注意,devtool
已经为你填写了 MD5 校验和。 -
将以下行添加到
validator_0.2.2.bb
的末尾:FILES:${PN} += "${datadir}" do_install:append() { rm -rf ${D}/usr/lib/dracut }
-
修正任何明显的错误,保存任何更改,然后退出编辑器。
-
要构建你的新配方,请使用以下命令:
$ devtool build validator
-
接下来,将编译好的
validator
可执行文件部署到目标模拟器:$ devtool deploy-target validator root@192.168.7.2
-
这会将必要的构建工件安装到目标模拟器上。
-
从你的 QEMU shell 中,运行你刚刚构建和部署的
validator
可执行文件:root@qemuarm64:~# validator --help
-
如果你看到大量与
validator
相关的自文档,那么说明构建和部署成功。如果没有看到,使用devtool
重复编辑、构建和部署步骤,直到你确信validator
能够正常工作。 -
一旦你满意了,清理你的目标仿真器:
$ devtool undeploy-target validator root@192.168.7.2
-
将所有工作合并回你的层:
$ devtool finish -f validator ../meta-mine
-
删除工作区中剩余的源代码:
$ devtool reset validator
如果你认为其他人可能从你的新配方中受益,那么可以将补丁提交到 Yocto。
修改由配方构建的源代码
假设你在jq
(一个命令行 JSON 预处理器)中发现了一个 bug。你在github.com/stedolan/jq
上搜索 Git 仓库,发现没有人报告这个问题。然后,你查看源代码。原来修复只需要几个小的代码修改,于是你决定自己给jq
打补丁。这时,devtool modify
就派上用场了。
这次,当devtool
查看 Yocto 的缓存元数据时,它会发现jq
已经有一个配方。如同devtool add
,devtool modify
会创建一个新的临时工作区,并在其中复制配方文件并提取上游源代码。jq
是用 C 语言编写的,并位于名为meta-oe
的现有 OpenEmbedded 层中。在我们能够修改软件包源代码之前,需要将此层以及jq
的依赖项添加到我们的工作镜像中:
-
首先,删除
build-mine
环境中的几个层:$ bitbake-layers remove-layer workspace $ bitbake-layers remove-layer meta-mine
-
接下来,从 GitHub 克隆
meta-openembedded
仓库(如果尚未存在):$ git clone -b scarthgap https://github.com/openembedded/meta-openembedded.git ../meta-openembedded
-
然后,将
meta-oe
和meta-mine
层添加到你的镜像中:$ bitbake-layers add-layer ../meta-openembedded/meta-oe $ bitbake-layers add-layer ../meta-mine
-
验证所有必要的层是否已被添加到镜像中:
$ bitbake-layers show-layers
-
命令的输出应该如下所示:
layer path priority ================================================================== core /home/frank/poky/meta 5 yocto /home/frank/poky/meta-poky 5 yoctobsp /home/frank/poky/meta-yocto-bsp 5 openembedded-layer /home/frank/meta-openembedded/meta-oe 5 meta-mine /home/frank/meta-mine 6
-
在
conf/local.conf
中添加以下行,因为onig
包是jq
的运行时依赖:IMAGE_INSTALL:append = " onig"
-
重新构建你的镜像:
$ devtool build-image core-image-full-cmdline
-
使用Ctrl + A和x从另一个终端退出 QEMU 并重启仿真器:
$ runqemu qemuarm64 nographic
像许多补丁工具一样,devtool modify
使用你的提交信息来生成补丁文件名,因此请保持提交信息简洁且有意义。它还会根据你的 GitHub 历史自动生成补丁文件,并创建一个包含新补丁文件名的.bbappend
文件。记得修剪和压缩你的 Git 提交,以便devtool
能够将你的工作合理地划分为补丁文件:
-
使用
devtool modify
命令并指定你希望修改的软件包名称:$ devtool modify jq
-
使用你喜欢的编辑器进行代码更改。使用标准的 Git 添加和提交工作流来跟踪你所做的更改。
-
使用以下命令构建修改过的源代码:
$ devtool build jq
-
接下来,将编译后的
jq
可执行文件部署到目标仿真器:$ devtool deploy-target jq root@192.168.7.2
-
这将必要的构建工件安装到目标仿真器中。
-
如果连接失败,请删除过期仿真器的密钥,具体操作如下:
$ ssh-keygen -f "/home/frank/.ssh/known_hosts" -R "192.168.7.2"
-
在路径中将
frank
替换为你的用户名。 -
从你的 QEMU shell 中运行你刚刚构建并部署的
jq
可执行文件。如果你无法再复现该 bug,那么你的更改是有效的。否则,重复编辑、构建和部署步骤,直到你满意为止。 -
一旦你满意了,清理你的目标仿真器:
$ devtool undeploy-target jq root@192.168.7.2
-
将所有工作合并回你的层:
$ devtool finish jq ../meta-mine
-
如果合并失败是因为 Git 源树有更改未提交,请删除或撤销任何剩余的
jq
构建工件,并再次尝试运行devtool finish
。 -
删除工作区中剩余的源文件:
$ devtool reset jq
如果你认为其他人也能受益于你的补丁,可以将其提交给上游项目的维护者。
升级配方到新版本
假设你正在使用 mypy Python 静态类型检查器在目标设备上进行开发,并且 mypy 的新版本刚刚发布。这个最新版本的 mypy 有一个新特性,你迫不及待地想要使用它。在等待 mypy 配方维护者升级到新版本之前,你决定自己升级配方。你可能会认为,这就像在配方文件中修改版本号一样简单,但其实还涉及到源代码归档的校验和。如果这个繁琐的过程可以完全自动化,那该多好?猜猜 devtool upgrade
是用来做什么的?mypy 是一个 Python 3 模块,因此在你升级它之前,你的镜像需要包含 Python 3、mypy 以及 mypy 的依赖项。要获取它们,按照以下步骤操作:
-
首先,从你的构建环境中删除几层:
$ bitbake-layers remove-layer workspace $ bitbake-layers remove-layer meta-mine
-
接下来,将 meta-python 和 meta-mine 层添加到你的镜像中:
$ bitbake-layers add-layer ../meta-openembedded/meta-python $ bitbake-layers add-layer ../meta-mine
-
3. 验证所有必要的层是否已添加到项目中:
$ bitbake-layers show-layers
-
命令的输出应如下所示:
layer path priority ================================================================== core /home/frank/poky/meta 5 yocto /home/frank/poky/meta-poky 5 yoctobsp /home/frank/poky/meta-yocto-bsp 5 openembedded-layer /home/frank/meta-openembedded/meta-oe 5 meta-python /home/frank/meta-openembedded/meta-python 5 meta-mine /home/frank/meta-mine 6
-
现在,应该有很多 Python 模块可以供你使用:
$ bitbake -s | grep ^python3
-
其中一个模块是
python3-mypy
。 -
通过在
conf/local.conf
中搜索python3
和python3-mypy
,确保它们都正在构建并安装到你的镜像中。如果它们不在那里,你可以通过向conf/local.conf
添加以下行来包括它们:IMAGE_INSTALL:append = " python3 python3-mypy"
-
重新构建你的镜像:
$ devtool build-image core-image-full-cmdline
-
从另一个 shell 使用 Ctrl + A 和 x 退出 QEMU,并重启仿真器:
$ runqemu qemuarm64 nographic
重要说明
在写作时,meta-python 中包含的 mypy 版本是 1.9.0,而 PyPI 上可用的最新版本是 1.12.1。
现在所有步骤都已到位,让我们进行升级:
-
首先,使用包的名称和目标版本运行 devtool upgrade 进行升级:
$ devtool upgrade python3-mypy --version 1.12.1
-
在构建你升级后的配方之前,我们先看一下它:
$ devtool edit-recipe python3-mypy
-
devtool 会在编辑器中打开
recipes/python3-mypy/python3-mypy_1.12.1.bb
文件。这个配方中没有任何版本特定的修改内容,所以保存新文件并退出编辑器。 -
要构建你的新配方,请使用以下命令:
$ devtool build python3-mypy
-
接下来,将新的 mypy 模块部署到目标仿真器:
$ devtool deploy-target python3-mypy root@192.168.7.2
这会将必要的构建工件安装到目标仿真器上。
-
如果连接失败,请按照这里所示删除过期的仿真器密钥:
$ ssh-keygen -f "/home/frank/.ssh/known_hosts" -R "192.168.7.2"
在路径中将 frank 替换为你的用户名。
-
从你的 QEMU shell 中,检查部署了哪个版本的 mypy:
root@qemuarm64:~# mypy --version mypy 1.12.1 (compiled: no)
-
如果输入
mypy --version
返回‘1.12.1
’,那么升级成功。如果没有返回该版本,则使用devtool
重复编辑、构建和部署步骤,直到找出问题所在。 -
一旦你满意,清理你的目标模拟器:
$ devtool undeploy-target python3-mypy root@192.168.7.2
-
清理你的工作空间:
rm -rf workspace/sources/python3-mypy/build rm -rf workspace/sources/python3-mypy/mypy/__pycache__
-
提交对
SOURCES.txt
的更改:cd workspace/sources/python3-mypy git add mypy.egg-info/SOURCES.txt git commit -m "add setup cfg to egg SOURCES"
-
将你所有的工作合并回到你的层中:
$ cd ../../.. $ devtool finish python3-mypy ../meta-mine
devtool finish
将源代码移动到一个名为 attic 的文件夹中。
-
如果合并失败,因为 GitHub 源代码树脏了,那么移除或撤销任何剩余的
python3-mypy
构建工件,然后再次尝试devtool finish
。 -
删除工作空间中剩余的源代码:
$ devtool reset python3-mypy
如果你认为其他人也可能急于将他们的发行版升级到某个包的最新版本,那么提交一个补丁到 Yocto。
最后,我们来到了如何构建自己发行版的话题。这个功能是 Yocto 特有的,而在 Buildroot 中明显缺失。发行版层是一个强大的抽象,可以跨多个项目共享,针对不同硬件。
构建你自己的发行版
在上一章开始时,我告诉你关于发行版层,例如 meta-poky
和它们的 conf/distro
子目录中包含的发行版元数据。正如我们所见,你不需要自己的发行版层就能构建定制的镜像。你可以在不修改任何 Poky 发行版元数据的情况下,完成很多工作。但如果你想修改发行版政策(例如功能、C 库实现、包管理器选择等),那么你可以选择构建自己的发行版。
构建你自己的发行版是一个三步过程:
-
创建一个新的发行版层。
-
创建一个发行版配置文件。
-
为你的发行版添加更多配方。
但在进入如何执行这些操作的技术细节之前,让我们先考虑一下,什么时候是自己制作发行版的合适时机。
什么时候该做,什么时候不该做
发行版设置定义了包格式(rpm
、deb
或 ipk
)、包源、init
系统(systemd
或 sysvinit
)以及特定包版本。你可以通过从 Poky 继承并覆盖需要为你的发行版改变的部分,来在一个新层中创建自己的发行版。然而,如果你发现自己除了明显的本地设置(例如相对路径)之外,向构建目录的 local.conf
文件中添加了很多值,那么可能是时候从头开始创建你自己的发行版了。
创建一个新的发行版层
你知道如何创建一个层。创建发行版层没有什么不同。
-
首先,导航到你克隆 Yocto 的目录的上一层。
-
接下来,设置你的 BitBake 工作环境:
$ source poky/oe-init-build-env build-rpi
-
这会设置一堆环境变量,并将你带回到之前的
build-rpi
目录。 -
从你的
build-rpi
环境中删除meta-gattd
层:$ bitbake-layers remove-layer meta-gattd
-
注释掉或删除
conf/local.conf
中的CORE_IMAGE_EXTRA_INSTALL
:#CORE_IMAGE_EXTRA_INSTALL += "gattd"
-
为我们的发行版创建一个新层:
$ bitbake-layers create-layer ../meta-mackerel
-
现在,将我们的新层添加到
build-rpi
配置中:$ bitbake-layers add-layer ../meta-mackerel
我们的发行版名称是 mackerel
。创建我们自己的发行版层使我们能够将发行版策略与包配方(实现)分开。
配置你的发行版
在 meta-mackerel
发行版层的 conf/distro
目录中创建发行版配置文件。给它取与发行版相同的名字(例如:mackerel.conf
)。
在 conf/distro/mackerel.conf
中设置所需的 DISTRO_NAME
和 DISTRO_VERSSION
变量:
DISTRO_NAME = "Mackerel (Mackerel Embedded Linux Distro)"
DISTRO_VERSION = "0.1"
以下可选变量也可以在mackerel.conf
中设置:
DISTRO_FEATURES: Add software support for these features.
DISTRO_EXTRA_RDEPENDS: Add these packages to all images.
DISTRO_EXTRA_RRECOMMENDS: Add these packages if they exist.
TCLIBC: Select this version of the C standard library.
完成这些变量设置后,你可以在 conf/local.conf
中定义你想要的几乎任何变量。查看其他发行版的 conf/distro
目录,例如 Poky 的目录,看看它们是如何组织的,或者复制并使用 poky/meta/conf/distro/defaultsetup.conf
作为模板。如果你决定将发行版配置文件拆分成多个包含文件,确保将它们放在你层的 conf/distro/include
目录中。
向你的发行版添加更多的配方
向你的发行版层添加更多的发行版相关元数据。你将需要为额外的配置文件添加配方。这些是尚未通过现有配方安装的配置文件。更重要的是,你还需要添加附加文件来定制现有的配方,并将它们的配置文件添加到你的发行版中。
运行时包管理
在发行版镜像中包含一个包管理器非常适合启用安全的空中更新和快速的应用开发。当你的团队在软件上频繁更新(每天多次迭代)时,频繁的包更新是一种保持每个人同步并向前推进的方式。完整的镜像更新是不必要的(只有一个包发生变化)且具有干扰性(需要重启)。能够从远程服务器获取包并将其安装到目标设备上,被称为运行时包管理。
Yocto 支持不同的包格式(rpm
、ipk
和 deb
)以及不同的包管理器(dnf
和 opkg
)。你为发行版选择的包格式决定了可以在其上使用的包管理器。
要为你的发行版选择包格式,可以在发行版的 conf
文件中设置 PACKAGE_CLASSES
变量。将以下行添加到 meta-mackerel/conf/distro/mackerel.conf
中:
PACKAGE_CLASSES ?= "package_ipk"
现在,让我们回到 build-rpi
目录:
$ source poky/oe-init-build-env build-rpi
我们的目标是 Raspberry Pi 4,因此请确保 MACHINE
在 conf/local.conf
中仍然正确设置:
MACHINE = "raspberrypi4-64"
在你的构建目录的 conf/local.conf
中注释掉 PACKAGE_CLASSES
,因为我们的发行版已经选择了 package_ipk
:
#PACKAGE_CLASSES ?= "package_rpm"
为了启用运行时包管理,将 package-management
添加到构建目录 conf/local.conf
中 EXTRA_IMAGE_FEATURES
的列表中:
EXTRA_IMAGE_FEATURES ?= "debug-tweaks ssh-server-openssh package-management"
这将安装一个包含当前构建中所有包的包数据库到你的发行版镜像中。预填充的包数据库是可选的,因为你总是可以在发行版镜像部署到目标后初始化包数据库。
最后,在构建目录的 conf/local.conf
文件中设置 DISTRO
变量为我们发行版的名称:
DISTRO = "mackerel"
这将你的构建目录的 conf/local.conf
文件指向我们的发行版配置文件。
最后,我们准备构建我们的发行版:
$ bitbake -c clean rpi-test-image
$ bitbake rpi-test-image
我们正在用不同的包格式重新构建 rpi-test-image
,所以这需要一些时间。完成的镜像这次会放在一个不同的目录中:
$ ls tmp-glibc/deploy/images/raspberrypi4-64/rpi-test-image*wic.bz2
使用 Etcher 将镜像写入 microSD 卡,并在你的 Raspberry Pi 4 上启动。像之前一样插入以太网并通过 SSH 连接:
$ ssh root@raspberrypi4-64.local
如果连接失败,请删除 Raspberry Pi 的过期密钥,如下所示:
$ ssh-keygen -f "/home/frank/.ssh/known_hosts" -R "raspberrypi4-64.local"
在路径中将 frank
替换为你的用户名。
登录后,验证是否已安装 opkg
包管理器:
root@raspberrypi4-64:~# which opkg
/usr/bin/opkg
没有远程包服务器,包管理器几乎没有用。
配置远程包服务器
设置 HTTP 远程包服务器并指向目标客户端比你想象的要简单。客户端的服务器地址配置在不同的包管理器之间有所不同。我们将手动配置 Raspberry Pi 4 上的 opkg
。
让我们从包服务器开始:
-
首先,导航到你克隆 Yocto 的目录的上一层。
-
接下来,设置你的 BitBake 工作环境:
$ source poky/oe-init-build-env build-rpi
-
这会设置一堆环境变量,并让你回到
build-rpi
目录。 -
构建
curl
包:$ bitbake curl
-
填充包索引:
$ bitbake package-index
-
定位包安装文件:
$ ls tmp-glibc/deploy/ipk
-
在 ipk 中应该有三个目录,分别是 cortexa72、all 和 raspberrypi4_64。架构目录是 cortexa72,机器目录是 raspberrypi4_64。根据镜像的构建配置,这两个目录的名称可能会有所不同。
-
导航到
ipk
目录,这是包安装文件所在的地方:$ cd tmp-glibc/deploy/ipk
-
获取你 Linux 主机的 IP 地址。
-
启动 HTTP 包服务器:
$ sudo python3 -m http.server --bind 192.168.1.69 80 [sudo] password for frank: Serving HTTP on 192.168.1.69 port 80 (http://192.168.1.69:80/) ...
-
将
192.168.1.69
替换为你的 Linux 主机的 IP 地址。
现在,让我们配置目标客户端:
-
通过 SSH 重新连接到你的 Raspberry Pi 4:
$ ssh root@raspberrypi4-64.local
-
编辑
/etc/opkg/opkg.conf
,使其如下所示:src/gz all http://192.168.1.69/all src/gz cortexa72 http://192.168.1.69/cortexa72 src/gz raspberrypi4_64 http://192.168.1.69/raspberrypi4_64 dest root / option lists_dir /var/lib/opkg/lists
-
将
192.168.1.69
替换为你的 Linux 主机的 IP 地址。 -
运行
opkg update
:root@raspberrypi4-64:~# opkg update Downloading http://192.168.1.69/all/Packages.gz. Updated source 'all'. Downloading http://192.168.1.69/aarch64/Packages.gz. Updated source 'aarch64'. Downloading http://192.168.1.69/raspberrypi4_64/Packages.gz. Updated source 'raspberrypi4_64'.
-
尝试运行
curl
:root@raspberrypi4-64:~# curl
-
由于没有安装
curl
,命令应该会失败。 -
安装
curl
:root@raspberrypi4-64:~# opkg install curl Installing libcurl4 (7.69.1) on root Downloading http://192.168.1.69/aarch64/ libcurl4_7.69.1-r0_aarch64.ipk. Installing curl (7.69.1) on root Downloading http://192.168.1.69/aarch64/curl_7.69.1-r0_aarch64.ipk. Configuring libcurl4. Configuring curl.
-
验证
curl
是否已安装:root@raspberrypi4-64:~# curl curl: try 'curl --help' for more information root@raspberrypi4-64:~# which curl /usr/bin/curl
当你继续在 Linux 主机的 build-rpi
目录中工作时,你可以从 Raspberry Pi 4 检查更新:
root@raspberrypi4-64:~# opkg list-upgradable
然后,你可以应用它们:
root@raspberrypi4-64:~# opkg upgrade
这样比重写镜像、交换 microSD 卡并重新启动更快。
摘要
我知道这可能有点多,理解起来需要些时间。相信我——这只是个开始。Yocto 确实有一个陡峭的学习曲线。幸运的是,Yocto 有大量文档和友好的社区可以指导你。还有 devtool
来自动化许多复制粘贴开发中的枯燥和错误。如果你使用提供的工具,并不断将工作保存到自己的层中,Yocto 并不需要那么痛苦。不久之后,你将能够打造自己的发行版层并运行自己的远程包服务器。
远程包服务器只是部署包和应用程序的一种方式。我们将在稍后的第十五章中了解其他几种方法。尽管标题如此,但我们将在该章节中看到的一些技术(例如,conda)适用于任何编程语言。虽然包管理器在开发中非常有用,但在生产环境中运行的嵌入式系统上,运行时包管理并不常见。我们将在第十章中深入研究完整镜像和容器化空中更新机制。
深入学习
-
过渡到自定义环境进行系统开发,Yocto 项目 –
docs.yoctoproject.org/transitioning-to-a-custom-environment.html
-
Yocto 项目开发任务手册,Yocto 项目 –
docs.yoctoproject.org/dev-manual/
-
使用 Devtool 简化你的 Yocto 项目工作流程,Tim Orling 撰写 –
www.youtube.com/watch?v=CiD7rB35CRE
第八章:Yocto 的内部工作原理
在本章中,我们将深入探讨Yocto,嵌入式 Linux 的顶级构建系统。我们将从 Yocto 的架构开始,带你一步一步地了解整个构建工作流。接着,我们将讨论 Yocto 的多层架构以及为什么将元数据分离到不同的层中是个好主意。随着项目中BitBake层级的逐步增多,问题必然会出现。我们将探讨一些调试 Yocto 构建失败的方法,包括任务日志、devshell
和依赖关系图。
拆解构建系统后,我们将重新回顾上一章中提到的 BitBake。此次,我们将更深入地讲解基本语法和语义,以便你能够从零开始编写自己的配方。我们将通过实际的配方、包含文件和配置文件中的 BitBake shell 和 Python 代码的真实示例,帮助你了解当你开始进入 Yocto 的元数据海洋时需要预期什么。
本章将覆盖以下主题:
-
拆解 Yocto 的架构与工作流
-
将元数据分离成层
-
排查构建失败
-
理解 BitBake 的语法和语义
技术要求
为了跟随本章中的示例,请确保你具备以下内容:
-
一个基于 Linux 的主机系统,至少有 90GB 的可用磁盘空间
-
Yocto 5.0 (scarthgap) LTS 版本
你应该已经在第六章中构建了 Yocto 的 5.0 (scarthgap) LTS 版本。如果尚未完成,请参阅兼容的 Linux 发行版和构建主机包部分,按照Yocto 项目快速构建指南中的说明在 Linux 主机上构建 Yocto。
本章中使用的代码可以在本书的 GitHub 仓库中的章节文件夹找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development
。
拆解 Yocto 的架构与工作流
Yocto 是一个复杂的系统,拆解它是理解它的第一步。构建系统的架构可以通过其工作流来组织。Yocto 的工作流来自于其基础的OpenEmbedded项目。源材料通过 BitBake 配方的元数据形式作为输入流入系统。构建系统利用这些元数据来获取、配置并编译源代码,最终生成二进制包。这些单独的输出包会在暂存区内汇集,最后生成完整的 Linux 镜像和 SDK,其中包括每个包的许可证信息:
图 8.1 – OpenEmbedded 架构工作流
以下是 Yocto 构建系统工作流的七个步骤,如前图所示:
-
定义用于策略、机器和软件元数据的层。
-
从软件项目的源 URI 拉取源代码。
-
提取源代码,应用补丁,并编译软件。
-
将构建产物安装到用于打包的暂存区域。
-
将已安装的构建产物打包成根文件系统的包源。
-
在提交二进制包源之前对其进行 QA 检查。
-
并行生成完成的 Linux 镜像和 SDK。
除了第一步和最后一步,工作流中的所有步骤都是按每个包的基础进行操作的。在编译前后,可能会进行代码检查、清理和其他静态分析。单元测试和集成测试可以直接在构建机器上运行,也可以在作为目标 SoC 代替的 QEMU 实例上运行,或在目标设备本身上运行。当构建完成后,生成的镜像可以部署到一组专用设备上进行进一步的测试。作为嵌入式 Linux 构建系统的金标准,Yocto 是许多产品软件 CI/CD 管道中的关键组件。
Yocto 生成的包可以是 rpm
、deb
或 ipk
格式。除了主二进制包外,构建系统默认尝试为一个配方生成所有以下包:
-
dbg
:二进制文件,包括调试符号 -
static-dev
:头文件和静态库 -
dev
:头文件和共享库符号链接 -
doc
:文档,包括手册页 -
locale
:语言翻译信息
如果没有启用 ALLOW_EMPTY
变量,则不会生成不包含任何文件的包。默认生成的包集合由 PACKAGES
变量决定。这两个变量都定义在 meta/classes-recipe/packagegroup.bbclass
中,但其值可以被继承该 BitBake 类的包组配方重写。
构建 SDK 启用了一种全新的开发工作流,用于操作单独的包配方。在上一章的 通过 devtool 捕获更改 部分中,我们学习了如何使用 devtool
添加和修改 SDK 软件包,以便将它们重新集成到镜像中。
元数据
元数据 是输入到构建系统的内容。它控制了构建什么以及如何构建。元数据不仅仅是配方。BSP、策略、补丁和其他形式的配置文件也是元数据。构建哪个版本的包以及从哪里拉取源代码,当然也是元数据的形式。开发人员通过命名文件、设置变量和运行命令来做出这些选择。这些配置操作、参数值及其生成的产物是另一种形式的元数据。Yocto 解析所有这些输入,并将其转化为一个完整的 Linux 镜像。
开发人员在使用 Yocto 构建时首先做出的选择是目标机器架构。你可以通过在项目的 conf/local.conf
文件中设置 MACHINE
变量来指定这一点。在针对 QEMU 时,我喜欢使用 MACHINE ?= "qemuarm64"
来指定 aarch64
作为机器架构。Yocto 会确保正确的编译器标志从 BSP 传播到其他构建层。
特定架构的设置定义在名为 tunes 的文件中,这些文件位于 Yocto 的 meta/conf/machine/include
目录中,以及各个 BSP 层本身。每个 Yocto 版本都包含若干 BSP 层。我们在上一章中广泛使用了 meta-raspberrypi
BSP 层。每个 BSP 的源代码都存储在其自己的 Git 仓库中。
要克隆 Xilinx 的 BSP 层,该层支持他们的 Zynq 系列 SoC,请使用以下命令:
$ git clone git://git.yoctoproject.org/meta-xilinx
这是 Yocto 附带的众多 BSP 层中的一个例子。后续的练习中不需要使用此层,因此可以随意丢弃它。
元数据需要源代码来执行操作。BitBake 的 do_fetch
任务可以通过多种不同的方式获取配方源文件。以下是最常见的两种方法:
-
当别人开发了你需要的软件时,最简单的方式是告诉 BitBake 下载该项目的 tarball 发布版本。
-
要扩展其他人的开源软件,只需在 GitHub 上 fork 该仓库。然后,BitBake 的
do_fetch
任务可以使用 Git 从给定的SRC_URI
克隆源文件。
如果你的团队负责该软件,你可以选择将其作为本地项目嵌入到你的工作环境中。你可以通过将其嵌套为子目录或使用 externalsrc
类将其定义为树外项目来实现。嵌入意味着源代码与您的层仓库绑定,不能轻易地在其他地方使用。使用 externalsrc
的树外项目需要在所有构建实例中具有相同的路径,这会破坏可重现性。这两种技术只是加速开发的工具,不应在生产环境中使用。
策略是作为发行层捆绑在一起的属性。这些策略包括 Linux 发行版所需的功能(例如 systemd
)、C 库实现(如 glibc
或 musl
)以及包管理器。每个发行层都有自己的 conf/distro
子目录。该目录中的 .conf
文件定义了分发或映像的顶级策略。有关发行层的示例,请查看 meta-poky
子目录。此 Poky 参考发行层包括用于构建默认、精简、前沿和替代版本的 .conf
文件,以便为目标设备构建。我们在上一章的 构建你自己的发行版 部分已经介绍过这一内容。
构建任务
我们已经看到 BitBake 的do_fetch
任务是如何下载配方的源代码的。构建过程的下一步是提取、修补、配置和编译该源代码:do_unpack
、do_patch
、do_configure
和do_compile
。
do_patch
任务使用FILESPATH
变量和配方的SRC_URI
变量来定位补丁文件并将其应用到目标源代码。FILESPATH
变量位于meta/classes/base.bbclass
中,定义了构建系统用来搜索补丁文件的默认目录集(Yocto 项目参考手册, docs.yoctoproject.org/ref-manual/index.html
)。按照约定,补丁文件的名称以.diff
和.patch
结尾,并位于与相应配方文件相对应的子目录下。此默认行为可以通过定义FILESEXTRAPATHS
变量并将文件路径追加到配方的SRC_URI
变量中进行扩展和覆盖。
在修补源代码后,do_configure
和do_compile
任务配置、编译并链接它:
图 8.2 – 包源
当do_compile
完成时,do_install
任务将生成的文件复制到一个暂存区,在那里它们会为打包做准备。然后,do_package
任务处理构建产物,并将它们组装成一个或多个包。在提交到包源区之前,do_package_qa
任务会对包产物进行一系列质量检查。这些自动生成的质量检查定义在meta/classes-global/insane.bbclass
中。最后,do_package_write_*
任务创建各个包并将其发送到包源区。一旦包源区被填充,BitBake 就准备好进行镜像和 SDK 的生成。
镜像生成
生成镜像是一个多阶段的过程,依赖于多个变量来执行一系列任务。do_rootfs
任务为镜像创建根文件系统。这些变量决定了哪些包将被安装到镜像中:
-
IMAGE_INSTALL
: 安装到镜像中的包 -
PACKAGE_EXCLUDE
: 从镜像中排除的包 -
IMAGE_FEATURES
: 安装到镜像中的附加包 -
PACKAGE_CLASSES
: 要使用的包格式(rpm
、deb
或ipk
) -
IMAGE_LINGUAS
: 要包含支持包的语言(文化)
回想一下,我们在第六章**中将软件包添加到IMAGE_INSTALL
变量中,作为编写镜像配方部分的一部分。IMAGE_INSTALL
变量中的软件包列表会传递给包管理器(dnf
、apt
或opkg
),以便将它们安装到镜像中。调用哪个包管理器取决于软件包源的格式:do_package_write_rpm
、do_package_write_deb
或do_package_write_ipk
。无论目标是否包含运行时包管理器,软件包安装都会进行。如果镜像中没有包管理器,那么安装脚本和包元数据将在此阶段结束时被删除,以保证清洁并节省空间。
一旦软件包安装完成,软件包的后安装脚本将被执行。这些后安装脚本是与软件包一起提供的。如果所有后安装脚本成功运行,则会生成一个清单文件,并对根文件系统镜像执行优化操作。这个顶层的.manifest
文件列出了所有已安装的软件包。默认的库大小和可执行文件启动时间优化由ROOTFS_POSTPROCESS_COMMAND
变量定义。
现在根文件系统已经完全填充,do_image
任务可以开始处理镜像。首先,执行IMAGE_PREPROCESS_COMMAND
变量定义的所有预处理命令。接下来,系统创建最终的镜像输出文件。它通过为IMAGE_FSTYPES
变量中指定的每种镜像类型(例如cpio.lz4
、ext4
和squashfs-lzo
)启动一个do_image_*
任务来实现。构建系统然后将IMAGE_ROOTFS
目录的内容转换为一个或多个镜像文件。如果指定的文件系统格式允许,这些输出的镜像文件将被压缩。最后,do_image_complete
任务通过执行IMAGE_POSTPROCESS_COMMAND
变量定义的每个后处理命令完成镜像制作。
现在我们已经从头到尾梳理了 Yocto 的整个构建工作流,让我们来看看一些结构化大型项目的最佳实践。
将元数据分为多个层次
Yocto 元数据围绕以下概念进行组织:
-
发行版:操作系统功能,包括 C 库的选择、初始化系统和窗口管理器
-
机器:CPU 架构、内核、驱动程序和引导加载程序
-
配方:应用程序二进制文件和/或脚本
-
镜像:开发、制造或生产
这些概念直接映射到构建系统的实际副产品,从而为我们设计项目时提供指导。我们可以匆忙将所有内容组装到单一的层中,但那样很可能导致一个不灵活且无法维护的项目。硬件不可避免地会被修订,而一个成功的消费电子产品很快就会变成一系列产品。基于这些原因,最好从一开始就采用多层次的方法,这样我们最终得到的软件组件可以轻松修改、更换和重用。
最低要求是为你开始使用 Yocto 的每个主要项目创建单独的分发层、BSP 层和应用层。分发层构建你的目标操作系统(Linux 发行版),你的应用程序将在其上运行。帧缓冲和窗口管理器配置文件属于分发层。BSP 层指定启动加载程序、内核和设备树,这些是硬件操作所需的。应用层包含构建所有组成你自定义应用程序的软件包所需的食谱。
我们第一次遇到 MACHINE
变量是在第六章**,当时我们进行第一次的 Yocto 构建时。我们在上一章的末尾查看了 DISTRO
变量,当时我们创建了自己的分发层。本书中的其他 Yocto 练习依赖于 meta-poky
作为它们的分发层。层通过将它们插入到你当前构建目录中的 conf/bblayers.conf
文件中的 BBLAYERS
变量中来添加到你的构建中。以下是 Poky 默认的 BBLAYERS
定义示例:
BBLAYERS ?= " \
/home/frank/poky/meta \
/home/frank/poky/meta-poky \
/home/frank/poky/meta-yocto-bsp \
"
不要直接编辑 bblayers.conf
文件,使用 bitbake-layers
命令行工具来处理项目层。不要冲动地直接修改 Poky 源代码树。始终在 Poky 上方创建自己的层(例如,meta-mine
),并在此处进行更改。
在开发过程中,BBLAYERS
变量在你当前构建目录(例如,build-mine
)中的 conf/bblayers.conf
文件中的样子应该是这样的:
BBLAYERS ?= " \
/home/frank/poky/meta \
/home/frank/poky/meta-poky \
/home/frank/poky/meta-yocto-bsp \
/home/frank/meta-mine \
/home/frank/build-mine/workspace \
"
workspace
是我们在上一章遇到的一个特殊临时层,当时我们使用 devtool
进行实验。每个 BitBake 层无论是什么类型的层,其基本目录结构都是相同的。层目录的名称通常约定以 meta
为前缀。以下是一个虚拟层的例子:
$ tree meta-example
meta-example
├── classes
│ ├── class-a.bbclass
│ ├── ...
│ └── class-z.bbclass
├── conf
│ └── layer.conf
├── COPYING.MIT
├── README
├── recipes-a
│ ├── package-a
│ │ └── package-a_0.1.bb
│ ├── ...
│ └── package-z
│ └── package-z_0.1.bb
├── recipes-b
│ └── ...
└── recipes-c
└── ...
每个层次都必须有一个 conf
目录,其中包含一个 layer.conf
文件,这样 BitBake 才能设置路径和搜索模式,以查找元数据文件。我们在 *第六章**中仔细查看了 layer.conf
的内容,当时我们为我们的 Nova 板创建了一个 meta-nova
层。BSP 和分发层也可能在 conf
目录下有一个 machine
或 distro
子目录,其中包含更多的 .conf
文件。我们在前一章中检查了机器和分发层的结构,当时我们在 meta-raspberrypi
层之上构建并创建了我们自己的 meta-mackerel
分发层。
classes
子目录仅在定义了自己 BitBake 类的层中需要。配方按照类别组织,比如 connectivity,因此 recipes-a
实际上是 recipes-connectivity
的占位符,依此类推。一个类别可以包含一个或多个包,每个包都有自己的一组 BitBake 配方文件(.bb
)。这些配方文件按包的发布版本号进行版本控制。同样,像 package-a
和 package-z
这样的名称只是实际包的占位符。
在这些不同的层次中,很容易迷失。即使你变得更加熟练使用 Yocto,仍然会有很多时候你会问自己,为什么某个特定的文件出现在你的镜像中。或者,更可能的情况是,你需要修改或扩展哪些配方文件来完成你需要做的事情?幸运的是,Yocto 提供了一些命令行工具来帮助你回答这些问题。我建议你探索 recipetool
、oe-pkgdata-util
和 oe-pkgdata-browser
,并熟悉它们。这样可以为你节省很多烦恼的时间。
构建失败的故障排除
在前两章中,我们学习了如何为 QEMU、Nova 板和 Raspberry Pi 4 构建可启动的镜像。但当事情出错时怎么办?在本节中,我们将介绍一些有用的调试技术,这些技术应该能让你应对 Yocto 构建失败时不再感到那么害怕。
要执行后续练习中的命令,你需要激活 BitBake 环境:
-
首先,导航到你克隆 Yocto 的目录的上一级。
-
接下来,设置你的 BitBake 工作环境:
$ source poky/oe-init-build-env build-rpi
这会设置一堆环境变量,并将你带回到我们在前一章中创建的 build-rpi
目录。
错误隔离
所以,你的构建失败了,但失败的原因在哪里呢?你有一个错误信息,但它是什么意思,来自哪里?不要绝望。调试的第一步是重现错误。一旦你能够重现错误,你就可以将问题缩小到一系列已知的步骤。回溯这些步骤就是找出故障的方式:
-
首先,查看 BitBake 构建错误信息,看看是否能认出其中的任何包或任务名称。如果你不确定工作区中有哪些包,可以使用以下命令获取它们的列表:
$ bitbake-layers show-recipes
-
一旦你确定了哪个软件包构建失败了,接下来可以在当前层中搜索与该软件包相关的任何配方或附加文件,如下所示:
$ find ../poky -name "*connman*.bb*"
-
要搜索的软件包是
connman
。前面find
命令中的../poky
参数假设你的构建目录与poky
相邻,就像前一章中的build-rpi
一样。 -
接下来,列出所有可用的
connman
配方任务:$ bitbake -c listtasks connman
-
为了重现错误,你可以按照以下方式重新构建
connman
:$ bitbake -c clean connman && bitbake connman
现在你已经知道了构建失败的配方和任务,可以继续进行调试的下一个阶段。
转储环境
在调试构建失败时,你将需要查看 BitBake 环境中变量的当前值。我们从顶部开始,一步步往下看:
-
首先,转储全局环境并搜索
DISTRO_FEATURES
的值:$ bitbake -e | less
-
输入
/DISTRO_FEATURES=
(注意前面的斜杠);less
应该跳转到一个类似这样的行:DISTRO_FEATURES="acl alsa argp bluetooth ext2 ipv4 ipv6 largefile pcmcia usbgadget usbhost wifi xattr nfs zeroconf pci 3g nfc x11 vfat largefile opengl ptest multiarch wayland vulkan pulseaudio sysvinit gobject-introspection-data ldconfig"
-
要转储 BusyBox 的软件包环境并定位其源代码目录,请使用以下命令:
$ bitbake -e busybox | grep ^S=
-
要定位 ConnMan 的工作目录,请使用以下命令:
$ bitbake-getvar -r connman WORKDIR
-
软件包的工作目录是 BitBake 构建过程中保存其配方任务日志的地方。
在 步骤 1 中,我们本可以将 bitbake -e
的输出通过管道传递给 grep
,但 less
让我们更容易追踪变量的评估过程。输入 /DISTRO_FEATURES
(不带尾部等号)来搜索更多该变量的出现位置。按 n 跳转到下一个出现位置,按 N 跳转回上一个出现位置。
相同的命令适用于图像以及软件包配方:
$ bitbake -e core-image-minimal | grep ^S=
在这种情况下,目标环境的转储属于 core-image-minimal
。
现在你知道了源代码和任务日志文件的位置,让我们来看看一些任务日志。
阅读任务日志
BitBake 会为每个 Shell 任务创建一个日志文件,并将其保存到软件包工作目录中的临时文件夹。以 ConnMan 为例,该临时文件夹的路径大致如下所示:
$ ./tmp-glibc/work/cortexa72-oe-linux/connman/1.42/temp
日志文件名的格式是 log.do_<task>.<pid>
。也有没有 <pid>
后缀的符号链接,它们指向每个任务的最新日志文件。日志文件包含任务运行的输出,通常这是调试问题所需的所有信息。如果没有,猜猜看你能做什么?
添加更多日志记录
从 Python 进行日志记录与从 Shell 进行日志记录有所不同。要从 Python 记录日志,你可以使用 BitBake 的 bb
模块,它调用了 Python 标准的 logger
模块,如下所示:
bb.plain -> none; Output: logs console
bb.note -> logger.info; Output: logs
bb.warn -> logger.warning; Output: logs console
bb.error -> logger.error; Output: logs console
bb.fatal -> logger.critical; Output: logs console
bb.debug -> logger.debug; Output: logs console
要从 Shell 日志记录,你可以使用 BitBake 的 logging
类,其源代码可以在 meta/classes-global/logging.bbclass
找到。所有继承了 base.bbclass
的配方都自动继承了 logging.bbclass
。这意味着以下所有日志记录函数应该已经可以在大多数 Shell 配方文件中使用:
bbplain -> Prints exactly what is passed in. Use sparingly.
bbnote -> Prints noteworthy conditions with the NOTE prefix.
bbwarn -> Prints a non-fatal warning with the WARNING prefix.
bberror -> Prints a non-fatal error with the ERROR prefix.
bbfatal -> Prints a fatal error and halts the build.
bbdebug -> Prints debug messages depending on log level.
根据 logging.bbclass
源代码,bbdebug
函数的第一个参数是一个整数调试日志级别:
# Usage: bbdebug 1 "first level debug message"
# bbdebug 2 "second level debug message
bbdebug () {
USAGE = 'Usage: bbdebug [123] "message"'
…
}
根据调试日志级别,bbdebug
消息可能会或可能不会输出到控制台。
从 devshell 运行命令
BitBake 提供了一个开发 shell,以便你可以在更交互的环境中手动运行构建命令。启动 devshell
需要一个终端复用器,比如 tmux
。要安装 tmux
,请使用以下命令:
$ sudo apt install tmux
要进入构建 ConnMan 的 devshell
,使用以下命令:
$ bitbake -c devshell connman
首先,该命令提取并修补 ConnMan 的源代码。接下来,它会打开一个新的终端,进入 ConnMan 的源目录,并正确设置构建环境。一旦进入 devshell
,你可以运行诸如 ./configure
和 make
等命令,或者直接使用 $CC
调用交叉编译器。devshell
非常适合用来尝试修改诸如 CFLAGS
或 LDFLAGS
等值,这些值会作为命令行参数或环境变量传递给 CMake 和 Autotools 等工具。至少,如果你正在阅读的错误消息没有意义,你可以增加构建命令的详细程度。
图示依赖关系
有时,构建错误的原因无法在软件包的配方文件中找到,因为错误实际上发生在构建某个软件包的依赖项时。要获取 ConnMan 包的依赖项列表,请使用以下命令:
$ bitbake -v connman
我们可以使用 BitBake 内置的任务资源管理器来显示和导航依赖关系:
$ bitbake -g connman -u taskexp
上述命令在分析 ConnMan 后启动任务资源管理器的图形界面:
重要提示
一些较大的镜像,如 core-image-x11,具有复杂的软件包依赖树,这些树很可能会导致任务资源管理器崩溃。
图 8.3 – 任务资源管理器
现在,让我们暂时离开构建和构建失败的话题,深入探讨 Yocto 项目的核心内容。我指的是 BitBake 的元数据。
理解 BitBake 的语法和语义
BitBake 是一个任务运行器。它在这方面类似于 GNU Make,不同之处在于它操作的是配方(recipes)而不是 Makefile。这些配方中的元数据定义了使用 shell 和 Python 编写的任务。BitBake 本身是用 Python 编写的。Yocto 项目基于的 OpenEmbedded 项目由 BitBake 和大量用于构建嵌入式 Linux 发行版的配方组成。BitBake 的强大之处在于它能够在满足任务间依赖关系的同时并行运行任务。它基于层次和继承的元数据方法使得 Yocto 在扩展性上具有 Buildroot 构建系统无法比拟的优势。
在 第六章中,我们学习了五种类型的 BitBake 元数据文件:.bb
、.bbappend
、.inc
、.bbclass
和 .conf
。我们还编写了用于构建基本 helloworld
程序和 nova-image
镜像的 BitBake 食谱。现在,我们将更仔细地查看 BitBake 元数据文件的内容。我们知道任务是用 shell 和 Python 的混合语言编写的,但代码分布在哪里,为什么要这样做?有哪些语言构造可供我们使用?我们能用它们做什么?我们如何编排元数据来构建我们的应用程序?在你能够充分利用 Yocto 的强大功能之前,你需要学习如何读写 BitBake 元数据。为此,你需要学习 BitBake 的语法和语义。
任务
任务是 BitBake 需要按顺序执行以运行食谱的函数。回想一下,任务名称以 do_
前缀开头。以下是来自 recipes-core/systemd
的一个任务:
do_deploy () {
install ${B}/src/boot/efi/systemd-boot*.efi ${DEPLOYDIR}
}
addtask deploy before do_build after do_compile
在此示例中,定义了一个名为 do_deploy
的函数,并通过 addtask
命令立即将其提升为任务。addtask
命令还指定了任务间的依赖关系。例如,这个 do_deploy
任务依赖于 do_compile
任务完成,而 do_build
任务依赖于 do_deploy
任务完成。addtask
表示的依赖关系只能是食谱文件内部的依赖关系。
任务也可以使用 deltask
命令删除。这将停止 BitBake 将任务作为食谱的一部分执行。要删除之前的 do_deploy
任务,可以使用以下命令:
deltask do_deploy
这将从食谱中删除任务,但原始的 do_deploy
函数定义仍然存在,并且仍然可以被调用。
依赖关系
为了确保高效的并行处理,BitBake 在任务级别处理依赖关系。我们看到 addtask
如何用于表示单个食谱文件内任务之间的依赖关系。不同食谱中的任务之间也存在依赖关系。实际上,这些任务间依赖关系正是我们通常在考虑包之间的构建时依赖和运行时依赖时所想到的。
任务间依赖关系
变量标志(varflags)是将属性或特性附加到变量的一种方式。它们的行为像哈希映射中的键,允许你将键设置为值并通过键检索值。BitBake 定义了一大套用于食谱和类的 varflags。这些 varflags 表示任务的组件和依赖关系。以下是一些 varflags 的示例:
do_patch[postfuncs] += "copy_sources"
do_package_index[depends] += "signing-keys:do_deploy"
do_rootfs[recrdeptask] += "do_package_write_deb do_package_qa"
分配给 varflag 键的值通常是一个或多个其他任务。这意味着 BitBake 的 varflags 为我们提供了另一种表示任务间依赖关系的方式,这与 addtask
不同。addtask
命令指定任务的执行时机(例如,before do_build after do_compile
)。大多数嵌入式 Linux 开发人员可能在日常工作中永远不需要接触 varflags。我在此引入它们,是为了让我们能够理解后面的 DEPENDS
和 RDEPENDS
示例。
构建时依赖关系
BitBake 使用 DEPENDS
变量来管理构建时依赖。任务的 deptask
varflag 表示在执行该任务之前,必须完成 DEPENDS
中每个项的任务(BitBake 用户手册,docs.yoctoproject.org/bitbake/bitbake-user-manual/bitbake-user-manual-metadata.html#build-dependencies
):
do_package[deptask] += "do_packagedata"
在这个示例中,DEPENDS
中每个项的 do_packagedata
任务必须在 do_package
执行之前完成。
另外,你可以绕过 DEPENDS
变量,使用 depends
标志显式定义构建时依赖:
do_patch[depends] += "quilt-native:do_populate_sysroot"
在这个示例中,属于 quilt-native
命名空间的 do_populate_sysroot
任务必须在 do_patch
执行之前完成。配方的任务通常会被分组到各自的命名空间中,以便进行这种直接访问。
运行时依赖
BitBake 使用 PACKAGES
和 RDEPENDS
变量来管理运行时依赖。PACKAGES
变量列出了配方创建的所有运行时包。每个包可以有 RDEPENDS
运行时依赖。这些是必须安装的包,以便给定的包能够运行。任务的 rdeptask
varflag 指定了在执行该任务之前,必须完成每个运行时依赖的任务(BitBake 用户手册,docs.yoctoproject.org/bitbake/bitbake-user-manual/bitbake-user-manual-metadata.html#runtime-dependencies
):
do_package_qa[rdeptask] = "do_packagedata"
在这个示例中,RDEPENDS
中每个项的 do_package_data
任务必须在 do_package_qa
执行之前完成。
同样,rdepends
标志的作用类似于 depends
标志,允许你绕过 RDEPENDS
变量。唯一的区别是 rdepends
在运行时生效,而不是在构建时。
变量
BitBake 变量语法类似于 Make 的变量语法。BitBake 中变量的作用域取决于变量定义所在的元数据文件类型。在配方文件(.bb
)中声明的每个变量都是局部的。在配置文件(.conf
)中声明的每个变量都是全局的。镜像只是一个配方,因此一个镜像无法影响另一个配方中的内容。
赋值和展开
变量赋值和展开的工作方式与 shell 中相似。默认情况下,赋值会在语句解析后立即发生,并且是无条件的。$
字符触发变量展开。花括号是可选的,主要用于保护变量不被紧随其后的字符展开。展开的变量通常会用双引号括起来,以防止意外的词分割和通配符扩展:
OLDPKGNAME = "dbus-x11"
PROVIDES:${PN} = "${OLDPKGNAME}"
变量是可变的,通常在引用时进行评估,而不是在赋值时进行评估,这一点与 Make 中的行为不同。这意味着,如果变量在赋值语句的右侧被引用,那么在左侧的变量展开之前,右侧引用的变量不会被评估。因此,如果右侧的值随着时间变化,那么左侧变量的值也会随之变化。
条件赋值仅在解析时变量未定义的情况下才定义该变量。这防止了在不希望发生的情况下进行重新赋值:
PREFERRED_PROVIDER_virtual/kernel ?= "linux-yocto"
条件赋值通常用于 makefile 的顶部,以防止可能已被构建系统(例如CC
、CFLAGS
和LDFLAGS
)设置的变量被覆盖。条件赋值确保我们不会在后续配方中向未定义的变量附加或前置值。
使用??=
进行的延迟赋值与?=
的行为相同,不同之处在于赋值发生在解析过程的末尾,而不是立即发生(BitBake 用户手册,超链接 "docs.yoctoproject.org/bitbake/bitbake-user-manual/bitbake-user-manual-metadata.html
#setting-a-weak-default-value"):
TOOLCHAIN_TEST_HOST ??= "localhost"
这意味着,如果一个变量名出现在多个延迟赋值的左侧,那么最后一个延迟赋值语句会“胜出”。
另一种形式的变量赋值会在解析时强制立即评估赋值的右侧:
target_datadir := "${datadir}"
请注意,:=
操作符用于立即赋值,它来源于 Make,而不是 shell。
添加和前置
在 BitBake 中,向变量或变量标志添加或前置值非常简单。以下两个操作符会在左侧的值和右侧附加或前置的值之间插入一个单独的空格:
CXXFLAGS += "-std=c++11"
PACKAGES =+ "gdbserver"
请注意,当+=
操作符应用于整数时,表示递增而不是附加,与应用于字符串值时的行为不同。
如果你希望省略单个空格,可以使用一些赋值操作符来实现:
BBPATH .= ":${LAYERDIR}"
FILESEXTRAPATHS =. "${FILE_DIRNAME}/systemd:"
BitBake 元数据文件中会使用单空格版本的添加和前置赋值操作符。
覆盖
BitBake 提供了一种用于向变量添加或前置值的替代语法。这种连接方式被称为覆盖语法:
CFLAGS:append = " -DSQLITE_ENABLE_COLUMN_METADATA"
PROVIDES:prepend = "${PN}"
尽管乍一看可能不太明显,前面两行并没有定义新变量。:append
和:prepend
后缀修改或覆盖现有变量的值。它们的作用更像是 BitBake 的.=
和=.
,与+=
和=+
操作符不同,后者在连接字符串时会省略单个空格。与这些操作符不同,覆盖是延迟的,因此赋值不会发生,直到所有解析完成。
最后,让我们看一个更高级的条件赋值形式,它涉及到在meta/conf/bitbake.conf
中定义的OVERRIDES
变量。OVERRIDES
变量是一个以冒号分隔的条件列表,用于指定需要满足的条件。该列表用于在多个相同变量的版本之间进行选择,每个版本都有不同的后缀。这些后缀对应条件的名称。假设OVERRIDES
列表包含${TRANSLATED_TARGET_ARCH}
作为条件。现在,您可以定义一个针对目标 CPU 架构为aarch64
的变量版本,例如VALGRINDARCH:aarch64
变量:
VALGRINDARCH ?= "${TARGET_ARCH}"
VALGRINDARCH:aarch64 = "arm64"
VALGRINDARCH:x86-64 = "amd64"
当TRANSLATED_TARGET_ARCH
变量扩展为aarch64
时,VALGRINDARCH:aarch64
版本的VALGRINDARCH
变量将优先于所有其他覆盖项。基于OVERRIDES
选择变量值比其他条件赋值方法(例如 C 语言中的#ifdef
指令)更加简洁和不易出错。
BitBake 还支持基于是否在OVERRIDES
中列出特定项目来对变量值进行附加和预加操作(BitBake 用户手册,docs.yoctoproject.org/bitbake/bitbake-user-manual/bitbake-user-manual-metadata.html
#conditional-metadata)。以下是一些实际应用示例:
EXTRA_OEMAKE:prepend:task-compile = "${PARALLEL_MAKE} "
EXTRA_OEMAKE:prepend:task-install = "${PARALLEL_MAKEINST} "
DEPENDS = "attr libaio libcap acl openssl zip-native"
DEPENDS:append:libc-musl = " fts "
EXTRA_OECONF:append:libc-musl = " LIBS=-lfts "
EXTRA_OEMAKE:append:libc-musl = " LIBC=musl "
注意libc-musl
是将字符串值附加到DEPENDS
、EXTRA_OECONF
和EXTRA_OEMAKE
变量的条件。与前面无条件覆盖语法的变量附加和预加操作一样,这种条件语法也是惰性求值的。赋值操作不会发生,直到食谱和配置文件被解析后。
基于OVERRIDES
的条件附加和预加操作可能会比较复杂,并且可能会导致意外的结果。我建议在采用这些高级 BitBake 特性之前,先通过大量实践掌握基于OVERRIDES
的条件赋值方法。
内联 Python
BitBake 中的@
符号允许我们在变量内部注入并执行 Python 代码。每次扩展=
运算符左侧的变量时,内联 Python 表达式都会被评估。在:=
运算符右侧的内联 Python 表达式只会在解析时评估一次。以下是内联 Python 变量扩展的一些示例:
PV = "${@bb.parse.vars_from_file(d.getVar('FILE', False),d)[1] or '1.0'}"
BOOST_MAJ = "${@"_".join(d.getVar("PV").split(".")[0:2])}"
GO_PARALLEL_BUILD ?= "${@oe.utils.parallel_make_argument(d, '-p %d')}"
注意,bb
和oe
是 BitBake 和 OpenEmbedded 的 Python 模块的别名。还要注意,d.getVar("PV")
用于从任务的运行时环境中获取PV
变量的值。d
变量指的是一个数据存储对象,BitBake 将原始执行环境的副本保存到该对象中。这也是 BitBake 的 Shell 和 Python 代码相互操作的方式。
函数
函数是构成 BitBake 任务的基本单元。它们可以用 Shell 或 Python 编写,并定义在.bbclass
、.bb
和.inc
文件中。
Shell
用 shell 编写的函数作为函数或任务执行。作为任务运行的函数通常以 do_
前缀命名。这是一个 shell 中的函数样式:
meson_do_install() {
DESTDIR='${D}' ninja -v ${PARALLEL_MAKEINST} install
}
写函数时请记得保持与 shell 无关。BitBake 使用 /bin/sh
执行 shell 片段,具体是哪个 shell 取决于主机的发行版,可能是也可能不是 Bash shell。通过运行 scripts/verify-bashisms
这个 linter 来检查你的 shell 脚本,避免使用 Bash 特有的语法。
Python
BitBake 理解三种类型的 Python 函数:纯 Python 函数、BitBake 风格函数和匿名函数。
纯 Python 函数
纯 Python 函数是用常规 Python 编写的,并由其他 Python 代码调用。这里的“纯”是指该函数完全在 Python 解释器的执行环境内运行,而不是在函数式编程的意义上。下面是来自 meta/recipes-connectivity/bluez5/bluez5.inc
的一个示例:
def get_noinst_tools_paths (d, bb, tools):
s = list()
bindir = d.getVar("bindir")
for bdp in tools.split():
f = os.path.basename(bdp)
s.append("%s/%s" % (bindir, f))
return "\n".join(s)
请注意,这个函数像真正的 Python 函数一样接受参数。我还想指出一些关于这个函数的值得注意的事情。首先,数据存储对象不可用,因此需要将其作为函数参数传入(此处是 d
变量)。其次,os
模块是自动可用的,因此无需导入或传入它。
纯 Python 函数可以通过内联 Python 被分配给 shell 变量,并使用 @
符号调用。实际上,这正是下行代码在该包含文件中发生的情况:
FILES:${PN}-noinst-tools = \
"${@get_noinst_tools_paths(d, bb, d.getVar('NOINST_TOOLS'))}"
请注意,在 @
符号后,d
数据存储对象和 bb
模块在内联 Python 范围内自动可用。
BitBake 风格的 Python 函数
BitBake 风格的 Python 函数定义通过 python
关键字表示,而不是 Python 原生的 def
关键字。这些函数通过调用 bb.build.exec_func()
从其他 Python 函数中执行,包括 BitBake 自身的内部函数。与纯 Python 函数不同,BitBake 风格的函数不接受参数。没有参数并不成问题,因为数据存储对象始终作为全局变量(即 d
)可用。虽然这种定义方式不如 Python 风格优雅,但 BitBake 风格在 Yocto 中占主导地位。下面是来自 meta/classes/sign_rpm.bbclass
的一个 BitBake 风格 Python 函数定义:
python sign_rpm () {
import glob
from oe.gpg_sign import get_signer
signer = get_signer(d, d.getVar('RPM_GPG_BACKEND'))
rpms = glob.glob(d.getVar('RPM_PKGWRITEDIR') + '/*')
signer.sign_rpms(rpms,
d.getVar('RPM_GPG_NAME'),
d.getVar('RPM_GPG_PASSPHRASE'),
d.getVar('RPM_FILE_CHECKSUM_DIGEST'),
int(d.getVar('RPM_GPG_SIGN_CHUNK')),
d.getVar('RPM_FSK_PATH'),
d.getVar('RPM_FSK_PASSWORD'))
}
匿名 Python 函数
匿名 Python 函数看起来很像 BitBake 风格的 Python 函数,但它在解析时执行。由于它们首先执行,匿名函数非常适合在解析时完成的操作,例如初始化变量和其他设置。匿名函数的定义可以带有或不带有 __anonymous
函数名:
python __anonymous () {
systemd_packages = "${PN} ${PN}-wait-online"
pkgconfig = d.getVar('PACKAGECONFIG')
if ('openvpn' or 'vpnc' or 'l2tp' or 'pptp') in pkgconfig.split():
systemd_packages += " ${PN}-vpn"
d.setVar('SYSTEMD_PACKAGES', systemd_packages)
}
python () {
packages = d.getVar('PACKAGES').split()
if d.getVar('PACKAGEGROUP_DISABLE_COMPLEMENTARY') != '1':
types = ['', '-dbg', '-dev']
if bb.utils.contains('DISTRO_FEATURES', 'ptest', True, False, d):
types.append('-ptest')
packages = [pkg + suffix for pkg in packages
for suffix in types]
d.setVar('PACKAGES', ' '.join(packages))
for pkg in packages:
d.setVar('ALLOW_EMPTY_%s' % pkg, '1')
}
匿名 Python 函数中的d
变量代表整个配方的 datastore(数据存储)(BitBake 用户手册,docs.yoctoproject.org/bitbake/bitbake-user-manual/bitbake-user-manual-metadata.html
#anonymous-python-functions)。因此,当你在匿名函数作用域内设置一个变量时,该值将在其他函数运行时通过全局数据存储对象可用。
RDEPENDS 重访
让我们回到运行时依赖的主题。这些是必须安装的包,以使某个特定包能够运行。这个列表在包的RDEPENDS
变量中定义。以下是populate_sdk_base.bbclass
中的一个有趣摘录:
do_sdk_depends[rdepends] = "${@get_sdk_ext_rdepends(d)}"
这里是相应的内联 Python 函数定义:
def get_sdk_ext_rdepends(d):
localdata = d.createCopy()
localdata.appendVar('OVERRIDES', ':task-populate-sdk-ext')
return localdata.getVarFlag('do_populate_sdk', 'rdepends')
这里有很多内容需要解释。首先,函数会复制数据存储对象,以避免修改任务的运行时环境。回想一下,OVERRIDES
变量是一个用于在多个版本的变量之间选择条件的列表。下一行将task-populate-sdk-ext
的条件添加到本地复制数据存储的OVERRIDES
列表中。最后,函数返回do_populate_sdk
任务的rdepends varflag
值。现在的区别是,rdepends
是使用_task-populate-sdk-ext
版本的变量进行评估的,例如以下内容:
SDK_EXT:task-populate-sdk-ext = "-ext"
SDK_DIR:task-populate-sdk-ext = "${WORKDIR}/sdk-ext"
我发现这种临时使用OVERRIDES
的方法既聪明又可怕。
BitBake 的语法和语义看起来可能很令人生畏。将 Shell 和 Python 结合起来,形成了一种有趣的语言特性混合。我们不仅知道如何定义变量和函数,而且还可以继承类文件、重载变量,并通过编程改变条件。这些高级概念会反复出现在.bb
、.bbappend
、.inc
、.bbclass
和.conf
文件中,随着时间的推移,它们会变得越来越容易识别。当我们努力提高 BitBake 的熟练度,并开始发挥我们新获得的能力时,错误是难以避免的。
总结
尽管你可以使用 Yocto 构建几乎任何东西,但有时很难知道构建系统在做什么或者是怎么做的。不过,我们还是有希望的。有一些命令行工具可以帮助我们找到某个东西的来源以及如何修改它。我们也可以读取和写入任务日志。还有devshell
,我们可以使用它从命令行配置和编译单个项目。如果我们从一开始就将项目分成多个层,那么我们可能会更好地利用我们所做的工作。
BitBake 结合了 Shell 和 Python,支持一些强大的语言构造,如继承、重载和条件变量选择。这既有好处也有坏处。好处在于层和配方是完全可组合和可定制的。
这不好,原因在于不同食谱文件和不同层次中的元数据可能会以奇怪且出乎意料的方式相互作用。将这些强大的语言特性与数据存储对象作为 Shell 和 Python 执行环境之间的门户功能结合,你将获得无数小时的乐趣。
这就结束了我们对 Yocto 项目及本书第二部分《构建嵌入式 Linux 镜像》的深入探讨。在本书的下一部分,我们将换个角度,研究系统架构与设计决策,从第九章开始。我们将在第十章中再次使用 Yocto,评估 Mender。
深入学习
-
Yocto 项目概述与概念手册,Yocto 项目 –
docs.yoctoproject.org/overview-manual/
-
我希望自己早知道的 Yocto 项目知识,Yocto 项目 –
docs.yoctoproject.org/what-i-wish-id-known.html
-
BitBake 用户手册,理查德·普迪(Richard Purdie)、克里斯·拉尔森(Chris Larson)和菲尔·布伦德尔(Phil Blundell)编著 –
docs.yoctoproject.org/bitbake/
-
使用 Yocto 项目的嵌入式 Linux 开发指南,亚历克斯·冈萨雷斯(Alex Gonzalez)编著
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者及其他读者进行讨论:packt.link/embeddedsystems
第三部分
系统架构与设计决策
本部分探讨了在开发正式进行之前需要做出的各种设计决策。它涵盖了文件系统、软件更新、设备驱动程序、init
程序和电源管理等主题。第十二章展示了如何使用单板计算机和附加板进行快速原型设计的技术,包括如何阅读原理图并在 Python 中编写硬件测试脚本。
每章介绍嵌入式 Linux 的一个主要领域。它描述了背景,以便您可以学习一般原则,但也包括详细的工作示例,以说明每个领域。您可以把这本书当作理论书或实例书。如果您两者兼顾,效果最佳:理解理论并在现实生活中尝试。
本部分包括以下章节:
-
第九章,创建存储策略
-
第十章,现场更新软件
-
第十一章,与设备驱动程序接口
-
第十二章,使用附加板进行原型设计
-
第十三章,启动 - init 程序
-
第十四章,管理电源
第九章:创建存储策略
嵌入式设备的海量存储选项对系统的其他部分有很大影响,特别是在系统的稳健性、速度和现场更新方法方面。大多数设备都以某种形式使用闪存。随着存储容量从几十兆字节增加到几十千兆字节,闪存的价格在过去几年中大幅下降。
本章我们将详细介绍闪存技术,并探讨不同的内存组织策略如何影响必须管理它的低级驱动程序软件,包括 Linux 内存技术设备(MTD)层。
每种闪存技术都有不同的文件系统选择。我将描述在嵌入式设备上最常见的那些,并通过总结每种闪存类型的选择来完成这项调查。最后,我们将考虑一些技术,使闪存得到最佳利用,并将所有内容整合成一个连贯的存储策略。
本章将涵盖以下主题:
-
存储选项
-
从启动加载程序访问闪存
-
从 Linux 访问闪存
-
闪存文件系统
-
NOR 和 NAND 闪存文件系统
-
管理闪存的文件系统
-
只读压缩文件系统
-
临时文件系统
-
使根文件系统只读
-
文件系统选择
技术要求
为了跟随示例,请确保你已准备好以下内容:
-
一台安装有
e2fsprogs
、genext2fs
、mtd-utils
、squashfs-tools
和util-linux
或其等效工具的基于 Linux 的主机系统 -
一个 microSD 卡读卡器和卡片
-
适用于 Linux 的 balenaEtcher
-
来自 第三章 的 U-Boot 源代码树
-
来自 第四章 的 Linux 内核源代码树
-
一条带有 3.3V 逻辑电平引脚的 USB 到 TTL 串口电缆
-
一台 BeaglePlay
-
一款能够提供 3A 电流的 5V USB-C 电源
你应该已经在 第三章 中为 BeaglePlay 下载并构建了 U-Boot。你也应该已经获得了来自 第四章 的 Linux 内核源代码树。
Ubuntu 提供了创建和格式化各种文件系统所需工具的大部分软件包。要在 Ubuntu 24.04 LTS 系统上安装这些工具,请使用以下命令:
$ sudo apt install e2fsprogs genext2fs mtd-utils squashfs-tools util-linux
mtd-utils
包含 mtdinfo
、mkfs.jffs2
、sumtool
、nandwrite
和 UBI 命令行工具。
存储选项
嵌入式设备需要低功耗、物理紧凑、稳健且在可能达到几十年生命周期的情况下可靠的存储。几乎所有情况下,这意味着使用固态存储。固态存储在许多年前通过 只读存储器(ROM)引入,但过去 20 年里,它一直是某种类型的闪存。在这段时间里,闪存经历了多个发展阶段,从 NOR 到 NAND,再到像 eMMC 这样的管理闪存。
NOR 闪存价格昂贵,但可靠,可以映射到 CPU 地址空间,从而允许你直接从闪存中执行代码。NOR 闪存芯片的容量较小,通常从几兆字节到大约一吉字节。
NAND 闪存比 NOR 闪存便宜得多,并且具有从数十兆字节到数十吉字节不等的更高容量。然而,它需要大量的硬件和软件支持才能将其转化为有用的存储介质。
管理型闪存由一个或多个 NAND 闪存芯片组成,配备了一个控制器,该控制器处理闪存的复杂性,并提供类似硬盘的硬件接口。其吸引力在于,它将复杂性从驱动程序软件中移除,并使系统设计师免于应对闪存技术的频繁变化。
SD 卡、eMMC 芯片和 USB 闪存驱动器都属于这一类别。几乎所有当前一代的智能手机和平板电脑都配备了 eMMC 存储,这一趋势可能会扩展到其他类型的嵌入式设备。
嵌入式系统中很少使用硬盘驱动器。一个例外是机顶盒和智能电视中的数字视频录制,它们需要大量存储并具有快速写入时间。
在所有情况下,可靠性至关重要:你希望设备能够在电力故障和意外重启的情况下启动并进入功能状态。你应该选择在此类情况下表现良好的文件系统。你的存储设备技术选择将限制你对文件系统的选择。
本节将学习 NOR 闪存和 NAND 闪存之间的差异,并在选择管理型闪存技术时考虑我们的选择。
NOR 闪存
NOR 闪存芯片中的存储单元被排列成擦除块,例如 128 KB。擦除一个块会将所有位设置为 1。它可以一次编程一个字(根据数据总线宽度,8、16 或 32 位)。每个擦除周期都会轻微损坏存储单元,经过一定次数的周期后,擦除块将变得不可靠,无法再使用。芯片的数据表中应提供最大擦除周期数,但通常在 1 K 到 1 M 之间。
数据可以逐字读取。芯片通常被映射到 CPU 地址空间,这意味着你可以直接从 NOR 闪存中执行代码。这使得它成为放置引导加载程序代码的一个方便位置,因为它只需要硬接地址映射,不需要额外的初始化。因此,支持这种 NOR 闪存的 SoC 通常会提供默认的内存映射,使其涵盖 CPU 的重置向量。
内核,甚至根文件系统,也可以位于闪存中,这样就避免了将它们复制到 RAM 中,从而创建了具有小内存占用的设备。这种技术被称为就地执行或XIP。它非常专业,我在这里不会进一步讨论。章节末尾的进一步学习部分包含了一些参考资料。
NOR 闪存芯片有一个标准的寄存器级接口,称为通用闪存接口(CFI),所有现代芯片都支持该接口。CFI 在标准 JESD68 中进行了描述,您可以从www.jedec.org/
获取。
现在我们已经了解了什么是 NOR 闪存,接下来让我们来看一下 NAND 闪存。
NAND 闪存
NAND 闪存比 NOR 闪存便宜得多,且容量更大。第一代 NAND 芯片在现在所称的单层单元(SLC)组织中,每个存储单元存储一个比特。后来的几代芯片在多层单元(MLC)芯片中,每个存储单元存储两个比特,而现在的三层单元(TLC)芯片每个存储单元存储三个比特。随着每个存储单元存储比特数的增加,存储的可靠性下降,因此需要更复杂的控制器硬件和软件来补偿这一点。当可靠性成为问题时,应确保使用 SLC NAND 闪存芯片。
与 NOR 闪存类似,NAND 闪存也被组织成多个擦除块,大小范围从 16 KB 到 512 KB,再次地,擦除一个块会将所有位设置为 1。但是,在块变得不可靠之前,擦除周期的次数较少。TLC 芯片通常只有大约 1K 次擦除周期,而 SLC 芯片可高达 100K 次。NAND 闪存只能以页面(通常为 2 KB 或 4 KB)的形式进行读写。由于它们无法按字节逐一访问,因此无法映射到地址空间中,所以代码和数据必须先被复制到 RAM 中,才能进行访问。
数据传输到芯片以及从芯片传输的过程中容易发生比特翻转,这可以通过使用错误修正码(ECC)来检测和修正。SLC 芯片通常使用简单的汉明码,可以在软件中高效实现,并能修正页面读取中的单比特错误。MLC 和 TLC 芯片需要更复杂的代码,如Bose-Chaudhuri-Hocquenghem(BCH)码,可以修正每页最多 8 比特的错误。修正如此多的错误需要闪存控制器内部的硬件支持。
ECC 需要存储在某个地方,因此每页会有一个额外的存储区域,称为带外(OOB)区域或备用区域。SLC 设计通常每 32 字节主存储会有 1 字节 OOB。因此,对于一个 2 KB 页面设备,OOB 为每页 64 字节,对于一个 4 KB 页面,OOB 为每页 128 字节。MLC 和 TLC 芯片具有相对更大的 OOB 区域,以容纳更复杂的 ECC。以下图展示了一个具有 128 KB 擦除块和 2 KB 页面的芯片组织:
图 9.1 – OOB 区域
在生产过程中,制造商会测试所有的块,并通过在每个块的页面的 OOB 区域中设置标志来标记任何失败的块。发现全新芯片中有多达 2%的块以这种方式标记为坏是很常见的。在擦除区域之前保存 OOB 信息以进行分析在出现问题时是有用的。此外,在达到擦除周期限制之前,类似比例的块给出擦除错误是符合规范的。NAND 闪存驱动程序应检测到并标记其为坏块。
一旦在 OOB 区域为坏块标志和 ECC 字节留出空间后,仍然有一些字节剩余。一些闪存文件系统利用这些空闲字节来存储文件系统元数据。因此,系统的许多部分对 OOB 区域的布局感兴趣:SoC ROM 引导代码、引导加载程序、内核 MTD 驱动程序、文件系统代码以及创建文件系统映像的工具。由于标准化程度不高,因此很容易出现引导加载程序使用无法被内核 MTD 驱动程序读取的 OOB 格式的情况。您需要确保它们彼此一致。
访问 NAND 闪存芯片需要一个 NAND 闪存控制器,通常是 SoC 的一部分。您需要引导加载程序和内核中对应的驱动程序。NAND 闪存控制器处理芯片的硬件接口,传输页面的数据,可能包括用于错误校正的硬件。
NAND 闪存芯片有一个称为开放 NAND 闪存接口(Open NAND Flash Interface,ONFI)的标准寄存器级接口,大多数现代芯片都遵循这一标准。有关更多信息,请参阅onfi.org/
。
现代 NAND 闪存技术非常复杂。仅仅将 NAND 闪存存储器与控制器配对已不再足够。我们还需要一个接口来抽象掉大部分技术细节,例如错误校正。
管理型闪存
在操作系统中支持闪存存储器的负担变小了,特别是对于 NAND 闪存而言,如果有一个明确定义的硬件接口和一个隐藏存储器复杂性的标准闪存控制器。这就是管理型闪存存储器,它越来越普遍。实质上,它意味着将一个或多个闪存芯片与提供理想存储设备的微控制器结合起来,具有小的扇区大小,并且与传统文件系统兼容。嵌入式系统中最重要的芯片类型是安全数字(Secure Digital,SD)卡及其嵌入式变体称为 eMMC。
多媒体卡(MultiMediaCard)和安全数字卡(Secure Digital cards)
多媒体卡(MMC)由 SanDisk 和西门子于 1997 年推出,是一种使用闪存的封装存储形式。随后,在 1999 年,SanDisk、松下和东芝共同创建了 SD 卡,它基于 MMC,但增加了加密和数字版权管理(DRM)功能,因此才有了“安全”这一名称部分。这两种卡都是为了消费类电子产品,如数码相机、音乐播放器和类似设备而设计的。目前,SD 卡是消费类和嵌入式电子产品中占主导地位的管理型闪存,尽管加密功能很少被使用。SD 规范的新版本允许更小的封装(miniSD 和 microSD)和更大的容量:高容量的 SDHC 最高可达 32GB,扩展容量的 SDXC 可达 2TB。
MMC 和 SD 卡的硬件接口非常相似。可以在全尺寸的 SD 卡插槽中使用全尺寸的 MMC 卡(但反过来不可行)。早期的版本使用了 1 位的串行外设接口(SPI)。更近期的卡片使用了 4 位接口。
该指令集用于读取和写入 512 字节的扇区内存。封装内部包含一个微控制器和一个或多个 NAND 闪存芯片,具体如下图所示:
图 9.2 – SD 卡封装
微控制器实现了指令集并管理闪存,执行闪存翻译层的功能,如本章后续所述。它们已经预格式化为 FAT 文件系统:SDSC 卡使用 FAT16,SDHC 卡使用 FAT32,SDXC 卡使用 exFAT。NAND 闪存芯片的质量以及微控制器上的软件在不同卡片之间差异很大。是否有卡片足够可靠以供深度嵌入使用仍存疑,尤其是使用 FAT 文件系统时,它容易导致文件损坏。请记住,MMC 和 SD 卡的主要使用场景是作为数码相机、平板电脑和手机的可移动存储。
eMMC
嵌入式 MMC或eMMC实际上是将 MMC 存储芯片封装成可以通过 4 位或 8 位接口焊接到主板上的存储器。它们通常用作操作系统的存储,因此这些组件能够执行该任务。芯片通常没有预格式化任何文件系统。
其他类型的管理型闪存
第一个管理型闪存技术之一是紧凑型闪存(CF),它使用了个人计算机内存卡国际协会(PCMCIA)硬件接口的一个子集。CF 通过并行先进技术附件(PATA)接口暴露内存,并且在操作系统中表现为标准硬盘。它们曾广泛应用于基于 x86 的单板计算机和专业视频及摄影设备中。
我们每天使用的另一种格式是USB 闪存驱动器。在这种情况下,内存是通过 USB 接口访问的,控制器实现了 USB 大容量存储规范以及闪存转换层和与闪存芯片的接口。USB 大容量存储协议基于 SCSI 磁盘命令集。与 MMC 和 SD 卡一样,它们通常预格式化为 FAT 文件系统。它们在嵌入式系统中的主要用例是与 PC 交换数据。
最近,作为一种新的管理型闪存存储选项,通用闪存存储(UFS)被加入到列表中。与 eMMC 类似,它是一个封装在芯片中的存储单元,安装在主板上。它具有高速串行接口,并且可以实现比 eMMC 更高的传输速率。它支持 SCSI 磁盘命令集。
现在我们知道了可用的闪存类型,让我们学习一下 U-Boot 如何从每种闪存中加载内核镜像。
从引导加载程序访问闪存内存
在第三章中,我提到了引导加载程序需要从各种闪存设备中加载内核二进制文件和其他镜像,并执行系统维护任务,如擦除和重新编程闪存内存。因此,引导加载程序必须具备支持读取、擦除和写入操作的驱动程序和基础设施,无论你使用的是 NOR、NAND 还是管理型内存。接下来的示例中我将使用 U-Boot。其他引导加载程序遵循类似的模式。
U-Boot 和 NOR 闪存
U-Boot 在drivers/mtd
中有 NOR CFI 芯片的驱动程序,并使用各种erase
命令擦除内存,通过cp.b
命令逐字节将数据复制到闪存单元。如果你的 NOR 闪存内存从0x40000000
映射到0x48000000
,其中 4MB 的内存从0x40040000
开始用于存储内核镜像。此时,你可以使用这些 U-Boot 命令将新的内核加载到闪存中:
=> tftpboot 0x100000 uImage
=> erase 0x40040000 0x403fffff
=> cp.b 0x100000 0x40040000 $(filesize)
上述示例中的filesize
变量由tftpboot
命令设置为刚刚下载的文件的大小。
U-Boot 和 NAND 闪存
对于 NAND 闪存,你需要一个 NAND 闪存控制器的驱动程序,通常可以在 U-Boot 源代码中的drivers/mtd/nand
目录找到。
你可以使用nand
命令来管理内存,使用它的erase
、write
和read
子命令。这个例子展示了一个内核镜像被加载到0x82000000
的 RAM 中,然后再从0x280000
偏移位置开始写入到闪存中:
=> tftpboot 0x82000000 uImage
=> nand erase 0x280000 0x400000
=> nand write 0x82000000 0x280000 $(filesize)
U-Boot 还可以读取存储在 JFFS2、YAFFS2 和 UBIFS 文件系统中的文件。nand write
会跳过标记为坏的块。
重要提示
如果你正在写入的数据属于文件系统,请确保文件系统也会跳过坏块。
U-Boot 和 MMC、SD 以及 eMMC
U-Boot 在drivers/mmc
中有多个 MMC 控制器的驱动程序。你可以通过用户界面级别使用mmc read
和mmc write
访问原始数据,从而处理原始内核和文件系统镜像。
U-Boot 还可以从 MMC 存储上的 FAT32 和 ext4 文件系统中读取文件。
U-Boot 需要驱动程序来访问 NOR、NAND 和管理闪存。你应使用哪个驱动程序取决于你选择的 NOR 芯片或 SoC 上的闪存控制器。从 Linux 访问原始 NOR 和 NAND 闪存涉及额外的软件层。
从 Linux 访问闪存
原始的 NOR 和 NAND 闪存由内存技术设备(MTD)子系统处理,该子系统为你提供了基本接口来读取、擦除和写入闪存块。对于 NAND 闪存,还有处理 OOB 区域的功能,用于识别坏块。
对于管理闪存,你需要驱动程序来处理特定的硬件接口。MMC/SD 卡和 eMMC 使用 mmcblk
驱动程序。而 CompactFlash 和硬盘则使用 sd
SCSI 磁盘驱动程序。USB 闪存驱动器使用 usb_storage
驱动程序,并与 sd
驱动程序一起使用。
内存技术设备
MTD 子系统由 David Woodhouse 于 1999 年启动,并在这期间得到了广泛的发展。在本节中,我将重点介绍它如何处理两种主要技术,NOR 和 NAND 闪存。MTD 包含三层:一组核心功能、用于各种类型芯片的驱动程序集合以及以字符设备或块设备形式呈现闪存的用户级驱动程序:
图 9.3 – MTD 层
芯片驱动程序处于最低层,与闪存芯片进行接口。NOR 闪存芯片只需要少数几个驱动程序,足以涵盖 CFI 标准和变种,以及一些不符合标准的芯片,这些芯片现在大多已过时。对于 NAND 闪存,你需要一个用于你所使用的 NAND 闪存控制器的驱动程序。这个驱动程序通常作为板级支持包的一部分提供。目前主线内核的 drivers/mtd/nand
目录中大约有 40 个相关驱动程序。
MTD 分区
在大多数情况下,你可能希望将闪存分区成多个区域,例如,提供引导加载程序、内核镜像或根文件系统的空间。在 MTD 中,有几种方法可以指定分区的大小和位置,主要的几种方法如下:
-
通过内核命令行使用
CONFIG_MTD_CMDLINE_PARTS
-
通过设备树使用
CONFIG_MTD_OF_PARTS
-
使用平台映射驱动程序
在第一个选项中,内核命令行选项为 mtdparts
,该选项在 Linux 源代码中的 drivers/mtd/parsers/cmdlinepart.c
文件内定义:
<mtddef> := <mtd-id>:<partdef>[,<partdef>]
<partdef> := <size>[@<offset>][<name>][ro][lk][slc]
<mtd-id> := unique name used in mapping driver/device (mtd->name)
<size> := standard linux memsize OR "-" to denote all remaining space
size is automatically truncated at end of device
if specified or truncated size is 0 the part is skipped
<offset> := standard linux memsize
if omitted the part will immediately follow the previous part
or 0 if the first part
<name> := '(' NAME ')'
或许一个例子能帮助理解。假设你有一个 128 MB 的闪存芯片,需要将其分成五个分区。一个典型的命令行如下:
mtdparts=:512k(SPL)ro,780k(U-Boot)ro,128k(U-BootEnv),4m(Kernel),-(Filesystem)
冒号前面的第一个元素是mtd-id
,它通过数字或板支持包分配的名称来标识闪存芯片。如果只有一个芯片,如这里的情况,可以留空。如果有多个芯片,每个芯片的信息由分号分隔。然后,对于每个芯片,有一个逗号分隔的分区列表,每个分区都有以字节、KB(k
)或 MB(m
)为单位的大小和一个括号中的名称。ro
后缀使分区对 MTD 只读,通常用于防止意外覆盖引导加载程序。芯片的最后一个分区的大小可能会被一个破折号(-
)替代,表示它应该占用剩余的所有空间。
你可以通过读取/proc/mtd
来查看运行时的配置摘要:
# cat /proc/mtd
dev: size erasesize name
mtd0: 00080000 00020000 "SPL"
mtd1: 000C3000 00020000 "U-Boot"
mtd2: 00020000 00020000 "U-BootEnv"
mtd3: 00400000 00020000 "Kernel"
mtd4: 07A9D000 00020000 "Filesystem"
每个分区在/sys/class/mtd
中有更详细的信息,包括擦除块大小和页面大小。可以通过mtdinfo
来很好的总结:
# mtdinfo /dev/mtd0
mtd0
Name: SPL
Type: nand
272 Creating a Storage Strategy
Eraseblock size: 131072 bytes, 128.0 KiB
Amount of eraseblocks: 4 (524288 bytes, 512.0 KiB)
Minimum input/output unit size: 2048 bytes
Sub-page size: 512 bytes
OOB size: 64 bytes
Character device major/minor: 90:0
Bad blocks are allowed: true
Device is writable: false
另一种指定 MTD 分区的方法是通过设备树。下面是一个例子,它创建了与命令行示例相同的分区:
nand@0,0 {
#address-cells = <1>;
#size-cells = <1>;
partition@0 {
label = "SPL";
reg = <0 0x80000>;
};
partition@80000 {
label = "U-Boot";
reg = <0x80000 0xc3000>;
};
partition@143000 {
label = "U-BootEnv";
reg = <0x143000 0x20000>;
};
partition@163000 {
label = "Kernel";
reg = <0x163000 0x400000>;
};
partition@563000 {
label = "Filesystem";
reg = <0x563000 0x7a9d000>;
};
};
第三种替代方法是将分区信息作为平台数据编码在mtd_partition
结构中,如下面从arch/arm/mach-omap2/board-omap3beagle.c
中提取的示例所示(NAND_BLOCK_SIZE
在其他地方定义为 128 KB):
static struct mtd_partition omap3beagle_nand_partitions[] = {
{
.name = "X-Loader",
.offset = 0,
.size = 4 * NAND_BLOCK_SIZE,
.mask_flags = MTD_WRITEABLE, /* force read-only */
},
{
.name = "U-Boot",
.offset = 0x80000;
.size = 15 * NAND_BLOCK_SIZE,
.mask_flags = MTD_WRITEABLE, /* force read-only */
},
{
.name = "U-Boot Env",
.offset = 0x260000;
.size = 1 * NAND_BLOCK_SIZE,
},
{
.name = "Kernel",
.offset = 0x280000;
.size = 32 * NAND_BLOCK_SIZE,
},
{
.name = "File System",
.offset = 0x680000;
.size = MTDPART_SIZ_FULL,
},
};
平台数据已被弃用:你只能在未更新以使用设备树的旧 SoC 的 BSP 中找到它。
MTD 设备驱动
MTD 子系统的上层包含一对设备驱动:
-
一个主设备号为
90
的字符设备。每个 MTD 分区号 N 有两个设备节点:/dev/mtdN
(次设备号=N2)和/dev/mtdNro
(次设备号=(N2 + 1))。后者只是前者的只读版本。 -
一个主设备号为
31
,次设备号为 N 的块设备。设备节点形式为/dev/mtdblockN
。
首先来看字符设备,因为它是两者中最常用的。字符设备的行为类似于存储中的文件,你可以轻松地从中读取文本并向其写入文本。
MTD 字符设备,mtd
字符设备是最重要的:它们允许你将底层闪存内存作为字节数组进行访问,从而能够读取和写入(编程)闪存。它还实现了一些ioctl
函数,允许你擦除块并管理 NAND 芯片上的 OOB 区域。以下列表摘自include/uapi/mtd/mtd-abi.h
:
-
MEMGETINFO
:获取基本的 MTD 特性信息。 -
MEMERASE
:擦除 MTD 分区中的块。 -
MEMWRITEOOB
:写入页面的带外数据。 -
MEMREADOOB
:读取页面的带外数据。 -
MEMLOCK
:锁定芯片(如果支持)。 -
MEMUNLOCK
:解锁芯片(如果支持)。 -
MEMGETREGIONCOUNT
:获取擦除区域的数量:如果分区中有不同大小的擦除块(这在 NOR 闪存中很常见,但在 NAND 中很少见),则返回非零值。 -
MEMGETREGIONINFO
:如果MEMGETREGIONCOUNT
非零,则可用于获取每个区域的偏移量、大小和块数。 -
MEMGETOOBSEL
:已弃用。 -
MEMGETBADBLOCK
:获取坏块标志。 -
MEMSETBADBLOCK
:设置坏块标志。 -
OTPSELECT
:如果芯片支持,则设置 OTP(一次性可编程)模式。 -
OTPGETREGIONCOUNT
:获取 OTP 区域的数量。 -
OTPGETREGIONINFO
:获取 OTP 区域的信息。 -
ECCGETLAYOUT
:已弃用。
有一组称为mtd-utils
的实用程序,用于操作闪存内存,这些实用程序使用这些ioctl
函数。源代码可以在git://git.infradead.org/mtd-utils.git
找到,并且在 Yocto 项目和 Buildroot 中作为包提供。基本工具在以下列表中列出。该包还包含 JFFS2 和 UBI/UBIFS 文件系统的实用程序,我将在后面介绍。对于这些工具中的每一个,MTD 字符设备是以下参数之一:
-
flash_erase
:擦除一系列块。 -
flash_lock
:锁定一系列块。 -
flash_unlock
:解锁一系列块。 -
nanddump:
从 NAND 闪存转储内存,可以选择性地包括 OOB 区域。跳过坏块。 -
nandtest
:测试并执行 NAND 闪存的诊断。 -
nandwrite
:将数据从文件写入 NAND 闪存,跳过坏块。提示
在写入新内容之前,您必须始终擦除闪存。
flash_erase
是执行此操作的命令。
要编程 NOR 闪存,您只需使用类似 cp 的文件复制命令将字节复制到 MTD 设备节点。
不幸的是,这在 NAND 闪存上不起作用,因为在第一个坏块时复制会失败。相反,使用nandwrite
,它会跳过任何坏块。要读取 NAND 闪存,您应该使用nanddump
,它也会跳过坏块。
MTD 块设备,mtdblock
mtdblock
驱动程序不常使用。它的作用是将闪存呈现为一个块设备,您可以用它来格式化并挂载文件系统。然而,它有严重的限制,因为它不处理 NAND 闪存中的坏块,不进行磨损均衡,也不处理文件系统块与闪存擦除块之间的大小不匹配。换句话说,它没有闪存转换层,而闪存转换层对可靠的文件存储至关重要。mtdblock
设备唯一有用的情况是将只读文件系统(如 SquashFS)挂载在可靠的闪存上,如 NOR 闪存。
提示
如果您需要在 NAND 闪存上使用只读文件系统,应该使用本章后面描述的 UBI 驱动程序。
将内核异常日志记录到 MTD
内核错误或崩溃通常通过 klogd
和 syslogd
守护进程记录到一个循环内存缓冲区或文件中。在重启之后,如果是环形缓冲区,日志会丢失。即便是文件,也可能在系统崩溃之前未能正确写入。更可靠的方法是将崩溃和内核恐慌信息写入一个 MTD 分区作为一个循环日志缓冲区。你可以通过启用 CONFIG_MTD_OOPS
并将 console=ttyMTDN
添加到内核命令行来启用它,N
是 MTD 设备编号,用于将信息写入该设备。
模拟 NAND 内存
NAND 模拟器通过系统 RAM 模拟 NAND 芯片。其主要用途是测试必须了解 NAND 的代码,而没有访问物理 NAND 内存的能力。模拟坏块、位翻转和其他错误的功能使你能够测试一些难以通过真实闪存内存进行的代码路径。更多信息可以参考代码本身,它提供了关于如何配置驱动程序的全面描述。代码位于 drivers/mtd/nand/nandsim.c
。通过 CONFIG_MTD_NAND_NANDSIM
内核配置来启用它。
MMC 块驱动
MMC/SD 卡和 eMMC 芯片使用 mmcblk
块驱动访问。你需要一个主机控制器来匹配你所使用的 MMC 适配器,它是板支持包的一部分。驱动程序位于 Linux 源代码中的 drivers/mmc/host
下。
MMC 存储使用分区表进行分区,方式与硬盘完全相同。也就是说,通过使用 fdisk
或类似的工具。
我们现在知道了 Linux 如何访问每种类型的闪存。接下来,我们将查看闪存固有的问题以及 Linux 如何通过文件系统或块设备驱动来处理它们。
闪存内存的文件系统
在有效利用闪存作为大容量存储时面临几个挑战:擦除块的大小与磁盘扇区的大小不匹配、每个擦除块的擦除周期有限以及 NAND 芯片上需要处理坏块。这些差异通过 闪存翻译层(FTL)来解决。
闪存翻译层
闪存翻译层具有以下特点:
-
子分配:文件系统在使用较小的分配单元时表现最佳,传统上是 512 字节的扇区。这个大小远小于一个 128 KB 或更大的闪存擦除块。因此,擦除块需要被细分为更小的单元,以避免浪费大量空间。
-
垃圾回收:子分配的一个结果是,一旦文件系统使用了一段时间,擦除块将包含良好的数据和陈旧的数据的混合体。由于我们只能释放整个擦除块,因此回收这些空闲空间的唯一方法是将好的数据合并到一个地方,然后将现在空的擦除块返回到空闲列表中。这就是所谓的垃圾回收,通常它作为一个后台线程实现。
-
磨损均衡:每个块的擦除次数是有限制的。为了最大化芯片的使用寿命,重要的是要移动数据,以便每个块的擦除次数大致相同。
-
坏块处理:在 NAND 闪存芯片上,必须避免使用任何标记为坏的块,如果某个块无法擦除,也需要将其标记为坏块。
-
鲁棒性:嵌入式设备可能会在没有预警的情况下断电或重启。任何文件系统都应该能够在不损坏的情况下应对,通常通过集成日志或事务日志来实现。
部署闪存转换层有几种方式:
-
在文件系统中:如 JFFS2、YAFFS2 和 UBIFS。
-
在块设备驱动程序中:UBIFS 所依赖的 UBI 驱动程序实现了闪存转换层的一些方面。
-
在设备控制器中:如在管理闪存设备中。
当闪存转换层位于文件系统或块设备驱动程序中时,代码是内核的一部分,因此我们可以看到它的工作原理,并预期它会随着时间的推移得到改进。另一方面,如果 FTL 在一个管理的闪存设备内部,它就被隐藏起来,我们无法验证它是否按照我们的需求工作。不仅如此,将 FTL 放入磁盘控制器意味着它会错过文件系统层所持有的有用信息,比如哪些扇区属于已删除的文件。后者的问题通过添加命令来解决,这些命令可以在文件系统和设备之间传递这些信息。我将在稍后的 TRIM
命令部分描述它是如何工作的。然而,代码可见性的问题依然存在。如果你使用的是管理闪存,你只能选择一个你信任的制造商。
现在我们已经了解了文件系统的动机,接下来看看哪些文件系统最适合哪种类型的闪存。
NOR 和 NAND 闪存存储器的文件系统
为了使用原始闪存芯片进行大规模存储,必须使用能够理解底层技术特性的文件系统。有三种这样的文件系统:
-
JFFS2(日志闪存文件系统 2):这是第一个用于 Linux 的闪存文件系统,至今仍在使用。它适用于 NOR 和 NAND 存储器,但在挂载时速度较慢。
-
YAFFS2(另一个闪存文件系统 2):这类似于 JFFS2,但专门用于 NAND 闪存存储器。它被 Google 作为 Android 设备上首选的原始闪存文件系统。
-
UBIFS(无序块映像文件系统):它与 UBI 块驱动程序配合使用,创建一个可靠的闪存文件系统。它适用于 NOR 和 NAND 存储器。由于它通常比 JFFS2 或 YAFFS2 提供更好的性能,因此应该是新设计中的首选解决方案。
所有这些都使用 MTD 作为与闪存存储的公共接口。
JFFS2
日志闪存文件系统起源于 1999 年为 Axis 2100 网络摄像头开发的软件。多年来,它是 Linux 上唯一的闪存文件系统,并已在许多不同类型的设备上部署。今天,它已不再是最佳选择,但我会首先介绍它,因为它展示了演变路径的开始。
JFFS2 是一个日志结构文件系统,使用 MTD 来访问闪存。在日志结构文件系统中,变更按顺序写入闪存作为节点。一个节点可能包含目录的变更,比如创建和删除的文件名,或者包含文件数据的变更。经过一段时间后,节点可能会被后续节点中的信息所替代,从而成为过时节点。NOR 和 NAND 闪存都被组织为擦除块。擦除一个块会将其所有位设置为 1。
JFFS2 将擦除块分类为三种类型:
-
空闲的:完全不包含任何节点。
-
干净的:只包含有效节点。
-
脏的:至少包含一个过时的节点。
在任何时候,只有一个块正在接收更新,这个块被称为开放块。如果电源丢失或系统重置,唯一可能丢失的数据是对开放块的最后一次写入。此外,节点在写入时会被压缩,从而增加闪存芯片的有效存储容量,如果你使用的是昂贵的 NOR 闪存内存,这一点非常重要。
当空闲块的数量降到某个阈值以下时,会启动一个垃圾回收内核线程,它会扫描脏块,将有效节点复制到开放块中,然后释放脏块。
与此同时,垃圾回收器提供了一种粗略的磨损均衡形式,因为它将有效数据从一个块循环到另一个块。开放块的选择方式意味着,只要一个块包含会时常变化的数据,它就会被大致擦除相同次数。有时,垃圾回收会选择一个干净的块,以确保那些包含静态数据且很少写入的块也能实现磨损均衡。
JFFS2 文件系统具有写透缓存,这意味着写入操作会同步地写入闪存,好像它们已经使用-o
sync 选项挂载。虽然这种方式提高了可靠性,但也增加了写入数据的时间。小写入存在一个额外问题:如果写入的长度与节点头部的大小(40 字节)相当,开销会变得很高。一个著名的极限情况是类似syslogd
产生的日志文件。
汇总节点
JFFS2 有一个显著的缺点:由于没有片上索引,目录结构必须在挂载时通过从头到尾读取日志来推导出来。在扫描结束时,你可以得到一个完整的有效节点目录结构的图像,但挂载时间与分区大小成正比。通常可以看到每兆字节挂载时间大约为一秒,这会导致总挂载时间达到几十秒或几百秒。
总结节点在 Linux 2.6.15 中成为一个可选项,用于减少挂载时扫描的时间。总结节点在打开的擦除块末尾写入,在它被关闭之前。总结节点包含挂载时扫描所需的所有信息,从而减少扫描时需要处理的数据量。总结节点可以通过牺牲约 5%的存储空间,减少挂载时间的 2 到 5 倍。它们通过CONFIG_JFFS2_SUMMARY
内核配置来启用。
清洁标记
一个已擦除的块,所有位都设置为 1,无法与一个已经写入 1 的块区分开来,但后者的内存单元没有刷新,直到它被擦除后才能再次编程。JFFS2 使用一种名为清洁标记的机制来区分这两种情况。在成功擦除块后,清洁标记会被写入块的起始位置或块的第一页的 OOB 区域。如果存在清洁标记,则该块必须是清洁块。
创建 JFFS2 文件系统
在运行时创建一个空的 JFFS2 文件系统与擦除带有清洁标记的 MTD 分区,然后挂载它一样简单。没有格式化步骤,因为空的 JFFS2 文件系统完全由空闲块组成。例如,要格式化 MTD 分区 6,你可以在设备上输入以下命令:
# flash_erase -j /dev/mtd6 0 0
# mount -t jffs2 mtd6 /mnt
-j
选项添加清洁标记,挂载时使用jffs2
类型将分区呈现为空文件系统。请注意,挂载的设备应指定为mtd6
,而不是/dev/mtd6
。或者,你可以使用/dev/mtdblock6
设备节点。这只是 JFFS2 的一个特殊性。一旦挂载,你就可以像使用其他文件系统一样使用它。
你可以直接从开发系统的暂存区创建文件系统镜像,使用mkfs.jffs2
将文件写出为 JFFS2 格式,并使用sumtool
添加总结节点。这两者都属于mtd-utils
包的一部分。
例如,要创建一个包含rootfs
文件的镜像,目标是一个擦除块大小为 128 KB(0x20000
)并带有总结节点的 NAND 闪存设备,你可以使用以下两个命令:
$ mkfs.jffs2 -n -e 0x20000 -p -d ~/rootfs -o ~/rootfs.jffs2
$ sumtool -n -e 0x20000 -p -i ~/rootfs.jffs2 -o ~/rootfs-sum.jffs2
-p
选项会在镜像文件的末尾添加填充,以使其成为一个完整的擦除块数量。-n
选项会抑制在镜像中创建干净标记,这对于 NAND 设备来说是正常的,因为干净标记位于 OOB 区域。对于 NOR 设备,则可以省略 -n
选项。你可以使用设备表和 mkfs.jffs2
来设置文件的权限和所有权,方法是添加 -D <device table>
。当然,Buildroot 和 Yocto Project 会为你自动完成这一切。
你可以从引导加载程序将镜像写入闪存。例如,如果你已将一个文件系统镜像加载到地址 0x82000000
的 RAM 中,并且想将其加载到从闪存芯片起始位置 0x163000
字节开始的闪存分区,该分区长度为 0x7a9d000
字节,你可以使用以下 U-Boot 命令:
nand erase clean 163000 7a9d000
nand write 82000000 163000 7a9d000
你也可以通过 Linux 使用 mtd
驱动程序来执行相同的操作,如下所示:
# flash_erase -j /dev/mtd6 0 0
# nandwrite /dev/mtd6 rootfs-sum.jffs2
要使用 JFFS2 根文件系统启动,你需要在内核命令行中传递 mtdblock
设备的分区信息,并且指定 rootfstype
,因为 JFFS2 不能自动检测:
root=/dev/mtdblock6 rootfstype=jffs2
在 JFFS2 引入后不久,另一种日志结构文件系统出现了。
YAFFS2
YAFFS 文件系统由 Charles Manning 于 2001 年编写,用于处理当时 JFFS2 无法处理的 NAND 闪存芯片。之后,为了处理更大(2 KB)页面大小的变化,开发出了 YAFFS2。YAFFS 的官方网站是 yaffs.net/
。
YAFFS 也是一个日志结构文件系统,遵循与 JFFS2 相同的设计原则。不同的设计决策意味着它具有更快的挂载时间扫描、更简单且更快速的垃圾回收,并且没有压缩,这提高了读写速度,但牺牲了存储利用效率。
YAFFS 并不限于 Linux,它已经移植到多种操作系统。它有双重许可:GPLv2(与 Linux 兼容)和适用于其他操作系统的商业许可证。不幸的是,YAFFS 代码从未合并到主线 Linux 中,因此你需要为内核打补丁。
要获取 YAFFS2 并为内核打补丁,请执行以下操作:
$ git clone git://www.aleph1.co.uk/yaffs2
$ cd yaffs2
$ ./patch-ker.sh c m <path to your link source>
然后,你可以通过 CONFIG_YAFFS_YAFFS2
配置内核。
创建 YAFFS2 文件系统
与 JFFS2 一样,要在运行时创建 YAFFS2 文件系统,你只需要擦除分区并挂载它,但请注意,在这种情况下,你不会启用干净标记:
# flash_erase /dev/mtd/mtd6 0 0
# mount -t yaffs2 /dev/mtdblock6 /mnt
要创建文件系统镜像,最简单的方式是使用 code.google.com/archive/p/yaffs2utils/
中的 mkyaffs2
工具:
$ mkyaffs2 -c 2048 -s 64 rootfs rootfs.yaffs2
在这里,-c
是页大小,-s
是 OOB 大小。有一个名为mkyaffs2image
的工具,它是 YAFFS 代码的一部分,但有一些缺点。首先,页大小和 OOB 大小是硬编码在源代码中的,因此如果你的内存与默认的 2,048 和 64 不匹配,你将需要编辑并重新编译。其次,OOB 布局与 MTD 不兼容,MTD 使用前两个字节作为坏块标记,而mkyaffs2image
则用这些字节存储部分 YAFFS 元数据。
要从目标设备上的 Linux shell 提示符将映像复制到 MTD 分区,请按照以下步骤操作:
# flash_erase /dev/mtd6 0 0
# nandwrite -a /dev/mtd6 rootfs.yaffs2
要使用 YAFFS2 根文件系统启动,请在内核命令行中添加以下内容:
root=/dev/mtdblock6 rootfstype=yaffs2
在讨论原始 NOR 和 NAND 闪存的文件系统时,让我们来看一下其中一种更现代的选项。这个文件系统运行在 UBI 驱动程序之上。
UBI 和 UBIFS
未排序块映像(UBI)驱动程序是一个闪存卷管理器,负责坏块处理和磨损均衡。它由 Artem Bityutskiy 实现,并首次出现在 Linux 2.6.22 中。与此同时,诺基亚的工程师们正在开发一个文件系统,旨在利用 UBI 的特性,称之为 UBIFS。它出现在 Linux 2.6.27 中。通过这种方式分离闪存转换层,使得代码更加模块化,也允许其他文件系统利用 UBI 驱动程序,正如我们稍后将看到的。
UBI
UBI 通过将物理擦除块(PEB)映射到逻辑擦除块(LEB),提供了一个理想化、可靠的闪存芯片视图。坏块不会映射到 LEB,因此永远不会使用。如果一个块无法擦除,它将被标记为坏块,并从映射中删除。UBI 在 LEB 的头部记录每个 PEB 被擦除的次数,然后更改映射,确保每个 PEB 被擦除相同的次数。
UBI 通过 MTD 层访问闪存。作为额外功能,它可以将 MTD 分区划分为多个 UBI 卷,从而改善磨损均衡。假设你有两个文件系统:一个包含相对静态的数据,如根文件系统,另一个包含不断变化的数据。
如果它们存储在单独的 MTD 分区中,磨损均衡只会影响第二个分区。而如果选择将它们存储在同一个 MTD 分区中的两个 UBI 卷中,磨损均衡将覆盖存储的两个区域,从而延长闪存的使用寿命。下图展示了这种情况:
图 9.4 – UBI 卷
通过这种方式,UBI 实现了闪存转换层的两个要求:磨损均衡和坏块处理。
为了准备一个 MTD 分区用于 UBI,你不需要像在 JFFS2 和 YAFFS2 中那样使用 flash_erase
。相反,你需要使用 ubiformat
工具,它会保留存储在 PEB 头部的擦除计数。ubiformat
需要知道 I/O 的最小单位,对于大多数 NAND 闪存芯片来说是页面大小,但一些芯片允许在子页(页面的一半或四分之一)中读取和写入。有关详细信息,请参考芯片的数据手册,如果不确定,请使用页面大小。此示例使用 2048 字节的页面大小准备 mtd6
:
# ubiformat /dev/mtd6 -s 2048
ubiformat: mtd0 (nand), size 134217728 bytes (128.0 MiB), 1024 eraseblocks of 131072 bytes (128.0 KiB), min. I/O size 2048 bytes
然后,你可以使用 ubiattach
命令来加载 UBI 驱动程序到一个已经按照这种方式准备好的 MTD 分区上:
# ubiattach -p /dev/mtd6 -O 2048
UBI device number 0, total 1024 LEBs (130023424 bytes, 124.0 MiB), available 998 LEBs (126722048 bytes, 120.9 MiB), LEB size 126976 bytes (124.0 KiB)
这会创建 /dev/ubi0
设备节点,你可以通过它访问 UBI 卷。你可以在多个 MTD 分区上使用 ubiattach
,这样它们可以通过 /dev/ubi1
、/dev/ubi2
等进行访问。请注意,由于每个 LEB 都有一个包含 UBI 使用的元信息的头部,因此 LEB 比 PEB 小两个页面。例如,一个 PEB 大小为 128 KB,页面大小为 2 KB 的芯片,其 LEB 大小为 124 KB。这是创建 UBIFS 映像时需要了解的重要信息。
PEB 到 LEB 的映射会在附加阶段加载到内存中,这个过程的时间与 PEB 的数量成正比,通常需要几秒钟。在 Linux 3.7 中添加了一个新功能,叫做 UBI 快速映射,它会定期将映射信息检查点保存到闪存中,从而减少了附加时间。与此相关的内核配置选项是 CONFIG_MTD_UBI_FASTMAP
。
在第一次附加到一个经过 ubiformat
格式化的 MTD 分区时,将没有卷。你可以使用 ubimkvol
创建卷。例如,假设你有一个 128 MB 的 MTD 分区,想将其分割为两个卷。第一个卷的大小为 32 MB,第二个卷将占用剩余的空间:
# ubimkvol /dev/ubi0 -N vol_1 -s 32MiB
Volume ID 0, size 265 LEBs (33648640 bytes, 32.1 MiB), LEB size 126976 bytes (124.0 KiB), dynamic, name "vol_1", alignment 1
# ubimkvol /dev/ubi0 -N vol_2 -m
Volume ID 1, size 733 LEBs (93073408 bytes, 88.8 MiB), LEB size 126976 bytes (124.0 KiB), dynamic, name "vol_2", alignment 1
现在,你有一个包含两个节点的设备:/dev/ubi0_0
和 /dev/ubi0_1
。你可以使用 ubinfo
来确认这一点:
# ubinfo -a /dev/ubi0
ubi0
Volumes count: 2
Logical eraseblock size: 126976 bytes, 124.0 KiB
Total amount of logical eraseblocks: 1024 (130023424 bytes, 124.0 MiB)
Amount of available logical eraseblocks: 0 (0 bytes)
Maximum count of volumes 128
Count of bad physical eraseblocks: 0
Count of reserved physical eraseblocks: 20
Current maximum erase counter value: 1
Minimum input/output unit size: 2048 bytes
Character device major/minor: 250:0
Present volumes: 0, 1
Volume ID: 0 (on ubi0)
Type: dynamic
Alignment: 1
Size: 265 LEBs (33648640 bytes, 32.1 MiB)
State: OK
Name: vol_1
Character device major/minor: 250:1
-----------------------------------
Volume ID: 1 (on ubi0)
Type: dynamic
Alignment: 1
Size: 733 LEBs (93073408 bytes, 88.8 MiB)
State: OK
Name: vol_2
Character device major/minor: 250:2
到这个时候,你有一个 128 MB 的 MTD 分区,包含两个 UBI 卷,大小分别为 32 MB 和 88.8 MB。总可用存储空间为 32 MB 加 88.8 MB,总计为 120.8 MB。剩余空间 7.2 MB 被 UBI 头部占用,这些头部位于每个 PEB 的开头,同时空间还为在芯片生命周期中可能损坏的块进行预留。
UBIFS
UBIFS 使用 UBI 卷来创建一个强大的文件系统。它通过添加子分配和垃圾回收功能,构建了一个完整的闪存转换层。与 JFFS2 和 YAFFS2 不同,它将索引信息存储在芯片上,因此挂载速度非常快,尽管不要忘记,事先附加 UBI 卷可能会耗费相当多的时间。它还允许像普通磁盘文件系统一样进行写回缓存,这样写入速度会更快,但在断电的情况下,未刷新到闪存的数据将会丢失。你可以通过小心使用 fsync(2)
和 fdatasync(2)
函数,在关键时刻强制刷新文件数据来解决这个问题。
UBIFS 具有日志功能,可以在断电时快速恢复。日志的最小大小为 4 MB,因此 UBIFS 不适用于非常小的闪存设备。
创建了 UBI 卷后,你可以使用卷的设备节点来挂载它们,例如 /dev/ubi0_0
,或通过使用整个分区的设备节点加上卷名称来挂载,如下所示:
# mount -t ubifs ubi0:vol_1 /mnt
为 UBIFS 创建文件系统镜像是一个两阶段的过程。首先,使用 mkfs.ubifs
创建一个 UBIFS 镜像,然后使用 ubinize
将其嵌入到 UBI 卷中。在第一阶段,mkfs.ubifs
需要通过 -m
参数告知页面大小,通过 -e
参数指定 UBI LEB 的大小,通过 -c
参数指定卷中最大擦除块的数量。如果第一个卷为 32 MB,且擦除块大小为 128 KB,那么擦除块的数量为 256。因此,要将 rootfs
目录的内容创建为名为 rootfs.ubi
的 UBIFS 镜像,可以输入以下命令:
$ mkfs.ubifs -r rootfs -m 2048 -e 124KiB -c 256 -o rootfs.ubi
第二阶段要求你为 ubinize
创建一个配置文件,描述镜像中每个卷的特性。帮助页面(ubinize -h
)提供了关于格式的详细信息。此示例创建了两个卷(vol_1
和 vol_2
):
[ubifsi_vol_1]
mode=ubi
image=rootfs.ubi
vol_id=0
vol_name=vol_1
vol_size=32MiB
vol_type=dynamic
[ubifsi_vol_2]
mode=ubi
image=data.ubi
vol_id=1
vol_name=vol_2
vol_type=dynamic
vol_flags=autoresize
第二个卷具有自动调整大小的标志,因此会扩展以填满 MTD 分区上的剩余空间。只有一个卷可以拥有这个标志。从这些信息中,ubinize
将创建一个由 -o
参数指定的镜像文件,PEB 大小为 -p
,页面大小为 -m
,子页面大小为 -s
:
$ ubinize -o ~/ubi.img -p 128KiB -m 2048 -s 512 ubinize.cfg
要将此镜像安装到目标设备上,你可以在目标设备上输入以下命令:
# ubiformat /dev/mtd6 -s 2048
# nandwrite /dev/mtd6 /ubi.img
# ubiattach -p /dev/mtd6 -O 2048
如果你想使用 UBIFS 根文件系统启动,你需要提供以下内核命令行参数:
ubi.mtd=6 root=ubi0:vol_1 rootfstype=ubifs
UBIFS 完成了对原始 NOR 和 NAND 闪存文件系统的调查。接下来,我们将研究托管闪存的文件系统。
托管闪存的文件系统
随着托管闪存技术的趋势不断发展,特别是 eMMC,我们需要考虑如何有效地使用它们。虽然它们看起来具有与硬盘驱动器相同的特性,但底层的 NAND 闪存芯片存在大擦除块、擦除周期有限和坏块处理等限制。我们还需要在断电情况下确保系统的鲁棒性。
你可以使用任何常见的磁盘文件系统,但我们应该尝试选择一个能够减少磁盘写入并且在非计划性关机后能够快速重启的文件系统。
Flashbench
为了充分利用底层的闪存存储器,你需要知道擦除块大小和页面大小。通常,制造商不会公布这些数字,但通过观察芯片或卡的行为,可以推测出这些参数。
Flashbench 就是这样一个工具。最初由 Arnd Bergman 编写,详细信息可以参阅 lwn.net/Articles/428584
中的 LWN 文章。你可以从 github.com/bradfa/flashbench
获取代码。
这是在 SanDisk 4 GB SDHC 卡上的典型运行:
$ sudo ./flashbench -a /dev/mmcblk0 --blocksize=1024
align 536870912 pre 4.38ms on 4.48ms post 3.92ms diff 332µs
align 268435456 pre 4.86ms on 4.9ms post 4.48ms diff 227µs
align 134217728 pre 4.57ms on 5.99ms post 5.12ms diff 1.15ms
align 67108864 pre 4.95ms on 5.03ms post 4.54ms diff 292µs
align 33554432 pre 5.46ms on 5.48ms post 4.58ms diff 462µs
align 16777216 pre 3.16ms on 3.28ms post 2.52ms diff 446µs
align 8388608 pre 3.89ms on 4.1ms post 3.07ms diff 622µs
align 4194304 pre 4.01ms on 4.89ms post 3.9ms diff 940µs
align 2097152 pre 3.55ms on 4.42ms post 3.46ms diff 917µs
align 1048576 pre 4.19ms on 5.02ms post 4.09ms diff 876µs
align 524288 pre 3.83ms on 4.55ms post 3.65ms diff 805µs
align 262144 pre 3.95ms on 4.25ms post 3.57ms diff 485µs
align 131072 pre 4.2ms on 4.25ms post 3.58ms diff 362µs
align 65536 pre 3.89ms on 4.24ms post 3.57ms diff 511µs
align 32768 pre 3.94ms on 4.28ms post 3.6ms diff 502µs
align 16384 pre 4.82ms on 4.86ms post 4.17ms diff 372µs
align 8192 pre 4.81ms on 4.83ms post 4.16ms diff 349µs
align 4096 pre 4.16ms on 4.21ms post 4.16ms diff 52.4µs
align 2048 pre 4.16ms on 4.16ms post 4.17ms diff 9ns
在这种情况下,flashbench
会在不同的二次幂边界前后读取 1,024 字节的块。当你跨越一个页面或擦除一个块边界时,边界后的读取时间会更长。最右边的列显示的是差异,也是最值得关注的地方。从底部开始,在 4 KB 时有一个大跳跃,这很可能是一个页面的大小。8 KB 处有第二次跳跃,从 52.4 微秒跳到 349 微秒。这是相当常见的,表明卡片可以使用多平面访问同时读取两个 4 KB 页面。之后,差异不再那么明显,但在 512 KB 处,差异从 485 微秒跳到 805 微秒,这可能是擦除块的大小。考虑到所测试的卡片相当陈旧,这些数字是可以预期的。
丢弃和 TRIM
通常,当你删除一个文件时,只有修改后的目录节点会被写入存储,而包含文件内容的扇区保持不变。当闪存翻译层位于磁盘控制器中时,像受控闪存一样,它并不知道这组磁盘扇区不再包含有效数据,因此它最终会复制过时的数据。
在过去的几年里,通过向磁盘控制器传递有关已删除扇区的信息的事务的增加,改善了这种情况。SCSI 和 SATA 规范有一个 TRIM
命令,MMC 也有一个类似的命令,称为 ERASE
。在 Linux 中,这个功能被称为 discard。
要利用丢弃功能,你需要一个支持它的存储设备——大多数当前的 eMMC 芯片都支持——以及一个匹配的 Linux 设备驱动程序。你可以通过查看 /sys/block/<block device>/queue/
中的块系统队列参数来检查。
关注的项如下:
-
discard_granularity
:设备的内部分配单元的大小。 -
discard_max_bytes
:一次可以丢弃的最大字节数。 -
discard_zeroes_data
:如果设置为1
,丢弃的数据将被设置为 0。
如果设备或设备驱动程序不支持丢弃,这些值将全部设置为 0
。举个例子,这些是我在 BeagleBone Black 上看到的 2 GB eMMC 芯片的参数:
# grep -s "" /sys/block/mmcblk0/queue/discard_*
/sys/block/mmcblk0/queue/discard_granularity:2097152
/sys/block/mmcblk0/queue/discard_max_bytes:2199023255040
/sys/block/mmcblk0/queue/discard_zeroes_data:1
更多信息可以在 Documentation/block/queue-sysfs.txt
内核文档文件中找到。
你可以通过在挂载文件系统时添加 -o
discard 选项来启用丢弃。ext4 和 F2FS 都支持此功能。
提示
在使用 -o
discard 挂载选项之前,确保存储设备支持丢弃,因为数据丢失可能会发生。
还可以通过命令行强制丢弃,无论分区如何挂载,使用 fstrim
命令,它是 util-linux
包的一部分。通常,你会定期运行这个命令来释放未使用的空间。fstrim
在已挂载的文件系统上操作,因此要修剪根文件系统,你需要输入以下命令:
# sudo fstrim -v /
/: 2061000704 bytes were trimmed
上面的示例使用了-v
详细模式选项,以便打印出已释放的字节数。在这种情况下,2,061,000,704 是文件系统中大约可用的空闲空间,因此它是可能已被修剪的最大存储量。
Ext4
扩展文件系统(ext)自 1992 年以来一直是 Linux 桌面的主要文件系统。目前的版本(ext4)非常稳定,经过充分测试,并且拥有一个日志系统,可以在未计划的关机后快速且基本无痛地恢复。它是管理闪存设备的良好选择,你会发现它是配备 eMMC 存储的 Android 设备的首选文件系统。如果设备支持丢弃,你可以在其上通过-o
discard 选项挂载 ext4 文件系统。
要在运行时格式化并创建 ext4 文件系统,输入以下命令:
# mkfs.ext4 /dev/mmcblk0p2
# mount -t ext4 -o discard /dev/mmcblk0p1 /mnt
要在构建时创建文件系统镜像,可以使用来自github.com/bestouff/genext2fs
的genext2fs
工具。在这个示例中,我使用-B
选项指定了块大小,使用-b
选项指定了镜像中的块数:
$ genext2fs -B 1024 -b 10000 -d rootfs rootfs.ext4
genext2fs
可以使用设备表来设置文件权限和所有权,具体操作参见第五章,使用-D <文件表>
选项。
顾名思义,这将生成一个 ext2 格式的镜像。你可以通过如下方式使用tune2fs
命令将其升级为 ext4(命令选项的详细信息可以在tune2fs(8)
手册页中找到):
$ tune2fs -j -J size=1 -O filetype,extents,uninit_bg,dir_index rootfs.ext4
$ e2fsck -pDf rootfs.ext4
Yocto 项目和 Buildroot 在创建 ext4 格式镜像时完全按照这些步骤操作。
虽然日志是防止设备在没有预警的情况下关机时的资产,但它确实会为每次写入事务增加额外的写入周期,导致闪存逐渐磨损。如果设备是电池供电,尤其是当电池不可拆卸时,意外关机的几率较小,因此你可能希望不启用日志功能。
即使启用了日志系统,文件系统在意外断电时仍可能发生损坏。在许多设备中,长按电源按钮、拔掉电源线或取出电池都可能导致设备立即关机。
由于缓冲 I/O 的特性,在写入数据到闪存时,如果在写入完成之前断电,数据可能会丢失。出于这些原因,在挂载之前,最好以非交互方式运行fsck
检查用户分区,修复任何文件系统损坏。否则,损坏可能会随着时间的推移积累,直到成为严重问题。
F2FS
Flash 友好文件系统(F2FS)是一种日志结构文件系统,专为管理闪存设备设计,尤其是 eMMC 芯片和 SD 卡。它由三星开发,并在 Linux 3.8 中合并到主线内核。它被标记为实验性,这意味着它尚未广泛部署,但似乎一些 Android 设备已经在使用它。
F2FS 考虑了页面和擦除块的大小,并尽量在这些边界上对数据进行对齐。日志格式能够在断电情况下提供恢复能力,并且提供良好的写入性能。在一些测试中,F2FS 比 ext4 提高了两倍的性能。在Documentation/filesystems/f2fs.txt
内核文档中有关于 F2FS 设计的详细描述,另外在本章的进一步学习部分也有相关参考资料。
mkfs.f2fs
工具通过-l
标签创建一个空的 F2FS 文件系统:
# mkfs.f2fs -l rootfs /dev/mmcblock0p1
# mount -t f2fs /dev/mmcblock0p1 /mnt
目前没有工具可以用来离线创建 F2FS 文件系统镜像。
FAT16/32
旧的 Microsoft 文件系统(FAT16 和 FAT32)继续作为大多数操作系统都能识别的常见格式发挥着重要作用。当你购买 SD 卡或 USB 闪存驱动器时,它几乎可以确定会被格式化为 FAT32,在某些情况下,卡上的微控制器是为 FAT32 访问模式进行优化的。此外,某些引导 ROM 要求使用 FAT 分区作为第二阶段引导加载程序。然而,FAT 格式显然不适合存储关键文件,因为它们容易损坏,且存储空间利用率差。
Linux 通过msdos
和vfat
文件系统支持 FAT16,但 FAT32 仅通过vfat
文件系统得到支持。要挂载一个设备,比如 SD 卡,在第二个 MMC 硬件适配器上,输入以下命令:
# mount -t vfat /dev/mmcblock1p1 /mnt
重要提示
过去,vfat
驱动程序曾存在许可问题,这可能(或可能不会)侵犯微软持有的专利。
FAT32 在设备的容量上有 32 GB 的限制。更大容量的设备可以使用 Microsoft 的 exFAT 格式进行格式化,且这是 SDXC 卡的要求。exFAT 没有内核驱动程序,但可以通过用户空间 FUSE 驱动程序来支持。由于 exFAT 是微软的专有格式,如果你在设备上支持该格式,必然会涉及到许可问题。
这就是面向管理闪存的读写文件系统。那对于节省空间的只读文件系统呢?选择很简单:SquashFS。
只读压缩文件系统
如果存储空间不够用来放下所有数据,压缩数据是非常有用的。JFFS2 和 UBIFS 默认进行即时数据压缩。然而,如果文件永远不会被写入,通常根文件系统就是这种情况,你可以通过使用只读压缩文件系统来实现更好的压缩比。Linux 支持几种这种文件系统:romfs
、cramfs
和squashfs
。前两者现在已经过时,因此我只描述 SquashFS。
SquashFS
SquashFS 文件系统由 Phillip Lougher 在 2002 年编写,作为cramfs
的替代品。它长期作为内核补丁存在,最终在 2009 年被合并到 Linux 主线版本 2.6.29 中。它非常易于使用。你可以通过mksquashfs
创建一个文件系统镜像并将其安装到闪存中:
$ mksquashfs rootfs rootfs.squashfs
结果文件系统是只读的,因此没有机制可以在运行时修改任何文件。更新 SquashFS 文件系统的唯一方法是擦除整个分区并编程一个新的镜像。
SquashFS 并不具备坏块感知能力,因此必须与可靠的闪存(如 NOR 闪存)一起使用。不过,只要使用 UBI 来创建一个模拟的可靠 MTD,它也可以用于 NAND 闪存。你需要启用CONFIG_MTD_UBI_BLOCK
内核配置,它会为每个 UBI 卷创建一个只读的 MTD 块设备。以下图示展示了两个 MTD 分区,每个分区都有相应的mtdblock
设备。第二个分区还用来创建一个 UBI 卷,该卷作为第三个可靠的mtdblock
设备暴露,你可以将其用于任何不具备坏块感知的只读文件系统:
图 9.5 – UBI 卷
只读文件系统非常适合不可变的内容,但对于那些不需要在重启后保留的临时文件怎么办呢?这时 RAM 磁盘就派上用场了。
临时文件系统
总是有一些文件,它们的生命周期很短或在重启后没有意义。许多这样的文件被放入/tmp
中,因此避免这些文件进入永久存储是有意义的。
临时文件系统(tmpfs
)非常适合这个目的。你可以通过简单地挂载tmpfs
来创建一个基于 RAM 的临时文件系统:
# mount -t tmpfs tmp_files /tmp
和procfs
及sysfs
一样,tmpfs
没有关联的设备节点,因此你必须提供一个占位符字符串,前面的示例中是tmp_files
。
使用的内存量会随着文件的创建和删除而增减。默认的最大大小是物理 RAM 的一半。在大多数情况下,如果tmpfs
的大小达到那么大,那将是一场灾难,因此最好使用-o
大小参数来限制它。该参数可以用字节、KB(k)、MB(m)或 GB(g)来指定,例如:
# mount -t tmpfs -o size=1m tmp_files /tmp
除了/tmp
,/var
的一些子目录也包含易失数据,通常做法是使用tmpfs
来处理它们,方法可以是为每个子目录创建一个单独的文件系统,或者更经济地使用符号链接。Buildroot 就是这样做的:
/var/cache -> /tmp
/var/lock -> /tmp
/var/log -> /tmp
/var/run -> /tmp
/var/spool -> /tmp
/var/tmp -> /tmp
在 Yocto 项目中,/run
和/var/volatile
是tmpfs
挂载点,并有符号链接指向它们,如下所示:
/tmp -> /var/tmp
/var/lock -> /run/lock
/var/log -> /var/volatile/log
/var/run -> /run
/var/tmp -> /var/volatile/tmp
在嵌入式 Linux 系统中,将根文件系统加载到 RAM 中并不罕见。这样,运行时可能发生的任何文件损坏都不会是永久性的。根文件系统不一定需要保存在 SquashFS 或tmpfs
中以便得到保护。你只需要确保根文件系统是只读的。
使根文件系统只读
你需要确保目标设备能够应对意外事件,包括文件损坏,并且仍然能够启动并实现至少最低水平的功能。将根文件系统设置为只读是实现这一目标的关键部分,因为它消除了意外覆盖的风险。将其设置为只读非常简单,只需在内核命令行上将 rw
替换为 ro
,或者使用本身为只读的文件系统,例如 SquashFS。然而,你会发现一些文件和目录传统上是可写的:
-
/etc/resolv.conf
:该文件由网络配置脚本写入,用来记录 DNS 名称服务器的地址。此信息是易失性的,因此你只需将其作为符号链接指向临时目录,例如/etc/resolv.conf -> /var/run/resolv.conf
。 -
/etc/passwd
:此文件与/etc/group
、/etc/shadow
和/etc/gshadow
一起存储用户和组的名称及密码。它们需要与持久存储区域建立符号链接。 -
/var/lib
:许多应用程序期望能够写入该目录并在此处保持永久数据。一种解决方案是在启动时将一组基本文件复制到tmpfs
文件系统中,然后将/var/lib
绑定挂载到新位置。你可以通过将一系列命令添加到启动脚本中来实现这一点:$ mkdir -p /var/volatile/lib $ cp -a /var/lib/* /var/volatile/lib $ mount --bind /var/volatile/lib /var/lib
-
/var/log
:这是syslogd
和其他守护进程保存日志的地方。通常不建议将日志记录到闪存中,因为这会产生许多小的写入周期。一个简单的解决方法是使用tmpfs
挂载/var/log
,使所有日志消息都为易失性数据。对于syslogd
,BusyBox 有一个可以将日志记录到循环环形缓冲区的版本。
如果你正在使用 Yocto 项目,你可以通过在 conf/local.conf
或你的镜像配方中添加 IMAGE_FEATURES = "read-only-rootfs"
来创建一个只读根文件系统。
文件系统选择
到目前为止,我们已经看过固态存储器背后的技术以及多种类型的文件系统。现在是时候总结可用的选择了。在大多数情况下,你将能够将存储需求分为以下三类:
-
永久性可读写数据:运行时配置、网络参数、密码、数据日志和用户数据
-
永久性只读数据:常量的程序、库和配置文件;例如,根文件系统
-
易失性数据:临时存储;例如,
/tmp
可读写存储的选择如下:
-
NOR:UBIFS 或 JFFS2
-
NAND:UBIFS、JFFS2 或 YAFFS2
-
eMMC:ext4 或 F2FS
对于只读存储,你可以使用任何带有 ro
属性的挂载方式。此外,如果你想节省空间,可以使用 SquashFS。最后,对于易失性存储,只有一个选择:tmpfs
。
总结
从一开始,闪存就成为了嵌入式 Linux 的首选存储技术。多年来,Linux 在从低级驱动程序到闪存感知文件系统的各个方面获得了非常好的闪存支持,最新的文件系统是 UBIFS。
随着新型闪存技术的引入速度不断加快,跟上顶端技术的变化变得越来越困难。系统设计师越来越多地转向使用 eMMC 形式的托管闪存,以提供一种稳定的硬件和软件接口,该接口与内部的内存芯片无关。嵌入式 Linux 开发者开始逐步掌握这些新型芯片。TRIM
在 ext4 和 F2FS 中的支持已经非常成熟,并且慢慢地开始进入芯片本身。此外,针对闪存优化的新文件系统的出现,例如 F2FS,标志着向前迈出了可喜的一步。
然而,事实依然是,闪存并不等同于硬盘驱动器。在减少文件系统写入次数时必须小心,尤其是当较高密度的 TLC 芯片可能仅支持 1,000 次擦除周期时。
在下一章中,我们将继续讨论存储选项的主题,并考虑在可能部署到远程位置的设备上保持软件更新的不同方法。
深入研究
-
XIP:过去、现在……未来?,作者:Vitaly Wool –
archive.fosdem.org/2007/slides/devrooms/embedded/Vitaly_Wool_XIP.pdf
-
使用廉价闪存驱动器优化 Linux,作者:Arnd Bergmann –
lwn.net/Articles/428584/
-
eMMC/SSD 文件系统调优方法,Cogent Embedded, Inc. –
elinux.org/images/b/b6/EMMC-SSD_File_System_Tuning_Methodology_v1.0.pdf
-
闪存友好文件系统 (F2FS),作者:Joo-Young Hwang –
elinux.org/images/1/12/Elc2013_Hwang.pdf
-
F2FS 拆解,作者:Neil Brown –
lwn.net/Articles/518988/
第十章:现场软件更新
在前几章中,我们讨论了为 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/
第十四章:管理电源
对于使用电池供电的设备,电源管理至关重要。我们能做的任何减少功耗的事情都会延长电池寿命。即便是使用主电源的设备,减少功耗也能降低能源成本并减少散热需求。本章将介绍电源管理的四个原则:
-
如果不急,就不要着急。
-
不要为空闲而感到羞耻。
-
关闭不使用的设备。
-
没有其他事情做时就去休息吧。
更技术性地说,电源管理系统应当降低 CPU 时钟频率。在空闲时,应选择最深的睡眠状态;应通过关闭未使用的外设来减轻负载;并应确保整个系统进入挂起状态,同时确保电源状态的切换迅速。
Linux 具有解决这些问题的功能。我将逐一描述每个功能,并提供示例和如何将其应用于嵌入式系统的建议。
一些术语,如 C-state 和 P-state,取自 高级配置和电源接口(ACPI)规范。我们将在讨论时详细描述这些术语。该规范的完整参考可见于 进一步学习 部分。
本章将涵盖以下主题:
-
测量功耗
-
调整时钟频率
-
选择最佳空闲状态
-
关闭外设电源
-
将系统置于睡眠状态
技术要求
要跟随示例,确保你拥有以下设备:
-
一台 Ubuntu 24.04 或更高版本的 LTS 主机系统
-
一张 microSD 卡和读卡器
-
balenaEtcher for Linux
-
一根以太网线和一个有可用端口的路由器,用于网络连接
-
一条带有 3.3 V 逻辑电平的 USB 到 TTL 串口电缆
-
BeaglePlay
-
一款能够提供 3 A 电流的 5 V USB-C 电源
本章中使用的代码可以在本书 GitHub 仓库的章节文件夹中找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development
。
测量功耗
本章的示例需要使用真实硬件,而非虚拟硬件。这意味着我们需要一个具备正常电源管理的 BeaglePlay。BeaglePlay 的 电源管理集成电路(PMIC)所需的固件可能在 meta-ti
层中,但我并未深入调查。我们将使用预构建的 Debian 镜像。
在 BeaglePlay 上安装 Debian 的过程与第十二章相同。如果还没有,重新访问 在 BeaglePlay 上安装 Debian 部分,并用 Debian Bookworm 刷写 eMMC。移除 BeaglePlay 上的任何 microSD 卡,并从 eMMC 启动。通过 SSH 连接到 beaglebone.local
并以 debian
用户登录。
验证是否正在运行正确版本的 Debian:
debian@BeagleBone:~$ cat /etc/os-release
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
VERSION_CODENAME=bookworm
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
现在检查电源管理是否正常工作:
debian@BeagleBone:~$ cat /sys/power/state
freeze mem disk
如果你看到所有三个状态,那么一切正常。如果只看到freeze
,那么电源管理子系统没有工作。请返回并仔细检查之前的步骤。
现在我们可以继续进行功耗测量了。有两种方法:外部和内部。要进行外部功耗测量,我们需要一个安培计来测量电流,一个伏特计来测量电压,然后将两者相乘得到功率。你可以使用基本的仪表读取数据,然后记录下来,或者使用更复杂的设备,集成数据记录功能,以便你可以看到功率随负载波动的变化,精确到毫秒级。为了本章的目的,我使用 USB-C 端口为 BeaglePlay 供电,并使用一个便宜的 USB-C 电源监视器,这种监视器的价格只需要几美元。
另一种方法是使用内建于 Linux 的监控系统。你会发现很多信息通过sysfs
向你报告。还有一个非常有用的程序叫做PowerTOP,它从各种来源收集信息并集中展示。PowerTOP 是一个适用于 Yocto 项目和 Buildroot 的包,也可以在 Debian 上安装。
要在 Debian Bookworm 上为 BeaglePlay 安装 PowerTOP,请运行以下命令:
debian@BeagleBone:~$ sudo apt update
<…>
debian@BeagleBone:~$ sudo apt install powertop
[sudo] password for debian:
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
Suggested packages:
cpufrequtils laptop-mode-tools
The following NEW packages will be installed:
powertop
0 upgraded, 1 newly installed, 0 to remove and 39 not upgraded.
Need to get 183 kB of archives.
After this operation, 649 kB of additional disk space will be used.
Get:1 http://deb.debian.org/debian bookworm/main arm64 powertop arm64 2.14-1+b2 [183 kB]
Fetched 183 kB in 0s (1279 kB/s)
Selecting previously unselected package powertop.
(Reading database ... 72376 files and directories currently installed.)
Preparing to unpack .../powertop_2.14-1+b2_arm64.deb ...
Unpacking powertop (2.14-1+b2) ...
Setting up powertop (2.14-1+b2) ...
更新可用包列表并安装 PowerTOP 之前,别忘了将 BeaglePlay 连接到以太网。
这是 PowerTOP 在 BeaglePlay 上运行的一个例子:
图 14.1 – PowerTOP 概览
在这张截图中,我们可以看到系统处于空闲状态,仅使用了 2.7%的 CPU。稍后我将在本章的使用 CPUFreq和CPUIdle 驱动程序小节中展示更多有趣的例子。
既然我们有了测量功耗的方法,接下来我们来看看在嵌入式 Linux 系统中管理电源的一个重要调整项:时钟频率。
调整时钟频率
如果跑步一公里消耗的能量比走路更多,那么也许将 CPU 运行在较低频率下能节省能源。让我们来看看。
CPU 在执行代码时的功耗是静态组件和动态组件的总和,静态组件主要由门电流泄漏引起,动态组件由门切换引起:
P[cpu] = P[static] + P[dyn]
动态功耗组件依赖于被切换的逻辑门的总电容、时钟频率和电压的平方:
P[dyn] = CFV[2]
仅仅改变频率本身并不能节省能量,因为对于给定任务,需要完成相同数量的 CPU 周期。如果我们将频率降低一半,同时保持电压不变,那么完成任务的时间将是原来的两倍,尽管所消耗的总能量是相同的。事实上,降低频率可能会实际上增加功耗,因为 CPU 进入空闲状态所需的时间更长。特别是当没有其他竞争任务时,CPU 的空闲状态是非常节能的。因此,在这种情况下,最好使用尽可能高的频率,以便 CPU 可以快速返回空闲状态。这被称为 竞速空闲。
重要注意事项
降低频率的另一个动机是:热管理。有时可能需要以较低的频率运行,以保持封装温度在允许范围内。但这不是我们这里关注的重点。
因此,如果我们想要减少功耗,我们必须能够调整 CPU 核心的工作电压。但对于任何给定的电压,都有一个最大频率,超过该频率,晶体管门的切换将变得不可靠。更高的频率需要更高的电压,因此两者需要一起调整。许多 SoC 实现了这样的功能,这被称为 动态电压和频率调节 (DVFS) 。制造商计算出核心频率和电压的最佳组合。每个组合被称为 工作性能点 (OPP) 。ACPI 规范将它们称为 P 状态,其中 P0
是具有最高频率的 OPP。虽然 OPP 是频率和电压的组合,但它通常仅按频率来称呼。
需要一个内核驱动程序来在 P 状态之间进行切换。接下来,我们将查看该驱动程序及其控制的调节器。
CPUFreq 驱动
Linux 有一个名为 CPUFreq 的组件,用于管理不同 OPP(工作性能点)之间的转换。它是每个 SoC 包的板级支持的一部分。CPUFreq 包含驱动程序,用于实现从一个 OPP 到另一个 OPP 的过渡,以及一组调节器(governors),用于实现何时切换的策略。它是通过 /sys/devices/system/cpu/cpuN/cpufreq
目录对每个 CPU 进行控制,其中 N
是 CPU 编号。在该目录下,我们可以找到一些文件,其中最有趣的包括:
-
cpuinfo_cur_freq
、cpuinfo_max_freq
和cpuinfo_min_freq
:这些是该 CPU 的当前频率,以及最大频率和最小频率,单位为 KHz。 -
cpuinfo_transition_latency
:这是从一个 OPP 切换到另一个 OPP 所需的时间,单位为纳秒。如果值未知,则设置为-1
。 -
scaling_available_frequencies
:这是该 CPU 上可用的 OPP 频率列表。 -
scaling_available_governors
:这是该 CPU 上可用的调节器列表。 -
scaling_governor
:这是当前使用的 CPUFreq 调节器。 -
scaling_min_freq
和scaling_max_freq
:这是调度器在 KHz 中的可用频率范围。 -
scaling_setspeed
:这是一个文件,允许在调度器为userspace
时手动设置频率,我将在本小节最后描述。
调度器设置更改 OPP 的策略。它可以在scaling_min_freq
和scaling_max_freq
的限制之间设置频率。调度器命名如下:
-
performance
:这始终选择最高的频率。 -
powersave
:这始终选择最低频率。 -
userspace
:这是用户空间程序设置频率的地方。 -
ondemand
:这根据 CPU 的利用率更改频率。如果 CPU 空闲时间少于 20%,它会将频率设置为最大值。如果 CPU 空闲时间超过 30%,它会随着空闲时间的增加而降低频率。 -
conservative
:这类似于ondemand
,只是它以 5%的步长切换到更高的频率,而不是立即切换到最大频率。 -
schedutil
:这个旨在与 Linux 调度器更好地集成。
Debian Bookworm 启动时的默认调度器是performance
:
$ cd /sys/devices/system/cpu/cpu0/cpufreq
$ cat scaling_governor
performance
BeaglePlay 的 TI Linux 内核仅内置了两个调度器:
$ cat scaling_available_governors
performance schedutil
其他调度器可以通过cpupower
或modprobe
动态加载:
debian@BeagleBone:~$ sudo modprobe cpufreq_userspace
要从 Debian Bookworm 在 BeaglePlay 上安装cpupower
,运行以下命令:
debian@BeagleBone:~$ sudo apt install linux-cpupower
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
The following additional packages will be installed:
libcpupower1
The following NEW packages will be installed:
libcpupower1 linux-cpupower
0 upgraded, 2 newly installed, 0 to remove and 39 not upgraded.
Need to get 1953 kB of archives.
After this operation, 2174 kB of additional disk space will be used.
Do you want to continue? [Y/n] Y
Get:1 http://deb.debian.org/debian bookworm-updates/main arm64 libcpupower1 arm64 6.1.124-1 [960 kB]
Get:2 http://deb.debian.org/debian bookworm-updates/main arm64 linux-cpupower arm64 6.1.124-1 [992 kB]
Fetched 1953 kB in 0s (8989 kB/s)
Selecting previously unselected package libcpupower1.
(Reading database ... 72409 files and directories currently installed.)
Preparing to unpack .../libcpupower1_6.1.124-1_arm64.deb ...
Unpacking libcpupower1 (6.1.124-1) ...
Selecting previously unselected package linux-cpupower.
Preparing to unpack .../linux-cpupower_6.1.124-1_arm64.deb ...
Unpacking linux-cpupower (6.1.124-1) ...
Setting up libcpupower1 (6.1.124-1) ...
Setting up linux-cpupower (6.1.124-1) ...
Processing triggers for libc-bin (2.36-9+deb12u9) ...
要切换到ondemand
调度器,运行以下命令:
debian@BeagleBone:~$ sudo cpupower frequency-set -g ondemand
Setting cpu: 0
Setting cpu: 1
Setting cpu: 2
Setting cpu: 3
ondemand
调度器用来决定何时更改 OPP 的参数可以在/sys/devices/system/cpu/cpufreq/ondemand/
找到并设置。
ondemand
和conservative
调度器都会考虑改变频率和电压所需的努力。这个 CPUFreq 值是cpuinfo_transition_latency
。这个计算仅适用于具有普通调度策略的线程。如果线程被实时调度,两个调度器都会立即选择最高的 OPP,以便线程能够满足其调度截止时间。
userspace
调度器允许通过用户空间守护进程来选择 OPP 的逻辑。示例包括cpudyn
和powernowd
,尽管这两者都偏向于 x86 架构的笔记本电脑,而非嵌入式设备。
现在我们知道了有关 CPUFreq 驱动程序的运行时详细信息所在的位置,让我们来看一下如何在编译时定义 OPP。
使用 CPUFreq
查看 BeaglePlay,我们发现 OPP 在设备树中进行了编码。以下是k3
-am625.dtsi
中的摘录:
a53_opp_table: opp-table {
compatible = "operating-points-v2-ti-cpu";
opp-shared;
syscon = <&wkup_conf>;
opp-200000000 {
opp-hz = /bits/ 64 <200000000>;
opp-supported-hw = <0x01 0x0007>;
clock-latency-ns = <6000000>;
};
opp-400000000 {
opp-hz = /bits/ 64 <400000000>;
opp-supported-hw = <0x01 0x0007>;
clock-latency-ns = <6000000>;
};
opp-600000000 {
opp-hz = /bits/ 64 <600000000>;
opp-supported-hw = <0x01 0x0007>;
clock-latency-ns = <6000000>;
};
opp-800000000 {
opp-hz = /bits/ 64 <800000000>;
opp-supported-hw = <0x01 0x0007>;
clock-latency-ns = <6000000>;
};
opp-1000000000 {
opp-hz = /bits/ 64 <1000000000>;
opp-supported-hw = <0x01 0x0006>;
clock-latency-ns = <6000000>;
};
opp-1250000000 {
opp-hz = /bits/ 64 <1250000000>;
opp-supported-hw = <0x01 0x0004>;
clock-latency-ns = <6000000>;
opp-suspend;
};
};
我们可以通过查看可用的频率来确认这些是运行时使用的 OPP:
$ cd /sys/devices/system/cpu/cpu0/cpufreq
$ cat scaling_available_frequencies
200000 400000 600000 800000 1000000 1250000 1400000
选择userspace
调度器:
debian@BeagleBone:~$ sudo cpupower frequency-set -g userspace
Setting cpu: 0
Setting cpu: 1
Setting cpu: 2
Setting cpu: 3
列出可用的频率步骤:
debian@BeagleBone:~$ sudo cpupower frequency-info
analyzing CPU 0:
driver: cpufreq-dt
CPUs which run at the same hardware frequency: 0 1 2 3
CPUs which need to have their frequency coordinated by software: 0 1 2 3
maximum transition latency: 6.00 ms
hardware limits: 200 MHz - 1.40 GHz
available frequency steps: 200 MHz, 400 MHz, 600 MHz, 800 MHz, 1000 MHz, 1.25 GHz, 1.40 GHz
available cpufreq governors: ondemand userspace performance schedutil
current policy: frequency should be within 200 MHz and 1.40 GHz.
The governor "userspace" may decide which speed to use
within this range.
current CPU frequency: 1.25 GHz (asserted by call to hardware)
现在我们可以使用 USB-C 电源监测器来测量每个 OPP 的功耗。这些测量不太准确,所以不要太当真。
通过写入scaling_setspeed
来设置频率:
# echo 200000 > /sys/devices/system/cpu/cpufreq/policy0/scaling_setspeed
在 BeaglePlay 上构建并运行MELD/Chapter14/do-work
程序:
$ cd MELD/Chapter15/do-work
$ make
$ ./do-work -l 80828
如果我们在变化频率的同时保持恒定负载,那么我们观察到以下现象:
频率 (MHz) | CPU 利用率 (%) | 功率 (mW) |
---|---|---|
200 | 88 | 1,160 |
400 | 44 | 1,160 |
600 | 29 | 1,160 |
800 | 22 | 1,210 |
1,000 | 18 | 1,210 |
1,250 | 14 | 1,210 |
1,400 | 13 | 1,210 |
表 14.1 – 不同频率下的功耗
这表明在较低频率下大约节省了 4%的功率。
在大多数情况下,ondemand
调节器是最佳选择,因为它会根据 CPU 负载在不同的 OPP 之间切换。要选择特定的调节器,可以通过配置内核,使用默认调节器如CPU_FREQ_DEFAULT_GOV_ONDEMAND
,或者使用init
脚本在启动时更改调节器。有关 Debian 如何使用 SysVinit 的示例,请参见MELD/Chapter14/cpufrequtils
。
有关 CPUFreq 驱动程序的更多信息,请查看 Linux 内核源代码树中Documentation/cpu-freq
目录下的文件。
在本节中,我们关注的是 CPU 忙碌时的功耗。在下一节中,我们将探讨如何在 CPU 空闲时节省功耗。
选择最佳空闲状态
当处理器没有更多工作要做时,它会执行停止指令并进入空闲状态。在空闲状态下,CPU 的功耗较低。当发生硬件中断等事件时,CPU 会退出空闲状态。大多数 CPU 有多个空闲状态,每个状态消耗的功率不同。通常,功率使用和延迟之间存在权衡,即退出空闲状态所需的时间。在 ACPI 规范中,这些状态被称为C 状态。
在更深的 C 状态中,更多的电路会关闭,代价是失去一些状态,因此恢复到正常操作所需的时间更长。例如,在某些 C 状态下,CPU 缓存可能会关闭,因此当 CPU 重新运行时,可能需要从主内存重新加载一些信息。这是非常昂贵的,因此只有在 CPU 有较大的概率在此状态下保持一段时间时,才应执行此操作。不同系统之间的状态数量不同,每个状态从休眠到完全激活都需要一些时间。
选择正确的空闲状态的关键是要有一个清晰的了解,CPU 将会有多长时间处于不活动状态。预测未来总是具有挑战性,但有一些因素可以提供帮助。其中之一是当前的 CPU 负载:如果当前负载很高,短期内很可能继续保持这种状态,这时深度休眠将没有太大好处。即使负载较低,也值得查看是否存在即将到期的定时事件。如果没有负载也没有定时器,那么进入更深的空闲状态是合理的。
选择最佳空闲状态的部分代码是 CPUIdle 驱动程序。有关它的详细信息,可以查看 Linux 内核源代码树中的Documentation/driver-api/pm/cpuidle.rst
文件。
CPUIdle 驱动程序
与 CPUFreq 子系统类似,CPUIdle由 BSP 的一部分驱动程序和决定策略的调度器组成。与 CPUFreq 不同,调度器无法在运行时更改,并且没有供用户空间调度器使用的接口。在撰写本文时,Debian Bookworm 并未支持 BeaglePlay 的 CPUIdle,因此我只能在此进行描述。
CPUIdle 暴露了/sys/devices/system/cpu/cpu0/cpuidle
目录中每个空闲状态的信息。在该目录中,每个睡眠状态都有一个名为state0
到stateN
的子目录。state0
是最轻的睡眠状态,stateN
是最深的。请注意,编号与 C 状态不匹配,并且 CPUIdle 没有与C0
(运行)对应的状态。每个状态下有以下文件:
-
desc
:此状态的简短描述 -
disable
:通过向此文件写入1
来禁用此状态的选项 -
latency
:当退出此状态时,CPU 核心恢复正常操作所需的时间(单位:微秒) -
name
:此状态的名称 -
power
:在此空闲状态下消耗的功率,单位为毫瓦 -
time
:在此空闲状态下花费的总时间(单位:微秒) -
usage
:该状态被进入的次数
CPUIdle 有两个调度器:
-
ladder
:根据上一个空闲期的持续时间,逐步向下或向上进入空闲状态。它适用于常规的定时器滴答,但不适用于动态滴答。 -
menu
:根据预期的空闲时间选择一个空闲状态。它适用于动态滴答系统。
您应根据NO_HZ
的配置选择其中之一,我将在本节末尾描述该配置。
再次强调,用户交互是通过sysfs
文件系统进行的。在/sys/devices/system/cpu/cpuidle
目录下,您将找到两个文件:
-
current_driver
:这是 CPUIdle 驱动程序的名称。 -
current_governor_ro
:这是调度器的名称。
这些文件显示正在使用哪个驱动程序和哪个调度器。
即使 CPU 完全处于空闲状态,大多数 Linux 系统仍然配置为在接收到系统定时器中断时定期唤醒。为了节省更多的电力,我们需要将 Linux 内核配置为无滴答操作。
无滴答操作
一个相关的话题是无滴答操作或NO_HZ
选项。如果系统完全空闲,最可能的中断源是系统定时器,它被编程为以每秒HZ
次的频率生成定时滴答,其中HZ
通常为100
。历史上,Linux 使用定时滴答作为测量超时的主要时间基准。
然而,如果没有注册定时器事件,在某个特定时刻唤醒 CPU 来处理定时器中断是显然浪费的。动态滴答内核配置选项CONFIG_NO_HZ_IDLE
会在定时器处理例程结束时查看定时器队列,并在下一个事件发生时安排下次中断。这避免了不必要的唤醒,使 CPU 可以长时间处于空闲状态。在任何对电源敏感的应用中,都应该配置内核启用此选项。
虽然 CPU 消耗了嵌入式 Linux 系统中的大部分电力,但系统中也有其他组件可以关闭以节省能源。
关闭外设电源
直到现在,我们讨论的都是 CPU 以及如何在其运行或空闲时减少功耗。现在是时候关注系统的其他部分,看看是否能在这里实现节能了。
在 Linux 内核中,这由运行时电源管理系统(runtime power management system)管理,简称运行时电源管理(runtime pm)。它与支持运行时电源管理的驱动程序配合工作,关闭未使用的设备,并在需要时再次唤醒它们。
它是动态的,且应对用户空间透明。设备驱动程序决定如何关闭硬件电源。通常,运行时电源管理包括关闭子系统时钟,也称为时钟门控(clock gating),以及在可能的情况下关闭核心电路。
运行时电源管理通过sysfs
接口暴露。每个设备都有一个名为power
的子目录,在这里你可以找到以下文件:
-
control
:这允许用户空间决定是否在此设备上使用运行时电源管理(runtime pm)。如果设置为auto
,则启用运行时电源管理;但如果设置为on
,设备始终开启,且不使用运行时电源管理。 -
runtime_enabled
:此项报告运行时电源管理是否启用
或禁用
,如果control
设置为on
,则报告禁止
。 -
runtime_status
:这报告设备的当前状态。它可能是活动
、挂起
或不支持
。 -
autosuspend_delay_ms
:这是设备挂起之前的时间。-1
表示永远等待。如果挂起设备硬件的代价较高,某些驱动程序会实现此功能,因为它可以防止设备快速挂起和恢复循环。
以具体示例为例,让我们来看一下 BeaglePlay 上的 MMC 驱动:
# cd /sys/devices/platform/bus@f0000/fa00000.mmc/mmc_host/mmc1/power
# grep "" *
async:enabled
grep: autosuspend_delay_ms: Input/output error
control:auto
runtime_active_kids:0
runtime_active_time:0
runtime_enabled:disabled
runtime_status:unsupported
runtime_suspended_time:0
runtime_usage:0
因此,运行时电源管理已被禁用,设备当前不受支持,我们无法确定再次挂起时会有多少延迟。
有关运行时电源管理的更多信息,请查看 Linux 内核源代码中的Documentation/power/runtime_pm.rst
。
现在我们知道了运行时电源管理是什么以及它是如何工作的,让我们看看它是如何实际应用的。
使系统进入休眠状态
还有一种电源管理技术需要考虑:将整个系统置于睡眠模式,预期它一段时间内不会再被使用。在 Linux 内核中,这被称为系统睡眠。通常由用户发起:用户决定设备应关闭一段时间。例如,当我准备回家时,我合上笔记本电脑的盖子并将其放进包里。Linux 中对系统睡眠的大部分支持来自笔记本电脑的支持。在笔记本电脑世界中,通常有两种选择:
-
挂起
-
休眠
第一个选项,也叫做挂起到内存,关闭除了系统内存之外的所有内容,因此机器仍然会消耗一点电量。当系统恢复时,内存会保留所有之前的状态,我的笔记本几秒钟内就能恢复工作。
如果我选择休眠选项,内存的内容会被保存到硬盘。系统完全不消耗电力,因此可以无限期地处于此状态。唤醒时,需要一些时间从硬盘恢复内存。休眠在嵌入式系统中很少使用,主要是因为闪存的读写速度较慢,也因为它对工作流程有侵入性。
如需更多信息,请查看内核源树中的Documentation/power
目录。
挂起到内存和休眠选项对应于 Linux 支持的四种睡眠状态中的两种。接下来,我们将讨论这两种系统睡眠类型以及其他 ACPI 电源状态。
电源状态
在 ACPI 规范中,睡眠状态被称为S 状态。Linux 支持四种睡眠状态(freeze、standby、mem 和 disk),这些状态与相应的 ACPI S 状态([S0
]、S1
、S3
和 S4
)对应,具体如下:
freeze
([S0]):这会停止(冻结)所有用户空间的活动,同时 CPU 和内存继续正常工作。
节能的原因在于没有运行任何用户空间代码。ACPI 没有等效的状态,因此 S0 是最接近的匹配。S0 是运行系统的状态。
-
standby
(S1):这类似于freeze
,不同的是,除了启动 CPU 外,它会将所有 CPU 下线。 -
mem
(S3):这会关闭系统电源并将内存置于自刷新模式。也被称为挂起到内存。 -
disk
(S4):这将内存保存到硬盘,并关闭系统电源。也被称为挂起到硬盘。
不是所有系统都支持所有状态。要了解哪些状态是可用的,请读取/sys/power/state
文件,如下所示:
debian@BeaglePlay:~$ cat /sys/power/state
freeze mem disk
要进入其中一个系统睡眠状态,只需将所需状态写入/sys/power/state
。
对于嵌入式设备,最常见的需求是使用mem
选项挂起到内存。例如,我可以像这样挂起 BeaglePlay:
# echo mem > /sys/power/state
设备在不到一秒的时间内关闭电源,然后电力消耗下降至 10 毫瓦,用我的基础万用表测量得出的数据。但如何再次唤醒它呢?这就是接下来的主题。
唤醒事件
在暂停设备之前,你必须有一种方式将其重新唤醒。内核在这方面会帮助你。如果没有至少一个唤醒源,系统将拒绝暂停并返回以下信息:
No sources enabled to wake-up! Sleep abort.
当然,这意味着系统的某些部分必须保持开启,即使是在最深的休眠状态下。这通常涉及到电源管理集成电路(PMIC)和实时时钟(RTC),并可能还包括如 GPIO、UART、以太网和 Wi-Fi 等接口。
唤醒事件通过sysfs
进行控制。/sys/device
中的每个设备都有一个名为power
的子目录,其中包含一个wakeup
文件,文件内容为以下字符串之一:
-
enabled
:表示该设备将生成唤醒事件 -
disabled
:表示该设备不会生成唤醒事件 -
(空):表示该设备无法生成唤醒事件
若要获取能够生成唤醒事件的设备列表,请搜索所有wakeup
包含enabled
或disabled
的设备:
$ find /sys/devices/ -name wakeup | xargs grep "abled"
我们已经了解了如何将设备挂起并通过外部接口(如 UART)事件唤醒它。如果我们希望设备在没有外部交互的情况下自我唤醒,该怎么办呢?这时 RTC 就发挥作用了。
来自实时时钟的定时唤醒
BeaglePlay 具有一个 RTC,可以生成最长 24 小时内的警报中断。如果存在,/sys/class/rtc/rtc1
目录将存在。该目录应包含wakealarm
文件。将一个数字写入wakealarm
文件会导致它在指定秒数后生成警报。如果你还启用了来自rtc1
的唤醒事件,RTC 将恢复一个挂起的设备。
例如,以下rtcwake
命令会将系统置于freeze
状态,并在 5 秒后通过 RTC 唤醒:
debian@BeagleBone:~$ sudo su –
root@BeagleBone:~# rtcwake -d /dev/rtc1 -m freeze -s 5
rtcwake: assuming RTC uses UTC ...
rtcwake: wakeup from "freeze" using /dev/rtc1 at Thu Jan 1 00:06:21 1970
相应的journalctl
输出如下所示:
Feb 08 01:21:53 BeagleBone kernel: PM: suspend entry (s2idle)
Feb 08 01:21:59 BeagleBone kernel: Filesystems sync: 0.000 seconds
Feb 08 01:21:59 BeagleBone kernel: Freezing user space processes
Feb 08 01:21:59 BeagleBone kernel: Freezing user space processes completed (elapsed 0.001 seconds)
Feb 08 01:21:59 BeagleBone kernel: OOM killer disabled.
Feb 08 01:21:59 BeagleBone kernel: Freezing remaining freezable tasks
Feb 08 01:21:59 BeagleBone kernel: Freezing remaining freezable tasks completed (elapsed 0.001 seconds)
Feb 08 01:21:59 BeagleBone kernel: printk: Suspending console(s) (use no_console_suspend to debug)
Feb 08 01:21:59 BeagleBone kernel: wlcore: down
Feb 08 01:21:59 BeagleBone kernel: wlcore: down
Feb 08 01:21:59 BeagleBone kernel: am65-cpsw-nuss 8000000.ethernet eth0: Link is Down
Feb 08 01:21:59 BeagleBone kernel: ti-sci 44043000.system-controller: ti_sci_resume: wakeup source: 0xFF
Feb 08 01:21:59 BeagleBone kernel: am65-cpsw-nuss 8000000.ethernet: set new flow-id-base 19
Feb 08 01:21:59 BeagleBone kernel: am65-cpsw-nuss 8000000.ethernet eth0: PHY [8000f00.mdio:00] driver [RTL8211F-VD Gigabit Ethernet]>
Feb 08 01:21:59 BeagleBone kernel: am65-cpsw-nuss 8000000.ethernet eth0: configuring for phy/rgmii-rxid link mode
Feb 08 01:21:59 BeagleBone kernel: wlcore: using inverted interrupt logic: 2
Feb 08 01:21:59 BeagleBone kernel: wlcore: PHY firmware version: Rev 8.2.0.0.243
Feb 08 01:21:59 BeagleBone kernel: wlcore: firmware booted (Rev 8.9.0.0.83)
Feb 08 01:21:59 BeagleBone kernel: OOM killer enabled.
Feb 08 01:21:59 BeagleBone kernel: Restarting tasks ... done.
Feb 08 01:21:59 BeagleBone kernel: PM: suspend exit
BeaglePlay 上的电源按钮也是一个唤醒源,因此在没有串口控制台的情况下,你可以使用它从freeze
状态恢复。确保按下电源按钮,而不是旁边的重置按钮;否则,板子将重新启动。
这篇文章结束了我们对四种 Linux 系统休眠模式的讨论。我们了解了如何将设备挂起至mem
或freeze
电源状态,并通过来自 RTC 或电源按钮的事件唤醒它。虽然 Linux 中的运行时电源管理主要是为笔记本电脑创建的,但我们可以利用这一支持来为也依靠电池供电的嵌入式系统服务。
总结
Linux 具有复杂的电源管理功能。在本章中,我描述了四个主要组件:
-
CPUFreq根据每个处理器核心的工作负载变化来调整 OPP,减少忙碌但仍有部分带宽剩余的核心的功耗,从而使我们能够降低频率。OPP 在 ACPI 规范中称为 P 状态。
-
CPUIdle选择更深的空闲状态,当 CPU 预期一段时间内不会被唤醒时。空闲状态在 ACPI 规范中被称为 C 状态。
-
运行时电源管理(Runtime pm)会关闭不需要的外设。
-
系统睡眠模式会将整个系统置于低功耗状态。通常由最终用户控制,例如,通过按下待机按钮。系统睡眠状态在 ACPI 规范中称为 S 状态。
大部分电源管理工作由 BSP(主引导程序)为你完成。你的主要任务是确保其为你预期的使用场景配置正确。只有最后一个组件,即选择系统睡眠状态,需要你编写一些代码,使最终用户能够进入和退出该状态。
本书的下一部分将讨论嵌入式应用程序的编写。我们将从打包和部署 Python 代码开始,然后探索容器化技术。
进一步学习
- 高级配置与电源管理接口规范,UEFI 论坛公司 –
uefi.org/sites/default/files/resources/ACPI_Spec_6_5_Aug29.pdf
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论: https://packt.link/embeddedsystems
第四部分
开发应用程序
本部分将帮助你完成项目的实施阶段。我们将从 Python 打包和依赖管理开始,这个话题随着机器学习应用的蓬勃发展而变得越来越重要。第十六章是一个关于 DevOps 的新章节,讲解了如何在 Docker 容器内持续构建和部署 Python 应用程序。接下来,我们将讨论各种进程间通信方式和多线程编程。最后,我们将仔细研究 Linux 如何管理内存,并展示如何使用各种工具来衡量内存使用情况并检测内存泄漏。
本部分包含以下章节:
-
第十五章,打包 Python
-
第十六章,部署容器镜像
-
第十七章,了解进程和线程
-
第十八章,管理内存
第十五章:打包 Python
Python是最流行的机器学习(ML)编程语言。再加上机器学习在我们日常生活中的普及,Python 在边缘设备上运行的需求不断增加也就不足为奇了。即使在转译器和 WebAssembly 的时代,将 Python 应用程序打包部署依然是一个未解决的问题。在本章中,你将了解有哪些选择可以将 Python 模块打包在一起,并在何时使用某种方法而非另一种方法。
我们从回顾今天 Python 打包解决方案的起源开始,从内建的标准distutils
到其继任者setuptools
。接下来,我们将审视pip
包管理器,然后转到用于 Python 虚拟环境的venv
,最后是conda
,这个通用的跨平台解决方案。
由于 Python 是解释型语言,你不能像使用 Go 等语言那样将程序编译为独立的可执行文件。这使得 Python 应用程序的部署变得复杂。运行 Python 应用程序需要安装 Python 解释器和若干运行时依赖项。这些要求需要与代码兼容才能使应用程序正常运行。这就需要软件组件的精确版本管理。解决这些部署问题正是 Python 打包的核心内容。
在本章中,我们将覆盖以下主题:
-
回顾 Python 打包的起源
-
使用
pip
安装 Python 包 -
使用
venv
管理 Python 虚拟环境 -
使用
conda
安装预编译的二进制文件
技术要求
为了跟随示例进行操作,请确保在你的 Linux 主机系统上安装了以下软件:
-
Python:Python 3 解释器和标准库
-
pip
:Python 3 的包管理器 -
venv
:用于创建和管理轻量级虚拟环境的 Python 模块 -
Miniconda:
conda
包和虚拟环境管理器的最小安装程序
我推荐使用 Ubuntu 24.04 LTS 或更高版本来进行本章的操作。尽管 Ubuntu 24.04 LTS 可以在 Raspberry Pi 4 上运行,但我仍然更喜欢在 x86-64 桌面 PC 或笔记本电脑上进行开发。Ubuntu 还预装了 Python 3 和pip
,因为 Python 在系统中被广泛使用。不要卸载python3
,否则你将无法使用 Ubuntu。要在 Ubuntu 上安装venv
,请输入以下命令:
$ sudo apt install python3-venv
重要说明
在到达conda
部分之前,不要安装 Miniconda,因为它会干扰依赖于系统 Python 安装的早期pip
练习。
回顾 Python 打包的起源
Python 打包的生态系统是一个庞大的失败尝试和被遗弃工具的墓地。Python 社区内关于依赖管理的最佳实践经常变化,一年的推荐解决方案可能在下一年就变成了一个行不通的方案。在研究这个话题时,记得查看信息发布的时间,并且不要相信任何可能已经过时的建议。
大多数 Python 库都使用setuptools
进行分发,包括在Python 软件包索引(PyPI)上找到的所有包。此分发方法依赖于setup.py
项目规范文件,Python 包安装器(pip)使用该文件来安装包。pip
还可以在项目安装后生成或冻结一个精确的依赖关系列表。这个可选的requirements.txt
文件由pip
与setup.py
一起使用,以确保项目安装是可重复的。
distutils
distutils是 Python 的原始打包系统。从 Python 2.0 开始,直到 Python 3.12 版本,它一直被包含在 Python 标准库中。distutils
提供了一个同名的 Python 包,可以被你的setup.py
脚本导入。现在由于distutils
已被弃用,直接使用该包不再受支持。setuptools
已成为其首选替代品。
尽管distutils
可能仍适用于简单项目,但社区已经向前发展。如今,distutils
仅因遗留原因而存在。许多 Python 库最初是在distutils
是唯一选择的时候发布的。将它们迁移到setuptools
现在将需要大量工作,并可能破坏现有用户的使用。
setuptools
setuptools通过增加对复杂结构的支持,扩展了distutils
,使得基于 Flask 和 FastAPI 等 Web 框架构建的大型应用程序更容易分发。它已成为 Python 社区事实上的打包系统。像distutils
一样,setuptools
也提供了一个同名的 Python 包,你可以在setup.py
脚本中导入它。distribute
是setuptools
的一个雄心勃勃的分支,最终合并回setuptools 0.7
,巩固了setuptools
作为 Python 打包的终极选择的地位。
setuptools
引入了一个命令行工具,称为easy_install
(现在已弃用),以及一个名为pkg_resources
的 Python 包,用于运行时的包发现和资源文件访问。setuptools
还可以生成充当其他可扩展包(例如框架和应用程序)插件的包。你可以通过在setup.py
脚本中为其他主包注册入口点来实现这一点,以便让其他包导入。
在 Python 的上下文中,术语distribution有着不同的含义。一个 distribution 是一个包含包、模块和其他资源文件的版本化存档,用于分发一个发布版本。release是某一时刻对 Python 项目的一个版本化快照。更糟糕的是,package和distribution这两个术语是多重含义的,Pythonista 们常常交替使用这两个词。为了方便起见,我们可以把 distribution 看作你下载的内容,而 package 是被安装并导入的模块。
切割一个发行版可能会产生多个分发文件,例如源分发和一个或多个构建分发文件。不同平台可能会有不同的构建分发文件,比如包含 Windows 安装程序的文件。构建分发 这个术语意味着在安装之前不需要构建步骤,但不一定意味着预编译。某些构建分发格式(如 Wheel(.whl
))会排除已编译的 Python 文件。例如,包含已编译扩展的构建分发称为 二进制分发。
扩展模块 是用 C 或 C++ 编写的 Python 模块。每个扩展模块都会编译成一个单独的动态加载库,例如 Linux 上的共享对象(.so
)和 Windows 上的动态链接库(.pyd
)。与此相比,纯模块必须完全用 Python 编写。setuptools
引入的 Egg(.egg
)构建分发格式支持纯模块和扩展模块。由于 Python 源代码(.py
)文件在 Python 解释器导入模块时会编译成字节码(.pyc
)文件,你可以理解像 Wheel 这样的构建分发格式可能会排除预编译的 Python 文件。
setup.py
假设你正在开发一个小型的 Python 程序,可能是查询远程 REST API 并将响应数据保存到本地 SQL 数据库。如何将你的程序与其依赖项打包,以便进行部署呢?你可以从定义一个 setup.py
脚本开始,setuptools
将使用该脚本来安装你的程序。使用 setuptools
进行部署是实现更复杂的自动化部署方案的第一步。
即使你的程序足够小,可以舒适地放在一个模块中,它也很可能不会一直保持这种状态。假设你的程序由一个名为 follower.py
的单个文件组成,像这样:
$ tree follower
follower
└── follower.py
然后,你可以通过将 follower.py
拆分成三个独立的模块,并将它们放置在一个名为 follower
的嵌套目录中,将这个模块转换为一个包:
$ tree follower/
follower/
└── follower
├── fetch.py
├── __main__.py
└── store.py
__main__.py
模块是你的程序开始的地方,因此它包含主要的顶层、面向用户的功能。fetch.py
模块包含向远程 REST API 发送 HTTP 请求的函数,store.py
模块包含将响应数据保存到本地 SQL 数据库的函数。要将这个包作为脚本运行,你需要向 Python 解释器传递 -m
选项,如下所示:
$ PYTHONPATH=follower python -m follower
PYTHONPATH
环境变量指向目标项目的包目录所在的目录。-m
选项后的 follower
参数告诉 Python 运行 follower
包下的 __main__.py
模块。像这样将包目录嵌套在项目目录中,为你的程序发展成由多个具有独立命名空间的包组成的大型应用程序铺平了道路。
在项目的各个部分都已正确放置后,我们现在准备创建一个最小化的setup.py
脚本,setuptools
可以用它来打包和部署项目:
from setuptools import setup
setup(
name='follower',
version='0.1',
packages=['follower'],
include_package_data=True,
install_requires=['requests', 'sqlalchemy']
)
install_requires
参数是一个外部依赖列表,这些依赖需要在项目运行时自动安装。请注意,在我的示例中,我并没有指定这些依赖需要什么版本,也没有指定从哪里获取它们。我只要求使用看起来和行为类似于requests
和sqlalchemy
的库。像这样将策略与实现分开,使你可以轻松地将官方 PyPI 版本的依赖替换为你自己的版本,以防你需要修复一个 bug 或添加一个功能。
信息提示
在依赖声明中添加可选的版本说明符是可以的,但在setup.py
中硬编码分发网址作为dependency_links
在原则上是错误的。
packages
参数告诉setuptools
要分发项目发布版本中的哪些内部包。由于每个包都在父项目目录的子目录中定义,因此在这种情况下唯一被打包的包是follower
。我将数据文件与 Python 代码一起包含在此分发中。为了做到这一点,你需要将include_package_data
参数设置为True
,这样setuptools
就会查找MANIFEST.in
文件并安装其中列出的所有文件。以下是MANIFEST.in
文件的内容:
include data/events.db
如果数据目录包含我们想要包含的嵌套目录,我们可以使用recursive-include
将它们及其内容一起包含:
recursive-include data *
这是最终的目录布局:
$ tree follower
follower
├── data
│ └── events.db
├── follower
│ ├── fetch.py
│ ├── __init__.py
│ └── store.py
├── MANIFEST.in
└── setup.py
setuptools
擅长构建和分发依赖于其他包的 Python 包。它之所以能够做到这一点,是因为它具备了入口点和依赖声明等特性,而这些特性在distutils
中根本没有。setuptools
与pip
配合得很好,并且setuptools
的新版本会定期发布。Wheel 构建分发格式是为了替代setuptools
引入的 Egg 格式而创建的。这一努力已经取得了很大成功,得益于一个流行的setuptools
扩展用于构建 wheels,并且pip
对安装 wheels 的良好支持。
使用 pip 安装 Python 包
现在你已经知道如何在setup.py
脚本中定义项目的依赖关系。那么,如何安装这些依赖呢?如何在找到更好的依赖时升级或替换它?当你不再需要某个依赖时,如何判断何时可以安全地删除它?
管理项目依赖是件棘手的事情。幸运的是,Python 自带了一个叫做pip的工具,可以在项目初期阶段提供帮助。这个名字代表了pip 安装 Python,这是一个递归首字母缩略词。pip
是 Python 的官方包管理器。
pip
的初始 1.0 版本于 2011 年 4 月 4 日发布,正好与 Node.js 和npm
的兴起同时发生。在成为pip
之前,该工具名为pyinstall
。pyinstall
于 2008 年创建,是作为easy_install
的替代工具,后者当时与setuptools
一起捆绑。easy_install
现已弃用,setuptools
推荐使用pip
代替。
由于pip
与 Python 安装程序一起提供,而且你可以在系统上安装多个版本的 Python(例如,2.7 和 3.13),了解你正在运行的pip
版本是很有帮助的:
$ pip --version
如果系统中找不到pip
可执行文件,那可能意味着你使用的是 Ubuntu 20.04 LTS 或更高版本,并且没有安装 Python 2.7。这没问题。我们将仅在本节其余部分中将pip3
替代为pip
,python3
替代为python
:
$ pip3 --version
如果有python3
但没有pip3
可执行文件,则可以按如下方式在基于 Debian 的发行版(如 Ubuntu)中安装它:
$ sudo apt install python3-pip
pip
将包安装到名为site-packages
的目录中。要找到你的site-packages
目录的位置,请运行以下命令:
$ python3 -m site | grep ^USER_SITE
重要提示
现在 Python 2 已经被弃用,pip3
和python3
命令在像 Ubuntu 这样的流行 Linux 发行版上可用。如果你的 Linux 发行版没有pip3
和python3
命令,则改用pip
和python
命令。
要获取系统中已安装的包的列表,使用以下命令:
$ pip3 list
列表显示pip
只是另一个 Python 包,因此你可以使用pip
来升级它自己,但我建议你不要这样做,至少长期来说不要这么做。我将在下一节中介绍虚拟环境时解释原因。
要获取安装在site-packages
目录中的包的列表,使用以下命令:
$ pip3 list --user
这个列表应该是空的,或者比系统包列表要短得多。
返回到上一节中的示例项目。cd
进入包含setup.py
的父级follower
目录。然后运行以下命令:
$ pip3 install --ignore-installed --user --break-system-packages .
pip
将使用setup.py
来获取并安装由install_requires
声明的包到你的site-packages
目录中。--user
选项指示pip
将包安装到site-packages
目录而不是全局目录。--ignore-installed
选项强制pip
重新安装系统中已经存在的任何必需包到site-packages
,以确保没有依赖项丢失。--break-system-packages
选项在基于 Debian 的 Linux 发行版(如 Ubuntu)中是必需的,因为这些发行版不鼓励用户在系统范围内安装非 Debian 打包的包。
现在再次列出site-packages
目录中的所有包:
$ pip3 list --user
Package Version
------------------ ---------
certifi 2025.1.31
charset-normalizer 3.4.1
follower 0.1
greenlet 3.1.1
idna 3.10
requests 2.32.3
SQLAlchemy 2.0.38
typing_extensions 4.12.2
urllib3 2.3.0
这次你应该看到requests
和SQLAlchemy
都在包列表中。
要查看刚刚安装的SQLAlchemy
包的详细信息,发出以下命令:
$ pip3 show sqlalchemy
显示的详细信息包含Requires
和Required-by
字段。这两个字段都是相关包的列表。你可以使用这些字段中的值和连续调用pip show
来追踪你项目的依赖树。但使用pip install
一个名为pipdeptree
的命令行工具并用它来代替可能更容易。
当Required-by
字段为空时,这是一个很好的指标,说明现在可以安全地从系统中卸载该包。如果没有其他包依赖于已删除包的Requires
字段中的包,那么也可以安全地卸载这些包。这就是如何使用pip
卸载sqlalchemy
的方式:
$ pip3 uninstall sqlalchemy -y --break-system-packages
末尾的-y
会抑制确认提示。要一次卸载多个包,只需在-y
前添加更多包名称。这里省略了--user
选项,因为pip
足够智能,能够在全局安装的包也存在时,优先从site-packages
中卸载包。
提示
从你的site-packages
目录中卸载follower
包及其所有依赖项,以免污染你的 Python 安装或 Linux 发行版,避免安装非 Debian 打包的包。
有时你需要一个执行某个特定功能或使用某项技术的包,但你不知道它的名称。你可以使用pip
在命令行中对 PyPI 进行关键字搜索,但这种方法通常会返回太多结果。通过在 PyPI 网站上搜索包(pypi.org/search/
)要容易得多,它允许你按各种分类器筛选结果。
requirements.txt
pip install
会安装包的最新发布版本,但通常你会想要安装与你的项目代码兼容的特定版本包。最终,你会想要升级你项目的依赖项。但在我展示如何做之前,我首先需要展示如何使用pip freeze
来固定你的依赖项。
requirements
文件允许你明确指定pip
应为你的项目安装哪些包和版本。按照惯例,项目的requirements 文件始终命名为requirements.txt
。requirements 文件的内容仅仅是一个列出项目依赖项的pip install
参数列表。这些依赖项有精确的版本号,以确保当其他人尝试重新构建和部署你的项目时不会有意外。将requirements.txt
文件添加到项目的代码库中是一个良好的实践,以确保构建的可复现性。
返回到我们的follower
项目,现在我们已经安装了所有依赖项并验证代码按预期工作,我们可以冻结pip
为我们安装的包的最新版本了。pip
有一个freeze
命令,输出已安装包及其版本。你将该命令的输出重定向到requirements.txt
文件:
$ pip3 freeze --user > requirements.txt
现在你有了一个requirements.txt
文件,克隆你项目的人可以使用-r
选项和要求文件的名称来安装所有依赖项:
$ pip3 install --user -r requirements.txt
自动生成的要求文件格式默认使用精确版本匹配(==
)。例如,像requests==2.32.3
这样的行告诉pip
,要安装的requests
版本必须是精确的2.32.3
。在要求文件中,你可以使用其他版本说明符,例如最小版本(>=
)、版本排除(!=
)和最大版本(<=
)。最小版本(>=
)匹配任何大于或等于右侧版本的版本。版本排除(!=
)匹配任何除了右侧版本之外的版本。最大版本(<=
)匹配任何小于或等于右侧版本的版本。
你可以在一行中结合多个版本说明符,用逗号分隔它们:
requests >=2.32.3,<3.0
当pip
安装要求文件中指定的包时,默认行为是从 PyPI 获取它们。你可以通过在requirements.txt
文件的顶部添加如下行,将 PyPI 的 URL(pypi.org/simple/)
)替换为备用 Python 包索引的 URL:
--index-url http://pypi.mydomain.com/mirror
建立并维护自己的私有 PyPI 镜像所需的努力不可小觑。当你只需要修复一个 bug 或为项目依赖项添加一个特性时,覆盖包源而非整个包索引更为合理。
我之前提到过,硬编码分发 URL 到setup.py
中是错误的。你可以在要求文件中使用-e
参数形式来覆盖单独的包源:
-e git+https://github.com/myteam/flask.git#egg=flask
在这个示例中,我指示pip
从我们团队的 GitHub 分支pallets/flask.git
获取flask
包源。-e
参数形式还可以接受 Git 分支名称、提交哈希或标签名称:
-e git+https://github.com/myteam/flask.git@master
-e git+https://github.com/myteam/flask.git@5142930ef57e2f0ada00248bdaeb95406d18eb7c
-e git+https://github.com/myteam/flask.git@v1.0
使用pip
将项目的依赖项升级到 PyPI 上发布的最新版本非常简单:
pip3 install --user --upgrade -r requirements.txt
在你验证了安装最新版本的依赖项不会破坏你的项目后,你可以将它们重新写入要求文件:
$ pip3 freeze --user > requirements.txt
确保冻结操作没有覆盖要求文件中的任何覆盖或特殊版本处理。撤销任何错误并将更新后的requirements.txt
文件提交到版本控制系统。
在某个时刻,升级你的项目依赖项可能会导致代码出现问题。新版本的包可能会引入回归或与项目不兼容的情况。要求文件格式提供了应对这些情况的语法。假设你在项目中使用的是requests
的 2.32.3 版本,而版本 3.0 发布了。根据语义版本控制的实践,增加主版本号表示requests
的版本 3.0 包含了对该库 API 的破坏性更改。
你可以这样表达新的版本要求:
requests ~= 2.32.3
兼容版本说明符(~=
)依赖于语义版本控制。兼容意味着大于或等于右侧的版本,并且小于下一个版本的主版本号(例如,>= 1.1
和 == 1.*
)。你已经看到我用更明确的方式表示了对 requests
的相同版本要求,如下所示:
requests >=2.32.3,<3.0
如果你一次只开发一个 Python 项目,这些 pip
依赖管理技巧是有效的。但很有可能你会使用同一台机器同时进行多个 Python 项目的开发,每个项目可能需要不同版本的 Python 解释器。仅使用 pip
处理多个项目的最大问题是,它将所有包安装到特定 Python 版本的相同用户 site-packages
目录中。这使得将一个项目的依赖与另一个项目隔离开来变得非常困难。
正如我们将在下一章看到的,pip
与 Docker 配合得很好,用于部署 Python 应用程序。你可以将 pip
添加到基于 Buildroot 或 Yocto 的 Linux 镜像中,但这仅能实现设备上的快速实验。像 pip
这样的 Python 运行时包安装程序并不适合 Buildroot 和 Yocto 环境,在这些环境中你希望在构建时定义嵌入式 Linux 镜像的所有内容。pip
在像 Docker 这样的容器化环境中运行良好,因为在这些环境中,构建时和运行时之间的界限往往是模糊的。
在 第七章中,你学习了如何在 meta-python
层中使用 Python 模块以及如何为自己的应用定义自定义层。你可以使用 pip freeze
生成的 requirements.txt
文件来指导从 meta-python
中选择依赖项,以便用于你的自定义层配方。Buildroot 和 Yocto 都是以系统范围的方式安装 Python 包,因此我们接下来要讨论的虚拟环境技术并不直接适用于嵌入式 Linux 构建。不过,它们有助于你为嵌入式 Python 应用程序组装完整的依赖项列表。
使用 venv 管理 Python 虚拟环境
虚拟环境是一个自包含的目录树,包含特定版本 Python 的 Python 解释器、用于管理项目依赖项的 pip
可执行文件以及本地的 site-packages
目录。在虚拟环境之间切换会让 shell 误认为当前唯一可用的 Python 和 pip
可执行文件是活动虚拟环境中存在的那些。最佳实践是为每个项目创建一个不同的虚拟环境。这种隔离方式解决了两个项目依赖于同一包的不同版本的问题。
虚拟环境对于 Python 来说并不新鲜。Python 安装的系统范围性质决定了它们的必要性。虚拟环境不仅可以让你安装同一包的不同版本,还为你提供了一种简单的方式来运行多个版本的 Python 解释器。管理 Python 虚拟环境的工具有很多。大约在 2019 年非常流行的工具pipenv
如今已经逐渐衰落。流行的conda
包管理器自 2014 年底起就支持 Python 虚拟环境。而 Python 3 内建的虚拟环境支持(venv
)从 2012 年引入以来,逐渐成熟并被广泛采用。
venv自 Python 3.3 版本以来就已经随 Python 一起发布。由于它仅与 Python 3 安装捆绑,因此venv
与需要 Python 2.7 的项目不兼容。由于 Python 2.7 的官方支持已于 2020 年 1 月 1 日结束,因此这个 Python 3 的限制问题现在已经不那么严重了。venv
基于流行的virtualenv
工具,而后者仍在维护并可以从 PyPI 获取。如果你有一个或多个仍然需要 Python 2.7 的项目,你可以使用virtualenv
而不是venv
来处理这些项目。
默认情况下,venv
会安装系统中找到的最新版本的 Python。如果你的系统中有多个 Python 版本,你可以在创建每个虚拟环境时,通过运行python3
或你想要的版本来选择特定的 Python 版本(Python 教程,docs.python.org/3/tutorial/venv.html
)。对于新项目来说,使用最新版本的 Python 通常没问题,但对于大多数遗留和企业软件来说却是不可接受的。我们将使用与你的 Ubuntu 系统一起提供的 Python 3 版本来创建和使用虚拟环境。
要创建虚拟环境,首先决定放置虚拟环境的目录,然后运行venv
模块并指定目标目录路径:
-
确保
venv
已安装在你的 Ubuntu 系统上:$ sudo apt install python3-venv
-
为你的项目创建一个新目录:
$ mkdir myproject
-
切换到那个新目录:
$ cd myproject
-
在名为
venv
的子目录中创建虚拟环境:$ python3 -m venv ./venv
现在你已经创建了虚拟环境,下面是如何激活并验证它:
-
如果你还没有进入你的项目目录,请切换到项目目录:
$ cd myproject
-
检查你的系统上
pip3
可执行文件的安装位置:$ which pip3 /usr/bin/pip3
-
激活项目的虚拟环境:
$ source ./venv/bin/activate
-
检查你的项目中
pip3
可执行文件的安装位置:(venv) $ which pip3 /home/frank/myproject/venv/bin/pip3
-
列出随虚拟环境一起安装的包:
(venv) $ pip3 list Package Version ------- ------- pip 24.0
如果你在虚拟环境中运行which pip
命令,你会看到pip
现在指向一个可执行文件。此时,你可以在虚拟环境中运行pip
或python
时省略3
。
接下来,让我们将一个名为hypothesis
的基于属性的测试库安装到现有的虚拟环境中:
-
如果你还没有进入你的项目目录,请切换到项目目录:
$ cd myproject
-
如果项目的虚拟环境尚未激活,请重新激活它:
$ source ./venv/bin/activate
-
安装
hypothesis
包:(venv) $ pip install hypothesis
-
列出当前已安装在虚拟环境中的包:
(venv) $ pip list Package Version ---------------- ------- attrs 25.1.0 hypothesis 6.125.2 pip 24.0 sortedcontainers 2.4.0
注意,除了 hypothesis
之外,列表中还新增了两个包(attrs
和 sortedcontainers
)。hypothesis
依赖这两个包。假设你有另一个 Python 项目依赖于 sortedcontainers
版本 1.5.10,而不是版本 2.4.0。这两个版本不兼容,因此会发生冲突。虚拟环境允许你为每个项目安装同一个包的不同版本。
你可能注意到,退出项目目录并不会停用其虚拟环境。别担心,停用虚拟环境就这么简单:
(venv) $ deactivate
$
这将把你带回全局系统环境,你必须再次输入 python3
和 pip3
。你现在已经了解了开始使用 Python 虚拟环境所需的所有知识。创建和切换虚拟环境现在已经是 Python 开发中的常见做法。隔离的环境让你更容易跟踪和管理多个项目中的依赖关系。
使用 conda 安装预编译的二进制文件
conda 是由 Anaconda 发行版为 PyData 社区提供的包和虚拟环境管理系统。Anaconda 发行版包括 Python 以及一些难以构建的开源项目的二进制文件,如 PyTorch 和 TensorFlow。conda
可以在没有完整 Anaconda 发行版的情况下安装,Anaconda 非常庞大,或者可以选择更小的 Miniconda 发行版,尽管它仍然超过 256 MB。
尽管 conda
在 pip
之后不久就为 Python 所创建,但它已经发展成一个通用的包管理器,类似于 APT 或 Homebrew。现在,它可以用来为任何语言打包和分发软件。因为 conda
下载的是预编译的二进制文件,所以安装 Python 扩展模块非常轻松。conda
的另一个重要卖点是它是跨平台的,完全支持 Linux、macOS 和 Windows。
除了包管理,conda
还是一个完整的虚拟环境管理器。conda
虚拟环境具备我们从 Python venv
环境中期待的所有好处,甚至更多。像 venv
一样,conda
允许你使用 pip
从 PyPI 安装包到项目的本地 site-packages
目录。如果你愿意,你也可以使用 conda
自带的包管理功能,从不同的渠道安装包。渠道是由 Anaconda 和其他软件发行版提供的包源。
环境管理
与venv
不同,conda
的虚拟环境管理器可以轻松处理多个版本的 Python,包括 Python 2.7。你需要在 Ubuntu 系统上安装 Miniconda 才能进行接下来的练习。你希望使用 Miniconda 而不是 Anaconda 来管理虚拟环境,因为 Anaconda 环境自带许多预安装的包,其中很多你根本不需要。Miniconda 环境经过精简,可以让你轻松安装任何 Anaconda 的包,必要时使用。
在 Ubuntu 24.04 LTS 上安装和更新 Miniconda:
-
下载 Miniconda:
$ wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh
-
安装 Miniconda:
$ bash Miniconda3-latest-Linux-x86_64.sh
-
更新根环境中所有已安装的包:
(base) $ conda update --all
你刚安装的 Miniconda 自带了conda
和一个根环境,里面有一个 Python 解释器和一些基本包。默认情况下,conda
根环境的python
和pip
可执行文件会安装在你的主目录中。conda
的根环境被称为base
。你可以通过运行以下命令查看它的安装位置,以及其他任何可用的conda
环境的位置:
(base) $ conda env list
在创建自己的conda
环境之前,验证一下这个根环境:
-
安装 Miniconda 后,打开一个新的终端。
-
检查根环境中
python
可执行文件的安装位置:(base) $ which python
-
检查 Python 的版本:
(base) $ python --version
-
检查根环境中
pip
可执行文件的安装位置:(base) $ which pip
-
检查
pip
的版本:(base) $ pip --version
-
列出根环境中已安装的包:
(base) $ conda list
接下来,创建并使用你自己的名为py311
的conda
环境:
-
创建一个名为
py311
的新虚拟环境:(base) $ conda create --name py311 python=3.11.9
-
激活你新的虚拟环境:
(base) $ source activate py311
-
检查你的环境中
python
可执行文件的安装位置:(py311) $ which python
-
检查 Python 的版本是否为 3.11.9:
(py311) $ python --version
-
列出你环境中已安装的包:
(py311) $ conda list
-
退出你的环境:
(py311) $ conda deactivate
使用conda
创建一个安装了 Python 2.7 的虚拟环境就像下面这样简单:
(base) $ conda create --name py27 python=2.7.18
再次查看你的conda
环境,看看py311
和py27
是否现在出现在列表中:
(base) $ conda env list
最后,让我们删除py27
环境,因为我们不会再使用它:
(base) $ conda remove --name py27 --all
现在你知道如何使用conda
来管理虚拟环境了,接下来让我们用它来管理这些环境中的包。
包管理
由于conda
支持虚拟环境,我们可以像使用venv
一样,使用pip
以隔离的方式在不同项目之间管理 Python 依赖项。作为一个通用的包管理器,conda
有自己的依赖管理功能。我们知道,conda list
会列出在活动虚拟环境中安装的所有包。我还提到过conda
使用的软件包源,称为通道(channels):
-
你可以通过输入以下命令获取
conda
配置的通道 URL 列表:(base) $ conda info
-
在继续之前,让我们重新激活你在上一个练习中创建的
py311
虚拟环境:(base) $ source activate py311 (py311) $
-
现在大多数 Python 开发都是在 Jupyter notebook 中进行的,所以我们先安装这些软件包:
(py311) $ conda install jupyter notebook
-
在提示时输入y。这将安装
jupyter
和notebook
软件包及其所有依赖项。当你输入conda list
时,你会看到安装的软件包列表比之前长得多。现在,让我们安装一些在计算机视觉项目中需要的 Python 软件包:(py311) $ conda install opencv matplotlib
-
再次在提示时输入y。这次安装的依赖项数量较少。
opencv
和matplotlib
都依赖于numpy
,所以conda
会自动安装该包,而不需要你指定。如果你想指定opencv
的较旧版本,可以通过以下方式安装所需版本:(py311) $ conda install opencv=4.6.0
-
conda
会尝试为该依赖项解决活动环境。由于当前虚拟环境中没有其他软件包依赖opencv
,目标版本很容易解决。如果有依赖项,那你可能会遇到软件包冲突,重新安装会失败。解决后,conda
会在降级opencv
及其依赖项之前提示你。输入y将opencv
降级到版本 4.6.0。 -
假设你改变了主意,或者发布了一个新的版本的
opencv
,解决了你之前的担忧。下面是如何将opencv
升级到 Anaconda 发行版提供的最新版本:(py311) $ conda update opencv
-
这次,
conda
会提示你是否想更新opencv
及其依赖项到最新版本。这次,输入n取消软件包更新。与其单独更新软件包,通常一次性更新活动虚拟环境中所有已安装的软件包更为方便:(py311) $ conda update --all
-
移除已安装的软件包也很简单:
(py311) $ conda remove jupyter notebook
当conda
移除jupyter
和notebook
时,它还会移除它们所有悬挂的依赖项。悬挂的依赖项是指那些没有其他已安装软件包依赖的已安装包。像大多数通用的包管理器一样,conda
不会移除其他已安装包仍依赖的任何依赖项。
-
有时候你可能不知道想要安装的软件包的确切名称。亚马逊提供了一个名为 Boto 的 AWS SDK for Python。像许多 Python 库一样,Boto 有一个适用于 Python 2 的版本和一个适用于 Python 3 的较新版本(Boto3)。要在 Anaconda 中搜索包含
boto
名称的包,可以输入以下命令:(py311) $ conda search '*boto*'
-
你应该在搜索结果中看到
boto3
和botocore
。在撰写本文时,Anaconda 上可用的最新版本的boto3
是 1.36.3。要查看该特定版本boto3
的详细信息,请输入以下命令:(py311) $ conda search boto3=1.36.3 --info
包详情显示boto3
版本 1.36.3 依赖于botocore
(botocore >=1.36.3,<1.37.0
),所以安装boto3
会同时安装botocore
。
假设你已经在 Jupyter notebook 中安装了所有开发 OpenCV 项目所需的包。你如何与他人分享这些项目需求,以便他们能够重现你的工作环境呢?答案可能会让你吃惊:
-
你将当前活动的虚拟环境导出为 YAML 文件:
(py311) $ conda env export > my-environment.yaml
-
就像
pip freeze
生成的需求列表一样,conda
导出的 YAML 文件是你虚拟环境中所有已安装包的列表,并附带它们的固定版本。从环境文件创建conda
虚拟环境需要使用-f
选项和文件名:$ conda env create -f my-environment.yaml
-
环境名称已包含在导出的 YAML 文件中,因此创建环境时无需使用
--name
选项。任何从my-environment.yaml
创建虚拟环境的人,在执行conda env list
时,都能在环境列表中看到py311
。
conda
是开发人员工具库中一个非常强大的工具。通过将通用包安装与虚拟环境结合使用,它提供了一个引人注目的部署方案。conda
实现了 Docker(接下来介绍)所做的许多相同目标,但没有使用容器。由于其专注于数据科学社区,它在 Python 上比 Docker 更具优势。因为主要的 ML 框架(如 PyTorch 和 TensorFlow)大多基于 CUDA,寻找 GPU 加速的二进制文件通常是困难的。conda
通过提供多个预编译的包二进制版本解决了这个问题。
将 conda
虚拟环境导出为 YAML 文件,以便在其他机器上安装,是另一种部署选项。这种解决方案在数据科学社区中很受欢迎,但在嵌入式 Linux 生产环境中不起作用。conda
不是 Yocto 支持的三大包管理器之一。即使 conda
可用,考虑到资源限制,将 Miniconda 安装到 Linux 镜像中的存储空间对大多数嵌入式系统来说并不合适。
如果你的开发板配备了像 NVIDIA Jetson 系列这样的 NVIDIA GPU,那么你确实希望使用 conda
来进行设备端开发。幸运的是,有一个名为 Miniforge (github.com/conda-forge/miniforge)
) 的 conda
安装程序,已知在像 Jetson 这样的 64 位 ARM 机器上能够正常工作。使用设备上的 conda
,你可以安装 jupyter
、numpy
、pandas
、scikit-learn
和大多数其他流行的 Python 数据科学库。
总结
到现在为止,你可能会问自己:“这些关于 Python 包管理的内容和嵌入式 Linux 有什么关系?”答案是“关系不大”,但请记住,这本书的标题中也包含了开发一词,而这一章与现代软件开发息息相关。作为开发人员,要成功,你需要能够快速、频繁并可重复地将代码部署到生产环境。这意味着你需要小心地管理依赖项,并尽可能地自动化整个过程。现在,你已经知道如何使用 Python 来实现这一点。
进一步学习
-
Python 包管理用户指南,PyPA –
packaging.python.org
-
setup.py 与 requirements.txt,Donald Stufft 编写 –
caremad.io/posts/2013/07/setup-vs-requirement
-
pip 用户指南,PyPA –
pip.pypa.io/en/latest/user_guide/
-
Conda 用户指南,Anaconda 公司 –
docs.conda.io/projects/conda/en/latest/user-guide
第十六章:部署容器镜像
在本章中,我将介绍 DevOps 运动的基本原则,并展示如何将它们应用到嵌入式 Linux 中。首先,我们将学习如何使用 Docker 将 Python 应用和其用户空间环境打包成一个容器镜像。接下来,我们将为 Python 蓝牙服务器应用设置一个基于 Docker 的持续集成和持续交付(CI/CD)管道。然后,我将演示如何将 Docker 快速添加到 Raspberry Pi 4 的 Yocto 镜像中。最后,我们将部署一个容器化的软件更新到运行 Docker 的 Raspberry Pi 4 上。
在本章中,我们将涵盖以下主题:
-
什么是 DevOps?
-
DevOps 与嵌入式 Linux
-
使用 Docker 部署 Python 应用
-
为 Python 应用设置 CI/CD 管道
-
将 Docker 添加到 Yocto 镜像中
-
使用 Docker 更新软件
技术要求
为了跟随示例,请确保你拥有以下内容:
-
一台至少有 90GB 可用磁盘空间的 Ubuntu 24.04 或更高版本的 LTS 主机系统
-
在主机系统上具有管理员或 sudo 权限的用户帐户
-
Yocto 5.0(Scarthgap)LTS 版本
-
一台 microSD 卡读卡器和卡
-
balenaEtcher
for Linux -
一根以太网电缆和带有可用端口的路由器以提供网络连接
-
一台 Raspberry Pi 4
-
一台能够提供 3A 电流的 5V USB-C 电源
你应该已经在第六章构建了 5.0(Scarthgap)LTS 版本的 Yocto。如果没有,请参考兼容的 Linux 发行版和构建主机软件包部分,按照Yocto 项目快速构建指南中的说明在 Linux 主机上构建 Yocto。
本章中使用的代码可以在本书的 GitHub 仓库的章节文件夹中找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development/tree/main/Chapter16
。
获取 Docker
在 Ubuntu 24.04 LTS 上安装 Docker:
-
更新软件包仓库:
$ sudo apt update
-
安装 Docker:
$ sudo apt install docker.io
-
启动 Docker 守护进程并启用其在启动时自动启动:
$ sudo systemctl enable --now docker
-
将自己添加到
docker
组:$ sudo usermod -aG docker <username>
-
重启 Docker 守护进程:
$ sudo systemctl restart docker
在步骤 4中将<username>
替换为你的用户名。我建议创建你自己的 Ubuntu 用户帐户,而不是使用默认的ubuntu
用户帐户,因为该帐户应该保留用于管理任务。
什么是 DevOps?
自 2009 年起,DevOps 运动已席卷软件行业。Patrick Debois 在观看 2009 年 Velocity 大会的演讲《每天部署 10 次》后,创造了DevOps这一术语。Patrick 是《DevOps 手册》的四位共同作者之一,其他三位作者是 Gene Kim、Jez Humble 和 John Willis。《DevOps 手册》首次出版于 2016 年,系统地阐述了这一运动的原则。这些理念源自精益生产和敏捷软件开发社区。DevOps 实践与敏捷方法论如 Scrum 和 Kanban 紧密相关。这些方法的目标始终是更快速地将高质量的产品交付给客户。
DevOps 力图将组织内的开发和运维团队融合在一起。在历史上,公司中负责操作软件的人与开发这些软件的人是分开的。有时会有一支专门的系统管理员(IT)团队,负责配置服务器和部署计划中的软件发布。这样的职责分离,再加上“大爆炸式”部署,往往会导致长时间的延迟和故障。开发与运维之间的关系会变得对立,失败时大家相互指责。相反,DevOps 鼓励密切合作、快速迭代和实验。错误是我们学习的方式。
持续集成和持续部署
精益生产的两个核心概念是价值流和与之相关的交付时间。精益哲学源自汽车工业,尤其是丰田生产系统。如果把价值流看作工厂的生产线,那么交付时间就是从客户提出需求到需求得到满足的时间。交付时间是衡量价值流表现的指标之一。减少交付时间使工厂能够更快地生产汽车。这个理念同样适用于软件。
在软件中,我们可以将交付时间看作从提交功能请求到该功能部署到生产环境的时间。每次开发人员提交并推送代码更改时,自动化构建过程会被触发。自动化构建会对新更改的代码运行一组单元测试。只有当构建成功并且测试套件通过时,代码更改才能合并到主分支。这些检查都是通过脚本自动执行的。构建软件和执行测试所需的时间越长,交付时间也就越长。
集成代码只是价值流的一部分。要向客户交付价值,软件必须部署到生产环境中。这通常意味着标记一个版本、在云端启动服务器,并将新版本安装到这些服务器上。为了确保部署顺利进行,可以采用几种技术。首先运行集成测试。在整个服务器集群上逐步推出版本。如果软件更新出现问题,可以回滚到先前的版本。只有当软件每天多次部署到生产环境中时,才能最大化开发人员的生产力。价值流即是CI/CD流水线。
基础设施即代码
我们需要的不仅仅是源代码来构建和部署大多数软件。如今,现代软件开发大多涉及 Docker。一个应用程序通常需要一个 Dockerfile、makefile 和 shell 脚本来构建和打包软件以供发布。在 CI/CD 流水线的不同阶段,这些项会由一个 YAML 文件来调用。由于它们并不是实际软件的一部分,我们可能不会将这些项视为代码。然而,它们与源代码一起存储在版本控制中,同样需要进行审查和维护。由于软件的构建和打包完全由脚本控制,因此这一任务是可以轻松重复的。
在部署过程中,涉及的 YAML 文件量会增加。像 Terraform 和 CloudFormation 这样的云原生工具是基于 YAML 的。我们通过应用声明式的 YAML 文件,使用这些工具来配置云基础设施并将软件版本发布工件部署到其中。部署由与构建和打包相同的顶级 YAML 文件驱动。这样,整个过程可以实现端到端自动化。尽管它们看起来不像代码,但 YAML 文件实际上是代码,应该遵循与高层编程语言(如 Python)编写的代码相同的质量标准。
安全是共同责任
当市场时间至关重要时,安全常常被放在次要位置。与部署类似,安全往往被委托给运维部门。像 2022 年的 Log4j 漏洞和 2024 年的 xz
后门等高调事件,展示了安全对日常业务至关重要。DevOps 认为,安全问题在开发的每个阶段都应该考虑,而不是事后才想到。知识产权和客户数据始终是加密的。像密钥和密码等机密信息会安全地存储在版本控制之外。安全最佳实践是每个人的责任,必须从一开始就加以执行。
监控与可观察性
通过系统统计、日志和追踪收集遥测数据,让我们能够了解应用程序的健康状况和性能。一旦获取了遥测数据,可以将其显示在 Grafana 仪表盘上进行分析。这样,我们可以在问题导致昂贵的服务中断之前,及时发现性能回退、资源泄漏等系统性问题。实时洞察能够触发快速响应和解决问题。更重要的是,遥测数据为我们提供了关于我们作为开发者表现的即时反馈,让我们能够学习和改进。
持续改进
精益制造提倡短周期、小变动和快速迭代。埃里克·里斯的《精益创业》推广了最小可行产品(MVP)的概念。MVP 是具有足够功能的产品版本,初步用户可以就该产品提供反馈。这些反馈会被审查,并迅速做出改进以推出下一个版本。软件的 CI/CD 流水线能比工厂生产线更快地推出 MVP。
持续反馈激励开发者更频繁地发布小规模的增量改进。MVP 方法让团队能够在投入更多时间和资源之前,看到哪些有效,哪些无效。通过这种方式,可以进行调整,确保为用户提供更多的价值。DevOps 认为,尽早整合小的改动会带来更好的结果。这与让单一开发者在没有用户反馈的情况下,孤军奋战于长期存在的功能分支是完全不同的做法。
透明度
如果一个组织的文化不鼓励合作,那么合作就不太可能发生。恐惧充斥着功能失调的组织。个人通过隐藏信息来采取战略性行动,防止别人受益。这种行为导致了信息孤岛,所有的沟通只能在私人会议和需要知道的情况下进行。错误被隐藏,因为害怕惩罚(例如,领导“杀信使”)。DevOps 心态是开放的。成功、失败和创意会在组织内分享,以促进最佳实践。如果你在某个任务上遇到困难,你应该向团队求助。
DevOps 和嵌入式 Linux
硬件很难。PCB 布局、合同制造和板卡修订需要消耗大量时间和金钱。与软件相比,硬件的风险更大。交货周期更长,错误可能会带来灾难性后果。嵌入式 Linux 作为硬件和软件之间的桥梁。
嵌入式 Linux 工程师与电气工程师紧密合作,处理板子启动过程中出现的问题。要求电气工程师重新布线或添加上拉电阻并不罕见。PCB 布局非常复杂。没有人是完美的,因此新板子在第一次启动时几乎不会成功。
鉴于如此高的风险,DevOps 原则似乎并不适用于硬件产品。像测试驱动开发(TDD)这样的行业趋势,经常被经验丰富的嵌入式开发人员认为不切实际。在处理真实硬件时,自动化测试更加困难,但并非不可能。一旦功能开始快速交付,投入时间和精力建立 CI/CD 管道将带来丰厚的回报。管理层可能会质疑你为何在前期做这么多流程工作,但当新产品提前交付时,他们的态度会发生变化。
持续集成与交叉编译
Linux 及其上层的大多数中间件是主要用 C 语言编写的,这意味着它必须针对目标的指令集架构(ISA)进行本地编译。在云端,这个 ISA 通常是运行在 Intel 或 AMD CPU 上的 x86-64 架构。而在能够运行 Linux 的嵌入式设备上,ISA 越来越倾向于 64 位 Arm 架构。由于大多数云基础设施运行在 Intel 和 AMD 的 CPU 上,为嵌入式 Linux 构建软件需要一个交叉编译工具链。然而,交叉编译并不是 GitHub Actions 或 GitLab CI 等云端 CI/CD 服务的常见用例。
Buildroot 和 Yocto 都旨在交叉编译嵌入式 Linux 镜像,但在云端运行这些工具可能会遇到挑战。它们需要大量的磁盘空间,且较长的构建时间让人望而却步。通过采用增量构建和智能缓存(例如 Yocto 的共享 sstate-cache
),可以提高构建效率。另一种方法是将 Docker 与 QEMU 结合使用,为 64 位 Arm 交叉编译容器镜像。这种容器化的方式对用户空间很有效,但模拟目标架构会减慢编译速度。
在真实硬件上的自动化测试
交付硬件时面临的最大挑战之一是实现硬件在环(HIL)测试。像交叉编译一样,自动化测试可以通过 QEMU 在云端轻松完成,但没有什么能替代在预定硬件上进行软件测试。当安全性成为问题时,HIL 测试不仅仅是一个预防性措施,而是一项必要的工作。挑战在于如何实现自动化,这也是为什么 HIL 测试往往需要投入与编写软件同等的努力。
最有效的 HIL 测试形式是模拟现实世界。硬件通过传感器和执行器与现实世界进行交互。它从传感器接收输入,并通过 I2C、SPI 和 CAN 等通信接口将输出发送到执行器。我们通过软件建模来模拟现实世界。这个软件模型运行在独立的 Linux 机器上。它像实际系统中的传感器和执行器一样,通过各种通信接口发送和接收消息。例如,为了测试电动汽车充电器,我们会将 PCB 连接到测试台上的模拟电池,并运行我们的模型进行测试。
持续交付与 OTA 更新
当云中的部署失败时,我们只需删除有问题的服务器并启动新的实例。我们不需要担心服务器砖化,因为我们可以随时从头开始。在云中,重新配置服务器是相对快速且不痛苦的。对于现场的消费类设备则无法如此处理。如果设备无法启动,那么它就没用了。同样,如果一个连接的设备突然与互联网断开,那么它就无法接收关键的 OTA 更新。
OTA 更新是嵌入式系统中软件持续交付的方式。OTA 更新需要在意外断电的情况下具备容错性。失败的 OTA 更新不能导致部分或未知的闪存镜像。否则,设备可能会变得无法启动。Buildroot 和 Yocto 支持像 Mender、RAUC 和 SWUpdate 这样的容错 OTA 更新解决方案。尽管这些工具可以防止你的设备群被砖化,但你仍然应该在全面发布之前充分测试软件版本。没有什么比糟糕的用户体验更能快速葬送新产品发布了。
基础设施即代码和构建系统
Buildroot 依赖于 makefile。Yocto 由 BitBake 食谱组成。就像定义云基础设施的 YAML 文件一样,这些构建元数据也可以视为代码,应当存储在版本控制中。这包括 Buildroot 的板定义配置和包定义。对于 Yocto,构建元数据包括 BSP 和发行版层。通过定义 Dockerfile 将你的嵌入式 Linux 构建环境容器化也很有益。这可以更容易地启动 CI/CD 管道,为你的目标设备构建镜像,也让其他人更容易重现你的构建环境,从而在他们的机器上进行本地开发。
保护边缘设备
互联网充满了危险。臭名昭著的 Mirai 僵尸网络最初是由一些想要攻击竞争 Minecraft 服务器的孩子们发起的。这个想法演变成了大规模的分布式拒绝服务(DDoS)攻击。Mirai 劫持了消费者的物联网设备,如网络摄像头和家用路由器,并将它们指向选定的网站。确保启动和 OTA 更新过程的安全性可以防止像 Mirai 这样的恶意软件在用户设备上运行。有关如何确保启动和 OTA 更新过程安全性的机制,请参见第十章。在边缘设备上,安全性至关重要,因为一旦你的设备群被劫持,你是无法找回它们的。
安全启动意味着设备只能从由设备制造商加密签名的镜像启动。启动时会插入签名验证步骤,以确保通过 OTA 更新应用的最新镜像的真实性。用户还希望所有设备上的数据都能加密以保护隐私。自动解锁加密卷需要在启动时输入密码。设备在运行时所需的任何密钥或密码应安全地存储在 TPM 或安全元件中。
边缘设备的监控与可观测性
从消费设备收集遥测数据非常困难,因为这些设备部署在用户的家中和办公室里。像所有其他连接到互联网的设备一样,任何想要将遥测数据传输到云端的新产品都必须通过防火墙。这通常需要用户在 Wi-Fi 路由器上打开一个外向端口。用户可能不够了解网络,或者出于隐私原因反对这样做。尽管像 MQTT 这样的标准 IoT 协议可以用于遥测,但它们并不总是适合每一个应用场景。在这个领域,像 Golioth 和 Memfault 这样的初创公司仍有很大的创新空间。
够多的理论和理论依据了。现在让我们将这些原则付诸实践。我们将从执行一个容器化的软件部署开始。您应该已经根据《获取 Docker》部分的说明,在您的 Linux 主机上安装了 Docker。
使用 Docker 部署 Python 应用程序
Docker 提供了另一种将 Python 代码与其他语言编写的软件捆绑的方法。Docker 背后的理念是,您不需要将应用程序打包并安装到一个预配置的服务器环境中,而是构建并分发一个包含您的应用程序及其所有运行时依赖的容器镜像。容器镜像更像是一个虚拟环境,而非虚拟机。虚拟机是一个完整的系统镜像,包括内核和操作系统。而容器镜像是一个最小的用户空间环境,只包含运行应用程序所需的二进制文件。
虚拟机运行在模拟硬件的虚拟机监控器上,而容器则直接运行在主机操作系统之上。与虚拟机不同,容器能够共享相同的操作系统和内核,而无需使用硬件模拟。容器依赖于 Linux 内核的两个特殊功能来实现隔离:命名空间和控制组。Docker 并没有发明容器技术,但他们是第一个构建使容器易于使用的工具的公司。由于 Docker 使构建和部署容器镜像变得如此简单,曾经的“在我的机器上能运行”这一借口不再成立。
Dockerfile 的结构
一个 Dockerfile 描述了 Docker 镜像的内容。每个 Dockerfile 都包含一组指令,指定使用哪个环境以及要运行哪些命令。我们将使用一个现有的项目模板中的 Dockerfile,而不是从零开始编写 Dockerfile。这个 Dockerfile 为一个非常简单的 Flask Web 应用程序生成 Docker 镜像,您可以扩展该镜像以满足自己的需求。该 Docker 镜像是基于 Debian Bookworm 构建的。除了 Flask,Docker 镜像还包括 uWSGI 和 Nginx,以提高性能。
首先,在浏览器中打开 GitHub 上的uwsgi-nginx-flask-docker
项目页面(github.com/tiangolo/uwsgi-nginx-flask-docker
)。然后,点击README.md
文件中的python-3.12
Dockerfile 链接。
现在,查看该 Dockerfile 中的第一行:
FROM tiangolo/uwsgi-nginx:python3.12
这个FROM
命令告诉 Docker 从 Docker Hub 的tiangolo
命名空间拉取一个名为uwsgi-nginx
的镜像,并且包含python3.12
。Docker Hub 是一个公共注册中心,用户可以在其中发布自己的 Docker 镜像供他人获取和部署。如果你愿意,也可以使用像 AWS ECR 或 Quay 这样的服务设置自己的镜像注册中心。你需要在命名空间前面插入注册中心服务的名称,像这样:
FROM quay.io/my-org/my-app:my-tag
否则,Docker 会默认从 Docker Hub 拉取镜像。FROM
就像 Dockerfile 中的include
语句,它将另一个 Dockerfile 的内容插入到你的 Dockerfile 中,以便你可以在其基础上构建。我喜欢把这种方式看作是图层化镜像。Debian Bookworm 是基础层,接着是 Python 3.12,然后是 uWSGI 加 Nginx,最后是你的 Flask 应用程序。你可以通过深入研究hub.docker.com/r/tiangolo/uwsgi-nginx
上的python3.12
Dockerfile 来了解更多有关镜像图层化的工作原理。
这是 Dockerfile 中下一个值得注意的行:
RUN pip install --no-cache-dir -r /tmp/requirements.txt
RUN
指令用于运行命令。Docker 会按顺序执行 Dockerfile 中的RUN
指令,以构建最终的 Docker 镜像。如果你查看 Git 仓库中的requirements.txt
文件,你会看到这个RUN
指令会在系统的site-packages
目录中安装 Flask。我们知道pip
可用,因为uwsgi-nginx
基础镜像也包括了 Python 3.12。
我们跳过 Nginx 的环境变量,直接进入复制操作:
COPY ./app /app
这个特定的 Dockerfile 位于一个 Git 仓库中,并与其他几个文件和子目录一起存放。COPY
指令将主机 Docker 运行环境中的一个目录(通常是仓库的 Git 克隆)复制到正在构建的容器中。
你正在查看的python3.12.dockerfile
文件位于tiangolo/uwsgi-nginx-flask-docker
仓库的docker-images
子目录中。在docker-images
目录中,有一个名为app
的子目录,里面包含一个 Hello World Flask Web 应用程序。这个COPY
指令将app
目录从示例仓库复制到 Docker 镜像的根目录:
WORKDIR /app
WORKDIR
指令告诉 Docker 从容器内的哪个目录开始工作。在这个例子中,它刚刚复制的/app
目录成为工作目录。如果目标工作目录不存在,WORKDIR
会创建它。Dockerfile 中后续出现的任何非绝对路径都将相对于/app
目录。
现在让我们来看一下如何在容器内设置一个环境变量:
ENV PYTHONPATH=/app
ENV
指令告诉 Docker 后面的内容是环境变量定义。PYTHONPATH
是一个环境变量,它扩展成一个以冒号分隔的路径列表,Python 解释器会在这些路径中查找模块和包。
接下来,我们跳到第二个RUN
指令:
RUN chmod +x /entrypoint.sh
RUN
指令告诉 Docker 从 Shell 运行一个命令。在这个例子中,运行的命令是chmod
,它用于更改文件权限。在这里,它使/entrypoint.sh
变为可执行文件。
Dockerfile 中的下一行是可选的:
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT
是这个 Dockerfile 中最有趣的指令。当启动容器时,它会暴露一个可执行文件到 Docker 主机的命令行。这允许你从命令行将参数传递给容器内的可执行文件。你可以在命令行中docker run
<image>
后附加这些参数。如果 Dockerfile 中有多个ENTRYPOINT
指令,那么只有最后一个ENTRYPOINT
会被执行。
Dockerfile 中的最后一行是:
CMD ["/start.sh"]
与ENTRYPOINT
指令类似,CMD
指令在容器启动时执行,而非构建时执行。当在 Dockerfile 中定义了ENTRYPOINT
指令时,CMD
指令定义了要传递给ENTRYPOINT
的默认参数。在这个例子中,/start.sh
路径是传递给/entrypoint.sh
的参数。/entrypoint.sh
中的最后一行执行/start.sh
:
#! /usr/bin/env sh
set -e
# If there's a prestart.sh script in the /app directory, run it before
# starting
PRE_START_PATH=/app/prestart.sh
echo "Checking for script in $PRE_START_PATH"
if [ -f $PRE_START_PATH ] ; then
echo "Running script $PRE_START_PATH"
. $PRE_START_PATH
else
echo "There is no script $PRE_START_PATH"
fi
# Start Supervisor, with Nginx and uWSGI
exec /usr/bin/supervisord
/start.sh
脚本来自uwsgi-nginx
基础镜像。/start.sh
在/entrypoint.sh
配置完容器运行环境后启动 Nginx 和 uWSGI。当CMD
与ENTRYPOINT
一起使用时,CMD
设置的默认参数可以通过 Docker 主机命令行进行覆盖。
大多数 Dockerfile 中没有ENTRYPOINT
指令,因此 Dockerfile 的最后一行通常是一个CMD
指令,它会在前台运行,而不是使用默认参数。你可以利用这个 Dockerfile 技巧保持一个通用的 Docker 容器在开发时运行:
CMD tail -f /dev/null
除了ENTRYPOINT
和CMD
外,这个python-3.12
Dockerfile 中的所有指令只在容器构建时执行。
构建一个 Docker 镜像
在我们能够构建 Docker 镜像之前,我们需要一个 Dockerfile。你可能已经在系统中拥有一些 Docker 镜像。
要查看 Docker 镜像的列表:
$ docker images
现在,让我们获取并构建我们刚刚分析过的 Dockerfile:
-
克隆包含 Dockerfile 的仓库:
$ git clone https://github.com/tiangolo/uwsgi-nginx-flask-docker.git
-
切换到仓库中的
docker-images
子目录:$ cd uwsgi-nginx-flask-docker/docker-images
-
将
python3.12.dockerfile
复制到名为Dockerfile
的文件中:$ cp python3.12.dockerfile Dockerfile
-
从 Dockerfile 构建一个镜像:
$ docker build -t my-image .
镜像构建完成后,它会出现在你的本地 Docker 镜像列表中:
$ docker images
新构建的my-image
应该出现在列表中。
运行一个 Docker 镜像
现在我们已经构建了一个 Docker 镜像,可以将其作为容器运行。
要获取系统上运行中的容器列表:
$ docker ps
要运行基于my-image
的容器:
$ docker run -d --name my-container -p 80:80 my-image
如果前面的命令因端口 80
被占用而失败,则将端口 80
替换为 8080
。现在查看你运行中的容器的状态:
$ docker ps
你应该在列表中看到一个名为 my-container
的容器,它基于一个名为 my-image
的镜像。docker run
命令中的 -p
选项将容器端口映射到主机端口。所以,在这个例子中,容器端口 80
映射到主机端口 80
。这种端口映射允许在容器内运行的 Flask Web 服务器处理 HTTP 请求。
停止 my-container
:
$ docker stop my-container
现在再次检查你运行中的容器的状态:
$ docker ps
my-container
应该不再出现在运行中的容器列表中。容器消失了吗?没有,它只是停止了。你仍然可以通过在 docker
ps
命令中添加 -a
选项来查看 my-container
及其状态:
$ docker ps -a
稍后我们将看看如何删除不再需要的容器。
拉取 Docker 镜像
在本节前面,我提到过 Docker Hub、AWS ECR 和 Quay 等镜像注册表。事实证明,我们从克隆的 Git 仓库构建的本地 Docker 镜像已经发布在 Docker Hub 上。从 Docker Hub 拉取预构建的镜像比在本地系统上自己构建它更快。该项目的 Docker 镜像可以在 hub.docker.com/r/tiangolo/uwsgi-nginx-flask
上找到。
从 Docker Hub 拉取我们构建的名为 my-image
的相同 Docker 镜像:
$ docker pull tiangolo/uwsgi-nginx-flask:python3.12
现在再次查看你的 Docker 镜像列表:
$ docker images
你应该在列表中看到一个新的uwsgi-nginx-flask
镜像。
要运行这个新拉取的镜像:
$ docker run -d --name flask-container -p 80:80 tiangolo/uwsgi-nginx-flask:python3.12
如果你不想键入完整的镜像名称,你可以用 docker images
中对应的镜像 ID(哈希值)替换前面的 docker run
命令中的完整镜像名称(repo:tag
)。
发布 Docker 镜像
要将 Docker 镜像发布到 Docker Hub,你必须首先拥有一个账户并登录。你可以通过访问 hub.docker.com
网站创建一个账户并注册。注册成功后,你就可以将现有镜像推送到你的 Docker Hub 仓库:
-
从命令行登录 Docker Hub 镜像注册表:
$ docker login
-
在提示时输入你的 Docker Hub 用户名和密码。
-
给一个已有的镜像打上一个新标签,标签名以你的仓库名称开头:
$ docker tag my-image:latest <repository>/my-image:latest
-
将前面命令中的
<repository>
替换为你在 Docker Hub 上的仓库名称(与用户名相同)。你也可以将要推送的另一个已有镜像名称替换为my-image:latest
。 -
将镜像推送到 Docker Hub 镜像注册表:
$ docker push <repository>/my-image:latest
-
再次进行与步骤 3相同的替换。
默认情况下,推送到 Docker Hub 的镜像是公开可用的。要访问你新发布镜像的网页,请访问hub.docker.com/repository/docker/<repository>/my-image
。将前面 URL 中的<repository>
替换为你在 Docker Hub 上的仓库名称(与用户名相同)。如果实际推送的镜像名称不同,也可以将my-image:latest
替换为该镜像的名称。如果你点击网页上的Tags标签,你应该能看到获取该镜像的docker pull
命令。
清理
我们知道docker images
列出镜像,docker ps
列出容器。在删除 Docker 镜像之前,我们必须先删除所有引用该镜像的容器。要删除 Docker 容器,首先需要知道容器的名称或 ID:
-
查找目标 Docker 容器的名称:
$ docker ps -a
-
如果容器正在运行,请停止它:
$ docker stop flask-container
-
删除 Docker 容器:
$ docker rm flask-container
将前面两个命令中的flask-container
替换为步骤 1中的容器名称或 ID。每个出现在docker ps
中的容器也都有与之相关联的镜像名称或 ID。一旦你删除了所有引用该镜像的容器,就可以删除该镜像。
Docker 镜像名称(repo:tag
)可能会很长(例如,tiangolo/uwsgi-nginx-flask:python3.12
)。因此,我发现删除镜像时直接复制并粘贴镜像的 ID(哈希值)更方便:
-
查找 Docker 镜像的 ID:
$ docker images
-
删除 Docker 镜像:
$ docker rmi <image-ID>
将前面的命令中的<image-ID>
替换为步骤 1中的镜像 ID。
如果你仅仅想清除系统中不再使用的所有容器和镜像:
$ docker system prune -a
docker system prune
会删除所有已停止的容器和悬挂镜像。
我们已经看到如何使用pip
来安装 Python 应用程序的依赖项。你只需在 Dockerfile 中添加一个调用pip install
的RUN
指令。由于容器是沙箱环境,它们提供了许多与虚拟环境相同的好处。但与conda
和venv
虚拟环境不同,Buildroot 和 Yocto 都支持 Docker 容器。Buildroot 有docker-engine
和docker-cli
软件包。Yocto 有meta-virtualization
层。如果你的设备由于 Python 包冲突需要隔离,你可以通过 Docker 实现这一点。
docker run
命令提供了将操作系统资源暴露给容器的选项。指定绑定挂载允许将主机上的文件或目录挂载到容器内,以供读写。默认情况下,容器不会向外界发布任何端口。当你运行my-container
镜像时,你使用了-p
选项将容器的端口80
发布到主机的端口80
。--device
选项将主机设备文件添加到非特权容器中的/dev
目录。如果你希望授予对主机上所有设备的访问权限,可以使用--privileged
选项。
容器的强项在于部署。能够推送一个 Docker 镜像,然后在任何主要的云平台上轻松拉取并运行,这一功能彻底改变了 DevOps 运动。由于像 balena 这样的 OTA 更新解决方案,Docker 在嵌入式 Linux 领域也在取得进展。Docker 的一个缺点是运行时的存储占用和内存开销。Go 二进制文件有点臃肿,但 Docker 在四核 64 位 Arm 单板计算机(如 Raspberry Pi 4 和 BeaglePlay)上运行得很好。如果你的目标设备有足够的性能,那么就在上面运行 Docker。你的软件开发团队会感谢你的。
重要提示
Podman 是 Docker 的替代品,提供了一个更轻量的无守护进程架构。与 Docker 不同,Podman 不需要一个持续在后台运行的服务,这使得它更加高效。它对无根容器的支持增强了安全性,并且它与 OCI 标准的兼容性确保了灵活性。
为 Python 应用设置 CI/CD 流水线
Docker 不仅仅是为了将软件部署到云端。基于云的 CI/CD 服务可以构建并发布 64 位 Arm 容器镜像,以便部署到边缘设备。容器化的软件更新比完整的 A/B 镜像更新更不具破坏性,因为它们不需要重启。即使只是片刻,用户看到他们的设备掉线时也会感到紧张。
容器化软件更新的风险比完整的 A/B 镜像更新小,因为它们不包括 Linux 内核。边缘设备可能因为内核更新错误而无法启动。如果没有 fail-safe 机制,设备实际上就会变砖。随着硬件老化,上游内核模块会失修。内核升级尤其危险,因为它们可能导致内核崩溃。
在第七章的基于现有 BSP 构建一节中,我们为 Raspberry Pi 4 的 Yocto 镜像添加了一个用于 Python 蓝牙服务器应用的自定义层。我们可以使用 Docker 将相同的应用部署到一批 Raspberry Pi 4 上。
Python 蓝牙服务器的源代码存放在一个公开的 Git 仓库中(github.com/fvasquez/gattd
)。每当提交推送到该仓库时,GitHub Actions 可以尝试构建并发布容器镜像。
创建 Dockerfile
要在容器内运行 gattd
,首先需要一个 Dockerfile。由于 gattd
是一个 蓝牙低能耗 (BLE) GATT 服务器,它依赖于在运行时可用的蓝牙硬件和软件。幸运的是,Raspberry Pi 4 内置了蓝牙,因此已经有了完善的内核支持来支持 BLE。我们的 gattd
容器镜像需要包含 BlueZ 软件栈,以便利用所有这些蓝牙支持。BlueZ 又依赖于 D-Bus,因此它也必须包含在我们的镜像中。D-Bus 是一种基于消息的中间件,能够实现同一计算机上多个进程之间的通信。D-Bus 中的 D 代表 desktop(桌面),但服务器也依赖它进行进程间通信。D-Bus 支持请求-响应和发布/订阅消息,并且与 systemd
深度集成。
由于 gattd
是一个 Python 应用,Dockerfile 中没有编译步骤。Python 发行版并不是通过 Yocto 编译的,而是 Dockerfile 顶部指定的基础 Linux 发行版或基础层的一部分。我选择了 Ubuntu 作为我的基础层,因为 Ubuntu LTS 版本经过充分的实际硬件测试,比如 Raspberry Pi 4。
依赖 Ubuntu 作为用户空间省去了构建自己发行版和进行所有相关测试的麻烦。既然 Canonical 已经为你做好了这件事,为什么还要费心维护一个 Linux 发行版层呢?选择 Ubuntu 可以节省宝贵的开发时间。你的软件团队不需要再学习 Yocto 或安装 eSDK。Ubuntu 是一个成熟的系统。
这是我提交到 gattd
仓库根目录的 Dockerfile:
# Dockerfile
FROM arm64v8/ubuntu:24.04
LABEL maintainer="fvasquez@gmail.com"
RUN apt update && apt-get install -y \
bluez \
dbus \
python3-dbus \
python3-gi
# Your app code, binaries, or other instructions
COPY . /app
WORKDIR /app
# Example app run
CMD ./entrypoint.sh
Docker 官方镜像 (DOI) 托管在 Docker Hub 上。DOI 计划的主要目标之一是发布针对 amd64 以外架构的容器镜像。DOI 支持的一个架构是 arm64v8,这是 Raspberry Pi 4 的指令集架构 (ISA)。Docker Hub 上的 arm64v8
组织代表 DOI 计划发布和维护大量容器镜像,其中包括 Debian、Ubuntu 和 Python 的官方 arm64v8 容器镜像。当我编写这个 Dockerfile 时,24.04 是最新的 Ubuntu LTS 版本。
gattd
应用主要依赖于 Python 标准库。唯一的其他 Python 包依赖是 D-Bus 的绑定和 GObject introspection 库的绑定。这两个包不需要额外的 pip install
步骤,因为 Ubuntu 上已有现成的包可用。与 JavaScript 不同,后者标准库非常有限,Python 提供了“随附电池”功能,因此你的应用可能不需要除 apt
外的其他包管理器。
请记住,COPY
指令将源文件从 Git 仓库复制到正在构建的容器中。等我解释完 gattd
容器镜像的发布方式后,我会再讲解 entrypoint.sh
脚本。
创建 GitHub Actions 工作流
GitHub Actions 是 GitHub 提供的免费 CI/CD 服务。每当向 gattd
仓库推送更改时,GitHub Actions 可以构建容器镜像并将其发布到 GitHub 容器注册表(GHCR)。GitHub Packages 是 GitHub 的软件包托管服务,用于软件发布。GHCR 是 GitHub Packages 的一部分,因此除了使用属于你或你所在组织的仓库之外,访问 GHCR 不需要额外的步骤。我拥有 gattd
仓库,它是我从 github.com/Jumperr-labs/python-gatt-server
叉出来的。Python 代码由 Dan Shemesh 编写,追溯到 2017 年。
像大多数 CI/CD 服务一样,GitHub Actions 工作流是作为 YAML 文件定义的。默认的工作流文件名为 main.yml
。对工作流文件的更改会被提交到仓库的 .github/workflows
目录中。由于这些文件与它们构建和部署的源代码一起存储在版本控制中,因此工作流文件构成了基础设施即代码。
下面是我为 gattd
仓库定义的 main.yml
工作流文件的内容:
name: Publish Docker Image to GHCR
on:
push:
branches: [ "master" ]
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-24.04-arm
steps:
# 1) Check out the code
- name: Check out code
uses: actions/checkout@v4
# 2) Log in to GitHub Container Registry
- name: Log in to GHCR
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# 3) Build and Push the Docker image
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
platforms: linux/arm64
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest
该 main.yml
文件也包含在书籍 Git 仓库的 Chapter16
文件夹中。
一个简单的三步工作流就足以将容器镜像发布到 GHCR。每当将提交推送到仓库的 master
分支时,工作流都会被触发。
重要提示
在为你自己的仓库创建 GitHub Actions 工作流时,确保在 main.yml
文件的 branches
列表中将 master
替换为 main
。否则,如果没有名为 master
的分支,工作流将失败。尽管 main
现在是 GitHub 上默认分支的名称,但当你创建新仓库时,Git 中的默认分支仍然是 master
。
另一个需要注意的点是 runs-on: ubuntu-24.04-arm
,它指示 GitHub Actions 为此工作流使用 arm64 托管的运行器。这意味着 GitHub 为此工作流启动的任何托管运行器都将在真实的 64 位 Arm CPU 核心上运行,从而无需进行交叉编译或仿真。
工作流的 Step 3 会构建并推送由仓库的 Dockerfile 定义的容器镜像。注意,platforms
只指定了 linux/arm64
。platforms
元素用于使用 Docker buildx
构建多平台容器镜像。Docker buildx
利用 QEMU 为非本地架构(即“平台”)编译容器镜像。由于 gattd
针对 Raspberry Pi 4,因此只需要为本地的 linux/arm64
平台构建容器镜像。Docker buildx
正在积极开发中。了解更多关于该插件以及构建多平台镜像的信息,请访问 github.com/docker/buildx
。
创建 GitHub Actions 工作流:
-
从你的仓库,点击顶部栏中的 Actions 图标。
-
在 Get started with GitHub Actions 下,点击 Skip this and set up a workflow yourself。
-
将
main.yml
的内容粘贴到编辑窗口中。 -
点击绿色的提交更改...按钮。
-
在提交更改对话框中,点击绿色的提交更改按钮。
图 16.1 – 提交更改
点击绿色的提交更改按钮会触发 GitHub Actions 工作流。GitHub 随后会启动一个托管运行器来构建仓库的 Dockerfile,并将任何生成的容器镜像推送到 GHCR。如果一切顺利,你会看到提交的状态为成功,并且在构建并推送任务旁边会有一个绿色圆圈中的白色勾号。这项工作流我第一次运行时花了 58 秒,现在每当提交推送到master
分支时都会触发。
拉取并运行最新镜像
Docker 需要空间来写入它拉取的容器镜像。大多数嵌入式 Linux 文件系统要么是只读的,要么太小,无法存储像gattd:latest
这样的容器镜像。这就是为什么你需要在树莓派 4 上安装一个通用的 Linux 发行版,比如 Ubuntu Server,以便进行本练习。最简单的方法是使用来自 raspberrypi.org 的官方树莓派映像工具。
首先,将树莓派映像工具下载并安装到你的 Linux 主机。有关如何操作的说明可以在 raspberrypi.com 上找到。
要将 Ubuntu Server 下载并安装到 microSD 卡:
-
将 microSD 卡插入你的 Linux 主机。
-
启动树莓派映像工具。
-
选择树莓派 4作为你的树莓派设备。
-
选择其他通用操作系统作为你的操作系统。
-
在操作系统菜单中,选择Ubuntu。
-
然后选择Ubuntu Server 24.04.1 LTS(64 位)或最接近的可用版本。
-
当询问是否应用操作系统自定义设置?时,点击编辑设置按钮。
-
如图所示,在一般设置页面输入用户名和密码:
图 16.2 – 一般设置
-
将
frank
替换为你想要的用户名。 -
在服务页面勾选启用 SSH,如图所示:
图 16.3 – 服务
-
点击红色的保存按钮。
-
选择 microSD 卡作为存储设备。
-
将 Ubuntu Server 镜像写入 microSD 卡。由于树莓派映像工具会格式化 microSD 卡上的所有可用空间,这个过程需要几分钟时间。
-
在树莓派映像工具完成写入后,弹出 microSD 卡。
-
将 microSD 卡插入你的树莓派 4。
-
通过 USB-C 端口为树莓派 4 供电。
要通过 SSH 连接到树莓派 4:
$ ssh <username>@raspberrypi.local
将<username>
替换为你在安装 Ubuntu Server 时创建的账户用户名。登录时,使用该账户创建的密码。
要在树莓派 4 上安装并配置 Docker:
-
更新软件包元数据:
$ sudo apt update
-
安装 Docker 守护进程:
$ sudo apt install docker.io
-
配置系统在启动时启动 Docker 守护进程:
$ sudo systemctl enable --now docker
-
将用户添加到
Docker
组:$ sudo usermod -aG docker <frank>
-
用你在安装 Ubuntu Server 时创建的账户用户名替换
<frank>
。 -
重启 Docker 守护进程:
$ sudo systemctl restart docker
-
关闭会话:
$ exit
再次通过 SSH 连接到 Raspberry Pi 4。
从 GHCR 拉取最新的 gattd
容器镜像:
frank@raspberrypi:~$ docker pull ghcr.io/fvasquez/gattd:latest
latest: Pulling from fvasquez/gattd
820619057a1c: Pull complete
84a50057c1f4: Pull complete
b6caffbfe56a: Pull complete
4f4fb700ef54: Pull complete
Digest: sha256:85ad5878bda3a390fe33d7474d88c2e921f51a7df314351be9d2e00a4c3ba8f1
Status: Downloaded newer image for ghcr.io/fvasquez/gattd:latest
ghcr.io/fvasquez/gattd:latest
要运行最新的 gattd
容器镜像:
frank@raspberrypi:~$ docker run --net=host --privileged -t ghcr.io/fvasquez/gattd:latest
* Starting system message bus dbus [ OK ]
* Starting bluetooth [ OK ]
Waiting for services to start... done! (in 2 s)
/app/gatt_server_example.py:25: PyGIDeprecationWarning: GObject.MainLoop is deprecated; use GLib.MainLoop instead
mainloop = GObject.MainLoop()
checking adapter /org/bluez, keys: dict_keys([dbus.String('org.freedesktop.DBus.Introspectable'), dbus.String('org.bluez.AgentManager1'), dbus.String('org.bluez.ProfileManager1'), dbus.String('org.bluez.HealthManager1')])
checking adapter /org/bluez/hci0, keys: dict_keys([dbus.String('org.freedesktop.DBus.Introspectable'), dbus.String('org.bluez.Adapter1'), dbus.String('org.freedesktop.DBus.Properties'), dbus.String('org.bluez.BatteryProviderManager1'), dbus.String('org.bluez.GattManager1'), dbus.String('org.bluez.Media1'), dbus.String('org.bluez.NetworkServer1'), dbus.String('org.bluez.LEAdvertisingManager1')])
found adapter /org/bluez/hci0
returning adapter /org/bluez/hci0
adapter: /org/bluez/hci0
checking adapter /org/bluez, keys: dict_keys([dbus.String('org.freedesktop.DBus.Introspectable'), dbus.String('org.bluez.AgentManager1'), dbus.String('org.bluez.ProfileManager1'), dbus.String('org.bluez.HealthManager1')])
checking adapter /org/bluez/hci0, keys: dict_keys([dbus.String('org.freedesktop.DBus.Introspectable'), dbus.String('org.bluez.Adapter1'), dbus.String('org.freedesktop.DBus.Properties'), dbus.String('org.bluez.BatteryProviderManager1'), dbus.String('org.bluez.GattManager1'), dbus.String('org.bluez.Media1'), dbus.String('org.bluez.NetworkServer1'), dbus.String('org.bluez.LEAdvertisingManager1')])
found adapter /org/bluez/hci0
returning adapter /org/bluez/hci0
Registering GATT application...
GetManagedObjects
GetAll
returning props
GATT application registered
Advertisement registered
Battery level: 98
Battery level: 96
Battery level: 94
Battery level: 92
Battery level: 90
Battery level: 88
这是当运行 gattd
容器镜像时执行的 entrypoint.sh
脚本:
#!/bin/bash
# Start services
systemctl start dbus
systemctl start bluetooth
# Wait for services to start
msg="Waiting for services to start..."
time=0
echo -n $msg
while [[ "$(pidof start-stop-daemon)" != "" ]]; do
sleep 1
time=$((time + 1))
echo -en "\r$msg $time s"
done
echo -e "\r$msg done! (in $time s)"
# Reset Bluetooth adapter by restarting it
hciconfig hci0 down
hciconfig hci0 up
# Start application
python3 /app/gatt_server_example.py
这个 entrypoint.sh
文件来自 Thomas Huffert 在 Medium 上写的一篇博文,内容是如何使用 BlueZ 运行容器化的蓝牙应用程序。链接到他的原始文章包含在本章结尾的 进一步学习 部分。
将 Docker 添加到 Yocto 镜像
我们不需要在 Raspberry Pi 4 上安装 Ubuntu 就能利用 Docker。Buildroot 和 Yocto 都能够为嵌入式目标构建 Docker。将 Docker 添加到 Yocto 镜像是非常简单的。只需将软件包附加到现有镜像中即可。我们将利用 第七章中 在现有 BSP 基础上构建 部分的 rpi-test-image
。
添加 meta-virtualization
层
Yocto 的 meta-virtualization
层包含启用云工具支持的配方。随着时间的推移,项目的重点已经从虚拟化技术(如 Xen、KVM 和 libvirt)转向了更受欢迎的容器化工具。Bruce Ashfield 已经领导了 meta-virtualization
的维护超过十年,致力于跟上云计算领域的最新创新。
选择容器化工具时,有如此多的竞争者,很难知道从哪里开始。meta-virtualization
层对于容器运行时的选择是中立的,Docker、Podman、containerd 和 Kubernetes 都得到完全支持。我决定专注于 Docker,因为它仍然是部署容器镜像的最流行工具。
以下练习假设你已经完成了 第七章中 构建现有 BSP 的练习,并且 poky
克隆的目录位于你的主目录中。
要添加 meta-virtualization
层:
-
首先,导航到克隆
poky
的目录上一级:$ cd ~
-
接下来,设置你的 BitBake 工作环境:
$ source poky/oe-init-build-env build-rpi
-
该脚本设置了一些环境变量,并将你带回之前构建
rpi-test-image
的build-rpi
目录。 -
然后,将
meta-virtualization
层添加到你的镜像中:$ bitbake-layers layerindex-fetch --branch scarthgap --fetchdir ~ meta-virtualization
-
该命令将克隆
meta-virtualization
层及其所有依赖层到你的主目录。 -
验证所有必要的层是否已被添加到镜像中:
$ bitbake-layers show-layers
-
命令的输出应该如下所示:
layer path priority =========================================================================== core /home/frank/poky/meta 5 yocto /home/frank/poky/meta-poky 5 yoctobsp /home/frank/poky/meta-yocto-bsp 5 openembedded-layer /home/frank/meta-openembedded/meta-oe 5 meta-python /home/frank/meta-openembedded/meta-python 5 networking-layer /home/frank/meta-openembedded/meta-networking 5 multimedia-layer /home/frank/meta-openembedded/meta-multimedia 5 raspberrypi /home/frank/meta-raspberrypi 9 filesystems-layer /home/frank/meta-openembedded/meta-filesystems 5 selinux /home/frank/meta-selinux 5 webserver /home/frank/meta-openembedded/meta-webserver 5 virtualization-layer /home/frank/meta-virtualization 8
如果你的输出缺少 meta-raspberrypi
层及以上层次的内容,则返回到 第七章,重复 构建现有 BSP 练习,然后再试添加 meta-virtualization
层。
安装 Docker
meta-virtualization
层包含了构建和安装 Docker 所需的配方。一旦该层被添加,我们可以将docker
包追加到 Yocto 镜像中。有多种方法可以实现这一目标,包括创建自定义镜像配方或发行版层。我选择在rpi-test-image
的基础上进行修改,并在build-rpi
目录中的conf/local.conf
文件进行更改。这样做纯粹是为了方便。修改conf/local.conf
并不是一种可维护的方式。
Docker 守护进程依赖 SSL 证书来验证镜像注册表的真实性。SSL 证书有设定的有效期限,因此需要某种准确的时间源。大多数计算机在启动时会根据从互联网获取的网络时间协议(NTP)来更新系统时钟。因此,除了需要在目标设备上安装 Docker 之外,你还需要一种同步系统时钟的方式,才能拉取容器镜像。
在rpi-test-image
上安装 Docker:
-
将以下行添加到你的
conf/local.conf
文件中:IMAGE_INSTALL:append = " ntp-utils docker"
-
将以下行添加到你的
conf/local.conf
文件中:EXTRA_USERS_PARAMS = "\ groupadd -r docker; \ usermod -a -G docker root; \ "
-
构建镜像:
$ bitbake rpi-test-image
-
第 2 步创建了一个名为
docker
的组,并将root
用户添加到该组中。这样,我们就可以在以root
身份登录时运行 Docker 命令。rpi-test-image
允许通过 SSH 进行root
登录,无需密码。此镜像仅用于演示。
一旦镜像构建完成,tmp/deploy/images/raspberrypi4-64
目录中应该会有一个名为rpi-test-image-raspberrypi4-64.rootfs.wic.bz2
的文件。使用 Etcher 将该镜像写入 microSD 卡,并将其在你的 Raspberry Pi 4 上启动:
-
将 microSD 卡插入主机。
-
启动 Etcher。
-
在 Etcher 中点击Flash from file。
-
定位到你为 Raspberry Pi 4 构建的
wic.bz2
镜像并打开它。 -
在 Etcher 中点击Select target。
-
选择你在第 1 步中插入的 microSD 卡。
-
在 Etcher 中点击Flash以写入镜像。
-
当 Etcher 完成闪存写入时,弹出 microSD 卡。
-
将 microSD 卡插入你的 Raspberry Pi 4。
-
通过 Raspberry Pi 4 的 USB-C 端口为其供电。
通过将 Raspberry Pi 4 连接到以太网,并观察网络活动灯是否闪烁,来确认 Pi 4 是否成功启动。
验证 Docker 守护进程是否在运行
在上一个练习中,我们为 Raspberry Pi 4 构建了一个包含 Docker 的可启动镜像。现在设备已经启动并通过以太网连接到本地网络,接下来我们来验证 Docker 守护进程是否在运行。请按以下步骤操作:
-
我们构建的镜像的主机名为
raspberrypi4-64
,所以你应该可以通过 SSH 以root
身份登录到设备:$ ssh root@raspberrypi4-64.local
-
在系统提示是否继续连接时输入
yes
。不会提示输入密码。如果在raspberrypi4-64.local
没有找到主机,可以使用arp-scan
等工具定位你的 Raspberry Pi 4 的 IP 地址,然后通过该 IP 地址 SSH 连接,而不是通过主机名连接。 -
列出当前正在运行的 Docker 版本信息:
# docker info Client: Version: 25.0.3 Context: default Debug Mode: false <…> WARNING: No memory limit support WARNING: No swap limit support WARNING: No kernel memory TCP limit support WARNING: No oom kill disable support
-
更新系统时钟:
# ntpdate pool.ntp.org 14 Jan 04:22:49 ntpdate[783]: step time server 45.33.53.84 offset +216229384.417735 sec
-
拉取并运行
hello-world
容器镜像:# docker run hello-world Unable to find image 'hello-world:latest' locally latest: Pulling from library/hello-world 478afc919002: Pull complete Digest: sha256:5b3cc85e16e3058003c13b7821318369dad01dac3dbb877aac3c28182255c724 Status: Downloaded newer image for hello-world:latest Hello from Docker! This message shows that your installation appears to be working correctly.
大多数现代 Linux 发行版依赖于systemd-timesyncd
来自动更新系统时钟。这消除了安装和运行ntp-utils
的需要。Yocto 的 Poky 参考发行版默认使用 SysVinit 作为其初始化系统。为了利用systemd-timesyncd
,我们需要将启动系统从 SysVinit 切换到systemd
。如果你希望在 Poky 中使用 systemd,请在conf/local.conf
中选择"poky-altcfg"
作为你的发行版配置。
从SysVinit
切换到systemd
的原因不仅仅是为了时间同步。由于systemd
是为进程监控而设计的,因此它非常适合监控微服务。微服务通常以容器的形式部署。将systemd
与 Docker 结合使用来启动、停止和重启 Linux 系统上的容器是有意义的。或者,你也可以使用 Docker Compose 来运行多容器应用,但这需要在你的 Yocto 镜像中添加另一个工具。
使用 Docker 更新软件
Balena 使用 Docker 容器来部署软件更新。设备运行 balenaOS,这是一个基于 Yocto 的 Linux 发行版,内置有 balenaEngine,这是 balena 兼容 Docker 的容器引擎。OTA 更新会通过 balenaCloud 自动推送,balenaCloud 是一个托管服务,用于管理设备的队列。Balena 也可以在本地模式下操作,这样更新就来自于你本地主机上运行的服务器,而不是来自云端。我们将在接下来的练习中坚持使用本地模式。
Balena 由 balena.io 编写和支持(balena.io
)。与 Mender 类似,balenaCloud 是一个付费的 OTA 更新服务。前十台设备免费,但如果超过此数量,你必须选择按月或按年计费的计划。关于该软件的更多信息,可以在 balena.io 的参考部分找到在线文档。由于我们的目标是部署并自动更新少量设备的软件以加速开发,因此我们不会深入探讨 balena 的工作原理。
Balena 为流行的开发板(如 Raspberry Pi 4 和 BeaglePlay)提供了预构建的 balenaOS 镜像。下载这些镜像需要一个 balenaCloud 账户。
创建账户
即使你只打算在本地模式下操作,首先需要做的就是注册一个 balenaCloud 账户。你可以通过访问dashboard.balenacloud.com/signup
并输入你的电子邮件地址和密码来完成注册,如下所示:
图 16.4 – balenaCloud 注册
点击提交按钮提交表单,处理完成后,你将被提示输入个人资料信息。你也可以选择跳过此表单,届时你将进入balenaCloud仪表盘,使用新账户查看。
如果您退出登录或会话过期,您可以通过导航到dashboard.balena-cloud.com/login
,并输入您注册时使用的电子邮件地址和密码,重新登录到仪表板。
创建应用程序
在我们将 Raspberry Pi 4 添加到 balenaCloud 帐户之前,我们首先需要创建一个舰队。
图 16.5 – 创建舰队
这里是在 balenaCloud 上为 Raspberry Pi 4 创建舰队的步骤:
-
使用您的电子邮件地址和密码登录balenaCloud仪表板。
-
点击位于左上角的创建舰队按钮,在舰队旁边,打开创建舰队对话框。
-
为新舰队输入名称,并选择Raspberry Pi 4 (使用 64 位操作系统)作为默认设备类型。
-
点击创建新舰队按钮在创建舰队对话框中提交表单。
新的舰队应该出现在balenaCloud仪表板的舰队页面上。
添加设备
现在我们在 balenaCloud 上有了一个舰队,让我们向其中添加一个 Raspberry Pi 4:
-
使用您的电子邮件地址和密码登录balenaCloud仪表板。
-
点击我们创建的新舰队。
-
在舰队摘要页面上点击添加设备按钮。
-
点击按钮会弹出添加新设备对话框。
-
确保选择的设备类型为Raspberry Pi 4 (使用 64 位操作系统)。由于您已将应用程序创建为Raspberry Pi 4 (使用 64 位操作系统)作为默认设备类型,因此应已选择该选项。
-
确保选择的操作系统为balenaOS。
-
确保选择的 balenaOS 版本是最新的。由于添加新设备默认为最新可用版本的 balenaOS,并将其标记为推荐,所以应该已经选择了该选项。
-
将开发选为 balenaOS 的版本。需要开发映像以启用更好的测试和故障排除的本地模式。
图 16.6 – 添加新设备
-
选择Wifi + Ethernet作为网络。您也可以选择仅以太网,但自动连接到 Wi-Fi 是一个非常方便的功能。
-
在相应的字段中输入您的 Wi-Fi 路由器的 SSID 和密码。请在以下截图中用您的 Wi-Fi 路由器的 SSID 替换ATTCXR2Xjn:
图 16.7 – Wifi + Ethernet
-
点击Flash按钮上的下箭头。
-
将压缩的镜像文件保存到您的主机上。
现在我们有一个 microSD 卡镜像可以用来为您的测试舰队中的任意数量的 Raspberry Pi 4 进行配置。
从您的主机机器为 Raspberry Pi 4 配置舰队的步骤现在应该已经很熟悉了。找到从 balenaCloud 下载的 balenaOS img.zip
文件,并使用 Etcher 将其写入 microSD 卡。将 microSD 卡插入 Raspberry Pi 4,并通过 USB-C 端口上电。
等待一两分钟,Raspberry Pi 4 会出现在你的 balenaCloud 仪表板的设备页面上:
图 16.8 – 设备
既然我们已经将 Raspberry Pi 4 连接到 balena 应用程序,我们需要启用本地模式,这样我们就可以从附近的主机机器而非云端部署 OTA 更新到它。
-
在你的 balenaCloud 仪表板的设备页面上点击你的目标 Raspberry Pi 4。我的设备名为evil-tree,你的设备会有不同的名称。
-
点击设置,进入你的 Raspberry Pi 4 的设置页面。
-
从设置页面启用本地模式:
图 16.9 – 启用本地模式
一旦启用了本地模式,日志面板将不再出现在设备的概述页面上。
在目标设备上启用本地模式后,我们几乎准备好将一些代码部署到它上面了。在此之前,我们需要安装 balena CLI。
安装 CLI
以下是在 Linux 主机上安装 balena CLI 的说明:
-
打开浏览器并导航到最新的 balena CLI 发布页面:
github.com/balena-io/balena-cli/releases/latest
。 -
点击最新的 Linux ZIP 文件进行下载。查找以
balena-cli-vX.Y.Z-linux-x64-standalone.zip
格式命名的文件名,其中 X、Y 和 Z 分别为主版本号、次版本号和补丁版本号。 -
将 ZIP 文件的内容解压到你的主目录:
$ cd ~ $ unzip Downloads/balena-cli-v20.2.9-linux-x64-standalone.zip
-
解压后的内容被封装在一个
balena-cli
目录中。 -
将
balena-cli
目录添加到你的PATH
环境变量中:$ export PATH=$PATH:~/balena-cli
-
如果你希望这些对
PATH
变量的更改保持有效,请在你的主目录下的.bashrc
文件中添加如下行。 -
验证安装是否成功:
$ balena version 20.2.9
-
本文写作时,最新版本的 balena CLI 是 20.2.9。
现在我们有了一个工作的 balena CLI,让我们扫描本地网络,查找我们配置的 Raspberry Pi 4:
$ sudo env "PATH=$PATH" balena device detect
Scanning for local balenaOS devices... Reporting scan results
-
host: bf04eba.local
address: 192.168.1.183
osVariant: development
dockerInfo:
Containers: 1
ContainersRunning: 1
ContainersPaused: 0
ContainersStopped: 0
Images: 1
Driver: overlay2
SystemTime: 2025-03-06T05:10:40.708637573Z
KernelVersion: 6.1.77-v8
OperatingSystem: balenaOS 6.4.1+rev1
Architecture: aarch64
dockerVersion:
Version: v20.10.43
ApiVersion: 1.41
注意扫描输出中bf04eba.local
的主机名和192.168.1.83
的 IP 地址。你的 Raspberry Pi 4 的主机名和 IP 地址会有所不同。请记录这两项信息,因为我们将在接下来的练习中使用它们。
推送项目
让我们通过本地网络将一个 Python 项目推送到 Raspberry Pi:
-
克隆一个项目来运行一个简单的“Hello World!”Python Web 服务器:
$ git clone https://github.com/balena-io-examples/balena-python-hello-world.git
-
进入项目目录:
$ cd balena-python-hello-world
-
将代码推送到你的 Raspberry Pi 4:
$ balena push 192.168.1.183
-
将你的设备 IP 地址替换为
192.168.1.183
这个参数。 -
等待 Docker 镜像构建并启动完成,并让应用程序在前台运行,以便它将日志输出到
stdout
。 -
从浏览器发出对
http://192.168.1.183
的请求。将你的设备 IP 地址替换为192.168.1.183
。
在 Raspberry Pi 4 上运行的 Web 服务器应该显示一个包含Welcome to balena的欢迎页面,并且如下所示的行应该出现在balena push
的实时输出中:
[Logs] [2025-03-06T05:16:46.546Z] [balena-hello-world] 192.168.1.177 - - [06/Mar/2025 05:16:46] "GET / HTTP/1.1" 200 -
日志条目中的 IP 地址应该是你发起 Web 请求的机器的 IP 地址。每次刷新网页时都会出现新的日志条目。要停止追踪日志并返回到 Shell,输入Ctrl + C。容器将在目标设备上继续运行,Web 服务器也会继续处理请求。
我们可以随时通过发出以下命令来重新开始追踪日志:
$ balena device logs 192.168.1.183
用你的设备的 IP 地址替换192.168.1.183
这个参数。
这个简单 Web 服务器的 HTML 文件可以在项目目录中的index.html
文件里找到:
tree
.
├── balena.yml
├── CHANGELOG.md
├── docker-compose.yml
├── Dockerfile.template
├── license.md
├── logo.png
├── README.md
├── repo.yml
├── requirements.txt
├── src
│ └── app.py
├── VERSION
└── views
├── index.html
└── public
├── bootstrap.min.css
├── confetti.js
├── favicon.ico
├── logo.svg
└── main.css
现在,让我们对项目源代码进行轻微修改并重新部署:
-
在你喜欢的编辑器中打开
views/index.html
。 -
将
Welcome to balena!
替换为Welcome to banana!
并保存更改。 -
以下是
git diff
输出的更改:$ git diff diff --git a/views/index.html b/views/index.html index c5fcddd..7796f5a 100644 --- a/views/index.html +++ b/views/index.html @@ -26,7 +26,7 @@ <div class="container mt-5 mb-5 p-0 pb-5"> <div class="row d-flex flex-column align-items-center"> - <h1>Welcome to balena!</h1> + <h1>Welcome to banana!</h1> <p class="text-center pl-5 pr-5 pt-0 pb-0">Now that you've deployed code to your device,<br /> explore the resources below to continue on your journey!</p> </div>
-
将新代码推送到你的 Raspberry Pi 4:
$ balena push 192.168.1.183
-
用你的设备的 IP 地址替换
192.168.1.183
这个参数。 -
等待 Docker 镜像更新。由于名为Livepush的智能缓存功能,过程这次应该会更快,这是本地模式独有的特性。
-
从 Web 浏览器向
http://192.168.1.183
发起请求。用你的设备的 IP 地址替换192.168.1.183
。
在 Raspberry Pi 4 上运行的 Web 服务器应该显示Welcome to banana!
我们可以通过 IP 地址 SSH 连接到本地目标设备:
$ balena device ssh 192.168.1.183
Last login: Thu Mar 6 05:59:21 2025 from 192.168.1.124
root@bf04eba:~#
用你的设备的 IP 地址替换192.168.1.183
。由于应用运行在 Docker 容器内,这一点并没有特别大的用处。
要通过 SSH 连接到运行 Python Web 服务器的容器并观察其操作,我们需要在balena
的ssh
命令中包含服务名称:
$ balena device ssh 192.168.1.183 balena-hello-world
root@6a4ef89e6f10:/usr/src/app# ls
CHANGELOG.md VERSION logo.png views
Dockerfile balena.yml repo.yml
Dockerfile.template docker-compose.yml requirements.txt
README.md license.md src
这个启动应用的服务名称是balena-hello-world
,如实时日志输出中所示。
恭喜!你已经成功创建了一个 balenaOS 镜像和主机开发环境,供你和你的团队使用,方便在项目代码上进行迭代并快速重新部署到目标设备。这可不是一件小事。以 Docker 容器的形式推送代码更改是全栈工程师非常熟悉的常见开发流程。借助 balena,他们现在可以利用自己熟悉的技术,在实际硬件上开发嵌入式 Linux 应用。
总结
在这一章节中,我们完成了很多事情。你现在知道如何为你的软件项目创建 Dockerfile 和 YAML 工作流。CI/CD 管道使用这种基础设施作为代码,自动构建并推送容器化的软件更新到边缘设备。你还利用容器在真实硬件上进行本地开发,然后再将更改推送到其他设备。像这样的现代 DevOps 实践使软件团队能够在不破坏系统的情况下更快地推进工作。
在下一章,我们将详细探讨 Linux 进程模型,描述什么是进程,进程与线程的关系,线程如何协作以及如何进行调度。如果你想创建一个稳健且可维护的嵌入式系统,理解这些内容非常重要。
进一步学习
-
DevOps 手册,第二版,作者:Gene Kim、Jez Humble、Patrick Debois 和 John Willis
-
Docker 文档,Docker 公司 –
docs.docker.com/reference/cli/docker/
-
如何运行容器化的蓝牙应用程序与 BlueZ,作者:Thomas Huffert –
medium.com/omi-uulm/how-to-run-containerized-bluetooth-applications-with-bluez-dced9ab767f6
第十七章:进程和线程的学习
在前面的章节中,我们考虑了创建嵌入式 Linux 平台的各个方面。现在,到了开始了解如何利用该平台创建工作设备的时候了。在这一章中,我将讨论 Linux 进程模型的含义,以及它如何涵盖多线程程序。我将探讨使用单线程和多线程进程的优缺点,以及进程之间的异步消息传递和协程。最后,我将讲解调度,并区分时间共享调度和实时调度策略。
虽然这些话题并非嵌入式计算的专属内容,但对于任何嵌入式设备的设计师来说,了解这些话题是很重要的。关于这些主题有很多优秀的参考资料,其中一些我会在本章末尾列出,但一般来说,它们并没有考虑嵌入式的使用案例。因此,我将专注于概念和设计决策,而非函数调用和代码。
在这一章中,我们将讨论以下主题:
-
进程还是线程?
-
进程
-
线程
-
ZeroMQ
-
调度
技术要求
为了跟上示例的进度,请确保你已经具备以下条件:
-
Python:Python 3 解释器及标准库
-
Miniconda:用于
conda
包和虚拟环境管理的最小安装程序
如果你还没有安装 Miniconda,请参见第十五章中关于conda
的部分,里面有安装 Miniconda 的步骤。本章的练习还需要 GCC C 编译器和 GNU Make,但这些工具通常在大多数 Linux 发行版中已经预装。
本章中使用的代码可以在本书 GitHub 仓库的Chapter17
文件夹中找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development/tree/main/Chapter17
。
进程还是线程?
许多熟悉实时操作系统(RTOS)的嵌入式开发人员认为 Unix 进程模型较为繁琐。另一方面,他们发现 RTOS 任务与 Linux 线程之间存在相似性,并倾向于将现有设计通过将 RTOS 任务一对一映射到线程的方式进行转移。我曾多次看到设计中整个应用程序是通过一个包含 40 个以上线程的进程来实现的。我想花些时间考虑这种做法是否明智。让我们从一些定义开始。
进程是一个内存地址空间和一个执行线程,如下图所示。地址空间对进程是私有的,因此不同进程中的线程无法访问它。这种内存隔离是由内核中的内存管理子系统创建的,它为每个进程维护一个内存页映射,并在每次上下文切换时重新编程内存管理单元。我将在第十八章中详细描述这一过程。部分地址空间被映射到一个文件,该文件包含程序运行时的代码和静态数据,如下所示:
图 17.1 – 进程
随着程序的运行,它将分配资源,例如堆栈空间、堆内存、文件引用等。当进程终止时,这些资源将被系统回收:所有内存将被释放,所有文件描述符将被关闭。
进程可以通过进程间通信(IPC)进行相互通信,例如本地套接字。我稍后会谈到 IPC。
线程是进程中的一个执行线程。所有进程从一个线程开始,该线程运行main()
函数,并被称为主线程。你可以创建额外的线程,例如,使用pthread_create(3)
POSIX 函数,这会导致多个线程在相同的地址空间中执行,如下图所示:
图 17.2 – 多个线程
线程位于同一进程中,彼此共享资源。它们可以读取和写入相同的内存,并使用相同的文件描述符。线程之间的通信是简单的,只要你处理好同步和锁的问题。
基于这些简要细节,你可以想象出两种极端的设计方案,用于一个假设的系统,将 40 个 RTOS 任务移植到 Linux。
你可以将任务映射到进程,并通过 IPC 使 40 个独立程序相互通信,例如,通过套接字发送消息。你将大大减少内存损坏的问题,因为每个进程中运行的主线程都被保护起来,免受其他进程的影响,同时减少资源泄漏,因为每个进程在退出后都会被清理。然而,进程之间的消息接口相当复杂,当一组进程紧密协作时,消息的数量可能会很大,进而成为系统性能的限制因素。此外,其中任何一个进程可能会终止,可能是由于程序中的 bug 导致崩溃,剩下的 39 个进程继续运行。每个进程都必须处理其邻近进程不再运行的情况,并且能够优雅地恢复。
在另一个极端,你可以将任务映射到线程,并将系统实现为一个包含 40 个线程的单一进程。因为它们共享相同的地址空间和文件描述符,合作变得更加容易。发送消息的开销减少或消除,线程之间的上下文切换比进程之间的切换更快。缺点是,你引入了一个可能性——即某个任务可能会破坏另一个任务的堆或栈。如果任何线程遇到致命错误,整个进程会终止,所有线程也会一起终止。最后,调试一个复杂的多线程进程可能会变得非常棘手。
你应该得出的结论是,没有一个设计是完美的,确实有更好的方式来处理。但在我们达到那个点之前,我会深入探讨一下进程和线程的 API 以及它们的行为。
进程
一个进程持有线程运行的环境:它包含内存映射、文件描述符、用户和组 ID 等。第一个进程是 init
进程,由内核在启动时创建,PID 为 1。此后,进程通过一种称为 分叉 的操作进行创建。
创建新进程
创建进程的 POSIX 函数是 fork(2)
。这个函数很特别,因为对于每次成功调用,它会返回两次:一次是在发出调用的进程中,称为 父进程,另一次是在新创建的进程中,称为 子进程,如以下图所示:
图 17.3 – 分叉
在调用之后,子进程是父进程的精确副本:它有相同的栈、相同的堆和相同的文件描述符,并且执行相同的代码行——也就是紧跟在 fork
后的那一行。
程序员唯一可以区分它们的方式是查看 fork
的返回值:对于子进程来说,它是 零,对于父进程来说,它是 大于零。实际上,返回给父进程的值是新创建的子进程的 PID。还有第三种可能性,即返回值为负,这表示 fork
调用失败,仍然只有一个进程。
尽管这两个进程大体相同,但它们在不同的地址空间中。一个进程对变量的修改,另一个进程是无法看到的。在底层,内核并不会对父进程的内存做物理复制,这样的操作会非常慢,并且不必要地消耗内存。相反,内存是共享的,但会标记为 写时复制 (CoW) 标志。如果父进程或子进程修改了这段内存,内核会进行复制,并写入复制后的内存。这使得分叉操作更加高效,同时也保持了进程地址空间的逻辑分离。我将在 第十八章 中详细讨论 CoW。
终止进程
一个进程可以通过调用exit(3)
函数自愿停止,或者通过接收到一个未处理的信号被强制停止。特别是SIGKILL
信号,无法被处理,因此它将始终终止一个进程。在所有情况下,终止进程将停止所有线程,关闭所有文件描述符,并释放所有内存。系统会向父进程发送一个SIGCHLD
信号,通知其子进程已经终止。
进程有一个返回值,通常由exit
的参数组成,如果进程正常终止;如果进程被终止,则由信号编号组成。这个返回值的主要用途是在 shell 脚本中:它允许你测试程序的返回值。按照惯例,0
表示成功,其他任何值都表示某种类型的失败。
父进程可以通过wait(2)
或waitpid(2)
函数收集返回值。这会导致一个问题:在子进程终止与父进程收集返回值之间会有延迟。在这段时间内,返回值必须存储在某个地方,并且已终止进程的 PID 号不能被重用。处于这种状态的进程被称为僵尸进程,在ps
和top
命令中显示为state Z
。只要父进程在接收到子进程终止通知时(通过SIGCHLD
信号;有关信号处理的详细信息,请参阅Linux 系统编程,Robert Love 著,O'Reilly Media,或The Linux Programming Interface,Michael Kerrisk 著,No Starch Press),调用wait
或waitpid
,通常僵尸进程存在的时间太短,无法出现在进程列表中。如果父进程未能收集返回值,它们将成为问题,因为最终将没有足够的资源来创建更多进程。
MELD/Chapter17/fork-demo
中的程序演示了进程创建和终止:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
int pid;
int status;
pid = fork();
if (pid == 0) {
printf("I am the child, PID %d\n", getpid());
sleep(10);
exit(42);
} else if (pid > 0) {
printf("I am the parent, PID %d\n", getpid());
wait(&status);
printf("Child terminated, status %d\n", WEXITSTATUS(status));
} else {
perror("fork:");
}
return 0;
}
wait
函数会阻塞,直到子进程退出并存储退出状态。运行时,您会看到类似如下的内容:
I am the parent, PID 13851
I am the child, PID 13852
Child terminated with status 42
子进程会继承父进程的大部分属性,包括用户和组 ID、所有打开的文件描述符、信号处理和调度特性。
运行不同的程序
fork
函数创建一个运行中的程序的副本,但它并不运行不同的程序。要实现这一点,你需要使用exec
系列函数之一:
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], ..., char *const envp[]);
每个进程都会加载并运行一个程序文件路径。如果函数成功,内核会丢弃当前进程的所有资源,包括内存和文件描述符,并为正在加载的新程序分配内存。当调用exec*
的线程返回时,它不会返回到调用后的代码行,而是返回到新程序的main()
函数中。在MELD/Chapter17/exec-demo
中有一个命令启动器示例:它会提示输入命令,例如/bin/ls
,然后进行分叉并执行你输入的字符串。以下是代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(int argc, char *argv[])
{
char command_str[128];
int pid;
int child_status;
int wait_for = 1;
while (1) {
printf("sh> ");
scanf("%s", command_str);
pid = fork();
if (pid == 0) {
/* child */
printf("cmd '%s'\n", command_str);
execl(command_str, command_str, (char *)NULL);
/* We should not return from execl, so only get
to this line if it failed */
perror("exec");
exit(1);
}
if (wait_for) {
waitpid(pid, &child_status, 0);
printf("Done, status %d\n", child_status);
}
}
return 0;
}
运行时,你会看到如下内容:
# ./exec-demo
sh> /bin/ls
cmd '/bin/ls'
bin etc lost+found proc sys var
boot home media run tmp
dev lib mnt sbin usr
Done, status 0
sh>
你可以通过输入Ctrl + C来终止程序。
可能看起来很奇怪,为什么会有一个函数来复制现有进程,而另一个函数则丢弃资源并将不同的程序加载到内存中,尤其是因为通常 fork
会紧接着调用 exec
函数。大多数操作系统将这两个操作合并为一个单独的调用。
然而,这种方式有显著的优势。例如,它使得在 shell 中实现重定向和管道变得非常容易。假设你想获取一个目录列表。以下是事件的顺序:
-
你在 shell 提示符中输入
ls
。 -
Shell 创建了其子进程的副本。
-
Shell 等待子进程完成。
-
子进程执行
/bin/ls
。 -
ls
程序将目录列表打印到stdout
(文件描述符 1),即终端。你将看到目录列表。 -
ls
程序终止,shell 恢复控制。
现在,假设你希望将目录列表重定向到一个文件中,而不是直接显示在屏幕上。此时,事件顺序如下:
-
你输入
ls > listing.txt
。 -
Shell 创建了其子进程的副本。
-
Shell 等待子进程完成。
-
子进程打开并截断
listing.txt
文件,并使用dup2(2)
将文件描述符 1(stdout
)复制到该文件的文件描述符上。 -
子进程执行
/bin/ls
。 -
程序像之前一样打印目录列表,但这次它将输出写入
listing.txt
文件。 -
ls
程序终止,shell 恢复控制。重要提示
在第 4 步中,有机会在执行程序之前修改子进程的环境。
ls
程序不需要知道它是在写入文件而不是终端。stdout
可以连接到管道,这样ls
程序仍然保持不变,可以将输出发送到另一个程序。这是 Unix 哲学的一部分,强调将许多小组件组合在一起,每个组件都能高效地完成任务,正如 Eric Steven Raymond 和 Addison Wesley 在《Unix 编程艺术》一书中描述的,特别是在管道、重定向与过滤器章节中。
到目前为止,我们在本节中查看的所有程序都在前台运行。但后台程序如何呢?它们等待某些事件的发生。让我们来看一下。
守护进程
我们已经在多个地方遇到过守护进程。守护进程是一个在后台运行的进程,由 init
进程拥有,并且不连接到控制终端。创建守护进程的步骤如下:
-
调用
fork
来创建一个新进程,父进程退出,从而创建一个孤儿进程,这个孤儿进程将被重新归到init
进程下。 -
子进程调用
setsid(2)
,创建一个新的会话和进程组,子进程是唯一的成员。这里的具体细节并不重要;你可以简单地将其视为一种将进程与任何控制终端隔离的方法。 -
将工作目录更改为根目录。
-
关闭所有文件描述符,并将
stdin
、stdout
和stderr
(描述符0
、1
和2
)重定向到/dev/null
,以确保没有输入,且所有输出都被隐藏。
幸运的是,所有前述步骤都可以通过一个函数调用daemon(3)
来实现。
进程间通信
每个进程都是一个独立的内存岛。你可以通过两种方式将信息从一个进程传递到另一个进程。首先,你可以将其从一个地址空间移动到另一个地址空间。其次,你可以创建一个共享内存区域,两个进程都可以访问并共享数据。
第一个通常与队列或缓冲区结合使用,以便在进程之间传递一系列消息。这意味着消息需要被复制两次:首先复制到一个临时存储区,然后再复制到目的地。一些例子包括套接字、管道和消息队列。
第二种方式不仅需要一种方法来创建可以同时映射到两个(或更多)地址空间的内存,而且还需要一种同步访问该内存的方式,例如使用信号量或互斥锁。
POSIX 为所有这些提供了函数。还有一个较老的 API 集,称为System V IPC,它提供了消息队列、共享内存和信号量,但它没有 POSIX 等价物灵活,因此我在这里不做描述。svipc(7)
手册页提供了这些设施的概述,更多细节可以参阅 Michael Kerrisk 的《Linux 程序接口》和 W. Richard Stevens 的《Unix 网络编程,第 2 卷》。
基于消息的协议通常比共享内存更易于编程和调试,但如果消息较大或数量很多,它们的速度会较慢。
基于消息的 IPC
有几种基于消息的 IPC 选项,我将简要总结如下。区分它们的属性如下:
-
消息流是否是单向或双向。
-
数据流是否是没有消息边界的字节流,还是保留边界的离散消息。在后一种情况下,消息的最大大小是一个重要因素。
-
消息是否带有优先级标签。
以下表格总结了这些属性在 FIFO、套接字和消息队列中的表现:
属性 | FIFO | Unix 套接字:流 | Unix 套接字:数据报 | POSIX 消息队列 |
---|---|---|---|---|
消息边界 | 字节流 | 字节流 | 离散消息 | 离散消息 |
单向/双向 | 单向 | 双向 | 单向 | 单向 |
最大消息大小 | 无限 | 无限 | 100 KB 至 200 KB 范围内 | 默认:8 KB,绝对最大:1 MB |
优先级级别 | 无 | 无 | 无 | 0 到 32767 |
表 17.1 – FIFO、套接字和消息队列的属性
我们将首先探讨的基于消息的 IPC 形式是 Unix 套接字。
Unix(或本地)套接字
Unix 套接字满足大多数要求,结合套接字 API 的熟悉度,是迄今为止最常见的机制。
Unix 套接字通过 AF_UNIX
地址族创建,并绑定到一个路径名。对套接字的访问权限由套接字文件的访问权限决定。与互联网套接字一样,套接字类型可以是 SOCK_STREAM
或 SOCK_DGRAM
,前者提供双向字节流,后者提供带有保留边界的离散消息。Unix 套接字的数据报是可靠的,这意味着它们不会丢失或重排序。数据报的最大大小是系统相关的,可以通过 /proc/sys/net/core/wmem_max
获取。通常是 100 KB 或更大。
Unix 套接字没有机制来指示消息的优先级。
FIFO 和命名管道
FIFO 和 命名管道 只是同一事物的不同术语。它们是匿名管道的扩展,用于在实现 shell 中的管道时在父子进程之间进行通信。
FIFO 是一种特殊的文件,通过 mkfifo(1)
命令创建。与 Unix 套接字一样,文件的访问权限决定了谁可以读取和写入。它们是单向的,这意味着通常有一个读取者和一个写入者,虽然也可以有多个写入者。数据是纯字节流,但它保证了小于与管道相关的缓冲区大小的消息的原子性。换句话说,写入小于此大小的数据不会被分割成多个小的写入,因此只要你一端的缓冲区足够大,你就可以一次性读取整个消息。现代内核的默认 FIFO 缓冲区大小为 64 KB,可以通过 fcntl(2)
和 F_SETPIPE_SZ
增加,最大值为 /proc/sys/fs/pipe-max-size
中的值,通常为 1 MB。没有优先级的概念。
POSIX 消息队列
消息队列通过一个以斜杠开头并仅包含一个 /
字符的名称来标识。消息队列保存在 mqueue
类型的伪文件系统中。你可以通过 mq_open(3)
创建一个队列并获取对现有队列的引用,该函数返回一个文件描述符。每个消息都有一个优先级,消息按优先级顺序读取,然后按年龄顺序读取。消息的最大长度是 /proc/sys/kernel/msgmax
字节。
默认值为 8 KB,但你可以通过将值写入 /proc/sys/kernel/msgmax
设置为 128 字节到 1 MB 之间的任何大小。由于引用是文件描述符,因此可以使用 select(2)
、poll(2)
和其他类似的函数来等待队列中的活动。
有关更多细节,请参考 Linux mq_overview(7)
手册页。
基于消息的进程间通信概述
Unix 套接字最常被使用,因为它们提供了所需的一切,除了可能的消息优先级。它们在大多数操作系统上实现,因此具有最大可移植性。
FIFO(先进先出队列)较少使用,主要是因为它缺少类似于数据报的功能。另一方面,API 非常简单,因为它提供了正常的open(2)
、close(2)
、read(2)
和write(2)
文件调用。
消息队列是这一组中使用最少的。内核中的代码路径没有像套接字(网络)和 FIFO(文件系统)调用那样得到优化。
还有一些更高层次的抽象,如 D-Bus,它们正在从主流 Linux 转向嵌入式设备。D-Bus 在底层使用 Unix 套接字和共享内存。
基于共享内存的进程间通信(IPC)
共享内存消除了在地址空间之间复制数据的需求,但引入了同步访问的难题。进程间同步通常通过使用信号量来实现。
POSIX 共享内存
为了在进程之间共享内存,必须创建一个新的内存区域,然后将其映射到每个想要访问它的进程的地址空间,如下图所示:
图 17.4 – POSIX 共享内存
命名 POSIX 共享内存段遵循我们在消息队列中遇到的模式。这些段通过以/字符开头并且仅包含一个这样的字符的名称来标识:
#define SHM_SEGMENT_NAME "/demo-shm"
shm_open(3)
函数接受名称并返回相应的文件描述符。如果该内存段尚不存在,并且设置了O_CREAT
标志,则会创建一个新的段。最初,它的大小为零。你可以使用(名字具有误导性的)ftruncate(2)
函数将其扩展到所需的大小:
int shm_fd;
struct shared_data *shm_p;
/* Attempt to create the shared memory segment */
shm_fd = shm_open(SHM_SEGMENT_NAME, O_CREAT | O_EXCL | O_RDWR, 0666);
if (shm_fd > 0) {
/* succeeded: expand it to the desired size (Note: dont't
do this every time because ftruncate fills it with zeros) */
printf("Creating shared memory and setting size=%d\n",
SHM_SEGMENT_SIZE);
if (ftruncate(shm_fd, SHM_SEGMENT_SIZE) < 0) {
perror("ftruncate");
exit(1);
}
<…>
} else if (shm_fd == -1 && errno == EEXIST) {
/* Already exists: open again without O_CREAT */
Shm_fd = shm_open(SHM_SEGMENT_NAME, O_RDWR, 0);
<…>
}
一旦你获得了共享内存的描述符,就可以使用mmap(2)
将其映射到进程的地址空间,以便不同进程中的线程可以访问该内存:
/* Map the shared memory */
shm_p = mmap(NULL, SHM_SEGMENT_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
MELD/Chapter17/shared-mem-demo
中的程序提供了一个使用共享内存段进行进程间通信的示例。以下是main
函数:
static sem_t *demo_sem;
<…>
int main(int argc, char *argv[])
{
char *shm_p;
printf("%s PID=%d\n", argv[0], getpid());
shm_p = get_shared_memory();
while (1) {
printf("Press enter to see the current contents of shm\n");
getchar();
sem_wait(demo_sem);
printf("%s\n", shm_p);
/* Write our signature to the shared memory */
sprintf(shm_p, "Hello from process %d\n", getpid());
sem_post(demo_sem);
}
return 0;
}
该程序使用共享内存段来在进程之间传递消息。消息内容是Hello from process string
,后跟其 PID。get_shared_memory
函数负责创建内存段(如果它不存在)或获取文件描述符(如果已存在)。它返回指向内存段的指针。请注意,存在一个信号量用于同步对内存的访问,以防止一个进程覆盖另一个进程的消息。
要尝试此功能,你需要在两个不同的终端会话中运行程序的两个实例。在第一个终端中,你会看到类似这样的内容:
# ./shared-mem-demo
./shared-mem-demo PID=271
Creating shared memory and setting size=65536
Press enter to see the current contents of shm
Press enter to see the current contents of shm
Hello from process 271
因为这是程序第一次运行,它会创建内存段。最初,消息区域是空的,但在执行一次循环后,它包含了该进程的 PID,即 271。现在,你可以在另一个终端中运行第二个实例:
# ./shared-mem-demo
./shared-mem-demo PID=279
Press enter to see the current contents of shm
Hello from process 271
Press enter to see the current contents of shm
Hello from process 279
它不会创建共享内存段,因为该内存段已经存在,并且它显示已经包含的消息,即另一个程序的 PID。按下 Enter 后,它会写入自己的 PID,第一个程序能够看到它。通过这种方式,两个程序可以相互通信。
POSIX IPC 函数是 POSIX 实时扩展的一部分,因此你需要将它们与 librt
链接。奇怪的是,POSIX 信号量是通过 POSIX 线程库实现的,因此你还需要将其与 pthreads
库链接。因此,在你针对 64 位 Arm SoC 进行编译时,编译参数如下所示:
$ aarch64-buildroot-linux-gnu-gcc shared-mem-demo.c -lrt -pthread -o shared-mem-demo
这就结束了我们对 IPC 方法的调研。当我们介绍 ZeroMQ 时,会重新回到基于消息的 IPC。现在,是时候看看多线程进程了。
线程
线程的编程接口是 POSIX 线程 API,该接口最初在 IEEE POSIX 1003.1c 标准(1995) 中定义,通常称为 pthreads。它作为 libpthread.so.0
C 库的附加部分实现。过去 20 年左右,pthreads
有过两种实现:LinuxThreads 和 Native POSIX Thread Library(NPTL)。后者对规范的遵循更为严格,尤其是在信号和进程 ID 的处理方面。现在,NPTL 已占主导地位。如果你遇到任何仍然使用 LinuxThreads
的 C 标准库,我建议避免使用它。
创建一个新线程
你可以用来创建线程的函数是 pthread_create(3)
:
int pthread_create(pthread_t *restrict thread, const pthread_attr_t *restrict attr, typeof(void *(void *)) *start_routine, void *restrict arg);
它创建了一个新的执行线程,线程从 start_routine
函数开始执行,并将描述符放置在 pthread_t
中,该描述符由 thread
指向。它继承了调用线程的调度参数,但这些参数可以通过传递指向线程属性的指针 attr
来覆盖。线程将立即开始执行。
pthread_t
是在程序中引用线程的主要方式,但也可以使用诸如 ps -eLf
这样的命令从外部查看线程:
UID PID PPID LWP C NLWP STIME TTY TIME CMD
<...>
chris 6072 5648 6072 0 3 21:18 pts/0 00:00:00 ./thread-demo
chris 6072 5648 6073 0 3 21:18 pts/0 00:00:00 ./thread-demo
BusyBox ps
小程序不支持 -eLf
选项,因此请确保在嵌入式目标上安装完整的 procps
包。
在前面的输出中,thread-demo
程序有两个线程。PID
和 PPID
列显示它们都属于同一个进程,并且具有相同的父进程,正如你所期望的那样。然而,LWP
列是很有趣的。LWP 代表 Light Weight Process,在这里,它是线程的另一种称呼。该列中的数字也被称为 Thread ID 或 TID。在主线程中,TID 与 PID 相同,但对于其他线程,它是不同(更高)的值。你可以在文档中要求提供 PID 的地方使用 TID,但要注意,这种行为特定于 Linux,并不具有可移植性。下面是一个简单的程序,展示了线程的生命周期(代码位于 MELD/Chapter17/thread-demo
):
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/syscall.h>
static void *thread_fn(void *arg)
{
printf("New thread started, PID %d TID %d\n",
getpid(), (pid_t)syscall(SYS_gettid));
sleep(10);
printf("New thread terminating\n");
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t t;
printf("Main thread, PID %d TID %d\n",
getpid(), (pid_t)syscall(SYS_gettid));
pthread_create(&t, NULL, thread_fn, NULL);
pthread_join(t, NULL);
return 0;
}
请注意,在 thread_fn
函数中,我通过 syscall(SYS_gettid)
获取线程 ID。在 glibc
2.30 之前,必须通过 syscall
直接调用 Linux,因为当时 C 库没有为 gettid()
提供包装函数。
给定内核能够调度的线程总数是有限制的。这个限制会根据系统的大小而变化,从小型设备上的约 1,000 到大型嵌入式设备上的数万个不等。实际数字可以在 /proc/sys/kernel/threads-max
中查看。一旦达到此限制,fork
和 pthread_create
将会失败。
终止一个线程
当以下任一情况发生时,线程会终止:
-
它达到了其
start_routine
的结束。 -
它调用了
pthread_exit(3)
。 -
它被另一个线程通过调用
pthread_cancel(3)
取消。 -
包含线程的进程会终止,例如,因为某个线程调用了
exit(3)
,或者进程收到了未处理、未屏蔽或未忽略的信号。
请注意,如果多线程程序调用 fork
,则只有发出调用的线程会在新的子进程中存在。fork
不会复制所有线程。
线程有一个返回值,是一个空指针。一个线程可以通过调用 pthread_join(2)
等待另一个线程终止并收集其返回值。正如我们在前面的部分提到的,thread-demo
代码中有一个这样的例子。这会产生一个类似于进程僵尸问题的问题:线程的资源,如堆栈,必须等到另一个线程与之连接后才能释放。如果线程保持 未连接 状态,程序将发生资源泄漏。
编译带有线程的程序
POSIX 线程的支持是 libpthread.so.0
库中 C 库的一部分。然而,构建包含线程的程序不仅仅是链接库:必须对编译器生成代码的方式进行修改,以确保某些全局变量,如 errno
,每个线程都有一个实例,而不是整个进程共享一个实例。
提示
在构建多线程程序时,添加 -pthread
开关。添加 -pthread
会自动将 -lpthread
添加到编译器驱动的链接命令中。
线程间通信
线程的一个大优点是它们共享地址空间,并且可以共享内存变量。这也是一个大缺点,因为它需要同步,以保持数据一致性,类似于进程之间共享内存段的方式,但提供了线程可以共享所有内存的条件。事实上,线程可以使用 线程局部存储(TLS)创建私有内存,但我在这里不会讨论这一点。
pthreads
接口提供了实现同步所需的基本功能:互斥锁和条件变量。如果你需要更复杂的结构,就必须自己构建。
值得注意的是,我们之前描述的所有 IPC 方法——即套接字、管道和消息队列——在同一进程中的线程之间同样有效。
互斥
要编写健壮的程序,你需要使用互斥锁保护每个共享资源,并确保每个读取或写入该资源的代码路径首先锁定互斥锁。如果你始终遵守这个规则,大多数问题应该能得到解决。剩下的问题与互斥锁的基本行为相关。我将在这里简要列出它们,但不会过多细谈:
-
死锁:当互斥锁永远被锁住时,就会发生死锁。一个经典的情况是致命的拥抱,在这种情况下,两个线程各自需要两个互斥锁,并且成功锁住了其中一个,但没有锁住另一个。每个线程都会阻塞,等待对方持有的锁,因此它们保持在原地。避免致命拥抱问题的一个简单规则是确保互斥锁始终按照相同的顺序进行锁定。其他解决方案包括超时和回退时间。
-
优先级反转:等待互斥锁造成的延迟可能导致实时线程错过截止日期。优先级反转的具体情况发生在一个高优先级线程被阻塞,等待一个低优先级线程持有的互斥锁。如果低优先级线程被其他中等优先级线程抢占,那么高优先级线程就会被迫等待一个不确定长度的时间。存在名为优先级继承和优先级上限的互斥锁协议,通过在内核中为每个锁定和解锁操作增加更多的处理开销来解决这一问题。
-
性能差:只要线程大部分时间不需要在互斥锁上阻塞,互斥锁对代码的开销是最小的。然而,如果你的设计中有一个资源被很多线程需要,那么竞争比例就会变得显著。这通常是一个设计问题,可以通过使用更细粒度的锁定或不同的算法来解决。
互斥锁并不是线程间同步的唯一方式。我们在讲解 POSIX 共享内存时见证了两个进程如何使用信号量互相通知对方。线程也有类似的机制。
条件变化
协作线程需要能够相互提醒,告诉对方某些事情已经改变,需要关注。这就是所谓的条件,而这个提醒是通过条件变量(或condvar)发送的。
条件只是你可以测试以得到真或假结果的东西。一个简单的例子是一个缓冲区,它包含零个或若干个项目。一个线程从缓冲区取出项目,缓冲区为空时它会休眠。另一个线程将项目放入缓冲区,并向其他线程发出信号,表明条件发生了变化。如果它正在休眠,它需要醒来并做点什么。唯一的复杂性是,条件根据定义是共享资源,因此必须通过互斥锁来保护。
下面是一个有两个线程的简单程序。第一个是生产者:它每秒醒来一次,将一些数据放入全局变量中,然后发出信号,表明数据已经发生变化。第二个线程是消费者:它等待条件变量,并在每次醒来时测试条件(即缓冲区中有一个非零长度的字符串)。你可以在MELD/Chapter17/condvar-demo
中找到代码:
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
char g_data[128];
pthread_cond_t cv = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutx = PTHREAD_MUTEX_INITIALIZER;
void *consumer(void *arg)
{
while (1) {
pthread_mutex_lock(&mutx);
while (strlen(g_data) == 0)
pthread_cond_wait(&cv, &mutx);
/* Got data */
printf("%s\n", g_data);
/* Truncate to null string again */
g_data[0] = 0;
pthread_mutex_unlock(&mutx);
}
return NULL;
}
void *producer(void *arg)
{
int i = 0;
while (1) {
sleep(1);
pthread_mutex_lock(&mutx);
sprintf(g_data, "Data item %d", i);
pthread_mutex_unlock(&mutx);
pthread_cond_signal(&cv);
i++;
}
return NULL;
}
请注意,当消费者线程在条件变量上阻塞时,它是在持有锁定的互斥锁的情况下进行的,这似乎为死锁埋下了伏笔——下次生产者线程尝试更新条件时会发生死锁。为了避免这种情况,pthread_cond_wait(3)
在线程被阻塞后会解锁互斥锁,然后在线程醒来并从等待中返回之前再次锁定它。
划分问题
现在我们已经覆盖了进程和线程的基础知识以及它们如何进行通信,是时候看看我们可以用它们做些什么了。
下面是我在构建系统时使用的一些规则:
-
规则 1:将交互较多的任务放在一起:重要的是通过将密切交互的线程放在同一个进程中来减少开销。
-
规则 2:不要把所有线程放在一个篮子里:另一方面,尽量将相互作用有限的组件放在不同的进程中,以提高弹性和模块化。
-
规则 3:不要在同一个进程中混合关键线程和非关键线程:这是对规则 2的进一步说明:系统的关键部分,可能是机器控制程序,应该尽可能保持简单,并且比其他部分写得更为严格。即使其他进程失败,它也必须能够继续运行。如果你有实时线程,按照定义,它们必须是关键线程,应该单独放入一个进程中。
-
规则 4:线程不应过于亲密:编写多线程程序时的一个诱惑是将线程之间的代码和变量交织在一起,因为这是一个集成的程序,做起来很容易。保持线程的模块化,并定义清晰的交互。
-
规则 5:不要认为线程是免费的:创建额外的线程非常容易,但协调它们的活动所需的额外复杂性代价很高。
-
规则 6:线程可以并行工作:线程可以在多核处理器上同时运行,从而提供更高的吞吐量。如果有一个大型计算任务,您可以为每个核心创建一个线程,并充分利用硬件。有一些库可以帮助您做到这一点,比如 OpenMP。您应该避免从头开始编写并行编程算法。
Android 设计是一个很好的例证。每个应用程序都是一个独立的 Linux 进程,有助于模块化内存管理,并确保一个应用崩溃不会影响整个系统。该进程模型还用于访问控制:一个进程只能访问其 UID 和 GID 允许的文件和资源。每个进程中都有一组线程。有一个用于管理和更新用户界面,一个用于处理来自操作系统的信号,几个用于管理动态内存分配和释放 Java 对象,以及至少两个线程的工作池,用于使用 Binder 协议从系统其他部分接收消息。
总结一下,进程提供了弹性,因为每个进程都有受保护的内存空间,当进程终止时,所有资源,包括内存和文件描述符,都会被释放,减少资源泄漏。另一方面,线程共享资源,可以通过共享变量轻松通信,并通过共享对文件和其他资源的访问来合作。线程通过工作池和其他抽象提供并行性,在多核处理器中非常有用。
ZeroMQ
套接字、命名管道和共享内存是进程间通信的手段。它们充当大多数非平凡应用程序中的消息传递过程的传输层。并发原语如互斥锁和条件变量用于管理共享访问和协调同一进程内运行的线程之间的工作。多线程编程是非常困难的,套接字和命名管道也有它们自己的一套注意事项。需要一个更高级别的 API 来抽象异步消息传递的复杂细节。这就是 ZeroMQ 的用武之地。
ZeroMQ 是一个异步消息传递库,类似于并发框架。它支持进程内、进程间、TCP 和多播传输,以及包括 C、C++、Go 和 Python 在内的各种编程语言绑定。这些绑定以及 ZeroMQ 基于套接字的抽象使团队可以轻松地在同一个分布式应用程序中混合使用不同的编程语言。该库还内置了常见的消息传递模式,如请求/回复、发布/订阅和并行管道。ZeroMQ 中的 zero 指的是 零成本,而 MQ 部分代表 消息队列。
我们将使用 ZeroMQ 探索基于消息的进程间和进程内通信。让我们首先为 Python 安装 ZeroMQ。
获取 pyzmq
我们将使用 ZeroMQ 的官方 Python 绑定来完成以下练习。我推荐在一个新的虚拟环境中安装pyzmq
包。如果你的系统上已经安装了conda
,创建一个 Python 虚拟环境非常简单。以下是使用conda
提供所需虚拟环境的步骤:
-
导航到包含示例的
zeromq
目录:(base) $ cd MELD/Chapter17/zeromq
-
创建一个名为
zeromq
的新虚拟环境:(base) $ conda create --name zeromq python=3.12 pyzmq
-
激活你的新虚拟环境:
(base) $ conda activate zeromq
-
检查 Python 版本是否为 3.12:
(zeromq) $ python –-version
-
列出你环境中已安装的包:
(zeromq) $ conda list
如果在包列表中看到pyzmq
及其依赖项,那么你现在可以开始运行以下练习了。
进程间消息传递
我们将从一个简单的回显服务器开始探索 ZeroMQ。服务器期望从客户端接收一个字符串形式的名称,并回复Hello <name>
。代码位于MELD/Chapter17/zeromq/server.py
:
import time
import zmq
context = zmq.Context()
socket = context.socket(zmq.REP)
socket.bind("tcp://*:5555")
while True:
# Wait for next request from client
message = socket.recv_pyobj()
print(f"Received request: {message}")
# Do some 'work'
time.sleep(1)
# Send reply back to client
socket.send_pyobj(f"Hello {message}")
服务器进程创建一个REP
类型的套接字用于响应,将该套接字绑定到端口5555
,并等待接收消息。在接收到请求并发送回回复之间,使用 1 秒的睡眠来模拟一些工作。
回显客户端的代码位于MELD/Chapter17/zeromq/client.py
:
import zmq
def main(who):
context = zmq.Context()
# Socket to talk to server
print("Connecting to echo server...")
socket = context.socket(zmq.REQ)
socket.connect("tcp://localhost:5555")
# Do 5 requests, waiting each time for a response
for request in range(5):
print(f"Sending request {request} ...")
socket.send_pyobj(who)
# Get the reply.
message = socket.recv_pyobj()
print(f"Received reply {request} [ {message} ]")
if __name__ == '__main__':
import sys
if len(sys.argv) != 2:
print("usage: client.py <username>")
raise SystemExit
main(sys.argv[1])
客户端进程接受一个用户名作为命令行参数。客户端为请求创建一个REQ
类型的套接字,连接到在端口5555
上监听的服务器进程,并开始发送包含传入用户名的消息。与服务器中的socket.recv()
一样,客户端中的socket.recv()
也会阻塞,直到队列中收到消息。
为了查看回显服务器和客户端代码的运行效果,激活你的zeromq
虚拟环境,并在MELD/Chapter17/zeromq
目录中运行planets.sh
脚本:
(zeromq) $ ./planets.sh
planets.sh
脚本会启动三个客户端进程,分别为Mars
、Jupiter
和Venus
。我们可以看到,三个客户端的请求是交错进行的,因为每个客户端在发送下一个请求之前会等待来自服务器的回复。由于每个客户端发送五个请求,我们应该从服务器接收到总共 15 个回复。使用 ZeroMQ 进行基于消息的进程间通信非常简单。现在,让我们结合 Python 的内建asyncio
模块和 ZeroMQ 来进行进程内消息传递。
进程内消息传递
asyncio
模块是在 Python 3.4 版本中引入的。它增加了一个可插拔的事件循环,用于通过协程执行单线程并发代码。协程(也称为绿色线程)在 Python 中使用async
/await
语法声明,这一语法从 C#中引入。与 POSIX 线程相比,协程的开销更小,更像是可恢复的函数。由于协程在事件循环的单线程上下文中操作,我们可以将pyzmq
与asyncio
结合使用,实现基于套接字的进程内消息传递。
这里是一个稍微修改过的协程示例,来自 github.com/zeromq/pyzmq
仓库:
import asyncio
import time
import zmq
from zmq.asyncio import Context, Poller
url = 'inproc://#1'
ctx = Context.instance()
async def ping() -> None:
"""print dots to indicate idleness"""
while True:
await asyncio.sleep(0.5)
print('.')
async def receiver() -> None:
"""receive messages with polling"""
pull = ctx.socket(zmq.PAIR)
pull.connect(url)
poller = Poller()
poller.register(pull, zmq.POLLIN)
while True:
events = await poller.poll()
if pull in dict(events):
print("recving", events)
msg = await pull.recv_multipart()
print('recvd', msg)
async def sender() -> None:
"""send a message every second"""
tic = time.time()
push = ctx.socket(zmq.PAIR)
push.bind(url)
while True:
print("sending")
await push.send_multipart([str(time.time() - tic).encode('ascii')])
await asyncio.sleep(1)
async def main() -> None:
tasks = [asyncio.create_task(coroutine()) for coroutine in [ping, receiver, sender]]
await asyncio.wait(tasks)
if __name__ == "__main__":
asyncio.run(main())
注意,receiver()
和 sender()
协程共享相同的上下文。url
部分指定的 inproc
传输方法用于线程间通信,比我们在前一个示例中使用的 tcp
传输要快得多。PAIR
模式将两个套接字独占连接。与 inproc
传输一样,这种消息传递模式仅在进程内工作,旨在实现线程间的信号传递。receiver()
和 sender()
协程都没有返回。asyncio
事件循环在两个协程之间交替执行,在线程阻塞或 I/O 完成时挂起和恢复每个协程。
要从你当前的 zeromq
虚拟环境中运行协程示例,使用以下命令:
(zeromq) $ python coroutines.py
sender()
向 receiver()
发送时间戳,后者显示这些时间戳。使用 Ctrl + C 终止进程。恭喜!你刚刚见证了不使用显式线程的进程内异步消息传递。关于协程和 asyncio
还有很多内容可以学习和讨论。这个示例只是让你感受一下 Python 与 ZeroMQ 配合使用时的新功能。暂时让我们抛开单线程事件循环,回到 Linux 的话题。
调度
本章我想要讨论的第二个大主题是调度。Linux 调度程序有一个准备好运行的线程队列,它的工作是将这些线程调度到可用的 CPU 上。每个线程都有一个调度策略,可能是时间共享的或实时的。时间共享线程有一个 niceness 值,决定了它们获得 CPU 时间的优先级。实时线程有 优先级,较高优先级的线程会抢占较低优先级的线程。调度程序是与线程打交道,而不是进程。每个线程都会被调度,无论它是在哪个进程中运行。
调度程序在以下任何情况下都会运行:
-
线程通过调用
sleep()
或其他阻塞系统调用被阻塞。 -
时间共享线程耗尽了它的时间片。
-
中断会导致线程解除阻塞,例如,I/O 完成时。
关于 Linux 调度程序的背景信息,我推荐你阅读 Robert Love 的《Linux 内核开发(第 3 版)》中关于进程调度的章节。
公平性与确定性
我将调度策略分为两类:时间共享和实时。时间共享策略基于公平性原则。它们旨在确保每个线程获得公平的处理器时间,并且没有线程能够独占系统。如果一个线程运行时间过长,它将被放到队列的末尾,以便其他线程也能运行。同时,公平性策略需要适应那些执行大量工作的线程,并为它们提供足够的资源来完成任务。时间共享调度非常好,因为它能根据各种工作负载自动进行调整。
另一方面,如果你有一个实时程序,公平性就没有帮助了。在这种情况下,你需要一个确定性策略,它至少能保证实时线程在正确的时间被调度,以便它们不会错过截止时间。这意味着实时线程必须抢占时间共享线程。实时线程还有一个静态优先级,调度器可以根据这个优先级在有多个线程同时运行时选择哪个线程先执行。Linux 的实时调度器实现了一个相当标准的算法,它会运行优先级最高的实时线程。大多数实时操作系统调度器也是按照这种方式编写的。
两种类型的线程可以共存。需要确定性调度的线程会先被调度,其余的时间会分配给时间共享线程。
时间共享策略
时间共享策略旨在实现公平性。从 Linux 2.6.23 开始,使用的调度器是完全公平调度器(CFS)。它不像传统的时间片轮转调度那样使用固定的时间片。相反,它会计算一个线程应该获得的 CPU 时间量,并将其与线程实际运行的时间进行平衡。如果线程超出了它的应得时间,并且有其他时间共享线程等待运行,调度器会暂停当前线程,改为运行一个等待的线程。
时间共享策略如下:
-
SCHED_NORMAL
(也叫SCHED_OTHER
):这是默认的调度策略。绝大多数 Linux 线程使用该策略。 -
SCHED_BATCH
:这与SCHED_NORMAL
类似,唯一不同的是,线程的调度粒度较大;即线程运行的时间较长,但必须等待更长时间才能再次被调度。其目的是减少背景处理(批处理任务)中的上下文切换次数,并减少 CPU 缓存的切换。 -
SCHED_IDLE
:这些线程只有在没有其他策略的线程准备好运行时才会运行。它的优先级是最低的。
你可以使用两对函数来获取和设置线程的策略和优先级。第一对函数以 PID 作为参数,影响进程中的主线程:
struct sched_param {
<…>
int sched_priority;
<…>
};
int sched_setscheduler(pid_t pid, int policy,
const struct sched_param *param);
int sched_getscheduler(pid_t pid);
第二对函数操作 pthread_t
,可以改变进程中其他线程的参数:
int pthread_setschedparam(pthread_t thread, int policy,
const struct sched_param *param);
int pthread_getschedparam(pthread_t thread, int *policy,
struct sched_param *param);
有关线程策略和优先级的更多信息,请参见sched(7)
手册页。现在我们知道了时间共享策略和优先级是什么,让我们来谈谈 nice 值。
nice 值
一些时间共享线程比其他线程更重要。你可以通过 nice 值来表示这一点,nice 值通过缩放因子来调整线程的 CPU 分配。这个名字来自于函数调用nice(2)
,它从 Unix 的早期就已经存在。通过减少对系统的负载或通过增加负载,线程变得更 nice。nice 值的范围是从19
,表示非常 nice,到-20
,表示非常不 nice。默认值是0
,表示适中或一般 nice。
可以改变SCHED_NORMAL
和SCHED_BATCH
线程的 nice 值。要降低 nice 值,这会增加 CPU 负载,你需要CAP_SYS_NICE
权限,该权限默认只授予root
用户。有关权限的更多信息,请参见capabilities(7)
手册页。
几乎所有关于改变nice
值的函数和命令的文档(如nice(2)
及nice
和renice
命令)都从进程角度讨论。然而,它实际上是与线程相关的。正如我们在前一节中提到的,你可以使用 TID 代替 PID 来改变单个线程的nice
值。nice
的标准描述中还有另一个不一致之处:nice
值被称为线程的优先级(或有时错误地被称为进程的优先级)。我认为这是误导性的,并将概念与实时优先级混淆了,而实时优先级是完全不同的概念。
实时策略
实时策略旨在确保确定性。实时调度器总是会运行准备好的优先级最高的实时线程。实时线程总是抢占时间共享线程。本质上,通过选择实时策略而非时间共享策略,你表示你已经掌握了该线程预期调度的内部信息,并希望覆盖调度器内置的假设。
有两种实时策略:
-
SCHED_FIFO
:这是一种运行至完成算法,意味着一旦线程开始运行,它将继续执行,直到被更高优先级的实时线程抢占,或在系统调用中被阻塞,或者直到它终止(完成)。 -
SCHED_RR
:这是一种轮转算法,如果线程超出了时间片(默认是 100 毫秒),它将在同一优先级的线程之间进行轮换。从 Linux 3.9 开始,可以通过/proc/sys/kernel/sched_rr_timeslice_ms
控制timeslice
值。除此之外,它的行为与SCHED_FIFO
相同。
每个实时线程的优先级范围是1
到99
,其中99
是最高优先级。
要给线程分配实时策略,你需要CAP_SYS_NICE
权限,默认情况下该权限仅授予 root 用户。
实时调度的一个问题,无论是在 Linux 还是其他地方,都是当一个线程变成计算密集型时,通常是因为某个 bug 导致它无限循环,这将阻止低优先级的实时线程与所有共享时间线程一起运行。在这种情况下,系统会变得不稳定,甚至可能完全死锁。防止这种情况发生的方法有几种。
首先,自 Linux 2.6.25 以来,调度器默认将 5%的 CPU 时间保留给非实时线程,这样即使一个失控的实时线程也无法完全停止系统。通过两个内核控制来配置:
-
/proc/sys/kernel/sched_rt_period_us
-
/proc/sys/kernel/sched_rt_runtime_us
它们的默认值分别为 1,000,000(1 秒)和 950,000(950 毫秒),这意味着每秒保留 50 毫秒用于非实时处理。如果希望实时线程能够占用 100%的时间,则将sched_rt_runtime_us
设置为-1
。
第二个选项是使用看门狗,无论是硬件还是软件,来监控关键线程的执行,并在它们开始错过截止日期时采取行动。我在第十三章中提到了看门狗。
选择一种策略
实际上,时间共享策略满足了大多数计算工作负载。I/O 密集型线程会花费大量时间被阻塞,并且总是有一些剩余的配额。当它们解除阻塞时,将几乎立即被调度。同时,CPU 密集型线程自然会占用所有剩余的 CPU 周期。可以对不太重要的线程应用正的 nice 值,对更重要的线程应用负的 nice 值。
当然,这只是平均行为;没有保证每次都会这样。如果需要更确定的行为,则需要实时策略。将线程标记为实时的标准如下:
-
它有一个必须生成输出的截止日期。
-
错过截止日期会影响系统的有效性。
-
它是事件驱动的。
-
它不是计算密集型的。
实时任务的例子包括经典的机器人臂伺服控制器、多媒体处理和通信处理。我将在稍后的第二十一章中讨论实时系统设计。
选择实时优先级
选择适合所有预期工作负载的实时优先级是一项复杂的任务,也是避免使用实时策略的一个充分理由。
选择优先级的最常用方法被称为速率单调分析(RMA),它来源于 Liu 和 Layland 在 1973 年发表的论文。它适用于具有周期性线程的实时系统,这是一个非常重要的类别。每个线程都有一个周期和一个利用率,利用率是该周期内线程将执行的时间比例。目标是平衡负载,使所有线程能够在下一个周期开始前完成执行阶段。RMA 指出,如果以下条件成立,就能实现这一目标:
-
优先级最高的线程是那些周期最短的线程。
-
总利用率小于 69%。
总利用率是所有单个利用率之和。它还假设线程之间的交互或在互斥锁等上被阻塞的时间可以忽略不计。
摘要
Linux 中内建的悠久 Unix 遗产和随附的 C 库几乎提供了编写稳定且具有韧性的嵌入式应用所需的一切。问题在于,对于每项工作,至少有两种方法可以实现你想要的结果。
本章重点讨论了系统设计的两个方面:将任务划分为多个独立进程,每个进程有一个或多个线程来完成工作,以及调度这些线程。我希望我能对这些内容有所阐明,并为你们进一步学习打下基础。
在下一章,我将探讨系统设计的另一个重要方面:内存管理。
进一步学习
-
《Unix 编程艺术》,作者:Eric Steven Raymond
-
《Linux 系统编程,第 2 版》,作者:Robert Love
-
《Linux 内核开发,第 3 版》,作者:Robert Love
-
《Linux 编程接口》,作者:Michael Kerrisk
-
《UNIX 网络编程,第 2 卷:进程间通信,第 2 版》,作者:W. Richard Stevens
-
《POSIX 线程编程》,作者:David R. Butenhof
-
《硬实时环境中的多程序调度算法》,作者:C. L. Liu 和 James W. Layland,发表于《ACM 期刊》,1973 年,第 20 卷,第 1 期,第 46-61 页
加入我们社区的 Discord
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:packt.link/embeddedsystems
第十八章:内存管理
本章涵盖与内存管理相关的问题,这是任何 Linux 系统中的重要话题,尤其是在嵌入式 Linux 中,系统内存通常是有限的。简要回顾虚拟内存后,我将向你展示如何衡量内存使用情况,如何检测内存分配问题,包括内存泄漏,以及当内存不足时会发生什么。你将需要了解可用的工具,从简单的工具如 free
和 top
到复杂的工具如 mtrace
和 Valgrind。
我们将学习内核空间和用户空间内存的区别,以及内核如何将物理内存页映射到进程的地址空间。然后,我们将定位并读取 proc
文件系统下各个进程的内存映射。我们将看到如何使用 mmap
系统调用将程序的内存映射到文件,这样它可以批量分配内存或与其他进程共享内存。在本章的后半部分,我们将使用 ps
来测量每个进程的内存使用情况,然后再使用更准确的工具,如 smem
和 ps_mem
。
本章将涵盖以下主题:
-
虚拟内存基础
-
内核空间内存布局
-
用户空间内存布局
-
进程内存映射
-
内存管理
-
交换
-
使用
mmap
映射内存 -
我的应用程序使用了多少内存?
-
每个进程的内存使用
-
识别内存泄漏
-
内存不足
技术要求
为了跟随示例,确保你的 Linux 主机系统已安装 gcc
、make
、top
、procps
、valgrind
和 smem
。
所有这些工具在大多数流行的 Linux 发行版(如 Ubuntu、Arch 等)上都可以使用。
本章中使用的代码可以在本书的 GitHub 仓库的章节文件夹中找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development/tree/main/Chapter18
。
虚拟内存基础
总结一下,Linux 配置了 CPU 的 内存管理单元 (MMU),为正在运行的程序呈现一个从零开始并在 32 位处理器上以 0xffffffff
结束的虚拟地址空间。这个地址空间默认被划分为 4 KB 的页面。如果 4 KB 的页面对你的应用来说太小,你可以配置内核使用 HugePages,从而减少访问页面表项所需的系统资源,并增加 转换后备缓冲区 (TLB) 的命中率。
Linux 将这个虚拟地址空间划分为供应用程序使用的区域,称为用户空间,以及供内核使用的区域,称为内核空间。两者之间的划分由一个名为PAGE_OFFSET
的内核配置参数设置。在典型的 32 位嵌入式系统中,PAGE_OFFSET
为0xc0000000
,将低 3GB 分配给用户空间,顶端的 1GB 分配给内核空间。用户地址空间按进程分配,每个进程运行在自己的沙箱中,与其他进程隔离。内核地址空间对于所有进程都是相同的,因为只有一个内核。
这个虚拟地址空间中的页面通过 MMU 映射到物理地址,MMU 使用页表来执行映射。
每个虚拟内存页面可以按以下方式进行映射或未映射:
-
未映射,因此尝试访问这些地址将导致
SIGSEGV
。 -
映射到进程专用的物理内存页面。
-
映射到与其他进程共享的物理内存页面。
-
映射并共享,并设置写时复制(CoW)标志:写操作会被内核捕获,内核会复制该页面,并将其映射到进程中,取代原始页面,然后允许写操作发生。
-
映射到一个由内核使用的物理内存页面。
内核还可以将页面映射到保留的内存区域,例如,访问设备驱动程序中的寄存器和内存缓冲区。
一个显而易见的问题是:为什么要这样做,而不是像典型的实时操作系统那样直接引用物理内存?
虚拟内存有许多优势,以下是其中的一些描述:
-
无效的内存访问会被捕获,应用程序通过
SIGSEGV
收到警告。 -
进程在自己的内存空间中运行,彼此隔离。
-
通过共享公共代码和数据(例如在库中)高效使用内存。
-
通过添加交换文件增加物理内存的表面量,尽管在嵌入式目标上交换操作很少见。
这些是有力的论点,但我不得不承认也有一些缺点。确定应用程序的实际内存预算是很困难的,这也是本章的主要关注点之一。默认的分配策略是过度承诺,这会导致棘手的内存不足情况,稍后我会在内存不足一节中讨论。最后,内存管理代码在处理异常(页面错误)时引入的延迟,使得系统变得不那么确定,这对实时程序来说非常重要。我将在第二十一章中讲解这一点。
内存管理在内核空间和用户空间中是不同的。接下来的章节将描述这些基本区别以及你需要了解的内容。
内核空间内存布局
内核内存的管理方式非常简单。它不是按需分页的,这意味着每次使用kmalloc()
或类似函数进行分配时,都会有实际的物理内存。内核内存从不被丢弃或分页出去。
一些架构在引导时会在内核日志消息中显示内存映射的摘要。这个跟踪来自一台 32 位的 Arm 设备(BeagleBone Black):
Memory: 511MB = 511MB total
Memory: 505980k/505980k available, 18308k reserved, 0K highmem
Virtual kernel memory layout:
vector : 0xffff0000 - 0xffff1000 ( 4 kB)
fixmap : 0xfff00000 - 0xfffe0000 ( 896 kB)
vmalloc : 0xe0800000 - 0xff000000 ( 488 MB)
lowmem : 0xc0000000 - 0xe0000000 ( 512 MB)
pkmap : 0xbfe00000 - 0xc0000000 ( 2 MB)
modules : 0xbf800000 - 0xbfe00000 ( 6 MB)
.text : 0xc0008000 - 0xc0763c90 (7536 kB)
.init : 0xc0764000 - 0xc079f700 ( 238 kB)
.data : 0xc07a0000 - 0xc0827240 ( 541 kB)
.bss : 0xc0827240 - 0xc089e940 ( 478 kB)
505,980 KB 可用内存是内核开始执行时看到的自由内存量,但在内核开始进行动态分配之前。
内核空间内存的消费者包括以下内容:
-
内核本身,换句话说,是从引导时加载的内核映像文件中的代码和数据。这些内容在前面的内核日志中显示为
.text
、.init
、.data
和.bss
段。.init
段在内核初始化完成后会被释放。 -
通过 slab 分配器分配的内存,用于各种内核数据结构。这包括使用
kmalloc()
进行的分配。它们来自标记为lowmem的区域。 -
通过
vmalloc()
分配的内存,通常用于分配比kmalloc()
可用内存更大的内存块。这些位于vmalloc区域。 -
设备驱动程序访问属于各种硬件部分的寄存器和内存的映射,您可以通过读取
/proc/iomem
来查看。这些也来自vmalloc区域,但由于它们映射到主系统内存之外的物理内存,因此不占用实际内存。 -
加载到标记为modules的区域中的内核模块。
-
其他未在任何地方跟踪的低级别分配。
现在我们知道了内核空间中内存的布局,让我们来看看内核实际使用了多少内存。
内核使用了多少内存?
不幸的是,关于内核使用多少内存这个问题,并没有确切的答案,但以下内容是我们能得到的最接近的答案。
首先,您可以通过之前显示的内核日志查看内核代码和数据占用的内存,或者使用size
命令:
$ cd ~
$ cd build_arm64
$ aarch64-buildroot-linux-gnu-size vmlinux
text data bss dec hex filename
26412819 15636144 620032 42668995 28b13c3 vmlinux
通常,与总内存量相比,内核为静态代码和数据段所占的内存较小。如果不是这种情况,您需要检查内核配置并删除不需要的组件。一个旨在构建小型内核的努力,称为Linux 内核简化,在项目停滞之前取得了良好的进展,Josh Triplett 的补丁最终在 2016 年从linux-next
树中移除。现在,减少内核内存占用的最佳方法是原地执行(XIP),您可以通过牺牲 RAM 来换取闪存(lwn.net/Articles/748198/
)。
您可以通过读取/proc/meminfo
获取更多内存使用信息:
# cat /proc/meminfo
MemTotal: 1996796 kB
MemFree: 1917020 kB
MemAvailable: 1894044 kB
Buffers: 2444 kB
Cached: 11976 kB
SwapCached: 0 kB
Active: 9440 kB
Inactive: 8964 kB
Active(anon): 92 kB
Inactive(anon): 4096 kB
Active(file): 9348 kB
Inactive(file): 4868 kB
Unevictable: 0 kB
Mlocked: 0 kB
SwapTotal: 0 kB
SwapFree: 0 kB
Dirty: 8 kB
Writeback: 0 kB
AnonPages: 4008 kB
Mapped: 6864 kB
Shmem: 200 kB
KReclaimable: 7412 kB
Slab: 20924 kB
SReclaimable: 7412 kB
SUnreclaim: 13512 kB
KernelStack: 1552 kB
PageTables: 540 kB
SecPageTables: 0 kB
NFS_Unstable: 0 kB
Bounce: 0 kB
WritebackTmp: 0 kB
CommitLimit: 998396 kB
Committed_AS: 7396 kB
VmallocTotal: 135288315904 kB
VmallocUsed: 4072 kB
VmallocChunk: 0 kB
<…>
每个字段的描述可以在手册页proc(5)
中找到。内核内存使用量是以下各项的总和:
-
Slab
:由 slab 分配器分配的总内存 -
KernelStack
:执行内核代码时使用的栈空间 -
PageTables
:用于存储页表的内存 -
VmallocUsed
:vmalloc()
分配的内存
对于 slab 分配,你可以通过读取/proc/slabinfo
获取更多信息。同样,/proc/vmallocinfo
中有**vmalloc**
区域的分配详细信息。在这两种情况下,你需要详细了解内核及其子系统,才能准确看到是哪个子系统进行的分配以及原因,这超出了本讨论的范围。
使用模块时,你可以通过lsmod
查看代码和数据所占的内存空间:
# lsmod
Module Size Used by
g_multi 47670 2
libcomposite 14299 1 g_multi
mt7601Usta 601404 0
这会留下低级分配,没有记录,这使得我们无法生成准确的内核空间内存使用统计。当我们将所有已知的内核和用户空间分配加起来时,这部分将表现为缺失的内存。
测量内核空间内存使用情况比较复杂。/proc/meminfo
中的信息有些有限,而/proc/slabinfo
和/proc/vmallocinfo
提供的附加信息难以解读。用户空间通过进程内存映射提供了更好的内存使用可见性。
用户空间内存布局
Linux 对用户空间采用懒分配策略,只有当程序访问内存时,才会映射物理内存页。例如,使用malloc(3)
分配 1 MB 的缓冲区时,返回的是指向内存地址块的指针,但并没有实际的物理内存。在页表条目中设置了一个标志,使得任何读或写访问都会被内核拦截。这就是页面错误。只有此时,内核才会尝试找到一页物理内存并将其添加到进程的页表映射中。让我们通过一个来自MELD/Chapter18/pagefault-demo
的简单程序来演示这一点:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>
#define BUFFER_SIZE (1024 * 1024)
void print_pgfaults(void)
{
int ret;
struct rusage usage;
ret = getrusage(RUSAGE_SELF, &usage);
if (ret == -1) {
perror("getrusage");
} else {
printf("Major page faults %ld\n", usage.ru_majflt);
printf("Minor page faults %ld\n", usage.ru_minflt);
}
}
int main(int argc, char *argv[])
{
unsigned char *p;
printf("Initial state\n");
print_pgfaults();
p = malloc(BUFFER_SIZE);
printf("After malloc\n");
print_pgfaults();
memset(p, 0x42, BUFFER_SIZE);
printf("After memset\n");
print_pgfaults();
memset(p, 0x42, BUFFER_SIZE);
printf("After 2nd memset\n");
print_pgfaults();
return 0;
}
当你运行它时,你将看到如下输出:
Initial state
Major page faults 0
Minor page faults 172
After malloc
Major page faults 0
Minor page faults 186
After memset
Major page faults 0
Minor page faults 442
After 2nd memset
Major page faults 0
Minor page faults 442
在初始化程序环境后,遇到了 172 个小型页面错误,在调用getrusage(2)
时又遇到了 14 个(这些数字会根据架构和你使用的 C 库版本有所不同)。重要的是填充内存时的增加:442 - 186 = 256。缓冲区为 1 MB,即 256 页。第二次调用memset(3)
没有任何影响,因为所有页面现在已经映射。
如你所见,当内核截获对尚未映射的页面的访问时,会生成页面错误。事实上,有两种类型的页面错误:轻微
和严重
。在轻微错误中,内核只需找到一个物理内存页面并将其映射到进程地址空间,如前面的代码所示。严重页面错误发生在虚拟内存映射到文件时,例如使用mmap(2)
,我稍后会描述。读取此内存意味着内核不仅需要找到一个内存页面并将其映射进来,还需要用文件中的数据填充它。因此,严重错误在时间和系统资源方面的成本要高得多。
虽然getrusage(2)
提供了有关进程中的轻微和严重页面错误的有用指标,但有时我们真正想看到的是进程的总体内存映射。
进程内存映射
每个在用户空间中运行的进程都有一个可以检查的进程映射。这些内存映射告诉我们程序如何分配内存以及它链接了哪些共享库。你可以通过proc
文件系统查看进程的内存映射。以下是init
进程(PID 1)的映射:
# cat /proc/1/maps
aaaaaf830000-aaaaaf83a000 r-xp 00000000 b3:62 397 /sbin/init.sysvinit
aaaaaf84f000-aaaaaf850000 r--p 0000f000 b3:62 397 /sbin/init.sysvinit
aaaaaf850000-aaaaaf851000 rw-p 00010000 b3:62 397 /sbin/init.sysvinit
aaaae9d63000-aaaae9d84000 rw-p 00000000 00:00 0 [heap]
ffff7ffb0000-ffff8013b000 r-xp 00000000 b3:62 309 /lib/libc.so.6
ffff8013b000-ffff8014d000 ---p 0018b000 b3:62 309 /lib/libc.so.6
ffff8014d000-ffff80150000 r--p 0018d000 b3:62 309 /lib/libc.so.6
ffff80150000-ffff80152000 rw-p 00190000 b3:62 309 /lib/libc.so.6
ffff80152000-ffff8015e000 rw-p 00000000 00:00 0
ffff8016c000-ffff80193000 r-xp 00000000 b3:62 304 /lib/ld-linux-aarch64.so.1
ffff801a4000-ffff801a6000 rw-p 00000000 00:00 0
ffff801a6000-ffff801a8000 r--p 00000000 00:00 0 [vvar]
ffff801a8000-ffff801aa000 r-xp 00000000 00:00 0 [vdso]
ffff801aa000-ffff801ac000 r--p 0002e000 b3:62 304 /lib/ld-linux-aarch64.so.1
ffff801ac000-ffff801ae000 rw-p 00030000 b3:62 304 /lib/ld-linux-aarch64.so.1
ffffd73ca000-ffffd73eb000 rw-p 00000000 00:00 0 [stack]
前两列显示每个映射的起始和结束虚拟地址,以及权限。权限在此处显示:
-
r
: 读取 -
w
: 写入 -
x
: 执行 -
s
: 共享 -
p
: 私有(写时复制)
如果映射与文件相关联,则文件名出现在最后一列,第三、四和五列包含文件起始处的偏移量、块设备号和文件的 inode。大多数映射指向程序本身和它链接的库。有两个区域,程序可以在其中分配内存,分别标记为[heap]
和[stack]
。使用malloc
分配的内存来自前者(除了非常大的分配,我们稍后会提到);堆栈上的分配来自后者。两个区域的最大大小由进程的ulimit
控制:
-
堆:
ulimit -d
,默认为无限制 -
堆栈:
ulimit -s
,默认为 8 MB
超出限制的分配会被SIGSEGV
拒绝。
当内存不足时,内核可能决定丢弃那些映射到文件且为只读的页面。如果该页面再次被访问,将导致严重页面错误,并从文件中重新读取。
交换
交换的理念是保留一些存储空间,内核可以将未映射到文件的内存页面放入其中,从而释放出内存供其他用途。它通过交换文件的大小增加了物理内存的有效大小。它并不是万能的:将页面复制到交换文件和从交换文件复制回来是有代价的,这在系统的实际内存不足以支撑其工作负载时变得显而易见,交换成为主要的活动。这有时被称为磁盘抖动。
在嵌入式设备上,交换通常不被使用,因为它不适用于闪存存储,频繁的写入会迅速磨损闪存。然而,你可能会考虑将交换设置为压缩 RAM(zram)。
将内存交换到压缩内存(zram)
zram 驱动程序创建基于 RAM 的块设备,命名为 /dev/zram0
、/dev/zram1
等。写入这些设备的页面在存储之前会被压缩。压缩比在 30% 到 50% 之间,你可以期望空闲内存大约增加 10%,但这会增加更多的处理量并相应地增加功耗。
要启用 zram,请使用以下选项配置内核:
CONFIG_SWAP
CONFIG_CGROUP_MEM_RES_CTLR
CONFIG_CGROUP_MEM_RES_CTLR_SWAP
CONFIG_ZRAM
然后,通过将以下内容添加到 /etc/fstab
中,在启动时挂载 zram:
/dev/zram0 none swap defaults zramsize=<size in bytes>, swapprio=<swap partition priority>
你可以使用以下命令开启或关闭交换:
# swapon /dev/zram0
# swapoff /dev/zram0
将内存交换到 zram 比交换到闪存存储更好,但这两种技术都不能替代足够的物理内存。
用户空间进程依赖内核管理虚拟内存。有时,程序需要比内核提供的更多的内存映射控制。有一个系统调用允许我们将内存映射到文件中,以便从用户空间进行更直接的访问。
使用 mmap 映射内存
一个进程从启动时就会有一定量的内存映射到程序文件的 文本(代码)和 数据 段中,并与它链接的共享库一起。它可以在运行时使用 malloc(3)
在堆上分配内存,并通过局部作用域变量以及使用 alloca(3)
分配的内存在栈上分配内存。它还可以在运行时使用 dlopen(3)
动态加载库。所有这些映射由内核处理。然而,进程也可以通过 mmap(2)
显式地操作其内存映射:
void *mmap(void *addr, size_t length, int prot, int flags,int fd, off_t offset);
此函数从具有 fd
描述符的文件中映射 length
字节的内存,从文件中的 offset
位置开始,并返回指向映射的指针(如果成功)。由于底层硬件按页工作,length
会向上舍入到最接近的整页数。保护参数 prot
是读、写和执行权限的组合,flags
参数至少包含 MAP_SHARED
或 MAP_PRIVATE
。还有许多其他标志,详细说明可以参考 mmap
的手册页。
你可以使用 mmap
做很多事情。接下来的章节将展示其中的一些。
使用 mmap 分配私有内存
你可以通过在 flags
参数中设置 MAP_ANONYMOUS
并将文件描述符 fd
设置为 -1
来使用 mmap
分配私有内存区域。这类似于使用 malloc
从堆中分配内存,不同的是分配的内存是页对齐的,并且是页的倍数。分配的内存与库使用的内存区域相同。事实上,这个区域因此也被一些人称为 mmap
区域。
匿名映射更适合大块分配,因为它们不会将堆与内存块绑定,这样会增加碎片化的可能性。有趣的是,你会发现 malloc
(至少在 glibc
中)在处理超过 128 KB 的请求时,会停止从堆中分配内存,并改用 mmap
,因此在大多数情况下,直接使用 malloc
就是正确的做法。系统会选择最合适的方式来满足请求。
使用 mmap 来共享内存
正如我们在 第十七章 中看到的,POSIX 共享内存需要 mmap
来访问内存段。在这种情况下,你需要设置 MAP_SHARED
标志,并使用 shm_open()
的文件描述符:
int shm_fd;
char *shm_p;
shm_fd = shm_open("/myshm", O_CREAT | O_RDWR, 0666);
ftruncate(shm_fd, 65536);
shm_p = mmap(NULL, 65536, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
另一个进程使用相同的调用、文件名、长度和标志来映射到该内存区域以进行共享。后续调用 msync(2)
控制何时将内存更新传递到底层文件。
通过 mmap
共享内存还提供了一种直接的方式来读写设备内存。
使用 mmap 访问设备内存
正如我在 第十一章 中提到的,驱动程序可能允许其设备节点进行内存映射,并与应用程序共享一些设备内存。具体实现取决于驱动程序。
一个例子是 Linux 帧缓冲区 /dev/fb0
。像 Xilinx Zynq 系列这样的 FPGA 也可以通过 mmap
从 Linux 中作为内存访问。帧缓冲接口定义在 /usr/include/linux/fb.h
中,包括一个 ioctl
函数来获取显示的大小和每像素的位数。然后,你可以使用 mmap
请求视频驱动程序与应用程序共享帧缓冲区,并读取和写入像素:
int f;
int fb_size;
unsigned char *fb_mem;
f = open("/dev/fb0", O_RDWR);
/* Use ioctl FBIOGET_VSCREENINFO to find the display
dimensions and calculate fb_size */
fb_mem = mmap(0, fb_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
/* read and write pixels through pointer fb_mem */
第二个例子是流媒体视频接口 Video 4 Linux 2 (V4L2) ,它定义在 /usr/include/linux/videodev2.h
中。每个视频设备都有一个名为 /dev/video<N>
的节点,从 /dev/video0
开始。这里有一个 ioctl
函数来请求驱动程序分配一些视频缓冲区,你可以将其 mmap
到用户空间。然后,只需要循环使用这些缓冲区,并根据是播放还是捕获视频流来填充或清空它们。
现在我们已经讲解了内存布局和映射,让我们来看看内存使用情况,从如何衡量它开始。
我的应用程序使用了多少内存?
与内核空间一样,不同的用户空间内存分配、映射和共享方式使得回答这个看似简单的问题变得相当困难。
首先,你可以询问内核它认为可用的内存量,你可以通过 free
命令来做到这一点。以下是输出的典型示例:
total used free shared buffers cached
Mem: 509016 504312 4704 0 26456 363860
-/+ buffers/cache: 113996 395020
Swap: 0 0 0
乍一看,这看起来像是一个几乎没有内存的系统,只剩下 4,704 KB 的空闲内存,总共 509,016 KB:少于 1%。然而,请注意,26,456 KB 在缓冲区中,而令人震惊的 363,860 KB 在缓存中。Linux 认为空闲内存是浪费的内存;内核使用空闲内存用于缓冲区和缓存,并知道在需要时可以缩小它们。从测量中去除缓冲区和缓存提供真正的空闲内存,即 395,020 KB:总量的 77%。在使用free
时,第二行标记为-/+ buffers/cache
的数字是重要的。
您可以通过将数字写入 /proc/sys/vm/drop_caches
强制内核释放缓存。
# echo 3 > /proc/sys/vm/drop_caches
这个数字实际上是一个位掩码,确定你想要释放的两种广义缓存类型之一:1
表示页面缓存,2
表示 dentry 和 inode 缓存的组合体。因为 1
和 2
是不同的位,写入 3
可以释放两种类型的缓存。
这些缓存的确切角色在这里并不特别重要,只要知道内核正在使用但可以在短时间内收回的内存即可。
free
命令告诉我们正在使用多少内存以及剩余多少。它既不告诉我们哪些进程正在使用不可用内存,也不告诉我们使用了多少。要测量这一点,我们需要其他工具。
每个进程的内存使用情况
有几种指标可以衡量进程正在使用的内存量。我将从最容易获取的两种开始:虚拟集大小(VSS)和常驻内存大小(RSS),这两者在大多数ps
和top
命令的实现中都可以找到:
-
VSS:在
ps
命令中称为VSZ
,在top
中称为VIRT
,这是由进程映射的所有内存区域的总和。由于虚拟内存的一部分仅在任何时候映射到物理内存中,这个数字的兴趣有限。 -
RSS:在
ps
中称为RSS
,在top
中称为RES
,这是映射到物理内存页的内存总和。这更接近进程实际内存预算,但存在一个问题:如果将所有进程的 RSS 相加,将会高估正在使用的内存,因为某些页面将是共享的。
让我们更多地了解top
和ps
命令。
使用 top 和 ps
BusyBox 提供的top
和ps
版本提供的信息非常有限。以下示例使用来自procps
包的完整版本。
这是使用自定义格式的ps
命令的输出,包括vsz
和rss
:
# ps -eo pid,tid,class,rtprio,stat,vsz,rss,comm
PID TID CLS RTPRIO STAT VSZ RSS COMMAND
1 1 TS - Ss 4496 2652 systemd
<…>
205 205 TS - Ss 4076 1296 systemd-journal
228 228 TS - Ss 2524 1396 udevd
581 581 TS - Ss 2880 1508 avahi-daemon
584 584 TS - Ss 2848 1512 dbus-daemon
590 590 TS - Ss 1332 680 acpid
594 594 TS - Ss 4600 1564 wpa_supplicant
同样,top
显示了每个进程的空闲内存和内存使用情况的摘要:
top - 21:17:52 up 10:04, 1 user, load average: 0.00, 0.01, 0.05
Tasks: 96 total, 1 running, 95 sleeping, 0 stopped, 0 zombie
%Cpu(s): 1.7 us, 2.2 sy, 0.0 ni, 95.9 id, 0.0 wa, 0.0 hi
KiB Mem : 509016 total, 278524 used, 230492 free, 25572 buffers
KiB Swap: 0 total, 0 used, 0 free, 170920 cached
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
595 root 20 0 64920 9.8m 4048 S 0.0 2.0 0:01.09 node
866 root 20 0 28892 9152 3660 S 0.2 1.8 0:36.38 Xorg
<…>
这些简单的命令使您了解内存使用情况,并在看到进程的 RSS 持续增加时提供了内存泄漏的第一个迹象。但是,它们在内存使用的绝对测量方面并不十分精确。
使用 smem
2009 年,Matt Mackall 开始研究如何对进程内存中的共享页面进行记账的问题,并增加了两个新的指标,分别为唯一集合大小(USS)和比例集合大小(PSS):
-
USS:这是分配到物理内存并且唯一属于一个进程的内存量;它不会与其他进程共享。它是进程终止时会释放的内存量。
-
PSS:这会将共享页面在物理内存中被多进程映射时的记账分配给所有映射该页面的进程。例如,如果某个库代码区域有 12 页,并且被六个进程共享,那么每个进程的 PSS 将增加 2 页。因此,如果你将所有进程的 PSS 加起来,你将得到这些进程实际使用的内存量。换句话说,PSS 就是我们一直在寻找的数字。
关于 PSS 的信息可以在/proc/<PID>/smaps
中找到,该文件包含每个映射的附加信息,显示在/proc/<PID>/maps
中。下面是该文件的一部分,提供了关于libc
代码段映射的信息:
ffffbd080000-ffffbd20b000 r-xp 00000000 b3:62 309 /lib/libc.so.6
Size: 1580 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
Rss: 1132 kB
Pss: 112 kB
Pss_Dirty: 0 kB
Shared_Clean: 1132 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 0 kB
Referenced: 1132 kB
Anonymous: 0 kB
KSM: 0 kB
LazyFree: 0 kB
AnonHugePages: 0 kB
ShmemPmdMapped: 0 kB
FilePmdMapped: 0 kB
Shared_Hugetlb: 0 kB
Private_Hugetlb: 0 kB
Swap: 0 kB
SwapPss: 0 kB
Locked: 0 kB
THPeligible: 0
VmFlags: rd ex mr mw me
注意,Rss
为1132 kB
,但由于它在多个其他进程之间共享,因此Pss
只有112 kB
。
有一个名为smem的工具,它将来自smaps
文件的信息整合并以多种方式呈现,包括饼状图或条形图。smem
的项目页面是www.selenic.com/smem/
。它作为软件包在大多数桌面发行版中提供。然而,由于它是用 Python 编写的,在嵌入式目标系统上安装它需要一个 Python 环境,这对于仅仅是一个工具来说可能太麻烦。为了解决这个问题,有一个名为smemcap的小程序,它从目标系统的/proc
捕获状态,并将其保存到一个可以在主机计算机上分析的 TAR 文件中。smemcap
是 BusyBox 的一部分,但也可以从源代码编译。
如果你以root
身份直接运行smem
,你将看到这些结果:
# smem -t
PID User Command Swap USS PSS RSS
1 root init [5] 0 136 267 1532
361 root /sbin/klogd -n 0 104 273 1708
367 root /sbin/getty 38400 tty1 0 108 278 1788
369 root /sbin/getty -L 115200 ttyS2 0 108 278 1788
358 root /sbin/syslogd -n -O /var/lo 0 108 279 1728
306 root udhcpc -R -b -p /var/run/ud 0 168 284 1372
366 root /bin/sh /bin/start_getty 11 0 116 315 1736
383 root -sh 0 220 506 2184
129 root /sbin/udevd -d 0 1436 1517 2380
351 root sshd: /usr/sbin/sshd [liste 0 928 1893 3764
380 root sshd: root@pts/0 0 3816 4900 7160
387 root python3 /usr/bin/smem -t 0 11968 12136 13456
-----------------------------------------------------------
12 1 0 19216 22926 40596
从输出的最后一行可以看到,在这种情况下,总的 PSS 大约是 RSS 的一半。
如果你没有安装 Python,或者不想在目标系统上安装 Python,你可以使用smemcap
捕获状态,再次以root
身份运行:
# smemcap > smem-beagleplay-cap.tar
然后,将 TAR 文件复制到主机上,使用smem -t -S
读取,尽管这一次不需要以root
身份运行命令:
$ smem -t -S smem-beagleplay-cap.tar
输出与我们直接运行smem
时获得的输出相同。
其他工具可供考虑
另一种显示 PSS 的方式是使用ps_mem(github.com/pixelb/ps_mem
),它以更简单的格式显示几乎相同的信息。它也是用 Python 编写的。
Android 也有一个工具,它显示每个进程的 USS 和 PSS 摘要,名为procrank,它可以通过一些小的修改交叉编译到嵌入式 Linux 上。你可以从github.com/csimmonds/procrank_linux
获取代码。
我们现在知道如何衡量每个进程的内存使用情况。假设我们使用刚才展示的工具来找到系统中占用内存最多的进程。那么我们该如何进一步深入这个进程,找出它出问题的地方呢?这就是下一节的内容。
识别内存泄漏
内存泄漏发生在内存被分配但在不再需要时没有释放。内存泄漏绝不仅仅是嵌入式系统的专属问题,但它成为一个问题,部分原因是目标设备本身内存有限,部分原因是它们通常长时间运行而不重启,这使得泄漏逐渐变成一个大水坑。
当你运行free
或top
并看到即使清空缓存,空闲内存仍然持续减少时,你就会意识到有内存泄漏,正如上一节所示。通过查看每个进程的 USS 和 RSS,你将能够识别泄漏的罪魁祸首。
有几种工具可以识别程序中的内存泄漏。我将介绍两种:mtrace
和valgrind
。
mtrace
mtrace是glibc
的一个组件,它跟踪对malloc
、free
及相关函数的调用,并识别在程序退出时未释放的内存区域。你需要在程序中调用mtrace()
函数以开始跟踪,然后在运行时将路径名写入MALLOC_TRACE
环境变量中,该变量指定了写入跟踪信息的文件路径。如果MALLOC_TRACE
不存在或无法打开该文件,则mtrace
钩子将无法安装。
虽然跟踪信息是以 ASCII 格式写入的,但通常使用mtrace
命令来查看它。以下是一个使用mtrace
的程序示例,来自MELD/Chapter18/mtrace-example
:
#include <mcheck.h>
#include <stdlib.h>
#include <stdio.h>
int main(int argc, char *argv[])
{
int j;
mtrace();
for (j = 0; j < 2; j++)
malloc(100); /* Never freed:a memory leak */
calloc(16, 16); /* Never freed:a memory leak */
exit(EXIT_SUCCESS);
}
以下是你在运行程序并查看跟踪时可能看到的内容:
$ export MALLOC_TRACE=mtrace.log
$ ./mtrace-example
$ mtrace mtrace-example mtrace.log
Memory not freed:
-----------------
Address Size Caller
0x0000000001479460 0x64 at /home/chris/mtrace-example.c:12
0x00000000014794d0 0x64 at /home/chris/mtrace-example.c:12
0x0000000001479540 0x100 at /home/chris/mtrace-example.c:14
不幸的是,mtrace
不会告诉你程序运行期间的泄漏内存。它必须先终止才能报告。
Valgrind
Valgrind是一个非常强大的工具,用于发现包括泄漏在内的内存问题。它的一个优势是你无需重新编译要检查的程序和库,尽管如果它们在编译时使用了-g
选项,以便包含调试符号表,它的效果会更好。它通过在模拟环境中运行程序并在多个点截获执行来工作。这导致了 Valgrind 的一个大缺点,那就是程序运行速度远低于正常速度,这使得它在测试有实时约束的应用时效果较差。
提示
顺便说一句,Valgrind 的名字常被误读:Valgrind 常见 FAQ 中表示 "grind" 部分的发音应该是短音 i,就像 "grinned"(与 "tinned" 押韵),而不是 "grind"(与 "find" 押韵)。FAQ、文档和下载可以访问 valgrind.org
。
Valgrind 包含多个诊断工具:
-
memcheck
:这是默认的工具,用于检测内存泄漏和一般的内存误用。 -
cachegrind
:此工具计算处理器缓存命中率。 -
callgrind
:此工具计算每个函数调用的开销。 -
helgrind
:此工具突出了 Pthread API 的误用,包括潜在的死锁和竞争条件。 -
DRD
:这是另一个 Pthread 分析工具。 -
massif
:此工具分析堆和栈的使用情况。
你可以通过 -tool
选项选择你想使用的工具。Valgrind 支持主要的嵌入式平台:Arm(Cortex-A)、PowerPC、MIPS 和 x86 的 32 位和 64 位版本。它在 Yocto 项目和 Buildroot 中都有提供作为软件包。
要找到我们的内存泄漏,我们需要使用默认的 memcheck
工具,并使用 -–leak-check=full
选项打印出发现泄漏的代码行:
$ valgrind --leak-check=full ./mtrace-example
==3384686== Memcheck, a memory error detector
==3384686== Copyright (C) 2002-2022, and GNU GPL'd, by Julian Seward et al.
==3384686== Using Valgrind-3.22.0 and LibVEX; rerun with -h for copyright info
==3384686== Command: ./mtrace-example
==3384686==
==3384686==
==3384686== HEAP SUMMARY:
==3384686== in use at exit: 456 bytes in 3 blocks
==3384686== total heap usage: 3 allocs, 0 frees, 456 bytes allocated
==3384686==
==3384686== 200 bytes in 2 blocks are definitely lost in loss record 1 of 2
==3384686== at 0x4846828: malloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==3384686== by 0x1091D3: main (mtrace-example.c:12)
==3384686==
==3384686== 256 bytes in 1 blocks are definitely lost in loss record 2 of 2
==3384686== at 0x484D953: calloc (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==3384686== by 0x1091EC: main (mtrace-example.c:14)
==3384686==
==3384686== LEAK SUMMARY:
==3384686== definitely lost: 456 bytes in 3 blocks
==3384686== indirectly lost: 0 bytes in 0 blocks
==3384686== possibly lost: 0 bytes in 0 blocks
==3384686== still reachable: 0 bytes in 0 blocks
==3384686== suppressed: 0 bytes in 0 blocks
==3384686==
==3384686== For lists of detected and suppressed errors, rerun with: -s
==3384686== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)
Valgrind 的输出显示在 mtrace-example.c
中发现了两个内存泄漏:第 12 行的 malloc
和第 14 行的 calloc
。但是,程序中缺少与这两个内存分配配对的 free
调用。如果不加以检查,长时间运行的进程中的内存泄漏最终可能导致系统内存耗尽。
内存耗尽
标准的内存分配策略是过度提交,这意味着内核允许应用程序分配比物理内存更多的内存。大多数情况下,这种做法是有效的,因为应用程序请求的内存量通常超过其实际需求。这也有助于 fork(2)
的实现:复制一个大型程序是安全的,因为内存页与写时复制标志共享。在大多数情况下,fork
之后会调用 exec
函数,这会解除共享内存并加载新程序。
然而,总是存在某些工作负载可能导致一组进程试图同时请求它们已经被承诺的内存分配,从而需求超过实际可用的内存量。这就是内存不足(OOM)的情况。此时,唯一的办法是终止一些进程,直到问题解决。这是内存不足杀手的工作。
在我们深入之前,/proc/sys/vm/overcommit_memory
中有一个内核分配的调优参数,你可以将其设置为以下值:
-
0
:启发式过度提交 -
1
:始终过度提交;绝不检查 -
2
:始终检查;绝不过度提交
选项 0
是默认设置,在大多数情况下是最佳选择。
选项 1
只在你运行的程序需要处理大型稀疏数组并分配大量内存但仅对其中一小部分进行写入时有用。在嵌入式系统的背景下,这种程序比较少见。
如果你担心内存耗尽,尤其是在任务或安全关键的应用中,选项 2
似乎是一个不错的选择。它将会失败大于提交限制的分配,而提交限制是交换空间大小加上总内存与过度提交比例的乘积。过度提交比例由 /proc/sys/vm/overcommit_ratio
控制,默认值为 50%。
举个例子,假设你有一台系统内存为 2 GB 的设备,并且你设置了一个非常保守的 25% 比例:
# echo 25 > /proc/sys/vm/overcommit_ratio
# grep -e MemTotal -e CommitLimit /proc/meminfo
MemTotal: 1996796 kB
CommitLimit: 499196 kB
没有交换空间,因此提交限制是 MemTotal
的 25%,这也是预期的结果。
/proc/meminfo
中还有另一个重要变量,叫做 Committed_AS
。它表示为了完成迄今为止所有分配所需的总内存。我在一个系统上找到了以下内容:
# grep -e MemTotal -e Committed_AS /proc/meminfo
MemTotal: 1996796 kB
Committed_AS: 2907335 kB
换句话说,内核已经承诺分配的内存超出了可用内存。因此,将 overcommit_memory
设置为 2
将意味着所有分配都会失败,无论 overcommit_ratio
设置为多少。为了让系统正常工作,我必须要么安装双倍的内存,要么大幅减少正在运行的进程数量,而当时大约有 40 个进程。
在所有情况下,最终的防线是 oom-killer
。它使用启发式方法计算每个进程的“坏度”得分,范围从 0 到 1000,然后终止得分最高的进程,直到释放出足够的空闲内存。你应该在内核日志中看到类似这样的内容:
[44510.490320] eatmem invoked oom-killer: gfp_mask=0x200da, order=0, oom_score_adj=0
你可以通过执行 echo f > /proc/sysrq-trigger
强制触发一个 OOM 事件。
你可以通过向 /proc/<PID>/oom_score_adj
写入调整值来影响进程的坏度得分。-1000
的值意味着坏度得分永远不会大于零,因此进程永远不会被杀死;+1000
的值意味着它将始终大于 1000,因此进程将始终被杀死。
总结
在虚拟内存系统中,完全记录每一个字节的内存使用情况几乎是不可能的。不过,你可以通过 free
命令获得一个相当准确的空闲内存总量的数字,排除缓冲区和缓存所占用的内存。通过在一段时间内和不同的工作负载下监控它,你应该可以确认它会保持在一个给定的限制范围内。
当你想要调整内存使用或识别意外分配的来源时,有一些资源可以提供更详细的信息。对于内核空间,最有用的信息在 /proc
目录下:meminfo
、slabinfo
和 vmallocinfo
。
在获取用户空间的准确测量时,最佳的度量标准是 PSS,如 smem
和其他工具所示。对于内存调试,你可以使用像 mtrace
这样的简单追踪工具,或者使用较为重型的 Valgrind memcheck
工具。
如果你对 OOM(内存不足)情况的后果有所担忧,你可以通过/proc/sys/vm/overcommit_memory
来微调分配机制,并且可以通过oom_score_adj
参数控制特定进程被终止的可能性。
下一章将详细介绍如何使用 GNU 调试器调试用户空间和内核代码,以及你通过观察代码运行时能够获得的洞见,其中包括我在这里描述的内存管理功能。
进一步学习
-
Linux 内核开发(第三版),作者:Robert Love
-
Linux 系统编程(第二版),作者:Robert Love
-
理解 Linux 虚拟内存管理,作者:Mel Gorman –
www.kernel.org/doc/gorman/pdf/understand.pdf
-
Valgrind 3.3:GNU/Linux 应用程序的高级调试与性能分析,作者:Julian Seward、Nicholas Nethercote 和 Josef Weidendorfer
第五部分
调试与性能优化
本部分最后介绍了如何有效利用 Linux 提供的众多调试和性能分析工具来检测问题和识别瓶颈。第十九章描述了如何为目标设备设置 GDB 进行远程调试。第二十章涵盖了 eBPF,这是一项新技术,能够在 Linux 内核中进行高级程序化追踪。最后一章将几条线索结合起来,解释了如何在实时应用程序中使用 Linux。
本部分包括以下章节:
-
第十九章,使用 GDB 进行调试
-
第二十章,性能分析与追踪
-
第二十一章,实时编程
第十九章:使用 GDB 调试
错误是不可避免的,识别并修复它们是开发过程的一部分。找到并表征程序缺陷有许多不同的技术,包括静态和动态分析、代码审查、跟踪、性能分析和交互式调试。我们将在下一章讨论跟踪器和性能分析器,但这里我想专注于通过调试器查看代码执行的传统方法,在我们的案例中是GNU 调试器(GDB)。GDB 是一个强大且灵活的工具。你可以用它来调试应用程序、检查程序崩溃后生成的后期文件(核心文件),甚至逐步调试内核代码。
本章将介绍以下内容:
-
GNU 调试器
-
准备调试
-
调试应用程序
-
即时调试
-
调试分支和线程
-
核心文件
-
GDB 用户界面
-
调试内核代码
技术要求
为了跟上示例的进度,请确保你已经准备好了以下内容:
-
一台运行 Ubuntu 24.04 或更高版本 LTS 的主机系统,且至少有 90 GB 的空闲磁盘空间
-
Buildroot 2024.02.6 LTS 版本
-
Yocto 5.0(Scarthgap)LTS 版本
-
一个 microSD 卡读卡器和卡
-
适用于 Linux 的 balenaEtcher
-
一根以太网电缆和一台具有可用端口的路由器,用于网络连接
-
一条带有 3.3 V 电平的 USB 转 TTL 串口电缆
-
一台 Raspberry Pi 4
-
一款能够提供 3 A 电流的 5 V USB-C 电源
你应该已经安装了 Buildroot 的 2024.02.6 LTS 版本,参考第六章。如果没有,请在按照第六章的说明在你的 Linux 主机上安装 Buildroot 之前,参考系统要求部分的Buildroot 用户手册(buildroot.org/downloads/manual/manual.html
)。
你应该已经在第六章中构建了 Yocto 的 5.0(Scarthgap)LTS 版本。如果还没有,请参考兼容的 Linux 发行版和构建主机包部分的Yocto 项目快速构建指南(docs.yoctoproject.org/brief-yoctoprojectqs/
),然后按照第六章中的说明在你的 Linux 主机上构建 Yocto。
本章使用的代码可以在本书 GitHub 仓库的章节文件夹中找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development/tree/main/Chapter19
。
GNU 调试器
GDB 是一个源级调试器,主要用于编译语言,如 C 和 C++,但也支持多种其他语言,如 Go 和 Objective-C。你应该阅读你所使用的 GDB 版本的说明,以了解对不同语言的支持情况。
项目网站是www.gnu.org/software/gdb/
,上面包含了很多有用的信息,包括 GDB 用户手册《调试与 GDB》。
默认情况下,GDB 有一个命令行用户界面,虽然有些人觉得它不太友好,但实际上只需一些练习就能轻松使用。如果你不喜欢命令行界面,有很多 GDB 的前端用户界面可以选择,我将在本章稍后描述其中的三个。
准备调试
你需要用调试符号编译你想调试的代码。GCC 为此提供了两个选项:-g
和-ggdb
。后者添加了专门针对 GDB 的调试信息,而前者生成适用于你所使用的目标操作系统的格式信息,使其更具可移植性。由于 GDB 是 Linux 上的默认调试器,因此最好使用-ggdb
。这两个选项都允许你指定调试信息的级别,从0
到3
:
-
0
:这不生成任何调试信息,等同于省略-g
或-ggdb
选项。 -
1
:这生成最少的信息,但包括函数名称和外部变量,足以生成回溯。 -
2
:这是默认设置,包含关于本地变量和行号的信息,以便你可以进行源代码级别的调试并逐步执行代码。 -
3
:这包括额外的信息,其中包括使 GDB 能够正确处理宏展开。
在大多数情况下,-g
就足够了:如果你在逐步执行代码时遇到问题,尤其是当代码包含宏时,可以保留-g3
或-ggdb3
。
下一步需要考虑的是代码优化的级别。编译器优化往往会破坏源代码行与机器代码之间的关系,这使得逐步调试变得不可预测。如果你遇到此类问题,很可能需要在不进行优化的情况下编译,省略-O
编译选项,或者使用-Og
,该选项启用不会干扰调试的优化。
一个相关的问题是栈帧指针,这是 GDB 生成函数调用回溯所必需的。在某些架构中,GCC 在更高级别的优化(-O2
及以上)下不会生成栈帧指针。如果你遇到必须使用-O2
编译但仍希望生成回溯的情况,你可以通过-fno-omit-frame-pointer
来覆盖默认行为。此外,注意一些经过手动优化的代码,它通过添加-fomit-frame-pointer
来省略栈帧指针:你可能需要暂时去除这些部分。
调试应用程序
你可以通过两种方式使用 GDB 调试应用程序:如果你正在开发运行在桌面和服务器上的代码,或者在任何你编译并运行代码的相同机器环境下,运行 GDB 本地化是很自然的。然而,大多数嵌入式开发是使用交叉工具链完成的,因此你希望调试在设备上运行的代码,但从交叉开发环境中控制它,在那里你有源代码和工具。我将重点讲解后者的情况,因为它是嵌入式开发者最常见的场景,但我也会展示如何设置一个本地调试的系统。我在这里不会描述 GDB 的基本使用方法,因为已经有很多很好的参考资料了,包括 GDB 用户手册和本章末尾建议的进一步学习部分。
使用 gdbserver 进行远程调试
远程调试的关键组件是调试代理 gdbserver,它运行在目标设备上并控制被调试程序的执行。gdbserver
通过网络连接或串口接口连接到主机上运行的 GDB 副本。
通过 gdbserver
进行调试几乎与本地调试相同,但并不完全一样。主要的区别集中在涉及两台计算机的事实上,它们必须处于正确的状态才能开始调试。以下是一些需要注意的事项:
-
在调试会话开始时,你需要通过
gdbserver
将你要调试的程序加载到目标设备上,然后从主机上的交叉工具链中单独加载 GDB。 -
GDB 和
gdbserver
需要在调试会话开始之前相互连接。 -
GDB 需要知道在主机上哪里可以找到调试符号和源代码,特别是对于共享库。
-
GDB
run
命令不被支持。 -
gdbserver
在调试会话结束时会终止,如果你想进行另一次调试会话,你需要重新启动它。 -
你需要调试符号和源代码来调试主机上的二进制文件,但不需要在目标设备上。这是因为目标设备上通常没有足够的存储空间存放这些符号和代码,它们需要在部署到目标设备之前被剥离。
-
GDB/
gdbserver
组合并不支持本地运行 GDB 的所有功能:例如,gdbserver
不能在fork
后跟踪子进程,而本地 GDB 可以。 -
如果 GDB 和
gdbserver
来自不同版本的 GDB,或者它们是相同版本但配置不同,可能会发生奇怪的情况。理想情况下,它们应该使用你喜欢的构建工具从相同的源代码构建。
调试符号会显著增加可执行文件的大小,有时会增加 10 倍。如 第五章 所述,移除调试符号而不重新编译所有内容是很有用的。用于此目的的工具是交叉工具链中 binutils
包中的 strip
。你可以通过这些开关来控制剥离级别:
-
--strip-all
:此选项会移除所有符号(默认设置)。 -
--strip-unneeded
:此选项会移除不需要用于重定位处理的符号。 -
--strip-debug
:此选项仅移除调试符号。重要说明
对于应用程序和共享库,
--strip-all
(默认设置)是可以的,但当涉及到内核模块时,你会发现它会导致模块无法加载。应该使用--strip-unneeded
。我仍在为--strip-debug
寻找一个用例。
牢记这一点,让我们来看一下使用 Yocto 项目和 Buildroot 进行调试时的具体细节。
设置 Yocto 项目进行远程调试
使用 Yocto 项目进行远程调试时需要做两件事:你需要将gdbserver
添加到目标镜像中,并且需要创建一个包含 GDB 并具有调试符号的 SDK,针对你打算调试的可执行文件。关于如何设置 Yocto 进行远程调试,有详细的文档可以参考docs.yoctoproject.org/dev-manual/debugging.html#using-the-gdbserver-method
。
首先,为了将gdbserver
包含到目标镜像中,你可以通过在conf/local.conf
中显式添加以下内容来实现:
IMAGE_INSTALL:append = " gdbserver"
如果没有串口控制台,还需要添加一个 SSH 守护进程,这样你就能通过某种方式在目标设备上启动gdbserver
:
EXTRA_IMAGE_FEATURES:append = " ssh-server-openssh"
或者,你可以将tools-debug
添加到EXTRA_IMAGE_FEATURES
中,这样就会将gdbserver
、原生gdb
和strace
添加到目标镜像中(我将在下一章中讲解strace
):
EXTRA_IMAGE_FEATURES:append = " tools-debug ssh-server-openssh"
然后重新构建目标镜像:
$ bitbake core-image-minimal-dev
对于第二部分,你只需要按照我在第六章中描述的方式构建一个 SDK:
$ bitbake -c populate_sdk core-image-minimal-dev
SDK 包含一个 GDB 副本,还包含目标的sysroot
,其中有所有程序和库的调试符号。最后,SDK 还包含可执行文件的源代码。你也可以直接使用 Yocto 构建中的 sysroot,而不是 SDK(docs.yoctoproject.org/sdk-manual/extensible.html#when-using-the-extensible-sdk-directly-in-a-yocto-build
)。
为版本 5.0./opt/poky/5.0.<n>/
中。目标的sysroot
位于/opt/poky/5.0.<n>/sysroots/cortexa72-poky-linux/
。程序位于/bin/
、/sbin/
、/usr/bin/
和/usr/sbin/
中,这些路径相对于sysroot
,而库文件则位于/lib/
和/usr/lib/
中。在这些目录中,你会找到一个名为.debug/
的子目录,里面包含每个程序和库的符号。GDB 会在.debug/
中查找符号信息。可执行文件的源代码存储在/usr/src/debug/
,相对于sysroot
。
设置 Buildroot 进行远程调试
Buildroot 不区分构建环境和应用程序开发环境:没有 SDK。假设您正在使用 Buildroot 内部工具链,您需要启用这些选项以将 GDB 复制到主机并将 gdbserver
复制到目标设备:
-
BR2_TOOLCHAIN_EXTERNAL
,在 工具链 | 工具链类型 | 外部工具链 -
BR2_TOOLCHAIN_EXTERNAL_GDB_SERVER_COPY
,在 工具链 | 将 gdb server 复制到目标设备 -
BR2_PACKAGE_GDB
,在 目标软件包 | 调试、性能分析和基准测试 | gdb
需要外部工具链,因为 Buildroot 2024.02.6 构建的工具链无法编译 gdbserver
。
您还需要构建带有调试符号的可执行文件,这需要在 构建选项 | 构建带调试符号的软件包 中启用 BR2_ENABLE_DEBUG
。
这将会在 output/host/<arch>/sysroot
中创建带有调试符号的库。
开始调试
现在您已经在目标设备上安装了 gdbserver
,并且在主机上有一个交叉编译的 GDB,您可以开始调试会话了。
连接 GDB 和 gdbserver
GDB 和 gdbserver
之间的连接可以通过网络或串行接口。在网络连接的情况下,您启动 gdbserver
时需要指定 TCP 端口号来监听,或者可选地指定一个 IP 地址来接受连接。大多数情况下,您不关心哪个 IP 地址将进行连接,因此只需提供端口号即可。在这个例子中,gdbserver
等待来自任何主机的 10000
端口的连接:
# gdbserver :10000 /usr/bin/helloworld
Process /usr/bin/helloworld created; pid = 581
Listening on port 10000
接下来,启动从您的工具链复制 GDB,指向一个未剥离的程序副本,以便 GDB 可以加载符号表:
$ aarch64-poky-linux-gdb helloworld
在 GDB 中,使用 target remote 命令连接到 gdbserver
,并提供目标设备的 IP 地址或主机名以及它等待的端口号:
(gdb) target remote raspberrypi4-64:10000
当 gdbserver
检测到来自主机的连接时,它会打印以下信息:
Remote debugging from host 192.168.1.123, port 50696
对于串行连接,过程类似。在目标设备上,您告诉 gdbserver
使用哪个串行端口:
# gdbserver /dev/ttyAMA0 /usr/bin/helloworld
您可能需要事先使用 stty(1)
或类似程序配置端口的波特率。一个简单的例子如下:
# stty -F /dev/ttyAMA0 115200
stty
有许多其他选项,详情请阅读手册页。值得注意的是,端口不能被用于其他任何用途。例如,您不能使用正在作为系统控制台的端口。
在主机上,您可以使用 target remote
命令加上连接线另一端的串口设备来连接 gdbserver
。在大多数情况下,您需要先设置主机串口的波特率,使用 GDB 命令 set serial baud
:
(gdb) set serial baud 115200
(gdb) target remote /dev/ttyUSB0
即使 GDB 和 gdbserver
已经连接,我们仍然没有准备好设置断点并开始逐步调试源代码。
设置 sysroot
GDB 需要知道在哪里找到你正在调试的程序和共享库的调试信息和源代码。在本地调试时,路径是已知的,并且内建在 GDB 中。但在使用交叉工具链时,GDB 无法猜测目标文件系统的根目录在哪里。你必须提供这些信息。
如果你使用 Yocto 项目 SDK 构建了你的应用程序,sysroot
就在 SDK 内,因此你可以在 GDB 中这样设置它:
(gdb) set sysroot /opt/poky/5.0.<n>/sysroots/cortexa72-poky-linux
如果你使用 Buildroot,你会发现 sysroot
在 output/host/<toolchain>/sysroot
中,而 output/staging
是它的一个符号链接。所以,对于 Buildroot,你可以这样设置 sysroot
:
(gdb) set sysroot /home/frank/buildroot/output/staging
GDB 还需要找到你正在调试的文件的源代码。GDB 有一个源代码搜索路径,你可以通过 show directories
命令查看:
(gdb) show directories
Source directories searched: $cdir:$cwd
这是默认值:$cwd
是在主机上运行的 GDB 实例的当前工作目录;$cdir
是源代码被编译的目录。后者通过标签 DW_AT_comp_dir
被编码到目标文件中。你可以使用 objdump --dwarf
查看这些标签,方法如下:
$ aarch64-poky-linux-objdump --dwarf helloworld | grep DW_AT_comp_dir
<…>
<23a> DW_AT_comp_dir : (indirect line string, offset: 0xfc): /home/frank/helloworld
<…>
在大多数情况下,默认值 $cdir
和 $cwd
是足够的,但如果目录在编译和调试之间发生了移动,就会出现问题。一个这样的情况发生在 Yocto 项目中。深入查看一个使用 Yocto 项目 SDK 编译的程序的 DW_AT_comp_dir
标签,你可能会注意到这一点:
$ aarch64-poky-linux-objdump --dwarf helloworld | grep DW_AT_comp_dir
<1e> DW_AT_comp_dir : (indirect string, offset: 0x1b): /usr/src/debug/glibc/2.39+git/csu
<4f> DW_AT_comp_dir : (indirect line string, offset: 0): /usr/src/debug/glibc/2.39+git/csu
<1c5> DW_AT_comp_dir : (indirect line string, offset: 0): /usr/src/debug/glibc/2.39+git/csu
<209> DW_AT_comp_dir : (indirect string, offset: 0x1b): /usr/src/debug/glibc/2.39+git/csu
<23a> DW_AT_comp_dir : (indirect line string, offset: 0xfc): /usr/src/debug/helloworld/1.0
<3e0> DW_AT_comp_dir : (indirect string, offset: 0x1b): /usr/src/debug/glibc/2.39+git/csu
<…>
在这里,你可以看到多次提到目录/usr/src/debug/glibc/2.39+git
,但它在哪里呢?答案是它在 SDK 的 sysroot
中,所以完整路径是/opt/poky/5.0.6/sysroots/cortexa72-poky-linux/usr/src/debug/glibc/2.39+git
。SDK 包含了目标镜像中所有程序和库的源代码。GDB 有一个简单的方式来应对整个目录树被移动的情况:substitute-path
。因此,在使用 Yocto 项目 SDK 进行调试时,你需要使用这些命令:
(gdb) set sysroot /opt/poky/5.0.<n>/sysroots/cortexa72-poky-linux
(gdb) set substitute-path /usr/src/debug /opt/poky/5.0.<n>/sysroots/cortexa72-poky-linux/usr/src/debug
你可能有其他共享库存储在 sysroot
外部。在这种情况下,你可以使用 set solib-search-path
,它可以包含一个以冒号分隔的目录列表,用来搜索共享库。GDB 只有在无法在 sysroot
中找到二进制文件时,才会搜索 solib-search-path
。
告诉 GDB 寻找源代码的第三种方式,无论是库还是程序,是使用 directory
命令:
(gdb) directory /home/frank//lib_mylib
Source directories searched: /home/frank//lib_mylib:$cdir:$cwd
以这种方式添加的路径优先级更高,因为它们在 sysroot
或 solib-search-path
之前被搜索。
GDB 命令文件
每次运行 GDB 时,有一些操作是必须进行的,比如设置 sysroot
。将这些命令放入命令文件中,并在每次启动 GDB 时运行它们非常方便。GDB 会从 $HOME/.gdbinit
读取命令,然后从当前目录中的 .gdbinit
文件读取,再从通过 -x
参数指定的文件中读取命令。然而,GDB 的最新版本出于安全原因会拒绝加载当前目录中的 .gdbinit
文件。你可以通过在 $HOME/.gdbinit
中添加如下行来覆盖这种行为:
set auto-load safe-path /
或者,如果你不想全局启用自动加载,可以像这样指定一个特定目录:
add-auto-load-safe-path /home/frank/myprog
我个人的偏好是使用 -x
参数指向命令文件,这样可以暴露文件的位置,避免忘记它。
为了帮助你设置 GDB,Buildroot 创建了一个包含正确 sysroot
命令的 GDB 命令文件,该文件位于 output/staging/usr/share/buildroot/gdbinit
。该文件包含如下行:
set sysroot /home/frank/buildroot/output/host/aarch64-buildroot-linux-gnu/sysroot
现在 GDB 正在运行并能找到所需的信息,让我们看看可以执行的一些命令。
GDB 命令概览
GDB 还有许多其他命令,具体描述请参考在线手册和进一步学习部分提到的资源。为了帮助你尽快上手,下面列出了最常用的命令。在大多数情况下,命令有简短的形式,具体列在以下表格中。
断点
以下是管理断点的命令:
命令 | 简短命令 | 用途 |
---|
|
break <location>
|
b <location>
在函数名、行号或行上设置断点。位置示例有 main 、5 和 sortbug.c:42 。 |
---|
|
info breakpoints
|
i b
列出断点。 |
---|
|
delete breakpoint <N>
|
d b <N>
删除断点 <N> 。 |
---|
运行与单步执行
以下是控制程序执行的命令:
命令 | 简短命令 | 用途 |
---|
|
run
|
r
将程序的一个新副本加载到内存并开始运行。这对使用 gdbserver 进行远程调试无效。 |
---|
|
continue
|
c
从断点继续执行。 |
---|
Ctrl + C |
|
step
|
s
单步执行一行代码,进入任何被调用的函数。 |
---|
|
next
|
n
单步执行一行代码,跳过函数调用。 |
---|
|
finish
- | 运行直到当前函数返回。 |
---|
获取信息
这些是获取调试器信息的命令:
命令 | 简短命令 | 用途 |
---|
|
backtrace
|
bt
列出调用栈。 |
---|
|
info threads
|
i th
显示程序中当前执行的线程信息。 |
---|
|
info sharedlibrary
|
i share
显示当前程序加载的共享库信息。 |
---|
|
print <variable>
|
p <variable>
打印变量的值。例如,print foo 。 |
---|
|
list
|
l
列出当前程序计数器周围的代码行。 |
---|
在我们开始单步调试程序之前,首先需要设置一个初始断点。
运行到断点
gdbserver
将程序加载到内存中,并在第一条指令处设置断点。然后它会等待 GDB 的连接。当连接建立时,你将进入调试会话。然而,你会发现如果你立即尝试单步执行,你会看到以下信息:
Cannot find bounds of current function
这是因为程序在汇编代码中暂停,该代码为 C/C++程序创建运行时环境。C/C++代码的第一行是main()
函数。要在main()
处停下,你需要在该处设置断点,然后使用continue
命令(缩写c
)告诉gdbserver
从程序开始处继续并停在main()
处:
(gdb) break main
Breakpoint 1, main (argc=1, argv=0xbefffe24) at helloworld.c:8 printf("Hello, world!\n");
(gdb) c
此时,你可能会看到以下内容:
Reading /lib/ld-linux.so.3 from remote target...
warning: File transfers from remote targets can be slow. Use "set sysroot" to access files locally instead.
在 GDB 的旧版本中,你可能会看到以下信息:
warning: Could not load shared library symbols for 2 libraries, e.g. /lib/libc.so.6.
在这两种情况下,问题是你忘记设置sysroot
!请再看看前面关于sysroot
的部分。
这与本地启动程序非常不同,在本地你只需输入run
。事实上,如果你在远程调试会话中尝试输入run
,你将看到一条消息,提示远程目标不支持run
命令,或者在 GDB 的旧版本中,它会挂起而没有任何解释。
使用 Python 扩展 GDB
我们可以将一个完整的 Python 解释器嵌入到 GDB 中,以扩展其功能。这是通过在构建前使用--with-python
选项配置 GDB 来实现的。GDB 有一个 API,将其内部状态暴露为 Python 对象。这个 API 允许我们定义自己的自定义 GDB 命令,作为 Python 脚本编写。这些额外的命令可能包括一些有用的调试辅助功能,比如跟踪点和漂亮的打印器,这些功能并没有内建在 GDB 中。
构建带有 Python 支持的 GDB
我们已经讲解了为远程调试设置 Buildroot。为了在 GDB 中启用 Python 支持,还需要一些额外步骤。我们不能使用 Buildroot 生成的工具链来构建具有 Python 支持的 GDB,因为它缺少一些必要的线程支持。
要构建支持 Python 的交叉 GDB,请执行以下步骤:
-
进入你安装 Buildroot 的目录:
$ cd buildroot
-
复制你希望为其构建镜像的板子的配置文件:
$ cd configs $ cp raspberrypi4_64_defconfig rpi4_64_gdb_defconfig $ cd ..
-
清除
output
目录中的先前构建的产物:$ make clean
-
激活你的配置文件:
$ make rpi4_64_gdb_defconfig
-
开始自定义你的镜像:
$ make menuconfig
-
通过进入Toolchain | Toolchain type | External toolchain并选择该选项来启用外部工具链的使用。
-
退出外部工具链,然后打开工具链子菜单。选择一个已知有效的工具链,比如Linaro AArch64 2018.05,作为你的外部工具链。
-
从工具链页面选择为主机构建交叉 GDB,并启用TUI 支持和Python 支持。
-
从工具链页面进入GDB 调试器版本子菜单,选择 Buildroot 中可用的最新版本 GDB。
-
返回 工具链 页面并进入 构建选项。选择 构建包含调试符号的包。
-
返回 构建选项 页面,进入 系统配置,并选择 启用密码登录为 root 用户。打开 Root 密码 并在文本框中输入一个非空密码。
-
返回 系统配置 页面,进入 目标包 | 调试、性能分析和基准测试。选择 gdb 包以将
gdbserver
添加到目标镜像中。 -
返回 调试、性能分析和基准测试,进入 目标包 | 网络应用。选择 dropbear 包以启用
scp
和ssh
访问目标。请注意,dropbear
不允许没有密码的root
用户通过scp
和ssh
进行访问。 -
添加 haveged 熵守护进程,该进程可以在 目标包 | 杂项 下找到,以便在启动时更快地提供 SSH 服务。
-
向镜像中添加另一个包,以便有东西可以调试。我选择了
bsdiff
二进制补丁/差异工具,它是用 C 编写的,可以在 目标包 | 开发工具 下找到。 -
保存更改并退出 Buildroot 的
menuconfig
。 -
保存你对配置文件所做的更改:
$ make savedefconfig
-
为目标设备构建镜像:
$ make
如果你想跳过之前的 menuconfig
步骤,可以在本章的代码归档中找到 Raspberry Pi 4 的现成 rpi4_64_gdb_defconfig
文件。将该文件从 MELD/Chapter19/buildroot/configs/
复制到你的 buildroot/configs
目录,并在其上运行 make
。
当构建完成时,应该会在 output/images/
目录下生成一个可启动的 sdcard.img
文件,你可以使用 Etcher 将其写入 microSD 卡。将该 microSD 卡插入目标设备并启动它。通过以太网线将目标设备连接到本地网络,并使用 arp-scan --localnet
查找其 IP 地址。通过 SSH 以 root
身份登录设备,并输入配置镜像时设置的密码。我为我的 rpi4_64_gdb_defconfig
镜像指定了 temppwd
作为 root
密码。
现在,让我们使用 GDB 远程调试 bsdiff
:
-
首先,导航到目标设备上的
/usr/bin
目录:# cd /usr/bin
-
然后,像之前调试
helloworld
一样,使用gdbserver
启动bdiff
:# gdbserver :10000 ./bsdiff /usr/bin/bzless /usr/bin/bzmore ~/patchfile Process ./bsdiff created; pid = 197 Listening on port 10000
-
在你的 Linux 主机上,将
tp.py
复制到主目录:$ cd ~ $ cp MELD/Chapter19/tp.py .
-
接下来,启动工具链中的 GDB,并指向未剥离的程序副本,以便 GDB 可以加载符号表:
$ cd ~/buildroot/output/build/bsdiff-4.3 $ ~/buildroot/output/host/bin/aarch64-linux-gdb bsdiff
-
在 GDB 中,像这样设置
sysroot
:(gdb) set sysroot ~/buildroot/output/staging
-
然后,使用命令
target remote
连接到gdbserver
,并提供目标的 IP 地址或主机名及其监听的端口:(gdb) target remote 192.168.1.127:10000
-
当
gdbserver
看到来自主机的连接时,它会打印以下信息:Remote debugging from host 192.168.1.123, port 36980
-
现在,我们可以将 Python 脚本如
tp.py
加载到 GDB 中,并像这样调用这些命令:(gdb) source ~/tp.py (gdb) tp search Breakpoint 1 at 0x555b7b9154: file /home/frank/buildroot/output/build/bsdiff-4.3/bsdiff.c, line 170.
在这种情况下,tp
是 tracepoint 命令的名称,search
是 bsdiff
中一个递归函数的名称。
-
在
main()
处设置断点:(gdb) break main Breakpoint 2 at 0x555b7b8e50: file /home/frank/buildroot/output/build/bsdiff-4.3/bsdiff.c, line 216.
-
继续:
(gdb) c Continuing. Breakpoint 2, main (argc=4, argv=0x7ff2c99ec8) at /home/frank/buildroot/output/build/bsdiff-4.3/bsdiff.c:216 216 if(argc!=4) errx(1,"usage: %s oldfile newfile patchfile\n",argv[0]);
-
继续:
(gdb) c Continuing. search @ /home/frank/buildroot/output/build/bsdiff-4.3/bsdiff.c:170 x(off_t: <optimized out>) [8] y(off_t: <optimized out>) [8] <…> search @ /home/frank/buildroot/output/build/bsdiff-4.3/bsdiff.c:170 x(off_t: <optimized out>) [8] y(off_t: <optimized out>) [8] [Inferior 1 (process 251) exited normally] Tracepoint 'search' Count: 10 (gdb)
bsdiff
程序执行二进制差异比较,接受三个参数:oldfile
,newfile
和patchfile
。bsdiff
生成的patchfile
作为输入传递给bspatch
程序,用于修补二进制文件。我们在目标平台上启动bsdiff
程序,参数为/usr/bin/bzless
,/usr/bin/bzmore
和~/patchfile
。来自 GDB 追踪点命令的输出表明,bsdiff.c
中第 170 行的search
函数在整个过程中被调用了 10 次。
GDB 中的 Python 支持也可以用来调试 Python 程序。GDB 可以查看 CPython 的内部实现,而标准的 Python 调试器pdb
无法做到这一点。它甚至可以向正在运行的 Python 进程中注入 Python 代码。这使得创建像内存分析工具这样的强大 Python 调试工具成为可能,这在其他情况下是无法实现的。
本地调试
在目标平台上运行本地版的 GDB 并不像远程调试那样常见,但也是可行的。除了将 GDB 安装到目标镜像中,你还需要安装你希望调试的可执行文件的未去除符号版本以及它们对应的源代码。Yocto 项目和 Buildroot 都允许你这么做。
重要提示
虽然本地调试并不是嵌入式开发人员常做的活动,但在目标平台上运行性能分析和追踪工具却是非常常见的。这些工具通常在目标平台上具有未去除符号的二进制文件和源代码时效果最佳,这正是我在这里所要讲的内容的一部分。我将在下一章中回到这个话题。
Yocto 项目
首先,通过将以下内容添加到conf/local.conf
中,将gdb
添加到目标镜像:
EXTRA_IMAGE_FEATURES:append = "tools-debug dbg-pkgs src-pkgs"
你需要调试的包的调试信息。Yocto 项目会构建包含调试信息和从二进制文件中去除的符号的包的调试版本。你可以通过将<package name>-dbg
添加到conf/local.conf
,选择性地将这些调试包添加到目标镜像中。或者,像之前所示,你可以通过将dbg-pkgs
添加到EXTRA_IMAGE_FEATURES
来一次性安装所有调试包。需要注意的是,这会大幅增加目标镜像的大小,可能增加几百兆字节。
类似地,你可以通过将<package name>-src
添加到conf/local.conf
中,将源代码包添加到目标镜像中。或者,你也可以通过将src-pkgs
添加到EXTRA_IMAGE_FEATURES
来安装所有源代码包。再次强调,这将显著增加目标镜像的大小。源代码将被安装到目标镜像中的/usr/src/debug/<package name>
目录下。这意味着 GDB 将能够自动找到它,而无需运行set substitute-path
。
Buildroot
使用 Buildroot,你可以通过启用这个选项,告诉它将本地版 GDB 安装到目标镜像中:
- 目标包 | 调试、性能分析和基准测试 | 完整调试器中的
BR2_PACKAGE_GDB_DEBUGGER
然后,要构建带有调试信息的二进制文件并将其安装到目标镜像中而不去除符号,请启用这两个选项中的第一个并禁用第二个:
-
构建选项中的
BR2_ENABLE_DEBUG
| 带调试符号的构建包 -
构建选项中的
BR2_STRIP_strip
| 剥离目标二进制文件
这就是我关于本地调试的所有内容。再次强调,这种做法在嵌入式设备上并不常见,因为额外的源代码和调试符号会增加目标镜像的体积。接下来,我们来看看另一种形式的远程调试。
即时调试
有时,程序在运行一段时间后开始表现异常,这时你可能想知道它在做什么。GDB 的attach
功能正是用来实现这一点的。我称之为即时调试。它适用于本地和远程调试会话。
在远程调试的情况下,你需要找到要调试的进程的 PID,并通过--attach
选项将其传递给gdbserver
。例如,如果 PID 是 109,你可以输入:
# gdbserver --attach :10000 109
Attached; pid = 109
Listening on port 10000
这会强制进程停止,就像它处于断点一样,让你以正常的方式启动交叉 GDB 并连接到gdbserver
。完成后,你可以分离调试器,让程序在没有调试器的情况下继续运行:
(gdb) detach
Detaching from program: /home/frank/helloworld, process 109
Ending remote debugging.
按 PID 附加到正在运行的进程无疑是很方便的,但多进程或多线程程序怎么办?也有使用 GDB 调试这类程序的技术。
调试 fork 和线程
当你调试的程序创建子进程时会发生什么?调试会话是跟随父进程还是子进程?这个行为由follow-fork-mode
控制,可能的值是parent
或child
,默认值是parent
。不幸的是,当前版本的gdbserver
不支持此选项,因此它仅适用于本地调试。如果你在使用gdbserver
时确实需要调试子进程,一个解决方法是修改代码,使得子进程在fork
后立即在一个变量上进行循环,从而给你机会附加一个新的gdbserver
会话到它,并设置该变量以使其退出循环。
当多线程进程中的一个线程命中断点时,默认行为是所有线程都停止。在大多数情况下,这是最好的做法,因为它允许你查看静态变量,而不会被其他线程更改。当你恢复线程的执行时,所有停止的线程都会重新启动,即使你在逐步调试,尤其是这种情况可能会导致问题。可以通过一个名为scheduler-locking
的参数来修改 GDB 处理停止线程的方式。通常它是off
,但如果将其设置为on
,只有在断点停下的线程会恢复,其他线程保持停止状态,这样你就可以看到该线程独立的行为,不受干扰。直到你将scheduler-locking
关闭之前,这种状态会持续下去。gdbserver
支持这个特性。
核心文件
核心文件捕获了在程序终止时程序失败的状态。你甚至不需要在调试器旁边,当错误表现出来时就能抓取到核心文件。所以,当你看到Segmentation fault (core dumped)
时,不要只是耸耸肩;要调查核心文件,并从中提取宝贵的信息。
第一个观察结果是,核心文件默认是不会创建的,只有当进程的核心文件资源限制不为零时才会创建。你可以使用ulimit -c
命令更改当前 Shell 的设置。要移除对核心文件大小的所有限制,请输入以下命令:
$ ulimit -c unlimited
默认情况下,核心文件被命名为core
,并保存在进程的当前工作目录中,这个目录是由/proc/<PID>/cwd
指向的。这个方案有不少问题。首先,当查看一个设备上有多个名为core
的文件时,无法明显知道每个文件是由哪个程序生成的。其次,进程的当前工作目录可能是在一个只读文件系统中,可能没有足够的空间来存储核心文件,或者进程可能没有写入当前工作目录的权限。
有两个文件控制核心文件的命名和存放位置。第一个是/proc/sys/kernel/core_uses_pid
。将1
写入该文件会导致死掉的进程的 PID 编号被附加到文件名中,这在你能从日志文件中将 PID 编号与程序名称关联时,还是相当有用的。
更有用的是/proc/sys/kernel/core_pattern
,它可以让你对核心文件有更多的控制。默认的模式是 core,但你可以将其更改为由这些元字符组成的模式:
-
%p
:PID -
%u
:被转储进程的实际 UID -
%g
:被转储进程的实际 GID -
%s
:导致转储的信号编号 -
%t
:转储的时间,以自纪元起的秒数表示,1970-01-01 00:00:00 +0000 (UTC) -
%h
:主机名 -
%e
:可执行文件名 -
%E
:可执行文件的路径名,其中的斜杠(/)被感叹号(!)替换 -
%c
:被转储进程的核心文件大小软资源限制
你还可以使用以绝对目录名称开头的模式,将所有核心文件集中在一个地方。例如,以下模式将所有核心文件放入/corefiles
目录,并以程序名和崩溃时间命名:
# echo /corefiles/core.%e.%t > /proc/sys/kernel/core_pattern
在核心转储之后,你会看到类似这样的内容:
# ls /corefiles
core.sort-debug.1431425613
有关更多信息,请参考core(5)
手册页。
使用 GDB 查看核心文件
这是一个查看核心文件的 GDB 会话示例:
$ arm-poky-linux-gnueabi-gdb sort-debug /home/chris/rootfs/corefiles/core.sort-debug.1431425613
<…>
Core was generated by './sort-debug'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x000085c8 in addtree (p=0x0, w=0xbeac4c60 "the") at sort-debug.c:41
41 p->word = strdup (w);
这表明程序在第 41 行停止。list
命令显示了附近的代码:
(gdb) list
37 static struct tnode *addtree (struct tnode *p, char *w)
38 {
39 int cond;
40
41 p->word = strdup (w);
42 p->count = 1;
43 p->left = NULL;
44 p->right = NULL;
45
backtrace
命令(简写为bt
)显示我们是如何到达此处的:
(gdb) bt
#0 0x000085c8 in addtree (p=0x0, w=0xbeac4c60 "the") at sort-debug.c:41
#1 0x00008798 in main (argc=1, argv=0xbeac4e24) at sort-debug.c:89
这是一个明显的错误:addtree()
被传入了一个空指针。
GDB 最初是一个命令行调试器,许多人至今仍然以这种方式使用它。尽管 LLVM 项目的 LLDB 调试器正在日益流行,但 GCC 和 GDB 仍然是 Linux 上主要的编译器和调试器。到目前为止,我们一直专注于 GDB 的命令行界面。现在我们将看看一些 GDB 的前端界面,它们逐渐拥有更现代的用户界面。
GDB 用户界面
GDB 通过 GDB 机器接口(GDB/MI)在低级别进行控制,GDB/MI 可用于将 GDB 包装在用户界面中或作为更大程序的一部分,并大大扩展了可用选项的范围。
在本节中,我将介绍三种非常适合调试嵌入式目标的工具:终端用户界面(TUI)、数据展示调试器(DDD)和Visual Studio Code。
终端用户界面
终端用户界面(TUI)是标准 GDB 包中的一个可选部分。其主要特点是一个代码窗口,显示即将执行的代码行以及所有断点。这比命令行模式下的list
命令有了明显的改进。
TUI 的吸引力在于它无需额外设置就能正常工作,并且由于它是文本模式,因此可以在 SSH 终端会话中使用,例如,在目标上本地运行gdb
时。大多数交叉工具链都配置了带有 TUI 的 GDB。只需在命令行中添加-tui
,你将看到如下界面:
图 19.1 – TUI
如果你仍然觉得 TUI 不够完善,并且更倾向于使用真正的图形前端来操作 GDB,GNU 项目也提供了这样的一个工具(www.gnu.org/software/ddd
)。
数据展示调试器
数据展示调试器(DDD)是一个简单的独立程序,它为 GDB 提供了一个图形用户界面,且操作简便,尽管 UI 控件看起来有些过时,但它完成了所有必要的功能。
--debugger
选项告诉 DDD 使用你工具链中的 GDB,且你可以使用-x
参数指定 GDB 命令文件的路径:
$ ddd --debugger aarch64-poky-linux-gdb -x gdbinit sort-debug
以下截图展示了其中一个最棒的功能:数据窗口,里面有一个网格,你可以随意调整其中的项目。如果双击指针,它会展开为一个新的数据项,并且链接会显示带箭头的形式:
图 19.2 – DDD
如果这两个 GDB 前端都不可接受,因为你是一个习惯使用行业内最新工具的全栈 Web 开发人员,那么我们依然有解决方案。
Visual Studio Code
Visual Studio Code是微软推出的一个非常流行的开源代码编辑器。由于它是一个用 TypeScript 编写的 Electron 应用,Visual Studio Code 比像 Eclipse 这样的完整 IDE 更轻量和响应迅速。通过社区贡献的扩展,它支持多种语言的丰富功能(代码补全、跳转到定义等)。还可以通过扩展将远程 GDB 调试集成到 Visual Studio Code 中,支持 C/C++。
图 19.3 – Visual Studio Code
没有规定的工作流程来将 Visual Studio Code 与 Buildroot 或 Yocto 集成。要启用远程 GDB 调试,你需要编辑项目文件,如settings.json
和launch.json
。这些项目文件指向工具链和sysroot
,并包含目标 IP 地址和 SSH 登录凭证。
调试内核代码
你可以使用kgdb
进行源级调试,方式类似于使用gdbserver
进行远程调试。还有一个自托管的内核调试器kdb
,它适用于一些轻量级任务,比如查看指令是否被执行并获取回溯以找出程序如何到达当前状态。最后,还有内核的Oops信息和恐慌信息,它们能够告诉你内核异常的原因。
使用 kgdb 调试内核代码
当使用源代码调试器查看内核代码时,必须记住,内核是一个复杂的系统,具有实时行为。不要指望调试像应用程序那样简单。逐步调试可能会因为修改内存映射或切换上下文而产生奇怪的结果。
kgdb是指内核 GDB 存根,已经是主线 Linux 的一部分多年了。内核的 DocBook 中有用户手册,你可以在kernel.org/doc/html/v6.6/dev-tools/kgdb.html
找到在线版本。
在大多数情况下,你将通过串行接口连接到kgdb
,该接口通常与串行控制台共享。因此,这种实现被称为kgdboc,即通过控制台的 kgdb。为了正常工作,它需要一个支持 I/O 轮询而非中断的tty
驱动程序,因为kgdb
在与 GDB 通信时必须禁用中断。一些平台支持通过 USB 进行kgdb
,也有通过以太网工作的版本,但不幸的是,这些版本没有进入主流 Linux 内核。
与优化和堆栈帧相关的相同注意事项也适用于内核,唯一的限制是内核被写成假定优化级别至少为-O1
。你可以通过在运行make
之前设置KCFLAGS
来覆盖内核的编译标志。
这些就是你需要进行内核调试的内核配置选项:
-
CONFIG_DEBUG_INFO
位于内核调试 | 编译时检查和编译器选项 | 调试信息 | 依赖工具链的默认 DWARF 版本菜单中。 -
CONFIG_FRAME_POINTER
可能是你架构的一个选项,位于内核调试 | 编译时检查和编译器选项 | 使用帧指针编译内核菜单中。 -
CONFIG_KGDB
位于内核调试 | 通用内核调试工具 | KGDB:内核调试器菜单中。 -
CONFIG_KGDB_SERIAL_CONSOLE
位于内核调试 | 通用内核调试工具 | KGDB:内核调试器 | KGDB:通过串行控制台使用 kgdb菜单中。
内核镜像必须是 ELF 对象格式,这样 GDB 才能将符号加载到内存中。这个文件就是在 Linux 构建目录中生成的vmlinux
。在 Yocto 中,你可以要求将它的副本包含到目标镜像和 SDK 中。它作为名为kernel-vmlinux
的包构建,你可以像安装其他软件包一样安装它,例如,通过将其添加到IMAGE_INSTALL
列表中。
文件被放入sysroot
引导目录,文件名大致如下:
/opt/poky/5.0.<n>/sysroots/cortexa72-poky-linux/boot/vmlinux-<version string>-v8
在 Buildroot 中,你会在内核构建的目录下找到vmlinux
,路径为output/build/linux-<version string>/vmlinux
。
你需要告诉kgdb
使用哪个串行端口,可以通过内核命令行或通过sysfs
在运行时指定。对于第一个选项,将kgdboc=<tty>,<baud rate>
添加到内核命令行:
kgdboc=ttyS0,115200
一个调试会话示例
展示kgdb
如何工作的最佳方式是通过一个简单的示例。但在你可以使用kgdb
之前,首先需要构建它。要构建kgdb
和kdb
,请完成构建带有 Python 支持的 GDB,然后执行以下步骤:
-
导航到你安装 Buildroot 的目录:
$ cd buildroot
-
从输出目录清理之前的构建产物:
$ make clean
-
在
board/raspberrypi4-64/config_4_64bit.txt
中注释掉miniuart-bt
的dtoverlay
:#dtoverlay=miniuart-bt
-
将以下行追加到
board/raspberrypi4-64/config_4_64bit.txt
:enable_uart=1 dtoverlay=uart
-
激活你保存的配置文件:
$ make rpi4_64_gdb_defconfig
-
构建你保存的配置:
$ make
-
开始定制你的内核:
$ make linux-menuconfig
-
通过导航到Kernel hacking | 编译时检查和编译器选项 | 调试信息并选择依赖工具链的默认 DWARF 版本来启用内核调试。
-
取消选择减少调试信息。
-
退出调试信息,然后选择用帧指针编译内核菜单。
-
退出编译时检查和编译器选项,并深入到通用内核调试工具 | KGDB: 内核调试器。
-
选择KGDB: 使用串行控制台上的 kgdb和KGDB_KDB: 包含 kgdb 的 kdb 前端。
-
退出内核黑客,并深入到内核功能。
-
取消选择随机化内核镜像地址。
-
保存更改并退出
linux-menuconfig
。 -
重新构建内核:
$ make linux-rebuild
-
删除 microSD 卡映像:
$ rm output/images/sdcard.img
-
重新生成 microSD 卡映像:
$ make
构建完成后,应该会在output/images/
目录下生成一个可引导的sdcard.img
文件,你可以使用 Etcher 将其写入 microSD 卡。将 microSD 卡插入目标设备中。将 USB 到 TTL 串行电缆连接到 Raspberry Pi 4(learn.adafruit.com/adafruits-raspberry-pi-lesson-5-using-a-console-cable/connect-the-lead
)。启动设备。
使用以太网电缆将目标设备连接到本地网络,使用arp-scan --localnet
查找其 IP 地址。以root
身份 SSH 登录设备,输入在配置映像时设置的密码。我为我的rpi4_64_gdb_defconfig
映像指定了temppwd
作为root
密码。将终端名称写入/sys/module/kgdboc/parameters/kgdboc
文件:
# echo ttyS0 > /sys/module/kgdboc/parameters/kgdboc
请注意,你不能通过这种方式设置波特率。如果它与控制台使用的是相同的tty
,那么它已经被设置。如果不是,请使用stty
或类似程序。
现在,你可以在主机上启动 GDB,选择与正在运行的内核匹配的vmlinux
文件:
$ cd buildroot
$ output/host/bin/aarch64-buildroot-linux-gnu-gdb output/build/linux-custom/vmlinux
GDB 从vmlinux
加载符号表并等待进一步输入。
接下来,关闭任何连接到控制台的终端模拟器:你将要用它进行 GDB 调试,如果两个同时活动,一些调试信息可能会被损坏。
现在,你可以返回 GDB 并尝试连接到kgdb
。然而,你会发现此时从目标远程设备收到的响应并没有帮助:
(gdb) set serial baud 115200
(gdb) target remote /dev/ttyUSB0
Remote debugging using /dev/ttyUSB0
Ignoring packet error, continuing...
warning: unrecognized item "timeout" in "qSupported" response
问题在于此时kgdb
并未监听连接。你需要中断内核,才能进入与其交互式的 GDB 会话。不幸的是,仅仅在 GDB 中输入Ctrl + C,像操作应用程序那样,并不起作用。你必须通过启动另一个 shell(例如,通过 SSH 连接到目标设备),并向目标板的/proc/sysrq-trigger
文件写入g
,强制内核触发一个陷阱:
# echo g > /proc/sysrq-trigger
目标在这一点上停止了。现在,你可以通过电缆主机端的串行设备连接到kgdb
:
(gdb) target remote /dev/ttyUSB0
Remote debugging using /dev/ttyUSB0
warning: multi-threaded target stopped without sending a thread-id, using first non-exited thread
[Switching to Thread 4294967294]
arch_kgdb_breakpoint () at ./arch/arm64/include/asm/kgdb.h:21
21 asm ("brk %0" : : "I" (KGDB_COMPILED_DBG_BRK_IMM));
(gdb)
最后,GDB 掌控了局面。你可以设置断点、检查变量、查看回溯等等。举个例子,在__sys_accept4
上设置一个断点:
(gdb) break __sys_accept4
Breakpoint 1 at 0xffffffc0089ffe40: file net/socket.c, line 1938.
(gdb) c
Continuing.
现在目标设备重新启动。退出你的 SSH 会话并尝试重新连接到目标设备。重新连接会调用__sys_accept4
并触发断点:
[New Thread 523]
[Switching to Thread 171]
Thread 79 hit Breakpoint 1, __sys_accept4 (fd=3, upeer_sockaddr=0x7ffa494848, upeer_addrlen=0x7ffa4946bc, flags=flags@entry=0)
at net/socket.c:1938
1938 {
(gdb)
如果你已经完成了调试会话,并且想禁用kgdboc
,只需将kgdboc
终端设置为 null:
# echo "" > /sys/module/kgdboc/parameters/kgdboc
就像用 GDB 附加到正在运行的进程一样,这种将内核捕获并通过串口控制台连接到kgdb
的技术,一旦内核启动完成就能工作。但如果因为 bug 内核一直无法完成启动呢?
调试早期代码
上面的示例适用于系统完全启动后执行的代码。如果你需要在早期介入,可以通过在kgdboc
选项后将kgdbwait
添加到命令行来让内核在启动时等待:
kgdboc=ttyS0,115200 kgdbwait
现在,当你启动时,你将在控制台上看到这个:
[ 1.103415] console [ttyS0] enabled
[ 1.108216] kgdb: Registered I/O driver kgdboc.
[ 1.113071] kgdb: Waiting for connection from remote gdb...
此时,你可以关闭控制台,并通过常规方式从 GDB 进行连接。
调试模块
调试内核模块带来了额外的挑战,因为代码在运行时被重定位,因此你需要找出它所在的地址。这些信息通过sysfs
提供。每个模块部分的重定位地址存储在/sys/module/<module name>/sections
中。请注意,由于 ELF 节以点(.
)开头,因此它们显示为隐藏文件,如果你想列出它们,需要使用ls -a
。重要的部分有.text
、.data
和.bss
。
以一个名为mbx
的模块为例:
# cat /sys/module/mbx/sections/.text
0xffffffc000cd9000
# cat /sys/module/mbx/sections/.bss
0xffffffc000cdb380
现在,你可以在kgdb
中使用这些数字来加载该模块在这些地址处的符号表:
(gdb) add-symbol-file /home/frank/buildroot/output/build/mbx-driver-1.0.0/mbx.ko 0xffffffc000cd9000 -s .bss 0xffffffc000cdb380
add symbol table from file "/home/frank/buildroot/output/build/mbx-driver-1.0.0/mbx.ko" at
.text_addr = 0xffffffc000cd9000
.bss_addr = 0xffffffc000cdb380
(y or n) y
Reading symbols from /home/frank/buildroot/output/build/mbx-driver-1.0.0/mbx.ko...
现在一切应该正常工作:你可以像在vmlinux
中一样设置断点并检查模块中的全局和局部变量:
(gdb) break mbx_write
Breakpoint 1 at 0xffffffc000cd9280: file /home/frank/buildroot/output/build/mbx-driver-1.0.0/./mbx.c, line 91.
(gdb) c
Continuing.
然后,强制设备驱动调用mbx_write
,它会触发断点:
[New Thread 231]
[Switching to Thread 227]
Thread 97 hit Breakpoint 1, mbx_write (file=0xffffff8041e83300, buffer=0x559dae9790 "hello\n", length=6, offset=0xffffffc00a083dc0)
at /home/frank/buildroot/output/build/mbx-driver-1.0.0/./mbx.c:91
91 {
(gdb)
如果你已经使用 GDB 调试用户空间的代码,那么调试内核代码和模块时使用kgdb
应该会让你感到非常熟悉。接下来我们来看一下kdb
。
使用 kdb 调试内核代码
尽管kdb
没有kgdb
和 GDB 的功能,但它仍然有其用途,而且由于是自托管的,因此不需要担心外部依赖。kdb
有一个简单的命令行界面,可以在串口控制台上使用。你可以用它来检查内存、寄存器、进程列表和dmesg
,甚至可以设置断点以在特定位置停下来。
要配置你的内核,以便通过串口控制台调用kdb
,请按照之前的步骤启用kgdb
,然后启用这个额外的选项:
- Kernel hacking | Generic Kernel Debugging Instruments | KGDB: kernel debugger | KGDB_KDB: include kdb frontend for kgdb中的
CONFIG_KGDB_KDB
现在,当你强制让内核进入陷阱时,控制台上将显示kdb
的 shell,而不是进入 GDB 会话:
# echo g > /proc/sysrq-trigger
Entering kdb (current=0xffffff8041b0be00, pid 176) on processor 2 due to Keyboard Entry
[2]kdb>
在 kdb
shell 中,你可以做很多事情。help
命令会列出所有选项。以下是概览:
-
获取信息:
-
ps
:显示活动进程。 -
ps A
:显示所有进程。 -
lsmod
:列出模块。 -
dmesg
:显示内核日志缓冲区。
-
-
断点:
-
bp
:设置断点。 -
bl
:列出断点。 -
bc
:清除断点。 -
bt
:打印回溯信息。 -
go
:继续执行。
-
-
检查内存和寄存器:
-
md
:显示内存。 -
rd
:显示寄存器内容。
-
这里是设置断点的一个简短示例:
[2]kdb> bp __sys_accept4
Instruction(i) BP #0 at 0xffffffc0089ffe40 (__sys_accept4)
is enabled addr at ffffffc0089ffe40, hardtype=0 installed=0
[2]kdb> go
内核恢复运行,控制台显示正常的 shell 提示符。如果你尝试重新连接到目标,它会触发断点并再次进入 kdb
:
Entering kdb (current=0xffffff8041b05d00, pid 175) on processor 2 due to Breakpoint @ 0xffffffc0089ffe40
[2]kdb>
kdb
不是一个源级调试器,所以你无法查看源代码或逐步执行。然而,你可以使用 bt
命令显示回溯信息,这对于了解程序流程和调用层次结构非常有用。
查看 Oops 信息
当内核执行无效的内存访问或非法指令时,内核 Oops 消息会被写入内核日志。最有用的部分是回溯信息,我将展示如何使用其中的信息来定位导致故障的代码行。我还会讨论如何在 Oops 信息导致系统崩溃时保留这些信息。
该 Oops 消息是通过写入 MELD/Chapter19/mbx-driver-oops
中的邮箱驱动程序生成的:
Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000
Mem abort info:
ESR = 0x0000000096000005
EC = 0x25: DABT (current EL), IL = 32 bits
SET = 0, FnV = 0
EA = 0, S1PTW = 0
FSC = 0x05: level 1 translation fault
Data abort info:
ISV = 0, ISS = 0x00000005
CM = 0, WnR = 0
user pgtable: 4k pages, 39-bit VAs, pgdp=0000000041fd3000
[0000000000000000] pgd=0000000000000000, p4d=0000000000000000, pud=0000000000000000
Internal error: Oops: 0000000096000005 [#1] PREEMPT SMP
Modules linked in: mbx(O) ipv6
CPU: 1 PID: 191 Comm: sh Tainted: G O 6.1.61-v8 #1
Hardware name: Raspberry Pi 4 Model B Rev 1.1 (DT)
pstate: 80000005 (Nzcv daif -PAN -UAO -TCO -DIT -SSBS BTYPE=--)
pc : mbx_write+0x2c/0xf8 [mbx]
lr : vfs_write+0xd8/0x420
sp : ffffffc00a23bcb0
x29: ffffffc00a23bcb0 x28: ffffff8041f89f00 x27: 0000000000000000
x26: 0000000000000000 x25: 0000000000000000 x24: ffffffc00a23bdc0
x23: 000000558cfee770 x22: 0000000000000000 x21: 0000000000000000
x20: 000000558cfee770 x19: 0000000000000006 x18: 0000000000000000
x17: 0000000000000000 x16: 0000000000000000 x15: 0000000000000000
x14: 0000000000000000 x13: 0000000000000000 x12: 0000000000000000
x11: 0000000000000000 x10: 0000000000000000 x9 : 0000000000000000
x8 : 0000000000000000 x7 : ffffffc00a23c000 x6 : ffffffc00a238000
x5 : ffffffc00a23bda0 x4 : ffffffc000cd9270 x3 : ffffffc00a23bdc0
x2 : 0000000000000006 x1 : 000000558cfee770 x0 : ffffffc008322638
该 Oops 信息中的 pc
行显示为 mbx_write+0x2c/0xf8 [mbx]
,它告诉你大部分你想知道的内容:最后一条指令是在名为 mbx
的内核模块的 mbx_write
函数中。更进一步,它位于该函数开始处偏移 0x2c
字节的位置,而该函数的总长度为 0xf8
字节。
接下来,查看回溯信息:
Call trace:
mbx_write+0x2c/0xf8 [mbx]
vfs_write+0xd8/0x420
ksys_write+0x74/0x100
__arm64_sys_write+0x24/0x30
invoke_syscall+0x54/0x120
el0_svc_common.constprop.3+0x90/0x120
do_el0_svc+0x3c/0xe0
el0_svc+0x20/0x60
el0t_64_sync_handler+0x90/0xc0
el0t_64_sync+0x15c/0x160
Code: aa0103f4 d503201f f94066b5 f110027f (f94002b6)
---[ end trace 0000000000000000 ]---
在这种情况下,我们并没有学到太多,仅仅是 mbx_write
是从虚拟文件系统函数 _vfs_write
被调用的。
如果能找到与 mbx_write+0x2c
相关的代码行,那将非常有帮助。我们可以使用 GDB 命令 disassemble
并加上 /s
修饰符,这样它会显示源代码和汇编代码。此示例中,代码位于 mbx.ko
模块中,因此我们将其加载到 gdb
中:
$ output/host/bin/aarch64-buildroot-linux-gnu-gdb output/build/mbx-driver-oops-1.0.0/mbx.ko
<…>
(gdb) disassemble /s mbx_write
Dump of assembler code for function mbx_write:
/home/frank/buildroot/output/build/mbx-driver-oops-1.0.0/./mbx.c:
96 {
0x00000000000002a0 <+0>: stp x29, x30, [sp, #-48]!
0x00000000000002a4 <+4>: mov x29, sp
0x00000000000002a8 <+8>: stp x19, x20, [sp, #16]
0x00000000000002ac <+12>: stp x21, x22, [sp, #32]
0x00000000000002b0 <+16>: mov x21, x0
0x00000000000002b4 <+20>: mov x0, x30
0x00000000000002b8 <+24>: mov x19, x2
0x00000000000002bc <+28>: mov x20, x1
0x00000000000002c0 <+32>: bl 0x2c0 <mbx_write+32>
97 struct mbx_data *m = (struct mbx_data *)file->private_data;
0x00000000000002c4 <+36>: ldr x21, [x21, #200]
98
99 if (length > MBX_LEN)
0x00000000000002c8 <+40>: cmp x19, #0x400
0x00000000000002cc <+44>: ldr x22, [x21]
0x00000000000002d0 <+48>: b.ls 0x344 <mbx_write+164> // b.plast
101 m->mbx_len = length;
0x00000000000002d4 <+52>: mov w0, #0x400 // #1024
0x00000000000002d8 <+56>: mov x1, #0x7ffffffc00 // #549755812864
100 length = MBX_LEN;
0x00000000000002dc <+60>: mov x19, #0x400 // #1024
101 m->mbx_len = length;
0x00000000000002e0 <+64>: str w0, [x21, #8]
<…>
从 line 97 可以看到,m
的类型是结构体 mbx_data *
。这是该结构体定义的位置:
#define MBX_LEN 1024
struct mbx_data {
char mbx[MBX_LEN];
int mbx_len;
};
看起来 m
变量是一个空指针,这就是导致 Oops 的原因。查看 m
初始化的代码,我们可以看到少了一行。通过初始化指针,如以下代码块所示,Oop 错误被消除了:
static int mbx_open(struct inode *inode, struct file *file)
{
if (MINOR(inode->i_rdev) >= NUM_MAILBOXES) {
printk("Invalid mbx minor number\n");
return -ENODEV;
}
file->private_data = &mailboxes[MINOR(inode->i_rdev)]; // HERE
return 0;
}
并不是每个 Oops 都这么容易定位,特别是当它发生在内核日志缓冲区内容还未显示时。
保留 Oops 信息
解码 Oops 只有在你能够捕获它的情况下才可能。如果系统在启动时崩溃,且在控制台启用之前,或在挂起之后,你将无法看到它。虽然有机制可以将内核 Oops 和消息记录到 MTD 分区或持久内存中,但这里有一种简单的技术,在许多情况下有效,且不需要太多预先考虑。
只要内存的内容在重置过程中没有被破坏(通常不会),你可以重启进入引导加载程序并使用它来显示内存。你需要知道内核日志缓冲区的位置,记住它是一个简单的文本消息环形缓冲区。符号是 __log_buf
。你可以在内核的 System.map
中查找这个符号:
$ grep __log_buf output/build/linux-custom/System.map
ffffffc0096ce718 b __log_buf
重要提示
从 Linux 3.5 版本开始,内核日志缓冲区中的每一行都包含一个 16 字节的二进制头部,编码了时间戳、日志级别以及其他信息。关于这一点,有一篇 Linux 每周新闻的讨论,标题为 Towards more reliable logging,链接为 lwn.net/Articles/492125/
。
在本节中,我们探讨了如何使用 kgdb
在源代码级别调试内核代码。接着我们研究了在 kdb
shell 中设置断点和打印回溯信息。最后,我们学习了如何通过 dmesg
或 U-Boot 命令行读取内核 Oops 消息。
总结
学会如何使用 GDB 进行交互式调试是嵌入式系统开发者工具箱中的一项有用工具。它是一个稳定、文档齐全且知名的工具。它具有通过在目标上放置代理来进行远程调试的能力,无论是用于应用程序的 gdbserver
还是用于内核代码的 kgdb
,虽然默认的命令行用户界面需要一些时间来适应,但也有许多替代的前端。我提到的三种前端是 TUI、DDD 和 Visual Studio Code。Eclipse 是另一个流行的前端,它通过 CDT 插件支持与 GDB 一起调试。我会在 Further study 部分的参考文献中提供如何配置 CDT 以支持交叉工具链并连接到远程设备的信息。
另一种同样重要的调试方法是收集崩溃报告并离线分析它们。在这个类别中,我们研究了应用程序核心转储和内核 Oops 消息。
然而,这只是识别程序缺陷的一种方式。在下一章中,我将讨论性能分析和跟踪作为分析和优化程序的方式。
进一步学习
-
《使用 GDB、DDD 和 Eclipse 调试的艺术》,作者:Norman Matloff 和 Peter Jay Salzman
-
《GDB 口袋参考》,作者:Arnold Robbins
-
《Python 解释器在 GNU 调试器中的应用》,作者:crazyguitar –
www.pythonsheets.com/appendix/python-gdb.html
-
《用 Python 扩展 GDB》,作者:Lisa Roach –
www.youtube.com/watch?v=xt9v5t4_zvE
-
掌握 Eclipse:交叉编译 –
2net.co.uk/tutorial/eclipse-cross-compile
-
掌握 Eclipse:远程访问和调试 –
2net.co.uk/tutorial/eclipse-rse
第二十章:性能分析与追踪
使用源代码级调试器进行交互式调试,如上一章所述,可以帮助你深入了解程序的工作原理,但它将你的视野限制在一小部分代码上。在本章中,我们将从更宏观的角度来看,是否系统按预期执行。
程序员和系统设计师通常不擅长猜测瓶颈在哪里。因此,如果你的系统存在性能问题,明智的做法是从整体系统开始分析,然后逐步深入,随着进展使用更复杂的工具。本章中,我将从广为人知的 top
命令开始,作为了解概况的手段。通常,问题可能局限于某个程序,你可以使用 Linux 性能分析工具 perf
进行分析。如果问题没有那么局限,并且你想获得更广泛的视角,perf
也能做到。为了诊断与内核相关的问题,我将介绍一些追踪工具——Ftrace、LTTng 和 eBPF——作为获取详细信息的手段。
我还将介绍 Valgrind,它因其沙盒执行环境,可以监控程序并报告程序运行时的代码。我将通过描述一个简单的追踪工具strace
来完成本章,它通过追踪程序发出的系统调用,揭示程序的执行过程。
在本章中,我们将讨论以下主题:
-
观察者效应
-
开始进行性能分析
-
使用
top
进行性能分析 -
使用 GDB 进行性能分析
-
介绍
perf
-
追踪事件
-
介绍 Ftrace
-
使用 LTTng
-
使用 eBPF
-
使用 Valgrind
-
使用
strace
技术要求
要跟随示例进行操作,请确保你拥有以下内容:
-
一台运行 Ubuntu 24.04 或更高版本 LTS 的主机系统,至少有 90 GB 的空闲磁盘空间
-
Buildroot 2024.02.6 LTS 版本
-
一个 microSD 卡读卡器和卡
-
适用于 Linux 的 balenaEtcher
-
一根以太网线和一个可用端口的路由器,用于网络连接
-
Raspberry Pi 4
-
一台能够提供 3A 电流的 5V USB-C 电源
你应该已经在 第六章 中安装了 Buildroot 2024.02.6 LTS 版本。如果没有,请参考 Buildroot 用户手册 中的 系统要求 部分 (buildroot.org/downloads/manual/manual.html
),然后按照 第六章 中的说明在你的 Linux 主机上安装 Buildroot。
本章中使用的代码可以在本书 GitHub 仓库中的章节文件夹中找到:github.com/PacktPublishing/Mastering-Embedded-Linux-Development/tree/main/Chapter20/buildroot
。
观察者效应
在深入了解工具之前,先让我们谈谈这些工具会向你展示什么。就像许多领域一样,测量某个特性会影响到观察本身。测量电源线中的电流需要测量小电阻上的电压降。然而,电阻本身会影响电流。分析也是如此:每一次系统观察都会消耗 CPU 周期,这些资源就不再用在应用程序上。测量工具还会干扰缓存行为,占用内存空间,写入磁盘,这一切都会使情况变得更糟。没有开销就没有测量。
我经常听到工程师说,他们做的分析工作完全误导。这通常是因为他们在某些非真实的情况下进行测量。始终尽量在目标系统上进行测量,运行发布版本的软件,并使用有效的数据集,最好是从实际环境中获得,尽量减少额外的服务。
发布构建通常意味着构建完全优化过的二进制文件,而没有调试符号。这些生产要求会严重限制大多数分析工具的功能。
一旦我们的系统启动并运行起来,我们会立刻遇到一个问题。虽然观察系统的自然状态很重要,但工具往往需要额外的信息才能理解事件。
一些工具需要特殊的内核选项。对于我们在本章中讨论的工具,这适用于perf
、Ftrace、LTTng 和 eBPF。因此,你可能需要为这些测试构建并部署一个新的内核。
调试符号在将原始程序地址转换为函数名和源代码行号时非常有用。部署带有调试符号的可执行文件不会改变代码的执行,但确实要求你拥有二进制文件副本,并且至少对你想要分析的组件,内核需要带有调试信息。一些工具在目标系统上安装这些内容会更有效,比如perf
。这些技巧与一般调试相同,正如我在第十九章中讨论的那样。
如果你希望工具生成调用图,你可能需要编译时启用堆栈帧。如果你希望工具能准确地将地址与源代码中的行号匹配,你可能需要使用较低级别的优化进行编译。
最后,一些工具需要将仪器插入程序中以捕获样本,因此你需要重新编译这些组件。这适用于内核的 Ftrace 和 LTTng。
请注意,你改变被观察系统的程度越大,你所做的测量与生产系统之间的关系就越难以建立。
提示
最好采取等待观察的方法,只有在需要时才进行更改,并时刻注意,每次更改时,你都会改变你正在测量的内容。
由于分析结果可能非常模糊,建议在使用更复杂和入侵性较强的工具之前,先从一些简单、易用且 readily available 的工具入手。
开始分析
在查看整个系统时,一个好的起点是使用像top
这样简单的工具,它能快速提供概览。它显示了正在使用的内存量、哪些进程消耗了 CPU 周期,以及这些消耗如何分布在不同的核心和时间上。
如果top
显示单个应用程序占用了所有的用户空间 CPU 周期,那么你可以使用perf
对该应用程序进行分析。
如果两个或更多进程的 CPU 使用率很高,可能是它们之间有某种关联,可能是数据通信。如果大量时间花费在系统调用或处理中断上,那么可能是内核配置或设备驱动程序出现问题。在任何情况下,你需要首先使用perf
对整个系统进行分析。
如果你想了解更多关于内核和事件顺序的内容,可以使用 Ftrace、LTTng 或 eBPF。
可能会有其他top
无法帮助你发现的问题。如果你有多线程代码,并且遇到锁死问题,或者数据出现随机性损坏,那么pidstat
(sysstat
的一部分)或 Valgrind 加上 Helgrind 插件可能会有所帮助。内存泄漏也属于这一类别;我在第十八章中介绍了与内存相关的诊断。
在深入探讨这些更高级的分析工具之前,我们先从大多数系统中都能找到的最基本的工具开始,包括生产环境中的系统。
使用 top 进行分析
top程序是一个简单的工具,不需要任何特殊的内核选项或符号表。BusyBox 中有一个基本版本,而procps
包中有一个功能更全的版本,后者在 Yocto 项目和 Buildroot 中都可以找到。你可能还想考虑使用htop
,它与top
功能相似,但界面更加友好。
首先,关注top
的概要行,如果你使用的是 BusyBox,则它是第二行;如果你使用的是procps
中的top
,则是第三行。下面是一个例子,使用 BusyBox 的top
:
Mem: 57044K used, 446172K free, 40K shrd, 3352K buff, 34452K cached
CPU: 58% usr 4% sys 0% nic 0% idle 37% io 0% irq 0% sirq
Load average: 0.24 0.06 0.02 2/51 105
PID PPID USER STAT VSZ %VSZ %CPU COMMAND
105 104 root R 27912 6% 61% ffmpeg -i track2.wav
<…>
概要行显示了在不同状态下运行的时间百分比,如下表所示:
procps | BusyBox | 描述 |
---|---|---|
us |
usr |
默认优先级值的用户空间程序 |
sy |
sys |
内核代码 |
ni |
nic |
优先级非默认值的用户空间程序 |
id |
idle |
空闲 |
wa |
io |
I/O 等待 |
hi |
irq |
硬件中断 |
si |
sirq |
软件中断 |
st |
- |
Steal 时间:仅在虚拟环境中相关 |
表 20.1 – procps top 与 BusyBox top 的对比
在前面的示例中,几乎所有时间(58%)都花费在用户模式下,系统模式下的时间较少(4%),所以这是一个在用户空间中受 CPU 限制的系统。总结后的第一行显示,只有一个应用程序负责:ffmpeg
。任何减少 CPU 使用的努力都应集中在这里。
这是另一个示例:
Mem: 13128K used, 490088K free, 40K shrd, 0K buff, 2788K cached
CPU: 0% usr 99% sys 0% nic 0% idle 0% io 0% irq 0% sirq
Load average: 0.41 0.11 0.04 2/46 97
PID PPID USER STAT VSZ %VSZ %CPU COMMAND
92 82 root R 2152 0% 100% cat /dev/urandom
<…>
这个系统几乎将所有时间都花费在内核空间(99% sys
),这是由于 cat
从 /dev/urandom
读取数据所导致。在这个人工案例中,仅仅对 cat
进行分析并不会帮助,但分析 cat
调用的内核函数可能会有所帮助。
top
的默认视图只显示进程,因此 CPU 使用率是进程中所有线程的总和。按 H 键可以查看每个线程的详细信息。同样,它会汇总所有 CPU 的时间。如果你使用的是 procps
版本的 top
,可以按 1 键查看每个 CPU 的摘要信息。
一旦通过 top
确定了问题进程,我们就可以将 GDB 附加到它上面。
使用 GDB 进行分析
你可以仅通过使用GDB在任意间隔停止应用程序,查看它的状态。这就是穷人分析器。它易于设置,是收集分析数据的一种方式。
过程很简单:
-
使用
gdbserver
(用于远程调试)或 GDB(用于本地调试)附加到进程。进程会停止。 -
查看程序停止时所在的函数。你可以使用
backtrace
GDB 命令查看调用栈。 -
输入
continue
,让程序继续执行。 -
一段时间后,按 Ctrl + C 停止程序,然后返回到 步骤 2。
如果你重复执行步骤 2 到 4 多次,你很快就能了解程序是否在循环或进展,如果你足够频繁地重复这些步骤,你就能了解代码中的热点所在。
有一个完整的网页专门讲解这个方法,地址是poormansprofiler.org/
,并且提供了一些脚本,让这个过程变得更简单。多年来,我在各种操作系统和调试器中多次使用过这个技巧。
这是统计分析的一个示例,其中你在一定间隔内对程序状态进行采样。经过一段时间的采样后,你可以开始了解函数执行的统计概率。令人惊讶的是,实际上你所需要的样本数非常少。其他统计分析工具包括 perf record
、OProfile 和 gprof
。
使用调试器进行采样具有侵入性,因为程序在采样期间会停止很长时间。其他工具可以以更低的开销进行采样。一个这样的工具是 perf
。
引入 perf
perf 是 Linux 性能事件计数子系统 perf_events
的缩写,也是与 perf_events
交互的命令行工具的名称。自 Linux 2.6.31 以来,它们已成为内核的一部分。在 Linux 源代码树中的 tools/perf/Documentation
以及 perfwiki.github.io
上有大量有用的信息。
开发 perf
的初衷是提供一种统一的方式来访问 性能测量单元 (PMU) 的寄存器,PMU 是大多数现代处理器核心的一部分。一旦 API 被定义并集成到 Linux 中,扩展它以涵盖其他类型的性能计数器便变得合乎逻辑。
从本质上讲,perf
是一个事件计数器集合,具有关于何时主动收集数据的规则。通过设置规则,您可以从整个系统、仅内核、仅一个进程及其子进程中捕获数据,并且可以跨所有 CPU 或仅一个 CPU 进行操作。它非常灵活。使用这个工具,您可以从查看整个系统开始,然后集中关注可能导致问题的设备驱动程序、运行缓慢的应用程序,或执行时间似乎比预期更长的库函数。
perf
命令行工具的代码是内核的一部分,位于 tools/perf
目录下。该工具和内核子系统是同步开发的,这意味着它们必须来自同一个版本的内核。perf
功能强大。在本章中,我将仅作为分析器来探讨它。关于其其他功能的描述,请阅读 perf
手册并参考本节开头提到的文档。
除了调试符号之外,我们还需要设置两个配置选项,以完全启用内核中的 perf
。
为 perf 配置内核
您需要一个已配置 perf_events
的内核,并且需要将 perf
命令交叉编译,以便在目标上运行。相关的内核配置是 CONFIG_PERF_EVENTS
,位于 General setup | Kernel Performance Events and Counters 菜单中。
如果您希望使用 tracepoints 进行分析——稍后会详细讲解——还需要启用有关 Ftrace 部分中描述的选项。同时,在这里启用 CONFIG_DEBUG_INFO
也是值得的。
perf
命令有许多依赖项,这使得交叉编译变得相当复杂。然而,Yocto 项目和 Buildroot 都有针对它的目标包。
您还需要为您感兴趣的二进制文件启用目标的调试符号;否则,perf
将无法解析地址到有意义的符号。理想情况下,您希望为整个系统(包括内核)启用调试符号。对于后者,请记住,内核的调试符号位于 vmlinux
文件中。
使用 Yocto 项目构建 perf
如果您使用的是标准的 linux-yocto
内核,perf_events
已经启用,因此无需进行更多操作。
要构建 perf
工具,您可以将其显式添加到目标镜像的依赖项中,或者您可以添加 tools-profile
特性。您还需要在目标镜像以及内核 vmlinux
镜像中启用调试符号。总的来说,您在 conf/local.conf
中需要配置以下内容:
EXTRA_IMAGE_FEATURES:append = " tools-profile dbg-pkgs src-pkgs"
IMAGE_INSTALL:append = " kernel-vmlinux binutils"
将 perf
添加到 Buildroot 镜像中涉及多个步骤。
使用 Buildroot 构建 perf
许多 Buildroot 内核配置不包括perf_events
,因此你应该首先检查内核是否包括前面章节提到的选项。
要交叉编译perf
,运行 Buildroot 的menuconfig
并选择以下选项:
-
在内核 | Linux 内核工具 | perf中选择
BR2_PACKAGE_LINUX_TOOLS_PERF
-
在内核 | Linux 内核工具 | perf中选择
BR2_PACKAGE_LINUX_TOOLS_PERF_TUI
| 启用 perf TUI
要构建带有调试信息的二进制文件并将它们安装到目标上而不剥离,启用这两个选项中的第一个并禁用第二个:
-
在构建选项中选择
BR2_ENABLE_DEBUG
| 构建带调试符号的包 -
在构建选项中选择
BR2_STRIP_strip
| 剥离目标二进制文件
要将未剥离的vmlinux
文件复制到目标映像中,选择以下选项:
-
在内核 | 内核二进制格式 | vmlinux中选择
BR2_LINUX_KERNEL_VMLINUX
-
在内核 | 将内核映像安装到目标的/boot 目录中选择
BR2_LINUX_KERNEL_INSTALL_TARGET
为了增加根文件系统的大小以适应未剥离的二进制文件和vmlinux
文件:
- 选择文件系统映像 | ext2/3/4 根文件系统 | 精确大小,然后在文本框中输入
960M
。
然后运行make clean
,接着运行make
。
一旦你完成了所有构建,你将需要手动将vmlinux
复制到目标映像中。
使用 perf 进行性能分析
你可以使用perf
通过事件计数器采样程序状态,并在一段时间内累积样本来创建性能分析报告。这是另一种统计性能分析的方法。默认事件计数器叫做cycles
,它是一个通用硬件计数器,映射到一个 PMU 寄存器,表示在核心时钟频率下的周期计数。
使用perf
创建分析报告是一个两阶段的过程:perf record
命令捕获样本并将其写入名为perf.data
的文件,然后perf report
命令分析结果。两个命令都在目标上运行。收集的样本会过滤出特定进程及其所有子进程的相关数据。以下是对一个查找linux
字符串的 shell 脚本进行分析的示例:
# perf record -a sh -c "find /usr/share | xargs grep linux > /dev/null"
[ perf record: Woken up 1 times to write data ]
[ perf record: Captured and wrote 0.176 MB perf.data (2677 samples) ]
# ls -l perf.data
-rw------- 1 root root 190024 Mar 9 14:28 perf.data
现在你可以使用perf report
命令显示perf.data
中的结果。
有三种用户界面可供选择:
-
--stdio
:这是一个纯文本界面,没有用户交互。你需要分别启动perf report
和annotate
来查看每个跟踪视图。 -
--tui
:这是一个基于文本的简单菜单界面,可以在屏幕之间进行导航。 -
--gtk
:这是一个图形界面,功能与--tui
相同。
默认情况下是 TUI,如本示例所示:
图 20.1 – perf 报告 TUI
perf
能够记录由进程执行的内核函数,因为它在内核空间收集样本。
列表按照最活跃的函数排在前面。在这个例子中,除了一个,所有函数都是在运行 grep
时捕获的。有些在库中,libc-2.20
,有些在程序中,busybox.nosuid
,还有些在内核中。我们能够获取程序和库函数的符号名,因为所有的二进制文件都已经安装在目标机器上并包含了调试信息,而内核符号是从 /boot/vmlinux
中读取的。如果你的 vmlinux
存放在其他位置,记得在 perf report
命令中加上 -k <path>
参数。你也可以通过使用 perf record -o <文件名>
将样本保存到不同的文件中,然后用 perf report -i <文件名>
进行分析,而不是将样本存储在 perf.data
中。
默认情况下,perf record
使用 cycles
计数器以 1,000 Hz 的频率进行采样。
提示
1,000 Hz 的采样频率可能比你实际需要的更高,也可能是观测者效应的原因。尝试较低的采样率;100 Hz 足以应对大多数情况。你可以使用 -F
选项来设置采样频率。
这仍然没有真正简化工作;列表顶部的函数大多是低级别的内存操作,且你可以相当肯定这些函数已经被优化过了。幸运的是,perf record
还为我们提供了向上爬取调用堆栈并查看这些函数被调用位置的能力。
调用图
如果能够回溯并查看这些高开销函数的周围上下文,那就更好了。你可以通过向 perf record
传递 -g
选项来捕获每个样本的回溯信息。
现在,perf report
显示一个加号(+
)表示该函数是调用链的一部分。你可以展开追踪,查看链条中更低位置的函数:
图 20.2 – perf report(调用图)
重要提示
生成调用图依赖于从堆栈中提取调用帧的能力,就像 GDB 中的回溯信息一样。解开堆栈所需的调试信息被编码在可执行文件中。对于某些架构和工具链的组合,由于二进制文件缺乏必要的调试信息,因此无法生成调用图。
回溯信息很有用,但这些函数的汇编代码在哪里,或者更好的是,源代码在哪里?
perf annotate
现在你已经知道了要查看哪些函数,接下来可以深入查看代码,并获得每个指令的命中次数。这就是 perf annotate
的作用,它通过调用目标机器上的 objdump
副本来实现。你只需将 perf annotate
替换为 perf report
即可。
perf annotate
需要可执行文件和 vmlinux
的符号表。以下是一个注释过的函数示例:
图 20.3 – perf annotate(汇编代码)
如果你想看到与汇编代码交织在一起的源代码,可以将相关源文件复制到目标设备。如果你使用的是 The Yocto Project 并且通过 src-pkgs
附加镜像特性构建,或者已经安装了单独的 <package>-src
包,那么源代码将已经安装在 /usr/src/debug
中。否则,你可以查看调试信息,查找源代码的位置:
$ cd ~/buildroot/output
$ host/aarch64-buildroot-linux-gnu/bin/objdump --dwarf target/lib/libc.so.6 | grep DW_AT_comp_dir | grep libgcc
<41f4dd> DW_AT_comp_dir : (indirect string, offset: 0x2d355): /home/frank/buildroot/output/build/host-gcc-initial-12.4.0/build/aarch64-buildroot-linux-gnu/libgcc
目标路径必须与 DW_AT_comp_dir
中看到的路径完全相同。
下面是源代码与汇编代码的注释示例:
图 20.4 – perf annotate(源代码)
现在我们可以看到 cmp r0
上方和 str r3, [fp, #-40]
指令下方的相应 C 源代码。
这就是我们对perf
的介绍。虽然在perf
之前还有其他统计采样分析器,比如 OProfile 和 gprof
,但这些工具近年来已经不再被广泛使用,因此我选择将它们省略。接下来,我们将讨论事件跟踪工具。
跟踪事件
到目前为止我们看到的所有工具都使用统计采样。你通常希望了解更多关于事件顺序的信息,以便你能查看它们并将它们相互关联。函数跟踪涉及通过在代码中加入跟踪点来捕捉事件信息,可能包括以下一些或全部内容:
-
时间戳
-
上下文信息,例如当前的 PID
-
函数参数和返回值
-
调用栈
它比统计分析更具侵入性,并且可能会生成大量数据。后者的问题可以通过在采样时应用过滤器以及稍后查看跟踪时应用过滤器来减轻。
我将在这里介绍三种跟踪工具:内核函数跟踪器 Ftrace、LTTng 和 eBPF。
引入 Ftrace
内核函数跟踪器 Ftrace 起源于 Steven Rostedt 等人为了追踪实时应用中高调度延迟的原因而开展的工作。Ftrace 出现在 Linux 2.6.27 中,并且自那时以来一直在积极开发。内核源代码中的 Documentation/trace
目录包含了许多关于内核跟踪的文档。
Ftrace 由多个跟踪器组成,可以记录内核中的各种活动。在这里,我将讨论 function
和 function_graph
跟踪器以及事件跟踪点。在第二十一章中,我将再次提到 Ftrace,并讨论实时延迟。
function
跟踪器为每个内核函数添加了跟踪点,以便记录和时间戳化调用。它通过 -pg
选项编译内核,以注入这些跟踪代码。function_graph
跟踪器更进一步,记录了函数的入口和出口,从而生成调用图。事件跟踪点功能记录与调用相关的参数。
Ftrace 具有非常适合嵌入式的用户界面,完全通过 debugfs
文件系统中的虚拟文件实现,这意味着你不必在目标上安装任何工具即可使其工作。不过,如果你愿意,也有其他用户界面可供选择:trace-cmd
是一个命令行工具,用于记录和查看跟踪,且可以在 Buildroot (BR2_PACKAGE_TRACE_CMD
) 和 The Yocto Project (trace-cmd
) 中使用。还有一个图形化的跟踪查看器 KernelShark,它作为 The Yocto Project 的一个软件包提供。
像 perf
一样,启用 Ftrace 需要设置一些内核配置选项。
准备使用 Ftrace
Ftrace 及其各种选项在内核配置菜单中进行配置。至少需要以下选项:
- Kernel hacking | Tracers | Kernel Function Tracer 菜单中的
CONFIG_FUNCTION_TRACER
强烈建议你开启这些选项:
-
Kernel hacking | Tracers | Kernel Function Tracer | Kernel Function Graph Tracer 菜单中的
CONFIG_FUNCTION_GRAPH_TRACER
-
Kernel hacking | Tracers | Kernel Function Tracer | Enable/disable function tracing dynamically 菜单中的
CONFIG_DYNAMIC_FTRACE
-
Kernel hacking | Tracers | Kernel Function Tracer | Kernel function profiler 菜单中的
CONFIG_FUNCTION_PROFILER
由于整个过程在内核中执行,因此无需进行用户空间的配置。
使用 Ftrace
在你使用 Ftrace 之前,你必须挂载 debugfs
文件系统,它位于 /sys/kernel/debug
目录下:
# mount -t debugfs none /sys/kernel/debug
所有 Ftrace 控制项都在 /sys/kernel/debug/tracing
目录下;这里甚至有一个迷你版的 HOWTO
文件在 README
中。
这是内核中可用的跟踪器列表:
# cat /sys/kernel/debug/tracing/available_tracers
blk function_graph function nop
当前使用的跟踪器由 current_tracer
显示。最初,它将是空跟踪器 nop
。
要捕获跟踪,请通过将 available_tracers
中某个跟踪器的名称写入 current_tracer
来选择跟踪器。然后,启用短时间的跟踪:
# echo function > /sys/kernel/debug/tracing/current_tracer
# echo 1 > /sys/kernel/debug/tracing/tracing_on
# sleep 1
# echo 0 > /sys/kernel/debug/tracing/tracing_on
在这一秒钟内,跟踪缓冲区将被填充上内核调用的每个函数的详细信息。跟踪缓冲区的格式是纯文本,如 Documentation/trace/ftrace.txt
中所描述。你可以通过 trace
文件读取跟踪缓冲区:
# cat /sys/kernel/debug/tracing/trace
# tracer: function
#
# entries-in-buffer/entries-written: 40051/40051 #P:1
#
# _-----=> irqs-off
# / _----=> need-resched
# | / _---=> hardirq/softirq
# || / _--=> preempt-depth
# ||| / delay
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
# | | | |||| | |
sh-361 [000] ...1 992.990646: mutex_unlock <-rb_simple_write
sh-361 [000] ...1 992.990658: __fsnotify_parent <-vfs_write
sh-361 [000] ...1 992.990661: fsnotify <-vfs_write
sh-361 [000] ...1 992.990663: __srcu_read_lock <-fsnotify
sh-361 [000] ...1 992.990666: preempt_count_add <-__srcu_read_lock
sh-361 [000] ...2 992.990668: preempt_count_sub <-__srcu_read_lock
sh-361 [000] ...1 992.990670: __srcu_read_unlock <-fsnotify
sh-361 [000] ...1 992.990672: __sb_end_write <-vfs_write
sh-361 [000] ...1 992.990674: preempt_count_add <-__sb_end_write
<…>
你可以在仅仅一秒钟内捕获大量数据点——在此情况下,超过 40,000 个。
和分析器一样,像这样的平面函数列表很难理解。如果你选择 function_graph
跟踪器,Ftrace 会捕获如下的调用图:
# tracer: function_graph
#
# CPU DURATION FUNCTION CALLS
# | | | | | | |
0) + 63.167 us | } /* cpdma_ctlr_int_ctrl */
0) + 73.417 us | } /* cpsw_intr_disable */
0) | disable_irq_nosync() {
0) | __disable_irq_nosync() {
0) | __irq_get_desc_lock() {
0) 0.541 us | irq_to_desc();
0) 0.500 us | preempt_count_add();
0) + 16.000 us | }
0) | __disable_irq() {
0) 0.500 us | irq_disable();
0) 8.208 us | }
0) | __irq_put_desc_unlock() {
0) 0.459 us | preempt_count_sub();
0) 8.000 us | }
0) + 55.625 us | }
0) + 63.375 us | }
现在你可以看到函数调用的嵌套,使用大括号 {
和 }
分隔。在终止的大括号处,会显示该函数的执行时间,如果超过 10 微秒,会有一个加号(+
)标注;如果超过 100 微秒,会有一个感叹号(!
)标注。
你通常只对由单个进程或线程引起的内核活动感兴趣,这时你可以通过将线程 ID 写入set_ftrace_pid
来将追踪限制为一个线程。
动态 Ftrace 和 trace 过滤器
启用CONFIG_DYNAMIC_FTRACE
允许 Ftrace 在运行时修改函数追踪位置,这有几个好处。首先,它触发了额外的构建时处理追踪函数探针,这使得 Ftrace 子系统能够在启动时定位它们并用nop
指令覆盖,从而将函数追踪代码的开销减少到几乎为零。你可以在生产环境或接近生产环境的内核中启用 Ftrace,而不会影响性能。
第二个好处是你可以选择性地启用函数追踪位置,而不是追踪所有内容。函数的列表被放入available_filter_functions
中。你可以通过将名称从available_filter_functions
复制到set_ftrace_filter
来按需启用函数追踪。要停止追踪该函数,向set_ftrace_notrace
写入其名称。你还可以使用通配符并将名称追加到列表中。例如,假设你对tcp
处理感兴趣:
# cd /sys/kernel/debug/tracing
# echo "tcp*" > set_ftrace_filter
# echo function > current_tracer
# echo 1 > tracing_on
运行一些测试,然后查看trace
:
# cat trace
# tracer: function
#
# entries-in-buffer/entries-written: 590/590 #P:1
#
# _-----=> irqs-off
# / _----=> need-resched
# | / _---=> hardirq/softirq
# || / _--=> preempt-depth
# ||| / delay
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
# | | | |||| | |
dropbear-375 [000] ...1 48545.022235: tcp_poll <-sock_poll
dropbear-375 [000] ...1 48545.022372: tcp_poll <-sock_poll
dropbear-375 [000] ...1 48545.022393: tcp_sendmsg <-inet_sendmsg
dropbear-375 [000] ...1 48545.022398: tcp_send_mss <-tcp_sendmsg
dropbear-375 [000] ...1 48545.022400: tcp_current_mss <-tcp_send_mss
<…>
set_ftrace_filter
函数还可以包含命令,用于在某些函数执行时启动和停止追踪。这里没有足够的空间详细说明这些内容,但如果你想了解更多,阅读Documentation/trace/ftrace.txt
中的Filter commands部分。
Trace 事件
function
和function_graph
追踪器只记录函数执行的时间。Trace 事件特性还记录与调用相关的参数,使追踪更加可读和具有信息性。例如,追踪事件不仅记录kmalloc
函数已被调用,还会记录请求的字节数和返回的指针。Trace 事件在perf
、LTTng 以及 Ftrace 中都被使用,但开发 trace 事件子系统的推动力来自于 LTTng 项目。
创建 trace 事件需要内核开发人员的努力。它们在源代码中使用TRACE_EVENT
宏进行定义,现在有超过一千个。你可以在运行时查看/sys/kernel/debug/tracing/available_events
中可用事件的列表。它们的命名格式为<subsystem
: function
>(例如,kmem:kmalloc
)。每个事件还通过tracing/events/<subsystem>/<function>
中的子目录进行表示:
# ls events/kmem/kmalloc
enable filter format id trigger
这些文件是:
-
enable
:你可以向此文件写入1
来启用事件。 -
filter
:这是一个表达式,事件必须评估为true
才能被追踪。 -
format
:这是事件及其参数的格式。 -
id
:这是一个数字标识符。 -
trigger
:这是一个命令,当事件发生时会执行,语法定义在Documentation/trace/ftrace.txt
的Filter commands部分。
我将展示一个涉及kmalloc
和kfree
的简单示例。事件跟踪不依赖于函数跟踪器,因此可以从选择nop
跟踪器开始:
# echo nop > current_tracer
接下来,通过单独启用每个事件来选择要跟踪的事件:
# echo 1 > events/kmem/kmalloc/enable
# echo 1 > events/kmem/kfree/enable
你也可以将事件名称写入set_event
,如下所示:
# echo "kmem:kmalloc kmem:kfree" > set_event
现在,当你阅读跟踪时,你可以看到函数及其参数:
# tracer: nop
#
# entries-in-buffer/entries-written: 359/359 #P:1
#
# _-----=> irqs-off
# / _----=> need-resched
# | / _---=> hardirq/softirq
# || / _--=> preempt-depth
# ||| / delay
# TASK-PID CPU# |||| TIMESTAMP FUNCTION
# | | | |||| | |
cat-382 [000] ...1 2935.586706: kmalloc:call_site=c0554644 ptr=de515a00 bytes_req=384 bytes_alloc=512 gfp_flags=GFP_ATOMIC|GFP_NOWARN|GFP_NOMEMALLOC
cat-382 [000] ...1 2935.586718: kfree: call_site=c059c2d8 ptr=(null)
在perf
中可见的跟踪事件与 tracepoint 事件完全相同。
由于没有臃肿的用户空间组件需要构建,Ftrace 非常适合部署到大多数嵌入式目标设备上。接下来,我们将看一下另一种流行的事件跟踪器,它的起源早于 Ftrace。
使用 LTTng
Linux Trace Toolkit(LTT)项目由 Karim Yaghmour 发起,目的是跟踪内核活动,并且是最早为 Linux 内核提供的跟踪工具之一。后来,Mathieu Desnoyers 接手了这个想法,并将其重新实现为下一代跟踪工具,LTTng。之后,项目扩展到了包括用户空间的跟踪。项目官网在lttng.org/
,并包含了一本全面的用户手册。
LTTng 由三个组件组成:
-
一个核心会话管理器
-
一个作为一组内核模块实现的内核跟踪器
-
一个作为库实现的用户空间跟踪器
除了这些,你还需要一个跟踪查看器,比如Babeltrace(babeltrace.org/
)或Eclipse Trace Compass插件,用于在主机或目标设备上显示和过滤原始跟踪数据。
LTTng 需要一个配置了CONFIG_TRACEPOINTS
的内核,当你选择Kernel hacking | Tracers | Kernel Function Tracer时,会启用该选项。
以下描述指的是 LTTng 版本 2.13。其他版本可能会有所不同。
LTTng 和 Yocto 项目
你需要将这些包添加到conf/local.conf
中的目标依赖项:
IMAGE_INSTALL:append = " lttng-tools lttng-modules lttng-ust"
如果你想在目标设备上运行 Babeltrace,也需要添加babeltrace2
包。
LTTng 和 Buildroot
你需要启用以下选项:
-
Target packages | Debugging, profiling and benchmark | lttng-modules菜单中的
BR2_PACKAGE_LTTNG_MODULES
-
Target packages | Debugging, profiling and benchmark | lttng-tools菜单中的
BR2_PACKAGE_LTTNG_TOOLS
对于用户空间的跟踪,请启用以下选项:
-
Target packages | System tools | util-linux | uuidd菜单中的
BR2_PACKAGE_UTIL_LINUX_UUIDD
-
Target packages | Libraries | Other | lttng-libust菜单中的
BR2_PACKAGE_LTTNG_LIBUST
-
Host utilities | host babeltrace2 菜单中的
BR2_PACKAGE_HOST_BABELTRACE2
目标设备上有一个名为babletrace2
的包。Buildroot 在output/host/usr/bin/babeltrace2
中为主机安装了babeltrace2
。
使用 LTTng 进行内核跟踪
LTTng 可以使用前面描述的 Ftrace 事件集作为潜在的跟踪点。最初,它们是禁用的。
LTTng 的控制接口是lttng
命令。您可以使用以下命令列出内核探针:
# lttng list --kernel
Kernel events:
-------------
writeback_nothread (loglevel: TRACE_EMERG (0)) (type: tracepoint)
writeback_queue (loglevel: TRACE_EMERG (0)) (type: tracepoint)
writeback_exec (loglevel: TRACE_EMERG (0)) (type: tracepoint)
<…>
跟踪是在会话上下文中捕获的,例如,在这个示例中称为test
:
# lttng create test
Session test created.
Traces will be written in /home/root/lttng-traces/test20150824-140942
# lttng list
Available tracing sessions:
1) test (/home/root/lttng-traces/test-20150824-140942) [inactive]
现在在当前会话中启用几个事件。您可以使用--all
选项启用所有内核跟踪点,但请记住有关生成过多跟踪数据的警告。让我们从几个与调度器相关的跟踪事件开始:
# lttng enable-event --kernel sched_switch,sched_process_fork
检查所有设置是否已完成:
# lttng list test
Tracing session test: [inactive]
Trace path: /home/root/lttng-traces/test-20150824-140942
Live timer interval (usec): 0
=== Domain: Kernel ===
Channels:
-------------
- channel0: [enabled]
Attributes:
overwrite mode: 0
subbufers size: 26214
number of subbufers: 4
switch timer interval: 0
read timer interval: 200000
trace file count: 0
trace file size (bytes): 0
output: splice()
Events:
sched_process_fork (loglevel: TRACE_EMERG (0)) (type: tracepoint) [enabled]
sched_switch (loglevel: TRACE_EMERG (0)) (type: tracepoint) [enabled]
现在开始跟踪:
# lttng start
运行测试负载,然后停止跟踪:
# lttng stop
会话的跟踪结果将写入会话目录,lttng-traces/<session>/kernel
。
使用 Babeltrace 查看器以文本格式转储原始跟踪数据。在本例中,我在主机上运行了它:
$ babeltrace2 lttng-traces/test-20150824-140942/kernel
输出内容过于冗长,无法完全显示在本页上,所以我会留给您的练习是以这种方式捕获和显示跟踪。使用 Babeltrace 的文本输出的优点是可以使用grep
和类似命令轻松搜索字符串。
图形跟踪查看器的一个不错选择是 Eclipse 的Trace Compass插件,它现在是 Eclipse IDE 的一部分,适用于 C/C++开发者包。将跟踪数据导入 Eclipse 通常有些复杂。请按照以下步骤操作:
-
打开跟踪视图。
-
通过选择文件 | 新建 | 跟踪项目来创建新项目。
-
输入项目名称,然后单击完成。
-
在项目资源管理器菜单中右键单击新建项目选项,然后选择导入。
-
展开跟踪,然后选择跟踪导入。
-
浏览到包含跟踪的目录(例如,
test-20150824-140942
),选中要包括的子目录(可能是kernel),然后单击完成。 -
展开项目,展开跟踪[1],然后双击kernel。
现在,让我们远离 LTTng,直接跳入最新和最伟大的 Linux 事件跟踪器。
使用 eBPF
伯克利数据包过滤器(BPF)是一项技术,最早于 1992 年引入,用于捕获、过滤和分析网络流量。2013 年,Alexi Starovoitov 在 Daniel Borkmann 的帮助下重写了 BPF。他们的工作,后来称为扩展 BPF(eBPF),在 2014 年合并到内核中,自 Linux 3.15 版本以来一直可用。eBPF 为在 Linux 内核内运行程序提供了一个沙盒执行环境。eBPF 程序用 C 语言编写,并且即时编译(JIT)成本地代码。在这之前,eBPF 中间字节码必须通过一系列安全检查,以防止程序崩溃内核。
尽管 eBPF 起源于网络,但现在已经成为一个在 Linux 内核中运行的通用虚拟机。通过使得在特定内核和应用程序事件上运行小程序变得简单,eBPF 已迅速成为 Linux 最强大的跟踪工具。就像 cgroups 对容器化部署的影响一样,eBPF 有潜力通过使用户能够全面监控生产系统,彻底革新可观察性。Netflix 和 Facebook 在其微服务和云基础设施中广泛使用 eBPF 进行性能分析,并防止 分布式拒绝服务(DDoS)攻击。
eBPF 周围的工具正在不断发展,BPF 编译器集合(BCC)和 bpftrace 已经成为两个最突出的前端工具。Brendan Gregg 深度参与了这两个项目,并在他的书籍《BPF 性能工具:Linux 系统和应用程序可观察性》中广泛撰写了有关 eBPF 的内容。由于 eBPF 涉及的范围如此广泛,许多可能性可能让人感觉不知所措。但和 cgroups 一样,我们并不需要理解 eBPF 的具体工作原理,就能开始利用它。BCC 提供了多个现成的工具和示例,我们可以直接从命令行运行。
为 eBPF 配置内核
一个名为 ply 的软件包(github.com/iovisor/ply
)于 2021 年 1 月 23 日合并到 Buildroot 中,并将在 Buildroot 2021.02 LTS 版本中包含。ply 是一个轻量级的动态跟踪器,利用 eBPF 使得探针可以附加到内核中的任意位置。与依赖 BCC 的 bpftrace
不同,ply
不依赖 LLVM,并且除了 libc
之外没有其他外部依赖。这使得它更容易移植到嵌入式 CPU 架构,如 arm
和 powerpc
。
让我们从为 Raspberry Pi 4 配置启用 eBPF 的内核开始:
$ cd buildroot
$ make clean
$ make raspberrypi4_64_defconfig
$ make linux-configure
make linux-configure
命令将在获取、解压并配置内核源代码之前,下载并构建一些主机工具。来自 Buildroot 2024.02.6 LTS 版本的 raspberrypi4_64_defconfig
指向了 Raspberry Pi 基金会 GitHub 分支中的自定义 6.1 内核源代码 tarball。检查你的 raspberrypi4_64_defconfig
内容,以验证你使用的内核版本。一旦 make linux-configure
配置了内核,我们可以重新配置它以支持 eBPF:
$ make linux-menuconfig
要从交互式菜单中搜索特定的内核配置选项,请按 / 并输入搜索字符串。搜索结果应返回一个匹配项的编号列表。输入某个编号即可直接跳转到该配置选项。
至少需要选择以下选项,以启用内核对 eBPF 的支持:
CONFIG_BPF=y
CONFIG_BPF_SYSCALL=y
以下项是为 BCC 设计的,但添加它们不会有害:
CONFIG_NET_CLS_BPF=m
CONFIG_NET_ACT_BPF=m
CONFIG_BPF_JIT=y
添加这些选项,以便用户能够编译并附加 eBPF 程序到 kprobe
、uprobe
和 tracepoint
事件:
CONFIG_HAVE_EBPF_JIT=y
CONFIG_BPF_EVENTS=y
为了让 ply
正常工作,需要选择以下项:
CONFIG_KPROBES=y
CONFIG_TRACEPOINTS=y
CONFIG_FTRACE=y
CONFIG_DYNAMIC_FTRACE=y
CONFIG_KPROBE_EVENTS_ON_NOTRACE=y
在退出make
linux-menuconfig
时确保保存你的更改,以便它们能被应用到output/build/linux-custom/.config
中,然后再构建你的 eBPF 启用内核。
使用 Buildroot 构建 ply
让我们构建ply
并安装该工具及一些示例脚本。ply
脚本被打包在MELD/Chapter20/
目录下的ebpf
包中,便于安装。要将它们复制到你的 2024.02.06 LTS 版 Buildroot 中:
$ cd ~
$ cp -a MELD/Chapter20/buildroot/* buildroot
现在构建适用于 Raspberry Pi 4 的ply
镜像:
$ cd buildroot
$ make rpi4_64_ply_defconfig
$ make
如果你的 Buildroot 版本是 2024.02.06 LTS,并且你正确地从MELD/Chapter20
复制了buildroot
叠加文件,那么ply
镜像应该能够成功构建。为该镜像构建的内核已为 eBPF 配置,因此不需要执行之前的linux-menuconfig
步骤。ply
镜像还会在/sys/kernel/debug
自动挂载debugfs
,因此ply
在启动时已经准备好运行。
将已完成的 microSD 卡插入到你的 Raspberry Pi 4 中,使用以太网电缆将其连接到本地网络,并启动设备。使用arp-scan
定位你的 Raspberry Pi 4 的 IP 地址,并使用你在上一节中设置的密码通过 SSH 以root
身份登录。我在configs/rpi4_64_ply_defconfig
中设置的root
密码是temppwd
,它包含在我提供的MELD/Chapter20/buildroot
叠加文件中。现在,我们准备好亲身体验 eBPF 的实验了。
使用 ply
使用 eBPF 几乎做任何事情,包括运行ply
工具和示例,都需要root
权限,这就是我们通过 SSH 启用了root
登录的原因。另一个前提是挂载debugfs
。如果你的/etc/fstab
中没有debugfs
条目,则需要从命令行挂载debugfs
:
# mount -t debugfs none /sys/kernel/debug
我们从按功能统计系统范围内的syscalls
开始:
# ply 'k:__arm64_sys_* { @syscalls[caller] = count(); }'
^C
@syscalls:
{ __arm64_sys_ppoll }: 1
{ __arm64_sys_rt_sigaction }: 2
{ __arm64_sys_rt_sigreturn }: 3
{ __arm64_sys_writev }: 12
{ __arm64_sys_brk }: 13
{ __arm64_sys_pselect6 }: 19
{ __arm64_sys_perf_event_open }: 174
{ __arm64_sys_epoll_pwait }: 176
{ __arm64_sys_newfstatat }: 188
{ __arm64_sys_close }: 205
{ __arm64_sys_ioctl }: 247
{ __arm64_sys_read }: 370
{ __arm64_sys_openat }: 383
注意,ply
会话会在用户按下Ctrl + C时终止,并显示追踪结果。你可能需要重复按Ctrl + C,直到ply
会话最终终止。
ply
脚本所在的目录不在PATH
环境变量中,因此请导航到该目录以便更轻松地执行:
# cd /root
我们从一个系统范围的脚本开始,该脚本显示读取大小的直方图:
# ./read-dist.ply
^C
@:
{ retsize }:
[ 2, 3] 1 ┤▏ │
...
[ 8, 15] 1 ┤▏ │
[ 16, 31] 1 ┤▏ │
...
[ 256, 511] 181 ┤███████████████████████████████▌│
tcp-send-recv.ply
脚本按可执行文件和方向统计 TCP I/O:
# ./tcp-send-recv.ply &
# redis-cli --latency
min: 0, max: 1, avg: 0.29 (1033 samples)^C
# fg %1
./tcp-send-recv.ply
^C
@:
{ dropbear , recv }: 26
{ redis-cli , recv }: 1033
{ redis-cli , send }: 1033
{ redis-server , send }: 1033
{ redis-server , recv }: 1034
{ dropbear , send }: 1048
在这个例子中,我在运行 Redis 客户端/服务器延迟测试时,追踪所有对tcp_sendmsg
和tcp_recvmsg
的调用。我是在 SSH 终端中进行测试的,所以也会报告dropbear
的 TCP I/O。显示的样本数从0
增加到1033
,这解释了dropbear
发送的1048
次。
heap-allocs.ply
脚本显示堆分配次数。我在 Redis 上进行了 100,000 个键的 LRU 缓存仿真:
# redis-cli flushall
OK
# ./heap-allocs.ply &
# redis-cli --lru-test 100000
40500 Gets/sec | Hits: 18606 (45.94%) | Misses: 21894 (54.06%)
41000 Gets/sec | Hits: 32880 (80.20%) | Misses: 8120 (19.80%)
40250 Gets/sec | Hits: 35996 (89.43%) | Misses: 4254 (10.57%)
41000 Gets/sec | Hits: 38091 (92.90%) | Misses: 2909 (7.10%)
41000 Gets/sec | Hits: 38766 (94.55%) | Misses: 2234 (5.45%)
41000 Gets/sec | Hits: 39277 (95.80%) | Misses: 1723 (4.20%)
41000 Gets/sec | Hits: 39597 (96.58%) | Misses: 1403 (3.42%)
41000 Gets/sec | Hits: 39807 (97.09%) | Misses: 1193 (2.91%)
41000 Gets/sec | Hits: 39916 (97.36%) | Misses: 1084 (2.64%)
^C
# fg %1
./heap-allocs.ply
^C
@heap_allocs:
{ redis-cli , 215 }: 1027
请注意,PID
为 215
的 redis-cli
实例进行了 1027
次堆内存分配。至此,我们已经介绍了 Linux 事件追踪工具:Ftrace、LTTng 和 eBPF。它们都至少需要一些内核配置才能工作。而 Valgrind 提供了更多的分析工具,完全在用户空间中运行,使用起来更为便捷。
使用 Valgrind
我在 第十八章 中介绍了 Valgrind,作为使用 memcheck
工具识别内存问题的工具。Valgrind 还有其他用于应用程序分析的有用工具。我在这里要讨论的两个工具是 Callgrind 和 Helgrind。由于 Valgrind 通过在沙箱中运行代码的方式工作,它可以在代码执行时检查代码并报告某些行为,而本地的追踪器和分析器无法做到这一点。
Callgrind
Callgrind 是一个生成调用图的分析器,它还收集有关处理器缓存命中率和分支预测的信息。如果你的瓶颈在于 CPU,Callgrind 会非常有用。如果涉及到重度 I/O 或多个进程,Callgrind 就不太适用了。
Valgrind 不需要内核配置,但需要调试符号。它在 The Yocto Project 和 Buildroot 中作为一个目标包提供(BR2_PACKAGE_VALGRIND
)。
你可以在目标系统上通过 Valgrind 运行 Callgrind,命令如下:
# valgrind --tool=callgrind <program>
这将生成一个名为 callgrind.out.<PID>
的文件,你可以将其复制到主机上,并使用 callgrind_annotate
进行分析。
默认情况下,它会将所有线程的数据捕获到一个单独的文件中。如果你在捕获时添加 --separate-threads=yes
选项,则每个线程都会有各自的分析文件,文件名为 callgrind.out.<PID>-<thread id>
。
Callgrind 可以模拟处理器的 L1/L2 缓存,并报告缓存未命中的情况。使用 --simulate-cache=yes
选项来捕获跟踪数据。L2 缓存未命中的代价要高于 L1,因此要特别注意具有高 D2mr
或 D2mw
计数的代码。
Callgrind 的原始输出可能会非常复杂且难以整理。像 KCachegrind 这样的可视化工具(kcachegrind.github.io/html/Home.html
)可以帮助你浏览 Callgrind 收集的大量数据。
Helgrind
Helgrind 是一个线程错误检测工具,用于检测 C、C++ 和 Fortran 程序中包含 POSIX 线程的同步错误。
Helgrind 可以检测三类错误。首先,它可以检测 API 使用不当的情况。例如,解锁已经解锁的互斥锁、解锁由不同线程锁定的互斥锁,或者没有检查某些 pthread
函数的返回值。其次,它监控线程获取锁的顺序,以检测可能导致死锁(也称为致命拥抱)的循环。最后,它检测数据竞争,当两个线程在没有使用适当的锁或其他同步机制来确保单线程访问时,访问共享内存位置,就会发生数据竞争。
使用 Helgrind 很简单;你只需要以下命令:
# valgrind --tool=helgrind <program>
它在找到问题和潜在问题时会打印出来。你可以通过添加--log-file=<文件名>
将这些信息导入到文件中。
Callgrind 和 Helgrind 依赖于 Valgrind 的虚拟化技术来进行性能分析和死锁检测。这种重量级的方法会减慢程序的执行速度,增加观察者效应的可能性。
有时,我们程序中的 bug 是如此容易重现且容易隔离,以至于只需要一个简单、低侵入性的工具,就能快速调试它们。这种工具往往就是strace
。
使用 strace
我以一个简单且普遍使用的工具top
开始本章,我将以另一个工具结束:strace。它是一个非常简单的跟踪器,捕捉程序及其子进程的系统调用。你可以用它来做以下事情:
-
学习一个程序所执行的系统调用。
-
查找那些失败的系统调用及其错误代码。如果程序启动失败,但没有打印错误信息,或者错误信息过于笼统,我发现这个功能非常有用。
-
查找一个程序打开了哪些文件。
-
查明一个正在运行的程序正在执行哪些
syscalls
,例如,看看它是否卡在某个循环中。
网上有很多更多的示例。只需搜索strace
的技巧和窍门。每个人都有一个自己喜欢的strace
故事,例如,alexbilson.dev/plants/technology/debug-a-program-with-strace/
。
strace
使用ptrace(2)
函数来挂钩从用户空间到内核的调用。如果你想了解更多关于ptrace
是如何工作的,手册页非常详细且出乎意料的易懂。
获取 trace 的最简单方法是将命令作为参数传递给strace
(为了清晰,列表已编辑):
# strace ./helloworld
execve("./helloworld", ["./helloworld"], [/* 14 vars */]) = 0
brk(0) = 0x11000
uname({sys="Linux", node="beaglebone", ...}) = 0
mmap2(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xb6f40000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat64(3, {st_mode=S_IFREG|0644, st_size=8100, ...}) = 0
mmap2(NULL, 8100, PROT_READ, MAP_PRIVATE, 3, 0) = 0xb6f3e000
close(3) = 0
open("/lib/tls/v7l/neon/vfp/libc.so.6", O_RDONLY|O_CLOEXEC) = -1
ENOENT (No such file or directory)
<…>
open("/lib/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\1\1\1\0\0\0\0\0\0\0\0\0\3\0(\0\1\0\0\0$`\1\0004\0\0\0"..., 512) = 512
fstat64(3, {st_mode=S_IFREG|0755, st_size=1291884, ...}) = 0
mmap2(NULL, 1328520, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0xb6df9000
mprotect(0xb6f30000, 32768, PROT_NONE) = 0
mmap2(0xb6f38000, 12288, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x137000) = 0xb6f38000
mmap2(0xb6f3b000, 9608, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0xb6f3b000
close(3)
<…>
write(1, "Hello, world!\n", 14Hello, world!) = 14
exit_group(0) = ?
+++ exited with 0 +++
大多数的 trace 展示了运行时环境是如何创建的。特别是,你可以看到库加载器是如何寻找libc.so.6
的,最终在/lib
中找到它。最后,它开始运行程序的main()
函数,打印出消息并退出。
如果你希望strace
跟踪原始进程创建的任何子进程或线程,可以添加-f
选项。
提示
如果你使用strace
跟踪创建线程的程序,几乎可以肯定你会想使用-f
选项。更好的是,使用-ff
和-o <文件名>
,这样每个子进程或线程的输出将被写入一个单独的文件,文件名为<filename>.<PID | TID>
。
strace
的一个常见用途是发现程序在启动时尝试打开的文件。你可以通过-e
选项限制跟踪的系统调用,并且可以使用-o
选项将跟踪结果写入文件,而不是stdout
:
# strace -e open -o ssh-strace.txt ssh localhost
这展示了ssh
在建立连接时打开的库文件和配置文件。
你甚至可以将strace
作为一个基本的性能分析工具。如果你使用-c
选项,它会累计系统调用所花费的时间,并打印出如下总结:
# strace -c grep linux /usr/lib/* > /dev/null
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ---------
78.68 0.012825 1 11098 18 read
11.03 0.001798 1 3551 write
10.02 0.001634 8 216 15 open
0.26 0.000043 0 202 fstat64
0.00 0.000000 0 201 close
0.00 0.000000 0 1 execve
0.00 0.000000 0 1 1 access
0.00 0.000000 0 3 brk
0.00 0.000000 0 199 munmap
0.00 0.000000 0 1 uname
0.00 0.000000 0 5 mprotect
0.00 0.000000 0 207 mmap2
0.00 0.000000 0 15 15 stat64
0.00 0.000000 0 1 getuid32
0.00 0.000000 0 1 set_tls
------ ----------- ----------- --------- --------- ----------
100.00 0.016300 15702 49 total
strace
极其多功能。我们只是略微触及了该工具的表面。
我推荐下载 使用 strace 监视你的程序,这是 Julia Evans 编写的免费杂志,网址是 wizardzines.com/zines/strace/
。
总结
没有人会抱怨 Linux 在分析和跟踪方面缺乏选项。本章为您概述了一些最常见的工具。
当遇到一个系统性能不如预期时,从 top
开始并尝试识别问题。如果问题出在某个特定的应用程序,那么你可以使用 perf record
/report
对其进行分析。请记住,你需要配置内核以启用 perf
,并且需要调试符号来支持二进制文件和内核。如果问题并不容易定位,可以使用 perf
或 ply
来获取系统范围的视图。
当你对内核的行为有具体问题时,Ftrace 显得尤为重要。function
和 function_graph
追踪器提供了函数调用关系和顺序的详细视图。事件追踪器则允许你提取更多关于函数的信息,包括参数和返回值。
LTTng 执行类似的功能,利用事件追踪机制,并增加高速环形缓冲区,以便从内核提取大量数据。
Valgrind 具有在沙箱中运行代码的优势,并且能够报告其他方式难以追踪的错误。使用 Callgrind 时,它可以生成调用图并报告处理器缓存的使用情况,而使用 Helgrind 时,则能报告与线程相关的问题。
最后,不要忘记strace
。它是一个很好的备用工具,可以帮助你查明程序正在执行哪些系统调用,从跟踪文件打开调用到查找文件路径名,甚至检查系统唤醒和接收信号。
在此期间,务必注意并尽量避免观察者效应,确保你的测量结果适用于生产系统。在下一章中,我们将深入探讨有助于量化目标系统实时性能的延迟追踪器。
进一步学习
-
使用 perf 进行分析和追踪,由 Julia Evans 编写
-
系统性能:企业与云计算,第 2 版,由 Brendan Gregg 编写
-
BPF 性能工具:Linux 系统与应用程序可观察性,由 Brendan Gregg 编写
-
ply:轻量级 eBPF 追踪,由 Frank Vasquez 编写:
www.youtube.com/watch?v=GuEEJlU9Mr8
-
使用 strace 监视你的程序,由 Julia Evans 编写
第二十一章:实时编程
计算机系统与现实世界之间的大部分交互都发生在实时中,因此这是嵌入式系统开发者需要关注的重要主题。我已经在多个地方讨论了实时编程:在第十七章中,我们研究了调度策略和优先级反转,在第十八章中,我描述了页面错误的问题以及内存锁定的必要性。现在是时候将这些话题结合起来,深入探讨实时编程了。
在本章中,我将首先讨论实时系统的特性,然后考虑这些特性对系统设计的影响,涉及应用层和内核层。我将描述实时PREEMPT_RT
内核补丁,并展示如何获取它并将其应用到主线内核中。最后的部分将描述如何使用两个工具cyclictest和Ftrace来表征系统延迟。
还有其他方法可以在嵌入式 Linux 设备上实现实时行为,例如,使用专用微控制器或在 Linux 内核旁边运行一个单独的实时内核,像 Xenomai 和 RTAI 那样。我在这里不讨论这些方法,因为本书的重点是使用 Linux 作为嵌入式系统的核心。
在本章中,我们将涵盖以下主题:
-
什么是实时?
-
识别非确定性的来源
-
理解调度延迟
-
内核抢占
-
可抢占内核锁
-
高分辨率定时器
-
避免页面错误
-
中断屏蔽
-
测量调度延迟
技术要求
要跟随示例进行操作,请确保您已经具备以下条件:
-
一个 Ubuntu 24.04 或更高版本的 LTS 主机系统,至少有 90 GB 的空闲磁盘空间
-
Yocto 5.0(Scarthgap)LTS 版本
-
一个 microSD 卡读卡器和卡
-
用于 Linux 的 balenaEtcher
-
一根以太网线和一个具有可用端口的路由器用于网络连接
-
一个 BeaglePlay
-
一个能够提供 3A 电流的 5V USB-C 电源
您应该已经构建了 Yocto 的 5.0(Scarthgap)LTS 版本,详见第六章。如果没有,请在按照第六章中的说明在 Linux 主机上构建 Yocto 之前,参考兼容的 Linux 发行版和构建主机软件包部分,内容请见Yocto 项目快速构建指南(docs.yoctoproject.org/brief-yoctoprojectqs/
)。
什么是实时?
实时编程的性质是软件工程师们喜爱长篇讨论的主题之一,通常会给出一系列相互矛盾的定义。我将首先阐述我认为关于实时最重要的内容。
一个任务是实时任务,如果它必须在某个特定时间点之前完成,这个时间点被称为截止时间。通过考虑在编译 Linux 内核时播放音频流的情况,可以区分实时任务和非实时任务。播放音频流是实时任务,因为有数据流不断到达音频驱动程序,必须按播放速率将音频样本块写入音频接口。而编译则不是实时的,因为没有截止时间。你只是希望它尽快完成;无论是 10 秒钟还是 10 分钟都不会影响内核二进制文件的质量。
另一个需要考虑的重要因素是错过截止时间的后果,这可能从轻微的烦恼到系统故障,甚至在极端情况下可能导致伤害或死亡。以下是一些例子:
-
播放音频流:截止时间大约在几十毫秒。如果音频缓冲区下溢,你会听到一个咔嚓声,这很烦人,但你很快就能适应。
-
移动和点击鼠标:截止时间也是在毫秒级别的。如果错过了,鼠标会不规则移动,按钮点击会丢失。如果问题持续存在,系统将变得无法使用。
-
打印纸张:纸张进给的截止时间在毫秒级别,如果错过了,可能会导致打印机卡纸,需要有人去修复。偶尔的卡纸是可以接受的,但没有人会购买一台经常卡纸的打印机。
-
在生产线瓶子上打印保质期:如果某个瓶子没有打印,整个生产线必须停止,瓶子被移除,并重新启动生产线,这样会非常昂贵。
-
烤蛋糕:大约有 30 分钟的截止时间。如果错过几分钟,蛋糕可能会烤坏。如果错过很长时间,房子可能会着火。
-
电涌检测系统:如果系统检测到电涌,必须在 2 毫秒内触发断路器。未能做到这一点会导致设备损坏,并可能造成伤害甚至死亡。
换句话说,错过截止时间会带来很多后果。我们通常会谈论这些不同的类别:
-
软实时:截止时间是理想的,但有时会错过,而系统不会因此被认为是失败。前面列表中的前两个例子就是这种情况的例子。
-
硬实时:在这种情况下,错过截止时间会产生严重后果。我们可以进一步将硬实时系统细分为任务关键型系统,其中错过截止时间会带来一定代价(如第四个例子),以及安全关键型系统,在这些系统中,错过截止时间可能会危及生命安全(如最后两个例子)。我加入烤蛋糕的例子是为了说明并非所有硬实时系统的截止时间都以毫秒或微秒为单位。
为安全关键系统编写的软件必须符合各种标准,以确保其具备可靠执行的能力。对于像 Linux 这样复杂的操作系统来说,满足这些要求是非常困难的。
对于任务关键系统,Linux 常常被用于各种控制系统,并且这种做法是可行且常见的。软件的需求取决于截止时间和置信度的组合,通常可以通过广泛的测试来确定。
因此,要说一个系统是实时的,必须在最大预期负载下测量其响应时间,并证明它在一定比例的时间内满足截止时间。作为经验法则,配置良好的使用主线内核的 Linux 系统适用于具有几十毫秒截止时间的软实时任务,而使用PREEMPT_RT
补丁的内核适用于具有几百微秒截止时间的软硬实时任务关键系统。
创建实时系统的关键是减少响应时间的可变性,这样你就能更有信心确保不会错过截止时间;换句话说,你需要让系统变得更具确定性。通常,这会以牺牲性能为代价。例如,缓存通过缩短访问数据项的平均时间来加速系统运行,但在缓存未命中的情况下,最大时间会更长。缓存使系统变得更快,但确定性更差,这正是我们不希望的。
提示
实时计算的一个误区是认为它很快。事实并非如此;一个系统越是确定性,最大吞吐量就越低。
本章的其余部分将讨论识别延迟的原因以及可以采取的减少措施。
确定非确定性来源
从根本上说,实时编程是确保控制实时输出的线程在需要时能够被调度,从而在截止时间前完成工作。任何阻止这一点的因素都是问题。以下是一些常见的问题领域:
-
调度:实时线程必须优先调度,因此它们必须采用实时策略,如
SCHED_FIFO
或SCHED_RR
。此外,它们应该按优先级降序分配,首先是具有最短截止时间的线程,这符合我在第十七章中描述的速率单调分析理论。 -
调度延迟:内核必须能够在事件发生时(如中断或定时器触发)立即重新调度,而不应受到无界延迟的影响。减少调度延迟是本章后续讨论的一个关键主题。
-
优先级倒置:这是基于优先级调度的一个后果,当一个高优先级线程被低优先级线程持有的互斥锁阻塞时,会导致无界延迟,正如我在 第十七章 中所描述的那样。用户空间有优先级继承和优先级天花板互斥锁;在内核空间,我们有 RT-互斥锁,它实现了优先级继承,稍后我会在实时内核部分讨论它们。
-
精确计时器:如果你想管理低毫秒级或微秒级的截止时间,你需要相应的计时器。高分辨率计时器至关重要,几乎所有内核都提供这个配置选项。
-
页面错误:在执行代码的临界区时发生页面错误将打乱所有的时间估算。你可以通过锁定内存来避免它们,正如我稍后所描述的那样。
-
中断:中断发生在不可预测的时刻,如果中断突然大量涌入,可能会导致意外的处理开销。避免这种情况有两种方法。一种是将中断作为内核线程运行,另一种是在多核设备上,屏蔽一个或多个 CPU 的中断处理。我将在后续部分讨论这两种可能性。
-
处理器缓存:这些提供了 CPU 与主内存之间的缓冲区,像所有缓存一样,处理器缓存是非确定性的来源,尤其是在多核设备上。不幸的是,这超出了本书的范围,但你可以参考本章末尾的参考资料以获取更多详细信息。
-
内存总线争用:当外设通过 DMA 通道直接访问内存时,它们会占用一部分内存总线带宽,这会导致 CPU 核心(或多个核心)访问变慢,从而增加程序执行的非确定性。然而,这是一个硬件问题,也超出了本书的范围。
我将在接下来的部分扩展讨论最重要的问题,并探讨如何应对这些问题。
理解调度延迟
实时线程需要在有任务时立即调度。然而,即使没有其他同等或更高优先级的线程,从唤醒事件发生的时刻(中断或系统定时器)到线程开始运行之间,总是存在一定的延迟。这就是所谓的调度延迟。它可以分解成几个组成部分,如下图所示:
图 21.1 – 调度延迟
首先,硬件中断延迟是指从中断发生到 中断服务例程(ISR)开始运行之间的时间延迟。这其中有一小部分是中断硬件本身的延迟,但最大的问题是由于中断在软件中被禁用。最小化这个 IRQ 关闭时间 非常重要。
接下来是中断延迟,这是从中断服务例程(ISR)处理完中断并唤醒等待该事件的线程的时间。它主要取决于 ISR 的编写方式。通常,它应该只需要很短的时间,单位为微秒。
最后的延迟是抢占延迟,即从内核被通知线程准备好运行,到调度程序实际运行该线程的时间。它取决于内核是否可以被抢占。如果内核正在执行关键区段的代码,那么重新调度将不得不等待。延迟的长度取决于内核抢占的配置。
内核抢占
抢占延迟发生的原因是当前线程的执行并不总是安全或可取的去抢占,并调用调度程序。主线 Linux 提供了三种抢占设置,通过 内核特性 | 抢占模式 菜单进行选择:
-
CONFIG_PREEMPT_NONE
:无抢占。 -
CONFIG_PREEMPT_VOLUNTARY
:启用对抢占请求的额外检查。 -
CONFIG_PREEMPT
:允许内核被抢占。
当抢占设置为 none
时,内核代码将继续执行,直到通过 syscall
返回到用户空间,此时总是允许抢占,或者遇到一个使当前线程停止的睡眠等待。由于它减少了内核和用户空间之间的过渡次数,并且可能减少总的上下文切换次数,因此该选项在牺牲较大的抢占延迟的情况下,能获得最高的吞吐量。它是服务器和一些桌面内核的默认设置,在这些情况下吞吐量比响应性更为重要。
第二个选项启用显式抢占点,如果设置了 need_resched
标志,调度程序将被调用,这减少了最坏情况下的抢占延迟,代价是吞吐量略微降低。一些发行版会在桌面上设置此选项。
第三个选项使内核可以被抢占,这意味着只要内核不在原子上下文中执行,外部中断就可以导致立即的调度。这减少了最坏情况下的抢占延迟,因此,在典型的嵌入式硬件上,总的调度延迟可以缩短至几毫秒左右。
这通常被描述为软实时选项,大多数嵌入式内核都配置为这种方式。当然,这会导致总体吞吐量稍微降低,但相比于为嵌入式设备提供更具确定性的调度,这通常并不那么重要。
实时 Linux 内核(PREEMPT_RT)
长期以来,一项致力于进一步减少延迟的工作被称为内核配置选项PREEMPT_RT。该项目由 Ingo Molnar、Thomas Gleixner 和 Steven Rostedt 发起,多年来得到了更多开发者的贡献。内核补丁可以在www.kernel.org/pub/linux/kernel/projects/rt
找到,同时也有一个维基页面:wiki.linuxfoundation.org/realtime/start
。
重要提示
PREEMPT_RT
已于 2024 年 9 月 20 日完全合并并启用在主线 Linux 内核中。PREEMPT_RT
对 x86、x86-64、arm64 和 riscv 架构的支持包含在 2024 年 11 月 17 日发布的 Linux 6.12 LTS 版本中。
中心计划是减少内核在原子上下文中运行的时间,在这种上下文中,调用调度程序并切换到其他线程是不安全的。典型的原子上下文包括内核处于以下状态时:
-
正在运行中断或陷阱处理程序。
-
持有自旋锁或处于 RCU 关键区段。自旋锁和 RCU 是内核锁原语,其细节在这里不做探讨。
-
在调用
preempt_disable()
和preempt_enable()
之间。 -
硬件中断被禁用(IRQs off)。
PREEMPT_RT
所包含的更改有两个主要目标:一个是通过将中断处理程序转换为内核线程来减少中断处理的影响,另一个是使锁可抢占,以便线程在持有锁时能够休眠。显然,这些更改有较大的开销,这使得平均情况下的中断处理变得较慢,但却更加确定性,这正是我们所追求的。
线程化中断处理程序
不是所有的中断都会触发实时任务,但所有的中断都会从实时任务中窃取周期。线程化中断处理程序允许为中断分配优先级,并在适当的时间安排它,如下图所示:
图 21.2 – 内联与线程化中断处理程序
如果中断处理程序代码以内核线程的形式运行,则没有理由不能被更高优先级的用户空间线程抢占,因此中断处理程序不会导致用户空间线程的调度延迟。线程化中断处理程序自 2.6.30 版本以来成为主线 Linux 的一个特性。你可以通过使用request_threaded_irq()
来注册一个中断处理程序,从而将其线程化,取代普通的request_irq()
。你还可以通过配置内核参数CONFIG_IRQ_FORCED_THREADING=y
来使线程化的 IRQ 成为默认,这将使所有的中断处理程序变成线程,除非它们明确通过设置IRQF_NO_THREAD
标志来防止这一点。当启用PREEMPT_RT
时,中断默认被配置为以这种方式作为线程。下面是一个你可能会看到的示例:
# ps -Leo pid,tid,class,rtprio,stat,comm,wchan | grep FF
PID TID CLS RTPRIO STAT COMMAND WCHAN
21 21 FF 99 S migration/0 smpboot_thread_fn
22 22 FF 1 S irq_work/0 smpboot_thread_fn
25 25 FF 1 S irq_work/1 smpboot_thread_fn
26 26 FF 99 S migration/1 smpboot_thread_fn
32 32 FF 1 S irq_work/2 smpboot_thread_fn
33 33 FF 99 S migration/2 smpboot_thread_fn
39 39 FF 1 S irq_work/3 smpboot_thread_fn
40 40 FF 99 S migration/3 smpboot_thread_fn
66 66 FF 50 S watchdogd kthread_worker_fn
78 78 FF 50 S irq/14-4d000000 irq_thread
<…>
103 103 FF 50 S irq/256-8000000 irq_thread
107 107 FF 50 S irq/293-xhci-hc irq_thread
111 111 FF 50 S irq/294-mmc0 irq_thread
112 112 FF 50 S irq/294-s-mmc0 irq_thread
119 119 FF 50 S irq/346-User Ke irq_thread
120 120 FF 50 S irq/476-mmc1 irq_thread
121 121 FF 50 S irq/476-s-mmc1 irq_thread
123 123 FF 50 S irq/295-mmc2 irq_thread
124 124 FF 50 S irq/295-s-mmc2 irq_thread
127 127 FF 50 S irq/472-fa00000 irq_thread
重要提示
中断线程都被赋予了默认的SCHED_FIFO
策略,并且优先级为50
。然而,将它们保持在默认值上没有意义;现在是你根据中断与实时用户空间线程的重要性来分配优先级的机会。
这是一个建议的线程优先级降序排列顺序:
-
POSIX 计时器线程
posixcputmr
应该始终具有最高优先级。 -
与最高优先级实时线程相关的硬件中断。
-
优先级最高的实时线程。
-
逐渐较低优先级的实时线程的硬件中断,随后是线程本身。
-
下一个最高优先级的实时线程。
-
非实时接口的硬件中断。
-
软件 IRQ 守护进程
ksoftirqd
,在 RT 内核中负责运行延迟的中断例程,并且在 Linux 3.6 之前,负责运行网络栈、块 I/O 层以及其他任务。
你可能需要尝试不同的优先级级别以达到平衡。你可以在启动脚本中使用类似以下命令的chrt
命令来更改优先级:
# chrt -f -p 90 `pgrep irq/293-xhci-hcd:usb1`
pgrep
命令是procps
软件包的一部分。
既然我们已经通过线程化的中断处理程序了解了实时 Linux 内核,接下来让我们更深入地探讨其实现。
可抢占内核锁
将大多数内核锁设置为可抢占是PREEMPT_RT
所做的最具侵入性的更改。
问题出现在自旋锁上,许多内核锁就是使用自旋锁的。自旋锁是一种忙等待互斥锁,在竞争的情况下不需要上下文切换,因此只要锁持有时间较短,它就非常高效。理想情况下,它们的锁持有时间应该小于两次重新调度所需的时间。
以下图显示了两个不同 CPU 上运行的线程在争用同一个自旋锁的情况。CPU 0先获得自旋锁,迫使CPU 1进入自旋状态,直到锁被解锁:
图 21.3 – 自旋锁
持有自旋锁的线程无法被抢占,因为这样做可能导致新线程进入相同的代码,并在尝试锁定相同的自旋锁时发生死锁。因此,在主线 Linux 中,锁定自旋锁会禁用内核的抢占,创建一个原子上下文。这意味着持有自旋锁的低优先级线程可能会阻止高优先级线程被调度,这种情况通常被称为优先级反转。
重要说明
PREEMPT_RT
采用的解决方案是将几乎所有自旋锁替换为 RT 互斥锁。互斥锁比自旋锁慢,但它是完全可抢占的。不仅如此,RT 互斥锁实现了优先级继承,因此不会受到优先级反转的影响。
现在我们对PREEMPT_RT
补丁的内容有了一些了解。那么,如何获得这些补丁呢?
获取 PREEMPT_RT 补丁
历史上,RT 开发者并没有为每个内核版本创建补丁集,因为这需要大量的移植工作。平均而言,他们为每隔一个内核版本创建补丁。从内核版本 5.9 开始,情况发生了变化,从那时起,每个内核版本都会生成一个补丁。本文写作时,支持的最新内核版本如下:
-
6.13-rt
-
6.12-rt
-
6.11-rt
-
6.10-rt
-
6.9-rt
-
6.8-rt
-
6.7-rt
-
6.6-rt
-
6.5-rt
-
6.4-rt
-
6.3-rt
-
6.1-rt
重要说明
这些补丁可以在
www.kernel.org/pub/linux/kernel/projects/rt
获取。从6.12-rt
版本开始,补丁包含了尚未合并到官方内核中的功能和优化。
如果你正在使用 Yocto 项目,已经有 RT 版本的内核了。否则,你可能已经从获取内核的地方获得了包含PREEMPT_RT
补丁的版本。如果没有,你就必须自己应用这个补丁。首先,确保PREEMPT_RT
补丁的版本和你的内核版本完全匹配;否则,你将无法干净地应用这些补丁。然后,按正常方式应用它,如以下命令行所示。然后,你就可以通过CONFIG_PREEMPT_RT_FULL
配置内核:
$ cd linux-6.6.74
$ zcat patch-6.6.74-rt48.patch.gz | patch -p1
前一段有个问题。RT
补丁仅在你使用兼容的主线内核时才能应用。你可能没有使用,因为这正是嵌入式 Linux 内核的特点。因此,你需要花一些时间查看失败的补丁,修复它们,然后分析目标板的支持情况,并添加缺失的实时支持。这些细节再次超出了本书的范围。如果你不确定该怎么办,应该向你使用的内核供应商或内核开发者论坛寻求支持。
Yocto 项目与 PREEMPT_RT
Yocto 项目提供了两个标准的内核食谱:linux-yocto
和 linux-yocto-rt
,并且已经应用了实时补丁。假设你的目标硬件被 Yocto 内核支持,你只需要选择 linux-yocto-rt
作为首选内核,并声明你的机器兼容。
由于我们使用 meta-ti-bsp
层为 BeaglePlay 构建 TI 内核,因此需要在 conf/local.conf
中添加以下两行,以构建实时内核:
PREFERRED_PROVIDER_virtual/kernel = "linux-ti-staging-rt"
COMPATIBLE_MACHINE_beagleplay-ti = "beagleplay-ti"
现在我们知道如何获取实时 Linux 内核,让我们换个话题,聊一聊计时。
高分辨率计时器
如果你有精确的计时要求,计时器分辨率就非常重要,这对于实时应用程序来说是典型的。Linux 中的默认计时器是一个以可配置速率运行的时钟,嵌入式系统通常为 100 Hz,服务器和桌面为 250 Hz。两个计时器滴答之间的间隔称为 jiffy,在之前给出的例子中,嵌入式 SoC 上是 10 毫秒,服务器上是 4 毫秒。
Linux 从 2.6.18 版本开始,借助实时内核项目获得了更精确的计时器,现在只要有高分辨率计时器源和设备驱动,它们就可以在所有平台上使用——这几乎总是成立的。你需要通过配置内核 CONFIG_HIGH_RES_TIMERS=y
来启用它。
启用此功能后,所有内核和用户空间的时钟将精确到底层硬件的粒度。找到实际的时钟粒度是困难的。显而易见的答案是通过 clock_getres(2)
提供的值,但它总是声称分辨率为 1 纳秒。
cyclictest
工具有一个选项可以分析时钟报告的时间,以猜测分辨率:
# cyclictest -R
# /dev/cpu_dma_latency set to 0us
WARN: reported clock resolution: 1 nsec
WARN: measured clock resolution approximately: 60 nsec
你也可以查看内核日志消息,查找与时钟相关的字符串,例如:
# dmesg | grep clock
[ 0.000000] clocksource: arch_sys_counter: mask: 0x3ffffffffffffff max_cycles: 0x2e2049d3e8, max_idle_ns: 440795210634 ns60563 Min: 13 Act: 67 Avg: 67 Max: 241
[ 0.000001] sched_clock: 58 bits at 200MHz, resolution 5ns, wraps every 4398046511102ns
[ 0.028415] clocksource: jiffies: mask: 0xffffffff max_cycles: 0xffffffff, max_idle_ns: 1911260446275000 ns
[ 0.058173] PTP clock support registered
[ 0.060830] clocksource: Switched to clocksource arch_sys_counter
[ 0.685471] clk: Disabling unused clocks
这两种方法提供了明显不同的数字,且都低于 1 微秒。内核日志显示的是计时器的基础分辨率(例如,jiffies、HPET、TSC),而不是应用时间保持调整后的有效分辨率。cyclictest
测量的是实际的唤醒延迟,这取决于调度器的唤醒延迟、IRQ 延迟和计时器硬件的准确性。
高分辨率计时器能够以足够的精度测量延迟的变化。现在,让我们来看几个减轻这种非确定性的方法。
避免页面错误
页面错误发生在应用程序读取或写入尚未提交到物理内存的内存时。页面错误的发生时间是不可预测的(或非常难以预测),因此它们是计算机中的另一个非确定性来源。
幸运的是,有一个函数可以让你提交进程使用的所有内存并将其锁定,以确保它不会引发页面错误。这个函数是 mlockall(2)
。它有两个标志:
-
MCL_CURRENT
:锁定当前映射的所有页面。 -
MCL_FUTURE
:锁定将来映射的页面。
通常在应用程序启动时调用 mlockall
,并设置这两个标志以锁定所有当前和未来的内存映射。
提示
MCL_FUTURE
不是魔法,分配或释放堆内存时,使用 malloc()/free()
或 mmap()
时仍然会有非确定性的延迟。这类操作最好在启动时完成,而不是在主控制循环中。
堆栈上分配的内存更为棘手,因为它是自动完成的,如果你调用一个使堆栈比之前更深的函数,你会遇到更多的内存管理延迟。一个简单的解决方法是在启动时将堆栈增长到一个比你认为会需要的更大的大小。代码可能如下所示:
#define MAX_STACK (512*1024)
static void stack_grow (void)
{
char dummy[MAX_STACK];
memset(dummy, 0, MAX_STACK);
return;
}
int main(int argc, char* argv[])
{
<…>
stack_grow ();
mlockall(MCL_CURRENT | MCL_FUTURE);
<…>
stack_grow()
函数在堆栈上分配一个大变量,然后将其清零,以强制这些内存页面提交给此进程。
中断是我们应该防范的另一种非确定性来源。
中断屏蔽
使用线程化的中断处理程序有助于通过以比不影响实时任务的中断处理程序更高的优先级运行某些线程,从而减少中断开销。如果你使用的是多核处理器,你可以采取不同的方法,完全屏蔽一个或多个核心的中断处理,使它们专门用于实时任务。这在正常的 Linux 内核或 PREEMPT_RT
内核中都有效。
实现这一目标的问题在于将实时线程绑定到一个 CPU 上,并将中断处理程序绑定到另一个 CPU 上。你可以使用 taskset 命令行工具设置线程或进程的 CPU 亲和性,或者使用 sched_setaffinity(2)
和 pthread_setaffinity_np(3)
函数。
要设置中断的亲和性,首先注意在 /proc/irq/<IRQ number>
中,每个中断编号都有一个子目录。中断的控制文件在其中,包括 smp_affinity
中的 CPU 掩码。将一个位掩码写入该文件,位设置为允许处理该 IRQ 的每个 CPU。
堆栈增长和中断屏蔽是提高响应性的巧妙技术,但如何判断它们是否真的有效呢?
测量调度延迟
所有的配置和调优都将毫无意义,如果你不能证明你的设备能够满足截止时间。你将需要自己的基准测试进行最终测试,但我在这里将描述两个重要的测量工具:cyclictest
和 Ftrace
。
cyclictest
cyclictest
最初由 Thomas Gleixner 编写,现在在大多数平台上以名为 rt-tests
的软件包提供。
如果你正在构建 Yocto 实时内核,你可以通过构建实时镜像配方来创建一个包含 rt-tests
的目标镜像:
$ bitbake core-image-rt
如果你正在为 BeaglePlay 构建 TI 实时内核,那么请使用 CONFIG_ARM_PSCI_IDLE=y
配置内核,以便 cyclictest
可以写入 /dev/cpu_dma_latency
套接字。
如果您正在为 BeaglePlay 构建 TI 实时内核,然后通过修改conf/local.conf
将rt-tests
附加到您的镜像:
IMAGE_INSTALL:append = " rt-tests"
构建最小镜像配方以在 BeaglePlay 的镜像上安装rt-tests
:
$ bitbake core-image-minimal
如果您使用 Buildroot,则需要在Target packages | Debugging, profiling and benchmark | rt-tests菜单中添加BR2_PACKAGE_RT_TESTS
包。
cyclictest
通过比较休眠所需的实际时间和请求的时间来测量调度延迟。如果没有延迟,它们将相同,报告的延迟将为 0。cyclictest
假设定时器分辨率小于 1 微秒。
它具有大量命令行选项。首先,您可以尝试在目标上以root
身份运行此命令:
# cyclictest -l 100000 -m -p 99
# /dev/cpu_dma_latency set to 0us
policy: fifo: loadavg: 0.00 0.00 0.00 1/119 430
T: 0 ( 422) P:99 I:1000 C: 100000 Min: 5 Act: 7 Avg: 7 Max: 48
所选选项如下:
-
-l N
: 循环 N 次(默认为无限次)。 -
-m
: 使用mlockall
锁定内存。 -
-p N
: 使用实时优先级 N。
结果行从左到右显示以下内容:
-
T: 0
: 这是线程 0,本次运行中的唯一线程。您可以使用-t
参数设置线程数。 -
( 422)
: 这是 PID 422。 -
P:99
: 优先级为 99。 -
I:1000
: 循环之间的间隔为 1,000 微秒。您可以使用-i N
参数设置间隔。 -
C:100000
: 此线程的最终循环计数为 100,000。 -
Min: 5
: 最小延迟为 5 微秒。 -
Act: 7
: 实际延迟为 7 微秒。实际延迟是最近的延迟测量值,仅在您观察cyclictest
运行时才有意义。 -
Avg: 7
: 平均延迟为 7 微秒。 -
Max: 48
: 最大延迟为 48 微秒。
这是在运行linux-ti-staging-rt
内核的空闲系统上进行的快速演示工具。要真正有用,您需要在运行预期的最大负载的同时至少 24 小时内运行测试。cyclictest
是调度延迟的标准度量。但是,它不能帮助您识别和解决特定的内核延迟问题。为此,您需要使用 Ftrace。
使用 Ftrace
内核函数跟踪器有助于跟踪内核延迟,这也是它最初编写的目的。这些跟踪器捕获运行期间检测到的最坏情况延迟的跟踪,显示导致延迟的函数。
兴趣跟踪器以及内核配置参数如下:
-
irqsoff
:CONFIG_IRQSOFF_TRACER
跟踪禁用中断的代码,记录最坏情况。 -
preemptoff
:CONFIG_PREEMPT_TRACER
类似于irqsoff
,但跟踪内核抢占被禁用的最长时间(仅适用于可抢占内核)。 -
preemptirqsoff
: 结合前两个跟踪器,记录禁用irqs
和/或抢占的最长时间。 -
wakeup
: 跟踪并记录最高优先级任务在唤醒后调度所需的最大延迟。 -
wakeup_rt
: 这与 wakeup 相同,但仅适用于具有SCHED_FIFO
、SCHED_RR
或SCHED_DEADLINE
策略的实时线程。 -
wakeup_dl
: 这是相同的,但仅适用于具有SCHED_DEADLINE
策略的期限调度线程。
请注意,运行 Ftrace 每次捕获新的最大值时都会增加大量延迟,通常在几十毫秒左右,而 Ftrace 本身可以忽略这些延迟。然而,这会扭曲cyclictest
等用户空间追踪工具的结果。换句话说,如果你在捕获追踪时运行cyclictest
,请忽略其结果。
选择追踪器的方法与我们在第二十章中看到的函数追踪器相同。下面是一个示例,展示了在禁用抢占的情况下捕获最大时长的追踪,持续 60 秒:
# echo preemptoff > /sys/kernel/debug/tracing/current_tracer
# echo 0 > /sys/kernel/debug/tracing/tracing_max_latency
# echo 1 > /sys/kernel/debug/tracing/tracing_on
# sleep 60
# echo 0 > /sys/kernel/debug/tracing/tracing_on
结果追踪,经过大量编辑,类似这样:
# cat /sys/kernel/debug/tracing/trace
# tracer: preemptoff
#
# preemptoff latency trace v1.1.5 on 3.14.19-yocto-standard
# -----------------------------------------------------------
# latency: 1160 us, #384/384, CPU#0 | (M:preempt VP:0, KP:0, SP:0 HP:0)
# ----------------
# | task: init-1 (uid:0 nice:0 policy:0 rt_prio:0)
# ----------------
# => started at: ip_finish_output
# => ended at: __local_bh_enable_ip
#
#
# _------=> CPU#
# / _-----=> irqs-off
# | / _----=> need-resched
# || / _---=> hardirq/softirq
# ||| / _--=> preempt-depth
# |||| / delay
# cmd pid ||||| time | caller
# \ / ||||| \ | /
init-1 0..s. 1us+: ip_finish_output
init-1 0d.s2 27us+: preempt_count_add <-cpdma_chan_submit
init-1 0d.s3 30us+: preempt_count_add <-cpdma_chan_submit
init-1 0d.s4 37us+: preempt_count_sub <-cpdma_chan_submit
<…>
init-1 0d.s2 1152us+: preempt_count_sub <-__local_bh_enable
init-1 0d..2 1155us+: preempt_count_sub <-__local_bh_enable_ip
init-1 0d..1 1158us+: __local_bh_enable_ip
init-1 0d..1 1162us!: trace_preempt_on <-__local_bh_enable_ip
init-1 0d..1 1340us : <stack trace>
在这里,你可以看到在运行追踪时,禁用内核抢占的最长时间为1160
微秒。这个简单的事实可以通过读取/sys/kernel/debug/tracing/tracing_max_latency
得到,但之前的追踪进一步提供了导致该测量的内核函数调用序列。标记为delay
的列显示了每个函数调用的点,最后是trace_preempt_on()
在1162us
时被调用,这时内核抢占重新启用。有了这些信息,你可以回溯调用链并(希望)弄清楚这是否是一个问题。
其他提到的追踪器工作原理相同。
结合 cyclictest 和 Ftrace
如果cyclictest
报告了异常长的延迟,你可以使用breaktrace
选项来中止程序,并触发 Ftrace 以获取更多信息。
你可以使用-b<N>
或--breaktrace=<N>
来调用breaktrace
,其中N
是触发追踪的延迟微秒数。你可以使用-T[tracer name]
或以下选项选择 Ftrace 追踪器:
-
-C
: 上下文切换 -
-E
: 事件 -
-f
: 函数 -
-w
: 唤醒 -
-W
: 唤醒-实时
例如,当测量到大于100
微秒的延迟时,这将触发 Ftrace 函数追踪器:
# cyclictest -a -t -p99 -b100
我们现在有两个互补的工具来调试延迟问题。cyclictest
检测暂停,Ftrace 提供详细信息。
总结
实时这个术语没有意义,除非你用一个截止时间和可接受的丢失率来限定它。当你拥有这两项信息时,你可以判断 Linux 是否适合作为操作系统,如果适合,接下来就可以开始调优你的系统以满足这些要求。调整 Linux 和你的应用程序以处理实时事件意味着使其更加确定性,从而保证实时线程能够可靠地按时完成任务。确定性通常是以总吞吐量为代价的,因此实时系统无法处理像非实时系统那样多的数据。
不能提供数学证明来表明像 Linux 这样的复杂操作系统总能满足给定的截止日期,因此唯一的做法是通过使用如cyclictest
和 Ftrace 等工具进行广泛测试,更重要的是,使用您自己针对自己应用程序的基准测试。
为了提高确定性,您需要同时考虑应用程序和内核。在编写实时应用程序时,您应遵循本章关于调度、锁定和内存的指导方针。
内核对系统的确定性有很大影响。幸运的是,这些年来在这方面做了很多工作。启用内核抢占是一个很好的第一步。如果你仍然发现它比你希望的更频繁地错过截止日期,那么你可能需要考虑PREEMPT_RT
。它确实可以产生低延迟,但你可能会遇到将 PREEMPT_RT
内核补丁与旧版(6.12 之前)供应商内核集成到特定板上的问题。你可能还需要,或者需要额外,使用 Ftrace 和类似工具来寻找延迟的原因。
这让我来到了嵌入式 Linux 解剖的结尾。作为嵌入式系统工程师需要具备非常广泛的技能,其中包括对硬件的低级了解以及内核如何与硬件交互。你需要成为一名出色的系统工程师,能够配置用户应用程序并调整它们以高效运行。所有这些都必须在硬件上完成,而这些硬件往往只是勉强能完成任务。这里有一句话总结了这一点:一个工程师能用一美元做别人用两美元做的事。希望你能通过本书中提供的信息实现这一点。
进一步学习
-
硬实时计算系统:可预测的调度算法与应用,作者:Giorgio Buttazzo
-
多核应用程序编程:适用于 Windows、Linux 和 Oracle Solaris,作者:Darryl Gove
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者讨论:packt.link/embeddedsystems
订阅我们的在线数字图书馆,全面访问超过 7,000 本书籍和视频,以及帮助您规划个人发展并推动职业生涯的行业领先工具。欲了解更多信息,请访问我们的网站。
为什么订阅?
-
通过来自超过 4,000 名行业专业人士的实用电子书和视频,将学习时间减少,编程时间增加
-
利用特别为您定制的技能计划提高您的学习效率
-
每月获得一本免费的电子书或视频
-
完全可搜索,轻松访问重要信息
-
复制粘贴、打印和书签内容
在 www.packt.com,你还可以阅读一系列免费的技术文章,注册各种免费的新闻通讯,并获得 Packt 图书和电子书的独家折扣和优惠。
你可能会喜欢的其他书籍
如果你喜欢本书,可能也对 Packt 出版的其他书籍感兴趣:
嵌入式 Linux 安全手册
Matt St. Onge
ISBN: 978-1-83588-564-2
-
理解如何根据设计标准确定最优硬件平台
-
认识到安全设计在嵌入式系统中的重要性
-
实现先进的安全措施,如 TPM、LUKS 加密和安全启动过程
-
发现安全生命周期管理的最佳实践,包括设备更新和升级机制
-
高效创建安全的软件供应链
-
通过控制设备上的访问和资源来实现防篡改功能
Linux 设备驱动开发(第二版)
John Madieu
ISBN: 978-1-80324-006-0
-
下载、配置、构建并定制 Linux 内核
-
使用设备树描述硬件
-
编写功能丰富的平台驱动程序,并利用 I2C 和 SPI 总线
-
充分利用新并发管理的工作队列基础设施
-
理解 Linux 内核时间管理机制并使用与时间相关的 API
-
使用 regmap 框架来提取代码并使其通用
-
使用 DMA 卸载 CPU 进行内存拷贝
-
使用 GPIO、IIO 和输入子系统与现实世界进行交互
Packt 正在寻找像你这样的作者
如果你有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并立即申请。我们与成千上万的开发者和技术专业人士合作,帮助他们与全球技术社区分享他们的见解。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。
分享你的想法
现在你已经完成了 《深入掌握嵌入式 Linux 开发(第四版)》,我们非常期待听到你的想法!如果你是在亚马逊购买的本书,请 点击这里直接前往亚马逊的评论页面,分享你的反馈或留下评论。
你的评论对我们和技术社区都很重要,能够帮助我们确保提供高质量的内容。