嵌入式系统架构第二版-全-
嵌入式系统架构第二版(全)
原文:
zh.annas-archive.org/md5/71d9ae8b678429780b0d22ae06fb3bae译者:飞龙
前言
由于微电子制造商和设计师在技术进步方面取得的成果,嵌入式系统在过去二十年里变得越来越受欢迎,这些成果旨在提高计算能力并减小微处理器和外设逻辑的尺寸。
设计、实现和集成这些系统的软件组件通常需要直接针对硬件功能的方法,在这种情况下,任务在单个线程中实现,没有操作系统提供抽象以访问 CPU 功能和外部外设。因此,嵌入式开发被认为是软件开发宇宙中的一个独立领域,其中开发者的方法和工作流程需要相应地调整。
本书简要介绍了典型嵌入式系统的硬件架构,介绍了开始开发目标架构所需的工具和方法,然后引导读者通过系统功能和外设交互进行操作。一些领域,如能效和连接性,被更详细地讨论,以更接近地了解设计低功耗和连接系统的技术。在本书的后期,从单个系统组件的实现开始,构建了一个更复杂的设计,其中包含一个(简化的)实时操作系统。最后,在本版的第二版中,我们增加了对 TrustZone-M 实现的分析,这是 ARM 作为其最新嵌入式微控制器系列的一部分引入的 TEE 技术。
讨论通常集中在特定的安全和安全机制上,通过建议旨在提高系统对应用程序代码中的编程错误或甚至恶意尝试破坏其完整性的鲁棒性的特定技术。
本书面向对象
如果你是一名希望了解嵌入式编程的软件开发者或设计师,这本书就是为你准备的。如果你是一名经验较少或初学者嵌入式程序员,愿意扩展你对嵌入式系统的知识,这本书也会很有用。更有经验的嵌入式软件工程师可能会发现这本书对刷新他们对设备驱动程序内部、内存安全性、安全数据传输、权限分离和安全执行域的知识很有帮助。
本书涵盖的内容
第一章, 嵌入式系统——实用方法, 是基于微控制器嵌入式系统的入门介绍。本书的范围从“嵌入式系统”的更广泛定义到将要分析的领域——具有物理内存映射的 32 位微控制器——得到了明确界定。
第二章, 工作环境和工作流程优化,概述了使用的工具和开发工作流程。这是对工具链、调试器和仿真器的介绍,这些工具可以用来生成二进制格式的代码,该代码可以上传并在目标平台上运行。
第三章, 架构模式,主要关于协作开发和测试的策略和开发方法。本章提出了在开发和测试嵌入式系统软件时通常使用的流程的描述。
第四章, 启动过程,分析了嵌入式系统的启动阶段、启动阶段和引导加载程序。它包含了对启动代码及其用于将软件分成几个启动阶段的机制的详细描述。
第五章, 内存管理,通过指出常见陷阱并解释如何避免可能导致应用程序代码中不可预测或不良行为的内存错误,提出了一些内存管理的最佳策略。
第六章, 通用外围设备,介绍了访问 GPIO 引脚和其他通用集成外围设备的方法。这是目标平台与外部世界的第一次交互,使用电信号执行简单的输入/输出操作。
第七章, 本地总线接口,指导您如何集成串行总线控制器(UART、SPI 和 I2C)。通过解释与嵌入式系统中常见的收发器交互所需的代码,引入了对最常见的总线通信协议的代码导向、详细分析。
第八章, 电源管理和节能,探讨了在节能系统中减少功耗的技术。设计低功耗和超低功耗嵌入式系统需要执行特定步骤以在运行所需任务的同时减少能耗。
第九章, 分布式系统和物联网架构,介绍了构建分布式和连接系统所需的可用协议和接口。物联网系统需要使用第三方库实现的标准化网络协议与远程端点进行通信。特别关注使用安全套接字在端点之间确保通信的安全性。
第十章, 并行任务和调度,通过实现实时任务调度器来解释多任务操作系统的基础设施。本章提出了三种从头开始实现微控制器操作系统的方法,使用不同的调度器(协作、抢占和安全的)。
第十一章,可信执行环境,描述了在嵌入式系统中通常可用的 TEE 机制,并提供了使用 ARM TrustZone-M 运行安全和非安全域的示例。在现代微控制器中,TEE 提供了通过限制从非安全执行域访问来保护特定内存区域或外围设备的机会。
为了充分利用本书
预期您精通 C 语言并了解计算机系统的工作原理。需要一个 GNU 或 Linux 开发机器来应用所解释的概念。有时需要通过提供的示例代码来完全理解实现的机制。鼓励您修改、改进和重用提供的示例,并应用建议的方法。

有关请求工具的附加使用说明可在第二章**,工作环境和 工作流程优化中找到。
如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 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、闪存和收发器的控制寄存器都映射在相同的物理地址空间:

图 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
在规则中自动分配的一些重要变量如下:

表 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变量来更改默认的编译器。以下是一个重要的变量列表,这些变量可能用于更改隐式规则和配方:例如,它可能更改默认的编译器。以下是一个重要的变量列表,这些变量可能用于更改隐式规则和配方:

表 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 寄存器的影响的同时查找编程错误。
嵌入式工作流程
如果与其他领域相比,嵌入式开发生命周期包括一些额外的步骤。代码必须进行交叉编译,然后处理映像并上传到目标,必须运行测试,并且在测量和验证阶段可能需要涉及硬件工具。使用编译语言时,本地应用程序软件的生命周期看起来像这个图表:

图 2.1 – 应用程序开发的典型生命周期
当在同一架构内编写软件时,测试和调试可以在编译后立即进行,通常更容易发现问题。这导致典型循环的时间更短。此外,如果应用程序由于错误而崩溃,底层操作系统可以生成核心转储,这可以在稍后通过调试器进行分析,方法是恢复虚拟内存内容和 CPU 寄存器上下文,在错误出现的那一刻。
另一方面,由于缺乏其他环境中操作系统提供的虚拟地址和内存分段,在嵌入式目标上拦截致命错误可能稍微更具挑战性,因为可能会出现内存和寄存器损坏的潜在副作用。即使某些目标可以通过触发诊断中断来拦截异常情况,例如 Cortex-M 中的硬故障处理程序,恢复生成错误的原始上下文通常是不可能的。
此外,每次生成新的软件时,都需要执行一些耗时步骤,例如将图像转换为特定格式,以及将图像上传到目标本身,这可能需要几秒钟到一分钟的时间,具体取决于图像的大小和与目标通信所使用的接口速度:

图 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 特定机器选项:

表 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 编译器类似,有不同级别的可能优化可供激活。在某些情况下,激活大小优化以生成更小的目标文件是有意义的。然而,在开发过程中,非优化映像适合闪存以方便调试过程更为可取,因为编译器可能会更改代码执行的顺序并隐藏某些变量的内容,这使得优化后的代码流更难跟踪。优化参数可以提供在命令行中,以选择所需的优化级别:

表 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 命令,并提供了它们用法的快速解释:


表 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 输出部分中存储了变量,示例启动代码的内存布局可能如下所示:

图 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 运行:

图 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
在这种情况下,闪存的几何形状如下所示:

图 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,包含一个表,其中包含指向打算从其他阶段导出使用的库函数的指针:

图 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 的微控制器上可能在运行时可配置:

图 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 字节用于存储两个指针:代码部分的调用起源,存储为返回点,以及帧指针,它包含调用之前的旧堆栈指针位置。
按照设计,每次函数被调用时堆栈都会增长,当函数返回时又会缩小。在特定情况下,对运行时堆栈使用量的估计尤其困难,这正是递归函数的目的。因此,在可能的情况下应避免在代码中使用递归,或者将其减少到最小并严格控制在其他情况下,要知道目标中为堆栈保留的内存区域很小:

图 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 区段结束和栈最低地址之间的可用内存可以被应用程序用于动态分配,同时,栈也被允许向相反方向增长。这是利用所有可用内存的有效方法,因为栈不需要一个下界,但只有在从两侧使用的总内存量适合指定区域时才是安全的。如果允许这些区段动态增长到更高的地址,那么如果两侧有重叠,就总有可能发生冲突:

图 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变量中,确保在区域被绘制后绘画停止,但仅限于栈中最后一个未使用的地址:

图 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,以便在现有、之前释放且可重用的块在池中找不到时完成分配:

图 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 中触发异常:

图 5.6 – 在 MPU 中将保护区域标记为不可访问,以防止栈溢出
我们在这个案例中为 MPU 使用的配置非常严格,不允许访问任何内存,除了映射闪存和 RAM 的区域。额外的 1-KB 保护区域确保我们可以在运行时检测到栈溢出。实际上,这种配置通过引入一个复制不可访问块的块,在物理上连续的空间中引入了两个分配给堆和栈区域的区域之间的虚假分离。尽管超出堆限制的堆分配不会直接触发溢出,但保护区域中的任何内存访问都会导致内存故障。
在实际应用中,MPU 的配置可能更加复杂,甚至可能在运行时改变其值。例如,在第十章,并行任务和调度中,我们将解释如何在实时操作系统中使用 MPU 来隔离线程地址空间。
摘要
嵌入式系统中的内存管理是大多数关键错误的来源,因此,必须特别关注为使用的平台和应用目的设计和实现正确的解决方案。当可能时,应仔细放置、调整大小和限定执行堆栈。
不提供动态分配的系统更安全,但具有更高复杂性的嵌入式系统从动态分配技术中受益。程序员必须意识到,内存处理中的错误可能对系统至关重要,并且非常难以发现,因此在代码处理动态分配的指针时需要格外小心。
MPU 可以是一个重要的工具,用于在内存区域上强制执行访问权限和属性,并且它可以用于多个目的。在下面的示例中,我们实现了一个基于 MPU 的机制来强制执行堆栈指针的物理边界。
在下一章中,我们将检查现代微控制器中包含的其他常见组件。我们将学习如何处理时钟设置、中断优先级、通用 I/O 通信以及其他可选功能。
第三部分 – 设备驱动程序和通信接口
本部分解释了如何编写嵌入式系统典型的接口和设备驱动程序。本部分将涵盖从外部世界到系统的所有系统通信,直至通过 TCP/IP 进行通信的分布式系统,并将特别关注提高物联网解决方案的安全性。
本部分包含以下章节:
-
第六章,通用外围设备
-
第七章,局部总线接口
-
第八章,电源管理和节能
-
第九章,分布式系统和物联网架构
第六章:通用外设
现代微控制器集成了多个功能,有助于构建稳定可靠的嵌入式系统。一旦系统启动并运行,就可以访问内存和外设,并具备基本功能。只有在此之后,才能通过激活相关外设的系统寄存器、设置时钟线的正确频率以及配置和激活中断来初始化系统的所有组件。在本章中,我们将描述微控制器暴露的接口,以访问内置外设和一些基本系统功能。我们将重点关注以下主题:
-
中断控制器
-
系统时间
-
通用定时器
-
通用 输入/输出 (GPIO)
-
看门狗
虽然这些外设通常可以通过芯片制造商实现和分发的硬件支持库访问,但我们的方法涉及完全理解硬件组件和所有相关寄存器的含义。这将通过配置和使用微控制器中通过硬件逻辑导出的接口的功能来实现。
在为特定平台设计驱动程序时,有必要研究微控制器提供的用于访问外设和 CPU 功能的接口。在提供的示例中,STM32F4 微控制器被用作实现平台特定功能的参考目标。尽管如此,检查我们参考平台上的可能实现使我们能够更好地了解如何使用硅制造商提供的文档与具有类似功能的通用目标进行交互。
技术要求
您可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/Embedded-Systems-Architecture-Second-Edition/tree/main/Chapter6。
位操作
本章的示例广泛使用了位操作来检查、设置和清除大寄存器(在大多数情况下,为 32 位长)内的单个位。你应该已经熟悉 C 语言中的位逻辑运算。
在示例中常用的操作如下:
-
R |= (1 << N): 寄存器 R 的新值将包含其原始值与一个掩码进行位或操作的位运算结果,该掩码除了我们想要设置的位对应的位置外,其余位均为零,该位被设置为 1 -
R &= ~(1 << N): 寄存器的新值是其原始值与一个掩码进行位与操作的位运算结果,该掩码除了我们想要清除的位对应的位置外,其余位均为 1,该位被设置为 0 -
(R & (1 << N) == (1 << N)): 只有当寄存器的第 N 位被设置时,才返回true
让我们快速跳到第一个主题。
中断控制器
由于现代嵌入式系统的快速发展,特别是中断控制器的研究,实时系统的精度得到了提高。为中断线路分配不同的优先级确保了高优先级中断源具有更低的中断延迟,使系统能够更快地响应优先级事件。然而,中断可能在系统运行时任何时候发生,包括在执行另一个中断服务例程期间。在这种情况下,中断控制器提供了一种链式中断处理程序的方法,执行顺序取决于分配给中断源的优先级级别。
Cortex-M 系列微处理器在实时和低功耗嵌入式应用中的流行,其中一个原因可能是其可编程实时控制器的设计——即嵌套向量中断控制器,简称NVIC。NVIC 支持多达 240 个中断源,这些中断源可以根据存储在微处理器逻辑中的优先级位进行分组,最多可达 256 个优先级级别。这些特性使其非常灵活,因为优先级也可以在系统运行时更改,从而最大化程序员的选择自由度。正如我们所知,NVIC 连接到代码区域开头的向量表。每当发生中断时,处理器会自动将执行应用的当前状态推入堆栈,并执行与中断线路相关联的服务例程。
没有中断优先级机制的系统实现连续中断处理。在这些情况下,中断链意味着在执行第一个服务例程结束时恢复上下文,然后在进入下一个服务例程时再次保存。NVIC 实现了一个尾链机制来执行嵌套中断。如果在另一个服务例程执行期间发生一个或多个中断,通常在中断结束时发生的从堆栈恢复上下文的拉取操作将被取消,控制器将取而代之从中断向量中获取第二个处理器的位置,并确保它在第一个之后立即执行。由于在硬件中实现了堆栈保存和恢复操作的加速,因此在所有这些中断链的情况下,中断延迟都显著降低。得益于其实现,NVIC 允许我们在系统运行时更改参数,并且能够根据优先级重新排列待处理信号的关联中断服务例程的执行顺序。此外,不允许在同一个处理器链中两次运行相同的中断,这可能是由于在其他处理器中更改优先级所引起的。这由 NVIC 逻辑内在强制执行,确保链中不可能出现循环。
外设的中断配置
每个中断线都可以通过位于地址 0xE000E100 和 0xE000E180 的 NVIC 中断设置/清除使能寄存器 NVIC_ISER 和 NVIC_ICER 来启用和禁用。如果目标支持超过 32 个外部中断,则将在相同位置映射 32 位寄存器数组。寄存器中的每个位都用于激活一个预定义的中断线,与该特定寄存器中的位位置相关联。例如,在 STM32F4 微控制器上,为了激活 NVIC_ISER 区域的中断线。
通用 NVIC 函数,用于启用中断,会在关联的 NVIC_ISER 寄存器中激活对应于 NVIC 中断号的标志:
#define NVIC_ISER_BASE (0xE000E100)
static inline void nvic_irq_enable(uint8_t n)
{
int i = n / 32;
volatile uint32_t *nvic_iser =
((volatile uint32_t *)(NVIC_ISER_BASE + 4 * i));
*nvic_iser |= (1 << (n % 32));
}
同样,要禁用中断,nvic_irq_disable 函数会在中断清除寄存器中激活相应的位:
#define NVIC_ICER_BASE (0xE000E180)
static inline void nvic_irq_disable(uint8_t n)
{
int i = n / 32;
volatile uint32_t *nvic_icer =
((volatile uint32_t *)(NVIC_ICER_BASE + 4 * i));
*nvic_icer |= (1 << (n % 32));
}
中断优先级映射在一个 8 位寄存器数组中,每个寄存器包含对应中断线的优先级值,从地址 0xE000E400 开始,以便它们可以独立访问以在运行时更改优先级:
#define NVIC_IPRI_BASE (0xE000E400)
static inline void nvic_irq_setprio(uint8_t n,
uint8_t prio)
{
volatile uint8_t *nvic_ipri = ((volatile uint8_t *)
(NVIC_IPRI_BASE + n));
*nvic_ipri = prio;
}
这些函数在为外设启用中断时,将有助于路由和优先级排序中断线。
系统时间
时间管理几乎是任何嵌入式系统的基本要求。微控制器可以被编程为在固定时间间隔触发中断,这通常用于递增单调系统时钟。为此,必须在启动时执行几个配置步骤,以便有一个稳定的滴答中断。许多处理器可以使用与 CPU 相同的振荡器作为源以自定义频率运行。振荡器的输入频率,可以是 CPU 内部或外部的,用于推导处理器的主时钟。集成到 CPU 中的可配置逻辑通过一个 锁相环(PLL)实现,该环将外部稳定源的输入时钟乘以,产生 CPU 和集成外设使用的所需频率。
调整闪存等待状态
如果初始化代码是从闪存中运行的,在更改系统时钟之前可能需要设置闪存内存的等待状态。如果微处理器以高频率运行,它可能需要在两个连续访问操作之间对具有 0x40023800 的持久性内存设置几个等待状态。访问控制寄存器(ACR),我们需要访问以设置等待状态的寄存器,位于该区域的开始处:
#define FLASH_BASE (0x40023C00)
#define FLASH_ACR (*(volatile uint32_t *)(FLASH_BASE +
0x00))
FLASH_ACR 寄存器的最低三位用于设置等待状态的数量。根据 STM32F407 数据手册,当系统以 168 MHz 运行时,访问闪存的理想等待状态数量是 5。同时,我们可以通过激活位 10 和 9 分别启用数据和指令缓存:
void flash_set_waitstates(void) {
FLASH_ACR = 5 | (1 << 10) | (1 << 9);
}
设置等待状态后,在将 CPU 频率设置得更高后,从闪存中运行代码是安全的,因此我们可以继续进行实际的时钟配置和分配到外设。
时钟配置
Cortex-M 微控制器中的时钟配置是通过位于内部外设区域特定地址的 重置和时钟控制(RCC)寄存器来完成的。RCC 配置是供应商特定的,因为它取决于微控制器中实现的 PLL 逻辑。寄存器在微控制器的文档中描述,通常,芯片制造商提供示例源代码,演示如何在微控制器上正确配置时钟。在我们的参考目标 STM32F407 上,假设使用外部 8 MHz 振荡器作为源,以下过程配置了一个 168 MHz 系统时钟,并确保时钟也分配到每个外设总线。以下代码确保 PLL 以所需值初始化,并且 CPU 时钟以期望的频率滴答。此过程在许多 STM Cortex-M 微控制器中很常见,PLL 配置的值可以从芯片文档中获得,或使用 ST 提供的软件工具计算。
此处提供的软件示例将使用系统特定模块,导出配置时钟和设置闪存延迟所需的函数。我们现在分析两种可能的 PLL 配置实现,针对两种不同的 Cortex-M 微控制器。
要访问 STM32F407-Discovery 中 PLL 的配置,首先,我们定义了一些快捷宏到由RCC提供的寄存器地址:
#define RCC_BASE (0x40023800)
#define RCC_CR (*(volatile uint32_t *)(RCC_BASE + 0x00))
#define RCC_PLLCFGR (*(volatile uint32_t *)(RCC_BASE +
0x04))
#define RCC_CFGR (*(volatile uint32_t *)(RCC_BASE + 0x08))
#define RCC_CR (*(volatile uint32_t *)(RCC_BASE + 0x00))
为了提高可读性,并确保代码在未来可维护,我们还定义了与相应寄存器中单比特值相关的助记符:
#define RCC_CR_PLLRDY (1 << 25)
#define RCC_CR_PLLON (1 << 24)
#define RCC_CR_HSERDY (1 << 17)
#define RCC_CR_HSEON (1 << 16)
#define RCC_CR_HSIRDY (1 << 1)
#define RCC_CR_HSION (1 << 0)
#define RCC_CFGR_SW_HSI 0x0
#define RCC_CFGR_SW_HSE 0x1
#define RCC_CFGR_SW_PLL 0x2
#define RCC_PLLCFGR_PLLSRC (1 << 22)
#define RCC_PRESCALER_DIV_NONE 0
#define RCC_PRESCALER_DIV_2 8
#define RCC_PRESCALER_DIV_4 9
最后,我们定义了用于配置 PLL 的平台特定常量值:
#define CPU_FREQ (168000000)
#define PLL_FULL_MASK (0x7F037FFF)
#define PLLM 8
#define PLLN 336
#define PLLP 2
#define PLLQ 7
#define PLLR 0
定义了一个额外的宏来调用DMB汇编指令,为了简洁起见,因为它将在代码中使用,以确保在执行下一个语句之前完成任何挂起的配置寄存器内存传输:
#define DMB() asm volatile ("dmb");
下一个函数将确保执行 PLL 初始化序列,以设置正确的 CPU 频率。首先,它将启用内部高速振荡器,并通过轮询 CR 来等待其就绪:
void rcc_config(void)
{
uint32_t reg32;
RCC_CR |= RCC_CR_HSION;
DMB();
while ((RCC_CR & RCC_CR_HSIRDY) == 0)
;
然后选择内部振荡器作为临时时钟源:
reg32 = RCC_CFGR;
reg32 &= ~((1 << 1) | (1 << 0));
RCC_CFGR = (reg32 | RCC_CFGR_SW_HSI);
DMB();
然后以相同的方式激活外部振荡器:
RCC_CR |= RCC_CR_HSEON;
DMB();
while ((RCC_CR & RCC_CR_HSERDY) == 0)
;
在此设备上,时钟可以通过三个系统总线分布到所有外围设备。使用预分频器,每个总线的频率可以按 2 或 4 的倍数缩放。在这种情况下,我们将 HPRE、PPRE1 和 PPRE2 的时钟速度分别设置为 168、84 和 46 MHz:
reg32 = RCC_CFGR;
reg32 &= ~0xF0;
RCC_CFGR = (reg32 | (RCC_PRESCALER_DIV_NONE << 4));
DMB();
reg32 = RCC_CFGR;
reg32 &= ~0x1C00;
RCC_CFGR = (reg32 | (RCC_PRESCALER_DIV_2 << 10));
DMB();
reg32 = RCC_CFGR;
reg32 &= ~0x07 << 13;
RCC_CFGR = (reg32 | (RCC_PRESCALER_DIV_4 << 13));
DMB();
PLL 配置寄存器被设置为包含将外部振荡器频率正确缩放到所需值的参数:
reg32 = RCC_PLLCFGR;
reg32 &= ~PLL_FULL_MASK;
RCC_PLLCFGR = reg32 | RCC_PLLCFGR_PLLSRC | PLLM |
(PLLN << 6) | (((PLLP >> 1) - 1) << 16) |
(PLLQ << 24);
DMB();
然后激活 PLL,执行将暂停,直到输出稳定:
RCC_CR |= RCC_CR_PLLON;
DMB();
while ((RCC_CR & RCC_CR_PLLRDY) == 0);
将 PLL 选为系统时钟的最终源:
reg32 = RCC_CFGR;
reg32 &= ~((1 << 1) | (1 << 0));
RCC_CFGR = (reg32 | RCC_CFGR_SW_PLL);
DMB();
while ((RCC_CFGR & ((1 << 1) | (1 << 0))) !=
RCC_CFGR_SW_PLL);
内部振荡器不再使用,可以禁用。控制权返回调用者,所有时钟都成功设置。
如前所述,时钟初始化的步骤严格依赖于微控制器中的 PLL 配置。为了正确初始化 CPU 和外围设备所需的系统时钟,以便在期望的频率下运行,始终建议参考硅制造商提供的微控制器数据手册。作为第二个例子,我们可以验证RCC和RCC2:
#define RCC (*(volatile uint32_t*))(0x400FE060)
#define RCC2 (*(volatile uint32_t*))(0x400FE070)
要将RCC寄存器重置到已知状态,必须在启动时将这些寄存器写入重置值:
#define RCC_RESET (0x078E3AD1)
#define RCC2_RESET (0x07802810)
此微控制器使用原始中断来通知 PLL 已锁定到请求的频率。可以通过读取原始中断状态(RIS)寄存器中的位6来检查中断状态:
#define RIS (*(volatile uint32_t*))(0x400FE050)
#define PLL_LRIS (1 << 6)
在这种情况下,时钟配置例程首先重置RCC寄存器,并设置适当的值以配置 PLL。PLL 被配置为从 8 MHz 振荡器源生成 400 MHz 时钟:
void rcc_config(void)
{
RCC = RCC_RESET;
RCC2 = RCC2_RESET;
DMB();
RCC = RCC_SYSDIV_50MHZ | RCC_PWMDIV_64 |
RCC_XTAL_8MHZ_400MHZ | RCC_USEPWMDIV;
从这个主 400 MHz 时钟通过系统分频器得到的 50 MHz CPU 频率。时钟先被预分频两次,然后应用一个4的倍数:
RCC2 = RCC2_SYSDIV2_4;
DMB();
外部振荡器也被启用:
RCC &= ~RCC_OFF;
RCC2 &= ~RCC2_OFF;
并且系统时钟分频器也被启用。同时,设置旁路位确保振荡器被用作系统时钟的源,PLL 被旁路:
RCC |= RCC_BYPASS | RCC_USESYSDIV;
DMB();
执行将保持,直到 PLL 稳定并锁定在所需的频率:
while ((RIS & PLL_LRIS) == 0) ;
在此点禁用RCC寄存器中的旁路位足以将 PLL 输出连接到系统时钟:
RCC &= ~RCC_BYPASS;
RCC2 &= ~RCC2_BYPASS;
}
时钟分配
一旦总线时钟可用,RCC逻辑可以被编程以将时钟分配给单个外设。为此,RCC公开了位图外设时钟源寄存器。在其中一个寄存器中设置相应的位将启用微控制器中每个映射外设的时钟。每个寄存器可以控制 32 个外设的时钟门控。
外设的顺序以及相应的寄存器和位严格依赖于特定的微控制器。STM32F4 有三个寄存器专门用于此目的。例如,为了启用内部看门狗的时钟源,只需在地址0x40021001c的时钟使能寄存器中将位号9设置为1:
#define APB1_CLOCK_ER (*(uint32_t *)(0x4002001c))
#define WDG_APB1_CLOCK_ER_VAL (1 << 9)
APB1_CLOCK_ER |= WDG_APB1_CLOCK_ER_VAL;
关闭未使用外设的时钟源可以节省电源;因此,如果目标支持时钟门控,它可以通过在运行时通过它们的时钟门控禁用单个外设来实现功耗的优化和微调。
启用 SysTick
一旦设置了稳定的 CPU 频率,我们就可以配置系统上的主定时器——SysTick。由于并非所有 Cortex-M 都需要特定的系统定时器实现,有时有必要使用普通辅助定时器来跟踪系统时间。然而,在大多数情况下,可以通过访问其配置来启用 SysTick 中断,该配置位于系统配置区域内的系统控制块中。在所有包含系统滴答的 Cortex-M 微控制器中,配置可以从地址0xE000E010开始找到,并公开了四个寄存器:
-
控制状态寄存器(
SYSTICK_CSR)偏移量为0 -
重载值寄存器(
SYSTICK_RVR)偏移量为4 -
当前值寄存器(
SYSTICK_CVR)偏移量为8 -
校准寄存器(
SYSTICK_CALIB)偏移量为12
SysTick 作为一个倒计时计时器工作。它持有一个 24 位值,每次 CPU 时钟滴答时该值会减少。当计时器达到0时,它会重新加载相同的值,如果配置为这样做,则触发 SysTick 中断。
作为访问 SysTick 寄存器的快捷方式,我们定义了它们的地址:
#define SYSTICK_BASE (0xE000E010)
#define SYSTICK_CSR (*(volatile uint32_t *)(SYSTICK_BASE +
0x00))
#define SYSTICK_RVR (*(volatile uint32_t *)(SYSTICK_BASE +
0x04))
#define SYSTICK_CVR (*(volatile uint32_t *)(SYSTICK_BASE +
0x08))
#define SYSTICK_CALIB (*(volatile uint32_t *)(SYSTICK_BASE
+ 0x0C))
由于我们知道 CPU 的频率(Hz),我们可以通过设置0中的值来定义系统滴答间隔,这样在启用倒计时后,第一个中断立即触发。通过配置控制/状态寄存器,SysTick 最终可以启用。CSR 最低三位的意义如下:
-
位 0:启用倒计时。在此位设置后,SysTick 计时器的计数器在每次 CPU 时钟间隔自动减少。
-
0,将生成 SysTick 中断。 -
位 2:源时钟选择。如果此位被清除,则使用外部参考时钟作为源。当此位被设置时,使用 CPU 时钟作为源。
我们将定义一个自定义的 SysTick 中断处理程序,因此我们还想设置位1。因为我们正确地配置了 CPU 时钟,并且我们在其上缩放系统滴答间隔重载值,所以我们还想设置位2。我们systick_enable例程的最后一行将一起在 CSR 中启用这三个位:
void systick_enable(void) {
SYSTICK_RVR = ((CPU_FREQ / 1000) - 1);
SYSTICK_CVR = 0;
SYSTICK_CSR = (1 << 0) | (1 << 1) | (1 << 2);
}
我们配置的系统计时器与实时操作系统(RTOSs)用于启动进程切换所使用的计时器相同。在我们的情况下,保持一个单调的系统墙钟,测量自时钟配置以来经过的时间,可能会有所帮助。系统计时器的中断服务例程的最小化实现可能如下所示:
volatile unsigned int jiffies = 0;
void isr_systick(void)
{
++jiffies;
}
这个简单的函数以及相关的全局volatile变量足以在应用程序运行时透明地跟踪时间。实际上,系统滴答中断是独立发生的,在jiffies变量在中断处理程序中增加时,以固定的时间间隔发生,而不改变主应用程序的流程。实际上发生的情况是,每当系统滴答计数器达到0时,执行被暂停,中断例程快速执行。当isr_systick返回时,通过恢复中断发生前存储在内存中的相同的执行上下文,主应用程序的流程得以恢复。
系统计时器变量必须在所有地方定义为volatile的原因是,其值在执行应用程序时可能会以与编译器对局部执行上下文行为可能预测的方式独立地改变。在这种情况下,volatile关键字确保编译器被迫产生代码,每次实例化变量时都检查变量的值,通过禁止使用基于变量未由局部代码修改的虚假假设的优化。
这里是一个使用前面函数来启动系统、配置主时钟和启用 SysTick 的示例主程序:
void main(void) {
flash_set_waitstates();
clock_config();
systick_enable();
while(1) {
WFI();
}
}
定义了WFI汇编指令的快捷方式(代表等待中断)。它在主应用程序中使用,以保持 CPU 在下一个中断发生之前不活动:
#define WFI() asm volatile ("wfi")
为了验证 SysTick 实际上正在运行,可以在调试器附加的情况下执行程序,并在一段时间后停止。如果系统滴答已正确配置,jiffies变量应始终显示自引导以来经过的毫秒数。
通用定时器
对于低端微控制器,提供 SysTick 定时器不是强制性的。某些目标可能没有系统定时器,但它们都暴露了某种类型的接口,以便程序能够实现时间驱动的操作。定时器通常非常灵活且易于配置,并且通常能够以固定间隔触发中断。STM32F4 提供了多达 17 个定时器,每个定时器都有不同的特性。定时器通常相互独立,因为每个定时器都有自己的中断线和单独的外设时钟门。例如,在 STM32F4 上,启用定时器 2 的时钟源和中断线的步骤如下。定时器接口基于一个计数器,它在每个滴答时增加或减少。该平台暴露的接口非常灵活,支持多个功能,包括选择不同的时钟源作为输入,将定时器串联的可能性,甚至可以编程的定时器实现内部结构。可以配置定时器向上或向下计数,并在内部计数器的不同值上触发中断事件。定时器可以是单次或连续的。
通常可以在硅供应商提供的支持库中找到对定时器接口的抽象,或者在其他开源库中。然而,为了理解微控制器暴露的接口,这里提供的示例再次直接通过配置寄存器与外围设备进行通信。
此示例主要使用 STM32F407 上通用定时器的默认设置。默认情况下,计数器在每个滴答时增加,直到其自动重载值,并在溢出时连续生成中断事件。可以设置预分频值来分频时钟源,以增加可能间隔的范围。为了生成以恒定间隔分散的中断,只需要访问几个寄存器:
-
控制寄存器 1和2(CR1和CR2)
-
直接内存访问(DMA)/中断使能寄存器(DIER)
-
状态寄存器(SR)
-
预分频计数器(PSC)
-
自动重载寄存器(ARR)
通常,这些寄存器的偏移量对所有定时器都是相同的,因此,给定基本地址,它们可以通过宏来计算。在这种情况下,只定义了正在使用的定时器的寄存器:
#define TIM2_BASE (0x40000000)
#define TIM2_CR1 (*(volatile uint32_t *)(TIM2_BASE + 0x00))
#define TIM2_DIER (*(volatile uint32_t *)(TIM2_BASE +
0x0c))
#define TIM2_SR (*(volatile uint32_t *)(TIM2_BASE + 0x10))
#define TIM2_PSC (*(volatile uint32_t *)(TIM2_BASE + 0x28))
#define TIM2_ARR (*(volatile uint32_t *)(TIM2_BASE + 0x2c))
此外,为了提高可读性,我们在将要配置的寄存器中定义了一些相关的位位置:
#define TIM_DIER_UIE (1 << 0)
#define TIM_SR_UIF (1 << 0)
#define TIM_CR1_CLOCK_ENABLE (1 << 0)
#define TIM_CR1_UPD_RS (1 << 2)
首先,我们将定义一个服务例程。timer接口要求我们在状态寄存器中清除一个标志,以确认中断。在这个简单的例子中,我们只是增加一个局部变量,以便我们可以通过在调试器中检查它来验证timer是否正在执行。我们将timer2_ticks变量标记为volatile,这样编译器就不会将其优化掉,因为它在代码中从未使用过:
void isr_tim2(void)
{
static volatile uint32_t timer2_ticks = 0;
TIM2_SR &= ~TIM_SR_UIF;
timer2_ticks++;
}
服务例程必须通过在startup.c中定义的中断向量中正确位置包含函数指针来关联:
isr_tim2 , // TIM2_IRQ 28
如果计时器连接到时钟树中的不同分支,就像这个例子一样,我们需要在计算预分频器和重载阈值时,考虑到时钟总线(为计时器供电)和实际 CPU 时钟频率之间的额外缩放因子。在 STM32F407 上,计时器 2 连接到高级外设总线(APB)总线,该总线运行在 CPU 频率的一半。
此初始化是一个函数示例,该函数自动计算TIM2_PSC和TIM2_ARR值,并根据给定的毫秒数间隔初始化计时器。时钟变量必须设置为计时器时钟源的频率,这可能与 CPU 频率不同。
以下定义仅适用于我们的平台,将时钟门控配置的地址和我们要使用的设备的中断号映射:
#define APB1_CLOCK_ER (*(volatile uint32_t *)(0x40023840))
#define APB1_CLOCK_RST (*(volatile uint32_t *)
(0x40023820))
#define TIM2_APB1_CLOCK_ER_VAL (1 << 0)
#define NVIC_TIM2_IRQN (28)
以下是从main函数中调用的函数,用于在期望的间隔内启用连续的计时器中断:
int timer_init(uint32_t clock, uint32_t interval_ms)
{
uint32_t val = 0;
uint32_t psc = 1;
uint32_t err = 0;
clock = (clock / 1000) * interval_ms;
while (psc < 65535) {
val = clock / psc;
err = clock % psc;
if ((val < 65535) && (err == 0)) {
val--;
break;
}
val = 0;
psc++;
}
if (val == 0)
return -1;
nvic_irq_enable(NVIC_TIM2_IRQN);
nvic_irq_setprio(NVIC_TIM2_IRQN, 0);
APB1_CLOCK_RST |= TIM2_APB1_CLOCK_ER_VAL;
DMB();
TIM2_PSC = psc;
TIM2_ARR = val;
TIM2_CR1 |= TIM_CR1_CLOCK_ENABLE;
TIM2_DIER |= TIM_DIER_UIE;
DMB();
return 0;
}
这里展示的示例只是系统定时器可能应用的一种可能。在参考平台上,定时器可用于不同的目的,例如测量脉冲之间的间隔、相互同步或根据选择的频率和占空比定期激活信号。这种最后一种用法将在本章后面的PWM小节中解释。对于目标上通用定时器的所有其他用途,请参阅所使用微控制器的参考手册。现在,我们的系统已配置就绪,可以运行,并且我们已经学会了如何管理时间和生成同步事件,现在是时候介绍我们的第一个外设,开始与外部世界通信了。在下一节中,我们将介绍 GPIO 引脚的多种配置,这些配置允许驱动或感应单个微控制器引脚上的电压。
GPIO
大多数微控制器芯片的引脚代表可配置的 I/O 线。每个引脚都可以配置为通过驱动引脚的电压作为数字输出来表示逻辑电平,或者通过比较电压作为数字输入来感知逻辑状态。尽管如此,一些通用引脚可以与替代功能相关联,例如模拟输入、串行接口或计时器的输出脉冲。引脚可能有几种可能的配置,但一次只能激活一个。GPIO 控制器公开所有引脚的配置,并在使用替代功能时管理引脚与子系统的关联。
引脚配置
根据 GPIO 控制器的逻辑,引脚可以全部激活、单独激活或分组激活。为了实现设置引脚并按需使用它们的驱动程序,可以参考微控制器的数据手册或硅供应商提供的任何示例实现。
在 STM32F4 的情况下,GPIO 引脚被分为组。每个组连接到一个单独的时钟门,因此,要使用与组关联的引脚,必须启用时钟门。以下代码将时钟源分配给组 D 的 GPIO 控制器:
#define AHB1_CLOCK_ER (*(volatile uint32_t *)(0x40023840))
#define GPIOD_AHB1_CLOCK_ER (1 << 3)
AHB1_CLOCK_ER |= GPIOD_AHB1_CLOCK_ER;
与 GPIO 控制器关联的配置寄存器映射到外设区域中的特定区域。对于 GPIOD 控制器,基本地址为 0x40020C00。在 STM32F4 微控制器上,有 10 个不同的寄存器用于配置和使用每个数字 I/O 组。由于组最多由 16 个引脚组成,一些寄存器可能使用每引脚 2 位的表示:
-
模式寄存器(地址空间中的偏移量
0)选择模式(在数字输入、数字输出、替代功能或模拟输入之间),每引脚使用 2 位 -
输出类型寄存器(偏移量
4)选择输出信号驱动逻辑(推挽或开漏) -
输出速度寄存器(偏移量
8)选择输出驱动速度 -
拉起寄存器(偏移量
12)启用或禁用内部上拉或下拉电阻 -
端口输入数据(偏移量
16)用于读取数字输入引脚的状态 -
端口输出数据(偏移量
20)包含数字输出的当前值 -
端口位设置/重置(偏移量
24)用于驱动数字输出信号高或低 -
端口配置锁定(偏移量
28) -
替代功能低位寄存器(偏移量
32),每引脚 4 位,引脚 0-7 -
替代功能高位寄存器(偏移量
36),每引脚 4 位,引脚 8-15
在使用之前必须配置引脚,并配置时钟门以将源时钟路由到控制器的组。此 GPIO 控制器上的配置可以通过查看具体示例来更好地解释。
数字输出
通过在模式寄存器中对应于给定引脚的位中将模式设置为输出,可以启用数字输出。为了能够控制连接到我们参考平台上的 LED 的 D13 引脚的电平,我们需要访问以下寄存器:
#define GPIOD_BASE 0x40020c00
#define GPIOD_MODE (*(volatile uint32_t *)(GPIOD_BASE +
0x00))
#define GPIOD_OTYPE (*(volatile uint32_t *)(GPIOD_BASE +
0x04))
#define GPIOD_PUPD (*(volatile uint32_t *)(GPIOD_BASE +
0x0c))
#define GPIOD_ODR (*(volatile uint32_t *)(GPIOD_BASE +
0x14))
#define GPIOD_BSRR (*(volatile uint32_t *)(GPIOD_BASE +
0x18))
在后续示例中,使用备用功能来更改引脚分配。包含备用功能设置的寄存器如下所示:
#define GPIOD_AFL (*(volatile uint32_t *)(GPIOD_BASE +
0x20))
#define GPIOD_AFH (*(volatile uint32_t *)(GPIOD_BASE +
0x24))
以下简单函数旨在控制连接到 STM32F4 上的蓝色 LED 的 D15 引脚的输出。主程序必须在调用任何其他函数之前调用led_setup,以便将引脚配置为输出并激活上拉/下拉内部电阻:
#define LED_PIN (15)
void led_setup(void)
{
uint32_t mode_reg;
首先,配置时钟门控以启用GPIOD控制器的时钟源:
AHB1_CLOCK_ER |= GPIOD_AHB1_CLOCK_ER;
模式寄存器被修改以设置 GPIO D15 的模式为数字输出。此操作分为两步。在寄存器中对应于引脚模式位置的 2 位中设置的任何先前值都会被清除:
GPIOD_MODE &= ~ (0x03 << (LED_PIN * 2));
在相同的位置,设置值为1,这意味着引脚现在配置为数字输出:
GPIOD_MODE |= 1 << (LED_PIN * 2);
要启用上拉和下拉内部电阻,我们执行相同的操作。在这种情况下要设置的值是2,对应以下内容:
GPIOD_PUPD &= ~(0x03 << (LED_PIN * 2));
GPIOD_PUPD |= 0x02 << (LED_PIN * 2);
}
在调用setup函数之后,应用程序和中断处理程序可以调用导出的函数,通过操作设置/重置寄存器来设置引脚的高电平或低电平值:
void led_on(void)
{
GPIOD_BSRR |= 1 << LED_PIN;
}
BSRR的最高半部分用于重置引脚。在重置寄存器位中写入1将引脚逻辑电平驱动到低电平:
void led_off(void)
{
GPIOD_BSRR |= 1 << (LED_PIN + 16);
}
定义了一个便利函数,用于在开和关之间切换 LED 值:
void led_toggle(void)
{
if ((GPIOD_ODR & (1 << LED_PIN)) == (1 << LED_PIN))
led_off();
else
led_on();
}
使用上一节中配置的timer,可以运行一个小程序,闪烁 STM32F407-Discovery 上的蓝色 LED。led_toggle函数可以从上一节中实现的timer的服务例程内部调用:
void isr_tim2(void)
{
TIM2_SR &= ~TIM_SR_UIF;
led_toggle();
}
在主程序中,在启动定时器之前必须初始化 LED 驱动器:
void main(void) {
flash_set_waitstates();
clock_config();
led_setup();
timer_init(CPU_FREQ, 1, 1000);
while(1)
WFI();
}
程序的主循环为空。每秒调用一次led_toggle操作来闪烁 LED。
PWM
脉冲宽度调制,或简称PWM,是一种常用的技术,用于控制不同类型的执行器,将消息编码到具有不同脉冲宽度的信号中,并且通常在数字输出线上以固定频率和可变占空比生成脉冲,用于不同的目的。
timer接口可能允许将引脚关联到输出 PWM 信号。在我们的参考微控制器上,四个输出比较通道可以与通用定时器关联,连接到 OC 通道的引脚可以配置为自动输出编码输出。在 STM32F407-Discovery 板上,用于演示数字输出功能的先前示例中的蓝色 LED 引脚 PD15,与可以由定时器 4 驱动的 OC4 关联。根据芯片文档,选择引脚的备用功能 2 直接将输出引脚连接到 OC4。
以下图示显示了使用备用功能 2 将引脚连接到定时器输出的引脚配置:

图 6.1 – 配置引脚 D15 使用备用功能 2 将其连接到定时器的输出
引脚被初始化,并设置为使用备用配置而不是普通数字输出,通过清除MODE寄存器位并将值设置为2:
GPIOD_MODE &= ~ (0x03 << (LED_PIN * 2));
GPIOD_MODE |= (2 << (LED_PIN * 2));
本 GPIO 组中的引脚 0 到 7 在GPIOD控制器的 AFL 寄存器中每个使用 4 位。更高位的引脚,在 8-15 范围内,在 AFH 寄存器中每个使用 4 位。一旦选择了备用模式,正确的备用功能号被编程到与引脚 15 关联的 4 位中,因此在这种情况下我们使用 AFH 寄存器:
uint32_t value;
if (LED_PIN < 8) {
value = GPIOD_AFL & (~(0xf << (LED_PIN * 4)));
GPIOD_AFL = value | (0x2 << (LED_PIN * 4));
} else {
value = GPIOD_AFH & (~(0xf << ((LED_PIN - 8) * 4)));
GPIOD_AFH = value |(0x2 << ((LED_PIN - 8) * 4));
}
pwm_led_init()函数,我们可以从主程序中调用它来配置 LED 引脚 PD15,将如下所示:
void led_pwm_setup(void)
{
AHB1_CLOCK_ER |= GPIOD_AHB1_CLOCK_ER;
GPIOD_MODE &= ~ (0x03 << (LED_PIN * 2));
GPIOD_MODE |= (2 << (LED_PIN * 2));
GPIOD_OSPD &= ~(0x03 << (LED_PIN * 2));
GPIOD_OSPD |= (0x03 << (LED_PIN * 2));
GPIOD_PUPD &= ~(0x03 << (LED_PIN * 2));
GPIOD_PUPD |= (0x02 << (LED_PIN * 2));
GPIOD_AFH &= ~(0xf << ((LED_PIN - 8) * 4));
GPIOD_AFH |= (0x2 << ((LED_PIN - 8) * 4));
}
设置定时器以生成 PWM 的函数与数字输出示例中使用的简单中断生成定时器中的函数类似,除了配置定时器以输出 PWM 需要修改四个额外的寄存器值:
-
捕获/比较使能 寄存器 (CCER)
-
捕获/比较模式寄存器 1 和 2 (CCMR1 和 CCMR2)
-
捕获通道 4 (CC4) 配置
我们将在示例中用于配置给定占空比的 PWM 函数的签名如下:
int pwm_init(uint32_t clock, uint32_t dutycycle)
{
启用时钟门控以打开定时器 4 仍然是必需的:
APB1_CLOCK_RST &= ~TIM4_APB1_CLOCK_ER_VAL;
APB1_CLOCK_ER |= TIM4_APB1_CLOCK_ER_VAL;
定时器和其输出比较通道暂时禁用,以便从干净的状态开始配置:
TIM4_CCER &= ~TIM_CCER_CC4_ENABLE;
TIM4_CR1 = 0;
TIM4_PSC = 0;
对于本例,我们可以使用固定的 PWM 频率 100 kHz,通过将自动重载值设置为输入时钟的1/100000,并强制不使用预分频器:
uint32_t val = clock / 100000;
负载周期是根据传递给pwm_init()函数的第二个参数的值计算的,该值以百分比表示。为了计算相应的阈值水平,使用以下简单公式,例如,值为 80 表示 PWM 将在 4/5 的时间内处于活动状态。结果值减去 1,除非为零,以避免下溢:
lvl = (val * threshold) / 100;
if (lvl != 0)
lvl--;
比较器值寄存器CCR4和自动重载值寄存器ARR相应设置。此外,在这种情况下,ARR的值减少 1,以考虑基于零的计数器:
TIM4_ARR = val - 1;
TIM4_CCR4 = lvl;
为了正确设置此平台上的 PWM 信号,我们首先确保我们将要配置的CCMR1寄存器部分被正确清除。这包括捕获选择和模式配置:
TIM4_CCMR1 &= ~(0x03 << 0);
TIM4_CCMR1 &= ~(0x07 << 4);
选择的PWM1模式是基于捕获/比较定时器的可能交替配置之一。为了启用该模式,我们在清除寄存器相关位后,在CCMR2中设置PWM1值:
TIM4_CCMR1 &= ~(0x03 << 0);
TIM4_CCMR1 &= ~(0x07 << 4);
TIM4_CCMR1 |= TIM_CCMR1_OC1M_PWM1;
TIM4_CCMR2 &= ~(0x03 << 8);
TIM4_CCMR2 &= ~(0x07 << 12);
TIM4_CCMR2 |= TIM_CCMR2_OC4M_PWM1;
最后,我们启用输出比较器 OC4。然后,定时器被设置为在计数器溢出时自动重载其存储的值:
TIM4_CCMR2 |= TIM_CCMR2_OC4M_PWM1;
TIM4_CCER |= TIM_CCER_CC4_ENABLE;
TIM4_CR1 |= TIM_CR1_CLOCK_ENABLE | TIM_CR1_ARPE;
}
使用 PWM 驱动 LED 上的电压可以改变其亮度,根据配置的占空比。例如,以下示例程序将 LED 的亮度降低到 50%,与由恒定电压输出供电的 LED 相比,例如数字输出示例中的 LED:
void main(void) {
flash_set_waitstates();
clock_config();
led_pwm_setup();
pwm_init(CPU_FREQ, 50);
while(1)
WFI();
}
通过动态改变占空比,可以更好地可视化 PWM 对 LED 亮度的效果。例如,可以设置一个定时器,每隔 50 毫秒产生一个中断。在中断处理程序中,占空比因子在 0-80%的范围内循环,使用 16 个步骤。在前 8 个步骤中,每个中断占空比增加 10%,从 0 增加到 80%,在最后 8 个步骤中,以相同的速率减少,将占空比恢复到0:
void isr_tim2(void) {
static uint32_t tim2_ticks = 0;
TIM2_SR &= ~TIM_SR_UIF;
if (tim2_ticks > 16)
tim2_ticks = 0;
if (tim2_ticks > 8)
pwm_init(master_clock, 10 * (16 - tim2_ticks));
else
pwm_init(master_clock, 10 * tim2_ticks);
tim2_ticks++;
}
如果我们在主程序中将定时器 2 初始化为触发恒定间隔中断,就像前面的示例一样,我们可以看到 LED 以有节奏的节奏闪烁,明暗交替。
在这种情况下,定时器 2 由主程序初始化,其关联的中断处理程序每秒更新定时器 4 的设置 20 次:
void main(void) {
flash_set_waitstates();
clock_config();
led_pwm_setup();
pwm_init(CPU_FREQ, 0);
timer_init(CPU_FREQ, 1, 50);
while(1)
WFI();
}
数字输入
一个配置为输入模式的 GPIO 引脚检测施加在其上的电压的逻辑电平。GPIO 控制器上所有输入引脚的逻辑值都可以从输入数据寄存器(IDR)中读取。在参考板上,引脚 A0 连接到用户按钮,因此可以在应用程序运行时随时读取按钮的状态。
可以通过时钟门控来开启GPIOA控制器:
#define AHB1_CLOCK_ER (*(volatile uint32_t *)(0x40023830))
#define GPIOA_AHB1_CLOCK_ER (1 << 0)
控制器本身映射到地址0x40020000:
#define GPIOA_BASE 0x40020000
#define GPIOA_MODE (*(volatile uint32_t *)(GPIOA_BASE +
0x00))
#define GPIOA_IDR (*(volatile uint32_t *)(GPIOA_BASE +
0x10))
为了设置引脚为输入,我们只需确保模式设置为0,通过清除与引脚0相关的两个模式位:
#define BUTTON_PIN (0)
void button_setup(void)
{
AHB1_CLOCK_ER |= GPIOA_AHB1_CLOCK_ER;
GPIOA_MODE &= ~ (0x03 << (BUTTON_PIN * 2));
}
应用程序现在可以通过读取 IDR 的最低位来随时检查按钮的状态。当按钮被按下时,参考电压连接到引脚,对应引脚的位值从0变为1:
int button_is_pressed(void)
{
return (GPIOA_IDR & (1 << BUTTON_PIN)) >> BUTTON_PIN;
}
基于中断的输入
在许多情况下,需要通过不断轮询 IDR 来主动读取引脚值并不方便,尤其是在应用需要响应状态变化时。微控制器通常提供机制将数字输入引脚连接到中断线上,以便应用能够实时响应与输入相关的事件,因为执行被中断以执行相关服务例程。
在参考微控制器单元(MCU)上,引脚 A0 可以连接到外部中断和事件控制器,也称为EXTI。EXTI 提供边缘检测触发器,可以附加到中断线上。引脚在 GPIO 组中的编号决定了与它关联的 EXTI 中断编号,因此如果需要,EXTI 0 中断例程可以连接到任何 GPIO 组的引脚 0:

图 6.2 – EXTI0 控制器将边缘检测触发器关联到连接到 PA0 的用户按钮
EXTI_CR寄存器位于地址0x40013808。每个寄存器用于设置与 EXTI 线关联的中断控制器。第一个寄存器的最低四位与 EXTI 线 0 相关。GPIO 组 A 的编号为 0,因此我们需要确保第一个EXTI_CR寄存器中的相应位被清除。下一个示例的目标是演示如何启用EXTI_CR寄存器以设置 GPIO 组 A:
#define EXTI_CR_BASE (0x40013808)
#define EXTI_CR0 (*(volatile uint32_t *)(EXTI_CR_BASE +
0x00))
#define EXTI_CR_EXTI0_MASK (0x0F)
EXTI0中断连接到 NVIC 行号6,因此我们添加此定义以配置 NVIC:
#define NVIC_EXTI0_IRQN (6)
STM32F4 微控制器中的 EXTI 控制器位于地址0x40013C00,并提供以下寄存器:
-
0. 设置/清除相应的位以启用/禁用每个 EXTI 线的中断。 -
4. 设置/清除相应的位以启用/禁用对应 EXTI 线的触发事件。 -
8. 设置相应的位,当相关的数字输入电平从 0 切换到 1 时生成事件和中断。 -
12. 设置相应的位,当相关的信号从逻辑值1下降回0时生成事件和中断。 -
16. 如果此寄存器中设置了位,则相关的中断事件将立即生成,并执行服务例程。此机制可用于实现自定义软件中断。 -
20. 要清除挂起的中断,服务例程应设置对应 EXTI 线的位,否则中断将保持挂起状态。直到 EXTI 线的 PR 位被清除,才会产生新的服务例程。
为了方便,我们可以定义寄存器如下:
#define EXTI_BASE (0x40013C00)
#define EXTI_IMR (*(volatile uint32_t *)(EXTI_BASE + 0x00))
#define EXTI_EMR (*(volatile uint32_t *)(EXTI_BASE + 0x04))
#define EXTI_RTSR (*(volatile uint32_t *)(EXTI_BASE +
0x08))
#define EXTI_FTSR (*(volatile uint32_t *)(EXTI_BASE +
0x0c))
#define EXTI_SWIER (*(volatile uint32_t *)(EXTI_BASE +
0x10))
#define EXTI_PR (*(volatile uint32_t *)(EXTI_BASE + 0x14))
在 PA0 上升沿触发中断,与按钮按下相关联的步骤如下:
void button_setup(void)
{
AHB1_CLOCK_ER |= GPIOA_AHB1_CLOCK_ER;
GPIOA_MODE &= ~ (0x03 << (BUTTON_PIN * 2));
EXTI_CR0 &= ~EXTI_CR_EXTI0_MASK;
nvic_irq_enable(NVIC_EXTI0_IRQN);
EXTI_IMR |= 1 << BUTTON_PIN;
EXTI_EMR |= 1 << BUTTON_PIN;
EXTI_RTSR |= 1 << BUTTON_PIN;
}
已设置 ISR、IMR 和 RTSR 对应位,并在 NVIC 中启用了中断。我们不再需要轮询数字输入值的变化,现在可以定义一个服务例程,每次按钮按下时都会调用它:
volatile uint32_t button_presses = 0;
void isr_exti0(void)
{
EXTI_PR |= 1 << BUTTON_PIN;
button_presses++;
}
在这个简单的例子中,button_presses计数器预计在每次按钮按下事件时增加一次。在实际场景中,基于机械接触的按钮(如 STM32F407-Discovery 上的按钮)使用此机制控制可能很棘手。实际上,单个物理按钮按下可能在过渡阶段触发上升沿中断多次。这种现象称为按钮抖动效应,可以使用特定的去抖动技术来减轻,这些技术在此未讨论。
模拟输入
一些引脚可以动态测量施加的电压,并将一个离散的数字分配给测量的值,使用模拟-数字信号转换器,或ADC。这对于从广泛范围的传感器获取数据非常有用,这些传感器能够以输出电压或简单地使用可变电阻的形式传递信息。
ADC 子系统的配置在不同平台上可能会有很大差异。现代微控制器上的 ADC 提供了广泛的配置选项。参考微控制器配备了 3 个独立的 ADC 控制器,共享 16 个输入通道,每个通道的分辨率为 12 位。提供了多种功能,例如获取数据的 DMA 传输和监控两个看门狗阈值之间的信号。
ADC 控制器通常设计为每秒自动采样输入值多次,并提供稳定的结果,这些结果立即可用。我们分析的这个案例更简单,它由单次读取操作的单次转换组成。
如果引脚支持并且通过通道连接到配置为模拟输入的其中一个,可以通过检查控制器上通道的映射来将特定引脚关联到控制器。在这个例子中,引脚 B1 用作模拟输入,可以通过通道9连接到ADB1控制器。以下常量和寄存器被定义为配置ADB1控制器:
#define APB2_CLOCK_ER (*(volatile uint32_t *)(0x40023844))
#define ADC1_APB2_CLOCK_ER_VAL (1 << 8)
#define ADC1_BASE (0x40012000)
#define ADC1_SR (*(volatile uint32_t *)(ADC1_BASE + 0x00))
#define ADC1_CR1 (*(volatile uint32_t *)(ADC1_BASE +
0x04))
#define ADC1_CR2 (*(volatile uint32_t *)(ADC1_BASE +
0x08))
#define ADC1_SMPR1 (*(volatile uint32_t *)(ADC1_BASE +
0x0c))
#define ADC1_SMPR2 (*(volatile uint32_t *)(ADC1_BASE +
0x10))
#define ADC1_SQR3 (*(volatile uint32_t *)(ADC1_BASE +
0x34))
#define ADC1_DR (*(volatile uint32_t *)(ADC1_BASE + 0x4c))
#define ADC_CR1_SCAN (1 << 8)
#define ADC_CR2_EN (1 << 0)
#define ADC_CR2_CONT (1 << 1)
#define ADC_CR2_SWSTART (1 << 30)
#define ADC_SR_EOC (1 << 1)
#define ADC_SMPR_SMP_480CYC (0x7)
这些是配置 GPIO 的常规定义,这次映射为GPIOB:
#define AHB1_CLOCK_ER (*(volatile uint32_t *)(0x40023830))
#define GPIOB_AHB1_CLOCK_ER (1 << 1)
#define GPIOB_BASE (0x40020400)
#define GPIOB_MODE (*(volatile uint32_t *)(GPIOB_BASE +
0x00))
#define ADC_PIN (1)
#define ADC_PIN_CHANNEL (9)
这三个模数转换器(ADC)共享一些用于常见设置的寄存器,例如时钟预分频因子,因此它们将以相同的频率运行。ADC 的预分频因子必须在数据手册推荐的转换器工作范围内设置——在目标平台上,通过公共预分频器将APB2时钟频率减半。公共 ADC 配置寄存器从端口0x40012300开始:
#define ADC_COM_BASE (0x40012300)
#define ADC_COM_CCR (*(volatile uint32_t *)(ADC_COM_BASE +
0x04))
根据这些定义,初始化函数可以编写如下。首先,我们启用 ADC 控制器和 GPIO 组的时钟门控:
int adc_init(void)
{
APB2_CLOCK_ER |= ADC1_APB2_CLOCK_ER_VAL;
AHB1_CLOCK_ER |= GPIOB_AHB1_CLOCK_ER;
PB1 设置为模拟输入模式,对应模式寄存器中的值3:
GPIOB_MODE |= 0x03 << (ADC_PIN * 2);
ADC1暂时关闭以设置所需的配置。公共时钟预分频器设置为0,意味着从输入时钟中除以 2。这确保了馈送到 ADC 控制器的频率在其操作范围内。扫描模式被禁用,连续模式也被禁用,因为我们在这个示例中不使用这些功能:
ADC1_CR2 &= ~(ADC_CR2_EN);
ADC_COM_CCR &= ~(0x03 << 16);
ADC1_CR1 &= ~(ADC_CR1_SCAN);
ADC1_CR2 &= ~(ADC_CR2_CONT);
通过使用两个寄存器SMPR1和SMPR2,可以根据使用的通道设置采样频率。每个寄存器使用 3 位表示一个通道的采样率,因此通道0到9可以使用SMPR1进行配置,而所有其他通道则通过SMPR2。PB1 的通道设置为9,因此在这种情况下,使用SMPR1寄存器,但为了提醒这一点,提供了在任何通道上设置采样率的通用机制:
if (ADC_PIN_CHANNEL > 9) {
uint32_t val = ADC1_SMPR2;
val = ADC_SMPR_SMP_480CYC << ((ADC_PIN_CHANNEL - 10) *
3);
ADC1_SMPR2 = val;
} else {
uint32_t val = ADC1_SMPR1;
val = ADC_SMPR_SMP_480CYC << (ADC_PIN_CHANNEL * 3);
ADC1_SMPR1 = val;
}
最后,在 ADC 控制器的转换序列中启用通道,使用SQR3到SQR1。每个源通道用五位表示,因此每个寄存器最多包含六个源,除了SQR1,它存储五个,并保留高位来指示寄存器中存储的堆栈长度减一。在我们的情况下,不需要设置长度减一字段,因为它对于SQR1中的单个源将是零:
ADC1_SQR3 |= (ADC_PIN_CHANNEL);
最后,通过设置CR2控制寄存器中的使能位,再次启用了ADC1模拟转换器,初始化函数成功返回:
ADC1_CR2 |= ADC_CR2_EN;
return 0;
}
在 ADC 初始化并配置为转换 PB1 上的模拟信号后,可以随时开始 A/D 转换。一个简单的阻塞读取函数将启动转换,等待转换成功开始,然后等待转换完成,通过查看状态寄存器中的转换结束(EOC)位:
int adc_read(void)
{
ADC1_CR2 |= ADC_CR2_SWSTART;
while (ADC1_CR2 & ADC_CR2_SWSTART)
;
while ((ADC1_SR & ADC_SR_EOC) == 0)
;
当转换完成时,相应的离散值在数据寄存器的最低 12 位上可用,并且可以被返回给调用者:
return (int)(ADC1_DR);
}
我们已经学会了如何使用 GPIO 与外界通信。相同的 GPIO 设置和管理接口将在下一章中再次有用,用于配置更复杂的本地总线接口,使用相关 GPIO 线的备用功能:
接下来的部分介绍了看门狗,这是本章分析的最后一个通用系统特性。看门狗通常存在于多个微控制器中,它提供了一个方便的紧急恢复程序,无论出于何种原因,当系统冻结且无法恢复其正常执行时。
看门狗
许多微控制器中常见的功能是存在看门狗定时器。看门狗确保系统不会陷入无限循环或其他代码中的任何阻塞情况。这在依赖于事件驱动的循环的裸机应用程序中特别有用,其中调用要求不阻塞,并在允许的时间内返回到主事件循环。
监视器必须被视为恢复无响应系统的最后手段,通过触发强制重启,无论 CPU 当前执行状态如何。
参考平台提供了一个独立的看门狗定时器,其计数器与通用定时器类似,具有 12 位分辨率和预分频因子。然而,看门狗的预分频因子以 2 的倍数表示,范围在 4(由值 0 表示)到 256(值 6)之间。
时钟源连接到一个较低速度的振荡器,通过时钟分布的独立分支。因此,时钟门控不涉及此外围设备的激活。
看门狗配置区域映射在外围设备地址区域中,由四个寄存器组成:
-
键寄存器(偏移量
0),用于通过在最低 16 位写入预定义值来触发三个解锁、启动和重置操作 -
预分频寄存器(偏移量
4),用于设置计数器的预分频因子 -
重载寄存器(偏移量
8),包含计数器的重载值 -
状态寄存器(偏移量
12),提供状态标志以同步设置操作
可以使用快捷宏来引用寄存器:
#define IWDG_BASE (0x40003000)
#define IWDG_KR (*(volatile uint32_t *)(IWDG_BASE + 0x00))
#define IWDG_PR (*(volatile uint32_t *)(IWDG_BASE + 0x04))
#define IWDG_RLR (*(volatile uint32_t *)(IWDG_BASE + 0x08))
#define IWDG_SR (*(volatile uint32_t *)(IWDG_BASE + 0x0c))
通过键寄存器可以触发的三种可能操作如下:
#define IWDG_KR_RESET 0x0000AAAA
#define IWDG_KR_UNLOCK 0x00005555
#define IWDG_KR_START 0x0000CCCC
状态中提供了两个有意义的状态位,并且必须检查以确保在解锁和设置预分频和重载值之前看门狗不忙:
#define IWDG_SR_RVU (1 << 1)
#define IWDG_SR_PVU (1 << 0)
初始化函数用于配置和启动看门狗,可能如下所示:
int iwdt_init(uint32_t interval_ms)
{
uint32_t pre = 0;
uint32_t counter;
在下一行,以毫秒为单位的输入值缩放到看门狗时钟的频率,该频率为 32 kHz:
counter = interval_ms << 5;
最小预分频因子是 4,因此值应该再次除以。然后我们寻找最小的预分频值,使得计数器适合 12 位,通过将计数器值减半并增加预分频因子,直到计数器适当地缩放:
counter >>= 2;
while (counter > 0xFFF) {
pre++;
counter >>= 1;
}
以下检查确保提供的间隔不会导致计数器为零或值太大,以至于无法适用于可用的缩放因子:
if (counter == 0)
counter = 1;
if (pre > 6)
return -1;
实际上已经完成了寄存器的初始化,但设备要求我们通过解锁操作来启动写入,并且只有在检查寄存器可用于写入之后:
while(IWDG_SR & IWDG_SR_PR_BUSY);
IWDG_KR = IWDG_KR_UNLOCK;
IWDG_PR = pre;
while (IWDG_SR & IWDG_SR_RLR_BUSY);
IWDG_KR = IWDG_KR_UNLOCK;
IWDG_RLR = counter;
启动看门狗简单地说就是将键寄存器中的START命令设置为启动操作:
IWDG_KR = IWDG_KR_START;
return 0;
}
一旦启动,看门狗就无法停止,将一直运行,直到计数器减到零,然后重启系统。
防止系统重启的唯一方法是通过手动重置计时器,这一操作通常被称为踢看门狗。看门狗驱动程序应该导出一个函数,允许应用程序重置计数器——例如,在主循环的每次迭代结束时。以下是我们提供的示例:
void iwdt_reset(void)
{
IWDG_KR = IWDG_KR_RESET;
}
作为对看门狗驱动程序的简单测试,可以在main()函数中初始化一个 2 秒的看门狗计数器:
void main(void) {
flash_set_waitstates();
clock_config();
button_setup();
iwdt_init(2000);
while(1)
WFI();
}
当按下按钮时,看门狗会在 GPIO 按钮的中断服务例程中被重置:
void isr_exti0(void)
{
EXTI_PR |= (1 << BUTTON_PIN);
iwdt_reset();
}
在这个测试中,如果用户按钮连续 2 秒没有被按下,系统将会重启,因此保持系统运行的唯一方法是通过反复按下按钮。
摘要
时钟配置、计时器和 I/O 线是本章中展示的通用外设,通常由广泛的微控制器支持。尽管在其他目标上,实现细节如寄存器名称和位置可能有所不同,但提出的方法在大多数嵌入式平台上都是有效的,通用外设是构建最基本系统功能以及与传感器和执行器交互的基石。
在下一章中,我们将重点关注大多数微处理器提供的串行通信通道,作为与其他设备以及目标系统附近的外设的通信接口。
第七章:本地总线接口
嵌入式系统与其周围其他系统之间的通信通过一些协议实现。大多数为嵌入式系统设计的微控制器支持最常用的接口,这些接口控制和规范对串行线的访问。其中一些协议非常流行,以至于它们已成为微控制器之间有线芯片通信的标准,以及控制电子设备(如传感器、执行器、显示器、无线收发器以及许多其他外围设备)的标准。本章描述了这些协议的工作原理,特别是通过在参考平台上运行的示例,具体关注系统软件的实现。特别是,本章将涵盖以下主题:
-
介绍串行通信
-
基于 UART 的异步串行总线
-
SPI 总线
-
I2C 总线
到本章结束时,您将学习如何集成常见的串行通信协议。
技术要求
您可以在 GitHub 上找到本章的代码文件,地址为github.com/PacktPublishing/Embedded-Systems-Architecture-Second-Edition/tree/main/Chapter7。
介绍串行通信
本章中我们将分析的协议管理对串行总线的访问,该总线可能由一根或多根线组成,以对应逻辑电平零和一的电气信号形式传输信息,当与特定的时间间隔相关联时。这些协议在数据总线线上传输和接收信息的方式上有所不同。为了传输一个字节,收发器将其编码为位序列,该序列与时钟同步。位逻辑值由接收器通过在时钟的特定前端读取其值来解释,这取决于时钟的极性。
每个协议指定了时钟的极性和传输数据所需的位顺序,这可以以最高有效位或最低有效位开始。例如,一个系统通过上升沿时钟调节的串行线传输 ASCII 字符D,并且以最高有效位首先发送,将产生如下信号:

图 7.1 – 在时钟上升沿的总线逻辑电平被解释为从最高有效位(MSB)开始,转换为字节值 0x44
我们现在将根据不同的标准定义串行通信接口的特性。特别是,我们将指出两个交换数据的端点之间时钟同步的选项;指定每个协议用于访问物理媒体的信号布线;最后,编程访问外围设备的实现细节,这些细节可能在不同平台上有所不同。
时钟和符号同步
为了使接收方理解消息,时钟必须在各个部分之间同步。时钟同步可能是隐式的,例如,在总线上设置相同的读写数据速率,或者通过使用额外的线从一侧共享时钟线来显式同步发送数据速率。不预见共享时钟线的串行协议称为异步。
符号同步应该明确。因为我们期望以字节的形式发送和接收信息,所以每个 8 位序列的开始应该通过在数据线上使用特殊的预同步序列或在正确的时间打开和关闭时钟来标记。每个协议定义的符号同步策略不同。
总线布线
建立双向通信所需的线条数量也取决于特定的协议。由于一根线一次只能在一个方向上传输 1 位信息,为了实现全双工通信,收发器应该连接到两根不同的线,用于发送和接收数据。如果协议支持半双工通信,它应该提供一种可靠的机制来调节媒体访问,并在同一根线上在接收和发送数据之间切换。
重要注意事项
两个端点必须共享一个共同的参考地电压,这意味着如果设备本身没有共享一个共同的地线,可能需要额外添加一根线来连接地线。
根据协议的不同,访问总线的设备可能要么共享类似的实现并作为对等体,要么在参与通信时分配不同的角色——例如,如果主设备负责同步时钟或调节对媒体访问的控制。
串行协议可能预见在同一总线上进行多于两个设备的通信。这可以通过为每个共享同一总线的从设备使用额外的从设备选择线,或者通过为每个端点分配逻辑地址,并在每个传输的预同步中包含通信的目的地址来实现。基于这些分类,以下表格给出了在嵌入式目标中实现的最流行的串行协议采用的方法概述:

本章详细介绍的协议只有前三个,因为它们在与嵌入式外围设备通信中最广泛使用。
编程外围设备
实现了之前描述的协议的多个外围设备通常集成到微控制器中,这意味着相关的串行总线可以直接连接到微控制器的特定引脚。外围设备可以通过时钟门控启用,并通过访问映射在内存空间外围区域的配置寄存器进行控制。连接到串行总线的引脚也必须配置以实现相应的备用功能,并且涉及的中断线应配置为在向量表中处理。
一些微控制器,包括我们的参考平台,支持直接内存访问(DMA)以加快外围设备和物理 RAM 之间的内存操作。在许多情况下,此功能有助于在更短的时间内处理通信数据,并提高系统的响应性。DMA 控制器可以被编程来启动传输操作,并在完成时触发中断。
控制与每个协议相关的功能的接口是针对外围设备暴露的功能特定的。在下一节中,将分析 UART、SPI 和 I2C 外围设备暴露的接口,并提供针对参考平台的代码示例,作为类似设备驱动程序可能实现的一种可能实现的示例。
基于 UART 的异步串行总线
由于其异步特性的简单性,UART 在历史上被用于许多不同的目的,它可以追溯到计算机的起源,并且仍然是在许多环境中使用的一种非常流行的电路。直到 2000 年代初,个人电脑至少包含一个 RS-232 串行端口,该端口由 UART 控制器和允许在更高电压下操作的收发器实现。如今,USB 已经取代了个人电脑上的串行通信,但主机计算机仍然可以通过 USB-UART 外围设备访问 TTL 串行总线。微控制器有一对或多对引脚可以与内部 UART 控制器相关联,并连接到串行总线,以配置一个双向、异步、全双工通信通道,用于连接到同一总线的设备。
协议描述
如前所述,异步串行通信依赖于发送器和接收器之间位率的隐式同步,以确保数据在通信接收端被正确处理。如果外围时钟足够快,可以保持设备以高频率运行,异步串行通信可能达到每秒几兆比特。
符号同步策略基于识别线上每个字节传输的开始。当没有设备传输时,总线处于空闲状态。
要开始传输,收发器将 TX 线拉低到低逻辑电平,持续的时间至少是位采样周期的一半,具体取决于位速率。随后,正在传输的字节组成位被转换为逻辑 0 或 1 值,这些值根据位速率保持在 TX 线上,对应于每个位的时间。在此启动条件被接收器轻松识别后,符号组成位按照特定的顺序依次传输,从最低有效位到最高有效位。
组成符号的数据位数也是可配置的。默认的数据长度为 8 位,允许每个符号转换为字节。在数据末尾,可以配置一个可选的奇偶校验位来计算活动位的数量,作为一种非常简单的冗余校验形式。如果存在奇偶校验位,可以配置为指示符号中 1 值的数量是奇数还是偶数。在返回空闲状态时,必须使用 1 或 2 个停止位来指示符号的结束。
停止位是通过在整个位传输期间将信号拉高来传输的,标记当前符号的结束,并迫使接收器开始接收下一个符号。1 个停止位是最常用的默认设置;1.5 和 2 个停止位设置提供了更长的符号间空闲间隔,这在过去与较慢、响应较慢的硬件通信时很有用,但今天很少使用。
在开始通信之前,两个端点必须了解这些设置。串行控制器通常不支持动态检测符号速率或连接到另一端的设备设置的任何设置,因此,成功尝试任何串行通信的唯一方法是在总线上使用相同的已知设置编程两个设备。作为回顾,这些设置如下:
-
比特率,以每秒比特数表示
-
每个符号中的数据位数(通常是 8 位)
-
奇偶校验位的意义,如果存在(
O表示奇数,E表示偶数,N表示不存在) -
停止位的数量
此外,发送器必须配置为在每个传输结束时发送 1、1.5 或 2 个停止位。1.5 和 2 个停止位在过去更广泛地使用,用于与古老的机电设备同步通信。如今,对于使用现代收发器的通信,奇偶校验和超过 1 个的停止位不再需要,并且很少使用。
这组设置通常被缩写为例如 115200-8-N-1 或 38400-8-O-2,分别表示一个 115.2 Kbps 的串行线路,每个符号有 8 个数据位,无奇偶校验和 1 个停止位,以及一个具有相同数据位、奇数奇偶校验和 2 个停止位的 38400 线路。
编程控制器
开发板通常提供多个 UART,我们的参考,STM32F407,也不例外。根据手册,UART3 可以与 PD8(TX)和 PD9(RX)引脚相关联,我们将在本例中使用这些引脚。打开 D GPIO 组时钟并设置 8 和 9 引脚为交替模式,交替函数为 7 的代码如下:
#define AHB1_CLOCK_ER (*(volatile uint32_t *)(0x40023830))
#define GPIOD_AHB1_CLOCK_ER (1 << 3)
#define GPIOD_BASE 0x40020c00
#define GPIOD_MODE (*(volatile uint32_t *)(GPIOD_BASE + 0x00))
#define GPIOD_AFL (*(volatile uint32_t *)(GPIOD_BASE + 0x20))
#define GPIOD_AFH (*(volatile uint32_t *)(GPIOD_BASE + 0x24))
#define GPIO_MODE_AF (2)
#define UART3_PIN_AF (7)
#define UART3_RX_PIN (9)
#define UART3_TX_PIN (8)
static void uart3_pins_setup(void)
{
uint32_t reg;
AHB1_CLOCK_ER |= GPIOD_AHB1_CLOCK_ER;
reg = GPIOD_MODE & ~ (0x03 << (UART3_RX_PIN * 2));
GPIOD_MODE = reg | (2 << (UART3_RX_PIN * 2));
reg = GPIOD_MODE & ~ (0x03 << (UART3_TX_PIN * 2));
GPIOD_MODE = reg | (2 << (UART3_TX_PIN * 2));
reg = GPIOD_AFH & ~(0xf << ((UART3_TX_PIN - 8) * 4));
GPIOD_AFH = reg | (UART3_PIN_AF << ((UART3_TX_PIN - 8) *
4));
reg = GPIOD_AFH & ~(0xf << ((UART3_RX_PIN - 8) * 4));
GPIOD_AFH = reg | (UART3_PIN_AF << ((UART3_RX_PIN - 8) *
4));
}
设备在其 APB1_CLOCK_ER 寄存器中有一个自己的时钟门控配置位,位于位置 18:
#define APB1_CLOCK_ER (*(volatile uint32_t *)(0x40023840))
#define UART3_APB1_CLOCK_ER_VAL (1 << 18)
每个 UART 控制器都可以通过映射到外设区域的寄存器来访问,这些寄存器相对于 UART 控制器基本地址有固定的偏移量:
-
0 -
4 -
8 -
UART_CRx寄存器位于偏移量12,用于设置串行端口参数、启用中断和 DMA,以及启用和禁用收发器
在本例中,我们定义了快捷宏来访问以下 UART3 的寄存器:
#define UART3 (0x40004800)
#define UART3_SR (*(volatile uint32_t *)(UART3))
#define UART3_DR (*(volatile uint32_t *)(UART3 + 0x04))
#define UART3_BRR (*(volatile uint32_t *)(UART3 + 0x08))
#define UART3_CR1 (*(volatile uint32_t *)(UART3 + 0x0c))
#define UART3_CR2 (*(volatile uint32_t *)(UART3 + 0x10))
我们定义了对应位字段中的位置:
#define UART_CR1_UART_ENABLE (1 << 13)
#define UART_CR1_SYMBOL_LEN (1 << 12)
#define UART_CR1_PARITY_ENABLED (1 << 10)
#define UART_CR1_PARITY_ODD (1 << 9)
#define UART_CR1_TX_ENABLE (1 << 3)
#define UART_CR1_RX_ENABLE (1 << 2)
#define UART_CR2_STOPBITS (3 << 12)
#define UART_SR_TX_EMPTY (1 << 7)
uart3_pins_setup 辅助函数可以在初始化函数的开始处调用,以设置引脚。该函数接受参数来设置 UART3 端口的波特率、奇偶校验位和停止位:
int uart3_setup(uint32_t bitrate, uint8_t data,
char parity, uint8_t stop)
{
uart3_pins_setup();
设备已开启:
APB1_CLOCK_ER |= UART3_APB1_CLOCK_ER_VAL;
在 CR1 配置寄存器中,设置启用发送器的位:
UART3_CR1 |= UART_CR1_TX_ENABLE;
UART_BRR 设置为包含时钟速度和所需波特率之间的除数:
UART3_BRR = CLOCK_SPEED / bitrate;
我们的功能还接受一个字符来指示所需的奇偶校验。选项为 O 或 E,分别表示奇数或偶数。任何其他字符都将禁用奇偶校验:
/* Default: No parity */
UART3_CR1 &= ~(UART_CR1_PARITY_ENABLED |
UART_CR1_PARITY_ODD);
switch (parity) {
case 'O':
UART3_CR1 |= UART_CR1_PARITY_ODD;
/* fall through to enable parity */
case 'E':
UART3_CR1 |= UART_CR1_PARITY_ENABLED;
break;
}
根据参数设置停止位的数量。配置使用寄存器的 2 位存储,值为 0 表示 1 个停止位,值为 2 表示 2 个:
reg = UART3_CR2 & ~UART_CR2_STOPBITS;
if (stop > 1)
UART3_CR2 = reg | (2 << 12);
配置现在完成。可以开启 UART 以开始传输:
UART3_CR1 |= UART_CR1_UART_ENABLE;
return 0;
}
现在可以通过在 UART_DR 寄存器上逐字节复制一个字节来在 PD8 上传输串行数据。
Hello world!
在开发嵌入式系统时,最有用的功能之一是将可用的 UART 之一转换为日志端口,这样就可以在主机计算机上使用串行到 USB 转换器读取执行期间产生的调试消息和其他信息:

图 7.2 – 主机通过转换器连接到目标平台的串行端口
UART 逻辑在两个方向上都包含 FIFO 缓冲区。通过在 UART_DR 寄存器上写入数据来填充发送 FIFO。在轮询模式下实际在 UART TX 线路上输出数据时,我们选择在写入每个字符之前检查 FIFO 是否为空,以确保一次不会将超过一个字符放入 FIFO。当 FIFO 为空时,设备会将 UART3_SR 中与 TX_FIFO_EMPTY 标志相关联的位设置为 1。以下函数展示了如何通过将作为参数传递的整个字符字符串传递,并在每个字节后等待 FIFO 为空来发送:
void uart3_write(const char *text)
{
const char *p = text;
int i;
volatile uint32_t reg;
while(*p) {
do {
reg = UART3_SR;
} while ((reg & UART_SR_TX_EMPTY) == 0);
UART3_DR = *p;
p++;
}
}
在主程序中,可以调用此函数,传递一个预格式化、以NULL结尾的字符串:
#include "system.h"
#include "uart.h"
void main(void) {
flash_set_waitstates();
clock_config();
uart3_setup(115200, 8, 'N', 1);
uart3_write("Hello World!\r\n");
while(1)
WFI();
}
如果主机连接到串行总线的另一个端点,因此,我们可以使用主机上的串行终端程序,例如minicom,来可视化Hello World!消息。
通过捕获用作目标UART_TX的 PD8 引脚的输出,并设置正确的串行解码选项,我们可以更好地了解接收端如何解析串行流。逻辑分析仪可以显示在每个起始条件之后如何采样数据位,并揭示与线上的字节相关的 ASCII 字符。逻辑分析仪工具通常能够解码在线捕获的位,并将每个传输的字节转换回 ASCII 格式。此功能提供了一种快速准确的方法来验证我们的串行通信是否合规,连续位之间的时间符合所选波特率,线上的内容与发送到 UART 收发器的数据匹配,如下面的图所示,它显示了我们的嵌入式目标从字符串发送“Hello”并将其传递给uart3_write函数。

图 7.3 – 使用 UART3 发送给主机的前 5 个字节的逻辑分析仪工具截图
newlib printf
编写预格式化字符串不是访问串行端口以提供调试消息的最理想 API。应用程序开发者肯定会希望系统公开一个标准的 C printf函数。当工具链包含标准 C 库的实现时,它通常给你连接主程序的标准输出的可能性。幸运的是,用于参考平台的工具链允许我们链接到newlib函数。类似于我们在第五章中做的,内存管理,使用newlib中的malloc和free函数,我们提供了一个后端函数叫做_write(),它从所有调用printf()格式化的字符串中获取输出。这里实现的_write函数将接收所有由printf()预格式化的字符串:
int _write(void *r, uint8_t *text, int len)
{
char *p = (char *)text;
int i;
volatile uint32_t reg;
text[len - 1] = 0;
while(*p) {
do {
reg = UART3_SR;
} while ((reg & UART_SR_TX_EMPTY) == 0);
UART3_DR = *p;
p++;
}
return len;
}
因此,在这种情况下,与newlib链接使我们能够使用printf来生成消息,包括其变长参数解析,如本例中的main()函数:
#include <stdio.h>
#include "system.h"
#include "uart.h"
void main(void) {
char name[] = "World";
flash_set_waitstates();
clock_config();
uart3_setup(115200, 8, 'N', 1);
printf("Hello %s!\r\n", name);
while(1)
WFI();
第二个示例将产生与第一个示例相同的输出,但这次使用newlib中的printf函数。
接收数据
要在相同的 UART 上启用接收器,初始化函数还应该通过UART_CR1寄存器中的相应开关打开接收器:
UART3_CR1 |= UART_CR1_TX_ENABLE | UART_CR1_RX_ENABLE;
这确保了收发器的接收端也被启用。为了在轮询模式下读取数据,阻塞直到接收到一个字符,我们可以使用以下函数,该函数将返回读取的字节值:
char uart3_read(void)
{
char c;
volatile uint32_t reg;
do {
reg = UART3_SR;
} while ((reg & UART_SR_RX_NOTEMPTY) == 0);
c = (char)(UART3_DR & 0xff);
return c;
}
这样,例如,我们可以将主机接收到的每个字符回显到控制台:
void main(void) {
char c[2];
flash_set_waitstates();
clock_config();
uart3_setup(115200, 8, 'N', 1);
uart3_write("Hello World!\r\n");
while(1) {
c[0] = uart3_read();
c[1] = 0;
uart3_write(c);
uart3_write("\r\n");
}
}
基于中断的输入/输出
本节中的示例基于通过连续检查UART_SR的标志来轮询 UART 的状态。写操作包含一个忙循环,该循环可以持续数毫秒,具体取决于字符串的长度。更糟糕的是,之前提供的读取函数在忙循环中旋转,直到从外围设备读取数据,这意味着整个系统在接收到新数据之前都会挂起。在单线程嵌入式系统中,以尽可能短的延迟返回主循环对于保持系统响应性非常重要。
要在不阻塞的情况下执行 UART 通信,请使用与 UART 相关联的中断线来根据接收的事件触发操作。UART 可以配置为在多种事件上触发中断信号。正如我们之前在示例中看到的,为了调节输入和输出操作,我们特别关注两个特定的事件:
-
一个 TX FIFO 空事件,允许传输更多数据
-
一个 RX FIFO 非空事件,表示有新接收的数据
通过设置UART_CR1中的相应位,可以启用这两个事件的中断。我们定义了两个辅助函数,其目的是独立地打开和关闭中断:
#define UART_CR1_TXEIE (1 << 7)
#define UART_CR1_RXNEIE (1 << 5)
static void uart3_tx_interrupt_onoff(int enable)
{
if (enable)
UART3_CR1 |= UART_CR1_TXEIE;
else
UART3_CR1 &= ~UART_CR1_TXEIE;
}
static void uart3_rx_interrupt_onoff(int enable)
{
if (enable)
UART3_CR1 |= UART_CR1_RXNEIE;
else
UART3_CR1 &= ~UART_CR1_RXNEIE;
}
一个服务例程可以与中断事件相关联,然后检查UART_SR中的标志以确定中断的原因:
void isr_uart3(void)
{
volatile uint32_t reg;
reg = UART3_SR;
if (reg & UART_SR_RX_NOTEMPTY) {
/* Receive a new byte */
}
if ((reg & UART_SR_TX_EMPTY)
{
/* resume pending transmission */
}
}
中断例程的实现取决于特定的系统设计。RTOS 可能会决定将串行端口的多路复用访问到多个线程,并唤醒等待访问资源的线程。在单线程应用程序中,可以添加中间系统缓冲区以提供非阻塞调用,这些调用在从接收缓冲区或发送缓冲区复制数据后立即返回。中断服务例程从总线上填充接收缓冲区以包含新数据,并从挂起缓冲区发送数据。使用适当的结构,例如循环缓冲区来实现系统输入和输出队列,可以确保分配的内存使用得到优化。
SPI 总线
SPI 总线提供了一种基于主从通信的不同方法。正如其名所示,该接口最初是为了控制外围设备而设计的。这反映在设计上,因为所有通信总是由总线上的主设备发起。得益于全双工引脚配置和同步时钟,它可能比异步通信快得多,因为对共享总线的系统之间的时钟偏移具有更好的鲁棒性。SPI 因其简单的逻辑和从设备无需预先配置以与主设备上预定义的速度通信的灵活性而被广泛用作多种不同设备的通信协议。只要定义了媒体访问策略,多个外围设备可以共享同一总线。主设备通常通过使用单独的 GPIO 线来控制从设备选择来控制一次控制一个外围设备,尽管这确实需要为每个从设备额外一根线。
协议描述
SPI 收发器的配置非常灵活。通常,微控制器上的收发器既能作为主设备也能作为从设备。必须事先知道一些预定义的设置,并在同一总线上共享主设备和所有从设备之间:
-
时钟极性,指示时钟滴答对应于时钟的上升沿还是下降沿
-
时钟相位,指示时钟空闲位置是高电平还是低电平
-
数据包的长度,介于 4 到 16 位之间的任何值
-
位顺序,指示数据是从最高有效位开始传输还是从最低有效位开始传输
由于时钟是同步的,并且始终由主设备强制,因此 SPI 没有预定义的操作频率,尽管使用过高的速度可能无法与所有外围设备和微控制器一起工作。
在主设备启动事务之前,SPI 与从设备的通信是禁用的。在每个事务的开始,主设备通过激活其从设备选择线来选择从设备:

图 7.4 – 可以使用一个额外的信号来选择总线上特定的从设备
要启动通信,主设备必须激活时钟,并且可以在 MOSI 线上向从设备发送命令序列。当检测到时钟时,从设备可以立即开始使用 MISO 线向相反方向传输字节。
即使主设备已经完成传输,它也必须遵守从设备实现的协议,并允许它在事务期间通过保持时钟活跃来回复。从设备被赋予一个预定义的字节数来与主设备通信。
为了即使在没有数据要传输给从设备的情况下也能保持时钟活跃,主设备可以通过 MOSI 线发送哑字节,这些字节会被从设备忽略。同时,只要主设备确保时钟持续运行,从设备就可以通过 MISO 线发送数据。与 UART 不同,在 SPI 中实现的从主通信模型中,从设备永远不会自发地启动 SPI 通信,因为主设备是唯一允许在总线上发送时钟的设备。每个 SPI 事务都是自包含的,在结束时,通过关闭相应的从设备选择信号来取消从设备的选中状态。
编程收发器
在参考板上,一个加速度计作为从设备连接到SPI1总线,因此我们可以通过配置收发器并执行一个双向事务到外围设备来检查如何在微控制器上实现通信的主设备部分。
SPI1总线在其配置寄存器映射到外设区域:
#define SPI1 (0x40013000)
#define SPI1_CR1 (*(volatile uint32_t *)(SPI1))
#define SPI1_CR2 (*(volatile uint32_t *)(SPI1 + 0x04))
#define SPI1_SR (*(volatile uint32_t *)(SPI1 + 0x08))
#define SPI1_DR (*(volatile uint32_t *)(SPI1 + 0x0c))
外设公开了总共四个寄存器:
-
两个位字段配置寄存器
-
一个状态寄存器
-
一个双向数据寄存器
很明显,接口与 UART 收发器类似,因为通信参数的配置是通过SPI_CRx寄存器进行的,FIFO 的状态可以通过查看SPI_SR来监控,而SPI_DR可以用来读取和写入串行总线上的数据。
配置寄存器 CR1 的值包含以下内容:
-
时钟相位,0 或 1,在位 0
-
时钟极性在位 1
-
位 2 中的 SPI 主模式标志
-
位率缩放因子在位 3-5
-
位 6 中的 SPI 使能标志
-
其他配置参数,如字长、LSB-first 和其他标志,在这个例子中不会使用,因为这些参数将保持默认值
CR2配置寄存器包含启用中断事件和 DMA 传输的标志,以及与这个例子相关的从设备选择输出使能(SSOE)标志。
SPI1_SR状态寄存器与上一节中的 UART 状态寄存器类似,因为它包含确定发送 FIFO 是否为空的标志,以及当接收侧的 FIFO 不为空时,调节传输的相位。
在这个例子中使用的标志对应的位定义为以下:
#define SPI_CR1_MASTER (1 << 2)
#define SPI_CR1_SPI_EN (1 << 6)
#define SPI_CR2_SSOE (1 << 2)
#define SPI_SR_RX_NOTEMPTY (1 << 0)
#define SPI_SR_TX_EMPTY (1 << 1)
RCC 控制着指向连接到APB2总线的SPI1收发器的时钟和复位线:
#define APB2_CLOCK_ER (*(volatile uint32_t *)(0x40023844))
#define APB2_CLOCK_RST (*(volatile uint32_t
*)(0x40023824))
#define SPI1_APB2_CLOCK_ER_VAL (1 << 12)
通过从 RCC 发送复位脉冲可以重置收发器:
static void spi1_reset(void)
{
APB2_CLOCK_RST |= SPI1_APB2_CLOCK_ER_VAL;
APB2_CLOCK_RST &= ~SPI1_APB2_CLOCK_ER_VAL;
}
PA5、PA6 和 PA7 引脚可以通过设置适当的备用功能与SPI1收发器相关联:
#define SPI1_PIN_AF 5
#define SPI1_CLOCK_PIN 5
#define SPI1_MOSI_PIN 6
#define SPI1_MISO_PIN 7
static void spi1_pins_setup(void)
{
uint32_t reg;
AHB1_CLOCK_ER |= GPIOA_AHB1_CLOCK_ER;
reg = GPIOA_MODE & ~(0x03 << (SPI1_CLOCK_PIN * 2));
reg &= ~(0x03 << (SPI1_MOSI_PIN));
reg &= ~(0x03 << (SPI1_MISO_PIN));
reg |= (2 << (SPI1_CLOCK_PIN * 2));
reg |= (2 << (SPI1_MOSI_PIN * 2)) | (2 << (SPI1_MISO_PIN
*2))
GPIOA_MODE = reg;
reg = GPIOA_AFL & ~(0xf << ((SPI1_CLOCK_PIN) * 4));
reg &= ~(0xf << ((SPI1_MOSI_PIN) * 4));
reg &= ~(0xf << ((SPI1_MISO_PIN) * 4));
reg |= SPI1_PIN_AF << ((SPI1_CLOCK_PIN) * 4);
reg |= SPI1_PIN_AF << ((SPI1_MOSI_PIN) * 4);
reg |= SPI1_PIN_AF << ((SPI1_MISO_PIN) * 4);
GPIOA_AFL = reg;
}
连接到加速度计“片选”线的附加引脚是 PE3,它被配置为输出,并带有上拉内部电阻。该引脚的逻辑是低电平有效,因此逻辑零将打开芯片:
#define SLAVE_PIN 3
static void slave_pin_setup(void)
{
uint32_t reg;
AHB1_CLOCK_ER |= GPIOE_AHB1_CLOCK_ER;
reg = GPIOE_MODE & ~(0x03 << (SLAVE_PIN * 2));
GPIOE_MODE = reg | (1 << (SLAVE_PIN * 2));
reg = GPIOE_PUPD & ~(0x03 << (SLAVE_PIN * 2));
GPIOE_PUPD = reg | (0x01 << (SLAVE_PIN * 2));
reg = GPIOE_OSPD & ~(0x03 << (SLAVE_PIN * 2));
GPIOE_OSPD = reg | (0x03 << (SLAVE_PIN * 2));
}
收发器的初始化从配置涉及的四个引脚开始。然后激活时钟门,并通过 RCC 通过脉冲接收复位信号:
void spi1_setup(int polarity, int phase)
{
spi1_pins_setup();
slave_pin_setup();
APB2_CLOCK_ER |= SPI1_APB2_CLOCK_ER_VAL;
spi1_reset();
默认参数(MSB-first,8 位字长)保持不变。此控制器的比特率缩放因子以 2 的幂表示,从2开始,对应于位字段值为0,每次增加时翻倍。通用驱动程序应根据所需的时钟速率和外设时钟频率计算正确的缩放因子。在这个简单的情况下,我们强制使用硬编码的缩放因子 64,对应于值5。
然后将SPI1_CR1设置为以下内容:
SPI1_CR1 = SPI_CR1_MASTER | (5 << 3) | (polarity << 1) |
(phase << 0);
最后,我们在SPI1_CR2中设置对应于SSOE标志位的位,并启用收发器:
SPI1_CR2 |= SPI_CR2_SSOE;
SPI1_CR1 |= SPI_CR1_SPI_EN;
}
读写操作现在可以开始了,因为主从 SPI 控制器都已准备好执行事务。
SPI 事务
读写函数代表了 SPI 事务的两个不同阶段。大多数 SPI 从设备都能够使用全双工机制进行通信,这样在时钟活跃时,字节可以在两个方向上交换。在每一个间隔期间,一个字节在两个方向上独立地通过 MISO 和 MOSI 线进行传输。
许多从设备实现的一种常见策略是通过使用设备数据表中记录的已知命令处理程序,在从设备中访问寄存器以进行读写操作。
STM32F407DISCOVERY 板上连接了一个加速度计到SPI1总线,该加速度计响应预定义的命令,访问设备内存中的特定寄存器以进行读写。在这些情况下,读写操作是顺序执行的:在第一个间隔期间,主设备传输命令处理程序,而设备没有要传输的内容,然后在随后的间隔中,实际字节在两个方向上传输。
这里描述的示例操作包括使用0x8F命令处理程序读取加速度计中的WHOAMI寄存器。外围设备应该响应包含0x3B值的 1 个字节,这正确地识别了设备并证明了 SPI 通信是正常工作的。然而,在传输命令字节期间,设备还没有要传输的内容,因此第一次读操作的结果可以被丢弃。同样,在发送命令后,主设备没有其他要传达给从设备的内容,因此它在读取从设备通过 MISO 线传输的字节的同时,在 MOSI 线上输出0xFF值。
在此特定设备上成功执行 1 字节读操作所需的步骤如下:
-
通过拉低从设备选择信号来开启从设备。
-
发送一个包含 1 字节读操作代码的字节。
-
当从设备使用时钟传输回复时,发送 1 个空字节。
-
在第二次间隔期间读取从设备传输的值。
-
通过将从设备选择信号拉高来关闭从设备。
为了做到这一点,我们定义如下阻塞读取和写入函数:
uint8_t spi1_read(void)
{
volatile uint32_t reg;
do {
reg = SPI1_SR;
} while ((reg & SPI_SR_RX_NOTEMPTY) == 0);
return (uint8_t)SPI1_DR;
}
void spi1_write(const char byte)
{
int i;
volatile uint32_t reg;
SPI1_DR = byte;
do {
reg = SPI1_SR;
} while ((reg & SPI_SR_TX_EMPTY) == 0);
}
读取操作等待RX_NOTEMPTY标志在SPI1_SR上启用,然后传输数据寄存器的内容。发送函数将传输的字节值传输到数据寄存器,然后通过等待TX_EMPTY标志来轮询操作结束。
这两个操作现在可以连接起来。主设备必须明确发送总共 2 个数据字节,因此我们的主要应用可以通过以下方式查询加速度计识别寄存器:
slave_on();
spi1_write(0x8F);
b = spi1_read();
spi1_write(0xFF);
b = spi1_read();
slave_off();
这是在总线上发生的情况:
-
在第一次写入时,将命令
0x8F发送到 MOSI。 -
使用第一个
spi1_read函数读取的值是从设备在监听传入命令时放入 MISO 的空字节位。在这个特定情况下,获得的值没有意义 – 因此,它被丢弃。 -
第二次写入将空字节位放在 MOSI 线上,因为主设备没有其他要传输的内容。这强制生成第二个字节的时钟,这是从设备回复命令所需的。
-
第二次读取处理主设备在写入空字节时通过 MISO 线传输的回复。在这个第二次事务中获得的值是来自从设备的有效回复,根据文档中命令的描述。
通过逻辑分析仪查看串行事务,我们可以清楚地区分两个阶段,以及相关的交替内容 – 首先,在 MOSI 上发送命令,然后是在 MISO 上接收回复:

图 7.5 – 双向 SPI 事务,包含主设备的请求和从设备的回复(从上到下:SPI1_MISO,SPI1_MOSI,SLAVE_SELECT,和 SPI1_CLOCK)
再次强调,使用带有忙循环的阻塞操作是一种非常不好的做法。这里之所以展示它,是为了解释成功完成双向 SPI 事务所需的原始操作。在实际嵌入式系统中,始终建议使用基于中断的传输,以确保 CPU 在等待传输完成时不会被忙循环占用。SPI 控制器提供中断信号,以指示控制器 FIFO 缓冲区的状态,以便将 SPI 事务与数据在任一方向上传输时所需采取的操作同步。
基于中断的 SPI 传输
用于启用 SPI 收发器中断的接口实际上与上一节中看到的 UART 接口非常相似。为了正确实现非阻塞事务,必须在它们的读和写阶段之间进行拆分,以便允许事件触发相关操作。
在SPI1_CR2寄存器中设置这两个位将分别启用在发送 FIFO 为空和接收 FIFO 非空时触发中断:
#define SPI_CR2_TXEIE (1 << 7)
#define SPI_CR2_RXNEIE (1 << 6)
包含在中断向量中的相关服务例程仍然可以查看SPI1_SR中的值,以推进事务到下一阶段:
void isr_spi1(void)
{
volatile uint32_t reg;
reg = SPI1_SR;
if (reg & SPI_SR_RX_NOTEMPTY) {
/* End of transmission: new data available on MISO*/
}
if ((reg & SPI_SR_TX_EMPTY)
{
/* End of transmission: the TX FIFO is empty*/
}
}
再次强调,中断上半部分的实现留给读者来完成,因为它依赖于系统需要实现的 API、事务的性质以及它们对系统响应性的影响。然而,短而高速的 SPI 事务可能会在时间上分散且短暂,以至于即使实现阻塞操作对系统延迟的影响也较小。
I2C 总线
本章分析的第三种串行通信协议是 I2C。从通信策略的角度来看,该协议与 SPI 有一些相似之处。然而,I2C 通信的默认比特率要低得多,因为该协议优先考虑低功耗而不是吞吐量。
同样的双线总线可以容纳多个参与者,包括主设备和从设备,并且不需要额外的信号来物理选择事务的从设备,因为从设备有固定的逻辑地址分配:

图 7.6 – 带有三个从机和外部上拉电阻的 I2C 总线
一根线传输由主设备生成的时钟,另一根用作双向同步数据路径。这是由于通道仲裁的独特机制,该机制依赖于收发器的电子设计,并且可以非常干净地处理同一总线上存在多个主设备的情况。
这两个信号必须通过上拉电阻连接到总线的较高电压(通常是 3.3V)。控制器从不驱动信号到高电平,而是让它在传输 1 时通过上拉电阻浮动到其默认值。因此,逻辑电平零始终占主导地位;如果任何连接到总线的设备通过拉低线路来强制零,那么所有设备都将读取该线路为低电平,无论有多少其他发送器在总线上保持逻辑电平 1。这使得总线可以由多个收发器同时控制,并且可以通过仅在总线可用时启动新事务来协调传输操作。在本节中,我们将介绍协议,以便介绍用于管理 I2C 控制器外设的软件工具。有关 I2C 总线通信和相关文档的更多信息,请参阅www.i2c-bus.org/。
协议描述
主从设备之间的同步是通过一个可识别的START 条件和一个STOP 条件来实现的,分别确定事务的开始和结束。总线最初处于空闲状态,当所有参与者都在空闲时,两个信号都处于高逻辑状态。
START条件是唯一一个可以通过 SDA 事务从低电平到高电平来识别STOP条件的情况,而 SCL 保持高电平。在STOP条件之后,总线再次空闲,只有当发送新的START条件时,才能启动通信。
主设备通过按此顺序将 SDA 和 SCL 拉低来发送一个START条件。一个帧由九个时钟周期组成。在每个时钟脉冲的边缘上升后,SDA 的电平不会改变,直到时钟再次变低。这允许我们在前 8 个时钟上升沿中传输 1 帧 8 字节。在最后一个时钟脉冲期间,主设备不驱动 SDA 线,此时由上拉电阻将其保持在高电平。任何想要确认帧接收的接收器都可以驱动信号变低。这个在第 9 个时钟脉冲上的条件被称为ACK。如果没有接收设备确认帧,SDA 保持高电平,发送器理解到帧没有达到预期的目的地:

图 7.7 – 总线上的单字节 I2C 事务,具有正确的 START 和 STOP 条件以及由接收器设置的 ACK 标志
一笔交易由两个或多个帧组成,并且始终由处于主模式的设备发起。每笔交易的第一帧被称为地址帧,其中包含下一个操作的地址和模式。交易中的所有后续帧都是数据帧,每个数据帧包含 1 个字节。主设备通过在执行停止条件之前保持交易活跃一定数量的帧数来决定交易由多少帧组成以及数据传输的方向。
从设备具有固定的 7 位地址,可以通过总线与之通信。当从设备在总线上检测到启动条件时,它必须监听地址帧并将其与自己的地址进行比较。如果地址匹配,必须在帧传输过程中的第九个时钟脉冲期间将 SDA 线拉低来确认地址帧。
数据始终以最高有效位(MSB)为前缀进行传输,地址帧的格式如下:

图 7.8 – 包含目标 7 位地址和 R/W̅标志的地址帧格式
上述图示显示了地址帧使用的格式。R/W̅位由主设备设置以指示交易的方向。R/W̅读作读取,而不是写入,这意味着0值表示写入操作,而1值表示读取操作。根据此位的值,交易后的数据字节要么流向从设备(写入操作),要么从选定的从设备流向主设备(读取操作)。在读取操作中,ACK 位的方向也会在选定从设备后的数据帧中反转,并且主设备应确认交易中接收到的每个帧。主设备可以选择在任何时候通过不在最后一个帧上拉低 ACK 位来终止传输,并在之后执行停止条件。
在传输地址帧之后,交易继续进行,数据可以通过后续的数据帧进行传输,每个数据帧包含 1 个字节,并且可以被接收器确认。如果地址帧中的R/W̅位设置为0,则主设备意图发起一个写入操作。一旦从设备通过识别自己为目标地址来确认地址帧,它就准备好接收数据,并确认数据帧,直到主设备发送停止条件。
I2C 协议规定,如果在事务结束时重复 START 条件,而不是发送 STOP 条件,可以直接开始新的交易,而不需要将总线设置为空闲状态。重复的 START 条件确保可以在同一总线上执行两个或更多事务而不会中断,例如,防止另一个主设备在他们之间开始通信。
一种不太流行的格式预计从设备有 10 位地址。10 位地址是标准的扩展,在后来引入,以与同一总线上可寻址的 7 位地址设备兼容。地址是通过使用 2 个连续帧来选择的,第一个 5 位是 11110,以指示选择 10 位地址。根据协议规范,以 0000 或 1111 开头的地址是保留的,并且从设备不得使用。在 10 位格式中,最高两位位包含在第一个帧的 A1 和 A0 中,而第二个帧包含剩余的 8 位。R/W̅ 位在第一个帧中保持其位置。这种寻址机制并不常见,因为只有少数从设备支持它。
时钟拉伸
我们观察到,在 I2C 事务中,主设备是唯一驱动 SCL 信号的设备。这始终是正确的,除非从设备尚未准备好从主设备传输请求的数据。在这种情况下,从设备可能会决定通过保持时钟线处于低电平来延迟交易,这会导致交易被挂起。主设备意识到其无法振荡时钟,因为释放 SCL 信号到浮空状态不会导致总线上逻辑电平变为高电平。主设备将继续尝试将 SCL 信号释放到其自然的高电平位置,直到从设备最终提供所需的数据,从而释放对线的锁定。
在被无限期挂起后,传输现在可以恢复,并且仍然期望主设备产生九个时钟脉冲以完成传输。因为在这个事务中不再期望有更多帧,所以主设备在最后不会将 ACK 位拉低,而是发送 STOP 条件以正确完成交易:

图 7.9 – 使用时钟拉伸技术延迟响应帧的 I2C 读取事务
尽管并非所有设备都支持时钟拉伸,但这种机制在请求的数据略微延迟时完成事务非常有用。时钟拉伸是 I2C 的一个非常独特的特性,使其成为与传感器和其他输入外围设备通信的非常灵活的协议。与无法及时提供值以完成事务的较慢设备通信时,时钟拉伸非常重要。建议由设计用于与通用 I2C 从设备通信的主设备正确支持此功能。在从设备端,为了强制执行时钟拉伸,设备必须提供硬件配置,允许我们将 SCL 线保持在逻辑低电平,直到它再次准备好。这意味着在这种情况下,SCL 线必须是双向的,从设备应该被设计为访问它,以强制下拉以保持事务活跃,同时准备下一帧的传输。
多个主设备
I2C 提供了一种确定性的机制来检测和响应总线上的多个主设备的存在,这同样基于 SDA 线的电气特性。
在开始任何通信之前,主设备通过检测 SDA 和 SCL 线来确保总线可用。START条件的设计方式本身就可以排除大多数冲突。当在两个边缘之间的初始宽限期内在 SDA 线上检测到低电平时,可以随时中断并发启动条件。仅此机制本身并不能防止两个 I2C 主设备同时访问通道,因为由于信号在电线上的传播时间,冲突仍然可能发生。
两个同时发起事务的主设备会持续比较线的状态,在每个比特被传输后。在两个主设备为两个不同传输完美同步的情况下,两个源上具有不同值的第一个比特只会被发送1值的主设备注意到,因为预期的值没有反映实际的线状态。该主设备会立即中止事务,发送器可以检测到网络上的冲突错误,在这种情况下,意味着仲裁权被让给了另一个主设备。同时,另一个主设备以及从设备都不会注意到任何事情,因为尽管总线线被无声地竞争,事务仍然会继续。
控制器编程
微控制器可能提供一个或多个 I2C 控制器,这些控制器可以使用备用功能绑定到特定的引脚。在我们的参考板上,为了启用I2C1总线,我们激活时钟门控,并通过访问映射在外围内存区域的控制、数据和状态寄存器来启动初始化过程:
#define APB1_CLOCK_ER (*(volatile uint32_t *)(0x40023840))
#define APB1_CLOCK_RST (*(volatile uint32_t *)(0x40023820))
#define I2C1_APB1_CLOCK_ER_VAL (1 << 21)
当配置为AF 4备用功能时,STM32F407 上的I2C1控制器与 PB6 和 PB9 引脚相关联:
#define I2C1_PIN_AF 4
#define I2C1_SCL 6
#define I2C1_SDA 9
#define GPIO_MODE_AF (2)
static void i2c1_pins_setup(void)
{
uint32_t reg;
AHB1_CLOCK_ER |= GPIOB_AHB1_CLOCK_ER;
/* Set mode = AF */
reg = GPIOB_MODE & ~(0x03 << (I2C1_SCL * 2));
reg &= ~(0x03 << (I2C1_SDA * 2));
GPIOB_MODE = reg | (2 << (I2C1_SCL * 2)) |
(2 << (I2C_SDA * 2));
/* Alternate function: */
reg = GPIOB_AFL & ~(0xf << ((I2C1_SCL) * 4));
GPIOB_AFL = reg | (I2C1_PIN_AF << ((I2C1_SCL - 8) * 4));
reg = GPIOB_AFH & ~(0xf << ((I2C1_SDA - 8) * 4));
GPIOB_AFH = reg | (I2C1_PIN_AF << ((I2C1_SDA - 8) * 4));
}
初始化函数访问 I2C 控制器的配置寄存器,这些寄存器映射在周边区域。在完成引脚配置和 RCC 启动序列后,通过使用APB1总线时钟的频率(MHz)来校准收发器的速度。当时钟校准完成后,通过在CR1寄存器中设置一个位来启用收发器。这里使用的参数将主总线时钟配置为以 400 kHz 运行。虽然协议的默认设置预见到 100 kHz 的时钟,但 400 kHz 选项后来被添加,并且现在许多设备都支持:
#define I2C1 (0x40005400)
#define APB1_SPEED_IN_MHZ (42)
#define I2C1_CR1 (*(volatile uint32_t *)(I2C1))
#define I2C1_CR2 (*(volatile uint32_t *)(I2C1 + 0x04))
#define I2C1_OAR1 (*(volatile uint32_t *)(I2C1 + 0x08))
#define I2C1_OAR2 (*(volatile uint32_t *)(I2C1 + 0x0c))
#define I2C1_DR (*(volatile uint32_t *)(I2C1 + 0x10))
#define I2C1_SR1 (*(volatile uint32_t *)(I2C1 + 0x14))
#define I2C1_SR2 (*(volatile uint32_t *)(I2C1 + 0x18))
#define I2C1_CCR (*(volatile uint32_t *)(I2C1 + 0x1c))
#define I2C1_TRISE (*(volatile uint32_t *)(I2C1 + 0x20))
#define I2C_CR2_FREQ_MASK (0x3ff)
#define I2C_CCR_MASK (0xfff)
#define I2C_TRISE_MASK (0x3f)
#define I2C_CR1_ENABLE (1 << 0)
void i2c1_setup(void)
{
uint32_t reg;
i2c1_pins_setup();
APB1_CLOCK_ER |= I2C1_APB1_CLOCK_ER_VAL;
I2C1_CR1 &= ~I2C_CR1_ENABLE;
i2c1_reset();
reg = I2C1_CR2 & ~(I2C_CR2_FREQ_MASK);
I2C1_CR2 = reg | APB1_SPEED_IN_MHZ;
reg = I2C1_CCR & ~(I2C_CCR_MASK);
I2C1_CCR = reg | (APB1_SPEED_IN_MHZ * 5);
reg = I2C1_TRISE & ~(I2C_TRISE_MASK);
I2C1_TRISE = reg | APB1_SPEED_IN_MHZ + 1;
I2C1_CR1 |= I2C_CR1_ENABLE;
}
从此刻起,控制器已准备好配置和使用,无论是主模式还是从模式。可以使用I2C1_DR读取和写入数据,就像 SPI 和 UART 一样。这里的主要区别是,对于主 I2C 设备,必须在I2C1_CR1寄存器中手动触发START和STOP条件。以下函数旨在实现此目的:
static void i2c1_send_start(void)
{
volatile uint32_t sr1;
I2C1_CR1 |= I2C_CR1_START;
do {
sr1 = I2C1_SR1;
} while ((sr1 & I2C_SR1_START) == 0);
}
static void i2c1_send_stop(void)
{
I2C1_CR1 |= I2C_CR1_STOP;
}
在每个条件的末尾,必须测试总线以检查可能出现的错误或异常事件。I2C1_CR1和I2C1_CR2中的标志组合必须反映事务继续所需的预期状态,或者在超时或不可恢复的错误发生时,必须优雅地终止。
由于事务设置期间可能发生的大量事件引起的复杂性,有必要实现一个完整的有限状态机,以跟踪传输的各个阶段,以便在主模式下使用收发器。
作为与收发器基本交互的演示,我们可以编写与总线进行顺序交互的代码,但在现实场景中,我们需要跟踪每个事务的状态,并对I2C1_SR1和I2C1_SR2中包含的标志组合可能出现的多种场景做出反应。此序列启动一个针对地址为0x42的 I2C 从设备的事务,如果从设备响应,它将发送两个字节,值分别为0x00和0x01。此序列的唯一目的是展示与收发器的交互,并且不会从任何可能的错误中恢复。在事务开始时,我们将与 ACK 或STOP条件相关的标志清零,并使用CR1中的最低位启用收发器:
void i2c1_test_sequence(void)
{
volatile uint32_t sr1, sr2;
const uint8_t address = 0x42;
I2C1_CR1 &= ~(I2C_CR1_ENABLE | I2C_CR1_STOP |
I2C_CR1_ACK);
I2C1_CR1 |= I2C_CR1_ENABLE;
为了确保没有其他主设备占用总线,程序将挂起,直到收发器中的忙标志被清除:
do {
sr2 = I2C1_SR2;
} while ((sr2 & I2C_SR2_BUSY) != 0);
使用之前定义的函数发送START条件,这将等待总线出现相同的START条件:
i2c1_send_start();
目标地址设置为即将发送的字节的最高 7 位。最低位也关闭,表示写操作。在接收从设备确认正确的地址选择后,必须将I2C1_SR2中的两个标志设置,以指示已选择主模式并且总线仍然被占用:
I2C1_DR = (address << 1);
do {
sr2 = I2C1_SR2;
} while ((sr2 & (I2C_SR2_BUSY | I2C_SR2_MASTER)) !=
(I2C_SR2_BUSY | I2C_SR2_MASTER));
与从设备的数据通信现在已经启动,可以传输 2 个数据字节。TX FIFO EMPTY 事件指示在事务中的帧内每个字节何时已传输:
I2C1_DR = (0x00);
do {
sr1 = I2C1_SR1;
} while ((sr1 & I2C_SR1_TX_EMPTY) != 0);
I2C1_DR = (0x01);
do {
sr1 = I2C1_SR1;
} while ((sr1 & I2C_SR1_TX_EMPTY) != 0);
最后,设置STOP条件,事务结束:
i2c1_send_stop();
}
中断处理
参考目标上的 I2C 控制器的事件接口足够复杂,可以为每个收发器提供两个独立的中断处理程序。针对通用 I2C 主机的建议实现包括适当的中断设置和定义所有状态和事件之间的组合。I2C 控制器可以配置为将中断与总线上发生的所有相关事件相关联,允许对特定角落情况进行微调,并实现 I2C 协议的更多或更完整的实现。
这就带我们来到了本章的结尾。
摘要
本章为我们提供了开始编程嵌入式目标上最流行的本地总线通信接口系统支持所需的信息。访问同一地理位置的外围设备和其他微控制器是嵌入式系统与传感器、执行器和其他设备交互的典型要求。
已经存在几种实现,为这里分析的收发器提供了更高层次的抽象。本章涵盖的串行通信协议,即 UART、SPI 和 I2C,通常可以通过作为板级支持包一部分的驱动程序访问,无需从头重新实现。然而,本章故意专注于从尽可能接近的角度研究组件的行为,以更好地理解硬件制造商提供的接口,并可能提供设计新方法访问接口的工具,这些方法针对特定平台或场景进行了定制或优化,同时理解某些协议设计特性背后的选择。
在下一章中,我们将描述通过研究现代嵌入式设备中存在的低功耗和超低功耗特性来降低嵌入式系统功耗的机制。
第八章:电源管理和节能
能效一直是微控制器市场的主要因素之一。自 2000 年代初以来,专为极低功耗设计的信号处理 16 位 RISC 微控制器,如 MSP430,一直引领着嵌入式系统中超低功耗优化架构的发展。
在过去几年中,功能丰富且能够运行实时操作系统的更先进的 32 位 RISC 微控制器,其尺寸和功耗都得到了降低,并进入了低功耗和超低功耗领域。依赖能量收集技术的电池供电系统和设备在许多行业中变得越来越普遍。现在,许多连接平台提供低功耗无线通信,因此越来越多的物联网系统在设计时包括了低功耗和超低功耗的特性。
根据架构,微控制器提供不同的策略来降低运行时的功耗,并在激活时实现消耗极低能量的低功耗状态。
降低嵌入式系统的能耗通常是一个复杂的过程。实际上,如果未正确停用,板上的所有设备都可能消耗电力。生成高频时钟是最昂贵的操作之一,因此 CPU 和总线时钟只有在使用时才应启用。
研究理想的节能策略取决于在性能和节能之间可以做出的妥协。专为超低功耗应用设计的微控制器能够降低 CPU 频率,甚至达到休眠状态的不同变体,在这种状态下,所有时钟都停止,外部外围设备关闭以实现最大程度的节能。
通过适当的能量分析技术,并实施超低功耗策略,电池供电设备可以在需要更换之前运行数年。使用太阳能板、热转换设备或从周围环境获取能量的其他形式,只要外部条件允许,经过良好分析的嵌入式系统可以无限期运行。
在非常高速运行的先进微处理器通常不是设计用来实施有效的功耗优化,这也是为什么像 Cortex-M 这样的小型低功耗微控制器在所有那些要求小功耗的嵌入式系统中如此受欢迎。
在本章中,我们将强调在设计低功耗和超低功耗嵌入式系统时的一些关键实践。以 Cortex-M 微控制器的低功耗扩展为例,展示了在真实目标上实现低功耗优化的实际应用。本章分为三个部分:
-
系统配置
-
低功耗工作模式
-
测量功耗
-
设计低功耗嵌入式应用
到本章结束时,你将了解如何管理微控制器和外设的不同低功耗配置。
技术要求
本章的代码文件可在 GitHub 上找到,网址为github.com/PacktPublishing/Embedded-Systems-Architecture-Second-Edition/tree/main/Chapter8。
系统配置
一个在其规范中包含功耗约束的系统必须设计成在所有方面满足要求,包括硬件、软件和机械设计。组件和外设的选择必须考虑它们的能耗。外部外设通常是功耗最大的组件,因此当它们未被使用时,必须由微控制器中断它们的电源。
本节将描述有关外设配置、系统时钟设置和电压控制的最佳实践,以及它们对功耗的影响。
硬件设计
在低功耗嵌入式系统中,硬件设计必须包括使用 GPIO 引脚开启或关闭外设的可能性。这最好使用一个通常为低电平的线路来完成,以便在 GPIO 未被微控制器驱动时,可以使用无源元件将其拉低。MOSFETs 通常用于控制外部外设的供电,使用 GPIO 信号来控制栅极电压。
即使通过中断外设的电源线关闭外设,也可能有较小的电流通过连接到它们的其他信号泄漏,例如串行总线或其他控制信号。硬件设计必须在早期原型阶段能够检测和识别这些泄漏,以最大限度地减少由此造成的能量损失。
此外,如果省电策略包括将微处理器置于深度睡眠操作模式的可能性,输入信号的逻辑必须进行调整,以提供正确的唤醒事件来恢复正常操作。在睡眠模式下可能不会驱动的信号必须保持一个已知的逻辑值,并使用无源元件强制执行。
时钟管理
未使用的内部外围设备和接口也必须保持关闭状态。如果平台支持,时钟门控通常是用于选择性地控制系统上每个外围设备和接口的时钟源的一种机制。在系统时钟门控配置中启用的每条时钟线都会增加功耗。此外,从慢速振荡器生成 CPU 时钟所应用的缩放因子越高,PLL 所需的能量就越高。PLL 是系统中最耗能的组件之一,CPU 的功耗也与其时钟频率成正比。许多 CPU 被设计为以较低的时钟速度运行,提供性能和节能之间的一系列可能权衡。因此,PLL 通常可以在运行时重新配置以适应不同的配置文件。然而,对系统时钟的任何更改都需要重新配置当前所有正在使用的计时器和外围设备的所有时钟分频器。
在参考平台上,我们可以在运行时重新配置 CPU 频率,以便在系统不需要计算性能时节省大量电力。为此,system.c中的函数已被修改,允许选择两种不同的运行频率。在性能模式下,系统以最大频率 168 MHz 运行。如果powersave标志参数不为零,则配置时钟以 48 MHz 运行,以实现更节能的场景:
void clock_pll_on(int powersave)
{
uint32_t reg32, plln, pllm, pllq,
pllp, pllr, hpre, ppre1, ppre2,
flash_waitstates;
if (powersave) {
cpu_freq = 48000000;
pllm = 8;
plln = 96;
pllp = 2;
pllq = 2;
pllr = 0;
hpre = RCC_PRESCALER_DIV_NONE;
ppre1 = RCC_PRESCALER_DIV_4;
ppre2 = RCC_PRESCALER_DIV_2;
flash_waitstates = 5;
} else {
cpu_freq = 168000000;
pllm = 8;
plln = 336;
pllp = 2;
pllq = 7;
pllr = 0;
hpre = RCC_PRESCALER_DIV_NONE;
ppre1 = RCC_PRESCALER_DIV_4;
ppre2 = RCC_PRESCALER_DIV_2;
flash_waitstates = 3;
}
闪存操作的等待状态数量也在此处进行了更改,因为根据 STM32F407 的文档,在 48 MHz 时,闪存只需要三个等待状态:
flash_set_waitstates(flash_waitstates);
设置系统时钟的步骤是常规的。首先,启用 HSI 并将其选为临时时钟源。之后,启用 8 MHz 外部振荡器,并准备好为 PLL 供电:
RCC_CR |= RCC_CR_HSION;
DMB();
while ((RCC_CR & RCC_CR_HSIRDY) == 0) {};
reg32 = RCC_CFGR;
reg32 &= ~((1 << 1) | (1 << 0));
RCC_CFGR = (reg32 | RCC_CFGR_SW_HSI);
DMB();
RCC_CR |= RCC_CR_HSEON;
DMB();
while ((RCC_CR & RCC_CR_HSERDY) == 0)
;
为所选模式设置的时钟分频器和乘数参数设置在 PLL 配置寄存器中,并启用 PLL:
reg32 = RCC_CFGR;
reg32 &= ~(0xF0);
RCC_CFGR = (reg32 | (hpre << 4));
DMB();
reg32 = RCC_CFGR;
reg32 &= ~(0x1C00);
RCC_CFGR = (reg32 | (ppre1 << 10));
DMB();
reg32 = RCC_CFGR;
reg32 &= ~(0x07 << 13);
RCC_CFGR = (reg32 | (ppre2 << 13));
DMB();
reg32 = RCC_PLLCFGR;
reg32 &= ~(PLL_FULL_MASK);
RCC_PLLCFGR = reg32 | RCC_PLLCFGR_PLLSRC | pllm |
(plln << 6) | (((pllp >> 1) - 1) << 16) | (pllq << 24);
}
更改 CPU 和系统时钟意味着必须重新配置使用这些时钟的所有外围设备。如果计时器正在运行,或者任何使用时钟作为参考的设备正在由应用程序使用,则必须根据时钟速度更新相应地调整用于提供时间参考的预分频寄存器。
以较低的速度运行系统提供了其他好处,例如,可以减少访问闪存所需的等待状态数量,并启用仅在系统未以全速运行时才可用的额外低功耗功能。
嵌入式平台通常包括低频时钟发生器,在 kHz 范围内,可以用作看门狗和实时时钟(RTCs)等时间保持设备的数据源。在低功耗操作模式下,外部或内部振荡器可以是活动的,并用于实现唤醒策略。
电压控制
微控制器的运行电压范围相对较宽。然而,供电较低的电压使得无法以全速运行 CPU,并且由于硬件的物理特性,闪存可能需要额外的等待状态。尽管如此,低电压容限的逻辑在某些情况下可以提高系统的整体经济性。
内部调节器通常可以配置为产生较低的核心信号电压,以便在 CPU 未以最大频率运行时在功耗和性能之间达到妥协。
常常被忽视的一个重要方面是数字输入逻辑中施密特触发器的功耗。当 GPIO 配置为数字输入,但没有通过外部无源组件强制到已知逻辑状态时,它们可能会在环境中的电磁场的影响下悬浮在平均值附近。这会导致输入信号被触发,从而在每个逻辑状态变化时损失少量能量。
低功耗操作模式
微控制器可以在不同的电源模式下执行,从全性能切换到完全休眠。正确理解微控制器的低功耗模式对于设计具有改进能源配置的系统是基本的。每个架构都提供特定的电源配置,其中 CPU 或其他总线和外设被禁用,以及系统软件用于进入和退出低功耗模式的适当机制。
在基于 ARM 的微控制器中,用于不同低功耗模式的术语可以总结如下:
-
正常操作模式:通过时钟门控选择活动组件,时钟以期望的频率运行。
-
睡眠模式:CPU 时钟暂时暂停,但所有外设保持与正常模式相同的功能。只要 CPU 不执行,在这种模式下可以节省明显的、即使是很小的电量。在接收到中断请求后可以恢复执行。这种模式也被一些芯片制造商称为等待模式。
-
停止模式:CPU 时钟和总线时钟被禁用。所有由微控制器供电的外设都关闭。内部 RAM 和 CPU 寄存器保留存储的值,因为主电压调节器仍然开启。功耗持续下降,但仍然可以通过外部中断或事件唤醒并恢复执行。这种模式通常也被称为深度睡眠模式,尽管实际上它是两种深度睡眠模式中的一种。
-
待机模式:所有电压稳压器都关闭,RAM 和寄存器的内容丢失。在待机阶段可能需要几微瓦的少量电力来保持备用电路的运行。唤醒仅在少数特定条件下才可能,例如外部供电的 RTC 或硬件预定义的唤醒事件引脚。当系统从待机唤醒时,将遵循正常的引导程序,并从复位服务例程恢复执行。
ARMv7 微代码提供了两条指令来进入低功耗操作模式:
-
等待中断(WFI)
-
等待事件(WFE)
这些指令可以在正常运行模式下随时调用。WFI会将系统置于低功耗模式,直到接收到下一个中断请求,而WFE则略有不同。只有系统中的少数事件,包括外部中断,可以配置为生成事件。如果系统处于使用WFE进入的睡眠或停止模式,则正常中断请求不会将系统放回正常运行模式。
调用后进入的低功耗模式取决于存储在0xE000ED10中的设置。SCR 仅提供 3 个有意义的 1 位标志字段:
-
SLEEPONEXIT(位 1):当启用时,系统将在下一个中断处理程序执行结束时进入低功耗模式。 -
SLEEPDEEP(位 2):确定在调用WFI或WFE或使用SLEEPONEXIT激活时返回中断时进入哪种模式。如果此位被清除,则选择睡眠模式。当此位激活时进入低功耗模式,系统将被置于停止或待机模式,具体取决于电源管理寄存器的配置。 -
SEVONPEND(位 4):当此位激活时,在低功耗模式下任何挂起的中断都会引起唤醒事件,无论是否使用WFI或WFE指令进入睡眠模式或停止模式。
注意,位 0、位 3 以及位 5-31 是保留的(必须保持为 0)。
深度睡眠配置
要在停止模式和待机模式之间进行选择,并设置与深度睡眠模式相关的某些参数,我们的参考平台提供了一个电源控制器,映射在内部外围区域,地址为0x40007000。该控制器由两个寄存器组成:
-
PWR_CR(控制寄存器)偏移量为0 -
PWR_SCR(状态和控制寄存器)偏移量为4
可以在这两个寄存器中配置的相关参数如下:
-
PWR_CR位 14。当激活时,在正常运行模式下通过配置内部稳压器为 CPU 核心逻辑产生略低的电压来节省额外的电力。此功能仅在目标未以最大频率运行时可用。 -
PWR_CR位 9。如果进入深度睡眠模式时处于活动状态,则在系统睡眠期间将完全关闭闪存。这导致节省了适量的电量,但也会影响唤醒时间。 -
PWR_CR位 1。此位确定 CPU 进入深度睡眠时进入哪种模式。如果清除,则选择停止模式。如果设置,系统进入待机模式。 -
PWR_CR位 0。此位仅在停止模式下有效。如果启用,它通过在内部电压调节器中启用欠压模式,在深度睡眠期间略微减少能耗。电流以降低泄漏模式供应给核心逻辑,这仍然允许您保留内存和寄存器的内容。此功能仅在系统未以全速运行时才可用。 -
PWR_CSR位 4。此标志确定唤醒引脚是否可以用作正常 GPIO,或者是否保留用于在待机期间检测唤醒信号。与参考平台中此功能相关联的引脚是 PA0。
PWR_CSR位 0。将1写入PWR_CR位 2(CWUF)。
在 STM32F407 微控制器上,我们可以使用以下宏访问与低功耗模式和配置相关的寄存器:
#define SCB_SCR (*(volatile uint32_t *)(0xE000ED10))
#define SCB_SCR_SEVONPEND (1 << 4)
#define SCB_SCR_SLEEPDEEP (1 << 2)
#define SCB_SCR_SLEEPONEXIT (1 << 1)
#define POW_BASE (0x40007000)
#define POW_CR (*(volatile uint32_t *)(POW_BASE + 0x00))
#define POW_SCR (*(volatile uint32_t *)(POW_BASE + 0x04))
#define POW_CR_VOS (1 << 14)
#define POW_CR_FPDS (1 << 9)
#define POW_CR_CWUF (1 << 2)
#define POW_CR_PDDS (1 << 1)
#define POW_CR_LPDS (1 << 0)
#define POW_SCR_WUF (1 << 0)
#define POW_SCR_EWUP (1 << 4)
对于低功耗模式的激活和自发生事件的生成,我们定义包含单个内联汇编指令的宏如下:
#define WFI() asm volatile ("wfi")
#define WFE() asm volatile ("wfe")
如果通过WFI进入睡眠模式,系统将暂停执行,直到下一个中断。使用WFE进入睡眠模式确保只有选定的事件可以再次唤醒系统。可以启用系统上发生的不同类型的事件来唤醒WFE。
当进入WFE时,NVIC 中所有活动的中断仍将被计为事件,从而唤醒WFE调用。可以通过在 NVIC 中禁用相应的 IRQ 线来临时过滤掉中断。如果通过 NVIC 以这种方式过滤中断,它将保持挂起状态,并在系统返回正常运行模式时立即处理。
停止模式
每次调用WFI或WFE指令时,只要SCB_SCR_SLEEPDEEP保持关闭状态,就会进入睡眠模式。可以通过启用SLEEPDEEP标志来启用其他低功耗模式。要进入可用的深度睡眠模式之一,必须在调用WFI或WFE之前配置SCB_SCR和 POW 寄存器。根据配置,系统将进入两种深度睡眠模式之一,停止或待机。
在以下示例中,一个连续的 1 Hz 定时器使用WFE切换 LED 10 次,然后进入深度睡眠模式。在定时器中断之间,main循环保持在睡眠模式,使用WFI:
void main(void) {
int sleep = 0;
pll_on(0);
button_setup();
led_setup();
timer_init(CPU_FREQ, 1, 1000);
while(1) {
if (timer_elapsed) {
WFE(); /* consume timer event */
led_toggle();
timer_elapsed = 0;
}
if (tim2_ticks > 10) {
sleep = 1;
tim2_ticks = 0;
}
if (sleep) {
enter_lowpower_mode();
WFE();
sleep = 0;
exit_lowpower_mode();
} else
WFI();
}
}
定时器的中断服务例程将tim2_ticks计数器增加1并设置timer_elapsed标志,这将使main循环切换 LED 并消耗由定时器生成的事件:
void isr_tim2(void) {
nvic_irq_clear(NVIC_TIM2_IRQN);
TIM2_SR &= ~TIM_SR_UIF;
tim2_ticks++;
timer_elapsed++;
}
enter_lowpower_mode过程负责根据所需的低功耗模式设置系统控制块和电源控制寄存器中的值,并相应地配置所有优化。
enter_lowpower_mode过程执行以下操作:
-
关闭 LED。
-
它设置
SCB_SCR和电源寄存器中的值,以配置在WFE时将进入的低功耗模式。 -
选择单个额外的电源优化。
实现如下:
void enter_lowpower_mode(void)
{
uint32_t scr = 0;
led_off();
scr = SCB_SCR;
scr &= ~SCB_SCR_SEVONPEND;
scr |= SCB_SCR_SLEEPDEEP;
scr &= ~SCB_SCR_SLEEPONEXIT;
SCB_SCR = scr;
POW_CR |= POW_CR_CWUF | POW_CR_FPDS | POW_CR_LPDS;
}
在这种情况下,通过激活低功耗电压调节器设置(通过POW_CR_LPDS)和关闭闪存(通过POW_CR_FPDS)来配置停止模式以尽可能减少功耗。
现在通过WFE()调用进入低功耗模式。为了能够唤醒系统,我们配置一个与用户在板上按按钮相关的EXTI事件。为此,我们将EXTI0配置为对上升沿敏感,因为当按下时,PA0 引脚的逻辑值从0变为1。
由于我们并不特别关注中断本身,我们确保在EXTI中关闭了生成中断请求的标志。事件控制器将确保生成一个事件,因为与输入引脚相关的标志在EXTI_EMR寄存器中被强制执行。
用户按钮事件的初始配置如下:
void button_setup(void)
{
uint32_t reg;
AHB1_CLOCK_ER |= GPIOA_AHB1_CLOCK_ER;
APB2_CLOCK_ER |= SYSCFG_APB2_CLOCK_ER;
GPIOA_MODE &= ~ (0x03 << (BUTTON_PIN * 2));
EXTI_CR0 &= ~EXTI_CR_EXTI0_MASK;
EXTI_IMR &= ~0x7FFFFF;
reg = EXTI_EMR & ~0x7FFFFF;
EXTI_EMR = reg | (1 << BUTTON_PIN);
reg = EXTI_RTSR & ~0x7FFFFF;
EXTI_RTSR = reg | (1 << BUTTON_PIN);
EXTI_FTSR &= ~0x7FFFFF;
}
没有为按钮配置中断,因为事件本身足以在停止模式下唤醒板。
进入停止模式后,PLL 将被禁用,当系统回到正常运行模式时,HSI 将自动被选为时钟源。为了恢复时钟配置,在退出停止模式后需要执行以下几个步骤:
-
清除
SCB_SCR_SLEEPDEEP标志,以便下一次调用WFI或WFE不会触发另一个切换到停止模式。 -
访问
POW_CR寄存器以清除硬件在停止模式结束时设置的唤醒标志。 -
由于时钟已恢复,PLL 再次被配置。
-
打开 LED。
-
再次启用
TIM2中断,以便在正常运行模式下恢复定时器的功能:void exit_lowpower_mode(void){SCB_SCR &= ~SCB_SCR_SLEEPDEEP;POW_CR |= POW_CR_CWUF | POW_CR_CSBF;clock_pll_on(0);timer_init(cpu_freq, 1, 1000);led_on();}
深度睡眠模式可以持续降低功耗,并且当系统必须保持当前运行状态但可以冻结更长时间时,这是理想的情况。
待机模式
在待机模式下,系统可以进入超低功耗模式,消耗仅几微安培的电流,同时等待外部事件重新初始化。进入待机模式需要你在调用WFI或WFE之前设置SCB_SCR_PDDS标志。当系统处于待机状态时,除了用于时钟独立看门狗定时器和实时时钟的低速振荡器外,所有电压调节器都关闭。
进入待机模式的步骤与进入停止模式的步骤略有不同。将SCB_SCR_PDDS标志设置为选择待机模式作为深度睡眠变体。在这种情况下,不激活标志SCB_SCR_LPDS,因为我们知道在待机模式下它没有效果:
void enter_lowpower_mode(void)
{
uint32_t scr = 0;
led_off();
scr = SCB_SCR;
scr &= ~SCB_SCR_SEVONPEND;
scr |= SCB_SCR_SLEEPDEEP;
scr &= ~SCB_SCR_SLEEPONEXIT;
SCB_SCR = scr;
POW_CR |= POW_CR_CWUF | POW_CR_FPDS | POW_CR_PDDS;
POW_SCR |= POW_CR_CSBF;
}
在这种情况下,为按钮按下设置EXTI事件是无用的,因为当微控制器处于待机模式时,GPIO 控制器将被禁用。退出此状态的最简单方法是配置实时时钟,在固定时间后生成一个唤醒事件。实际上,在待机阶段,只有少数外设会被保持激活,它们都被分组在时钟配置的特殊部分,即备份域。备份域包括实时时钟和一小部分时钟树,包含内部和外部低速振荡器。对备份域相关寄存器的写入访问由禁用备份域保护的标志,或POW_CR_DPB,位于POW_CR寄存器的第 8 位控制。
RTC 配置寄存器,从地址0x40002870开始映射在外设区域,由于电磁干扰而受到保护,这意味着在访问其他寄存器之前必须写入一个特殊的值序列。集成到参考平台中的 RTC 复杂且具有许多功能,例如跟踪日期和时间,以及设置自定义闹钟和常规时间戳事件。对于本例,我们只想使用唤醒事件,因此大多数 RTC 寄存器在此处未进行文档说明。
我们访问 RTC 的受限寄存器集如下:
-
控制寄存器(
RTC_CR)暴露了 RTC 提供的各种功能配置。在示例中,我们使用与唤醒触发器相关的值,通过唤醒定时器中断使能标志RTC_CR_WUTIE启用中断,并使用RTC_CR_WUTE也启用唤醒定时器计数器。 -
在本例中,初始化和状态寄存器(
RTC_ISR)用于通过特殊标志RTC_ISR_WUTWF在定时器设置期间检查唤醒定时器设置寄存器的写入状态。 -
唤醒定时器寄存器(
RTC_WUTR)用于设置下一次唤醒事件之前的时间间隔。 -
写保护寄存器(
RTC_WPR)用于在向该区域的其他寄存器写入之前传输解锁序列。
映射这些寄存器和有意义的字段的前置宏如下:
#define RTC_BASE (0x40002800)
#define RTC_CR (*(volatile uint32_t *)(RTC_BASE + 0x08))
#define RTC_ISR (*(volatile uint32_t *)(RTC_BASE + 0x0c))
#define RTC_WUTR (*(volatile uint32_t *)(RTC_BASE + 0x14))
#define RTC_WPR (*(volatile uint32_t *)(RTC_BASE + 0x24))
#define RTC_CR_WUP (0x03 << 21)
#define RTC_CR_WUTIE (1 << 14)
#define RTC_CR_WUTE (1 << 10)
#define RTC_ISR_WUTF (1 << 10)
#define RTC_ISR_WUTWF (1 << 2)
初始化 RTC 以生成唤醒事件的步骤包括以下内容:
-
如果尚未开启,请开启时钟门控以配置电源寄存器,以启用
POW_CR_DPB标志,从而启动 RTC 的设置:void rtc_init(void) {APB1_CLOCK_ER |= PWR_APB1_CLOCK_ER_VAL;POW_CR |= POW_CR_DPB; -
在 RCC 中,通过备份域寄存器配置中的位 15 启用 RTC。
RCC_BACKUP |= RCC_BACKUP_RTCEN; -
如果可用,启用备用时钟源,选择低速内部(LSI)振荡器或低速外部(LSE)振荡器。
-
在此示例中,我们使用 LSI 振荡器,因为参考平台上没有 LSE 振荡器。然而,外部振荡器更准确,并且在可用时总是首选,用于可靠的计时。时钟启用后,该过程通过轮询状态寄存器中的一个位等待它变得就绪:
RCC_CSR |= RCC_CSR_LSION;while (!(RCC_CSR & RCC_CSR_LSIRDY)); -
选择 LSI 作为 RTC 的源:
RCC_BACKUP |= (RCC_BACKUP_RTCSEL_LSI <<RCC_BACKUP_RTCSEL_SHIFT); -
启用中断和事件生成,将 EXTI 的第 22 行关联到上升沿:
EXTI_IMR |= (1 << 22);EXTI_EMR |= (1 << 22);EXTI_RTSR |= (1 << 22); -
通过写入解锁序列到
RTC_WPR解锁对 RTC 寄存器的写入:RTC_WPR = 0xCA;RTC_WPR = 0x53; -
禁用 RTC,以便允许写入配置寄存器。通过轮询
RTC_ISR_WUTWF等待写入操作成为可能:RTC_CR &= ~RTC_CR_WUTE;DMB();while (!(RTC_ISR & RTC_ISR_WUTWF)); -
在下一个唤醒事件之前设置间隔的值。LSI 频率为 32,768 Hz,唤醒间隔寄存器的默认分频器设置为 16,因此
RTC_WUTR中的每个单位代表 1/2048 秒。要设置 5 秒的间隔,我们使用以下方法:RTC_WUTR = (2048 * 5) – 1; -
启用唤醒事件:
RTC_CR |= RTC_CR_WUP; -
清除在从待机模式返回时可能设置的唤醒标志:
RTC_ISR &= ~RTC_ISR_WUTF; -
为了完成序列,我们将一个无效字节写入
RTC_WPR。这样,RCC 寄存器的写保护再次开启:RTC_WPR = 0xb0;} -
在进入待机模式之前,启用 RTC,以下程序确保计时器处于活动状态并计数,唤醒事件的生成是活跃的:
void rtc_start(void){RTC_WPR = 0xCA;RTC_WPR = 0x53;RTC_CR |= RTC_CR_WUTIE |RTC_CR_WUTE;while (((RTC_ISR) & (RTC_ISR_WUTWF)));RTC_WPR = 0xb0;}
如果在进入待机之前调用显示的流程,当唤醒事件发生时,系统将再次启动,但它不会从暂停的地方恢复执行,就像在其他低功耗模式中发生的那样。相反,它从复位中断处理程序重新开始,在中断向量表的开头。因此,此示例不需要为exit_lowpower_mode实现,将系统切换到待机模式的WFE指令永远不会返回到相同的执行上下文。最终,待机示例的main函数看起来如下:
void main(void) {
int sleep = 0;
clock_pll_on(0);
led_setup();
rtc_init();
timer_init(cpu_freq, 1, 1000);
while(1) {
if (timer_elapsed) {
WFE(); /* Consume timer event */
led_toggle();
timer_elapsed = 0;
}
if (tim2_ticks > 10) {
sleep = 1;
tim2_ticks = 0;
}
if (sleep) {
enter_lowpower_mode();
rtc_start();
WFE(); /* Never returns */
}
else
WFI();
}
}
唤醒间隔
在设计低功耗策略时需要考虑的一个重要方面是唤醒时间间隔,换句话说,系统在切换到低功耗模式后恢复执行所需的时间。具有实时要求的系统可能在功耗和反应性之间留出一些妥协的空间,但了解从不同低功耗模式唤醒操作的影响,以便预测最坏情况下的操作延迟是很重要的。唤醒时间在很大程度上取决于微控制器的硬件设计,并且很大程度上依赖于架构。
在我们的参考平台上,从睡眠模式唤醒需要少量 CPU 周期,但对于深度睡眠模式,情况就不同了。从停止模式唤醒需要几个微秒。在停止模式下激活的进一步优化,例如更改电压调节器或关闭闪存,会持续影响恢复到正常运行所需的时间。从待机模式唤醒后,唤醒间隔会更长,达到毫秒级别,因为系统在唤醒事件后应该完全重新启动,而启动代码执行时间会加到 CPU 唤醒所需的毫秒数中。
当设计低功耗系统时,必须考虑并正确测量这些唤醒时间,尤其是在系统必须处理实时约束的情况下。必须选择适合应用时序和能量配置文件要求的最佳低功耗模式,同时考虑到如果系统经常唤醒,这些间隔变得不可忽略时,离开低功耗模式产生的开销。
一旦系统设计为在适当的低功耗模式下运行,我们需要一个可靠的机制来测量系统运行时的功耗。下一节建议了一种常见的机制,通过跟踪测试电路中的电流值来测量微控制器的低功耗操作模式的影响,以及评估引入的所有节能优化。
测量功耗
可以在任何时候通过将电流表串联连接到设备来测量目标使用的电流。然而,这种机制并不能显示在时间间隔内值的所有振荡,这就是为什么通常使用示波器采样分流电阻两端的寄生电压值是有用的。
将分流电阻串联放置在目标设备与电源的两侧。其典型值相对较小,在几欧姆的范围内,以确保寄生电压保持较低,但仍可由示波器测量:

图 8.1 – 使用示波器测量分流电阻上的电压以采样电流
由于串联电路的特性,通过分流器的电流与目标系统使用的电流相同,因此分流电阻两端的电压相应地变化。
开发板
为了看到电源优化的效果,我们必须排除与系统无关的电子设备。例如,我们的参考板 STM32F407DISCOVERY 上有一个额外的微控制器,用于为主机提供调试接口,并且它使用相同的 USB 连接器供电。然而,开发套件通常提供一种方法来测量电流,排除无关硬件,使我们能够正确评估微控制器的低功耗特性,排除板上的开发相关电路。
在我们的参考板上,JP1 跳线可用于打开电源和微控制器电路之间的电路。通过将跳线替换为连接到两个引脚的电流表,我们可以测量实际系统使用的电流。同样,可以通过使用示波器采样分流电阻上的电压来应用分流电阻以监控电流。
一个配备可靠能量计量的实验室是评估低功耗实现和协助原型和设计阶段能量优化的良好起点。
设计低功耗嵌入式应用程序
在本节中,提出了一些设计模式,通过评估即将设计的系统所有组件的功耗和状态,以在目标设备上实现更好的能耗配置文件。一旦我们知道如何在目标设备上测量值,以及所选架构和微处理器系列中低功耗模式的详细信息,就可以编程应用程序,同时考虑其他参数,例如我们编写的软件的能量效率。
用睡眠模式替换忙碌循环
忙碌循环在爱好者中非常受欢迎的原因是它们非常容易实现。假设系统需要等待一个数字输入切换到低逻辑状态,并且这个输入映射到某个 GPIO。这可以通过以下一行代码轻松完成:
while((GPIOX_IDR & (1 << INPUT_PINX)) != 0)
;
虽然这完全按预期工作,但它将迫使 CPU 进入一个 fetch-decode-execute 循环,并在相同的几个指令之间跳跃,直到条件变为 false。正如我们所见,微控制器使用的功率主要取决于 CPU 的运行速度。较低的频率对应于每条指令使用的功率较小。在无限循环中执行指令而不切换到低功耗模式,将 CPU 的功耗设定在其最高值,持续一定可测量的时间——在这种情况下,逻辑输入改变状态所需的时间。
如果没有启用中断,积极轮询值是唯一的方法。本书中的示例倾向于引导你走向适当的中断处理方法。正确处理等待逻辑切换的方法是预见与下一个操作相关的中断线的激活。在 GPIO 线的情况下,我们可以使用外部中断触发器在条件满足时唤醒主循环,并在等待事件时切换到低功耗模式,而不是循环等待。
在许多其他情况下,通过调查另一种访问当前阻止系统执行下一步操作的外设的方法,可以避免实现如前所述的循环。现代串行和网络控制器配备了中断信号,当我们访问的硬件没有这些信号时,总有其他方法通过外部中断线感知事件。当一个设备确实只能以轮询模式运行时,作为最后的手段,可以通过将操作与定时器中断关联来降低轮询频率,这样就可以每秒轮询几次,甚至偶尔一次,使用与实际外设速度更匹配的间隔。执行定时操作允许 CPU 在之间睡眠,并切换到低功耗模式,从而降低 CPU 在忙碌循环时所需的平均能量。
本章中多次提到的这个规则的例外情况是在激活系统组件后等待就绪标志。以下代码激活了内部低速振荡器,并在进入低速模式之前用于待机模式示例。CSR 寄存器被轮询,直到低速振荡器实际运行:
RCC_CSR |= RCC_CSR_LSION;
while (!(RCC_CSR & RCC_CSR_LSIRDY))
;
在微控制器硅芯片中的集成外设上执行此类操作,具有几个 CPU 时钟周期的已知延迟,因此不会影响实时约束,因为类似内部操作的最大延迟通常在微控制器文档中提及。当轮询发生在状态和反应时间可能依赖于外部因素的不可预测的寄存器上时,情况就会改变,系统可能会出现长时间的忙碌循环。
在较长的非活动期间进行深度睡眠
如我们所知,待机模式允许系统以尽可能低的功耗冻结,处于超低功耗范围内。当设计对超低功耗有非常严格的要求,并且满足以下条件时,建议使用待机模式:
-
存在一种可行的唤醒策略,并且与当前的硬件设计兼容
-
系统可以在不依赖其先前状态的情况下恢复执行,因为 RAM 和 CPU 寄存器的内容已丢失,系统在唤醒时从复位服务例程重新启动
通常,较长的非活动期,例如,可以使用 RTC 在指定时间编程唤醒闹钟,更适合使用待机模式。这适用于在白天以编程间隔读取传感器和启用执行器、跟踪时间和一些状态变量等情况。
在大多数其他情况下,停止模式仍然可以节省足够的电量,并提供更短的唤醒间隔。停止模式的另一个主要优点是唤醒策略选项的灵活性增加。实际上,任何基于中断或可配置的事件都可以用来从微控制器的低功耗深度睡眠模式唤醒系统,因此它更适合与微控制器周围的外围设备和接口进行一些异步交互的状态。
选择时钟速度
平台提供的所有计算能力是否在所有时候都是必需的?
现今微处理器的处理性能与 20 年前的个人计算机相当,这些计算机已经能够进行快速操作,甚至处理实时多媒体内容。嵌入式应用并不总是需要 CPU 以全频率运行。特别是在访问外围设备时,而不是进行数值计算,CPU 和总线时钟的速度并不重要。当每次 CPU 性能不是执行管道的瓶颈时,无论是正常运行模式还是睡眠模式,所选频率降低时,两者所需的能量都大大减少。
许多微控制器被设计为降低 CPU 和内部总线的运行频率,这也通常允许系统以较低的电压供电。正如我们所见,时钟的改变可以在运行时进行,以在功率和性能方面做出相应的妥协。然而,这意味着所有使用时钟作为参考的设备都必须重新配置,因此这种改变在执行时间上有成本,不应滥用。将频率变化添加到系统设计的一个方便方法是,将两个或多个 CPU 频率缩放选项分离成自定义电源状态,并通过在性能和功耗之间淡入淡出切换到所需状态。
功率状态转换
考虑一个连接到传感器的系统,通过网络接口产生和传输数据。传感器被激活后,系统必须等待它准备好,这通常需要几秒钟。然后连续读取传感器多次,然后关闭。数据通过网络设备进行处理、加密和传输。接下来几小时系统保持空闲,然后重复相同的操作。状态机的初步粗略建模如下:

图 8.2 – 假设的传感器读取系统的状态机
在连续两个周期之间预见的长时间空闲间隔表明,将系统大部分时间置于待机状态,并为系统编程一个 RTC 闹钟,以便在下次采集前自动唤醒,可能是一个好主意。
对于其他状态,也有可能进行其他不太明显的优化。在从传感器获取数据时,CPU 的完整计算能力可能从未被充分利用,因为系统大部分时间都在与传感器通信,或者等待,可能在睡眠模式下,直到接收到下一个值。在这种情况下,我们可以提供一个节能的运行模式,确保系统以较低的频率运行,这样在运行和睡眠模式之间交替时,两者都会受到较小的能量足迹的影响。只有在数据处理、转换并通过网络设备发送时,才需要更高的性能。在这种情况下,系统将被优化以更快地运行并在更短的时间内处理数据。如果传感器能够在准备开始数据采集时唤醒系统,则可以在传感器激活后预见一个停止阶段。
一旦每个阶段都与其优化的低功耗模式和选定的操作频率相关联,我们就可以在我们的设计文档中添加注释,以提醒我们低功耗优化将如何实现,以实现性能、能源经济和低延迟的最佳组合。以下图总结了阶段之间的转换及其相关的低功耗模式:

图 8.3 – 每个运行和空闲状态下的功耗优化
为嵌入式系统调整其最佳能效曲线是一个精细的过程,它对其他性能指标有重大影响,会引入延迟并减慢执行速度。在大多数情况下,它包括在提供可接受性能的同时,保持功耗和能量需求在期望范围内找到最佳折衷点。
摘要
现代嵌入式系统为低功耗甚至超低功耗设计打开了多种可能性。本章分析了针对参考微控制器可用的不同能效曲线,以及如何设计、集成和评估这些程序以控制能量感知嵌入式系统中的功耗。在理解了目标设备可用的几种选项后,实现低功耗模式和进一步节能技术是构建耐用且可靠的电池供电和能量收集设备的关键。
在下一章中,我们将转向介绍连接设备,并描述在嵌入式系统架构中处理网络协议和接口的影响。
第九章:分布式系统和物联网架构
通过访问通信外围设备,如网络控制器和无线接口,微控制器能够与附近的设备以及通过互联网的远程服务器建立数据通信。
一组相互连接并交互的嵌入式目标可以被视为一个自包含的分布式系统。可以使用非标准,甚至专有的协议来实现同质化的机器到机器通信。
根据它实现的协议集,嵌入式系统可能能够成功地与异构的远程系统通信。实现标准化的或广泛支持的协议引入了与同一地理区域内网关交互,以及通过互联网与远程云服务器交互的可能性。
小型嵌入式设备的连接范围可能包括使用信息技术(IT)系统进行远程协调。这两个世界的相遇改变了现代对分布式系统的解释:低功耗、低成本的设备现在可以成为根植于 IT 的服务的一部分,反过来,它们可以将分支扩展到本地化和专业化的传感器和执行器,从而创造出所谓的物联网(IoT)。
这一技术步骤被许多人视为革命性的,能够永远改变我们获取技术的方式,以及人机交互过程。不幸的是,物联网通信的安全方面往往被忽视,导致不愉快的事件,可能损害传输数据的机密性和完整性,并允许攻击者控制远程设备。
本章分析了可以集成到嵌入式目标中的电信技术和协议,利用它们从整个嵌入式系统的角度更好地理解设计,直至集成到物联网网络中。
我们将学习网络模型,从物理层和建立无线或有线链路的可能技术开始,直至定制嵌入式应用程序,它们可以使用标准通信协议与云服务建立安全通信。
尤其,我们将关注以下内容:
-
网络接口
-
互联网协议
-
TLS
-
应用协议
到本章结束时,你将深入理解当今微控制器的物联网功能。
技术要求
在本章中,我们假设您已经熟悉现代计算机网络的一般概念,尽管不需要有分布式应用的经验。为了更全面地了解与本章内容相关的网络编程背景,我们建议进一步阅读《动手实践 C 语言网络编程》(L. Van Winkle – Packt Publishing 2019)。本书的仓库中没有提供本章的具体示例。更完整的 TCP 和传输层安全性(TLS)客户端/服务器通信示例可以在这里展示的开源项目的源代码分布中找到。
网络接口
嵌入式设备通常集成了一个或多个通信接口。许多微控制器集成了以太网接口的媒体访问控制(MAC)部分,因此连接一个物理层收发器(PHY)就可以实现局域网访问。一些设备与无线电收发器相连,在固定的频率范围内工作,并实现一个或多个协议以通过无线链路进行通信。无线通信中常用的频率是 2.4 GHz 频段,蓝牙和 802.11 Wi-Fi 都在使用这个频段,以及一些低于 1 GHz 的特定 ISM 频段,这些频段取决于当地法规。可用的亚 GHz 频率包括欧盟的 868 MHz ISM 频段和美国的 915 MHz ISM 频段。收发器通常设计为根据特定的链路协议访问物理层,调节两个或多个设备之间对物理媒体的共享访问。虽然访问相同媒体的两个接口可以有不同的配置,但实现的 MAC 模型必须遵循所有端点上的相同规范,以便建立点对点通信。MAC 层的一部分可能是在设备本身中实现的,它反过来可以使用并行或串行接口将数据传输到和从微控制器。
硬件制造商可能会分发设备驱动程序以访问链路层。当完整的源代码可用时,开发者更容易定制媒体访问,集成设备通信功能,并将通信定制为媒体支持的任何协议栈。然而,许多设备驱动程序只是部分开源,有时限制了与开放标准的集成可能性。此外,将第三方专有代码集成到嵌入式系统中会影响项目维护,通常需要解决已知问题或启用制造商未预见的功能,并且肯定会影响系统的安全模型。
在嵌入式系统中实现设备驱动程序,无论是有线还是无线网络接口,包括在通信逻辑中集成相关的访问控制机制,并处理特定的信道特性。链路的一些特性可能会影响高级通信的设计,从而影响整个分布式系统的架构。在可靠地与 MAC 机制交互的同时,比特率、延迟和最大数据包大小等问题必须在设计阶段解决和评估,以根据系统的目标评估所需资源。
下一个部分提供了嵌入式世界中一些流行网络接口的概述,这些接口通常由连接的设备用于与其他分布式系统组件通信。接下来的部分将建议一些标准,以在通信基础设施和协议设计过程中选择最适合特定目的的技术。
MAC
在任何物理媒体上建立成功通信链路的最重要组件被归类在 MAC 逻辑中,其实现通常是软件和硬件共同的责任。不同的技术已经发展起来,以定义标准来访问现在用于机器对机器通信的链路,而只有少数能够在没有进行协议转换的中间网关的地理分布式物联网系统中扩展。
一些标准直接源自 IT 世界,并包括现有 TCP/IP 技术的改编,这些技术能够缩小规模以适应嵌入式系统有限的资源。其他标准完全在小型嵌入式设备的背景下发展起来,通过与经典 IT 基础设施的建模在低功耗无线技术之上实现 TCP/IP 协议的交互。在两种情况下,研究融合是由将小型、低成本、自供电设备更广泛集成到物联网服务中的需求所决定的。
对于嵌入式系统来说,没有一种万能的解决方案来定义网络访问。嵌入式行业的需求差异促使开发了定制的 MAC 协议和技术,这些协议和技术既有标准的也有专有的,每个都针对特定功能或一系列嵌入式系统的需求进行定制。
在以下子部分中,将描述一些最成功的机器对机器通信 MAC 技术,考虑到与采用该技术和集成模式相关的方面。
以太网
即使对于整个系统的大小与 RJ-45 连接器相当的情况可能听起来有点不切实际,以太网仍然是可集成到嵌入式系统中最可靠和最快的通信通道。
许多 Cortex-M 微控制器配备了一个以太网 MAC 控制器,该控制器必须与外部 PHY 集成。其他链路层协议实现了相同的链路层寻址机制,即在每个传输的包中附加一个 14 字节的预头,指示源和目的链路地址以及正在传输的包中包含的有效载荷类型。每当数据包通过 TCP/IP 堆栈路由到类似以太网的接口时,MAC 地址都会被重写,以便与数据包在其前往最终目的地的旅程中必须穿越的下一个链路相匹配。
设备驱动程序可以激活过滤器,丢弃所有不涉及主机的流量,否则这些流量将不必要地影响 TCP/IP 堆栈处理的背景数据通信量。
Wi-Fi
在无线宇宙的所有可能性中,802.11 Wi-Fi 被选中,因为它具有高速、低延迟的信道,以及最广泛可能的拓扑兼容性,包括与个人计算机和移动设备。然而,Wi-Fi 收发器的功耗有时对于低功耗设备来说可能难以承受。调节媒体访问的协议和机制的复杂性需要一定量的控制软件,这些软件通常以二进制形式分发,因此没有制造商的支持,无法进行调试和维护。
Wi-Fi 提供了大带宽和合理的低延迟,并且可以在数据链路层实现身份验证和加密。
虽然在技术上可以通过配置 Wi-Fi 收发器以对等模式操作来实现本地网状网络,但配备 802.11 技术的嵌入式系统主要用于连接到现有基础设施,以与其他便携式设备交互并访问互联网。
市场上可提供几种嵌入式低成本平台,配备 TCP/IP 堆栈和内置的 RTOS,可以作为独立平台使用,或集成到完整的系统中,以作为工作站或提供接入点的方式访问无线局域网。
低速率无线个人区域网络 (LR-WPANs)
传感器网状网络广泛使用无线技术,在局部地理区域内建立通信。802.15.4 标准规定了 2.4 GHz 和亚 GHz 频段的接入,为有限范围的局域网提供典型最大比特率为 250 Kbps,可以使用低成本、低功耗的收发器进行访问。媒体接入不基于基础设施,并在 MAC 层支持争用解决和碰撞检测,使用信标系统。每个节点可以使用 2 个字节进行寻址,特殊地址0xFFFF保留用于广播流量,以到达所有可见节点。802.15.4 帧的最大有效载荷大小固定为 127 字节,因此无法封装从以太网或无线局域网链路路由的全尺寸 IP 数据包。能够通过 802.15.4 接口进行通信的网络协议实现,要么是特定应用的,要么不支持 IP 网络,或者提供分片和压缩机制,以在多个无线帧之间传输和接收每个数据包。
虽然 LR-WPAN 并不是专门为物联网设计的,也不直接与经典 IP 基础设施兼容,但有多种选择可以在 802.15.4 之上构建网络。事实上,虽然标准规定了在可见节点之间交换帧的 MAC 协议,但已经开发了多种链路层技术,包括标准和非标准技术,以在 802.15.4 之上定义网络。
LR-WPAN 工业链路层扩展
多亏了收发器的灵活性,以及传输和接收 802.15.4 原始帧的能力,实现 LR-WPAN 的网络协议相对容易。
在物联网时代之前,过程自动化行业是第一个采用 802.15.4 技术的,并且长期以来一直在寻找一个标准协议栈,以实现不同制造商设备之间的兼容性。Zigbee 协议栈努力成为 802.15.4 网络的事实上、行业强制标准,考虑到其商业使用中适用的专有、封闭源代码和版税,取得了显著的成功。在平行努力中,国际自动化学会(ISA)提出了一份开放标准 ISA100.11a 的建议,旨在定义基于 802.15.4 链路构建网络的指南,用于工业自动化过程。另一个工业自动化协议,最初由一个企业联盟开发,然后由国际电工委员会(IEC)批准为工业自动化标准,是 WirelessHART。
类似于 Zigbee、ISA100.1 和 WirelessHART 的技术定义了 802.15.4 之上的整个协议栈,包括网络定义和传输机制,提供定制地址机制和通信模型,并导出一个 API,该 API 可用于集成应用程序。从分布式系统设计的角度来看,为定制网络中的设备启用互联网连接,不实现 IP 栈,需要一个或多个设备充当网关,重新路由和转换每个数据包以适应定制 LR-WPAN 协议栈。然而,这种转换过程违反了 TCP/IP 通信的端到端语义,影响了通信的各个方面,包括端到端安全。
6LoWPAN
6LoWPAN,在 RFC 4944 中描述,是 IETF 标准化的 802.15.4 链路协议,能够传输 IPv6 数据包,并且是 IP 兼容性 LR-WPANs 的既定标准。6LoWPAN 使得嵌入式系统能够通过 802.15.4 接口访问互联网,只要节点实现了 TCP/IP 网络,并且链路层提供了使用短 LR-WPAN 帧传输和接收完整 IP 数据包的机制。数据包的内容被分割并传输到连续的传输单元中,网络和传输头部可以选压缩以减少传输开销。
目前没有 6LoWPAN 标准的 IPv4 对应版本;然而,IETF 正在评估采用类似方法的提案,以使嵌入式节点能够实现传统的 IPv4 连接。
6LoWPAN 是多个网络栈实现的一部分,也是近期创建工业联盟 Thread group 的尝试之一,该联盟的目标是推广基于开放标准协议的完全 IPv6、低功耗的网状网络技术,这些协议是为物联网设计的。多个免费和开源的 TCP/IP 栈以及嵌入式操作系统支持 6LoWPAN,并且可以访问 802.15.4 收发器,提供构建基于功能和协议的 IP 网络的必要链路基础设施。
网状网络可以可选地添加到链路层,以提供一种名为“网状下”的透明桥接机制,其中所有帧都由链路层重复发送到网状网络的远程角落,直到到达目的地。
由于 6LoWPAN 为构建网络拓扑提供了基础设施,因此网状网络可以采用不同的方法,使用应用层协议在 IP 级别更新路由表。这些机制被称为“网状路由”,基于标准化的动态路由机制,也可以用于扩展跨越不同物理链路的网状网络。
蓝牙
另一种不断发展的机器到机器连接技术是蓝牙。其物理层基于 2.4 GHz 通信来建立主机/设备通信或为支持多个协议(包括 TCP/IP 通信)的 PAN 提供基础设施。得益于其长期的成功及其在个人电脑和便携式设备市场中的广泛采用,蓝牙连接已经开始在嵌入式微控制器领域获得人气,这主要归因于最近标准在降低功耗方向上的发展。
最初设计为近距离设备的无线串行通信替代品,经典的蓝牙技术已经发展到支持集成专用通道,包括具有 TCP/IP 功能的网络接口和专用音频和视频流链接。
标准定义的第 4 版引入的协议栈的低功耗变体,旨在限制嵌入式传感器节点的能耗,并引入了一组新的服务。传感器设备可以导出通用属性配置文件(GATT),客户端(通常为主机机器)可以通过它来建立与设备的通信。当目标设备上的收发器处于非活动状态时,它消耗少量电力,同时仍然可以从客户端发现其属性并启动 GATT 传输。蓝牙现在主要用于短距离通信;从个人电脑和便携式设备访问传感器节点;与远程音频设备(如扬声器、耳机和无绳汽车语音接口)交换多媒体内容;以及在几个医疗保健应用中,因为一些配置文件专门为此目的而设计。
移动网络
使用与便携式设备通过移动网络(如 GSM/GPRS、3G 和 LTE)访问互联网相同的技术,现在可以将没有固定基础设施的远程设备连接起来。接入宽带移动连接的设备所具有的日益增长的复杂性、成本和能源需求,使得将此类网络通信集成到基于微控制器的嵌入式设备中的影响日益增加。移动网络原生支持 TCP/IP 协议,并提供直接连接到互联网,或在某些情况下,连接到接入基础设施提供的受限网络。
尽管在某些特定市场(如汽车和铁路)中仍然很受欢迎,但宽带网络接入配置文件通常对于从远程传感器设备传输少量信息来说过于冗余,而用于访问较老、窄带宽技术的简单调制解调器正逐渐从市场上消失。
随着移动网络技术的发展,专注于手机市场的需求,嵌入式设备架构师正在寻找更适合分布式物联网系统需求的新技术。新技术更好地满足嵌入式市场的目标,并朝着低功耗、低成本、长距离通信的方向发展。
低功耗广域网(LPWANs)
LWPANs 是一系列新兴技术,填补了市场对低成本、低功耗、长距离、窄带通信的需求。至于 LR-WPANs,不同的工业联盟已经形成,试图征服市场,并在某些情况下,为通用 LPWAN 网络建立标准协议栈。这个过程导致了在功能、成本和节能特性方面的健康竞争。
LPWAN 技术通常基于亚 GHz 的物理信道,但使用不同的无线电设置,从而增加了通信范围。设备可以通过空中相互通信,在某些情况下,使用基础设施来增加覆盖范围,甚至在基站可视范围内跨越数千公里。
在这个领域最引人注目的新兴技术包括以下内容:
-
LoRa/LoRaWAN:基于专利的无线接入机制和完全专有的协议栈,这项技术提供了与类似技术相比具有高比特率的远程通信。虽然它提供了几个有趣的功能,例如在无基础设施的情况下进行本地节点到节点的通信,但封闭的协议方法使得这种方法对嵌入式市场不太吸引人,并且不太可能最终在 LPWAN 竞争中保持其位置,而是更倾向于更开放的标准。
-
Sigfox:这种超窄带无线电技术需要基础设施才能运行,并在非常长的范围内提供特别低的比特率。受监管的基础设施接入允许每天从或向节点传输有限数量的字节,并且消息的有效负载固定为 12 字节。尽管物理层实现是专有的,但协议栈以源代码形式分发。然而,一些国家的无线电法规仍然是一个开放的问题,可能会影响这项技术在全球范围内的开发,尽管它在欧洲市场取得了相当大的成功。
-
Weightless:另一种基于超窄带的科技,Weightless 是 LPWAN 在亚 GHz 范围内运行的完全开放标准。在范围和性能方面与 Sigfox 相似,它提供了一个改进的安全模型,作为经典预共享密钥部署机制的替代方案,允许通过空中安全密钥协商机制。
-
DASH7:这里描述的技术中最年轻的是基于完全开放的设计。整个轻量级协议栈的源代码由 DASH7 联盟提供,这使得该技术更容易集成到嵌入式系统中。这个协议栈旨在在设计分布式系统时提供灵活性,因为定义网络拓扑结构有多种选择。
LPWAN 协议与 IP 不直接兼容,需要网络中的一个节点根据从节点获取的远程通信数据生成 TCP/IP 流量。网络流量的间歇性和低比特率特性使这些技术在其自己的领域内运行,并需要节点在分布式系统架构预见访问互联网上的远程节点时能够重新路由数据。
选择合适的网络接口
根据用例,每个嵌入式系统都可能从本节中描述的技术提供的通信设施中受益。由于某些嵌入式设备的高度专业化,针对特定用例的设计甚至可能超出这种分类,并使用为特定用例设计的科技。在某些情况下,由于某些环境中的辐射法规或媒体无法可靠地传输无线电波(如水下或通过人体),无线通信是不可能的。
潜艇可能通过特定的收发器进行通信,使用声波来表示数据。同时,也有其他广泛的技术可用于有线通信。电力线通信允许重用现有电线来更新旧设备,并带来本地网络连接,通过使用不会影响电线原有用途的高频调制来扩展以太网或串行接口总线。
实际上,嵌入式设备在连接性方面有广泛的可能性。最佳选择始终取决于具体的用例和系统上可用的资源,这些资源用于实现达到通信另一端所需的协议和标准。在选择通信技术时,可能需要考虑几个方面:
-
通信范围
-
数据传输所需的比特率
-
总拥有成本(收发器价格、集成努力和服务成本)
-
媒体特定的限制,例如由收发器引入的任何延迟
-
射频干扰对硬件设计要求的影响
-
最大传输单元
-
功耗和能源足迹
-
支持与第三方系统兼容的协议或标准
-
符合互联网协议以集成到物联网系统中
-
网络拓扑的灵活性、动态路由和网状网络可行性
-
安全模型
-
实现特定技术驱动程序和协议所需资源
-
使用开放标准以避免长期项目的锁定
连接设备的技术每一种都提供了不同的方法来处理其内在设计中这些方面的解决方式,也取决于技术是否是从不同的上下文中借用的,例如以太网或 GSM/LTE,或者是否是为低功耗嵌入式系统设计的,如 LR-WPAN 和 LWPAN 协议。
在设计分布式系统时选择适当的通信通道是一项需要硬件和软件设计严格协作的操作。创建连接设备涉及一个更复杂的层次,尤其是在低功耗领域。
下一节将重点介绍如何实现互联网协议以适应缩小到嵌入式设备,以产生在标准内操作且功能丰富的网络端点。TCP/IP 堆栈实现可以扩展和配置以满足物联网分布式系统的需求。这里不涵盖将非 IP 协议通过边界网关翻译以在物联网系统中集成非标准通信的情况(边缘网关),因为这些通常涉及具有多个网络接口的更大专用系统。
正如我们所观察到的,嵌入式行业已经足够专业化,能够在标准边缘进行操作,但一个新的研究趋势正在将 TCP/IP 通信重新定位回其作为网络通信既定标准的原始位置,这是由于现有 IT 基础设施在分布式系统中的影响力日益增加,包括小型、低功耗、成本效益的嵌入式系统。这也最近在市场上扩展到标准安全功能,增加了在嵌入式系统中安全端到端通信协议(如 TLS 和 DTLS)的存在。
互联网协议
在 20 世纪 80 年代初标准化,现在通常被称为 TCP/IP 的 IP 栈,是一组网络、传输和应用协议,提供在广泛的技术和接口上提供标准通信。在接下来的小节中,我们将讨论这些标准协议如何集成到嵌入式系统中,描述嵌入式应用程序用于与远程端点通信的接口,以及如何与堆栈的不同层进行交互,从网络接口到套接字抽象,以建立与远程对等方的连接或无连接会话。
标准协议,定制实现
使用非标准协议栈设计分布式通信,在几乎所有情况下,都不值得付出重新发明最先进技术的努力。TCP/IP 标准已经经过了数十年的广泛研究,并且已经成为我们今天所知道的互联网的主要构建块,整合了数十亿种异构设备。为嵌入式系统配备 TCP/IP 功能不再是开创性的任务,因为存在几个开源实现,并且它们可以轻松地集成到小型嵌入式系统中,只要它们可以访问提供两个或更多端点之间数据传输能力的物理通信通道。
套接字是访问网络应用中传输层通信的标准方式。伯克利套接字模型,后来由 POSIX 标准化,包括函数和组件的命名标准以及 UNIX 操作系统的行为。如果 TCP/IP 栈与操作系统集成,调度器可以提供一个机制在等待特定输入时挂起调用者,并且可以实现对 POSIX 规范的套接字调用 API 的实现。然而,在裸机事件驱动应用程序中,使用回调与套接字同步,以遵循主循环的事件驱动模型。因此,编写与网络协议交互的应用程序在 API 和范例方面略有不同。在单个线程的非阻塞网络应用程序中,除了主循环函数本身外,没有任何操作应该在等待事件时使 CPU 忙碌。套接字函数调用也不例外,需要一种机制来启动操作,注册一个处理操作结束的回调函数,然后立即返回主循环。
TCP/IP 栈
现代 TCP/IP 栈可能是分布式嵌入式系统最基本的部分。通信的可靠性取决于标准协议实现得有多准确,而设备上运行的服务安全性可能会因 TCP/IP 栈实现、其接口驱动程序以及提供套接字抽象的粘合代码中的缺陷而受到损害。
最受欢迎的嵌入式设备开源 TCP/IP 库是轻量级 IP栈,最好称为lwIP。它集成了许多实时操作系统,甚至由硬件制造商捆绑分发,lwIP 提供了 IPv4 和 IPv6 网络、UDP 和 TCP 套接字通信、DNS 和 DHCP 客户端,以及可以仅使用几十 KB 内存集成到嵌入式系统中的丰富应用层协议。尽管为小型微控制器量身定制,但像 lwIP 这样的功能齐全的栈所需资源对于一些较小的设备来说超出了范围,包括大多数具有超低功耗特性的传感器处理目标。
微 IP,通常称为 uIP,是一个基于处理单个缓冲区一次的非凡但绝妙的直觉的最小化 TCP/IP 实现。不需要在内存中分配多个缓冲区,可以将 TCP/IP 通信所需的 RAM 量限制在尽可能小的范围内,并简化 TCP 和其他协议的实现复杂性,从而减少了整个堆栈的代码大小。uIP 并未设计用于扩展到更高的比特率或实现高级功能,但有时它是连接资源非常有限的节点(主要是 LR-WPAN 网络)的最佳折衷方案。
picoTCP 是一个具有较近历史的免费软件 TCP/IP 堆栈。它与 lwIP 具有相似的资源占用和功能列表,但具有不同的模块化设计和对物联网协议的更强关注,提供动态路由、IP 过滤和 NAT 功能。通过在 802.15.4 设备上对 6LoWPAN 的原生支持,picoTCP 可以用于构建网状网络,既可以利用 6LoWPAN 中的网状功能,也可以使用更经典的通过路由协议(如 OLSR 和 AODV)提供的动态路由方法。
对于开源和专有 TCP/IP 堆栈,都存在其他实现,这些实现可以集成到裸机应用程序和嵌入式操作系统中,通常提供类似的 API 以集成接口驱动程序并与系统交互,以向高级应用程序提供套接字通信。嵌入式 TCP/IP 堆栈通过设备驱动程序连接到网络设备,提供发送帧到网络的功能,并能够使用入口点函数交付接收到的数据包,该函数是 TCP/IP 堆栈用来接管数据包的。当前由 TCP/IP 堆栈处理的数据包可能需要异步操作,因此应用程序或操作系统必须确保定期调用堆栈循环函数,以便它可以处理缓冲区中的数据包。最后,传输层为应用程序提供套接字接口,以便创建和使用套接字与远程端点通信。
网络设备驱动程序
为了集成网络接口的驱动程序,TCP/IP 堆栈向其底层暴露了一个接口,发送和接收包含帧或数据包的缓冲区。如果设备支持链路层以太网地址,TCP/IP 堆栈必须连接一个额外的组件来处理以太网帧,并激活邻居发现协议,在开始任何 IP 通信之前找到接收设备的 MAC 地址。
lwIP 提供了一个 netif 结构,描述了一个网络接口,该结构必须由驱动程序代码分配,但随后由堆栈使用 netif_add 函数自动初始化:
struct *netif netif_add(struct netif *mynetif,
struct ip_addr *ipaddr,
struct ip_addr *netmask,
struct ip_addr *gw, void *state,
err_t (* init)(struct netif *netif),
err_t (* input)(struct pbuf *p, struct netif *netif));
ipaddr、netmask和gw参数可用于设置通过此接口创建的链路的初始 IPv4 配置。lwIP 支持每个接口一个 IPv4 地址和三个 IPv6 地址,但所有这些都可以在稍后通过访问netif结构中的相关字段进行重新配置。IP 地址可以通过静态 IP 地址或自动分配机制进行配置,例如 DHCP 协商,或从链路本地地址推导。
state变量是一个用户定义的指针,可以在驱动程序代码中使用netif->state指针创建网络设备和私有字段之间的关联。
提供给init参数的函数指针在栈初始化期间被调用,使用相同的netif指针,并且驱动程序必须使用它来初始化netif设备的剩余字段。
通过输入参数提供的函数指针描述了栈在从网络接收到数据包时必须执行的内联操作。如果设备使用以太网帧进行通信,则应提供ethernet_input函数以指示在解析帧内容之前需要为以太网帧进行额外处理,并且网络支持邻居发现协议,在传输数据之前将 IP 地址关联到 MAC 地址。如果驱动程序处理裸 IP 数据包,则接收函数应为ip_input。
设备驱动程序初始化在init函数中完成,该函数还必须为netif结构中的其他重要字段分配值:
-
hw_addr:包含以太网设备的 MAC 地址,如果支持的话。 -
mtu:此接口允许的最大传输单元大小。 -
name/num:用于系统中的设备标识。 -
output:此函数指针由栈调用,用于向准备传输的 IP 数据包追加自定义链路头。对于以太网设备,此指针应指向etharp_output以触发邻居发现机制。 -
link_output:当缓冲区准备好传输时,栈调用此函数指针。
在通过调用netif_up将链路标记为up之后,设备驱动程序可以在接收到新数据包时调用输入函数,而栈本身将调用output/link_output函数与驱动程序进行交互。
picoTCP 导出类似的接口以实现设备驱动程序,但它支持每个接口多个地址,因此 IP 配置与设备驱动程序分开。每个设备都有一个与其关联的 IPv4 和 IPv6 链接列表,每个链接都有自己的 IP 配置,以实现多宿主服务。在 picoTCP 中,设备驱动程序结构必须以 pico_device 结构的物理条目作为其第一个字段开始。这样,两个结构都指向相同的地址,设备可以在 pico_device 结构的末尾维护自己的私有字段。为了初始化设备,结构在驱动程序中分配,并调用 pico_device_init:
int pico_device_init(struct pico_device *dev, const char *name, const uint8_t *mac);
需要的三个参数是预分配的设备结构、用于系统内识别的名称,以及如果有的话,以太网 MAC 地址。如果 MAC 为空,堆栈将绕过以太网协议,并且由驱动程序处理的全部流量都是没有链路层扩展的裸 IP 数据包。驱动程序必须实现 send 函数,该函数由堆栈使用,以将接口要传输的帧或数据包发送出去,输入通过 pico_stack_recv 函数进行管理:
int32_t pico_stack_recv(struct pico_device *dev, uint8_t *buffer, uint32_t len);
设备再次作为参数传递,以便堆栈自动识别接口是接收以太网帧还是没有头部的原始 IP 数据包,并相应地做出反应。可以使用 pico_ipv4_link_add 和 pico_ipv6_link_add 配置 IP 地址,并通过其 API 访问路由表以添加网关和特定网络的静态路由。
运行 TCP/IP 堆栈
要集成网络堆栈,系统通常必须提供一些商品,例如时间管理和堆内存管理。堆栈所需的全部系统功能在编译时通过系统特定的配置头文件关联,该文件相应地关联函数和全局值。
根据物理通道的特性以及要达到的吞吐量,TCP/IP 堆栈在堆内存的使用上可能会变得非常苛刻,直到上层能够处理它们,它都会分配空间给新的接收缓冲区。在某些设计中,为 TCP/IP 堆栈操作分配单独的内存池可能有助于通过设置阈值和硬限制来控制堆栈的内存使用,而不会影响系统上其他组件的功能。
大多数库使用系统提供的单调计数器来实现自己的内部计时器,该计数器由系统提供并由系统中的另一个组件独立增加。可以通过使用SysTick中断来增加时间跟踪值,从而提供足够的精度,以便堆栈可以为协议组织定时操作。对于 lwIP,只需导出一个名为lwip_sys_now的全局变量即可,该变量包含自启动以来经过的时间,以毫秒为单位。picoTCP 需要导出一个名为PICO_TIME_MS的宏或内联函数,返回相同的值。这两个堆栈都期望应用程序的主循环通过调用核心 API 中的函数,提供重复的入口点,以管理系统协议的内部状态。
要检查是否有任何挂起的计时器已过期,系统在 lwIP 中调用sys_check_timeouts,或在 picoTCP 中调用pico_stack_tick,从主事件循环或当在操作系统内运行时,调用一个专用线程。连续调用之间的间隔可能会影响计时器的精度,通常不应超过几毫秒,以确保网络堆栈对定时事件做出响应。
网络接口还必须轮询来自网络的输入,无论是连续的还是通过系统实现的适当中断处理。当有新数据可用时,设备驱动程序分配新的缓冲区,并通过调用数据链路或网络层的输入函数来启动处理。
使用 lwIP 的典型裸机应用程序首先执行堆栈和设备驱动程序的初始化步骤。网络接口的结构在主函数堆栈中分配,并使用静态 IPv4 配置初始化。以下代码假设设备驱动程序导出一个名为driver_netdev_create的函数,该函数填充接口特定的字段和回调:
void main(void)
{
struct netif netif;
struct ip_addr ipaddr, gateway, netmask;
IP4_ADDR(&ipaddr, 192,168,0,2);
IP4_ADDR(&gw, 192,168,0,1);
IP4_ADDR(&netmask, 255,255,255,0);
lwip_init();
netif_add(&netif, &ipaddr, &netmask, &gw, NULL,
driver_netdev_create, ethernet_input);
netif_set_default(&netif);
然后在 TCP/IP 堆栈中激活网络接口:
netif_set_up(&netif);
在进入主循环之前,应用程序通过创建和配置套接字以及关联回调来初始化通信:
application_init_sockets();
在这种情况下,主循环依赖于驱动程序导出一个名为driver_netdev_poll的函数,这是驱动程序在接收到新帧时调用ethernet_input的函数。最后,调用sys_check_timeouts,以便 lwIP 可以跟踪挂起的计时器:
while (1) {
/* poll netif, pass packet to lwIP */
driver_netdev_poll(&netif);
sys_check_timeouts();
WFI();
}
}
预期裸机应用程序运行 picoTCP 时会有类似的流程。设备驱动程序的初始化与堆栈无关,并且驱动程序预期会在包含在自定义driver_device类型的pico_device结构中作为强制第一个成员调用pico_device_init。驱动程序仅导出driver_netdev_create函数,该函数还关联其特定的网络轮询函数指针,该指针将由pico_stack_tick调用。堆栈期望在驱动程序的poll函数有新到达的待处理数据包时调用pico_stack_recv回调:
void main(void)
{
struct driver_device dev;
struct ip4 addr, netmask, gw, zero, any;
pico_string_to_ipv4("192.168.0.2", &ipaddr.addr);
pico_string_to_ipv4("255.255.255.0", &netmask.addr);
pico_string_to_ipv4("192.168.0.1", &gw.addr);
any.addr = 0;
pico_stack_init();
driver_netdev_create(&dev);
IPv4 地址配置是通过访问 IPv4 模块的 API 来完成的。应用程序可以通过调用pico_ipv4_link_add并指定地址和子网掩码来关联一个或多个 IP 地址配置。在 IP 协议中自动创建一个路由,通过接口到达子网中的所有邻居:
pico_ipv4_link_add(&dev, ipaddr, netmask);
要添加默认路由,将网关与0.0.0.0地址(表示任何主机)关联,并设置度量值为1。默认网关可以在以后通过为其他子网定义更具体的路由来覆盖:
pico_ipv4_route_add(any, any, gw, 1, NULL);
与前一个示例类似,应用程序现在可以初始化其套接字并关联堆栈在需要时将调用的回调:
application_init_sockets();
这个简单的主循环会重复调用pico_stack_tick,这将轮询所有关联的网络接口,并在所有协议模块中执行所有挂起的操作:
while (1)
pico_stack_tick();
WFI();
}
所有 TCP/IP 操作都与套接字回调相关联,当应用程序需要响应网络和超时事件时,这些回调会被调用。当需要管理单个协议的内部状态时,堆栈会自动设置超时。如前所述,在无操作系统的环境中,提供访问套接字通信的接口基于自定义回调,具体取决于特定堆栈的实现。下一节将展示如何在两种不同的 TCP/IP 堆栈实现中使用非阻塞套接字 API。
套接字通信
lwIP 为裸机套接字通信提供的接口,也称为原始套接字 API,由自定义调用组成,每个调用都指定在堆栈期望事件发生时调用的回调。当发生特定事件时,lwIP 将从主循环函数中调用回调。
lwIP 中对 TCP 套接字的描述包含在 TCP 特定的协议控制块结构中,即tcp_pcb。为了为监听 TCP 套接字分配一个新的控制块,使用以下函数:
struct tcp_pcb *tcp_new(void);
要接受 TCP 连接,裸机 lwIP TCP 服务器首先会调用以下:
err_t tcp_bind(struct tcp_pcb *pcb, ip_addr_t *ipaddr,
u16_t port);
err_t tcp_listen(struct tcp_pcb *pcb);
这些非阻塞函数将套接字绑定到本地地址并将其置于监听状态。
在这一点上,一个使用阻塞套接字的 POSIX 应用程序会调用 accept 函数,该函数会在套接字上无限期地等待下一个传入的连接。相反,lwIP 原生应用程序会调用以下函数:
void tcp_accept(struct tcp_pcb *pcb,
err_t (* accept)(void *arg, struct tcp_pcb *newpcb,
err_t err)
);
这仅仅表明服务器已准备好接受新的连接,并且希望在建立新的传入连接时被回调到已作为参数传递的 accept 函数的地址。
使用相同的机制,为了接收下一个数据段,应用程序调用以下函数:
void tcp_recv(struct tcp_pcb *pcb,
err_t (* recv)(void *arg, struct tcp_pcb *tpcb,
struct pbuf *p, err_t err)
);
这表示 TCP/IP 堆栈应用程序已准备好接收 TCP 连接上的下一个段,并且可以在有新缓冲区可用时执行操作,因为堆栈调用了在调用 tcp_recv 时指定的实际 recv 函数。
类似地,picoTCP 将一个回调函数与每个套接字对象关联起来。该回调函数是一个公共点,用于响应任何与套接字相关的事件,例如新的传入 TCP 连接、套接字缓冲区中有新数据要读取,或者上一个写入操作的结束。
当创建套接字时指定回调函数:
struct pico_socket *pico_socket_open(uint16_t net,
uint16_t proto,
void (*wakeup)(uint16_t ev,
struct pico_socket *s));
前面的函数在指定的网络和传输协议上下文中创建一个新的套接字对象,net 和 proto 参数分别,并通过调用应用程序提供的 wakeup 函数来响应所有套接字事件。使用此机制,picoTCP 成功检测到半关闭的套接字连接和其他事件,这些事件可能不是与当前操作直接相关,但可能由于套接字通信模型中的状态变化而发生。
可以使用这些函数在新建的套接字上配置 TCP 套接字服务器:
int pico_socket_bind(struct pico_socket *s,
void *local_addr,
uint16_t *port);
int pico_socket_listen(struct pico_socket *s, int backlog);
在这一点上,应用程序必须等待传入的连接而不调用 accept。每当建立一个新的传入连接时,就会生成一个事件,该事件调用 wakeup 函数,然后应用程序最终可以调用 accept 来生成新的套接字对象,对应于传入的连接:
struct pico_socket *pico_socket_accept(
struct pico_socket *s,
void *orig,
uint16_t *local_port);
传递给 picoTCP wakeup 回调函数的第一个参数是一个掩码,表示在套接字上发生的事件类型。事件可能如下所示:
-
EV_RD:指示在传入数据缓冲区中有数据可读。 -
EV_CONN:指示在调用connect之后或处于监听状态等待时,已建立新的连接,在调用accept之前。 -
EV_CLOSE:当连接的另一端发送一个FINTCP 段时触发,表示它已经完成了传输。套接字处于CLOSE_WAIT状态,意味着在终止连接之前,应用程序可能仍然可以发送数据。 -
EV_FIN:指示套接字已被关闭,并且在回调函数返回后不再可用。 -
EV_ERR:发生了错误。
TCP/IP 堆栈提供的回调接口一开始可能使用起来有些晦涩,但它是实现更高吞吐量的非常有效的方法,当在应用中正确实现时。
我们分析的两个 TCP/IP 堆栈都能够通过在单独的线程中运行 TCP/IP 库主循环,并通过系统调用提供对套接字的访问,与操作系统结合提供更标准化的 API。
套接字通信只是 TCP/IP 堆栈公开的 API 之一。堆栈中实现的其它协议提供它们自己的函数签名;这些在两个库的手册中都有描述。
无连接协议
TCP 是一种广泛使用的传输协议,在连接导向模式对应用有意义的地方。它的无连接对应协议 UDP,主要用于解决不同范围的问题,但在某些情况下,它可以满足小型、资源受限的嵌入式系统的所有需求。实际上,TCP 实现很大,在某些平台上,它们占据了可用的闪存空间相当大的部分。这是由于 TCP 的复杂内部机制,导致需要包含大量代码来管理重传、超时和确认;组织缓冲区;以及跟踪每个套接字的多态状态机。
另一方面,UDP 非常简单,对从套接字接口到网络以及相反方向的数据进行很少的转换。通常,UDP 实现的大小要小得多,由于缺乏可靠性要求,不需要跟踪已传输或接收的数据的顺序和间隙,这影响了运行时 RAM 的使用。当网络特性允许时,使用 UDP 进行低流量冗余数据传输通常是一个可行的选择。
网状网络和动态路由
如前所述,链路层协议可能能够实现网状下机制,这为上层隐藏了拓扑的复杂性。当链路层协议不实现此功能,或者当网状解决方案可能扩展到不同的网络接口时,采用不同的方法,此时必须实现一个标准协议,该协议与接口无关。每个链路直接连接两个设备,这些设备反过来协调以检测到达远程节点的最佳网络路径,基于检测到的拓扑。路径上的中间节点被配置为根据当前拓扑上的信息路由流量到目的地:

图 9.1 – 网状网络拓扑示例(节点 A 选择节点 C 将数据包路由到 I,在检测到最佳的四跳路由后)
在某些情况下,拓扑不是固定的,而是在路径中的节点不可用或更改其位置时演变,改变其与相邻节点的直接可见性。具有非静态拓扑的网状网络被称为移动自组织网络 (MANETs)。为 MANETs 设计的动态路由机制必须能够对拓扑变化做出反应并相应地更新其路由,因为网络是持续演变的。
路由在网状机制是在 TCP/IP 堆栈中实现的,因为它们必须能够在运行时重新配置 IP 路由表,并访问套接字通信。基于动态 IP 路由的网状网络依赖于不同的协议,这些协议可以分为两类:
-
主动动态路由协议:每个网络节点发送广播消息来宣布其在网络上的存在,其他节点可以通过读取消息来检测邻居的存在,并将邻居列表传达给邻居。网状网络始终处于可用状态,并且在拓扑变化时需要固定的重新配置时间。
-
反应式动态路由协议:在没有数据交换时,节点可以处于空闲状态,然后通过查询每个邻居来配置路径,请求到达目的地的路由。然后重复消息,增加计数器以跟踪跳数,直到到达目的地,此时,使用回复,网络可以定义发送者请求的路径。这些机制意味着动态路由是在需要时形成的,因此通信的第一条消息可能会遭受额外的延迟;另一方面,它需要的能量更少,可能对拓扑变化的反应更快。
前一组中最广泛使用的协议如下:
-
优化链路状态路由 (OLSR),由 IETF 在 RFC3626 和 RFC7181 中标准化
-
更好的移动自组织网络 方法 (B.A.T.M.A.N.)
-
Babel (IETF RFC6126)
-
目的序列距离 向量 (DSDV)
IETF 标准化的反应式、按需路由协议如下:
-
自组织、按需、距离向量 (AODV),RFC3561
-
动态源路由 (RFC4728)
路由协议的选择,再次强调,取决于需要构建的网状网络的要求。在数据稀疏和电池供电的节点网络中,反应式、按需协议是最合适的,在这些网络中,路由协议的较长时间反应是可以接受的。始终开启的嵌入式系统可能从主动路由机制中受益,这些机制确保路由表始终更新到网络的最新已知状态,并且每个节点始终知道到达每个可能的最佳路由,但与此同时,需要定期更新以广播包的形式穿越网络,不断刷新网络节点及其邻居的状态。
picoTCP,它被设计用来为物联网设备提供高级路由技术,支持一种网状下层机制,在 6LoWPAN 链路层,以及两种路由上层协议,即 OLSR(反应式)和 AODV(主动式),为将 TCP/IP 通信集成到移动、自组织网络提供了更广泛的选择。例如,要启用 OLSR,只需编译支持 OLSR 的堆栈,OLSR 守护服务将自动在主 TCP/IP 堆栈循环中启用并运行。所有必须参与网状网络定义的设备都必须通过调用 pico_olsr_add 添加:
pico_olsr_add(struct pico_device *dev);
AODV 网络可以通过类似的方式启用,接口是通过 pico_aodv_add 函数添加的:
pico_aodv_add(struct pico_devices *dev);
在这两种情况下,服务将透明地为用户运行,并在检测到网络上的新节点时更改路由表,对于 OLSR 来说是在检测到新节点时,或者每次我们请求与远程节点通信并创建一个按需路由以到达它时。不在直接可见范围内的节点指定一个第一跳网关,以确保目标节点可以通过路由度量作为跳数指示符被到达,这样当找到一个新的、更短的路径时,路由将被替换,通信可以继续,理想情况下不会因路由替换而造成中断。
路由协议,如 OLSR,在计算网状网络中给定目的地的最佳路径时,可以考虑比跳数更多的参数。例如,在计算最佳路径时,可以集成有关无线链路质量的信息,如信噪比或接收信号强度的指示。这允许我们根据多个参数选择路由,并始终选择无线信号方面的最佳选项。
路由上层网状网络策略没有预见转发广播包的机制,这些广播包必须由链路层协议重复转发,以便到达网络中的所有节点。然而,已知实现此类机制可以轻易触发乒乓效应,即单个数据包在两个或更多节点之间弹跳,因此链路层实现的广播转发机制必须通过跟踪最近几帧通过这种方式转发的帧来避免重复转发相同的帧。
对于现实世界中的物联网系统,通信需要实现传输中数据的安全。这包括但不限于加密,以确保传输数据的机密性。
实施标准安全协议确保网络中异构组件(例如,设备与远程服务器之间)之间的互操作性,以端到端的方式,并依赖于与经典 IT 世界中使用的协议完美兼容的软件解决方案。下一节将探讨传输层安全,并提出了建议。
TLS
链路层协议通常提供一些基本的安全机制,以确保连接到特定网络的客户端的认证,并通过使用如 AES 之类的对称密钥加密数据。在大多数情况下,链路层的认证足以保证基本的安全级别。然而,在 LR-WPAN 网络堆栈中经常使用的预共享、众所周知的密钥可能容易受到多种攻击,并且如果密钥被泄露,使用预共享密钥将允许攻击者解密在同一链路上之前捕获的任何流量。
参与物联网分布式系统的设备需要实现更高等级的安全性,尤其是在没有任何内存保护措施的嵌入式设备中,任何后门都意味着攻击者可以控制设备,并检索所有敏感信息,例如用于与远程系统通信的认证和加密的私钥。TLS 是一套旨在通过标准 TCP/IP 套接字提供安全通信的加密协议。该组件的责任主要集中在分布式系统中安全通信的三个关键要求上:
-
通过使用对称加密,确保涉及部分之间的机密性。TLS 定义了旨在生成一次性对称密钥的加密技术,这些密钥在它们生成的会话结束时失效。
-
使用公钥加密技术对通信中涉及的各方进行认证,以签署和验证挑战负载。由于非对称密钥的性质,只有拥有秘密私钥的部分才能签署负载,而任何人都可以通过检查与签名消息的公钥对应项来验证签名的真实性。
-
使用消息摘要确保通信的完整性,从而验证消息在其路径上没有被修改。
几种开源的协议套件实现可用于嵌入式市场,以启用标准加密算法和安全的套接字通信策略。
注意
在此背景下,应尽可能避免使用闭源的专有安全组件实现,因为在封闭系统中追踪安全问题是相当困难的,并且必须盲目信任实现源以进行漏洞管理。
由免费和开源软件库wolfSSL提供的实现是最完整和最新的之一。该库提供了 TLS 和 DTLS 的最新标准版本,并针对小型嵌入式系统中的性能和可靠性进行了设计,包括对许多用于系统安全的嵌入式平台的硬件加速器和随机数生成器的支持。
wolfSSL 在其核心库(wolfCrypt)中实现了加密原语,并将它们分组为 TLS 套接字使用的密码套件,这些套接字可以轻松集成到裸机网络应用程序和任何提供传输套接字通信 API 的嵌入式操作系统中。这些加密原语针对嵌入式设备进行了优化,并使用汇编代码对性能最关键的操作进行优化,以获得最佳性能。
为微控制器设计的 TLS/SSL 库的主要优势是它实现了与互联网上任何 PC 或服务器相同的协议,但代码大小只有一小部分,并且在所有时候都保持资源使用(如最昂贵的加密操作期间的内存使用)在控制之下。
采用支持尖端加密算法的 TLS 库,可以使物联网网络中经典 IT 基础设施组件实施的安全措施实现完美集成。在云端,旨在由远程嵌入式系统访问的服务应允许根据椭圆曲线选择更高效的密码套件,因为基于 RSA 的公钥加密需要更大的密钥和复杂的计算才能达到相同的安全级别。TLS 1.3 规范中包含了基于公钥加密的新标准,如 Curve22519,以提供更多资源系统的有效密钥处理,同时保持较老算法相同的安全级别。在选择异构系统之间 TLS 通信的加密算法时,必须考虑在目标上执行的操作的计算时间,例如加密、会话密钥生成、有效载荷签名和验证。
保护套接字通信
wolfSSL 内置了对许多嵌入式操作系统的支持,以适应不同范例提供的特定内存配置和套接字接口,并且可以集成到任何兼容的 TCP/IP 堆栈的裸机系统中,或者通过通用的基于回调的输入/输出(I/O)接口轻松适应。
在任何情况下,无论是裸机还是操作系统,应用程序都必须设计为访问 wolfSSL_accept 或 wolfSSL_connect,分别以服务器模式或客户端模式,以与远程系统启动 TLS 握手。然后可以使用 wolfSSL_read 和 wolfSSL_write 函数进行数据通信,而不是使用 TCP/IP 堆栈导出的正常套接字读写函数,这样流就可以由 TLS 库在顶部构建的附加 SSL 处理。
以下使用示例涉及使用 wolfSSL 在 TCP 连接之上创建 TLS 套接字。在 UDP 之上创建 DTLS 套接字(无连接套接字的 TLS 等价物)的方法相当类似,并且仍然使用与 TLS 相同的连接/接受范例,尽管 UDP 通常以点对点方式使用,不暴露客户端和服务器之间的网络区别,而 TCP 则有这种区别。有关创建 DTLS 无连接安全套接字的更多信息,请参阅 wolfSSL 用户手册 (www.wolfssl.com/documentation/manuals/wolfssl/index.html)。
在我们的简单使用示例中,在访问任何 API 之前,首先使用 wolfSSL_Init 初始化库。这是初始化和创建通常称为上下文的新对象的要求。单个上下文实现一个特定方法(本例中的 TLS v. 1.2 服务器)并将通过一个称为 WOLFSSL 的不同抽象与一个或多个现有套接字相关联。从同一上下文生成的多个 SSL 对象共享相同的加密密钥和 I/O 回调函数,wolfSSL 可以使用这些函数查询系统以获取传入数据,或通过套接字连接传输处理后的数据:
wolfSSL_Init();
wolfSSL_CTX *ctx;
ctx = wolfSSL_CTX_new(wolfTLSv1_2_server_method());
wolfSSL_SetIORecv(ctx, wolfssl_recv_cb);
wolfSSL_SetIOSend(ctx, wolfssl_send_cb);
系统中实现了两个回调函数,用于通过使用特定于系统的 TCP 套接字 API 访问 TCP/IP 堆栈中的套接字通信。例如,假设一个自定义 TCP 实现在裸机环境中导出读写函数为 tcp_socket_write 和 tcp_socket_read,并且当 TCP/IP 堆栈忙碌或未准备好处理缓冲区时,这些函数返回 0。wolfssl_send_cb 回调函数可以实现,以在成功的情况下返回处理的数据大小,或者返回特殊值 WOLFSSL_CBIO_ERR_WANT_WRITE,这表示 I/O 操作无法在不阻塞的情况下完成:
int wolfssl_send_cb(WOLFSSL* ssl, char *buf, int sz, void *sk_ctx)
{
tcp_ip_socket *sk = (tcp_ip_socket *)sk_ctx;
int ret = tcp_socket_write(sk, buf, sz);
if (ret > 0)
return ret;
else
return WOLFSSL_CBIO_ERR_WANT_WRITE;
}
相应的读取回调将使用相应的 WOLFSSL_CBIO_ERR_WANT_READ 特殊值来指示从堆栈中没有可处理的数据:
int wolfssl_recv_cb(WOLFSSL *ssl, char *buf, int sz, void *sk_ctx)
{
tcp_ip_socket *sk = (tcp_ip_socket *)sk_ctx;
int ret = tcp_socket_read(sk, buf, sz);
if (ret > 0)
return ret;
else
return WOLFSSL_CBIO_ERR_WANT_READ;
}
对于大多数常用的操作系统和 TCP/IP 堆栈 API,wolfSSL 已经提供了默认的 I/O 回调函数,因此只要激活正确的配置选项,就不需要实现自定义回调函数。
与每个连接关联的wolfSSL_CTX对象,在开始任何通信之前必须配备一组证书和密钥。在更复杂的系统中,证书和密钥存储在文件系统中,当 wolfSSL 集成到使用文件操作时可以访问。在通常不支持文件系统的嵌入式系统中,证书和密钥可以存储在内存中,并使用指向内存中位置的指针将其加载到上下文中:
wolfSSL_CTX_use_certificate_buffer(ctx, certificate, len, SSL_FILETYPE_ASN1);
wolfSSL_CTX_use_PrivateKey_buffer(ctx, key, len,SSL_FILETYPE_ASN1 );
将套接字上下文传递给回调函数是在底层 TCP 连接建立之后设置的。对于服务器,这可以在accept函数的上下文中完成,而客户端可以在connect函数成功返回后关联套接字到特定的 SSL 上下文。在服务器端接受 SSL 连接需要应用程序调用wolfSSL_accept,以便在任何实际数据传输之前完成 SSL 握手。SSL 接受过程应该在将 TCP/IP 套接字对象的指针关联为 SSL 对象的上下文之后,并用作与该套接字相关的回调函数的sk_ctx参数:
tcp_ip_socket new_sk = accept(listen_sk, origin);
WOLFSSL ssl = wolfSSL_new(ctx);
if (new_sk) {
wolfSSL_SetIOReadCtx(ssl, new_sk);
wolfSSL_SetIOWriteCtx(ssl, new_sk);
wolfSSL_accept是在设置套接字上下文之后调用的,因为accept机制可能已经需要调用底层堆栈以通过其状态:
int ret = wolfSSL_accept(ssl);
如果 SSL 握手成功,wolfSSL_accept返回WOLFSSL_SUCCESS特殊值,因此安全套接字现在可以通过wolfSSL_read和wolfSSL_write函数进行通信。在裸机应用程序中运行时,wolfSSL_read和wolfSSL_write必须在非阻塞模式下使用,通过在运行时在 SSL 会话对象上设置此标志:
wolfSSL_set_using_nonblock(ssl, 1);
使用非阻塞 I/O 为 wolfSSL 函数确保可以保持之前描述的用于传输套接字的事件驱动主循环模型,因为调用库函数永远不会使系统停滞。wolfSSL 中的 API 函数被设计为立即返回特定的值(例如WANT_WRITE和WANT_READ),以指示操作正在进行中,并且相关的函数(例如,在这种情况下是wolfSSL_accept)应该在稍后当底层 TCP 套接字有新数据可用时再次调用。
一旦运输端点之间的通信得到保障,就可以使用安全套接字通信来交换数据。以下是对物联网系统中使用的一些最常见应用协议的概述。
应用协议
为了能够在分布式场景中与远程设备和云服务器通信,嵌入式系统必须实现与现有基础设施兼容的标准协议。在设计远程服务时采取的最常见方法有两种:
-
基于 Web 的服务
-
消息协议
前者主要是经典的、基于客户端-服务器的表示状态转移(REST)通信,这种通信在通过个人电脑或便携式设备访问的 Web 服务中很受欢迎。Web 服务在云端不需要特别适应来支持嵌入式系统,除了选择一个嵌入式友好的密码集,如安全套接字通信部分所述。然而,请求-回复通信模型在分布式应用程序的设计上引入了一些限制。HTTP 协议可以通过两个 HTTP 端点的共同协议升级,并支持 WebSocket,这是一个在 HTTP 服务之上提供对称、双向通道抽象的协议。
消息协议是一种不同的方法,更好地反映了传感器或执行器嵌入式系统的功能,其中信息通过使用短二进制消息进行交换,这些消息可以通过中间代理进行中继,并从服务器节点收集或分发。当网络包括较小的节点时,消息协议是首选选择,因为与基于人类可读字符串的 Web 服务相比,它具有更简单的数据表示。Web 服务通常基于人类可读字符串,并为必须处理 ASCII 字符串的目标增加了更大的传输大小和内存占用。
在这两种情况下,TLS 应在基础设施和设备级别得到支持,以实现端到端加密和可靠的设备识别。明文认证和预共享密钥加密是过时的技术,因此不应成为现代分布式系统安全策略的一部分。
消息协议
基于消息的通信协议在计算机网络软件中并非新奇,但与物联网分布式系统特别匹配,尤其是在一个基于消息的一对多模型允许我们同时到达许多设备并建立双向通信,或者来自不同位置的多个设备可以通过充当通信代理的外部服务器相互通信的场景中。这一领域的标准化不足导致了几个不同的模型,每个模型都有自己的 API 和网络协议定义。
然而,一些特定的开放标准已经被设计用来实现安全的分布式消息系统,这些系统专门针对资源减少和带宽有限的网络,通过包括一些在小代码足迹内合理可行的规范。这种情况适用于消息队列遥测传输(MQTT)协议。得益于其发布者-订阅者模型和通过 TCP/IP 在不同物理位置互联嵌入式设备的能力,MQTT 已被广泛使用,并得到多个云架构的支持。
该协议依赖于 TCP 来建立与中央代理的连接,该代理将发布者的消息分发到订阅者。发布者推送特定主题的数据,该主题由 URI 描述,订阅者可以在连接时过滤他们想要跟踪的主题,以便代理只转发与过滤器匹配的消息。
对于小型嵌入式设备也存在一些客户端库的实现,尽管其中许多缺乏对安全机制的支持。该协议支持明文密码认证机制,这不是一种有效的安全措施,并且绝不应该在清晰的 TCP/IP 通信之上使用,因为密码很容易在路径中被截获。
根据标准,除了通过 IANA 注册的 TCP 端口1883进行的基于套接字的 TCP 通信外,还可以建立一个 SSL 会话,该会话使用 TCP 端口8883。wolfSSL 提供了一个使用 SSL 会话在 TCP 之上提供的安全实现,这是一个名为wolfMQTT的独立 GPL 库。该库默认提供安全的 MQTT 套接字连接。它能够通过证书和公钥实现客户端和服务器身份验证,并通过建立的会话提供对称密钥加密。
REST 架构模式
REST是由 Roy Fielding 提出的术语,用于描述 Web 服务使用无状态协议与远程系统通信的模式。在符合 REST 的系统中,资源以针对特定 URI 的 HTTP 请求的形式访问,使用与通过远程浏览器请求获得的网页相同的协议栈。实际上,REST 请求是扩展的 HTTP 请求,将所有数据表示为编码字符串,通过可读的 HTTP 流在 TCP 上传输。
采用这种模式在服务器端提供了许多架构上的好处,并允许我们构建具有非常高的可扩展性的分布式系统。尽管这种方法并不非常高效,而且肯定不是针对嵌入式系统资源设计的,但嵌入式系统可以通过实现一个简单的 REST 客户端与由 RESTful 系统公开的远程 Web 服务进行交互。
分布式系统 – 单点故障
设计分布式系统还意味着要考虑链路缺陷、不可达网关和其他故障。嵌入式设备在断开互联网连接时不应停止工作,而应提供基于本地网关的回退机制。例如,考虑一个用于控制房屋中所有加热和冷却单元的民用电物联网系统,可以通过便携式设备访问,并使用任何网络访问远程协调。温度传感器、加热器和冷却器使用嵌入式设备的网状网络进行控制,而中央控制位于远程云服务器上。系统可以根据用户设置和传感器读数远程控制执行器。这使我们能够在远程位置访问服务,允许用户根据用户界面发送的命令调整系统,以在每个房间设置所需的温度,这些命令由云处理并转发,以到达嵌入式设备。只要所有组件都连接到互联网,物联网系统就会按预期工作。
然而,在连接失败的情况下,用户将无法控制系统或激活任何功能。在局域网内终止本地设备上的应用程序服务确保了在互联网连接失败以及任何阻止本地网络访问远程云设备的故障情况下服务的连续性。如果这种机制得以实施,即使系统与互联网断开连接,仍可以提供故障转移替代方案来访问传感器和执行器,前提是所有参与者在一个共同的局域网中连接。此外,拥有一个本地系统处理和转发设置和命令可以减少请求动作的延迟,因为请求不需要穿越互联网进行处理和转发回同一网络。设计可靠的物联网网络必须包括对所有用于提供服务的链接和设备的单点故障的仔细评估,这必须包括用于访问服务、消息代理和远程设备的骨干链路,这些设备可能会在整个系统中引起故障或其他问题。
摘要
本章为我们概述了机器到机器分布式系统和物联网服务的架构设计,包括连接的嵌入式设备,重点关注在嵌入式开发中常被忽视或低估的安全元素。所提出的技术允许在非常小的目标上实现全面、专业级、安全和快速的 TCP/IP 连接,并使用最先进的技术,如最新的 TLS 加密套件。在考虑针对基于微控制器的目标可用的硬件和软件技术方面,已经考虑了多种方法,以更广泛地了解构建分布式嵌入式系统所用的技术、协议和安全算法。
下一章将通过解释如何从头开始编写适用于 Cortex-M 微处理器的小型调度器,来展示现代嵌入式微控制器的多任务可能性,并将总结在嵌入式目标上运行的实时操作系统的关键角色。
第四部分 – 多线程
本部分通过调度器的开发和 ARM CPU 中上下文变化的解释,介绍了并行多线程应用程序。在最后一章中,解释了 TEE 方法,并举例说明了使用 TrustZone-M 安全系统。
本部分包含以下章节:
-
第十章,并行任务与调度
-
第十一章,可信执行环境
第十章:并行任务和调度
当系统的复杂性增加,软件需要同时管理多个外围设备和事件时,依赖操作系统来协调和同步所有不同的操作会更加方便。将应用程序逻辑分离到不同的线程中提供了一些重要的架构优势。每个组件在其运行单元内执行设计的操作,并且当它暂停或等待输入或超时事件时,它可以释放 CPU。
在本章中,将介绍实现多线程嵌入式操作系统的机制。这将通过开发针对参考平台定制的最小化操作系统来完成,并从头开始逐步编写,提供一个可运行的调度器以并行运行多个任务。
调度器的内部实现主要在系统服务调用中完成,其设计影响系统的性能和其他特性,例如实时相关任务的优先级水平和时间约束。在示例代码中,将解释和实现一些可能的调度策略,针对不同的上下文。
并行运行多个线程意味着资源可以被共享,并且存在对同一内存的并发访问的可能性。大多数设计用于运行多线程系统的微处理器提供原始函数,通过特定的汇编指令可访问,以实现如信号量之类的锁定机制。我们的示例操作系统公开了互斥锁和信号量原始函数,线程可以使用它们来控制对共享资源的访问。
通过引入内存保护机制,可以根据它们的地址提供资源分离,并让内核通过系统调用接口监督所有涉及硬件的操作。大多数实时嵌入式操作系统更喜欢无段化的扁平模型,以尽可能减小内核代码的大小,并使用最小化的 API 来优化应用程序可用的资源。示例内核将向我们展示如何创建系统调用 API 来集中控制资源,使用物理内存分段来保护内核、系统控制块、映射的外围设备和其他任务,以增加系统的安全性级别。
本章分为以下部分:
-
任务管理
-
调度器实现
-
同步
-
系统资源分离
到本章结束时,您将学会如何构建一个多线程嵌入式环境。
技术要求
您可以在 GitHub 上找到本章的代码文件,链接为github.com/PacktPublishing/Embedded-Systems-Architecture-Second-Edition/tree/main/Chapter10。
任务管理
操作系统通过交替运行并行应用程序来提供并行运行进程和线程的抽象。实际上,在单 CPU 系统中,一次只能有一个运行线程。当运行线程执行时,所有其他线程都在等待队列中,直到下一个任务切换。
在协作模型中,任务切换始终是线程实现请求的自愿行为。相反的方法,称为抢占,要求内核在任务的任何执行点定期中断任务,暂时保存状态并恢复下一个任务。
切换正在运行的任务包括将 CPU 寄存器的值存储在 RAM 中,并从内存中加载已选为运行的下个任务的值。这个操作更广为人知的是上下文切换,它是调度系统的核心。
任务块
在系统中,任务以任务块结构的形式表示。此对象包含调度器在所有时间跟踪任务状态所需的所有信息,并且依赖于调度器的设计。任务可能在编译时定义,在内核启动后启动,或者在系统运行时生成和终止。
每个任务块可能包含一个指向起始函数的指针,该函数定义了在任务生成时执行的代码的开始,以及一组可选参数。为每个任务分配内存作为其私有堆栈区域。这样,每个线程和进程的执行上下文就与其他所有上下文分离,当任务被中断时,寄存器的值可以存储在特定于任务的内存区域中。特定于任务的堆栈指针存储在任务块结构中,并在上下文切换时用于存储 CPU 寄存器的值。
使用单独的堆栈运行需要预先保留一些内存,并将其与每个任务关联。在最简单的情况下,所有使用相同大小堆栈的任务都在调度器启动之前创建,并且不能被终止。这样,已保留以与私有堆栈关联的内存可以连续,并关联到每个新任务。用于堆栈区域的内存区域可以在链接脚本中定义。
参考平台有一个独立的内核耦合内存,映射在0x10000000。在众多安排内存段的方法中,我们决定将用于将堆栈区域与线程关联的堆栈空间起始部分映射到 CCRAM 的开始处。剩余的 CCRAM 空间用作内核的堆栈,这样就将所有 SRAM(除了.data和.bss部分)留给了堆分配。指针通过链接脚本中的以下PROVIDE指令导出:
PROVIDE(_end_stack = ORIGIN(CCRAM) + LENGTH(CCRAM));
PROVIDE(stack_space = ORIGIN(CCRAM));
PROVIDE(_start_heap = _end);
在内核源代码中,stack_space被声明为外部,因为它由链接脚本导出。我们还声明了为每个任务的执行堆栈保留的空间量(以四字节词表示):
extern uint32_t stack_space;
#define STACK_SIZE (256)
每次创建新任务时,堆栈空间中的下一个千字节被分配为其执行堆栈,初始堆栈指针被设置为该区域的最高地址,因为执行堆栈向后增长:

图 10.1 – 为任务提供单独执行堆栈所使用的内存配置
可以声明一个简单的任务块结构,如下所示:
#define TASK_WAITING 0
#define TASK_READY 1
#define TASK_RUNNING 2
#define TASK_NAME_MAXLEN 16;
struct task_block {
char name[TASK_NAME_MAXLEN];
int id;
int state;
void (*start)(void *arg);
void *arg;
uint32_t *sp;
};
定义了一个全局数组来包含系统的所有任务块。我们必须使用全局索引来跟踪已创建的任务,以便我们可以使用相对于任务标识符和当前运行任务的 ID 在内存中的位置:
#define MAX_TASKS 8
static struct task_block TASKS[MAX_TASKS];
static int n_tasks = 1;
static int running_task_id = 0;
#define kernel TASKS[0]
使用此模型,任务块在数据段中预先分配,字段就地初始化,并跟踪索引。数组的第一个元素是为内核的任务块保留的,它是当前正在运行的过程。
在我们的例子中,通过调用task_create函数并提供一个名称、入口点和其参数来创建任务。对于具有预定义任务数量的静态配置,这通常在内核初始化时完成,但更高级的调度器可能允许我们在调度器运行时分配新的控制块以在运行时生成新进程:
struct task_block *task_create(char *name, void (*start)(void *arg), void *arg)
{
struct task_block *t;
int i;
if (n_tasks >= MAX_TASKS)
return NULL;
t = &TASKS[n_tasks];
t->id = n_tasks++;
for (i = 0; i < TASK_NAME_MAXLEN; i++) {
t->name[i] = name[i];
if (name[i] == 0)
break;
}
t->state = TASK_READY;
t->start = start;
t->arg = arg;
t->sp = ((&stack_space) + n_tasks * STACK_SIZE);
task_stack_init(t);
return t;
}
要实现task_stack_init函数,该函数初始化进程开始运行时堆栈中的值,我们需要了解上下文切换的工作原理,以及当调度器运行时如何启动新任务。
上下文切换
上下文切换过程包括在执行期间获取 CPU 寄存器的值,并将它们保存在当前正在运行的任务的堆栈底部。然后,我们必须恢复下一个任务的值以恢复其执行。此操作必须在中断上下文中发生,其内部机制是 CPU 特定的。在参考平台上,任何中断处理程序都可以替换当前正在运行的任务并恢复另一个上下文,但这种操作更常在关联于系统事件的断电服务例程中完成。Cortex-M 提供了两个 CPU 异常,它们旨在为上下文切换提供基本支持,因为它们可以在任何上下文中任意触发:
-
PendSV:这是抢占式内核在设置系统控制块中特定寄存器的一个位后,强制在不久的将来发生中断的默认方式,它通常与下一个任务的上下文切换相关联。
-
SVCall:这是用户应用程序提交正式请求以访问由内核管理的资源的入口点。这个特性是为了提供一个 API 来安全地访问内核,请求从组件或驱动程序的操作。由于操作的结果可能不会立即可用,SVCall 还可以允许抢占调用任务以提供阻塞系统调用的抽象。
在上下文切换期间用于将 CPU 寄存器的值存储到或从内存中恢复的例程在 Cortex-M CPU 上部分由硬件实现。这意味着当进入中断时,寄存器的一部分副本会自动推送到堆栈中。堆栈中的寄存器副本称为 堆栈帧,包含 R0 到 R3、R12、LR、PC 和 xPSR 寄存器,顺序如下所示:

图 10.2 – 当进入中断处理程序时,寄存器会自动复制到堆栈中
然而,堆栈指针不包括 CPU 寄存器的另一半 – 即 R4 到 R11。因此,为了成功完成上下文切换,打算替换正在运行的进程的系统处理程序必须存储包含这些寄存器值的额外堆栈帧,并在从处理程序返回之前恢复下一个任务的额外堆栈帧。ARM Thumb-2 汇编提供了如何将连续 CPU 寄存器的值推送到堆栈并恢复到原位的指令。以下两个函数用于在堆栈中推入和弹出额外堆栈帧:
static void __attribute__((naked)) store_context(void)
{
asm volatile("mrs r0, msp");
asm volatile("stmdb r0!, {r4-r11}");
asm volatile("msr msp, r0");
asm volatile("bx lr");
}
static void __attribute__((naked)) restore_context(void)
{
asm volatile("mrs r0, msp");
asm volatile("ldmfd r0!, {r4-r11}");
asm volatile("msr msp, r0");
asm volatile("bx lr");
}
((naked)) 属性用于防止 GCC 将由几个汇编指令组成的序言和尾随序列放入编译代码中。序言会改变额外堆栈帧区域中的一些寄存器的值,这些值将在尾随中恢复,这与使用汇编指令访问寄存器值的函数的目的相冲突。由于缺少尾随,naked 函数通过跳转回调用指令(存储在 LR 寄存器中)来返回。
由于汇编 push 操作的结果,被抢占的进程的堆栈看起来是这样的:

图 10.3 – 剩余的寄存器值被复制到堆栈中以完成上下文切换
创建任务
当系统运行时,除了正在运行的进程外,所有任务都处于 等待 状态,这意味着完整的堆栈帧被保存在堆栈底部,堆栈指针被存储在控制块中以供调度器用于恢复每个进程。
新创建的任务将在上下文切换的中间醒来第一次。在那个时刻,预期任务将保留其 CPU 寄存器的先前状态,但显然,新任务没有这样的东西。在堆栈创建时,一个伪造的堆栈帧被推到堆栈的末尾,以便当任务恢复时,存储的值被复制到系统寄存器中,任务可以从其入口点恢复。
task_create函数依赖于一个堆栈初始化函数task_stack_init,该函数将系统寄存器的初始值推送到堆栈,以便任务可以恢复,并将存储的堆栈指针移动到额外帧的开始,该帧可以留作未初始化。为了方便访问堆栈帧中存储的寄存器,我们必须声明一个stack_frame结构,该结构使用一个字段对应一个寄存器,以及一个extra_frame结构,仅为了完整性:
struct stack_frame {
uint32_t r0, r1, r2, r3, r12, lr, pc, xpsr;
};
struct extra_frame {
uint32_t r4, r5, r6, r7, r8, r9, r10, r11;
};
static void task_stack_init(struct task_block *t)
{
struct stack_frame *tf;
t->sp -= sizeof(struct stack_frame);
tf = (struct stack_frame *)(t->sp);
tf->r0 = (uint32_t) t->arg;
tf->pc = (uint32_t) t->start;
tf->lr = (uint32_t) task_terminated;
tf->xpsr = (1 << 24);
t->sp -= sizeof(struct extra_frame);
}
一旦上下文已恢复,异常处理程序返回过程会自动从我们正在构建的堆栈帧中恢复上下文。起始任务的寄存器初始化如下:
-
程序计数器(PC)包含起始函数的地址,系统将跳转到该地址以切换到该任务,这是第一次切换到该任务。
-
task_create。 -
执行程序状态寄存器(xPSR)必须编程为仅在位 24 处设置强制性的 Thumb 标志。
-
task_terminated函数只是一个无限循环,被视为系统错误。在其他情况下,如果允许任务终止,可以设置一个函数作为任务的通用退出点,以执行从起始函数返回所需的清理操作。
一旦创建了初始堆栈帧,任务就可以参与多任务,并且可以在任何时间被调度器选中以恢复执行,从与所有其他未运行任务相同的状态:

图 10.4 – 在不同执行状态下三个任务的栈指针
我们简单的内核主函数现在可以创建进程并准备堆栈,但尚未实际运行它们,直到我们实现了调度器的内部机制。在这种情况下,时间跟踪很有用,因此SysTick在启动时被启用以跟踪系统中的时间。内核的任务块被初始化,并创建了两个新任务:
void main(void) {
clock_pll_on(0);
systick_enable();
led_setup();
kernel.name[0] = 0;
kernel.id = 0;
kernel.state = TASK_RUNNING;
task_create("test0",task_test0, NULL);
task_create("test1",task_test1, NULL);
while(1) {
schedule();
}
}
创建的两个主要任务指向不同的起始函数,并且两者都将参数设置为NULL。这两个函数都不应该返回,并且可以根据实现的调度器策略被中断和恢复。
要从这一点继续,需要实现调度器的内部机制以启动和交替执行我们刚刚定义的并行任务。
调度器实现
系统的架构取决于调度器的实现方式。任务可以在协作模式下运行,直到它们自愿决定将 CPU 交由下一个任务,或者操作系统可以决定触发中断以在幕后交换正在运行的任务,并应用特定策略来决定任务切换之间的间隔以及选择下一个任务的优先级。在两种情况下,上下文切换都发生在可用的监督调用之一中,用于决定下一个要调度的任务,并执行上下文切换。在本节中,将通过 PendSV 添加完整的上下文切换过程到我们的示例中,然后分析并实现一些可能的调度策略。
监督调用
调度器的核心组件是与系统中断事件(如 PendSV 和 SVCall)关联的异常处理器。在 Cortex-M 上,可以通过设置 PENDSET 标志在任何时候由软件触发 PendSV 异常,对应于中断控制和状态寄存器的第 28 位,位于地址 0xE000ED04 的 SCB 中。定义了一个简单的宏来通过设置标志来启动上下文切换:
#define SCB_ICSR (*((volatile uint32_t *)0xE000ED04))
#define schedule() SCB_ICSR |= (1 << 28)
来自内核的调度调用以及所有后续调用都将导致上下文切换,现在可以在 PendSV 处理器中实现。为了完成上下文切换,处理器必须执行以下步骤:
-
将当前栈指针从 SP 寄存器存储到任务块中。
-
通过调用
store_context将额外的栈帧推入栈中。 -
将当前任务的状态更改为
TASK_READY。 -
选择一个新任务以恢复。
-
将新任务的状态更改为
TASK_RUNNING。 -
从关联的任务块中检索新的栈指针。
-
通过调用
restore_context从栈中弹出额外的栈帧。 -
为中断处理器设置一个特殊的返回值,以在 PendSV 服务例程结束时激活线程模式。
isr_pendsv 函数必须是裸函数,因为它通过 store 和 restore_context 函数直接访问 CPU 寄存器:
void __attribute__((naked)) isr_pendsv(void)
{
store_context();
asm volatile("mrs %0, msp" : "=r"(
TASKS[running_task_id].sp));
TASKS[running_task_id].state = TASK_READY;
running_task_id++;
if (running_task_id >= n_tasks)
running_task_id = 0;
TASKS[running_task_id].state = TASK_RUNNING;
asm volatile("msr msp, %0"::"r"(
TASKS[running_task_id].sp));
restore_context();
asm volatile("mov lr, %0" ::"r"(0xFFFFFFF9));
asm volatile("bx lr");
}
在返回之前加载到 LR 的值用于指示我们在中断结束时返回到线程模式。根据最后 3 位值的差异,服务例程通知 CPU 在从中断返回时使用哪个栈指针。在这种情况下使用的 0xFFFFFFF9 值对应于使用主栈指针的线程模式。在示例扩展以支持内核和进程之间具有单独栈指针时,将需要不同的值。
这样,完整的上下文已经在 PendSV 服务例程中实现,目前它只是选择下一个任务,并在数组中的最后一个任务之后执行具有 ID 0 的内核。每当调用调度宏时,都会触发服务例程以处理模式运行。
协作调度器
可以定义不同的策略来交替系统中任务的执行。在最简单的情况下,每个任务通过调用调度宏自愿挂起其执行。
在这个示例实现中,定义了两个线程。它们都将打开一个 LED,并在关闭 LED 并显式调用schedule()函数以触发上下文切换之前,在忙循环中占用 CPU 1 秒钟:
void task_test0(void *arg)
{
uint32_t now = jiffies;
blue_led_on();
while(1) {
if ((jiffies - now) > 1000) {
blue_led_off();
schedule();
now = jiffies;
blue_led_on();
}
}
}
void task_test1(void *arg)
{
uint32_t now = jiffies;
red_led_on();
while(1) {
if ((jiffies - now) > 1000) {
red_led_off();
schedule();
now = jiffies;
red_led_on();
}
}
}
这个小操作系统终于运行起来了,内核正在按顺序调度两个任务。具有 ID 0 的任务在每个循环的开始时也会恢复,但在这种简单的情况下,内核任务只是在循环中调用调度,立即恢复 ID 为 1 的任务。这种设计下,系统的反应性完全取决于任务的实现,因为每个任务可以无限期地占用 CPU,从而阻止其他任务运行。协作模型仅在非常特定的场景中使用,其中每个任务直接负责调节其 CPU 周期并与其他线程协作,这可能会影响整个系统的响应性和公平性。
为了简化,这个实现没有考虑到jiffies变量的回绕。如果每毫秒增加一次,jiffies会在大约 42 天后溢出其最大值。与我们的简单示例不同,真正的操作系统必须实现一个适当的机制来比较时间变量,这里没有展示,该机制可以在计算时间差时检测到回绕。
并发和时间片
另一种方法是为每个任务分配短时间段的 CPU 时间,并在非常短的时间间隔内不断交换进程。抢占式调度器可以自主中断正在运行的任务,以恢复下一个任务,而无需任务本身的显式请求。它还可以强制执行其关于选择下一个要运行的任务以及 CPU 分配给每个任务的持续时间(即其时间片)的政策。
从任务的角度来看,执行现在可以连续进行,并且完全独立于调度器,调度器在幕后不断中断和恢复每个任务,给人一种所有任务都在同时运行的错觉。可以将线程重新定义为在两个不同的间隔闪烁 LED:
void task_test0(void *arg)
{
uint32_t now = jiffies;
blue_led_on();
while(1) {
if ((jiffies - now) > 500) {
blue_led_toggle();
now = jiffies;
}
}
}
void task_test1(void *arg)
{
uint32_t now = jiffies;
red_led_on();
while(1) {
if ((jiffies - now) > 125) {
red_led_toggle();
now = jiffies;
}
}
}
为了以轮询方式交替任务,我们可以在 SysTick 处理程序内部触发 PendSV 的执行,这会导致在固定间隔发生任务切换。新的 SysTick 处理程序每TIMESLICE毫秒触发一次上下文切换:
#define TIMESLICE (20)
void isr_systick(void)
{
if ((++jiffies % TIMESLICE) == 0)
schedule();
}
在这个新的配置中,我们现在有一个更完整的模型,允许多个任务独立运行,并且调度完全由内核监督。
阻塞任务
我们迄今为止实现的简单调度器只为任务提供了两种状态:TASK_READY和TASK_RUNNING。可以实施第三个状态来定义一个不需要恢复的任务,因为它已被阻塞并正在等待事件或超时。任务可以等待某种类型的系统事件,如下所示:
-
来自正在使用的输入/输出(I/O)设备的中断事件
-
来自另一个任务的通信,例如 TCP/IP 堆栈
-
同步机制,如互斥锁或信号量,用于访问系统中当前不可用的共享资源
-
超时事件
为了管理不同的状态,调度器可以实施两个或更多列表来区分当前正在运行或准备运行的任务与等待事件的那些任务。然后,调度器从TASK_READY状态的那些任务中选择下一个任务,并忽略列表中阻塞的任务:

图 10.5 – 描述任务执行状态的有限状态机
这个调度器的第二个版本使用全局指针跟踪当前正在运行的任务,而不是数组的索引,并将任务组织成两个列表:
-
tasklist_active:这包含正在运行的任务的任务块以及所有处于TASK_READY状态的等待调度任务 -
tasklist_waiting:这包含当前阻塞的任务的任务块
对于这种新机制,最容易实现的展示是sleep_ms函数,它可以由任务用来临时切换到等待状态并在未来设置一个恢复点以便再次调度。提供这种设施允许我们的任务在 LED 切换动作之间休眠,而不是运行一个忙等待循环,反复检查计时器是否已过期。这些新任务不仅更高效,因为它们不会在忙等待循环中浪费 CPU 周期,而且也更易于阅读:
void task_test0(void *arg){
blue_led_on();
while(1) {
sleep_ms(500);
blue_led_toggle();
}
}
void task_test1(void *arg)
{
red_led_on();
while(1) {
sleep_ms(125);
red_led_toggle();
}
}
为了将任务块排列成列表,必须在结构中添加一个指向下一个元素的指针,以便在运行时填充两个列表。为了管理sleep_ms函数,必须添加一个新的字段来跟踪任务应该被放入活动列表以恢复时的时间系统:
struct task_block {
char name[TASK_NAME_MAXLEN];
int id;
int state;
void (*start)(void *arg);
void *arg;
uint8_t *sp;
uint32_t wakeup_time;
struct task_block *next;
};
这些列表可以用两个简单的函数来管理,用于插入/删除元素:
struct task_block *tasklist_active = NULL;
struct task_block *tasklist_waiting = NULL;
static void tasklist_add(struct task_block **list,struct task_block *el)
{
el->next = *list;
*list = el;
}
static int tasklist_del(struct task_block **list, struct task_block *delme)
{
struct task_block *t = *list;
struct task_block *p = NULL;
while (t) {
if (t == delme) {
if (p == NULL)
*list = t->next;
else
p->next = t->next;
return 0;
}
p = t;
t = t->next;
}
return -1;
}
必须添加两个额外的函数来将任务从活动列表移动到等待列表,反之亦然,这还会改变任务本身的状态:
static void task_waiting(struct task_block *t)
{
if (tasklist_del(&tasklist_active, t) == 0) {
tasklist_add(&tasklist_waiting, t);
t->state = TASK_WAITING;
}
}
static void task_ready(struct task_block *t)
{
if (tasklist_del(&tasklist_waiting, t) == 0) {
tasklist_add(&tasklist_active, t);
t->state = TASK_READY;
}
}
sleep_ms函数设置恢复时间并将任务移动到等待状态,然后激活调度器以使任务被抢占:
void sleep_ms(int ms)
{
if (ms < TASK_TIMESLICE)
return;
t_cur->wakeup_time = jiffies + ms;
task_waiting(t_cur);
schedule();
}
新的 PendSV 处理器从活动列表中选择下一个要运行的任务,假设该列表始终至少包含一个任务,因为内核主任务永远不会被置于等待状态。新线程通过 tasklist_next_ready 函数选择,该函数还确保如果当前任务已从活动列表中移除,或者处于队列末尾,则选择活动列表的头部作为下一个时间片:
static inline struct task_block *tasklist_next_ready(struct task_block *t)
{
if ((t->next == NULL) || (t->next->state != TASK_READY))
return tasklist_active;
return t->next;
}
这个小型函数是新基于双列表调度器的核心,在每个上下文切换的中间调用以选择 PendSV 中的下一个活动任务:
void __attribute__((naked)) isr_pendsv(void)
{
store_context();
asm volatile("mrs %0, msp" : "=r"(t_cur->sp));
if (t_cur->state == TASK_RUNNING) {
t_cur->state = TASK_READY;
}
t_cur = tasklist_next_ready(t_cur);
t_cur->state = TASK_RUNNING;
asm volatile("msr msp, %0" ::"r"(t_cur->sp));
restore_context();
asm volatile("mov lr, %0" ::"r"(0xFFFFFFF9));
asm volatile("bx lr");
}
最后,为了检查每个休眠任务的唤醒时间,内核遍历等待任务列表,并在唤醒时间到达时将任务块移回活动列表。内核初始化现在包括一些额外的步骤,以确保内核任务本身在启动时被放入运行任务列表中:
void main(void) {
clock_pll_on(0);
led_setup();
button_setup();
systick_enable();
kernel.name[0] = 0;
kernel.id = 0;
kernel.state = TASK_RUNNING;
kernel.wakeup_time = 0;
tasklist_add(&tasklist_active, &kernel);
task_create("test0",task_test0, NULL);
task_create("test1",task_test1, NULL);
task_create("test2",task_test2, NULL);
while(1) {
struct task_block *t = tasklist_waiting;
while (t) {
if (t->wakeup_time && (t->wakeup_time < jiffies)) {
t->wakeup_time = 0;
task_ready(t);
}
t = t->next;
}
WFI();
}
}
等待资源
在给定时间间隔内阻塞只是任务暂时从活动列表中排除的一种可能性。内核可能实现其他事件和中断处理程序,以便将任务带回到调度循环中,从而使任务可以阻塞,在 TASK_WAITING 状态下等待来自特定资源集的 I/O 事件。
在我们的示例代码中,可以实现一个读取函数来从任务中检索按钮的状态,该任务会阻塞,直到按钮被按下才返回。在此期间,调用任务保持在等待列表中,并且永远不会被调度。每次按钮被按下时切换绿色 LED 的任务依赖于 button_read() 作为其阻塞点:
#define BUTTON_DEBOUNCE_TIME 120
void task_test2(void *arg)
{
uint32_t toggle_time = 0;
green_led_off();
while(1) {
if (button_read()) {
if((jiffies - toggle_time) > BUTTON_DEBOUNCE_TIME)
{
green_led_toggle();
toggle_time = jiffies;
}
}
}
}
button_read 函数跟踪调用任务,因此当按钮被按下时,使用 button_task 指针唤醒它。任务被移动到等待列表中,并在驱动程序中启动读取操作,然后任务被抢占:
struct task_block *button_task = NULL;
int button_read(void)
{
if (button_task)
return 0;
button_task = t_cur;
task_waiting(t_cur);
button_start_read();
schedule();
return 1;
}
为了在按钮按下时通知调度器,驱动程序使用一个回调,该回调由内核在初始化时指定,并将其作为参数传递给 button_setup:
static void (*button_callback)(void) = NULL;
void button_setup(void (*callback)(void))
{
AHB1_CLOCK_ER |= GPIOA_AHB1_CLOCK_ER;
GPIOA_MODE &= ~ (0x03 << (BUTTON_PIN * 2));
EXTI_CR0 &= ~EXTI_CR_EXTI0_MASK;
button_callback = callback;
}
内核将 button_wakeup 函数与驱动程序回调相关联,以便当发生事件时,如果任务正在等待按钮按下通知,它将被移回活动任务列表,并在调度器选择它运行时立即恢复:
void button_wakeup(void)
{
if (button_task) {
task_ready(button_task);
button_task = NULL;
schedule();
}
}
在按钮驱动程序中,为了启动阻塞操作,启用中断并将其与信号的上升沿相关联,这对应于按钮按下事件:
void button_start_read(void)
{
EXTI_IMR |= (1 << BUTTON_PIN);
EXTI_EMR |= (1 << BUTTON_PIN);
EXTI_RTSR |= (1 << BUTTON_PIN);
nvic_irq_enable(NVIC_EXTI0_IRQN);
}
当检测到事件时,回调函数在中断上下文中执行。中断被禁用,直到下一次调用 button_start_read:
void isr_exti0(void)
{
nvic_irq_disable(NVIC_EXTI0_IRQN);
EXTI_PR |= (1 << BUTTON_PIN);
if (button_callback)
button_callback();
}
任何依赖中断处理来解锁相关任务的设备驱动程序或系统模块都可能使用回调机制与调度器交互。使用类似的阻塞策略,可以实施读写操作,以保持调用任务在等待列表中,直到检测到所需事件并处理调度器代码中的回调。
其他为裸机嵌入式应用设计的系统组件和库可能需要额外的层来通过阻塞调用集成到操作系统中。嵌入式 TCP/IP 堆栈实现,如 lwIP 和 picoTCP,提供了一个可移植的 RTOS 集成层,包括通过在专用任务中运行循环函数实现的阻塞套接字调用,该任务管理与其他任务中使用的套接字 API 的通信。预期将互斥锁和信号量等锁定机制实现为阻塞调用,当请求的资源不可用时,将挂起任务。
我们迄今为止实施的调度策略非常反应灵敏,在任务之间提供了完美的交互水平,但它没有预见优先级级别,这在设计实时系统时是必要的。
实时调度
实时操作系统的一个关键要求是能够通过在短时间内执行相关代码来对选定的几个事件做出反应。为了实现具有严格时间要求的特性,操作系统必须专注于快速中断处理和调度,而不是其他指标,如吞吐量或公平性。每个任务可能有特定的要求,例如截止日期,表示执行必须开始或停止的确切时间,或者与可能引入系统其他任务依赖的共享资源相关。一个能够以确定性时间要求执行任务的系统必须能够在可测量的固定时间内满足截止日期。
接近实时调度是一个复杂的问题。关于这个主题有权威的文献,因此这里不会对主题进行详细解释。研究表明,基于为每个任务分配的优先级的几种方法,结合在运行时切换任务的适当策略,可以提供足够的近似,以提供针对实时要求的通用解决方案。
为了支持具有确定截止日期的硬实时任务,操作系统应考虑实现以下特性:
-
在调度器中实现快速上下文切换过程
-
系统在禁用中断的情况下运行的可测量间隔
-
短中断处理程序
-
支持中断优先级
-
支持任务优先级以最小化硬实时任务的延迟
从任务调度的角度来看,实时任务的延迟主要与系统在外部事件发生时恢复任务的能力有关。
为了保证一组选定任务具有确定性的延迟,RTOS 通常实现固定优先级级别,这些级别在任务创建时分配,并在调度器监督调用每次执行时确定下一个任务的选择顺序。
应当在具有较高优先级的任务中实现时间关键操作。已经研究了多种调度策略,以优化实时任务的反应时间,同时保持系统响应,并允许与可能饥饿的较低优先级任务相关的问题。为特定场景找到最优的调度策略可能非常困难;关于确定性地计算实时系统延迟和抖动的细节超出了本书的范围。
提出的一种方法在实时操作系统(RTOS)中非常受欢迎。它通过在调度器监督调用每次调用时,从准备执行的任务中选择具有最高优先级的任务,为实时任务提供立即的上下文切换。这种称为静态优先级驱动抢占式调度的调度策略在所有情况下都不是最优的,因为任务的延迟取决于同一优先级级别的任务数量,并且没有预见任何机制来防止在系统负载较高的情况下,具有较低优先级的任务可能出现的饥饿问题。然而,该机制足够简单,可以轻松实现以展示优先级机制对实时任务延迟的影响。
另一种可能的方法是在运行时根据任务的特征动态重新分配优先级。实时调度器可能从一种确保首先选择最接近截止日期的任务的机制中受益。这种称为SCHED_DEADLINE调度器的做法,从 Linux 3.14 版本开始包含在内,是实现这种机制的实现,尽管实现起来相对简单,但在嵌入式操作系统中并不那么受欢迎。
此示例展示了静态优先级驱动调度器的简单实现。我们使用四个独立的列表来存储活动任务,每个列表对应于系统支持的每个优先级级别。每个任务在创建时都会分配一个优先级级别,内核的优先级保持在0,其主要任务仅在所有其他任务都处于睡眠状态时运行,其唯一目的是检查睡眠任务的计时器。当任务准备好时,可以将它们插入具有相应优先级级别的活动任务列表中,当它们被阻塞时,它们将被移动到等待列表中。为了跟踪任务的静态优先级,将优先级字段添加到任务块中:
struct task_block {
char name[TASK_NAME_MAXLEN];
int id;
int state;
void (*start)(void *arg);
void *arg;
uint8_t *sp;
uint32_t wakeup_time;
uint8_t priority;
struct task_block *next;
};
必须定义两个快捷函数来快速将任务块添加到具有相同优先级的任务列表中,并从中移除:
static void tasklist_add_active(struct task_block *el)
{
tasklist_add(&tasklist_active[el->priority], el);
}
static int tasklist_del_active(struct task_block *el)
{
return tasklist_del(&tasklist_active[el->priority], el);
}
当任务被移除或插入到给定优先级的活动任务列表中时,它们可以用于新的task_waiting和task_ready函数版本:
static void task_waiting(struct task_block *t)
{
if (tasklist_del_active(t) == 0) {
tasklist_add(&tasklist_waiting, t);
t->state = TASK_WAITING;
}
}
static void task_ready(struct task_block *t)
{
if (tasklist_del(&tasklist_waiting, t) == 0) {
tasklist_add_active(t);
t->state = TASK_READY;
}
}
在系统中创建了三个任务,但按钮按下事件会阻塞的那个任务具有更高的优先级级别:
void main(void) {
clock_pll_on(0);
led_setup();
button_setup(button_wakeup);
systick_enable();
kernel.name[0] = 0;
kernel.id = 0;
kernel.state = TASK_RUNNING;
kernel.wakeup_time = 0;
kernel.priority = 0;
tasklist_add_active(&kernel);
task_create("test0",task_test0, NULL, 1);
task_create("test1",task_test1, NULL, 1);
task_create("test2",task_test2, NULL, 3);
while(1) {
struct task_block *t = tasklist_waiting;
while (t) {
if (t->wakeup_time && (t->wakeup_time < jiffies)) {
t->wakeup_time = 0;
task_ready(t);
}
t = t->next;
}
WFI();
}
}
选择下一个任务的功能被重新设计,以找到那些准备运行的任务中优先级最高的任务。为此,从最高优先级到最低优先级遍历优先级列表。如果最高优先级的列表与当前任务之一相同,则如果可能,选择同一级别的下一个任务,以确保在相同优先级级别内竞争 CPU 的任务之间有一个轮询机制。在其他任何情况下,选择最高优先级列表中的第一个任务:
static int idx;
static inline struct task_block *
tasklist_next_ready(struct task_block *t)
{
for (idx = MAX_PRIO - 1; idx >= 0; idx--) {
if ((idx == t->priority) && (t->next != NULL) &&
(t->next->state == TASK_READY))
return t->next;
if (tasklist_active[idx])
return tasklist_active[idx];
}
return t;
}
与具有单个优先级级别的调度器相比,在处理 ID 等于2的任务的按钮按下事件时,主要区别在于按钮按下事件和任务本身的反应之间的时间间隔。这两个调度器都通过在按钮事件的中断处理程序中立即将任务放回就绪状态来实现抢占。
然而,在第一种情况下,任务会回到正在调度的任务轮询中,与其他相同优先级的任务竞争,这可能导致任务反应的延迟。在最坏的情况下,我们可以估计这将是N * TIMESLICE,其中N是中断发生时准备运行的过程数。
使用基于优先级的调度方法,在发生中断后,实时任务是第一个被调度的任务,这样从中断到恢复任务所需的时间是可测量的,大约是几微秒,因为 CPU 执行可预测数量的指令以执行所有中间操作。
实时嵌入式操作系统对于实现生命关键系统至关重要,主要是在交通和医疗行业。另一方面,它们依赖于简化的模型来尽可能保持基本系统操作轻量级,并具有最小的系统调用接口和系统 API 开销。相反的方法可能包括增加内核的复杂性,以引入吞吐量、任务交互、内存安全性改进和其他性能指标方面的优化,这可能更适合具有宽松或不存在实时要求的嵌入式系统。更严格的基于优先级的调度策略可以提高延迟并保证在受控场景中的实时响应,但在通用嵌入式系统中使用时不太灵活,在其他约束比任务延迟更有力的情况下,基于时间的抢占式调度方法可能提供更好的结果。
同步
在一个多线程环境中,内存、外设和系统访问是共享的,系统应提供同步机制,以允许任务在系统级可用资源的仲裁上协作。
互斥锁和信号量是并行线程之间最常用的同步机制,因为它们提供了解决大多数并发问题的最小集合。可能阻塞调用任务的函数必须能够与调度器交互,在资源不可用且锁未释放或信号量增加之前,将任务移动到等待状态。
信号量
信号量是最常见的同步原语,它提供了一个具有独占访问权限的计数器,并且被两个或更多线程用于在特定共享资源的仲裁上协作。提供给任务提供的 API 必须保证该对象可以用来实现一个具有独占访问权限的计数器,这通常需要在 CPU 上提供一些辅助功能。因此,同步策略的内部实现依赖于目标处理器中实现的微代码。
在 Cortex-M3/M4 上,锁定机制的实现依赖于 CPU 提供的执行独占操作的指令。参考平台的指令集提供了以下两个指令:
-
加载寄存器独占(LDREX):从内存中的地址加载一个值到 CPU 寄存器中。
-
存储寄存器独占(STREX):尝试将寄存器中包含的新值存储在内存中的地址,该地址对应于最后一个 LDREX 指令。如果 STREX 成功,CPU 保证内存中的值写入是独占的,并且自上次 LDREX 调用以来该值没有被修改。在两个并发 LDREX/STREX 部分之间,只有一个将导致寄存器成功写入;第二个 STREX 指令将失败,返回零。
这些指令的特性保证了独占访问一个计数器,然后用于实现信号量和互斥锁的基本功能。
sem_trywait函数尝试减少信号量的值。除非信号量的值为0,否则操作总是允许的,这将导致立即失败。函数在成功时返回0,如果信号量值为零,则返回-1,此时不可能减少信号量的值。
sem_trywait事件序列如下:
-
信号量变量的值(一个通过独占加载和存储指令访问的整数)从函数参数指向的内存中读取到寄存器 R1 中。
-
如果 R1 的值为
0,则信号量无法获取,函数返回-1。 -
R1 的值减一。
-
R1 的值存储在由函数参数指向的内存中,STREX 操作的结果放入 R2。
-
如果操作成功,R2 包含
0,信号量被获取并成功递减,函数可以返回成功状态。 -
如果存储操作失败(尝试并发访问),则立即重复该过程进行第二次尝试。
以下汇编例程实现了所有步骤,成功时返回 0,递减失败时返回 -1:
sem_trywait:
LDREX r1, [r0]
CMP r1, #0
BEQ sem_trywait_fail
SUBS r1, #1
STREX r2, r1, [r0]
CMP r2, #0
BNE sem_trywait
DMB
MOVS r0, #0
BX lr
sem_trywait_fail:
DMB
MOV r0, #-1
BX lr
以下代码是增加信号量的相应函数,它与等待例程类似,除了计数信号量被增加,并且操作最终将成功,即使多个任务同时尝试访问信号量。函数在成功时返回 0,除非计数器之前的值是零,在这种情况下它返回 1,以提醒调用者通知任何处于等待状态下的监听者值已增加,相关资源现在可用:
.global sem_dopost
sem_dopost:
LDREX r1, [r0]
ADDS r1, #1
STREX r2, r1, [r0]
CMP r2, #0
BNE sem_dopost
CMP r0, #1
DMB
BGE sem_signal_up
MOVS r0, #0
BX lr
sem_signal_up:
MOVS r0, #1
BX lr
为了将 sem_wait 函数的阻塞状态集成到调度器中,操作系统对任务公开的信号量接口将非阻塞的 sem_trywait 调用包装成其阻塞版本,当信号量的值为零时,该任务将被阻塞。
为了实现信号量接口的阻塞版本,semaphore 对象可能跟踪访问资源并等待后置事件的任务。在这种情况下,任务的标识符存储在名为 listeners 的数组中:
#define MAX_LISTENERS 4
struct semaphore {
uint32_t value;
uint8_t listeners[MAX_LISTENERS];
};
typedef struct semaphore semaphore;
当等待操作失败时,任务将被阻塞,并且只有在另一个任务成功执行后置操作后才会再次尝试。任务标识符被添加到该资源的监听者数组中:
int sem_wait(semaphore *s)
{
int i;
if (s == NULL)
return -1;
if (sem_trywait(s) == 0)
return 0;
for (i = 0; i < MAX_LISTENERS; i++) {
if (!s->listeners[i])
s->listeners[i] = t_cur->id;
if (s->listeners[i] == t_cur->id)
break;
}
task_waiting(t_cur);
schedule();
return sem_wait(s);}
汇编例程 sem_dopost 如果后置操作触发了从零到一的增量,则返回正值,这意味着如果存在监听者,它们必须恢复以尝试获取刚刚变得可用的资源。
互斥锁
1 允许第一个锁定操作。
由于信号量的性质,在值达到 0 之后尝试递减其计数器将失败,因此我们快速实现的互斥锁接口将信号量原语 sem_wait 和 sem_post 分别重命名为 mutex_lock 和 mutex_unlock。
两个任务可以同时尝试递减未上锁的互斥锁,但只有一个会成功;另一个将失败。在示例调度器的互斥锁的阻塞版本中,基于信号量函数构建的互斥锁 API 包装器如下:
typedef semaphore mutex;
#define mutex_init(m) sem_init(m, 1)
#define mutex_trylock(m) sem_trywait(m)
#define mutex_lock(x) sem_wait(x)
#define mutex_unlock(x) sem_post(x)
对于信号量和互斥锁,到目前为止编写的示例操作系统为与调度器集成的同步机制提供了一个完整的 API。
优先级反转
在使用基于抢占和优先级的调度器以及集成同步机制开发操作系统时,经常会遇到优先级反转现象。这种情况会影响与其他低优先级任务共享资源的实时任务的反应时间,在某些情况下,可能会导致高优先级任务因不可预测的时间而饥饿。当高优先级任务等待低优先级任务释放资源时,这一事件发生,而此时低优先级任务可能被系统中的其他无关任务抢占。
特别是,可能触发此现象的事件序列如下:
-
T1、T2 和 T3 是三个正在运行的任务,分别具有 1、2 和 3 的优先级。
-
T1 使用资源X上的互斥锁获取锁。
-
T1 被优先级更高的 T3 抢占。
-
T3 试图访问共享资源X,并在互斥锁上阻塞。
-
T1 在临界区恢复执行。
-
T1 被优先级更高的 T2 抢占。
-
在 T1 释放锁并唤醒 T3 之前,任意数量的优先级大于 1 的任务可以中断 T1 的执行。
避免这种情况的一种可能机制称为优先级继承。该机制包括临时将共享资源的任务的优先级提高到所有访问该资源的任务的最高优先级。这样,低优先级任务不会导致高优先级任务的调度延迟,并且实时需求仍然得到满足。
系统资源分离
我们在本章中构建的示例操作系统已经具有许多有趣的功能,但它仍然具有扁平模型的特点,没有内存分段或权限分离。简约系统不提供任何机制来分离系统资源并规范对内存空间的访问。相反,系统中的任务被允许执行任何特权操作,包括读取和修改其他任务的内存,在内核地址空间中执行操作,以及在运行时直接访问外围设备和 CPU 寄存器。
目标平台上有不同的方法,旨在通过在内核中引入有限数量的修改来提高系统的安全性:
-
实施内核/进程权限分离
-
在调度器中集成内存保护
-
通过监督调用提供系统调用接口以访问资源
让我们详细讨论每个方法。
权限级别
Cortex-M CPU 设计为以两种不同的特权级别运行代码。当在系统上运行不受信任的应用程序代码时,特权分离非常重要,允许内核始终保持对执行的掌控,并防止由于用户线程的不当行为而导致系统故障。启动时的默认执行级别是特权级别,以允许内核启动。应用程序可以配置为在用户级别执行,并在上下文切换操作期间使用不同的栈指针寄存器。
特权级别的更改只能在异常处理程序期间进行,并且是通过使用特殊的异常返回值来完成的,该值在从执行上下文切换的异常处理程序返回之前存储在 LR 中。控制特权级别的标志是 CONTROL 寄存器的最低位,可以在返回异常处理程序之前在上下文切换期间更改,以将应用程序线程降级到用户特权级别运行。
此外,大多数 Cortex-M CPU 提供了两个独立的栈指针 CPU 寄存器:
-
主栈指针(MSP)
-
进程栈指针(PSP)
遵循 ARM 的推荐,操作系统必须使用 PSP 来执行用户线程,而 MSP 则由中断处理程序和内核使用。栈的选择取决于异常处理程序结束时的特殊返回值。我们迄今为止实现的调度器将此值硬编码为 0xFFFFFFF9,用于在中断后以线程模式返回,并保持以特权级别执行代码。从中断处理程序返回 0xFFFFFFFD 值会告诉 CPU 在返回线程模式时选择 PSP 作为栈指针寄存器。
为了正确实现特权分离,用于切换任务的 PendSV 处理程序必须修改为使用被抢占任务的正确栈指针来保存和恢复上下文。我们迄今为止使用的 store_context 和 restore_context 函数分别重命名为 store_kernel_context 和 restore_kernel_context,因为内核仍在使用主栈指针。还添加了两个新函数,用于从新的上下文切换例程中存储和恢复线程上下文,该例程使用 PSP 寄存器,用于存储和恢复线程的上下文:
static void __attribute__((naked)) store_user_context(void)
{
asm volatile("mrs r0, psp");
asm volatile("stmdb r0!, {r4-r11}");
asm volatile("msr psp, r0");
asm volatile("bx lr");
}
static void __attribute__((naked)) restore_user_context(void)
{
asm volatile("mrs r0, psp");
asm volatile("ldmfd r0!, {r4-r11}");
asm volatile("msr psp, r0");
asm volatile("bx lr");
}
在调度器的安全版本中,PendSV 服务例程选择正确的栈指针来存储和恢复上下文,并调用相关的例程。根据新的上下文,存储在 LR 中的返回值用于选择用作新栈指针的寄存器,并将特权级别设置在 CONTROL 寄存器中,以便在即将到来的线程模式中使用 1 或 0 的值分别切换到用户或特权级别:
void __attribute__((naked)) isr_pendsv(void)
{
if (t_cur->id == 0) {
store_kernel_context();
asm volatile("mrs %0, msp" : "=r"(t_cur->sp));
} else {
store_user_context();
asm volatile("mrs %0, psp" : "=r"(t_cur->sp));
}
if (t_cur->state == TASK_RUNNING) {
t_cur->state = TASK_READY;
}
t_cur = tasklist_next_ready(t_cur);
t_cur->state = TASK_RUNNING;
if (t_cur->id == 0) {
asm volatile("msr msp, %0" ::"r"(t_cur->sp));
restore_kernel_context();
asm volatile("mov lr, %0" ::"r"(0xFFFFFFF9));
asm volatile("msr CONTROL, %0" ::"r"(0x00));
} else {
asm volatile("msr psp, %0" ::"r"(t_cur->sp));
restore_user_context();
asm volatile("mov lr, %0" ::"r"(0xFFFFFFFD));
asm volatile("msr CONTROL, %0" ::"r"(0x01));
}
asm volatile("bx lr");
}
在CONTROL寄存器中设置特权模式位运行的任务对系统的资源访问有限制。特别是,线程无法访问 SCB 区域中的寄存器,这意味着一些基本操作,例如通过 NVIC 启用和禁用中断,仅限于内核的专用使用。当与 MPU 结合使用时,通过在访问级别实施内存分离,特权分离可以进一步提高系统的安全性,从而检测并中断行为不当的应用程序代码。
内存分段
动态内存分段策略可以集成到调度器中,以确保单个任务不会访问与系统关键组件关联的内存区域,并且需要内核监督的资源可以从用户空间访问。
在第五章 内存管理中,我们看到了如何使用 MPU 来界定连续的内存段,并禁止系统上运行的任何代码访问特定区域。MPU 控制器提供了一个权限掩码,以更细粒度地更改单个内存区域的属性。特别是,只有当 CPU 在特权级别运行时,我们才允许访问某些区域,这是一种有效的方法,可以防止用户应用程序在没有内核监督的情况下访问系统的某些区域。一个安全的操作系统可能会决定完全排除应用程序任务访问外围区域和系统寄存器,为此可以使用仅内核权限标志对这些区域进行设置。与 MPU 区域属性寄存器中特定权限相关联的值可以定义如下:
#define RASR_KERNEL_RW (1 << 24)
#define RASR_KERNEL_RO (5 << 24)
#define RASR_RDONLY (6 << 24)
#define RASR_NOACCESS (0 << 24)
#define RASR_USER_RW (3 << 24)
#define RASR_USER_RO (2 << 24)
MPU 配置可以在引导时由内核强制执行。在这个例子中,我们将闪存区域设置为全局可读,作为区域0,使用RASR_RDONLY,并将 SRAM 区域设置为全局可访问,作为区域1,映射到地址0x20000000:
int mpu_enable(void)
{
volatile uint32_t type;
volatile uint32_t start;
volatile uint32_t attr;
type = MPU_TYPE;
if (type == 0)
return -1;
MPU_CTRL = 0;
start = 0;
attr = RASR_ENABLED | MPUSIZE_256K | RASR_SCB |
RASR_RDONLY;
mpu_set_region(0, start, attr);
start = 0x20000000;
attr = RASR_ENABLED | MPUSIZE_128K | RASR_SCB | RASR_USER_RW | RASR_NOEXEC;
mpu_set_region(1, start, attr);
更严格的策略甚至可以限制非特权模式下用户任务对 SRAM 的使用,但这需要对任务启动时映射的.data和.bss区域进行重新组织。在这个例子中,我们只是演示了如何将每个任务的内存保护策略集成到调度器中,以防止访问系统资源并保护其他任务的堆栈区域。CCRAM 是我们想要保护的区域,因为它包含内核的执行堆栈,以及系统中其他任务的堆栈。为此,CCRAM 区域必须标记为仅对内核专有的访问权限,作为区域2。随后,在上下文切换期间必须为选定的任务创建一个异常,以允许访问其自己的堆栈空间:
start = 0x10000000;
attr = RASR_ENABLED | MPUSIZE_64K | RASR_SCB |
RASR_KERNEL_RW | RASR_NOEXEC;
mpu_set_region(2, start, attr);
外围区域和系统寄存器是我们系统中的受限区域,因此在运行时它们也被标记为仅限内核访问。在我们的安全操作系统设计中,想要访问外围设备的任务必须使用系统调用来执行受监督的特权操作:
start = 0x40000000;
attr = RASR_ENABLED | MPUSIZE_1G | RASR_SB |
RASR_KERNEL_RW | RASR_NOEXEC;
mpu_set_region(4, start, attr);
start = 0xE0000000;
attr = RASR_ENABLED | MPUSIZE_256M | RASR_SB |
RASR_KERNEL_RW | RASR_NOEXEC;
mpu_set_region(5, start, attr);
SHCSR |= MEMFAULT_ENABLE;
MPU_CTRL = 1;
return 0;
}
在上下文切换期间,在从isr_pendsv服务例程返回之前,调度器可以调用我们自定义 MPU 模块导出的函数,以临时允许以非特权模式访问下一个要运行的任务的堆栈区域:
void mpu_task_stack_permit(void *start)
{
uint32_t attr = RASR_ENABLED | MPUSIZE_1K |
RASR_SCB | RASR_USER_RW;
MPU_CTRL = 0;
DMB();
mpu_set_region(3, (uint32_t)start, attr);
MPU_CTRL = 1;
}
这些进一步的限制限制了当前实现的任务直接访问任何资源的可能性。为了保持与之前相同的功能,示例系统现在必须导出一个新的安全 API,以便任务请求系统操作。
系统调用
本章中我们实现的示例操作系统的最新版本不再允许我们的任务控制系统资源,例如输入输出外围设备,甚至不允许任务自愿阻塞,因为sleep_ms函数不允许设置挂起标志以启动上下文切换。
操作系统导出一个 API,任务可以通过系统调用机制通过 SVCall 异常访问该 API,该异常由isr_svc服务例程处理,并且可以在任何时间通过svc指令从任务触发。
在这个简单的示例中,我们使用svc 0汇编指令通过定义快捷宏SVC()来切换到处理程序模式:
#define SVC() asm volatile ("svc 0")
我们将这个指令封装在一个 C 函数中,以便我们可以向它传递参数。平台提供的 ABI 在模式切换过程中通过 R0-R3 寄存器传递调用参数的前四个参数。我们的示例 API 不允许我们将任何参数传递给系统调用,而是使用 R0 中的第一个参数来识别从应用程序传递到内核的请求:
static int syscall(int arg0)
{
SVC();
}
这样,我们为这个操作系统实现了整个系统调用接口,该接口由以下无参数的系统调用组成。每个系统调用都有一个关联的识别号,通过arg0传递。系统调用列表是任务和内核之间接口的契约,也是任务使用系统保护资源的唯一方式:
#define SYS_SCHEDULE 0
#define SYS_BUTTON_READ 1
#define SYS_BLUELED_ON 2
#define SYS_BLUELED_OFF 3
#define SYS_BLUELED_TOGGLE 4
#define SYS_REDLED_ON 5
#define SYS_REDLED_OFF 6
#define SYS_REDLED_TOGGLE 7
#define SYS_GREENLED_ON 8
#define SYS_GREENLED_OFF 9
#define SYS_GREENLED_TOGGLE 10
这些系统调用必须在isr_svc中处理。通过调用处理程序上下文中的驱动程序函数来控制外围设备和系统阻塞寄存器是可以完成的,即使在这里这样做只是为了简洁。在适当的设计中,需要多个指令才能完成的操作应该推迟到内核任务下次调度时运行。以下代码仅用于展示一个可能的isr_svc实现,该实现响应系统 API 允许的用户请求,以控制板上的 LED 和按钮,同时提供一个可以扩展以实现阻塞系统调用的机制。
svc服务例程执行由处理程序本身传递的请求命令。如果系统调用是阻塞的,例如SYS_SCHEDULE系统调用,则处理程序内会选择一个新的任务以完成任务切换。
svc例程现在可以处理内部命令,如下面的示例处理函数所示:
void __attribute__((naked)) isr_svc(int arg)
{
store_user_context();
asm volatile("mrs %0, psp" : "=r"(t_cur->sp));
if (t_cur->state == TASK_RUNNING) {
t_cur->state = TASK_READY;
}
switch(arg) {
case SYS_BUTTON_READ: /* cmd to read button value */
button_start_read();
break;
case SYS_SCHEDULE: /* cmd to schedule the next task */
t_cur = tasklist_next_ready(t_cur);
t_cur->state = TASK_RUNNING;
break;
case SYS_BLUELED_ON: /* cmd to turn on blue LED */
blue_led_on();
break;
/* case ... (more LED related cmds follow) */
}
与PendSV中一样,在例程结束时恢复上下文,尽管这是可选的,但如果调用必须阻塞,则可能会发生任务切换:
if (t_cur->id == 0) {
asm volatile("msr msp, %0" ::"r"(t_cur->sp));
restore_kernel_context();
asm volatile("mov lr, %0" ::"r"(0xFFFFFFF9));
asm volatile("msr CONTROL, %0" ::"r"(0x00));
} else {
asm volatile("msr psp, %0" ::"r"(t_cur->sp));
restore_user_context();
mpu_task_stack_permit(((uint8_t *)((&stack_space))
+(t_cur->id << 10))); asm volatile("mov lr, %0" ::"r"(0xFFFFFFFD));
asm volatile("msr CONTROL, %0" ::"r"(0x01));
}
asm volatile("bx lr");}
虽然功能有限,但新系统导出所有必要的 API,以便我们的应用程序线程在从任务代码中移除所有禁止的特权调用并调用新创建的系统调用后再次运行:
void task_test0(void *arg)
{
while(1) {
syscall(SYS_BLUELED_ON);
mutex_lock(&m);
sleep_ms(500);
syscall(SYS_BLUELED_OFF);
mutex_unlock(&m);
sleep_ms(1000);
}
}
void task_test1(void *arg)
{
syscall(SYS_REDLED_ON);
while(1) {
sleep_ms(50);
mutex_lock(&m);
syscall(SYS_REDLED_TOGGLE);
mutex_unlock(&m);
}
}
void task_test2(void *arg)
{
uint32_t toggle_time = 0;
syscall(SYS_GREENLED_OFF);
while(1) {
button_read();
if ((jiffies - toggle_time) > 120) {
syscall(SYS_GREENLED_TOGGLE);
toggle_time = jiffies;
}
}
}
如果安全操作系统在内核空间中实现所有操作并必须提供所有允许的系统调用的实现,其代码大小可能会迅速增长。另一方面,它为任务之间提供了物理内存分离,并保护系统资源和其他内存区域免受应用程序代码中意外错误的影响。
嵌入式操作系统
如本章前几节所示,从头开始构建针对定制解决方案的调度器并非不可能,如果做得恰当,将提供与所需架构最接近的近似,并专注于目标硬件提供的特定特性。然而,在现实场景中,建议考虑许多可用的嵌入式操作系统选项之一,这些选项已准备好集成到架构中,并支持所选硬件平台,同时提供本章中我们了解到的功能。
许多适用于微控制器的内核实现都是开源的,并且处于良好的开发状态,因此它们在嵌入式市场中的既定角色是当之无愧的。其中一些足够流行且经过广泛测试,可以为构建可靠的嵌入式多任务应用程序提供基础。
操作系统选择
选择最适合开发目的和开发平台操作系统的任务是一项微妙的工作,它会影响整体架构,可能对整个开发模型产生影响,并可能在应用程序代码库中引入 API 锁定。选择标准根据硬件特性、与其他组件(如第三方库)的集成、提供与外围设备和接口交互的设施以及最重要的是,系统设计旨在覆盖的使用案例范围而有所不同。
除了调度器和内存管理之外,操作系统通常还包括一系列集成库、模块和工具。根据目的,嵌入式操作系统可能提供一套覆盖多个领域的套件,包括以下内容:
-
平台特定的硬件抽象层
-
常见外设的设备驱动程序
-
TCP/IP 堆栈集成以实现连接性
-
文件系统和文件抽象
-
集成电源管理系统
根据调度器中线程模型的实现,一些系统可能会以预定义的任务数量运行,这些任务在编译时配置,而其他系统则选择更复杂的进程和线程层次结构,允许我们在运行时创建新的线程,并在执行过程中的任何时刻终止它们。然而,在嵌入式系统中,动态任务创建和终止很少是必需的,在这些情况下,替代的设计可能有助于我们绕过这一限制。
更复杂的系统由于系统异常代码中的额外逻辑而引入了一些开销,并且不太适合关键实时操作,这也是为什么如今大多数成功的实时操作系统(RTOS)都保持其简单的架构,提供运行多个线程所需的最基本功能,采用易于管理的平坦内存模式,并且不需要额外的上下文切换来管理操作权限,以保持低延迟并符合实时要求。
由于可用的选项众多,以及随着技术进步的持续发展,提供嵌入式设备的操作系统的详尽列表超出了本书的范围。与 PC 领域不同,那里只有少数几个操作系统主导整个市场,而嵌入式操作系统在设计和 API、驱动程序、支持的硬件以及构建工具等方面都彼此非常不同。
在本章的最后部分,我们将通过比较并行任务执行、内存管理和可用辅助功能的设计选择,来探讨两种最流行的嵌入式设备开源操作系统:FreeRTOS 和 Riot OS。
FreeRTOS
在撰写本文时,FreeRTOS 可能是嵌入式设备开源操作系统中最受欢迎的,其活跃开发已接近 20 年,FreeRTOS 在许多嵌入式平台上具有极高的可移植性,拥有数十个可用的硬件端口,并支持大多数,如果不是所有嵌入式 CPU 架构。
该系统在设计时考虑到代码占用小和接口简单,不提供完整的驱动程序平台或高级 CPU 特定功能,而是专注于两个方面:线程的实时调度和堆内存管理。其设计的简单性使其能够移植到大量平台上,并使开发重点集中在少量经过充分测试和可靠的操作上。
另一方面,硬件制造商提供的第三方库和示例代码通常将 FreeRTOS 集成到他们的软件套件中,大多数情况下作为测试应用程序和示例中裸机方法的唯一替代方案。由于第三方代码不是直接包含在 FreeRTOS 中,这促进了不同解决方案之间的竞争,例如,它可以与许多 TCP/IP 堆栈实现集成以提供网络支持,尽管它们都不是核心系统的一部分或与内核紧密集成。设备驱动程序不包括在内核中,但有一些基于 FreeRTOS 与板级支持包集成的完整系统演示,这些板级支持包由制造商分发或作为更广泛生态系统的一部分,其中 FreeRTOS 作为内核的一部分。
调度器是抢占式的,具有固定的优先级级别,并通过共享互斥锁实现优先级继承。所有线程的优先级级别和堆栈空间大小是在创建线程时确定的。一个典型的 FreeRTOS 应用程序从其main函数开始,负责初始化线程和启动调度器。可以使用xTaskCreate函数创建新任务:
xTaskCreate(task_entry_fn, "TaskName", task_stack_size,
( void * ) custom_params, priority, task_handle);
第一个参数是main函数的指针,它将是任务的入口点。当任务入口点被调用时,作为第四个参数指定的自定义参数将作为函数的唯一参数传递,允许我们在任务创建时与线程共享用户定义的参数。xTaskCreate的第二个参数只是任务的可打印字符串名称,用于调试目的。第三个和第五个参数分别指定此任务的堆栈大小和优先级。最后,最后一个参数是可选的指向任务内部结构的指针,当xTaskCreate返回时,如果提供了有效的指针,则会填充。此对象是TaskHandle_t类型,用于访问一些任务功能,例如任务通知或通用任务实用工具。
一旦应用程序创建了其任务,主函数通过调用以下内容来调用主调度器:
vTaskStartScheduler();
如果一切顺利,此函数永远不会返回,并且应用程序的主函数成为实际的内核任务,负责调度之前定义的任务以及以后可以添加的新任务。
FreeRTOS 提供的最有趣的功能之一是堆内存管理,它提供了五种针对不同设计优化的风味:
-
堆 1:允许在堆中只进行一次性的静态分配,没有释放内存的可能性。如果应用程序可以在开始时分配所有所需的空间,那么这很有用,因为内存将永远不会再次对系统可用。
-
堆 2:允许释放内存,但不重新组装已释放的块。此机制适用于具有有限堆分配数量的实现,特别是如果它们保持与之前释放的对象相同的大小。如果使用不当,此模型可能会导致严重碎片化的堆栈,长期运行中存在堆耗尽的风险,即使分配对象的总体大小没有增加,也是由于缺乏内存重组。
-
由第三方库提供的
malloc/free实现确保在 FreeRTOS 多线程上下文中使用时,包装的内存操作成为线程安全的。此模型允许我们通过在单独的模型中定义malloc/free函数或使用库实现并附加sbrk()系统调用来定义自定义的内存管理方法,正如在 第五章 中所见,内存管理。 -
free块被合并,并执行一些维护工作以优化来自不同线程的异构分配的堆使用。此方法限制了堆的碎片化并提高了长期内存使用效率。 -
堆 5:此方法与堆 4 使用相同的机制,但允许我们定义多个非连续内存区域作为同一堆空间的组成部分。只要在初始化时定义这些区域并通过可用的 API 提供给系统,这种方法就是解决物理碎片化的现成解决方案。
选择特定的堆模型包括包含定义相同函数但实现不同的可用源文件之一。这些文件是 FreeRTOS 分发的一部分,具有可识别的名称(heap_1.c、heap_2.c 等)。只能选择其中一个,并将其链接到最终应用程序以管理内存。
FreeRTOS 堆内存管理器公开的重要函数是 pvPortMalloc 和 pvPortFree,它们与我们在 第五章 中看到的 malloc 和 free 函数具有类似的签名和效果,内存管理。
支持 MPU 和线程模式,线程可以在受限模式下运行,此时唯一可访问的内存是分配给特定线程的内存。当在受限模式下运行线程时,系统 API 仍然可用,因为系统函数被映射到内存中的特定区域。主要安全策略是通过自愿将任务置于受限模式并允许任务仅访问其自己的堆栈以及映射内存中的最多三个可配置区域来定义内存访问边界。
低功耗管理仅限于睡眠模式,默认情况下没有实现深度睡眠机制。然而,系统允许我们重新定义调度回调函数以进入自定义的低功耗模式,这些模式可以作为实施定制节能策略的起点。
FreeRTOS 的最新版本包括特定的第三方代码分发版,作为构建安全连接平台(用于物联网系统)的起点。相同的作者还创建了一个为 FreeRTOS 设计的 TCP/IP 栈,它作为 FreeRTOS Plus 套件的一部分与内核和 wolfSSL 库一起分发,以支持安全套接字通信。
RIOT OS
主要建立在受限制的微控制器之上,如 Cortex-M0,低功耗嵌入式系统通常是小型、电池供电或能量收集设备,偶尔使用无线技术连接到远程服务。这些小型、低成本的系统在物联网项目和即装即用场景中都有应用,它们可以在单一集成电源上运行多年,几乎无需维护成本。
在这些用例中,裸机架构仍然非常流行。然而,一些非常轻量级的操作系统被设计出来,以尽可能少的资源来组织和同步任务,同时仍然专注于节能和连接性。开发这类操作系统时的挑战是找到一种方法,将复杂的网络协议放入几千字节内存中。为物联网服务设计的未来证明系统提供了本机 IPv6 网络连接,通常通过 6LoWPAN 实现,并配备了完整的、极简的 TCP/IP 栈,旨在牺牲吞吐量以换取更小的内存占用。
由于它们的代码量小,这些系统可能由于设计而缺乏一些高级功能。例如,它们可能不提供任何内存安全策略,或者具有有限的连接栈以节省资源。在仅使用 UDP 网络栈上运行这类系统并不罕见。
Riot OS 拥有一个快速增长的爱好者群体和系统开发者社区。项目的目标是提供一个为低功耗设计的系统,考虑到将设备集成到更大分布式系统的需求。核心系统非常可扩展,因为单个组件可以在编译时排除。
Riot OS 使用的方案与我们在 FreeRTOS 中看到的极简主义概念不同,在 FreeRTOS 中,最基本数量的代码是操作系统核心的一部分,而其他所有内容都作为外部组件集成。Riot OS 提供了广泛的库和设备支持代码,包括网络栈和无线驱动程序通信,这使得该系统特别适合物联网。不属于操作系统核心功能的组件被划分为可选模块,并设计了一个基于 makefile 的自定义构建系统,以方便将这些模块包含到应用程序中。
从 API 角度来看,Riot 社区的选择是尽可能模仿 POSIX 接口。这改善了来自不同背景的程序员进行嵌入式应用程序开发的体验,并用于使用标准 C 语言提供的 API 编写代码以访问系统资源。然而,系统仍然运行在平面模型上。系统级别没有实现权限分离,用户空间应用程序仍然应该通过直接引用系统内存来访问系统资源。
作为额外的安全措施,MPU 可以通过在堆栈底部放置一个小型只读区域来检测单线程中的堆栈溢出,如果线程试图超出其分配的堆栈空间写入,则会触发异常。
Riot 实现了一些通信堆栈作为模块,包括一个名为 GNRC 的最小化 IP 堆栈。GNRC 是一个仅支持 IPv6 的实现,针对底层 802.15.4 网络的特性进行了优化,并提供了一个套接字实现,用于编写轻量级的物联网应用程序。网络支持包括一个 lwIP 兼容层。lwIP 作为模块包含在内,以便在需要时提供更完整的 TCP/IP 实现。WolfSSL 也作为一个模块提供,这为使用最新 TLS 版本来保护套接字通信以及利用加密功能来保护静态数据(例如)提供了可能性。
Riot 提供的一个功能是访问低功耗模式的配置,该配置通过电源管理模块集成到系统中。该模块为管理特定平台的功能提供了抽象,例如 Cortex-M 平台上的停止和待机模式。低功耗模式可以在运行时从应用程序代码中激活,以促进在架构中集成低功耗策略。这是通过实时时钟、看门狗定时器或其他外部信号返回到正常运行模式来实现的。
Riot OS 中的调度器是无滴答的,主要基于协作。任务可以通过调用 task_yield 函数或通过调用任何阻塞函数来访问内核功能(如 IPC 和定时器)和硬件外围设备来显式地挂起自己。Riot OS 不强制基于时间片的并发;当接收到硬件中断时,任务会被唯一地强制中断。使用此调度器编程应用程序需要特别注意,因为在一个任务中意外创建一个忙循环可能会导致整个系统因饥饿而锁定。
Riot OS 中的任务可以通过 thread_create 函数创建:
thread_create(task_stack_mem, task_stack_size, priority,
flags, task_entry_fn, (void*)custom_args, "TaskName");
虽然 thread_create 的语法可能看起来与 FreeRTOS 中等效函数的语法相似,但我们可以在两个调度器的处理方法中找到一些差异。例如,在 Riot OS 中,为创建的任务保留的堆栈空间必须由调用者分配。堆栈空间不能在任务创建时自动分配,这意味着调用者需要更多的代码,但也提供了在内存中自定义每个堆栈空间位置的更多灵活性。正如我们之前提到的,调度器是无滴答的,因此没有必要手动启动它。任务可以在执行过程中随时创建和停止。
由于是为具有少量可用 RAM 的嵌入式目标设计的,Riot OS 中不建议使用动态分配的内存。然而,该系统提供了三种不同的堆内存管理方法:
-
malloc函数使用此实现,而free函数没有效果。 -
内存数组分配器:一个静态分配的缓冲区可以用作固定、预定义大小的伪动态分配请求的内存池。这种分配器在应用程序处理多个相同大小的缓冲区的情况下可能很有用。这个分配器有一个自定义的 API,并且不会修改默认的 malloc 函数的行为。
-
malloc是一个可选模块。当编译进模块时,该模块将替换一次性分配器提供的malloc和free函数,然后禁用该分配器。
Riot OS 是一个有趣的起点,用于物联网系统。它提供了一系列设备驱动程序和模块,这些驱动程序和模块是在一个轻量级且节能的核心系统之上构建和集成的,包括一个具有抢占式调度器的微内核。
摘要
在本章中,我们通过从头开始实现一个嵌入式操作系统,仅为了研究系统的内部结构,以及各种机制如何集成到调度器中,以及如何为任务提供阻塞调用、驱动程序 API 和同步机制,从而探讨了嵌入式操作系统的典型组件。
然后,我们分析了两个非常流行的开源实时嵌入式微控制器操作系统的组件,即 FreeRTOS 和 Riot OS,以突出设计选择、实现和为应用程序提供的工作线程和内存管理 API 的差异。
到目前为止,我们可以选择最适合我们架构的操作系统,并在需要时通过实现我们喜欢的调度、优先级机制、任务和内核本身的特权分离以及内存分段来编写自己的操作系统。
在下一章中,我们将更深入地探讨可信执行环境(TEEs),特别是 ARM 在其最新系列微控制器中最近引入的 TrustZone-M 功能,它增加了新的正交特权分离级别。
第十一章:可信执行环境
微控制器硬件架构技术演变的 重要一步最近已经通过引入一种域分离机制而实现,这种机制在其他架构中已经存在,通常被称为可信执行环境(Trusted Execution Environment,简称 TEE)或TEE。
TEE 是一种抽象,它提供了两个或更多分离的执行域,或“世界”,它们具有不同的能力和权限来访问设备、资源和外围设备。
将一个或多个软件组件和模块的执行环境隔离出来,也通常被称为沙盒技术,包括限制它们对系统的“视图”,而不影响它们的性能和正常操作。这是计算机科学中许多用例和领域的需求,而不仅仅是提高嵌入式系统的安全性。
在其他域中类似的硬件辅助隔离机制是我们今天所知道的云服务器基础设施的构建块,以虚拟化扩展和安全隔离机制的形式存在,这些机制允许我们在同一硬件上同时运行多个“客户”虚拟机或容器。
本章分析的概念和技术如下:
-
沙盒技术
-
TrustZone-M
-
系统资源分离
-
构建和运行示例
到本章结束时,你将了解 TEE 以及如何配置和使用 TrustZone-M 在 Cortex-M 微控制器上以获得两个独立的执行域。
技术要求
为了运行本书存储库中提供的示例,需要一个 STM32L552 微控制器。TrustZone-M 技术仅由最新的 ARM Cortex-M 微控制器系列支持。STM32L552 是一款 Cortex-M33,完全支持 TrustZone-M,这使得它成为学习这项技术的便捷且经济实惠的选择。
本章的代码文件可在github.com/PacktPublishing/Embedded-Systems-Architecture-Second-Edition/tree/main/Chapter11找到。
沙盒技术
沙盒技术是计算机安全领域的一个通用概念,它指的是一系列硬件和软件措施,这些措施限制了一个或多个系统组件的“视图”,以限制系统因意外故障或故意制造的恶意攻击而受影响的区域,并防止它们在整个系统中传播。沙盒技术可以有不同的形式和实现方式,这些方式可能或可能不利用特定的硬件功能来提高安全性和有效性。
通过术语 TEE,我们指的是那些涉及 CPU 始终跟踪运行代码的安全状态的沙箱机制,而不会显著影响运行应用程序的性能。由于这些 TEE 机制与 CPU 设计内在绑定,因此沙箱中的 TEE 行为、管理和通信模型在不同异构平台之间有所不同,并且高度依赖于架构。此外,TEE 可用于不同的目的,通常与密码学结合使用,通过硬件信任根来保护软件的完整性和真实性。
在 2005 年,英特尔为 x86 处理器实现了第一套虚拟化指令(Intel VT),以便原生地运行隔离的虚拟机代码(而不是在宿主机器上的专用进程中模拟 CPU),通过提供核心组件(CPU、RAM 和外设)的硬件辅助虚拟化。英特尔 CPU 通过扩展现有的分层保护域(通常简称为环,已用于内核/用户空间分离)来限制客户虚拟机对真实硬件的访问。
虚拟机并不是 x86 处理器上 TEE 的唯一用例。英特尔软件保护扩展(SGX)是一组存在于许多 x86 CPU 中的安全相关指令,用于保护特定的内存区域或区域,防止未经授权的访问。尽管这些指令最近已从消费级英特尔 CPU 中移除,但它们仍然存在于云和商业硬件领域的特定微处理器中。SGX 可用于多种目的,例如提供一个安全的保险库来隐藏用于安全使用的秘密密钥。然而,最初,它们被引入是为了在 PC 上实现数字版权管理(DRM),这将通过仅授权预授权、签名的软件应用程序访问受保护的内容来强制执行媒体和专有软件内容的版权保护。在这种设置中,TEE 保护系统免受的对手是最终用户本身。
之后,AMD 为其 CPU 添加了特定的架构扩展,这些扩展被归类为一种称为安全加密虚拟化(SEV)的技术。除了为由虚拟机管理程序管理的虚拟机提供沙箱外,SEV 还使用硬件辅助加密来确保在执行过程中单个内存页面甚至 CPU 寄存器内容的机密性。
然而,英特尔架构并不是第一个引入 CPU 辅助、内置的安全扩展的。ARM 在 2000 年代初开始研究可信计算,并于 2003 年最终宣布支持名为 TrustZone 的技术。现代 ARM 微处理器,如 Cortex-A 系列中的那些,支持名为 TrustZone-A 的技术,该技术实现了两个独立的 Secure(S)和非 Secure(NS)世界,后者对实际系统有一个受限的预配置视图,而前者能够直接访问所有硬件资源。
要找到第一个实现 TEE 的微控制器,我们必须看看最近设计的 RISC-V 架构。RISC-V 家族中的微处理器和微控制器都提供了相互独立的完整沙盒,无论是在 32 位还是 64 位架构中实现“S”或“U”扩展。每个硬件辅助容器都提供系统上可用资源的一个子集,并运行自己的固件。
最后,ARM 最新的微控制器系列,ARMv8-M 系列,包括实现基于现有且成熟的 TrustZone 技术设计的安全和非安全执行域之间隔离所需的扩展和微代码。这个特性被称为 TrustZone-M,这是我们将在本章后面更详细地关注的特定技术。ARMv8-M 是作为本书前几章参考平台的 ARMv7-M 系列微控制器的直接演变。
本章的其余部分将专门介绍 TrustZone-M 以及如何在嵌入式系统中配置和开发组件,实现 ARMv8-M 系列微控制器上的 TEE。从现在起,术语 TrustZone 将专门指 TrustZone-M 技术。
TrustZone-M
ARMv7-M 核心,如 Cortex-M0+和 Cortex-M4 微控制器,几十年来一直主导着嵌入式市场,并且仍然是许多嵌入式系统设计的首选。尽管已经发生了一些变化和增加,但新的 Cortex-M23 和 Cortex-M33 核心,以及更新的 M35P 和 M55,继承了并扩展了许多 Cortex-M0、Cortex-M4 和 Cortex-M7 微控制器的成功特性。
在典型的 TrustZone 用例中,软件开发的不同阶段可能涉及多个参与者。设备的所有者可能提供一个基础系统,该系统已经配备了在安全世界中授权运行的全部软件。这仍然为系统集成商提供了定制非安全部分的可能性,但视图受到安全域允许的资源配置的限制。在这种情况下,系统集成商接收到的系统部分锁定,TrustZone 已启用,并设置了闪存保护以保护其完整性。提供的安全软件监督非安全域中任何自定义软件的执行,同时保留安全世界中映射的资源,并限制运行应用程序的访问。没有授权访问安全执行域的系统集成商仍然可以在非安全世界中运行特权或非特权软件,从而包括访问授权接口的操作系统和设备驱动程序,这些接口可以是直接访问,也可以得到安全监督的一些帮助。
本章相关示例可以在参考平台上编译和运行。此示例基于在第第四章“启动过程”中介绍的引导加载程序示例。这是因为我们想要描述的基于 TrustZone 的解决方案的结构相似性,因为两个执行域的软件被分别打包到不同的二进制文件中。在 TrustZone 的情况下,引导加载程序代码在安全域中执行与在非安全世界中运行的应用程序之间的分离将帮助我们理解构建、配置和运行真实系统组件所使用的元素和工具。
下一个子节包含对参考平台的描述,然后我们将简要介绍安全和非安全域背后的执行模型,这将使我们进一步分析 TrustZone-M 单元和控制器在系统资源分离方面的调节。
参考平台
在示例中用作参考的微控制器是 STM32L552,这是一种 Cortex-M33 CPU,可以在方便的 Nucleo-144 格式开发板上找到。STM32L5 系列微控制器可能被认为是较老 STM32F4 系列的最新演变,通过结合低功耗模式和高性能来针对相同的市场份额。因此,本章选择该系列微控制器作为提供的示例的参考平台。然而,将要描述的大多数 TrustZone-M 技术和组件概念都适用于 ST Microelectronics 和其他几个芯片制造商提供的所有 ARMv8-M 系列微控制器。
在 STM32L552ZE 上,CPU 时钟可以配置为以 110 MHz 运行。微控制器配备了 256 KB 的 SRAM,分为两个银行 SRAM1 和 SRAM2,分别映射到不同的区域。512 KB 的闪存内存可以用作一个连续的空间,或者配置为两个独立的银行。ST 微控制器提供了特定于平台的库和工具,这些库和工具不包括在提供的示例中,通常这些示例基于一个全新的实现,从理解文档开始。在将要介绍的示例中,此方法的唯一例外是使用 STM32 编程命令行界面STM32_Programmer_CLI,该界面可以通过连接到 PC 的 USB 电缆并通过 ST-Link 调试器在板子上运行以下命令来显示可编程选项字节的当前值:
STM32_Programmer_CLI -c port=swd -ob displ
此工具将用于设置开启和关闭 TrustZone 所需的选项字节以及设置其他用于分离闪存区域的选项。选项字节值存储在非易失性存储器中,且在板子断电后这些值将得以保留。
重要提示
通过编程工具修改一些可访问的选项字节可能是不可逆的,在某些情况下,可能会损坏您的设备。在更改任何选项之前,请参阅您的微控制器的参考手册和应用笔记。
其中一个选项字节包含TZEN标志,根据出厂默认设置,该标志应被禁用。只有在 TrustZone-M 配置完成后,我们才会在目标设备上启用它以上传和运行示例。安全世界中的引导加载程序部分将负责为作为不同二进制文件安装的应用程序设置环境,并在非安全域中执行它。我们将通过引入为此目的引入的新 ARMv8 汇编指令来演示两个世界之间的转换。
在下一小节中,我们将介绍 ARMv8-M 架构中用于执行代码和控制执行域的扩展。这些扩展是通用的,包含在所有支持 TrustZone 的 ARMv8-M 微控制器中,并且是分离域执行的核心组件。
安全和非安全执行域
在第十章“并行任务和调度”中,我们了解到,在内存分段的帮助下,线程之间以及线程和操作系统之间的资源分离是可能的。在 ARMv8-m 系列微控制器中,TrustZone-M 通常被称为安全扩展,因为它确实在目标上运行的应用程序软件组件之间增加了一个额外的权限分离级别。这些安全扩展并不取代我们在调度器的安全版本中之前实现的现有线程分离。相反,它们在现有的分离之上引入了一个额外的安全模式。
与没有这些扩展的操作系统强制执行线程模式和特权模式之间的分离,并可以使用 MPU 设置访问内存映射区域的边界类似,TrustZone-M 添加了安全(S)和非安全(NS)执行域(或“世界”),CPU 可以控制对单个资源的访问。
在这些世界中的每一个,仍然可以通过使用基于 CONTROL 位的现有机制来实现特权/线程分离。每个安全世界都可以有自己的特权和非特权执行模式。在 NS 世界中运行的操作系统仍然可以使用从之前的 ARMv7-M 架构继承的经典特权分离。这总共创建了四个可用的执行上下文,CPU 可以同时跟踪,如下表所示,为域和特权级别的组合:
| 安全域 | 非安全域 |
|---|---|
| 安全特权执行 | 非安全特权执行 |
| 安全线程执行 | 非安全线程执行 |
表 11.1 – 安全/非安全域中的可用执行模式
如我们在第十章,“并行任务与调度”中指出的,Cortex-M4 提供了两个独立的栈指针(MSP 和 PSP)来跟踪执行线程或内核代码时的不同上下文。在 Cortex-M33 中,总共有四个不同的栈指针,分别是 MSP_S、PSP_S、MSP_NS 和 PSP_NS。每个栈指针在执行期间都会根据当前域和上下文被映射到实际的 SP 寄存器。
当 CPU 上存在 MAIN 扩展时,ARMv8-M 架构增加了一个非常方便的功能,正如我们的参考平台所示。这四个栈指针中的每一个都有一个对应的栈指针限制(SPLIM)寄存器(分别称为 MSPLIM_S、PSPLIM_S、MSPLIM_NS 和 PSPLIM_NS)。这些寄存器指示四种情况下栈指针值的下限。这实际上是对栈溢出子节中分析的问题的有效对策,该子节在第五章,“内存管理”中。当发生这种情况时,CPU 将不断在运行时检查栈是否超过其内存中的下限,并生成异常。这种机制提供了一种比在第五章,“内存管理”中提出的示例中提出的在堆和栈分配的两个内存区域之间引入保护区域更好的硬件辅助方式来保护内存,防止意外栈溢出和与其他内存区域的冲突。
我们已经分析了如何在执行模式之间切换,以及如何在从系统调用返回时设置或清除 CONTROL 位在特权与线程执行模式之间的交易中扮演的角色。在安全和非安全世界之间切换安全和非安全执行机制是通过特定的汇编指令实现的,我们将在介绍资源分离之后进行解释。
为了更好地理解非安全世界运行的软件可能访问或无法访问的系统资源,下一节将详细介绍 TrustZone-M 控制器模块提供的不同可能性,以隔离和分离硬件资源。
系统资源隔离
当 TrustZone-M 启用时,所有映射到内存的区域,包括 RAM、外设,甚至 FLASH 存储器,都会获得一个新的安全属性。除了安全和非安全域之外,安全属性还可能假设第三个值,非安全可调用(NSC)。这个最后的属性定义了用于通过特定机制从非安全世界到安全世界执行交易的特定内存区域,该机制将在最后一节“构建和运行示例”中解释。NSC 区域用于提供类似系统调用的安全 API,具有新的功能。安全域公开服务例程,可以在访问其非安全对应方的安全资源时执行特定的受控操作。
安全属性和内存区域
Cortex-M33 微控制器提供各种级别的保护。这些级别效果的组合决定了与系统上资源相关的哪些内存映射区域对执行域可用,以及哪些仅从安全世界可访问。
启用 TrustZone-M 还会复制一些系统资源的表示。通常从地址 0x08000000 开始映射的闪存,在区域 0x0C000000 有一个别名,用于从安全世界访问相同的存储。许多系统寄存器是“分区的”,并在不同的内存位置有安全和非安全版本。例如,当 TrustZone 禁用时,GPIOA 控制器映射到地址 0x42020000。当 TrustZone 启用时,如果 GPIOA 控制器可以从非安全世界访问,则运行在非安全域的软件将使用相同的地址。然而,运行在安全域的软件将使用从地址 0x52020000 开始映射的相同控制器。相同的分区也适用于外设区域中的许多其他寄存器,这些寄存器有相同寄存器的安全和非安全版本映射到两个单独的区域。
在由其他 TrustZone 感知组件处理之前,每个内存访问都由负责配置属性的单元进行监控和过滤。这些是安全属性单元(SAU)和实现定义属性单元(IDAU)。这些单元影响整个内存映射的可访问性,而不管每个区域关联的资源类型如何。虽然 SAU 可以通过一组寄存器进行配置,但 IDAU 包含由芯片制造商强制执行的硬编码映射。IDAU 和 SAU 属性的组合影响每个内存映射区域的可访问性,特别是以下方面:
-
由 IDAU 映射为安全的区域不受 SAU 属性的影响,并且始终保持为安全映射
-
由 IDAU 映射为 NSC 的区域可以是安全的或 NSC,这取决于 SAU 属性
-
由 IDAU 映射为非安全的区域将遵循 SAU 映射
每个区域的属性及其结果映射总结在下表中:
| IDAU 属性 | SAU 属性 | 结果属性 |
|---|---|---|
| 安全 | 安全、NSC 或非安全 | 安全 |
| NSC | 安全 | 安全 |
| 非安全 | 安全 | 安全 |
| NSC | NSC 或非安全 | NSC |
| 非安全 | NSC | NSC |
| 非安全 | 非安全 | 非安全 |
表 11.2 – IDAU 和 SAU 属性的组合
默认情况下,我们的 STM32L552 参考平台中的 IDAU 强制执行几个关键区域的保护/NSC 映射:
-
安全空间中的闪存映射,从地址
0x0C000000开始 -
第二个 SRAM 银行,SRAM2,从地址
0x30000000开始映射 -
地址
0x50000000到0x5FFFFFFF之间的内存,保留用于安全外设的控制和配置
SAU 在复位时将所有区域设置为安全,并且默认情况下是禁用的。要执行非安全代码,我们必须在 IDAU 配置允许的间隔内定义至少两个非安全区域。
在我们的示例中,我们在启用 SAU 之前初始化了一些内存区域,以允许应用程序访问。SAU 通过四个主要的 32 位寄存器进行控制:
-
SAU_CTRL(SAU 控制):用于激活 SAU。它包含一个标志来“反转”SAU 过滤器的逻辑,通过将所有内存区域设置为非安全。
-
SAU_RNR(SAU 区域号寄存器):包含在配置内存区域时选择的区域号。对 SAU_RBAR 和 SAU_RLAR 的进一步写入将引用此编号区域。
-
SAU_RBAR(SAU 区域基址寄存器):指示我们想要配置的区域的基本地址。
-
SAU_RLAR(SAU 区域限制地址寄存器):包含要配置的区域结束地址。最低 5 位保留为标志位。位 1,当开启时,表示区域是安全的或非安全可调用的。位 0 启用区域,并指示其配置已完成。
在以下示例代码中,你可以找到sau_init_region便利函数。给定一个区域标识符、基址、结束地址和安全位值,它将相应地设置所有寄存器值:
static void sau_init_region(uint32_t region,
uint32_t start_addr,
uint32_t end_addr,
int secure)
{
uint32_t secure_flag = 0;
if (secure)
secure_flag = SAU_REG_SECURE;
SAU_RNR = region & SAU_REGION_MASK;
SAU_RBAR = start_addr & SAU_ADDR_MASK;
SAU_RLAR = (end_addr & SAU_ADDR_MASK)
| secure_flag | SAU_REG_ENABLE;
}
这个函数由secure_world_init初始化函数调用,用于映射我们想要为此示例配置的四个 SAU 区域,具体如下:
-
nsc_blue_led_toggle,这是应用程序访问连接到 Nucleo 板上蓝色 LED 的否则仅限安全的 GPIO 的唯一方式。 -
0x08040000。这是我们的非安全应用程序代码将驻留的地方。 -
区域 2:SRAM1 银行的一个非安全部分,可以被非安全应用程序用于堆栈和变量。这是确保应用程序可以访问 RAM 地址的必要步骤。
-
0x40000000,包括非安全 GPIO 控制器。此区域将由非安全应用程序访问以设置系统时钟并控制示例中的绿色 LED。
示例中 SAU 初始化的代码如下:
static void secure_world_init(void)
{
/* Non-secure callable: NSC functions area */
sau_init_region(0, 0x0C001000, 0x0C001FFF, 1);
/* Non-secure: application flash area */
sau_init_region(1, 0x08040000, 0x0804FFFF, 0);
/* Non-secure RAM region in SRAM1 */
sau_init_region(2, 0x20018000, 0x2002FFFF, 0);
/* Non-secure: internal peripherals */
sau_init_region(3, 0x40000000, 0x4FFFFFFF, 0);
此函数末尾的代码激活了 SAU 并启用了一个特定处理程序,用于检测安全故障:
/* Enable SAU */
SAU_CTRL = SAU_INIT_CTRL_ENABLE;
/* Enable securefault handler */
SCB_SHCSR |= SCB_SHCSR_SECUREFAULT_EN;
}
默认情况下,启用 SAU 会将所有区域标记为安全,因此每个区域配置都会在可寻址内存空间内裁剪一个非安全或非安全可调用“窗口”。在我们的示例配置中,区域 0 是唯一带有 NSC 标志的区域,这意味着安全应用程序(稍后解释)将在这里安装 NSC 代码。区域 1、2 和 3 是当在启用 TrustZone-M 的非安全域中运行时唯一可能访问的内存区域。
如前所述,IDAU/SAU 只是 TrustZone-M 保护机制的第一个过滤级别。闪存和 RAM 通过额外的安全门进行保护,这些门可以是基于块的或基于水印的。STM32L552 微控制器配备了一个全局 TrustZone 控制器(GTZC),它包括一个基于水印的门控制器用于闪存和一个基于块的控制器用于定义安全/非安全 RAM 块。
闪存和安全的标志
在目标平台上,闪存可以配置为映射为一个单一的、连续的空间,或者通过激活双银行配置将其分成两半。为了我们的 TrustZone-M 示例,我们将保持闪存在一个单独的银行中。
在此配置中,当 TrustZone 启用时,我们可以在连续闪存空间的高半部分分配一个非安全区域,起始地址为 0x08040000。当闪存分为两个银行时,每个银行都可以配置其自己的独立安全水印。起始/结束地址之间的闪存区域被标记为安全,而所有未标记的区域都是非安全的。每个银行中的安全区域由选项字节 SECWMx_PSTRT 和 SECWMx_PEND 的值界定。如果界定符重叠——即 SECWMx_PEND 的值大于 SECWMx_PSTRT 的值——则整个区域被标记为非安全。
他们的值可以使用提供的程序员工具进行修改,如下所示:
STM32_Programmer_CLI -c port=swd -ob SECWM1_PSTRT=0
SECWM1_PEND=0x39
在单银行模式下,每个闪存扇区为 4096 B。通过设置这些选项字节,我们将前 64 个扇区(从 0x00 到 0x39)标记为安全,这留下了从地址 0x08040000 开始的闪存另一半,供我们的示例中的非安全应用程序使用。使用 -ob displ 选项启动的程序员工具将显示以下内容:
Secure Area 1:
SECWM1_PSTRT : 0x0 (0x8000000)
SECWM1_PEND : 0x39 (0x8039000)
GTZC 配置和基于块的 SRAM 保护
在参考平台的 TrustZone 控制器中存在一个用于控制访问的额外门。GTZC 的基于块的门组件允许我们配置仅安全位到 SRAM 的部分。STM32L552 上的 SRAM 分为两个主要银行:
-
SRAM1:192 KB 的 RAM 映射到地址
0x08000000 -
SRAM2:64 KB 的 RAM 映射到地址
0x30000000并在 IDAU 中设置为 NSC
在我们的示例中,我们将 SRAM1 的高半部分标记为非安全,起始地址为 0x2018000。为此,GTZC 提供了两套寄存器,每套寄存器对应一个银行,以配置基于块的门到 RAM 的每一页。每个块代表 25 个 6B,每个 32 位寄存器通过每个块保留一个安全位,可以映射 32 页,也定义为 8 KB 的超级块。需要 24 个寄存器来映射 SRAM1 中的 24 个超级块,总共 192 KB,而映射 SRAM2 中的 64 KB 区域只需要 8 个。
就像 SAU 初始化一样,示例中采用的方法再次依赖于一个方便的宏,该宏给定一个内存区、超级块编号及其寄存器值,计算指向超级块的正确寄存器的地址,并生成正确的赋值语句:
#define SET_GTZC_MPCBBx_S_VCTR(bank,n,val) \
(*((volatile uint32_t *)(GTZC_MPCBB##bank##_S_VCTR_BASE )\
+ n ))= val
这样我们就可以轻松地在循环中配置连续区域的基于块的门。安全世界应用程序示例使用以下函数来配置两个银行的基于块的门:
static void gtzc_init(void)
{
int i;
/* Configure lower half of SRAM1 as secure */
for (i = 0; i < 12; i++) {
SET_GTZC_MPCBBx_S_VCTR(1, i, 0xFFFFFFFF);
}
/* Configure upper half of SRAM1 as non-secure */
for (i = 12; i < 24; i++) {
SET_GTZC_MPCBBx_S_VCTR(1, i, 0x0);
}
/* Configure SRAM2 as secure */
for (i = 0; i < 8; i++) {
SET_GTZC_MPCBBx_S_VCTR(2, i, 0xFFFFFFFF);
}
}
现在我们已经拥有了在系统上运行最简单的非安全应用程序所需的一切;我们在 SAU 中定义了非安全区域,设置了闪存分离的水印,并最终设置了基于块的门以启用对 SRAM1 高半部分的非安全访问。
然而,还有一个值得注意的方面,那就是配置对外围设备进行安全访问的可能性。
配置对外围设备的安全访问
在参考平台上,外围设备分为两类:
-
可安全配置的外围设备:外围设备不是直接连接到本地总线,而是通过由TrustZone 安全控制器(TZSC)控制的门系统连接
-
信任区域感知的外围设备:这些是与 TrustZone 机制集成的外围设备——例如,通过提供根据执行域提供单独接口来访问其资源
对于第一类外围设备,在安全和非安全域内配置安全访问和特权访问可以通过 GTZC 中的 TZSC 寄存器进行。在系统启动时,所有设备默认设置为安全,因此要启用对 UART、I2C、定时器和其他外围设备的访问,需要关闭与特定控制器关联的安全位。
信任区域感知的外围设备在安全域和非安全域都有寄存器银行。在下一个示例中,我们配置了三个 GPIO 控制器(GPIOA、GPIOB和GPIOC),这些控制器通过引脚 C7(绿色 LED)、B7(蓝色 LED)和 A9(红色 LED)连接到 Nucleo-144 板上的 LED。当启用 TrustZone 时,GPIO 控制器寄存器被分入两个区域。你会在示例代码中注意到安全和非安全应用程序中两个 LED 驱动接口之间的差异。在led.h的安全版本中,我们为 GPIO 控制器寄存器定义了以下地址基:
#define GPIOA_BASE 0x52020000
#define GPIOB_BASE 0x52020400
#define GPIOC_BASE 0x52020800
在非安全世界应用程序中,相同的控制器映射到非安全外围设备地址空间:
#define GPIOA_BASE 0x42020000
#define GPIOB_BASE 0x42020400
#define GPIOC_BASE 0x42020800
这确保了当在非安全域运行时,GPIO 配置只能通过分配给非安全空间的接口访问。
此外,每个 GPIO 控制器都提供了一个接口来安全地控制每个单个受控引脚。这是通过一个只写寄存器实现的,该寄存器控制安全和非安全访问,并带有对应每个引脚的标志。该寄存器称为 GPIOx_SECCFG,位于每个 GPIO 控制器空间的0x24偏移处。当在安全域运行时,该寄存器仅可写入。
在示例中,我们定义了设置/清除每个连接到三个 LED 的 GPIO 引脚的安全位的函数。例如,我们可以在将非安全应用程序部署到舞台之前设置红色 LED 的安全状态,通过调用red_led_secure(1)来禁止在应用程序中更改 LED 状态,该函数的实现如下:
void red_led_secure(int onoff)
{
if (onoff)
GPIOA_SECCFG |= (1 << RED_LED);
else
GPIOA_SECCFG &= ~(1 << RED_LED);
}
我们的安全世界示例应用程序实际上在部署之前限制了蓝色和红色 LED 的访问,同时允许访问绿色 LED:
red_led_secure(1);
green_led_secure(0);
blue_led_secure(1);
在域切换之后,非安全应用将尝试开启所有三个 LED 灯,但实际上只有绿色 LED 灯会被点亮,其他 LED 灯将保持关闭状态,因为通过非安全接口的访问受到在安全世界中设置的 SECCFG 位的控制,并且对 GPIO 没有影响。
然而,闪烁蓝色 LED 仍然将使用一个特殊的非安全可调用接口来完成,这将在下一节“跨域转换”小节中解释。
在配置完所有可安全化和 TrustZone 感知的外设之后,我们最终准备好构建和安装两个域的固件镜像,并观察它们对系统的影响。
构建和运行示例
最后,我们将所学到的关于 TrustZone-M 的所有知识付诸实践,通过激活启用 TrustZone-M 所需的选项标志,并运行与执行域相关的两个软件组件。
启用 TrustZone-M
默认情况下,当我们的微控制器处于出厂状态时,TrustZone-M 是关闭的。开启 TrustZone 是一个单向操作,但通常不是不可逆的,除非结合其他硬件辅助保护机制,使得在嵌入式系统部署时无法禁用它。然而,一旦启用,禁用 TrustZone 需要比仅仅清除寄存器中的一个位更复杂的程序。
重要提示
请参考您的微控制器参考手册和应用笔记,并确保您理解启用或尝试禁用 TrustZone-M 在您的设备上的程序及其后果。
在参考平台上,为了启用 TrustZone,我们通过以下命令在选项字节中设置相关的标志:
STM32_Programmer_CLI -c port=swd mode=hotplug -ob TZEN=1
一旦 TrustZone 被启用,我们就可以构建和安装安全固件。下一小节将突出构建系统安全部分时需要考虑的一些重要方面。
安全应用入口点
在安全世界链接脚本中定义的区域反映了安全固件所看到的系统资源。我们分配了一个覆盖 SRAM1 银行下半部分的 RAM 区域:
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 0x00018000
我们.text和.data LMS 最终位于 FLASH 区域,映射到其安全域地址:
FLASH (rx) : ORIGIN = 0x0C000000, LENGTH = 0x1000
在我们的简单示例中,4 KB 足以存储引导加载程序镜像。此外,我们定义了一个非安全可调用区域,该区域将包含我们安全 stub 的实现。这是一个专门用于从非安全世界通过预定义的跨域特殊功能调用来访问安全 API 的区域:
FLASH_NSC(rx): ORIGIN = 0x0C001000, LENGTH = 0x1000
参考平台上的安全应用入口点硬编码在选项字节中。在安装我们的镜像之前,我们必须确保SECBOOTADD0的选项字节配置为指向地址0x0C000000,这是安全系统视图下闪存的开始地址。如果由于任何原因该值已被修改,可以通过以下命令恢复:
STM32_Programmer_CLI -c port=swd mode=hotplug -ob SECBOOTADD0=0x180000
这是因为SECBOOTADD0的粒度是 128 字节,所以设置0x180000的值将导致指向地址0x0C000000的指针。
最后这个值完成了选项字节的设置,因此我们最终准备好构建和安装安全应用程序。
一份选项字节及其值的列表,按顺序分配以配置示例代码的目标运行,可在本书的存储库中找到,在Chapter11/option-bytes.txt文件中。
编译和链接安全世界应用程序
如果你查看安全世界应用程序的 Makefile,你将注意到在构建过程中引入了两个新的标志。gcc 要求我们使用-mcmse标志来指示我们正在为 TrustZone 系统编译安全代码。通过添加此标志,我们告诉编译器生成nsc_led.c文件:
void __attribute__((cmse_nonsecure_entry))
nsc_blue_led_toggle(void)
{
if ((GPIOB_ODR & (1 << BLUE_LED)) == (1 << BLUE_LED))
blue_led_off();
else
blue_led_on();
}
__attribute__((cmse_nonsecure_entry))编译器属性告诉 gcc 为该函数生成 SG 存根。我们在链接器脚本中定义的FLASH_NSC部分用于存储我们配置的安全 API 的 SG 存根。SG 存根自动放置在名为.gnu.sgstubs的部分中,我们在示例链接器脚本中将它放置在FLASH_NSC区域:
.gnu.sgstubs :
{
. = ALIGN(4);
*(.gnu.sgstubs*) /* Secure Gateway stubs */
. = ALIGN(4);
} >FLASH_NSC
额外的链接器标志--cmse-implib和--out-implib=led_cmse.o具有不同的目的,这不会直接影响安全域。当链接安全应用程序时,通过添加这些标志,我们要求链接器创建一个新的目标文件,这个文件将不会链接到最终的安全应用程序中。这个新的目标文件将链接到非安全世界应用程序中,并包含安全 API 的封装。这些封装准备从非安全到非安全可调用世界的跳转。换句话说,这个新的文件led_cmse.o是非安全世界对应的安全调用通过非安全可调用 SG 存根的实现。这些封装由链接器生成,并包含跳转到非安全可调用存根所需的代码。总结一下,为了构建安全应用程序,我们需要引入两组特定的标志:
-
–mcmse编译时标志,它告诉 gcc 我们正在为 TrustZone 生成安全代码,并为非安全入口点启用 SG 存根 -
–cmse-implib和–out-implib=…链接器标志,它们告诉链接器在目标文件格式中生成封装,这些封装随后将链接到非安全域以访问相关的安全 API 调用
一旦使用make构建,可以使用以下命令将安全固件映像上传到设备闪存:
STM32_Programmer_CLI -c port=swd -d bootloader.bin 0x0C000000
微控制器的闪存现在已填充了安全固件,我们的增强型引导加载程序,它已准备好设置 TrustZone 控制器中的所有参数并准备非安全应用程序。显然的下一步是编译和安装非安全世界的对应版本。
编译和链接非安全应用程序
我们非安全应用的链接脚本定义了从非安全执行域看世界的边界。安全和 NSC 区域从这里无法访问。我们对闪存的看法仅限于其上半部分,可访问的 RAM 仅限于 SRAM1 银行的下半部分。非安全应用中的target.ld链接脚本如下定义这些区域:
FLASH (rx) : ORIGIN = 0x08040000, LENGTH = 256K
RAM (rwx) : ORIGIN = 0x20018000, LENGTH = 96K
从这一点开始,构建过程类似于构建没有 TrustZone 支持的正常应用。与它的安全对应物不同,非安全应用不需要任何特殊的编译器或链接器标志。
值得注意的是一个例外,即安全应用构建过程中生成的额外目标文件,允许非安全应用短暂地与安全世界交互。安全域和非安全域之间的合同由安全世界定义的安全 API 组成。在我们的例子中,我们只定义了一个单一的 secure 函数,nsc_blue_led_toggle。包含封装器(在我们的例子中称为cmse_led.o)的对象文件,在编译安全域代码时自动生成,并在非安全应用中链接,实际上它是满足安全应用中这些特殊符号符号依赖的代码。我们将在下一小节跨域转换中探讨此过程的细节。
一旦通过运行make构建了非安全应用,我们将非安全固件镜像上传到目标设备的内部闪存中,起始地址为0x08040000:
STM32_Programmer_CLI -c port=swd -d image.bin 0x08040000
现在我们将更详细地研究从安全域到非安全域以及相反方向的转换,以了解新的 ARMv8-M 指令如何在转换操作中发挥作用,以及在这些情况下应该如何使用。
跨域转换
当我们的安全世界示例引导加载程序准备阶段非安全世界应用时,我们可以在准备 CPU 寄存器和执行跳转到非安全域的汇编代码中注意到一些差异。首先,当启用 TrustZone 时,VTOR系统寄存器被分页。这意味着有两个独立的寄存器分别保存向量表的偏移量,每个执行域一个——VTOR_S 和 VTOR_NS,分别对应安全域和非安全域。在跳转到非安全世界代码的入口点之前,VTOR_NS 寄存器应包含非安全世界应用的中断向量偏移量。正如我们所知,IV 位于二进制图像的起始处,因此引导加载程序main过程中的以下赋值确保最终我们的非安全域代码能够执行中断服务例程:
/* Update IV */
VTOR_NS = ((uint32_t)app_IV);
在设置此系统寄存器之后,我们获取部署所需的两个重要指针,类似于我们为没有 TrustZone-M 功能的引导加载程序(如第四章中提出的)所做的那样,启动过程。这些指针存储在非安全应用二进制映像的前两个 32 位字中,分别是初始堆栈指针和包含isr_reset处理程序地址的实际入口点。在部署之前,我们将这两个地址读入局部堆栈变量中:
app_end_stack =
(*((uint32_t *)(NS_WORLD_ENTRY_ADDRESS)));
app_entry =
(void *)(*((uint32_t *)(NS_WORLD_ENTRY_ADDRESS + 4)));
在我们的示例中,我们提前为非安全应用确定堆栈区域的大小,如下计算允许的堆栈最低地址:
app_stack_limit = app_end_stack - MAX_NS_STACK_SIZE;
然后,我们将此值赋给 MSPLIM_NS 寄存器。MSPLIM_NS 是一个特殊寄存器,因此,像往常一样,我们必须使用msr指令:
asm volatile("msr msplim_ns, %0" ::"r"(app_stack_limit));
然后,我们设置新堆栈指针的值,一旦域转换完成,它将替换 SP:
asm volatile("msr msp_ns, %0" ::"r"(app_end_stack));
实际跳转到非安全代码的地方与我们在第四章中介绍的先前引导加载程序有很大的不同,启动过程。首先,我们必须确保跳转的地址调整以符合 ARMv8 转换中使用的约定。我们从二进制映像中读取到app_entry局部变量的值实际上是奇数,这是在相同域内跳转时给 PC 寄存器分配新值时的经典要求——例如,在 ARMv7-M 中使用mov pc, ...指令,如第四章中示例引导加载程序中的那样。在 ARMv8-M 中,同时执行跳转和域转换到非安全世界的指令是blxns。然而,在调用blxns或任何暗示跳转到非安全地址的指令时,我们必须确保跳转的目标地址的最低有效位被关闭。因此,在执行blxns之前,我们将app_entry的值减一:
/* Jump to non-secure app_entry */
asm volatile("mov r12, %0" ::"r"
((uint32_t)app_entry - 1));
asm volatile("blxns r12" );
这是在最终部署我们的非安全应用之前,在安全域中执行的最后一个指令。如果我们使用调试器在执行这些最后指令时检查寄存器的值,我们可以看到 CPU 寄存器的值正在更新,然后最终,SP 寄存器将指向非安全域中的新上下文。
从这一点开始,任何尝试跳回安全域的行为当然是不允许的,并且将生成一个异常。然而,正如我们之前提到的,放置在 NSC 区域中的函数的目的是为了从非安全域提供临时和受控的安全函数执行。
在我们的示例中,在过渡到非安全执行域之前,我们通过设置GPIOx_SECCFG寄存器中的相应位,对与三个 LED 相关的 GPIO 线的访问施加了一些限制,正如本章之前在配置对外设的安全访问小节中解释的那样。
当示例的两个映像都上传到目标平台时,我们可以通过观察三个 LED 来观察电源循环和效果。重启后,我们应该看到启动时打开并保持开启状态的红 LED,同时安全代码在引导加载程序中运行。经过任意数量的循环旋转后,给我们足够的时间检查 LED 状态,红 LED 将关闭并处于安全状态。蓝色 LED 也通过在部署前执行的blue_led_secure(1)调用而处于安全状态。绿色 LED 未处于安全状态,可以在非安全域中正常访问。
当非安全应用启动时,我们可以看到绿色 LED 常亮,而蓝色 LED 快速闪烁。后者之所以可能,是因为非安全应用可以访问安全 API 内的一个功能。
我们可以通过在安全世界的elf文件上运行arm-none-eabi-objdump –D来查看此函数生成的汇编代码。我们立即注意到生成的非安全可调用函数桩实际上是一个放置在非可调用部分开头的简短过程:
0c001000 <nsc_blue_led_toggle>:
c001000: e97f e97f sg
c001004: f7ff bdd2 b.w c000bac
<__acle_se_nsc_blue_led_toggle>
在 NSC 区域运行的代码中最有趣的部分是使用特殊的汇编指令sg,这是 ARMv8-M 中引入的新指令,其特定目的是从非安全域实现安全调用。此指令准备将分支到安全空间中的安全调用,并且仅在从非安全可调用区域执行时才是合法的。
此外,请注意,实际实现实际上包含在由编译器生成的__acle_se_nsc_blue_led_toggle函数中,并放置在闪存的 S 区域。
通过将生成的对象包含在最终映像中并以相同方式反汇编非安全应用,生成的nsc_blue_led_toggle覆盖层的汇编代码应该看起来像以下这样:
080408e8 <__nsc_blue_led_toggle_veneer>:
80408e8: f85f f000 ldr.w pc, [pc] ; 80408ec \
<__nsc_blue_led_toggle_veneer+0x4>
80408ec: 0c001001
从非安全域调用安全函数的过程的结论位于实际安全函数切换蓝色 LED 的实现尾部,即__acle_se_nsc_blue_led_toggle:
c000bee: 4774 bxns lr
这应该对我们来说已经很熟悉了,因为它实际上是之前见过的bxlns指令的非链接版本,它执行跳转到存储在链接寄存器中的非安全覆盖层的返回地址,同时返回到非安全域。以下清单是本章示例中当从 NS 执行域调用安全函数时涉及步骤的回顾:
-
非安全世界代码调用
nsc_blue_led_toggle的包装器,该包装器在编译安全代码时实现,并在链接到非安全应用程序的cmse_leds.o对象中。 -
包装器知道 NSC 区域中的 SG 占位符位置。这个区域可以从安全世界执行,同时被放置在安全固件中的特定区域。然后包装器继续跳转到 SG 占位符。
-
SG 占位符调用
sg指令,启动到安全世界的转换,然后跳转到实际实现,__acle_se_nsc_blue_led_toggle。现在它在安全域中执行,执行请求的操作(在我们的例子中,这是切换连接到蓝色 LED 的 GPIO 线的值)。 -
当程序终止时,安全功能通过使用
bxns指令返回到非安全世界,同时跳转回非安全世界原始调用者的地址。
尽管很简单,我们的例子展示了如何配置和使用所有需要的特性来分离两个执行域,以及用于实现两个世界之间交互的机制。在安全域中这些交互的设计将决定提供给非安全应用程序的能力。转换的边界和接口就像两部分之间的合同,由硬件本身强制执行,多亏了 TrustZone-M。
摘要
ARMv8-M 是 ARM 为现代微控制器定义的最新架构。它通过集成几个新功能扩展并完善了其前身 ARMv7-M 的能力。对于这种新型架构设计最重要的改进是实现 TEE 的可能性,通过分离执行域并创建一个沙盒环境来执行非安全应用程序。
在实际场景中,这为不同提供者的应用程序部署提供了灵活性,这些应用程序在访问系统上的功能和资源方面具有不同的信任级别。
在最后一章中,我们分析了 TrustZone-M 技术中可用的机制。TrustZone-M 可以在 ARMv8-M 系统上激活,目的是集成一个强大的、硬件辅助的解决方案,旨在保护系统组件免受安全域中运行的系统监督组件未明确授权的任何访问。


浙公网安备 33010602011771号