嵌入式系统架构第二版(一)
由于微电子制造商和设计师在技术进步方面取得的成果,嵌入式系统在过去二十年里变得越来越受欢迎,这些成果旨在提高计算能力并减小微处理器和外设逻辑的尺寸。设计、实现和集成这些系统的软件组件通常需要直接针对硬件功能的方法,在这种情况下,任务在单个线程中实现,没有操作系统提供抽象以访问 CPU 功能和外部外设。因此,嵌入式开发被认为是软件开发宇宙中的一个独立领域,其中开发者的方法和工作流程需要相应地调整。
原文:
zh.annas-archive.org/md5/71d9ae8b678429780b0d22ae06fb3bae译者:飞龙
前言
由于微电子制造商和设计师在技术进步方面取得的成果,嵌入式系统在过去二十年里变得越来越受欢迎,这些成果旨在提高计算能力并减小微处理器和外设逻辑的尺寸。
设计、实现和集成这些系统的软件组件通常需要直接针对硬件功能的方法,在这种情况下,任务在单个线程中实现,没有操作系统提供抽象以访问 CPU 功能和外部外设。因此,嵌入式开发被认为是软件开发宇宙中的一个独立领域,其中开发者的方法和工作流程需要相应地调整。
本书简要介绍了典型嵌入式系统的硬件架构,介绍了开始开发目标架构所需的工具和方法,然后引导读者通过系统功能和外设交互进行操作。一些领域,如能效和连接性,被更详细地讨论,以更接近地了解设计低功耗和连接系统的技术。在本书的后期,从单个系统组件的实现开始,构建了一个更复杂的设计,其中包含一个(简化的)实时操作系统。最后,在本版的第二版中,我们增加了对 TrustZone-M 实现的分析,这是 ARM 作为其最新嵌入式微控制器系列的一部分引入的 TEE 技术。
讨论通常集中在特定的安全和安全机制上,通过建议旨在提高系统对应用程序代码中的编程错误或甚至恶意尝试破坏其完整性的鲁棒性的特定技术。
本书面向对象
如果你是一名希望了解嵌入式编程的软件开发者或设计师,这本书就是为你准备的。如果你是一名经验较少或初学者嵌入式程序员,愿意扩展你对嵌入式系统的知识,这本书也会很有用。更有经验的嵌入式软件工程师可能会发现这本书对刷新他们对设备驱动程序内部、内存安全性、安全数据传输、权限分离和安全执行域的知识很有帮助。
本书涵盖的内容
第一章, 嵌入式系统——实用方法, 是基于微控制器嵌入式系统的入门介绍。本书的范围从“嵌入式系统”的更广泛定义到将要分析的领域——具有物理内存映射的 32 位微控制器——得到了明确界定。
第二章, 工作环境和工作流程优化,概述了使用的工具和开发工作流程。这是对工具链、调试器和仿真器的介绍,这些工具可以用来生成二进制格式的代码,该代码可以上传并在目标平台上运行。
第三章, 架构模式,主要关于协作开发和测试的策略和开发方法。本章提出了在开发和测试嵌入式系统软件时通常使用的流程的描述。
第四章, 启动过程,分析了嵌入式系统的启动阶段、启动阶段和引导加载程序。它包含了对启动代码及其用于将软件分成几个启动阶段的机制的详细描述。
第五章, 内存管理,通过指出常见陷阱并解释如何避免可能导致应用程序代码中不可预测或不良行为的内存错误,提出了一些内存管理的最佳策略。
第六章, 通用外围设备,介绍了访问 GPIO 引脚和其他通用集成外围设备的方法。这是目标平台与外部世界的第一次交互,使用电信号执行简单的输入/输出操作。
第七章, 本地总线接口,指导您如何集成串行总线控制器(UART、SPI 和 I2C)。通过解释与嵌入式系统中常见的收发器交互所需的代码,引入了对最常见的总线通信协议的代码导向、详细分析。
第八章, 电源管理和节能,探讨了在节能系统中减少功耗的技术。设计低功耗和超低功耗嵌入式系统需要执行特定步骤以在运行所需任务的同时减少能耗。
第九章, 分布式系统和物联网架构,介绍了构建分布式和连接系统所需的可用协议和接口。物联网系统需要使用第三方库实现的标准化网络协议与远程端点进行通信。特别关注使用安全套接字在端点之间确保通信的安全性。
第十章, 并行任务和调度,通过实现实时任务调度器来解释多任务操作系统的基础设施。本章提出了三种从头开始实现微控制器操作系统的方法,使用不同的调度器(协作、抢占和安全的)。
第十一章,可信执行环境,描述了在嵌入式系统中通常可用的 TEE 机制,并提供了使用 ARM TrustZone-M 运行安全和非安全域的示例。在现代微控制器中,TEE 提供了通过限制从非安全执行域访问来保护特定内存区域或外围设备的机会。
为了充分利用本书
预期您精通 C 语言并了解计算机系统的工作原理。需要一个 GNU 或 Linux 开发机器来应用所解释的概念。有时需要通过提供的示例代码来完全理解实现的机制。鼓励您修改、改进和重用提供的示例,并应用建议的方法。
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/01.jpg
有关请求工具的附加使用说明可在第二章**,工作环境和 工作流程优化中找到。
如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误 。
下载示例代码文件
您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Embedded-Systems-Architecture-Second-Edition。如果代码有更新,它将在 GitHub 仓库中更新。
我们还从我们丰富的书籍和视频目录中提供了其他代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/kVMr1。
使用的约定
本书使用了多种文本约定。
文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。以下是一个示例:“必须从命令行调用提供一个单独的配置文件,在/scripts目录下提供多个平台和开发板配置。”
代码块设置如下:
/* Jump to non secure app_entry */ asm volatile("mov r12, %0" ::"r" ((uint32_t)app_entry - 1)); asm volatile("blxns r12" );
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
Secure Area 1:
SECWM1_PSTRT : 0x0 (0x8000000)
SECWM1_PEND : 0x39 (0x8039000)
任何命令行输入或输出都按以下方式编写:
$ renode /opt/renode/scripts/single-node/stm32f4_discovery.resc
调试器控制台的命令按以下方式编写:
> add-symbol-file app.elf 0x1000
> bt full
小贴士或重要注意事项
看起来像这样。
联系我们
我们始终欢迎读者的反馈。
customercare@packtpub.com并在您的消息主题中提及本书标题。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/support/errata并填写表格。
copyright@packt.com,并附有链接到该材料。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《嵌入式系统架构 第 2 版》,我们非常乐意听到您的想法!请点击此处直接转到该书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?
您的电子书购买是否与您选择的设备不兼容?
不要担心,现在,随着每本 Packt 书籍的购买,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不会就此结束,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。
按照以下简单步骤获取福利:
- 扫描下面的二维码或访问以下链接:
packt.link/free-ebook/9781803239545
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件。
第一部分 - 嵌入式系统开发简介
本部分提供了一个俯瞰嵌入式开发的视角,解释了它与开发者可能熟悉的其他技术领域的不同之处。第二章帮助将开发者的工作站转变为实际的硬件/软件开发实验室,并优化了开发、测试、调试和部署嵌入式软件所需的步骤。
本部分包含以下章节:
-
第一章,嵌入式系统——实用方法
-
第二章,工作环境和工作流程优化
第一章:嵌入式系统 – 实用方法
为嵌入式系统设计和编写软件与传统的高级软件开发相比,面临的是一套不同的挑战。
本章概述了这些挑战,并介绍了本书中将用作参考的基本组件和平台。
在本章中,我们将讨论以下主题:
-
领域定义
-
通用 输入/输出 (GPIO)
-
接口和外设
-
连接系统
-
隔离机制简介
-
参考平台
领域定义
嵌入式系统 是执行特定、专用任务且没有直接或持续用户交互的计算设备。由于市场和技术的多样性,这些设备有不同的形状和大小,但通常,它们都具有较小的尺寸和有限的资源。
在本书中,将通过开发与它们的资源和外设交互的软件组件来分析嵌入式系统的概念和构建块。第一步是在嵌入式系统的更广泛定义内,定义本书中解释的技术和架构模式的适用范围。
嵌入式 Linux 系统
嵌入式市场的一部分依赖于具有足够功率和资源来运行 GNU/Linux OS 变体的设备。这些系统通常被称为嵌入式 Linux,本书的范围不包括它们,因为它们的发展包括组件设计和集成的不同策略。一个典型的能够运行基于 Linux 内核的系统的硬件平台配备了相当大的 RAM,高达几吉字节,以及足够的存储空间来存储 GNU/Linux 发行版中提供的所有软件组件。
此外,为了使 Linux 内存管理为系统上的每个进程提供独立的虚拟地址空间,硬件必须配备一个内存管理单元(MMU),这是一个辅助操作系统在运行时将物理地址转换为虚拟地址,反之亦然的硬件组件。
这类设备具有不同的特性,对于构建定制解决方案来说通常是过度的,这些解决方案可以使用更简单的设计并降低单件的生产成本。
硬件制造商和芯片设计师已经研究了新技术来提高基于微控制器的系统的性能。在过去几十年中,他们引入了新一代的平台,这些平台将降低硬件成本、固件复杂性、尺寸和功耗,为嵌入式市场提供一套最有趣的功能。
由于它们的规格,在某些实际场景中,嵌入式系统必须能够在短时间内执行一系列任务,这个时间是短、可测量和可预测的。这类系统被称为实时系统,与在桌面、服务器和移动电话中使用的多任务计算方法不同。
实时处理是嵌入式 Linux 平台上极难实现,如果不是不可能实现的目标。Linux 内核不是为硬实时处理设计的,即使有补丁可以修改内核调度器以帮助满足这些要求,其结果也无法与专门为此目的设计的裸机、受限系统相提并论。
一些其他的应用领域,例如电池供电和能量收集设备,可以从较小的嵌入式设备的低功耗能力和通常集成到嵌入式连接设备中的无线通信技术的能效中受益。基于 Linux 的系统通常在资源量和硬件复杂度上不足以在能耗水平上降低,或者需要付出努力才能达到类似的能耗水平。
本书将分析基于微控制器的系统类型是 32 位系统,这些系统能够在单线程、裸机应用程序中运行软件,以及集成简约的实时操作系统,这些操作系统在嵌入式系统的工业制造中非常流行,我们每天使用它们来完成特定任务。它们越来越被采用,以帮助定义更通用、多用途的开发平台。
低端 8 位微控制器
在过去,8 位微控制器主导了嵌入式市场。它们设计的简单性使我们能够编写能够完成一组预定义任务的小型应用程序,但过于简单,通常配备的资源很少,不足以实现嵌入式系统,尤其是在 32 位微控制器已经发展到覆盖这些设备在相同的价格、尺寸和功耗范围内的所有用例的情况下。
现在,8 位微控制器大多被限制在教育平台套件的市场上,旨在向业余爱好者和新手介绍电子设备上软件开发的基础。由于 8 位平台缺乏允许开发高级系统编程、多线程和高级功能以构建专业嵌入式系统的特性,本书不涵盖这些平台。
在本书的语境中,术语嵌入式系统指的是一类基于微控制器硬件架构运行的系统,提供有限的资源,但允许通过硬件架构提供的特性来构建实时系统,以实现系统编程。
硬件架构
嵌入式系统的架构围绕其微控制器构建,有时也称为微控制器单元(MCU)。这通常是一个包含处理器、RAM、闪存、串行接收器和发送器以及其他核心组件的单个集成电路。市场上提供了许多不同的架构、供应商、价格范围、功能和集成资源的选择。这些通常设计成低成本、低资源、低能耗、自包含的系统,集成在单个集成电路中,这也是它们通常被称为片上系统(SoC)的原因。
由于可以集成的处理器、内存和接口的多样性,微控制器实际上没有实际的参考架构。尽管如此,一些架构元素在广泛的型号和品牌中是通用的,甚至在不同的处理器架构之间也是通用的。
一些微控制器专注于特定应用,并暴露出特定的接口与外围设备和外部世界通信。其他微控制器则专注于提供降低硬件成本或非常有限的能耗的解决方案。
尽管如此,以下组件几乎被硬编码到每个微控制器中:
-
微处理器
-
RAM
-
闪存
-
串行收发器
此外,越来越多的设备能够访问网络,以与其他设备和网关进行通信。一些微控制器可能提供已建立的标准,如 以太网或 Wi-Fi 接口,或者专门为满足嵌入式系统约束而设计的特定协议,如亚 GHz 无线接口或控制器局域网(CAN)总线,部分或全部在集成电路中实现。
所有组件都必须与处理器共享总线线,处理器负责协调逻辑。RAM、闪存和收发器的控制寄存器都映射在相同的物理地址空间:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/B18730_01_01.jpg
图 1.1 – 一个通用微控制器内部组件的简化框图
RAM 和 Flash Memory 的映射地址取决于具体型号,通常在数据表中提供。微控制器可以在其原生机器语言中运行代码;也就是说,一系列指令被转换成特定于其运行架构的二进制文件。默认情况下,编译器提供通用可执行文件作为编译和汇编操作的输出,这需要转换成目标格式才能执行。
处理器部分被设计用来直接从 RAM 以及其内部的闪存中执行存储在其自身特定二进制格式中的指令。这通常从内存中的零位置或微控制器手册中指定的另一个已知地址开始映射。CPU 可以从 RAM 中更快地获取并执行代码,但最终的固件存储在闪存中,这通常比几乎所有微控制器上的 RAM 都要大,并且允许它在电源周期和重启之间保留数据。
为嵌入式微控制器编译软件操作系统并将其加载到闪存中需要一个主机机器,这是一套特定的硬件和软件工具。还需要了解目标设备特性的某些知识,以便指导编译器在可执行映像中组织符号。由于许多有效的原因,C 是嵌入式软件中最流行的语言,尽管它不是唯一可用的选项。高级语言,如 Rust 和 C++,在结合特定的嵌入式运行时或在某些情况下通过完全从语言中移除运行时支持的情况下,可以生成嵌入式代码。
注意
本书将完全专注于 C 代码,因为它比任何其他高级语言都更少抽象,这使得在查看代码的同时更容易描述底层硬件的行为。
所有现代嵌入式系统平台至少都有一种机制(如JTAG)用于调试目的和将软件上传到闪存。当从主机机器访问调试接口时,调试器可以与处理器中的断点单元交互,中断和恢复执行,并且还可以从内存中的任何地址读取和写入。
嵌入式编程的一个重要部分是在使用 MCU 公开的接口的同时与外围设备进行通信。嵌入式软件开发需要基本的电子知识,理解原理图和数据表的能力,以及使用测量工具(如逻辑分析仪或示波器)的信心。
理解挑战
接近嵌入式开发意味着始终关注规格以及硬件限制。嵌入式软件开发是一个持续的挑战,需要关注以最高效的方式执行一组特定任务,同时充分考虑可用的有限资源。需要处理一些妥协,这在其他环境中是不常见的。以下是一些例子:
-
在闪存中可能没有足够的空间来实现一个新功能
-
可能没有足够的 RAM 来存储复杂结构或复制大型数据缓冲区
-
处理器可能不够快,无法及时完成所有必需的计算和数据处理
-
电池供电和能量收集设备可能需要更低的能耗以满足使用寿命的预期。
此外,PC 和移动操作系统大量使用 MMU(内存管理单元),这是处理器的一个组件,它允许在物理地址和虚拟地址之间进行运行时转换。
MMU 是实现任务之间以及任务与内核本身之间地址空间分离的必要抽象。嵌入式微控制器没有 MMU,通常缺乏存储内核、应用程序和库所需的大量非易失性内存。因此,嵌入式系统通常在一个任务中运行,主循环按照特定顺序执行所有数据处理和通信。一些设备可以运行嵌入式操作系统,这些操作系统比它们的 PC 版本要简单得多。
应用程序开发者通常将底层系统视为一种商品,而嵌入式开发通常意味着整个系统必须从头开始实现,从引导程序到应用程序逻辑。在嵌入式环境中,由于缺乏更复杂的抽象,如进程和操作系统内核之间的内存分离,各种软件组件之间关系更为紧密。
首次接触嵌入式系统的开发者可能会发现,在某些系统中进行测试和调试比仅仅运行软件并读取结果要复杂得多。这在那些设计时几乎没有或没有人机交互界面的系统中尤其如此。
一种成功的方法需要健康的流程,这包括定义良好的测试用例、来自规格说明分析的关键性能指标列表,以确定权衡的可能性、可用于执行所有所需测量的工具和程序,以及一个建立良好且高效的原型阶段。
在这个背景下,安全性值得特别考虑。通常,在系统级别编写代码时,考虑到可能的故障对整个系统的影响是明智的。大多数嵌入式应用程序代码在硬件上以扩展权限运行,单个任务的不当行为可能会影响整个固件的稳定性和完整性。正如我们将看到的,一些平台提供了特定的内存保护机制和内置的权限分离,这对于构建安全系统非常有用,即使在没有基于分离进程地址空间的完整操作系统的情况下也是如此。
多线程
使用专为构建嵌入式系统设计的微控制器的一个优点是,可以通过时间共享资源在单独的执行单元中运行逻辑上分离的任务。
嵌入式软件最流行的设计是基于单循环的顺序执行模型,其中模块和组件连接起来以暴露回调接口。然而,现代微控制器提供了系统开发者可以用来构建多任务环境以运行逻辑上分离的应用程序的功能和核心逻辑特性。
这些特性在处理更复杂的实时系统时特别有用,并帮助我们理解基于进程隔离和内存分段的实现安全模型的可能性。
RAM
“640 KB 的内存对每个人来说都应该足够了”
– 比尔·盖茨(微软的创始人兼前董事)
这句著名的话在过去三十年中被多次引用,以强调技术进步和 PC 行业的杰出成就。虽然对于许多软件工程师来说这可能听起来像是一个笑话,但 30 多年后,在 MS-DOS 最初发布之后,嵌入式编程仍然需要考虑这些数据。
尽管大多数嵌入式系统今天都能够突破这个限制,尤其是由于外部 DRAM 接口的可用性,但可以用 C 语言编程的最简单设备可能只有 4 KB 的 RAM 来实施整个系统逻辑。在设计嵌入式系统时,必须考虑到这一点,通过估算系统必须执行的所有操作所需的潜在内存量,以及任何时间可能用于与外围设备和附近设备通信的缓冲区。
系统级别的内存模型比 PC 和移动设备的内存模型要简单。内存访问通常在物理级别进行,因此你代码中的所有指针都在告诉你它们所指向的数据的物理位置。在现代计算机中,操作系统负责将物理地址转换为运行任务的虚拟表示。
对于那些没有 MMU 的系统中,仅物理内存访问的优势在于减少了在编码和调试时处理地址转换的复杂性。另一方面,任何现代操作系统实现的一些功能,如进程交换和通过内存重定位动态调整地址空间大小,变得繁琐,有时甚至不可能。
在嵌入式系统中处理内存尤为重要。习惯于编写应用程序代码的程序员期望底层操作系统提供一定级别的保护。虚拟地址空间不允许内存区域重叠,操作系统可以轻松检测未经授权的内存访问和段违规,因此它迅速终止进程,避免整个系统受到损害。
在嵌入式系统中,尤其是在编写裸机代码时,必须手动检查每个地址池的边界。意外修改错误内存中的几个位,甚至访问不同的内存区域,可能会导致致命的、不可撤销的错误。整个系统可能会挂起,或者在最坏的情况下变得不可预测。在嵌入式系统中处理内存时,特别是在处理生命关键设备时,需要采取安全的方法。在开发过程中太晚识别内存错误是复杂的,并且通常需要比强制自己编写安全代码并保护系统免受程序员错误更多的资源。
正确的内存处理技术将在第五章 内存管理中解释。
闪存内存
在服务器或个人计算机中,可执行应用程序和库驻留在存储设备上。在执行开始之前,它们被访问、转换,可能还会解压缩,并存储在 RAM 中。
嵌入式设备的固件通常是一个包含所有软件组件的单个二进制文件,可以传输到 MCU 的内部闪存内存中。由于闪存直接映射到内存空间中的一个固定地址,处理器能够无中间步骤地顺序从其中获取并执行单个指令。这种机制称为原地执行(XIP)。
软件固件上的所有不可修改部分不需要加载到内存中,并且可以通过内存空间中的直接寻址来访问。这包括不仅可执行指令,还包括所有被编译器标记为常量的变量。另一方面,支持 XIP 在准备存储在闪存中的固件映像时需要一些额外的步骤,并且需要指导链接器关于目标上的不同内存映射区域。
在微控制器的地址空间中映射的内部闪存内存不可用于写入。由于闪存存储器的硬件特性,更改内部闪存的内容只能通过基于块的访问方式完成。在更改闪存内存中单个字节的值之前,必须先擦除并重写包含该字节的整个块。大多数制造商提供的用于写入基于块的闪存内存的机制被称为应用内编程(IAP)。一些文件系统实现通过创建一个临时副本来处理基于块的闪存设备上的写入操作,该副本在执行写入操作时使用。
在选择基于微控制器的解决方案的组件时,匹配闪存的大小与固件所需的空间至关重要。闪存通常是 MCU 中最昂贵的组件之一,因此在大规模部署时,选择具有较小闪存的 MCU 可能更经济。在其他领域,考虑到代码大小来开发软件现在并不常见,但在尝试将多个功能适应如此小的存储时可能需要这样做。最后,在构建固件及其组件链接时,某些架构上可能存在编译器优化,以减少代码大小。
存储在 MCU 硅芯片之外的非易失性存储器通常可以通过特定的接口访问,例如串行外设接口。外部闪存使用的技术与内部闪存不同,内部闪存旨在快速执行代码。虽然外部闪存通常更密集且成本更低,但它们不允许在物理地址空间中进行直接内存映射,这使得它们不适合存储固件映像。这是因为如果没有机制用于在 RAM 中加载可执行符号,那么执行按顺序获取指令的代码将是不可能的——在这些设备上,读取访问是按块一次进行的。另一方面,与 IAP 相比,写入访问可能更快,这使得这类非易失性存储器设备对于存储某些设计中在运行时检索的数据非常理想。
通用输入/输出(GPIO)
任何微控制器都能实现的最基本功能是控制集成电路特定引脚上的信号。微控制器可以打开或关闭数字输出,这对应于当分配给它的值为 1 时应用于引脚的参考电压,而当值为 0 时为零伏特。同样,当引脚配置为输入时,可以使用引脚检测 1 或 0。当施加的电压高于某个特定阈值时,软件将读取数字值“1”。
ADC 和 DAC
一些芯片具有板载 ADC 控制器,能够检测施加到引脚上的电压并对其进行采样。这通常用于从提供可变电压输出的输入外围设备获取测量值。嵌入式软件能够读取电压,其精度取决于预定义的范围。
DAC 控制器是 ADC 控制器的逆过程,它将微控制器寄存器上的值转换为相应的电压。
定时器和 PWM
微控制器可能提供多种测量时间的方法。通常,至少有一个基于倒计时计时器的接口可以触发中断并在到期时自动重置。
配置为输出的 GPIO 引脚可以编程为输出预配置频率和占空比的方波。这被称为脉冲宽度调制(PWM),有多个用途,从控制输出外设到调节 LED 亮度,甚至通过扬声器播放可听声音。
关于 GPIO、中断定时器和看门狗的更多详细信息将在第六章,“通用外设”中探讨。
接口和外设
为了与外设和其他微控制器通信,嵌入式领域已经建立了几个事实上的标准。微控制器的一些外部引脚可以被编程以使用特定协议与外部外设进行通信。大多数架构上可用的常见接口如下:
-
基于异步 UART 的串行通信
-
串行外围设备接口(SPI)总线
-
集成电路间(I2C)总线
-
通用串行总线(USB)
让我们逐一详细回顾。
基于异步 UART 的串行通信
异步通信由通用异步收发传输器(UART)提供。这些接口,通常被称为串行端口,之所以称为异步,是因为它们不需要共享时钟信号来同步发送方和接收方,而是根据预定义的时钟速率进行工作,这些速率可以在通信过程中对齐。微控制器可能包含多个 UART,可以根据请求连接到特定的引脚集。UART 作为全双工通道提供异步通信,通过两条独立的线连接每个端点的 RX 引脚到另一侧的 TX 引脚。
为了相互理解,两个端点的系统必须使用相同的参数设置 UART。这包括在电线上的字节封装和帧速率。所有这些参数都必须在通信通道正确建立之前由两个端点预先知道。尽管比其他类型的串行通信简单,但基于 UART 的串行通信在电子设备中仍然被广泛使用,尤其是作为调制解调器和 GPS 接收器的接口。此外,使用 TTL 到 USB 串行转换器,很容易将 UART 连接到主机上的控制台,这对于提供日志消息通常很方便。
SPI
对经典基于 UAR 的通信的一种不同方法是SPI。这种技术在 20 世纪 80 年代末推出,旨在通过引入几个改进来取代异步串行通信与外设之间的通信:
-
串行时钟线用于同步端点
-
主从协议
-
在同一条三线总线上进行一点对多点的通信
主设备,通常是微控制器,与一个或多个从设备共享总线。为了触发通信,使用一个单独的从设备选择(SS)信号来寻址连接到总线的每个从设备。总线使用两个独立的信号进行数据传输,每个方向一个信号,以及一个共享的时钟线来同步通信的两端。由于时钟线是由主设备生成的,因此数据传输更可靠,这使得能够实现比普通 UART 更高的比特率。SPI 在多代微控制器中持续成功的一个关键因素是,从设备的设计复杂性很低,可以简单到只是一个单级移位寄存器。SPI 通常用于传感器设备、LCD、闪存控制器和网络接口。
I2C
I2C 稍微复杂一些,这是因为它是基于不同的目的设计的:在相同的两线总线上连接多个微控制器以及多个从设备。两个信号是串行时钟(SCL)和串行数据(SDA)。与 SPI 或 UART 不同,总线是半双工的,因为流量的两个方向共享相同的信号。得益于协议中集成的 7 位从设备寻址机制,它不需要为选择从设备而专门设置额外的信号。在相同的总线上允许有多个主设备,前提是系统中的所有主设备在总线争用时都遵循仲裁逻辑。
USB
USB 协议最初设计用来取代 UART 并在同一硬件连接器中包含许多协议,因此在个人电脑、便携式设备和大量外围设备中非常流行。
该协议以主机-设备模式工作,通信的一侧,即设备,在主机侧暴露出控制器可以使用的服务。许多微控制器中存在的 USB 收发器可以在两种模式下工作。通过实现 USB 标准的上层,微控制器可以模拟不同类型的设备,例如串行端口、存储设备和点对点以太网接口,从而创建可以连接到主机系统的基于微控制器的 USB 设备。
如果收发器支持主机模式,嵌入式系统可以作为 USB 主机,设备可以连接到它。在这种情况下,系统应实现设备驱动程序和应用来访问设备提供的功能。
当在同一 USB 控制器上实现两种模式时,收发器在移动模式(OTG)下工作,并且可以在运行时选择和配置所需的模式。
在第七章“本地总线接口”中,将提供对一些最常用的用于与外围设备和相邻系统通信的协议的更详细介绍。
连接的系统
现在越来越多的嵌入式设备,针对不同的市场设计,现在能够与其周围区域的同侪进行网络通信,或者通过网关路由其流量到更广泛的网络或互联网。术语物联网(IoT)被用来描述那些嵌入式设备可以使用互联网协议进行通信的网络。
这意味着物联网设备可以在网络中像更复杂的系统(如 PC 或移动设备)一样被寻址,最重要的是,它们使用互联网通信典型的传输层协议来交换数据。TCP/IP 是由 IETF 标准化的协议套件,它是互联网和其他自包含局域网基础设施的基石。
互联网协议(IP)提供网络连接,但前提是底层链接提供基于分组的通信以及控制和调节对物理媒体的访问机制。幸运的是,许多网络接口都满足这些要求。虽然一些分布式嵌入式系统仍在使用与 TCP/IP 不兼容的替代协议族,但在目标上使用 TCP/IP 标准的一个明显优势是,在与非嵌入式系统通信的情况下,无需翻译机制来路由超出局域网范围的帧。
除了在非嵌入式系统中广泛使用的链接类型,如以太网或无线局域网,嵌入式系统还可以从专门为物联网引入的需求设计的广泛技术中受益。已经研究了新的标准并将其付诸实施,以提供对受限设备的有效通信,定义通信模型以应对特定的资源使用限制和能源效率要求。
最近,新的链路技术已经开发出来,旨在为广域网络通信提供更低的比特率和功耗。这些协议旨在提供窄带、长距离通信。帧太小,无法容纳 IP 数据包,因此这些技术主要用于传输小型有效载荷,例如周期性传感器数据,或者如果存在双向通道,则用于传输设备配置参数,并且它们需要某种形式的网关来翻译通信,以便它可以通过互联网传输。
然而,与云服务交互通常需要连接网络中的所有节点,并在主机中直接实现服务器和 IT 基础设施所使用的相同技术。在嵌入式设备上启用 TCP/IP 通信并不总是直接的。尽管有几种开源实现可供选择,但系统 TCP/IP 代码复杂,体积庞大,并且通常具有可能难以满足的内存需求。
同样的观察也适用于安全套接字层(SSL)和传输层安全性(TLS)库,它为两个通信端点之间增加了机密性和认证。选择合适的微控制器对于任务至关重要,如果系统需要连接到互联网并支持安全套接字通信,那么在设计阶段就必须更新闪存和 RAM 的要求,以确保与第三方库的集成。
分布式系统的挑战
设计分布式嵌入式系统,尤其是基于无线链路技术的系统,增加了一系列有趣的挑战。
这些挑战中的一些与以下方面相关:
-
选择正确的技术和协议
-
对比特率、包大小和媒体访问的限制
-
节点的可用性
-
拓扑中的单点故障
-
配置路由
-
验证涉及的宿主
-
媒体上通信的机密性
-
缓冲对网络速度、延迟和 RAM 使用的影响
-
实现协议栈的复杂性
第九章,分布式系统和物联网架构,分析了嵌入式系统中实现的一些链路层技术,以提供远程通信,其中 TCP/IP 通信被集成到与物联网服务集成的分布式系统设计中。
隔离机制的介绍
一些较新的微控制器包括对在板上运行的受信任和非受信任软件之间隔离的支持。这种机制基于一种仅在特定架构上可用的 CPU 扩展,通常依赖于 CPU 内部两种执行模式之间的一种物理分离。系统中的所有非受信任区域运行的代码都将对 RAM、设备和外围设备有一个受限的视图,这必须由受信任的对应方提前动态配置。
从受信任区域运行的软件也可以通过跨越安全/非安全边界的特殊功能调用,提供非受信任世界无法直接访问的功能。
第十一章,可信执行环境,探讨了可信执行环境(TEEs)背后的技术,以及涉及实际嵌入式系统的软件组件,以提供一个安全的环境来运行非信任模块和组件。
参考平台
嵌入式 CPU 核心的首选设计策略是精简指令集计算机(RISC)。在所有 RISC CPU 架构中,硅制造商使用几个参考设计作为指导,以生产集成到微控制器中的核心逻辑。每个参考设计在 CPU 实现的不同特性方面与其他设计有所不同。每个参考设计包括一个或多个集成到嵌入式系统中的微处理器系列,它们具有以下共同特征:
-
用于寄存器和地址的词大小(8 位、16 位、32 位或 64 位)
-
指令集
-
寄存器配置
-
字节序
-
扩展的 CPU 特性(中断控制器、FPU、MMU)
-
缓存策略
-
流水线设计
为您的嵌入式系统选择参考平台取决于您的项目需求。较小的、功能较少的处理器通常更适合低功耗、较小的 MCU 封装和较低的成本。另一方面,高端系统提供更大的资源集,其中一些系统具有专门的硬件来处理具有挑战性的计算(例如浮点单元或用于卸载对称加密操作的高级加密标准(AES)硬件模块)。8 位和 16 位核心设计正在逐渐被 32 位架构取代,但一些成功的方案在特定市场和爱好者中仍然相对流行。
ARM 参考设计
ARM 是嵌入式市场中最普遍的参考设计供应商,为嵌入式应用生产了超过 100 亿个基于 ARM 的微控制器。嵌入式行业中一些最有趣的内核设计之一是 ARM Cortex-M 系列,该系列包括一系列从成本效益和节能到专为多媒体微控制器设计的高性能核心。尽管它们分布在三个不同的指令集(ARMv6、ARMv7 和 ARMv8)中,但所有 Cortex-M CPU 都共享相同的编程接口,这提高了同一系列微控制器之间的可移植性。
本书中的大多数示例将基于这一系列的 CPU。尽管其中表达的大部分概念也适用于其他核心设计,但选择一个参考平台现在可以打开对底层硬件交互进行更全面分析的大门。特别是,本书中的一些示例使用了 ARMv7 指令集的特定汇编指令,这些指令在 Cortex-M CPU 核心中实现。
Cortex-M 微处理器
Cortex-M 系列 32 位核心的主要特征如下:
-
16 个通用 CPU 寄存器
-
仅适用于代码密度优化的 Thumb 16 位指令
-
内置嵌套向量中断控制器(NVIC),具有 8 到 16 个优先级级别
-
ARMv6-M(M0、M0+)、ARMv7-M(M3、M4、M7)或 ARMv8-M(M23、M33)架构
-
可选的 8 区域内存保护单元(MPU)
-
可选 TEE 隔离机制(ARM TrustZone-M)
总内存地址空间为 4 GB。内部 RAM 的起始地址通常映射到固定的地址0x20000000。内部闪存以及其他外设的映射取决于硅制造商。然而,最高的 512 MB(0xE0000000到0xFFFFFFFF)地址被保留用于系统控制块(SCB),它将多个配置参数和诊断信息组合在一起,软件可以在任何时间访问这些信息,以直接与核心交互。
同步与外设和其他硬件组件的通信可以通过中断线触发。处理器可以接收和识别几种不同的数字输入信号,并迅速对其做出反应,中断软件的执行并临时跳转到内存中的特定位置。Cortex-M 系列高端核心支持多达 240 条中断线。
中断向量位于闪存软件图像的起始位置,包含将在特定事件上自动执行的中断例程的地址。得益于 NVIC,中断线可以分配优先级,以便在执行较低优先级中断的例程时发生更高优先级的中断,当前的中断例程将暂时挂起,以便为更高优先级的中断线提供服务。这确保了这些信号线的最小中断延迟,这对于系统尽可能快地执行是相当关键的。
在任何时刻,目标上的软件都可以在两种权限模式下运行:非特权或特权模式。CPU 内置了对系统软件和应用软件之间权限分离的支持,甚至为两个独立的栈指针提供了两个不同的寄存器。在第十章“并行任务与调度”中,我们将更详细地探讨如何正确实现权限分离,以及如何在目标上运行不受信任的代码时强制执行内存分离。例如,这用于隐藏诸如私钥之类的秘密,防止非安全世界直接访问。在第十一章“可信执行环境”中,我们将学习如何正确实现权限分离,以及如何在目标上以不同信任级别运行应用代码时,在操作系统内部强制执行内存分离。
许多微控制器中都有 Cortex-M 内核,来自不同的硅供应商。软件工具对所有平台都是相似的,但每个微控制器都有不同的配置需要考虑。收敛库可用于隐藏制造商特定的细节,并提高不同型号和品牌之间的可移植性。制造商提供参考套件和所有必要的文档以供入门,这些套件旨在在设计阶段进行评估,也可能在后续阶段开发原型时有用。其中一些评估板配备了传感器、多媒体电子设备或其他外设,以扩展微控制器的功能。甚至有些包括预配置的第三方“中间件”库,如 TCP/IP 通信栈、TLS 和加密库、简单的文件系统以及其他辅助组件,以及可以快速轻松添加到软件项目中的模块。
摘要
在接近嵌入式软件需求时,首先必须对硬件平台及其组件有一个良好的理解。通过描述现代微控制器的架构,本章指出了嵌入式设备的一些特性以及开发者应该如何高效地重新思考满足需求和解决问题的方法,同时考虑到目标平台的功能和限制。
在下一章中,我们将分析嵌入式开发中通常使用的工具和流程,包括命令行工具链和集成开发环境(IDEs)。我们将了解如何组织工作流程以及如何有效地预防、定位和修复错误。
第二章:工作环境和工作流程优化
成功软件项目的第一步是选择合适的工具。嵌入式开发需要一套硬件和软件工具,这些工具可以简化开发者的工作,并可能显著提高生产率和缩短总开发时间。本章提供了这些工具的描述,并给出了如何使用它们来改进工作流程的建议。
第一部分为我们概述了原生 C 编程中的工作流程,并逐步揭示了将模型转换为嵌入式开发环境所需的必要变化。通过对其组件的分析,介绍了GCC 工具链,这是一套用于构建嵌入式应用的开发工具。
最后,在最后两节中,提出了与目标机交互的策略,以提供对在平台上运行的嵌入式软件进行调试和验证的机制。
本章涵盖的主题如下:
-
工作流程概述
-
文本编辑器与集成环境
-
GCC 工具链
-
与目标机的交互
-
验证
到本章结束时,你将学会如何通过遵循一些基本规则,保持对测试准备的关注,以及一种智能的调试方法,来创建一个优化的工作流程。
工作流程概述
使用 C 语言编写软件,以及在其他任何编译型语言中,都需要将代码转换成特定目标机的可执行格式才能运行。C 语言可以在不同的架构和执行环境中进行移植。程序员依赖于一系列工具来编译、链接、执行和调试软件到特定目标。
构建嵌入式系统的固件映像依赖于一套类似的工具,这些工具可以为特定目标生成固件映像,称为工具链。本节概述了编写 C 语言软件和生成可直接在编译它们的机器上运行的程序的常用工具集。然后,工作流程必须扩展并适应,以集成工具链组件并为目标平台生成可执行代码。
C 编译器
C 编译器是一种负责将源代码翻译成机器代码的工具,该代码可以被特定的 CPU 解释。每个编译器只能为一种环境生成机器代码,因为它将函数翻译成特定机器的指令,并且它被配置为使用特定架构的地址模型和寄存器布局。大多数 GNU/Linux 发行版中包含的本地编译器是GNU 编译器集合,通常称为GCC。GCC 是一个自 1987 年以来在 GNU 通用公共许可证下分发的免费软件编译器系统,自那时起,它已成功用于构建类 UNIX 系统。系统中的 GCC 可以编译 C 代码,生成能够在与编译器运行的机器相同架构上运行的应用程序和库。
GCC 编译器以.c扩展名的源代码文件作为输入,并生成包含函数和变量初始值的对象文件,这些函数和变量从输入源代码翻译成机器指令。编译器可以被配置在编译结束时执行针对目标平台的特定优化步骤,并插入调试数据以方便后续调试。
使用主机编译器将源文件编译成对象的简约命令行只需要-c选项,指示 GCC 程序将源代码编译成同名的对象:
$ gcc -c hello.c
此声明将尝试编译包含在hello.c文件中的 C 源代码,并将其转换为存储在新建的hello.o文件中的特定机器代码。
为特定目标平台编译代码需要一套为此目的设计的工具。存在针对特定架构的编译器,它们提供创建特定目标机器指令的编译器,与构建机器不同。为不同目标生成代码的过程称为交叉编译。交叉编译器在开发机器(主机)上运行,以生成可在目标上执行的特定机器代码。
在下一节中,介绍了一个基于 GCC 的工具链,作为为嵌入式目标创建固件的工具。那里描述了 GCC 编译器的语法和特性。
构建由单独模块组成的程序的第一步是将所有源代码编译成目标文件,以便系统所需的组件在最终步骤中分组和组织在一起,该步骤包括链接所有必需的符号并安排内存区域以准备最终的可执行文件,这由工具链中的另一个专用组件完成。
链接器
链接器是组合可执行程序并解决作为输入提供的对象文件之间依赖关系的工具。
链接器生成的默认可执行格式是可执行和链接格式(ELF)。在许多 Unix 和 Unix-like 系统中,ELF 是程序的默认标准格式,对象、共享库甚至 GDB 核心转储。该格式已被设计用于在磁盘和其他媒体上存储程序,以便宿主操作系统可以通过在 RAM 中加载指令并分配程序数据的空间来执行它。
可执行文件被划分为多个部分,这些部分可以映射到程序执行所需的内存中的特定区域。ELF 文件以一个包含指向文件内部各个部分的指针的头部开始,这些部分包含程序的代码和数据。
链接器将描述可执行程序内容的区域映射到以.(点)开头的一般部分。运行可执行文件所需的最小部分集合包括以下内容:
-
.text:包含程序的代码,以只读模式访问。它包含程序的执行指令。编译进对象文件中的函数由链接器安排在这个部分,程序总是在这个内存区域中执行指令。 -
.rodata:包含不能在运行时更改的常量值。编译器将其作为存储常量的默认部分,因为它不允许在运行时修改存储的值。 -
.data:包含程序所有初始化变量的值,在运行时以读写模式访问。它是包含所有变量(静态或全局)的部分,这些变量已在代码中初始化。在执行之前,该区域通常被重新映射到 RAM 中的可写位置,并在程序初始化期间自动复制 ELF 的内容,在运行时,在执行主函数之前。 -
.bss:这是一个为未初始化数据保留的部分,在运行时以读写模式访问。它的名字来源于 20 世纪 50 年代为 IBM 704 编写的旧微代码中的古老汇编指令。它最初是main()函数的缩写。
当在宿主机器上构建本地软件时,链接步骤的许多复杂性都被隐藏了,但链接器默认配置为将编译的符号安排到特定的部分,这些部分可以在程序执行时由操作系统用于在进程虚拟地址空间中分配相应的段。可以通过简单地调用gcc来为宿主机器创建一个可工作的可执行文件,这次不使用-c选项,提供必须链接在一起以生成 ELF 文件的对象文件列表。-o选项用于指定输出文件名,否则默认为a.out:
$ gcc -o helloworld hello.o world.o
此命令将尝试构建helloworld文件,这是一个主机系统的 ELF 可执行文件,使用先前编译到两个对象中的符号。
在嵌入式系统中,情况略有不同,因为引导裸机应用程序意味着在链接时必须将部分映射到内存中的物理区域。为了指示链接器将部分关联到已知的物理地址,必须提供一个自定义链接脚本文件,描述可执行裸机应用程序的内存布局,并提供可能由目标系统需要的附加自定义部分。
在“链接可执行文件”部分将提供对链接步骤的更详细解释。
Make:构建自动化工具
有几种开源工具可用于自动化构建过程,其中一些在不同开发环境中被广泛使用。Make是标准的 UNIX 工具,用于自动化从源代码创建所需二进制图像的步骤,检查每个组件的依赖关系,并按正确顺序执行步骤。Make 是一个标准的POSIX 工具,它是许多类 UNIX 系统的一部分。在 GNU/Linux 发行版中,它作为一个独立工具实现,是 GNU 项目的一部分。从现在开始,GNU Make 实现将简单地称为 Make。
Make 设计为通过在命令行上不带参数简单地调用make命令来执行默认构建,前提是工作目录中存在makefile。makefile 是一个特殊的指令文件,包含构建所有所需文件直到生成预期输出文件的规则和配方。提供类似构建自动化解决方案的开源替代品存在,例如 CMake 和 SCons,但本书中的所有示例都是使用 Make 构建的,因为它提供了一个简单且足够基本的构建系统控制环境,并且它是POSIX标准化的。
一些集成开发环境使用内置机制来协调构建步骤或生成 makefile,在用户请求构建输出文件时自动调用 Make。然而,手动编辑 makefile 可以完全控制生成最终图像的中间步骤,用户可以自定义用于生成所需输出文件的配方和规则。
对于交叉编译针对 Cortex-M 目标的代码,不需要安装特定版本,但在编写 makefile 中的目标和指令时,需要考虑一些额外参数,例如工具链二进制文件的位置或编译器需要的特定标志。
使用构建过程的一个优点是,目标可能具有来自其他中间组件的隐式依赖关系,这些依赖关系在编译时自动解决。如果所有依赖关系都正确配置,makefile 确保仅在需要时执行中间步骤,当只有少数源文件被更改或单个目标文件被删除时,可以减少整个项目的编译时间。
Makefile 有特定的语法来描述规则。每个规则以期望作为规则输出的目标文件开始,后面跟着一个冒号和先决条件的列表,这些先决条件是执行规则所需的文件。随后是一系列配方项,每个配方项描述 Make 将执行的动作以创建所需的目标:
target: [prerequisites]
recipe
recipe
...
默认情况下,Make 将在解析文件时执行遇到的第一个规则,如果命令行中没有指定规则名称。如果任何先决条件不可用,Make 将自动在相同的 makefile 中查找可以递归创建所需文件的规则,直到满足需求链。
Makefile 可以在执行时将自定义文本字符串分配给内部变量。变量名可以使用 = 运算符分配,并通过在它们前面加上 $ 来引用。例如,以下赋值用于将两个目标文件的名称放入 OBJS 变量中:
OBJS = hello.o world.o
在规则中自动分配的一些重要变量如下:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/011.jpg
表 2.1 – 可用于 makefile 脚本中的某些自动变量
这些变量在配方动作行中使用起来很方便。例如,从两个目标文件生成 helloworld ELF 文件的配方可以写成如下:
helloworld: $(OBJS)
gcc -o $(@) $(^)
一些规则是由 Make 隐式定义的。例如,从各自的源文件创建 hello.o 和 world.o 文件的规则可以省略,因为 Make 期望能够以最明显的方式获得这些目标文件中的每一个,即如果存在,通过编译同名 C 源文件。这意味着这个最小化 makefile 已经能够从源文件编译这两个目标文件,并使用宿主系统的默认选项将它们链接在一起。
如果可执行文件与其中一个先决条件对象(去掉 .o 扩展名)同名,链接配方也可以是隐式的。如果最终的 ELF 文件名为 hello,我们的 makefile 可以简单地变成以下单行:
hello: world.o
这将自动解决 hello.o 和 world.o 的依赖关系,然后使用类似于我们在显式目标中使用的隐式链接器配方将它们链接在一起。
隐式规则使用预定义变量,这些变量在规则执行之前自动分配,但可以在 makefile 中修改。例如,可以通过更改CC变量来更改默认的编译器。以下是一个重要的变量列表,这些变量可能用于更改隐式规则和配方:例如,它可能更改默认的编译器。以下是一个重要的变量列表,这些变量可能用于更改隐式规则和配方:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/02.jpg
表 2.2 – 指定默认工具链和标志的隐式、预定义变量
当为嵌入式平台链接裸机应用程序时,必须相应地修改 makefile,正如本章后面所示,需要几个标志来正确交叉编译源文件并指示链接器使用所需的内存布局来组织内存部分。此外,通常还需要额外的步骤来操作 ELF 文件并将其转换为可以传输到目标系统的格式。然而,makefile 的语法是相同的,这里显示的简单规则与用于构建示例的规则没有太大区别。如果使用隐式规则,默认变量仍然需要调整以修改默认行为。
当在 makefile 中正确配置所有依赖项时,Make 确保只有在目标文件比其依赖项旧时才执行规则,因此当只有少数源文件被修改或单个目标文件被删除时,可以减少整个项目的编译时间。
Make 是一个非常强大的工具,其功能范围远远超出了本书中用于生成示例的少数功能。掌握构建过程的自动化可能有助于优化构建过程。makefile 的语法包括有用的功能,例如条件语句,可以通过使用不同的目标或环境变量调用 makefile 来产生不同的结果。为了更好地理解 Make 的能力,请参阅可用的 GNU Make 手册,网址为www.gnu.org/software/make/manual。
调试器
在宿主环境中,调试在操作系统上运行的应用程序是通过运行调试器工具来完成的,该工具可以附加到现有进程或根据可执行 ELF 文件及其命令行参数启动一个新的进程。GCC 套件提供的默认调试选项称为GDB,即GNU 调试器的缩写。虽然 GDB 是一个命令行工具,但已经开发了几个前端来提供更好的执行状态可视化,并且一些集成开发环境在跟踪正在执行的单独行时提供了与调试器交互的内置前端。
再次强调,当要调试的软件在远程平台上运行时,情况略有变化。可以在开发机上运行与工具链一起分发的 GDB 版本,以连接到远程调试会话。在远程目标上进行的调试会话需要一个中间工具,该工具配置为将 GDB 命令转换为对核心 CPU 和相关硬件基础设施的实际操作,以建立与核心的通信。
一些嵌入式平台提供了硬件断点,这些断点用于在执行所选指令时触发系统异常。
在本章的后面部分,我们将看到如何与目标建立远程 GDB 会话,以便在当前点中断其执行,逐步执行代码,设置断点和观察点,并检查和修改内存中的值。
介绍了一些 GDB 命令,为 GDB 命令行界面提供的某些功能提供快速参考,这些功能可以有效地用于调试嵌入式应用程序。
调试器提供了对软件在运行时正在做什么的最佳理解,并便于在直接查看执行对内存和 CPU 寄存器的影响的同时查找编程错误。
嵌入式工作流程
如果与其他领域相比,嵌入式开发生命周期包括一些额外的步骤。代码必须进行交叉编译,然后处理映像并上传到目标,必须运行测试,并且在测量和验证阶段可能需要涉及硬件工具。使用编译语言时,本地应用程序软件的生命周期看起来像这个图表:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/B18730_02_01.jpg
图 2.1 – 应用程序开发的典型生命周期
当在同一架构内编写软件时,测试和调试可以在编译后立即进行,通常更容易发现问题。这导致典型循环的时间更短。此外,如果应用程序由于错误而崩溃,底层操作系统可以生成核心转储,这可以在稍后通过调试器进行分析,方法是恢复虚拟内存内容和 CPU 寄存器上下文,在错误出现的那一刻。
另一方面,由于缺乏其他环境中操作系统提供的虚拟地址和内存分段,在嵌入式目标上拦截致命错误可能稍微更具挑战性,因为可能会出现内存和寄存器损坏的潜在副作用。即使某些目标可以通过触发诊断中断来拦截异常情况,例如 Cortex-M 中的硬故障处理程序,恢复生成错误的原始上下文通常是不可能的。
此外,每次生成新的软件时,都需要执行一些耗时步骤,例如将图像转换为特定格式,以及将图像上传到目标本身,这可能需要几秒钟到一分钟的时间,具体取决于图像的大小和与目标通信所使用的接口速度:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/B18730_02_02.jpg
图 2.2 – 嵌入式开发生命周期,包括环境所需的其他步骤
在开发的一些阶段,当可能需要多次连续迭代以最终实现功能或检测缺陷时,编译和测试软件之间的时机会影响整个生命周期的效率。软件中实现的具体任务,涉及通过串行或网络接口进行通信,只能通过信号分析或观察对涉及的外围或远程系统的影响来验证。分析嵌入式系统上的电气效应需要一些硬件设置和仪器配置,这会增加更多的时间。
最后,开发由运行不同软件映像的多个设备组成的分布式嵌入式系统可能会导致为这些设备中的每一个重复前面的迭代。在可能的情况下,应通过在每个设备上使用相同的映像和不同的设置配置参数,以及通过实现并行固件升级机制来消除这些步骤。例如,JTAG 协议支持将软件映像上传到共享相同总线的多个目标,这显著减少了固件升级所需的时间,尤其是在涉及更多设备的分布式系统中。
无论预期的项目有多复杂,通常都值得在开始时投入所需的时间来优化软件开发的生命周期,以便在以后提高效率。没有开发者喜欢长时间将注意力从实际的编码步骤上移开,在一个需要太多时间或人工交互才能完成过程的次优环境中工作可能会令人沮丧。
可以使用文本编辑器从头开始创建嵌入式项目,或者通过在集成开发环境中创建新项目。
文本编辑器与集成环境之间的比较
虽然这主要取决于开发者的个人喜好,但在嵌入式社区中,关于是使用独立的文本编辑器还是更喜欢将工具链的所有组件集成到一个图形用户界面中的争论仍然存在。
现代集成开发环境(IDE)集成了以下任务的工具:
-
管理项目的组件
-
快速访问所有用于编辑的文件以及上传软件到板上的扩展
-
通过单击开始调试会话
微控制器制造商通常将他们的开发套件与 IDE 一起分发,这使得访问特定于微控制器的先进功能变得容易,这得益于预配置的设置和向导,它们简化了新项目的创建。大多数 IDE 包含用于自动生成特定微控制器引脚复用设置的设置代码的控件,从图形界面开始。其中一些甚至提供模拟器和工具来预测运行时资源使用情况,例如动态内存和功耗。
这些工具中的大多数都是基于 Eclipse 的某种定制,Eclipse 是一个流行的开源桌面集成开发环境(IDE),最初设计为 Java 软件开发的工具,后来由于扩展和自定义界面的可能性,在许多其他领域也非常成功。
使用 IDE 方法也有其缺点。IDE 通常不将实际的工具链嵌入到代码中。相反,它们提供了一个前端界面来与编译器、链接器、调试器和其他工具交互。为此,它们必须将所有标志、配置选项、包含文件的路径以及编译时定义的符号存储在一个机器可读的配置文件中。一些用户发现通过导航 GUI 的多个菜单来访问这些选项很困难。项目的一些其他关键组件,如链接脚本,也可能隐藏在底层,在某些情况下甚至由 IDE 自动生成,难以阅读。然而,对于大多数 IDE 用户来说,这些缺点被集成环境开发的优点所抵消。
尽管如此,还有一个必须考虑的注意事项。项目迟早会被自动构建和测试,正如在 Make:构建自动化工具 部分中分析的那样。机器人通常在 IDE 中是糟糕的用户,尽管它们可以使用命令行界面构建和运行任何测试,甚至与真实目标交互。使用 IDE 进行嵌入式开发的开发团队应始终考虑提供通过命令行替代策略构建和测试任何软件的选项。
尽管工具链的一些复杂性可以通过图形用户界面(GUI)进行抽象,但了解底层应用程序集的功能仍然很有用。本章的剩余部分将探讨 GCC 工具链,这是许多 32 位微控制器最受欢迎的跨架构编译器集。
GCC 工具链
在 IDE 的情况下,其复杂性是通过用户界面进行抽象的,而工具链是一组独立的软件应用程序,每个应用程序都服务于特定的目的。
GCC 是构建嵌入式系统的参考工具链之一,因为它具有模块化结构,允许为多个架构提供后端。由于其开源模型以及从其构建定制工具链的灵活性,基于 GCC 的工具链是嵌入式系统中最受欢迎的开发工具之一。
使用基于命令行的工具链构建软件具有多个优点,包括自动化中间步骤的可能性,这些步骤将所有模块从源代码构建成最终映像。这在需要连续编程多个设备或需要在持续集成服务器上自动化构建时尤其有用。
ARM 为所有最受欢迎的开发主机分发 GNU Arm Embedded Toolchain。工具链以描述目标的三元组为前缀。在 GNU Arm Embedded Toolchain 的情况下,前缀是 arm-none-eabi,表示交叉编译器后端配置为为 ARM 生成对象,没有特定于操作系统的 API 支持,并且具有嵌入式 ABI。
交叉编译器
与工具链一起分发的交叉编译器是 GCC 的一个变体,后端配置为构建包含特定架构机器代码的对象文件。编译的输出是一组包含只能由特定目标解释的符号的对象文件。Arm-none-eabi-gcc,ARM 提供的用于构建微控制器软件的 GCC 变体,可以将 C 代码编译成适用于多个不同目标的机器指令和 CPU 优化。每个架构都需要自己的特定工具链,该工具链将生成特定目标的可执行文件。
GCC 后端对 ARM 架构支持多个机器特定选项,用于选择 CPU 的正确指令集和机器特定优化参数。
下表列出了 GCC 后端作为 -m 标志提供的某些 ARM 特定机器选项:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/03.jpg
表 2.3 – GCC ARM 特定架构的编译器选项
要编译与通用 ARM Cortex M4 兼容的代码,每次调用编译器时都必须指定 -mthumb 和 -mcpu=cortex-m4 选项:
$ arm-none-eabi-gcc -c test.c -mthumb -mcpu=cortex-m4
这个编译步骤产生的 test.o 文件与使用 gcc 主机从相同源代码编译的文件非常不同。如果比较的不是两个对象文件,而是中间汇编代码,这种差异将更容易理解。实际上,当使用 -S 选项调用编译器时,编译器能够创建中间汇编代码文件,而不是编译和组装的对象。
与主机 GCC 编译器类似,有不同级别的可能优化可供激活。在某些情况下,激活大小优化以生成更小的目标文件是有意义的。然而,在开发过程中,非优化映像适合闪存以方便调试过程更为可取,因为编译器可能会更改代码执行的顺序并隐藏某些变量的内容,这使得优化后的代码流更难跟踪。优化参数可以提供在命令行中,以选择所需的优化级别:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/04.jpg
表 2.4 – GCC 优化级别
另一个在调试和原型设计过程中经常使用的通用 GCC 命令行选项是 -g 标志,它指示编译器在最终对象中保留调试相关数据,以便在调试器中运行时便于访问函数和变量的可读句柄。
为了通知编译器我们正在运行裸机应用程序,使用 -ffreestanding 命令行选项。在 GCC 术语中,独立环境由在链接步骤中可能缺少标准库定义,并且最重要的是,此选项会通知编译器它不应期望使用主函数作为程序的入口点或在执行开始之前提供任何前导代码。当为嵌入式平台编译代码时,此选项是必需的,因为它启用了在第四章,“启动过程”中描述的启动机制。
GCC 程序支持的命令行选项比这里快速介绍的多得多。要获得功能性的更完整概述,请参阅可用的 GNU GCC 手册,网址为 gcc.gnu.org/onlinedocs/。
要在 Make 的自动构建中集成交叉编译工具链,需要在 makefile 中进行一些更改。
假设工具链已正确安装在开发主机上,并且可在其执行路径中访问,则只需更改 makefile 中的默认编译器命令即可使用 CC Make 变量:
CC=arm-none-eabi-gcc
运行编译选项所需的自定义命令行选项可以通过 CFLAGS 变量导出:
CFLAGS=-mthumb -mcpu=cortex-m4 -ffreestanding
使用默认的 makefile 变量,如 CC 和 CFLAGS,可以启用隐式 makefile 规则,从具有相同名称的 C 源文件构建目标文件,以及自定义编译器配置。
编译编译器
GCC 工具链的二进制发行版可用于下载到几个特定的目标和主机机器。为了编译适用于 ARM Cortex-M 微处理器的代码,arm-none-eabi工具链对大多数 GNU/Linux 发行版可用。然而,在某些情况下,从头开始构建工具链可能很有用。例如,当某个目标的编译器尚未存在或未以二进制格式提供给我们喜欢的开发环境时。这个过程也有助于更好地理解构建工具所需的各个组件。
menuconfig内核。在安装 crosstool-NG 后,可以通过以下方式调用配置器:
$ ct-ng menuconfig
一旦创建了配置,就可以开始构建过程。由于操作需要检索所有组件、修补它们并构建工具链,因此根据主机机器的速度和互联网连接速度,检索所有组件可能需要几分钟。可以通过以下命令启动构建过程:
$ ct-ng build
预定义的配置可用于编译常用的工具链,主要用于运行 Linux 的目标。当为 Linux 目标编译工具链时,有几个 C 库可供选择。在我们的案例中,因为我们想要一个裸机工具链,所以newlib是默认选择。其他几个库提供了 C 标准库子集的实现,例如uClibc和musl。newlib库是一个小型跨平台 C 库,主要设计用于没有操作系统在板上的嵌入式系统,并且作为默认库在许多 GCC 发行版中提供,包括 ARM 分发的arm-none-eabi交叉编译器。
链接可执行文件
在命令行中使用-T filename选项,链接器被要求用包含在 filename 中的自定义脚本替换程序的默认内存布局。
.ld扩展名,并且是用特定语言编写的。一般来说,每个编译对象的符号都被分组在最终可执行映像的各个部分中。
脚本可以与 C 代码交互,通过 GCC 特定的与符号关联的属性,导出脚本内部定义的符号,并遵循代码中提供的指示。GCC 提供了__attribute__关键字,用于在符号定义前添加,以激活针对每个符号的 GCC 特定、非标准属性。
一些 GCC 属性可以用来向链接器传达以下信息:
-
弱符号,可以被具有相同名称的符号覆盖
-
要存储在 ELF 文件特定部分的符号,在链接脚本中定义
-
隐式使用的符号,这可以防止链接器丢弃符号,因为代码中没有任何地方引用它
weak属性用于定义弱符号,可以在代码的任何其他地方通过具有相同名称的另一个定义来覆盖。例如,考虑以下定义:
void __attribute__(weak) my_procedure(int x) {/* do nothing */}
在这种情况下,过程被定义为不执行任何操作,但可以在代码库的任何其他地方通过再次定义它来覆盖它,使用相同的名称,但这次不带weak属性:
void my_procedure(int x) { y = x; }
链接步骤确保最终的可执行文件恰好包含每个定义的符号的一个副本,如果没有属性,则是指不带属性的副本。这种机制引入了在代码中具有相同功能的不同实现的可能性,这些实现可以通过在链接阶段包含不同的目标文件来更改。这在编写可移植到不同目标的同时仍保持相同抽象的代码时特别有用。
除了在 ELF 描述中所需的默认部分之外,还可以添加自定义部分来存储特定的符号,例如函数和变量,在固定的内存地址。当数据存储在可能在不同时间上传到闪存的闪存页的起始位置时,这很有用,而软件本身可能在不同的时间上传。在某些情况下,这是针对特定目标的设置的情况。
在定义符号时使用自定义 GCC section属性确保符号最终位于最终映像中的期望位置。只要在链接器中存在条目来定位它们,部分可以具有自定义名称。以下是如何将section属性添加到符号定义的示例:
const uint8_t
__attribute__((section(".keys")))
private_key[KEY_SIZE] = {0};
在这个例子中,数组被放置在.keys部分,这需要在链接器脚本中为其创建自己的条目。
被认为是一种良好的实践,让链接器在最终映像中丢弃未使用的符号,尤其是在使用嵌入式应用程序未完全利用的第三方库时。这可以通过 GCC 使用链接器垃圾收集器来完成,通过-gc-sections命令行选项激活。如果提供了此标志,代码中未使用的部分将被自动丢弃,未使用的符号也将被排除在最终映像之外。
为了防止链接器丢弃与特定部分关联的符号,used属性将符号标记为程序隐式使用。可以在同一声明中列出多个属性,用逗号分隔,如下所示:
const uint8_t __attribute__((used,section(".keys")))
private_key[KEY_SIZE] = {0};
在这个例子中,属性既表明private_key数组属于.keys部分,又表明它不能被链接器垃圾收集器丢弃,因为它被标记为已使用。
一个用于嵌入式目标的简单链接脚本至少定义了与RAM和FLASH映射相关的两个部分,并将一些预定义的符号导出以指导工具链的汇编器了解内存区域。基于 GNU 工具链的裸机系统通常从一个MEMORY部分开始,描述系统内两个不同区域的映射,如下所示:
MEMORY {
FLASH(rx) : ORIGIN = 0x00000000, LENGTH=256k
RAM(rwx) : ORIGIN = 0x20000000, LENGTH=64k
}
上述代码片段描述了系统使用的两个内存区域。第一个块是 256k 映射到FLASH,其中r 和 x 标志表示该区域可进行读取和执行操作。这强制了整个区域的只读属性,并确保没有变体部分被放置在那里。另一方面,RAM 可以直接以写入模式访问,这意味着变量将被放置在该区域内的某个部分。在这个特定示例中,目标将 FLASH 映射在地址空间的开头,而 RAM 从 512 MB 开始映射。每个目标都有自己的地址空间映射和闪存/RAM 大小,这使得链接脚本针对特定目标。
如本章前面所述,.text和.rodata ELF 部分只能进行读取访问,因此它们可以安全地存储在 FLASH 区域,因为它们在目标运行时不会被修改。另一方面,.data和.bss必须映射到 RAM 以确保它们可修改。
可以在脚本中添加额外的自定义部分,在需要将额外的部分存储在内存的特定位置时。链接脚本还可以导出与内存中特定位置或动态大小部分的长度相关的符号,这些符号可以称为外部符号,并在 C 源代码中访问。
链接脚本中的第二个语句块称为SECTIONS,包含在定义的内存区域特定位置的部分分配。当脚本中的.符号与一个变量相关联时,它代表该区域中的当前位置,该位置从可用的低地址开始逐步填充。
每个部分都必须指定它必须映射到的区域。以下示例虽然仍然不完整,无法运行二进制可执行文件,但它展示了如何使用链接脚本部署不同的部分。.text和.rodata部分映射到闪存内存:
SECTIONS
{
/* Text section (code and read-only data) */
.text :
{
. = ALIGN(4);
_start_text = .;
*(.text*) /* code */
. = ALIGN(4);
_end_text = .;
*(.rodata*) /* read only data */
. = ALIGN(4);
_end_rodata = .;
} > FLASH
可修改的部分映射在 RAM 中,这里有两个特殊情况需要注意。
AT关键字用于向链接器指示加载地址,这是.data中变量的原始值存储的区域,而实际使用的执行地址在另一个内存区域。关于.data部分的加载地址和虚拟地址的更多详细信息,请参阅第四章,启动过程。
用于 .bss 段的 NOLOAD 属性确保该段在 ELF 文件中不存储预定义的值。未初始化的全局和静态变量由链接器映射到由链接器分配的 RAM 区域:
_stored_data = .;
.data: AT(__stored_data)
{
. = ALIGN(4);
_start_data = .;
*(.data*)
. = ALIGN(4);
_start_data = .;
} > RAM
.bss (NOLOAD):
{
. = ALIGN(4);
_start_bss = .;
*(.bss*)
. = ALIGN(4);
_end_bss = .;
} > RAM
强制链接器保留段在最终可执行文件中的另一种方法是使用 KEEP 指令标记段。请注意,这是之前解释的 __attribute__((used)) 机制的替代方案:
.keys :
{
. = ALIGN(4);
*(.keys*) = .;
KEEP(*(.keys*));
} > FLASH
通常来说,让链接器创建一个与结果二进制文件并存的 .map 文件是有用的,可以通过在链接步骤中添加 -Map=filename 选项来实现,如下所示:
$ arm-none-eabi-ld -o image.elf object1.o object2.o
-T linker_script.ld -Map=map_file.map
映射文件包含了所有符号的位置和描述,按段分组。这对于在图像中查找符号的具体位置以及验证由于配置错误而意外丢弃的有用符号非常有用。
交叉编译工具链为通用功能提供标准 C 库,例如字符串操作或标准类型声明。这些实际上是操作系统应用程序空间中可用的库调用子集,包括标准输入/输出函数。这些函数的后端实现通常留给应用程序,因此调用需要与硬件交互的库函数,如 printf,意味着在库外实现了一个写入函数,提供最终的设备或外围设备传输。
后端写入函数的实现决定了哪个通道将作为嵌入式应用程序的标准输出。链接器能够自动解析对标准库调用的依赖,使用内置的 newlib 实现。为了在链接过程中排除标准 C 库符号,可以将 -nostdlib 选项添加到传递给 GCC 的链接步骤的选项中。
二进制格式转换
尽管包含所有编译符号的二进制格式,ELF 文件前面有一个包含内容描述和指向文件中各段起始位置指针的头部。所有这些额外信息在嵌入式目标上运行时并不需要,因此链接器生成的 ELF 文件必须转换为一个纯二进制文件。工具链中的一个工具 objcopy 可以将图像从一种标准格式转换为其他格式,通常的做法是将 ELF 转换为不带符号的原始二进制图像。要将图像从 ELF 转换为二进制格式,请调用以下命令:
$ arm-none-eabi-objcopy -I elf -O binary image.elf image.bin
这将创建一个名为 image.bin 的新文件,该文件包含原始 ELF 可执行文件中的符号,可以上传到目标设备。
即使通常不适用于使用第三方工具直接上传到目标设备,也可以通过调试器加载符号并将它们上传到闪存地址。原始的 ELF 文件对于 GNU 工具链中的其他诊断工具(如 nm 和 readelf)的目标也很有用,这些工具显示每个模块中的符号,包括它们的类型和相对于二进制图像的相对地址。此外,通过在最终图像或单个对象文件上使用 objdump 工具,可以检索到关于图像的多个细节,包括使用 -d 反汇编选项可视化整个汇编代码:
arm-none-eabi-objdump -d image.elf
到目前为止,工具链已为我们提供了在目标微控制器上运行、调试和分析编译软件所需的所有工件。为了传输图像或开始调试会话,我们需要额外的特定工具,下一节将进行描述。
与目标交互
为了开发目的,嵌入式平台通常通过 JTAG 或 SWD 接口进行访问。通过这些通信通道,可以将软件上传到目标设备的闪存中,并访问片上调试功能。市场上存在一些自包含的 JTAG/SWD 适配器,可以通过主机上的 USB 进行控制,而一些开发板配备了额外的芯片,用于控制连接到主机的 JTAG 通道。
一个强大的通用开源工具,用于访问目标上的 JTAG/SWD 功能,是 Open On-Chip Debugger (OpenOCD)。一旦正确配置,它将创建可以用于命令控制台和与调试器前端交互的本地套接字。一些开发板配备了额外的接口,用于与核心 CPU 通信。例如,STMicroelectronics 为 Cortex-M 设计的原理图板很少不配备 ST-Link 芯片技术,这允许直接访问调试和闪存操作功能。得益于其灵活的后端,OpenOCD 可以使用不同的传输类型和物理接口(包括 ST-Link 和其他协议)与这些设备通信。支持多种不同的板,配置文件可以在 OpenOCD 中找到。
当启动时,OpenOCD 在预配置的端口上打开两个本地 TCP 服务器套接字,为目标平台提供通信服务。一个套接字提供了一个可以通过 Telnet 访问的交互式命令控制台,而另一个是用于远程调试的 GDB 服务器,如下一节所述。
OpenOCD 伴随两套配置文件集一起分发,这些配置文件描述了目标微控制器和外设(在 target/ 目录中),以及用于通过 JTAG 或 SWD 与其通信的调试接口(在 interface/ 目录中)。第三套配置文件(在 board/ 目录中)包含针对知名系统的配置文件,例如配备接口芯片的开发板,该芯片通过包含正确的文件将两个接口和目标设置结合起来。
为了配置 OpenOCD 以使用 openocd.cfg 配置文件:
telnet_port 4444
gdb_port 3333
source [find board/stm32f7discovery.cfg]
从 openocd.cfg 通过 source 指令导入的特定于板的配置文件,指示 OpenOCD 使用 ST-Link 接口与目标通信,并为 STM32F 系列微控制器设置所有 CPU 特定选项。
主配置文件中指定的两个端口,使用 telnet_port 和 gdb_port 指令,指示 OpenOCD 打开两个监听 TCP 套接字。
通常称为监视控制台的第一个套接字可以通过连接到本地的 4444 TCP 端口,使用命令行中的 Telnet 客户端来访问:
$ telnet localhost 4444
Open On-Chip Debugger
>
OpenOCD 初始化、擦除闪存和传输映像的指令序列以以下内容开始:
> init
> halt
> flash probe 0
执行在软件映像的开始处停止。在 probe 命令之后,闪存被初始化,OpenOCD 将打印一些信息,包括映射到闪存上写入的地址。以下信息显示在 STM32F746 上:
device id = 0x10016449
flash size = 1024kbytes
flash "stm32f2x" found at 0x08000000
可以使用以下命令检索闪存的几何形状:
> flash info 0
在 STM32F746 上显示如下:
#0 : stm32f2x at 0x08000000, size 0x00100000, buswidth 0, chipwidth 0
# 0: 0x00000000 (0x8000 32kB) not protected
# 1: 0x00008000 (0x8000 32kB) not protected
# 2: 0x00010000 (0x8000 32kB) not protected
# 3: 0x00018000 (0x8000 32kB) not protected
# 4: 0x00020000 (0x20000 128kB) not protected
# 5: 0x00040000 (0x40000 256kB) not protected
# 6: 0x00080000 (0x40000 256kB) not protected
# 7: 0x000c0000 (0x40000 256kB) not protected
STM32F7[4|5]x - Rev: Z
该闪存包含八个扇区。如果 OpenOCD 目标支持,可以通过从控制台发出以下命令来完全擦除闪存:
> flash erase_sector 0 0 7
一旦擦除闪存内存,我们可以使用 flash write_image 指令将其上传软件映像,并将其链接并转换为原始二进制格式。由于原始二进制格式不包含关于其在映射区域中目标地址的信息,因此必须将闪存中的起始地址作为最后一个参数提供,如下所示:
> flash write_image /path/to/image.bin 0x08000000
这些指令可以附加到 openocd.cfg 文件中,或者附加到不同的配置文件中,以便自动化执行特定操作所需的所有步骤,例如擦除闪存和上传更新后的映像。
一些硬件制造商提供自己的工具集以与设备交互。STMicroelectronics 设备可以使用 ST-Link 工具进行编程,这是一个开源项目,包括一个闪存工具 (st-flash) 和一个 GDB 服务器对应工具 (st-util)。一些平台内置了接受替代格式或二进制传输过程的引导加载程序。一个常见的例子是 dfu-util,这是一个免费软件工具。
每个工具,无论是通用的还是特定的,都倾向于达到相同的目标,即与设备通信并提供调试代码的接口,尽管它们通常向开发工具暴露不同的接口。
大多数制造商提供的用于与特定系列微控制器一起工作的 IDE,在 IDE 中集成了他们自己的工具或第三方应用程序,以访问闪存映射并控制目标上的执行。虽然,一方面,他们承诺隐藏操作的不必要复杂性并提供一键式固件上传,但另一方面,他们通常不提供方便的界面用于同时编程多个目标,或者至少在需要批量上传初始工厂固件的生产中,效率不高。
了解从命令行界面了解机制和流程,可以让我们理解每次将新固件上传到目标设备时幕后发生的事情,并预测在此阶段可能影响生命周期的相关问题。
GDB 会话
无论程序员的准确性如何或我们正在工作的项目的复杂性如何,大部分的开发时间都将花费在试图理解我们的软件做什么,或者更有可能的是,什么出了问题以及为什么软件在代码首次编写时没有按照预期行为。调试器是我们工具链中最强大的工具,它允许我们直接与 CPU 通信,设置断点,逐条控制执行流程,并检查 CPU 寄存器、局部变量和内存区域的值。对调试器的良好了解意味着花费在试图弄清楚发生了什么的时间更少,并且更有效地寻找错误和缺陷。
arm-none-eabi 工具链包括一个能够解释远程目标内存和寄存器布局的 GDB,并且可以通过与主机 GDB 相同的接口访问,前提是其后端能够与嵌入式平台通信,使用 OpenOCD 或类似的宿主工具通过 GDB 服务器协议与目标通信。如前所述,OpenOCD 可以配置为提供 GDB 服务器接口,在所提出的配置中,该接口位于端口 3333。
在启动 arm-none-eabi-gdb 之后,我们可以使用 GDB 的 target 命令连接到正在运行的工具。在 OpenOCD 运行时连接到 GDB 服务器可以使用 target 命令:
> target remote localhost:3333
所有 GDB 命令都可以缩写,因此命令通常变为以下形式:
> tar rem :3333
连接后,目标设备通常会停止执行,允许 GDB 获取当前正在执行的指令、堆栈跟踪和 CPU 寄存器值的信息。
从现在开始,可以使用调试器界面正常地逐步执行代码,设置断点和观察点,并在运行时检查和修改 CPU 寄存器和可写内存区域。
GDB 可以完全通过其命令行界面使用,使用快捷键和命令来启动和停止执行,以及访问内存和寄存器。
以下参考表列举了调试会话中可用的几个 GDB 命令,并提供了它们用法的快速解释:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/05a.jpghttps://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/05b.jpg
表 2.5 – 一些常用的 GDB 命令
GDB 是一个非常强大和完整的调试器,本节中展示的命令只是其实际潜力的一个小部分。我们建议您通过阅读其手册来发现 GDB 提供的其他功能,以找到最适合您需求的命令集。
IDE 通常提供单独的图形模式来处理调试会话,该模式与编辑器集成,允许你在系统以 调试模式 运行时设置断点、观察变量和探索内存区域的内容。
验证
仅调试或甚至简单的输出分析在验证系统行为和识别代码中的问题和不良影响时通常是不够的。为了验证单个组件的实现以及在不同条件下的整个系统的行为,可以采取不同的方法。虽然在某些情况下,结果可以直接从主机机器测量,但在更具体的情况下,通常很难重现确切的场景或从系统输出中获取必要的信息。
外部工具在分析更复杂、分布式系统中的通信接口和网络设备时可能很有用。在其他情况下,可以使用模拟或仿真环境在目标之外测试单个模块,以运行代码库的小部分。
本节考虑了不同的测试、验证策略和工具,以提供任何场景的解决方案。
功能测试
在编写代码之前编写测试用例通常被认为是现代编程中的最佳实践。首先编写测试不仅加快了开发阶段,还改善了工作流程的结构。通过从一开始就设定明确和可衡量的目标,更难在单个组件的设计中引入概念性缺陷,并且它还强制模块之间有更清晰的分离。更具体地说,嵌入式开发者通过直接交互验证系统正确行为的可能性较小;因此,只要预期的结果可以从主机系统直接测量,测试驱动开发(TDD)就是验证单个组件以及整个系统的功能行为的首选方法。
然而,必须考虑的是,测试往往引入了对特定硬件的依赖,有时嵌入式系统的输出只能通过特定的硬件工具或非常独特和特殊的用法场景来验证。在这些所有情况下,传统的 TDD 范式不太适用,项目可以通过模块化设计受益,从而在合成环境中(如仿真器或单元测试平台)测试尽可能多的组件。
编写测试通常涉及编程主机,以便在嵌入式软件执行或在与断点之间的执行过程中,可以检索有关运行目标的信息。目标可以配置为通过通信接口(如基于 UART 的串行端口)提供即时输出,该接口可以由主机解析。通常,在主机上使用高级解释型编程语言编写测试工具更为方便,这样可以更好地组织测试用例,并轻松地使用正则表达式集成测试结果的解析。Python、Perl、Ruby 和其他具有类似特性的语言,通常非常适合此目的,也得益于为收集和分析测试结果以及与持续集成引擎交互而设计的库和组件。良好的测试和验证基础设施组织比其他任何因素都更有利于项目的稳定性,因为只有当所有现有测试在每次修改时都重复执行,才能在正确的时间检测到回归。在开发过程中持续运行所有测试用例不仅提高了尽早检测到不期望的影响的效率,而且通过直接测量失败次数,有助于始终使开发目标可见,并使项目生命周期的任何阶段对组件的重构更加可行。
效率是关键,因为嵌入式编程是一个迭代的过程,其中多个步骤需要反复执行,并且对开发者的要求是预测性的,而不是反应性的。
硬件工具
如果有一个工具对于辅助嵌入式软件开发人员来说是绝对不可或缺的,那就是逻辑分析仪。通过测量涉及微控制器的输入和输出信号,可以检测信号的电气行为、它们的时序,甚至接口协议中单个比特的数字编码。大多数逻辑分析仪可以通过感应线缆的电压来识别和解码符号序列,这通常是验证协议是否正确实现以及是否符合与外围设备和网络端点通信的合同的最有效方式。虽然逻辑分析仪在历史上仅作为独立的专用计算机提供,但它们通常以其他形式提供,例如可以通过 USB 或以太网接口连接到主机的电子仪器,并使用基于 PC 的软件来捕获和解码信号。这个过程的结果是对涉及信号的完整离散分析,这些信号以恒定的速率采样,然后在屏幕上可视化。
虽然示波器可以执行类似任务,但在处理离散信号时,它们通常比逻辑分析仪配置得更复杂。尽管如此,示波器是分析模拟信号(如模拟音频和无线电收发器之间的通信)的最佳工具。根据任务,可能最好使用其中一个,但总的来说,逻辑分析仪最大的优势是它提供了对离散信号的更好洞察。混合信号逻辑分析仪通常是在示波器的灵活性和离散信号逻辑分析的简单性及洞察力之间的一种良好折衷。
示波器和逻辑分析仪通常用于捕获特定时间窗口内信号的活动,这可能难以与运行中的软件同步。而不是连续捕获这些信号,捕获的开始可以与一个物理事件同步,例如数字信号首次改变其值或模拟信号超过预定义的阈值。这是通过配置仪器使用触发器来启动捕获来实现的,这保证了所捕获的信息只包含对当前诊断有意义的时序片段。
测试非目标
另一种提高开发效率的有效方法是尽可能减少与实际目标的交互。当然,这并不总是可能的,尤其是在开发需要在实际硬件上测试的设备驱动程序时,但存在工具和方法可以在开发机上直接部分测试软件。
非特定于 CPU 的代码部分可以编译为主机机器架构,并直接运行,只要它们的周围环境被适当抽象以模拟真实环境。可测试的软件可以小到单个函数,在这种情况下,可以专门为开发架构编写单元测试。
单元测试通常是小型的应用程序,通过提供已知输入并验证其输出来验证单个组件的行为。Linux 系统上有几个工具可以帮助编写单元测试。check库提供了一个接口,通过编写几个预处理器宏来定义单元测试。结果是小型自包含的应用程序,每次代码更改时都可以在主机机器上运行。测试函数所依赖的系统组件使用模拟进行抽象。例如,以下代码检测并丢弃来自串行线接口的特定转义序列,Esc + C,从串行线读取,直到返回\0字符:
int serial_parser(char *buffer, uint32_t max_len)
{
int pos = 0;
while (pos < max_len) {
buffer[pos] = read_from_serial();
if (buffer[pos] == (char)0)
break;
if (buffer[pos] == ESC) {
buffer[++pos] = read_from_serial();
if (buffer[pos] == 'c')
pos = pos - 1;
continue;
}
pos++;
}
return pos;
}
一组单元测试,使用检查测试套件来验证此函数,可能看起来如下:
START_TEST(test_plain) {
const char test0[] = "hello world!";
char buffer[40];
set_mock_buffer(test0);
fail_if(serial_parser(buffer, 40) != strlen(test0));
fail_if(strcmp(test0,buffer) != 0);
}
END_TEST
每个测试用例都可以包含在其START_TEST()/END_TEST块中,并提供不同的初始配置:
START_TEST(test_escape) {
const char test0[] = "hello world!";
const char test1[] = "hello \033cworld!";
char buffer[40];
set_mock_buffer(test1);
fail_if(serial_parser(buffer, 40) != strlen(test0));
fail_if(strcmp(test0,buffer) != 0);
}
END_TEST
START_TEST(test_other) {
const char test2[] = "hello \033dworld!";
char buffer[40];
set_mock_buffer(test2);
fail_if(serial_parser(buffer, 40) != strlen(test2));
fail_if(strcmp(test2,buffer) != 0);
}
END_TEST
这个第一个test_plain测试确保没有转义字符的字符串被正确解析。第二个测试确保跳过了转义序列,第三个测试验证类似的转义字符串没有被输出缓冲区修改。
串行通信是通过一个模拟函数来模拟的,该函数在代码在目标上运行时替换了驱动程序提供的原始serial_read功能。这是一个简单的模拟,它向解析器提供了一个可以使用set_serial_buffer辅助函数重新初始化的常量缓冲区。模拟代码如下:
static int serial_pos = 0;
static char serial_buffer[40];
char read_from_serial(void) {
return serial_buffer[serial_pos++];
}
void set_mock_buffer(const char *buf)
{
serial_pos = 0;
strncpy(serial_buffer, buf, 20);
}
单元测试对于提高代码质量非常有用,但当然,在项目经济中实现高代码覆盖率需要消耗大量的时间和资源。功能测试也可以通过将函数分组到自包含的模块中,并实现比模拟更复杂的模拟器来直接在开发环境中运行,这些模拟器针对特定测试用例。在串行解析器的例子中,可以在主机机器上的不同串行驱动程序上测试整个应用程序逻辑,该驱动程序也能够模拟整个串行线的对话,并与系统中的其他组件交互,例如虚拟终端和其他生成输入序列的应用程序。
当在单个测试用例中覆盖更大部分的代码时,模拟环境的复杂性会增加,并且需要复制嵌入式系统在主机上的环境的工作量也会随之增加。尽管如此,将它们作为整个开发周期中的验证工具,甚至集成到自动化测试过程中,是一种良好的实践。
有时,实现一个模拟器可以提供更完整的测试集,或者可能是唯一可行的选择。例如,考虑那些使用 GPS 接收器进行定位的嵌入式系统:在北半球测试带有负纬度的应用程序逻辑是不可能的,因此编写一个模拟器来模仿来自这种接收器的数据是验证我们的最终设备不会在赤道停止工作的最快方式。
模拟器
在开发机上运行代码的另一种有效方法,这对我们的代码库影响较小,并放宽了特定的可移植性要求,是在主机 PC 上模拟整个平台。模拟器是一种计算机程序,可以复制整个系统的功能,包括其核心 CPU、内存和一组外围设备。一些现代的 PC 虚拟化管理程序源自lm3s6965evb,这是一个基于 Cortex-M 的旧微控制器,制造商不再推荐用于新设计,但它完全由 QEMU 支持。
一旦使用lm3s6965evb作为目标创建了一个二进制镜像,并且使用objcopy正确转换为原始二进制格式后,可以通过以下方式调用 QEMU 来运行一个完全模拟的系统:
$ qemu-system-arm -M lm3s6965evb --kernel image.bin
--kernel选项指示模拟器在启动时运行镜像,虽然这个名字可能听起来不合适,但它被称为kernel是因为 QEMU 广泛用于在其他合成目标上模拟无头 Linux 系统。同样,可以通过使用 QEMU 内置的 GDB 服务器通过-gdb选项启动一个方便的调试会话,该选项还可以使系统停止,直到我们的 GDB 客户端连接到它:
$ qemu-system-arm -M lm3s6965evb --kernel image.bin -nographic -S -gdb tcp::3333
同样,与实际目标一样,我们可以将arm-none-eabi-gdb连接到localhost上的 TCP 端口3333,并开始调试软件镜像,就像它在实际平台上运行时一样。
模拟方法的局限性在于,QEMU 只能用于调试不涉及与实际现代硬件交互的通用特性。尽管如此,使用 Cortex-M3 目标运行 QEMU 可以快速了解通用 Cortex-M 特性,如内存管理、系统中断处理和处理器模式,因为 Cortex-M CPU 的许多特性都得到了精确的模拟。
使用 Renode (renode.io) 可以实现更精确的微控制器系统模拟。Renode 是一个开源、可配置的模拟器,适用于许多不同的微控制器和基于 CPU 的嵌入式系统。仿真包括外围设备、传感器、LED,甚至无线和有线接口,用于连接多个模拟系统和主机网络。
Renode 是一个带有命令行控制台桌面应用程序。必须从命令行调用提供一个配置文件,在 /scripts 目录下提供了多个平台和开发板配置。这意味着一旦安装,可以通过以下命令启动 STM32F4 开发板 的模拟器:
$ renode /opt/renode/scripts/single-node/stm32f4_discovery.resc
此命令将在模拟的 STM32F4 目标闪存中加载演示固件,并将模拟的 UART 串行端口之一的重定向到新窗口中的控制台。要启动演示,请在 Renode 控制台中输入 start。
示例脚本包含一个运行 Contiki 操作系统 的演示固件映像。固件映像通过 Renode 命令由脚本加载:
sysbus LoadELF $bin
其中 $bin 是一个指向要加载到模拟闪存中的固件 ELF 文件路径(或 URL)的变量。此选项,以及 UART 分析器端口和其他在启动模拟器时执行的特定命令,可以通过自定义脚本文件轻松更改。
Renode 集成了一个 GDB 服务器,可以在启动仿真之前从 Renode 控制台或启动脚本中启动,例如,使用以下命令:
machine StartGdbServer 3333
在这种情况下,3333 是 GDB 服务器将监听的 TCP 端口,正如其他情况下使用 QEMU 和物理目标上的调试器一样。
与非常通用的模拟器 QEMU 不同,Renode 是一个旨在协助嵌入式开发人员在整个生命周期中工作的项目。能够模拟不同的完整平台,为包括 RISC-V 在内的多个架构上的传感器创建模拟,使其成为快速自动化测试多个目标或测试即使实际硬件不可用时的系统独特工具。
最后但同样重要的是,得益于其自己的脚本语言,Renode 与测试自动化系统完美集成,其中可以启动、停止和恢复模拟目标,并在测试运行时更改所有设备和外围设备的配置。
提出的测试策略定义方法考虑了不同的场景。想法是引入一系列可能的软件验证解决方案,从实验室设备到在模拟和仿真环境中进行的离目标测试,供开发者在特定场景中选择。
摘要
本章介绍了用于嵌入式系统开发的工具。提出了一种实用方法,帮助您快速上手工具链以及与硬件平台通信所需的实用工具。使用适当的工具可以使嵌入式开发更加容易并缩短工作流程迭代。
在下一章中,我们提供了与大型团队协作时工作流程组织的指示。基于实际经验,我们提出了分割和组织任务、执行测试、在设计阶段迭代以及嵌入式项目定义和实施的解决方案。
第二部分 – 核心系统架构
本部分会深入探讨一些内容,首先向您介绍实用软件设计,然后逐步引导您了解正确启动机制和内存管理所需的代码,重点在于内存安全方法。
本部分包含以下章节:
-
第三章, 建筑模式
-
第四章, 启动程序
-
第五章, 内存管理
第三章:架构模式
从零开始启动嵌入式项目意味着通过经历所有研究和开发阶段,并考虑所有参与部分的协同作用,逐步走向最终解决方案。
软件开发需要在这些阶段中相应地发展。为了在不产生过多开销的情况下获得最佳结果,有一些最佳实践要遵循,以及一些工具要发现。
本章描述了一种基于实际经验的可能的方法,用于配置管理工具和设计模式。描述这种方法可能有助于您理解在一个专注于生产嵌入式设备或解决方案的团队中工作的动态。
本章我们将讨论以下主题:
-
配置管理
-
源代码组织
-
嵌入式项目的生命周期
-
安全考虑
到本章结束时,您将了解基于规范和平台限制设计系统时有用的架构模式概述。
配置管理
当作为团队工作时,协调和同步可以优化以提高效率。跟踪和控制开发生命周期可以平滑开发流程,减少停机时间和成本。
已知的最重要工具,用于帮助管理软件生命周期如下:
-
版本控制
-
问题跟踪
-
代码审查
-
持续集成
对于四个类别,存在不同的选项。源代码通过版本控制系统在开发者之间同步。问题跟踪系统(ITSs)通常由跟踪系统活动和已知错误的网络平台组成。可以通过特定的基于网络的工具鼓励代码审查,并通过版本控制系统的规则强制执行。
持续集成工具确保构建和测试执行任务被安排为自动执行,定期或在代码更改时执行,收集测试结果,并通知开发者关于回归的情况。
版本控制
无论您是单独工作还是在大型开发团队中,正确跟踪开发进度都极其重要。版本控制工具允许开发者通过按按钮随时回滚失败的实验,并查看其历史记录,以清晰地了解项目在任何时候是如何演变的。
版本控制系统,也称为版本控制系统或VCS,通过简化合并操作来鼓励合作。最更新的官方版本被称为主干、主或主要分支,具体取决于所使用的 VCS。VCSs 提供,包括其他事物,细粒度的访问控制和作者归属,直至单个提交。
最现代和最广泛使用的开源 VCS 之一是 Git。最初作为 Linux 内核的 VCS 而创建,Git 提供了一系列功能,但最重要的是,它提供了一个灵活的机制,允许快速且可靠地在不同版本和功能分支之间切换,并促进了代码中冲突修改的集成。
注意
在描述与版本控制系统(VCS)相关的特定活动时,本书使用了 Git 术语。
提交是版本控制系统中的一个操作,它会导致仓库出现新版本。仓库按照分层结构跟踪提交序列和每个版本中引入的更改:
-
分支:提交的线性序列称为分支。
-
HEAD:分支中的最新版本称为 HEAD。
-
master:Git 将主开发分支称为 master。master 分支是开发的主要焦点。错误修复和较小更改可以直接提交到 master。
-
功能分支:这些分支用于进行独立任务,在持续进行的实验中,最终将被合并到主分支。在不被滥用的情况下,功能分支非常适合在较小的子团队中处理任务,可以简化代码审查过程,允许开发者同时在不同的分支上工作,并将完成的任务的验证集中为单个 合并 请求。
合并操作是指将两个不同分支上的两个版本合并在一起,这两个分支在开发过程中可能已经分叉,并在代码中存在冲突。一些合并是微不足道的,可以由版本控制系统自动解决,而其他合并可能需要手动修复。
使用有意义的详细提交信息可以提高仓库历史的可读性,并有助于跟踪后续的回归。标签可以用于跟踪已发布和分发的中间版本。
跟踪活动
使用 ITS 可以简化跟踪活动和任务。一些工具可以直接链接到版本控制系统,以便将任务链接到仓库中的特定提交,反之亦然。这通常是一个好主意,因为可以很好地了解为了完成特定任务而进行的更改。
首先,将规范分解为简短的活动有助于开发方法。理想情况下,任务尽可能小,可以按类别分组。随后,可以根据中间目标和考虑最终硬件的可用性来设置优先级。创建的任务应分组到中间里程碑中,一些工具将其称为蓝图,这样就可以根据单个任务所取得的进展来衡量向中间交付成果的整体进度。
ITS 可以用于跟踪项目中的实际问题。错误报告应该足够详细,以便其他开发者能够理解症状并重现行为,从而证明代码中存在缺陷。理想情况下,最终用户和早期采用者应该能够向跟踪系统添加新问题,以便跟踪系统可以用于跟踪与开发团队的全部沟通。基于社区的开放源代码项目应向用户提供公开可访问的 ITS 接口。
修复错误的活动通常比开发任务具有更高的优先级,除非在少数情况下,例如,当错误是中间原型临时近似的结果,预计将在下一次迭代中修复。当一个错误影响了之前证明可以正常工作的系统行为时,它必须被标记为回归。这很重要,因为回归通常可以与普通错误不同处理,因为可以使用版本控制工具将它们追溯到单个提交。
仓库控制平台提供多种工具,包括源代码历史浏览和之前描述的问题跟踪功能。GitLab 是此类仓库控制平台的免费开源实现,可以安装并作为自托管解决方案运行。社区项目通常托管在社交编码平台,如 GitHub,这些平台旨在促进对开源和免费软件项目的贡献。
代码审查
通常集成到 ITS 工具中,代码审查通过鼓励对代码库中提出的更改进行批判性分析来促进团队合作,这有助于在提议的更改进入主分支之前检测潜在问题。根据项目要求,代码审查可能被推荐,甚至由团队强制执行,以提高代码质量并通过人工检查早期发现缺陷。
当与版本控制系统(VCS)正确集成时,可以在提交被认为可以合并之前,设置来自团队成员的强制正面审查的阈值。可以使用与 VCS 集成的工具,如Gerrit,强制要求对主分支上的每个提交进行审查。根据贡献的大小,这种机制可能会引入一些不必要的开销,因此,在大多数情况下,将分支引入主分支时,将分支引入主分支引入的更改分组在一起可能更合适,以方便审查。基于合并请求的机制使审查者可以概述整个修改开发过程中引入的更改。在接受外部贡献的开源项目中,代码审查是验证来自不太受信任的贡献者或通常来自维护者团队外部的更改的必要步骤。代码审查是防止可能被伪装且无法通过自动测试和代码分析工具检测到的恶意代码的最强大工具。
持续集成
如前所述,在嵌入式环境中,测试驱动的方法至关重要。在开发过程中自动化测试是及时检测回归和缺陷的最佳方式。使用自动化服务器,例如Jenkins,可以计划执行多个动作,或称作业,以响应式(例如每次提交时)、定期(例如每周二凌晨 1 点)或手动(根据用户请求)执行。以下是一些可以自动化的作业示例,以提高嵌入式项目的效率:
-
开发机器上的单元测试
-
系统验证测试
-
模拟环境中的功能测试
-
物理目标平台上的功能测试
-
稳定性测试
-
静态代码分析
-
生成文档
-
标签、版本控制和打包
必须在设计阶段决定所需的质量水平,并据此编写测试用例。可以使用gcov在每次测试执行后测量单元测试代码覆盖率。一些针对生命关键应用的项目可能需要单元测试有非常高的覆盖率,但为复杂系统编写完整的测试集会对总编程工作产生重大影响,并可能显著增加开发成本,因此,在大多数情况下,研究效率和质量的正确平衡是可取的。
对于功能测试,需要采取不同的方法。在目标上实现的所有功能都应该进行测试,并且应该使用预先准备好的测试来定义性能指标和验收阈值。在无法在目标系统和其周围环境中重新创建完整用例的所有情况下,功能测试应该在尽可能接近真实使用场景的环境中运行。
源代码组织
代码库应包含构建最终映像所需的所有源代码、第三方库、数据、脚本和自动化。将自包含库保存在单独的目录中是一个好主意,这样它们就可以通过替换子目录轻松更新到新版本。Makefiles 和其他脚本可以放置在项目的根目录中。
应用程序代码应简短、综合,并访问抽象宏观功能的模块。功能模块应描述一个过程,同时隐藏底层实现的细节,例如在适当采样和处理后从传感器读取数据。追求小型、自包含且充分抽象的模块也使得架构的组件更容易进行测试。将应用程序组件的大多数逻辑与其硬件特定实现分离,提高了跨不同平台的可移植性,并允许我们在开发阶段更改目标上的外设和接口。然而,过度抽象会影响成本,包括开发努力和所需资源,因此应研究正确的平衡点。
硬件抽象
通用原型平台由硅制造商构建和分发,用于评估微控制器和外设,因此软件开发的部分工作可能经常在这些设备上进行,甚至在最终产品的设计开始之前。
可在评估板上运行的软件通常以源代码或专有预编译库的形式作为参考实现分发。这些库可以根据最终目标进行配置和调整,从开始就用作参考硬件抽象,并更新其设置以匹配硬件配置的变化。
在我们的参考目标上,对通用 Cortex-M 微控制器的硬件组件支持以Cortex Microcontroller Software Interface Standard(CMSIS)库的形式提供,由 ARM 作为参考实现分发。硅制造商通过扩展 CMSIS 来获取其特定的硬件抽象。与特定硬件抽象链接的应用程序可以通过其特定的 API 调用访问外设,并通过 CMSIS 访问核心 MCU 功能。
要使代码在不同系列的 MCU 之间可移植,驱动程序可能需要在供应商特定 API 调用之上提供额外的抽象级别。如果 HAL 实现多个目标,它可以提供相同的 API 来访问多个平台上的通用功能,在幕后隐藏硬件特定实现。
CMSIS 和其他免费软件替代品,如libopencm3和unicore-mx的目标是将所有通用的 Cortex-M 抽象和最常见的 Cortex-M 硅制造商的特定代码分组,同时在控制系统和外围设备时掩盖平台特定调用之间的差异。
不论是硬件抽象,还是在引导的最早阶段所需的某些代码都非常特定于软件打算运行的每个目标。每个平台都有自己的特定地址空间分段、中断向量以及配置寄存器偏移。这意味着,在编写旨在在不同平台之间通用的代码时,自动化构建的 makefile 和脚本必须可配置,以便使用正确的启动代码和链接器配置进行链接。
本书中的示例不依赖于任何特定的硬件抽象,因为它们旨在通过直接与系统寄存器交互来控制系统组件,同时专注于与硬件组件的交互,并实现平台特定的设备驱动程序。
中间件
一些功能可能已经有一个已知的解决方案,该解决方案之前由单个开发者、社区或企业实现。解决方案可能是通用的,也许是为不同的平台设计的,甚至可能来自嵌入式世界之外。
在任何情况下,寻找任何可能已经编码并等待集成到我们项目中的数据转换库、协议实现或子系统模型总是值得的。
几个开源库和软件组件已经准备好被包含到嵌入式项目中,使我们能够实现更广泛的功能集。从开源项目中集成组件对于提供标准功能特别有用。有大量经过验证的开源实现,专为嵌入式设备设计,可以轻松集成到嵌入式项目中,以下是一些示例:
-
实时操作系统
-
密码学库
-
TCP/IP、6LoWPAN 和其他网络协议
-
传输层安全性(TLS)库
-
文件系统
-
物联网消息队列协议
-
解析器
本书后面将更详细地描述这些类别中的一些组件。
在软件基础上使用操作系统允许我们管理内存区域和线程执行。在这种情况下,线程独立于彼此执行,甚至可以在线程之间以及运行中的线程和内核之间实现内存分离。当设计复杂性增加或模块中存在无法重新设计的已知阻塞点时,这种方法是可取的。如果使用操作系统,其他库通常需要多线程支持,这可以在编译时启用。
集成第三方库的决定必须通过测量在目标平台上执行特定任务所需的资源(以代码大小和使用的内存来衡量)来评估。由于整个固件作为单个可执行文件分发,所有组件的许可证必须兼容,并且集成不得违反任何单个组件的许可证条款。
应用代码
应用代码的作用是从项目设计的最高层协调所有涉及的模块,并编排系统的启发式策略。一个设计良好的干净主模块使我们能够清晰地看到系统的所有宏观模块,它们之间的关系以及各个组件的执行时间。
裸机应用程序围绕一个主无限循环函数构建,该函数负责在底层库和驱动程序的入口点之间分配 CPU 时间。执行是顺序发生的,因此代码只能由中断处理程序挂起。因此,从主循环中调用的所有函数和库调用都应该尽可能快地返回,因为隐藏在其他模块中的停滞点可能会损害系统的反应性,甚至永远阻塞它们,从而永远无法返回主循环。理想情况下,在裸机系统中,每个组件都设计为使用事件驱动范式与主循环交互,主循环不断等待事件和机制注册回调,以在特定事件上唤醒应用程序。
裸机、单线程方法的优点是线程之间不需要同步,所有内存都可以被代码中的任何函数访问,并且不需要实现复杂机制,如上下文和执行模型切换。然而,当中断发生且执行流程在任何时刻都可能被外部事件中断以执行特定处理程序时,可能仍然需要一些基本的同步机制。
如果多个任务需要在操作系统上运行,每个任务应尽可能限制在其自己的模块内,并明确导出其启动函数和公共变量作为全局符号。在这种情况下,任务可以休眠并调用阻塞函数,这些函数应实现特定于操作系统的阻塞机制。
由于 Cortex-M CPU 的灵活性,系统上可以激活不同级别的线程和进程分离。
CPU 提供了多个工具来促进具有任务分离、多种执行模式、内核特定寄存器、特权分离和内存分段技术的多线程系统开发。这些选项允许架构师定义更复杂、更倾向于通用应用的系统,这些系统在进程之间提供特权分离和内存分段,但也允许定义更小、更简单、更直接的系统,这些系统不需要这些功能,因为它们通常是为单一目的设计的。
选择基于非特权线程的执行模型会导致系统上下文变化实现变得更加复杂,并可能影响实时操作的延迟,这就是为什么裸机、单线程解决方案对于大多数实时应用仍然更受欢迎。
安全考虑
在设计新系统时考虑的最重要方面之一是安全性。根据系统的特性、要求和风险评估,可能需要不同的对策。增强安全性的功能通常是硬件和软件努力的结合,以提供针对已知攻击的特定保护。
漏洞管理
软件组件会随着新功能的引入和缺陷的修复而不断进化。在后续版本中发现的某些缺陷,如果没有及时采取适当行动,可能会影响运行过时软件的系统的安全性。一旦第三方组件中的漏洞完全向公众披露,继续运行过时代码就不再是一个好的选择。
在公共网络上运行的已知缺陷的旧版本有更大的可能性成为系统受损、软件执行控制或重要数据被盗攻击的攻击面。最好的应对策略是在系统设计初期就准备,包括使用适合特定用例、安全要求和安全级别的程序来规划远程更新。
当使用第三方库时,跟踪其最新版本的开发并充分理解已修复缺陷的影响是合适的,尤其是当这些缺陷被标记为安全问题的时候。
软件加密
加密算法在适当的时候应该被使用,例如,用于加密存储在本地或在两个系统之间传输的数据,验证网络上的远程参与者,或验证数据未被篡改且来自可信源。
良好的密码学始终基于开放、透明的标准,因此系统的安全性完全取决于密钥的安全性,这是荷兰密码学家奥古斯特·凯克霍夫在 19 世纪提出的凯克霍夫原则,而不是依赖于秘密机制,寄希望于其实现永远不会被披露或逆向工程。(尽管对于对信息安全概念有一定信心的人来说,这个最后声明应该是显而易见的,但在过去,许多嵌入式系统都采用了隐蔽性安全,这是一种在缺乏适当资源运行成熟的密码学原语的老式硬件架构上走捷径的坏习惯。)
现在,嵌入式密码库存在,能够在基于微控制器的系统中运行与 PC 和服务器上使用的相同最新标准算法,同时它们也在变得更加强大,适合运行(通常是 CPU 密集型)的密码学数学原语。一个完整的密码库通常提供三种算法家族的现成实现:
-
非对称密码学(RSA,ECC)基于一对密钥,私钥和公钥,它们相互关联。除了单向加密外,这些算法还提供其他机制,例如验证签名和从两个密钥对中派生二级密钥,例如,用作通过不受信任的介质通信的两个端点之间的共享秘密。
-
对称密码学(AES,ChaCha20)主要用于双向加密,在两个方向上使用相同的预共享秘密密钥。
-
哈希算法(SHA)提供了一种注入式摘要计算,通常用于验证数据是否被更改。
wolfCrypt 提供了针对嵌入式系统优化的算法完整集,它是作为 wolfSSL 的一部分分发的密码引擎,wolfSSL 是一个由专业人士维护的开源库,它还包括传输层安全性协议,这些将在第九章中进一步解释,分布式系统和 物联网架构。
硬件密码学
在设计过程的初期就考虑安全性方面非常重要,以便提前确定实现正确机制所需的软件和硬件组件。仅仅添加一个密码库并不能保证系统安全性的提高,除非所有要求都得到满足,这通常意味着需要特定硬件组件的参与。
一些算法需要具有高熵的随机值,在没有特定硬件帮助的情况下,在微控制器上通常很难获得,例如真随机数生成器(TRNGs)。
其他基于公钥的加密需要信任锚存储,这意味着一个在运行时不能被攻击者修改的内存位置,通常依赖于可能存在于闪存控制器上的某些非易失性内存特性。最后,为了存储密钥,可能需要硬件辅助来提供一个只能由特权代码访问的安全保险库,在某些情况下,它从软件中永远无法访问,并且仅允许与安全存储耦合的硬件加密引擎一起使用。
运行不受信任的代码
随着嵌入式系统的复杂性和代码内存的增加,看到来自多个来源的软件组件集成到一个单一的固件映像中并不罕见。一些系统甚至提供软件开发套件,可以运行用户提供的自定义代码。
其他可能有一个接口允许您从远程位置执行代码。在这些所有情况下,考虑分离机制以防止意外(或故意)访问那些不应被低能力演员访问的内存区域或外围设备是合适的。
大多数微控制器提供两个执行权限级别,在某些平台上,可以通过在操作系统中的上下文切换来根据这些权限划分可寻址的内存空间。新一代微控制器提供基于当前阶段执行级别的内存边界严格强制执行的 TEE(信任执行环境)。
嵌入式项目的生命周期
现代开发框架建议将工作分解成更小的动作点,并在项目开发过程中通过产生中间工作交付成果来标记里程碑。每个交付成果都专注于提供一个整个系统的原型,缺失的功能暂时使用占位代码来替代。
这些推荐对于嵌入式项目似乎特别有效。在一个每个错误都可能使整个系统陷入致命状态的环境中,一次只处理一个小动作点,是一种高效的方法,可以在代码库中及时识别缺陷和回归,前提是在开发的早期阶段就建立了持续集成(CI)机制。中间里程碑应尽可能频繁,因此,在开发阶段尽快创建最终系统的原型是明智的。在识别、优先排序和分配行动给团队时,必须考虑到这一点。
一旦定义了达到目标所需的步骤,我们需要找到产生中间里程碑的工作原型的最佳顺序。在分配工作之前,考虑到开发动作之间的依赖关系,对工作优先级进行排序。
对系统行为和硬件约束的逐步理解可能会在开发过程中改变对系统架构的看法,因为会遇到意外问题。对中间原型进行的测量和评估作为反应而更改规范可能需要大量代码重构。丢弃项目中的连贯部分并用新的、改进的设计替换通常有利于项目的质量,并可能在后期阶段提高生产力。这个过程,称为重构,不应被视为开发开销,只要它是旨在改进系统设计和行为。
最后,创建系统软件的过程包括为应用程序定义一个清晰的 API,以便以期望的方式与系统交互。嵌入式系统通常提供特定的 API 来访问系统资源;然而,某些操作系统和库可能提供 POSIX-like 接口以访问功能。在任何情况下,API 都是系统接口的入口点,必须设计得易于使用并且有良好的文档记录。
定义项目步骤
在分析规范、定义所需步骤和分配优先级时,可能需要考虑几个因素。考虑设计一个带有 PM10 空气质量串行传感器的空气质量监测设备,该设备每小时收集测量数据到内部闪存,然后使用无线收发器每天将所有统计数据发送到网关。目标系统是基于 Cortex-M MCU 的定制板,其尺寸足够运行最终软件。最终硬件设计将在对发送数据到网关的收发器进行一些实际测量后才能获得。
实现这些规范最终目标所需的步骤列表可能如下所示:
-
在目标设备上启动最小系统(空主循环)。
-
设置串行端口
0以进行日志记录。 -
设置串行端口
1以与传感器通信。 -
设置一个定时器。
-
编写 PM10 传感器驱动程序。
-
创建一个每小时唤醒并从传感器读取的应用程序。
-
编写一个闪存子模块以存储/恢复测量数据。
-
设置 SPI 端口以与无线电芯片通信。
-
编写无线电驱动程序。
-
实现一个与网关通信的协议。
-
每 24 次测量后,应用程序将每日测量数据发送到网关。
注意
一些步骤可能依赖于其他步骤,因此存在执行顺序的约束。通过使用模拟器或仿真器,可以消除一些这些依赖关系。
例如,我们可能希望在只有一种方法可以通过网关上的模拟无线电信道测试协议与网关上运行的代理进行测试的情况下,才实现通信协议,而不需要有一个工作的无线电。保持模块自包含,并且对外仅暴露最小 API 调用集,使得将单个模块分离出来在不同的架构和受控环境中运行和测试变得更加容易,然后再将其集成到目标系统中。
原型设计
由于它是规格的一部分,我们知道我们应该优先处理与无线电通信相关的活动,以便硬件团队能够在设计上取得进展,因此在这种情况下,第一个原型必须执行以下操作:
-
在目标设备上启动最小系统(空主循环)。
-
设置串行端口
0进行日志记录。 -
设置 SPI 端口以与无线电芯片通信。
-
编写无线电驱动程序。
-
设置定时器。
-
编写主应用程序以测试无线电信道(定期发送原始数据包)。
这个第一个原型已经会开始看起来像最终设备,即使它还不知道如何与传感器通信。一些测试用例可以已经实现,在模拟网关上运行,以检查消息是否被接收且有效。
在进行下一个原型定义时,我们可以开始添加一些额外的功能。在与网关进行协议进展时,并不需要真实的传感器读数,因为可以使用虚构的、合成的测试值来重现特定的行为。这使我们能够在真实硬件不可用的情况下,继续进行其他任务。
不论是开发团队采用纯敏捷软件开发方法还是使用不同的方法,在嵌入式开发环境中快速原型设计允许更快地应对路径上的不确定性,这些不确定性通常取决于硬件的行为和软件中需要采取的操作。
在嵌入式开发团队中,提供可行的中间交付成果是一种常见做法,这直接源于敏捷方法。敏捷软件开发预计在短时间内定期交付可工作的软件。就像先前的例子一样,中间原型不必实现最终软件图像的所有逻辑,而是必须用于证明概念、进行测量或在系统的较小部分上提供示例。
重构
重构通常被认为是对失败的激进补救措施,但实际上它是一种健康的实践,在系统最终成形和软件组件及外围设备支持随时间演变的同时,可以改进软件。
如果所有测试都在旧代码上运行,重构工作会更好。在重新设计模块内部结构的同时,单元测试应该适应新的函数签名。另一方面,如果模块的 API 保持不变,正在重构的模块的现有功能测试不应改变,并且只要与其他模块的接口保持相同,它将提供关于过程状态和准确性的持续反馈。
相比于较大的代码库,较小的代码库更容易进行重构,这又给我们一个理由保持每个模块较小且专注于系统上的特定功能。通过中间交付的原型进行进展意味着对应用程序代码的不断修改,当子系统被设计为相互独立以及与应用程序代码本身独立时,这应该需要更少的努力。
API 和文档
我们都知道一本书不应该仅凭封面来判断。然而,一个系统通常可以通过其 API 来判断,这可能揭示系统内部实现和系统架构师的设计选择。一个清晰、易读且易于理解的 API 是嵌入式系统最重要的特性之一。应用开发者期望能够快速理解如何访问功能,并以最有效的方式使用系统。API 代表了系统与应用程序之间的合同,因此,它必须在开发之前设计,并且在最终交付过程中尽可能少地修改,如果需要修改的话。
API 中的一些接口可能描述了复杂的子系统并抽象出更详细的特点,因此始终提供足够的文档以帮助应用开发者熟悉并利用所有系统功能是一个好主意。提供文档的方式有很多,可以是将用户手册作为单独的文件分发到仓库中,或者直接在代码中包含不同接口的解释。
代码中的注释数量并不是质量的指标。每当代码被修改时,注释往往会过时,因为开发者可能会忘记更新注释以匹配代码中的新行为。此外,并非所有代码都需要注释;良好的习惯,如保持函数简短且复杂度低或使用表达性符号名称,在大多数情况下会使代码注释变得多余,因为代码可以自我解释。
对于包含复杂计算、位移动、详细条件或初次阅读代码时不易察觉的副作用等代码行,存在例外。某些代码部分可能还需要在开头进行描述,例如,具有多个返回值和特定错误处理的函数。在两个案例之间不包含 break 指令的 switch/case 语句必须始终有注释来表明这是有意为之,而不是错误。
他们还可能需要解释为什么某些操作被分组在两个或多个案例之间。添加没有提供任何有价值代码解释的冗余注释只会使代码更难以阅读。
另一方面,使用单独的编辑器和工具来描述模块的行为需要投入精力,因为每次代码发生重大变化时,所有文档都必须更新,并且开发者被要求将注意力从实际代码上转移开。
通常,需要记录的重要部分是之前提到的合同的描述,列举并解释应用程序和其他相关组件在运行时可以访问的函数和变量。由于这些声明可以包含在头文件中,因此可以通过在每个导出符号的声明上方添加扩展注释来描述整个合同。
存在将注释转换为格式化文档的软件工具。一个流行的例子是Doxygen,这是一个免费的开源文档生成工具,它解析整个代码库中匹配特定语法的注释,以生成超文本、结构化 PDF 手册和其他多种格式。如果文档在代码库中,更新和跟踪其结果对开发者的工作流程来说更容易且侵入性更小。在自动化服务器上集成文档生成可以提供在主分支每次提交时所有 API 的全新生成的手册副本。
摘要
提出的方法论旨在作为参考模式的一个示例,用于设计和管理嵌入式项目的发展。虽然可能有些描述的模式并不适用于所有项目,但本章的目标是鼓励嵌入式架构师寻找可能使软件生命周期更高效、成本更低的流程改进。最后,我们分析了在需要时通过添加适当的过程和组件来提高安全性的可能性。
在下一章中,我们将分析嵌入式系统启动时发生的事情,以及如何使用简单、裸机、主循环方法准备可启动的应用程序。
第四章:启动程序
现在机制、工具和方法都已经就绪,是时候开始关注在目标上运行软件所需的程序了。启动嵌入式系统是一个通常需要了解特定系统和所涉及的机制的过程。根据目标的不同,我们需要在手册中查找一些指示,以了解系统对开发者的期望,以便成功从闪存中启动可执行文件。本章将专注于启动过程的描述,特别强调我们决定用作参考平台的 Cortex-M 微控制器。特别是,我们将涵盖以下主题:
-
中断向量表
-
内存布局
-
构建和运行启动代码
-
多个启动阶段
到本章结束时,您将了解主循环嵌入式开发的整体情况。
技术要求
您可以在 GitHub 上找到本章的代码文件,地址为 github.com/PacktPublishing/Embedded-Systems-Architecture-Second-Edition/tree/main/Chapter4。
中断向量表
中断向量表,通常缩写为 IVT 或简单地 IV,是一组与 CPU 关联的函数指针,用于处理特定的 异常,例如故障、来自应用程序的系统服务请求和来自外设的中断请求。IVT 通常位于二进制图像的开头,因此从闪存的最低地址开始存储。
来自硬件组件或外设的中断请求将迫使 CPU 突然暂停执行并执行向量中相关位置的功能。因此,这些函数被称为 中断服务例程(或简称 ISRs)。运行时异常和故障可以像处理硬件中断一样处理,因此通过相同的表关联了特殊的服务例程和内部 CPU 触发器。
在向量中枚举的 ISRs 的顺序及其确切位置取决于 CPU 架构、微控制器型号和支持的外设。每条中断线对应一个预定义的中断号,并且根据微控制器的特性,可能被分配一个优先级。
在 Cortex-M 微控制器中,内存的前 16 位位置被保留用于存储系统处理器的指针,这些指针与架构相关,并关联到不同类型的 CPU 运行时异常。最低地址用于存储栈指针的初始值,接下来的 15 个位置被保留用于系统服务和故障处理器。然而,其中一些位置被保留但没有连接到任何事件。在 Cortex-M CPU 中可以使用单独的服务例程处理的系统异常如下:
-
复位
-
不可屏蔽 中断 (NMI)
-
硬件故障
-
内存异常
-
总线故障
-
使用故障
-
监督调用
-
调试监视器事件
-
PendSV 调用
-
系统滴答
硬件中断的顺序,从位置 16 开始,取决于微控制器配置,因此取决于特定的硅模型,因为中断配置涉及特定的组件、接口和外部外围设备活动。
在本书的代码仓库中可以找到 STM32F407 和 LM3S 目标的外部中断处理程序的全量向量。
启动代码
为了启动一个可工作的系统,我们需要定义中断向量和将指针与定义的函数关联起来。我们参考平台的典型启动代码文件使用 GCC 的section属性将中断向量放置在专用部分。由于该部分将被放置在映像的起始位置,我们必须从为初始堆栈指针保留的空间开始定义我们的中断向量,然后是系统异常处理程序。
零对应于保留/未使用插槽的位置:
__attribute__ ((section(".isr_vector")))
void (* const IV[])(void) =
{
(void (*)(void))(END_STACK),
isr_reset,
isr_nmi,
isr_hard_fault,
isr_mem_fault,
isr_bus_fault,
isr_usage_fault,
0, 0, 0, 0,
isr_svc,
isr_dbgmon,
0,
isr_pendsv,
isr_systick,
从这个位置开始,我们定义外部外围设备的中断线如下:
isr_uart0,
isr_ethernet,
/* … many more external interrupts follow */
};
启动代码还必须包括数组中引用的每个符号的实现。处理程序可以定义为无参数的void过程,其格式与 IV 签名相同。
void isr_bus_fault(void) {
/* Bus error. Panic! */
while(1);
}
由于不可恢复的总线错误,此示例中的中断处理程序永远不会返回,并使系统永远挂起。可以使用弱符号将空的中断处理程序与系统和外部中断关联起来,这些弱符号可以在设备驱动程序模块中通过在相关代码部分重新定义它们来覆盖。
重置处理程序
当微控制器上电时,它从reset处理程序开始执行。这是一个特殊的 ISR,它不会返回,而是执行.data和.bss段的初始化,然后调用应用程序的入口点。.data和.bss段的初始化包括将.data段中变量的初始值从闪存复制到运行时访问变量的实际 RAM 段,并在 RAM 中的.bss段用零填充,以确保静态符号的初始值按照 C 语言约定为零。
.data和.bss段在 RAM 中的源地址和目标地址由链接器在生成二进制映像时计算,并通过链接脚本导出为指针。isr_reset的实现可能看起来像以下这样:
void isr_reset(void)
{
unsigned int *src, *dst;
src = (unsigned int *) &_stored_data;
dst = (unsigned int *) &_start_data;
while (dst != (unsigned int *)&_end_data) {
*dst = *src;
dst++;
src++;
}
dst = &_start_bss;
while (dst != (unsigned int *)&_end_bss) {
*dst = 0;
dst++;
}
main();
}
当.bss和.data段中的变量被初始化后,最终可以调用main函数,这是应用程序的入口点。应用程序代码通过实现一个无限循环来确保main永远不会返回。
分配堆栈
为了符合 CPU 的应用程序二进制接口(ABI),需要在内存中为执行栈分配空间。这可以通过不同的方式完成,但通常,在链接脚本中标记栈空间的末尾并将其关联到 RAM 中未使用的特定区域更为可取,而不是在某个部分中使用。
通过链接脚本导出的END_STACK符号所获得的地址指向 RAM 中未使用区域的末尾。如前所述,其值必须存储在向量表的开头,在我们的例子中是在 IV 之前,地址为0。栈末尾的地址必须是常量,不能在运行时计算,因为 IV 内容存储在闪存中,因此以后不能修改。
在内存中正确设置执行栈的大小是一项微妙的工作,它包括评估整个代码库,同时考虑到局部变量和执行过程中任何时刻的调用跟踪深度。与栈使用相关所有因素的分析和故障排除将作为下一章中更广泛主题的一部分进行讨论。这里提供的简单启动代码具有足够的栈大小,足以容纳局部变量和函数调用栈,因为它由链接脚本尽可能远地映射到.bss和.data部分。关于栈放置的进一步方面将在第五章 内存管理中进行考虑。
故障处理程序
当发生执行错误或策略违规时,CPU 会触发与故障相关的事件。CPU 能够检测到许多运行时错误,例如以下内容:
-
尝试在标记为可执行的内存区域之外执行代码
-
从无效位置获取数据或执行的下一条指令
-
使用未对齐地址进行非法加载或存储
-
零除
-
尝试访问不可用的协处理器功能
-
尝试在当前运行模式下允许的内存区域之外进行读写/执行
一些核心微控制器根据错误类型支持不同类型的异常。Cortex-M3/M4 可以根据总线错误、使用故障、内存访问违规和通用故障进行区分,触发相关异常。在其他较小的系统中,关于运行时错误类型的详细信息较少。
很常见,故障会使系统无法使用或无法继续执行,因为 CPU 寄存器值或栈被破坏。在某些情况下,即使在异常处理程序中放置断点也不足以检测问题的原因,这使得调试更加困难。一些 CPU 支持关于故障原因的扩展信息,这些信息在异常发生后可以通过内存映射寄存器获得。在 Cortex-M3/M4 的情况下,这些信息通过所有 Cortex-M3/M4 CPU 上的0xE000ED28获得。
如果相应的异常处理程序实现了某种恢复策略,内存违规可能不会导致严重后果,并且可以在运行时检测和响应故障,这在多线程环境中特别有用,我们将在第九章“分布式系统和物联网架构”中更详细地看到。
内存布局
如我们所知,链接脚本包含了链接器如何组装嵌入式系统组件的指令。更具体地说,它描述了映射到内存中的部分以及它们如何部署到目标机的闪存和 RAM 中,如第二章“工作环境和工作流程优化”中提供的示例所示。
在大多数嵌入式设备中,尤其是在我们的参考平台上,链接脚本中的.text输出部分,其中包含所有可执行代码,还应包括专门用于在可执行图像开头存储 IV 的特殊输入部分。
我们通过在.text输出部分的开头添加.isr_vector部分来集成链接脚本,在其余代码之前:
.text :
{
*(.isr_vector)
*(.text*)
*(.rodata*)
} > FLASH
在闪存中定义一个只读区域,该区域专门用于向量表,是我们系统正确启动的唯一严格要求,因为 CPU 在启动时从内存中的0x04地址检索isr_reset函数的地址。
在定义闪存中的文本和只读区域之后,链接脚本应导出当前地址的值,即存储在闪存中的.data输出部分的开始。该部分包含在代码中初始化的所有全局和静态变量的初始值。在示例链接脚本中,.data部分的开始由_stored_data链接脚本变量标记,如下所示:
_stored_data = .;
数据部分最终将在 RAM 中映射,但其初始化是通过isr_reset函数手动完成的,该函数通过从闪存复制内容到 RAM 中实际指定的.data部分区域。链接脚本提供了一个机制来分离定义部分中的AT关键字。如果没有指定AT关键字,则默认情况下,LMA 设置为与 VMA 相同的地址。在我们的例子中,.data输入部分的 VMA 位于 RAM 中,并通过_start_data指针导出,该指针将被isr_vector用作复制从闪存存储的符号值的目标地址。然而,.data的 LMA 位于闪存中,因此我们将 LMA 地址设置为闪存中的_stored_data指针,同时定义.data输出部分:
.data : AT (_stored_data)
{
_start_data = .;
*(.data*)
. = ALIGN(4);
_end_data = .;
} > RAM
对于.bss,没有 LMA,因为该部分在图像中不存储任何数据。当包含.bss输出部分时,其 VMA 将自动设置为.data输出部分的末尾:
.bss :
{
_start_bss = .;
*(.bss*)
. = ALIGN(4);
_end_bss = .;
_end = .;
} > RAM
最后,在这个设计中,我们期望链接器提供执行堆栈的初始值。使用内存中的最高地址是单线程应用程序的一个常见选择,尽管,如下一章所讨论的,这可能会在堆栈溢出的情况下引起问题。然而,对于这个例子来说,这是一个可接受的解决方案,我们通过在链接脚本中添加以下行来定义 END_STACK 符号:
END_STACK = ORIGIN(RAM) + LENGTH(RAM);
为了更好地理解每个符号在内存中的位置,可以在代码的不同位置添加变量定义到启动文件中。这样,我们可以在第一次在调试器中运行可执行文件时检查变量在内存中的存储位置。假设我们在 .data 和 .bss 输出部分中存储了变量,示例启动代码的内存布局可能如下所示:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/B18730_04_01.jpg
图 4.1 – 示例启动代码中的内存布局
当可执行文件链接时,符号在编译时自动设置,以指示内存中每个部分的开始和结束。在我们的例子中,指示每个部分开始和结束的变量会根据链接器在创建可执行文件时包含的各部分的大小自动分配正确的值。由于每个部分的大小在编译时是已知的,链接器能够识别出 .text 和 .data 部分无法适应闪存的情况,并在构建结束时生成链接器错误。创建映射文件对于检查每个符号的大小和位置很有用。在我们的引导示例代码中,映射文件中 .text 部分如下所示:
.text 0x0000000000000000 0x168
0x0000000000000000 _start_text = .
*(.isr_vector)
.isr_vector 0x0000000000000000 0xf0 startup.o
0x0000000000000000 IV
*(.text*)
.text 0x00000000000000f0 0x78 startup.o
0x00000000000000f0 isr_reset
0x0000000000000134 isr_fault
0x000000000000013a isr_empty
0x0000000000000146 main
同样,我们可以在编译时通过链接脚本找到每个部分的边界:
0x0000000000000000 _start_text = .
0x0000000000000168 _end_text = .
0x0000000020000000 _start_data = .
0x0000000020000004 _end_data = .
0x0000000020000004 _start_bss = .
0x0000000020000328 _end_bss = .
0x0000000020000328 _end = .
.rodata 输入部分,在这个极简示例中为空,映射在闪存区域中,位于 .text 和数据 LMA 之间。这部分是为常量符号预留的,因为常量不需要映射到 RAM 中。在定义常量符号时强制使用 const C 修饰符是明智的,因为 RAM 经常是我们最宝贵的资源,在某些情况下,通过将常量符号移动到闪存中,即使节省几个字节的可写内存也可能对项目开发产生影响,因为闪存通常要大得多,其使用情况在链接时可以很容易地确定。
构建和运行引导代码
这里提供的示例是可以在目标设备上运行的简单可执行镜像之一。为了汇编、编译和链接所有内容,我们可以使用一个简单的 makefile,它自动化所有步骤,并允许我们专注于我们的软件生命周期。
当镜像准备就绪时,我们可以将其传输到实际目标设备,或者使用仿真器运行它。
Makefile
一个非常基本的 Makefile 用于构建我们的启动应用程序,描述了最终目标(image.bin)以及构建它所需的中间步骤。一般来说,Makefile 语法非常广泛,涵盖本书范围之外的所有 Make 提供的功能。然而,这里解释的几个概念应该足以让你开始自动化构建过程。
在这个例子中,定义我们的 Makefile 的目标相当简单。包含 IV、一些异常处理程序以及我们在示例中使用的 main 和全局变量的 startup.c 源文件可以被编译和汇编成一个 startup.o 对象文件。链接器使用 target.ld 链接器脚本中提供的指示来部署符号到正确的部分,生成 .elf 可执行映像。
最后,使用 objcopy 将 .elf 可执行文件转换为二进制映像,该映像可以传输到目标或使用 QEMU 运行:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/B18730_04_02.jpg
图 4.2 – 构建步骤和依赖关系
Makefile 应该包含一些配置变量来描述工具链。= 赋值运算符允许你在调用 make 命令时为变量设置值。其中一些变量在编译和链接过程中默认使用。通常使用 CROSS_COMPILE 变量来定义工具链前缀,并将其用作构建过程中涉及的工具的前缀:
CROSS_COMPILE=arm-none-eabi-
CC=$(CROSS_COMPILE)gcc
LD=$(CROSS_COMPILE)ld
OBJCOPY=$(CROSS_COMPILE)objcopy
通过运行 make 并将不同的值分配给 CROSS_COMPILE 环境变量来更改此项目的默认交叉编译器。所有工具的名称都以前缀 CROSS_COMPILE 扩展,以便构建步骤将使用给定工具链的组件。同样,我们可以定义编译器和链接器的默认标志:
CFLAGS=-mcpu=cortex-m3 -mthumb -g -ggdb -Wall -Wno-main
LDFLAGS=-T target.ld -gc-sections -nostdlib -Map=image.map
当不带参数调用时,Make 会构建 image.bin Makefile 中定义的第一个目标。可以为 image.bin 定义一个新的目标,如下所示:
image.bin: image.elf
$(OBJCOPY) -O binary $^ $@
$@ 和 $^ 变量将在配方中分别替换为目标和依赖项列表。这意味着在示例中,Makefile 将按照以下方式处理配方:
arm-none-eabi-objcopy -O binary image.elf image.bin
这是我们需要从 .elf 可执行文件生成原始二进制映像的命令。
同样,我们可以定义 image.elf 的配方,这是链接步骤,依赖于编译的 startup.o 对象文件和链接器脚本:
image.elf: startup.o target.ld
$(LD) $(LDFLAGS) startup.o -o $@
在这种情况下,我们不会使用 $^ 变量来表示依赖项列表,因为配方在链接命令行中使用 LDFLAGS 包含了链接器脚本。链接步骤的配方将由 main 如下展开:
arm-none-eabi-ld -T target.ld -gc-sections -nostdlib -Map=image.map startup.o -o image.elf
使用 -nostdlib 确保不会自动将工具链中可用的默认 C 库链接到项目中,这些库默认情况下会被链接到可执行文件中。这确保了不会自动提取任何符号。
解决依赖关系的最后一步是将源代码编译成目标文件。这是在 makefile 中隐式配方中完成的,最终在项目默认值使用时转换为以下内容:
arm-none-eabi-gcc -c -o startup.o startup.c -mcpu=cortex-m3 -mthumb -g -ggdb -Wall -Wno-main
使用 -mcpu=cortex-m3 标志确保生成的代码与 Cortex-M3 及以后的 Cortex-M 目标兼容。实际上,相同的二进制文件最终可以在任何 Cortex-M3、M4 或 M7 目标上运行,并且它是通用的,直到我们决定使用任何特定的 CPU 特性,或者定义硬件中断处理程序,因为它们的顺序取决于特定的微控制器。
通过定义一个 clean 目标,在任何时候,都可以通过删除中间目标和最终镜像并再次运行 make 来从头开始。clean 目标通常也包含在同一个 makefile 中。在我们的例子中,它看起来如下所示:
clean:
rm -f image.bin image.elf *.o image.map
clean 目标通常没有依赖。运行 make clean 会按照配方中的指示删除所有中间和最终目标,同时保留源文件和链接脚本不变。
运行应用程序
一旦构建了镜像,我们可以在真实目标上运行它,或者使用 qemu-system-arm,如第二章中所述,工作环境和流程优化。由于应用程序在模拟器上运行时不会产生输出,为了更深入地了解软件的实际行为,我们需要将其附加到调试器上。在运行模拟器时,必须使用 -S 选项调用 qemu-system-arm,这意味着停止,这样它就不会在调试器连接之前开始执行。由于前一步骤中的 CFLAGS 变量包含 -g 选项,所有符号名称都将保留在 .elf 可执行文件中,以便调试器可以逐行跟踪代码执行,设置断点并检查变量的值。
逐步遵循程序,并将地址和值与 .map 文件中的进行比较,有助于理解正在发生的事情以及整个引导过程中上下文是如何变化的。
多个引导阶段
通过引导加载程序引导目标在多种情况下很有用。在实际场景中,能够在远程位置的设备上更新正在运行的软件意味着开发人员能够在嵌入式系统第一版部署后修复错误和引入新功能。
这在发现现场错误或软件需要重新设计以适应需求变化时,对维护来说是一个巨大的优势。引导加载程序可以实现自动远程升级和其他有用的功能,例如以下内容:
-
从外部存储加载应用程序镜像
-
在引导前验证应用程序镜像的完整性
-
在应用程序损坏情况下的故障转移机制
可以链式连接多个引导加载程序以执行多阶段引导序列。这允许您为多个引导阶段拥有独立的软件映像,这些映像可以独立上传到闪存。如果存在,第一阶段引导通常非常简单,仅用于简单地选择下一阶段的入口点。然而,在某些情况下,早期阶段可能从稍微复杂的设计中受益,以实现软件升级机制或其他功能。这里提出的示例展示了使用许多 Cortex-M 处理器提供的功能实现的两个引导阶段之间的分离。这个简单的引导加载程序的唯一目的是初始化系统,以便在下一阶段引导应用程序。
引导加载程序
第一阶段引导加载程序作为正常独立应用程序启动。其 IV 必须位于闪存的开始处,reset处理程序初始化相关的.data和.bss内存段,就像在正常单阶段引导中一样。闪存开始处应预留一个分区用于.text和.data引导加载程序段。为此,引导加载程序的链接脚本将仅包括闪存内存的开始部分,而应用程序的链接脚本将具有相同大小的偏移量。
实际上,引导加载程序和应用程序将被构建成两个独立的二进制文件。这样,两个链接脚本可以为段使用相同的名称,而仅在链接内存中FLASH分区的描述上有所不同。尽管如此,下面建议的方法只是可能配置之一:更复杂的设置可能从使用所有分区的起始地址和大小导出完整几何形状中受益。
如果我们想为引导加载程序分区预留 4 KB,我们可以在引导加载程序的链接脚本中硬编码FLASH区域如下:
FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 0x00001000
类似地,应用程序的链接脚本在原点有一个偏移量,硬编码为引导加载程序的大小,这样应用程序的.text输出段始终从0x1000地址开始。从应用程序的角度来看,整个FLASH区域从0x00001000地址开始:
FLASH (rx) : ORIGIN = 0x00001000, LENGTH = 0x0003F000
在这种情况下,闪存的几何形状如下所示:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/B18730_04_03.jpg
图 4.3 – 闪存内容布局,显示引导加载程序和应用程序的段
应用程序中断向量表(IV)中的reset处理程序存储在向量表内的偏移量4处。
应用程序可以强制实施自己的内存布局。在启动时,它将能够根据新的几何形状初始化新的.data和.bss段,甚至可以定义新的初始堆栈指针和 IV。引导加载程序可以通过读取存储在地址0x1000的 IV 中的前两个单词来获取这两个指针:
uint32_t app_end_stack = (*((uint32_t *)(APP_OFFSET)));
void (* app_entry)(void);
app_entry = (void *)(*((uint32_t *)(APP_OFFSET + 4)));
在跳转到应用程序的入口点之前,我们希望将主执行堆栈指针重置为堆栈的末尾地址。由于 MSP 是 ARMv7-M 架构中的一个专用 CPU 寄存器,它只能使用汇编指令从寄存器移动特殊(msr)来写入。以下代码在引导加载程序中内联,以将正确的应用程序堆栈指针设置为存储在应用程序映像开头于闪存的值:
asm volatile("msr msp, %0" ::"r"(app_end_stack));
在 Cortex-M3 和其他更强大的 32 位 Cortex-M CPU 中,系统控制块区域中存在一个控制寄存器,可以在运行时指定向量表的偏移量。这是0xE000ED08。将应用程序偏移量写入此寄存器意味着从那时起,新的 IV 就位了,并且应用程序中定义的中断处理程序将在异常发生时执行:
uint32_t * VTOR = (uint32_t *)0xE000ED08;
*VTOR = (uint32_t *)(APP_OFFSET);
当此机制不可用时,例如在 Cortex-M0 微控制器中,它没有 VTOR,应用程序在启动后仍然会与引导加载程序共享中断向量。为了提供不同的一组中断处理程序,相关的函数指针可以存储在闪存的不同区域,引导加载程序可以在每次中断时检查应用程序是否已启动,如果是的话,则从应用程序空间中的表中调用相应的处理程序。
在处理指向中断处理程序和其他异常例程的指针时,重要的是要考虑在代码运行期间任何时间都可能发生异常,尤其是如果引导加载程序已启用 CPU 中的外围设备或激活了定时器。为了防止不可预测地跳转到中断例程,在更新指针时建议禁用所有中断。
指令集提供了暂时屏蔽所有中断的机制。在全局禁用中断的情况下运行时,执行不能被任何异常中断,除了 NMI。在 Cortex-M 中,可以使用cpsid i汇编语句暂时禁用中断:
asm volatile ("cpsid i");
要再次启用中断,使用cpsie i指令:
asm volatile ("cpsie i");
禁用中断的情况下运行代码应尽可能严格地执行,而不仅仅是在没有其他解决方案的特殊情况下,因为这会影响整个系统的延迟。在这种情况下,它被用来确保在 IV 被重新定位时不会调用任何服务例程。
引导加载程序在其短暂的生命周期中执行的最后一个操作是直接跳转到应用 IV 中的reset处理程序。由于该函数永远不会返回,并且刚刚分配了一个全新的堆栈空间,我们通过将 CPU 程序计数器寄存器的值设置为从app_entry地址开始执行来强制无条件跳转,app_entry由isr_reset指向:
asm volatile("mov pc, %0" :: "r"(app_entry));
在我们的例子中,这个函数永远不会返回,因为我们替换了执行栈指针的值。这与 reset 处理器预期的行为兼容,它反过来会跳转到应用程序中的主函数。
构建镜像
由于两个可执行文件将分别构建在单独的 .elf 文件中,存在机制将两个分区的内文合并成一个单一镜像,以便上传到目标设备或用于仿真器。可以通过使用 objcopy 的 --pad-to 选项将引导加载分区填充至其大小,当将 .elf 可执行文件转换为二进制镜像时。使用 0xFF 值填充填充区域可以减少闪存的使用,这可以通过传递 --gap- 选项 fill=0xFF 来实现。生成的镜像 bootloader.bin 将正好是 4096 字节,以便在末尾附加应用程序镜像。组成包含两个分区的镜像的步骤如下:
$ arm-none-eabi-objcopy -O binary --pad-to=4096 --gap-fill=0xFF bootloader.elf bootloader.bin
$ arm-none-eabi-objcopy -O binary app.elf app.bin
$ cat bootloader.bin app.bin > image.bin
使用十六进制编辑器查看生成的 image.bin 文件,应该能够通过识别 objdump 使用的填充零模式来识别第一个分区中引导加载器的结束,以及从地址 0x1000 开始的应用程序代码。
通过将应用程序偏移量对齐到闪存中物理页面的起始处,甚至可以在单独的步骤中上传两个镜像,例如,允许您升级应用程序代码,同时不修改引导加载分区。
调试多阶段系统
两个或更多阶段的分离意味着两个可执行文件的符号被链接到不同的 .elf 文件中。使用两组符号进行调试仍然可能,但必须分两步在调试器中加载来自两个 .elf 文件的符号。当使用引导加载器的符号执行调试器时,通过将 bootloader.elf 文件作为参数添加,或使用 GDB 命令行的文件命令,引导加载器的符号被加载到调试会话的符号表中。要添加来自应用程序 .elf 文件的符号,我们可以在稍后阶段使用 add-symbol-file 添加相应的 .elf 文件。
与 file 命令不同,add-symbol-file 指令确保第二个可执行文件的符号被加载,而不会覆盖之前加载的符号,并允许您指定 .text 部分开始的地址。在本文例中构建的系统,两组符号之间没有冲突,因为两个分区在闪存上不共享任何区域。调试器可以正常继续执行,并在引导加载器跳转到应用程序入口点后仍然拥有所有符号:
> add-symbol-file app.elf
add symbol table from file "app.elf"(y or n) y
Reading symbols from app.elf...done.
在两个可执行文件之间共享相同的段和符号名称是合法的,因为这两个可执行文件是自包含的,并且没有链接在一起。当我们在调试期间通过名称引用符号时,调试器会意识到重复的名称。例如,如果我们放置一个断点在 main 上,并且我们已经正确加载了两个可执行文件的符号,那么断点将在两个位置设置:
> b main
Breakpoint 1 at 0x14e: main. (2 locations)
> info b
Num Type Disp Enb Address What
1 breakpoint keep y <MULTIPLE>
1.1 y 0x0000014e in main at startup_bl.c:53
1.2 y 0x00001158 in main at startup.c:53
不同的引导阶段彼此完全隔离,不共享任何可执行代码。因此,带有不同许可证的软件,即使它们不兼容,也可以在不同的引导阶段中运行。正如示例所示,两个软件映像可以使用相同的符号名称而不产生冲突,就像它们在两个不同的系统上运行一样。
在某些情况下,然而,多个引导阶段可能具有共同的功能,可以使用相同的库来实现。不幸的是,没有简单的方法可以从单独的软件映像访问库的符号。下一个示例中描述的机制通过只在闪存中存储一次所需的符号,为两个阶段之间的共享库提供访问权限。
共享库
假设有一个小型库提供通用工具或设备驱动程序,该库被引导加载程序和应用软件共同使用。即使占位符很小,也不建议在闪存中重复定义相同的功能。相反,库可以链接到引导加载程序的专用部分,并在后续阶段引用。在我们的前两个阶段示例中,我们可以安全地将 API 函数指针放置在地址 0x400 的数组中,该地址位于我们当前使用的中断向量之后。在实际项目中,偏移量必须足够高,以便在内存中的实际向量表之后。.utils 输入部分放置在链接脚本中,在向量表和引导加载程序中 .text 的开始之间:
.text :
{
_start_text = .;
KEEP(*(.isr_vector))
. = 0x400;
KEEP(*(.utils))
*(.text*)
*(.rodata*)
. = ALIGN(4);
_end_text = .;
} > FLASH
实际的功能定义可以放在不同的源文件中,并在引导加载程序中链接。实际上在 .utils 部分的是包含指向 .text 引导加载程序输出部分内部实际功能地址的指针表:
__attribute__((section(".utils"),used))
static void *utils_interface[4] = {
(void *)(utils_open),
(void *)(utils_write),
(void *)(utils_read),
(void *)(utils_close)
};
现在引导加载程序的布局中增加了这个额外的 .utils 部分,地址对齐为 0x400,包含一个表,其中包含指向打算从其他阶段导出使用的库函数的指针:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/B18730_04_04.jpg
图 4.4 – 带有 .utils 部分的引导加载程序分区
应用程序期望在指定的地址找到功能表:
static void **utils_interface = (void**)(0x00000400);
现在已经可以访问存储在引导加载程序中的单个函数的地址,但关于这些函数签名的信息却不存在。因此,只有当指针被转换为与预期的函数签名匹配时,应用程序才能正确地访问 API。然后可以提供一个内联包装器,以便应用程序代码可以直接访问该函数:
static inline int utils_read(void *buf, int size) {
int (*do_read)(void*, int) = (int (*)(void*,int))
(utils_interface[2]);
return do_read(buf, size);
}
在这种情况下,合同在两个模块之间隐式共享,函数签名之间的对应关系在编译时不会被检查,存储在闪存中的函数指针的有效性也不会被检查。另一方面,避免二进制代码重复是一种有效的方法,并且可能通过在分离的上下文中共享符号来有效地减少闪存使用。
远程固件更新
在嵌入式系统设计中包含引导加载程序的原因之一通常是提供一个机制,以便从远程位置更新正在运行的应用程序。如前一章所述,可靠的更新机制通常是漏洞管理的一个关键要求。在运行 Linux 的丰富嵌入式系统中,引导加载程序通常配备自己的 TCP/IP 堆栈、网络设备驱动程序和特定协议的实现,以自主地传输内核和文件系统更新。在较小的嵌入式系统中,通常方便将此任务分配给应用程序,在大多数情况下,该应用程序已经使用类似的通信渠道用于其他功能目的。一旦新的固件被下载并存储在任何非易失性存储支持中(例如,在闪存末尾的分区中),引导加载程序可以实现一个机制,通过覆盖应用程序分区中的先前固件来安装接收到的更新。
安全引导
许多项目需要一个机制来防止执行未经授权或篡改的固件,这种固件可能被攻击者有意破坏以尝试控制系统。这是一个安全引导加载程序的任务,它使用密码学来验证基于板载固件映像内容的签名。实现此类机制的安全引导加载程序依赖于一个信任锚来存储公钥,并要求使用必须附加到固件映像文件上的清单。清单包含由与存储在设备中的公钥相关联的私钥所有者创建的签名。密码学签名验证是一种非常有效的防止未经授权的固件更新的方法,无论是来自远程位置还是来自物理攻击。
从零开始实现一个安全的引导加载程序是一项相当大的工作量。一些开源项目提供了使用加密算法对镜像进行签名和验证的机制。wolfBoot是一个提供当前固件和更新安装候选者完整性和真实性检查的安全引导加载程序。它提供了一个在更新安装过程中交换两个固件分区内容的故障安全机制,以在新的更新镜像执行失败时提供备份。引导加载程序附带生成签名并将清单附加到要传输到设备的文件的工具,以及一系列可配置的选项、加密方式和功能。
摘要
理解引导过程是开发嵌入式系统的一个关键步骤。我们已经看到了如何直接引导到裸机应用程序,并且我们检查了多阶段系统引导中涉及的架构,例如具有不同入口点的单独链接脚本、通过 CPU 寄存器重定位 IVs 以及跨阶段的共享代码段。
在下一章中,我们将探讨内存管理的机制和方法,这是在开发安全可靠的嵌入式系统时需要考虑的最重要因素。
第五章:内存管理
处理内存是嵌入式系统程序员最重要的任务之一,并且在系统开发的每个阶段都无疑是最重要的考虑因素。本章介绍了在嵌入式系统中管理内存的常用模型、内存的几何形状和映射,以及如何防止可能危害目标上运行的软件稳定性和安全性的问题。
本章分为四个部分:
-
内存映射
-
执行栈
-
堆管理
-
内存保护单元
到本章结束时,你将深入了解如何在嵌入式系统中管理内存。
技术要求
你可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/Embedded-Systems-Architecture-Second-Edition/tree/main/Chapter5/memory。
内存映射
应用软件通常可以从环境中可用的内存处理抽象中受益。在现代个人计算机的操作系统上,每个进程都可以访问其自己的内存空间,这些空间也可以通过重新映射内存块到虚拟内存地址来重新定位。此外,通过内核提供的虚拟内存池,可以实现动态内存分配。嵌入式设备不依赖于这些机制,因为没有方法可以将虚拟地址分配给物理内存位置。在所有上下文和运行模式下,所有符号只能通过指向物理地址来访问。
正如我们在上一章中看到的,启动裸机嵌入式应用程序需要在编译时定义分配在可用地址空间指定区域内的段,使用链接脚本。为了正确配置嵌入式软件中的内存段,分析各个区域的特性和我们可以用来组织和管理的内存区域的技术是重要的。
内存模型和地址空间
可用地址的总数取决于内存指针的大小。32 位机器可以引用 4 GB 的连续内存空间,这被分割以容纳系统中的所有内存映射设备。这可能会包括以下内容:
-
内部 RAM
-
闪存
-
系统控制寄存器
-
微控制器内部的组件
-
外部外围总线
-
额外的外部 RAM
每个区域都有一个固定的物理地址,这可能会依赖于平台的特性。所有位置都是硬编码的,其中一些是平台特定的。
在 ARM Cortex-M 中,总的地址空间被划分为六个宏区域。根据它们的目的,这些区域有不同的权限,以便存在只能进行读取操作的内存区域,或者不允许在原地执行的区域。这些限制在硬件中实现,但在包含 MPU 的微控制器上可能在运行时可配置:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/B18730_05_01.jpg
图 5.1 – ARM Cortex-M 地址空间
通常,只有小部分(与物理组件大小相同)被映射到这些区域中。尝试访问未映射到任何硬件的内存会在 CPU 中触发异常。在接近目标平台时,了解与板上硬件对应的内存部分的地址和大小非常重要,以便在链接脚本和源代码中正确描述可用的地址空间几何形状。
代码区域
Cortex-M 微控制器地址空间的最低 512 MB 保留用于可执行代码。支持 XIP 的目标总是将闪存映射到这个区域,并且通常在运行时不允许写入。在我们之前的例子中,.text 和 .rodata 部分被映射到这个区域,因为它们在软件执行期间保持不变。此外,所有非零定义符号的初始值都放置在这个区域,并且需要显式地复制和重新映射到可写段,以便在运行时修改它们的值。正如我们已知的,0x00000000,而其他人选择不同的起始地址(例如,0x10000000 或 0x08000000)。STM32F4 闪存映射到 0x08000000 并提供了一个别名,以便可以在运行时从地址 0x00000000 访问相同的内存。
注意
当闪存地址从地址 0 开始时,空指针可以被解引用,并将指向代码区域的开始,这通常是可以读取的。虽然这在技术上违反了 C 标准,但在嵌入式 C 代码中,在这种情况下从地址 0x00000000 读取是一种常见的做法——例如,在 ARM 的 IVT 中读取初始堆栈指针。
RAM 区域
内部 RAM 银行被映射到第二个 512 MB 块中的地址,起始地址为 0x20000000。外部内存银行可以映射到 1 GB 区域的任何位置,起始地址为 0x60000000。根据 Cortex-M 微控制器内部 SRAM 的几何形状或外部内存银行的偏移量,实际上可访问的内存区域可以映射到允许范围内的非连续的不同内存部分。内存管理必须考虑到物理映射的不连续性,并分别引用每个部分。例如,STM32F407 MPU 有两个非连续映射的内部 SRAM 块:
-
地址
0x20000000(由两个连续的 112 KB 和 16 KB 块组成)的 128 KB SRAM -
一个独立的 64 KB
0x10000000银行
这第二个内存与 CPU 紧密耦合,并针对时间关键操作进行了优化,这允许从 CPU 本身进行零等待状态访问。
在这种情况下,我们可以在链接脚本中将这两个块引用为两个单独的区域:
flash (rx) : ORIGIN = 0x08000000, LENGTH = 256K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
CCMSRAM(rwx) : ORIGIN = 0x10000000, LENGTH = 64K
虽然 RAM 区域是为数据设计的,但它通常保留执行权限,因此代码部分可以被加载到 RAM 中并在运行时执行。在 RAM 中执行代码扩展了系统的灵活性,使我们能够在将代码部分加载到内存之前处理它们。那些不打算在原地执行的二进制文件也可以以其他格式存储在任何设备上,甚至可以使用压缩或加密算法。虽然有时很方便,但使用 RAM 中的部分来存储可执行代码会从系统中夺取宝贵的运行时内存。在设计系统之前必须仔细考虑这些好处,尤其是从应用程序的实际运行时内存需求的角度来看。
外设访问区域
内部 RAM 区域之后的 512 MB 区域,从地址0x40000000开始,是为通常集成到微控制器中的外设保留的。从地址0xA0000000开始的 1 GB 区域则用于映射外部内存芯片和其他可以在 MCU 寻址空间中内存映射但不是原始芯片封装部分的外设。为了正确访问外设,必须事先了解 MCU 封装内部组件的配置和内存映射设备的地址。这些区域不允许代码执行。
系统区域
Cortex-M 内存映射的最高 512 MB 是为访问系统配置和私有控制块保留的。这个区域包含系统控制寄存器,这些寄存器用于编程处理器,以及外设控制寄存器,用于配置设备和外设。不允许在这些区域执行代码,并且当处理器在特权级别运行时,该区域是唯一可访问的,如在第十章并行任务和[调度]中更详细地解释。
通过解引用其众所周知的地址来访问硬件寄存器,在运行时设置和获取它们的值是有用的。然而,编译器无法区分映射到 RAM 的变量赋值和系统控制块中的配置寄存器。因此,编译器通常认为通过改变内存事务的顺序来优化代码是一个好主意,这实际上可能会在下一个操作依赖于所有之前内存传输的正确结论时产生不可预测的效果。因此,在访问配置寄存器时需要格外小心,以确保在执行下一个操作之前,内存传输操作已经完成。
内存事务的顺序
在 ARM CPU 上,内存系统不保证内存事务的执行顺序与生成它们的指令相同。内存事务的顺序可以被改变以适应硬件的特性,例如访问底层物理内存所需的等待状态,或者通过在微代码级别实现的推测性分支预测机制。虽然 Cortex-M 微控制器保证了涉及外设和系统区域的交易顺序严格,但在所有其他情况下,代码必须相应地进行配置,通过放置足够的内存屏障来确保在执行下一个指令之前,之前的内存事务已经执行。Cortex-M 指令集包括三种类型的屏障:
-
数据内存 屏障(DMB)
-
数据同步 屏障(DSB)
-
指令同步 屏障(ISB)
DSB 是一个软屏障,用于确保在下一个内存事务发生之前,所有挂起的交易都已执行。DSB 实际上用于暂停执行,直到所有挂起的交易都已执行。此外,ISB 还会刷新 CPU 流水线,确保在内存事务之后重新获取所有新指令,从而防止由过时的内存内容引起的任何副作用。有许多情况下需要使用屏障:
-
更新 VTOR 以更改 IV 的地址后
-
更新内存映射后
-
在执行修改自身代码的过程中
执行栈
如前一章所见,裸机应用程序以空堆栈区域开始执行。执行堆栈向后增长,从启动时提供的高地址到每次存储新项目时的低地址。堆栈始终跟踪函数调用链,通过在每次函数调用时存储分支点,但它在函数执行期间也充当临时存储。每个函数的局部作用域内的变量在函数执行时存储在堆栈中。因此,在开发嵌入式系统时,保持堆栈使用量在控制之下是最关键的任务之一。
嵌入式编程要求我们在编码时始终意识到堆栈使用情况。将大对象放置在堆栈中,例如通信缓冲区或长字符串,通常不是一个好主意,考虑到堆栈的空间总是非常有限。编译器可以被指示在单个函数所需的堆栈空间超过某个阈值时产生警告,例如,在这个代码中:
void function(void)
{
char buffer[200];
read_serial_buffer(buffer);
}
如果使用 GCC 选项 -Wstack-usage=100 进行编译,将产生以下警告:
main.c: In function 'function':
main.c:15:6: warning: stack usage is 208 bytes [-Wstack-usage=]
这可以在编译时拦截。
虽然这种机制有助于识别局部堆栈过度使用,但它不能有效地识别代码中所有潜在的堆栈溢出,因为函数调用可能是嵌套的,它们的堆栈使用量会累加。我们的函数每次被调用时都会使用 208 字节的堆栈,其中 200 字节用于在堆栈中托管 buffer 局部变量,另外 8 字节用于存储两个指针:代码部分的调用起源,存储为返回点,以及帧指针,它包含调用之前的旧堆栈指针位置。
按照设计,每次函数被调用时堆栈都会增长,当函数返回时又会缩小。在特定情况下,对运行时堆栈使用量的估计尤其困难,这正是递归函数的目的。因此,在可能的情况下应避免在代码中使用递归,或者将其减少到最小并严格控制在其他情况下,要知道目标中为堆栈保留的内存区域很小:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/B18730_05_02.jpg
图 5.2 – 当函数被调用以存储帧指针和局部变量时,堆栈指针向下移动
堆栈放置
在启动时,可以通过设置 IV 表的第一词中的所需内存地址来选择堆栈区域的初始指针,该地址对应于在闪存中加载的二进制图像的起始位置。
这个指针可以在编译时设置,有多种方式。来自第四章,“启动过程”,的简单示例展示了如何为堆栈分配一个特定区域或使用来自链接脚本导出的符号。
使用链接脚本作为描述内存区域和段的中心点,使得代码在类似平台之间更具可移植性。
由于我们的 STM32F407 在地址 0x10000000 提供了一个额外的、紧密耦合的 64-KB 内存银行,我们可能想要为其保留下 16 KB 作为执行栈,并将其余部分保留在单独的部分以供以后使用。链接脚本必须在 MEMORY 块中定义顶部的区域:
MEMORY
{
FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 1M
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
CCRAM(rwx) : ORIGIN = 0x10000000, LENGTH = 64K
}
现在可以在文件末尾导出两个符号,通过分配常量、预定义的值:
_stack_size = 16 * 1024;
_stack_end = ORIGIN(CCRAM) + _stack_size;
_stack_size 和 _stack_end 的值可以通过应用程序作为普通 C 符号访问。当向量表初始化时,_stack_end 被放置在地址 0,以指示最高的栈地址:
__attribute__ ((section(".isr_vector")))
void (* const IV[])(void) =
{
(void (*)(void))(&_end_stack),
isr_reset, // Reset
isr_fault, // NMI
isr_fault, // HardFault
/* more interrupt routines follow */
在可能的情况下,将单独的内存区域委托给栈区域是一个好主意,就像在这个例子中一样。不幸的是,这并不是所有平台都可行。
大多数具有物理内存映射的嵌入式设备为整个 RAM 提供一个单一的连续映射区域。在这些情况下,组织内存的常见策略是将初始栈指针放置在可映射内存末尾的最高可用地址。这样,栈可以从内存顶部向下自由增长,而应用程序仍然可以使用内存从未被任何其他部分使用的最低地址分配动态对象。虽然这种机制被认为是最有效的,给人一种似乎可以耗尽所有可用 RAM 的错觉,但它很危险,因为两个向相反方向增长的区域可能会碰撞,导致不可预测的结果。
栈溢出
栈大小和放置的主要问题是,在单线程、裸机应用程序中,从栈溢出情况中恢复是非常困难的,甚至可能是不可能的。当栈在其自身的物理区域内自包含,例如在单独的内存银行中,如果其下限是一个未映射到任何设备的区域,栈溢出将导致硬故障异常,这可以被捕获以停止目标。
在其他情况下,例如当相邻内存用于其他目的时,栈指针可能会溢出到其他段,存在破坏其他内存区域的具体风险,包括甚至打开恶意代码注入和针对目标任意代码执行攻击的大门。通常,最佳策略是在启动时分配足够的栈空间,尽可能地将栈与其他内存区隔离开来,并在运行时检查栈的使用情况。将栈配置为使用 RAM 中可用的最低地址确保了栈溢出会导致硬错误,而不是访问内存中相邻区域的有效指针。对于具有单一连续内存映射 RAM 区域的裸机系统,最经典的方法是将初始栈指针放在可用的最高地址,并使其向后增长到较低的地址。链接脚本导出映射的最高地址作为初始栈指针:
_end_stack = ORIGIN(RAM) + LENGTH(RAM);
.bss 区段结束和栈最低地址之间的可用内存可以被应用程序用于动态分配,同时,栈也被允许向相反方向增长。这是利用所有可用内存的有效方法,因为栈不需要一个下界,但只有在从两侧使用的总内存量适合指定区域时才是安全的。如果允许这些区段动态增长到更高的地址,那么如果两侧有重叠,就总有可能发生冲突:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/B18730_05_03.jpg
图 5.3 – 堆分配和执行栈向相反方向增长
在具有单一连续内存区域的嵌入式系统中,两个连续内存区域之间的冲突是非常常见且危险的事件。在本章稍后提出的解决方案,即在 内存保护单元 部分中,可以通过在中间插入一个不可访问的第三块来将内存分成两个逻辑块,并帮助识别和拦截这些情况。
栈着色
测量运行时所需栈空间的有效方法是在估计的栈空间中填充一个已知的模式。这种机制非正式地被称为栈着色,它揭示了执行栈在任何时刻的最大扩展。通过运行带有着色栈的软件,实际上可以通过寻找最后一个可识别的模式来测量使用的栈空间量,并假设栈指针在执行过程中已经移动,但永远不会越过那个点。
我们可以在复位处理程序中手动执行栈绘制,在内存初始化期间进行。为此,我们需要分配一个绘制区域。在这种情况下,将是内存的最后 8 KB,直到_end_stack。再次强调,在reset_handler函数中操作栈时,不应使用局部变量。reset_handler函数将当前栈指针的值存储在sp全局变量中:
static unsigned int sp;
在处理程序中,可以在调用main()之前添加以下部分:
asm volatile("mrs %0, msp" : "=r"(sp));
dst = ((unsigned int *)(&_end_stack)) – (8192 / sizeof(unsigned int)); ;
while (dst < sp) {
*dst = 0xDEADC0DE;
dst++;
}
首条汇编指令用于将栈指针的当前值存储到sp变量中,确保在区域被绘制后绘画停止,但仅限于栈中最后一个未使用的地址:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/B18730_05_04.jpg
图 5.4 – 使用可识别的图案绘制栈区域有助于估计原型中使用的栈内存
可以在运行时定期检查当前的栈使用情况 – 例如,在main循环中 – 以检测带有可识别图案的区域。仍然被涂色的区域至今尚未被执行栈使用,表明了仍然可用的栈空间量。
此机制可用于验证应用程序运行时所需的栈空间量。根据设计,此信息可以稍后用于设置栈可用段的安全下限。然而,栈绘制并不总是有效的,因为它提供了执行期间使用的栈的测量值,但它可能忽略了栈使用可能更大的边缘情况。在每次测试结束时关注栈绘制,同时增加测试覆盖率,可能有助于在开发阶段分配适当的栈空间。
堆管理
关键安全性的嵌入式系统通常设计为不实现任何动态内存分配。虽然这听起来可能有些极端,但它最小化了应用代码中最常见的编程错误对系统运行的影响,这些错误可能导致系统运行出现灾难性的后果。
另一方面,动态分配是一种强大的工具,因为它提供了对内存块生命周期和大小的完全控制。许多为嵌入式设备设计的第三方库都期望存在动态内存分配的实现。动态内存通过在内存中维护每个分配的状态和大小来管理,通过跟踪指向下一个空闲内存区域的指针,并在处理新的分配请求时重用已释放的块。
堆分配的标准编程接口由两个基本函数组成:
void *malloc(size_t size);
void free(void *ptr);
这些函数签名由 ANSI-C 标准定义,通常在操作系统中找到。它们允许我们请求一个给定大小的新的内存区域,并释放由指定指针引用的先前分配的区域。更完整的堆管理支持一个额外的调用,realloc,它允许我们调整先前分配的内存区域的大小,无论是在原地调整还是将其重新定位到一个足够大的新段,以容纳给定大小的对象:
void *realloc(void *ptr, size_t size);
虽然 realloc 通常被大多数嵌入式系统实现省略,但在某些情况下,调整内存中的对象大小可能是有用的。
根据实现方式,内存管理在连接已释放的连续块以创建更大的可用段时可能更有效率或更低效,而不必分配新空间。实时操作系统通常提供具有不同堆管理的分配器。以 FreeRTOS 为例,它提供了五个不同的可移植堆管理器可供选择。
如果我们选择一个允许动态分配的解决方案,重要的是在设计时要考虑到几个重要因素:
-
堆放置区域的几何形状
-
如果堆与栈共享,则分配给堆的部分的上限,以防止堆栈冲突
-
如果没有足够的内存来满足新分配请求,应采取的政策
-
如何处理内存碎片化并尽可能减小未使用块的开销
-
使用单独的池来分离特定对象和模块使用的内存
-
将单个内存池分散到非连续的区域
当目标上没有可用的分配器时——例如,如果我们从头开始开发裸机应用程序——我们可能需要实现一个响应设计特性的分配器。这可以通过从头开始提供 malloc/free 函数的自定义实现来完成,或者使用使用的 C 库提供的实现。第一种方法可以完全控制碎片化、内存区域和用于实现堆的池,而后者隐藏了大部分处理,同时仍然允许定制(连续的)内存区域和边界。在接下来的两个部分中,我们将更详细地探讨两种可能策略。
自定义实现
与服务器和个人计算机不同,在这些系统中内存分配是通过特定大小的页面来处理的,在裸机嵌入式系统中,堆通常是物理内存的一个或多个连续区域,可以使用任何对齐方式内部划分。基于 malloc/free 接口的堆内存分配构建包括在内存中跟踪请求的分配。这通常是通过在每个分配前附加一个小型头部来完成的,以跟踪状态和分配部分的大小,这可以在 free 函数中使用来验证分配的块并将其提供给下一次分配。一个基本的实现,从 .bss 部分结束后的第一个可用地址开始提供动态内存,可能使用以下预头表示内存中的每个块:
struct malloc_block {
unsigned int signature;
unsigned int size;
};
可以分配两个不同的签名来识别有效的块并区分仍在使用的块和已经释放的块:
#define SIGNATURE_IN_USE (0xAAC0FFEE)
#define SIGNATURE_FREED (0xFEEDFACE)
#define NULL (((void *)0))
malloc 函数应该跟踪堆中的最高地址。在这个例子中,一个静态变量被用来标记堆的当前末尾。它在开始时设置为起始地址,并且每次分配新块时都会增长:
void *malloc(unsigned int size)
{
static unsigned int *end_heap = 0;
struct malloc_block *blk;
char *ret = NULL;
if (!end_heap) {
end_heap = &_start_heap;
}
下面的两行确保请求的块是 32 位对齐的,以优化对 malloc_block 的访问:
if (((size >>2) << 2) != size)
size = ((size >> 2) + 1) << 2;
然后 malloc 函数首先在堆中查找之前已释放的内存部分:
blk = (struct malloc_block *)&_start_heap;
while (blk < end_heap) {
if ((blk->signature == SIGNATURE_FREED) &&
(blk->size >= size)) {
blk->signature = SIGNATURE_IN_USE;
ret = ((char *)blk) + sizeof(struct malloc_block);
return ret;
}
blk = ((char *)blk) + sizeof(struct malloc_block) +
blk->size;
}
如果找不到可用插槽,或者如果没有一个足够大以满足分配所需的大小,内存将在栈的末尾分配,并且指针相应地更新:
blk = (struct malloc_block *)end_heap;
blk->signature = SIGNATURE_IN_USE;
blk->size = size;
ret = ((char *)end_heap) + sizeof(struct malloc_block);
end_heap = ret + size;
return ret;
}
在这两种情况下,返回的地址隐藏了在其前面的 malloc_block 控制结构。end_heap 变量始终指向堆中最后分配的块的末尾,但它并不是内存使用的指示,因为在此期间可能已经释放了中间的块。这个示例 free 函数,演示了一个非常简单的情况,仅对需要释放的块执行基本检查,并将签名设置为指示该块不再被使用:
void free(void *ptr)
{
struct malloc_block *blk = (struct malloc_block *)
(((char *)ptr)-sizeof(struct malloc_block));
if (!ptr)
return;
if (blk->signature != SIGNATURE_IN_USE)
return;
blk->signature = SIGNATURE_FREED;
}
尽管这个例子非常简单,但其目的是解释堆分配的基本功能,而不考虑所有现实生活中的限制和限制。实际上,分配和释放不同大小的对象可能会导致碎片化。为了最大限度地减少这种现象对内存使用和活动分配之间浪费空间的影响,free 函数至少应该实现某种机制来合并不再使用的相邻区域。此外,前面的例子 malloc 假设堆部分没有上限,不对 end_heap 指针的新位置进行任何检查,并且在没有可用内存进行分配时没有定义策略。
虽然工具链和库通常提供 malloc 和 free 的默认实现,但在现有实现不符合要求的情况下,实现自定义基于堆的分配机制仍然是有意义的——例如,如果我们想管理单独的内存池或将单独的物理内存部分合并以在同一个池中使用。
在具有物理内存映射的系统上,无法完全解决碎片化问题,因为无法移动之前分配的块以优化可用的空间。然而,通过控制分配的数量,尽可能多地重用分配的块,并避免频繁调用 malloc/free,特别是请求不同大小的块,可以减轻这个问题。
不论实现方式如何,动态内存的使用都会引入许多安全问题,并且应该在所有生命关键系统中避免使用,在一般情况下,在不必要的地方也应避免使用。简单的单用途嵌入式系统可以被设计为完全避免使用动态内存分配。在这些情况下,可以提供一个简单的 malloc 接口,以允许在启动期间进行永久分配。
使用 newlib
工具链可能提供一系列实用工具,通常包括动态内存分配机制。基于 GCC 的微控制器工具链包括一组减少的标准 C 调用,通常在内置的标准 C 库中。一个流行的选择,通常包含在 ARM-GCC 嵌入式工具链中,是 newlib。虽然提供了许多标准调用的实现,但 newlib 通过允许定制涉及硬件的操作,尽可能保持灵活性。只要实现了所需的系统调用,newlib 库就可以集成到单线程、裸机应用程序和实时操作系统中。
对于 malloc,newlib 需要一个现有的 sbrk 函数实现。这个函数预期在每次新的分配需要扩展堆空间时将堆指针向前移动,并将旧的堆值返回给 malloc,以便在现有、之前释放且可重用的块在池中找不到时完成分配:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/B18730_05_05.jpg
图 5.5 – newlib 实现 malloc 和 free 并依赖于现有的 _sbrk 实现
_sbrk 函数的一个可能的实现如下:
void * _sbrk(unsigned int incr)
{
static unsigned char *heap = NULL;
void *old_heap = heap;
if (((incr & 0x03) != incr)
incr = ((incr >> 2) + 1) << 2;
if (old_heap == NULL)
old_heap = heap = (unsigned char *)&_start_heap;
heap += incr;
return old_heap;
}
如果代码在未使用 -nostdlib 标志的情况下进行链接,那么在代码的任何地方调用的 malloc 和 free 函数将会自动在工具链中构建的 newlib 库中找到,并包含在最终的二进制文件中。如果没有定义 _sbrk 符号,则会导致链接错误。
限制堆大小
在迄今为止看到的所有分配函数中,软件对为堆保留的内存量没有设置限制。虽然防止栈溢出通常很难,而且恢复起来极其困难,但应用程序通常可以优雅地处理可用堆内存耗尽的情况——例如,通过取消或推迟需要分配的操作。在更复杂的线程系统中,操作系统可以通过终止非关键进程来主动响应内存短缺,为新分配腾出空间。一些使用页面交换机制的高级系统,如 Linux,可能会在可用内存上实现超分配。这种机制确保内存分配永远不会失败,malloc永远不会返回NULL来指示失败。
系统中的内存消耗进程可能在任何时候由内核线程,即内存不足杀手,终止,为新分配腾出空间,以便其他资源消耗较少的进程继续运行。在嵌入式系统中,特别是如果没有多线程,当堆上没有剩余物理空间时,最好让分配器返回NULL,这样系统可以继续运行,应用程序可能通过识别内存不足事件来恢复。可以通过在链接脚本中导出其上边界地址来限制堆在内存中的部分,如下所示:
_heap_end = ORIGIN(RAM) + LENGTH(RAM);
newlib库malloc实现的后端可以考虑到在_sbrk()函数中引入的新上限:
void * _sbrk(unsigned int incr) {
static unsigned char *heap = NULL;
void *old_heap = heap;
if (((incr & 0x03) != incr)
incr = ((incr >> 2) + 1) << 2;
if (old_heap == NULL)
old_heap = heap = (unsigned char *)&_start_heap;
if ((heap + incr) >= &_end_heap)
return (void *)(-1);
else
heap += incr;
return old_heap;
}
当sbrk因堆分配内存不足而返回特殊值(void *)(-1)时,这表示调用malloc的内存不足,无法执行所需的分配。然后malloc将返回NULL给调用者。
在这种情况下,调用者始终检查malloc()每次调用的返回值,并且应用程序逻辑能够正确检测系统内存不足,并尝试从中恢复,这一点非常重要。
多个内存池
在某些系统中,将内存的独立部分作为动态内存堆保留是有用的,每个部分都专门用于系统中的特定功能。出于不同原因,如确保特定模块或子系统不会使用比编译时分配的更多内存,或者确保具有相同大小的分配可以重用相同的物理内存空间,从而减少碎片化影响,或者甚至为与外围设备或网络设备的 DMA 操作分配预定义的固定内存区域,可以实现使用独立池的堆分配机制。可以通过通常在链接脚本中导出符号的方式来界定不同池的分区。以下示例预先在内存中为两个池分配空间,分别为 8 KB 和 4 KB,位于 RAM 中.bss部分的末尾:
PROVIDE(_start_pool0 = _end_bss);
PROVIDE(_end_pool0 = _start_pool0 + 8KB);
PROVIDE(_start_pool1 = _end_pool0);
PROVIDE(_end_pool1 = _start_pool1 + 4KB);
必须定义一个自定义的分配函数,因为malloc接口不支持选择池,但函数可以针对两个池都进行通用化。可以使用全局结构体来填充由链接器导出的值:
struct memory_pool {
void *start;
void *end;
void *cur;
};
static struct memory_pool mem_pool[2] = {
{
.start = &_start_pool0;
.end = &_end_pool0;
},
{
.start = &_start_pool1;
.end = &_end_pool1;
},
};
函数必须接受一个额外的参数来指定池。然后,使用相同的算法执行分配,只需更改当前指针和所选池的边界。在这个版本中,在将当前堆值向前移动之前检测到内存不足错误,返回NULL以通知调用者:
void *mempool_alloc(int pool, unsigned int size)
{
struct malloc_block *blk;
struct memory_pool *mp;
char *ret = NULL;
if (pool != 0 && pool != 1)
return NULL;
mp = mem_pool[pool];
if (!mp->cur)
mp->cur = mp->start;
if (((size >>2) << 2) != size)
size = ((size >> 2) + 1) << 2;
blk = (struct malloc_block *)mp->start;
while (blk < mp->cur) {
if ((blk->signature == SIGNATURE_FREED) &&
(blk->size >= size)) {
blk->signature = SIGNATURE_IN_USE;
ret = ((char *)blk) + sizeof(struct malloc_block);
return ret;
}
blk = ((char *)blk) + sizeof(struct malloc_block) +
blk->size;
}
blk = (struct malloc_block *)mp->cur;
if (mp->cur + size >= mp->end)
return NULL;
blk->signature = SIGNATURE_IN_USE;
blk->size = size;
ret = ((char *)mp->cur) + sizeof(struct malloc_block);
mp->cur = ret + size;
return ret;
}
再次强调,此机制不考虑内存碎片,因此mempool_free函数可以与free具有相同的实现,对于简化的malloc来说,唯一必要的事情是将释放的块标记为未使用。
在更完整的情况下,当free或单独的垃圾回收例程负责合并连续释放的块时,可能需要在每个池中跟踪释放的块,在列表中或在可以访问以检查合并是否可能的其他数据结构中。
常见的堆使用错误
在某些环境中,使用动态内存分配被认为是不安全的,因为它众所周知是导致讨厌的错误的来源,这些错误通常是关键且非常难以识别和修复。动态分配可能难以跟踪,尤其是在代码大小和复杂性增加以及存在许多动态分配的数据结构时。这在多线程环境中已经非常严重,在那里仍然可以实施回退机制,例如终止行为不当的应用程序,但在单线程嵌入式系统中,这些类型的错误通常对系统是致命的。使用堆分配编程时最常见的错误类型如下:
-
NULL指针解引用 -
双重
free -
free之后的使用 -
未调用
free导致内存泄漏
通过遵循一些简单的规则可以避免其中的一些。malloc返回的值在使用指针之前应该始终进行检查。这在资源有限的环境中尤为重要,分配器可以返回NULL指针以指示没有可用于分配的内存。首选的方法是确保当所需的内存不可用时有一个明确的策略。在任何情况下,所有动态指针都必须进行检查,以确保在尝试解引用之前它们不指向NULL值。
释放NULL指针是一个合法的操作,当调用free时必须识别。通过在函数开始处包含一个检查,如果指针是NULL,则不执行任何操作,并忽略调用。
紧接着,我们还可以检查内存是否在释放之前已被释放。在我们的free函数中,我们通过在内存中对malloc_block结构的签名实现一个简单的检查。可以添加日志消息,甚至断点来调试第二个free函数的来源:
if (blk->signature != SIGNATURE_IN_USE) {
/* Double free detected! */
asm("BKPT #0") ;
return;
}
不幸的是,这种机制可能只在某些情况下有效。事实上,如果之前释放的块被分配器再次分配,将无法检测到其原始引用的进一步使用,第二次free会导致第二个引用也丢失。同样,由于没有方法可以判断已释放的内存块是否再次被访问,使用-after-free错误也难以诊断。可以通过在free调用后用可识别的模式标记释放的块,这样如果块的 内容在free调用后被更改,那么在该块上调用malloc的下一个实例可以检测到更改。然而,这并不能保证检测到所有情况,并且仅适用于对释放的指针的写访问;此外,这无法识别所有读取访问释放内存的情况。
内存泄漏容易诊断,但有时难以定位。资源有限时,忘记释放分配的内存很快就会耗尽所有可用的堆。虽然有一些用于追踪分配的技术,但通常足够使用调试器中断软件,寻找相同大小的重复分配来追踪有问题的调用者。
总结来说,由于动态内存错误的灾难性和恐怖性,它们可能是嵌入式系统上最大的挑战之一。因此,编写更安全的应用程序代码在资源方面通常比在系统级别寻找内存错误(例如,对分配器进行仪器化)要便宜得多。彻底分析每个分配对象的生存期,并尽可能使逻辑清晰易读,可以防止大多数与指针处理相关的问题,并节省大量本应花费在调试上的时间。
内存保护单元
在没有虚拟地址映射的系统上,创建可以由软件在运行时访问的段的分离更困难。内存保护单元,通常称为 MPU,是许多基于 ARM 的微控制器中可选的组件。MPU 通过设置本地权限和属性来分离内存中的段。这种机制在实际场景中有几种用途,例如,当 CPU 在用户模式下运行时防止访问内存,或者防止从 RAM 的可写位置获取可执行代码。当 MPU 启用时,它通过在违反规则时触发内存异常中断来强制执行这些规则。
虽然操作系统通常用于创建进程堆栈分离并强制对系统内存的特权访问,但 MPU 在许多其他情况下也很有用,包括裸机应用程序。
MPU 配置寄存器
在 Cortex-M 中,与 MPU 配置相关的控制块区域位于系统控制块中,起始地址为0xE000ED90。使用五个寄存器来访问 MPU:
-
0x00包含有关 MPU 系统可用性和支持的区域数量的信息。此寄存器也适用于没有 MPU 的系统,以指示不支持该功能。 -
0x04用于激活 MPU 系统并启用所有未明确映射在 MPU 中的区域的默认背景映射。如果未启用背景映射,则不允许访问未映射的区域。 -
RNR偏移量0x08用于选择要配置的区域。 -
可以通过
RBAR偏移量0x0C来访问以更改所选区域的基本地址。 -
RASR的偏移量0x10定义了所选区域的权限、属性和大小。
编程 MPU
Cortex-M 微控制器的 MPU 支持多达八个不同的可编程区域。可以在程序开始时实现并调用一个启用 MPU 并设置所有区域的函数。MPU 寄存器在 HAL 库中映射,但在此情况下,我们将定义自己的版本并直接访问它们:
#define MPU_BASE 0xE000ED90
#define MPU_TYPE (*(volatile uint32_t *)(MPU_BASE + 0x00))
#define MPU_CTRL (*(volatile uint32_t *)(MPU_BASE + 0x04))
#define MPU_RNR (*(volatile uint32_t *)(MPU_BASE + 0x08))
#define MPU_RBAR (*(volatile uint32_t *)(MPU_BASE + 0x0c))
#define MPU_RASR (*(volatile uint32_t *)(MPU_BASE + 0x10))
在我们的示例中,我们使用了以下定义的位字段值定义来在RASR中设置正确的属性:
#define RASR_ENABLED (1)
#define RASR_RW (1 << 24)
#define RASR_RDONLY (5 << 24)
#define RASR_NOACCESS (0 << 24)
#define RASR_SCB (7 << 16)
#define RASR_SB (5 << 16)
#define RASR_NOEXEC (1 << 28)
可能的大小,最终应在RASR的大小字段中以比特 1:5 结束,编码如下:
#define MPUSIZE_1K (0x09 << 1)
#define MPUSIZE_2K (0x0a << 1)
#define MPUSIZE_4K (0x0b << 1)
#define MPUSIZE_8K (0x0c << 1)
#define MPUSIZE_16K (0x0d << 1)
#define MPUSIZE_32K (0x0e << 1)
#define MPUSIZE_64K (0x0f << 1)
#define MPUSIZE_128K (0x10 << 1)
#define MPUSIZE_256K (0x11 << 1)
#define MPUSIZE_512K (0x12 << 1)
#define MPUSIZE_1M (0x13 << 1)
#define MPUSIZE_2M (0x14 << 1)
#define MPUSIZE_4M (0x15 << 1)
#define MPUSIZE_8M (0x16 << 1)
#define MPUSIZE_16M (0x17 << 1)
#define MPUSIZE_32M (0x18 << 1)
#define MPUSIZE_64M (0x19 << 1)
#define MPUSIZE_128M (0x1a << 1)
#define MPUSIZE_256M (0x1b << 1)
#define MPUSIZE_512M (0x1c << 1)
#define MPUSIZE_1G (0x1d << 1)
#define MPUSIZE_2G (0x1e << 1)
#define MPUSIZE_4G (0x1f << 1)
当我们进入mpu_enable函数时,首先要做的是确保我们的目标上具有该功能,通过检查MPU_TYPE寄存器:
int mpu_enable(void)
{
volatile uint32_t type;
volatile uint32_t start;
volatile uint32_t attr;
type = MPU_TYPE;
if (type == 0) {
/* MPU not present! */
return -1;
}
为了配置 MPU,我们必须确保在更改基本地址和每个区域的属性时,它处于禁用状态:
MPU_CTRL = 0;
包含可执行代码的闪存区域可以被标记为只读区域0。RASR属性的值如下:
start = 0;
attr = RASR_ENABLED | MPUSIZE_256K | RASR_SCB |
RASR_RDONLY;
mpu_set_region(0, start, attr);
整个 RAM 区域可以映射为读写。如果我们不需要从 RAM 中执行代码,我们可以在这种情况下设置1:
start = 0x20000000;
attr = RASR_ENABLED | MPUSIZE_64K | RASR_SCB | RASR_RW
| RASR_NOEXEC;
mpu_set_region(1, start, attr);
由于内存映射是按照内存区域编号的顺序处理的,我们可以使用区域2在区域1内创建一个异常。编号较高的区域比编号较低的区域具有更高的优先级,因此可以在具有较低编号的现有映射内创建异常。
区域 2 用于定义一个保护区域,作为栈向后增长的底部边界,其目的是拦截栈溢出。实际上,如果程序在任何时刻尝试访问保护区域,则会触发异常,操作失败。在这种情况下,保护区域占据栈底部的 1 KB。其属性中没有配置访问权限。MPU 确保该区域在运行时不可访问:
start = (uint32_t)(&_end_stack) - (STACK_SIZE + 1024);
attr = RASR_ENABLED | MPUSIZE_1K | RASR_SCB |
RASR_NOACCESS | RASR_NOEXEC;
mpu_set_region(2, start, attr);
最后,我们将系统区域描述为一个可读写、不可执行且不可缓存的区域,以便在 MPU 再次激活后程序仍然能够访问系统寄存器。我们为此使用区域 3:
start = 0xE0000000;
attr = RASR_ENABLED | MPUSIZE_256M | RASR_SB
RASR_RW | RASR_NOEXEC;
mpu_set_region(3, start, attr);
作为最后一步,我们再次启用 MPU。MPU 将允许我们定义一个 背景区域,为那些在活动区域配置中未覆盖的区域设置默认权限。在这种情况下,背景策略的定义缺失导致所有未明确映射的区域都无法访问:
MPU_CTRL = 1;
return 0;
}
设置内存区域起始地址和属性的辅助函数看起来如下:
static void mpu_set_region(int region, uint32_t start, uint32_t attr)
{
MPU_RNR = region;
MPU_RBAR = start;
MPU_RNR = region;
MPU_RASR = attr;
}
在此示例中用于设置 MPU_RASR 中属性和大小的值是根据寄存器本身的结构定义的。MPU_RASR 是一个位域寄存器,包含以下字段:
-
位 0:启用/禁用区域。
-
位 1:5:分区的大小(请参阅分配给此字段的特殊值)。
-
位 16:18:分别指示内存是否可缓冲、可缓存和共享。设备和系统寄存器应始终标记为不可缓存,以确保事务的严格顺序,如本章开头所述。
-
位 24:26:访问权限(读/写),分别针对用户模式和监督模式。
-
XN标志)。
现在可以编写一个溢出栈的程序,并在调用 mpu_enable 函数和不调用时,在调试器中看到差异。如果目标上可用 MPU,现在它能够拦截栈溢出,在 CPU 中触发异常:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/emb-sys-arch-2e/img/B18730_05_06.jpg
图 5.6 – 在 MPU 中将保护区域标记为不可访问,以防止栈溢出
我们在这个案例中为 MPU 使用的配置非常严格,不允许访问任何内存,除了映射闪存和 RAM 的区域。额外的 1-KB 保护区域确保我们可以在运行时检测到栈溢出。实际上,这种配置通过引入一个复制不可访问块的块,在物理上连续的空间中引入了两个分配给堆和栈区域的区域之间的虚假分离。尽管超出堆限制的堆分配不会直接触发溢出,但保护区域中的任何内存访问都会导致内存故障。
在实际应用中,MPU 的配置可能更加复杂,甚至可能在运行时改变其值。例如,在第十章,并行任务和调度中,我们将解释如何在实时操作系统中使用 MPU 来隔离线程地址空间。
摘要
嵌入式系统中的内存管理是大多数关键错误的来源,因此,必须特别关注为使用的平台和应用目的设计和实现正确的解决方案。当可能时,应仔细放置、调整大小和限定执行堆栈。
不提供动态分配的系统更安全,但具有更高复杂性的嵌入式系统从动态分配技术中受益。程序员必须意识到,内存处理中的错误可能对系统至关重要,并且非常难以发现,因此在代码处理动态分配的指针时需要格外小心。
MPU 可以是一个重要的工具,用于在内存区域上强制执行访问权限和属性,并且它可以用于多个目的。在下面的示例中,我们实现了一个基于 MPU 的机制来强制执行堆栈指针的物理边界。
在下一章中,我们将检查现代微控制器中包含的其他常见组件。我们将学习如何处理时钟设置、中断优先级、通用 I/O 通信以及其他可选功能。
第三部分 – 设备驱动程序和通信接口
本部分解释了如何编写嵌入式系统典型的接口和设备驱动程序。本部分将涵盖从外部世界到系统的所有系统通信,直至通过 TCP/IP 进行通信的分布式系统,并将特别关注提高物联网解决方案的安全性。
本部分包含以下章节:
-
第六章,通用外围设备
-
第七章,局部总线接口
-
第八章,电源管理和节能
-
第九章,分布式系统和物联网架构
openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。
更多推荐

所有评论(0)