精通-Linux-嵌入式编程-全-

精通 Linux 嵌入式编程(全)

原文:zh.annas-archive.org/md5/3996AD3946F3D9ECE4C1612E34BFD814

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

嵌入式系统是一种内部带有计算机的设备,看起来不像计算机。洗衣机、电视、打印机、汽车、飞机和机器人都由某种类型的计算机控制,在某些情况下甚至不止一个。随着这些设备变得更加复杂,以及我们对它们的期望不断扩大,控制它们的强大操作系统的需求也在增长。越来越多的情况下,Linux 是首选的操作系统。

Linux 的力量来自于其鼓励代码共享的开源模式。这意味着来自许多不同背景的软件工程师,通常是竞争公司的雇员,可以合作创建一个最新的操作系统内核,并跟踪硬件的发展。从这个代码库中,支持从最大的超级计算机到手表的各种设备。Linux 只是操作系统的一个组成部分。还需要许多其他组件来创建一个工作系统,从基本工具,如命令行,到具有网页内容和与云服务通信的图形用户界面。Linux 内核以及其他大量的开源组件允许您构建一个可以在各种角色中发挥作用的系统。

然而,灵活性是一把双刃剑。虽然它为系统设计者提供了多种解决特定问题的选择,但也带来了如何知道哪种选择最佳的问题。本书的目的是详细描述如何使用免费、开源项目构建嵌入式 Linux 系统,以产生稳健、可靠和高效的系统。它基于作者多年作为顾问和培训师的经验,使用示例来说明最佳实践。

本书涵盖的内容

《精通嵌入式 Linux 编程》按照典型嵌入式 Linux 项目的生命周期进行组织。前六章告诉您如何设置项目以及 Linux 系统的构建方式,最终选择适当的 Linux 构建系统。接下来是必须就系统架构和设计选择做出某些关键决策的阶段,包括闪存存储器、设备驱动程序和init系统。随后是编写应用程序以利用您构建的嵌入式平台的阶段,其中有两章关于进程、线程和内存管理。最后,我们来到了调试和优化平台的阶段,这在第 12 和 13 章中讨论。最后一章描述了如何为实时应用程序配置 Linux。

第一章,“起步”,通过描述项目开始时系统设计者的选择,为整个故事铺垫。

第二章,“了解工具链”,描述了工具链的组件,重点介绍交叉编译。它描述了在哪里获取工具链,并提供了如何从源代码构建工具链的详细信息。

第三章,“关于引导加载程序”,解释了引导加载程序初始化设备硬件的作用,并以 U-Boot 和 Bareboot 为例进行了说明。它还描述了设备树,这是一种编码硬件配置的方法,用于许多嵌入式系统。

第四章,“移植和配置内核”,提供了如何为嵌入式系统选择 Linux 内核并为设备内部的硬件进行配置的信息。它还涵盖了如何将 Linux 移植到新的硬件上。

第五章,“构建根文件系统”,通过逐步指南介绍了嵌入式 Linux 实现中用户空间部分的概念,以及如何配置根文件系统的方法。

第六章,“选择构建系统”,涵盖了两个嵌入式 Linux 构建系统,它们自动化了前四章描述的步骤,并结束了本书的第一部分。

第七章,“创建存储策略”,讨论了管理闪存存储带来的挑战,包括原始闪存芯片和嵌入式 MMC 或 eMMC 封装。它描述了适用于每种技术类型的文件系统,并涵盖了如何在现场更新设备固件的技术。

第八章,“介绍设备驱动程序”,描述了内核设备驱动程序如何与硬件交互,并提供了简单驱动程序的示例。它还描述了从用户空间调用设备驱动程序的各种方法。

第九章,“启动 - init 程序”,展示了第一个用户空间程序init如何启动其余系统。它描述了init程序的三个版本,每个版本适用于不同的嵌入式系统组,从 BusyBox init到 systemd 的复杂性逐渐增加。

第十章,“了解进程和线程”,从应用程序员的角度描述了嵌入式系统。本章介绍了进程和线程、进程间通信和调度策略。

第十一章,“内存管理”,介绍了虚拟内存背后的思想,以及地址空间如何划分为内存映射。它还涵盖了如何检测正在使用的内存和内存泄漏。

第十二章,“使用 GDB 调试”,向您展示如何使用 GNU 调试器 GDB 交互式调试用户空间和内核代码。它还描述了内核调试器kdb

第十三章,“性能分析和跟踪”,介绍了可用于测量系统性能的技术,从整个系统概要开始,然后逐渐聚焦于导致性能不佳的特定领域。它还描述了 Valgrind 作为检查应用程序对线程同步和内存分配正确性的工具。

第十四章,“实时编程”,提供了关于 Linux 上实时编程的详细指南,包括内核和实时内核补丁的配置,还提供了测量实时延迟的工具描述。它还涵盖了如何通过锁定内存来减少页面错误的信息。

本书所需内容

本书使用的软件完全是开源的。在大多数情况下,使用的版本是写作时可用的最新稳定版本。虽然我尽力以不特定于特定版本的方式描述主要特性,但其中的命令示例不可避免地包含一些在较新版本中无法使用的细节。我希望随附的描述足够详细,以便您可以将相同的原则应用于软件包的较新版本。

创建嵌入式系统涉及两个系统:用于交叉编译软件的主机系统和运行软件的目标系统。对于主机系统,我使用了 Ubuntu 14.04,但大多数 Linux 发行版都可以进行少量修改后使用。同样,我不得不选择一个目标系统来代表嵌入式系统。我选择了两个:BeagelBone Black 和 QEMU CPU 模拟器,模拟 ARM 目标。后一个目标意味着您可以尝试示例,而无需投资于实际目标设备的硬件。同时,应该可以将示例应用于广泛的目标,只需根据具体情况进行适应,例如设备名称和内存布局。

目标主要软件包的版本为 U-Boot 2015.07、Linux 4.1、Yocto Project 1.8 "Fido"和 Buildroot 2015.08。

这本书适合谁

这本书非常适合已经熟悉嵌入式系统并想要了解如何创建最佳设备的 Linux 开发人员和系统程序员。需要基本的 C 编程理解和系统编程经验。

约定

在这本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是这些样式的一些示例以及它们的含义解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:"我们可以使用流 I/O 函数fopen(3)fread(3)fclose(3)。"

代码块设置如下:

static struct mtd_partition omap3beagle_nand_partitions[] = {
  /* All the partition sizes are listed in terms of NAND block size */
  {
    .name        = "X-Loader",
    .offset      = 0,
    .size        = 4 * NAND_BLOCK_SIZE,
    .mask_flags  = MTD_WRITEABLE,  /* force read-only */
  }
}

当我们希望引起您对代码块的特定部分的注意时,相关行或项目会以粗体显示:

static struct mtd_partition omap3beagle_nand_partitions[] = {
  /* All the partition sizes are listed in terms of NAND block size */
  {
    .name        = "X-Loader",
    .offset      = 0,
    .size         = 4 * NAND_BLOCK_SIZE,
    .mask_flags  = MTD_WRITEABLE,  /* force read-only */
  }
}

任何命令行输入或输出都以以下方式编写:

# flash_erase -j /dev/mtd6 0 0
# nandwrite /dev/mtd6 rootfs-sum.jffs2

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:"第二行在控制台上打印消息请按 Enter 键激活此控制台。"

注意

警告或重要说明会显示在这样的框中。

提示

提示和技巧会显示为这样。

读者反馈

我们始终欢迎读者的反馈。让我们知道您对这本书的看法——您喜欢或不喜欢的地方。读者的反馈对我们很重要,因为它可以帮助我们开发出您真正能够充分利用的标题。

要向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书名。

如果您在某个专题上有专业知识,并且有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在您是 Packt 书籍的自豪所有者,我们有一些事情可以帮助您充分利用您的购买。

下载示例代码

您可以从www.packtpub.com的帐户中下载示例代码文件,适用于您购买的所有 Packt Publishing 图书。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便文件直接通过电子邮件发送给您。

勘误

尽管我们已经尽最大努力确保内容的准确性,但错误确实会发生。如果您在我们的书籍中发现错误——可能是文本或代码中的错误,我们将不胜感激,如果您能向我们报告。通过这样做,您可以帮助其他读者避免挫折,并帮助我们改进本书的后续版本。如果您发现任何勘误,请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表格链接,并输入您的勘误详情。一旦您的勘误经过验证,您的提交将被接受,并且勘误将被上传到我们的网站或添加到该标题的勘误部分的任何现有勘误列表中。

要查看先前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索框中输入书名。所需信息将显示在勘误表部分下。

盗版

互联网上侵犯版权材料的盗版是跨媒体的持续问题。在 Packt,我们非常重视版权和许可的保护。如果您在互联网上发现我们作品的任何非法副本,请立即向我们提供位置地址或网站名称,以便我们采取补救措施。

请通过<copyright@packtpub.com>与我们联系,并提供涉嫌盗版材料的链接。

我们感谢您帮助保护我们的作者和我们提供有价值内容的能力。

问题

如果您对本书的任何方面有问题,可以通过<questions@packtpub.com>与我们联系,我们将尽力解决问题。

第一章:起步

你即将开始你的下一个项目,这一次它将运行 Linux。在你动手之前,你应该考虑些什么?让我们从一个高层次来看嵌入式 Linux,看看为什么它如此受欢迎,开源许可证的影响是什么,以及你需要什么样的硬件来运行 Linux。

Linux 在 1999 年左右首次成为嵌入式设备的可行选择。那时 Axis(www.axis.com)发布了他们的第一款 Linux 动力网络摄像头,而 TiVo(www.tivo.com)发布了他们的第一款数字视频录像机(DVR)。自 1999 年以来,Linux 变得越来越受欢迎,以至于今天它是许多产品类别的首选操作系统。在撰写本文时,即 2015 年,有大约 20 亿台设备运行 Linux。其中包括大量运行 Android 的智能手机,Android 使用了 Linux 内核,以及数亿台机顶盒、智能电视和 Wi-Fi 路由器,更不用说一系列体积较小的设备,如车辆诊断、称重秤、工业设备和医疗监测单元。

那么,为什么你的电视运行 Linux?乍一看,电视的功能很简单:它必须在屏幕上显示视频流。为什么像 Linux 这样复杂的类 Unix 操作系统是必要的?

简单的答案是摩尔定律:英特尔的联合创始人戈登·摩尔在 1965 年观察到,芯片上的组件密度大约每两年翻一番。这适用于我们设计和使用的日常生活中的设备,就像它适用于台式机、笔记本电脑和服务器一样。大多数嵌入式设备的核心是一个高度集成的芯片,其中包含一个或多个处理器核心,并与主存储器、大容量存储和多种类型的外围设备进行接口。这被称为片上系统,或 SoC,它们随着摩尔定律的增长而变得越来越复杂。典型的 SoC 有一个技术参考手册,长达数千页。你的电视不仅仅是像旧模拟电视一样显示视频流。

视频流是数字的,可能是加密的,需要处理才能创建图像。你的电视(或很快将会)连接到互联网。它可以接收来自智能手机、平板电脑和家庭媒体服务器的内容。它可以(或很快将会)用于玩游戏。等等。你需要一个完整的操作系统来管理这种复杂程度。

以下是一些推动 Linux 采用的要点:

  • Linux 具有必要的功能。它有一个良好的调度程序,一个良好的网络堆栈,支持 USB、Wi-Fi、蓝牙,许多种存储介质,对多媒体设备的良好支持等等。它满足了所有要求。

  • Linux 已经移植到了各种处理器架构,包括一些在 SoC 设计中非常常见的架构——ARM、MIPS、x86 和 PowerPC。

  • Linux 是开源的,所以你有自由获取源代码并修改以满足你的需求。你或者代表你工作的人可以为你特定的 SoC 板或设备创建一个板支持包。你可以添加可能在主线源代码中缺失的协议、功能和技术。你可以删除你不需要的功能以减少内存和存储需求。Linux 是灵活的。

  • Linux 有一个活跃的社区;在 Linux 内核的情况下,非常活跃。内核每 10 到 12 周发布一个新版本,每个版本都包含来自大约 1000 名开发人员的代码。活跃的社区意味着 Linux 是最新的,并支持当前的硬件、协议和标准。

  • 开源许可证保证你可以访问源代码。没有供应商的约束。

因此,Linux 是复杂设备的理想选择。但我在这里应该提到一些注意事项。复杂性使得理解变得更加困难。再加上快速发展的开发过程和开源的分散结构,您必须付出一些努力来学习如何使用它,并随着其变化而不断重新学习。我希望本书能在这个过程中有所帮助。

选择合适的操作系统

Linux 是否适合您的项目?Linux 在解决问题的复杂性得到合理解释的情况下效果很好。在需要连接性、稳健性和复杂用户界面的情况下尤其有效。但它无法解决所有问题,因此在您着手之前需要考虑以下一些事项:

  • 您的硬件是否能胜任?与传统的实时操作系统(RTOS)如 VxWorks 相比,Linux 需要更多的资源。它至少需要一个 32 位处理器,以及更多的内存。我将在典型硬件要求部分详细介绍。

  • 您是否具备正确的技能?项目的早期阶段,即板卡调试,需要对 Linux 及其与硬件的关系有详细的了解。同样,在调试和优化应用程序时,您需要能够解释结果。如果您内部没有这些技能,您可能需要外包一些工作。当然,阅读本书会有所帮助!

  • 您的系统是否实时?Linux 可以处理许多实时活动,只要您注意一些细节,我将在《第十四章》中详细介绍,实时编程

仔细考虑这些要点。成功的最佳指标可能是寻找运行 Linux 的类似产品,并看看它们是如何做到的;遵循最佳实践。

参与者

开源软件是从哪里来的?谁写的?特别是,这与嵌入式开发的关键组件 - 工具链、引导加载程序、内核和根文件系统中的基本实用程序有何关系?

主要参与者是:

  • 开源社区。毕竟,这是生成您将要使用的软件的引擎。社区是一群开发人员的松散联盟,其中许多人以某种方式获得资助,可能是通过非营利组织、学术机构或商业公司。他们共同努力以推进各种项目的目标。其中有许多项目,有些小,有些大。我们在本书的其余部分将使用的一些项目是 Linux 本身、U-Boot、BusyBox、Buildroot、Yocto 项目以及 GNU 组织下的许多项目。

  • CPU 架构师 - 这些是设计我们使用的 CPU 的组织。这里的重要组织包括 ARM/Linaro(基于 ARM 的 SoC)、英特尔(x86 和 x86_64)、想象科技(MIPS)和 Freescale/IBM(PowerPC)。他们实现或者至少影响对基本 CPU 架构的支持。

  • SoC 供应商(Atmel、Broadcom、Freescale、英特尔、高通、TI 等)- 他们从 CPU 架构师那里获取内核和工具链,并对其进行修改以支持他们的芯片。他们还创建参考板:这些设计被下一级用来创建开发板和实际产品。

  • 板卖家和 OEM - 这些人从 SoC 供应商那里获取参考设计,并将其构建到特定产品中,例如机顶盒或摄像头,或创建更通用的开发板,例如 Avantech 和 Kontron 的开发板。一个重要的类别是廉价的开发板,如 BeagleBoard/BeagleBone 和 Raspberry Pi,它们已经创建了自己的软件和硬件附加组件生态系统。

这些构成了一个链条,您的项目通常位于末端,这意味着您不能自由选择组件。您不能简单地从[kernel.org](http:// kernel.org)获取最新的内核,除非在极少数情况下,因为它不支持您正在使用的芯片或板。

这是嵌入式开发的一个持续问题。理想情况下,每个环节的开发者都会将他们的变更推送到上游,但他们没有这样做。发现一个内核有成千上万个未合并到上游的补丁并不罕见。此外,SoC 供应商倾向于只为他们最新的芯片积极开发开源组件,这意味着对于任何超过几年的芯片,支持将被冻结,不会收到任何更新。

其结果是,大多数嵌入式设计都基于旧版本的软件。它们不会接收安全修复、性能增强或新版本中的功能。像 Heartbleed(OpenSSL 库中的一个漏洞)和 Shellshock(bash shell 中的一个漏洞)这样的问题得不到修复。我将在本章后面的安全主题下更多地谈论这个问题。

你能做些什么?首先,向你的供应商提问:他们的更新政策是什么,他们多久修订一次内核版本,当前的内核版本是什么,之前的是什么?他们的政策是如何将变更合并到上游的?一些供应商在这方面取得了巨大进展。你应该偏好他们的芯片。

其次,你可以采取措施使自己更加自给自足。本书旨在更详细地解释依赖关系,并向你展示在哪些方面你可以自助。不要盲目接受 SoC 或板卡供应商提供的软件包,而不考虑其他选择。

项目生命周期

这本书分为四个部分,反映了项目的各个阶段。这些阶段不一定是顺序的。通常它们会重叠,你需要回头去重新审视之前完成的事情。然而,它们代表了开发者在项目进展过程中的关注点:

  • 嵌入式 Linux 的要素(第 1 至 6 章)将帮助你建立开发环境,并为后续阶段创建一个工作平台。它通常被称为“板卡引导”阶段。

  • 系统架构和设计选择(第 7 至 9 章)将帮助你审视一些关于程序和数据存储、如何在内核设备驱动程序和应用程序之间划分工作,以及如何初始化系统的设计决策。

  • 编写嵌入式应用程序(第 10 和 11 章)展示了如何有效利用 Linux 进程和线程模型,以及如何在资源受限的设备中管理内存。

  • 调试和优化性能(第 12 和 13 章)描述了如何在应用程序和内核中跟踪、分析和调试代码。

关于实时(第十四章, 实时编程)的第五部分有些独立,因为它是嵌入式系统的一个小但重要的类别。为实现实时行为而设计对四个主要阶段都有影响。

嵌入式 Linux 的四个要素

每个项目都始于获取、定制和部署这四个要素:工具链、引导加载程序、内核和根文件系统。这是本书第一部分的主题:

  • 工具链:这包括为目标设备创建代码所需的编译器和其他工具。其他一切都依赖于工具链。

  • 引导加载程序:这是必要的,用于初始化板卡并加载和启动 Linux 内核。

  • 内核:这是系统的核心,管理系统资源并与硬件进行接口。

  • 根文件系统:这包含了在内核完成初始化后运行的库和程序。

当然,这里还有第五个要素,没有在这里提到。那就是专门针对你的嵌入式应用程序的程序集合,使设备能够完成其预定任务,无论是称重杂货、播放电影、控制机器人还是驾驶无人机。

通常情况下,当你购买 SoC 或板卡时,可能会作为一个包的一部分或全部提供这些元素。但是,出于前面段落提到的原因,它们可能不是最好的选择。我将在前六章中为您提供背景,以便做出正确的选择,并向您介绍两个自动化整个过程的工具:Buildroot 和 Yocto Project。

开源

嵌入式 Linux 的组件是开源的,所以现在是考虑这意味着什么,为什么开源工作方式以及这如何影响您将从中创建的通常是专有的嵌入式设备的好时机。

许可证

谈到开源时,经常使用“免费”这个词。对于这个主题的新手来说,他们通常认为这意味着无需支付任何费用,而开源软件许可确实保证您可以免费使用软件开发和部署系统。然而,这里更重要的意义是自由,因为您可以自由获取源代码并以任何您认为合适的方式进行修改,并在其他系统中重新部署。这些许可证赋予了您这个权利。与允许您免费复制二进制文件但不提供源代码的共享软件许可证,或者允许您在某些情况下免费使用软件(例如个人使用但不允许商业使用)的其他许可证相比,这些都不是开源。

我将提供以下评论,以帮助您了解使用开源许可证的影响,但我想指出,我是一名工程师,而不是律师。以下是我对许可证及其解释方式的理解。

开源许可证大致分为两类:来自自由软件基金会的GPLGeneral Public License)和来自BSDBerkeley Software Distribution)、Apache 基金会和其他组织的宽松许可证。

宽松许可证基本上表示,您可以修改源代码并在自己选择的系统中使用它,只要您不以任何方式修改许可证条款。换句话说,在这个限制下,您可以按照自己的意愿使用它,包括将其构建到可能是专有系统中。

GPL 许可证相似,但有条款强制您将获取和修改软件的权利传递给最终用户。换句话说,您分享您的源代码。其中一个选项是通过将其放在公共服务器上使其完全公开。另一个选项是通过书面提供代码的要约,仅向最终用户提供。GPL 进一步规定,您不能将 GPL 代码合并到专有程序中。任何尝试这样做的行为都会使 GPL 适用于整个程序。换句话说,您不能在一个程序中将 GPL 和专有代码结合在一起。

那么,图书馆呢?如果它们使用 GPL 许可证,任何与它们链接的程序也会成为 GPL。然而,大多数图书馆都是根据Lesser General Public License (LGPL)许可。如果是这种情况,你可以允许从专有程序中链接它们。

前面的描述都是针对 GPL v2 和 LGPL v2.1 的。我应该提到最新版本的 GPL v3 和 LGPL v3。这些是有争议的,我承认我并不完全理解其影响。然而,意图是确保系统中的 GPLv3 和 LGPL v3 组件可以被最终用户替换,这符合开源软件的精神。但这确实会带来一些问题。一些 Linux 设备用于根据订阅级别或其他限制获取信息,替换软件的关键部分可能会影响这一点。机顶盒属于这一类。还存在安全问题。如果设备的所有者可以访问系统代码,那么不受欢迎的入侵者也可能会访问。通常的防御措施是拥有由权威(供应商)签名的内核映像,以防止未经授权的更新。这是否侵犯了我修改设备的权利?意见不一。

注意

TiVo 机顶盒是这场辩论的重要组成部分。它使用 Linux 内核,该内核根据 GPL v2 许可。TiVo 发布了他们版本的内核源代码,因此符合许可证。TiVo 还有一个只会加载由他们签名的内核二进制文件的引导加载程序。因此,你可以为 TiVo 盒构建修改后的内核,但无法在硬件上加载它。自由软件基金会认为这不符合开源软件的精神,并将此过程称为“Tivoization”。GPL v3 和 LGPL v3 是明确防止这种情况发生的。一些项目,特别是 Linux 内核,一直不愿采用第三版许可证,因为它会对设备制造商施加限制。

嵌入式 Linux 的硬件

如果你正在为嵌入式 Linux 项目设计或选择硬件,你需要注意什么?

首先,CPU 架构必须得到内核支持,除非你当然打算自己添加一个新的架构!查看 Linux 4.1 的源代码,有 30 种架构,每种都在arch/目录下有一个子目录表示。它们都是 32 位或 64 位架构,大多数带有内存管理单元(MMU),但也有一些没有。在嵌入式设备中最常见的是 ARM、MIPS、PowerPC 和 X86,每种都有 32 位和 64 位变体,并且都有内存管理单元。

本书的大部分内容是针对这类处理器编写的。还有另一类没有 MMU 的处理器,运行一个名为微控制器 Linux 或 uClinux 的 Linux 子集。这些处理器架构包括 ARC、Blackfin、Microblaze 和 Nios。我会不时提到 uClinux,但不会详细介绍,因为这是一个相当专业的话题。

其次,你需要合理数量的 RAM。16 MiB 是一个不错的最低值,尽管使用一半的 RAM 也完全可以运行 Linux。如果你愿意对系统的每个部分进行优化,甚至可以使用 4 MiB 运行 Linux。甚至可能更低,但是有一个临界点,那时它就不再是 Linux 了。

第三,通常是闪存这样的非易失性存储。8 MiB 对于简单设备如网络摄像头或简单路由器已经足够了。与 RAM 一样,如果你真的愿意,你可以使用更少的存储创建一个可行的 Linux 系统,但是越低,就越困难。Linux 对闪存设备有广泛的支持,包括原始 NOR 和 NAND 闪存芯片以及 SD 卡、eMMC 芯片、USB 闪存等形式的受控闪存。

第四,调试端口非常有用,最常见的是 RS-232 串行端口。它不一定要安装在生产板上,但可以使板子的启动、调试和开发更加容易。

第五,您需要一些手段在从头开始时加载软件。几年前,板子会配备 JTAG 接口,但现代 SoC 有能力直接从可移动介质加载引导代码,特别是 SD 和 micro SD 卡,或者串行接口,如 RS-232 或 USB。

除了这些基础知识外,还有与设备需要完成工作的特定硬件位的接口。主线 Linux 配备了成千上万种不同设备的开源驱动程序,SoC 制造商和第三方芯片的 OEM 提供了质量不等的驱动程序,但请记住我对一些制造商的承诺和能力的评论。作为嵌入式设备的开发人员,您会发现自己花费了相当多的时间来评估和调整第三方代码,如果有的话,或者与制造商联系,如果没有的话。最后,您将不得不为设备的任何独特接口编写设备支持,或者找人替您完成。

本书中使用的硬件

本书中的示例旨在是通用的,但为了使它们相关且易于遵循,我不得不选择一个特定的设备作为示例。我使用了两个示例设备:BeagleBone Black 和 QEMU。第一个是广泛可用且便宜的开发板,可用于严肃的嵌入式硬件。第二个是一个机器模拟器,可用于创建典型的嵌入式硬件系统。诱人的是只使用 QEMU,但是像所有模拟一样,它与真实情况并不完全相同。使用 BeagleBone,您可以满足与真实硬件交互并看到真正的 LED 闪烁的满足感。诱人的是选择比 BeagleBone Black 更为时尚的板子,但我相信它的流行度使其具有一定的长寿性,并意味着它将在未来几年内继续可用。

无论如何,我鼓励您尝试使用这两个平台中的任何一个或者您手头上可能有的任何嵌入式硬件来尝试尽可能多的示例。

BeagleBone Black

BeagleBone 和后来的 BeagleBone Black 是由 Circuitco LLC 生产的一款小型信用卡大小的开放硬件设计的开发板。主要信息库位于www.beagleboard.org。规格的主要要点是:

  • TI AM335x 1GHz ARM® Cortex-A8 Sitara SoC

  • 512 MiB DDR3 RAM

  • 2 或 4 GiB 8 位 eMMC 板载闪存

  • 用于调试和开发的串行端口

  • 可用作引导设备的 MicroSD 连接器

  • 迷你 USB OTG 客户端/主机端口,也可用于为板子供电

  • 全尺寸 USB 2.0 主机端口

  • 10/100 以太网端口

  • HDMI 用于视频和音频输出

此外,还有两个 46 针扩展头,有许多不同的子板,称为披风,可以使板子适应许多不同的功能。但是,在本书的示例中,您不需要安装任何披风。

除了板子本身,您还需要:

  • 一根迷你 USB 到全尺寸 USB 电缆(随板子提供)以提供电源,除非您拥有此列表上的最后一项。

  • 一个 RS-232 电缆,可以与板子提供的 6 针 3.3 伏 TTL 电平信号进行接口。Beagleboard 网站上有兼容电缆的链接。

  • 一个 microSD 卡和一种从开发 PC 或笔记本电脑上写入软件到板子上所需的手段。

  • 一根以太网电缆,因为一些示例需要网络连接。

  • 可选,但建议使用,能够提供 1A 或更多电流的 5V 电源适配器。

QEMU

QEMU 是一个机器模拟器。它有许多不同的版本,每个版本都可以模拟处理器架构和使用该架构构建的许多板子。例如,我们有以下内容:

  • qemu-system-arm:ARM

  • qemu-system-mips:MIPS

  • qemu-system-ppc:PowerPC

  • 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

前面命令行中使用的选项是:

  • -machine vexpress-a9:创建一个 ARM Versatile Express 开发板的模拟,配备 Cortex A-9 处理器

  • -m 256M:为其分配 256 MiB 的 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

要配置网络的主机端,您需要来自用户模式 LinuxUML)项目的tunctl命令;在 Debian 和 Ubuntu 上,该软件包的名称为uml-utilities。您可以使用以下命令创建一个虚拟网络:

$ sudo tunctl -u $(whoami) -t tap0

这将创建一个名为tap0的网络接口,它连接到模拟的 QEMU 机器中的网络控制器。您可以像配置任何其他接口一样配置tap0

所有这些选项在接下来的章节中都有详细描述。我将在大多数示例中使用 Versatile Express,但使用不同的机器或架构应该也很容易。

本书中使用的软件

我只使用了开源软件来开发工具和目标操作系统和应用程序。我假设您将在开发系统上使用 Linux。我使用 Ubuntu 14.04 测试了所有主机命令,因此对该特定版本有一些偏见,但任何现代 Linux 发行版都可能运行良好。

摘要

嵌入式硬件将继续变得更加复杂,遵循摩尔定律所设定的轨迹。Linux 具有利用硬件的能力和灵活性。

Linux 只是开源软件中的一个组件,您需要创建一个可工作产品所需的许多组件。代码是免费提供的,这意味着许多不同层次的人和组织都可以做出贡献。然而,嵌入式平台的多样性和快速发展的步伐导致了软件的孤立池,它们的共享效率不如预期高。在许多情况下,您将依赖于这些软件,特别是由 SoC 或板卡供应商提供的 Linux 内核,以及较小程度上的工具链。一些 SoC 制造商正在更好地推动他们的变更上游,并且这些变更的维护变得更加容易。

幸运的是,有一些强大的工具可以帮助您创建和维护设备的软件。例如,Buildroot 非常适合小型系统,Yocto Project 适合更大的系统。

在我描述这些构建工具之前,我将描述嵌入式 Linux 的四个元素,您可以将其应用于所有嵌入式 Linux 项目,无论它们是如何创建的。下一章将全面介绍这些元素中的第一个,即工具链,您需要用它来为目标平台编译代码。

第二章:了解工具链

工具链是嵌入式 Linux 的第一个元素,也是项目的起点。在这个早期阶段做出的选择将对最终结果产生深远影响。您的工具链应能够有效地利用硬件,使用处理器的最佳指令集,使用浮点单元(如果有的话)等。它应该支持您需要的语言,并且具有对 POSIX 和其他系统接口的稳固实现。此外,发现安全漏洞或错误时,应及时更新。最后,它应该在整个项目中保持不变。换句话说,一旦选择了工具链,坚持使用它是很重要的。在项目进行过程中以不一致的方式更改编译器和开发库将导致隐蔽的错误。

获得工具链就像下载和安装一个软件包一样简单。但是,工具链本身是一个复杂的东西,我将在本章中向您展示。

什么是工具链?

工具链是将源代码编译成可在目标设备上运行的可执行文件的一组工具,包括编译器、链接器和运行时库。最初,您需要一个工具链来构建嵌入式 Linux 系统的另外三个元素:引导加载程序、内核和根文件系统。它必须能够编译用汇编、C 和 C++编写的代码,因为这些是基本开源软件包中使用的语言。

通常,Linux 的工具链是基于 GNU 项目(www.gnu.org)的组件构建的,这在撰写本文时仍然是大多数情况下的情况。然而,在过去的几年里,Clang 编译器和相关的 LLVM 项目(llvm.org)已经发展到了可以成为 GNU 工具链的可行替代品的地步。LLVM 和基于 GNU 的工具链之间的一个主要区别在于许可证;LLVM 采用 BSD 许可证,而 GNU 采用 GPL。Clang 也有一些技术优势,比如更快的编译速度和更好的诊断,但 GNU GCC 具有与现有代码库的兼容性和对各种体系结构和操作系统的支持。事实上,仍然有一些领域 Clang 无法取代 GNU C 编译器,特别是在编译主流 Linux 内核时。很可能,在未来一年左右的时间里,Clang 将能够编译嵌入式 Linux 所需的所有组件,因此将成为 GNU 的替代品。在clang.llvm.org/docs/CrossCompilation.html上有一个关于如何使用 Clang 进行交叉编译的很好的描述。如果您想将其作为嵌入式 Linux 构建系统的一部分使用,EmbToolkit(www.embtoolkit.org)完全支持 GNU 和 LLVM/Clang 工具链,并且有许多人正在努力使用 Clang 与 Buildroot 和 Yocto Project。我将在第六章中介绍嵌入式构建系统,选择构建系统。与此同时,本章将重点介绍 GNU 工具链,因为这是目前唯一的完整选项。

标准的 GNU 工具链由三个主要组件组成:

  • Binutils:一组二进制实用程序,包括汇编器和链接器 ld。它可以在www.gnu.org/software/binutils/上获得。

  • GNU 编译器集合(GCC):这些是 C 和其他语言的编译器,根据 GCC 的版本,包括 C++、Objective-C、Objective-C++、Java、Fortran、Ada 和 Go。它们都使用一个通用的后端,生成汇编代码,然后传递给 GNU 汇编器。它可以在gcc.gnu.org/上获得。

  • C 库:基于 POSIX 规范的标准化 API,是应用程序与操作系统内核之间的主要接口。有几个 C 库需要考虑,见下一节。

除此之外,您还需要一份 Linux 内核头文件的副本,其中包含在直接访问内核时所需的定义和常量。现在,您需要它们来编译 C 库,但以后在编写程序或编译与特定 Linux 设备交互的库时也会需要它们,例如通过 Linux 帧缓冲驱动程序显示图形。这不仅仅是将头文件复制到内核源代码的 include 目录中的问题。这些头文件仅供内核使用,并包含原始状态下用于编译常规 Linux 应用程序会导致冲突的定义。

相反,您需要生成一组经过清理的内核头文件,我在第五章 构建根文件系统中进行了说明。

通常并不重要内核头文件是否是从您将要使用的 Linux 的确切版本生成的。由于内核接口始终向后兼容,只需要头文件来自于与目标上使用的内核相同或更旧的内核即可。

大多数人认为 GNU 调试器 GDB 也是工具链的一部分,并且通常在这一点上构建它。我将在第十二章 使用 GDB 进行调试中讨论 GDB。

工具链类型 - 本地与交叉工具链

对于我们的目的,有两种类型的工具链:

  • 本地:这个工具链在与生成的程序相同类型的系统上运行,有时甚至是同一台实际系统。这是桌面和服务器的常见情况,并且在某些嵌入式设备类别上变得流行。例如,运行 Debian for ARM 的树莓派具有自托管的本地编译器。

  • 交叉:这个工具链在与目标不同类型的系统上运行,允许在快速桌面 PC 上进行开发,然后加载到嵌入式目标进行测试。

几乎所有嵌入式 Linux 开发都是使用交叉开发工具链完成的,部分原因是大多数嵌入式设备不适合程序开发,因为它们缺乏计算能力、内存和存储空间,另一部分原因是它保持了主机和目标环境的分离。当主机和目标使用相同的架构,例如 X86_64 时,后一点尤为重要。在这种情况下,诱人的是在主机上进行本地编译,然后简单地将二进制文件复制到目标上。这在一定程度上是有效的,但很可能主机发行版会比目标更频繁地接收更新,为目标构建代码的不同工程师将具有略有不同版本的主机开发库,因此您将违反工具链在项目生命周期内保持恒定的原则。如果确保主机和目标构建环境保持同步,您可以使这种方法奏效,但更好的方法是保持主机和目标分开,交叉工具链是实现这一点的一种方式。

然而,有一个支持本地开发的反对意见。跨平台开发需要跨编译所有你需要的库和工具到你的目标平台上。我们将在本章后面看到,跨编译并不总是简单的,因为大多数开源软件包并不是设计成这种方式构建的。集成构建工具,包括 Buildroot 和 Yocto 项目,通过封装交叉编译一系列 typical 嵌入式系统中需要的软件包的规则来帮助,但是,如果你想编译大量额外的软件包,最好是本地编译它们。例如,使用交叉编译器为树莓派或 BeagleBone 提供 Debian 发行版是不可能的,它们必须本地编译。从头开始创建本地构建环境并不容易,需要首先创建一个交叉编译器来引导目标上的本地构建环境,并使用它来构建软件包。你需要一个充分配置的目标板的构建农场,或者你可以使用 QEMU 来模拟目标。如果你想进一步了解这一点,你可能想看看 Scratchbox 项目,现在已经发展到了第二代 Scratchbox2。它是由诺基亚开发的,用于构建他们的 Maemo Linux 操作系统,今天被 Mer 项目和 Tizen 项目等使用。

与此同时,在本章中,我将专注于更主流的交叉编译器环境,这相对容易设置和管理。

CPU 架构

工具链必须根据目标 CPU 的能力进行构建,其中包括:

  • CPU 架构:arm、mips、x86_64 等

  • 大端或小端操作:一些 CPU 可以在两种模式下运行,但每种模式的机器码是不同的。

  • 浮点支持:并非所有版本的嵌入式处理器都实现了硬件浮点单元,如果是这样,工具链可以配置为调用软件浮点库。

  • 应用二进制接口(ABI):用于在函数调用之间传递参数的调用约定

对于许多体系结构,ABI 在处理器系列中是恒定的。一个值得注意的例外是 ARM。ARM 架构在 2000 年代后期过渡到了扩展应用二进制接口(EABI),导致以前的 ABI 被命名为旧应用二进制接口(OABI)。虽然 OABI 现在已经过时,但你仍然会看到有关 EABI 的引用。从那时起,EABI 分为两个,基于传递浮点参数的方式。原始的 EABI 使用通用寄存器(整数)寄存器,而新的 EABIHF 使用浮点寄存器。EABIHF 在浮点运算方面显着更快,因为它消除了整数和浮点寄存器之间的复制需求,但它与没有浮点单元的 CPU 不兼容。因此,选择是在两种不兼容的 ABI 之间:你不能混合使用这两种,因此你必须在这个阶段做出决定。

GNU 使用前缀来标识可以生成的各种组合,由三到四个由破折号分隔的组件元组组成,如下所述:

  • CPU:CPU 架构,如 arm、mips 或 x86_64。如果 CPU 有两种字节序模式,可以通过添加 el 表示小端,或者 eb 表示大端来区分。很好的例子是小端 MIPS,mipsel 和大端 ARM,armeb。

  • 供应商:这标识了工具链的提供者。例如 buildroot、poky 或者 unknown。有时会完全省略。

  • 内核:对于我们的目的,它总是'linux'。

  • 操作系统:用户空间组件的名称,可能是gnuuclibcgnu。ABI 也可以附加在这里,因此对于 ARM 工具链,您可能会看到gnueabignueabihfuclibcgnueabiuclibcgnueabihf

您可以使用gcc-dumpmachine选项找到构建工具链时使用的元组。例如,您可能会在主机计算机上看到以下内容:

$ gcc -dumpmachine
x86_64-linux-gnu

注意

当在机器上安装本地编译器时,通常会创建到工具链中每个工具的链接,没有前缀,这样你就可以使用命令gcc调用编译器。

以下是使用交叉编译器的示例:

$ mipsel-unknown-linux-gnu-gcc -dumpmachine
mipsel-unknown-linux-gnu

选择 C 库

Unix 操作系统的编程接口是用 C 语言定义的,现在由 POSIX 标准定义。C 库是该接口的实现;它是 Linux 程序与内核之间的网关,如下图所示。即使您使用其他语言编写程序,例如 Java 或 Python,相应的运行时支持库最终也必须调用 C 库:

选择 C 库

C 库是应用程序与内核之间的网关

每当 C 库需要内核的服务时,它将使用内核系统调用接口在用户空间和内核空间之间进行转换。可以通过直接进行内核系统调用来绕过 C 库,但这是很麻烦的,几乎从不需要。

有几个 C 库可供选择。主要选项如下:

  • glibc:可在www.gnu.org/software/libc找到。这是标准的 GNU C 库。它很大,并且直到最近都不太可配置,但它是 POSIX API 的最完整实现。

  • eglibc:可在www.eglibc.org/home找到。这是嵌入式 GLIBC。它是对 glibc 的一系列补丁,添加了配置选项和对 glibc 未覆盖的架构的支持(特别是 PowerPC e500)。eglibc 和 glibc 之间的分裂总是相当人为的,幸运的是,从版本 2.20 开始,eglibc 的代码库已经合并回 glibc,留下了一个改进的库。eglibc 不再维护。

  • uClibc:可在www.uclibc.org找到。 'u'实际上是希腊字母'μ',表示这是微控制器 C 库。它最初是为了与 uClinux(没有内存管理单元的 CPU 的 Linux)一起工作而开发的,但后来已经适应用于完整的 Linux。有一个配置实用程序,允许您根据需要微调其功能。即使完整配置也比 glibc 小,但它不是 POSIX 标准的完整实现。

  • musl libc:可在www.musl-libc.org找到。这是一个为嵌入式系统设计的新 C 库。

那么,应该选择哪个?我的建议是,如果您使用 uClinux 或存储空间或 RAM 非常有限,因此小尺寸将是一个优势,那么只使用 uClibc。否则,我更喜欢使用最新的 glibc 或 eglibc。我没有 musl libc 的经验,但如果您发现 glibc/eglibc 不合适,尽管尝试一下。这个过程总结在下图中:

选择 C 库

选择 C 库

查找工具链

对于交叉开发工具链,您有三种选择:您可以找到与您的需求匹配的现成工具链,可以使用嵌入式构建工具生成的工具链,该工具链在第六章中有介绍,或者您可以按照本章后面描述的方式自己创建一个。

预先构建的交叉工具链是一个吸引人的选择,因为你只需要下载和安装它,但你受限于特定工具链的配置,并且依赖于你获取它的个人或组织。最有可能的是以下之一:

  • SoC 或板卡供应商。大多数供应商提供 Linux 工具链。

  • 致力于为特定架构提供系统级支持的联盟。例如,Linaro (www.linaro.org)为 ARM 架构提供了预构建的工具链。

  • 第三方 Linux 工具供应商,如 Mentor Graphics、TimeSys 或 MontaVista。

  • 桌面 Linux 发行版的交叉工具包,例如,基于 Debian 的发行版有用于 ARM、MIPS 和 PowerPC 目标的交叉编译软件包。

  • 由集成嵌入式构建工具之一生成的二进制 SDK,Yocto 项目在autobuilder.yoctoproject.org/pub/releases/CURRENT/toolchain上有一些示例,还有 Denx 嵌入式 Linux 开发工具包在 ftp://ftp.denx.de/pub/eldk/上。

  • 一个你找不到的论坛链接。

在所有这些情况下,你必须决定提供的预构建工具链是否满足你的要求。它是否使用你喜欢的 C 库?提供商是否会为你提供安全修复和错误修复的更新,考虑到我在第一章中对支持和更新的评论,起步。如果你对任何一个问题的答案是否定的,那么你应该考虑创建你自己的工具链。

不幸的是,构建工具链并不是一件容易的事。如果你真的想自己完成所有工作,请看Cross Linux From Scratch (trac.clfs.org)。在那里,你会找到如何创建每个组件的逐步说明。

一个更简单的选择是使用 crosstool-NG,它将这个过程封装成一组脚本,并有一个菜单驱动的前端。不过,你仍然需要相当多的知识,才能做出正确的选择。

使用构建系统如 Buildroot 或 Yocto 项目更简单,因为它们在构建过程中生成工具链。这是我偏好的解决方案,正如我在第六章中所展示的,选择构建系统

使用 crosstool-NG 构建工具链

我将从 crosstool-NG 开始,因为它允许你看到创建工具链的过程,并创建几种不同的工具链。

几年前,Dan Kegel 编写了一组脚本和 makefile 用于生成交叉开发工具链,并称之为 crosstool (kegel.com/crosstool)。2007 年,Yann E. Morin 基于这个基础创建了下一代 crosstool,即 crosstool-NG (crosstool-ng.org)。今天,这无疑是从源代码创建独立交叉工具链的最方便的方法。

安装 crosstool-NG

在开始之前,你需要在主机 PC 上安装一个可用的本地工具链和构建工具。要在 Ubuntu 主机上使用 crosstool-NG,你需要使用以下命令安装软件包:

$ sudo apt-get install automake bison chrpath flex g++ git gperf gawk libexpat1-dev libncurses5-dev libsdl1.2-dev libtool python2.7-dev texinfo

接下来,从 crosstool-NG 下载部分获取当前版本,crosstool-ng.org/download/crosstool-ng。在我的示例中,我使用了 1.20.0。解压并创建前端菜单系统 ct-ng,如下所示的命令:

$ tar xf crosstool-ng-1.20.0.tar.bz2
$ cd crosstool-ng-1.20.0
$ ./configure --enable-local
$ make
$ make install

--enable-local选项意味着程序将安装到当前目录,这样可以避免需要 root 权限,如果你要安装到默认位置/usr/local/bin,则需要 root 权限。从当前目录输入./ct-ng启动 crosstool 菜单。

选择工具链

Crosstool-NG 可以构建许多不同的工具链组合。为了使初始配置更容易,它附带了一组样本,涵盖了许多常见用例。使用./ct-ng list-samples来生成列表。

例如,假设你的目标是 BeagleBone Black,它有一个 ARM Cortex A8 核心和一个 VFPv3 浮点单元,并且你想使用一个当前版本的 glibc。最接近的样本是arm-cortex_a8-linux-gnueabi。你可以通过在名称前加上show-来查看默认配置:

$ ./ct-ng show-arm-cortex_a8-linux-gnueabi
[L..] arm-cortex_a8-linux-gnueabi
OS             : linux-3.15.4
Companion libs : gmp-5.1.3 mpfr-3.1.2 cloog-ppl-0.18.1 mpc-1.0.2 libelf-0.8.13
binutils       : binutils-2.22
C compiler     : gcc-4.9.1 (C,C++)
C library      : glibc-2.19 (threads: nptl)
Tools          : dmalloc-5.5.2 duma-2_5_15 gdb-7.8 ltrace-0.7.3 strace-4.8

要将其选择为目标配置,你需要输入:

$ ./ct-ng  arm-cortex_a8-linux-gnueabi

在这一点上,你可以通过使用配置菜单命令menuconfig来审查配置并进行更改:

$ ./ct-ng menuconfig

菜单系统基于 Linux 内核的menuconfig,所以对于任何配置过内核的人来说,用户界面的导航都是熟悉的。如果不熟悉,请参考第四章,移植和配置内核,了解menuconfig的描述。

在这一点上,有一些配置更改是我建议你做的:

  • 路径和杂项选项中,禁用使工具链只读 (CT_INSTALL_DIR_RO)

  • 目标选项 | 浮点数中,选择硬件 (FPU) (CT_ARCH_FLOAT_HW)

  • C 库 | 额外配置中,添加--enable-obsolete-rpc (CT_LIBC_GLIBC_EXTRA_CONFIG_ARRAY)

第一个是必要的,如果你想在安装后向工具链添加库,我将在本章后面描述。接下来是为具有硬件浮点单元的处理器选择最佳浮点实现。最后是强制生成一个过时的头文件rpc.h的工具链,这个头文件仍然被许多软件包使用(请注意,只有在选择 glibc 时才会出现这个问题)。括号中的名称是存储在配置文件中的配置标签。当你做出更改后,退出menuconfig,并在这样做时保存配置。

配置数据保存在一个名为.config的文件中。查看文件时,你会看到文本的第一行是Automatically generated make config: don't edit,这通常是一个很好的建议,但我建议你在这种情况下忽略它。你还记得关于工具链 ABI 的讨论吗?ARM 有两个变体,一个是将浮点参数传递到整数寄存器中,另一个是使用 VFP 寄存器。你刚刚选择的浮点配置是后者,所以元组的 ABI 部分应该是eabihf。有一个配置参数恰好符合你的要求,但它不是默认启用的,也不会出现在菜单中,至少在这个版本的 crosstool 中不会。因此,你需要编辑.config并添加如下粗体显示的行:

[…]
#
# arm other options
#
CT_ARCH_ARM_MODE="arm"
CT_ARCH_ARM_MODE_ARM=y
# CT_ARCH_ARM_MODE_THUMB is not set
# CT_ARCH_ARM_INTERWORKING is not set
CT_ARCH_ARM_EABI_FORCE=y
CT_ARCH_ARM_EABI=y
CT_ARCH_ARM_TUPLE_USE_EABIHF=y
[...]

现在你可以使用 crosstool-NG 来获取、配置和构建组件,根据你的规格输入以下命令:

$ ./ct-ng build

构建大约需要半个小时,之后你会发现你的工具链出现在~/x-tools/arm-cortex_a8-linux-gnueabihf/中。

工具链的解剖

为了了解典型工具链中有什么,我想要检查一下你刚刚创建的 crosstool-NG 工具链。

工具链位于目录~/x-tools/arm-cortex_a8-linux-gnueabihf/bin中。在那里你会找到交叉编译器arm-cortex_a8-linux-gnueabihf-gcc。要使用它,你需要使用以下命令将该目录添加到你的路径中:

$ PATH=~/x-tools/arm-cortex_a8-linux-gnueabihf/bin:$PATH

现在你可以使用一个简单的hello world程序,看起来像这样:

#include <stdio.h>
#include <stdlib.h>
int main (int argc, char *argv[])
{
  printf ("Hello, world!\n");
  return 0;
}

然后像这样编译它:

$ arm-cortex_a8-linux-gnueabihf-gcc helloworld.c -o helloworld

你可以使用file命令来确认它已经被交叉编译,以打印文件的类型:

$ file helloworld
helloworld: ELF 32-bit LSB executable, ARM, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 3.15.4, not stripped

了解你的交叉编译器

想象一下,你刚刚收到了一个工具链,你想了解更多关于它是如何配置的。你可以通过查询 gcc 来了解很多信息。例如,要找到版本,你可以使用--version

$ arm-cortex_a8-linux-gnueabi-gcc --version
arm-cortex_a8-linux-gnueabi-gcc (crosstool-NG 1.20.0) 4.9.1
Copyright (C) 2014 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

$ arm-cortex_a8-linux-gnueabi-gcc -v
Using built-in specs.
COLLECT_GCC=arm-cortex_a8-linux-gnueabihf-gcc
COLLECT_LTO_WRAPPER=/home/chris/x-tools/arm-cortex_a8-linux-gnueabihf/libexec/gcc/arm-cortex_a8-linux-gnueabihf/4.9.1/lto-wrapper
Target: arm-cortex_a8-linux-gnueabihf
Configured with: /home/chris/hd/home/chris/build/MELP/build/crosstool-ng-1.20.0/.build/src/gcc-4.9.1/configure --build=x86_64-build_unknown-linux-gnu --host=x86_64-build_unknown-linux-gnu --target=arm-cortex_a8-linux-gnueabihf --prefix=/home/chris/x-tools/arm-cortex_a8-linux-gnueabihf --with-sysroot=/home/chris/x-tools/arm-cortex_a8-linux-gnueabihf/arm-cortex_a8-linux-gnueabihf/sysroot --enable-languages=c,c++ --with-arch=armv7-a --with-cpu=cortex-a8 --with-tune=cortex-a8 --with-float=hard --with-pkgversion='crosstool-NG 1.20.0' --enable-__cxa_atexit --disable-libmudflap --disable-libgomp --disable-libssp --disable-libquadmath --disable-libquadmath-support --disable-libsanitizer --with-gmp=/home/chris/hd/home/chris/build/MELP/build/crosstool-ng-1.20.0/.build/arm-cortex_a8-linux-gnueabihf/buildtools --with-mpfr=/home/chris/hd/home/chris/build/MELP/build/crosstool-ng-1.20.0/.build/arm-cortex_a8-linux-gnueabihf/buildtools --with-mpc=/home/chris/hd/home/chris/build/MELP/build/crosstool-ng-1.20.0/.build/arm-cortex_a8-linux-gnueabihf/buildtools --with-isl=/home/chris/hd/home/chris/build/MELP/build/crosstool-ng-1.20.0/.build/arm-cortex_a8-linux-gnueabihf/buildtools --with-cloog=/home/chris/hd/home/chris/build/MELP/build/crosstool-ng-1.20.0/.build/arm-cortex_a8-linux-gnueabihf/buildtools --with-libelf=/home/chris/hd/home/chris/build/MELP/build/crosstool-ng-1.20.0/.build/arm-cortex_a8-linux-gnueabihf/buildtools --with-host-libstdcxx='-static-libgcc -Wl,-Bstatic,-lstdc++,-Bdynamic -lm' --enable-threads=posix --enable-target-optspace --enable-plugin --enable-gold --disable-nls --disable-multilib --with-local-prefix=/home/chris/x-tools/arm-cortex_a8-linux-gnueabihf/arm-cortex_a8-linux-gnueabihf/sysroot --enable-c99 --enable-long-long
Thread model: posix
gcc version 4.9.1 (crosstool-NG 1.20.0)

那里有很多输出,但值得注意的有:

  • --with-sysroot=/home/chris/x-tools/arm-cortex_a8-linux-gnueabihf/arm-cortex_a8-linux-gnueabihf/sysroot:这是默认的 sysroot 目录,请参阅以下部分以获取解释

  • --enable-languages=c,c++:使用此选项,我们启用了 C 和 C++语言

  • --with-arch=armv7-a:使用 ARM v7a 指令集生成代码

  • --with-cpu=cortex-a8 and --with-tune=cortex-a8:进一步调整代码以适应 Cortex A8 核心

  • --with-float=hard:生成浮点单元的操作码,并使用 VFP 寄存器作为参数

  • --enable-threads=posix:启用 POSIX 线程

这些是编译器的默认设置。您可以在 gcc 命令行上覆盖大多数设置,因此,例如,如果要为不同的 CPU 编译,可以通过在命令行中添加-mcpu来覆盖配置的设置--with-cpu,如下所示:

$ arm-cortex_a8-linux-gnueabihf-gcc -mcpu=cortex-a5 helloworld.c -o helloworld

您可以使用--target-help打印出可用的特定于体系结构的选项范围,如下所示:

$ arm-cortex_a8-linux-gnueabihf-gcc --target-help

你可能会想知道在生成工具链时是否很重要是否得到了精确的配置,如果以后可以更改,答案取决于您预期使用它的方式。如果您计划为每个目标创建一个新的工具链,那么最好在开始时设置所有内容,因为这将减少以后出错的风险。稍微提前到第六章,选择构建系统,我称之为 Buildroot 哲学。另一方面,如果您想构建一个通用的工具链,并且准备在为特定目标构建时提供正确的设置,那么您应该使基本工具链通用,这是 Yocto 项目处理事务的方式。前面的例子遵循 Buildroot 哲学。

sysroot、库和头文件

工具链 sysroot 是一个包含库、头文件和其他配置文件子目录的目录。它可以在配置工具链时通过--with-sysroot=设置,也可以在命令行上使用--sysroot=设置。您可以使用-print-sysroot来查看默认 sysroot 的位置:

$ arm-cortex_a8-linux-gnueabi-gcc -print-sysroot
/home/chris/x-tools/arm-cortex_a8-linux-gnueabihf/arm-cortex_a8-linux-gnueabihf/sysroot

您将在 sysroot 中找到以下内容:

  • lib:包含 C 库和动态链接器/加载器ld-linux的共享对象

  • usr/lib:C 库的静态库存档以及随后可能安装的任何其他库

  • usr/include:包含所有库的头文件

  • usr/bin:包含在目标上运行的实用程序,例如ldd命令

  • /usr/share:用于本地化和国际化

  • sbin:提供了 ldconfig 实用程序,用于优化库加载路径

明显地,其中一些需要在开发主机上用于编译程序,而其他一些,例如共享库和ld-linux,需要在目标运行时使用。

工具链中的其他工具

以下表格显示了工具链中的各种其他命令以及简要描述:

命令 描述
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 库不是单个库文件。它由四个主要部分组成,共同实现 POSIX 函数 API:

  • libc:包含诸如printfopenclosereadwrite等众所周知的 POSIX 函数的主 C 库

  • libm:数学函数,如cosexplog

  • libpthread:所有以pthread_开头的 POSIX 线程函数

  • librt:POSIX 的实时扩展,包括共享内存和异步 I/O

第一个libc总是被链接,但其他的必须使用-l选项显式链接。-l的参数是去掉lib的库名称。因此,例如,通过调用sin()计算正弦函数的程序将使用-lm链接libm

arm-cortex_a8-linux-gnueabihf-gcc myprog.c -o myprog -lm

您可以使用readelf命令验证已链接到此程序或任何其他程序的库:

$ arm-cortex_a8-linux-gnueabihf-readelf -a myprog | grep "Shared library"
0x00000001 (NEEDED)         Shared library: [libm.so.6]
0x00000001 (NEEDED)         Shared library: [libc.so.6]

共享库需要运行时链接器,您可以使用以下命令公开它:

$ arm-cortex_a8-linux-gnueabihf-readelf -a myprog | grep "program interpreter"
 [Requesting program interpreter: /lib/ld-linux-armhf.so.3]

这是如此有用,以至于我有一个包含这些命令的脚本文件:

#!/bin/sh
${CROSS_COMPILE}readelf -a $1 | grep "program interpreter"
${CROSS_COMPILE}readelf -a $1 | grep "Shared library"

链接库:静态和动态链接

您为 Linux 编写的任何应用程序,无论是 C 还是 C++,都将与 C 库 libc 链接。这是如此基本,以至于您甚至不必告诉gccg++去做,因为它总是链接 libc。您可能想要链接的其他库必须通过-l选项显式命名。

图书馆代码可以以两种不同的方式链接:静态链接,意味着应用程序调用的所有库函数及其依赖项都从库存档中提取并绑定到可执行文件中;动态链接,意味着代码中生成对库文件和这些文件中的函数的引用,但实际的链接是在运行时动态完成的。

静态库

静态链接在一些情况下很有用。例如,如果您正在构建一个仅包含 BusyBox 和一些脚本文件的小型系统,将 BusyBox 静态链接并避免复制运行时库文件和链接器会更简单。它还会更小,因为您只链接应用程序使用的代码,而不是提供整个 C 库。如果您需要在运行时库可用之前运行程序,静态链接也很有用。

通过在命令行中添加-static,您可以告诉 gcc 将所有库静态链接起来:

$ arm-cortex_a8-linux-gnueabihf-gcc -static helloworld.c -o helloworld-static

您会注意到二进制文件的大小大幅增加:

$ ls -l
-rwxrwxr-x 1 chris chris   5323 Oct  9 09:01 helloworld
-rwxrwxr-x 1 chris chris 625704 Oct  9 09:01 helloworld-static

静态链接从库存档中提取代码,通常命名为lib[name].a。在前面的情况下,它是libc.a,位于[sysroot]/usr/lib中:

$ ls -l $(arm-cortex_a8-linux-gnueabihf-gcc -print-sysroot)/usr/lib/libc.a
-r--r--r-- 1 chris chris 3434778 Oct  8 14:00 /home/chris/x-tools/arm-cortex_a8-linux-gnueabihf/arm-cortex_a8-linux-gnueabihf/sysroot/usr/lib/libc.a

请注意,语法$(arm-cortex_a8-linux-gnueabihf-gcc -print-sysroot)将程序的输出放在命令行上。我正在使用它作为一种通用方式来引用 sysroot 中的文件。

创建静态库就像使用ar命令创建对象文件的存档一样简单。如果我有两个名为test1.ctest2.c的源文件,并且我想创建一个名为libtest.a的静态库,那么我会这样做:

$ arm-cortex_a8-linux-gnueabihf-gcc -c test1.c
$ arm-cortex_a8-linux-gnueabihf-gcc -c test2.c
$ arm-cortex_a8-linux-gnueabihf-ar rc libtest.a test1.o test2.o
$ ls -l
total 24
-rw-rw-r-- 1 chris chris 2392 Oct  9 09:28 libtest.a
-rw-rw-r-- 1 chris chris  116 Oct  9 09:26 test1.c
-rw-rw-r-- 1 chris chris 1080 Oct  9 09:27 test1.o
-rw-rw-r-- 1 chris chris  121 Oct  9 09:26 test2.c
-rw-rw-r-- 1 chris chris 1088 Oct  9 09:27 test2.o

然后我可以使用以下命令将libtest链接到我的helloworld程序中:

$ arm-cortex_a8-linux-gnueabihf-gcc helloworld.c -ltest -L../libs -I../libs -o helloworld

共享库

部署库的更常见方式是作为在运行时链接的共享对象,这样可以更有效地使用存储和系统内存,因为只需要加载一份代码副本。这也使得可以轻松更新库文件,而无需重新链接所有使用它们的程序。

共享库的目标代码必须是位置无关的,以便运行时链接器可以自由地将其定位在内存中的下一个空闲地址。为此,使用 gcc 添加-fPIC参数,然后使用-shared选项进行链接:

$ arm-cortex_a8-linux-gnueabihf-gcc -fPIC -c test1.c
$ arm-cortex_a8-linux-gnueabihf-gcc -fPIC -c test2.c
$ arm-cortex_a8-linux-gnueabihf-gcc -shared -o libtest.so test1.o test2.o

要将应用程序与此库链接,您需要添加-ltest,与前面段落中提到的静态情况完全相同,但是这次代码不包含在可执行文件中,而是有一个对运行时链接器必须解析的库的引用:

$ arm-cortex_a8-linux-gnueabihf-gcc helloworld.c -ltest -L../libs -I../libs -o helloworld
$ list-libs helloworld
[Requesting program interpreter: /lib/ld-linux-armhf.so.3]
0x00000001 (NEEDED)                     Shared library: [libtest.so]
0x00000001 (NEEDED)                     Shared library: [libc.so.6]

这个程序的运行时链接器是/lib/ld-linux-armhf.so.3,必须存在于目标文件系统中。链接器将在默认搜索路径/lib/usr/lib中查找libtest.so。如果您希望它也在其他目录中查找库,可以在 shell 变量LD_LIBRARY_PATH中放置一个以冒号分隔的路径列表:

# export LD_LIBRARY_PATH=/opt/lib:/opt/usr/lib

理解共享库版本号

共享库的一个好处是它们可以独立于使用它们的程序进行更新。库更新有两种类型:修复错误或以向后兼容的方式添加新功能的更新,以及破坏现有应用程序兼容性的更新。GNU/Linux 有一个版本控制方案来处理这两种情况。

每个库都有一个发布版本和一个接口号。发布版本只是一个附加到库名称的字符串,例如 JPEG 图像库 libjpeg 当前发布版本为 8.0.2,因此库的名称为libjpeg.so.8.0.2。有一个名为libjpeg.so的符号链接指向libjpeg.so.8.0.2,因此当您使用-ljpeg编译程序时,您将链接到当前版本。如果安装了版本 8.0.3,链接将被更新,您将链接到新版本。

现在,假设出现了版本 9.0.0,并且它破坏了向后兼容性。libjpeg.so现在指向libjpeg.so.9.0.0,因此任何新程序都将链接到新版本,可能在 libjpeg 接口发生更改时引发编译错误,开发人员可以修复。目标上未重新编译的任何程序都将以某种方式失败,因为它们仍在使用旧接口。这就是soname的作用。soname在构建库时编码接口号,并在运行时链接器加载库时使用。它的格式为<库名称>.so.<接口号>。对于libjpeg.so.8.0.2sonamelibjpeg.so.8

$ readelf -a /usr/lib/libjpeg.so.8.0.2 | grep SONAME
0x000000000000000e (SONAME)             Library soname: [libjpeg.so.8]

使用它编译的任何程序都将在运行时请求libjpeg.so.8,这将是目标上的一个指向libjpeg.so.8.0.2的符号链接。安装 libjpeg 的 9.0.0 版本时,它将具有sonamelibjpeg.so.9,因此可以在同一系统上安装两个不兼容版本的相同库。使用libjpeg.so.8.*.*链接的程序将加载libjpeg.so.8,而使用libjpeg.so.9.*.*链接的程序将加载libjpeg.so.9

这就是为什么当您查看<sysroot>/usr/lib/libjpeg*目录列表时,会找到这四个文件:

  • libjpeg.a:这是用于静态链接的库存档

  • libjpeg.so -> libjpeg.so.8.0.2:这是一个符号链接,用于动态链接

  • libjpeg.so.8 -> libjpeg.so.8.0.2:这是在运行时加载库时使用的符号链接

  • libjpeg.so.8.0.2:这是实际的共享库,用于编译时和运行时

前两个仅在主机计算机上用于构建,后两个在目标上运行时需要。

交叉编译的艺术

拥有可用的交叉工具链只是旅程的起点,而不是终点。在某些时候,您将希望开始交叉编译各种工具、应用程序和库,这些都是您在目标设备上需要的。其中许多是开源软件包,每个软件包都有自己的编译方法和特点。其中一些常见的构建系统包括:

  • 纯 makefile,其中工具链由make变量CROSS_COMPILE控制

  • 被称为 Autotools 的 GNU 构建系统

  • CMake (cmake.org)

我这里只会涵盖前两个,因为这些是甚至基本嵌入式 Linux 系统所需的。对于 CMake,在前面一点引用的 CMake 网站上有一些很好的资源。

简单的 makefile

一些重要的软件包非常容易进行交叉编译,包括 Linux 内核、U-Boot 引导加载程序和 Busybox。对于这些软件包,您只需要将工具链前缀放在make变量CROSS_COMPILE中,例如arm-cortex_a8-linux-gnueabi-。注意末尾的破折号-

因此,要编译 Busybox,您需要键入:

$ make CROSS_COMPILE=arm-cortex_a8-linux-gnueabi-

或者,您可以将其设置为 shell 变量:

$ export CROSS_COMPILE=arm-cortex_a8-linux-gnueabi-
$ make

在 U-Boot 和 Linux 的情况下,您还必须将make变量ARCH设置为它们支持的机器架构之一,我将在第三章和第四章中介绍,关于引导加载程序移植和配置内核

Autotools

名称 Autotools 指的是一组工具,它们被用作许多开源项目中的构建系统。这些组件以及相应的项目页面是:

Autotools 的作用是消除软件包可能编译的许多不同类型系统之间的差异,考虑到不同版本的编译器、不同版本的库、头文件的不同位置以及与其他软件包的依赖关系。使用 Autotools 的软件包附带一个名为configure的脚本,该脚本检查依赖关系并根据其发现生成 makefile。配置脚本还可以让您有机会启用或禁用某些功能。您可以通过运行./configure --help来查看提供的选项。

要为本机操作系统配置、构建和安装软件包,通常会运行以下三个命令:

$ ./configure
$ make
$ sudo make install

Autotools 也能够处理交叉开发。您可以通过设置这些 shell 变量来影响配置脚本的行为:

  • CC:C 编译器命令

  • CFLAGS:额外的 C 编译器标志

  • LDFLAGS:额外的链接器标志,例如,如果您在非标准目录<lib dir>中有库,则可以通过添加-L<lib dir>将其添加到库搜索路径

  • LIBS:包含要传递给链接器的额外库的列表,例如数学库-lm

  • CPPFLAGS:包含 C/C++预处理器标志,例如,您可以添加-I<include dir>来在非标准目录<include dir>中搜索头文件

  • CPP:要使用的 C 预处理器

有时只需设置CC变量即可,如下所示:

$ CC=arm-cortex_a8-linux-gnueabihf-gcc ./configure

在其他时候,这将导致如下错误:

[...]
checking whether we are cross compiling... configure: error: in '/home/chris/MELP/build/sqlite-autoconf-3081101':
configure: error: cannot run C compiled programs.
If you meant to cross compile, use '--host'.
See 'config.log' for more details

失败的原因是configure经常尝试通过编译代码片段并运行它们来发现工具链的功能,以查看发生了什么,如果程序已经进行了交叉编译,这种方法是行不通的。然而,错误消息中有解决问题的提示。Autotools 理解编译软件包时可能涉及的三种不同类型的机器:

  • 构建:这是用于构建软件包的计算机,默认为当前计算机。

  • 主机:这是程序将在其上运行的计算机:对于本地编译,这将保持为空白,并且默认为与构建相同的计算机。对于交叉编译,您需要将其设置为您的工具链的元组。

  • 目标:这是程序将为其生成代码的计算机:例如,构建交叉编译器时会设置这个。

因此,要进行交叉编译,您只需要覆盖主机,如下所示:

$ CC=arm-cortex_a8-linux-gnueabihf-gcc \
./configure --host=arm-cortex_a8-linux-gnueabihf

最后要注意的一件事是默认安装目录是<sysroot>/usr/local/*。通常会将其安装在<sysroot>/usr/*中,以便从默认位置获取头文件和库文件。配置典型的 Autotools 软件包的完整命令是:

$ CC=arm-cortex_a8-linux-gnueabihf-gcc \
./configure --host=arm-cortex_a8-linux-gnueabihf --prefix=/usr

例如:SQLite

SQLite 库实现了一个简单的关系型数据库,在嵌入式设备上非常受欢迎。您可以通过获取 SQLite 的副本来开始:

$ wget http://www.sqlite.org/2015/sqlite-autoconf-3081101.tar.gz
$ tar xf sqlite-autoconf-3081101.tar.gz
$ cd sqlite-autoconf-3081101

接下来,运行配置脚本:

$ CC=arm-cortex_a8-linux-gnueabihf-gcc \
./configure --host=arm-cortex_a8-linux-gnueabihf --prefix=/usr

看起来好像可以了!如果失败,终端会打印错误消息,并记录在config.log中。请注意,已创建了几个 makefile,现在您可以构建它:

$ make

最后,通过设置make变量DESTDIR将其安装到工具链目录中。如果不这样做,它将尝试将其安装到主机计算机的/usr目录中,这不是您想要的。

$ make DESTDIR=$(arm-cortex_a8-linux-gnueabihf-gcc -print-sysroot) install

您可能会发现最终的命令失败并出现文件权限错误。crosstool-NG 工具链默认为只读,因此在构建时将CT_INSTALL_DIR_RO设置为y是很有用的。另一个常见问题是工具链安装在系统目录(例如/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/pkgconfigsqlite3.pc:这是软件包配置文件,如下一节所述。

  • <sysroot>/usr/lib/includesqlite3.hsqlite3ext.h:这些是头文件。

  • <sysroot>/usr/share/man/man1:sqlite3.1。这是手册页。

现在,您可以在链接阶段添加-lsqlite3来编译使用 sqlite3 的程序:

$ arm-cortex_a8-linux-gnueabihf-gcc -lsqlite3 sqlite-test.c -o sqlite-test

其中,sqlite-test.c是一个调用 SQLite 函数的假设程序。由于 sqlite3 已安装到 sysroot 中,编译器将无需任何问题地找到头文件和库文件。如果它们已安装在其他位置,您将需要添加-L<lib dir>-I<include dir>

当然,还会有运行时依赖关系,您需要将适当的文件安装到目标目录中,如第五章中所述,构建根文件系统

软件包配置

跟踪软件包依赖关系非常复杂。软件包配置实用程序pkg-configwww.freedesktop.org/wiki/Software/pkg-config)通过在[sysroot]/usr/lib/pkgconfig中保持 Autotools 软件包的数据库来帮助跟踪已安装的软件包以及每个软件包需要的编译标志。例如,SQLite3 的软件包配置名为sqlite3.pc,包含其他需要使用它的软件包所需的基本信息:

$ cat $(arm-cortex_a8-linux-gnueabihf-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.8.11.1
Libs: -L${libdir} -lsqlite3
Libs.private: -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:

$ PKG_CONFIG_LIBDIR=$(arm-cortex_a8-linux-gnueabihf-gcc -print-sysroot)/usr/lib/pkgconfig \
pkg-config sqlite3 --libs --cflags
 -lsqlite3

现在输出是-lsqlite3。在这种情况下,你已经知道了,但通常情况下你不会知道,所以这是一种有价值的技术。最终的编译命令将是:

$ PKG_CONFIG_LIBDIR=$(arm-cortex_a8-linux-gnueabihf-gcc -print-sysroot)/usr/lib/pkgconfig \
arm-cortex_a8-linux-gnueabihf-gcc $(pkg-config sqlite3 --cflags --libs) sqlite-test.c -o sqlite-

交叉编译的问题

Sqlite3 是一个行为良好的软件包,可以很好地进行交叉编译,但并非所有软件包都如此温顺。典型的痛点包括:

  • 自制构建系统,例如 zlib,有一个配置脚本,但它的行为不像前一节中描述的 Autotools 配置。

  • 读取pkg-config信息、头文件和其他文件的配置脚本,忽略--host覆盖

  • 坚持尝试运行交叉编译代码的脚本

每种情况都需要仔细分析错误,并向配置脚本提供正确的信息或修补代码以完全避免问题。请记住,一个软件包可能有很多依赖项,特别是对于使用 GTK 或 QT 的图形界面或处理多媒体内容的程序。例如,mplayer 是一个用于播放多媒体内容的流行工具,它依赖于 100 多个库。构建它们将需要数周的努力。

因此,我不建议以这种方式手动交叉编译目标的组件,除非没有其他选择,或者要构建的软件包数量很少。一个更好的方法是使用 Buildroot 或 Yocto Project 等构建工具,或者通过为目标架构设置本地构建环境来完全避免这个问题。现在你可以看到为什么像 Debian 这样的发行版总是本地编译的了。

总结

工具链始终是你的起点:从那里开始的一切都依赖于拥有一个工作的、可靠的工具链。

大多数嵌入式构建环境都是基于交叉开发工具链的,它在强大的主机计算机上构建代码,并在运行代码的目标计算机上创建了明确的分离。工具链本身由 GNU binutils、GNU 编译器集合中的 C 编译器,很可能还有 C++编译器,以及我描述过的 C 库之一组成。通常在这一点上会生成 GNU 调试器 gdb,我在第十二章中描述了它,使用 GDB 进行调试。此外,要密切关注 Clang 编译器,因为它将在未来几年内发展。

你可能从零开始,只有一个工具链,也许是使用 crosstool-NG 构建的,或者从 Linaro 下载的,并使用它来编译你在目标上需要的所有软件包,接受这将需要大量的辛苦工作。或者,你可以作为一个分发的一部分获得工具链,该分发包括一系列软件包。一个分发可以使用 Buildroot 或 Yocto Project 等构建系统从源代码生成,也可以是来自第三方的二进制分发,也许是像 Mentor Graphics 这样的商业企业,或者是像 Denx ELDK 这样的开源项目。要注意的是,作为硬件包的一部分免费提供给你的工具链或分发通常配置不良且未得到维护。无论如何,你应该根据自己的情况做出选择,然后在整个项目中保持一致。

一旦你有了一个工具链,你就可以用它来构建嵌入式 Linux 系统的其他组件。在下一章中,你将学习关于引导加载程序的知识,它可以让你的设备启动并开始引导过程。

第三章:关于引导加载程序的一切

引导加载程序是嵌入式 Linux 的第二个元素。它是启动系统并加载操作系统内核的部分。在本章中,我将研究引导加载程序的作用,特别是它如何使用称为设备树的数据结构将控制权从自身传递给内核,也称为扁平设备树FDT。我将介绍设备树的基础知识,以便您能够跟随设备树中描述的连接,并将其与实际硬件联系起来。

我将研究流行的开源引导加载程序 U-Boot,并看看如何使用它来引导目标设备,以及如何定制它以适应新设备。最后,我将简要介绍 Barebox,这是一个与 U-Boot 共享历史的引导加载程序,但可以说它具有更清晰的设计。

引导加载程序的作用是什么?

在嵌入式 Linux 系统中,引导加载程序有两个主要任务:基本系统初始化和内核加载。实际上,第一个任务在某种程度上是第二个任务的附属,因为只有在加载内核所需的系统工作正常时才需要。

当执行引导加载程序代码的第一行时,随着通电或复位,系统处于非常基本的状态。DRAM 控制器尚未设置,因此主存储器不可访问,同样,其他接口也尚未配置,因此通过 NAND 闪存控制器、MMC 控制器等访问的存储器也不可用。通常,在开始时仅有一个 CPU 核心和一些芯片上的静态存储器是可操作的。因此,系统引导包括几个代码阶段,每个阶段都将系统的更多部分带入运行。

早期引导阶段在加载内核所需的接口正常工作后停止。这包括主存储器和用于访问内核和其他映像的外围设备,无论是大容量存储还是网络。引导加载程序的最后一步是将内核加载到 RAM 中,并为其创建执行环境。引导加载程序与内核之间的接口细节是特定于体系结构的,但在所有情况下,这意味着传递有关引导加载程序已知的硬件信息的指针,并传递一个内核命令行,这是一个包含 Linux 必要信息的 ASCII 字符串。一旦内核开始执行,引导加载程序就不再需要,并且可以回收它使用的所有内存。

引导加载程序的附属任务是提供维护模式,用于更新引导配置,将新的引导映像加载到内存中,可能运行诊断。这通常由一个简单的命令行用户界面控制,通常通过串行接口。

引导序列

在更简单的时代,几年前,只需要将引导加载程序放在处理器的复位向量处的非易失性存储器中。当时 NOR 闪存存储器很常见,由于它可以直接映射到地址空间中,因此是存储的理想方法。以下图表显示了这样的配置,复位向量位于闪存存储器区域的顶端 0xfffffffc 处。引导加载程序被链接,以便在该位置有一个跳转指令,指向引导加载程序代码的开始位置:

引导序列

旧日的引导

从那时起,它可以初始化内存控制器,使主存储器 DRAM 可用,并将自身复制到 DRAM 中。一旦完全运行,引导加载程序可以将内核从闪存加载到 DRAM 中,并将控制权转移给它。

然而,一旦远离像 NOR 闪存这样的简单线性可寻址存储介质,引导序列就变成了一个复杂的多阶段过程。细节对于每个 SoC 都非常具体,但它们通常遵循以下各个阶段。

阶段 1:ROM 代码

在没有可靠的外部存储器的情况下,立即在重置或上电后运行的代码必须存储在 SoC 芯片上;这就是所谓的 ROM 代码。它在制造芯片时被编程,因此 ROM 代码是专有的,不能被开源等效物替换。ROM 代码对不在芯片上的任何硬件都可以做出非常少的假设,因为它将与另一个设计不同。这甚至适用于用于主系统内存的 DRAM 芯片。因此,ROM 代码只能访问大多数 SoC 设计中找到的少量静态 RAM(SRAM)。SRAM 的大小从 4 KiB 到几百 KiB 不等:

第 1 阶段:ROM 代码

第 1 阶段引导加载程序

ROM 代码能够从几个预编程位置之一加载一小块代码到 SRAM 中。例如,TI OMAP 和 Sitara 芯片将尝试从 NAND 闪存的前几页,或通过 SPI(串行外围接口)连接的闪存,或 MMC 设备的前几个扇区(可能是 eMMC 芯片或 SD 卡),或 MMC 设备的第一个分区上名为MLO的文件中加载代码。如果从所有这些存储设备读取失败,那么它将尝试从以太网、USB 或 UART 读取字节流;后者主要用作在生产过程中将代码加载到闪存中,而不是用于正常操作。大多数嵌入式 SoC 都有类似的 ROM 代码工作方式。在 SRAM 不足以加载像 U-Boot 这样的完整引导加载程序的 SoC 中,必须有一个称为二级程序加载器或 SPL 的中间加载器。

在这个阶段结束时,下一阶段的引导加载程序存在于芯片内存中,ROM 代码跳转到该代码的开头。

第 2 阶段:SPL

SPL 必须设置内存控制器和系统的其他必要部分,以准备将第三阶段程序加载器(TPL)加载到主内存 DRAM 中。SPL 的功能受其大小限制。它可以从存储设备列表中读取程序,就像 ROM 代码一样,再次使用从闪存设备开始的预编程偏移量,或者像u-boot.bin这样的众所周知的文件名。SPL 通常不允许用户交互,但它可以打印版本信息和进度消息,这些消息将显示在控制台上。以下图解释了第 2 阶段的架构:

第 2 阶段:SPL

第二阶段引导

SPL 可能是开源的,就像 TI x-loader 和 Atmel AT91Bootstrap 一样,但它通常包含供应商提供的专有代码,以二进制块的形式提供。

在第二阶段结束时,DRAM 中存在第三阶段加载器,并且 SPL 可以跳转到该区域。

第 3 阶段:TPL

现在,最后,我们正在运行像 U-Boot 或 Barebox 这样的完整引导加载程序。通常,有一个简单的命令行用户界面,让您执行维护任务,如将新的引导和内核映像加载到闪存中,加载和引导内核,并且有一种方法可以在没有用户干预的情况下自动加载内核。以下图解释了第 3 阶段的架构:

第 3 阶段:TPL

第三阶段引导

在第三阶段结束时,内存中存在一个等待启动的内核。嵌入式引导加载程序通常在内核运行后从内存中消失,并且在系统操作中不再起任何作用。

使用 UEFI 固件引导

大多数嵌入式 PC 设计和一些 ARM 设计都基于通用可扩展固件接口(UEFI)标准的固件,有关更多信息,请参阅官方网站www.uefi.org。引导顺序基本上与前一节中描述的相同:

第一阶段:处理器从闪存加载 UEFI 引导管理器固件。在某些设计中,它直接从 NOR 闪存加载,而在其他设计中,芯片上有 ROM 代码,它从 SPI 闪存加载引导管理器。引导管理器大致相当于 SPL,但可能允许用户通过基于文本或图形界面进行交互。

第二阶段:引导管理器从EFI 系统分区ESP)或硬盘或固态硬盘加载引导固件,或通过 PXE 引导从网络服务器加载。如果从本地磁盘驱动器加载,则 EXP 由已知的 GUID 值 C12A7328-F81F-11D2-BA4B-00A0C93EC93B 标识。分区应使用 FAT32 格式进行格式化。第三阶段引导加载程序应该位于名为<efi_system_partition>/boot/boot<machine_type_short_name>.efi的文件中。

例如,在 x86_64 系统上加载器的文件路径是:/efi/boot/bootx64.efi

第三阶段:在这种情况下,TPL 必须是一个能够将 Linux 内核和可选的 RAM 磁盘加载到内存中的引导加载程序。常见选择包括:

  • GRUB 2:这是 GNU 统一引导加载程序,第 2 版,是 PC 平台上最常用的 Linux 加载程序。然而,有一个争议,即它根据 GPL v3 许可,这可能使其与安全引导不兼容,因为许可要求提供代码的引导密钥。网站是www.gnu.org/software/grub/

  • gummiboot:这是一个简单的与 UEFI 兼容的引导加载程序,已经集成到 systemd 中,并且根据 LGPL v2.1 许可。网站是wiki.archlinux.org/index.php/Systemd-boot

从引导加载程序到内核的转移

当引导加载程序将控制权传递给内核时,它必须向内核传递一些基本信息,其中可能包括以下一些内容:

  • 在 PowerPC 和 ARM 架构上:一种与 SoC 类型相关的数字

  • 迄今为止检测到的硬件的基本细节,包括至少物理 RAM 的大小和位置,以及 CPU 时钟速度

  • 内核命令行

  • 可选的设备树二进制文件的位置和大小

  • 可选的初始 RAM 磁盘的位置和大小

内核命令行是一个纯 ASCII 字符串,用于控制 Linux 的行为,例如设置包含根文件系统的设备。我将在下一章中详细介绍这一点。通常会将根文件系统提供为 RAM 磁盘,在这种情况下,引导加载程序有责任将 RAM 磁盘映像加载到内存中。我将在第五章中介绍创建初始 RAM 磁盘的方法,构建根文件系统

传递这些信息的方式取决于架构,并且近年来发生了变化。例如,对于 PowerPC,引导加载程序过去只是传递一个指向板信息结构的指针,而对于 ARM,它传递了一个指向“A 标签”列表的指针。在Documentation/arm/Booting中有关内核源代码格式的良好描述。

在这两种情况下,传递的信息量非常有限,大部分信息需要在运行时发现或硬编码到内核中作为“平台数据”。广泛使用平台数据意味着每个设备都必须有为该平台配置和修改的内核。需要一种更好的方法,这种方法就是设备树。在 ARM 世界中,从 2013 年 2 月发布 Linux 3.8 开始,逐渐摆脱了 A 标签,但仍然有相当多的设备在现场使用,甚至在开发中,仍在使用 A 标签。

介绍设备树

你几乎肯定会在某个时候遇到设备树。本节旨在为您快速概述它们是什么以及它们是如何工作的,但有许多细节没有讨论。

设备树是定义计算机系统的硬件组件的灵活方式。通常,设备树由引导加载程序加载并传递给内核,尽管也可以将设备树与内核映像捆绑在一起,以适应不能单独处理它们的引导加载程序。

该格式源自 Sun Microsystems 引导加载程序 OpenBoot,它被正式规范为 Open Firmware 规范,IEEE 标准 IEEE1275-1994。它曾在基于 PowerPC 的 Macintosh 计算机上使用,因此是 PowerPC Linux 端口的一个合乎逻辑的选择。从那时起,它已被许多 ARM Linux 实现大规模采用,并在较小程度上被 MIPS、MicroBlaze、ARC 和其他架构所采用。

我建议访问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@0x80000000 {
    device_type = "memory";
    reg = <0x80000000 0x20000000>; /* 512 MB */
  };
};

在这里,我们有一个包含cpus节点和内存节点的根节点。cpus节点包含一个名为cpu@0的单个 CPU 节点。通常约定节点的名称包括一个@后跟一个地址,用于将其与其他节点区分开。

根节点和 CPU 节点都有一个兼容属性。Linux 内核使用这个属性来将此名称与设备驱动程序中的struct of_device_id导出的字符串进行匹配(有关更多信息,请参见第八章,“介绍设备驱动程序”)。这是一个惯例,该值由制造商名称和组件名称组成,以减少不同制造商制造的类似设备之间的混淆,因此ti,am33xxarm,cortex-a8compatible通常有多个值,其中有多个驱动程序可以处理此设备。它们按最合适的顺序列出。

CPU 节点和内存节点都有一个device_type属性,描述设备的类别。节点名称通常是从device_type派生的。

reg 属性

内存和 CPU 节点都有一个reg属性,它指的是寄存器空间中的一系列单元。reg属性由两个值组成,表示范围的起始地址和大小(长度)。两者都以零个或多个 32 位整数(称为单元)写下。因此,内存节点指的是从 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属性分配了一个单一值:节点reg = <0>

Phandles 和中断

到目前为止,设备树的结构假设存在一个组件的单一层次结构,而实际上存在多个层次结构。除了组件与系统其他部分之间的明显数据连接之外,它还可能连接到中断控制器、时钟源和电压调节器。为了表达这些连接,我们有 phandles。

以一个包含可以生成中断并且中断控制器的串行端口的系统为例:

/dts-v1/;
{
  intc: interrupt-controller@48200000 {
    compatible = "ti,am33xx-intc";
    interrupt-controller;
    #interrupt-cells = <1>;
    reg = <0x48200000 0x1000>;
  };
  serial@44e09000 {
    compatible = "ti,omap3-uart";
    ti,hwmods = "uart1";
    clock-frequency = <48000000>;
    reg = <0x44e09000 0x2000>;
    interrupt-parent = <&intc>;
    interrupts = <72>;
  };
};

我们有一个中断控制器节点,它有特殊属性#interrupt-cells,告诉我们需要多少个 4 字节值来表示一个中断线。在这种情况下,只需要一个给出 IRQ 号码,但通常使用额外的值来描述中断,例如1 = 低到高边沿触发2 = 高到低边沿触发,等等。

查看serial节点,它有一个interrupt-parent属性,引用了它连接到的中断控制器的标签。这就是 phandle。实际的 IRQ 线由interrupts属性给出,在这种情况下是72

serial节点有其他我们之前没有见过的属性:clock-frequencyti,hwmods。这些是特定类型设备的绑定的一部分,换句话说,内核设备驱动程序将读取这些属性来管理设备。这些绑定可以在 Linux 内核源代码的Documentation/devicetree/bindings/目录中找到。

设备树包含文件

许多硬件在同一系列 SoC 和使用相同 SoC 的板之间是共同的。这在设备树中通过将共同部分拆分为include文件来反映,通常使用扩展名.dtsi。开放固件标准将/include/定义为要使用的机制,就像在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/pinctrl/am33xx.h包含普通的 C 宏:

#define PULL_DISABLE (1 << 3)
#define INPUT_EN (1 << 5)
#define SLEWCTRL_SLOW (1 << 6)
#define SLEWCTRL_FAST 0

如果设备树源文件使用内核 kbuild 构建,所有这些问题都会得到解决,因为它首先通过 C 预处理器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 24  &edma 25>;
  dma-names = "tx", "rx";
  interrupts = <64>;
  interrupt-parent = <&intc>;
  reg = <0x48060000 0x1000>;
  status = "disabled";
};

注意

注意,状态是disabled,意味着没有设备驱动程序应该绑定到它,而且它有标签mmc1

am335x-bone-common.dtsi中,它被 BeagleBone 和 BeagleBone Black 都包含,相同的节点通过它的 phandle 被引用:

&mmc1 {
  status = "okay";
  bus-width = <0x4>;
  pinctrl-names = "default";
  pinctrl-0 = <&mmc1_pins>;
  cd-gpios = <&gpio0 6 GPIO_ACTIVE_HIGH>;
  cd-inverted;
};

在这里,mmc1被启用(status="okay")因为两个变体都有物理 MMC1 设备,并且pinctrl已经建立。然后,在am335x-boneblack.dts中,你会看到另一个对mmc1的引用,它将其与电压调节器关联起来:

&mmc1 {
  vmmc-supply = <&vmmcsd_fixed>;
};

因此,像这样分层源文件可以提供灵活性,并减少重复代码的需求。

编译设备树

引导加载程序和内核需要设备树的二进制表示,因此必须使用设备树编译器dtc进行编译。结果是一个以.dtb结尾的文件,称为设备树二进制或设备树 blob。

Linux 源代码中有一个dtc的副本,在scripts/dtc/dtc中,它也可以作为许多 Linux 发行版的软件包使用。您可以使用它来编译一个简单的设备树(不使用#include的设备树)如下:

$ dtc simpledts-1.dts -o simpledts-1.dtb
DTC: dts->dts on file "simpledts-1.dts"

要注意的是,dtc不提供有用的错误消息,它只对语言的基本语法进行检查,这意味着在源文件中调试打字错误可能是一个漫长的过程。

要构建更复杂的示例,您将需要使用内核kbuild,如下一章所示。

选择引导加载程序

引导加载程序有各种形状和大小。您希望从引导加载程序中获得的特征是它们简单且可定制,并且有许多常见开发板和设备的示例配置。以下表格显示了一些通常使用的引导加载程序:

名称 架构
Das U-Boot ARM, Blackfin, MIPS, PowerPC, SH
Barebox ARM, Blackfin, MIPS, PowerPC
GRUB 2 X86, X86_64
RedBoot ARM, MIPS, PowerPC, SH
CFE Broadcom MIPS
YAMON MIPS

我们将专注于 U-Boot,因为它支持许多处理器架构和大量的个别板和设备。它已经存在很长时间,并且有一个良好的社区支持。

也许您收到了一个与您的 SoC 或板一起的引导加载程序。像往常一样,仔细看看您拥有的东西,并询问您可以从哪里获取源代码,更新政策是什么,如果您想进行更改他们将如何支持您等等。您可能要考虑放弃供应商提供的加载程序,改用开源引导加载程序的当前版本。

U-Boot

U-Boot,或者以其全名 Das U-Boot,最初是嵌入式 PowerPC 板的开源引导加载程序。然后,它被移植到基于 ARM 的板上,后来又移植到其他架构,包括 MIPS、SH 和 x86。它由 Denx 软件工程托管和维护。有大量的信息可用,一个很好的起点是www.denx.de/wiki/U-Boot。还有一个邮件列表在<u-boot@lists.denx.de>

构建 U-Boot

首先要获取源代码。与大多数项目一样,推荐的方法是克隆 git 存档并检出您打算使用的标签,本例中是写作时的当前版本:

$ git clone git://git.denx.de/u-boot.git
$ cd u-boot
$ git checkout v2015.07

或者,您可以从 ftp://ftp.denx.de/pub/u-boot/获取一个 tarball。

configs/目录中有超过 1,000 个常见开发板和设备的配置文件。在大多数情况下,您可以根据文件名猜出要使用哪个,但您可以通过查看board/目录中每个板的README文件来获取更详细的信息,或者您可以在适当的网络教程或论坛中找到信息。不过要注意,自 2014.10 版本以来,U-Boot 的配置方式发生了很多变化。请仔细检查您正在遵循的说明是否合适。

以 BeagleBone Black 为例,我们发现在configs/中有一个名为am335x_boneblack_defconfig的可能配置文件,并且在 am335x 芯片的板README文件board/ti/am335x/README中找到了文本该板生成的二进制文件支持...Beaglebone Black。有了这些知识,为 BeagleBone Black 构建 U-Boot 就很简单了。您需要通过设置make变量CROSS_COMPILE来告知 U-Boot 交叉编译器的前缀,然后使用make [board]_defconfig类型的命令选择配置文件,如下所示:

$ make CROSS_COMPILE=arm-cortex_a8-linux-gnueabihf- am335x_boneblack_defconfig
$ make CROSS_COMPILE=arm-cortex_a8-linux-gnueabihf-

编译的结果是:

  • u-boot:这是以 ELF 对象格式的 U-Boot,适合与调试器一起使用

  • u-boot.map:这是符号表

  • u-boot.bin:这是 U-Boot 的原始二进制格式,适合在设备上运行

  • u-boot.img:这是u-boot.bin添加了 U-Boot 头的版本,适合上传到正在运行的 U-Boot 副本

  • u-boot.srec:这是以 Motorola srec格式的 U-Boot,适合通过串行连接传输

BeagleBone Black 还需要一个Secondary Program LoaderSPL),如前所述。这是同时构建的,命名为MLO

$ ls -l MLO u-boot*
-rw-rw-r-- 1 chris chris 76100 Dec 20 11:22 MLO
-rwxrwxr-x 1 chris chris 2548778 Dec 20 11:22 u-boot
-rw-rw-r-- 1 chris chris 449104 Dec 20 11:22 u-boot.bin
-rw-rw-r-- 1 chris chris 449168 Dec 20 11:22 u-boot.img
-rw-rw-r-- 1 chris chris 434276 Dec 20 11:22 u-boot.map
-rw-rw-r-- 1 chris chris 1347442 Dec 20 11:22 u-boot.srec

其他目标的过程类似。

安装 U-Boot

首次在板上安装引导加载程序需要一些外部帮助。如果板上有硬件调试接口,比如 JTAG,通常可以直接将 U-Boot 的副本加载到 RAM 中并运行。从那时起,您可以使用 U-Boot 命令将其复制到闪存中。这些细节非常依赖于板子,并且超出了本书的范围。

一些 SoC 设计内置了引导 ROM,可以用于从各种外部来源(如 SD 卡、串行接口或 USB)读取引导代码,BeagleBone Black 中的 AM335x 芯片就是这种情况。以下是如何通过 micro-SD 卡加载 U-Boot。

首先,格式化 micro-SD 卡,使第一个分区为 FAT32 格式,并标记为可引导。如果有直接的 SD 卡插槽可用,卡片将显示为/dev/mmcblk0,否则,如果使用内存卡读卡器,它将显示为/dev/sdb,或/dev/sdc等。现在,假设卡片显示为/dev/mmcblk0,输入以下命令对 micro-SD 卡进行分区:

$ sudo sfdisk -D -H 255 -S 63 /dev/mmcblk0 << EOF 
,9,0x0C,*
,,,-
EOF

将第一个分区格式化为FAT16

$ sudo mkfs.vfat -F 16 -n boot /dev/mmcblk0p1

现在,挂载您刚刚格式化的分区:在某些系统上,只需简单地拔出 micro-SD 卡,然后再插入即可,而在其他系统上,您可能需要单击一个图标。在当前版本的 Ubuntu 上,它应该被挂载为/media/[user]/boot,所以我会像这样将 U-Boot 和 SPL 复制到它:

cp MLO u-boot.img /media/chris/boot

最后,卸载它。

在 BeagleBone 板上没有电源的情况下,插入 micro-SD 卡。

插入串行电缆。串行端口应该出现在您的 PC 上,如/dev/ttyUSB0或类似。

启动适当的终端程序,如gtktermminicompicocom,并以 115,200 bps 的速度,无流控制连接到端口:

$ gtkterm -p /dev/ttyUSB0 -s 115200

按住 Beaglebone 上的Boot Switch按钮,使用外部 5V 电源连接器启动板,大约 5 秒后释放按钮。您应该在串行控制台上看到一个 U-Boot 提示:

U-Boot#

使用 U-Boot

在本节中,我将描述一些您可以使用 U-Boot 执行的常见任务。

通常,U-Boot 通过串行端口提供命令行界面。它提供一个为每个板定制的命令提示符。在示例中,我将使用U-Boot#。输入help会打印出此版本 U-Boot 中配置的所有命令;输入help <command>会打印出有关特定命令的更多信息。

默认的命令解释器非常简单。按左右光标键没有命令行编辑;按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\0" \
"myvar2=value2\0"

您可以使用setenv从 U-Boot 命令行创建和修改变量。例如,setenv foo bar会创建变量foo,其值为bar。请注意,变量名称和值之间没有=号。您可以通过将其设置为空字符串setenv foo来删除变量。您可以使用printenv将所有变量打印到控制台,或者使用printenv foo打印单个变量。

通常,可以使用saveenv命令将整个环境保存到某种永久存储中。如果有原始 NAND 或 NOR 闪存,则会保留一个擦除块,通常还有另一个用于冗余副本,以防止损坏。如果有 eMMC 或 SD 卡存储,它可以存储在磁盘分区中的文件中。其他选项包括存储在通过 I2C 或 SPI 接口连接的串行 EEPROM 中,或者存储在非易失性 RAM 中。

引导映像格式

U-Boot 没有文件系统。相反,它使用 64 字节的标头标记信息块,以便跟踪内容。您可以使用mkimage命令为 U-Boot 准备文件。以下是其用法的简要总结:

$ mkimage
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 fit-image
mkimage -V ==> print version information and exit

例如,为 ARM 处理器准备内核映像的命令是:

$ mkimage -A arm -O linux -T kernel -C gzip -a 0x80008000 \
-e 0x80008000 -n 'Linux' -d zImage uImage

加载映像

通常,您将从可移动存储介质(如 SD 卡或网络)加载映像。SD 卡在 U-Boot 中由mmc驱动程序处理。将映像加载到内存的典型序列如下:

U-Boot# mmc rescan
U-Boot# fatload mmc 0:1 82000000 uimage
reading uimage
4605000 bytes read in 254 ms (17.3 MiB/s)
U-Boot# iminfo 82000000

## Checking Image at 82000000 ...
Legacy image found
Image Name: Linux-3.18.0
Created: 2014-12-23 21:08:07 UTC
Image Type: ARM Linux Kernel Image (uncompressed)
Data Size: 4604936 Bytes = 4.4 MiB
Load Address: 80008000
Entry Point: 80008000
Verifying Checksum ... OK

mmc rescan命令重新初始化mmc驱动程序,也许是为了检测最近插入的 SD 卡。接下来,使用fatload从 SD 卡上的 FAT 格式分区中读取文件。格式如下:

fatload <interface> [<dev[:part]> [<addr> [<filename> [bytes [pos]]]]]

如果<interface>mmc,如我们的情况,<dev:part>是从零开始计数的mmc接口的设备号,以及从一开始计数的分区号。因此,<0:1>是第一个设备上的第一个分区。选择的内存位置0x82000000是为了在此时未被使用的 RAM 区域中。如果我们打算引导此内核,我们必须确保在解压缩内核映像并将其定位到运行时位置0x80008000时,不会覆盖此 RAM 区域。

要通过网络加载映像文件,您可以使用Trivial File Transfer ProtocolTFTP)。这需要您在开发系统上安装 TFTP 守护程序 tftpd,并启动它运行。您还必须配置 PC 和目标板之间的任何防火墙,以允许 UDP 端口 69 上的 TFTP 协议通过。tftpd 的默认配置仅允许访问目录/var/lib/tftpboot。下一步是将要传输的文件复制到该目录中。然后,假设您使用一对静态 IP 地址,这样就无需进行进一步的网络管理,加载一组内核映像文件的命令序列应如下所示:

U-Boot# setenv ipaddr 192.168.159.42
U-Boot# setenv serverip 192.168.159.99
U-Boot# 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 加载内核映像并将其编程到闪存:

U-Boot# fatload mmc 0:1 82000000 uimage
reading uimage
4605000 bytes read in 254 ms (17.3 MiB/s)

U-Boot# nandecc hw
U-Boot# nand erase 280000 400000

NAND erase: device 0 offset 0x280000, size 0x400000
Erasing at 0x660000 -- 100% complete.
OK
U-Boot# nand write 82000000 280000 400000

NAND write: device 0 offset 0x280000, size 0x400000
4194304 bytes written: OK

现在您可以使用nand read从闪存中加载内核:

U-Boot# nand read 82000000 280000 400000

引导 Linux

bootm命令启动内核映像。语法是:

bootm [内核地址] [ramdisk 地址] [dtb 地址]

内核映像的地址是必需的,但如果内核配置不需要 ramdisk 和 dtb,则可以省略 ramdisk 和 dtb 的地址。如果有 dtb 但没有 ramdisk,则第二个地址可以替换为破折号(-)。看起来像这样:

U-Boot# bootm 82000000 - 83000000

使用 U-Boot 脚本自动引导

显然,每次打开电源时键入一长串命令来引导板是不可接受的。为了自动化这个过程,U-Boot 将一系列命令存储在环境变量中。如果特殊变量bootcmd包含一个脚本,它将在bootdelay秒的延迟后在上电时运行。如果你在串行控制台上观看,你会看到延迟倒计时到零。在这段时间内,你可以按任意键终止倒计时,并进入与 U-Boot 的交互会话。

创建脚本的方式很简单,尽管不容易阅读。你只需附加由分号分隔的命令,分号前必须有一个反斜杠转义字符。因此,例如,要从闪存中的偏移加载内核镜像并引导它,你可以使用以下命令:

setenv bootcmd nand read 82000000 400000 200000\;bootm 82000000

将 U-Boot 移植到新板

假设你的硬件部门创建了一个基于 BeagleBone Black 的名为“Nova”的新板,你需要将 U-Boot 移植到它上面。你需要了解 U-Boot 代码的布局以及板配置机制的工作原理。在 2014.10 版本中,U-Boot 采用了与 Linux 内核相同的配置机制,Kconfig。在接下来的几个版本中,现有的配置设置将从include/configs中的当前位置移动到Kconfig文件中。截至 2014.10 版本,每个板都有一个Kconfig文件,其中包含从旧的boards.cfg文件中提取的最小信息。

你将要处理的主要目录是:

  • arch:包含特定于每个支持的架构的代码,位于 arm、mips、powerpc 等目录中。在每个架构中,都有一个家族成员的子目录,例如在arch/arm/cpu中,有包括 amt926ejs、armv7 和 armv8 在内的架构变体的目录。

  • : 包含特定于板的代码。如果同一个供应商有多个板,它们可以被收集到一个子目录中,因此基于 BeagelBone 的 am335x evm 板的支持在board/ti/am335x中。

  • 公共: 包含核心功能,包括命令行和可以从中调用的命令,每个命令都在一个名为cmd_[命令名称].c的文件中。

  • doc:包含几个描述 U-Boot 各个方面的README文件。如果你想知道如何进行 U-Boot 移植,这是一个很好的起点。

  • 包括:除了许多共享的头文件外,这还包括非常重要的子目录include/configs,在这里你会找到大部分的板配置设置。随着向Kconfig的转变,信息将被移出到Kconfig文件中,但在撰写本文时,这个过程才刚刚开始。

Kconfig 和 U-Boot

KconfigKconfig文件中提取配置信息,并将总系统配置存储在一个名为.config的文件中的方式在第四章中有详细描述,移植和配置内核。U-Boot 采用了 kconfig 和 kbuild,并进行了一些更改。一个 U-Boot 构建可以产生最多三个二进制文件:一个普通的 u-boot.bin,一个二级程序加载器SPL),和一个三级程序加载器TPL),每个可能有不同的配置选项。因此,.config文件和默认配置文件中的行可以用下表中显示的代码前缀来表示它们适用于哪个目标:

仅普通镜像
S: 仅 SPL 镜像
T: 仅 TPL 镜像
ST: SPL 和 TPL 镜像
+S: 普通和 SPL 镜像
+T: 普通和 TPL 镜像
+ST: 普通、SPL 和 TPL 镜像

每个板都有一个存储在configs/[板名称]_defconfig中的默认配置。对于你的 Nova 板,你需要创建一个名为nova_defonfig的文件,并在其中添加这些行:

CONFIG_SPL=y
CONFIG_SYS_EXTRA_OPTIONS="SERIAL1,CONS_INDEX=1,EMMC_BOOT"
+S:CONFIG_ARM=y
+S:CONFIG_TARGET_NOVA=y

在第一行,CONFIG_SPL=y会导致生成 SPL 二进制文件 MLO,CONFIG_ARM=y会导致在第三行包含arch/arm/Kconfig的内容。在第四行,CONFIG_TARGET_NOVA=y选择您的板。请注意,第三行和第四行都以+S:为前缀,以便它们适用于 SPL 和普通二进制文件。

您还应该在 ARM 架构的Kconfig中添加一个菜单选项,允许人们选择 Nova 作为目标:

CONFIG_SPL=y
config TARGET_NOVA
bool "Support Nova!"

特定于板的文件

每个板都有一个名为board/[board name]board/[vendor]/[board name]的子目录,其中应包含:

  • Kconfig:包含板的配置选项

  • MAINTAINERS:包含有关板当前是否被维护以及如果是的话由谁维护的记录

  • Makefile:用于构建特定于板的代码

  • README:包含有关 U-Boot 端口的任何有用信息,例如,涵盖了哪些硬件变体

此外,可能还有特定于板的功能的源文件。

您的 Nova 板基于 BeagleBone,而 BeagleBone 又基于 TI AM335x EVM,因此,您可以首先复制 am335x 板文件:

$ mkdir board/nova
$ cp -a board/ti/am335x board/nova

接下来,更改Kconfig文件以反映 Nova 板:

if TARGET_NOVA

config SYS_CPU
default "armv7"

config SYS_BOARD
default "nova"

config SYS_SOC
default "am33xx"

config SYS_CONFIG_NAME
default "nova"
endif

SYS_CPU设置为armv7会导致arch/arm/cpu/armv7中的代码被编译和链接。将SYS_SOC设置为am33xx会导致arch/arm/cpu/armv7/am33xx中的代码被包含,将SYS_BOARD设置为nova会引入board/nova,将SYS_CONFIG_NAME设置为nova意味着头文件include/configs/nova.h用于进一步的配置选项。

board/nova中还有另一个文件需要更改,即放置在board/nova/u-boot.lds的链接器脚本,其中硬编码引用了board/ti/am335x/built-in.o。将其更改为使用nova本地的副本:

diff --git a/board/nova/u-boot.lds b/board/nova/u-boot.lds
index 78f294a..6689b3d 100644
--- a/board/nova/u-boot.lds
+++ b/board/nova/u-boot.lds
@@ -36,7 +36,7 @@ SECTIONS
*(.__image_copy_start)
*(.vectors)
CPUDIR/start.o (.text*)
- board/ti/am335x/built-in.o (.text*)
+ board/nova/built-in.o (.text*)
*(.text*)
}

配置头文件

每个板在include/configs中都有一个头文件,其中包含大部分配置。该文件由板的Kconfig中的SYS_CONFIG_NAME标识符命名。该文件的格式在 U-Boot 源树顶层的README文件中有详细描述。

对于您的 Nova 板,只需将am335x_evm.h复制到nova.h并进行少量更改:

diff --git a/include/configs/nova.h b/include/configs/nova.h
index a3d8a25..8ea1410 100644
--- a/include/configs/nova.h
+++ b/include/configs/nova.h
@@ -1,5 +1,5 @@
/*
- * am335x_evm.h
+ * nova.h, based on am335x_evm.h
*
* Copyright (C) 2011 Texas Instruments Incorporated - http://www.ti.com/
*
@@ -13,8 +13,8 @@
* GNU General Public License for more details.
*/
-#ifndef __CONFIG_AM335X_EVM_H
-#define __CONFIG_AM335X_EVM_H
+#ifndef __CONFIG_NOVA
+#define __CONFIG_NOVA
#include <configs/ti_am335x_common.h>
@@ -39,7 +39,7 @@
#define V_SCLK (V_OSCK)
/* Custom script for NOR */
-#define CONFIG_SYS_LDSCRIPT "board/ti/am335x/u-boot.lds"
+#define CONFIG_SYS_LDSCRIPT "board/nova/u-boot.lds"
/* Always 128 KiB env size */
#define CONFIG_ENV_SIZE (128 << 10)
@@ -50,6 +50,9 @@
#define CONFIG_PARTITION_UUIDS
#define CONFIG_CMD_PART
+#undef CONFIG_SYS_PROMPT
+#define CONFIG_SYS_PROMPT "nova!> "
+
#ifdef CONFIG_NAND
#define NANDARGS \
"mtdids=" MTDIDS_DEFAULT "\0" \

构建和测试

要为 Nova 板构建,请选择您刚刚创建的配置:

$ make CROSS_COMPILE=arm-cortex_a8-linux-gnueabi- nova_defconfig
$ make CROSS_COMPILE=arm-cortex_a8-linux-gnueabi-

MLOu-boot.img复制到您之前创建的 micro-SD 卡的 FAT 分区,并启动板。

猎鹰模式

我们习惯于现代嵌入式处理器的引导涉及 CPU 引导 ROM 加载 SPL,SPL 加载u-boot.bin,然后加载 Linux 内核。您可能想知道是否有办法减少步骤数量,从而简化和加快引导过程。答案是 U-Boot“猎鹰模式”,以游隼命名,据称是所有鸟类中最快的。

这个想法很简单:让 SPL 直接加载内核映像,跳过u-boot.bin。没有用户交互,也没有脚本。它只是从 flash 或 eMMC 中的已知位置加载内核到内存中,传递给它一个预先准备好的参数块并启动它运行。配置猎鹰模式的详细信息超出了本书的范围。如果您想了解更多信息,请查看doc/README.falcon

Barebox

我将以另一个引导加载程序结束这一章,它与 U-Boot 有相同的根源,但对引导加载程序采取了新的方法。它源自 U-Boot,在早期实际上被称为 U-Boot v2。Barebox 的开发人员旨在结合 U-Boot 和 Linux 的最佳部分,包括类似 POSIX 的 API 和可挂载的文件系统。

Barebox 项目网站是www.barebox.org,开发者邮件列表是<barebox@lists.infradead.org>

获取 Barebox

要获取 Barebox,克隆 git 存储库并检出您想要使用的版本:

$ git clone git://git.pengutronix.de/git/barebox.git
$ cd barebox
$ git checkout v2014.12.0

代码的布局类似于 U-Boot:

  • arch:包含每个支持的架构的特定代码,其中包括所有主要的嵌入式架构。SoC 支持在arch/[architecture]/mach-[SoC]中。对于单独的板支持在arch/[architecture]/boards中。

  • common:包含核心功能,包括 shell。

  • commands:包含可以从 shell 中调用的命令。

  • Documentation:包含文档文件的模板。要构建它,输入"make docs"。结果放在Documentation/html中。

  • drivers:包含设备驱动程序的代码。

  • include:包含头文件。

构建 Barebox

Barebox 长期以来一直使用kconfig/kbuild。在arch/[architecture]/configs中有默认的配置文件。举个例子,假设你想为 BeagleBoard C4 构建 Barebox。你需要两个配置,一个是 SPL,一个是主二进制文件。首先,构建 MLO:

$ make ARCH=arm CROSS_COMPILE=arm-cortex_a8-linux-gnueabi- omap3530_beagle_xload_defconfig
$ make ARCH=arm CROSS_COMPILE=arm-cortex_a8-linux-gnueabi-

结果是次级程序加载器 MLO。

接下来,构建 Barebox:

$ make ARCH=arm CROSS_COMPILE=arm-cortex_a8-linux-gnueabi- omap3530_beagle_defconfig
$ make ARCH=arm CROSS_COMPILE=arm-cortex_a8-linux-gnueabi-

将两者都复制到 SD 卡上:

$ cp MLO /media/boot/
$ cp barebox-flash-image /media/boot/barebox.bin

然后,启动板子,你应该在控制台上看到这样的消息:

barebox 2014.12.0 #1 Wed Dec 31 11:04:39 GMT 2014

Board: Texas Instruments beagle
nand: Trying ONFI probe in 16 bits mode, aborting !
nand: NAND device: Manufacturer ID: 0x2c, Chip ID: 0xba (Micron ), 256MiB, page
size: 2048, OOB size: 64
omap-hsmmc omap3-hsmmc0: registered as omap3-hsmmc0
mci0: detected SD card version 2.0
mci0: registered disk0
malloc space: 0x87bff400 -> 0x87fff3ff (size 4 MiB)
booting from MMC

barebox 2014.12.0 #2 Wed Dec 31 11:08:59 GMT 2014

Board: Texas Instruments beagle
netconsole: registered as netconsole-1
i2c-omap i2c-omap30: bus 0 rev3.3 at 100 kHz
ehci ehci0: USB EHCI 1.00
nand: Trying ONFI probe in 16 bits mode, aborting !
nand: NAND device: Manufacturer ID: 0x2c, Chip ID: 0xba (Micron NAND 256MiB 1,8V
16-bit), 256MiB, page size: 2048, OOB size: 64
omap-hsmmc omap3-hsmmc0: registered as omap3-hsmmc0
mci0: detected SD card version 2.0
mci0: registered disk0
malloc space: 0x85e00000 -> 0x87dfffff (size 32 MiB)
environment load /boot/barebox.env: No such file or directory
Maybe you have to create the partition.
no valid environment found on /boot/barebox.env. Using default environment
running /env/bin/init...

Hit any key to stop autoboot: 0

Barebox 正在不断发展。在撰写本文时,它缺乏 U-Boot 所具有的广泛硬件支持,但对于新项目来说是值得考虑的。

总结

每个系统都需要一个引导加载程序来启动硬件并加载内核。U-Boot 受到许多开发人员的青睐,因为它支持一系列有用的硬件,并且相对容易移植到新设备上。在过去几年中,嵌入式硬件的复杂性和不断增加的种类导致了设备树的引入,作为描述硬件的一种方式。设备树只是系统的文本表示,编译成设备树二进制dtb),并在内核加载时传递给内核。内核需要解释设备树,并加载和初始化设备驱动程序。

在使用中,U-Boot 非常灵活,允许从大容量存储、闪存或网络加载和引导镜像。同样,Barebox 也可以实现相同的功能,但硬件支持的基础较小。尽管其更清晰的设计和受 POSIX 启发的内部 API,但在撰写本文时,它似乎还没有被接受到自己的小而专注的社区之外。

在介绍了一些 Linux 引导的复杂性之后,下一章中你将看到嵌入式项目的第三个元素,内核,进入到过程的下一个阶段。

第四章:移植和配置内核

内核是嵌入式 Linux 的第三个元素。它是负责管理资源和与硬件接口的组件,因此几乎影响到最终软件构建的每个方面。它通常根据您的特定硬件配置进行定制,尽管正如我们在第三章中看到的,设备树允许您通过设备树的内容创建一个通用内核,以适应特定硬件。

在本章中,我们将看看如何为板载获取内核,以及如何配置和编译它。我们将再次看看引导加载程序,这次重点放在内核所扮演的角色上。我们还将看看设备驱动程序以及它们如何从设备树中获取信息。

内核的主要作用是什么?

Linux 始于 1991 年,当时 Linus Torvalds 开始为基于 Intel 386 和 486 的个人计算机编写操作系统。他受到了四年前 Andrew S. Tanenbaum 编写的 Minix 操作系统的启发。Linux 在许多方面与 Minix 不同,主要区别在于它是一个 32 位虚拟内存内核,代码是开源的,后来发布在 GPL 2 许可下。

1991 年 8 月 25 日,他在comp.os.minix新闻组上宣布了这一消息,这是一篇著名的帖子,开头是大家好,所有使用 minix 的人 - 我正在为 386(486) AT 克隆机做一个(免费)操作系统(只是一项爱好,不会像 gnu 那样大而专业)。这个想法从四月份开始酝酿,现在已经开始准备。我想听听大家对 minix 中喜欢/不喜欢的东西的反馈,因为我的操作系统在某种程度上类似(minix)(由于实际原因,文件系统的物理布局相同,等等)

严格来说,Linus 并没有编写操作系统,而是编写了一个内核,这是操作系统的一个组成部分。为了创建一个工作系统,他使用了 GNU 项目的组件,特别是工具链、C 库和基本命令行工具。这种区别至今仍然存在,并且使 Linux 在使用方式上具有很大的灵活性。它可以与 GNU 用户空间结合,创建一个在台式机和服务器上运行的完整 Linux 发行版,有时被称为 GNU/Linux;它可以与 Android 用户空间结合,创建著名的移动操作系统;或者它可以与基于 Busybox 的小型用户空间结合,创建一个紧凑的嵌入式系统。与 BSD 操作系统(FreeBSD、OpenBSD 和 NetBSD)形成对比,其中内核、工具链和用户空间组合成一个单一的代码库。

内核有三个主要任务:管理资源、与硬件接口和提供 API,为用户空间程序提供有用的抽象级别,如下图所示:

内核的主要作用是什么?

在用户空间运行的应用程序以较低的 CPU 特权级别运行。除了进行库调用之外,它们几乎无法做任何事情。用户空间和内核空间之间的主要接口是 C 库,它将用户级函数(如 POSIX 定义的函数)转换为内核系统调用。系统调用接口使用特定于体系结构的方法,如陷阱或软件中断,将 CPU 从低特权用户模式切换到高特权内核模式,从而允许访问所有内存地址和 CPU 寄存器。

系统调用处理程序将调用分派到适当的内核子系统:调度调用调度程序,文件系统调用文件系统代码等。其中一些调用需要来自底层硬件的输入,并将被传递给设备驱动程序。在某些情况下,硬件本身通过引发中断来调用内核函数。中断只能由设备驱动程序处理,而不能由用户空间应用程序处理。

换句话说,您的应用程序执行的所有有用的功能都是通过内核完成的。因此,内核是系统中最重要的元素之一。

选择内核

下一步是选择适合您项目的内核,平衡了始终使用最新软件版本的愿望和对特定供应商添加的需求。

内核开发周期

Linux 已经以快速的速度发展,每 8 到 12 周发布一个新版本。近年来,版本号的构造方式有所改变。2011 年 7 月之前,版本号采用了三位数的版本方案,版本号看起来像 2.6.39。中间的数字表示它是开发人员还是稳定版本,奇数(2.1.x、2.3.x、2.5.x)是给开发人员的,偶数是给最终用户的。从 2.6 版本开始,长期的开发分支(奇数)的概念被放弃了,因为它减缓了新功能向用户提供的速度。从 2.6.39 到 2011 年 7 月的 3.0 的编号变化纯粹是因为 Linus 觉得数字变得太大了:在这两个版本之间,Linux 的功能或架构没有发生巨大的飞跃。他还趁机去掉了中间的数字。从那时起,2015 年 4 月,他将主要版本从 3 提升到 4,也纯粹是为了整洁,而不是因为有任何重大的架构变化。

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 存储库中的提交日志,但是每个发布版本大约有 10,000 个或更多的条目,很难得到一个概述。幸运的是,有Linux Kernel Newbies网站,kernelnewbies.org,您可以在kernelnewbies.org/LinuxVersions找到每个版本的简要概述。

稳定和长期支持版本

Linux 的快速变化速度是一件好事,因为它将新功能引入了主线代码库,但它并不太适合嵌入式项目的较长生命周期。内核开发人员以两种方式解决了这个问题。首先,他们承认一个发布版本可能包含需要在下一个内核发布版本之前修复的错误。这就是由 Greg Kroah-Hartman 维护的稳定 Linux 内核的作用。发布后,内核从“主线”(由 Linus 维护)转变为“稳定”(由 Greg 维护)。稳定内核的错误修复版本由第三个数字标记,如 3.18.1、3.18.2 等。在 3 版本之前,有四个发布数字,如 2.6.29.1、2.6.39.2 等。

您可以使用以下命令获取稳定树:

$ git clone \
git://git.kernel.org/pub/scm/linux/kernel/git/stable/linux-stable.git

您可以使用git chckout获取特定版本,例如版本 4.1.10:

$ cd linux-stable
$ git checkout v4.1.10

通常,稳定的内核只维护到下一个主线发布,通常是 8 到 12 周后,因此您会发现在kernel.org上只有一个或两个稳定的内核。为了满足那些希望在更长时间内获得更新并确保任何错误都将被发现和修复的用户,一些内核被标记为长期,并维护两年或更长时间。每年至少有一个长期内核。在撰写本文时,kernel.org上总共有八个长期内核:4.1、3.18、3.14、3.12、3.10、3.4、3.2 和 2.6.32。后者已经维护了五年,目前版本为 2.6.32.68。如果您正在构建一个需要维护这么长时间的产品,最新的长期内核可能是一个不错的选择。

供应商支持

在理想的世界中,您可以从kernel.org下载内核,并为任何声称支持 Linux 的设备进行配置。然而,这并不总是可能的:事实上,主线 Linux 只对可以运行 Linux 的许多设备中的一小部分具有坚实的支持。您可能会从独立的开源项目、Linaro 或 Yocto 项目等地方找到对您的板子或 SoC 的支持,或者从提供嵌入式 Linux 第三方支持的公司那里找到支持,但在许多情况下,您将被迫寻求您的 SoC 或板子的供应商提供一个可用的内核。正如我们所知,有些供应商比其他供应商更好。

提示

我在这一点上唯一的建议是选择给予良好支持的供应商,或者更好的是,让他们的内核更改进入主线。

许可

Linux 源代码根据 GPL v2 许可,这意味着您必须以许可中指定的一种方式提供内核的源代码。

内核许可的实际文本在COPYING文件中。它以 Linus 撰写的附录开头,附录指出通过系统调用接口从用户空间调用内核的代码不被视为内核的衍生作品,因此不受许可的约束。因此,在 Linux 上运行专有应用程序没有问题。

然而,有一个 Linux 许可的领域引起了无休止的混乱和争论:内核模块。内核模块只是在运行时与内核动态链接的一段代码,从而扩展了内核的功能。GPL 对静态链接和动态链接没有区别,因此内核模块的源代码似乎受到 GPL 的约束。但是,在 Linux 的早期,关于这一规则的例外情况进行了辩论,例如与 Andrew 文件系统有关。这段代码早于 Linux,因此(有人认为)不是衍生作品,因此许可不适用。多年来,关于其他代码的类似讨论也进行了讨论,结果是现在普遍认为 GPL 不一定适用于内核模块。这由内核MODULE_LICENSE宏所规定,该宏可以取值Proprietary,表示它不是根据 GPL 发布的。如果您打算自己使用相同的论点,您可能需要阅读一篇经常引用的电子邮件主题,标题为Linux GPL 和二进制模块例外条款?yarchive.net/comp/linux/gpl_modules.html)。

GPL 应该被视为一件好事,因为它保证了当你和我在嵌入式项目上工作时,我们总是可以获得内核的源代码。没有它,嵌入式 Linux 将会更难使用,更加分散。

构建内核

在决定基于哪个内核构建您的构建之后,下一步是构建它。

获取源代码

假设您有一个在主线上受支持的板子。您可以通过 git 获取源代码,也可以通过下载 tarball 获取。使用 git 更好,因为您可以查看提交历史,轻松查看您可能进行的任何更改,并且可以在分支和版本之间切换。在此示例中,我们正在克隆稳定树并检出版本标签 4.1.10:

$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/stable/linux-stable.git linux
$ cd linux
$ git checkout v4.1.10

或者,您可以从cdn.kernel.org/pub/linux/kernel/v4.x/linux-4.1.10.tar.xz下载 tarball。

这里有很多代码。在 4.1 内核中有超过 38,000 个文件,包含 C 源代码、头文件和汇编代码,总共超过 1250 万行代码(由 cloc 实用程序测量)。尽管如此,了解代码的基本布局并大致知道在哪里寻找特定组件是值得的。感兴趣的主要目录有:

  • arch: 这包含特定于体系结构的文件。每个体系结构都有一个子目录。

  • Documentation: 这包含内核文档。如果您想要找到有关 Linux 某个方面的更多信息,首先请查看这里。

  • drivers: 这包含设备驱动程序,成千上万个。每种类型的驱动程序都有一个子目录。

  • fs: 这包含文件系统代码。

  • include: 这包含内核头文件,包括构建工具链时所需的头文件。

  • init: 这包含内核启动代码。

  • kernel: 这包含核心功能,包括调度、锁定、定时器、电源管理和调试/跟踪代码。

  • mm: 这包含内存管理。

  • net: 这包含网络协议。

  • scripts: 这包含许多有用的脚本,包括设备树编译器 dtc,我在第三章中描述了关于引导加载程序的一切

  • 工具: 这包含许多有用的工具,包括 Linux 性能计数器工具 perf,在第十三章中我会描述性能分析和跟踪

随着时间的推移,您将熟悉这种结构,并意识到,如果您正在寻找特定 SoC 的串行端口代码,您将在drivers/tty/serial中找到它,而不是在arch/$ARCH/mach-foo中找到,因为它是设备驱动程序,而不是 Linux 在该 SoC 上运行的核心部分。

了解内核配置

Linux 的一个优点是您可以根据不同的工作需求配置内核,从小型专用设备(如智能恒温器)到复杂的移动手持设备。在当前版本中有成千上万的配置选项。正确配置配置本身就是一项任务,但在此之前,我想向您展示它是如何工作的,以便您更好地理解正在发生的事情。

配置机制称为Kconfig,与之集成的构建系统称为Kbuild。两者都在Documentation/kbuild/中有文档。Kconfig/Kbuild在内核以及其他项目中都有使用,包括 crosstool-NG、U-Boot、Barebox 和 BusyBox。

配置选项在名为Kconfig的文件层次结构中声明,使用Documentation/kbuild/kconfig-language.txt中描述的语法。在 Linux 中,顶层Kconfig看起来像这样:

mainmenu "Linux/$ARCH $KERNELVERSION Kernel Configuration"
config SRCARCH
  string
  option env="SRCARCH"
  source "arch/$SRCARCH/Kconfig"

最后一行包括与体系结构相关的配置文件,该文件根据启用的选项源自其他Kconfig文件。体系结构发挥如此重要的作用有两个含义:首先,在配置 Linux 时必须通过设置ARCH=[architecture]指定体系结构,否则它将默认为本地机器体系结构;其次,每个体系结构的顶级菜单布局都不同。

您放入ARCH的值是您在arch目录中找到的子目录之一,其中有一个奇怪之处,即ARCH=i386ARCH=x86_64都具有源arch/x86/Kconfig

Kconfig文件主要由菜单组成,由menumenu titleendmenu关键字界定,菜单项由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".

config后面的参数命名了一个变量,在这种情况下是DEVMEM。由于这个选项是一个布尔值,它只能有两个值:如果启用了,它被赋值为y,如果没有,这个变量根本就没有定义。在屏幕上显示的菜单项的名称是在bool关键字后面的字符串。

这个配置项,以及所有其他配置项,都存储在一个名为.config的文件中(注意,前导点'.'表示它是一个隐藏文件,不会被ls命令显示,除非你输入ls -a来显示所有文件)。存储在.config中的变量名都以CONFIG_为前缀,所以如果DEVMEM被启用,那么这一行就是:

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_HAS_ATOMIC64_DEC_IF_POSITIVE
  select ARCH_HAS_ELF_RANDOMIZE
[...]

有几个配置实用程序可以读取Kconfig文件并生成一个.config文件。其中一些在屏幕上显示菜单,并允许你进行交互式选择。Menuconfig可能是大多数人熟悉的一个,但还有xconfiggconfig

你可以通过make启动每一个,记住,在内核的情况下,你必须提供一个架构,就像这里所示的那样:

$ make ARCH=arm menuconfig

在这里,你可以看到在前一段中突出显示了DEVMEM config选项的menuconfig

理解内核配置

使用 menuconfig 进行内核配置

星号(*)在项目的左侧表示它被选中(="y"),或者如果是M,表示它已被选中以构建为内核模块。

提示

通常你会看到像enable CONFIG_BLK_DEV_INITRD,这样的指令,但是要浏览这么多菜单,找到设置这个配置的地方可能需要一段时间。所有的配置编辑器都有一个search功能。你可以在menuconfig中按下斜杠键/来访问它。在 xconfig 中,它在编辑菜单中,但是在这种情况下,确保你省略了你要搜索的变量的CONFIG_部分。

有这么多东西要配置,每次构建内核时都从零开始是不合理的,所以在arch/$ARCH/configs中有一组已知的工作配置文件,每个文件包含了单个 SoC 或一组 SoC 的合适配置值。你可以用make [配置文件名]来选择其中一个。例如,要配置 Linux 在使用 armv7-a 架构的各种 SoC 上运行,其中包括 BeagleBone Black AM335x,你可以输入:

$ make ARCH=arm multi_v7_defconfig

这是一个通用的内核,可以在不同的板上运行。对于更专业的应用,例如使用供应商提供的内核时,默认的配置文件是板支持包的一部分;在构建内核之前,你需要找出要使用哪一个。

还有另一个有用的配置目标名为oldconfig。这需要一个现有的.config文件,并要求您为任何没有配置值的选项提供配置值。当将配置移动到更新的内核版本时,您将使用它:将.config从旧内核复制到新的源目录,并运行make ARCH=arm oldconfig来使其保持最新。它还可以用于验证您手动编辑的.config文件(忽略顶部出现的文本自动生成的文件;请勿编辑:有时可以忽略警告)。

如果您对配置进行更改,修改后的.config文件将成为设备的一部分,并需要放置在源代码控制下。

当您启动内核构建时,将生成一个头文件include/generated/autoconf.h,其中包含每个配置值的#define,以便它可以像 U-Boot 一样包含在内核源中。

使用 LOCALVERSION 标识您的内核

您可以使用make kernelversion目标来查找您构建的内核版本:

$ make kernelversion
4.1.10

这在运行时通过uname命令报告,并且还用于命名存储内核模块的目录。

如果您从默认配置更改,建议附加您自己的版本信息,您可以通过设置CONFIG_LOCALVERSION来配置,您将在常规设置配置菜单中找到它。也可以(但不建议)通过编辑顶层 makefile 并将其附加到以EXTRAVERSION开头的行来执行相同的操作。例如,如果我想要使用标识符melp和版本 1.0 标记我正在构建的内核,我会在.config文件中定义本地版本如下:

CONFIG_LOCALVERSION="-melp-v1.0"

运行make kernelversion会产生与以前相同的输出,但现在,如果我运行make kernelrelease,我会看到:

$ make kernelrelease
4.1.10-melp-v1.0

它还会在内核日志的开头打印:

Starting kernel ...
[    0.000000] Booting Linux on physical CPU 0x0
[    0.000000] Linux version 4.1.10-melp-v1.0 (chris@builder) (gcc version 4.9.1 (crosstool-NG 1.20.0) ) #3 SMP Thu Oct 15 21:29:35 BST 2015

现在我可以识别和跟踪我的自定义内核。

内核模块

我已经多次提到内核模块。桌面 Linux 发行版广泛使用它们,以便根据检测到的硬件和所需的功能在运行时加载正确的设备和内核功能。没有它们,每个驱动程序和功能都必须静态链接到内核中,使其变得不可行大。

另一方面,对于嵌入式设备来说,硬件和内核配置通常在构建内核时就已知,因此模块并不那么有用。实际上,它们会造成问题,因为它们在内核和根文件系统之间创建了版本依赖关系,如果一个更新了而另一个没有更新,可能会导致启动失败。因此,嵌入式内核通常会构建为完全没有任何模块。以下是一些适合使用内核模块的情况:

  • 当您有专有模块时,出于前一节中给出的许可原因。

  • 通过推迟加载非必要驱动程序来减少启动时间。

  • 当有多个驱动程序可以加载并且将占用太多内存以静态编译它们时。例如,您有一个 USB 接口来支持一系列设备。这与桌面发行版中使用的论点基本相同。

编译

内核构建系统kbuild是一组make脚本,它从.config文件中获取配置信息,计算出依赖关系,并编译所有必要的内容,以生成包含所有静态链接组件的内核映像,可能还包括设备树二进制文件和一个或多个内核模块。这些依赖关系在每个可构建组件的目录中的 makefile 中表示。例如,以下两行摘自drivers/char/Makefile

obj-y                    += mem.o random.o
obj-$(CONFIG_TTY_PRINTK) += ttyprintk.o

obj-y规则无条件地编译文件以生成目标,因此mem.crandom.c始终是内核的一部分。在第二行中,ttyprintk.c取决于配置参数。如果CONFIG_TTY_PRINTKy,它将被编译为内置模块,如果是m,它将作为模块构建,如果参数未定义,则根本不会被编译。

对于大多数目标,只需键入make(带有适当的ARCHCROSS_COMPILE)即可完成工作,但逐步进行也是有益的。

编译内核映像

要构建内核映像,您需要知道您的引导加载程序期望什么。这是一个粗略的指南:

  • U-Boot:传统上,U-Boot 需要一个 uImage,但较新版本可以使用bootz命令加载zImage文件

  • x86 目标:它需要一个bzImage文件

  • 大多数其他引导加载程序:它需要一个zImage文件

以下是构建zImage文件的示例:

$ make -j 4 ARCH=arm CROSS_COMPILE=arm-cortex_a8-linux-gnueabihf- zImage

提示

-j 4选项告诉make并行运行多少个作业,从而减少构建所需的时间。一个粗略的指南是运行与 CPU 核心数量相同的作业。

构建bzImageuImage目标时也是一样的。

构建具有多平台支持的 ARM 的uImage文件存在一个小问题,这是当前一代 ARM SoC 内核的常态。 ARM 的多平台支持是在 Linux 3.7 中引入的。它允许单个内核二进制文件在多个平台上运行,并且是朝着为所有 ARM 设备拥有少量内核的道路上的一步。内核通过读取引导加载程序传递给它的机器号或设备树来选择正确的平台。问题出在因为每个平台的物理内存位置可能不同,因此内核的重定位地址(通常是从物理 RAM 的起始位置偏移 0x8000 字节)也可能不同。当内核构建时,重定位地址由mkimage命令编码到uImage头中,但如果有多个重定位地址可供选择,则会失败。换句话说,uImage格式与多平台映像不兼容。您仍然可以从多平台构建创建一个 uImage 二进制文件,只要您为希望在其上引导此内核的特定 SoC 提供LOADADDR。您可以通过查看mach-[your SoC]/Makefile.boot并注意zreladdr-y的值来找到加载地址。

对于 BeagleBone Black,完整的命令如下:

$ make -j 4 ARCH=arm CROSS_COMPILE=arm-cortex_a8-linux-gnueabihf- LOADADDR=0x80008000 uImage

内核构建在顶层目录中生成两个文件:vmlinuxSystem.map。第一个vmlinux是内核的 ELF 二进制文件。如果您已启用调试编译内核(CONFIG_DEBUG_INFO=y),它将包含可用于像kgdb这样的调试器的调试符号。您还可以使用其他 ELF 二进制工具,如size

$ arm-cortex_a8-linux-gnueabihf-size vmlinux
 text     data      bss        dec       hex    filename
8812564   790692   8423536   18026792   1131128   vmlinux

System.map以人类可读的形式包含符号表。

大多数引导加载程序不能直接处理 ELF 代码。还有一个进一步的处理阶段,它将vmlinux放置在arch/$ARCH/boot中,这些二进制文件适用于各种引导加载程序:

  • Image:将vmlinux转换为原始二进制文件。

  • zImage:对于 PowerPC 架构,这只是Image的压缩版本,这意味着引导加载程序必须进行解压缩。对于所有其他架构,压缩的Image被附加到一个解压缩和重定位它的代码存根上。

  • uImagezImage加上 64 字节的 U-Boot 头。

在构建过程中,您将看到正在执行的命令的摘要:

$ make -j 4 ARCH=arm CROSS_COMPILE=arm-cortex_a8-linux-gnueabihf-zImage
CC     init/main.o
CHK    include/generated/compile.h
CC     init/version.o
CC     init/do_mounts.o
CC     init/do_mounts_rd.o
CC     init/do_mounts_initrd.o
LD     init/mounts.o
[...]

有时,当内核构建失败时,查看实际执行的命令很有用。要做到这一点,请在命令行中添加V=1

$ make ARCH=arm CROSS_COMPILE=arm-cortex_a8-linux-gnueabihf- V=1 zImage
[...]
arm-cortex_a8-linux-gnueabihf-gcc -Wp,-MD,init/.do_mounts_initrd.o.d  -nostdinc -isystem /home/chris/x-tools/arm-cortex_a8-linux-gnueabihf/lib/gcc/arm-cortex_a8-linux-gnueabihf/4.9.1/include -I./arch/arm/include -Iarch/arm/include/generated/uapi -Iarch/arm/include/generated  -Iinclude -I./arch/arm/include/uapi -Iarch/arm/include/generated/uapi -I./include/uapi -Iinclude/generated/uapi -include ./include/linux/kconfig.h -D__KERNEL__ -mlittle-endian -Wall -Wundef -Wstrict-prototypes -Wno-trigraphs -fno-strict-aliasing -fno-common -Werror-implicit-function-declaration -Wno-format-security -std=gnu89 -fno-dwarf2-cfi-asm -mabi=aapcs-linux -mno-thumb-interwork -mfpu=vfp -funwind-tables -marm -D__LINUX_ARM_ARCH__=7 -march=armv7-a -msoft-float -Uarm -fno-delete-null-pointer-checks -O2 --param=allow-store-data-races=0 -Wframe-larger-than=1024 -fno-stack-protector -Wno-unused-but-set-variable -fomit-frame-pointer -fno-var-tracking-assignments -Wdeclaration-after-statement -Wno-pointer-sign -fno-strict-overflow -fconserve-stack -Werror=implicit-int -Werror=strict-prototypes -Werror=date-time -DCC_HAVE_ASM_GOTO    -D"KBUILD_STR(s)=#s" -D"KBUILD_BASENAME=KBUILD_STR(do_mounts_initrd)"  -D"KBUILD_MODNAME=KBUILD_STR(mounts)" -c -o init/do_mounts_initrd.o init/do_mounts_initrd.c
[...]

编译设备树

下一步是构建设备树,或者如果您有多平台构建,则构建多个设备树。dtbs 目标根据arch/$ARCH/boot/dts/Makefile中的规则使用该目录中的设备树源文件构建设备树:

$ make ARCH=arm dtbs
...
DTC     arch/arm/boot/dts/omap2420-h4.dtb
DTC     arch/arm/boot/dts/omap2420-n800.dtb
DTC     arch/arm/boot/dts/omap2420-n810.dtb
DTC     arch/arm/boot/dts/omap2420-n810-wimax.dtb
DTC     arch/arm/boot/dts/omap2430-sdp.dtb
...

.dtb文件生成在与源文件相同的目录中。

编译模块

如果您已经配置了一些功能作为模块构建,可以使用modules目标单独构建它们:

$ make -j 4 ARCH=arm CROSS_COMPILE=arm-cortex_a8-linux-gnueabihf- modules

编译的模块具有.ko后缀,并且生成在与源代码相同的目录中,这意味着它们散布在整个内核源代码树中。找到它们有点棘手,但您可以使用modules_install make 目标将它们安装到正确的位置。默认位置是开发系统中的/lib/modules,这几乎肯定不是您想要的位置。要将它们安装到根文件系统的暂存区域(我们将在下一章讨论根文件系统),请使用INSTALL_MOD_PATH提供路径:

$ make -j4 ARCH=arm CROSS_COMPILE=arm-cortex_a8-linux-gnueabihf- INSTALL_MOD_PATH=$HOME/rootfs modules_install

内核模块被放置在相对于文件系统根目录的目录/lib/modules/[kernel version]中。

清理内核源代码

有三个用于清理内核源代码树的 make 目标:

  • 清理:删除对象文件和大部分中间文件。

  • mrproper:删除所有中间文件,包括.config文件。使用此目标将源树恢复到克隆或提取源代码后的状态。如果您对名称感到好奇,Mr Proper 是一种在世界某些地区常见的清洁产品。make mrproper的含义是给内核源代码进行彻底的清洁。

  • distclean:这与 mrproper 相同,但还会删除编辑器备份文件、补丁剩余文件和软件开发的其他工件。

引导您的内核

引导高度依赖于设备,但以下是在 BeagleBone Black 和 QEMU 上使用 U-Boot 的一个示例:。

BeagleBone Black

以下 U-Boot 命令显示了如何在 BeagleBone Black 上启动 Linux:

U-Boot# fatload mmc 0:1 0x80200000 zImage
reading zImage
4606360 bytes read in 254 ms (17.3 MiB/s)
U-Boot# fatload mmc 0:1 0x80f00000 am335x-boneblack.dtb
reading am335x-boneblack.dtb
29478 bytes read in 9 ms (3.1 MiB/s)
U-Boot# setenv bootargs console=ttyO0,115200
U-Boot# bootz 0x80200000 - 0x80f00000
Kernel image @ 0x80200000 [ 0x000000 - 0x464998 ]
## Flattened Device Tree blob at 80f00000
   Booting using the fdt blob at 0x80f00000
   Loading Device Tree to 8fff5000, end 8ffff325 ... OK
Starting kernel ...
[   0.000000] Booting Linux on physical CPU 0x0
...

请注意,我们将内核命令行设置为console=ttyO0,115200。这告诉 Linux 要使用哪个设备进行控制台输出,在本例中是板上的第一个 UART 设备ttyO0,速度为每秒 115,200 位。如果没有这个设置,我们将在Starting the kernel ...后看不到任何消息,因此将不知道它是否工作。

QEMU

假设您已经安装了qemu-system-arm,您可以使用 multi_v7 内核和 ARM Versatile Express 的.dtb文件启动它,如下所示:

$ QEMU_AUDIO_DRV=none \
qemu-system-arm -m 256M -nographic -M vexpress-a9 -kernel zImage -dtb vexpress-v2p-ca9.dtb -append "console=ttyAMA0"

请注意,将QEMU_AUDIO_DRV设置为none只是为了抑制关于音频驱动程序缺少配置的 QEMU 的错误消息,我们不使用音频驱动程序。

要退出 QEMU,请键入Ctrl-A,然后键入x(两个单独的按键)。

内核恐慌

虽然一切开始得很顺利,但最终却以失败告终:

[    1.886379] Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(0,0)
[    1.895105] ---[ end Kernel panic - not syncing: VFS: Unable to mount root fs on unknown-block(0, 0)

这是内核恐慌的一个很好的例子。当内核遇到不可恢复的错误时,就会发生恐慌。默认情况下,它会在控制台上打印一条消息,然后停止。您可以设置panic命令行参数,以允许在恐慌后重新启动之前等待几秒钟。

在这种情况下,不可恢复的错误是因为没有根文件系统,说明内核没有用户空间来控制它是无用的。您可以通过提供根文件系统作为 ramdisk 或可挂载的大容量存储设备来提供用户空间。我们将在下一章讨论如何创建根文件系统,但是为了让事情正常运行,假设我们有一个名为uRamdisk的 ramdisk 文件,然后您可以通过在 U-Boot 中输入以下命令来引导到 shell 提示符:

fatload mmc 0:1 0x80200000 zImage
fatload mmc 0:1 0x80f00000 am335x-boneblack.dtb
fatload mmc 0:1 0x81000000 uRamdisk
setenv bootargs console=ttyO0,115200 rdinit=/bin/sh
bootz 0x80200000 0x81000000 0x80f00000

在这里,我已经在命令行中添加了rdinit=/bin/sh,这样内核将运行一个 shell 并给我们一个 shell 提示符。现在,控制台上的输出看起来像这样:

...
[    1.930923] sr_init: No PMIC hook to init smartreflex
[    1.936424] sr_init: platform driver register failed for SR
[    1.964858] Freeing unused kernel memory: 408K (c0824000 - c088a000)
/ # uname -a
Linux (none) 3.18.3 #1 SMP Wed Jan 21 08:34:58 GMT 2015 armv7l GNU/Linux
/ #

最后,我们有了一个提示符,可以与我们的设备交互。

早期用户空间

为了从内核初始化到用户空间的过渡,内核必须挂载一个根文件系统并在该根文件系统中执行一个程序。这可以通过 ramdisk 来实现,就像前一节中所示的那样,也可以通过在块设备上挂载一个真实的文件系统来实现。所有这些代码都在init/main.c中,从rest_init()函数开始,该函数创建了 PID 为 1 的第一个线程,并运行kernel_init()中的代码。如果有一个 ramdisk,它将尝试执行program /init,这将承担设置用户空间的任务。

如果找不到并运行/init,它将尝试通过在init/do_mounts.c中调用prepare_namespace()函数来挂载文件系统。这需要一个root=命令行来指定用于挂载的块设备的名称,通常的形式是:

  • root=/dev/<disk name><partition number>

  • root=/dev/<disk name>p<partition number>

例如,对于 SD 卡上的第一个分区,应该是root=/dev/mmcblk0p1。如果挂载成功,它将尝试执行/sbin/init,然后是/etc/init/bin/init,然后是/bin/sh,在第一个有效的停止。

init程序可以在命令行上被覆盖。对于 ramdisk,使用rdinit=(我之前使用rdinit=/bin/sh来执行 shell),对于文件系统,使用init=

内核消息

内核开发人员喜欢通过大量使用printk()和类似的函数来打印有用的信息。消息根据重要性进行分类,0 是最高级别:

Level Value 含义
KERN_EMERG 0 系统无法使用
KERN_ALERT 1 必须立即采取行动
KERN_CRIT 2 临界条件
KERN_ERR 3 错误条件
KERN_WARNING 4 警告条件
KERN_NOTICE 5 正常但重要的条件
KERN_INFO 6 信息
KERN_DEBUG 7 调试级别的消息

它们首先被写入一个缓冲区__log_buf,其大小为CONFIG_LOG_BUF_SHIFT的 2 次幂。例如,如果是 16,那么__log_buf就是 64 KiB。您可以使用命令dmesg来转储整个缓冲区。

如果消息的级别低于控制台日志级别,则会在控制台上显示该消息,并放置在__log_buf中。默认控制台日志级别为 7,这意味着级别为 6 及以下的消息会被显示,过滤掉级别为 7 的KERN_DEBUG。您可以通过多种方式更改控制台日志级别,包括使用内核参数loglevel=<level>或命令dmesg -n <level>

内核命令行

内核命令行是一个字符串,由引导加载程序通过bootargs变量传递给内核,在 U-Boot 的情况下;它也可以在设备树中定义,或作为内核配置的一部分在CONFIG_CMDLINE中设置。

我们已经看到了一些内核命令行的示例,但还有许多其他的。在Documentation/kernel-parameters.txt中有一个完整的列表。这里是一个更小的最有用的列表:

名称 描述
debug 将控制台日志级别设置为最高级别 8,以确保您在控制台上看到所有内核消息。
init= 从挂载的根文件系统中运行的init程序,默认为/sbin/init
lpj= loops_per_jiffy设置为给定的常数,参见下一段。
panic= 内核发生 panic 时的行为:如果大于零,则在重新启动之前等待的秒数;如果为零,则永远等待(这是默认值);如果小于零,则立即重新启动。
quiet 将控制台日志级别设置为 1,抑制除紧急消息之外的所有消息。由于大多数设备都有串行控制台,输出所有这些字符串需要时间。因此,使用此选项减少消息数量可以减少启动时间。
rdinit= 从 ramdisk 运行的init程序,默认为/init
ro 将根设备挂载为只读。对于始终是读/写的 ramdisk 没有影响。
root= 要挂载根文件系统的设备。
rootdelay= 在尝试挂载根设备之前等待的秒数,默认为零。如果设备需要时间来探测硬件,则此参数很有用,但也请参阅rootwait
rootfstype= 根设备的文件系统类型。在许多情况下,在挂载期间会自动检测到,但对于jffs2文件系统是必需的。
rootwait 无限期等待根设备被检测到。通常在使用mmc设备时是必需的。
rw 将根设备挂载为读/写(默认)。

lpj参数经常在减少内核启动时间方面提到。在初始化期间,内核循环大约 250 毫秒来校准延迟循环。该值存储在变量loops_per_jiffy中,并且报告如下:

Calibrating delay loop... 996.14 BogoMIPS (lpj=4980736)

如果内核始终在相同的硬件上运行,它将始终计算相同的值。通过在命令行中添加lpj=4980736,可以缩短 250 毫秒的启动时间。

将 Linux 移植到新板子

任务的范围取决于您的板子与现有开发板有多相似。在第三章中,关于引导加载程序,我们将 U-Boot 移植到了一个名为 Nova 的新板子上,该板子基于 BeagleBone Black(实际上就是基于它),因此在这种情况下,需要对内核代码进行的更改很少。如果要移植到全新和创新的硬件上,则需要做更多工作。我只会考虑简单的情况。

arch/$ARCH中的特定于体系结构的代码组织因系统而异。x86 体系结构非常干净,因为硬件细节在运行时被检测到。PowerPC 体系结构将 SoC 和特定于板子的文件放在子目录平台中。ARM 体系结构具有所有 ARM 板子和 SoC 中最多的特定于板子和 SoC 的文件。特定于平台的代码位于arch/arm中名为mach-*的目录中,大约每个 SoC 一个。还有其他名为plat-*的目录,其中包含适用于某个 SoC 的几个版本的通用代码。在 Nova 板的情况下,相关目录是mach-omap2。不过,不要被名称所迷惑,它包含对 OMAP2、3 和 4 芯片的支持。

在接下来的章节中,我将以两种不同的方式对 Nova 板进行移植。首先,我将向您展示如何使用设备树进行移植,然后再进行移植,因为现场有很多符合此类别的设备。您会发现,当您有设备树时,这将更加简单。

有设备树

首先要做的是为板子创建一个设备树,并修改它以描述板子上的附加或更改的硬件。在这种简单情况下,我们将只是将am335x-boneblack.dts复制到nova.dts,并更改板子名称:

/dts-v1/;
#include "am33xx.dtsi"
#include "am335x-bone-common.dtsi"
/ {
     model = "Nova";
     compatible = "ti,am335x-bone-black", "ti,am335x-bone", "ti,am33xx";
  };
...

我们可以显式构建nova.dtb

$ make  ARCH=arm nova.dtb

或者,如果我们希望nova.dtb在 OMAP2 平台上默认生成,可以使用make ARCH=arm dtbs,然后我们可以将以下行添加到arch/arm/boot/dts/Makefile中:

dtb-$(CONFIG_SOC_AM33XX) += \
[...]
nova.dtb \
[...]

现在我们可以像以前一样启动相同的zImage文件,使用multi_v7_defconfig进行配置,但是加载nova.dtb,如下所示:

Starting kernel ...

[    0.000000] Booting Linux on physical CPU 0x0
[    0.000000] Initializing cgroup subsys cpuset
[    0.000000] Initializing cgroup subsys cpu
[    0.000000] Initializing cgroup subsys cpuacct
[    0.000000] Linux version 3.18.3-dirty (chris@builder) (gcc version 4.9.1 (crosstool-N
G 1.20.0) ) #1 SMP Wed Jan 28 07:50:50 GMT 2015
[    0.000000] CPU: ARMv7 Processor [413fc082] revision 2 (ARMv7), cr=10c5387d
[    0.000000] CPU: PIPT / VIPT nonaliasing data cache, VIPT aliasing instruction cache
[    0.000000] Machine model: Nova
...

我们可以通过复制multi_v7_defconfig来创建自定义配置,并添加我们需要的功能,并通过留出不需要的功能来减小代码大小。

没有设备树

首先,我们需要为板子创建一个配置名称,本例中为NOVABOARD。我们需要将其添加到您的 SoC 的mach-目录的Kconfig文件中,并且需要为 SoC 支持本身添加一个依赖项,即OMAPAM33XX

这些行添加到arch/arm/mach-omap2/Kconfig中:

config MACH_NOVA BOARD
bool "Nova board"
depends on SOC_OMAPAM33XX
default n

对于每个板卡都有一个名为board-*.c的源文件,其中包含特定于目标的代码和配置。在我们的情况下,它是基于board-am335xevm.cboard-nova.c。必须有一个规则来编译它,条件是CONFIG_MACH_NOVABOARD,这个添加到arch/arm/mach-omap2/Makefile中的内容会处理:

obj-$(CONFIG_MACH_NOVABOARD) += board-nova.o

由于我们不使用设备树来识别板卡,我们将不得不使用较旧的机器编号机制。这是由引导加载程序传递给寄存器 r1 的每个板卡的唯一编号,ARM 启动代码将使用它来选择正确的板卡支持。ARM 机器编号的权威列表保存在:www.arm.linux.org.uk/developer/machines/download.php。您可以从www.arm.linux.org.uk/developer/machines/?action=new#请求一个新的机器编号。

如果我们劫持机器编号4242,我们可以将其添加到arch/arm/tools/mach-types中,如下所示:

machine_is_xxx   CONFIG_xxxx        MACH_TYPE_xxx      number
...
nova_board       MACH_NOVABOARD     NOVABOARD          4242

当我们构建内核时,它将用于创建include/generated/中存在的mach-types.h头文件。

机器编号和板卡支持是通过一个结构绑定在一起的,该结构定义如下:

MACHINE_START(NOVABOARD, "nova_board")
/* Maintainer: Chris Simmonds */
.atag_offset    = 0x100,
.map_io         = am335x_evm_map_io,
.init_early     = am33xx_init_early,
.init_irq       = ti81xx_init_irq,
.handle_irq     = omap3_intc_handle_irq,
.timer          = &omap3_am33xx_timer,
.init_machine   = am335x_evm_init,
MACHINE_END

请注意,一个板卡文件中可能有多个机器结构,允许我们创建一个可以在多个不同板卡上运行的内核。引导加载程序传递的机器编号将选择正确的机器结构。

最后,我们需要为我们的板卡选择一个新的默认配置,该配置选择CONFIG_MACH_NOVABOARD和其他特定于它的配置选项。在下面的示例中,它将位于arch/arm/configs/novaboard_defconfig。现在您可以像往常一样构建内核映像:

$ make ARCH=arm novaboard_defconfig
$ make -j 4 ARCH=arm CROSS_COMPILE=arm-cortex_a8-linux-gnueabi- zImage

工作完成之前还有一步。引导加载程序需要修改以传递正确的机器编号。假设您正在使用 U-Boot,您需要将 Linux 生成的机器编号复制到 U-Boot 文件arch/arm/include/asm/mach-types.h中。然后,您需要更新 Nova 的配置头文件include/configs/nova.h,并添加以下行:

#define CONFIG_MACH_TYPE          MACH_TYPE_NOVABOARD

现在,最后,您可以构建 U-Boot 并使用它来引导 Nova 板上的新内核:

Starting kernel ...

[    0.000000] Linux version 3.2.0-00246-g0c74d7a-dirty (chris@builder) (gcc version 4.9.
1 (crosstool-NG 1.20.0) ) #3 Wed Jan 28 11:45:10 GMT 2015
[    0.000000] CPU: ARMv7 Processor [413fc082] revision 2 (ARMv7), cr=10c53c7d
[    0.000000] CPU: PIPT / VIPT nonaliasing data cache, VIPT aliasing instruction cache
[    0.000000] Machine: nova_board

额外阅读

以下资源提供了有关本章介绍的主题的更多信息:

总结

Linux 是一个非常强大和复杂的操作系统内核,可以与各种类型的用户空间结合,从简单的嵌入式设备到使用 Android 的日益复杂的移动设备,再到完整的服务器操作系统。其优势之一是可配置性。获取源代码的权威位置是www.kerenl.org,但您可能需要从该设备的供应商或支持该设备的第三方获取特定 SoC 或板卡的源代码。为特定目标定制内核可能包括对核心内核代码的更改,为不在主线 Linux 中的设备添加额外的驱动程序,一个默认的内核配置文件和一个设备树源文件。

通常情况下,您会从目标板的默认配置开始,然后通过运行诸如menuconfig之类的配置工具进行调整。在这一点上,您应该考虑的一件事是内核功能和驱动程序是否应该编译为模块或内置。内核模块通常对嵌入式系统没有太大优势,因为功能集和硬件通常是明确定义的。然而,模块通常被用作将专有代码导入内核的一种方式,还可以通过在引导后加载非必要驱动程序来减少启动时间。构建内核会生成一个压缩的内核映像文件,根据您将要使用的引导加载程序和目标架构的不同,它的名称可能是zImagebzImageuImage。内核构建还会生成您配置的任何内核模块(作为.ko文件),以及设备树二进制文件(作为.dtb文件),如果您的目标需要的话。

将 Linux 移植到新的目标板可能非常简单,也可能非常困难,这取决于硬件与主线或供应商提供的内核有多大不同。如果您的硬件是基于一个众所周知的参考设计,那么可能只需要对设备树或平台数据进行更改。您可能需要添加设备驱动程序,这在第八章中有讨论,介绍设备驱动程序。然而,如果硬件与参考设计有根本的不同,您可能需要额外的核心支持,这超出了本书的范围。

内核是基于 Linux 的系统的核心,但它不能单独工作。它需要一个包含用户空间的根文件系统。根文件系统可以是一个 ramdisk 或通过块设备访问的文件系统,这将是下一章的主题。正如我们所看到的,没有根文件系统启动内核会导致内核恐慌。

第五章:构建根文件系统

根文件系统是嵌入式 Linux 的第四个也是最后一个元素。阅读完本章后,您将能够构建、引导和运行一个简单的嵌入式 Linux 系统。

本章探讨了通过从头开始构建根文件系统来探索根文件系统背后的基本概念。主要目的是提供您理解和充分利用 Buildroot 和 Yocto Project 等构建系统所需的背景信息,我将在第六章选择构建系统中进行介绍。

我将在这里描述的技术通常被称为自定义RYO。在嵌入式 Linux 的早期,这是创建根文件系统的唯一方法。仍然有一些用例适用于 RYO 根文件系统,例如当 RAM 或存储量非常有限时,用于快速演示,或者用于任何标准构建系统工具(容易)无法满足您的要求的情况。然而,这些情况非常罕见。让我强调一下,本章的目的是教育性的,而不是为了构建日常嵌入式系统的配方:请使用下一章中描述的工具。

第一个目标是创建一个最小的根文件系统,以便给我们一个 shell 提示符。然后,以此为基础,我们将添加脚本来启动其他程序,并配置网络接口和用户权限。了解如何从头开始构建根文件系统是一项有用的技能,它将帮助您理解我们在后面章节中看到的更复杂的示例时发生了什么。

根文件系统中应该包含什么?

内核将获得一个根文件系统,可以是 ramdisk,从引导加载程序传递的指针,或者通过root=参数在内核命令行上挂载的块设备。一旦有了根文件系统,内核将执行第一个程序,默认情况下命名为init,如第四章移植和配置内核中的早期用户空间部分所述。然后,就内核而言,它的工作就完成了。由init程序开始处理脚本,启动其他程序等,调用 C 库中的系统函数,这些函数转换为内核系统调用。

要创建一个有用的系统,您至少需要以下组件:

  • init:通常通过运行一系列脚本来启动一切的程序。

  • shell:需要为您提供命令提示符,但更重要的是运行init和其他程序调用的 shell 脚本。

  • 守护进程:由init启动的各种服务器程序。

  • :通常,到目前为止提到的程序都链接到必须存在于根文件系统中的共享库。

  • 配置文件: init和其他守护程序的配置存储在一系列 ASCII 文本文件中,通常位于/etc目录中。

  • 设备节点:特殊文件,提供对各种设备驱动程序的访问。

  • /proc 和/sys:代表内核数据结构的两个伪文件系统,以目录和文件的层次结构表示。许多程序和库函数读取这些文件。

  • 内核模块:如果您已经配置了内核的某些部分为模块,它们通常会在/lib/modules/[kernel version]中。

此外,还有系统应用程序或应用程序,使设备能够完成其预期工作,并收集它们所收集的运行时最终用户数据。

另外,也有可能将上述所有内容压缩成一个单独的程序。您可以创建一个静态链接的程序,它会在init之外启动并且不运行其他程序。我只遇到过这样的配置一次。例如,如果您的程序命名为/myprog,您可以将以下命令放在内核命令行中:

init=/myprog

或者,如果根文件系统被加载为 ramdisk,你可以输入以下命令:

rdinit=/myprog

这种方法的缺点是你无法使用通常用于嵌入式系统的许多工具;你必须自己做一切。

目录布局

有趣的是,Linux 并不关心文件和目录的布局,只要存在由init=rdinit=命名的程序,你可以自由地将东西放在任何你喜欢的地方。例如,比较运行安卓的设备的文件布局和桌面 Linux 发行版的文件布局:它们几乎完全不同。

然而,许多程序希望某些文件在特定位置,如果设备使用类似的布局,对开发人员有所帮助,除了安卓。Linux 系统的基本布局在文件系统层次结构标准FHS)中定义,参见本章末尾的参考资料。FHS 涵盖了从最大到最小的所有 Linux 操作系统的实现。嵌入式设备根据需要有一个子集,但通常包括以下内容:

  • /bin:所有用户必需的程序

  • /dev:设备节点和其他特殊文件

  • /etc:系统配置

  • /lib:必需的共享库,例如组成 C 库的那些库

  • /procproc文件系统

  • /sbin:对系统管理员至关重要的程序

  • /syssysfs文件系统

  • /tmp:放置临时或易失性文件的地方

  • /usr:至少应包含目录/usr/bin/usr/lib/usr/sbin,其中包含额外的程序、库和系统管理员实用程序

  • /var:可能在运行时被修改的文件和目录的层次结构,例如日志消息,其中一些必须在引导后保留

这里有一些微妙的区别。/bin/sbin之间的区别仅仅是/sbin不需要包含在非 root 用户的搜索路径中。使用 Red Hat 衍生的发行版的用户会熟悉这一点。/usr的重要性在于它可能在与根文件系统不同的分区中,因此它不能包含任何引导系统所需的内容。这就是前面描述中所说的“必需”的含义:它包含了在引导时需要的文件,因此必须是根文件系统的一部分。

提示

虽然似乎在四个目录中存储程序有些多余,但反驳的观点是这并没有什么坏处,甚至可能有些好处,因为它允许你将/usr存储在不同的文件系统中。

暂存目录

你应该首先在主机计算机上创建一个暂存目录,在那里你可以组装最终将传输到目标设备的文件。在下面的示例中,我使用了~/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

为了更清晰地看到目录层次结构,你可以使用方便的tree命令,下面的示例中使用了-d选项只显示目录:

$ tree -d

├── bin
├── dev
├── etc
├── home
├── lib
├── proc
├── sbin
├── sys
├── tmp
├── usr
│   ├── bin
│   ├── lib
│   └── sbin
└── var
 └── log

POSIX 文件访问权限

在这里讨论的上下文中,每个进程,也就是每个正在运行的程序,都属于一个用户和一个或多个组。用户由一个称为用户 IDUID的 32 位数字表示。关于用户的信息,包括从 UID 到名称的映射,保存在/etc/passwd中。同样,组由组 IDGID表示,信息保存在/etc/group中。始终存在一个 UID 为 0 的 root 用户和一个 GID 为 0 的 root 组。root 用户也被称为超级用户,因为在默认配置中,它可以绕过大多数权限检查,并且可以访问系统中的所有资源。基于 Linux 的系统中的安全性主要是关于限制对 root 账户的访问。

每个文件和目录也都有一个所有者,并且属于一个组。进程对文件或目录的访问级别由一组访问权限标志控制,称为文件的模式。有三组三个位:第一组适用于文件的所有者,第二组适用于与文件相同组的成员,最后一组适用于其他人,即世界其他地方的人。位用于文件的读取(r)、写入(w)和执行(x)权限。由于三个位恰好适合八进制数字,它们通常以八进制表示,如下图所示:

POSIX 文件访问权限

还有一组特殊含义的三个位:

  • SUID (4):如果文件是可执行文件,则将进程的有效 UID 更改为文件的所有者的 UID。

  • SGID (2):如果文件是可执行文件,则将进程的有效 GID 更改为文件的组的 GID。

  • Sticky (1):在目录中,限制删除,以便一个用户不能删除属于另一个用户的文件。这通常设置在/tmp/var/tmp上。

SUID 位可能是最常用的。它为非 root 用户提供了临时特权升级到超级用户以执行任务。一个很好的例子是ping程序:ping打开一个原始套接字,这是一个特权操作。为了让普通用户使用ping,通常由 root 拥有并设置了 SUID 位,这样当您运行ping时,它将以 UID 0 执行,而不管您的 UID 是多少。

要设置这些位,请使用八进制数字 4、2、1 和chmod命令。例如,要在您的暂存根目录中设置/bin/ping的 SUID,您可以使用以下命令:

$ 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

注意

请注意最后一个文件列表中的s:这表明设置了 SUID。

暂存目录中的文件所有权权限

出于安全和稳定性原因,非常重要的是要注意将要放置在目标设备上的文件的所有权和权限。一般来说,您希望将敏感资源限制为只能由 root 访问,并尽可能多地使用非 root 用户运行程序,以便如果它们受到外部攻击,它们尽可能少地向攻击者提供系统资源。例如,设备节点/dev/mem提供对系统内存的访问,这在某些程序中是必要的。但是,如果它可以被所有人读取和写入,那么就没有安全性,因为每个人都可以访问一切。因此,/dev/mem应该由 root 拥有,属于 root 组,并且具有 600 的模式,这样除了所有者之外,其他人都无法读取和写入。

然而,暂存目录存在问题。您在那里创建的文件将归您所有,但是,当它们安装到设备上时,它们应该属于特定的所有者和组,主要是 root 用户。一个明显的修复方法是使用以下命令在此阶段更改所有权:

$ cd ~/rootfs
$ sudo chown -R root:root *

问题是您需要 root 权限来运行该命令,并且从那时起,您将需要 root 权限来修改暂存目录中的任何文件。在您知道之前,您将以 root 身份进行所有开发,这不是一个好主意。这是我们稍后将回头解决的问题。

根文件系统的程序

现在,是时候开始用必要的程序和支持库、配置和数据文件填充根文件系统了,首先概述您将需要的程序类型。

init 程序

您在上一章中已经看到init是第一个要运行的程序,因此具有 PID 1。它以 root 用户身份运行,因此对系统资源具有最大访问权限。通常,它运行启动守护程序的 shell 脚本:守护程序是在后台运行且与终端没有连接的程序,在其他地方可能被称为服务器程序。

Shell

我们需要一个 shell 来运行脚本,并给我们一个命令行提示符,以便我们可以与系统交互。在生产设备中可能不需要交互式 shell,但它对开发、调试和维护非常有用。嵌入式系统中常用的各种 shell 有:

  • bash:是我们从桌面 Linux 中熟悉和喜爱的大型工具。它是 Unix Bourne shell 的超集,具有许多扩展或bashisms

  • ash:也基于 Bourne shell,并且在 Unix 的 BSD 变体中有着悠久的历史。Busybox 有一个 ash 的版本,已经扩展以使其与bash更兼容。它比bash小得多,因此是嵌入式系统的非常受欢迎的选择。

  • hush:是一个非常小的 shell,在引导加载程序章节中我们简要介绍过。它在内存非常少的设备上非常有用。BusyBox 中有一个版本。

提示

如果您在目标上使用ashhush作为 shell,请确保在目标上测试您的 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的形式导出其主要函数。例如,cat命令是在coreutils/cat.c中实现的,并导出cat_main。BusyBox 本身的主函数根据命令行参数将调用分派到正确的小工具。

因此,要读取文件,您可以启动busybox,后面跟上您想要运行的小工具的名称,以及小工具期望的任何参数,如下所示:

$ busybox cat my_file.txt

您还可以运行busybox而不带任何参数,以获取已编译的所有小工具的列表。

以这种方式使用 BusyBox 相当笨拙。让 BusyBox 运行cat小工具的更好方法是创建一个从/bin/cat/bin/busybox的符号链接。

$ ls -l bin/cat bin/busybox
-rwxr-xr-x 1 chris chris 892868 Feb  2 11:01 bin/busybox
lrwxrwxrwx 1 chris chris      7 Feb  2 11:01 bin/cat -> busybox

当您在命令行输入cat时,实际运行的程序是busybox。BusyBox 只需要检查传递给argv[0]的命令尾部,它将是/bin/cat,提取应用程序名称cat,并进行表查找以匹配catcat_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 使用与内核相同的KconfigKbuild系统,因此交叉编译很简单。你可以通过克隆 git 存档并检出你想要的版本(写作时最新的是 1_24_1)来获取源代码,就像这样:

$ git clone git://busybox.net/busybox.git
$ cd busybox
$ git checkout 1_24_1

你也可以从busybox.net/downloads下载相应的tarball文件。然后,配置 BusyBox,从默认配置开始,这样可以启用几乎所有 BusyBox 的功能:

$ make distclean
$ make defconfig

在这一点上,你可能想要运行make menuconfig来微调配置。你几乎肯定想要在Busybox Settings | Installation Options (CONFIG_PREFIX)中设置安装路径,指向暂存目录。然后,你可以像通常一样进行交叉编译:

$ make -j 4 ARCH=arm CROSS_COMPILE=arm-cortex_a8-linux-gnueabihf-

结果是可执行文件busybox。对于 ARM v7a 的defconfig构建,它的大小约为 900 KiB。如果这对你来说太大了,你可以通过配置掉你不需要的实用程序来减小它。

要安装 BusyBox,请使用以下命令:

$ make install

这将把二进制文件复制到CONFIG_PREFIX配置的目录,并创建所有的符号链接。

ToyBox - BusyBox 的替代品

BusyBox 并不是唯一的选择。例如,Android 有一个名为 Toolbox 的等效工具,但它更适合 Android 的需求,对于一般嵌入式环境没有用。一个更有用的选择是 ToyBox,这是一个由 Rob Landley 发起和维护的项目,他以前是 BusyBox 的维护者。ToyBox 的目标与 BusyBox 相同,但更注重遵守标准,特别是 POSIX-2008 和 LSB 4.1,而不是与 GNU 对这些标准的扩展的兼容性。ToyBox 比 BusyBox 小,部分原因是它实现的小程序更少。

然而,主要的区别是许可证,是 BSD 而不是 GPL v2,这使它与具有 BSD 许可的用户空间的操作系统兼容,比如 Android 本身。

根文件系统的库

程序与库链接。你可以将它们全部静态链接,这样目标设备上就不会有库了。但是,如果你有两三个以上的程序,这将占用不必要的大量存储空间。所以,你需要将共享库从工具链复制到暂存目录。你怎么知道哪些库?

一个选择是将它们全部复制,因为它们肯定有些用处,否则它们就不会存在!这当然是合乎逻辑的,如果你正在为他人用于各种应用程序的平台创建一个平台,那么这将是正确的方法。但要注意,一个完整的glibc相当大。在 CrossTool-NG 构建的glibc 2.19 的情况下,/lib/usr/lib占用的空间为 33 MiB。当然,你可以通过使用 uClibc 或 Musel libc库大大减少这个空间。

另一个选择是只挑选你需要的那些库,为此你需要一种发现库依赖关系的方法。使用我们从第二章中的一些知识,了解工具链库,你可以使用readelf来完成这个任务:

$ cd ~/rootfs
$ arm-cortex_a8-linux-gnueabihf-readelf -a bin/busybox | grep "program interpreter"
 [Requesting program interpreter: /lib/ld-linux-armhf.so.3]
$ arm-cortex_a8-linux-gnueabihf-readelf -a bin/busybox | grep "Shared library"
0x00000001 (NEEDED)              Shared library: [libm.so.6]
0x00000001 (NEEDED)              Shared library: [libc.so.6]

现在你需要在工具链中找到这些文件,并将它们复制到暂存目录。记住你可以这样找到sysroot

$ arm-cortex_a8-linux-gnueabihf-gcc -print-sysroot
/home/chris/x-tools/arm-cortex_a8-linux-gnueabihf/arm-cortex_a8-linux-gnueabihf/sysroot

为了减少输入量,我将把它保存在一个 shell 变量中:

$ export SYSROOT=`arm-cortex_a8-linux-gnueabihf-gcc -print-sysroot`

如果你在sysroot中查看/lib/ld-linux-armhf.so.3,你会发现,它实际上是一个符号链接:

$ ls -l $SYSROOT/lib/ld-linux-armhf.so.3
[...]/sysroot/lib/ld-linux-armhf.so.3 -> ld-2.19.so

libc.so.6libm.so.6重复此操作,您将得到三个文件和三个符号链接的列表。使用cp -a进行复制,这将保留符号链接:

$ cd ~/rootfs
$ cp -a $SYSROOT/lib/ld-linux-armhf.so.3 lib
$ cp -a $SYSROOT/lib/ld-2.19.so lib
$ cp -a $SYSROOT/lib/libc.so.6 lib
$ cp -a $SYSROOT/lib/libc-2.19.so lib
$ cp -a $SYSROOT/lib/libm.so.6 lib
$ cp -a $SYSROOT/lib/libm-2.19.so lib

对每个程序重复此过程。

提示

这样做只有在获取最小的嵌入式占用空间时才值得。有可能会错过通过dlopen(3)调用加载的库,主要是插件。我们将在本章后面配置网络接口时,通过 NSS 库的示例来说明。

通过剥离来减小尺寸

通常情况下,库和程序都会编译时内置符号表信息,如果使用了调试开关-g,则更多。您很少需要这些信息。节省空间的一种快速简单的方法是剥离它们。此示例显示了剥离前后的libc

$ file rootfs/lib/libc-2.19.so
rootfs/lib/libc-2.19.so: ELF 32-bit LSB shared object, ARM, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 3.15.4, not stripped
$ ls -og rootfs/lib/libc-2.19.so
-rwxrwxr-x 1 1547371 Feb  5 10:18 rootfs/lib/libc-2.19.so
$ arm-cortex_a8-linux-gnueabi-strip rootfs/lib/libc-2.19.so
$ file rootfs/lib/libc-2.19.so
rootfs/lib/libc-2.19.so: ELF 32-bit LSB shared object, ARM, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 3.15.4, stripped
$ ls -l rootfs/lib/libc-2.19.so
-rwxrwxr-x 1 chris chris 1226024 Feb  5 10:19 rootfs/lib/libc-2.19.so
$ ls -og rootfs/lib/libc-2.19.so
-rwxrwxr-x 1 1226024 Feb  5 10:19 rootfs/lib/libc-2.19.so

在这种情况下,我们节省了 321,347 字节,大约为 20%。

在剥离内核模块时,使用以下命令:

strip --strip-unneeded <module name>

否则,您将剥离重定位模块代码所需的符号,导致加载失败。

设备节点

Linux 中的大多数设备都由设备节点表示,符合 Unix 哲学的一切皆文件(除了网络接口,它们是套接字)。设备节点可能是块设备或字符设备。块设备是诸如 SD 卡或硬盘等大容量存储设备。字符设备基本上是其他任何东西,再次除了网络接口。设备节点的传统位置是目录/dev。例如,串行端口可以由设备节点/dev/ttyS0表示。

使用程序mknod(缩写为 make node)创建设备节点:

mknod <name> <type> <major> <minor>

name是您要创建的设备节点的名称,type可以是c表示字符设备,b表示块设备。它们各自有一个主要号和次要号,内核使用这些号码将文件请求路由到适当的设备驱动程序代码。内核源代码中有一个标准主要和次要号的列表,位于Documentation/devices.txt中。

您需要为系统上要访问的所有设备创建设备节点。您可以手动使用mknod命令来执行此操作,就像我在这里所示的那样,或者您可以使用稍后提到的设备管理器之一来在运行时自动创建它们。

使用 BusyBox 启动只需要两个节点:consolenull。控制台只需要对 root 可访问,设备节点的所有者,因此访问权限为 600。空设备应该对所有人可读可写,因此模式为 666。您可以使用mknod-m选项在创建节点时设置模式。您需要是 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 Oct 28 11:37 console
crw-rw-rw- 1 root root 1, 3 Oct 28 11:37 null

您可以使用标准的rm命令删除设备节点:没有rmnod命令,因为一旦创建,它们就是普通文件。

proc 和 sysfs 文件系统

procsysfs是两个伪文件系统,它们提供了内核内部工作的窗口。它们都将内核数据表示为目录层次结构中的文件:当您读取其中一个文件时,您看到的内容并不来自磁盘存储,而是由内核中的一个函数即时格式化的。一些文件也是可写的,这意味着将调用内核函数并使用您写入的新数据,如果格式正确且您有足够的权限,它将修改内核内存中存储的值。换句话说,procsysfs提供了另一种与设备驱动程序和其他内核代码交互的方式。

procsysfs应该挂载在目录/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中找到的文件的最佳参考是proc(5)手册页。

实际上,随着时间的推移,proc中的文件数量及其布局变得相当混乱。在 Linux 2.6 中,sysfs被引入以有序方式导出数据的子集。

相比之下,sysfs导出了一个与设备及其相互连接方式相关的文件的有序层次结构。

挂载文件系统

mount命令允许我们将一个文件系统附加到另一个文件系统中的目录,形成文件系统的层次结构。在顶部被内核挂载时,称为根文件系统。mount命令的格式如下:

mount [-t vfstype] [-o options] device directory

您需要指定文件系统的类型vfstype,它所在的块设备节点,以及您要将其挂载到的目录。在-o之后,您可以给出各种选项,更多信息请参阅手册。例如,如果您想要将包含ext4文件系统的 SD 卡的第一个分区挂载到目录/mnt,您可以输入以下内容:

mount -t ext4 /dev/mmcblk0p1 /mnt

假设挂载成功,您将能够在目录/mnt中看到存储在 SD 卡上的文件。在某些情况下,您可以省略文件系统类型,让内核探测设备以找出存储的内容。

看看挂载proc文件系统的例子,有一些奇怪的地方:没有设备节点/dev/proc,因为它是一个伪文件系统,而不是一个真正的文件系统。但mount命令需要一个设备作为参数。因此,我们必须提供一个字符串来代替设备,但这个字符串是什么并不重要。这两个命令实现了完全相同的结果:

mount -t proc proc /proc
mount -t proc nodevice /proc

在挂载伪文件系统时,通常在设备的位置使用文件系统类型。

内核模块

如果您有内核模块,它们需要安装到根文件系统中,使用内核make modules_install目标,就像我们在上一章中看到的那样。这将把它们复制到目录/lib/modules/<kernel version>中,以及modprobe命令所需的配置文件。

请注意,您刚刚在内核和根文件系统之间创建了一个依赖关系。如果您更新其中一个,您将不得不更新另一个。

将根文件系统传输到目标位置

在暂存目录中创建了一个骨架根文件系统后,下一个任务是将其传输到目标位置。在接下来的章节中,我将描述三种可能性:

  • ramdisk:由引导加载到 RAM 中的文件系统映像。Ramdisks 易于创建,并且不依赖于大容量存储驱动程序。当主根文件系统需要更新时,它们可以用于后备维护模式。它们甚至可以用作小型嵌入式设备的主根文件系统,当然也可以用作主流 Linux 发行版中的早期用户空间。压缩的 ramdisk 使用最少的存储空间,但仍然消耗 RAM。内容是易失性的,因此您需要另一种存储类型来存储永久数据,例如配置参数。

  • 磁盘映像:根文件系统的副本,格式化并准备好加载到目标设备的大容量存储设备上。例如,它可以是一个ext4格式的映像,准备好复制到 SD 卡上,或者它可以是一个jffs2格式的映像,准备好通过引导加载到闪存中。创建磁盘映像可能是最常见的选项。有关不同类型的大容量存储的更多信息,请参阅第七章,“创建存储策略”。

  • 网络文件系统:暂存目录可以通过 NFS 服务器导出到网络,并在启动时由目标设备挂载。在开发阶段通常会这样做,而不是重复创建磁盘映像并重新加载到大容量存储设备上,这是一个相当慢的过程。

我将从 ramdisk 开始,并用它来说明对根文件系统的一些改进,比如添加用户名和设备管理器以自动创建设备节点。然后,我将向您展示如何创建磁盘映像,最后,如何使用 NFS 在网络上挂载根文件系统。

创建引导 ramdisk

Linux 引导 ramdisk,严格来说,是一个初始 RAM 文件系统initramfs,是一个压缩的cpio存档。cpio是一个古老的 Unix 存档格式,类似于 TAR 和 ZIP,但更容易解码,因此在内核中需要更少的代码。您需要配置内核以支持initramfsCONFIG_BLK_DEV_INITRD

实际上,有三种不同的方法可以创建引导 ramdisk:作为一个独立的cpio存档,作为嵌入在内核映像中的cpio存档,以及作为内核构建系统在构建过程中处理的设备表。第一种选项提供了最大的灵活性,因为我们可以随心所欲地混合和匹配内核和 ramdisk。但是,这意味着您需要处理两个文件而不是一个,并且并非所有的引导加载程序都具有加载单独 ramdisk 的功能。稍后我将向您展示如何将其构建到内核中。

独立的 ramdisk

以下一系列指令创建存档,对其进行压缩,并添加一个 U-Boot 标头,以便加载到目标设备上:

$ cd ~/rootfs
$ find . | cpio -H newc -ov --owner root:root > ../initramfs.cpio
$ cd ..
$ gzip initramfs.cpio
$ mkimage -A arm -O linux -T ramdisk -d initramfs.cpio.gz uRamdisk

请注意,我们使用了cpio选项--owner root:root。这是对前面提到的文件所有权问题的一个快速修复,使cpio文件中的所有内容的 UID 和 GID 都为 0。

uRamdisk文件的最终大小约为 2.9 MiB,没有内核模块。再加上内核zImage文件的 4.4 MiB,以及 U-Boot 的 440 KiB,总共需要 7.7 MiB 的存储空间来引导此板。我们离最初的 1.44 MiB 软盘还有一段距离。如果大小是一个真正的问题,您可以使用以下选项之一:

  • 通过留出您不需要的驱动程序和功能,使内核变得更小

  • 通过留出您不需要的实用程序,使 BusyBox 变得更小

  • 使用 uClibc 或 musl libc 代替 glibc

  • 静态编译 BusyBox

引导 ramdisk

我们可以做的最简单的事情是在控制台上运行一个 shell,以便与设备进行交互。我们可以通过将rdinit=/bin/sh添加到内核命令行来实现这一点。现在,您可以引导设备。

使用 QEMU 引导

QEMU 有-initrd选项,可以将initframfs加载到内存中,因此完整的命令现在如下所示:

$ cd ~/rootfs
$ QEMU_AUDIO_DRV=none \
qemu-system-arm -m 256M -nographic -M vexpress-a9 -kernel zImage -append "console=ttyAMA0 rdinit=/bin/sh" -dtb vexpress-v2p-ca9.dtb -initrd initramfs.cpio.gz

引导 BeagleBone Black

要启动 BeagleBone Black,请引导到 U-Boot 提示符,并输入以下命令:

fatload mmc 0:1 0x80200000 zImage
fatload mmc 0:1 0x80f00000 am335x-boneblack.dtb
fatload mmc 0:1 0x81000000 uRamdisk
setenv bootargs console=ttyO0,115200 rdinit=/bin/sh
bootz 0x80200000 0x81000000 0x80f00000

如果一切顺利,您将在控制台上获得一个根 shell 提示符。

挂载 proc

请注意,ps命令不起作用:这是因为proc文件系统尚未被挂载。尝试挂载它,然后再次运行ps

对此设置的一个改进是编写一个包含需要在启动时执行的内容的 shell 脚本,并将其作为rdinit=的参数。脚本将类似于以下代码片段:

#!/bin/sh
/bin/mount -t proc proc /proc
/bin/sh

以这种方式使用 shell 作为init对于快速修补非常方便,例如,当您想要修复带有损坏init程序的系统时。但是,在大多数情况下,您将使用一个init程序,我们将在后面进一步介绍。

将 ramdisk cpio 构建到内核映像中

在某些情况下,最好将 ramdisk 构建到内核映像中,例如,如果引导加载程序无法处理 ramdisk 文件。要做到这一点,更改内核配置并将CONFIG_INITRAMFS_SOURCE设置为您之前创建的cpio存档的完整路径。如果您使用menuconfig,它在常规设置 | Initramfs 源文件中。请注意,它必须是以.cpio结尾的未压缩cpio文件;而不是经过 gzip 压缩的版本。然后,构建内核。您应该看到它比以前大。

引导与以前相同,只是没有 ramdisk 文件。对于 QEMU,命令如下:

$ cd ~/rootfs
$ QEMU_AUDIO_DRV=none \
qemu-system-arm -m 256M -nographic -M vexpress-a9 -kernel zImage -append "console=ttyAMA0 rdinit=/bin/sh" -dtb vexpress-v2p-ca9.dtb

对于 BeagleBone Black,将这些命令输入 U-Boot:

fatload mmc 0:1 0x80200000 zImage
fatload mmc 0:1 0x80f00000 am335x-boneblack.dtb
setenv bootargs console=ttyO0,115200 rdinit=/bin/sh
bootz 0x80200000 – 0x80f00000

当然,您必须记住每次更改 ramdisk 的内容并重新生成.cpio文件时都要重新构建内核。

另一种构建带有 ramdisk 的内核的方法

将 ramdisk 构建到内核映像中的一个有趣的方法是使用设备表生成cpio存档。设备表是一个文本文件,列出了存档中包含的文件、目录、设备节点和链接。压倒性的优势在于,您可以在cpio文件中创建属于 root 或任何其他 UID 的条目,而无需自己拥有 root 权限。您甚至可以创建设备节点。所有这些都是可能的,因为存档只是一个数据文件。只有在 Linux 在引导时扩展它时,才会使用您指定的属性创建真实的文件和目录。

这是我们简单的rootfs的设备表,但缺少大部分到busybox的符号链接,以便更易管理:

dir /proc 0755 0 0
dir /sys 0755 0 0
dir /dev 0755 0 0
nod /dev/console 0600 0 0 c 5 1
nod /dev/null 0666 0 0 c 1 3
nod /dev/ttyO0 0600 0 0 c 252 0
dir /bin 0755 0 0
file /bin/busybox /home/chris/rootfs/bin/busybox 0755 0 0
slink /bin/sh /bin/busybox 0777 0 0
dir /lib 0755 0 0
file /lib/ld-2.19.so /home/chris/rootfs/lib/ld-2.19.so 0755 0 0
slink /lib/ld-linux.so.3 /lib/ld-2.19.so 0777 0 0
file /lib/libc-2.19.so /home/chris/rootfs/lib/libc-2.19.so 0755 0 0
slink /lib/libc.so.6 /lib/libc-2.19.so 0777 0 0
file /lib/libm-2.19.so /home/chris/rootfs/lib/libm-2.19.so 0755 0 0
slink /lib/libm.so.6 /lib/libm-2.19.so 0777 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>

内核提供了一个工具,读取此文件并创建cpio存档。源代码在usr/gen_init_cpio.c中。scripts/gen_initramfs_list.sh中有一个方便的脚本,它从给定目录创建设备表,这样可以节省很多输入。

要完成任务,您需要将CONFIG_INITRAMFS_SOURCE设置为指向设备表文件,然后构建内核。其他一切都和以前一样。

旧的 initrd 格式

Linux ramdisk 的旧格式称为initrd。在 Linux 2.6 之前,这是唯一可用的格式,并且如果您使用 Linux 的无 mmu 变体 uCLinux,则仍然需要它。它相当晦涩,我在这里不会涉及。内核源代码中有更多信息,在Documentation/initrd.txt中。

init 程序

在引导时运行 shell,甚至是 shell 脚本,对于简单情况来说是可以的,但实际上您需要更灵活的东西。通常,Unix 系统运行一个名为init的程序,它启动并监视其他程序。多年来,已经有许多init程序,其中一些我将在第九章中描述,启动 - init 程序。现在,我将简要介绍 BusyBox 中的init

init开始读取配置文件/etc/inittab。这是一个对我们的需求足够简单的示例:

::sysinit:/etc/init.d/rcS
::askfirst:-/bin/ash

第一行在启动init时运行一个 shell 脚本rcS。第二行将消息请按 Enter 键激活此控制台打印到控制台,并在按下Enter时启动一个 shell。/bin/ash前面的-表示它将是一个登录 shell,在给出 shell 提示之前会源自/etc/profile$HOME/.profile。以这种方式启动 shell 的一个优点是启用了作业控制。最直接的影响是您可以使用Ctrl + C来终止当前程序。也许您之前没有注意到,但是等到您运行ping程序并发现无法停止它时!

BusyBox init在根文件系统中没有inittab时提供默认的inittab。它比前面的更加广泛。

脚本/etc/init.d/rcS是放置需要在启动时执行的初始化命令的地方,例如挂载procsysfs文件系统:

#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sysfs /sys

确保使rcS可执行,就像这样:

$ cd ~/rootfs
$ chmod +x etc/init.d/rcS

您可以通过更改-append参数在 QEMU 上尝试它,就像这样:

-append "console=ttyAMA0 rdinit=/sbin/init"

要在 BeagelBone Black 上实现相同的效果,需要更改 U-Boot 中的bootargs变量,如下所示:

setenv bootargs console=ttyO0,115200 rdinit=/sbin/init

配置用户帐户

正如我已经暗示的,以 root 身份运行所有程序并不是一个好的做法,因为如果一个程序受到外部攻击,那么整个系统都处于风险之中,而且如果作为 root 运行的程序行为不端,它可能会造成更大的破坏。最好创建非特权用户帐户,并在不需要完全 root 权限的地方使用它们。

用户名称配置在/etc/passwd中。每个用户一行,由冒号分隔的七个信息字段:

  • 登录名

  • 用于验证密码的哈希码,或者更通常地是一个x,表示密码存储在/etc/shadow

  • UID

  • GID

  • 一个注释字段,通常留空

  • 用户的主目录

  • (可选)此用户将使用的 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用户受限,密码就是安全的。

影子密码文件由每个用户的一个条目组成,由九个字段组成。这是一个与前一段中显示的passwd文件相似的例子:

root::10933:0:99999:7:::
daemon:*:10933:0:99999:7:::

前两个字段是用户名和密码哈希。剩下的七个与密码老化有关,这在嵌入式设备上通常不是问题。如果您对完整的细节感兴趣,请参阅手册页shadow(5)

在这个例子中,root的密码是空的,这意味着root可以在不输入密码的情况下登录,这在开发过程中很有用,但在生产中不适用!您可以使用mkpasswd命令生成密码哈希,或者在目标上运行passwd命令,并将目标上的/etc/shadow中的哈希字段复制并粘贴到分段目录中的默认 shadow 文件中。

daemon 的密码是*,这不会匹配任何登录密码,再次确保 daemon 不能用作常规用户帐户。

组名以类似的方式存储在/etc/group中。格式如下:

  • 组的名称

  • 组密码,通常是一个x字符,表示没有组密码

  • GID

  • 属于该组的用户的可选列表,用逗号分隔。

这是一个例子:

root:x:0:
daemon:x:1:

向根文件系统添加用户帐户

首先,你必须向你的暂存目录添加etc/passwdetc/shadowetc/group,就像前面的部分所示的那样。确保shadow的权限为 0600。

登录过程由一个名为getty的程序启动,它是 BusyBox 的一部分。你可以使用inittab中的respawn关键字启动它,当登录 shell 终止时,getty将被重新启动,因此inittab应该如下所示:

::sysinit:/etc/init.d/rcS
::respawn:/sbin/getty 115200 console

然后重新构建 ramdisk,并像之前一样使用 QEMU 或 BeagelBone Black 进行尝试。

启动守护进程

通常,你会希望在启动时运行某些后台进程。让我们以日志守护程序syslogd为例。syslogd的目的是积累来自其他程序(大多数是其他守护程序)的日志消息。当然,BusyBox 有一个适用于此的小工具!

启动守护进程就像在etc/inittab中添加这样一行那样简单:

::respawn:syslogd -n

respawn表示,如果程序终止,它将自动重新启动;-n表示它应该作为前台进程运行。日志将被写入/var/log/messages

提示

你可能也想以同样的方式启动klogdklogd将内核日志消息发送到syslogd,以便将其记录到永久存储中。

顺便提一下,在典型的嵌入式 Linux 系统中,将日志文件写入闪存并不是一个好主意,因为这样会使其磨损。我将在第七章中介绍日志记录的选项,创建存储策略

更好地管理设备节点

使用mknod静态创建设备节点非常费力且不灵活。还有其他方法可以根据需要自动创建设备节点:

  • devtmpfs:这是一个伪文件系统,在引导时挂载到/dev上。内核会为内核当前已知的所有设备填充它,并在运行时检测到新设备时创建节点。这些节点由root拥有,并具有默认权限 0600。一些众所周知的设备节点,如/dev/null/dev/random,覆盖默认值为 0666(请参阅drivers/char/mem.c中的struct memdev)。

  • mdev:这是一个 BusyBox 小工具,用于向目录填充设备节点,并根据需要创建新节点。有一个配置文件/etc/mdev.conf,其中包含节点所有权和模式的规则。

  • udev:现在是systemd的一部分,是桌面 Linux 和一些嵌入式设备上的解决方案。它非常灵活,是高端嵌入式设备的不错选择。

提示

虽然mdevudev都可以自行创建设备节点,但更常见的做法是让devtmpfs来完成这项工作,并使用mdev/udev作为实施设置所有权和权限策略的一层。

使用 devtmpfs 的示例

如果你已经启动了之前的 ramdisk 示例之一,尝试devtmpfs就像输入这个命令一样简单:

# mount -t devtmpfs devtmpfs /dev

你应该看到/dev里面充满了设备节点。要进行永久修复,将这个添加到/etc/init.d/rcS中:

#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs devtmpfs /dev

事实上,内核初始化会自动执行这一操作,除非你提供了initramfs ramdisk,就像我们所做的那样!要查看代码,请查看init/do_mounts.c,函数prepare_namespace()

使用 mdev 的示例

虽然设置mdev有点复杂,但它允许你在创建设备节点时修改权限。首先,有一个启动阶段,通过-s选项选择,当mdev扫描/sys目录查找有关当前设备的信息并用相应的节点填充/dev目录。

如果你想跟踪新设备的上线并为它们创建节点,你需要将mdev作为热插拔客户端写入/proc/sys/kernel/hotplug。将这些添加到/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中添加规则来更改。例如,要为nullrandomurandom设备提供正确的模式,您需要将其添加到/etc/mdev.conf中:

null     root:root 666
random   root:root 444
urandom  root:root 444

该格式在 BusyBox 源代码中的docs/mdev.txt中有记录,并且在名为examples的目录中有更多示例。

静态设备节点到底有多糟糕?

静态创建的设备节点确实有一个优点:它们在引导过程中不需要花费任何时间来创建,而其他方法则需要。如果最小化引导时间是一个优先考虑的问题,使用静态创建的设备节点将节省可测量的时间。

配置网络

接下来,让我们看一些基本的网络配置,以便我们可以与外部世界通信。我假设有一个以太网接口eth0,我们只需要一个简单的 IP v4 配置。

这些示例使用了 BusyBox 的网络实用程序,并且对于简单的用例来说足够了,使用old-but-reliable ifupifdown程序。您可以阅读这两者的 man 页面以获取更多细节。主要的网络配置存储在/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 10.0.0.42
  netmask 255.255.255.0
  network 10.0.0.0

对于使用 DHCP 分配的动态 IP 地址,etc/network/interfaces看起来像这样:

auto lo
iface lo inet loopback
auto eth0
iface eth0 inet dhcp

您还需要配置一个 DHCP 客户端程序。BusyBox 有一个名为udchpcd的程序。它需要一个应该放在/usr/share/udhcpc/default.script中的 shell 脚本。在 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中同名的文件解决,除了主机名,它可能还会通过 DNS 查找来解决。

要使其工作,您需要使用这些文件填充/etc。网络、协议和服务在所有 Linux 系统中都是相同的,因此可以从开发 PC 中的/etc中复制。/etc/hosts至少应包含环回地址:

127.0.0.1 localhost

我们将在稍后讨论其他的passwdgroupshadow

拼图的最后一块是执行名称解析的库。它们是根据nsswitch.conf的内容按需加载的插件,这意味着如果您使用readelf或类似工具,它们不会显示为依赖项。您只需从工具链的sysroot中复制它们:

$ cd ~/rootfs
$ cp -a $TOOLCHAIN_SYSROOT/lib/libnss* lib
$ cp -a $TOOLCHAIN_SYSROOT/lib/libresolv* lib

使用设备表创建文件系统映像

内核有一个实用程序gen_init_cpio,它根据文本文件中设置的格式指令创建一个cpio文件,称为设备表,允许非根用户创建设备节点,并为任何文件或目录分配任意 UID 和 GID 值。

相同的概念已应用于创建其他文件系统映像格式的工具:

  • jffs2mkfs.jffs2

  • ubifsmkfs.ubifs

  • ext2genext2fs

我们将在第七章中讨论jffs2ubifs创建存储策略,当我们研究用于闪存的文件系统时。第三个ext2是一个相当古老的硬盘格式。

它们都需要一个设备表文件,格式为<name> <type> <mode> <uid> <gid> <major> <minor> <start> <inc> <count>,其中以下内容适用:

  • name:文件名

  • type:以下之一:

  • f:一个常规文件

  • d:一个目录

  • c:字符特殊设备文件

  • b:块特殊设备文件

  • p:FIFO(命名管道)

  • uid:文件的 UID

  • gid:文件的 GID

  • majorminor:设备号(仅设备节点)

  • startinccount:(仅设备节点)允许您从start中的minor号开始创建一组设备节点

您不必像使用gen_init_cpio那样指定每个文件:您只需将它们指向一个目录-暂存目录-并列出您需要在最终文件系统映像中进行的更改和异常。

一个简单的示例,为我们填充静态设备节点如下:

/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生成一个 4 MiB(即默认大小的 4,096 个块,每个块 1,024 字节)的文件系统映像:

$ genext2fs -b 4096 -d rootfs -D device-table.txt -U rootfs.ext2

现在,您可以将生成的映像rootfs.ext复制到 SD 卡或类似的设备。

将根文件系统放入 SD 卡中

这是一个从普通块设备(如 SD 卡)挂载文件系统的示例。相同的原则适用于其他文件系统类型,我们将在第七章创建存储策略中更详细地讨论它们。

假设您有一个带有 SD 卡的设备,并且第一个分区用于引导文件,MLOu-boot.img-就像 BeagleBone Black 上一样。还假设您已经使用genext2fs创建了一个文件系统映像。要将其复制到 SD 卡,请插入卡并识别其被分配的块设备:通常为/dev/sd/dev/mmcblk0。如果是后者,请将文件系统映像复制到第二个分区:

$ sudo dd if=rootfs.ext2 of=/dev/mmcblk0p2

然后,将 SD 卡插入设备,并将内核命令行设置为root=/dev/mmcblk0p2。完整的引导顺序如下:

fatload mmc 0:1 0x80200000 zImage
fatload mmc 0:1 0x80f00000 am335x-boneblack.dtb
setenv bootargs console=ttyO0,115200 root=/dev/mmcblk0p2
bootz 0x80200000 – 0x80f00000

使用 NFS 挂载根文件系统

如果您的设备有网络接口,最好在开发过程中通过网络挂载根文件系统。这样可以访问几乎无限的存储空间,因此您可以添加具有大型符号表的调试工具和可执行文件。作为额外的奖励,对于开发机上托管的根文件系统所做的更新将立即在目标上生效。您还有日志文件的副本。

为了使其工作,您的内核必须配置为CONFIG_ROOT_NFS。然后,您可以通过将以下内容添加到内核命令行来配置 Linux 在引导时进行挂载:

root=/dev/nfs

给出 NFS 导出的详细信息如下:

nfsroot=<host-ip>:<root-dir>

配置连接到 NFS 服务器的网络接口,以便在引导时,在init程序运行之前使用此命令:

ip=<target-ip>

有关 NFS 根挂载的更多信息,请参阅内核源中的Documentation/filesystems/nfs/nfsroot.txt

您还需要在主机上安装和配置 NFS 服务器,对于 Ubuntu,您可以使用以下命令完成:

$ sudo apt-get install nfs-kernel-server

NFS 服务器需要告知哪些目录正在导出到网络,这由/etc/exports控制。向该文件添加类似以下行:

/<path to staging> *(rw,sync,no_subtree_check,no_root_squash)

然后,重新启动服务器以应用更改,对于 Ubuntu 来说是:

$ sudo /etc/init.d/nfs-kernel-server restart

使用 QEMU 进行测试

以下脚本创建了一个虚拟网络,将主机上的网络设备tap0与目标上的eth0使用一对静态 IPv4 地址连接起来,然后使用参数启动 QEMU,以使用tap0作为模拟接口。您需要更改根文件系统的路径为您的暂存目录的完整路径,如果它们与您的网络配置冲突,可能还需要更改 IP 地址:

#!/bin/bash

KERNEL=zImage
DTB=vexpress-v2p-ca9.dtb
ROOTDIR=/home/chris/rootfs

HOST_IP=192.168.1.1
TARGET_IP=192.168.1.101
NET_NUMBER=192.168.1.0
NET_MASK=255.255.255.0

sudo tunctl -u $(whoami) -t tap0
sudo ifconfig tap0 ${HOST_IP}
sudo route add -net ${NET_NUMBER} netmask ${NET_MASK} dev tap0
sudo sh -c "echo  1 > /proc/sys/net/ipv4/ip_forward"

QEMU_AUDIO_DRV=none \
qemu-system-arm -m 256M -nographic -M vexpress-a9 -kernel $KERNEL -append "console=ttyAMA0 root=/dev/nfs rw nfsroot=${HOST_IP}:${ROOTDIR} ip=${TARGET_IP}" -dtb ${DTB} -net nic -net tap,ifname=tap0,script=no

该脚本可用作run-qemu-nfs.sh

它应该像以前一样启动,但现在直接通过 NFS 导出使用暂存目录。您在该目录中创建的任何文件将立即对目标设备可见,而在设备上创建的文件将对开发 PC 可见。

使用 BeagleBone Black 进行测试

类似地,您可以在 BeagleBone Black 的 U-Boot 提示符下输入这些命令:

setenv serverip 192.168.1.1
setenv ipaddr 192.168.1.101
setenv npath [path to staging directory]
setenv bootargs console=ttyO0,115200 root=/dev/nfs rw nfsroot=${serverip}:${npath} ip=${ipaddr}

然后,要引导它,从sdcard中加载内核和dtb,就像以前一样:

fatload mmc 0:1 0x80200000 zImage
fatload mmc 0:1 0x80f00000 am335x-boneblack.dtb
bootz 0x80200000 - 0x80f00000

文件权限问题

已经在暂存目录中的文件由您拥有,并且在运行ls -l时会显示在目标上,无论您的 UID 是什么,通常为 1,000。由目标设备创建的任何文件都将由 root 拥有。整个情况一团糟。

不幸的是,没有简单的方法。最好的建议是复制暂存目录并将所有权更改为root:root(使用sudo chown -R 0:0 *),并将此目录导出为 NFS 挂载。这样可以减少在开发和目标系统之间共享根文件系统的不便。

使用 TFTP 加载内核

当使用诸如 BeagleBone Black 之类的真实硬件时,最好通过网络加载内核,特别是当根文件系统通过 NFS 挂载时。这样,您就不会使用设备上的任何本地存储。如果不必一直重新刷新内存,可以节省时间,并且意味着您可以在闪存存储驱动程序仍在开发中时完成工作(这种情况经常发生)。

U-Boot 多年来一直支持简单文件传输协议TFTP)。首先,您需要在开发机器上安装tftp守护程序。在 Ubuntu 上,您将安装tftpd-hpa软件包,该软件包授予/var/lib/tftpboot目录中的文件对U-Boottftp客户端的读取访问权限。

假设您已将zImageam335x-boneblack.dtb复制到/var/lib/tftpboot,请在 U-Boot 中输入以下命令以加载和启动:

setenv serverip 192.168.1.1
setenv ipaddr 192.168.1.101
tftpboot 0x80200000 zImage
tftpboot 0x80f00000 am335x-boneblack.dtb
setenv npath [path to staging]
setenv bootargs console=ttyO0,115200 root=/dev/nfs rw nfsroot=${serverip}:${npath} ip=${ipaddr}
bootz 0x80200000 - 0x80f00000

对于tftpboot的响应通常是这样的:

setenv ipaddr 192.168.1.101
nova!> setenv serverip 192.168.1.1
nova!> tftpboot 0x80200000 zImage
link up on port 0, speed 100, full duplex
Using cpsw device
TFTP from server 192.168.1.1; our IP address is 192.168.1.101
Filename 'zImage'.
Load address: 0x80200000
Loading: T T T T

最后一行的T字符行表示有些问题,TFTP 请求超时。最常见的原因如下:

  • 服务器的 IP 地址不正确。

  • 服务器上没有运行 TFTP 守护程序。

  • 服务器上的防火墙阻止了 TFTP 协议。大多数防火墙默认确实会阻止 TFTP 端口 69。

在这种情况下,tftp 守护程序没有运行,所以我用以下命令启动了它:

$ sudo service tftpd-hpa restart

额外阅读

  • 文件系统层次结构标准,目前版本为 3.0,可在refspecs.linuxfoundation.org/fhs.shtml上找到。

  • ramfs, rootfs and initramfs , Rob Landley,2005 年 10 月 17 日,这是 Linux 源代码中的一部分,可在Documentation/filesystems/ramfs-rootfs-initramfs.txt上找到。

总结

Linux 的一个优点是它可以支持各种根文件系统,从而使其能够满足各种需求。我们已经看到可以手动使用少量组件构建简单的根文件系统,并且 BusyBox 在这方面特别有用。通过一步一步地进行这个过程,我们对 Linux 系统的一些基本工作原理有了了解,包括网络配置和用户帐户。然而,随着设备变得更加复杂,任务很快变得难以管理。而且,我们始终担心可能存在我们没有注意到的实现中的安全漏洞。在下一章中,我们将研究使用嵌入式构建系统来帮助我们。

第六章:选择构建系统

前几章涵盖了嵌入式 Linux 的四个元素,并逐步向您展示了如何构建工具链、引导加载程序、内核和根文件系统,然后将它们组合成基本的嵌入式 Linux 系统。而且有很多步骤!现在是时候看看如何通过尽可能自动化来简化这个过程。我将介绍嵌入式构建系统如何帮助,并特别介绍两种构建系统:Buildroot 和 Yocto Project。这两种都是复杂而灵活的工具,需要整本书来充分描述它们的工作原理。在本章中,我只想向您展示构建系统背后的一般思想。我将向您展示如何构建一个简单的设备镜像,以便对系统有一个整体感觉,然后如何进行一些有用的更改,使用前几章中的 Nova 板示例。

不再自己制作嵌入式 Linux

手动创建系统的过程,如第五章中所述的构建根文件系统,称为roll your ownRYO)过程。它的优点是您完全控制软件,可以根据自己的喜好进行定制。如果您希望它执行一些非常奇特但创新的操作,或者如果您希望将内存占用减少到最小,RYO 是一种方法。但是,在绝大多数情况下,手动构建是浪费时间并产生质量较差、难以维护的系统。

它们通常在几个月的时间内逐步构建,通常没有记录,很少从头开始重新创建,因为没有人知道每个部分来自哪里。

构建系统

构建系统的理念是自动化我到目前为止描述的所有步骤。构建系统应该能够从上游源代码构建一些或所有以下内容:

  • 工具链

  • 引导加载程序

  • 内核

  • 根文件系统

从上游源代码构建对于许多原因都很重要。这意味着您可以放心,随时可以重新构建,而无需外部依赖。这还意味着您拥有用于调试的源代码,并且可以满足分发给用户的许可要求。

因此,为了完成其工作,构建系统必须能够执行以下操作:

  • 从上游下载源代码,可以直接从源代码控制系统或作为存档文件,并将其缓存在本地

  • 应用补丁以启用交叉编译,修复与体系结构相关的错误,应用本地配置策略等

  • 构建各种组件

  • 创建一个暂存区并组装一个根文件系统

  • 创建各种格式的镜像文件,准备加载到目标设备上

其他有用的东西如下:

  • 添加您自己的软件包,例如应用程序或内核更改

  • 选择各种根文件系统配置文件:大或小,带有或不带有图形或其他功能

  • 创建一个独立的 SDK,您可以将其分发给其他开发人员,以便他们不必安装完整的构建系统

  • 跟踪所选软件包使用的各种开源许可证

  • 允许您为现场更新创建更新

  • 具有用户友好的用户界面

在所有情况下,它们将系统的组件封装成包,一些用于主机,一些用于目标。每个软件包由一组规则定义,以获取源代码,构建它,并将结果安装在正确的位置。软件包之间存在依赖关系和构建机制来解决依赖关系并构建所需的软件包集。

开源构建系统在过去几年中已经显著成熟。有许多构建系统,包括:

  • Buildroot:使用 GNU makeKconfig的易于使用的系统(buildroot.org

  • EmbToolkit:用于生成根文件系统的简单系统;在撰写本文时,是唯一支持 LLVM/Clang 的系统(www.embtoolkit.org

  • OpenEmbedded:一个强大的系统,也是 Yocto 项目和其他项目的核心组件(openembedded.org

  • OpenWrt:一个面向无线路由器固件构建的构建工具(openwrt.org

  • PTXdist:由 Pengutronix 赞助的开源构建系统(www.pengutronix.de/software/ptxdist/index_en.html

  • Tizen:一个全面的系统,重点放在移动、媒体和车载设备上(www.tizen.org

  • Yocto 项目:这扩展了 OpenEmbedded 核心的配置、层、工具和文档:可能是最受欢迎的系统(www.yoctoproject.org

我将专注于其中两个:Buildroot 和 Yocto 项目。它们以不同的方式和不同的目标解决问题。

Buildroot 的主要目标是构建根文件系统映像,因此得名,尽管它也可以构建引导加载程序和内核映像。它易于安装和配置,并且可以快速生成目标映像。

另一方面,Yocto 项目在定义目标系统的方式上更加通用,因此可以构建相当复杂的嵌入式设备。每个组件都以 RPM、.dpkg.ipk格式的软件包生成(见下一节),然后将这些软件包组合在一起以制作文件系统映像。此外,您可以在文件系统映像中安装软件包管理器,这允许您在运行时更新软件包。换句话说,当您使用 Yocto 项目构建时,实际上是在创建自己的定制 Linux 发行版。

软件包格式和软件包管理器

主流 Linux 发行版在大多数情况下是由 RPM 或 deb 格式的二进制(预编译)软件包集合构建而成。RPM代表Red Hat 软件包管理器,在 Red Hat、Suse、Fedora 和其他基于它们的发行版中使用。基于 Debian 的发行版,包括 Ubuntu 和 Mint,使用 Debian 软件包管理器格式deb。此外,还有一种轻量级格式专门用于嵌入式设备,称为Itsy PacKage格式,或ipk,它基于deb

在设备上包含软件包管理器的能力是构建系统之间的重要区别之一。一旦在目标设备上安装了软件包管理器,您就可以轻松地部署新软件包并更新现有软件包。我将在下一章讨论这一点的影响。

Buildroot

Buildroot 项目网站位于buildroot.org

当前版本的 Buildroot 能够构建工具链、引导加载程序(U-Boot、Barebox、GRUB2 或 Gummiboot)、内核和根文件系统。它使用 GNU make作为主要构建工具。

buildroot.org/docs.html上有很好的在线文档,包括Buildroot 用户手册

背景

Buildroot 是最早的构建系统之一。它始于 uClinux 和 uClibc 项目的一部分,作为生成用于测试的小型根文件系统的一种方式。它于 2001 年末成为一个独立项目,并持续发展到 2006 年,之后进入了一个相当休眠的阶段。然而,自 2009 年 Peter Korsgaard 接管以来,它一直在快速发展,增加了对基于glibc的工具链的支持以及构建引导加载程序和内核的能力。

Buildroot 也是另一个流行的构建系统 OpenWrt(wiki.openwrt.org)的基础,它在 2004 年左右从 Buildroot 分叉出来。OpenWrt 的主要重点是为无线路由器生产软件,因此软件包混合物是面向网络基础设施的。它还具有使用.ipk格式的运行时软件包管理器,因此可以在不完全重新刷写镜像的情况下更新或升级设备。

稳定版本和支持

Buildroot 开发人员每年发布四次稳定版本,分别在 2 月、5 月、8 月和 11 月。它们以git标签的形式标记为<year>.02<year>.05<year>.08<year>.11。通常,当您启动项目时,您将使用最新的稳定版本。但是,稳定版本发布后很少更新。要获得安全修复和其他错误修复,您将不得不在可用时不断更新到下一个稳定版本,或者将修复程序回溯到您的版本中。

安装

通常情况下,您可以通过克隆存储库或下载存档来安装 Buildroot。以下是获取 2015.08.1 版本的示例,这是我写作时的最新稳定版本:

$ git clone git://git.buildroot.net/buildroot
$ cd buildroot
$ git checkout 2015.08.1

等效的 TAR 存档可从buildroot.org/downloads获取。

接下来,您应该阅读Buildroot 用户手册中的系统要求部分,网址为buildroot.org/downloads/manual/manual.html,并确保您已安装了那里列出的所有软件包。

配置

Buildroot 使用KconfigKbuild机制,就像内核一样,我在第四章的理解内核配置部分中描述的那样,移植和配置内核。您可以直接使用make menuconfig(或xconfiggconfig)从头开始配置它,或者您可以选择存储在configs/目录中的大约 90 个各种开发板和 QEMU 模拟器的配置之一。键入make help列出所有目标,包括默认配置。

让我们从构建一个默认配置开始,您可以在 ARM QEMU 模拟器上运行:

$ cd buildroot
$ make qemu_arm_versatile_defconfig
$ make

提示

请注意,您不需要使用-j选项告诉make要运行多少个并行作业:Buildroot 将自行充分利用您的 CPU。如果您想限制作业的数量,可以运行make menuconfig并查看Build选项下的内容。

构建将花费半小时到一小时的时间,这取决于您的主机系统的能力和与互联网的连接速度。完成后,您会发现已创建了两个新目录:

  • dl/:这包含了 Buildroot 构建的上游项目的存档

  • output/:这包含了所有中间和最终编译的资源

您将在output/中看到以下内容:

  • build/:这是每个组件的构建目录。

  • host/:这包含 Buildroot 所需的在主机上运行的各种工具,包括工具链的可执行文件(在output/host/usr/bin中)。

  • images/:这是最重要的,包含构建的结果。根据您的配置选择,您将找到引导加载程序、内核和一个或多个根文件系统镜像。

  • staging/:这是指向工具链的sysroot的符号链接。链接的名称有点令人困惑,因为它并不指向我在第五章中定义的暂存区。

  • target/:这是根目录的暂存区。请注意,您不能将其作为根文件系统使用,因为文件所有权和权限未正确设置。Buildroot 在创建文件系统映像时使用设备表来设置所有权和权限,如前一章所述。

运行

一些示例配置在boards/目录中有相应的条目,其中包含自定义配置文件和有关在目标上安装结果的信息。对于您刚刚构建的系统,相关文件是board/qemu/arm-vexpress/readme.txt,其中告诉您如何使用此目标启动 QEMU。

假设您已经按照第一章中描述的方式安装了qemu-system-arm起步,您可以使用以下命令运行它:

$ qemu-system-arm -M vexpress-a9 -m 256 \
-kernel output/images/zImage \
-dtb output/images/vexpress-v2p-ca9.dtb \
-drive file=output/images/rootfs.ext2,if=sd \
-append "console=ttyAMA0,115200 root=/dev/mmcblk0" \
-serial stdio -net nic,model=lan9118 -net user

您应该在启动 QEMU 的同一终端窗口中看到内核引导消息,然后是登录提示符:

Booting Linux on physical CPU 0x0
Initializing cgroup subsys cpuset

Linux version 4.1.0 (chris@builder) (gcc version 4.9.3 (Buildroot 2015.08) ) #1 SMP Fri Oct 30 13:55:50 GMT 2015

CPU: ARMv7 Processor [410fc090] revision 0 (ARMv7), cr=10c5387d

CPU: PIPT / VIPT nonaliasing data cache, VIPT aliasing instruction cache
Machine model: V2P-CA9
[...]
VFS: Mounted root (ext2 filesystem) readonly on device 179:0.
devtmpfs: mounted
Freeing unused kernel memory: 264K (8061e000 - 80660000)
random: nonblocking pool is initialized
Starting logging: OK
Starting mdev...
Initializing random number generator... done.
Starting network...

Welcome to Buildroot
buildroot login:

root身份登录,无需密码。

您会看到 QEMU 启动一个黑色窗口,除了具有内核引导消息的窗口。它用于显示目标的图形帧缓冲区。在这种情况下,目标从不写入framebuffer,这就是为什么它是黑色的原因。要关闭 QEMU,可以在 root 提示符处键入poweroff,或者只需关闭framebuffer窗口。这适用于 QEMU 2.0(Ubuntu 14.04 上的默认版本),但在包括 QEMU 1.0.50(Ubuntu 12.04 上的默认版本)在内的早期版本中失败,因为存在 SCSI 仿真问题。

创建自定义 BSP

接下来,让我们使用 Buildroot 为我们的 Nova 板创建 BSP,使用前几章中相同版本的 U-Boot 和 Linux。建议存储更改的位置是:

  • board/<organization>/<device>:包含 Linux、U-Boot 和其他组件的补丁、二进制文件、额外的构建步骤、配置文件

  • configs/<device>_defconfig:包含板的默认配置

  • packages/<organization>/<package_name>:是放置此板的任何额外软件包的位置

我们可以使用 BeagleBone 配置文件作为基础,因为 Nova 是近亲:

$ make clean  #  Always do a clean when changing targets
$ make beaglebone_defconfig

现在.config文件已设置为 BeagleBone。接下来,为板配置创建一个目录:

$ mkdir -p board/melp/nova

U-Boot

在第三章中,引导程序全解,我们为 Nova 创建了一个基于 U-Boot 2015.07 版本的自定义引导程序,并为其创建了一个补丁文件。我们可以配置 Buildroot 选择相同的版本,并应用我们的补丁。首先将补丁文件复制到board/melp/nova,然后使用make menuconfig将 U-Boot 版本设置为 2015.07,补丁目录设置为board/melp/nova,并将板名称设置为 nova,如此屏幕截图所示:

U-Boot

Linux

在第四章中,移植和配置内核,我们基于 Linux 4.1.10 构建了内核,并提供了一个名为nova.dts的新设备树。将设备树复制到board/melp/nova,并更改 Buildroot 内核配置以使用此版本和 nova 设备树,如此屏幕截图所示:

Linux

构建

现在,您可以通过键入make为 Nova 板构建系统,这将在目录output/images中生成这些文件:

MLO  nova.dtb  rootfs.ext2  u-boot.img  uEnv.txt  zImage

最后一步是保存配置的副本,以便您和其他人可以再次使用它:

$ make savedefconfig BR2_DEFCONFIG=configs/nova_defconfig

现在,您已经为 Nova 板创建了 Buildroot 配置。

添加您自己的代码

假设您开发了一些程序,并希望将其包含在构建中。您有两个选择:首先,使用它们自己的构建系统单独构建它们,然后将二进制文件作为叠加卷入最终构建中。其次,您可以创建一个 Buildroot 软件包,可以从菜单中选择并像其他软件包一样构建。

叠加

覆盖只是在构建过程的后期阶段复制到 Buildroot 根文件系统顶部的目录结构。它可以包含可执行文件、库和任何您想要包含的其他内容。请注意,任何编译的代码必须与运行时部署的库兼容,这意味着它必须使用 Buildroot 使用的相同工具链进行编译。使用 Buildroot 工具链非常容易:只需将其添加到路径中:

$ PATH=<path_to_buildroot>/output/host/usr/bin:$PATH

工具的前缀是<ARCH>-linux-

覆盖目录由BR2_ROOTFS_OVERLAY设置,其中包含一个由空格分隔的目录列表,您应该在 Buildroot 根文件系统上覆盖它。它可以在menuconfig中配置,选项为系统配置 | 根文件系统覆盖目录

例如,如果将helloworld程序添加到bin目录,并在启动时添加一个脚本,您将创建一个包含以下内容的覆盖目录:

覆盖

然后,您将board/melp/nova/overlay添加到覆盖选项中。

根文件系统的布局由system/skeleton目录控制,权限在device_table_dev.txtdevice_table.txt中设置。

添加软件包

Buildroot 软件包存储在package目录中,有 1000 多个软件包,每个软件包都有自己的子目录。软件包至少包含两个文件:Config.in,其中包含使软件包在配置菜单中可见所需的Kconfig代码片段,以及名为<package_name>.mkmakefile。请注意,软件包不包含代码,只包含获取代码的指令,如下载 tarball、执行 git pull 等。

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_<大写软件包名称>的格式。然后是一个布尔值和软件包名称,它将出现在配置菜单中,并允许用户选择此软件包。帮助部分是可选的(但希望有用)。

接下来,通过编辑package/Config.in并在前面的部分提到的源配置文件,将新软件包链接到目标软件包菜单中。您可以将其附加到现有子菜单中,但在这种情况下,创建一个仅包含我们软件包的新子菜单似乎更整洁:

menu "My programs"
  source "package/helloworld/Config.in"
endmenu

然后,创建一个 makefile,package/helloworld/helloworld.mk,以提供 Buildroot 所需的数据:

HELLOWORLD_VERSION:= 1.0.0
HELLOWORLD_SITE:= /home/chris/MELP/helloworld/
HELLOWORLD_SITE_METHOD:=local
HELLOWORLD_INSTALL_TARGET:=YES

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)/bin
endef

$(eval $(generic-package))

代码的位置被硬编码为本地路径名。在更现实的情况下,您将从源代码系统或某种中央服务器获取代码:Buildroot 用户指南中有如何执行此操作的详细信息,其他软件包中也有大量示例。

许可合规性

Buildroot 基于开源软件,它编译的软件包也是开源的。在项目的某个阶段,您应该检查许可证,可以通过运行以下命令来执行:

$ make legal-info

信息被收集到output/legal-info中。在host-manifest.csv中有用于编译主机工具的许可证摘要,在目标中有manifest.csv。在Buildroot 用户手册README文件中有更多信息。

Yocto 项目

Yocto 项目比 Buildroot 更复杂。它不仅可以像 Buildroot 一样构建工具链、引导加载程序、内核和根文件系统,还可以为您生成整个 Linux 发行版,其中包含可以在运行时安装的二进制软件包。

Yocto 项目主要是一组类似于 Buildroot 包的配方,但是使用 Python 和 shell 脚本的组合编写,并使用名为 BitBake 的任务调度程序生成你配置的任何内容。

www.yoctoproject.org/ 有大量在线文档。

背景

Yocto 项目的结构如果你先看一下背景会更有意义。它的根源在于 OpenEmbedded,openembedded.org/,而 OpenEmbedded 又源自于一些项目,用于将 Linux 移植到各种手持计算机上,包括 Sharp Zaurus 和 Compaq iPaq。OpenEmbedded 于 2003 年诞生,作为这些手持计算机的构建系统,但很快扩展到包括其他嵌入式板。它是由一群热情的程序员开发并继续开发的。

OpenEmbedded 项目旨在使用紧凑的 .ipk 格式创建一组二进制软件包,然后可以以各种方式组合这些软件包,创建目标系统,并在运行时安装在目标上。它通过为每个软件创建配方并使用 BitBake 作为任务调度程序来实现这一点。它非常灵活。通过提供正确的元数据,你可以根据自己的规格创建整个 Linux 发行版。一个相当知名的是 The Ångström Distributionwww.angstrom-distribution.org,但还有许多其他发行版。

在 2005 年的某个时候,当时是 OpenedHand 的开发人员 Richard Purdie 创建了 OpenEmbedded 的一个分支,选择了更保守的软件包,并创建了一段时间稳定的发布。他将其命名为 Poky,以日本小吃命名(如果你担心这些事情,Poky 的发音与 hockey 押韵)。尽管 Poky 是一个分支,但 OpenEmbedded 和 Poky 仍然并行运行,共享更新,并保持体系结构大致同步。英特尔在 2008 年收购了 OpenedHand,并在 2010 年他们成立 Yocto 项目时将 Poky Linux 转移到了 Linux 基金会。

自 2010 年以来,OpenEmbedded 和 Poky 的共同组件已经合并为一个名为 OpenEmbedded core 的独立项目,或者简称 oe-core。

因此,Yocto 项目汇集了几个组件,其中最重要的是以下内容:

  • Poky:参考发行版

  • oe-core:与 OpenEmbedded 共享的核心元数据

  • BitBake:任务调度程序,与 OpenEmbedded 和其他项目共享

  • 文档:每个组件的用户手册和开发人员指南

  • Hob:OpenEmbedded 和 BitBake 的图形用户界面

  • Toaster:OpenEmbedded 和 BitBake 的基于 Web 的界面

  • ADT Eclipse:Eclipse 的插件,使使用 Yocto 项目 SDK 更容易构建项目

严格来说,Yocto 项目是这些子项目的总称。它使用 OpenEmbedded 作为其构建系统,并使用 Poky 作为其默认配置和参考环境。然而,人们经常使用术语“Yocto 项目”来指代仅构建系统。我觉得现在已经为时已晚,所以为了简洁起见,我也会这样做。我提前向 OpenEmbedded 的开发人员道歉。

Yocto 项目提供了一个稳定的基础,可以直接使用,也可以使用元层进行扩展,我将在本章后面讨论。许多 SoC 供应商以这种方式为其设备提供了板支持包。元层也可以用于创建扩展的或不同的构建系统。有些是开源的,比如 Angstrom 项目,另一些是商业的,比如 MontaVista Carrier Grade Edition、Mentor Embedded Linux 和 Wind River Linux。Yocto 项目有一个品牌和兼容性测试方案,以确保组件之间的互操作性。您会在各种网页上看到类似“Yocto 项目兼容 1.7”的声明。

因此,您应该将 Yocto 项目视为嵌入式 Linux 整个领域的基础,同时也是一个完整的构建系统。您可能会对yocto这个名字感到好奇。Yocto 是 10-24 的国际单位制前缀,就像微是 10-6 一样。为什么要给项目取名为 yocto 呢?部分原因是为了表明它可以构建非常小的 Linux 系统(尽管公平地说,其他构建系统也可以),但也可能是为了在基于 OpenEmbedded 的Ångström 发行版上取得优势。Ångström 是 10-10。与 yocto 相比,那太大了!

稳定版本和支持

通常,Yocto 项目每六个月发布一次,分别在 4 月和 10 月。它们主要以代号而闻名,但了解 Yocto 项目和 Poky 的版本号也是有用的。以下是我写作时最近的四个版本的表格:

代号 发布日期 Yocto 版本 Poky 版本
Fido 2015 年 4 月 1.8 13
Dizzy 2014 年 10 月 1.7 12
Daisy 2014 年 4 月 1.6 11
Dora 2013 年 10 月 1.5 10

稳定版本在当前发布周期和下一个周期内受到安全和关键错误修复的支持,即发布后大约 12 个月。这些更新不允许进行工具链或内核版本更改。与 Buildroot 一样,如果您希望获得持续支持,可以升级到下一个稳定版本,或者可以将更改移植到您的版本。您还可以选择从操作系统供应商(如 Mentor Graphics、Wind River 等)获得长达数年的商业支持。

安装 Yocto 项目

要获取 Yocto 项目的副本,您可以克隆存储库,选择代码名称作为分支,本例中为fido

$ git clone -b fido git://git.yoctoproject.org/poky.git

您还可以从downloads.yoctoproject.org/releases/yocto/yocto-1.8/poky-fido-13.0.0.tar.bz2下载存档。

在第一种情况下,您会在poky目录中找到所有内容,在第二种情况下,是poky-fido-13.0.0/

此外,您应该阅读《Yocto 项目参考手册》(www.yoctoproject.org/docs/current/ref-manual/ref-manual.html#detailed-supported-distros)中标题为“系统要求”的部分,并特别确保其中列出的软件包已安装在您的主机计算机上。

配置

与 Buildroot 一样,让我们从 ARM QEMU 模拟器的构建开始。首先要源化一个脚本来设置环境:

$ cd poky
$ source oe-init-build-env

这将为您创建一个名为build的工作目录,并将其设置为当前目录。所有的配置、中间和可部署文件都将放在这个目录中。每次您想要处理这个项目时,都必须源化这个脚本。

您可以通过将其作为参数添加到oe-init-build-env来选择不同的工作目录,例如:

$ source oe-init-build-env build-qemuarm

这将使您进入build-qemuarm目录。然后,您可以同时进行几个项目:通过oe-init-build-env的参数选择要使用的项目。

最初,build目录只包含一个名为conf的子目录,其中包含此项目的配置文件:

  • local.conf:包含要构建的设备和构建环境的规范。

  • bblayers.conf:包含要使用的层的目录列表。稍后将会有更多关于层的内容。

  • templateconf.cfg:包含一个包含各种conf文件的目录的名称。默认情况下,它指向meta-yocto/conf

现在,我们只需要在local.conf中将MACHINE变量设置为qemuarm,方法是删除此行开头的注释字符:

MACHINE ?= "qemuarm"

构建

要实际执行构建,需要运行bitbake,告诉它要创建哪个根文件系统镜像。一些常见的图像如下:

  • 核心图像-最小:一个小型的基于控制台的系统,对于测试和作为自定义图像的基础很有用。

  • 核心图像-最小 initramfs:类似于核心图像-最小,但构建为 ramdisk。

  • 核心图像-x11:通过 X11 服务器和 xterminal 终端应用程序支持图形的基本图像。

  • 核心图像-sato:基于 Sato 的完整图形系统,Sato 是基于 X11 和 GNOME 构建的移动图形环境。图像包括几个应用程序,包括终端、编辑器和文件管理器。

通过给 BitBake 最终目标,它将向后工作,并首先构建所有依赖项,从工具链开始。现在,我们只想创建一个最小的图像来查看它是否有效:

$ bitbake core-image-minimal

构建可能需要一些时间,可能超过一个小时。完成后,您将在构建目录中找到几个新目录,包括build/downloads,其中包含构建所需的所有源文件,以及build/tmp,其中包含大部分构建产物。您应该在tmp中看到以下内容:

  • work:包含构建目录和所有组件的分段区域,包括根文件系统

  • deploy:包含要部署到目标上的最终二进制文件:

  • deploy/images/[机器名称]:包含引导加载程序、内核和根文件系统镜像,准备在目标上运行

  • deploy/rpm:包含组成图像的 RPM 软件包

  • deploy/licenses:包含从每个软件包中提取的许可文件

运行

当构建 QEMU 目标时,将生成一个内部版本的 QEMU,从而无需安装 QEMU 软件包以避免版本依赖。有一个名为runqemu的包装脚本用于这个内部 QEMU。

要运行 QEMU 仿真,请确保已经源自oe-init-build-env,然后只需键入:

$ runqemu qemuarm

在这种情况下,QEMU 已配置为具有图形控制台,因此启动消息和登录提示将显示在黑色帧缓冲屏幕上:

运行中

您可以以root身份登录,无需密码。您可以通过关闭帧缓冲窗口关闭 QEMU。您可以通过在命令行中添加nographic来启动不带图形窗口的 QEMU:

$ runqemu qemuarm nographic

在这种情况下,使用键序Ctrl + A + X关闭 QEMU。

runqemu脚本有许多其他选项,键入runqemu help以获取更多信息。

Yocto 项目的元数据按层结构化,按照惯例,每个层的名称都以meta开头。Yocto 项目的核心层如下:

  • 元:这是 OpenEmbedded 核心

  • meta-yocto:特定于 Yocto 项目的元数据,包括 Poky 发行版

  • meta-yocto-bsp:包含 Yocto 项目支持的参考机器的板支持软件包

BitBake 搜索配方的层列表存储在<your build directory>/conf/bblayers.conf中,并且默认情况下包括前面列表中提到的所有三个层。

通过以这种方式构建配方和其他配置数据,很容易通过添加新的层来扩展 Yocto 项目。额外的层可以从 SoC 制造商、Yocto 项目本身以及希望为 Yocto 项目和 OpenEmbedded 增加价值的广泛人员那里获得。在layers.openembedded.org上有一个有用的层列表。以下是一些示例:

  • meta-angstrom:Ångström 发行版

  • meta-qt5:Qt5 库和实用程序

  • meta-fsl-arm:Freescale 基于 ARM 的 SoC 的 BSP

  • meta-fsl-ppc:Freescale 基于 PowerPC 的 SoC 的 BSP

  • meta-intel:Intel CPU 和 SoC 的 BSP

  • meta-ti:TI 基于 ARM 的 SoC 的 BSP

添加一个层就像将 meta 目录复制到合适的位置一样简单,通常是在默认的 meta 层旁边,并将其添加到bblayers.conf中。只需确保它与您正在使用的 Yocto 项目版本兼容即可。

为了说明层的工作原理,让我们为我们的 Nova 板创建一个层,我们可以在本章的其余部分中使用它来添加功能。每个元层必须至少有一个配置文件conf/layer.conf,还应该有一个README文件和一个许可证。有一个方便的辅助脚本可以为我们完成基本工作:

$ cd poky
$ scripts/yocto-layer create nova

脚本会要求设置优先级,以及是否要创建示例配方。在这个示例中,我只接受了默认值:

Please enter the layer priority you'd like to use for the layer: [default: 6]
Would you like to have an example recipe created? (y/n) [default: n]
Would you like to have an example bbappend file created? (y/n) [default: n]
New layer created in meta-nova.
Don't forget to add it to your BBLAYERS (for details see meta-nova\README).

这将创建一个名为meta-nova的层,其中包含conf/layer.conf、概要READMECOPYING.MIT中的 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"

它将自己添加到BBPATH,并将其包含的配方添加到BBFILES。通过查看代码,您可以看到配方位于以recipes-开头的目录中,并且文件名以.bb结尾(用于普通 BitBake 配方),或以.bbappend结尾(用于通过添加和覆盖指令扩展现有普通配方的配方)。此层的名称为nova,它被添加到BBFILE_COLLECTIONS中的层列表中,并且具有优先级6。如果相同的配方出现在几个层中,则具有最高优先级的层中的配方获胜。

由于您即将构建一个新的配置,最好从创建一个名为build-nova的新构建目录开始:

$ cd ~/poky
$ . oe-init-build-env build-nova

现在,您需要将此层添加到您的构建配置中,conf/bblayers.conf

LCONF_VERSION = "6"

BBPATH = "${TOPDIR}"
BBFILES ?= ""

BBLAYERS ?= " \
  /home/chris/poky/meta \
  /home/chris/poky/meta-yocto \
  /home/chris/poky/meta-yocto-bsp \
 /home/chris/poky/meta-nova \
  "
BBLAYERS_NON_REMOVABLE ?= " \
  /home/chris/poky/meta \
  /home/chris/poky/meta-yocto \"

您可以使用另一个辅助脚本确认它是否设置正确:

$ bitbake-layers show-layers
layer                 path                     priority
==========================================================
meta              /home/chris/poky/meta            5
meta-yocto        /home/chris/poky/meta-yocto      5
meta-yocto-bsp    /home/chris/poky/meta-yocto-bsp  5
meta-nova         /home/chris/poky/meta-nova       6

在那里,您可以看到新的层。它的优先级为6,这意味着我们可以覆盖具有较低优先级的其他层中的配方。

此时运行一个构建,使用这个空层是一个好主意。最终目标将是 Nova 板,但是现在,通过在conf/local.conf中的MACHINE ?= "beaglebone"之前去掉注释,为 BeagelBone Black 构建一个小型镜像。然后,使用bitbake core-image-minimal构建一个小型镜像。

除了配方,层还可以包含 BitBake 类、机器的配置文件、发行版等。接下来我将看一下配方,并向您展示如何创建自定义镜像以及如何创建软件包。

BitBake 和配方

BitBake 处理几种不同类型的元数据,包括以下内容:

  • recipes:以.bb结尾的文件。这些文件包含有关构建软件单元的信息,包括如何获取源代码副本、对其他组件的依赖关系以及如何构建和安装它。

  • append:以.bbappend结尾的文件。这些文件允许覆盖或扩展配方的一些细节。A.bbappend文件只是将其指令附加到具有相同根名称的配方(.bb)文件的末尾。

  • 包括:以.inc结尾的文件。这些文件包含多个食谱共有的信息,允许信息在它们之间共享。可以使用includerequire关键字来包含这些文件。不同之处在于,如果文件不存在,require会产生错误,而include不会。

  • :以.bbclass结尾的文件。这些文件包含常见的构建信息,例如如何构建内核或如何构建autotools项目。这些类在食谱和其他类中使用inherit关键字进行继承和扩展。classes/base.bbclass类在每个食谱中都会被隐式继承。

  • 配置:以.conf结尾的文件。它们定义了管理项目构建过程的各种配置变量。

食谱是一组以 Python 和 shell 代码的组合编写的任务。任务的名称如do_fetchdo_unpackdo_patchdo_configuredo_compiledo_install等。您可以使用 BitBake 来执行这些任务。

默认任务是do_build,因此您正在运行该食谱的构建任务。您可以通过像这样运行bitbake core-image-minimal来列出食谱中可用的任务:

$ bitbake -c listtasks core-image-minimal

-c选项允许您指定任务,省略do_部分。一个常见的用法是-c fetch来获取一个食谱所需的代码:

$ bitbake -c fetch busybox

您还可以使用fetchall来获取目标代码和所有依赖项的代码:

$ bitbake -c fetchall core-image-minimal

食谱文件通常被命名为<package-name>_version.bb。它们可能依赖于其他食谱,这将允许 BitBake 计算出需要执行的所有子任务,以完成顶层作业。不幸的是,我在这本书中没有空间来描述依赖机制,但您将在 Yocto Project 文档中找到完整的描述。

例如,要在meta-nova中为我们的helloworld程序创建一个食谱,您可以创建以下目录结构:

meta-nova/recipes-local/helloworld
├── files
│   └── helloworld.c
└── helloworld_1.0.bb

食谱是helloworld_1.0.bb,源代码是食谱目录中子目录文件的本地文件。食谱包含这些说明:

DESCRIPTION = "A friendly program that prints Hello World!"
PRIORITY = "optional"
SECTION = "examples"

LICENSE = "GPLv2"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/GPL-2.0;md5=801f80980d171dd6425610833a22dbe6"

SRC_URI = "file://helloworld.c"
S = "${WORKDIR}"

do_compile() {
  ${CC} ${CFLAGS} -o helloworld helloworld.c
}

do_install() {
  install -d ${D}${bindir}
  install -m 0755 helloworld ${D}${bindir}
}

源代码的位置由SRC_URI设置:在这种情况下,它将在食谱目录中搜索目录、文件、helloworldhelloworld-1.0。唯一需要定义的任务是do_compiledo_install,它们简单地编译一个源文件并将其安装到目标根文件系统中:${D}扩展到目标设备的分段区域,${bindir}扩展到默认的二进制目录/usr/bin

每个食谱都有一个许可证,由LICENSE定义,这里设置为GPLv2。包含许可证文本和校验和的文件由LIC_FILES_CHKSUM定义。如果校验和不匹配,BitBake 将终止构建,表示许可证以某种方式发生了变化。许可证文件可能是软件包的一部分,也可能指向meta/files/common-licenses中的标准许可证文本之一,就像这里一样。

默认情况下,商业许可证是不允许的,但很容易启用它们。您需要在食谱中指定许可证,如下所示:

LICENSE_FLAGS = "commercial"

然后,在您的conf/local.conf中,您可以明确允许此许可证,如下所示:

LICENSE_FLAGS_WHITELIST = "commercial"

为了确保它编译正确,您可以要求 BitBake 构建它,如下所示:

$ bitbake  helloworld

如果一切顺利,您应该看到它已经在tmp/work/cortexa8hf-vfp-neon-poky-linux-gnueabi/helloworld/中为其创建了一个工作目录。

您还应该看到tmp/deploy/rpm/cortexa8hf_vfp_neon/helloworld-1.0-r0.cortexa8hf_vfp_neon.rpm中有一个 RPM 软件包。

尽管如此,它还不是目标镜像的一部分。要安装的软件包列表保存在名为IMAGE_INSTALL的变量中。您可以通过将此行添加到您的conf/local.conf中的列表末尾来追加到该列表:

IMAGE_INSTALL_append = " helloworld"

请注意,第一个双引号和第一个软件包名称之间必须有一个空格。现在,该软件包将被添加到您 bitbake 的任何镜像中:

$ bitbake core-image-minimal

如果您查看tmp/deploy/images/beaglebone/core-image-minimal-beaglebone.tar.bz2,您将看到确实已安装/usr/bin/helloworld

通过 local.conf 自定义图像

您可能经常希望在开发过程中向图像添加软件包或以其他方式进行微调。如前所示,您可以通过添加类似以下语句来简单地追加要安装的软件包列表:

IMAGE_INSTALL_append = " strace helloworld"

毫无疑问,您也可以做相反的事情:可以使用以下语法删除软件包:

IMAGE_INSTALL_remove = "someapp"

您可以通过EXTRA_IMAGE_FEATURES进行更广泛的更改。这里列不完,我建议您查看Yocto Project 参考手册图像功能部分和meta/classes/core-image.bbclass中的代码。以下是一个简短的列表,应该可以让您了解可以启用的功能:

  • dbg-pkgs:为图像中安装的所有软件包安装调试符号包。

  • debug-tweaks:允许无密码进行 root 登录和其他使开发更容易的更改。

  • package-management:安装软件包管理工具并保留软件包管理器数据库。

  • read-only-rootfs:使根文件系统只读。我们将在第七章中详细介绍这一点,创建存储策略

  • x11:安装 X 服务器。

  • x11-base:安装带有最小环境的 X 服务器。

  • x11-sato:安装 OpenedHand Sato 环境。

编写图像配方

local.conf进行更改的问题在于它们是本地的。如果您想创建一个要与其他开发人员共享或加载到生产系统的图像,那么您应该将更改放入图像配方中。

图像配方包含有关如何为目标创建图像文件的指令,包括引导加载程序、内核和根文件系统映像。您可以使用此命令获取可用图像的列表:

$ ls meta*/recipes*/images/*.bb

core-image-minimal的配方位于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 += "helloworld strace"

现在,您可以从local.conf中删除IMAGE_INSTALL_append行,并使用以下命令构建它:

$ bitbake nova-image

如果您想进一步控制根文件系统的内容,可以从空的IMAGE_INSTALL变量开始,并像这样填充它:

SUMMARY = "A small image with helloworld and strace packages" IMAGE_INSTALL = "packagegroup-core-boot helloworld strace"
IMAGE_LINGUAS = " "
LICENSE = "MIT"
IMAGE_ROOTFS_SIZE ?= "8192"
inherit core-image

IMAGE_LINGUAS包含要在目标图像中安装的glibc区域设置的列表。它们可能占用大量空间,因此在这种情况下,我们将列表设置为空,只要我们不需要区域设置相关的库函数就可以了。IMAGE_ROOTFS_SIZE是生成的磁盘映像的大小,以 KiB 为单位。大部分工作由我们在最后继承的core-image类完成。

创建 SDK

能够创建一个其他开发人员可以安装的独立工具链非常有用,避免了团队中每个人都需要完整安装 Yocto Project 的需求。理想情况下,您希望工具链包括目标上安装的所有库的开发库和头文件。您可以使用populate_sdk任务为任何图像执行此操作,如下所示:

$ bitbake nova-image -c populate_sdk

结果是一个名为tmp/deploy/sdk中的自安装 shell 脚本:

poky-<c_library>-<host_machine>-<target_image><target_machine>-toolchain-<version>.sh

这是一个例子:

poky-glibc-x86_64-nova-image-cortexa8hf-vfp-neon-toolchain-1.8.1.sh

请注意,默认情况下,工具链不包括静态库。您可以通过向local.conf或图像配方添加类似以下行来单独启用它们:

TOOLCHAIN_TARGET_TASK_append = " glibc-staticdev"

您也可以像下面这样全局启用它们:

SDKIMAGE_FEATURES_append = " staticdev-pkgs"

如果您只想要一个基本的工具链,只需 C 和 C++交叉编译器,C 库和头文件,您可以运行:

$ bitbake meta-toolchain

要安装 SDK,只需运行 shell 脚本。默认安装目录是/opt/poky,但安装脚本允许您更改:

$ tmp/deploy/sdk/poky-glibc-x86_64-nova-image-cortexa8hf-vfp-neon-toolchain-1.8.1.sh

Enter target directory for SDK (default: /opt/poky/1.8.1):

You are about to install the SDK to "/opt/poky/1.8.1". Proceed[Y/n]?

[sudo] password for chris:

Extracting SDK...done

Setting it up...done

SDK has been successfully set up and is ready to be used.

要使用工具链,首先要源环境设置脚本:

. /opt/poky/1.8.1/environment-setup-cortexa8hf-vfp-neon-poky-linux-gnueabi

以这种方式生成的工具链未配置有效的sysroot

$ arm-poky-linux-gnueabi-gcc -print-sysroot

/not/exist

因此,如果您尝试像我在之前的章节中所示的那样进行交叉编译,它将失败,如下所示:

$ arm-poky-linux-gnueabi-gcc helloworld.c -o helloworld

helloworld.c:1:19: fatal error: stdio.h: No such file or directory

#include <stdio.h>

 ^

compilation terminated.

这是因为编译器已配置为通用于广泛范围的 ARM 处理器,当您使用正确的一组gcc标志启动它时,微调就完成了。只要使用$CC进行编译,一切都应该正常工作:

$ $CC helloworld.c -o helloworld

许可审计

Yocto Project 要求每个软件包都有许可证。每个软件包构建时,许可证的副本位于tmp/deploy/licenses/[packagenam.e]中。此外,图像中使用的软件包和许可证的摘要位于<image name>-<machine name>-<date stamp>目录中。如下所示:

$ ls tmp/deploy/licenses/nova-image-beaglebone-20151104150124
license.manifest  package.manifest

第一个文件列出了每个软件包使用的许可证,第二个文件只列出了软件包名称。

进一步阅读

您可以查看以下文档以获取更多信息:

  • 《Buildroot 用户手册》,buildroot.org/downloads/manual/manual.html

  • Yocto Project 文档:有九个参考指南,还有一个由其他指南组合而成的第十个(所谓的“Mega-manual”),网址为www.yoctoproject.org/documentation

  • 《即时 Buildroot》,作者 Daniel Manchón Vizuete,Packt Publishing,2013

  • 《使用 Yocto Project 进行嵌入式 Linux 开发》,作者 Otavio Salvador 和 Daianne Angolini,Packt Publishing,2014

摘要

使用构建系统可以减轻创建嵌入式 Linux 系统的工作量,通常比手工打造自己的系统要好得多。如今有一系列开源构建系统可用:Buildroot 和 Yocto Project 代表了两种不同的方法。Buildroot 简单快速,适用于相当简单的单用途设备:我喜欢称之为传统嵌入式 Linux。

Yocto Project 更加复杂和灵活。它是基于软件包的,这意味着您可以选择安装软件包管理器,并在现场对单个软件包进行更新。元层结构使得扩展元数据变得容易,社区和行业对 Yocto Project 的支持非常好。缺点是学习曲线非常陡峭:您应该期望需要几个月的时间才能熟练掌握它,即使那样,它有时也会做出您意想不到的事情,至少这是我的经验。

不要忘记,使用这些工具创建的任何设备都需要在现场维护一段时间,通常是多年。Yocto Project 将在发布后约一年提供点发布,Buildroot 通常不提供任何点发布。在任何情况下,您都会发现自己必须自行维护您的发布,否则需要支付商业支持费用。第三种可能性,忽视这个问题,不应被视为一个选择!

在下一章中,我将讨论文件存储和文件系统,以及您在那里做出的选择将如何影响嵌入式 Linux 的稳定性和可维护性。

第七章:创建存储策略

嵌入式设备的大容量存储选项对系统的其余部分在稳健性、速度和现场更新方法方面产生了巨大影响。

大多数设备以某种形式使用闪存存储器。随着存储容量从几十兆字节增加到几十吉字节,闪存存储器在过去几年中变得更加廉价。

在本章中,我将详细介绍闪存存储器背后的技术,以及不同的存储器组织如何影响必须管理它的低级驱动程序软件,包括 Linux 内存技术设备层 MTD。

对于每种闪存技术,都有不同的文件系统选择。我将描述在嵌入式设备上最常见的文件系统,并在一节中总结每种闪存类型的选择。

最后几节考虑了利用闪存存储器的最佳技术,研究了如何在现场更新设备,并将所有内容整合成一种连贯的存储策略。

存储选项

嵌入式设备需要存储器,它需要耗电少、物理上紧凑、稳固,并且在长达数十年的寿命内可靠。在几乎所有情况下,这意味着固态存储器,它在许多年前就已经引入了只读存储器(ROM),但在过去 20 年中一直是各种闪存存储器。在这段时间里,闪存存储器经历了几代,从 NOR 到 NAND 再到 eMMC 等托管闪存。

NOR 闪存价格昂贵,但可靠,并且可以映射到 CPU 地址空间,这使得可以直接从闪存中执行代码。NOR 闪存芯片容量较低,从几兆字节到大约一吉字节不等。

NAND 闪存存储器比 NOR 便宜得多,容量更大,范围从几十兆字节到几十吉字节。然而,它需要大量的硬件和软件支持,才能将其转化为有用的存储介质。

托管闪存存储器由一个或多个 NAND 闪存芯片与控制器组成,控制器处理闪存存储器的复杂性,并提供类似硬盘的硬件接口。吸引人的地方在于它可以减少驱动程序软件的复杂性,并使系统设计人员免受闪存技术的频繁变化的影响。SD 卡、eMMC 芯片和 USB 闪存驱动器属于这一类。几乎所有当前的智能手机和平板电脑都使用 eMMC 存储,这一趋势可能会在其他类别的嵌入式设备中继续发展。

在嵌入式系统中很少使用硬盘驱动器。一个例外是机顶盒和智能电视中的数字视频录制,这需要大量的存储空间和快速的写入时间。

在所有情况下,稳健性是最重要的:您希望设备在断电和意外重置的情况下能够引导并达到功能状态。您应该选择在这种情况下表现良好的文件系统。

NOR 闪存

NOR 闪存芯片中的存储单元被排列成擦除块,例如 128 KiB。擦除块会将所有位设置为 1。它可以一次编程一个字(8、16 或 32 位,取决于数据总线宽度)。每次擦除循环都会轻微损坏存储单元,经过多次循环后,擦除块变得不可靠,无法再使用。芯片的最大擦除循环次数应该在数据表中给出,但通常在 10 万到 100 万次之间。

数据可以逐字读取。芯片通常被映射到 CPU 地址空间中,这意味着可以直接从 NOR 闪存中执行代码。这使得它成为放置引导加载程序代码的便利位置,因为它不需要除了硬连地址映射之外的任何初始化。支持 NOR 闪存的 SoC 具有配置,可以给出默认的内存映射,使其包含 CPU 的复位向量。

内核,甚至根文件系统,也可以位于闪存中,避免将它们复制到 RAM 中,从而创建具有小内存占用的设备。这种技术称为原地执行,或XIP。这是非常专业的,我在这里不会进一步讨论。本章末尾有一些参考资料。

NOR 闪存芯片有一个称为通用闪存接口CFI的标准寄存器级接口,所有现代芯片都支持。

NAND 闪存

NAND 闪存比 NOR 闪存便宜得多,并且容量更大。第一代 NAND 芯片以每个存储单元存储一个位,即现在所称的SLC单级单元组织。后来的几代转向每个存储单元存储两位,即多级单元MLC)芯片,现在转向每个存储单元存储三位,即三级单元TLC)芯片。随着每个存储单元的位数增加,存储的可靠性降低,需要更复杂的控制器硬件和软件来进行补偿。

与 NOR 闪存一样,NAND 闪存被组织成擦除块,大小从 16 KiB 到 512 KiB 不等,再次擦除块会将所有位设置为 1。然而,块变得不可靠之前的擦除循环次数较低,对于 TLC 芯片通常只有 1K 次,而对于 SLC 则高达 100K 次。NAND 闪存只能以页面的形式读取和写入,通常为 2 或 4 KiB。由于它们无法逐字节访问,因此无法映射到地址空间,因此代码和数据必须在访问之前复制到 RAM 中。

与芯片之间的数据传输容易发生位翻转,可以使用纠错码进行检测和纠正。SLC 芯片通常使用简单的海明码,可以在软件中高效实现,并可以纠正页面读取中的单个位错误。MLC 和 TLC 芯片需要更复杂的编码,例如BCHBose-Chaudhuri-Hocquenghem),可以纠正每页高达 8 位的错误。这些需要硬件支持。

纠错码必须存储在某个地方,因此每页都有一个额外的内存区域,称为带外OOB)区域,也称为备用区域。MLC 设计通常每 32 个字节的主存储空间有 1 个字节的 OOB,因此对于 2 KiB 页面设备,每页的 OOB 为 64 字节,对于 4 KiB 页面,则为 128 字节。MLC 和 TLC 芯片具有比例更大的 OOB 区域,以容纳更复杂的纠错码。下图显示了具有 128 KiB 擦除块和 2 KiB 页面的芯片的组织结构:

NAND 闪存

在生产过程中,制造商测试所有块,并标记任何失败的块,通过在每个块中的每个页面的 OOB 区域设置标志来实现。发现全新芯片以这种方式标记为坏的块高达 2%并不罕见。此外,在擦除循环限制达到之前,类似比例的块出现擦除错误是在规范内的。NAND 闪存驱动程序应该检测到这一点,并将其标记为坏块。

在 OOB 区域为坏块标志和 ECC 字节留出空间后,仍然有一些字节剩下。一些闪存文件系统利用这些空闲字节来存储文件系统元数据。因此,许多人对 OOB 区域的布局感兴趣:SoC ROM 引导代码、引导加载程序、内核 MTD 驱动程序、文件系统代码以及创建文件系统映像的工具。标准化程度不高,因此很容易出现这样的情况:引导加载程序使用无法被内核 MTD 驱动程序读取的 OOB 格式写入数据。您需要确保它们都达成一致。

访问 NAND 闪存芯片需要一个 NAND 闪存控制器,通常是 SoC 的一部分。您需要引导加载程序和内核中相应的驱动程序。NAND 闪存控制器处理与芯片的硬件接口,传输数据到和从页面,并可能包括用于纠错的硬件。

NAND 闪存芯片有一个称为开放 NAND 闪存接口ONFi)的标准寄存器级接口,大多数现代芯片都遵循这一标准。请参阅www.onfi.org

管理闪存

在操作系统中支持闪存存储的负担,尤其是 NAND 存储器,如果有一个明确定义的硬件接口和一个隐藏存储器复杂性的标准闪存控制器,那么负担就会减轻。这就是管理闪存存储器,它变得越来越普遍。实质上,它意味着将一个或多个闪存芯片与一个微控制器结合起来,提供一个与传统文件系统兼容的小扇区大小的理想存储设备。嵌入式系统中最重要的管理闪存类型是安全数字SD)卡和嵌入式变体称为eMMC

多媒体卡和安全数字卡

多媒体卡MMC)于 1997 年由 SanDisk 和西门子推出,作为一种使用闪存存储的封装形式。不久之后,1999 年,SanDisk、松下和东芝创建了基于 MMC 的 SD 卡,增加了加密和数字版权管理(即安全部分)。两者都是为数码相机、音乐播放器和类似设备而设计的消费类电子产品。目前,SD 卡是消费类和嵌入式电子产品中主要的管理闪存形式,尽管加密功能很少被使用。SD 规范的更新版本允许更小的封装(mini SD 和 micro SD,通常写作 uSD)和更大的容量:高容量 SDHC,最高达 32GB,扩展容量 SDXC,最高达 2TB。

MMC 和 SD 卡的硬件接口非常相似,可以在全尺寸 SD 卡槽中使用全尺寸 MMC(但反之则不行)。早期版本使用 1 位串行外围接口SPI);更近期的卡使用 4 位接口。有一个用于读写 512 字节扇区内存的命令集。在封装内部有一个微控制器和一个或多个 NAND 闪存芯片,如下图所示:

多媒体卡和安全数字卡

微控制器实现命令集并管理闪存,执行闪存转换层的功能,如本章后面所述。它们预先格式化为 FAT 文件系统:SDSC 卡上为 FAT16,SDHC 上为 FAT32,SDXC 上为 exFAT。NAND 闪存芯片的质量和微控制器上的软件在卡片之间差异很大。有人质疑它们是否足够可靠,尤其是对于容易发生文件损坏的 FAT 文件系统。请记住,MMC 和 SD 卡的主要用途是相机、平板电脑和手机上的可移动存储。

eMMC

eMMC嵌入式 MMC只是 MMC 存储器的封装,可以焊接到主板上,使用 4 位或 8 位接口进行数据传输。但是,它们旨在用作操作系统的存储,因此组件能够执行该任务。芯片通常没有预先格式化任何文件系统。

其他类型的管理闪存

最早的管理闪存技术之一是CompactFlashCF),使用个人计算机存储卡国际协会PCMCIA)接口的子集。CF 通过并行 ATA 接口公开存储器,并在操作系统中显示为标准硬盘。它们在基于 x86 的单板计算机和专业视频和摄像设备中很常见。

我们每天使用的另一种格式是 USB 闪存驱动器。在这种情况下,通过 USB 接口访问内存,并且控制器实现 USB 大容量存储规范以及闪存转换层和与闪存芯片的接口。USB 大容量存储协议又基于 SCSI 磁盘命令集。与 MMC 和 SD 卡一样,它们通常预先格式化为 FAT 文件系统。它们在嵌入式系统中的主要用途是与个人电脑交换数据。

注意

对于受管理的闪存存储的选项列表的最新添加是通用闪存存储UFS)。与 eMMC 一样,它被封装在安装在主板上的芯片中。它具有高速串行接口,可以实现比 eMMC 更高的数据速率。它支持 SCSI 磁盘命令集。

从引导加载程序访问闪存

在第三章中,关于引导加载程序的一切,我提到了引导加载程序需要从各种闪存设备加载内核二进制文件和其他映像,并且能够执行系统维护任务,如擦除和重新编程闪存。因此,引导加载程序必须具有支持您拥有的内存类型的读取、擦除和写入操作的驱动程序和基础设施,无论是 NOR、NAND 还是受管理的内存。我将在以下示例中使用 U-Boot;其他引导加载程序遵循类似的模式。

U-Boot 和 NOR 闪存

U-Boot 在drivers/mtd中具有 NOR CFI 芯片的驱动程序,并具有erase命令来擦除内存和cp.b命令来逐字节复制数据,编程闪存。假设您有从 0x40000000 到 0x48000000 映射的 NOR 闪存,其中从 0x40040000 开始的 4MiB 是内核映像,那么您将使用这些 U-Boot 命令将新内核加载到闪存中:

U-Boot# tftpboot 100000 uImage
U-Boot# erase 40040000 403fffff
U-Boot# cp.b 100000 40040000 $(filesize)

前面示例中的变量filesize是由tftpboot命令设置为刚刚下载的文件的大小。

U-Boot 和 NAND 闪存

对于 NAND 闪存,您需要一个针对 SoC 上的 NAND 闪存控制器的驱动程序,您可以在drivers/mtd/nand中找到。您可以使用nand命令来使用子命令erasewriteread来管理内存。此示例显示内核映像被加载到 RAM 的 0x82000000 处,然后从偏移 0x280000 开始放入闪存:

U-Boot# tftpboot 82000000 uImage
U-Boot# nand erase 280000 400000
U-Boot# nand write 82000000 280000 $(filesize)

U-Boot 还可以读取存储在 JFFS2、YAFFS2 和 UBIFS 文件系统中的文件。

U-Boot 和 MMC、SD 和 eMMC

U-Boot 在drivers/mmc中具有几个 MMC 控制器的驱动程序。您可以在用户界面级别使用mmc readmmc write来访问原始数据,这允许您处理原始内核和文件系统映像。

U-Boot 还可以从 MMC 存储器上的 FAT32 和 ext4 文件系统中读取文件。

从 Linux 访问闪存内存

原始 NOR 和 NAND 闪存由内存技术设备子系统(MTD)处理,该子系统提供了读取、擦除和写入闪存块的基本接口。对于 NAND 闪存,有处理 OOB 区域和识别坏块的功能。

对于受管理的闪存,您需要驱动程序来处理特定的硬件接口。MMC/SD 卡和 eMMC 使用 mmcblk 驱动程序;CompactFlash 和硬盘使用 SCSI 磁盘驱动程序 sd。USB 闪存驱动器使用 usb_storage 驱动程序以及 sd 驱动程序。

内存技术设备

内存技术 设备MTD)子系统由 David Woodhouse 于 1999 年创建,并在随后的几年中得到了广泛的发展。在本节中,我将集中讨论它处理的两种主要技术,NOR 和 NAND 闪存。

MTD 由三层组成:一组核心功能、一组各种类型芯片的驱动程序以及将闪存内存呈现为字符设备或块设备的用户级驱动程序,如下图所示:

内存技术设备

芯片驱动程序位于最低级别,并与闪存芯片进行接口。对于 NOR 闪存芯片,只需要少量驱动程序,足以覆盖 CFI 标准和变体,以及一些现在大多已经过时的不符合标准的芯片。对于 NAND 闪存,您将需要一个用于所使用的 NAND 闪存控制器的驱动程序;这通常作为板支持包的一部分提供。在当前主线内核中的drivers/mtd/nand目录中有大约 40 个这样的驱动程序。

MTD 分区

在大多数情况下,您会希望将闪存内存分成多个区域,例如为引导加载程序、内核映像或根文件系统提供空间。在 MTD 中,有几种指定分区大小和位置的方法,主要包括:

  • 通过内核命令行使用CONFIG_MTD_CMDLINE_PARTS

  • 通过设备树使用CONFIG_MTD_OF_PARTS

  • 使用平台映射驱动程序

在第一种选项的情况下,要使用的内核命令行选项是mtdparts,在 Linux 源代码中在drivers/mtd/cmdlinepart.c中定义如下:

mtdparts=<mtddef>[;<mtddef]
<mtddef>  := <mtd-id>:<partdef>[,<partdef>]
<mtd-id>  := unique name for the chip
<partdef> := <size>[@<offset>][<name>][ro][lk]
<size>    := size of partition OR "-" to denote all remaining
             space
<offset>  := offset to the start of the partition; leave blank
             to follow the previous partition without any gap
<name>    := '(' NAME ')'

也许一个例子会有所帮助。假设您有一个 128MB 的闪存芯片,要分成五个分区。一个典型的命令行将是:

mtdparts=:512k(SPL)ro,780k(U-Boot)ro,128k(U-BootEnv),
4m(Kernel),-(Filesystem)

冒号:之前的第一个元素是mtd-id,它通过编号或者由板支持包分配的名称来标识闪存芯片。如果只有一个芯片,可以留空。如果有多个芯片,每个芯片的信息用分号分隔。然后,对于每个芯片,有一个逗号分隔的分区列表,每个分区都有以字节、千字节k或兆字节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
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

等效的分区信息可以在设备树的一部分中编写,如下所示:

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 在其他地方定义为 128K):

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,
  },
};

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中:

IOCTL 描述
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.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 设备有用的唯一情况是在可靠的闪存内存(如 NOR)上挂载只读文件系统,例如 Squashfs。

提示

如果要在 NAND 闪存上使用只读文件系统,应该使用 UBI 驱动程序,如本章后面所述。

将内核 oops 记录到 MTD

内核错误,或者 oopsies,通常通过 klogdsyslogd 守护进程记录到循环内存缓冲区或文件中。重启后,如果是环形缓冲区,日志将会丢失,即使是文件,系统崩溃前可能也没有正确写入。

提示

更可靠的方法是将 oops 和内核恐慌写入 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 或类似的实用程序。

闪存内存的文件系统

在有效利用闪存内存进行大容量存储时存在几个挑战:擦除块和磁盘扇区大小不匹配,每个擦除块的擦除周期有限,以及 NAND 芯片上需要坏块处理。这些差异通过全局闪存转换层FTL来解决。

闪存转换层

闪存转换层具有以下特点:

  • 子分配:文件系统最适合使用小的分配单元,传统上是 512 字节扇区。这比 128 KiB 或更大的闪存擦除块要小得多。因此,必须将擦除块细分为更小的单元,以避免浪费大量空间。

  • 垃圾收集:子分配的一个结果是,文件系统在使用一段时间后,擦除块将包含好数据和陈旧数据的混合。由于我们只能释放整个擦除块,因此重新获取空闲空间的唯一方法是将好数据合并到一个位置并将现在空的擦除块返回到空闲列表中:这就是垃圾收集,通常作为后台线程实现。

  • 磨损平衡:每个块的擦除周期都有限制。为了最大限度地延长芯片的寿命,重要的是移动数据,使每个块大致相同次数地擦除。

  • 坏块处理:在 NAND 闪存芯片上,您必须避免使用任何标记为坏的块,并且如果无法擦除,则将好块标记为坏。

  • 稳健性:嵌入式设备可能会突然断电或重置,因此任何文件系统都应该能够在没有损坏的情况下应对,通常是通过包含事务日志或日志来实现。

部署闪存转换层有几种方法:

  • 在文件系统中:与 JFFS2、YAFFS2 和 UBIFS 一样

  • 在块设备驱动程序中:UBI 驱动程序实现了闪存转换层的一些方面,UBIFS 依赖于它

  • 在设备控制器中:与托管闪存设备一样

当闪存转换层位于文件系统或块驱动程序中时,代码是内核的一部分,因此是开源的,这意味着我们可以看到它的工作方式,并且我们可以期望它会随着时间的推移而得到改进。另一方面,FTL 位于托管闪存设备中;它被隐藏起来,我们无法验证它是否按照我们的期望工作。不仅如此,将 FTL 放入磁盘控制器意味着它错过了文件系统层保存的信息,比如哪些扇区属于已删除且不再包含有用数据的文件。后一个问题通过在文件系统和设备之间添加传递此信息的命令来解决,我将在后面的TRIM命令部分中描述,但代码可见性的问题仍然存在。如果您使用托管闪存,您只需选择一个您可以信任的制造商。

NOR 和 NAND 闪存内的文件系统

要将原始闪存芯片用于大容量存储,您必须使用了解底层技术特性的文件系统。有三种这样的文件系统:

  • 日志闪存文件系统 2,JFFS2:这是 Linux 的第一个闪存文件系统,至今仍在使用。它适用于 NOR 和 NAND 存储器,但在挂载时速度慢。

  • 另一种闪存文件系统 2,YAFFS2:这类似于 JFFS2,但专门用于 NAND 闪存。它被 Google 采用为 Android 设备上首选的原始闪存文件系统。

  • 未排序块映像文件系统,UBIFS: 这是最新的适用于 NOR 和 NAND 存储器的闪存感知文件系统,它与 UBI 块驱动程序一起使用。它通常比 JFFS2 或 YAFFS2 提供更好的性能,因此应该是新设计的首选解决方案。

所有这些都使用 MTD 作为闪存内存的通用接口。

JFFS2

日志闪存文件系统始于 1999 年 Axis 2100 网络摄像机的软件。多年来,它是 Linux 上唯一的闪存文件系统,并已部署在成千上万种不同类型的设备上。今天,它并不是最佳选择,但我会首先介绍它,因为它展示了进化路径的开始。

JFFS2 是一种使用 MTD 访问闪存内存的日志结构文件系统。在日志结构文件系统中,更改被顺序写入闪存内存作为节点。一个节点可能包含对目录的更改,例如创建和删除的文件名,或者它可能包含对文件数据的更改。一段时间后,一个节点可能被后续节点中包含的信息取代,并成为过时的节点。

擦除块分为三种类型:

  • 空闲: 它根本不包含任何节点

  • 干净: 它只包含有效节点

  • : 它至少包含一个过时的节点

在任何时候,都有一个正在接收更新的块,称为打开块。如果断电或系统重置,唯一可能丢失的数据就是对打开块的最后一次写入。此外,节点在写入时会被压缩,增加了闪存芯片的有效存储容量,这对于使用昂贵的 NOR 闪存存储器非常重要。

当空闲块的数量低于阈值时,将启动一个垃圾收集器内核线程,扫描脏块并将有效节点复制到打开块,然后释放脏块。

同时,垃圾收集器提供了一种粗糙的磨损平衡,因为它将有效数据从一个块循环到另一个块。选择打开块的方式意味着只要它包含不时更改的数据,每个块被擦除的次数大致相同。有时会选择一个干净的块进行垃圾收集,以确保包含很少写入的静态数据的块也得到磨损平衡。

JFFS2 文件系统具有写穿缓存,这意味着写入的数据会同步写入闪存内存,就好像已经使用-o sync选项挂载一样。虽然提高了可靠性,但会增加写入数据的时间。小写入还存在另一个问题:如果写入的长度与节点头部的大小(40 字节)相当,开销就会很高。一个众所周知的特例是由 syslogd 产生的日志文件。

摘要节点

JFFS2 有一个主要的缺点:由于没有芯片上的索引,目录结构必须在挂载时通过从头到尾读取日志来推导。在扫描结束时,您可以得到有效节点的目录结构的完整图像,但所花费的时间与分区的大小成正比。挂载时间通常为每兆字节一秒左右,导致总挂载时间为几十秒或几百秒。

为了减少挂载时的扫描时间,摘要节点在 Linux 2.6.15 中成为一个选项。摘要节点是在关闭之前的打开擦除块的末尾写入的。摘要节点包含挂载时扫描所需的所有信息,从而减少了扫描期间需要处理的数据量。摘要节点可以将挂载时间缩短两到五倍,但会增加大约 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选项flash_erase添加清洁标记,并使用类型jffs2挂载分区作为空文件系统。请注意,要挂载的设备是给定为mtd6,而不是/dev/mtd6。或者,您可以提供块设备节点/dev/mtdblock6。这只是 JFFS2 的一个特殊之处。一旦挂载,您可以像任何文件系统一样处理它,并且在下次启动和挂载时,所有文件仍将存在。

您可以直接从开发系统的暂存区使用mkfs.jffs2以 JFFS2 格式写出文件系统图像,并使用sumtool添加摘要节点。这两者都是mtd-utils软件包的一部分。

例如,要为擦除块大小为 128 KB(0x20000)且具有摘要节点的 NAND 闪存设备创建rootfs中的文件的图像,您将使用以下两个命令:

$ 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[设备表]来设置文件的权限和所有权。当然,Buildroot 和 Yocto Project 将为您完成所有这些工作。

您可以从引导加载程序将图像编程到闪存中。例如,如果您已将文件系统图像加载到 RAM 的地址 0x82000000,并且希望将其加载到从闪存芯片开始的 0x163000 字节处的闪存分区,并且长度为 0x7a9d000 字节,则 U-Boot 命令将是:

nand erase clean 163000 7a9d000
nand write 82000000 163000 7a9d000

您可以使用 mtd 驱动程序从 Linux 执行相同的操作:

# flash_erase -j /dev/mtd6 0 0
# nandwrite /dev/mtd6 rootfs-sum.jffs2

要使用 JFFS2 根文件系统进行引导,您需要在内核命令行上传递mtdblock设备用于分区和根fstype,因为 JFFS2 无法自动检测:

root=/dev/mtdblock6 rootfstype=jffs2

YAFFS2

YAFFS 文件系统是由 Charles Manning 于 2001 年开始编写的,专门用于处理当时 JFFS2 无法处理的 NAND 闪存芯片。后来的更改以处理更大(2 KiB)的页面大小导致了 YAFFS2。YAFFS 的网站是www.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/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

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 卷中,磨损平衡将在存储的两个区域上进行,并且闪存的寿命将增加。以下图表说明了这种情况:

UBI

通过这种方式,UBI 满足了闪存转换层的两个要求:磨损平衡和坏块处理。

要为 UBI 准备 MTD 分区,不要像 JFFS2 和 YAFFS2 一样使用flash_erase,而是使用ubiformat实用程序,它保留存储在 PED 标头中的擦除计数。 ubiformat需要知道 IO 的最小单位,对于大多数 NAND 闪存芯片来说,这是页面大小,但是一些芯片允许以半页或四分之一页的子页进行读写。有关详细信息,请参阅芯片数据表,如果有疑问,请使用页面大小。此示例使用 2,048 字节的页面大小准备mtd6

# ubiformat /dev/mtd6 -s 2048

您可以使用ubiattach命令在已准备好的 MTD 分区上加载 UBI 驱动程序:

# ubiattach -p /dev/mtd6 -O 2048

这将创建设备节点/dev/ubi0,通过它可以访问 UBI 卷。您可以多次使用ubiattach来处理其他 MTD 分区,在这种情况下,它们可以通过/dev/ubi1/dev/ubi2等进行访问。

PEB 到 LEB 的映射在附加阶段加载到内存中,这个过程需要的时间与 PEB 的数量成正比,通常需要几秒钟。在 Linux 3.7 中添加了一个名为 UBI fastmap 的新功能,它会定期将映射检查点到闪存中,从而减少了附加时间。内核配置选项是CONFIG_MTD_UBI_FASTMAP

ubiformat后第一次附加到 MTD 分区时,不会有卷。您可以使用ubimkvol创建卷。例如,假设您有一个 128MB 的 MTD 分区,并且您想要使用具有 128 KB 擦除块和 2 KB 页面的芯片将其分成 32 MB 和 96 MB 两个卷:

# ubimkvol /dev/ubi0 -N vol_1 -s 32MiB
# ubimkvol /dev/ubi0 -N vol_2 -s 96MiB

现在,您有设备节点/dev/ubi0_0/dev/ubi0_1。您可以使用ubinfo确认情况:

# ubinfo -a /dev/ubi0
ubi0
Volumes count:                           2
Logical eraseblock size:                 15360 bytes, 15.0 KiB
Total amount of logical eraseblocks:     8192 (125829120 bytes, 120.0 MiB)
Amount of available logical eraseblocks: 0 (0 bytes)
Maximum count of volumes                 89
Count of bad physical eraseblocks:       0
Count of reserved physical eraseblocks:  160
Current maximum erase counter value:     1
Minimum input/output unit size:          512 bytes
Character device major/minor:            250:0
Present volumes:                         0, 1
Volume ID:   0 (on ubi0)
Type:        dynamic
Alignment:   1
Size:        2185 LEBs (33561600 bytes, 32.0 MiB)
State:       OK
Name:        vol_1
Character device major/minor: 250:1
-----------------------------------
Volume ID:   1 (on ubi0)
Type:        dynamic
Alignment:   1
Size:        5843 LEBs (89748480 bytes, 85.6 MiB)
State:       OK
Name:        vol_2
Character device major/minor: 250:2

请注意,由于每个 LEB 都有一个头部来包含 UBI 使用的元信息,因此 LEB 比 PEB 小一个页面。例如,一个 PEB 大小为 128 KB,页面大小为 2 KB 的芯片将具有 126 KB 的 LEB。这是您在创建 UBIFS 映像时需要的重要信息。

UBIFS

UBIFS 使用 UBI 卷创建一个稳健的文件系统。它添加了子分配和垃圾收集以创建一个完整的闪存转换层。与 JFFS2 和 YAFFS2 不同,它将索引信息存储在芯片上,因此挂载速度很快,尽管不要忘记预先附加 UBI 卷可能需要相当长的时间。它还允许像普通磁盘文件系统一样进行写回缓存,这意味着写入速度更快,但通常的问题是在断电事件中,未从缓存刷新到闪存内存的数据可能会丢失。您可以通过谨慎使用fsync(2)fdatasync(2)函数来解决这个问题,在关键点强制刷新文件数据。

UBIFS 具有用于断电快速恢复的日志。日志占用一些空间,通常为 4 MiB 或更多,因此 UBIFS 不适用于非常小的闪存设备。

创建 UBI 卷后,您可以使用卷的设备节点/dev/ubi0_0进行挂载,或者使用整个分区的设备节点加上卷名称进行挂载,如下所示:

# mount -t ubifs ubi0:vol_1 /mnt

为 UBIFS 创建文件系统映像是一个两阶段的过程:首先使用mkfs.ubifs创建一个 UBIFS 映像,然后使用ubinize将其嵌入到 UBI 卷中。

对于第一阶段,mkfs.ubifs需要使用-m指定页面大小,使用-e指定 UBI LEB 的大小,记住 LEB 通常比 PEB 短一个页面,使用-c指定卷中擦除块的最大数量。如果第一个卷是 32 MiB,擦除块是 128 KiB,那么擦除块的数量是 256。因此,要获取目录 rootfs 的内容并创建一个名为rootfs.ubi的 UBIFS 映像,您需要输入以下内容:

$ mkfs.ubifs -r rootfs -m 2048 -e 126KiB -c 256 -o rootfs.ubi

第二阶段需要您为ubinize创建一个配置文件,描述映像中每个卷的特性。帮助页面(ubinize -h)提供了格式的详细信息。此示例创建了两个卷,vol_1vol_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

受管理的闪存文件系统

随着受管理的闪存技术的发展,特别是 eMMC,我们需要考虑如何有效地使用它。虽然它们看起来具有与硬盘驱动器相同的特性,但一些 NAND 闪存芯片具有大擦除块的限制,擦除周期有限,并且坏块处理能力有限。当然,在断电事件中我们需要稳健性。

可以使用任何正常的磁盘文件系统,但我们应该尽量选择一个减少磁盘写入并在非计划关闭后快速重启的文件系统,通常由日志提供。

Flashbench

为了最佳利用底层闪存,您需要了解擦除块大小和页大小。通常制造商不会公布这些数字,但可以通过观察芯片或卡的行为来推断出它们。

Flashbench 就是这样一个工具。最初是由 Arnd Bergman 编写的,可以在LWN 文章中找到。您可以从github.com/bradfa/flashbench获取代码。

这是一个典型的 SanDisk GiB 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 在各种 2 的幂边界之前和之后读取块,本例中为 1,024 字节。当您跨越页或擦除块边界时,边界后的读取时间会变长。最右边的列显示了差异,这是最有趣的部分。从底部开始阅读,4 KiB 处有一个很大的跳跃,这很可能是一个页的大小。在 8 KiB 处,从 52.4µs 跳到 349µs 有第二个跳跃。这是相当常见的,表明卡可以使用多平面访问同时读取两个 4 KiB 页。除此之外,差异不太明显,但在 512 KiB 处有一个明显的跳跃,从 485µs 跳到 805µs,这可能是擦除块的大小。考虑到被测试的卡相当古老,这些是您可以预期的数字。

丢弃和 TRIM

通常,当您删除文件时,只有修改后的目录节点被写入存储,而包含文件内容的扇区保持不变。当闪存转换层位于磁盘控制器中时,例如受管理的闪存,它不知道这组磁盘扇区不再包含有用数据,因此最终会复制过时的数据。

在过去几年中,传递有关已删除扇区的事务的添加已改善了情况。SCSI 和 SATA 规范有一个TRIM命令,MMC 有一个类似的命令称为ERASE。在 Linux 中,此功能称为discard

要使用discard,您需要一个支持它的存储设备 - 大多数当前的 eMMC 芯片都支持 - 以及与之匹配的 Linux 设备驱动程序。您可以通过查看/sys/block/<block device>/queue/中的块系统队列参数来检查。感兴趣的是以下内容:

  • discard_granularity:设备内部分配单元的大小

  • discard_max_bytes:一次可以丢弃的最大字节数

  • discard_zeroes_data:如果为1,丢弃的数据将被设置为零

如果设备或设备驱动程序不支持discard,这些值都将设置为零。以下是您将从 BeagleBone Black 上的 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中有更多信息。

您可以通过在mount命令中添加选项-o discard来在挂载文件系统时启用discard。ext4 和 F2FS 都支持它。

提示

在使用-o discard mount选项之前,请确保存储设备支持discard,否则可能会发生数据丢失。

还可以独立于分区的挂载方式从命令行强制执行discard,使用的是util-linux软件包的fstrim命令。通常,您可以定期运行此命令,例如每周运行一次,以释放未使用的空间。fstrim在挂载的文件系统上操作,因此要修剪根文件系统/,您需要输入以下内容:

# fstrim -v /
/: 2061000704 bytes were trimmed

上面的例子使用了冗长选项-v,因此打印出了潜在释放的字节数。在这种情况下,2,061,000,704 是文件系统中的大约可用空间,因此这是可能被释放的最大存储量。

Ext4

扩展文件系统 ext 自 1992 年以来一直是 Linux 桌面的主要文件系统。当前版本 ext4 非常稳定,经过了充分测试,并且具有使从意外关机中恢复变得快速且基本无痛的日志。它是受控闪存设备的不错选择,您会发现它是 Android 设备的首选文件系统,这些设备具有 eMMC 存储。如果设备支持discard,您应该使用选项-o discard进行挂载。

要在运行时格式化和创建 ext4 文件系统,您需要输入以下命令:

# mkfs.ext4 /dev/mmcblk0p2
# mount -t ext4 -o discard /dev/mmcblk0p1 /mnt

要创建文件系统镜像,可以使用genext2fs实用程序,可从genext2fs.sourceforge.net获取。在这个例子中,我已经用-B指定了块大小,并用-b指定了镜像中的块数:

$ genext2fs -B 1024 -b 10000 -d rootfs rootfs.ext4

genext2fs可以利用设备表来设置文件权限和所有权,如第五章中所述,构建根文件系统,使用-D [文件表]

顾名思义,这实际上会生成一个.ext2格式的镜像。您可以使用tune2fs进行升级,具体命令选项的详细信息在tune2fs的主页面中。

$ tune2fs -j -J size=1 -O filetype,extents,uninit_bg,dir_index rootfs.ext4
$ e2fsck -pDf rootfs.ext4

Yocto 项目和 Buildroot 在创建.ext4格式的镜像时使用完全相同的步骤。

虽然日志对于可能在没有警告的情况下断电的设备是一种资产,但它确实会给每个写事务增加额外的写周期,从而耗尽闪存。如果设备是由电池供电的,特别是如果电池无法移除,那么意外断电的可能性很小,因此您可能希望不使用日志。

F2FS

Flash-Friendly File SystemF2FS,是为受控闪存设备设计的日志结构文件系统,特别适用于 eMMC 和 SD。它由三星编写,并在 3.8 版中合并到主线 Linux。它被标记为实验性,表明它尚未被广泛部署,但似乎一些 Android 设备正在使用它。

F2FS 考虑了页面和擦除块大小,并尝试在这些边界上对齐数据。日志格式在断电时具有弹性,并且具有良好的写入性能,在某些测试中显示出比 ext4 的两倍改进。在内核文档中有 F2FS 设计的良好描述,位于Documentation/filesystems/f2fs.txt,并且在本章末尾有参考资料。

mfs2.fs2实用程序使用标签-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 分区用于第二阶段引导加载程序,例如 TI OMAP 芯片。然而,FAT 格式绝对不适合存储关键文件,因为它们容易损坏并且对存储空间利用不佳。

Linux 通过msdos文件系统支持 FAT16,通过vfat文件系统支持 FAT32 和 FAT16。在大多数情况下,您需要包括vfat驱动程序。然后,要挂载设备,比如第二个mmc硬件适配器上的 SD 卡,您需要输入以下命令:

# mount -t vfat /dev/mmcblock1p1 /mnt

过去,vfat驱动程序曾存在许可问题,可能侵犯了 Microsoft 持有的专利。

FAT32 对设备大小有 32 GiB 的限制。容量更大的设备可以使用 Microsoft exFAT 格式进行格式化,并且这是 SDXC 卡的要求。没有 exFAT 的内核驱动程序,但可以通过用户空间 FUSE 驱动程序来支持。由于 exFAT 是 Microsoft 专有的,如果您在设备上支持这种格式,肯定会有许可证方面的影响。

只读压缩文件系统

如果存储空间不够,压缩数据是有用的。JFFS2 和 UBIFS 默认情况下都进行即时数据压缩。但是,如果文件永远不会被写入,通常情况下是根文件系统,您可以通过使用只读的压缩文件系统来实现更好的压缩比。Linux 支持几种这样的文件系统:romfscramfssquashfs。前两者现在已经过时,因此我只描述squashfs

squashfs

squashfs是由 Phillip Lougher 于 2002 年编写的,作为cramfs的替代品。它作为一个内核补丁存在了很长时间,最终在 2009 年的 Linux 主线版本 2.6.29 中合并。它非常容易使用:您可以使用mksquashfs创建一个文件系统映像,并将其安装到闪存存储器中:

$ mksquashfs rootfs rootfs.squashfs

由于生成的文件系统是只读的,因此没有机制可以在运行时修改任何文件。更新squashfs文件系统的唯一方法是擦除整个分区并编程一个新的映像。

squashfs不具备坏块感知功能,因此必须与可靠的闪存存储器一起使用,例如 NOR 闪存。它可以在 NAND 闪存上使用,只要您使用 UBI 在其上创建一个模拟的、可靠的 MTD 卷。您必须启用内核配置CONFIG_MTD_UBI_BLOCK,这将为每个 UBI 卷创建一个只读的 MTD 块设备。下图显示了两个 MTD 分区,每个分区都有相应的mtdblock设备。第二个分区还用于创建一个 UBI 卷,该卷作为第三个可靠的mtdblock设备公开,您可以将其用于任何不具备坏块感知功能的只读文件系统:

squashfs

临时文件系统

总是有一些文件的生命周期很短,或者在重新启动后就不再重要。许多这样的文件被放在/tmp中,因此将这些文件保存在永久存储中是有意义的。

临时文件系统tmpfs非常适合这个目的。您可以通过简单地挂载tmpfs来创建一个临时的基于 RAM 的文件系统:

# mount -t tmpfs tmp_files /tmp

procfssysfs一样,tmpfs没有与设备节点相关联,因此您必须在前面的示例中提供一个占位符字符串tmp_files

使用的内存量会随着文件的创建和删除而增长和缩小。默认的最大大小是物理 RAM 的一半。在大多数情况下,如果tmpfs增长到那么大,那将是一场灾难,因此最好使用-o size参数对其进行限制。该参数可以以字节、KiB(k)、MiB(m)或 GiB(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/volatiletmpfs挂载点,具有指向它们的符号链接,如下所示:

/tmp ->       /var/tmp
/var/lock ->  /run/lock
/var/log ->   /var/volatile/log
/var/run ->   /run
/var/tmp ->   /var/volatile/tmp

使根文件系统只读

您需要使目标设备能够在发生意外事件时存活,包括文件损坏,并且仍然能够引导并实现至少最低级别的功能。使根文件系统只读是实现这一目标的关键部分,因为它消除了意外覆盖。将其设置为只读很容易:在内核命令行中用ro替换rw,或者使用固有的只读文件系统,如squashfs。但是,您会发现有一些传统上是可写的文件和目录:

  • /etc/resolv.conf:此文件由网络配置脚本编写,用于记录 DNS 名称服务器的地址。这些信息是易失性的,因此您只需将其设置为指向临时目录的符号链接,例如/etc/resolv.conf -> /var/run/resolv.conf

  • /etc/passwd:此文件与/etc/group/etc/shadow/etc/gshadow一起存储用户和组名称以及密码。它们需要像resolv.conf一样被符号链接到持久存储区域。

  • /var/lib:许多应用程序希望能够写入此目录并在此处保留永久数据。一种解决方案是在启动时将一组基本文件复制到tmpfs文件系统,然后通过将一系列命令绑定到新位置的/var/lib来将/var/lib绑定到新位置,将这些命令放入其中一个启动脚本中:

mkdir -p /var/volatile/lib
cp -a /var/lib/* /var/volatile/lib
mount --bind /var/volatile/lib /var/lib

  • /var/log:这是 syslog 和其他守护程序保存其日志的地方。通常,由于产生许多小的写入周期,将日志记录到闪存内存中是不可取的。一个简单的解决方案是使用tmpfs挂载/var/log,使所有日志消息都是易失性的。在syslogd的情况下,BusyBox 有一个版本,可以记录到循环环形缓冲区。

如果您正在使用 Yocto 项目,可以通过将IMAGE_FEATURES = "read-only-rootfs"添加到conf/local.conf或您的镜像配方来创建只读根文件系统。

文件系统选择

到目前为止,我们已经看过固态存储器背后的技术以及许多类型的文件系统。现在是总结选项的时候了。

在大多数情况下,您将能够将存储需求分为这三类:

  • 永久的、可读写的数据:运行时配置、网络参数、密码、数据日志和用户数据

  • 永久的只读数据:程序、库和配置文件是恒定的,例如根文件系统

  • 易失性数据:临时存储,例如/tmp

读写存储的选择如下:

  • NOR:UBIFS 或 JFFS2

  • NAND:UBIFS、JFFS2 或 YAFFS2

  • eMMC:ext4 或 F2FS

注意

对于只读存储,您可以使用上述所有内容,并带有ro属性进行挂载。此外,如果要节省空间,可以在 NAND 闪存的情况下使用squashfs,使用 UBI mtdblock设备仿真来处理坏块。

最后,对于易失性存储,只有一种选择,即tmpfs

现场更新

已经有几个广为人知的安全漏洞,包括 Heartbleed(OpenSSL 库中的一个错误)和 Shellshock(bash shell 中的一个错误),这两者都可能对当前部署的嵌入式 Linux 设备造成严重后果。光是出于这个原因,就非常希望有一种机制来更新现场设备,以便在出现安全问题时进行修复。还有其他很好的原因:部署其他错误修复和功能更新。

更新机制的指导原则是不应该造成任何伤害,要记住墨菲定律:如果有可能出错,迟早会出错。任何更新机制必须是:

  • 健壮:它不能使设备无法操作。我将谈论原子更新;系统要么成功更新,要么根本不更新,并继续像以前一样运行。

  • 故障安全:它必须能够优雅地处理中断的更新。

  • 安全:它不能允许未经授权的更新,否则它将成为一种攻击机制。

通过复制要更新的内容的副本并在安全时切换到新副本来实现原子性。

故障安全性要求必须有一种机制来检测失败的更新,例如硬件看门狗,并且有一个已知的良好软件副本可以回退。

安全性可以通过本地和经过密码或 PIN 码认证的更新来实现。但是,如果更新是远程和自动的,就需要通过网络进行一定级别的认证。最终,您可能希望添加安全的引导加载程序和签名的更新二进制文件。

有些组件比其他组件更容易更新。引导加载程序非常难以更新,因为通常存在硬件约束,意味着只能有一个引导加载程序,因此如果更新失败就无法备份。另一方面,引导加载程序通常不是运行时错误的原因。最好的建议是避免在现场更新引导加载程序。

粒度:文件、软件包或镜像?

这是一个重要的问题,取决于您的整体系统设计和所需的健壮性水平。

文件更新可以是原子的:技术是将新内容写入同一文件系统中的临时文件,然后使用 POSIX rename(2)函数将其移动到旧文件上。它有效是因为重命名是保证原子性的。然而,这只是问题的一部分,因为文件之间会有依赖关系需要考虑。

在软件包(RPMdpkgipk)级别进行更新是一个更好的选择,假设您有一个运行时软件包管理器。毕竟,这就是桌面发行版多年来一直在做的事情。软件包管理器有一个更新数据库,并可以跟踪已更新和未更新的内容。每个软件包都有一个更新脚本,旨在确保软件包更新是原子的。最大的优势是您可以轻松更新现有软件包,安装新软件包,并删除过时的软件包。如果您使用的是以只读方式挂载的根文件系统,则在更新时必须暂时重新挂载为读写,这会打开一个小的损坏窗口。

软件包管理器也有缺点。它们无法更新原始闪存中的内核或其他镜像。在设备部署并多次更新后,您可能会得到大量软件包和软件包版本的组合,这将使每个新的更新周期的质量保证变得更加复杂。在更新期间发生断电时,软件包管理器也无法保证安全。

第三个选项是更新整个系统镜像:内核、根文件系统、用户应用程序等。

原子镜像更新

为了使更新是原子的,我们需要两样东西:一个可以在更新期间使用的操作系统的第二个副本,以及引导加载程序中选择要加载的操作系统副本的机制。第二个副本可能与第一个完全相同,从而实现操作系统的完全冗余,或者它可能是一个专门用于更新主操作系统的小型操作系统。

在第一种方案中,有两份操作系统副本,每个副本由 Linux 内核、根文件系统和系统应用程序组成,如下图所示:

原子镜像更新

最初,引导标志未设置,因此引导加载副本 1。要安装更新,操作系统的更新程序将覆盖副本 2。完成后,它设置引导标志并重新启动。现在,引导加载新的操作系统。安装进一步更新时,副本 2 中的更新程序将覆盖副本 1,并清除引导标志,因此您在两个副本之间来回移动。

如果更新失败,引导标志不会更改,并且将使用上一个良好的操作系统。即使更新由多个组件组成,如内核镜像、DTB、根文件系统和系统应用程序文件系统,整个更新也是原子的,因为只有在所有更新完成时才会更新引导标志。

这种方案的主要缺点是需要存储两份操作系统的副本。

您可以通过保留一个纯粹用于更新主操作系统的最小操作系统来减少存储需求,如下图所示:

原子镜像更新

当您想要安装更新时,设置引导标志并重新启动。一旦恢复操作系统运行,它启动更新程序,覆盖主操作系统镜像。完成后,清除引导标志并再次重新启动,这次加载新的主操作系统。

恢复操作系统通常比主操作系统小得多,可能只有几兆字节,因此存储开销并不大。事实上,这是 Android 采用的方案。主操作系统有几百兆字节,但恢复模式操作系统只是一个简单的几兆字节的 ramdisk。

进一步阅读

以下资源提供了有关本章介绍的主题的更多信息:

总结

从一开始,闪存存储技术一直是嵌入式 Linux 的首选技术,多年来 Linux 已经获得了非常好的支持,从低级驱动程序到支持闪存的文件系统,最新的是 UBIFS。

然而,随着新的闪存技术推出的速度加快,要跟上高端变化变得更加困难。系统设计师越来越倾向于使用 eMMC 形式的托管闪存,以提供稳定的硬件和软件接口,独立于内部存储芯片。嵌入式 Linux 开发人员开始逐渐掌握这些新芯片。对于 ext4 和 F2FS 中的 TRIM 的支持已经很成熟,并且它正在慢慢地进入芯片本身。此外,出现了新的针对管理闪存优化的文件系统,比如 F2FS,这是一个值得欢迎的进步。

然而,事实仍然是,闪存存储技术与硬盘驱动器不同。你必须小心减少文件系统写入的次数 - 尤其是高密度 TLC 芯片可能只支持 1000 次擦除循环。

最后,在现场更新设备上存储的文件和图像时,有一个更新策略是至关重要的。其中一个关键部分是决定是否使用软件包管理器。软件包管理器可以给你灵活性,但不能提供完全可靠的更新解决方案。你的选择取决于方便性和稳健性之间的平衡。

下一章描述了如何通过设备驱动程序控制系统的硬件组件,包括内核中的驱动程序以及用户空间中控制硬件的程度。

第八章:介绍设备驱动程序

内核设备驱动程序是将底层硬件暴露给系统其余部分的机制。作为嵌入式系统的开发人员,您需要了解设备驱动程序如何适应整体架构以及如何从用户空间程序中访问它们。您的系统可能会有一些新颖的硬件部件,您将不得不找出一种访问它们的方法。在许多情况下,您会发现已经为您提供了设备驱动程序,您可以在不编写任何内核代码的情况下实现您想要的一切。例如,您可以使用sysfs中的文件来操作 GPIO 引脚和 LED,并且有库可以访问串行总线,包括 SPI 和 I2C。

有很多地方可以找到如何编写设备驱动程序的信息,但很少有地方告诉你为什么要这样做以及在这样做时的选择。这就是我想在这里介绍的内容。但是,请记住,这不是一本专门写内核设备驱动程序的书,这里提供的信息是为了帮助您在这个领域中导航,而不一定是为了在那里设置家。有很多好书和文章可以帮助您编写设备驱动程序,其中一些列在本章末尾。

设备驱动程序的作用

如第四章中所述,移植和配置内核,内核的功能之一是封装计算机系统的许多硬件接口,并以一致的方式呈现给用户空间程序。有设计的框架使得在内核中编写设备的接口逻辑变得容易,并且可以将其集成到内核中:这就是设备驱动程序,它是介于其上方的内核和其下方的硬件之间的代码片段。设备驱动程序是控制物理设备(如 UART 或 MMC 控制器)或虚拟设备(如空设备(/dev/null)或 ramdisk)的软件。一个驱动程序可以控制多个相同类型的设备。

内核设备驱动程序代码以高特权级别运行,就像内核的其余部分一样。它可以完全访问处理器地址空间和硬件寄存器。它可以处理中断和 DMA 传输。它可以利用复杂的内核基础设施进行同步和内存管理。这也有一个缺点,即如果有错误的驱动程序出现问题,它可能会导致系统崩溃。因此,有一个原则是设备驱动程序应尽可能简单,只提供信息给应用程序,真正的决策是在应用程序中做出的。你经常听到这被表达为内核中没有策略

在 Linux 中,有三种主要类型的设备驱动程序:

  • 字符:这是用于具有丰富功能范围和应用程序代码与驱动程序之间薄层的无缓冲 I/O。在实现自定义设备驱动程序时,这是首选。

  • :这具有专门针对从大容量存储设备进行块 I/O 的接口。有一个厚的缓冲层,旨在使磁盘读取和写入尽可能快,这使其不适用于其他用途。

  • 网络:这类似于块设备,但用于传输和接收网络数据包,而不是磁盘块。

还有第四种类型,它表现为伪文件系统中的一组文件。例如,您可以通过/sys/class/gpio中的一组文件访问 GPIO 驱动程序,我将在本章后面描述。让我们首先更详细地看一下三种基本设备类型。

字符设备

这些设备在用户空间通过文件名进行标识:如果你想从 UART 读取数据,你需要打开设备节点,例如,在 ARM Versatile Express 上的第一个串行端口将是/dev/ttyAMA0。驱动程序在内核中以不同的方式进行标识,使用的是主设备号,在给定的示例中是204。由于 UART 驱动程序可以处理多个 UART,还有第二个号码,称为次设备号,用于标识特定的接口,例如在这种情况下是 64。

# 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/devices.txt中。该列表不经常更新,也不包括前面段落中描述的ttyAMA设备。然而,如果你查看drivers/tty/serial/amba-pl011.c中的源代码,你会看到主设备号和次设备号是如何声明的。

#define SERIAL_AMBA_MAJOR       204
#define SERIAL_AMBA_MINOR       64

当一个设备有多个实例时,设备节点的命名约定为<基本名称><接口号>,例如,ttyAMA0ttyAMA1等。

正如我在第五章中提到的,构建根文件系统,设备节点可以通过多种方式创建:

  • devtmpfs:当设备驱动程序使用驱动程序提供的基本名称(ttyAMA)和实例号注册新的设备接口时创建的节点。

  • udevmdev(没有devtmpfs):与devtmpfs基本相同,只是需要一个用户空间守护程序从sysfs中提取设备名称并创建节点。我稍后会谈到sysfs

  • mknod:如果你使用静态设备节点,可以使用mknod手动创建它们。

你可能会从上面我使用的数字中得到这样的印象,即主设备号和次设备号都是 8 位数字,范围在 0 到 255 之间。实际上,从 Linux 2.6 开始,主设备号有 12 位长,有效数字范围为 1 到 4095,次设备号有 20 位,范围为 0 到 1048575。

当你打开一个设备节点时,内核会检查主设备号和次设备号是否落在该类型设备驱动程序注册的范围内(字符或块)。如果是,它会将调用传递给驱动程序,否则打开调用失败。设备驱动程序可以提取次设备号以找出要使用的硬件接口。如果次设备号超出范围,它会返回错误。

要编写一个访问设备驱动程序的程序,你必须对其工作原理有一定了解。换句话说,设备驱动程序与文件不同:你对它所做的事情会改变设备的状态。一个简单的例子是伪随机数生成器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;
}

Unix 驱动程序模型的好处在于,一旦我们知道有一个名为urandom的设备,并且每次从中读取数据时,它都会返回一组新的伪随机数据,我们就不需要再了解其他任何信息。我们可以直接使用诸如open(2)read(2)close(2)等普通函数。

我们可以使用流 I/O 函数fopen(3)fread(3)fclose(3),但是这些函数隐含的缓冲区通常会导致意外的行为。例如,fwrite(3)通常只写入用户空间缓冲区,而不是设备。我们需要调用fflush(3)来强制刷新缓冲区。

提示

不要在调用设备驱动程序时使用流 I/O 函数,比如fread(3)fwrite(3)

块设备

块设备也与设备节点相关联,同样具有主设备号和次设备号。

提示

尽管字符设备和块设备使用主设备号和次设备号进行标识,但它们位于不同的命名空间。主设备号为 4 的字符驱动程序与主设备号为 4 的块驱动程序没有任何关联。

对于块设备,主编号用于标识设备驱动程序,次编号用于标识分区。让我们以 MMC 驱动程序为例:

# ls -l /dev/mmcblk*

brw-------    1 root root  179,   0 Jan  1  1970 /dev/mmcblk0
brw-------    1 root root  179,   1 Jan  1  1970 /dev/mmcblk0p1
brw-------    1 root root  179,   2 Jan  1  1970 /dev/mmcblk0p2
brw-------    1 root root  179,   8 Jan  1  1970 /dev/mmcblk1
brw-------    1 root root  179,   9 Jan  1  1970 /dev/mmcblk1p1
brw-------    1 root root  179,  10 Jan  1  1970 /dev/mmcblk1p2

主编号为 179(在devices.txt中查找!)。次编号用于标识不同的mmc设备和该设备上存储介质的分区。对于 mmcblk 驱动程序,每个设备有八个次编号范围:从 0 到 7 的次编号用于第一个设备,从 8 到 15 的次编号用于第二个设备,依此类推。在每个范围内,第一个次编号代表整个设备的原始扇区,其他次编号代表最多七个分区。

您可能已经了解到 SCSI 磁盘驱动程序,称为 sd,用于控制使用 SCSI 命令集的一系列磁盘,其中包括 SCSI、SATA、USB 大容量存储和 UFS(通用闪存存储)。它的主编号为 8,每个接口(或磁盘)有 16 个次编号。从 0 到 15 的次编号用于第一个接口,设备节点的名称为sdasda15,从 16 到 31 的编号用于第二个磁盘,设备节点为sdbsdb15,依此类推。这一直持续到第 16 个磁盘,从 240 到 255,节点名称为sdp。由于 SCSI 磁盘非常受欢迎,还有其他为它们保留的主编号,但我们不需要在这里担心这些。

分区是使用诸如fdisksfidskparted之类的实用程序创建的。一个例外是原始闪存:MTD 驱动程序的分区信息是内核命令行或设备树中的一部分,或者是第七章中描述的其他方法之一,创建存储策略

用户空间程序可以通过设备节点直接打开和与块设备交互。这不是常见的操作,通常用于执行分区、格式化文件系统和挂载等管理操作。一旦文件系统被挂载,您将通过该文件系统中的文件间接与块设备交互。

网络设备

网络设备不是通过设备节点访问的,也没有主次编号。相反,内核会根据字符串和实例号为网络设备分配一个名称。以下是网络驱动程序注册接口的示例方式:

my_netdev = alloc_netdev(0, "net%d", NET_NAME_UNKNOWN, netdev_setup);
ret = register_netdev(my_netdev);

这将创建一个名为net0的网络设备,第一次调用时为net1,依此类推。更常见的名称是loeth0wlan0

请注意,这是它起始的名称;设备管理器(如udev)可能会在以后更改为其他名称。

通常,网络接口名称仅在使用诸如ipifconfig之类的实用程序配置网络以建立网络地址和路由时使用。此后,您通过打开套接字间接与网络驱动程序交互,并让网络层决定如何将它们路由到正确的接口。

但是,可以通过创建套接字并使用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;
  int i;
  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 (i = 0; i < 6; i++)
    printf("%02x:", (unsigned char)ifr.ifr_hwaddr.sa_data[i]);
  printf("\n");
  close(s);
  return 0;
}

这是一个标准设备ioctl,由网络层代表驱动程序处理,但是可以定义自己的ioctl编号并在自定义网络驱动程序中处理它们。

在运行时了解驱动程序

一旦您运行了 Linux 系统,了解加载的设备驱动程序及其状态是很有用的。您可以通过阅读/proc/sys中的文件来了解很多信息。

首先,您可以通过读取/proc/devices来列出当前加载和活动的字符和块设备驱动程序:

# cat /proc/devices

Character devices:

  1 mem
  2 pty
  3 ttyp
  4 /dev/vc/0
  4 tty
  4 ttyS
  5 /dev/tty
  5 /dev/console
  5 /dev/ptmx
  7 vcs
 10 misc
 13 input
 29 fb
 81 video4linux
 89 i2c
 90 mtd
116 alsa
128 ptm
136 pts
153 spi
180 usb
189 usb_device
204 ttySC
204 ttyAMA
207 ttymxc
226 drm
239 ttyLP
240 ttyTHS
241 ttySiRF
242 ttyPS
243 ttyWMT
244 ttyAS
245 ttyO
246 ttyMSM
247 ttyAML
248 bsg
249 iio
250 watchdog
251 ptp
252 pps
253 media
254 rtc

Block devices:

259 blkext
  7 loop
  8 sd
 11 sr
 31 mtdblock
 65 sd
 66 sd
 67 sd
 68 sd
 69 sd
 70 sd
 71 sd
128 sd
129 sd
130 sd
131 sd
132 sd
133 sd
134 sd
135 sd
179 mmc

对于每个驱动程序,您可以看到主要编号和基本名称。但是,这并不能告诉您每个驱动程序连接到了多少设备。它只显示了ttyAMA,但并没有提示它连接了四个真实的 UART。我稍后会回到这一点,当我查看sysfs时。如果您正在使用诸如mdevudevdevtmpfs之类的设备管理器,您可以通过查看/dev中的字符和块设备接口来列出它们。

您还可以使用ifconfigip列出网络接口:

# ip link show

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

2: eth0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc pfifo_fast state DOWN mode DEFAULT qlen 1000
    link/ether 54:4a:16:bb:b7:03 brd ff:ff:ff:ff:ff:ff

3: usb0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT qlen 1000
    link/ether aa:fb:7f:5e:a8:d5 brd ff:ff:ff:ff:ff:ff

您还可以使用众所周知的命令lsusblspci来查找连接到 USB 或 PCI 总线的设备。关于它们的信息在各自的手册和大量的在线指南中都有,所以我在这里不再详细描述它们。

真正有趣的信息在sysfs中,这是下一个主题。

从 sysfs 获取信息

您可以以一种迂腐的方式定义sysfs,即内核对象、属性和关系的表示。内核对象是一个目录,属性是一个文件,关系是从一个对象到另一个对象的符号链接。

从更实际的角度来看,自 Linux 设备驱动程序模型在 2.6 版本中引入以来,它将所有设备和驱动程序表示为内核对象。您可以通过查看/sys来看到系统的内核视图,如下所示:

# ls /sys

block  bus  class  dev  devices  firmware  fs  kernel  module  power

在发现有关设备和驱动程序的信息方面,我将查看三个目录:devicesclassblock

设备:/sys/devices

这是内核对自启动以来发现的设备及其相互连接的视图。它是按系统总线在顶层组织的,因此您看到的内容因系统而异。这是 Versatile Express 的 QEMU 仿真:

# ls
 /sys/devices
armv7_cortex_a9  platform      system
breakpoint       software      virtual

所有系统上都存在三个目录:

  • 系统:这包含了系统核心的设备,包括 CPU 和时钟。

  • 虚拟:这包含基于内存的设备。您将在virtual/mem中找到出现为/dev/null/dev/random/dev/zero的内存设备。您将在virtual/net中找到环回设备lo

  • 平台:这是一个通用术语,用于指代通过传统硬件总线连接的设备。这几乎可以是嵌入式设备上的任何东西。

其他设备出现在与实际系统总线对应的目录中。例如,PCI 根总线(如果有)显示为pci0000:00

浏览这个层次结构相当困难,因为它需要对系统的拓扑结构有一定的了解,而且路径名变得相当长,很难记住。为了让生活变得更容易,/sys/class/sys/block提供了设备的两种不同视图。

驱动程序:/sys/class

这是设备驱动程序的视图,按其类型呈现,换句话说,这是一种软件视图而不是硬件视图。每个子目录代表一个驱动程序类,并由驱动程序框架的一个组件实现。例如,UART 设备由tty层管理,您将在/sys/class/tty中找到它们。同样,您将在/sys/class/net中找到网络设备,在/sys/class/input中找到输入设备,如键盘、触摸屏和鼠标,依此类推。

每个子目录中都有一个符号链接,指向该类型设备的每个实例在/sys/device中的表示。

举个具体的例子,让我们看一下/sys/class/tty/ttyAMA0

# cd  /sys/class/tty/ttyAMA0/
# ls
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

链接设备引用了设备的硬件节点,子系统指向/sys/class/tty。其他属性是设备的属性。有些属性是特定于 UART 的,比如xmit_fifo_size,而其他属性适用于许多类型的设备,比如中断号irq和设备号dev。一些属性文件是可写的,允许您在运行时调整驱动程序的参数。

dev属性特别有趣。如果您查看它的值,您会发现以下内容:

# cat /sys/class/tty/ttyAMA0/dev
204:64

这是设备的主要和次要编号。当驱动程序注册了这个接口时,就会创建这个属性,如果没有devtmpfs的帮助,udevmdev就会从这个文件中读取这些信息。

块驱动程序:/sys/block

设备模型的另一个重要视图是块驱动程序视图,你可以在/sys/block中找到。每个块设备都有一个子目录。这个例子来自 BeagleBone Black:

# ls /sys/block/

loop0  loop4  mmcblk0       ram0   ram12  ram2  ram6
loop1  loop5  mmcblk1       ram1   ram13  ram3  ram7
loop2  loop6  mmcblk1boot0  ram10  ram14  ram4  ram8
loop3  loop7  mmcblk1boot1  ram11  ram15  ram5  ram9

如果你查看这块板上的 eMMC 芯片mmcblk1,你可以看到接口的属性和其中的分区:

# cd /sys/block/mmcblk1
# ls

alignment_offset   ext_range     mmcblk1p1  ro
bdi                force_ro      mmcblk1p2  size
capability         holders       power      slaves
dev                inflight      queue      stat
device             mmcblk1boot0  range      subsystem
discard_alignment  mmcblk1boot1  removable  uevent

因此,通过阅读sysfs,你可以了解系统上存在的设备(硬件)和驱动程序(软件)。

寻找合适的设备驱动程序

典型的嵌入式板是基于制造商的参考设计,经过更改以适合特定应用。它可能通过 I2C 连接温度传感器,通过 GPIO 引脚连接灯和按钮,通过外部以太网 MAC 连接,通过 MIPI 接口连接显示面板,或者其他许多东西。你的工作是创建一个自定义内核来控制所有这些,那么你从哪里开始呢?

有些东西非常简单,你可以编写用户空间代码来处理它们。通过 I2C 或 SPI 连接的 GPIO 和简单外围设备很容易从用户空间控制,我稍后会解释。

其他东西需要内核驱动程序,因此你需要知道如何找到一个并将其整合到你的构建中。没有简单的答案,但这里有一些地方可以找到。

最明显的地方是制造商网站上的驱动程序支持页面,或者你可以直接问他们。根据我的经验,这很少能得到你想要的结果;硬件制造商通常不太懂 Linux,他们经常给出误导性的信息。他们可能有二进制的专有驱动程序,也可能有源代码,但是适用于与你拥有的内核版本不同的版本。所以,尽管可以尝试这种途径。我总是会尽力寻找适合手头任务的开源驱动程序。

你的内核可能已经支持:主线 Linux 中有成千上万的驱动程序,供应商内核中也有许多特定于供应商的驱动程序。首先运行make menuconfig(或xconfig),搜索产品名称或编号。如果找不到完全匹配的,尝试更通用的搜索,考虑到大多数驱动程序处理同一系列产品。接下来,尝试在驱动程序目录中搜索代码(这里用grep)。始终确保你正在运行适合你的板的最新内核:较新的内核通常有更多的设备驱动程序。

如果你还没有驱动程序,可以尝试在线搜索并在相关论坛上询问,看看是否有适用于不同 Linux 版本的驱动程序。如果找到了,你就需要将其移植到你的内核中。如果内核版本相似,可能会很容易,但如果相隔 12 到 18 个月以上,接口很可能已经发生了变化,你将不得不重写驱动程序的一部分,以使其与你的内核集成。你可能需要外包这项工作。如果所有上述方法都失败了,你就得自己找解决方案。

用户空间的设备驱动程序

在你开始编写设备驱动程序之前,暂停一下,考虑一下是否真的有必要。对于许多常见类型的设备,有通用的设备驱动程序,允许你直接从用户空间与硬件交互,而不必编写一行内核代码。用户空间代码肯定更容易编写和调试。它也不受 GPL 的限制,尽管我不认为这本身是一个好理由。

它们可以分为两大类:通过sysfs中的文件进行控制的设备,包括 GPIO 和 LED,以及通过设备节点公开通用接口的串行总线,比如 I2C。

GPIO

通用输入/输出GPIO)是数字接口的最简单形式,因为它可以直接访问单个硬件引脚,每个引脚可以配置为输入或输出。 GPIO 甚至可以用于通过在软件中操作每个位来创建更高级的接口,例如 I2C 或 SPI,这种技术称为位操作。主要限制是软件循环的速度和准确性以及您想要为它们分配的 CPU 周期数。一般来说,使用CONFIG_PREEMPT编译的内核很难实现比毫秒更好的定时器精度,使用RT_PREEMPT编译的内核很难实现比 100 微秒更好的定时器精度,我们将在第十四章中看到,实时编程。 GPIO 的更常见用途是读取按钮和数字传感器以及控制 LED、电机和继电器。

大多数 SoC 有很多 GPIO 位,这些位被分组在 GPIO 寄存器中,通常每个寄存器有 32 位。芯片上的 GPIO 位通过多路复用器(称为引脚复用器)路由到芯片封装上的 GPIO 引脚,我稍后会描述。在电源管理芯片和专用 GPIO 扩展器中可能有额外的 GPIO 位,通过 I2C 或 SPI 总线连接。所有这些多样性都由一个名为gpiolib的内核子系统处理,它实际上不是一个库,而是 GPIO 驱动程序用来以一致的方式公开 IO 的基础设施。

有关gpiolib实现的详细信息在内核源中的Documentation/gpio中,驱动程序本身在drivers/gpio中。

应用程序可以通过/sys/class/gpio目录中的文件与gpiolib进行交互。以下是在典型嵌入式板(BeagleBone Black)上看到的内容的示例:

# ls  /sys/class/gpio
export  gpiochip0   gpiochip32  gpiochip64  gpiochip96  unexport

gpiochip0gpiochip96目录代表了四个 GPIO 寄存器,每个寄存器有 32 个 GPIO 位。如果你查看其中一个gpiochip目录,你会看到以下内容:

# ls /sys/class/gpio/gpiochip96/
base  label   ngpio  power  subsystem  uevent

文件base包含寄存器中第一个 GPIO 引脚的编号,ngpio包含寄存器中位的数量。在这种情况下,gpiochip96/base是 96,gpiochip96/ngpio是 32,这告诉您它包含 GPIO 位 96 到 127。寄存器中最后一个 GPIO 和下一个寄存器中第一个 GPIO 之间可能存在间隙。

要从用户空间控制 GPIO 位,您首先必须从内核空间导出它,方法是将 GPIO 编号写入/sys/class/gpio/export。此示例显示了 GPIO 48 的过程:

# echo 48 > /sys/class/gpio/export
# ls /sys/class/gpio
export      gpio48    gpiochip0   gpiochip32  gpiochip64  gpiochip96  unexport

现在有一个新目录gpio48,其中包含了控制引脚所需的文件。请注意,如果 GPIO 位已被内核占用,您将无法以这种方式导出它。

目录gpio48包含这些文件:

# ls /sys/class/gpio/gpio48
active_low  direction  edge  power  subsystem   uevent  value

引脚最初是输入的。要将其更改为输出,请将out写入direction文件。文件value包含引脚的当前状态,低电平为 0,高电平为 1。如果它是输出,您可以通过向value写入 0 或 1 来更改状态。有时,在硬件中低电平和高电平的含义是相反的(硬件工程师喜欢做这种事情),因此将 1 写入active_low会反转含义,以便在value中将低电压报告为 1,高电压为 0。

您可以通过将 GPIO 编号写入/sys/class/gpio/unexport来从用户空间控制中删除 GPIO。

从 GPIO 处理中断

在许多情况下,可以将 GPIO 输入配置为在状态更改时生成中断,这允许您等待中断而不是在低效的软件循环中轮询。如果 GPIO 位可以生成中断,则文件edge存在。最初,它的值为none,表示它不会生成中断。要启用中断,您可以将其设置为以下值之一:

  • rising:上升沿中断

  • falling:下降沿中断

  • both:上升沿和下降沿中断

  • none:无中断(默认)

您可以使用poll()函数等待中断,事件为POLLPRI。如果要等待 GPIO 48 上的上升沿,首先要启用中断:

# echo 48 > /sys/class/gpio/export
# echo rising > /sys/class/gpio/gpio48/edge

然后,您可以使用poll()等待更改,如此代码示例所示:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <poll.h>

int main (int argc, char *argv[])
{
  int f;
  struct pollfd poll_fds [1];
  int ret;
  char value[4];
  int n;
  f = open("/sys/class/gpio/gpio48", O_RDONLY);
  if (f == -1) {
    perror("Can't open gpio48");
    return 1;
  }
  poll_fds[0].fd = f;
  poll_fds[0].events = POLLPRI | POLLERR;
  while (1) {
    printf("Waiting\n");
    ret = poll(poll_fds, 1, -1);
    if (ret > 0) {
        n = read(f, &value, sizeof(value));
        printf("Button pressed: read %d bytes, value=%c\n",
        n, value[0]);
    }
  }
  return 0;
}

LED

LED 通常是通过 GPIO 引脚控制的,但是还有另一个内核子系统,提供了更专门的控制,用于特定目的。 leds内核子系统增加了设置亮度的功能,如果 LED 具有该功能,并且可以处理连接方式不同于简单 GPIO 引脚的 LED。它可以配置为在事件上触发 LED,例如块设备访问或只是心跳以显示设备正在工作。在Documentation/leds/中有更多信息,驱动程序位于drivers/leds/中。

与 GPIO 一样,LED 通过sysfs中的接口进行控制,在/sys/class/leds中。LED 的名称采用devicename:colour:function的形式,如下所示:

# ls /sys/class/leds
beaglebone:green:heartbeat  beaglebone:green:usr2
beaglebone:green:mmc0       beaglebone:green:usr3

这显示了一个单独的 LED:

# ls /sys/class/leds/beaglebone:green:usr2
brightness    max_brightness  subsystem     uevent
device        power           trigger

brightness文件控制 LED 的亮度,可以是 0(关闭)到max_brightness(完全打开)之间的数字。如果 LED 不支持中间亮度,则任何非零值都会打开它,零会关闭它。文件trigger列出了触发 LED 打开的事件。触发器列表因实现而异。这是一个例子:

# cat /sys/class/leds/beaglebone:green:heartbeat/trigger
none mmc0 mmc1 timer oneshot [heartbeat] backlight gpio cpu0 default-on

当前选择的触发器显示在方括号中。您可以通过将其他触发器之一写入文件来更改它。如果您想完全通过“亮度”控制 LED,请选择none。如果将触发器设置为timer,则会出现两个额外的文件,允许您以毫秒为单位设置开启和关闭时间:

# echo timer > /sys/class/leds/beaglebone:green:heartbeat/trigger
# ls /sys/class/leds/beaglebone:green:heartbeat
brightness  delay_on    max_brightness  subsystem   uevent
delay_off   device      power           trigger
# cat /sys/class/leds/beaglebone:green:heartbeat/delay_on
500
# cat /sys/class/leds/beaglebone:green:heartbeat/delay_off
500
#

如果 LED 具有片上定时器硬件,则闪烁会在不中断 CPU 的情况下进行。

I2C

I2C 是一种简单的低速 2 线总线,通常用于访问 SoC 板上没有的外围设备,例如显示控制器、摄像头传感器、GPIO 扩展器等。还有一个相关的标准称为 SMBus(系统管理总线),它在 PC 上发现,用于访问温度和电压传感器。SMBus 是 I2C 的子集。

I2C 是一种主从协议,主要是 SoC 上的一个或多个主控制器。从设备由制造商分配的 7 位地址 - 请阅读数据表 - 允许每个总线上最多 128 个节点,但保留了 16 个,因此实际上只允许 112 个节点。总线速度为标准模式下的 100 KHz,或者快速模式下的最高 400 KHz。该协议允许主设备和从设备之间的读写事务最多达 32 个字节。通常,第一个字节用于指定外围设备上的寄存器,其余字节是从该寄存器读取或写入的数据。

每个主控制器都有一个设备节点,例如,这个 SoC 有四个:

# ls -l /dev/i2c*
crw-rw---- 1 root i2c 89, 0 Jan  1 00:18 /dev/i2c-0
crw-rw---- 1 root i2c 89, 1 Jan  1 00:18 /dev/i2c-1
crw-rw---- 1 root i2c 89, 2 Jan  1 00:18 /dev/i2c-2
crw-rw---- 1 root i2c 89, 3 Jan  1 00:18 /dev/i2c-3

设备接口提供了一系列ioctl命令,用于查询主控制器并向 I2C 从设备发送readwrite命令。有一个名为i2c-tools的软件包,它使用此接口提供基本的命令行工具来与 I2C 设备交互。工具如下:

  • i2cdetect:这会列出 I2C 适配器并探测总线

  • i2cdump:这会从 I2C 外设的所有寄存器中转储数据

  • i2cget:这会从 I2C 从设备读取数据

  • i2cset:这将数据写入 I2C 从设备

i2c-tools软件包在 Buildroot 和 Yocto Project 中可用,以及大多数主流发行版。只要您知道从设备的地址和协议,编写一个用户空间程序来与设备通信就很简单:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <i2c-dev.h>
#include <sys/ioctl.h>
#define I2C_ADDRESS 0x5d
#define CHIP_REVISION_REG 0x10

void main (void)
{
  int f_i2c;
  int val;

  /* Open the adapter and set the address of the I2C device */
  f_i2c = open ("/dev/i2c-1", O_RDWR);
  ioctl (f_i2c, I2C_SLAVE, I2C_ADDRESS);

  /* Read 16-bits of data from a register */
  val = i2c_smbus_read_word_data(f, CHIP_REVISION_REG);
  printf ("Sensor chip revision %d\n", val);
  close (f_i2c);
}

请注意,标头i2c-dev.h是来自i2c-tools软件包的标头,而不是来自 Linux 内核标头的标头。 i2c_smbus_read_word_data()函数是在i2c-dev.h中内联编写的。

有关 I2C 在Documentation/i2c/dev-interface中的 Linux 实现的更多信息。主控制器驱动程序位于drivers/i2c/busses中。

SPI

串行外围接口总线类似于 I2C,但速度更快,高达低 MHz。该接口使用四根线,具有独立的发送和接收线,这使得它可以全双工操作。总线上的每个芯片都使用专用的芯片选择线进行选择。它通常用于连接触摸屏传感器、显示控制器和串行 NOR 闪存设备。

与 I2C 一样,它是一种主从协议,大多数 SoC 实现了一个或多个主机控制器。有一个通用的 SPI 设备驱动程序,您可以通过内核配置CONFIG_SPI_SPIDEV启用它。它为每个 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中的示例代码。

编写内核设备驱动程序

最终,当您耗尽了上述所有用户空间选项时,您会发现自己不得不编写一个设备驱动程序来访问连接到您的设备的硬件。虽然现在不是深入细节的时候,但值得考虑一下选择。字符驱动程序是最灵活的,应该可以满足 90%的需求;如果您正在使用网络接口,网络设备也适用;块设备用于大容量存储。编写内核驱动程序的任务是复杂的,超出了本书的范围。在本节末尾有一些参考资料,可以帮助您一路前行。在本节中,我想概述与驱动程序交互的可用选项——这通常不是涵盖的主题——并向您展示驱动程序的基本结构。

设计字符设备接口

主要的字符设备接口基于字节流,就像串口一样。然而,许多设备并不符合这个描述:例如,机器人手臂的控制器需要移动和旋转每个关节的功能。幸运的是,与设备驱动程序进行通信的其他方法不仅仅是read(2)write(2)

  • ioctlioctl函数允许您向驱动程序传递两个参数,这两个参数可以有任何您喜欢的含义。按照惯例,第一个参数是一个命令,用于选择驱动程序中的几个函数中的一个,第二个参数是一个指向结构体的指针,该结构体用作输入和输出参数的容器。这是一个空白画布,允许您设计任何您喜欢的程序接口,当驱动程序和应用程序紧密链接并由同一团队编写时,这是非常常见的。然而,在内核中,ioctl已经被弃用,您会发现很难让任何具有新ioctl用法的驱动程序被上游接受。内核维护人员不喜欢ioctl,因为它使内核代码和应用程序代码过于相互依赖,并且很难在内核版本和架构之间保持两者同步。

  • sysfs:这是现在的首选方式,一个很好的例子是之前描述的 GPIO 接口。其优点是它是自我记录的,只要您为文件选择描述性名称。它也是可脚本化的,因为文件内容是 ASCII 字符串。另一方面,每个文件要求包含一个单一值,这使得如果您需要同时更改多个值,就很难实现原子性。例如,如果您想设置两个值然后启动一个操作,您需要写入三个文件:两个用于输入,第三个用于触发操作。即使这样,也不能保证其他两个文件没有被其他人更改。相反,ioctl通过单个函数调用中的结构传递所有参数。

  • mmap:您可以通过将内核内存映射到用户空间来直接访问内核缓冲区和硬件寄存器,绕过内核。您可能仍然需要一些内核代码来处理中断和 DMA。有一个封装这个想法的子系统,称为uio,即用户 I/O。在Documentation/DocBook/uio-howto中有更多文档,drivers/uio中有示例驱动程序。

  • sigio:您可以使用内核函数kill_fasync()从驱动程序发送信号,以通知应用程序事件,例如输入准备就绪或接收到中断。按照惯例,使用信号 SIGIO,但它可以是任何人。您可以在 UIO 驱动程序drivers/uio/uio.c和 RTC 驱动程序drivers/char/rtc.c中看到一些示例。主要问题是编写可靠的信号处理程序很困难,因此它仍然是一个很少使用的设施。

  • debugfs:这是另一个伪文件系统,它将内核数据表示为文件和目录,类似于procsysfs。主要区别在于debugfs不得包含系统正常操作所需的信息;它仅用于调试和跟踪信息。它被挂载为mount -t debugfs debug /sys/kernel/debug

内核文档中有关debugfs的良好描述,Documentation/filesystems/debugfs.txt

  • procproc文件系统已被弃用,除非它与进程有关,这是文件系统的最初预期目的。但是,您可以使用proc发布您选择的任何信息。并且,与sysfsdebugfs不同,它可用于非 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;
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;
}

struct file_operations dummy_fops = {
  .owner = THIS_MODULE,
  .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(THIS_MODULE, DEVICE_NAME);
  for (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 (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(dummy_init);
module_exit(dummy_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Chris Simmonds");
MODULE_DESCRIPTION("A dummy driver");

代码末尾的宏module_initmodule_exit指定了在加载和卸载模块时要调用的函数。其他三个添加了有关模块的一些基本信息,可以使用modinfo命令从编译的内核模块中检索。

模块加载时,将调用dummy_init()函数。

调用register_chrdev可以看到它何时成为一个字符设备,传递一个指向包含驱动程序实现的四个函数指针的struct file_operations指针。虽然register_chrdev告诉内核有一个主编号为 42 的驱动程序,但它并没有说明驱动程序的类型,因此它不会在/sys/class中创建条目。没有在/sys/class中的条目,设备管理器无法创建设备节点。因此,代码的下几行创建了一个设备类dummy,以及该类的四个名为dummy0dummy3的设备。结果是/sys/class/dummy目录,其中包含dummy0dummy3子目录,每个子目录中都包含一个名为dev的文件,其中包含设备的主要和次要编号。这就是设备管理器创建设备节点/dev/dummy0/dev/dummy3所需的全部内容。

exit函数必须释放init函数声明的资源,这里指的是释放设备类和主要编号。

该驱动程序的文件操作由dummy_open()dummy_read()dummy_write()dummy_release()实现,并在用户空间程序调用open(2)read(2)write(2)close(2)时调用。 它们只是打印内核消息,以便您可以看到它们被调用。 您可以使用echo命令从命令行演示这一点:

# echo hello > /dev/dummy0

[ 6479.741192] dummy_open
[ 6479.742505] dummy_write 6
[ 6479.743008] dummy_release

在这种情况下,消息出现是因为我已登录到控制台,默认情况下内核消息会打印到控制台。

该驱动程序的完整源代码不到 100 行,但足以说明设备节点和驱动程序代码之间的链接方式,说明设备类是如何创建的,允许设备管理器在加载驱动程序时自动创建设备节点,以及数据如何在用户空间和内核空间之间移动。 接下来,您需要构建它。

编译和加载

此时,您有一些驱动程序代码,希望在目标系统上进行编译和测试。 您可以将其复制到内核源树中并修改 makefile 以构建它,或者您可以将其编译为树外模块。 让我们首先从树外构建开始。

您需要一个简单的 makefile,该 makefile 使用内核构建系统来完成艰苦的工作:

LINUXDIR := $(HOME)/MELP/build/linux

obj-m := dummy.o
all:
        make ARCH=arm CROSS_COMPILE=arm-cortex_a8-linux-gnueabihf- \
          -C $(LINUXDIR) M=$(shell pwd)
clean:
        make -C $(LINUXDIR) M=$(shell pwd) clean

LINUXDIR设置为您将在目标设备上运行模块的内核目录。 代码obj-m:= dummy.o将调用内核构建规则,以获取源文件dummy.c并创建内核模块dummy.ko。 请注意,内核模块在内核发布和配置之间不具有二进制兼容性,该模块只能在其编译的内核上加载。

构建的最终结果是内核dummy.ko,您可以将其复制到目标并按照下一节中所示加载。

如果要在内核源树中构建驱动程序,该过程非常简单。 选择适合您的驱动程序类型的目录。 该驱动程序是基本字符设备,因此我将dummy.c放在drivers/char中。 然后,编辑该目录中的 makefile,并添加一行以无条件地构建驱动程序作为模块,如下所示:

obj-m  += dummy.o

或者将以下行添加到无条件构建为内置:

obj-y   += dummy.o

如果要使驱动程序可选,可以在Kconfig文件中添加菜单选项,并根据配置选项进行条件编译,就像我在第四章中描述的那样,移植和配置内核,描述内核配置时。

加载内核模块

您可以使用简单的insmodlsmodrmmod命令加载,卸载和列出模块。 这里显示了加载虚拟驱动程序:

# insmod /lib/modules/4.1.10/kernel/drivers/dummy.ko
# lsmod
dummy 1248 0 - Live 0xbf009000 (O)
# rmmod dummy

如果模块放置在/lib/modules/<kernel release>中的子目录中,例如示例中,可以使用depmod命令创建模块依赖数据库:

# depmod -a
# ls /lib/modules/4.1.10/
kernel               modules.builtin.bin  modules.order
modules.alias        modules.dep          modules.softdep
modules.alias.bin    modules.dep.bin      modules.symbols
modules.builtin      modules.devname      modules.symbols.bin

module.*文件中的信息由modprobe命令使用,以按名称而不是完整路径定位模块。 modprobe还具有许多其他功能,这些功能在手册中有描述。

模块依赖信息也被设备管理器使用,特别是udev。 例如,当检测到新硬件时,例如新的 USB 设备,udevd守护程序会被警报,并从硬件中读取供应商和产品 ID。 udevd扫描模块依赖文件,寻找已注册这些 ID 的模块。 如果找到一个,它将使用modprobe加载。

发现硬件配置

虚拟驱动程序演示了设备驱动程序的结构,但它缺乏与真实硬件的交互,因为它只操作内存结构。 设备驱动程序通常用于与硬件交互,其中的一部分是能够首先发现硬件,要记住的是在不同配置中它可能位于不同的地址。

在某些情况下,硬件本身提供信息。可发现总线上的设备(如 PCI 或 USB)具有查询模式,该模式返回资源需求和唯一标识符。内核将标识符和可能的其他特征与设备驱动程序进行匹配,并将它们配对。

然而,大多数 SoC 上的硬件块都没有这样的标识符。您必须以设备树或称为平台数据的 C 结构的形式提供信息。

在 Linux 的标准驱动程序模型中,设备驱动程序会向适当的子系统注册自己:PCI、USB、开放固件(设备树)、平台设备等。注册包括标识符和称为探测函数的回调函数,如果硬件的 ID 与驱动程序的 ID 匹配,则会调用该函数。对于 PCI 和 USB,ID 基于设备的供应商和产品 ID,对于设备树和平台设备,它是一个名称(ASCII 字符串)。

设备树

我在第三章中向您介绍了设备树,关于引导程序的一切。在这里,我想向您展示 Linux 设备驱动程序如何与这些信息连接。

作为示例,我将使用 ARM Versatile 板,arch/arm/boot/dts/versatile-ab.dts,其中以太网适配器在此处定义:

net@10010000 {
  compatible = "smsc,lan91c111";
  reg = <0x10010000 0x10000>;
  interrupts = <25>;
};

平台数据

在没有设备树支持的情况下,还有一种使用 C 结构描述硬件的备用方法,称为平台数据。

每个硬件都由struct platform_device描述,其中包含名称和资源数组的指针。资源的类型由标志确定,其中包括以下内容:

  • IORESOURCE_MEM:内存区域的物理地址

  • IORESOURCE_IO:IO 寄存器的物理地址或端口号

  • IORESOURCE_IRQ:中断号

以下是从arch/arm/mach-versatile/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 KiB 的内存区域和一个中断。平台数据必须在初始化板时向内核注册:

void __init versatile_init(void)
{
  platform_device_register(&versatile_flash_device);
  platform_device_register(&versatile_i2c_device);
  platform_device_register(&smc91x_device);
  [ ...]

将硬件与设备驱动程序连接起来

在前面的部分中,您已经看到了以设备树和平台数据描述以太网适配器的方式。相应的驱动程序代码位于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,其中包含对探测函数的回调,驱动程序名称smc91x,以及对struct of_device_id的指针。

如果此驱动程序已由设备树配置,内核将在设备树节点中的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信息。驱动程序负责映射内存并安装中断处理程序。第三个参数在前面两种情况下都是零,如果有多个特定类型的资源,则会起作用。

设备树允许您配置的不仅仅是基本内存范围和中断。在probe函数中有一段代码,用于从设备树中提取可选参数。在这个片段中,它获取了register-io-width属性:

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/smsc911x.txt中。

这里要记住的主要事情是,驱动程序应该注册一个probe函数和足够的信息,以便内核在找到与其了解的硬件匹配时调用probe。设备树描述的硬件与设备驱动程序之间的链接是通过compatible属性实现的。平台数据与驱动程序之间的链接是通过名称实现的。

额外阅读

以下资源提供了关于本章介绍的主题的更多信息:

  • Linux Device Drivers, 4th edition,作者Jessica McKellarAlessandro RubiniJonathan CorbetGreg Kroah-Hartman。在撰写本文时尚未出版,但如果它像前作一样好,那将是一个不错的选择。但是,第三版已经过时,不建议阅读。

  • Linux Kernel Development, 3rd edition,作者Robert LoveAddison-Wesley Professional; (July 2, 2010) ISBN-10: 0672329468

  • Linux Weekly Newswww.lwn.net

摘要

设备驱动程序的工作是处理设备,通常是物理硬件,但有时也是虚拟接口,并以一种一致和有用的方式呈现给更高级别。Linux 设备驱动程序分为三大类:字符、块和网络。在这三种中,字符驱动程序接口是最灵活的,因此也是最常见的。Linux 驱动程序适用于一个称为驱动模型的框架,通过sysfs公开。几乎所有设备和驱动程序的状态都可以在/sys中看到。

每个嵌入式系统都有自己独特的硬件接口和要求。Linux 为大多数标准接口提供了驱动程序,通过选择正确的内核配置,您可以使设备非常快速地运行起来。这样,您就可以处理非标准组件,需要添加自己的设备支持。

在某些情况下,您可以通过使用通用的 GPIO、I2C 等驱动程序并编写用户空间代码来避开问题。我建议这作为一个起点,因为这样可以让您有机会熟悉硬件,而不必编写内核代码。编写内核驱动程序并不特别困难,但是如果您这样做,需要小心编码,以免影响系统的稳定性。

我已经谈到了编写内核驱动程序代码:如果您选择这条路线,您将不可避免地想知道如何检查它是否正常工作并检测任何错误。我将在第十二章中涵盖这个主题,使用 GDB 进行调试

下一章将全面介绍用户空间初始化以及init程序的不同选项,从简单的 BusyBox 到复杂的 systemd。

第九章:启动- init 程序

我在第四章中看到了内核如何引导到启动第一个程序init的点,在第五章中,构建根文件系统和第六章中,选择构建系统,我看了创建不同复杂性的根文件系统,其中都包含了init程序。现在是时候更详细地看看init程序,并发现它对系统的重要性。

init有许多可能的实现。我将在本章中描述三种主要的实现:BusyBox init,System V initsystemd。对于每种实现,我将概述其工作原理和最适合的系统类型。其中一部分是在复杂性和灵活性之间取得平衡。

内核引导后

我们在第四章中看到了移植和配置内核,内核引导代码如何寻找根文件系统,要么是initramfs,要么是内核命令行上指定的文件系统root=,然后执行一个程序,默认情况下是initramfs/init,常规文件系统的/sbin/initinit程序具有根特权,并且由于它是第一个运行的进程,它具有进程 ID(PID)为 1。如果由于某种原因init无法启动,内核将会恐慌。

init程序是所有其他进程的祖先,如pstree命令所示,它是大多数发行版中psmisc软件包的一部分:

# pstree -gn

init(1)-+-syslogd(63)
        |-klogd(66)
        |-dropbear(99)
        `-sh(100)---pstree(109)

init程序的工作是控制系统并使其运行。它可能只是一个运行 shell 脚本的 shell 命令-在第五章的开头有一个示例,构建根文件系统—但在大多数情况下,您将使用专用的init守护程序。它必须执行的任务如下:

  • 在启动时,它启动守护程序,配置系统参数和其他必要的东西,使系统进入工作状态。

  • 可选地,它启动守护程序,比如在允许登录 shell 的终端上启动getty

  • 它接管因其直接父进程终止而变成孤儿的进程,并且没有其他进程在线程组中。

  • 它通过捕获信号SIGCHLD并收集返回值来响应init的任何直接子进程的终止,以防止它们变成僵尸进程。我将在第十章中更多地讨论僵尸进程,了解进程和线程

  • 可选地,它重新启动那些已经终止的守护进程。

  • 它处理系统关闭。

换句话说,init管理系统的生命周期,从启动到关闭。目前的想法是init很适合处理其他运行时事件,比如新硬件和模块的加载和卸载。这就是systemd的作用。

介绍 init 程序

在嵌入式设备中,您最有可能遇到的三种init程序是 BusyBox init,System V initsystemd。Buildroot 有选项可以构建所有三种,其中 BusyBox init是默认选项。Yocto Project 允许您在 System V initsystemd之间进行选择,System V init是默认选项。

以下表格提供了比较这三种程序的一些指标:

BusyBox init System V init systemd
--- --- --- ---
复杂性 中等
启动速度 中等
所需的 shell ash ash 或 bash
可执行文件数量 0 4 50(*)
libc 任何 任何 glibc
大小(MiB) 0 0.1 34(*)

(*)基于system的 Buildroot 配置。

总的来说,从 BusyBox initsystemd,灵活性和复杂性都有所增加。

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后运行程序。用于在终端上启动交互式 shell 而无需提示用户名或密码。

  • once:运行程序一次,但如果终止则不尝试重新启动。

  • wait:运行程序并等待其完成。

  • restart:当init接收到信号SIGHUP时运行程序,表示应重新加载inittab文件。

  • ctrlaltdel:当init接收到信号SIGINT时运行程序,通常是在控制台上按下Ctrl + Alt + Del的结果。

  • shutdown:当init关闭时运行程序。

以下是一个小例子,它挂载procsysfs,并在串行接口上运行 shell:

null::sysinit:/bin/mount -t proc proc /proc
null::sysinit:/bin/mount -t sysfs sysfs /sys
console::askfirst:-/bin/sh

对于简单的项目,您希望启动少量守护进程并可能在串行终端上启动登录 shell,手动编写脚本很容易,如果您正在创建一个RYOroll your own)嵌入式 Linux,这是合适的。但是,随着需要配置的内容增加,您会发现手写的init脚本很快变得难以维护。它们往往不太模块化,因此每次添加新组件时都需要更新。

Buildroot init 脚本

多年来,Buildroot 一直在有效地使用 BusyBox init。Buildroot 在/etc/init.d中有两个脚本,名为rcSrcK。第一个在启动时启动,并遍历所有以大写S开头后跟两位数字的脚本,并按数字顺序运行它们。这些是启动脚本。rcK脚本在关闭时运行,并遍历所有以大写K开头后跟两位数字的脚本,并按数字顺序运行它们。这些是关闭脚本。

有了这个,Buildroot 软件包可以轻松提供自己的启动和关闭脚本,使用两位数字来规定它们应该运行的顺序,因此系统变得可扩展。如果您正在使用 Buildroot,这是透明的。如果没有,您可以将其用作编写自己的 BusyBox init脚本的模型。

System V init

这个init程序受 UNIX System V 的启发,可以追溯到 20 世纪 80 年代中期。在 Linux 发行版中最常见的版本最初是由 Miquel van Smoorenburg 编写的。直到最近,它被认为是引导 Linux 的方式,显然包括嵌入式系统,而 BusyBox init是 System V init的精简版本。

与 BusyBox init相比,System V init有两个优点。首先,引导脚本以众所周知的模块化格式编写,使得在构建时或运行时轻松添加新包。其次,它具有运行级别的概念,允许通过从一个运行级别切换到另一个运行级别来一次性启动或停止一组程序。

有从 0 到 6 编号的 8 个运行级别,另外还有 S:

  • S:单用户模式

  • 0:关闭系统

  • 1 至 5:通用使用

  • 6:重新启动系统

级别 1 到 5 可以随您的意愿使用。在桌面 Linux 发行版中,它们通常分配如下:

  • 1:单用户

  • 2:无需网络配置的多用户

  • 3:带网络配置的多用户

  • 4:未使用

  • 5:带图形登录的多用户

init程序启动由/etc/inittab中的initdefault行给出的默认runlevel。您可以使用telinit [runlevel]命令在运行时更改运行级别,该命令向init发送消息。您可以使用runlevel命令找到当前运行级别和先前的运行级别。以下是一个示例:

# runlevel
N 5
# telinit 3
INIT: Switching to runlevel: 3
# runlevel
5 3

在第一行上,runlevel的输出是N 5,这意味着没有先前的运行级别,因为自启动以来runlevel没有改变,当前的runlevel5。在改变runlevel后,输出是5 3,显示已从5转换到3haltreboot命令分别切换到06的运行级别。您可以通过在内核命令行上给出不同的单个数字06,或者S表示单用户模式,来覆盖默认的runlevel。例如,要强制runlevel为单用户,您可以在内核命令行上附加S,看起来像这样:

console=ttyAMA0 root=/dev/mmcblk1p2 S

每个运行级别都有一些停止事物的脚本,称为kill脚本,以及另一组启动事物的脚本,称为start脚本。进入新的runlevel时,init首先运行kill脚本,然后运行start脚本。在新的runlevel中运行守护进程,如果它们既没有start脚本也没有kill脚本,那么它们将收到SIGTERM信号。换句话说,切换runlevel的默认操作是终止守护进程,除非另有指示。

事实上,在嵌入式 Linux 中并不经常使用运行级别:大多数设备只是启动到默认的runlevel并保持在那里。我有一种感觉,部分原因是大多数人并不知道它们。

提示

运行级别是在不同模式之间切换的一种简单方便的方式,例如,从生产模式切换到维护模式。

System V init是 Buildroot 和 Yocto Project 的一个选项。在这两种情况下,init 脚本已经被剥离了任何 bash 特定的内容,因此它们可以与 BusyBox ash shell 一起工作。但是,Buildroot 通过用 SystemV init替换 BusyBox init程序并添加模仿 BusyBox 行为的inittab来作弊。Buildroot 不实现运行级别,除非切换到级别 0 或 6 会停止或重新启动系统。

接下来,让我们看一些细节。以下示例取自 Yocto Project 的 fido 版本。其他发行版可能以稍有不同的方式实现init脚本。

inittab

init程序首先读取/etc/inttab,其中包含定义每个runlevel发生的事情的条目。格式是我在前一节中描述的 BusyBox inittab的扩展版本,这并不奇怪,因为 BusyBox 首先从 System V 借鉴了它!

inittab中每行的格式如下:

id:runlevels:action:process

字段如下所示:

  • id:最多四个字符的唯一标识符。

  • runlevels:应执行此条目的运行级别。(在 BusyBox inittab中留空)

  • action:以下给出的关键字之一。

  • process:要运行的命令。

这些操作与 BusyBox init的操作相同:sysinitrespawnoncewaitrestartctrlaltdelshutdown。但是,System V init没有askfirst,这是 BusyBox 特有的。

例如,这是 Yocto Project 目标 core-image-minimal 提供的完整的inttab

# /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将默认的runlevel设置为5。接下来的条目si::sysinit:/etc/init.d/rcS在启动时运行脚本rcS。稍后会有更多关于这个的内容。稍后,有一组六个条目,以l0:0:wait:/etc/init.d/rc 0开头。它们在运行级别发生变化时运行脚本/etc/init.d/rc:这个脚本负责处理startkill脚本。还有一个运行级别S的条目,运行单用户登录程序。

inittab的末尾,有两个条目,当进入运行级别 1 到 5 时,它们运行一个getty守护进程在设备/dev/ttyAMA0/dev/tty1上生成登录提示,从而允许你登录并获得交互式 shell:

AMA0:12345:respawn:/sbin/getty 115200 ttyAMA0
1:2345:respawn:/sbin/getty 38400 tty1

设备ttyAMA0是我们用 QEMU 模拟的 ARM Versatile 板上的串行控制台,对于其他开发板来说可能会有所不同。Tty1 是一个虚拟控制台,通常映射到图形屏幕,如果你的内核使用了CONFIG_FRAMEBUFFER_CONSOLEVGA_CONSOLE。桌面 Linux 通常在虚拟终端 1 到 6 上生成六个getty进程,你可以用组合键Ctrl + Alt + F1Ctrl + Alt + F6来选择,虚拟终端 7 保留给图形屏幕。嵌入式设备上很少使用虚拟终端。

sysinit条目运行的脚本/etc/init.d/rcS几乎只是进入运行级别S

#!/bin/sh

[...]
exec /etc/init.d/rc S

因此,第一个进入的运行级别是S,然后是默认的runlevel 5。请注意,runlevel S不会被记录,也不会被runlevel命令显示为先前的运行级别。

init.d 脚本

需要响应runlevel变化的每个组件都有一个在/etc/init.d中执行该变化的脚本。脚本应该期望两个参数:startstop。稍后我会举一个例子。

runlevel处理脚本/etc/init.d/rcrunlevel作为参数进行切换。对于每个runlevel,都有一个名为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开头的脚本。这些是startkill脚本:Buildroot 使用了相同的想法,从这里借鉴过来:

# ls /etc/rc5.d
S01networking   S20hwclock.sh   S99rmnologin.sh S99stop-bootlogd
S15mountnfs.sh  S20syslog

实际上,这些是指向init.d中适当脚本的符号链接。rc脚本首先运行所有以K开头的脚本,添加stop参数,然后运行以S开头的脚本,添加start参数。再次强调,两位数字代码用于指定脚本应该运行的顺序。

添加新的守护进程

假设你有一个名为simpleserver的程序,它是作为传统的 Unix 守护进程编写的,换句话说,它会分叉并在后台运行。你将需要一个像这样的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参数启动守护进程,确保任何时候都不会有多个实例在运行,并使用-K按名称查找守护进程,并默认发送信号SIGTERM。将此脚本放在/etc/init.d/simpleserver中并使其可执行。

然后,从你想要从中运行这个程序的每个运行级别添加symlinks,在这种情况下,只有默认的runlevel5

# cd /etc/init.d/rc5.d
# ln -s ../init.d/simpleserver S99simpleserver

数字99表示这将是最后启动的程序之一。请记住,可能会有其他以S99开头的链接,如果是这样,rc脚本将按照词法顺序运行它们。

在嵌入式设备中很少需要过多担心关机操作,但如果有需要做的事情,可以在 0 和 6 级别添加kill symlinks

# cd /etc/init.d/rc0.d
# ln -s ../init.d/simpleserver K01simpleserver
# cd /etc/init.d/rc6.d
# ln -s ../init.d/simpleserver K01simpleserver

启动和停止服务

您可以通过直接调用/etc/init.d中的脚本与之交互,例如,控制syslogdklogd守护进程的syslog脚本:

# /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

所有脚本都实现了startstop,并且应该实现help。有些还实现了status,它会告诉您服务是否正在运行。仍在使用 System V init的主流发行版有一个名为 service 的命令,用于启动和停止服务,并隐藏直接调用脚本的细节。

systemd

systemd将自己定义为系统和服务管理器。该项目由 Lennart Poettering 和 Kay Sievers 于 2010 年发起,旨在创建一套集成的工具,用于管理 Linux 系统,包括init守护程序。它还包括设备管理(udev)和日志记录等内容。有人会说它不仅仅是一个init程序,它是一种生活方式。它是最先进的,仍在快速发展。systemd在桌面和服务器 Linux 发行版上很常见,并且在嵌入式 Linux 系统上也变得越来越受欢迎,特别是在更复杂的设备上。那么,它比 System V init在嵌入式系统上更好在哪里呢?

  • 配置更简单更合乎逻辑(一旦你理解了它),而不是 System V init有时候复杂的 shell 脚本,systemd有单元配置文件来设置参数

  • 服务之间有明确的依赖关系,而不是仅仅设置脚本运行顺序的两位数代码

  • 为每个服务设置权限和资源限制很容易,这对安全性很重要

  • systemd可以监视服务并在需要时重新启动它们

  • 每个服务和systemd本身都有看门狗

  • 服务并行启动,减少启动时间

在这里,不可能也不合适对systemd进行完整描述。与 System V init一样,我将专注于嵌入式用例,并以 Yocto Fido 生成的配置为例,该配置具有systemd版本 219。我将进行快速概述,然后向您展示一些具体示例。

使用 Yocto Project 和 Buildroot 构建 systemd

Yocto Fido 中的默认init是 System V。要选择systemd,请在配置中添加这些行,例如,在conf/local.conf中:

DISTRO_FEATURES_append = " systemd"
VIRTUAL-RUNTIME_init_manager = "systemd"

请注意,前导空格很重要!然后重新构建。

Buildroot 将systemd作为第三个init选项。它需要 glibc 作为 C 库,并且需要启用特定一组配置选项的内核版本为 3.7 或更高。在systemd源代码的顶层的README文件中有完整的依赖项列表。

介绍目标、服务和单元

在我描述systemd init如何工作之前,我需要介绍这三个关键概念。

首先,目标是一组服务,类似于但更一般化的 SystemV runlevel。有一个默认目标,它是在启动时启动的服务组。

其次,服务是可以启动和停止的守护进程,非常类似于 SystemV service

最后,一个单元是一个描述targetservice和其他几个东西的配置文件。单元是包含属性和值的文本文件。

您可以使用systemctl命令更改状态并了解发生了什么。

单元

配置的基本项是单元文件。单元文件位于三个不同的位置:

  • /etc/systemd/system:本地配置

  • /run/systemd/system:运行时配置

  • /lib/systemd/system:分发范围内的配置

在寻找单元时,systemd按照这个顺序搜索目录,一旦找到匹配项就停止,这样可以通过在/etc/systemd/system中放置同名单元来覆盖分发范围内单元的行为。您可以通过创建一个空的本地文件或链接到/dev/null来完全禁用一个单元。

所有单元文件都以标有[Unit]的部分开头,其中包含基本信息和依赖项,例如:

[Unit]
Description=D-Bus System Message Bus
Documentation=man:dbus-daemon(1)
Requires=dbus.socket

单元依赖关系通过RequiresWantsConflicts来表达:

  • Requires: 此单元依赖的单元列表,当此单元启动时启动

  • Wants: Requires的一种较弱形式:列出的单元被启动,但如果它们中的任何一个失败,当前单元不会停止

  • 冲突: 一个负依赖:列出的单元在此单元启动时停止,反之亦然

处理依赖关系会产生一个应该启动(或停止)的单元列表。关键字BeforeAfter确定它们启动的顺序。停止的顺序只是启动顺序的相反:

  • Before: 在列出的单元之前应启动此单元

  • After: 在列出的单元之后应启动此单元

在以下示例中,After指令确保网络后启动 Web 服务器:

[Unit]
Description=Lighttpd Web Server
After=network.target

在没有BeforeAfter指令的情况下,单元将并行启动或停止,没有特定的顺序。

服务

服务是可以启动和停止的守护进程,相当于 System V 的service。服务是以.service结尾的一种单元文件,例如lighttpd.service

服务单元有一个描述其运行方式的[Service]部分。以下是lighttpd.service的相关部分:

[Service]
ExecStart=/usr/sbin/lighttpd -f /etc/lighttpd/lighttpd.conf -D
ExecReload=/bin/kill -HUP $MAINPID

这些是启动服务和重新启动服务时要运行的命令。您可以在这里添加更多配置点,因此请参考systemd.service的手册页。

目标

目标是另一种将服务(或其他类型的单元)分组的单元类型。它是一种只有依赖关系的单元类型。目标的名称以.target结尾,例如multi-user.target。目标是一种期望状态,起到与 System V 运行级别相同的作用。

systemd 如何引导系统

现在我们可以看到systemd如何实现引导。systemd由内核作为/sbin/init的符号链接到/lib/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来查找默认目标,如下所示:

# systemctl get-default
multi-user.target

启动诸如multi-user.target之类的目标会创建一个依赖树,将系统带入工作状态。在典型系统中,multi-user.target依赖于basic.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

[Unit]部分只包含一个描述,以便在使用systemctl和其他命令列出时正确显示。没有依赖关系;就像我说的,它非常简单。

[Service]部分指向可执行文件,并带有一个指示它分叉的标志。如果它更简单并在前台运行,systemd将为我们进行守护进程,Type=forking将不需要。

[Install]部分使其依赖于multi-user.target,这样我们的服务器在系统进入多用户模式时启动。

一旦单元保存在/etc/systemd/system/simpleserver.service中,您可以使用systemctl start simpleserversystemctl stop simpleserver命令启动和停止它。您可以使用此命令查找其当前状态:

# 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来启用它,如下所示:

# systemctl enable simpleserver
Created symlink from /etc/systemd/system/multi-user.target.wants/simpleserver.service to /etc/systemd/system/simpleserver.service.

现在您可以看到如何在运行时添加依赖项,而无需编辑任何单元文件。一个目标可以有一个名为<target_name>.target.wants的目录,其中可以包含指向服务的链接。这与在目标中的[Wants]列表中添加依赖单元完全相同。在这种情况下,您会发现已创建了此链接:

/etc/systemd/system/multi-user.target.wants/simpleserver.service
/etc/systemd/system/simpleserver.service

如果这是一个重要的服务,如果失败,您可能希望重新启动。您可以通过向[Service]部分添加此标志来实现:

Restart=on-abort

Restart的其他选项是on-successon-failureon-abnormalon-watchdogon-abortalways

添加看门狗

看门狗是嵌入式设备中的常见要求:如果关键服务停止工作,通常需要采取措施重置系统。在大多数嵌入式 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 秒进行一次保持活动状态的检查。如果未能交付,该服务将被重新启动,但如果在五分钟内重新启动超过四次,systemd将强制立即重新启动。再次,在systemd手册中有关于这些设置的完整描述。

像这样的看门狗负责个别服务,但如果systemd本身失败,或者内核崩溃,或者硬件锁定。在这些情况下,我们需要告诉systemd使用看门狗驱动程序:只需将RuntimeWatchdogSec=NN添加到/etc/systemd/system.confsystemd将在该期限内重置看门狗,因此如果systemd因某种原因失败,系统将重置。

嵌入式 Linux 的影响

systemd在嵌入式 Linux 中有许多有用的功能,包括我在这个简要描述中没有提到的许多功能,例如使用切片进行资源控制(参见systemd.slice(5)systemd.resource-control(5)的手册页)、设备管理(udev(7))和系统日志记录设施(journald(5))。

您必须权衡其大小:即使只构建了核心组件systemdudevdjournald,其存储空间也接近 10 MiB,包括共享库。

您还必须记住,systemd的开发与内核紧密相关,因此它不会在比systemd发布时间早一年或两年的内核上工作。

进一步阅读

以下资源提供了有关本章介绍的主题的进一步信息:

总结

每个 Linux 设备都需要某种类型的init程序。如果您正在设计一个系统,该系统只需在启动时启动少量守护程序并在此后保持相对静态,那么 BusyBoxinit就足够满足您的需求。如果您使用 Buildroot 作为构建系统,通常这是一个不错的选择。

另一方面,如果您的系统在启动时或运行时服务之间存在复杂的依赖关系,并且您有存储空间,那么systemd将是最佳选择。即使没有复杂性,systemd在处理看门狗、远程日志记录等方面也具有一些有用的功能,因此您应该认真考虑它。

很难仅凭其自身的优点支持 System Vinit,因为它几乎没有比简单的 BusyBoxinit更多的优势。尽管如此,它仍将长期存在,仅仅因为它存在。例如,如果您正在使用 Yocto Project 进行构建,并决定不使用systemd,那么 System Vinit就是另一种选择。

在减少启动时间方面,systemd比 System Vinit更快,但是,如果您正在寻找非常快速的启动,没有什么能比得上简单的 BusyBoxinit和最小的启动脚本。

本章是关于一个非常重要的进程,init。在下一章中,我将描述进程的真正含义,它与线程的关系,它们如何合作以及它们如何被调度。如果您想创建一个健壮且易于维护的嵌入式系统,了解这些内容是很重要的。

第十章:了解进程和线程

在前面的章节中,我们考虑了创建嵌入式 Linux 平台的各个方面。现在是时候开始了解如何使用该平台创建工作设备了。在本章中,我将讨论 Linux 进程模型的含义以及它如何包含多线程程序。我将探讨使用单线程和多线程进程的利弊。我还将研究调度,并区分时间共享和实时调度策略。

虽然这些主题与嵌入式计算无关,但对于嵌入式设备的设计者来说,了解这些主题非常重要。关于这个主题有很多好的参考书籍,其中一些我在本章末尾引用,但一般来说,它们并不考虑嵌入式用例。因此,我将集中讨论概念和设计决策,而不是函数调用和代码。

进程还是线程?

许多熟悉实时操作系统(RTOS)的嵌入式开发人员认为 Unix 进程模型很繁琐。另一方面,他们认为 RTOS 任务和 Linux 线程之间存在相似性,并倾向于使用一对一的映射将现有设计转移到线程。我曾多次看到整个应用程序都是使用包含 40 个或更多线程的一个进程来实现的设计。我想花一些时间考虑这是否是一个好主意。让我们从一些定义开始。

进程是一个内存地址空间和一个执行线程,如下图所示。地址空间对进程是私有的,因此在不同进程中运行的线程无法访问它。这种内存分离是由内核中的内存管理子系统创建的,该子系统为每个进程保留一个内存页映射,并在每次上下文切换时重新编程内存管理单元。我将在第十一章管理内存中详细描述这是如何工作的。地址空间的一部分映射到一个文件,其中包含程序正在运行的代码和静态数据:

进程还是线程?

随着程序的运行,它将分配资源,如堆栈空间,堆内存,文件引用等。当进程终止时,系统将回收这些资源:所有内存都被释放,所有文件描述符都被关闭。

进程可以使用进程间通信(IPC)(如本地套接字)相互通信。我将在后面谈论 IPC。

线程是进程内的执行线程。所有进程都从运行main()函数的一个线程开始,称为主线程。您可以使用 POSIX 线程函数pthread_create(3)创建额外的线程,导致额外的线程在相同的地址空间中执行,如下图所示。由于它们在同一个进程中,它们共享资源。它们可以读写相同的内存并使用相同的文件描述符,因此线程之间的通信很容易,只要您注意同步和锁定问题:

进程还是线程?

因此,基于这些简要细节,您可以想象一个假设系统的两种极端设计,该系统有 40 个 RTOS 任务被移植到 Linux。

您可以将任务映射到进程,并通过 IPC 进行通信,例如通过套接字发送消息,有 40 个单独的程序。这样做可以大大减少内存损坏问题,因为每个进程中运行的主线程都受到其他线程的保护,还可以减少资源泄漏,因为每个进程在退出后都会被清理。然而,进程之间的消息接口非常复杂,当一组进程之间有紧密的合作时,消息的数量可能会很大,因此成为系统性能的限制因素。此外,40 个进程中的任何一个可能会终止,也许是因为出现错误导致崩溃,剩下的 39 个继续运行。每个进程都必须处理其邻居不再运行并优雅地恢复的情况。

在另一个极端,您可以将任务映射到线程,并将系统实现为包含 40 个线程的单个进程。合作变得更容易,因为它们共享相同的地址空间和文件描述符。发送消息的开销减少或消除,线程之间的上下文切换比进程之间的快。缺点是引入了一个任务破坏另一个任务的堆栈的可能性。如果任何一个线程遇到致命错误,整个进程将终止,带走所有的线程。最后,调试复杂的多线程进程可能是一场噩梦。

您应该得出的结论是,这两种设计都不是理想的,有更好的方法。但在我们达到这一点之前,我将更深入地探讨进程和线程的 API 和行为。

进程

进程保存了线程可以运行的环境:它保存了内存映射、文件描述符、用户和组 ID 等。第一个进程是init进程,它是由内核在启动期间创建的,PID 为 1。此后,进程是通过复制创建的,这个操作称为 forking。

创建一个新进程

创建进程的POSIX函数是fork(2)。这是一个奇怪的函数,因为对于每次成功调用,都有两个返回值:一个在进行调用的进程中,称为父进程,另一个在新创建的进程中,称为子进程,如下图所示:

创建一个新进程

在调用之后,子进程是父进程的精确副本,它有相同的堆栈、相同的堆、相同的文件描述符,并执行与fork(2)后面的相同代码行。程序员唯一能够区分它们的方法是查看 fork 的返回值:对于子进程,返回值为零,对于父进程,返回值大于零。实际上,在父进程中返回的值是新创建的子进程的 PID。还有第三种可能性,即返回值为负,意味着 fork 调用失败,仍然只有一个进程。

尽管这两个进程最初是相同的,但它们处于单独的地址空间中。一个进程对变量的更改不会被另一个进程看到。在底层,内核不会对父进程的内存进行物理复制,这将是一个相当缓慢的操作,并且会不必要地消耗内存。相反,内存是共享的,但标记有写时复制CoW)标志。如果父进程或子进程修改了这个内存,内核首先会进行复制,然后写入复制。这样做既有了高效的 fork 函数,又保留了进程地址空间的逻辑分离。我将在第十一章管理内存中讨论 CoW。

终止进程

进程可以通过调用exit(3)函数自愿停止,或者通过接收未处理的信号而被迫停止。特别是,一个信号SIGKILL无法被处理,因此将总是杀死一个进程。在所有情况下,终止进程将停止所有线程,关闭所有文件描述符,并释放所有内存。系统会向父进程发送一个SIGCHLD信号,以便它知道发生了这种情况。

进程有一个返回值,由exit(3)的参数组成,如果它正常终止,或者如果它被杀死,则由信号编号组成。这主要用于 shell 脚本:它允许您测试程序的返回值。按照惯例,0表示成功,其他值表示某种失败。

父进程可以使用wait(2)waitpid(2)函数收集返回值。这会导致一个问题:子进程终止和其父进程收集返回值之间会有延迟。在这段时间内,返回值必须存储在某个地方,现在已经死掉的进程的 PID 号码不能被重用。处于这种状态的进程是僵尸,在 ps 或 top 中是 Z 状态。只要父进程调用wait(2)waitpid(2),每当它被通知子进程的终止(通过SIGCHLD信号,参见Linux 系统编程,由Robert LoveO'Reilly MediaThe Linux Programming Interface,由Michael KerriskNo Starch Press有关处理信号的详细信息),僵尸存在的时间太短,无法在进程列表中显示出来。如果父进程未能收集返回值,它们将成为一个问题,因为您将无法创建更多进程。

这是一个简单的示例,显示了进程的创建和终止:

#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(2)函数会阻塞,直到子进程退出并存储退出状态。当您运行它时,会看到类似这样的东西:

I am the parent, PID 13851
I am the child, PID 13852
Child terminated with status 42

子进程继承了父进程的大部分属性,包括用户和组 ID(UID 和 GID),所有打开的文件描述符,信号处理和调度特性。

运行不同的程序

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()函数。这是一个命令启动器的示例:它提示输入一个命令,例如/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;
}

有一个函数复制现有进程,另一个丢弃其资源并将不同的程序加载到内存中,这可能看起来有点奇怪,特别是因为fork后几乎立即跟随exec。大多数操作系统将这两个操作合并为一个单独的调用。

然而,这确实有明显的优势。例如,它使得在 shell 中实现重定向和管道非常容易。想象一下,您想要获取目录列表,这是事件的顺序:

  1. 在 shell 提示符处键入ls

  2. shell 分叉一个自身的副本。

  3. 子进程执行/bin/ls

  4. ls程序将目录列表打印到stdout(文件描述符 1),该文件描述符连接到终端。您会看到目录列表。

  5. ls程序终止,shell 重新获得控制。

现在,想象一下,您希望通过重定向输出使用>字符将目录列表写入文件。现在的顺序如下:

  1. 您键入ls > listing.txt

  2. shell 分叉一个自身的副本。

  3. 子进程打开并截断文件listing.txt,并使用dup2(2)将文件的文件描述符复制到文件描述符 1(stdout)。

  4. 子进程执行/bin/ls

  5. 程序像以前一样打印列表,但这次是写入到listing.txt

  6. ls程序终止,shell 重新获得控制。

请注意,在第三步有机会修改子进程执行程序之前的环境。ls程序不需要知道它正在写入文件而不是终端。stdout可以连接到管道,因此ls程序仍然不变,可以将输出发送到另一个程序。这是 Unix 哲学的一部分,即将许多小组件组合在一起,每个组件都能很好地完成一项工作,如The Art of Unix Programming,作者Eric Steven Raymond, Addison Wesley中所述;(2003 年 9 月 23 日)ISBN 978-0131429017,特别是在Pipes, Redirection, and Filters部分。

守护进程

我们已经在几个地方遇到了守护进程。守护进程是在后台运行的进程,由init进程,PID1拥有,并且不连接到控制终端。创建守护进程的步骤如下:

  1. 调用fork()创建一个新进程,之后父进程应该退出,从而创建一个孤儿进程,将被重新分配给init

  2. 子进程调用setsid(2),创建一个新的会话和进程组,它是唯一的成员。这里确切的细节并不重要,你可以简单地将其视为一种将进程与任何控制终端隔离的方法。

  3. 将工作目录更改为根目录。

  4. 关闭所有文件描述符,并将stdinstdoutsterr(描述符 0、1 和 2)重定向到/dev/null,以便没有输入,所有输出都被隐藏。

值得庆幸的是,所有前面的步骤都可以通过一个函数调用daemon(3)来实现。

进程间通信

每个进程都是一个内存岛。你可以通过两种方式将信息从一个进程传递到另一个进程。首先,你可以将它从一个地址空间复制到另一个地址空间。其次,你可以创建一个两者都可以访问的内存区域,从而共享数据。

通常第一种方法与队列或缓冲区结合在一起,以便进程之间有一系列消息传递。这意味着消息需要复制两次:首先到一个临时区域,然后到目的地。一些例子包括套接字、管道和 POSIX 消息队列。

第二种方法不仅需要一种将内存映射到两个(或更多)地址空间的方法,还需要一种同步访问该内存的方法,例如使用信号量或互斥体。POSIX 有所有这些功能的函数。

还有一组较旧的 API 称为 System V IPC,它提供消息队列、共享内存和信号量,但它不像 POSIX 等效果那样灵活,所以我不会在这里描述它。svipc(7)的 man 页面概述了这些设施,The Linux Programming Interface,作者Michael KerriskNo Starch PressUnix Network Programming, Volume 2,作者W. Richard Stevens中有更多细节。

基于消息的协议通常比共享内存更容易编程和调试,但如果消息很大,则速度会慢。

基于消息的 IPC

有几种选项,我将总结如下。区分它们的属性是:

  • 消息流是单向还是双向。

  • 数据流是否是字节流,没有消息边界,或者是保留边界的离散消息。在后一种情况下,消息的最大大小很重要。

  • 消息是否带有优先级标记。

以下表格总结了 FIFO、套接字和消息队列的这些属性:

属性 FIFO Unix 套接字:流 Unix 套接字:数据报 POSIX 消息队列
消息边界 字节流 字节流 离散 离散
单/双向 单向 双向 单向 单向
最大消息大小 无限制 无限制 在 100 KiB 到 250 KiB 范围内 默认:8 KiB,绝对最大:1 MiB
优先级级别 0 到 32767

Unix(或本地)套接字

Unix 套接字满足大多数要求,并且与套接字 API 的熟悉度结合在一起,它们是迄今为止最常见的机制。

Unix 套接字使用地址族AF_UNIX创建,并绑定到路径名。对套接字的访问取决于套接字文件的访问权限。与 Internet 套接字一样,套接字类型可以是SOCK_STREAMSOCK_DGRAM,前者提供双向字节流,后者提供保留边界的离散消息。Unix 套接字数据报是可靠的,这意味着它们不会被丢弃或重新排序。数据报的最大大小取决于系统,并且可以通过/proc/sys/net/core/wmem_max获得。通常为 100 KiB 或更大。

Unix 套接字没有指示消息优先级的机制。

FIFO 和命名管道

FIFO 和命名管道只是相同事物的不同术语。它们是匿名管道的扩展,用于在父进程和子进程之间通信,并用于在 shell 中实现管道。

FIFO 是一种特殊类型的文件,由命令mkfifo(1)创建。与 Unix 套接字一样,文件访问权限决定了谁可以读和写。它们是单向的,意味着有一个读取者和通常一个写入者,尽管可能有几个。数据是纯字节流,但保证了小于管道关联缓冲区的消息的原子性。换句话说,小于此大小的写入将不会分成几个较小的写入,因此读取者将一次性读取整个消息,只要读取端的缓冲区大小足够大。现代内核的 FIFO 缓冲区的默认大小为 64 KiB,并且可以使用fcntl(2)F_SETPIPE_SZ增加到/proc/sys/fs/pipe-max-size中的值,通常为 1 MiB。

没有优先级的概念。

POSIX 消息队列

消息队列由名称标识,名称必须以斜杠/开头,并且只能包含一个/字符:消息队列实际上保存在类型为mqueue的伪文件系统中。您可以通过mq_open(3)创建队列并获取对现有队列的引用,该函数返回一个文件。每条消息都有一个优先级,并且消息按优先级和年龄顺序从队列中读取。消息的最大长度可以达到/proc/sys/kernel/msgmax字节。默认值为 8 KiB,但您可以将其设置为范围为 128 字节到 1 MiB 的任何大小,方法是将该值写入/proc/sys/kernel/msgmax字节。每条消息都有一个优先级。它们按优先级和年龄顺序从队列中读取。由于引用是文件描述符,因此您可以使用select(2)poll(2)和其他类似的函数等待队列上的活动。

参见 Linux man 页面mq_overview(7)

基于消息的 IPC 的总结

Unix 套接字最常用,因为它们提供了除消息优先级之外的所有所需功能。它们在大多数操作系统上都有实现,因此具有最大的可移植性。

FIFO 很少使用,主要是因为它们缺乏数据报的等效功能。另一方面,API 非常简单,使用常规的open(2)close(2)read(2)write(2)文件调用。

消息队列是这组中最不常用的。内核中的代码路径没有像套接字(网络)和 FIFO(文件系统)调用那样进行优化。

还有更高级的抽象,特别是 dbus,它正在从主流 Linux 转移到嵌入式设备。DBus 在表面下使用 Unix 套接字和共享内存。

基于共享内存的 IPC

共享内存消除了在地址空间之间复制数据的需要,但引入了对其进行同步访问的问题。进程之间的同步通常使用信号量来实现。

POSIX 共享内存

要在进程之间共享内存,首先必须创建一个新的内存区域,然后将其映射到每个希望访问它的进程的地址空间中,如下图所示:

POSIX 共享内存

POSIX 共享内存遵循我们在消息队列中遇到的模式。段的标识以/字符开头,并且正好有一个这样的字符。函数shm_open(3)接受名称并返回其文件描述符。如果它不存在并且设置了O_CREAT标志,那么将创建一个新段。最初它的大小为零。使用(名字有点误导的)ftruncate(2)将其扩展到所需的大小。

一旦你有了共享内存的描述符,你可以使用mmap(2)将其映射到进程的地址空间中,因此不同进程中的线程可以访问该内存。

这是一个例子:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>  /* For mode constants */
#include <fcntl.h>
#include <sys/types.h>
#include <errno.h>
#include <semaphore.h>
#define SHM_SEGMENT_SIZE 65536
#define SHM_SEGMENT_NAME "/demo-shm"
#define SEMA_NAME "/demo-sem"

static sem_t *demo_sem;
/*
 * If the shared memory segment does not exist already, create it
 * Returns a pointer to the segment or NULL if there is an error
 */

static void *get_shared_memory(void)
{
  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);
    }
    /* Create a semaphore as well */
    demo_sem = sem_open(SEMA_NAME, O_RDWR | O_CREAT, 0666, 1);

    if (demo_sem == SEM_FAILED)
      perror("sem_open failed\n");
  }
  else if (shm_fd == -1 && errno == EEXIST) {
    /* Already exists: open again without O_CREAT */
    shm_fd = shm_open(SHM_SEGMENT_NAME, O_RDWR, 0);
    demo_sem = sem_open(SEMA_NAME, O_RDWR);

    if (demo_sem == SEM_FAILED)
      perror("sem_open failed\n");
  }

  if (shm_fd == -1) {
    perror("shm_open " SHM_SEGMENT_NAME);
    exit(1);
  }
  /* Map the shared memory */
  shm_p = mmap(NULL, SHM_SEGMENT_SIZE, PROT_READ | PROT_WRITE,
    MAP_SHARED, shm_fd, 0);

  if (shm_p == NULL) {
    perror("mmap");
    exit(1);
  }
  return shm_p;
}
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;
}

Linux 中的内存来自于tmpfs文件系统,挂载在/dev/shm/run/shm中。

线程

现在是时候看看多线程进程了。线程的编程接口是 POSIX 线程 API,最初在 IEEE POSIX 1003.1c 标准(1995 年)中定义,通常称为 Pthreads。它作为 C 库的附加部分实现,libpthread.so。在过去 15 年左右,已经有两个版本的 Pthreads,Linux Threads 和本地 POSIX 线程库NPTL)。后者更符合规范,特别是在处理信号和进程 ID 方面。它现在相当占主导地位,但你可能会遇到一些使用 Linux Threads 的旧版本 uClibc。

创建新线程

创建线程的函数是pthread_create(3)

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *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

程序thread-demo有两个线程。PIDPPID列显示它们都属于同一个进程,并且有相同的父进程,这是你所期望的。不过,标记为LWP的列很有趣。LWP代表轻量级进程,在这个上下文中,是线程的另一个名称。该列中的数字也被称为线程 IDTID。在主线程中,TID 与 PID 相同,但对于其他线程,它是一个不同(更高)的值。一些函数将在文档规定必须给出 PID 的地方接受 TID,但请注意,这种行为是特定于 Linux 的,不具有可移植性。以下是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;
}

有一个getttid(2)的 man 页面解释说你必须直接进行 Linux syscall,因为没有 C 库包装器,如所示。

给定内核可以调度的线程总数是有限的。该限制根据系统的大小而变化,从小型设备上的大约 1,000 个到较大嵌入式设备上的数万个。实际数量可以在/proc/sys/kernel/threads-max中找到。一旦达到这个限制,fork()pthread_create()将失败。

终止线程

线程在以下情况下终止:

  • 它到达其start_routine的末尾

  • 它调用pthread_exit(3)

  • 它被另一个线程调用pthread_cancel(3)取消

  • 包含线程的进程终止,例如,因为一个线程调用exit(3),或者进程接收到一个未处理、屏蔽或忽略的信号

请注意,如果一个多线程程序调用fork(2),只有发出调用的线程会存在于新的子进程中。fork不会复制所有线程。

线程有一个返回值,是一个 void 指针。一个线程可以通过调用pthread_join(2)等待另一个线程终止并收集其返回值。在前面部分提到的thread-demo代码中有一个例子。这会产生一个与进程中的僵尸问题非常相似的问题:线程的资源,例如堆栈,在另一个线程加入之前无法被释放。如果线程保持未加入状态,程序中就会出现资源泄漏。

使用线程编译程序

对 POSIX 线程的支持是 C 库的一部分,在库libpthread.so中。然而,构建带有线程的程序不仅仅是链接库:必须对编译器生成的代码进行更改,以确保某些全局变量,例如errno,每个线程都有一个实例,而不是整个进程共享一个。

提示

构建一个多线程程序时,您必须在编译和链接阶段添加开关-pthread

线程间通信

线程的一个巨大优势是它们共享地址空间,因此可以共享内存变量。这也是一个巨大的缺点,因为它需要同步以保持数据一致性,类似于进程之间共享的内存段,但需要注意的是,对于线程,所有内存都是共享的。线程可以使用线程本地存储TLS)创建私有内存。

pthreads接口提供了实现同步所需的基本功能:互斥锁和条件变量。如果您需要更复杂的结构,您将不得不自己构建它们。

值得注意的是,之前描述的所有 IPC 方法在同一进程中的线程之间同样有效。

互斥排除

为了编写健壮的程序,您需要用互斥锁保护每个共享资源,并确保每个读取或写入资源的代码路径都先锁定了互斥锁。如果您始终遵循这个规则,大部分问题应该可以解决。剩下的问题与互斥锁的基本行为有关。我会在这里简要列出它们,但不会详细介绍:

  • 死锁:当互斥锁永久锁定时会发生。一个经典的情况是致命的拥抱,其中两个线程分别需要两个互斥锁,并且已经锁定了其中一个,但没有锁定另一个。每个块都在等待另一个已经锁定的锁,因此它们保持原样。避免致命拥抱问题的一个简单规则是确保互斥锁总是以相同的顺序锁定。其他解决方案涉及超时和退避期。

  • 优先级反转:由于等待互斥锁造成的延迟,实时线程可能会错过截止日期。优先级反转的特定情况发生在高优先级线程因等待被低优先级线程锁定的互斥锁而被阻塞。如果低优先级线程被中间优先级的其他线程抢占,高优先级线程将被迫等待无限长的时间。有互斥锁协议称为优先级继承和优先级上限,它们以每次锁定和解锁调用在内核中产生更大的处理开销来解决问题。

  • 性能差:互斥锁会给代码引入最小的开销,只要线程大部分时间不必在其上阻塞。然而,如果您的设计有一个被许多线程需要的资源,争用比变得显著。这通常是一个设计问题,可以通过使用更细粒度的锁定或不同的算法来解决。

改变条件

合作线程需要一种方法来通知彼此发生了变化并需要关注。这个东西称为条件,警报通过条件变量condvar发送。

条件只是一个可以测试以给出truefalse结果的东西。一个简单的例子是一个包含零个或一些项目的缓冲区。一个线程从缓冲区中取出项目,并在空时休眠。另一个线程将项目放入缓冲区,并通知另一个线程已经这样做了,因为另一个线程正在等待的条件已经改变。如果它正在休眠,它需要醒来并做一些事情。唯一的复杂性在于条件是一个共享资源,因此必须受到互斥锁的保护。以下是一个简单的例子,遵循了前一节描述的生产者-消费者关系:

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 (buffer_empty(data))
      pthread_cond_wait(&cv, &mutx);
    /* Got data: take from buffer */
    pthread_mutex_unlock(&mutx);
    /* Process data item */
  }
  return NULL;
}

void *producer(void *arg)
{
  while (1) {
    /* Produce an item of data */
    pthread_mutex_lock(&mutx);
    add_data(data);
    pthread_mutex_unlock(&mutx);
    pthread_cond_signal(&cv);
  }
  return NULL;
}

请注意,当消费者线程在condvar上阻塞时,它是在持有锁定的互斥锁的情况下这样做的,这似乎是下一次生产者线程尝试更新条件时产生死锁的原因。为了避免这种情况,pthread_condwait(3)在线程被阻塞后解锁互斥锁,并在唤醒它并从等待中返回时再次锁定它。

问题的分区

现在我们已经介绍了进程和线程的基础知识以及它们之间的通信方式,是时候看看我们可以用它们做些什么了。

以下是我在构建系统时使用的一些规则:

  • 规则 1:保持具有大量交互的任务。

通过将紧密相互操作的线程放在一个进程中,最小化开销。

  • 规则 2:不要把所有的线程放在一个篮子里。

另一方面,为了提高韧性和模块化,尽量将交互有限的组件放在单独的进程中。

  • 规则 3:不要在同一个进程中混合关键和非关键线程。

这是对规则 2 的进一步阐释:系统的关键部分,可能是机器控制程序,应尽可能简单,并以比其他部分更严格的方式编写。它必须能够在其他进程失败时继续运行。如果有实时线程,它们必须是关键的,并且应该单独放入一个进程中。

  • 规则 4:线程不应该过于亲密。

编写多线程程序时的一个诱惑是在线程之间交织代码和变量,因为它们都在一个程序中,很容易做到。不要让线程之间的交互模块化。

  • 规则 5:不要认为线程是免费的。

创建额外的线程非常容易,但成本很高,尤其是在协调它们的活动所需的额外同步方面。

  • 规则 6:线程可以并行工作。

线程可以在多核处理器上同时运行,从而提高吞吐量。如果有一个庞大的计算任务,可以为每个核心创建一个线程,并充分利用硬件。有一些库可以帮助你做到这一点,比如 OpenMP。你可能不应该从头开始编写并行编程算法。

Android 设计是一个很好的例子。每个应用程序都是一个单独的 Linux 进程,这有助于模块化内存管理,尤其是确保一个应用程序崩溃不会影响整个系统。进程模型也用于访问控制:一个进程只能访问其 UID 和 GID 允许的文件和资源。每个进程中都有一组线程。有一个用于管理和更新用户界面的线程,一个用于处理来自操作系统的信号,几个用于管理动态内存分配和释放 Java 对象,以及至少两个线程的工作池,用于使用 Binder 协议从系统的其他部分接收消息。

总之,进程提供了韧性,因为每个进程都有受保护的内存空间,当进程终止时,包括内存和文件描述符在内的所有资源都被释放,减少了资源泄漏。另一方面,线程共享资源,因此可以通过共享变量轻松通信,并且可以通过共享对文件和其他资源的访问来合作。线程通过工作池和其他抽象提供并行性,在多核处理器上非常有用。

调度

我想在本章中要讨论的第二个重要主题是调度。Linux 调度器有一个准备运行的线程队列,其工作是在 CPU 上安排它们。每个线程都有一个调度策略,可以是时间共享或实时。时间共享线程有一个 niceness 值,它增加或减少它们对 CPU 时间的权利。实时线程有一个优先级,较高优先级的线程将抢占较低优先级的线程。调度器与线程一起工作,而不是进程。每个线程都会被安排,不管它运行在哪个进程中。

调度器在以下情况下运行:

  • 线程通过调用sleep()或阻塞 I/O 调用来阻塞

  • 时间共享线程耗尽了其时间片

  • 中断会导致线程解除阻塞,例如,因为 I/O 完成。

关于 Linux 调度器的背景信息,我建议阅读Linux Kernel Development中关于进程调度的章节,作者是 Robert Love,Addison-Wesley Professional 出版社,ISBN-10: 0672329468。

公平性与确定性

我将调度策略分为时间共享和实时两类。时间共享策略基于公平原则。它们旨在确保每个线程获得公平的处理器时间,并且没有线程可以独占系统。如果一个线程运行时间过长,它将被放到队列的末尾,以便其他线程有机会运行。同时,公平策略需要调整到正在执行大量工作的线程,并为它们提供资源以完成工作。时间共享调度很好,因为它可以自动调整到各种工作负载。

另一方面,如果你有一个实时程序,公平性是没有帮助的。相反,你需要一个确定性的策略,它至少会给你最小的保证,即你的实时线程将在正确的时间被调度,以便它们不会错过截止日期。这意味着实时线程必须抢占时间共享线程。实时线程还有一个静态优先级,调度器可以用它来在多个实时线程同时运行时进行选择。Linux 实时调度器实现了一个相当标准的算法,它运行最高优先级的实时线程。大多数 RTOS 调度器也是以这种方式编写的。

两种类型的线程可以共存。需要确定性调度的线程首先被调度,剩下的时间被分配给时间共享线程。

时间共享策略

时间共享策略是为了公平而设计的。从 Linux 2.6.23 开始,使用的调度器是Completely Fair SchedulerCFS)。它不像通常意义上的时间片。相反,它计算了一个线程如果拥有其公平份额的 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,因此可以更改进程中其他线程的参数:

pthread_setschedparam(pthread_t thread, int policy, const struct sched_param *param);
pthread_getschedparam(pthread_t thread, int *policy, struct sched_param *param);

Niceness

有些时间共享线程比其他线程更重要。您可以使用 nice 值来指示这一点,它将线程的 CPU 权利乘以一个缩放因子。这个名字来自于 Unix 早期的函数调用 nice(2)。通过减少系统上的负载,线程变得nice,或者通过增加负载来朝相反方向移动。值的范围是从 19(非常 nice)到 -20(非常不 nice)。默认值是 0,即平均 nice 或一般般。

nice 值可以更改 SCHED_NORMALSCHED_BATCH 线程的值。要减少 niceness,增加 CPU 负载,您需要 CAP_SYS_NICE 权限,这仅适用于 root 用户。

几乎所有更改 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 还是其他地方,是线程变得计算密集,通常是因为错误导致其无限循环,这会阻止优先级较低的实时线程以及所有时间共享线程运行。系统变得不稳定,甚至可能完全锁死。有几种方法可以防范这种可能性。

首先,自 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 周期。可以将积极的优先级值应用于不太重要的线程,将负值应用于重要的线程。

当然,这只是平均行为,不能保证这种情况总是存在。如果需要更确定的行为,则需要实时策略。标记线程为实时的因素包括:

  • 它有一个必须生成输出的截止日期

  • 错过截止日期将损害系统的有效性

  • 它是事件驱动的

  • 它不是计算绑定的

实时任务的示例包括经典的机器人臂伺服控制器,多媒体处理和通信处理。

选择实时优先级

选择适用于所有预期工作负载的实时优先级是一个棘手的问题,也是避免首先使用实时策略的一个很好的理由。

选择优先级的最常用程序称为速率单调分析RMA),根据 1973 年 Liu 和 Layland 的论文。它适用于具有周期性线程的实时系统,这是一个非常重要的类别。每个线程都有一个周期和一个利用率,即其执行期的比例。目标是平衡负载,以便所有线程都能在下一个周期之前完成其执行阶段。RMA 规定,如果:

  • 最高优先级给予具有最短周期的线程

  • 总利用率低于 69%

总利用率是所有个体利用率的总和。它还假设线程之间的交互或在互斥锁上阻塞的时间是可以忽略不计的。

进一步阅读

以下资源提供了有关本章介绍的主题的更多信息:

  • 《Unix 编程艺术》,作者Eric Steven RaymondAddison Wesley;(2003 年 9 月 23 日)ISBN 978-0131429017

  • 《Linux 系统编程,第二版》,作者Robert LoveO'Reilly Media;(2013 年 6 月 8 日)ISBN-10:1449339530

  • 《Linux 内核开发》,Robert LoveAddison-Wesley Professional;(2010 年 7 月 2 日)ISBN-10:0672329468

  • 《Linux 编程接口》,作者Michael KerriskNo Starch Press;(2010 年 10 月)ISBN 978-1-59327-220-3

  • 《UNIX 网络编程:卷 2:进程间通信,第二版》,作者W. Richard StevensPrentice Hall;(1998 年 8 月 25 日)ISBN-10:0132974290

  • 《使用 POSIX 线程编程》,作者ButenhofDavid RAddison-WesleyProfessional

  • 《硬实时环境中的多道程序调度算法》,作者C. L. LiuJames W. LaylandACM 杂志,1973 年,第 20 卷,第 1 期,第 46-61 页

总结

内置在 Linux 和附带的 C 库中的长期 Unix 传统几乎提供了编写稳定和弹性嵌入式应用程序所需的一切。问题在于,对于每项工作,至少有两种方法可以实现您所期望的结果。

在本章中,我专注于系统设计的两个方面:将其分成单独的进程,每个进程都有一个或多个线程来完成工作,以及对这些线程进行调度。我希望我已经为您解开了一些疑惑,并为您进一步研究所有这些内容提供了基础。

在下一章中,我将研究系统设计的另一个重要方面,即内存管理。

第十一章:管理内存

本章涵盖了与内存管理相关的问题,这对于任何 Linux 系统都是一个重要的话题,但对于嵌入式 Linux 来说尤其重要,因为系统内存通常是有限的。在简要回顾了虚拟内存之后,我将向您展示如何测量内存使用情况,如何检测内存分配的问题,包括内存泄漏,以及当内存用尽时会发生什么。您必须了解可用的工具,从简单的工具如freetop,到复杂的工具如 mtrace 和 Valgrind。

虚拟内存基础知识

总之,Linux 配置 CPU 的内存管理单元,向运行的程序呈现一个虚拟地址空间,从零开始,到 32 位处理器上的最高地址0xffffffff结束。该地址空间被分成 4 KiB 的页面(也有一些罕见的系统使用其他页面大小)。

Linux 将这个虚拟地址空间分为一个称为用户空间的应用程序区域和一个称为内核空间的内核区域。这两者之间的分割由一个名为PAGE_OFFSET的内核配置参数设置。在典型的 32 位嵌入式系统中,PAGE_OFFSET0xc0000000,将低 3 GiB 分配给用户空间,将顶部 1 GiB 分配给内核空间。用户地址空间是针对每个进程分配的,因此每个进程都在一个沙盒中运行,与其他进程分离。内核地址空间对所有进程都是相同的:只有一个内核。

这个虚拟地址空间中的页面通过内存管理单元MMU)映射到物理地址,后者使用页表执行映射。

每个虚拟内存页面可能是:

  • 未映射,访问将导致SIGSEGV

  • 映射到进程私有的物理内存页面

  • 映射到与其他进程共享的物理内存页面

  • 映射并与设置了写时复制标志的共享:写入被内核捕获,内核复制页面并将其映射到原始页面的进程中,然后允许写入发生

  • 映射到内核使用的物理内存页面

内核可能还会将页面映射到保留的内存区域,例如,以访问设备驱动程序中的寄存器和缓冲内存

一个明显的问题是,为什么我们要这样做,而不是像典型的 RTOS 那样直接引用物理内存?

虚拟内存有许多优点,其中一些在这里描述:

  • 无效的内存访问被捕获,并通过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)

505980 KiB 可用的数字是内核在开始执行但在开始进行动态分配之前看到的空闲内存量。

内核空间内存的使用者包括以下内容:

  • 内核本身,换句话说,从内核映像文件在启动时加载的代码和数据。这在前面的代码中显示在段.text.init.data.bss中。一旦内核完成初始化,.init段就被释放。

  • 通过 slab 分配器分配的内存,用于各种内核数据结构。这包括使用kmalloc()进行的分配。它们来自标记为lowmem的区域。

  • 通过vmalloc()分配的内存,通常比通过kmalloc()可用的内存大。这些位于 vmalloc 区域。

  • 用于设备驱动程序访问属于各种硬件部分的寄存器和内存的映射,可以通过阅读/proc/iomem来查看。这些来自 vmalloc 区域,但由于它们映射到主系统内存之外的物理内存,它们不占用任何真实的内存。

  • 内核模块,加载到标记为模块的区域。

  • 其他低级别的分配在其他地方没有被跟踪。

内核使用多少内存?

不幸的是,对于这个问题并没有一个完整的答案,但接下来的内容是我们能得到的最接近的。

首先,你可以在之前显示的内核日志中看到内核代码和数据占用的内存,或者你可以使用size命令,如下所示:

$ arm-poky-linux-gnueabi-size vmlinux
text      data     bss       dec       hex       filename
9013448   796868   8428144   18238460  1164bfc   vmlinux

通常,与总内存量相比,大小很小。如果不是这样,你需要查看内核配置,并删除那些你不需要的组件。目前正在努力允许构建小内核:搜索 Linux-tiny 或 Linux Kernel Tinification。后者有一个项目页面tiny.wiki.kernel.org/

你可以通过阅读/proc/meminfo来获取有关内存使用情况的更多信息:

# cat /proc/meminfo
MemTotal:         509016 kB
MemFree:          410680 kB
Buffers:            1720 kB
Cached:            25132 kB
SwapCached:            0 kB
Active:            74880 kB
Inactive:           3224 kB
Active(anon):      51344 kB
Inactive(anon):     1372 kB
Active(file):      23536 kB
Inactive(file):     1852 kB
Unevictable:           0 kB
Mlocked:               0 kB
HighTotal:             0 kB
HighFree:              0 kB
LowTotal:         509016 kB
LowFree:          410680 kB
SwapTotal:             0 kB
SwapFree:              0 kB
Dirty:                16 kB
Writeback:             0 kB
AnonPages:         51248 kB
Mapped:            24376 kB
Shmem:              1452 kB
Slab:              11292 kB
SReclaimable:       5164 kB
SUnreclaim:         6128 kB
KernelStack:        1832 kB
PageTables:         1540 kB
NFS_Unstable:          0 kB
Bounce:                0 kB
WritebackTmp:          0 kB
CommitLimit:      254508 kB
Committed_AS:     734936 kB
VmallocTotal:     499712 kB
VmallocUsed:       29576 kB
VmallocChunk:     389116 kB

这些字段的描述在proc(5)的 man 页面中。内核内存使用是以下内容的总和:

  • 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

这留下了低级别的分配,没有记录,这阻止我们生成一个准确的内核空间内存使用情况。当我们把我们知道的所有内核和用户空间分配加起来时,这将出现为缺失的内存。

用户空间内存布局

Linux 采用懒惰的分配策略,只有在程序访问时才映射物理内存页面。例如,使用malloc(3)分配 1 MiB 的缓冲区返回一个内存地址块的指针,但没有实际的物理内存。在页表条目中设置一个标志,以便内核捕获任何读取或写入访问。这就是所谓的页错误。只有在这一点上,内核才尝试找到一个物理内存页,并将其添加到进程的页表映射中。值得用一个简单的程序来演示这一点:

#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 MiB,即 256 页。第二次调用memset(3)没有任何区别,因为现在所有页面都已映射。

正如您所看到的,当内核捕获到对未映射的页面的访问时,将生成页面错误。实际上,有两种页面错误:次要和主要。次要错误时,内核只需找到一个物理内存页面并将其映射到进程地址空间,如前面的代码所示。主要页面错误发生在虚拟内存映射到文件时,例如使用mmap(2),我将很快描述。从该内存中读取意味着内核不仅需要找到一个内存页面并将其映射进来,还需要从文件中填充数据。因此,主要错误在时间和系统资源方面要昂贵得多。

进程内存映射

您可以通过proc文件系统查看进程的内存映射。例如,这是init进程的 PID 1 的映射:

# cat /proc/1/maps
00008000-0000e000 r-xp 00000000 00:0b 23281745   /sbin/init
00016000-00017000 rwxp 00006000 00:0b 23281745   /sbin/init
00017000-00038000 rwxp 00000000 00:00 0          [heap]
b6ded000-b6f1d000 r-xp 00000000 00:0b 23281695   /lib/libc-2.19.so
b6f1d000-b6f24000 ---p 00130000 00:0b 23281695   /lib/libc-2.19.so
b6f24000-b6f26000 r-xp 0012f000 00:0b 23281695   /lib/libc-2.19.so
b6f26000-b6f27000 rwxp 00131000 00:0b 23281695   /lib/libc-2.19.so
b6f27000-b6f2a000 rwxp 00000000 00:00 0
b6f2a000-b6f49000 r-xp 00000000 00:0b 23281359   /lib/ld-2.19.so
b6f4c000-b6f4e000 rwxp 00000000 00:00 0
b6f4f000-b6f50000 r-xp 00000000 00:00 0          [sigpage]
b6f50000-b6f51000 r-xp 0001e000 00:0b 23281359   /lib/ld-2.19.so
b6f51000-b6f52000 rwxp 0001f000 00:0b 23281359   /lib/ld-2.19.so
beea1000-beec2000 rw-p 00000000 00:00 0          [stack]
ffff0000-ffff1000 r-xp 00000000 00:00 0          [vectors]

前三列显示每个映射的开始和结束虚拟地址以及权限。权限在这里显示:

  • r = 读

  • w = 写

  • x = 执行

  • s = 共享

  • p = 私有(写时复制)

如果映射与文件相关联,则文件名将出现在最后一列,第四、五和六列包含从文件开始的偏移量,块设备号和文件的 inode。大多数映射都是到程序本身和它链接的库。程序可以分配内存的两个区域,标记为[heap][stack]。使用malloc(3)分配的内存来自前者(除了非常大的分配,我们稍后会讨论);堆栈上的分配来自后者。两个区域的最大大小由进程的ulimit控制:

  • ulimit -d,默认无限制

  • 堆栈ulimit -s,默认 8 MiB

超出限制的分配将被SIGSEGV拒绝。

当内存不足时,内核可能决定丢弃映射到文件且只读的页面。如果再次访问该页面,将导致主要页面错误,并从文件中重新读取。

交换

交换的想法是保留一些存储空间,内核可以将未映射到文件的内存页面放置在其中,以便它可以释放内存供其他用途使用。它通过交换文件的大小增加了物理内存的有效大小。这并非是万能药:将页面复制到交换文件和从交换文件复制页面都会产生成本,这在承载工作负载的真实内存太少的系统上变得明显,并开始磁盘抖动

在嵌入式设备上很少使用交换,因为它与闪存存储不兼容,常量写入会迅速磨损。但是,您可能希望考虑交换到压缩的 RAM(zram)。

交换到压缩内存(zram)

zram 驱动程序创建名为/dev/zram0/dev/zram1等的基于 RAM 的块设备。写入这些设备的页面在存储之前会被压缩。通过 30%至 50%的压缩比,您可以预期整体空闲内存增加约 10%,但会增加更多的处理和相应的功耗。它在一些低内存的 Android 设备上使用。

要启用 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

使用 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的文件中的offset开始映射length字节的内存,并在成功时返回映射的指针。由于底层硬件以页面为单位工作,length被舍入到最接近的整页数。保护参数prot是读、写和执行权限的组合,flags参数至少包含MAP_SHAREDMAP_PRIVATE。还有许多其他标志,这些标志在 man 页面中有描述。

mmap 有许多用途。以下是其中一些。

使用 mmap 分配私有内存

您可以使用 mmap 通过设置MAP_ANONYMOUS标志和fd文件描述符为-1来分配一个私有内存区域。这类似于使用malloc(3)从堆中分配内存,只是内存是按页对齐的,并且是页的倍数。内存分配在与库相同的区域。事实上,出于这个原因,一些人称该区域为 mmap 区域。

匿名映射更适合大型分配,因为它们不会用内存块固定堆,这会增加碎片化的可能性。有趣的是,您会发现malloc(3)(至少在 glibc 中)停止为超过 128 KiB 的请求从堆中分配内存,并以这种方式使用 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);

使用 mmap 访问设备内存

正如我在第八章中提到的,介绍设备驱动程序,驱动程序可以允许其设备节点被 mmap,并与应用程序共享一些设备内存。确切的实现取决于驱动程序。

一个例子是 Linux 帧缓冲区,/dev/fb0。该接口在/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, f, 0);
/* read and write pixels through pointer fb_mem */

第二个例子是流媒体视频接口,Video 4 Linux,版本 2,或者 V4L2,它在/usr/include/linux/videodev2.h中定义。每个视频设备都有一个名为/dev/videoN的节点,从/dev/video0开始。有一个ioctl函数来请求驱动程序分配一些视频缓冲区,你可以将其映射到用户空间。然后,只需要循环缓冲区并根据播放或捕获视频流的情况填充或清空它们。

我的应用程序使用了多少内存?

与内核空间一样,分配、映射和共享用户空间内存的不同方式使得回答这个看似简单的问题变得相当困难。

首先,您可以询问内核认为有多少可用内存,可以使用free命令来执行此操作。以下是输出的典型示例:

             total     used     free   shared  buffers   cached
Mem:        509016   504312     4704        0    26456   363860
-/+ buffers/cache:   113996   395020
Swap:            0        0        0

提示

乍一看,这看起来像是一个几乎没有内存的系统,只有 4704 KiB 的空闲内存,占用了 509,016 KiB 的不到 1%。然而,请注意,26,456 KiB 在缓冲区中,而 363,860 KiB 在缓存中。Linux 认为空闲内存是浪费的内存,因此内核使用空闲内存用于缓冲区和缓存,因为它们在需要时可以被收缩。从测量中去除缓冲区和缓存可以得到真正的空闲内存,即 395,020 KiB;占总量的 77%。在使用 free 时,标有-/+ buffers/cache的第二行上的数字是重要的。

您可以通过向/proc/sys/vm/drop_caches写入 1 到 3 之间的数字来强制内核释放缓存:

# echo 3 > /proc/sys/vm/drop_caches

实际上,该数字是一个位掩码,用于确定您要释放的两种广义缓存中的哪一种:1 表示页面缓存,2 表示 dentry 和 inode 缓存的组合。这些缓存的确切作用在这里并不特别重要,只是内核正在使用的内存可以在短时间内被回收。

每个进程的内存使用

有几种度量方法可以衡量进程使用的内存量。我将从最容易获得的两种开始——虚拟集大小vss)和驻留内存大小rss),这两种在大多数pstop命令的实现中都可以获得:

  • Vss:在ps命令中称为 VSZ,在top中称为 VIRT,是进程映射的内存总量。它是/proc/<PID>/map中显示的所有区域的总和。这个数字的兴趣有限,因为只有部分虚拟内存在任何时候都被分配到物理内存。

  • Rss:在ps中称为 RSS,在top中称为 RES,是映射到物理内存页面的内存总和。这更接近进程的实际内存预算,但是有一个问题,如果将所有进程的 Rss 相加,您将高估内存的使用,因为一些页面将是共享的。

使用 top 和 ps

BusyBox 的topps版本提供的信息非常有限。以下示例使用了procps包中的完整版本。

ps命令显示了 Vss(VSZ)和 Rss(RSS)以及包括vszrss的自定义格式,如下所示:

# 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,  0.2 si,  0.0 st
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
1098 debian    20   0 29076  16m 8312 S   0.0  3.2   0:01.29 wicd-client
  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。因此,如果将所有进程的 Pss 数字相加,就可以得到这些进程实际使用的内存量。换句话说,Pss 就是我们一直在寻找的数字。

这些信息可以在/proc/<PID>/smaps中找到,其中包含了/proc/<PID>/maps中显示的每个映射的附加信息。以下是这样一个文件中的一个部分,它提供了有关libc代码段的映射的信息:

b6e6d000-b6f45000 r-xp 00000000 b3:02 2444 /lib/libc-2.13.so
Size:                864 kB
Rss:                 264 kB
Pss:                   6 kB
Shared_Clean:        264 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:          264 kB
Anonymous:             0 kB
AnonHugePages:         0 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Locked:                0 kB
VmFlags: rd ex mr mw me

注意

请注意,Rss 为 264 KiB,但由于它在许多其他进程之间共享,因此 Pss 只有 6 KiB。

有一个名为 smem 的工具,它汇总了smaps文件中的信息,并以各种方式呈现,包括饼图或条形图。smem 的项目页面是www.selenic.com/smem。它在大多数桌面发行版中都作为一个软件包提供。但是,由于它是用 Python 编写的,在嵌入式目标上安装它需要一个 Python 环境,这可能会给一个工具带来太多麻烦。为了解决这个问题,有一个名为smemcap的小程序,它可以在目标上捕获/proc的状态,并将其保存到一个 TAR 文件中,以便稍后在主机计算机上进行分析。它是 BusyBox 的一部分,但也可以从smem源代码编译而成。

root身份本地运行smem,你会看到这些结果:

# smem -t
 PID User  Command                   Swap      USS     PSS     RSS
 610 0     /sbin/agetty -s ttyO0 11     0      128     149     720
1236 0     /sbin/agetty -s ttyGS0 1     0      128     149     720
 609 0     /sbin/agetty tty1 38400      0      144     163     724
 578 0     /usr/sbin/acpid              0      140     173     680
 819 0     /usr/sbin/cron               0      188     201     704
 634 103   avahi-daemon: chroot hel     0      112     205     500
 980 0     /usr/sbin/udhcpd -S /etc     0      196     205     568
  ...
 836 0     /usr/bin/X :0 -auth /var     0     7172    7746    9212
 583 0     /usr/bin/node autorun.js     0     8772    9043   10076
1089 1000  /usr/bin/python -O /usr/     0     9600   11264   16388
------------------------------------------------------------------
  53 6                                  0    65820   78251  146544

从输出的最后一行可以看出,在这种情况下,总的 Pss 大约是 Rss 的一半。

如果你没有或不想在目标上安装 Python,你可以再次以root身份使用smemcap来捕获状态:

# smemcap > smem-bbb-cap.tar

然后,将 TAR 文件复制到主机并使用smem -S读取,尽管这次不需要以root身份运行:

$ smem -t -S smem-bbb-cap.tar

输出与本地运行时的输出相同。

其他需要考虑的工具

另一种显示 Pss 的方法是通过ps_mem(github.com/pixelb/ps_mem),它以更简单的格式打印几乎相同的信息。它也是用 Python 编写的。

Android 也有一个名为procrank的工具,可以通过少量更改在嵌入式 Linux 上进行交叉编译。你可以从github.com/csimmonds/procrank_linux获取代码。

识别内存泄漏

内存泄漏发生在分配内存后不释放它,当它不再需要时。内存泄漏并不是嵌入式系统特有的问题,但它成为一个问题的部分原因是目标本来就没有太多内存,另一部分原因是它们经常长时间运行而不重启,导致泄漏变成了一个大问题。

当你运行freetop并看到可用内存不断减少时,即使你清除缓存,你会意识到有一个泄漏,如前面的部分所示。你可以通过查看每个进程的 Uss 和 Rss 来确定罪魁祸首(或罪魁祸首)。

有几种工具可以识别程序中的内存泄漏。我将看两种:mtraceValgrind

mtrace

mtrace是 glibc 的一个组件,它跟踪对malloc(3)free(3)和相关函数的调用,并在程序退出时识别未释放的内存区域。你需要在程序内部调用mtrace()函数开始跟踪,然后在运行时,将路径名写入MALLOC_TRACE环境变量,以便写入跟踪信息的文件。如果MALLOC_TRACE不存在或文件无法打开,mtrace钩子将不会安装。虽然跟踪信息是以 ASCII 形式写入的,但通常使用mtrace命令来查看它。

这是一个例子:

#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:11
0x00000000014794d0     0x64  at /home/chris/mtrace-example.c:11
0x0000000001479540    0x100  at /home/chris/mtrace-example.c:15

不幸的是,mtrace在程序运行时不能告诉你有关泄漏内存的信息。它必须先终止。

Valgrind

Valgrind 是一个非常强大的工具,可以发现内存问题,包括泄漏和其他问题。一个优点是你不必重新编译要检查的程序和库,尽管如果它们已经使用-g选项编译,以便包含调试符号表,它的工作效果会更好。它通过在模拟环境中运行程序并在各个点捕获执行来工作。这导致 Valgrind 的一个很大的缺点,即程序以正常速度的一小部分运行,这使得它对测试任何具有实时约束的东西不太有用。

注意

顺便说一句,这个名字经常被错误发音:Valgrind 的 FAQ 中说grind的发音是短的i--就像grinned(押韵tinned)而不是grined(押韵find)。FAQ、文档和下载都可以在valgrind.org找到。

Valgrind 包含几个诊断工具:

  • memcheck:这是默认工具,用于检测内存泄漏和内存的一般误用

  • cachegrind:这个工具计算处理器缓存命中率

  • callgrind:这个工具计算每个函数调用的成本

  • helgrind:这个工具用于突出显示 Pthread API 的误用、潜在死锁和竞争条件

  • DRD:这是另一个 Pthread 分析工具

  • massif:这个工具用于分析堆和栈的使用情况

您可以使用-tool选项选择您想要的工具。Valgrind 可以在主要的嵌入式平台上运行:ARM(Cortex A)、PPC、MIPS 和 32 位和 64 位的 x86。它在 Yocto Project 和 Buildroot 中都作为一个软件包提供。

要找到我们的内存泄漏,我们需要使用默认的memcheck工具,并使用选项--leakcheck=full来打印出发现泄漏的行:

$ valgrind --leak-check=full ./mtrace-example
==17235== Memcheck, a memory error detector
==17235== Copyright (C) 2002-2013, and GNU GPL'd, by Julian Seward et al.
==17235== Using Valgrind-3.10.0.SVN and LibVEX; rerun with -h for copyright info
==17235== Command: ./mtrace-example
==17235==
==17235==
==17235== HEAP SUMMARY:
==17235==  in use at exit: 456 bytes in 3 blocks
==17235==  total heap usage: 3 allocs, 0 frees, 456 bytes allocated
==17235==
==17235== 200 bytes in 2 blocks are definitely lost in loss record 1 of 2
==17235==    at 0x4C2AB80: malloc (in /usr/lib/valgrind/vgpreload_memcheck-linux.so)
==17235==    by 0x4005FA: main (mtrace-example.c:12)
==17235==
==17235== 256 bytes in 1 blocks are definitely lost in loss record 2 of 2
==17235==    at 0x4C2CC70: calloc (in /usr/lib/valgrind/vgpreload_memcheck-linux.so)
==17235==    by 0x400613: main (mtrace-example.c:14)
==17235==
==17235== LEAK SUMMARY:
==17235==    definitely lost: 456 bytes in 3 blocks
==17235==    indirectly lost: 0 bytes in 0 blocks
==17235==      possibly lost: 0 bytes in 0 blocks
==17235==    still reachable: 0 bytes in 0 blocks
==17235==         suppressed: 0 bytes in 0 blocks
==17235==
==17235== For counts of detected and suppressed errors, rerun with: -v
==17235== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)

内存不足

标准的内存分配策略是过度承诺,这意味着内核将允许应用程序分配比物理内存更多的内存。大多数情况下,这很好用,因为应用程序通常会请求比实际需要的更多的内存。它还有助于fork(2)的实现:可以安全地复制一个大型程序,因为内存页面是带有copy-on-write标志的共享的。在大多数情况下,fork后会调用exec函数,这样就会取消内存共享,然后加载一个新程序。

然而,总有可能某个特定的工作负载会导致一组进程同时尝试兑现它们被承诺的分配,因此需求超过了实际存在的内存。这是一种内存不足的情况,或者OOM。在这一点上,除了杀死进程直到问题消失之外别无选择。这是内存不足杀手的工作。

在我们讨论这些之前,有一个内核分配的调整参数在/proc/sys/vm/overcommit_memory中,你可以将其设置为:

  • 0:启发式过度承诺(这是默认设置)

  • 1:始终过度承诺,永不检查

  • 2:始终检查,永不过度承诺

选项 1 只有在运行与大型稀疏数组一起工作并分配大量内存但只写入其中一小部分的程序时才真正有用。在嵌入式系统的环境中,这样的程序很少见。

选项 2,永不过度承诺,似乎是一个不错的选择,如果您担心内存不足,也许是在任务或安全关键的应用中。它将失败于大于承诺限制的分配,这个限制是交换空间的大小加上总内存乘以过度承诺比率。过度承诺比率由/proc/sys/vm/overcommit_ratio控制,默认值为 50%。

例如,假设您有一台设备,配备了 512MB 的系统 RAM,并设置了一个非常保守的比率为 25%:

# echo 25 > /proc/sys/vm/overcommit_ratio
# grep -e MemTotal -e CommitLimit /proc/meminfo
MemTotal:         509016 kB
CommitLimit:      127252 kB

没有交换空间,因此承诺限制是MemTotal的 25%,这是预期的。

/proc/meminfo中还有另一个重要的变量:Committed_AS。这是迄今为止需要满足所有分配的总内存量。我在一个系统上找到了以下内容:

# grep -e MemTotal -e Committed_AS /proc/meminfo
MemTotal:         509016 kB
Committed_AS:     741364 kB

换句话说,内核已经承诺了比可用内存更多的内存。因此,将overcommit_memory设置为2意味着所有分配都会失败,而不管overcommit_ratio如何。要使系统正常工作,我要么必须安装双倍的 RAM,要么严重减少正在运行的进程数量,大约有 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,因此将始终被杀死。

进一步阅读

以下资源提供了有关本章介绍的主题的进一步信息:

  • Linux 内核开发,第 3 版,作者Robert LoveAddison WesleyO'Reilly Media; (2010 年 6 月) ISBN-10: 0672329468

  • Linux 系统编程,第 2 版,作者Robert LoveO'Reilly Media; (2013 年 6 月 8 日) ISBN-10: 1449339530

  • 了解 Linux VM 管理器,作者Mel Gormanwww.kernel.org/doc/gorman/pdf/understand.pdf

  • Valgrind 3.3 - Gnu/Linux 应用程序的高级调试和性能分析,作者J SewardN. NethercoteJ. WeidendorferNetwork Theory Ltd; (2008 年 3 月 1 日) ISBN 978-0954612054

摘要

在虚拟内存系统中考虑每个内存使用的字节是不可能的。但是,您可以使用free命令找到一个相当准确的总空闲内存量,不包括缓冲区和缓存所占用的内存。通过在一段时间内监视它,并使用不同的工作负载,您应该对它将保持在给定限制内感到自信。

当您想要调整内存使用情况或识别意外分配的来源时,有一些资源可以提供更详细的信息。对于内核空间,最有用的信息在于/proc: meminfoslabinfovmallocinfo

在获取用户空间的准确测量方面,最佳指标是 Pss,如smem和其他工具所示。对于内存调试,您可以从诸如mtrace之类的简单跟踪器获得帮助,或者您可以选择使用 Valgrind memcheck 工具这样的重量级选项。

如果您担心内存不足的后果,您可以通过/proc/sys/vm/overcommit_memory微调分配机制,并且可以通过oom_score_adj参数控制特定进程被杀死的可能性。

下一章将全面介绍如何使用 GNU 调试器调试用户空间和内核代码,以及您可以从观察代码运行中获得的见解,包括我在这里描述的内存管理函数。

第十二章:使用 GDB 进行调试

错误是难免的。识别和修复它们是开发过程的一部分。有许多不同的技术用于查找和表征程序缺陷,包括静态和动态分析,代码审查,跟踪,性能分析和交互式调试。我将在下一章中介绍跟踪器和性能分析器,但在这里,我想集中讨论通过调试器观察代码执行的传统方法,也就是我们的情况下的 GNU 调试器 GDB。GDB 是一个强大而灵活的工具。您可以使用它来调试应用程序,检查程序崩溃后生成的后期文件(core文件),甚至逐步执行内核代码。

在本章中,我将向您展示如何使用 GDB 调试应用程序,如何查看核心文件以及如何调试内核代码,重点是与嵌入式 Linux 相关的方面。

GNU 调试器

GDB 是用于编译语言的源级调试器,主要用于 C 和 C++,尽管也支持各种其他语言,如 Go 和 Objective。您应该阅读您正在使用的 GDB 版本的说明,以了解对各种语言的支持的当前状态。项目网站是www.gnu.org/software/gdb,其中包含了许多有用的信息,包括 GDB 手册。

GDB 默认具有命令行用户界面,有些人可能会觉得这个界面令人望而却步,但实际上,只要稍加练习,就会发现它很容易使用。如果您不喜欢命令行界面,那么有很多 GDB 的前端用户界面可供选择,我稍后会描述其中的三个。

准备调试

您需要使用调试符号编译要调试的代码。GCC 提供了两个选项:-g-ggdb。后者添加了特定于 GDB 的调试信息,而前者生成了适合您使用的目标操作系统的适当格式的信息,使其更具可移植性。在我们的特定情况下,目标操作系统始终是 Linux,无论您使用-g还是-ggdb都没有太大区别。更有趣的是,这两个选项都允许您指定调试信息的级别,从 0 到 3:

  • 0:这根本不生成调试信息,等同于省略-g-ggdb开关

  • 1:这产生的信息很少,但包括函数名称和外部变量,足以生成回溯

  • 2:这是默认设置,包括有关局部变量和行号的信息,以便您可以进行源级调试并逐步执行代码

  • 3:这包括额外的信息,其中包括 GDB 正确处理宏扩展

在大多数情况下,-g足够了,但如果您在通过代码时遇到问题,特别是如果它包含宏,那么请保留-g3-ggdb3

要考虑的下一个问题是代码优化级别。编译器优化往往会破坏源代码和机器代码之间的关系,这使得通过源代码进行步进变得不可预测。如果您遇到这样的问题,您很可能需要在不进行优化的情况下进行编译,省略-O编译开关,或者至少将其降低到级别 1,使用编译开关-O1

一个相关的问题是堆栈帧指针,GDB 需要它们来生成当前函数调用的回溯。在某些架构上,GCC 不会在更高级别的优化(-O2)中生成堆栈帧指针。如果您发现自己确实需要使用-O2进行编译,但仍然希望进行回溯,您可以使用-fno-omit-frame-pointer来覆盖默认行为。还要注意一下手动优化的代码,通过添加-fomit-frame-pointer来省略帧指针:您可能需要暂时将它们移除。

使用 GDB 调试应用程序

您可以使用 GDB 以两种方式调试应用程序。如果您正在开发要在台式机和服务器上运行的代码,或者在任何编译和运行代码在同一台机器上的环境中运行代码,那么自然会本地运行 GDB。然而,大多数嵌入式开发都是使用交叉工具链进行的,因此您希望调试在设备上运行的代码,但是要从具有源代码和工具的交叉开发环境中控制它。我将专注于后一种情况,因为它没有得到很好的记录,但它是嵌入式开发人员最有可能遇到的情况。我不打算在这里描述使用 GDB 的基础知识,因为已经有许多关于该主题的良好参考资料,包括 GDB 手册和本章末尾建议的进一步阅读。

我将从一些关于使用 gdbserver 的细节开始,然后向您展示如何配置 Yocto 项目和 Buildroot 进行远程调试。

使用 gdbserver 进行远程调试

远程调试的关键组件是调试代理 gdbserver,它在目标上运行并控制正在调试的程序的执行。Gdbserver 通过网络连接或 RS-232 串行接口连接到在主机上运行的 GDB 的副本。

通过 gdbserver 进行调试几乎与本地调试相同,但并非完全相同。区别主要集中在涉及两台计算机并且它们必须处于正确状态以进行调试。以下是一些需要注意的事项:

  • 在调试会话开始时,您需要使用 gdbserver 在目标上加载要调试的程序,然后在主机上使用交叉工具链中的 GDB 单独加载 GDB。

  • GDB 和 gdbserver 需要在调试会话开始之前相互连接。

  • 在主机上运行的 GDB 需要告诉它在哪里查找调试符号和源代码,特别是对于共享库。

  • GDB 的run命令无法按预期工作。

  • gdbserver 在调试会话结束时将终止,如果您想要另一个调试会话,您需要重新启动它。

  • 您需要在主机上为要调试的二进制文件获取调试符号和源代码,但不一定需要在目标上。通常目标上没有足够的存储空间,因此在部署到目标之前需要对它们进行剥离。

  • GDB/gdbserver 组合不具有本地运行的 GDB 的所有功能:例如,gdbserver 无法在fork()后跟随子进程,而本地 GDB 可以。

  • 如果 GDB 和 gdbserver 是不同版本或者是相同版本但配置不同,可能会发生一些奇怪的事情。理想情况下,它们应该使用您喜欢的构建工具从相同的源构建。

调试符号会显著增加可执行文件的大小,有时会增加 10 倍。如第五章中所述,构建根文件系统,可以在不重新编译所有内容的情况下删除调试符号。这项工作的工具是您交叉工具链中的 strip。您可以使用以下开关来控制 strip 的侵略性:

  • --strip-all:(默认)删除所有符号

  • --strip-unneeded:删除不需要进行重定位处理的符号

  • --strip-debug:仅删除调试符号

提示

对于应用程序和共享库,--strip-all(默认)是可以的,但是对于内核模块,您会发现它会阻止模块加载。改用--strip-unneeded。我仍在研究–strip-debug的用例。

考虑到这一点,让我们看看在 Yocto 项目和 Buildroot 中进行调试涉及的具体内容。

设置 Yocto 项目

Yocto 项目在 SDK 的一部分中为主机构建了交叉 GDB,但是您需要对目标配置进行更改以在目标映像中包含 gdbserver。您可以显式添加该软件包,例如通过将以下内容添加到conf/local.conf,再次注意这个字符串的开头必须有一个空格:

IMAGE_INSTALL_append = " gdbserver"

或者,您可以将tools-debug添加到EXTRA_IMAGE_FEATURES中,这将同时将 gdbserver 和 strace 添加到目标映像中(我将在下一章中讨论strace):

EXTRA_IMAGE_FEATURES = "debug-tweaks tools-debug"

设置 Buildroot

使用 Buildroot,您需要同时启用选项来为主机构建交叉 GDB(假设您正在使用 Buildroot 内部工具链),并为目标构建 gdbserver。具体来说,您需要启用:

  • BR2_PACKAGE_HOST_GDB,在菜单工具链 | 为主机构建交叉 gdb

  • BR2_PACKAGE_GDB,在菜单目标软件包 | 调试、性能分析和基准测试 | gdb

  • BR2_PACKAGE_GDB_SERVER,在菜单目标软件包 | 调试、性能分析和基准测试 | gdbserver

开始调试

现在,您在目标上安装了 gdbserver,并且在主机上安装了交叉 GDB,您可以开始调试会话了。

连接 GDB 和 gdbserver

GDB 和 gdbserver 之间的连接可以通过网络或串行接口进行。在网络连接的情况下,您可以使用 TCP 端口号启动 gdbserver 进行监听,并且可以选择接受连接的 IP 地址。在大多数情况下,您不需要关心将连接到哪个 IP 地址,因此只需提供端口号即可。在此示例中,gdbserver 等待来自任何主机的端口10000的连接:

# gdbserver :10000 ./hello-world
Process hello-world created; pid = 103
Listening on port 10000

接下来,从您的工具链启动 GDB,将相同的程序作为参数传递,以便 GDB 可以加载符号表:

$ arm-poky-linux-gnueabi-gdb hello-world

在 GDB 中,您使用target remote命令进行连接,指定目标的 IP 地址或主机名以及它正在等待的端口:

(gdb) target remote 192.168.1.101:10000

当 gdbserver 看到来自主机的连接时,它会打印以下内容:

Remote debugging from host 192.168.1.1

串行连接的过程类似。在目标上,您告诉 gdbserver 要使用哪个串行端口:

# gdbserver /dev/ttyO0 ./hello-world

您可能需要使用stty或类似的程序预先配置端口波特率。一个简单的示例如下:

# stty -F /dev/ttyO1 115200

stty还有许多其他选项,请阅读手册以获取更多详细信息。值得注意的是,该端口不能用于其他用途,例如,您不能使用作为系统控制台使用的端口。在主机上,您可以使用target remote加上电缆末端的串行设备来连接到 gdbserver。在大多数情况下,您将希望使用 GDB 命令set remotebaud设置主机串行端口的波特率:

(gdb) set remotebaud 115200
(gdb) target remote /dev/ttyUSB0

设置 sysroot

GDB 需要知道共享库的调试符号和源代码的位置。在本地调试时,路径是众所周知的,并内置到 GDB 中,但是在使用交叉工具链时,GDB 无法猜测目标文件系统的根目录在哪里。您可以通过设置 sysroot 来实现。Yocto 项目和 Buildroot 处理库符号的方式不同,因此 sysroot 的位置也大不相同。

Yocto 项目在目标文件系统映像中包含调试信息,因此您需要解压在build/tmp/deploy/images中生成的目标映像 tar 文件,例如:

$ mkdir ~/rootfs
$ cd ~/rootfs
$ sudo tar xf ~/poky/build/tmp/deploy/images/beaglebone/core-image-minimal-beaglebone.tar.bz2Then you can point sysroot to the root of the unpacked files:
(gdb) set sysroot /home/chris/MELP/rootfs

Buildroot 根据BR2_ENABLE_DEBUG编译具有最小或完整调试符号的库,将它们放入分段目录,然后在将它们复制到目标映像时剥离它们。因此,对于 Buildroot 来说,sysroot 始终是分段区域,而不管根文件系统从何处提取。

GDB 命令文件

每次运行 GDB 时,您需要做一些事情,例如设置 sysroot。将这些命令放入命令文件中,并在每次启动 GDB 时运行它们非常方便。GDB 从$HOME/.gdbinit读取命令,然后从当前目录中的.gdbinit读取命令,然后从使用-x参数在命令行上指定的文件中读取命令。然而,出于安全原因,最近的 GDB 版本将拒绝从当前目录加载.gdbinit。您可以通过向$HOME/.gdbinit添加以下行来覆盖该行为,以便为单个目录禁用检查:

add-auto-load-safe-path /home/chris/myprog/.gdbinit

您还可以通过添加以下内容全局禁用检查:

set auto-load safe-path /

我个人偏好使用-x参数指向命令文件,这样可以暴露文件的位置,以免忘记它。

为了帮助您设置 GDB,Buildroot 创建一个包含正确 sysroot 命令的 GDB 命令文件,位于output/staging/usr/share/buildroot/gdbinit中。它将包含类似于这样的命令:

set sysroot /home/chris/buildroot/output/host/usr/arm-buildroot-linux-gnueabi/sysroot

GDB 命令概述

GDB 有很多命令,这些命令在在线手册和进一步阅读部分提到的资源中有描述。为了帮助您尽快上手,这里列出了最常用的命令。在大多数情况下,命令都有一个缩写形式,该缩写形式在完整命令下面列出。

断点

以下表格显示了断点的命令:

命令 用途
break <location>``b <location> 在函数名、行号或行上设置断点。例如:"main"、"5"和"sortbug.c:42"
info break``i b 列出断点
delete break <N>``d b <N> 删除断点N

运行和步进

以下表格显示了运行和步进的命令:

命令 用途
run``r 将程序的新副本加载到内存中并开始运行。这对使用 gdbserver 进行远程调试是无效的
continuec 从断点继续执行
Ctrl-C 停止正在调试的程序
step``s 执行一行代码,进入调用的任何函数
next``n 执行一行代码,跳过函数调用
finish 运行直到当前函数返回

信息命令

以下表格显示了获取信息的命令:

命令 用途
backtrace``bt 列出调用堆栈
info threads 从断点继续执行
Info libs 停止程序
print <variable>``p <variable> 打印变量的值,例如print foo
list 列出当前程序计数器周围的代码行

运行到断点

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
8 printf("Hello, world!\n");

如果此时您看到以下内容:

warning: Could not load shared library symbols for 2 libraries, e.g. /lib/libc.so.6.

这意味着您忘记了设置 sysroot!

这与本地启动程序非常不同,您只需键入run。实际上,如果您在远程调试会话中尝试键入run,您要么会看到一条消息,说明远程目标不支持run,要么在较旧版本的 GDB 中,它将在没有任何解释的情况下挂起。

调试共享库

要调试由构建工具构建的库,您需要对构建配置进行一些更改。对于在构建环境之外构建的库,您需要做一些额外的工作。

Yocto 项目

Yocto 项目构建二进制包的调试变体,并将它们放入build/tmp/deploy/<package manager>/<target architecture>中。以下是此示例的调试包,这里是 C 库的示例:

build/tmp/deploy/rpm/armv5e/libc6-dbg-2.21-r0.armv5e.rpm

您可以通过将<package name-dbg>添加到目标配方来有选择地将这些调试包添加到目标映像中。对于glibc,该包的名称为glibc-dbg。或者,您可以简单地告诉 Yocto 项目通过将dbg-pkgs添加到EXTRA_IMAGE_FEATURES来安装所有调试包。请注意,这将大大增加目标映像的大小,可能会增加数百兆字节。

Yocto 项目将调试符号放在名为.debug的隐藏目录中,分别位于libusr/lib目录中。GDB 知道在 sysroot 中的这些位置查找符号信息。

调试软件包还包含安装在目标镜像中的源代码副本,位于目录usr/src/debug/<package name>中,这也是尺寸增加的原因之一。您可以通过向您的配方添加以下内容来阻止它发生:

PACKAGE_DEBUG_SPLIT_STYLE = "debug-without-src"

不过,请记住,当您使用 gdbserver 进行远程调试时,您只需要在主机上具有调试符号和源代码,而不需要在目标上具有。没有什么能阻止您从已安装在目标上的镜像的副本中删除lib/.debugusr/lib/.debugusr/src目录。

Buildroot

Buildroot 通常是直截了当的。您只需要重新构建带有行级调试符号的软件包,为此您需要启用以下内容:

  • 在菜单构建选项 | 使用调试符号构建软件包

这将在output/host/usr/<arch>/sysroot中创建带有调试符号的库,但目标镜像中的副本仍然被剥离。如果您需要在目标上使用调试符号,也许是为了本地运行 GDB,您可以通过将构建选项 | 目标上的二进制文件剥离命令设置为none来禁用剥离。

其他库

除了使用调试符号进行构建之外,您还需要告诉 GDB 在哪里找到源代码。GDB 有一个用于源文件的搜索路径,您可以使用show directories命令查看:

(gdb) show directories
Source directories searched: $cdir:$cwd

这些是默认搜索路径:$cdir是编译目录,即源代码编译的目录;$cwd是 GDB 的当前工作目录。

通常这些就足够了,但如果源代码已经移动,您将需要使用如下所示的 directory 命令:

(gdb) dir /home/chris/MELP/src/lib_mylib
Source directories searched: /home/chris/MELP/src/lib_mylib:$cdir:$cwd

即时调试

有时,程序在运行一段时间后会开始表现异常,您可能想知道它在做什么。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/chris/MELP/helloworld/helloworld, process 109
Ending remote debugging.

调试分支和线程

当您调试的程序进行分支时会发生什么?调试会跟随父进程还是子进程?这种行为由follow-fork-mode控制,可能是parentchild,默认为 parent。不幸的是,当前版本的 gdbserver 不支持此选项,因此它仅适用于本地调试。如果您确实需要在使用 gdbserver 时调试子进程,一种解决方法是修改代码,使得子进程在分支后立即循环一个变量,这样您就有机会附加一个新的 gdbserver 会话,并设置变量以使其退出循环。

当多线程进程中的线程命中断点时,默认行为是所有线程都会停止。在大多数情况下,这是最好的做法,因为它允许您查看静态变量,而不会被其他线程更改。当您恢复线程的执行时,所有已停止的线程都会启动,即使您是单步执行,尤其是最后一种情况可能会导致问题。有一种方法可以修改 GDB 处理已停止线程的方式,通过称为scheduler-locking的参数。通常它是off,但如果将其设置为on,则只有在断点处停止的线程会恢复,其他线程将保持停止状态,这样您就有机会查看线程在没有干扰的情况下的操作。直到您关闭scheduler-locking为止,这种情况将继续存在。Gdbserver 支持此功能。

核心文件

核心文件捕获了程序在终止时的状态。当错误发生时,您甚至不必在调试器旁边。因此,当您看到Segmentation fault (core dumped)时,请不要耸肩;调查核心文件并提取其中的信息宝库。

首先要注意的是,默认情况下不会创建核心文件,而只有在进程的核心文件资源限制为非零时才会创建。您可以使用ulimit -c更改当前 shell 的限制。要删除核心文件大小的所有限制,请键入以下内容:

$ ulimit -c unlimited

默认情况下,核心文件命名为core,并放置在进程的当前工作目录中,该目录由/proc/<PID>/cwd指向。这种方案存在一些问题。首先,在查看具有多个名为core的文件的设备时,不明显知道每个文件是由哪个程序生成的。其次,进程的当前工作目录很可能位于只读文件系统中,或者可能没有足够的空间来存储core文件,或者进程可能没有权限写入当前工作目录。

有两个文件控制着core文件的命名和放置。第一个是/proc/sys/kernel/core_uses_pid。向其写入1会导致将正在死亡的进程的 PID 号附加到文件名中,只要您可以从日志文件中将 PID 号与程序名称关联起来,这就有些有用。

更有用的是/proc/sys/kernel/core_pattern,它可以让您对core文件有更多的控制。默认模式是core,但您可以将其更改为由这些元字符组成的模式:

  • %p:PID

  • %u:转储进程的真实 UID

  • %g:转储进程的真实 GID

  • %s:导致转储的信号编号

  • %t:转储时间,表示自 1970-01-01 00:00:00 +0000(UTC)以来的秒数。

  • %h:主机名

  • %e:可执行文件名

  • %E:可执行文件的路径名,斜杠(/)替换为感叹号(!

  • %c:转储进程的核心文件大小软资源限制

您还可以使用以绝对目录名开头的模式,以便将所有core文件收集到一个地方。例如,以下模式将所有核心文件放入/corefiles目录,并使用程序名称和崩溃时间命名它们:

# echo /corefiles/core.%e.%t > /proc/sys/kernel/core_pattern

核心转储后,您会发现类似以下内容:

$ ls /corefiles/
core.sort-debug.1431425613

有关更多信息,请参阅 man 页面core(5)

对于核心文件的更复杂处理,您可以将它们传输到进行一些后处理的程序。核心模式以管道符号|开头,后跟程序名称和参数。例如,我的 Ubuntu 14.04 有这个核心模式:

|/usr/share/apport/apport %p %s %c %P

Apport 是 Canonical 使用的崩溃报告工具。这种方式运行的崩溃报告工具在进程仍在内存中运行时运行,并且内核将核心镜像数据传递给它的标准输入。因此,该程序可以处理图像,可能会剥离其中的部分以减小文件系统中的大小,或者仅在核心转储时扫描它以获取特定信息。该程序可以查看各种系统数据,例如,读取程序的/proc文件系统条目,并且可以使用 ptrace 系统调用来操作程序并从中读取数据。但是,一旦核心镜像数据从标准输入中读取,内核就会进行各种清理,使有关该进程的信息不再可用。

使用 GDB 查看核心文件

以下是查看核心文件的 GDB 会话示例:

$ arm-poky-linux-gnueabi-gdb sort-debug /home/chris/MELP/rootdirs/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);

这显示程序在第 43 行停止。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 用户界面

GDB 是通过 GDB 机器接口 GDB/MI 进行低级控制的,该接口用于将 GDB 包装在用户界面中或作为更大程序的一部分,并且大大扩展了可用的选项范围。

我只提到了那些在嵌入式开发中有用的功能。

终端用户界面

终端用户界面TUI)是标准 GDB 软件包的可选部分。其主要特点是代码窗口,显示即将执行的代码行以及任何断点。它绝对改进了命令行模式 GDB 中的list命令。

TUI 的吸引力在于它只需要工作,不需要任何额外的设置,并且由于它是文本模式,因此在运行gdb时可以通过 ssh 终端会话在目标上使用。大多数交叉工具链都使用 TUI 配置 GDB。只需在命令行中添加-tui,您将看到以下内容:

终端用户界面

数据显示调试器

数据显示调试器DDD)是一个简单的独立程序,可以让您以最小的麻烦获得 GDB 的图形用户界面,尽管 UI 控件看起来有些过时,但它确实做到了必要的一切。

--debugger选项告诉 DDD 使用您的工具链中的 GDB,并且您可以使用 GDB 命令文件的-x参数:

$ ddd --debugger arm-poky-linux-gnueabi-gdb -x gdbinit sort-debug

以下屏幕截图展示了其中一个最好的功能:数据窗口,其中包含以您希望的方式重新排列的项目。如果双击指针,它会展开为一个新的数据项,并且链接会显示为箭头:

数据显示调试器

Eclipse

Eclipse,配备了C 开发工具包CDT)插件,支持使用 GDB 进行调试,包括远程调试。如果您在 Eclipse 中进行所有的代码开发,这是显而易见的工具,但是,如果您不是经常使用 Eclipse,那么可能不值得为了这个任务而设置它。我需要整整一章的篇幅来充分解释如何配置 CDT 以使用交叉工具链并连接到远程设备,因此我将在本章末尾的参考资料中为您提供更多信息。接下来的屏幕截图显示了 CDT 的调试视图。在右上窗口中,您可以看到进程中每个线程的堆栈帧,右上方是显示变量的监视窗口。中间是代码窗口,显示了调试器停止程序的代码行。

Eclipse

调试内核代码

调试应用程序代码有助于了解代码的工作方式以及在代码发生故障时发生了什么,并且您可以对内核进行相同的操作,但有一些限制。

您可以使用kgdb进行源级调试,类似于使用gdbserver进行远程调试。还有一个自托管的内核调试器kdb,对于轻量级任务非常方便,例如查看指令是否执行并获取回溯以找出它是如何到达那里的。最后,还有内核 oops 消息和紧急情况,它们告诉您有关内核异常原因的很多信息。

使用 kgdb 调试内核代码

在使用源代码调试器查看内核代码时,您必须记住内核是一个复杂的系统,具有实时行为。不要期望调试像应用程序一样容易。逐步执行更改内存映射或切换上下文的代码可能会产生奇怪的结果。

kgdb是多年来一直是 Linux 主线的内核 GDB 存根的名称。内核 DocBook 中有用户手册,您可以在www.kernel.org/doc/htmldocs/kgdb/index.html找到在线版本。

连接到 kgdb 的广泛支持方式是通过串行接口,通常与串行控制台共享,因此此实现称为kgdboc,意思是控制台上的 kgdb。为了工作,它需要支持 I/O 轮询而不是中断的平台 tty 驱动程序,因为 kgdb 在与 GDB 通信时必须禁用中断。一些平台支持通过 USB 进行 kgdb,还有一些可以通过以太网工作的版本,但不幸的是,这些都没有进入主线 Linux。

内核的优化和堆栈帧也适用于内核,但内核的限制是,内核被写成至少为-O1的优化级别。您可以通过在运行make之前设置KCGLAGS来覆盖内核编译标志。

然后,这些是您需要进行内核调试的内核配置选项:

  • CONFIG_DEBUG_INFO内核调试 | 编译时检查和编译器选项 | 使用调试信息编译内核菜单

  • CONFIG_FRAME_POINTER可能是您的架构的一个选项,并且在内核调试 | 编译时检查和编译器选项 | 使用帧指针编译内核菜单

  • CONFIG_KGDB内核调试 | KGDB:内核调试器菜单

  • CONFIG_KGDB_SERIAL_CONSOLE内核调试 | KGDB:内核调试器 | KGDB:使用串行控制台菜单

除了uImagezImage压缩内核映像,您还需要以 ELF 对象格式的内核映像,以便 GDB 可以将符号加载到内存中。这个文件称为在构建 Linux 的目录中生成的vmlinux。在 Yocto 项目中,您可以请求在目标映像中包含一个副本,这对于这个和其他调试任务非常方便。它构建为一个名为kernel-vmlinux的软件包,您可以像其他软件包一样安装,例如将其添加到IMAGE_INSTALL_append列表中。该文件放入引导目录,名称如下:

boot/vmlinux-3.14.26ltsi-yocto-standard

在 Buildroot 中,您将在构建内核的目录中找到vmlinux,该目录位于output/build/linux-<version string>/vmlinux中。

一个示例调试会话

展示它的最佳方法是通过一个简单的例子。

您需要告诉kgdb要使用哪个串行端口,可以通过内核命令行或通过sysfs在运行时进行设置。对于第一种选项,请将kgdboc=<tty>,<波特率>添加到命令行,如下所示:

kgdboc=ttyO0,115200

对于第二个选项,启动设备并将终端名称写入/sys/module/kgdboc/parameters/kgdboc文件,如下所示:

# echo ttyO0 > /sys/module/kgdboc/parameters/kgdboc

请注意,您不能以这种方式设置波特率。如果它与控制台相同的tty,则已经设置,如果不是,请使用stty或类似的程序。

现在您可以在主机上启动 GDB,选择与正在运行的内核匹配的vmlinux文件:

$ arm-poky-linux-gnueabi-gdb ~/linux/vmlinux

GDB 从vmlinux加载符号表,并等待进一步的输入。

接下来,关闭连接到控制台的任何终端仿真器:您将要在 GDB 中使用它,如果两者同时活动,一些调试字符串可能会损坏。

现在,您可以返回到 GDB 并尝试连接到kgdb。但是,您会发现此时从target remote得到的响应是无用的:

(gdb) set remotebaud 115200
(gdb) target remote /dev/ttyUSB0
Remote debugging using /dev/ttyUSB0
Bogus trace status reply from target: qTStatus

问题在于此时kgdb没有在监听连接。您需要在可以与之进行交互的 GDB 会话之前中断内核。不幸的是,就像您在应用程序中一样,仅在 GDB 中键入Ctrl + C是无效的。您需要通过例如通过 ssh 在目标板上启动另一个 shell,并向目标板的/proc/sysrq-trigger写入g来强制内核陷入:

# echo g > /proc/sysrq-trigger

目标在这一点上停止。现在,您可以通过电缆主机端的串行设备连接到kgdb

(gdb) set remotebaud 115200
(gdb) target remote /dev/ttyUSB0
Remote debugging using /dev/ttyUSB0
0xc009a59c in arch_kgdb_breakpoint ()

最后,GDB 掌控了。您可以设置断点,检查变量,查看回溯等。例如,设置一个在sys_sync上的断点,如下所示:

(gdb) break sys_sync
Breakpoint 1 at 0xc0128a88: file fs/sync.c, line 103.
(gdb) c
Continuing.

现在目标恢复了。在目标上输入sync调用sys_sync并触发断点。

[New Thread 87]
[Switching to Thread 87]

Breakpoint 1, sys_sync () at fs/sync.c:103

如果您已经完成了调试会话并想要禁用kgdboc,只需将kgdboc终端设置为 null:

# echo "" >  /sys/module/kgdboc/parameters/kgdboc

调试早期代码

在系统完全引导时执行您感兴趣的代码的情况下,前面的示例适用。如果您需要尽早进入系统,可以通过在kgdboc选项之后添加kgdbwait到命令行来告诉内核在引导期间等待:

kgdboc=ttyO0,115200 kgdbwait

现在,当您引导时,您将在控制台上看到这个:

 1.103415] console [ttyO0] 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
0xbf000000
# cat /sys/module/mbx/sections/.data
0xbf0003e8
# cat /sys/module/mbx/sections/.bss
0xbf0005c0

现在,您可以在 GDB 中使用这些数字来加载模块的符号表:

(gdb) add-symbol-file /home/chris/mbx-driver/mbx.ko 0xbf000000 \
-s .data 0xbf0003e8 -s .bss 0xbf0005c0
add symbol table from file "/home/chris/mbx-driver/mbx.ko" at
 .text_addr = 0xbf000000
 .data_addr = 0xbf0003e8
 .bss_addr = 0xbf0005c0

现在一切应该正常工作:您可以设置断点并检查模块中的全局和局部变量,就像在vmlinux中一样:

(gdb) break mbx_write

Breakpoint 1 at 0xbf00009c: file /home/chris/mbx-driver/mbx.c, line 93.

(gdb) c
Continuing.

然后,强制设备驱动程序调用mbx_write,它将触发断点:

Breakpoint 1, mbx_write (file=0xde7a71c0, buffer=0xadf40 "hello\n\n",
 length=6, offset=0xde73df80)
 at /home/chris/mbx-driver/mbx.c:93

使用 kdb 调试内核代码

尽管kdb没有kgdb和 GDB 的功能,但它确实有其用途,并且作为自托管的工具,没有外部依赖需要担心。kdb具有一个简单的命令行界面,您可以在串行控制台上使用它。您可以使用它来检查内存、寄存器、进程列表、dmesg,甚至设置断点以在特定位置停止。

要配置通过串行控制台访问kgd,请启用kgdb,如前所示,然后启用此附加选项:

  • CONFIG_KGDB_KDB,位于KGDB: 内核调试 | 内核调试器 | KGDB_KDB: 包括 kgdb 的 kdb 前端菜单中

现在,当您强制内核陷入陷阱时,您将在控制台上看到kdb shell,而不是进入 GDB 会话:

# echo g > /proc/sysrq-trigger
[   42.971126] SysRq : DEBUG

Entering kdb (current=0xdf36c080, pid 83) due to Keyboard Entry
kdb>

kdb shell 中有很多事情可以做。help命令将打印所有选项。这是一个概述。

获取信息:

  • ps:显示活动进程

  • ps A:显示所有进程

  • lsmod:列出模块

  • dmesg:显示内核日志缓冲区

断点:

  • bp:设置断点

  • bl:列出断点

  • bc:清除断点

  • bt:打印回溯

  • go:继续执行

检查内存和寄存器:

  • md:显示内存

  • rd:显示寄存器

这是设置断点的一个快速示例:

kdb> bp sys_sync
Instruction(i) BP #0 at 0xc01304ec (sys_sync)
 is enabled  addr at 00000000c01304ec, hardtype=0 installed=0

kdb> go

内核恢复正常,控制台显示正常的 bash 提示符。如果键入sync,它会触发断点并再次进入kdb

Entering kdb (current=0xdf388a80, pid 88) due to Breakpoint @ 0xc01304ec

kdb不是源代码调试器,因此您无法查看源代码或单步执行。但是,您可以使用bt命令显示回溯,这对于了解程序流程和调用层次结构很有用。

当内核执行无效的内存访问或执行非法指令时,内核 oops 消息将被写入内核日志。其中最有用的部分是回溯,我想向您展示如何使用其中的信息来定位导致故障的代码行。我还将解决如果 oops 消息导致系统崩溃时如何保留 oops 消息的问题。

查看 oops

oops 消息看起来像这样:

[   56.225868] Unable to handle kernel NULL pointer dereference at virtual address 00000400[   56.229038] pgd = cb624000[   56.229454] [00000400] *pgd=6b715831, *pte=00000000, *ppte=00000000[   56.231768] Internal error: Oops: 817 [#1] SMP ARM[   56.232443] Modules linked in: mbx(O)[   56.233556] CPU: 0 PID: 98 Comm: sh Tainted: G   O  4.1.10 #1[   56.234234] Hardware name: ARM-Versatile Express[   56.234810] task: cb709c80 ti: cb71a000 task.ti: cb71a000[   56.236801] PC is at mbx_write+0x14/0x98 [mbx][   56.237303] LR is at __vfs_write+0x20/0xd8[   56.237559] pc : [<bf0000a0>]    lr : [<c0307154>]  psr: 800f0013[   56.237559] sp : cb71bef8  ip : bf00008c  fp : 00000000[   56.238183] r10: 00000000  r9 : cb71a000  r8 : c02107c4[   56.238485] r7 : cb71bf88  r6 : 000afb98  r5 : 00000006  r4 : 00000000[   56.238857] r3 : cb71bf88  r2 : 00000006  r1 : 000afb98  r0 : cb61d600
[   56.239276] Flags: Nzcv  IRQs on  FIQs on  Mode SVC_32  ISA ARM  Segment user[   56.239685] Control: 10c5387d  Table: 6b624059  DAC: 00000015[   56.240019] Process sh (pid: 98, stack limit = 0xcb71a220)

PC is at mbx_write+0x14/0x98 [mbx]告诉您大部分您想知道的内容:最后一条指令在名为mbx的内核模块中的mbx_write函数中。此外,它是从函数开始的偏移量0x14字节,该函数的长度为0x98字节。

接下来,看一下回溯:

[   56.240363] Stack: (0xcb71bef8 to 0xcb71c000)[   56.240745] bee0:                                                       cb71bf88 cb61d600[   56.241331] bf00: 00000006 c0307154 00000000 c020a308 cb619d88 00000301 00000000 00000042[   56.241775] bf20: 00000000 cb61d608 cb709c80 cb709c78 cb71bf60 c0250a54 00000000 cb709ee0[   56.242190] bf40: 00000003 bef4f658 00000000 cb61d600 cb61d600 00000006 000afb98 cb71bf88[   56.242605] bf60: c02107c4 c030794c 00000000 00000000 cb61d600 cb61d600 00000006 000afb98[   56.243025] bf80: c02107c4 c0308174 00000000 00000000 00000000 000ada10 00000001 000afb98[   56.243493] bfa0: 00000004 c0210640 000ada10 00000001 00000001 000afb98 00000006 00000000[   56.243952] bfc0: 000ada10 00000001 000afb98 00000004 00000001 00000020 000ae274 00000000[   56.244420] bfe0: 00000000 bef4f49c 0000fcdc b6f1aedc 600f0010 00000001 00000000 00000000[   56.245653] [<bf0000a0>] (mbx_write [mbx]) from [<c0307154>] (__vfs_write+0x20/0xd8)[   56.246368] [<c0307154>] (__vfs_write) from [<c030794c>] (vfs_write+0x90/0x164)[   56.246843] [<c030794c>] (vfs_write) from [<c0308174>] (SyS_write+0x44/0x9c)[   56.247265] [<c0308174>] (SyS_write) from [<c0210640>] (ret_fast_syscall+0x0/0x3c)[   56.247737] Code: e5904090 e3520b01 23a02b01 e1a05002 (e5842400)[   56.248372] ---[ end trace 999c378e4df13d74 ]---

在这种情况下,我们并没有学到更多,只是mbx_write是从虚拟文件系统代码中调用的。

找到与mbx_write+0x14相关的代码行将非常好,我们可以使用objdump。我们可以从objdump -S中看到mbx_writembx.ko中的偏移量为0x8c,因此最后执行的指令位于0x8c + 0x14 = 0xa0。现在,我们只需要查看该偏移量并查看其中的内容:

$ arm-poky-linux-gnueabi-objdump -S mbx.kostatic ssize_t mbx_write(struct file *file,const char *buffer, size_t length, loff_t * offset){  8c:   e92d4038        push    {r3, r4, r5, lr}  struct mbx_data *m = (struct mbx_data *)file->private_data;  90:   e5904090        ldr     r4, [r0, #144]  ; 0x90  94:   e3520b01        cmp     r2, #1024       ; 0x400  98:   23a02b01        movcs   r2, #1024       ; 0x400  if (length > MBX_LEN)    length = MBX_LEN;    m->mbx_len = length;  9c:   e1a05002        mov     r5, r2  a0:   e5842400        str     r2, [r4, #1024] ; 0x400

这显示了它停止的指令。代码的最后一行显示在这里:

m->mbx_len = length;

您可以看到m的类型是struct mbx_data *。这是定义该结构的地方:

#define MBX_LEN 1024 struct mbx_data {  char mbx[MBX_LEN];  int mbx_len;};

因此,看起来m变量是一个空指针,这导致了 oops。

保存 oops

解码 oops 只有在首次捕获它时才可能。如果系统在启动期间在启用控制台之前或在挂起后崩溃,则不会看到它。有机制可以将内核 oops 和消息记录到 MTD 分区或持久内存中,但这里有一种在许多情况下都有效且需要很少事先考虑的简单技术。

只要在重置期间内存内容未被损坏(通常情况下不会),您可以重新启动到引导加载程序并使用它来显示内存。您需要知道内核日志缓冲区的位置,记住它是文本消息的简单环形缓冲区。符号是__log_buf。在内核的System.map中查找此内容:

$ grep __log_buf System.mapc0f72428 b __log_buf

然后,通过减去PAGE_OFFSET0xc0000000,并在 BeagleBone 上加上 RAM 的物理起始地址0x80000000,将内核逻辑地址映射到 U-Boot 可以理解的物理地址,因此c0f72428 - 0xc0000000 + 0x80000000 = 80f72428

然后使用 U-Boot 的md命令显示日志:

U-Boot# md 80f7242880f72428: 00000000 00000000 00210034 c6000000    ........4.!.....80f72438: 746f6f42 20676e69 756e694c 6e6f2078    Booting Linux on80f72448: 79687020 61636973 5043206c 78302055     physical CPU 0x80f72458: 00000030 00000000 00000000 00730084    0.............s.80f72468: a6000000 756e694c 65762078 6f697372    ....Linux versio80f72478: 2e34206e 30312e31 68632820 40736972    n 4.1.10 (chris@80f72488: 6c697562 29726564 63672820 65762063    builder) (gcc ve80f72498: 6f697372 2e34206e 20312e39 6f726328    rsion 4.9.1 (cro80f724a8: 6f747373 4e2d6c6f 2e312047 302e3032    sstool-NG 1.20.080f724b8: 20292029 53203123 5720504d 4f206465    ) ) #1 SMP Wed O
80f724c8: 32207463 37312038 3a31353a 47203335    ct 28 17:51:53 G

注意

从 Linux 3.5 开始,内核日志缓冲区中的每行都有一个 16 字节的二进制头,其中编码了时间戳、日志级别和其他内容。在 Linux Weekly News 的一篇名为走向更可靠的日志记录的文章中有关于此的讨论,网址为lwn.net/Articles/492125/

额外阅读

以下资源提供了有关本章介绍的主题的更多信息:

  • 使用 GDB、DDD 和 Eclipse 进行调试的艺术,作者Norman MatloffPeter Jay SalzmanNo Starch Press;第 1 版(2008 年 9 月 28 日),ISBN 978-1593271749

  • GDB 口袋参考,作者Arnold RobbinsO'Reilly Media;第 1 版(2005 年 5 月 12 日),ISBN 978-0596100278

  • 熟悉 Eclipse:交叉编译2net.co.uk/tutorial/eclipse-cross-compile

  • 熟悉 Eclipse:远程访问和调试2net.co.uk/tutorial/eclipse-rse

总结

用于交互式调试的 GDB 是嵌入式开发人员工具箱中的一个有用工具。它是一个稳定的、有文档支持的、众所周知的实体。它有能力通过在目标上放置代理来远程调试,无论是用于应用程序的 gdbserver 还是用于内核代码的 kgdb,尽管默认的命令行用户界面需要一段时间才能习惯,但有许多替代的前端。我提到的三个是 TUI、DDD 和 Eclipse,这应该涵盖了大多数情况,但还有其他前端可以尝试。

调试的第二种同样重要的方法是收集崩溃报告并离线分析它们。在这个类别中,我已经查看了应用程序的核心转储和内核 oops 消息。

然而,这只是识别程序中缺陷的一种方式。在下一章中,我将讨论分析和优化程序的方法,即性能分析和跟踪。

第十三章:性能分析和跟踪

使用源级调试器进行交互式调试,如前一章所述,可以让您深入了解程序的工作方式,但它将您的视野限制在一小部分代码上。在本章中,我将着眼于更大的图片,以查看系统是否按预期运行。

程序员和系统设计师在猜测瓶颈位置时通常表现得很糟糕。因此,如果您的系统存在性能问题,最好从整个系统开始查看,然后逐步使用更复杂的工具。在本章中,我首先介绍了众所周知的top命令,作为获取概述的手段。问题通常可以局限在单个程序上,您可以使用 Linux 分析器perf进行分析。如果问题不是如此局限,而您想获得更广泛的图片,perf也可以做到。为了诊断与内核相关的问题,我将描述跟踪工具FtraceLTTng,作为收集详细信息的手段。

我还将介绍 Valgrind,由于其沙箱执行环境,可以监视程序并在其运行时报告代码。我将以描述一个简单的跟踪工具strace来完成本章,它通过跟踪程序所做的系统调用来揭示程序的执行。

观察者效应

在深入了解工具之前,让我们谈谈工具将向您展示什么。就像在许多领域一样,测量某个属性会影响观察本身。测量线路中的电流需要测量一个小电阻上的电压降。然而,电阻本身会影响电流。性能分析也是如此:每个系统观察都会消耗 CPU 周期,这些资源将不再用于应用程序。测量工具还会影响缓存行为,占用内存空间,并写入磁盘,这些都会使情况变得更糟。没有不带开销的测量。

我经常听到工程师说,性能分析的结果完全是误导性的。这通常是因为他们在接近真实情况下进行测量。始终尝试在目标上进行测量,使用软件的发布版本构建,使用有效的数据集,尽可能少地使用额外服务。

符号表和编译标志

我们将立即遇到一个问题。虽然观察系统处于其自然状态很重要,但工具通常需要额外的信息来理解事件。

一些工具需要特殊的内核选项,特别是在介绍中列出的那些,如perfFtraceLTTng。因此,您可能需要为这些测试构建和部署新的内核。

调试符号对将原始程序地址转换为函数名称和代码行非常有帮助。部署带有调试符号的可执行文件不会改变代码的执行,但这确实需要您拥有使用debug编译的二进制文件和内核的副本,至少对于您想要进行性能分析的组件。例如,一些工具在目标系统上安装这些组件效果最佳,比如perf。这些技术与一般调试相同,正如我在第十二章中所讨论的那样,使用 GDB 进行调试

如果您想要一个工具生成调用图,您可能需要启用堆栈帧进行编译。如果您希望工具准确地将地址与代码行对应起来,您可能需要以较低级别的优化进行编译。

最后,一些工具需要将插装仪器插入程序中以捕获样本,因此您将不得不重新编译这些组件。这适用于应用程序的gprof,以及内核的FtraceLTTng

请注意,您观察的系统发生的变化越大,您所做的测量与生产系统之间的关系就越难以建立。

提示

最好采取等待和观察的方法,只有在需要明确时才进行更改,并且要注意,每次这样做时,都会改变您正在测量的内容。

开始进行分析

在查看整个系统时,一个很好的起点是使用top这样的简单工具,它可以让您快速地获得概览。它会显示正在使用多少内存,哪些进程正在占用 CPU 周期,以及这些情况如何分布在不同的核心和时间上。

如果top显示单个应用程序在用户空间中使用了所有的 CPU 周期,那么您可以使用perf对该应用程序进行分析。

如果两个或更多进程的 CPU 使用率很高,那么它们之间可能存在某种耦合,也许是数据通信。如果大量的周期花费在系统调用或处理中断上,那么可能存在内核配置或设备驱动程序的问题。在任何一种情况下,您需要从整个系统开始进行分析,再次使用perf

如果您想了解更多关于内核和事件顺序的信息,可以使用FtraceLTTng

top可能无法帮助您解决其他问题。如果您有多线程代码,并且存在死锁问题,或者存在随机数据损坏问题,那么 Valgrind 加上 Helgrind 插件可能会有所帮助。内存泄漏也属于这一类问题:我在第十一章中介绍了与内存相关的诊断,管理内存

使用 top 进行分析

top是一个简单的工具,不需要任何特殊的内核选项或符号表。BusyBox 中有一个基本版本,procps包中有一个更功能齐全的版本,该包在 Yocto Project 和 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 -- 窃取时间:仅在虚拟化环境中相关

在前面的例子中,几乎所有的时间(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
 [...]

这个系统几乎所有的时间都花在内核空间,因为cat正在从/dev/urandom读取。在这种人为的情况下,仅对cat进行分析是没有帮助的,但对cat调用的内核函数进行分析可能会有所帮助。

top的默认视图只显示进程,因此 CPU 使用率是进程中所有线程的总和。按H键查看每个线程的信息。同样,它会汇总所有 CPU 上的时间。如果您使用的是procps top,可以通过按1键查看每个 CPU 的摘要。

想象一下,有一个单独的用户空间进程占用了大部分时间,看看如何对其进行分析。

穷人的分析器

您可以通过使用 GDB 在任意间隔停止应用程序并查看其正在执行的操作来对应用程序进行分析。这就是穷人的分析器。它很容易设置,也是收集分析数据的一种方法。

该过程很简单,这里进行了解释:

  1. 使用gdbserver(用于远程调试)或 gbd(用于本地调试)附加到进程。进程停止。

  2. 观察它停在哪个功能上。您可以使用backtrace GDB命令查看调用堆栈。

  3. 输入continue以使程序恢复。

  4. 过一会儿,输入Ctrl + C再次停止它,然后回到步骤 2。

如果您多次重复步骤 2 到 4,您将很快了解它是在循环还是在进行,如果您重复这些步骤足够多次,您将了解代码中的热点在哪里。

有一个专门的网页致力于这个想法,网址为poormansprofiler.org,还有一些脚本可以使它变得更容易。多年来,我已经在各种操作系统和调试器中多次使用了这种技术。

这是统计分析的一个例子,您可以在间隔时间内对程序状态进行采样。经过一些样本后,您开始了解执行函数的统计可能性。您真正需要的样本数量是令人惊讶的少。其他统计分析器包括perf recordOProfilegprof

使用调试器进行采样是具有侵入性的,因为在收集样本时程序会停止一段时间。其他工具可以以更低的开销做到这一点。

我现在将考虑如何使用perf进行统计分析。

介绍 perf

perfLinux 性能事件计数子系统perf_events的缩写,也是与perf_events进行交互的命令行工具的名称。自 Linux 2.6.31 以来,它们一直是内核的一部分。在tools/perf/Documentation目录中的 Linux 源树中有大量有用的信息,还可以在perf.wiki.kernel.org找到。

开发perf的最初动力是提供一种统一的方式来访问大多数现代处理器核心中的性能测量单元PMU)的寄存器。一旦 API 被定义并集成到 Linux 中,将其扩展到涵盖其他类型的性能计数器就变得合乎逻辑。

在本质上,perf是一组事件计数器,具有关于何时主动收集数据的规则。通过设置规则,您可以从整个系统中捕获数据,或者只是内核,或者只是一个进程及其子进程,并且可以跨所有 CPU 或只是一个 CPU 进行。它非常灵活。使用这个工具,您可以从查看整个系统开始,然后关注似乎导致问题的设备驱动程序,或者运行缓慢的应用程序,或者似乎执行时间比您想象的长的库函数。

perf命令行工具的代码是内核的一部分,位于tools/perf目录中。该工具和内核子系统是手牵手开发的,这意味着它们必须来自相同版本的内核。perf可以做很多事情。在本章中,我将仅将其作为分析器进行检查。有关其其他功能的描述,请阅读perf手册页并参考前一段提到的文档。

为 perf 配置内核

您需要一个配置为perf_events的内核,并且需要交叉编译的perf命令才能在目标上运行。相关的内核配置是CONFIG_PERF_EVENTS,位于菜单General setup | Kernel Performance Events And Counters中。

如果您想使用 tracepoints 进行分析(稍后会详细介绍),还要启用有关Ftrace部分中描述的选项。当您在那里时,也值得启用CONFIG_DEBUG_INFO

perf命令有许多依赖项,这使得交叉编译变得非常混乱。然而,Yocto Project 和 Buildroot 都有针对它的目标软件包。

您还需要在目标上为您感兴趣的二进制文件安装调试符号,否则perf将无法将地址解析为有意义的符号。理想情况下,您希望为整个系统包括内核安装调试符号。对于后者,请记住内核的调试符号位于vmlinux文件中。

使用 Yocto Project 构建 perf

如果您正在使用标准的 linux-yocto 内核,perf_events 已经启用,因此无需进行其他操作。

要构建perf工具,您可以将其明确添加到目标镜像的依赖项中,或者您可以添加 tools-profile 功能,该功能还会引入gprof。如前所述,您可能希望在目标镜像上有调试符号,以及内核vmlinux镜像。总之,这是您在conf/local.conf中需要的内容:

EXTRA_IMAGE_FEATURES = "debug-tweaks dbg-pkgs tools-profile"
IMAGE_INSTALL_append = " kernel-vmlinux"

使用 Buildroot 构建 perf

许多 Buildroot 内核配置不包括perf_events,因此您应该首先检查您的内核是否包括前面部分提到的选项。

要交叉编译 perf,请运行 Buildroot 的menuconfig并选择以下内容:

  • BR2_LINUX_KERNEL_TOOL_PERFKernel | Linux Kernel Tools中。要构建带有调试符号的软件包并在目标上安装未剥离的软件包,请选择这两个设置。

  • BR2_ENABLE_DEBUGBuild options | build packages with debugging symbols菜单中。

  • BR2_STRIP = noneBuild options | strip command for binaries on target菜单中。

然后,运行make clean,然后运行make

构建完所有内容后,您将需要手动将vmlinux复制到目标镜像中。

使用 perf 进行性能分析

您可以使用perf来使用事件计数器之一对程序的状态进行采样,并在一段时间内累积样本以创建一个性能分析。这是统计分析的另一个例子。默认事件计数器称为循环,这是一个通用的硬件计数器,映射到表示核心时钟频率的 PMU 寄存器的循环计数。

使用perf创建性能分析是一个两阶段过程:perf record命令捕获样本并将其写入一个名为perf.data的文件(默认情况下),然后perf report分析结果。这两个命令都在目标上运行。正在收集的样本已经被过滤,以用于您指定的进程及其子进程,以及您指定的命令。以下是一个示例,对搜索字符串linux的 shell 脚本进行性能分析:

# perf record sh -c "find /usr/share | xargs grep linux > /dev/null"
[ perf record: Woken up 2 times to write data ]
[ perf record: Captured and wrote 0.368 MB perf.data (~16057 samples) ]
# ls -l perf.data
-rw-------    1 root     root      387360 Aug 25  2015 perf.data

现在,您可以使用命令perf report显示来自perf.data的结果。您可以在命令行上选择三种用户界面:

  • --stdio:这是一个纯文本界面,没有用户交互。您将需要启动perf report并为跟踪的每个视图进行注释。

  • --tui:这是一个简单的基于文本的菜单界面,可以在屏幕之间进行遍历。

  • --gtk:这是一个图形界面,其行为与--tui相同。

默认为 TUI,如此示例所示:

使用 perf 进行性能分析

perf能够记录代表进程执行的内核函数,因为它在内核空间中收集样本。

列表按最活跃的函数首先排序。在此示例中,除了一个函数在运行grep时捕获之外,其他所有函数都被捕获。有些在库libc-2.20中,有些在程序busybox.nosuid中,有些在内核中。我们对程序和库函数有符号名称,因为所有二进制文件都已安装在目标上,并带有调试信息,并且内核符号是从/boot/vmlinux中读取的。如果您的vmlinux位于不同的位置,请在perf report命令中添加-k <path>。您可以使用perf record -o <file name>将样本保存到不同的文件中,而不是将样本存储在perf.data中,并使用perf report -i <file name>进行分析。

默认情况下,perf record 使用循环计数器以 1000Hz 的频率进行采样。

提示

1000Hz 的采样频率可能比您实际需要的要高,并且可能是观察效应的原因。尝试较低的频率:根据我的经验,100Hz 对大多数情况已经足够了。您可以使用-F选项设置采样频率。

调用图

这仍然并不是真的让生活变得容易;列表顶部的函数大多是低级内存操作,你可以相当肯定它们已经被优化过了。很高兴能够退后一步,看看这些函数是从哪里被调用的。您可以通过在每个样本中捕获回溯来做到这一点,可以使用perf record-g选项来实现。

现在,perf report在函数是调用链的一部分时显示加号(+)。您可以展开跟踪以查看链中较低的函数:

调用图

注意

生成调用图依赖于从堆栈中提取调用帧的能力,就像在 GDB 中需要回溯一样。解开堆栈所需的信息被编码在可执行文件的调试信息中,但并非所有架构和工具链的组合都能够做到这一点。

perf annotate

现在您知道要查看哪些函数,很高兴能够深入了解并查看代码,并对每条指令进行计数。这就是perf annotate的作用,它调用了安装在目标上的objdump的副本。您只需要使用perf annotate来代替perf report

perf annotate需要可执行文件和 vmlinux 的符号表。这是一个带注释的函数的示例:

perf annotate

如果您想看到与汇编程序交错的源代码,可以将相关部分复制到目标设备。如果您正在使用 Yocto Project 并使用额外的镜像功能dbg-pkgs构建,或者已安装了单独的-dbg软件包,则源代码将已经安装在/usr/src/debug中。否则,您可以检查调试信息以查看源代码的位置:

$ arm-buildroot-linux-gnueabi-objdump --dwarf lib/libc-2.19.so  | grep DW_AT_comp_dir
 <3f>   DW_AT_comp_dir : /home/chris/buildroot/output/build/host-gcc-initial-4.8.3/build/arm-buildroot-linux-gnueabi/libgcc

目标上的路径应该与DW_AT_comp_dir中看到的路径完全相同。

这是带有源代码和汇编代码的注释示例:

perf annotate

其他分析器:OProfile 和 gprof

这两个统计分析器早于perf。它们都是perf功能的子集,但仍然非常受欢迎。我只会简要提到它们。

OProfile 是一个内核分析器,始于 2002 年。最初,它有自己的内核采样代码,但最近的版本使用perf_events基础设施来实现这一目的。有关更多信息,请访问oprofile.sourceforge.net。OProfile 由内核空间组件和用户空间守护程序和分析命令组成。

OProfile 需要启用这两个内核选项:

  • 常规设置 | 分析支持中的CONFIG_PROFILING

  • 常规设置 | OProfile 系统分析中的CONFIG_OPROFILE

如果您正在使用 Yocto Project,则用户空间组件将作为tools-profile镜像功能的一部分安装。如果您正在使用 Buildroot,则该软件包将通过BR2_PACKAGE_OPROFILE启用。

您可以使用以下命令收集样本:

# operf <program>

等待应用程序完成,或按下Ctrl + C停止分析。分析数据存储在<cur-dir>/oprofile_data/samples/current中。

使用opreport生成概要文件。OProfile 手册中记录了各种选项。

gprof是 GNU 工具链的一部分,是最早的开源代码分析工具之一。它结合了编译时的插装和采样技术,使用 100 Hz 的采样率。它的优点是不需要内核支持。

要准备使用gprof进行分析的程序,您需要在编译和链接标志中添加-pg,这会注入收集有关调用树信息的代码到函数前言中。运行程序时,会收集样本并将其存储在一个缓冲区中,当程序终止时,会将其写入名为gmon.out的文件中。

您可以使用gprof命令从gmon.out中读取样本和程序的副本中的调试信息。

例如,如果您想要对 BusyBox 的grep applet 进行分析。您需要使用-pg选项重新构建 BusyBox,运行命令,并查看结果:

# busybox grep "linux" *
# ls -l gmon.out
-rw-r--r-- 1 root root   473 Nov 24 14:07 gmon.out

然后,您可以在目标机或主机上分析捕获的样本,使用以下内容:

# gprof busybox
Flat profile:

Each sample counts as 0.01 seconds.
 no time accumulated

 %   cumulative   self              self     total
 time   seconds   seconds    calls  Ts/call  Ts/call  name
 0.00     0.00     0.00      688     0.00     0.00  xrealloc
 0.00     0.00     0.00      345     0.00     0.00  bb_get_chunk_from_file
 0.00     0.00     0.00      345     0.00     0.00  xmalloc_fgetline
 0.00     0.00     0.00       6      0.00     0.00  fclose_if_not_stdin
 0.00     0.00     0.00       6      0.00     0.00  fopen_for_read
 0.00     0.00     0.00       6      0.00     0.00  grep_file
[...]
 Call graph

granularity: each sample hit covers 2 byte(s) no time propagated

index  % time    self  children    called     name
 0.00    0.00      688/688  bb_get_chunk_from_file [2]
[1]      0.0     0.00    0.00      688         xrealloc [1]
----------------------------------------------------------
 0.00    0.00      345/345  xmalloc_fgetline [3]
[2]      0.0     0.00    0.00      345      bb_get_chunk_from_file [2]
 0.00    0.00      688/688  xrealloc [1]
---------------------------------------------------------
 0.00    0.00      345/345  grep_file [6]
[3]      0.0     0.00    0.00     345       xmalloc_fgetline [3]
 0.00    0.00     345/345   bb_get_chunk_from_file [2]
--------------------------------------------------------
 0.00    0.00       6/6     grep_main [12]
[4]      0.0     0.00    0.00       6       fclose_if_not_stdin [4]
[...]

请注意,执行时间都显示为零,因为大部分时间都花在系统调用上,而gprof不会对系统调用进行跟踪。

提示

gprof不会捕获多线程进程的主线程以外的线程的样本,并且不会对内核空间进行采样,这些限制了它的实用性。

跟踪事件

到目前为止,我们所见过的所有工具都使用统计采样。通常您希望了解事件的顺序,以便能够看到它们并将它们与彼此关联起来。函数跟踪涉及使用跟踪点对代码进行仪器化,以捕获有关事件的信息,并可能包括以下一些或全部内容:

  • 时间戳

  • 上下文,例如当前 PID

  • 函数参数和返回值

  • 调用堆栈

它比统计分析更具侵入性,并且可能会生成大量数据。通过在捕获样本时应用过滤器,以及在查看跟踪时稍后应用过滤器,可以减轻后者。

我将在这里介绍两个跟踪工具:内核函数跟踪器FtraceLTTng

介绍 Ftrace

内核函数跟踪器Ftrace是由 Steven Rostedt 等人进行的工作发展而来,他们一直在追踪高延迟的原因。Ftrace出现在 Linux 2.6.27 中,并自那时以来一直在积极开发。在内核源代码的Documentation/trace中有许多描述内核跟踪的文档。

Ftrace由许多跟踪器组成,可以记录内核中各种类型的活动。在这里,我将讨论functionfunction_graph跟踪器,以及事件 tracepoints。在第十四章中,实时编程,我将重新讨论 Ftrace,并使用它来显示实时延迟。

function跟踪器对每个内核函数进行仪器化,以便可以记录和时间戳调用。值得一提的是,它使用-pg开关编译内核以注入仪器化,但与 gprof 的相似之处就到此为止了。function_graph跟踪器进一步记录函数的进入和退出,以便可以创建调用图。事件 tracepoints 功能还记录与调用相关的参数。

Ftrace具有非常适合嵌入式的用户界面,完全通过debugfs文件系统中的虚拟文件实现,这意味着您无需在目标机上安装任何工具即可使其工作。尽管如此,如果您愿意,还有其他用户界面可供选择:trace-cmd是一个命令行工具,可记录和查看跟踪,并且在 Buildroot(BR2_PACKAGE_TRACE_CMD)和 Yocto Project(trace-cmd)中可用。还有一个名为 KernelShark 的图形跟踪查看器,可作为 Yocto Project 的一个软件包使用。

准备使用 Ftrace

Ftrace及其各种选项在内核配置菜单中进行配置。您至少需要以下内容:

  • 在菜单内核调试 | 跟踪器 | 内核函数跟踪器中的CONFIG_FUNCTION_TRACER

出于以后会变得清晰的原因,您最好也打开这些选项:

  • 在菜单内核调试 | 跟踪器 | 内核函数图跟踪器中的CONFIG_FUNCTION_GRAPH_TRACER

  • 在菜单内核调试 | 跟踪器 | 启用/禁用动态函数跟踪中的CONFIG_DYNAMIC_FTRACE

由于整个系统托管在内核中,因此不需要进行用户空间配置。

在使用Ftrace之前,您必须挂载debugfs文件系统,按照惯例,它位于/sys/kernel/debug目录中:

# mount –t debugfs none /sys/kernel/debug

所有Ftrace的控件都在/sys/kernel/debug/tracing目录中;甚至在README文件中有一个迷你的HOWTO

这是内核中可用的跟踪器列表:

# 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
[...]

您可以在短短一秒钟内捕获大量数据点。

与分析器一样,很难理解这样的平面函数列表。如果选择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 µs,则用加号+进行注释,如果花费的时间超过100 µs,则用感叹号!进行注释。

通常您只对由单个进程或线程引起的内核活动感兴趣,这种情况下,您可以通过将线程 ID 写入set_ftrace_pid来限制跟踪到一个线程。

动态 Ftrace 和跟踪过滤器

启用CONFIG_DYNAMIC_FTRACE允许 Ftrace 在运行时修改函数trace站点,这有一些好处。首先,它触发了跟踪函数探针的额外构建时间处理,使 Ftrace 子系统能够在引导时定位它们并用 NOP 指令覆盖它们,从而将函数跟踪代码的开销几乎降为零。然后,您可以在生产或接近生产的内核中启用 Ftrace 而不会影响性能。

第二个优点是您可以有选择地启用函数trace sites而不是跟踪所有内容。函数列表放入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

运行一些测试,然后查看跟踪:

# 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部分。

跟踪事件

在前面的部分中描述的函数和function_graph跟踪器仅记录执行函数的时间。跟踪事件功能还记录与调用相关的参数,使跟踪更易读和信息丰富。例如,跟踪事件将记录请求的字节数和返回的指针,而不仅仅是记录调用了函数kmalloc。跟踪事件在 perf 和 LTTng 以及 Ftrace 中使用,但跟踪事件子系统的开发是由 LTTng 项目促成的。

创建跟踪事件需要内核开发人员的努力,因为每个事件都是不同的。它们在源代码中使用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:这是一个必须对事件进行跟踪的表达式。

  • 格式:这是事件和参数的格式。

  • id:这是一个数字标识符。

  • 触发器:这是在事件发生时执行的命令,使用Documentation/trace/ftrace.txt过滤命令部分定义的语法。我将为您展示一个涉及kmallockfree的简单示例。

事件跟踪不依赖于功能跟踪器,因此首先选择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 事件

使用 LTTng

Linux Trace Toolkit 项目是由 Karim Yaghmour 发起的,作为跟踪内核活动的手段,并且是最早为 Linux 内核提供的跟踪工具之一。后来,Mathieu Desnoyers 接受了这个想法,并将其重新实现为下一代跟踪工具 LTTng。然后,它被扩展以覆盖用户空间跟踪以及内核。项目网站位于lttng.org/,包含了全面的用户手册。

LTTng 由三个组件组成:

  • 核心会话管理器

  • 作为一组内核模块实现的内核跟踪器

  • 作为库实现的用户空间跟踪器

除此之外,您还需要一个跟踪查看器,比如 Babeltrace(www.efficios.com/babeltrace)或 Eclipse Trace Compaas 插件,以在主机或目标上显示和过滤原始跟踪数据。

LTTng 需要一个配置了CONFIG_TRACEPOINTS的内核,当您选择内核调试 | 跟踪器 | 内核函数跟踪器时会启用。

以下描述是针对 LTTng 版本 2.5 的;其他版本可能有所不同。

LTTng 和 Yocto 项目

您需要将这些软件包添加到目标依赖项中,例如在conf/local.conf中:

IMAGE_INSTALL_append = " lttng-tools lttng-modules lttng-ust"

如果您想在目标上运行 Babeltrace,还需要附加软件包babeltrace

LTTng 和 Buildroot

您需要启用以下内容:

  • 在菜单目标软件包 | 调试、性能分析和基准测试 | lttng-modules中的BR2_PACKAGE_LTTNG_MODULES

  • 在菜单目标软件包 | 调试、性能分析和基准测试 | lttng-tools中的BR2_PACKAGE_LTTNG_TOOLS

对于用户空间跟踪,启用此选项:

  • 在菜单目标软件包 | | 其他中的BR2_PACKAGE_LTTNG_LIBUST,启用lttng-libust

有一个名为lttng-babletrace的软件包供目标使用。Buildroot 会自动构建主机的babeltrace并将其放置在output/host/usr/bin/babeltrace中。

使用 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/test-20150824-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 查看器以文本格式转储原始跟踪数据,在这种情况下,我在主机计算机上运行它:

$ babeltrace  lttng-traces/test-20150824-140942/kernel

输出内容过于冗长,无法适应本页,因此我将其留给您,读者,以此方式捕获和显示跟踪。eBabeltrace 的文本输出具有一个优点,那就是可以使用 grep 和类似的命令轻松搜索字符串。

一个用于图形跟踪查看器的不错选择是 Eclipse 的 Trace Compass 插件,它现在是 Eclipse IDE for C/C++ Developers 捆绑包的一部分。将跟踪数据导入 Eclipse 通常有点麻烦。简而言之,您需要按照以下步骤进行操作:

  1. 打开跟踪透视图。

  2. 通过选择文件 | 新建 | 跟踪项目来创建一个新项目。

  3. 输入项目名称,然后点击完成

  4. 项目资源管理器菜单中右键单击新建项目选项,然后选择导入

  5. 展开跟踪,然后选择跟踪导入

  6. 浏览到包含跟踪的目录(例如test-20150824-140942),选中要指示的子目录的复选框(可能是内核),然后点击完成

  7. 现在,展开项目,在其中展开Traces[1],然后在其中双击kernel

  8. 您应该在以下截图中看到跟踪数据:使用 LTTng 进行内核跟踪

在前面的截图中,我已经放大了控制流视图,以显示dropbear和 shell 之间的状态转换,以及lttng守护程序的一些活动。

使用 Valgrind 进行应用程序分析。

我在第十一章中介绍了 Valgrind,内存管理,作为使用 memcheck 工具识别内存问题的工具。Valgrind 还有其他有用的应用程序分析工具。我要在这里看的两个是CallgrindHelgrind。由于 Valgrind 通过在沙盒中运行代码来工作,它能够在代码运行时检查并报告某些行为,而本地跟踪器和分析器无法做到这一点。

Callgrind

Callgrind 是一个生成调用图的分析器,还收集有关处理器缓存命中率和分支预测的信息。如果您的瓶颈是 CPU 密集型,Callgrind 才有用。如果涉及大量 I/O 或多个进程,则没有用。

Valgrind 不需要内核配置,但需要调试符号。它在 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.out.122-01callgrind.out.122-02等。

Callgrind 可以模拟处理器 L1/L2 缓存并报告缓存未命中。使用--simulate-cache=yes选项捕获跟踪。L2 未命中比 L1 未命中要昂贵得多,因此要注意具有高 D2mr 或 D2mw 计数的代码。

Helgrind

这是一个用于检测 C、C++和 Fortran 程序中包含 POSIX 线程的同步错误的线程错误检测器。

Helgrind 可以检测三类错误。首先,它可以检测 API 的不正确使用。例如,它可以解锁已经解锁的互斥锁,解锁由不同线程锁定的互斥锁,不检查某些 Pthread 函数的返回值。其次,它监视线程获取锁的顺序,从而检测可能由于锁的循环形成而产生的潜在死锁。最后,它检测数据竞争,当两个线程访问共享内存位置而不使用适当的锁或其他同步来确保单线程访问时可能发生。

使用 Helgrind 很简单,您只需要这个命令:

# valgrind --tool=helgrind <program>

它在发现问题和潜在问题时打印出来。您可以通过添加--log-file=<filename>将这些消息定向到文件。

使用 strace 显示系统调用

我从简单且无处不在的工具top开始了本章,我将以另一个工具strace结束。它是一个非常简单的跟踪器,可以捕获程序及其子进程所进行的系统调用。您可以使用它来执行以下操作:

  • 了解程序进行了哪些系统调用。

  • 找出那些一起失败的系统调用以及错误代码。如果程序无法启动但没有打印错误消息,或者消息太一般化,我发现这很有用。strace显示了失败的系统调用。

  • 查找程序打开了哪些文件。

  • 找出正在运行的程序进行了哪些系统调用,例如查看它是否陷入了循环中。

在线上还有更多的例子,只需搜索strace的技巧和窍门。每个人都有自己喜欢的故事,例如,chadfowler.com/blog/2014/01/26/the-magic-of-strace

strace使用ptrace(2)函数来挂钩用户空间到内核的调用。如果您想了解更多关于ptrace如何工作的信息,man 手册详细且易懂。

获取跟踪的最简单方法是像这样运行带有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 +++

大部分的跟踪显示了运行时环境是如何创建的。特别是您可以看到库加载器是如何寻找libc.so.6的,最终在/lib中找到它。最后,它开始运行程序的main()函数,打印其消息并退出。

如果您希望strace跟踪原始进程创建的任何子进程或线程,请添加-f选项。

提示

如果您正在使用strace来跟踪创建线程的程序,几乎肯定需要-f选项。最好使用-ff-o <file name>,这样每个子进程或线程的输出都将被写入一个名为<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

摘要

没有人能抱怨 Linux 缺乏性能分析和跟踪的选项。本章为您概述了一些最常见的选项。

当面对性能不如预期的系统时,从top开始并尝试识别问题。如果问题被证明是单个应用程序引起的,那么您可以使用perf record/report来对其进行性能分析,但需要注意您必须配置内核以启用perf,并且需要二进制文件和内核的调试符号。OProfile 是perf record的替代方案,可以提供类似的信息。gprof已经过时,但它的优势是不需要内核支持。如果问题没有那么局部化,使用perf(或 OProfile)来获取系统范围的视图。

当您对内核的行为有特定问题时,Ftrace就派上用场了。functionfunction_graph跟踪器提供了函数调用关系和顺序的详细视图。事件跟踪器允许您提取有关函数的更多信息,包括参数和返回值。LTTng 执行类似的角色,利用事件跟踪机制,并添加了高速环形缓冲区以从内核中提取大量数据。Valgrind 具有特殊优势,它在沙盒中运行代码,并且可以报告其他方式难以跟踪到的错误。

使用 Callgrind 工具,它可以生成调用图并报告处理器缓存的使用情况,而使用 Helgrind 时,它可以报告与线程相关的问题。最后,不要忘记strace。它是发现程序正在进行哪些系统调用的良好工具,从跟踪文件打开调用以查找文件路径名到检查系统唤醒和传入信号。

与此同时,要注意并尽量避免观察者效应:确保您正在进行的测量对生产系统是有效的。在下一章中,我将继续探讨这一主题,深入探讨帮助我们量化目标系统实时性能的延迟跟踪工具。

第十四章:实时编程

计算机系统与现实世界之间的许多交互都是实时进行的,因此这对于嵌入式系统的开发人员来说是一个重要的主题。到目前为止,我已经在几个地方提到了实时编程:在第十章中,了解进程和线程,我研究了调度策略和优先级反转,在第十一章中,管理内存,我描述了页面错误的问题和内存锁定的需求。现在,是时候把这些主题联系在一起,深入研究实时编程了。

在本章中,我将首先讨论实时系统的特性,然后考虑应用程序和内核级别的系统设计的影响。我将描述实时内核补丁PREEMPT_RT,并展示如何获取它并将其应用于主线内核。最后几节将描述如何使用两个工具cyclictestFtrace来表征系统延迟。

嵌入式 Linux 设备实现实时行为的其他方法,例如,使用专用微控制器或在 Linux 内核旁边使用单独的实时内核,就像 Xenomai 和 RTAI 所做的那样。我不打算在这里讨论这些,因为本书的重点是将 Linux 用作嵌入式系统的核心。

什么是实时?

实时编程的性质是软件工程师喜欢长时间讨论的主题之一,通常给出一系列矛盾的定义。我将从阐明我认为实时重要的内容开始。

如果一个任务必须在某个时间点之前完成,这个时间点被称为截止日期,那么这个任务就是实时任务。通过考虑在编译 Linux 内核时在计算机上播放音频流时会发生什么,可以看出实时任务和非实时任务之间的区别。

第一个是实时任务,因为音频驱动程序不断接收数据流,并且必须以播放速率将音频样本块写入音频接口。同时,编译不是实时的,因为没有截止日期。您只是希望它尽快完成;无论它花费 10 秒还是 10 分钟,都不会影响内核的质量。

另一个重要的事情要考虑的是错过截止日期的后果,这可能从轻微的烦恼到系统故障和死亡。以下是一些例子:

  • 播放音频流:截止日期在几十毫秒的范围内。如果音频缓冲区不足,你会听到点击声,这很烦人,但你会克服它。

  • 移动和点击鼠标:截止日期也在几十毫秒的范围内。如果错过了,鼠标会移动不稳定,按钮点击将丢失。如果问题持续存在,系统将变得无法使用。

  • 打印一张纸:纸张进纸的截止日期在毫秒级范围内,如果错过了,可能会导致打印机卡住,有人必须去修理。偶尔卡纸是可以接受的,但没有人会购买一台不断卡纸的打印机。

  • 在生产线上的瓶子上打印保质期:如果一个瓶子没有被打印,整个生产线必须停止,瓶子被移除,然后重新启动生产线,这是昂贵的。

  • 烘烤蛋糕:有大约 30 分钟的截止日期。如果你迟到了几分钟,蛋糕可能会被毁掉。如果你迟到了很长时间,房子就会烧毁。

  • 电力浪涌检测系统:如果系统检测到浪涌,必须在 2 毫秒内触发断路器。未能这样做会损坏设备,并可能伤害或杀死人员。

换句话说,错过截止日期会有许多后果。我们经常谈论这些不同的类别:

  • 软实时:截止日期是可取的,但有时会错过而系统不被视为失败。前两个例子就是这样。

  • 硬实时:在这里,错过截止日期会产生严重影响。我们可以进一步将硬实时细分为在错过截止日期会产生成本的关键任务系统,比如第四个例子,以及在错过截止日期会对生命和肢体造成危险的安全关键系统,比如最后两个例子。我提出银行的例子是为了表明,并非所有硬实时系统的截止日期都是以微秒计量的。

为安全关键系统编写的软件必须符合各种标准,以确保其能够可靠地执行。对于像 Linux 这样复杂的操作系统来说,要满足这些要求非常困难。

在关键任务系统中,Linux 通常可以用于各种控制系统,这是可能的,也是常见的。软件的要求取决于截止日期和置信水平的组合,这通常可以通过广泛的测试来确定。

因此,要说一个系统是实时的,你必须在最大预期负载下测量其响应时间,并证明它在约定时间内满足截止日期的比例。作为一个经验法则,使用主线内核的良好配置的 Linux 系统适用于截止日期为几十毫秒的软实时任务,而使用PREEMPT_RT补丁的内核适用于截止日期为几百微秒的软实时和硬实时的关键任务系统。

创建实时系统的关键是减少响应时间的变化,以便更有信心地确保它们不会被错过;换句话说,你需要使系统更确定性。通常情况下,这是以性能为代价的。例如,缓存通过缩短访问数据项的平均时间来使系统运行得更快,但在缓存未命中的情况下,最大时间更长。缓存使系统更快但不太确定,这与我们想要的相反。

提示

实时计算的神话是它很快。事实并非如此,系统越确定性越高,最大吞吐量就越低。

本章的其余部分关注识别延迟的原因以及您可以采取的措施来减少延迟。

识别非确定性的来源

从根本上说,实时编程是确保实时控制输出的线程在需要时被调度,从而能够在截止日期之前完成工作。任何阻碍这一点的都是问题。以下是一些问题领域:

  • 调度:实时线程必须在其他线程之前被调度,因此它们必须具有实时策略,SCHED_FIFOSCHED_RR。此外,它们应该按照我在第十章中描述的速率单调分析理论,按照截止日期最短的顺序分配优先级。

  • 调度延迟:内核必须能够在事件(如中断或定时器)发生时立即重新调度,并且不会受到无限延迟的影响。减少调度延迟是本章后面的一个关键主题。

  • 优先级反转:这是基于优先级的调度的结果,当高优先级线程在低优先级线程持有的互斥锁上被阻塞时,会导致无限延迟,正如我在第十章中所描述的,了解进程和线程。用户空间具有优先级继承和优先级屏障互斥锁;在内核空间中,我们有实时互斥锁,它实现了优先级继承,我将在实时内核部分讨论它。

  • 准确的定时器:如果你想要管理毫秒或微秒级别的截止期限,你需要匹配的定时器。高分辨率定时器至关重要,并且几乎所有内核都有配置选项。

  • 页面错误:在执行关键代码部分时发生页面错误会破坏所有时间估计。您可以通过锁定内存来避免它们,我稍后会详细描述。

  • 中断:它们在不可预测的时间发生,并且如果突然出现大量中断,可能会导致意外的处理开销。有两种方法可以避免这种情况。一种是将中断作为内核线程运行,另一种是在多核设备上,将一个或多个 CPU 屏蔽免受中断处理的影响。我稍后会讨论这两种可能性。

  • 处理器缓存:提供了 CPU 和主内存之间的缓冲区,并且像所有缓存一样,是非确定性的来源,特别是在多核设备上。不幸的是,这超出了本书的范围,但是可以参考本章末尾的参考资料。

  • 内存总线争用:当外围设备通过 DMA 通道直接访问内存时,它们会占用一部分内存总线带宽,从而减慢 CPU 核心(或核心)的访问速度,因此有助于程序的非确定性执行。然而,这是一个硬件问题,也超出了本书的范围。

我将在接下来的章节中扩展重要问题并看看可以采取什么措施。

列表中缺少的一项是电源管理。实时和电源管理的需求方向相反。在睡眠状态之间切换时,电源管理通常会导致高延迟,因为设置电源调节器和唤醒处理器都需要时间,改变核心时钟频率也需要时间,因为时钟需要时间稳定。但是,你肯定不会期望设备立即从挂起状态响应中断吧?我知道在至少喝一杯咖啡之后我才能开始一天。

理解调度延迟

实时线程需要在有任务要做时立即调度。然而,即使没有其他相同或更高优先级的线程,从唤醒事件发生的时间点(中断或系统定时器)到线程开始运行的时间总会有延迟。这称为调度延迟。它可以分解为几个组件,如下图所示:

理解调度延迟

首先,硬件中断延迟是指从中断被断言直到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,并且有一个维基,包括一个 FAQ(略有过时),位于rt.wiki.kernel.org

多年来,项目的许多部分已经并入了主线 Linux,包括高分辨率定时器、内核互斥锁和线程中断处理程序。然而,核心补丁仍然留在主线之外,因为它们相当具有侵入性,而且(有人声称)只有很小一部分 Linux 用户受益。也许,有一天,整个补丁集将被合并到上游。

中央计划是减少内核在原子上下文中运行的时间,这是不安全调用调度程序并切换到不同线程的地方。典型的原子上下文是内核:

  • 正在运行中断或陷阱处理程序

  • 持有自旋锁或处于 RCU 临界区。自旋锁和 RCU 是内核锁原语,这里的细节并不相关

  • 在调用preempt_disable()preempt_enable()之间

  • 硬件中断被禁用

PREEMPT_RT的更改分为两个主要领域:一个是通过将中断处理程序转换为内核线程来减少中断处理程序的影响,另一个是使锁可抢占,以便线程在持有锁的同时可以休眠。很明显,这些更改会带来很大的开销,这使得平均情况下中断处理变慢,但更加确定,这正是我们所追求的。

线程中断处理程序

并非所有中断都是实时任务的触发器,但所有中断都会从实时任务中窃取周期。线程中断处理程序允许将优先级与中断关联,并在适当的时间进行调度,如下图所示:

线程中断处理程序

如果中断处理程序代码作为内核线程运行,那么它就没有理由不被优先级更高的用户空间线程抢占,因此中断处理程序不会增加用户空间线程的调度延迟。自 2.6.30 版起,分线程中断处理程序已成为主线 Linux 的特性。您可以通过使用request_threaded_irq()注册单个中断处理程序来请求将其作为线程化,而不是使用普通的request_irq()。您可以通过配置内核使所有处理程序成为线程,将分线程中断设置为默认值CONFIG_IRQ_FORCED_THREADING=y,除非它们通过设置IRQF_NO_THREAD标志明确阻止了这一点。当您应用PREEMPT_RT补丁时,默认情况下会将中断配置为线程。以下是您可能看到的示例:

# ps -Leo pid,tid,class,rtprio,stat,comm,wchan | grep FF
PID     TID     CLS     RTPRIO  STAT    COMMAND          WCHAN
3       3       FF      1       S      ksoftirqd/0      smpboot_th
7       7       FF      99      S      posixcputmr/0    posix_cpu_
19      19      FF      50      S      irq/28-edma      irq_thread
20      20      FF      50      S      irq/30-edma_err  irq_thread
42      42      FF      50      S      irq/91-rtc0      irq_thread
43      43      FF      50      S      irq/92-rtc0      irq_thread
44      44      FF      50      S      irq/80-mmc0      irq_thread
45      45      FF      50      S      irq/150-mmc0     irq_thread
47      47      FF      50      S      irq/44-mmc1      irq_thread
52      52      FF      50      S      irq/86-44e0b000  irq_thread
59      59      FF      50      S      irq/52-tilcdc    irq_thread
65      65      FF      50      S      irq/56-4a100000  irq_thread
66      66      FF      50      S      irq/57-4a100000  irq_thread
67      67      FF      50      S      irq/58-4a100000  irq_thread
68      68      FF      50      S      irq/59-4a100000  irq_thread
76      76      FF      50      S      irq/88-OMAP UAR  irq_thread

在这种情况下,运行linux-yocto-rt的 BeagleBone 只有gp_timer中断没有被线程化。定时器中断处理程序以内联方式运行是正常的。

注意

请注意,中断线程都已被赋予默认策略SCHED_FIFO和优先级50。然而,将它们保留为默认值是没有意义的;现在是您根据中断的重要性与实时用户空间线程相比分配优先级的机会。

以下是建议的降序线程优先级顺序:

  • POSIX 计时器线程posixcputmr应始终具有最高优先级。

  • 与最高优先级实时线程相关的硬件中断。

  • 最高优先级的实时线程。

  • 逐渐降低优先级的实时线程的硬件中断,然后是线程本身。

  • 非实时接口的硬件中断。

  • 软中断守护程序ksoftirqd,在 RT 内核上负责运行延迟中断例程,并且在 Linux 3.6 之前负责运行网络堆栈、块 I/O 层和其他内容。您可能需要尝试不同的优先级级别以获得平衡。

您可以使用chrt命令作为引导脚本的一部分来更改优先级,使用类似以下的命令:

# chrt -f -p 90 `pgrep irq/28-edma`

pgrep命令是procps软件包的一部分。

可抢占内核锁

使大多数内核锁可抢占是PREEMPT_RT所做的最具侵入性的更改,这段代码仍然在主线内核之外。

问题出现在自旋锁上,它们用于大部分内核锁定。自旋锁是一种忙等待互斥锁,在争用情况下不需要上下文切换,因此只要锁定时间很短,它就非常高效。理想情况下,它们应该被锁定的时间少于两次重新调度所需的时间。以下图表显示了在两个不同 CPU 上运行的线程争用相同自旋锁的情况。CPU0首先获得它,迫使CPU1自旋,等待直到它被解锁:

可抢占内核锁

持有自旋锁的线程不能被抢占,因为这样做可能会使新线程进入相同的代码并在尝试锁定相同自旋锁时发生死锁。因此,在主线 Linux 中,锁定自旋锁会禁用内核抢占,创建原子上下文。这意味着持有自旋锁的低优先级线程可以阻止高优先级线程被调度。

注意

PREEMPT_RT采用的解决方案是用 rt-mutexes 几乎替换所有自旋锁。互斥锁比自旋锁慢,但是完全可抢占。不仅如此,rt-mutexes 实现了优先级继承,因此不容易发生优先级反转。

获取 PREEMPT_RT 补丁

RT 开发人员不会为每个内核版本创建补丁集,因为这需要大量的工作。平均而言,他们为每个其他内核创建补丁。在撰写本文时支持的最新内核版本如下:

  • 4.1-rt

  • 4.0-rt

  • 3.18-rt

  • 3.14-rt

  • 3.12-rt

  • 3.10-rt

这些补丁可以在www.kernel.org/pub/linux/kernel/projects/rt上找到。

如果您正在使用 Yocto 项目,那么内核已经有了rt版本。否则,您获取内核的地方可能已经应用了PREEMPT_RT补丁。否则,您将不得不自己应用补丁。首先确保PREEMPT_RT补丁版本与您的内核版本完全匹配,否则您将无法干净地应用补丁。然后按照正常方式应用,如下所示:

$ cd linux-4.1.10
$ zcat patch-4.1.10-rt11.patch.gz | patch -p1

然后,您将能够使用CONFIG_PREEMPT_RT_FULL配置内核。

最后一段有一个问题。RT 补丁只有在使用兼容的主线内核时才会应用。您可能不会使用兼容的内核,因为这是嵌入式 Linux 内核的特性,因此您将不得不花一些时间查看失败的补丁并修复它们,然后分析您的目标的板支持并添加任何缺失的实时支持。这些细节再次超出了本书的范围。如果您不确定该怎么做,您应该向您正在使用的内核的开发人员和内核开发人员论坛咨询。

Yocto 项目和 PREEMPT_RT

Yocto 项目提供了两个标准的内核配方:linux-yoctolinux-yoco-rt,后者已经应用了实时补丁。假设您的目标受到这些内核的支持,那么您只需要选择linux-yocto-rt作为首选内核,并声明您的设备兼容,例如,通过向您的conf/local.conf添加类似以下的行:

PREFERRED_PROVIDER_virtual/kernel = "linux-yocto-rt"
COMPATIBLE_MACHINE_beaglebone = "beaglebone"

高分辨率定时器

如果您有精确的定时要求,这在实时应用程序中很典型,那么定时器分辨率就很重要。Linux 中的默认定时器是以可配置速率运行的时钟,嵌入式系统通常为 100 赫兹,服务器和台式机通常为 250 赫兹。两个定时器滴答之间的间隔称为jiffy,在上面的示例中,嵌入式 SoC 上为 10 毫秒,服务器上为 4 毫秒。

Linux 在 2.6.18 版本中从实时内核项目中获得了更精确的定时器,现在它们在所有平台上都可用,只要有高分辨率定时器源和设备驱动程序——这几乎总是如此。您需要使用CONFIG_HIGH_RES_TIMERS=y配置内核。

启用此功能后,所有内核和用户空间时钟都将准确到基础硬件的粒度。找到实际的时钟粒度很困难。显而易见的答案是clock_getres(2)提供的值,但它总是声称分辨率为一纳秒。我将在后面描述的cyclictest工具有一个选项,用于分析时钟报告的时间以猜测分辨率:

# cyclictest -R
# /dev/cpu_dma_latency set to 0us
WARN: reported clock resolution: 1 nsec
WARN: measured clock resolution approximately: 708 nsec
You can also look at the kernel log messages for strings like this:
# dmesg | grep clock
OMAP clockevent source: timer2 at 24000000 Hz
sched_clock: 32 bits at 24MHz, resolution 41ns, wraps every 178956969942ns
OMAP clocksource: timer1 at 24000000 Hz
Switched to clocksource timer1

这两种方法给出了不同的数字,我无法给出一个好的解释,但由于两者都低于一微秒,我很满意。

在实时应用程序中避免页面错误

当应用程序读取或写入未提交到物理内存的内存时,会发生页面错误。不可能(或者非常困难)预测何时会发生页面错误,因此它们是计算机中另一个非确定性的来源。

幸运的是,有一个函数可以让您为进程提交所有内存并将其锁定,以便它不会引起页面错误。这就是mlockall(2)。这是它的两个标志:

  • MCL_CURRENT:锁定当前映射的所有页面

  • MCL_FUTURE:锁定稍后映射的页面

通常在应用程序启动时调用mlockall(2),同时设置两个标志以锁定所有当前和未来的内存映射。

提示

请注意,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 都设置了一个位。

测量调度延迟

您可能进行的所有配置和调整都将是无意义的,如果您不能证明您的设备满足截止日期。最终测试需要您自己的基准测试,但我将在这里描述两个重要的测量工具:cyclictestFtrace

cyclictest

cyclictest 最初由 Thomas Gleixner 编写,现在在大多数平台上都可以在名为rt-tests的软件包中使用。如果您使用 Yocto Project,可以通过构建实时镜像配方来创建包含rt-tests的目标镜像,方法如下:

$ bitbake core-image-rt

如果您使用 Buildroot,您需要在菜单目标软件包 | 调试、性能分析和基准测试 | rt-tests中添加软件包BR2_PACKAGE_RT_TESTS

cyclictest 通过比较实际休眠所需的时间和请求的时间来测量调度延迟。如果没有延迟,它们将是相同的,报告的延迟将为零。cyclictest 假设定时器分辨率小于一微秒。

它有大量的命令行选项。首先,您可以尝试在目标上以 root 身份运行此命令:

# cyclictest -l 100000 -m -n -p 99
# /dev/cpu_dma_latency set to 0us
policy: fifo: loadavg: 1.14 1.06 1.00 1/49 320

T: 0 (  320) P:99 I:1000 C: 100000 Min:  9 Act:  13 Avg:  15 Max:  134

所选的选项如下:

  • -l N: 循环 N 次:默认为无限

  • -m: 使用 mlockall 锁定内存

  • -n: 使用clock_nanosleep(2)而不是nanosleep(2)

  • -p N: 使用实时优先级N

结果行从左到右显示以下内容:

  • T: 0: 这是线程 0,这次运行中唯一的线程。您可以使用参数-t设置线程数。

  • (320): 这是 PID 320。

  • P:99: 优先级为 99。

  • I:1000: 循环之间的间隔为 1,000 微秒。您可以使用参数-i N设置间隔。

  • C:100000: 该线程的最终循环计数为 100,000。

  • Min: 9: 最小延迟为 9 微秒。

  • Act:13: 实际延迟为 13 微秒。实际延迟是最近的延迟测量,只有在观察cyclictest运行时才有意义。

  • Avg:15: 平均延迟为 15 微秒。

  • Max:134: 最大延迟为 134 微秒。

这是在运行未修改的linux-yocto内核的空闲系统上获得的,作为该工具的快速演示。要真正有用,您需要在运行负载代表您期望的最大负载的同时,进行 24 小时或更长时间的测试。

cyclictest生成的数字中,最大延迟是最有趣的,但了解值的分布也很重要。您可以通过添加-h <N>来获得最多迟到N微秒的样本的直方图。使用这种技术,我在相同的目标板上运行了没有抢占、标准抢占和 RT 抢占的内核,同时通过洪水 ping 加载以太网流量,获得了三个跟踪。命令行如下所示:

# cyclictest -p 99 -m -n -l 100000 -q -h 500 > cyclictest.data

以下是没有抢占生成的输出:

cyclictest

没有抢占时,大多数样本在截止日期之前 100 微秒内,但有一些离群值高达 500 微秒,这基本上是您所期望的。

这是使用标准抢占生成的输出:

cyclictest

有抢占时,样本在较低端分布,但没有超过 120 微秒的情况。

这是使用 RT 抢占生成的输出:

cyclictest

RT 内核是明显的赢家,因为一切都紧密地集中在 20 微秒左右,没有超过 35 微秒的情况。

因此,cyclictest是调度延迟的标准度量。但它无法帮助您识别和解决内核延迟的特定问题。为此,您需要Ftrace

使用 Ftrace

内核函数跟踪器有跟踪器可帮助跟踪内核延迟,这也是它最初编写的目的。这些跟踪器捕获了在运行过程中检测到的最坏情况延迟的跟踪,显示导致延迟的函数。感兴趣的跟踪器以及内核配置参数如下:

  • irqsoffCONFIG_IRQSOFF_TRACER跟踪禁用中断的代码,记录最坏情况

  • preemptoffCONFIG_PREEMPT_TRACER类似于irqsoff,但跟踪内核抢占被禁用的最长时间(仅适用于可抢占内核)

  • preemptirqsoff:它结合了前两个跟踪,记录了禁用irqs和/或抢占的最长时间

  • wakeup:跟踪并记录唤醒后最高优先级任务被调度所需的最大延迟

  • wakeup_rt:与唤醒相同,但仅适用于具有SCHED_FIFOSCHED_RRSCHED_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>

在这里,您可以看到在运行跟踪时禁用内核抢占的最长时间为 1,160 微秒。通过阅读/sys/kernel/debug/tracing/tracing_max_latency,可以获得这个简单的事实,但上面的跟踪进一步提供了导致该测量的内核函数调用序列。标记为delay的列显示了每个函数被调用的时间,最后一次调用是在1162us时的trace_preempt_on(),在这一点上内核抢占再次被启用。有了这些信息,您可以回顾调用链,并(希望)弄清楚这是否是一个问题。

提到的其他跟踪器工作方式相同。

结合 cyclictest 和 Ftrace

如果cyclictest报告出现意外长的延迟,您可以使用breaktrace选项中止程序并触发Ftrace以获取更多信息。

您可以使用-b<N>--breaktrace=<N>来调用 breaktrace,其中N是将触发跟踪的延迟的微秒数。您可以使用-T[tracer name]或以下之一选择Ftrace跟踪器:

  • -C:上下文切换

  • -E:事件

  • -f:函数

  • -w:唤醒

  • -W:唤醒-rt

例如,当测量到大于 100 微秒的延迟时,这将触发Ftrace函数跟踪器:

# cyclictest -a -t -n -p99 -f -b100

进一步阅读

以下资源提供了有关本章介绍的主题的更多信息:

  • 硬实时计算系统:可预测的调度算法和应用,作者ButtazzoGiorgioSpringer,2011

  • 多核应用程序编程,作者Darryl GoveAddison Wesley,2011

总结

实时这个术语是没有意义的,除非您用截止日期和可接受的错过率来限定它。当您知道这一点时,您可以确定 Linux 是否适合作为操作系统的候选,并且开始调整系统以满足要求。调整 Linux 和您的应用程序以处理实时事件意味着使其更具确定性,以便它可以在截止日期内可靠地处理数据。确定性通常是以总吞吐量为代价的,因此实时系统无法处理与非实时系统一样多的数据。

不可能提供数学证明,证明像 Linux 这样的复杂操作系统总是能满足给定的截止日期,因此唯一的方法是通过使用cyclictestFtrace等工具进行广泛测试,更重要的是使用您自己的应用程序的基准测试。

为了提高确定性,您需要考虑应用程序和内核。在编写实时应用程序时,您应该遵循本章关于调度、锁定和内存的指导方针。

内核对系统的确定性有很大影响。幸运的是,多年来已经进行了大量工作。启用内核抢占是一个很好的第一步。如果您发现它错过截止日期的频率比您想要的要高,那么您可能需要考虑PREEMPT_RT内核补丁。它们确实可以产生低延迟,但它们尚未纳入主线内核,这意味着您可能在将它们与特定板子的供应商内核集成时遇到问题。您可能需要使用Ftrace和类似工具来找出延迟的原因。

这就是我对嵌入式 Linux 的剖析的结束。作为嵌入式系统工程师需要具备广泛的技能,从对硬件的低级了解,系统引导程序的工作原理以及内核与其交互的方式,到成为一个能够配置用户应用程序并调整其以高效方式运行的优秀系统工程师。所有这些都必须在几乎总是只能胜任任务的硬件上完成。有一句话概括了这一切,“一个工程师可以用一美元做到别人用两美元才能做到的事情”。我希望您能够通过我在本书中提供的信息实现这一点。

posted @ 2024-05-16 19:15  绝不原创的飞龙  阅读(62)  评论(0编辑  收藏  举报