RTOS-微控制器实用指南-全-
RTOS 微控制器实用指南(全)
原文:
zh.annas-archive.org/md5/e3a62828dee48c0db69e5e6b6eecb930译者:飞龙
前言
这本实践指南将为您提供将实时操作系统(RTOS)在微控制器单元(MCU)上启动和运行所需的最重要功能知识。如果您对使用实际硬件的动手示例学习如何实现 RTOS 应用感兴趣,并讨论常见的性能与开发时间权衡,那么您就来到了正确的位置!
我们将使用 FreeRTOS 内核实现代码,使用流行的 STM32 ARM MCU 和低成本的 STM Nucleo 开发板进行操作,并使用 SEGGER 调试工具进行代码调试/分析。本书中使用的所有工具都已被选择,因为它们对业余爱好者或刚开始的专业人士来说易于获取,同时也因为它们在现实世界的专业团队中的普及。通过阅读本书并完成示例,您获得的知识和经验将直接应用于实际开发环境。
本书面向对象
RTOS 编程不是初学者的主题,绝对不是学习嵌入式系统的正确起点。如果您对 MCU 或 C 语言一无所知,那么您最好先从基础知识开始,并在深入研究这个更高级的主题之前获得一些实际操作经验。
那么,谁将从阅读本书中受益最大?
专业程序员:您一直在裸机(无操作系统)上进行编程,并希望通过学习如何使用实时操作系统(RTOS)来满足严格的时序要求、平衡并发操作以及创建模块化代码来提高您的微控制器(MCU)编程技能。
对“亲自动手”感兴趣的学生:您一直在学习理论、听讲座和编写实验室练习代码,但现在您正在寻找一本完整的指南,帮助您开始接触并与之互动的实物。
转向更高级主题的创客:您已经编写了一些草图或脚本,但您正在寻找下一个挑战。也许您想从头开始创建一个基于 MCU 的全系统 – 这里的信息将帮助您在编程方面走上正轨。您甚至还会得到一些关于在选择项目 MCU 时应该寻找什么的提示。
本书涵盖内容
本书共分为四个部分,包含 17 章。如果您已经熟悉其中的一些内容,不需要从头到尾阅读本书。例如,如果您已经熟悉基本 RTOS 概念和实时系统,可以自由跳转到第四章,选择合适的 MCU。以下是本书包含的章节简要描述:
第一章,介绍实时系统,是对实时操作系统(RTOS)是什么以及何时以及为什么使用它的简单介绍。还讨论了基于 MCU 的 RTOS 的硬件和软件替代方案。
第二章,理解 RTOS 任务,提供了超级循环与 RTOS 任务之间的比较,包括使用这两种方法实现并行操作的各种方式。
第三章,任务信号和通信机制,是对更多 RTOS 概念的简要介绍,包含大量图表。本章以及第二章理解 RTOS 任务,应作为参考和快速复习概念和术语的有用资源,以防万一需要。
第四章,选择合适的 MCU,帮助你了解在选择 MCU 时应考虑哪些因素。在了解硬件和固件之间的相互依赖性之后,我们探讨了为什么硬件和固件工程师都参与系统设计如此重要。
第五章,选择 IDE,介绍了并讨论了各种类型的集成开发环境(IDE),包括为什么你可能决定选择一个而不是另一个(或者一个都不选)。如何设置 STM32CubeIDE 和导入示例代码的说明也包含在此处。
第六章,实时系统调试工具,涵盖了调试嵌入式系统的工具,包括本书剩余部分我们将使用的调试工具——SEGGER Ozone 和 SEGGER SystemView 可视化软件。如何使用 Ozone 和 SystemView 的说明也包含在此处。还包括基于硬件的测试设备以及一些对嵌入式系统开发工作流程有用的工具。
第七章,FreeRTOS 调度器,教授你使用 FreeRTOS 创建任务的各种方法以及如何排除启动故障。你将了解任务状态以及性能优化的不同方式。
第八章,保护数据和同步任务,涵盖了使用信号量进行任务同步以及使用互斥锁进行数据保护,以及如何避免竞争条件和优先级反转。还涵盖了软件定时器。
第九章,任务间通信,探讨了在任务之间传递信息的不同方法,提供了使用队列通过值和引用传递信息的不同示例,讨论了两种方法的优点和考虑因素。我们还将了解一种轻量级任务间通信机制,即直接任务通知,包括任务通知和队列的比较。
第十章,驱动程序和中断服务例程,深入探讨了如何使用各种 FreeRTOS 原语(包括信号量、队列和流缓冲区)实现高效的驱动程序的一些详细示例。我们还将探讨如何结合 MCU 硬件(如 DMA)使用 FreeRTOS,以提供极高效的驱动程序实现。本章直接与 MCU 的外设寄存器以及 STM32 HAL 代码一起工作。
第十一章,在任务间共享硬件外设,教你如何创建可以在多个任务中安全使用并共享硬件资源的驱动程序。我们将调整 STM 提供的 USB CDC 实现,使其更易于用户使用和更高效,通过互斥锁和队列包装,使其可以在多个任务中安全使用。
第十二章,创建良好抽象架构的技巧,涵盖了代码的可重用性、灵活性和硬件可移植性,着眼于创建使你的工作更轻松的抽象。还提供了一些关于源代码组织以帮助促进重用的建议。
第十三章,使用队列创建松散耦合,是书中涵盖的所有概念的总结。它包括一个完整的示例,展示了用于创建正确抽象的端到端应用的松散耦合架构。我们将使用之前开发的 USB CDC 虚拟通信端口以及 LED 抽象,使用命令队列创建一个松散耦合、完全可重用的 LED 序列器。这个嵌入式应用程序可以通过一个用 Python 编写的跨平台 UI 从 PC 上控制。
第十四章,选择 RTOS API,继续我们关于高级架构的讨论,探讨可用于访问 FreeRTOS 功能的三种不同的 API:本机 FreeRTOS API、ARM 的 CMSIS-RTOS 和 POSIX。讨论主题包括对可用功能的比较以及为什么你可能选择其中之一用于不同的项目。
第十五章,FreeRTOS 内存管理,仔细研究了 FreeRTOS 中内存管理的几种不同选项。我们将探讨静态分配与动态分配,以及使用内存保护单元(MPU)。
第十六章,多处理器和多核系统,教您多处理器和多核系统为何被用于各种原因——了解它们是什么以及如何让系统的不同部分进行通信。
第十七章,故障排除技巧和下一步行动,涵盖了系统故障排除的技巧,包括如何避免堆栈溢出以及如何故障排除挂起的系统。还提供了一些下一步行动的建议。
为了充分利用本书
为了使本书中的示例尽可能容易地为非常广泛的人群工作,已经做出了所有努力。为了充分利用本书(通过完成示例),您需要以下硬件:
-
具有互联网访问的 Windows、macOS 或 Linux PC
-
STM32 Nucleo-F767ZI 开发板
-
两条 Micro-USB 线。
-
跳线—20 到 22 AWG (~0.65 mm)实心线。
不同工具的详细设置说明包含在各章节中。
如果您使用的是本书的数字版,我们建议您自己输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。
由于本书针对的是编程低级嵌入式系统,我们将使用 C 作为首选语言。假设您对微控制器有一定的了解,以及阅读数据表的能力。如果您对 C 语言(或 C++)有很好的理解,那么您应该能够轻松阅读本书——不需要先前的 RTOS 知识。由于我们将与嵌入式系统中的 MCU 一起工作,偶尔也会讨论硬件方面的内容,主要涉及 MCU 和开发板的功能。这些主题将详细说明,以便即使硬件知识有限的人也能轻松理解。您应该能够舒适地与处理开发硬件互动,尽管不需要实际的汇编。
下载示例代码文件。
您可以从github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择“支持”标签。
-
点击“代码下载”。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载完成后,请确保您使用最新版本解压缩或提取文件夹,例如:
-
Windows 的 WinRAR/7-Zip
-
Mac 的 Zipeg/iZip/UnRarX
-
7-Zip/PeaZip for Linux
本书的相关代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/上找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781838826734_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:"func1() 负责读取传感器的值并将其存储在
sensorReadings数组"
代码块设置如下:
void func1( int16_t calOffset)
{
int16_t tempValue;
tempValue = readSensor();
tempValue = tempValue + calOffset;
sensorReadings[0] = tempValue;
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
/* ADC Config */
hnucleo_Adc.Instance = NUCLEO_ADCx;
/* (ClockPrescaler must not exceed 36MHz) */
hnucleo_Adc.Init.ClockPrescaler = ADC_CLOCKPRESCALER_PCLK_DIV4;
hnucleo_Adc.Init.Resolution = ADC_RESOLUTION12b;
hnucleo_Adc.Init.DataAlign = ADC_DATAALIGN_RIGHT;
hnucleo_Adc.Init.ContinuousConvMode = DISABLE;
hnucleo_Adc.Init.DiscontinuousConvMode = DISABLE;
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中如下所示。以下是一个示例:“从管理面板中选择 System info。”
警告或重要注意事项如下所示。
技巧和窍门如下所示。
联系我们
我们始终欢迎读者的反馈。
总体反馈:如果您对本书的任何方面有任何疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com给我们发送邮件。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/support/errata,选择您的书,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评论
请留下您的评价。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,而我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问 packt.com.
第一部分:引言和实时操作系统(RTOS)概念
什么是实时系统,以及构成实时操作系统(RTOS)的主要组件是什么?这些问题我们将在这本书的第一节中解答。这些先验知识将成为我们后续章节中通过实际示例和动手练习建立的基础。如果你已经熟悉其他 RTOS,你可能可以略读或跳过这一节。
本节包括以下章节:
-
第一章, 介绍实时系统
-
第二章,理解 RTOS 任务
-
第三章,任务信号和通信机制
第一章:介绍实时系统
实时系统有各种各样的实现和使用案例。本书侧重于如何使用实时操作系统(RTOS)在微控制器单元(MCU)上创建实时应用程序。
在本章中,我们将从 RTOS(实时操作系统)的概述开始,了解具有实时要求的广泛系统。从那里,我们将探讨实现实时性能的不同方法,以及可能使用的系统类型(如硬件、固件和软件)的概述。我们将通过讨论在 MCU 应用中使用 RTOS 何时是可取的,以及何时可能根本不必要的来结束本章。
简而言之,在本章中我们将涵盖以下主题:
-
那么,“实时”究竟是什么呢?
-
定义 RTOS
-
决定何时使用 RTOS
技术要求
本章没有软件或硬件要求。
那么,什么是“实时”呢?
任何对给定事件有确定响应的系统都可以被认为是“实时”的。如果一个系统在未满足时间要求时被认为是失败的,那么它必须是实时的。失败的定义(以及系统失败的影响)可能会有很大的不同。认识到实时要求可能会有很大的变化,无论是时间要求的速度还是未满足所需实时截止日期的严重后果,这一点极为重要。
时间要求范围
为了说明可能遇到的定时要求范围,让我们考虑几个不同的系统,这些系统从模拟-数字转换器(ADCs)获取读数。
我们将首先研究的是一个控制温度的烙铁(如下图表所示)的控制系统。我们关注的系统部分是 MCU、ADC、传感器和加热器。
MCU 负责以下工作:
-
通过 ADC 从温度传感器读取数据
-
运行闭环控制算法(以保持烙铁尖端恒定温度)
-
根据需要调整加热器的输出
这些可以在以下图表中看到:

由于烙铁尖端的温度变化并不快,MCU 可能只需要每秒获取 50 个 ADC 样本(50 Hz)。负责调整加热器(以保持恒定温度)的控制算法运行速度更慢,为 5 Hz:

ADC 将激活一个硬件线路,表示转换已完成,并准备好将读数传输到 MCU 的内部存储。读取 ADC 的 MCU 有最多 20 毫秒的时间将数据从 ADC 传输到内部存储,然后需要取新的读数(如以下图所示)。MCU 还需要运行控制算法,以计算加热器输出的更新值,频率为 5Hz(200 毫秒)。这两个案例(尽管不是特别快)都是实时要求的例子:

现在,在 ADC 读取频谱的另一端,我们可能有一个高带宽网络分析仪或示波器,它将以每秒数十吉赫兹的速率读取 ADC!原始的 ADC 读数很可能会被转换到频域,并以每秒数十次的速度在高清前面板上图形化显示。这样的系统需要执行大量的处理,并且必须严格遵守极严格的时序要求,才能正常工作。
在频谱的中间部分,你会找到如闭环运动控制器这样的系统,这些系统通常需要在数百 Hz 到数十 kHz 之间执行其 PID 控制回路,以便在快速移动的系统提供稳定性。那么,实时有多快呢?好吧,正如你仅从 ADC 的例子中就能看到的,这取决于。
在一些先前的情况中,例如示波器或烙铁,未能满足时序要求会导致性能不佳或报告错误数据。在烙铁的情况下,这可能是温度控制不佳(这可能会损坏组件)。对于测试设备,错过截止日期可能会导致错误的读数,这是一种失败。对于一些人来说,这可能不是什么大问题,但对于依赖报告数据准确性的设备用户来说,这可能是非常重要的。一些用于标准验证的实验室设备提供了产品符合性的检查。如果设备中存在未检测到的故障,导致测量不准确,可能会报告错误值。可能可以重新运行可疑的测试。然而,如果需要频繁重新测试,并且无法保证可靠的读数,那么测试设备将开始变得可疑,被视为不可靠,销量将下降——所有这一切都是因为未能持续满足实时要求。
在其他系统中,例如无人机飞行控制或工业过程控制中的运动控制,未能及时运行控制算法可能会导致更严重的物理灾难,例如坠毁。在这种情况下,后果可能是致命的。
幸运的是,可以采取一些步骤来避免所有这些故障场景。
保证实时行为的方法
确保系统按预期工作的一种最简单的方法是确保它在满足要求的同时尽可能简单。这意味着抵制过度复杂化简单任务的冲动。如果烤面包机是用来烤一片面包的,那么不要在上面安装显示屏并让它告诉你天气;只需让它打开加热元件适当的时间即可。这个简单的任务多年来一直无需任何代码或可编程设备就能完成。
作为程序员,如果我们遇到一个问题,我们往往会立即伸手去拿最近的微控制器单元并开始编码。然而,某些产品的功能(尤其是如果产品具有机电组件)最好在不使用代码的情况下处理。汽车窗户实际上不需要带有轮询循环的微控制器来运行,通过驱动器打开电机,并监视传感器以获取反馈来关闭它们。这项任务实际上可以通过几个机械开关和二极管来处理。如果需要为给定的系统提供反馈报告机制——例如,在窗户卡住的情况下需要断言的错误——那么可能别无选择,只能使用更复杂的解决方案。然而,作为工程师,我们的目标始终应该是相同的——尽可能简单地解决问题,不要增加额外的复杂性。
如果一个问题可以通过硬件独立解决,那么首先与团队一起探索这个可能性,然后再考虑使用微控制器单元(MCU)。如果一个问题可以通过使用简单的 while 循环来执行一些传感器状态的轮询来处理,那么只需轮询传感器的状态即可;可能没有必要开始编写 中断服务例程(ISRs)。如果设备的功能是单一用途的,那么在许多情况下,一个完整的实时操作系统(RTOS)可能会起到反作用——所以不要使用它!
实时系统的类型
实现实时行为有许多不同的方法。以下部分是关于你可能会遇到的各种实时系统的讨论。此外,请注意,以下系统可以作为子系统一起工作。这些不同的子系统可以在产品、板或甚至芯片级别出现(这种方法在第十六章多处理器和多核系统)中讨论)。
硬件
原始的实时系统,硬件,仍然是对于极严格公差和/或快速时序要求的首选。它可以采用离散数字逻辑、模拟组件、可编程逻辑或应用特定集成电路(ASIC)。可编程逻辑器件(PLDs)、复杂可编程逻辑器件(CPLDs)和现场可编程门阵列(FPGAs)是这个解决方案中可编程逻辑器件部分的各个成员。基于硬件的实时系统可以涵盖从模拟滤波器、闭环控制、简单的状态机到复杂的视频编解码器等任何内容。如果考虑到节能,ASIC 可以比基于 MCU 的解决方案消耗更少的电力。一般来说,硬件的优势在于并行执行操作和瞬间(当然,这是一个过于简化的说法),而单核 MCU 只能提供并行处理的假象。
实时硬件开发的缺点通常包括以下内容:
-
非可编程设备的僵化性。
-
所需的专业知识通常不如软件/固件开发者常见。
-
完全功能可编程设备(例如,大型 FPGAs)的成本。
-
开发定制 ASIC 的高成本。
纯硬件固件
纯硬件固件(就我们的目的而言)被认为是任何不是建立在某种预存在内核/调度器之上的固件。一些工程师更进一步,认为真正的纯硬件固件不能使用任何预存在的库(例如供应商提供的硬件抽象库)——这种观点也有一定的合理性。纯硬件实现的优势在于用户的代码对硬件的所有方面都有完全的控制。主循环代码执行被中断的唯一方式是如果发生中断。在这种情况下,其他任何东西要控制 CPU 的唯一方式是现有的中断服务例程(ISR)完成或发生另一个更高优先级的中断。
当需要执行的任务数量少且相对简单时,或者有一个单一的任务时,纯硬件固件解决方案表现优异。如果固件保持专注并遵循最佳实践,由于 ISR(或在某些情况下,ISR 的缺乏)之间的交互相对较少,确定性的性能通常容易测量和保证。在某些极端情况下,对于负载很重的 MCU(或 ROM/RAM 高度受限的 MCU),纯硬件是唯一的选择。
当裸机实现处理异步事件变得更加复杂时,它们开始与 RTOS 提供的功能重叠。需要记住的一个重要考虑因素是,通过使用 RTOS——而不是尝试自己构建线程安全的系统——您可以自动受益于 RTOS 提供商所进行的所有测试。您还将有机会使用具有事后诸葛亮能力的代码——今天可用的所有 RTOS 都已经存在了几年。作者一直在适应和添加功能,使它们对不同应用具有鲁棒性和灵活性。
基于 RTOS 的固件
在微控制器(MCU)上运行调度内核的固件是基于 RTOS 的固件。调度器的引入和一些 RTOS 原语使得任务可以在拥有处理器的感觉下运行(详细讨论见第二章,理解 RTOS 任务)。使用 RTOS 可以使系统在执行其他复杂任务的同时,对最重要的事件保持响应。
所有这些任务同时运行有一些缺点。共享数据的任务之间可能会出现相互依赖性——如果处理不当,依赖性会导致任务意外阻塞。尽管有处理这种情况的措施,但它确实增加了代码的复杂性。中断通常使用任务信号来尽快处理中断,并将尽可能多的处理推迟到任务中。如果处理得当,这种解决方案对于保持复杂系统响应性是极好的,尽管存在许多复杂的交互。然而,如果处理不当,这种设计范式可能导致更多的时间抖动和更少的确定性。
基于 RTOS 的软件
在包含内存管理单元(MMU)和中央处理单元(CPU)的完整操作系统上运行的软件被认为是基于实时操作系统(RTOS)的软件。采用这种方法实现的应用程序可能非常复杂,需要各种内部和外部系统之间进行许多不同的交互。使用完整操作系统的优点是它所附带的所有能力——包括硬件和软件。
在硬件方面,通常有更多的 CPU 核心可用,运行在更高的时钟频率上。可能有数 GB 的 RAM 和持久性内存可用。添加外围硬件可能就像添加一张卡一样简单(前提是有现成的驱动程序)。
在软件方面,有大量的开源和供应商专有解决方案用于网络堆栈、UI 开发、文件处理等。在所有这些能力和选项之下,内核的实现方式仍然确保关键任务不会无限期地被阻塞,这是传统操作系统所能实现的。正因为如此,获得确定性的性能仍然在掌握之中,就像 RTOS 固件一样。
精心打造的操作系统软件
与基于实时操作系统(RTOS)的软件类似,标准操作系统拥有开发者可能需要的所有库和功能。然而,它缺少的是对满足时序要求的严格关注。一般来说,使用传统操作系统实现的系统将具有更少的确定性行为(在安全关键情况下,没有任何行为是可以真正依赖的)。如果没有灾难性的后果,如果软实时要求宽松,如果截止日期不按时完成,标准操作系统仍然可以工作,只要在选择运行的软件栈及其资源使用上保持谨慎。带有PREEMPT_RT补丁的 Linux 内核就是这种实时系统的良好例子。
因此,现在我们已经概述了实现实时系统的所有选项,是时候定义当我们说 RTOS 时,具体是指基于微控制器(MCU)的 RTOS 了。
定义 RTOS
操作系统(如 Windows、Linux 和 macOS)被创建作为一种提供一致编程环境的方式,它抽象了底层硬件,使得编写和维护计算机程序变得更容易。它们为应用程序程序员提供了许多不同的原语(如线程和互斥锁),可以用来创建更复杂的行为。例如,可以创建一个多线程程序,它提供了对共享数据的受保护访问:

前面的应用程序并没有实现线程和互斥锁原语,它只是使用了它们。线程和互斥锁的实际实现由操作系统处理。这有几个优点:
-
应用程序代码更简单。
-
更易于理解——无论程序员是谁,都使用相同的原语,这使得理解不同人编写的代码变得更容易。
-
更好的硬件可移植性——在适当的预防措施下,代码可以在操作系统支持的任何硬件上运行而无需修改。
在前面的例子中,使用了一个互斥锁来确保一次只有一个线程可以访问共享数据。在通用操作系统中,每个线程都会无限期地等待互斥锁变为可用,然后才继续访问共享数据。这就是 RTOS 与通用操作系统不同的地方。在 RTOS 中,所有阻塞的系统调用都有时间限制。而不是无限期地等待互斥锁,RTOS 允许指定最大延迟。例如,如果线程 1 尝试获取互斥锁,在 100 毫秒或 1 秒后仍然没有获取到,它将继续等待互斥锁变为可用。
在实时操作系统(RTOS)的实现中,指定了等待互斥锁(Mutex)可用的最大时间。如果线程 1 指定必须在 100 毫秒内获取互斥锁,但在 101 毫秒后仍未收到互斥锁,线程 1 将收到一个通知,表明互斥锁没有及时获取。这个超时是为了帮助创建一个确定性系统。
任何提供以确定性方式执行给定代码的 OS 都可以被认为是实时操作系统。这种 RTOS 的定义涵盖了相当大数量的系统。
有一些特性往往区分了一个 RTOS 应用程序与另一个:不满足实时截止日期的频率和严重性。RTOS 应用程序的不同范围通常被归纳为三个类别——硬实时、稳定实时和软实时系统。
不要过于纠结于稳定和软实时系统之间的差异。这些术语的定义甚至在我们行业内都没有达成一致意见。真正重要的是,你要了解你系统的需求,并设计一个解决方案来满足它们!
如果一个故障会导致生命丧失或重大财产损失,那么该故障的严重性通常被认为是安全关键的。有一些硬实时系统与安全性无关。
硬实时系统
硬实时系统必须 100%地按时完成其截止日期。如果系统没有按时完成截止日期,那么它被认为已经失败。这并不一定意味着如果在一个硬实时系统中发生故障会伤害到人——只是如果它错过了一个截止日期,那么系统已经失败了。
硬实时系统的例子可以在医疗设备中找到,例如起搏器和具有极严格参数控制的控制系统。在起搏器的例子中,如果起搏器错过在正确时间点发放电脉冲的截止日期,它可能会杀死患者(这就是为什么起搏器被定义为安全关键系统)。
相比之下,如果一个计算机数控(CNC)铣床上的运动控制系统没有及时响应一个命令,它可能会将工具插入到正在加工的部件的错误部分,从而损坏它。在我们提到的这些情况下,一个故障导致了生命丧失,而另一个将一些金属变成了废料——但两者都是由一个错过截止日期的单个故障引起的。
稳定实时系统
与硬实时系统相反,稳定实时系统需要几乎每次都能按时完成其截止日期。如果视频和音频暂时失去同步,这可能不会被视为系统故障,但很可能会让视频的消费者感到不满。
在大多数控制系统中(类似于之前例子中的烙铁),一些稍微超出指定时间读取的样本不太可能完全破坏系统控制。如果一个控制系统有一个自动获取新样本的 ADC,如果 MCU 没有及时读取新样本,它将被新的一个覆盖。这种情况偶尔会发生,但如果它发生得太频繁,温度稳定性就会被破坏。在一个特别要求高的系统中,可能只需要错过几个样本,整个控制系统就会超出规格。
软实时系统
软实时系统在系统必须满足其截止日期的频率方面最为宽松。这些系统通常只提供尽力而为的承诺来保持截止日期。
汽车上的巡航控制是一个软实时系统的良好例子,因为它没有硬性规格或期望。驾驶员通常不会期望他们的速度收敛到设定速度的±x英里/小时/公里。他们期望在合理的情况下,例如没有大坡,控制系统最终会让他们接近他们期望的速度大多数时候。
RTOSes 的范围
RTOSes 的功能、架构以及它们最适合的处理器的大小各不相同。在较小的方面,我们有针对小型 8-32 位 MCU 的 RTOSes,如 FreeRTOS、Keil RTX、Micrium µC、ThreadX 以及更多。这类 RTOS 适用于微控制器,并提供一个紧凑的实时内核作为最基本的服务。当从 MCU 转向 32 位和 64 位应用处理器时,你往往会发现 RTOSes,如 Wind River VxWorks 和 Wind River Linux、Green Hills 的 Integrity OS,甚至带有PREEMPT_RT内核扩展的 Linux。这些完整的操作系统提供了大量的软件,为实时调度需求以及一般计算任务提供解决方案。即使是我们刚刚提到的操作系统,我们也只是触及了表面。在 RTOSes 的所有级别,无论是大是小,都有免费和付费的解决方案(一些成本超过 10,000 美元)。
那么,为什么你会选择为解决方案付费,当有免费的东西可用时呢?免费可用的 RTOS 解决方案和付费解决方案之间的主要区别因素是安全认证、中间件和客户支持。因为 RTOS 提供了一个高度确定的执行环境,它们通常用于复杂的安全关键应用。我们通常所说的 安全关键 是指一个系统,其故障可能会伤害人员或造成重大损害。这些系统需要确定性的操作,因为它们必须始终以可预测的方式行事。保证代码在固定时间内对事件做出响应是确保它们行为一致的重要步骤。大多数这些安全关键应用都受到监管,并有自己的监管机构和标准,例如飞机的 DO-178B 和 DO-178C 或工业应用的 IEC 61508 SIL 3 和 ISO 26262 ASILD。为了使安全关键认证更加经济实惠,设计人员通常会保持这些系统的代码极其简单(这样就可以从数学上证明系统将始终如一地运行,不会出错),或者将商业 RTOS 解决方案作为起点,这些解决方案已经通过了认证。WITTENSTEIN SafeRTOS 是 FreeRTOS 的一个分支,已经获得了工业、医疗和汽车使用的认证。
中间件也可以是复杂系统中极其重要的组成部分。中间件是介于 用户代码(即你,应用开发者编写的代码)和底层,例如实时操作系统(RTOS)或裸机(无 RTOS)之间的代码。付费解决方案的另一个价值主张是,生态系统提供了一套预先集成的、高质量的中件(例如文件系统、网络堆栈、GUI 框架、工业协议等),这最大限度地减少了开发工作并降低了整体项目风险。使用中间件而不是 自行开发 的原因是为了减少内部开发团队编写的原始代码量。这减少了团队的风险和总耗时——因此,这可以是一项值得的投资,这取决于项目复杂性和时间要求等因素。
付费解决方案通常还会附带来自固件供应商的一些级别的客户支持。工程师的雇佣和保留成本很高。经理最害怕的事情之一就是走进一个房间里满是工程师在困惑他们的工具,而不是解决需要解决的 真正 问题。拥有专家帮助,只需一封电子邮件或一个电话就能得到,可以显著提高团队的生产力,这导致周转时间缩短,每个人都更加快乐。
FreeRTOS 提供付费支持和培训选项,以及付费的中间件解决方案,可以集成。然而,也有开源和/或免费提供的中间件组件,其中一些将在本书中讨论。
本书使用的 RTOS
在所有可用的选项中,你可能想知道:为什么这本书只介绍了一种在单个 MCU 型号上的 RTOS?有几个原因,其中之一是,我们将涵盖的大部分概念几乎适用于任何可用的 RTOS,就像良好的编码习惯超越了你所使用的语言一样。通过专注于单个 MCU 上的 RTOS 的单个实现,我们将能够比如果尝试讨论所有替代方案更深入地探讨主题。
FreeRTOS 是针对 MCU 最流行的 RTOS 实现之一,并且非常广泛可用。它已经存在超过 15 年,并且已经移植到数十个平台。如果你曾经与一个真正熟悉 RTOS 编程的低级嵌入式系统工程师交谈过,他们肯定听说过 FreeRTOS,并且很可能至少使用过一次。通过关注 FreeRTOS,你将能够快速地将你对 FreeRTOS 的知识迁移到其他硬件,或者如果情况需要,过渡到另一个 RTOS。
我们使用 FreeRTOS 的另一个原因?嗯,它是免费的!FreeRTOS 在 MIT 许可下分发。有关许可和其他 FreeRTOS 衍生产品(如 SAFERTOS 和 OpenRTOS)的更多详细信息,请参阅www.freertos.org/a00114.html。
下面的图示显示了 FreeRTOS 在典型 ARM 固件堆栈中的位置。"堆栈"指的是构成系统的所有不同的层的固件组件以及它们是如何一层层堆叠的。这里的"用户"指的是使用 FreeRTOS 的程序员(而不是嵌入式系统的最终用户):

一些值得注意的项目如下:
-
用户代码能够访问相同的 FreeRTOS API,无论底层硬件端口实现如何。
-
FreeRTOS 不会阻止用户代码使用供应商提供的驱动程序、CMSIS 或原始硬件寄存器。
拥有一个在硬件上保持一致的标准化 API 意味着代码可以轻松地在硬件目标之间迁移,而无需不断重写。能够让代码直接与硬件通信也提供了在必要时编写极其高效代码的手段(以牺牲可移植性为代价)。
既然我们已经知道了 RTOS 是什么,让我们更详细地看看何时使用 RTOS 是合适的。
决定何时使用 RTOS
有时,当某人第一次听说实时操作系统这个术语时,他们可能会错误地认为 RTOS 是唯一实现嵌入式系统中实时行为的方法。虽然这当然是可以理解的(尤其是考虑到这个名字),但这与事实相去甚远。有时,最好将 RTOS 视为一个潜在的解决方案,而不是用于所有事情的解决方案。一般来说,对于一个基于 MCU 的 RTOS 要成为特定问题的理想解决方案,它需要具有金发姑娘级别的复杂性——既不太简单,也不太复杂。
如果存在一个极其简单的问题,例如监控两个状态并在它们都存在时触发警报,解决方案可能是一个直接的硬件解决方案(例如 AND 门)。在这种情况下,可能没有必要进一步复杂化问题,因为 AND 门解决方案将会非常快,具有高确定性和极端可靠性。它也将需要非常少的发展时间。
现在,考虑一个只有一两个任务需要执行的情况,例如控制电机的速度并监控编码器以确保正确地行进了正确的距离。这当然可以通过离散的模拟和数字硬件实现,但具有可配置的距离会增加一些复杂性。此外,调整控制回路系数可能需要调整电位计设置(可能每个单独的板都需要),这在某些或大多数情况下,按照今天的制造标准是不理想的。因此,在硬件解决方案方面,我们只剩下 CPLD 或 FPGA 来实现运动控制算法并跟踪行进距离。这恰好非常适合两者,因为它可能足够小,可以放入 CPLD,但在某些情况下,FPGA 的成本可能无法接受。这个问题也经常由 MCU 处理。如果现有的内部资源没有与硬件语言或工具链相关的专业知识,那么裸机 MCU 固件解决方案可能是一个不错的选择。
假设问题更加复杂,例如一个控制多个不同执行器的设备,从一系列传感器读取数据,并将这些值存储在本地存储中。也许该设备还需要连接到某种网络,如以太网、Wi-Fi、控制器局域网络(CAN)等。实时操作系统可以很好地解决这个问题。需要完成许多不同的任务,这些任务或多或少是异步的,这使得很容易论证实时操作系统带来的额外复杂性是值得的。实时操作系统帮助我们确保低优先级、更复杂的任务(如网络和文件系统堆栈)不会干扰更时间敏感的任务(如控制执行器和读取传感器)。在许多情况下,可能存在某种控制系统,通常从时间上定义良好的间隔运行中获益——这是实时操作系统的优势。
现在,考虑一个与之前类似的系统,但现在有多个网络要求,例如提供网页服务、在复杂的企业环境中处理用户身份验证,以及将文件推送到需要不同网络文件协议的多个共享目录。这种复杂程度可以通过实时操作系统实现,但同样,根据可用的团队资源,这可能会更好留给一个完整的操作系统来处理(无论是实时操作系统还是通用操作系统),因为许多所需的复杂软件栈已经存在。有时,可能会采用多核方法,其中一个核心运行实时操作系统,而另一个核心运行通用操作系统。
到目前为止,可能已经很清楚,没有一种确定的方法可以精确地确定哪种实时解决方案适用于所有情况。每个项目和团队都会有他们自己独特的需求、背景、技能组合和情境,这些都为这一决策奠定了基础。选择解决方案时需要考虑许多因素;保持开放的心态,并选择最适合您团队和当时项目的解决方案是很重要的。
摘要
在本章中,我们介绍了如何识别实时需求,以及实现实时系统的不同平台。到现在,您应该对可能具有实时需求的广泛系统以及满足这些实时需求的各种方式有所认识。
在下一章中,我们将通过更深入地研究两种不同的编程模型——超级循环和实时操作系统任务,来开始探讨基于 MCU 的实时固件。
问题
如我们所总结,以下是一些问题,供您测试对本章材料的理解。您将在附录的评估部分找到答案:
-
带有实时要求的系统是否总是需要非常快?
-
实时系统是否总是需要实时操作系统?
-
固件是否是满足实时性要求的唯一途径?
-
什么是实时系统?
-
列举 3-4 种实时系统的类型。
-
在什么情况下使用实时操作系统(RTOS)来满足实时性要求是合适的?
第二章:理解 RTOS 任务
超级循环编程范式通常是嵌入式系统工程师遇到的第一个编程方法之一。使用超级循环实现的程序有一个顶层循环,该循环遍历系统需要执行的各种函数。这些简单的 while 循环易于创建和理解(当它们很小的时候)。在 FreeRTOS 中,任务与超级循环非常相似——主要区别是系统可以有多个任务,但只有一个超级循环。
在本章中,我们将更深入地研究超级循环以及使用它们实现一定程度的并行性的不同方法。之后,将比较超级循环和任务,并介绍关于任务执行的理论思考方式。最后,我们将探讨如何在 RTOS 内核中实际执行任务,并比较两种基本的调度算法。
本章将涵盖以下主题:
-
介绍超级循环编程
-
使用超级循环实现并行操作
-
比较 RTOS 任务与超级循环
-
使用 RTOS 任务实现并行操作
-
RTOS 任务与超级循环——优点和缺点
技术要求
本章没有软件或硬件要求。
介绍超级循环编程
所有嵌入式系统都共享的一个共同特性是——它们没有退出点。由于其本质,嵌入式代码通常预期始终可用——在后台默默运行,处理日常维护任务,并随时准备接收用户输入。与旨在启动和停止程序的桌面环境不同,如果微控制器退出 main() 函数,它就没有任何事情可做。如果发生这种情况,整个设备可能已经停止工作。因此,嵌入式系统中的 main() 函数永远不会返回。与应用程序不同,应用程序由其宿主操作系统启动和停止,大多数基于嵌入式 MCU 的应用程序在电源开启时启动,在系统断电时突然结束。由于这种突然关闭,嵌入式应用程序通常没有与应用程序通常关联的任何关闭任务,例如释放内存和资源。
以下代码代表了超级循环的基本思想。在继续更详细的解释之前,请先看看这个:
void main ( void )
{
while(1)
{
func1();
func2();
func3();
//do useful stuff, but don't return
//(otherwise, where would we go. . what would we do. . .?!)
}
}
尽管非常简单,前面的代码有几个值得注意的特性。while 循环永远不会返回——它会永远执行相同的三个函数(这是预期的)。这三个看似无辜的函数调用在实时系统中可能隐藏一些令人惊讶的问题。
基本超级循环
这个永远不会返回的主循环通常被称为超级循环。思考超级这个词总是很有趣,因为它控制着系统中的大多数事物——在下图中,除非超级循环使其发生,否则什么都不会完成。这种设置非常适合非常简单的系统,这些系统只需要执行几个耗时不太多的任务。基本的超级循环结构非常容易编写和理解;如果你试图解决的问题可以用简单的超级循环完成,那么就使用简单的超级循环。以下是之前展示的代码的执行流程——每个函数都是顺序调用的,循环永远不会退出:

现在,让我们看看在实时系统中这种执行看起来是什么样子,以及与这种方法相关的一些缺点。
实时系统中的超级循环
当简单的超级循环快速运行时(通常是因为它们的功能/责任有限),它们非常响应。然而,超级循环的简单性既是祝福也是诅咒。由于每个函数总是跟随前一个函数,它们总是以相同的顺序调用,并且完全依赖于彼此。任何一个函数引入的延迟都会传播到下一个函数,这会导致执行该循环迭代的总时间增加(如下图所示)。如果 func1 在循环中执行一次需要 10 us,而下一次需要 100 ms,那么 func2 在第二次通过循环时不会像第一次那样快地被调用:

让我们更深入地看看这个问题。在先前的图中,func3 负责检查表示外部事件的标志的状态(此事件表示信号的上升沿)。func3 检查标志的频率取决于 func1 和 func2 执行所需的时间。设计良好且响应迅速的超级循环通常会非常快速地执行,比事件发生的频率检查得更频繁(见标注 B)。当外部事件发生时,循环直到 func3 下一次执行时才会检测到该事件(见标注 A、C 和 D)。请注意,事件生成和 func3 检测到事件之间存在延迟。此外,延迟并不总是恒定的:这种时间差异被称为抖动。
在许多基于超级循环的系统里,超级循环的执行速度与缓慢发生的事件轮询相比极高。我们无法在页面上展示在检测到事件之间执行数百(或数千)次迭代的循环!
如果一个系统在响应事件时有一个已知的最大抖动量,那么它被认为是确定的。也就是说,它将在事件发生后的一段时间内可靠地响应事件。在实时系统中,高确定度对于时间关键组件至关重要,因为没有它,系统可能无法及时响应重要事件。
考虑一个循环不断检查硬件标志以等待事件的情况(这被称为轮询)。循环越紧密,标志检查的速度越快——当标志频繁检查时,代码对感兴趣事件的响应性会更强。如果我们有一个需要及时处理的事件,我们可以编写一个非常紧密的循环并等待重要事件发生。这种方法是可行的——但仅当该事件是系统唯一感兴趣的事情时。如果整个系统的唯一责任就是监视该事件(没有后台 I/O、通信等),那么这是一种有效的方法。这种类型的情形在当今复杂的现实世界系统中很少见。仅轮询的系统响应性差是其局限性。接下来,我们将探讨如何在超级循环中实现更多的并行性。
使用超级循环实现并行操作
即使基本超级循环只能按顺序执行函数,仍然有方法实现并行性。微控制器(MCU)有几类专门的硬件,旨在从 CPU 中分担一些负担,同时仍然使系统保持高度响应。本节将介绍这些系统以及如何在超级循环风格的程序中使用它们。
介绍中断
对单个事件进行轮询不仅浪费 CPU 周期和电力,而且还会导致系统对其他任何事情都没有响应,这通常应该避免。那么,我们如何让单核处理器并行处理事情呢?好吧,我们做不到——毕竟能力有限。但是,由于我们的处理器每秒可能运行数百万条指令,因此有可能让它执行接近并行的事情。微控制器还包括用于生成中断的专用硬件。中断向微控制器提供信号,允许它在事件发生时直接跳转到中断服务例程(ISR)。这是如此关键的功能,以至于 ARM Cortex-M 核心为此提供了一个标准的外设,称为嵌套向量中断控制器(NVIC)。NVIC 提供了一种处理中断的通用方式。这个术语中的嵌套部分表示即使中断也可以被具有更高优先级的中断中断。这非常方便,因为它允许我们最小化系统中最关键部分延迟和抖动的数量。
那么,中断如何适应超级循环以更好地实现并行活动的错觉呢?ISR 内部的代码通常尽可能保持简短,以最小化中断中花费的时间。这有几个原因。如果中断非常频繁,且 ISR 包含大量指令,ISR 可能在没有再次被调用之前就返回。对于 UART 或 SPI 等通信外设,这意味着丢失数据(这显然是不希望的)。保持代码简短的另一个原因是其他中断也需要服务,这就是为什么推迟非 ISR 上下文中的代码执行是一个好主意。
为了快速了解 ISR 如何影响抖动,让我们看看一个简单的例子,外部模拟到数字转换器(ADC)向 MCU 发出信号,表示已读取并准备好将转换传输到 MCU(请参考此处所示的硬件图):

在 ADC 硬件中,一个引脚被专门用于表示已将模拟值转换为数字表示,并准备好传输到 MCU。然后 MCU 将在通信介质(图中为 COM)上启动传输。
接下来,让我们看看 ISR 调用如何在时间上相互堆叠,相对于转换就绪线的上升沿。以下图表显示了在响应信号上升沿时被调用的六个不同 ISR 实例。硬件中上升沿发生时与固件中 ISR 被调用之间的小段时间是最小延迟。ISR 响应的抖动是许多不同周期中延迟的差异:

对于关键的中断服务例程(ISR),有不同方法来最小化延迟和抖动。在基于 ARM Cortex-M 的微控制器(MCU)中,中断优先级是灵活的——可以在运行时为单个中断源分配不同的优先级。能够重新排序中断是一种确保系统最重要的部分在需要时获得 CPU 的方法。
如前所述,保持中断中执行的代码量尽可能短是很重要的,因为 ISR 内部的代码将优先于任何非 ISR 的代码(例如main())。此外,较低优先级的中断只有在所有较高优先级 ISR 中的代码都已执行且 ISR 退出后才会执行——这就是为什么保持 ISR 简短很重要的原因。始终尝试限制 ISR 的责任(以及因此的代码)是一个好主意。
当多个中断嵌套时,它们不会完全返回——实际上,ARM Cortex M 处理器有一个非常有用的特性,称为中断尾链。如果处理器检测到即将退出的中断,但另一个中断正在等待,则下一个 ISR 将在没有处理器完全恢复中断前的状态的情况下执行,这进一步减少了延迟。
中断和超级循环
在中断服务例程(ISR)中实现最小指令和责任的一种方法是在 ISR 中完成尽可能少的工作,然后设置一个由超级循环中的代码检查的标志。这样,中断可以尽快得到处理,而无需整个系统都致力于等待该事件。在下面的图中,注意中断是如何被多次生成,最终由 func3 处理的。
根据中断试图实现的确切内容,它通常会从一个相关的外设取一个值并将其推入一个数组(或者从一个数组中取一个值并将其馈送到外设寄存器)。在我们的外部 ADC 的情况下,ISR(每次 ADC 执行转换时都会触发)会输出到 ADC,传输数字读数,并将其存储在 RAM 中,设置一个标志以指示一个或多个值已准备好处理。这允许中断被多次处理,而不涉及高级代码:

对于正在传输大量数据块的通信外设,可以使用数组作为存储要传输项目的队列。在整个传输结束时,可以设置一个标志来通知主循环完成。有许多情况下使用队列值是合适的。例如,如果需要对数据块执行一些处理,通常先收集数据,然后在中断之外一起处理整个块是有利的。中断驱动的方法不是实现这种阻塞数据方法的唯一方式。在下一节中,我们将查看一个可以使移动大量数据对程序员来说更容易,对处理器来说更高效的硬件。
介绍 DMA
记得那个处理器真正不能并行处理事务的断言吗?这依然成立。然而……现代微控制器不仅仅包含一个处理核心。当我们的处理核心正在处理指令时,MCU 内部还有许多其他硬件子系统正在努力工作。其中一个努力工作的子系统被称为直接内存访问控制器(DMA):

上述图示展示了一个非常简化的硬件模块图,展示了从 RAM 到 UART 外设的两种不同数据路径的视图。
在没有 DMA 的情况下从 UART 接收字节流的情况下,UART 的信息将移动到 UART 寄存器中,由 CPU 读取,然后推送到 RAM 中存储:
-
CPU 必须检测到单个字节(或字)已被接收,无论是通过轮询 UART 寄存器标志,还是通过设置一个当字节准备好时会被触发的中断服务例程。
-
在 UART 传输字节之后,CPU 可以将其放入 RAM 以进行进一步处理。
-
步骤 1 和步骤 2 会重复,直到整个消息接收完成。
在相同场景下使用 DMA 时,会发生以下情况:
-
CPU 配置 DMA 控制器和外围设备以进行传输。
-
DMA 控制器负责处理 UART 外围设备和 RAM 之间的所有传输。这不需要 CPU 的干预。
-
当整个传输完成时,CPU 会收到通知,可以直接处理整个字节流。
大多数程序员如果习惯了处理超级循环和中断服务例程(ISRs),会发现直接内存访问(DMA)几乎像魔法一样神奇。控制器被配置为在需要时将内存块传输到外围设备,并在传输完成后提供通知(通常是中断)——这就完成了!
当然,这种便利性是有代价的。设置 DMA 传输最初确实需要一些时间,因此对于小传输,实际上可能需要比使用中断或轮询方法更多的时间来设置传输。
还有一些需要注意的注意事项:每个 MCU 都有特定的限制,因此在依赖 DMA 作为系统关键设计组件的可用性之前,务必阅读数据表、参考手册和勘误表:
-
MCU 内部总线的带宽限制了可以可靠地放置在单个总线上的带宽密集型外围设备的数量。
-
有时,映射到外围设备的 DMA 通道的有限可用性也会使设计过程复杂化。
正是因为这些原因,让所有团队成员参与嵌入式系统早期阶段的设计非常重要,而不仅仅是“扔过墙”。
DMA 对于高效访问大量外围设备非常出色,它使我们能够向系统添加更多和更多的功能。然而,当我们开始向超级循环添加越来越多的代码模块时,子系统之间的相互依赖关系也会变得更加复杂。在下一节中,我们将讨论扩展超级循环以适应复杂系统的挑战。
扩展超级循环
因此,我们现在有一个响应迅速的系统,能够可靠地处理中断。也许我们还配置了一个 DMA 控制器来处理通信外设的繁重工作。我们为什么还需要 RTOS 呢?好吧,完全有可能你不需要!如果系统只处理有限的责任,并且它们都不是特别复杂或耗时,那么可能不需要比超级循环更复杂的东西。
然而,如果系统还负责生成 用户界面(UI),运行复杂耗时算法,或处理复杂的通信堆栈,那么这些任务很可能需要相当多的时间。如果因为 MCU 正在处理从关键传感器收集数据,一个花哨的、引人注目的 UI 开始出现一点卡顿,那也不是什么大问题。动画可以被降低或消除,而实时系统的重要部分仍然保持完好。但如果是动画看起来仍然完美无瑕,尽管传感器有数据丢失呢?
在我们的行业中,每天都会以各种不同的方式出现这个问题。有时,如果系统设计得足够好,丢失的数据会被检测并标记(但无法恢复:它永远消失了)。如果设计团队真的很幸运,它甚至可能在内部测试中失败。然而,在许多情况下,丢失的传感器数据可能完全未被注意到,直到有人注意到其中一个读数似乎有点不对……有时候。如果每个人都足够幸运,关于可疑读数的错误报告可能包括一个提示,表明它似乎只在有人在前面板上(玩那些花哨的动画)时发生。这至少会给被分配调试问题的可怜的固件工程师一个提示——但我们通常甚至没有这么幸运。
这些就是需要 RTOS 的系统类型。确保最关键的任务在必要时始终运行,并将低优先级任务安排在有空闲时间时运行,这是抢占式调度器的优势所在。在这种配置中,关键传感器的读数可以被推入它们自己的任务,并分配高优先级——在处理传感器的时候,实际上会中断系统中的任何其他任务(除了中断服务例程)。那个复杂的通信堆栈可以分配比关键传感器更低的优先级。最后,花哨的 UI 和那些花哨的动画得到剩余的处理器周期。它可以自由地执行尽可能多的滑动 alpha 混合动画,但只有在处理器没有其他更好的事情可做的时候。
将 RTOS 任务与超级循环进行比较
到目前为止,我们只是非常随意地提到了任务,但任务到底是什么呢?一个简单的方式来思考任务就是,它只是另一个主循环。在抢占式 RTOS 中,任务与超级循环之间有两个主要区别:
-
每个任务都有自己的私有栈。与主循环中的超级循环不同,它共享系统栈,任务拥有自己的栈,系统中的其他任务都不会使用这个栈。这允许每个任务都有自己独立的调用栈,而不会干扰其他任务。
-
每个任务都被分配了一个优先级。这个优先级允许调度器做出决定,确定哪个任务应该运行(目标是确保系统中优先级最高的任务始终在进行有用的工作)。
考虑到这两个特性,每个任务都可以像它是处理器唯一要执行的事情一样进行编程。你有没有一个想监控的单个标志,以及一些用于闪亮动画的计算?没问题:只需编程任务并为其分配一个合理的优先级,相对于系统功能的其余部分。抢占式调度器将始终确保当有工作要做时,最重要的任务正在执行。当一个高优先级任务不再有有用的工作可执行,并且它在等待系统中的其他事情时,一个低优先级任务将被切换到上下文并允许运行。
FreeRTOS 调度器将在第七章 FreeRTOS 调度器中更详细地讨论。
使用 RTOS 任务实现并行操作
之前,我们查看了一个循环遍历三个函数的超级循环。现在,作为一个非常简单的例子,让我们将这三个函数中的每一个移动到它自己的任务中。我们将使用这三个简单的任务来检查以下内容:
-
理论任务编程模型:如何从理论上描述三个任务
-
实际的循环调度:使用循环调度算法执行任务时的样子
-
实际的抢占式调度:使用抢占式调度执行任务时的样子
在现实世界的程序中,几乎每个任务都不是一个单独的函数;我们只是用它作为之前过度简化的超级循环的类比。
理论任务编程模型
这里有一些使用超级循环执行三个函数的伪代码。同样的三个函数也包含在基于任务的系统中——每个 RTOS 任务(在右侧)包含的功能与左侧超级循环中的函数相同。在讨论使用超级循环与使用带有调度器的任务驱动方法执行代码时的差异时,我们将继续使用这一点:

你可能会立即注意到超级循环实现和实时操作系统(RTOS)实现之间的一个区别是无限while循环的数量。超级循环实现只有一个无限while循环(在main()中),但每个任务都有自己的无限while循环。
在超级循环中,由超级循环执行的三个函数在调用下一个函数之前都会运行到完成,然后循环继续到下一个迭代(以下图所示):

在 RTOS 实现中,每个任务本质上都是一个小的无限while循环。而超级循环中的函数总是依次顺序调用(由超级循环中的逻辑编排),任务可以简单地被视为在调度器启动后并行执行。以下是一个 RTOS 执行三个任务的图示:

在图中,你会注意到每个while循环的大小并不相同。这是使用执行任务在并行的调度器而不是超级循环的许多好处之一——程序员不需要立即担心最长执行循环的长度会减慢其他更紧密循环的速度。图中显示了Task 2的循环比Task 1长得多。在超级循环系统中,这会导致func1的功能执行频率降低(因为超级循环需要先执行func1,然后是func2,最后是func3)。在基于任务的编程模型中,情况并非如此——每个任务的循环可以被视为与系统中的其他任务隔离——并且它们都并行运行。
这种隔离和感知到的并行执行是使用 RTOS 的一些好处;它减轻了程序员的一些复杂性。所以——这是概念化任务的最简单方法——它们只是简单地独立的无穷while循环,所有这些循环都并行执行……在理论上。在现实中,事情并不这么简单。在接下来的两个部分中,我们将一瞥幕后发生的事情,以使其看起来像任务正在并行执行。
轮询调度
概念化实际任务执行的最简单方法之一是轮询调度。在轮询调度中,每个任务都会得到一小段时间来使用处理器,这由调度器控制。只要任务有工作要做,它就会执行。对任务来说,它似乎拥有整个处理器。调度器负责处理切换到下一个任务所需的所有上下文复杂性:

这与之前显示的三个任务相同,只是这次不是理论上的概念化,而是按时间顺序枚举任务循环的每次迭代。因为轮询调度程序为每个任务分配相等的时间片,所以最短的任务(任务 1)几乎执行了其循环的六次迭代,而具有最慢循环的任务(任务 2)只完成了第一次迭代。任务 3 执行了其循环的三次迭代。
在一个超级循环执行相同的功能与一个轮询调度例程执行它们之间有一个极其重要的区别:任务 3 在 任务 2 完成之前就完成了它相对紧凑的循环。当超级循环以串行方式运行函数时,函数 3 甚至在 函数 2 完成之前都不会开始。因此,虽然调度程序没有提供真正的并行性,但每个任务都能获得其应有的 CPU 周期。因此,在这种调度方案中,如果一个任务的循环较短,它将比循环较长的任务执行得更频繁。
所有这些切换都会带来(轻微的)成本——每当发生上下文切换时,都需要调用调度程序。在这个例子中,任务并没有明确调用调度程序来运行。在 FreeRTOS 运行在 ARM Cortex-M 上的情况下,调度程序将从 SysTick 中断中调用(更多细节可以在第七章,《FreeRTOS 调度程序》中找到)。为了确保调度程序内核非常高效,尽可能减少运行时间,投入了大量的努力。然而,事实仍然是它将运行,并消耗 CPU 周期。在大多数系统中,这种少量开销通常不会引起注意(或显著),但在某些系统中可能会成为问题。例如,如果一个设计处于可行性的极限边缘,因为它有非常严格的时序要求,并且几乎没有多余的 CPU 周期,那么如果超级循环/中断方法已经被仔细表征和优化,那么额外的开销可能不是所希望的(或完全必要的)。然而,最好尽可能避免这种情况,因为即使在中等复杂度的系统中,忽略中断堆栈组合(或嵌套条件偶尔需要更长的时间)并导致系统错过截止日期的可能性非常高。
基于抢占的调度
预先调度提供了一种确保系统始终在执行其最重要任务的机制。一个预先调度算法将优先考虑最重要的任务,无论系统中其他部分发生什么情况——除了中断,因为它们发生在调度程序“之下”,并且总是具有更高的优先级。这听起来非常直接——确实如此——但是还有一些细节需要考虑。
让我们来看看这三个相同的任务。这三个任务都具有相同的功能:一个简单的while循环,不断地增加一个易变的变量。
现在,考虑以下三种情况,以确定三个任务中哪一个将获得上下文。以下图表具有与之前相同的任务,采用轮询调度。每个任务都有足够多的工作要做,这将防止任务失去上下文:

那么,当设置了三个不同优先级(A、B 和 C)的三个不同任务时会发生什么呢?
-
A(左上角):
任务 1在系统中具有最高的优先级——它获得了所有的处理器时间!无论任务 1执行多少次迭代,如果它是系统中优先级最高的任务,并且它有工作要做(不需要等待系统中的其他任何东西),它将获得上下文并运行。 -
B(右上角):
任务 2是系统中优先级最高的任务。由于它有足够多的工作要做,不需要等待系统中的其他任何东西,任务 2将获得上下文。由于任务 2被配置为系统中的最高优先级,它将执行,直到它需要等待系统中的其他东西。 -
C(左下角):
任务 3被配置为系统中优先级最高的任务。没有其他任务运行,因为它们的优先级较低。
现在,显然,如果你实际上设计了一个需要多个任务并行运行的系统,如果系统中的所有任务都需要 100%的 CPU 时间并且不需要等待任何东西,抢占式调度器就没有什么用处了。这种设置对于实时系统来说也不是一个好的设计,因为它完全超载(并且忽略了系统旨在执行的三项主要功能中的两项)!所描述的情况被称为任务饥饿,因为只有系统中优先级最高的任务获得了 CPU 时间,而其他任务则被剥夺了处理器时间。
另一个值得指出的是,调度器仍然按照预定的间隔运行。无论系统发生什么,调度器都会勤奋地以预定的滴答速度运行。
这有一个例外。FreeRTOS 有一个专为极低功耗设备设计的无滴答调度器模式,它防止调度器在相同的预定间隔上运行。
这里展示了使用抢占式调度器的更实际的使用案例:

在这种情况下,任务 1是系统中优先级最高的任务(它也恰好执行得非常快)——任务 1唯一被从上下文中移除的时间是当调度器需要运行时;否则,它将保持上下文直到它没有更多的工作要执行。
任务 2 是下一个最高优先级——你也会注意到这个任务被设置为在 RTOS 调度器滴答时执行一次(由向下箭头表示)。任务 3 是系统中的最低优先级任务:它只有在系统中没有其他值得做的事情时才会获得上下文。在这个图中有三个主要点值得注意:
-
A:
任务 2有上下文。即使它被调度器中断,但在调度器运行后,它会立即再次获得上下文(因为它仍然有工作要执行)。 -
B:
任务 2已经完成了迭代 0 的工作。调度器已经运行并确定(因为系统中没有其他任务需要运行)任务 3可以获得处理器时间。 -
C:
任务 2已经开始运行第 4 次迭代,但任务 1现在有一些工作要做——即使任务 2还未完成该迭代的任务。调度器立即将任务 1切换进来执行其更高优先级的工作。在任务 1完成它需要做的事情后,任务 2被切换回来完成第 4 次迭代。这次,迭代一直运行到下一个滴答,任务 2再次运行(迭代 5)。在任务 2完成迭代 5 后,没有更高优先级的工作要做,因此系统中的最低优先级任务(任务 3)再次运行。看起来任务 3终于完成了迭代 0,因此它继续到迭代 1 并继续前进……
希望你还在这里!如果不在,那也行,鉴于这是一个非常抽象的例子。关键要点是系统中最高优先级的任务具有优先权。
这只是对第七章[2fa909fe-91a6-48c1-8802-8aa767100b8f.xhtml]中详细介绍的相关的调度概念的简要介绍,《FreeRTOS 调度器》,以将任务的概念置于上下文中,展示它们可以以不同的方式运行和调度。那里讨论了许多更多细节和实现所需系统性能的策略,以及实际世界的例子。
RTOS 任务与超级循环——优点和缺点
超级循环非常适合具有有限责任的简单系统。如果一个系统足够简单,它们可以提供非常低的响应事件抖动,但前提是循环足够紧凑。随着系统变得更加复杂并承担更多责任,轮询率降低。这种降低的轮询率会导致对事件响应的更大抖动。可以通过引入中断来对抗增加的抖动。随着基于超级循环的系统变得更加复杂,跟踪和保证对事件的响应变得更加困难。
在具有不仅耗时任务,而且需要对外部事件有良好响应的更复杂系统中,RTOS 非常有价值。使用 RTOS,系统复杂性的增加、ROM、RAM 和初始设置时间的增加是换取一个更容易理解的系统,该系统可以更容易地及时保证对外部事件的响应。
摘要
我们在本章中涉及了许多与超级循环和任务相关的概念。到现在为止,你应该对如何将超级循环与中断和 DMA 结合使用以提供并行处理,从而保持系统响应性,而不使用 RTOS 有很好的理解。我们在理论上介绍了基于任务的架构,以及在使用 FreeRTOS 时你将遇到的两种主要调度类型(轮询和抢占)。你还非常简要地看到了抢占式调度器如何调度不同优先级的任务。所有这些概念都很重要,所以在我们继续前进并讨论更高级的主题时,请随时参考这些简单的例子。
在下一章中,你将了解到各种任务间通信机制,这些机制将导致与本章中覆盖的类似上下文切换。随着我们通过本书并转向中断和任务通信机制,我们将讨论许多现实世界的例子,并深入探讨你需要编写的代码,以创建可靠的实时系统。
问题
在我们总结的时候,这里有一份问题列表,供你测试你对本章内容的了解。你将在附录的评估部分找到答案:
-
什么是超级循环?
-
一个无限
while循环 -
一个监控嵌入式系统中所有函数调用的循环
-
上述两种选项
-
-
RTOS 任务应该始终优先于超级循环。
-
真的
-
假的
-
-
列举复杂超级循环的一个缺点。
-
如何提高基于超级循环的应用的响应性?
-
列举两种超级循环与 RTOS 任务不同的方式。
-
RTOS 任务具有哪些特性,可以帮助确保最关键的任务在时间不那么关键的任务之前获得 CPU 时间?
-
时间切片
-
优先级
-
轮询调度
-
-
哪种类型的调度器试图在执行不那么关键的任务之前先执行最关键的任务?
进一步阅读
如果你对于中断和 DMA 还不太熟悉,这里有两个资源很好地描述了它们的使用(相对于 MCU):
-
STM 应用笔记 AN4031 – 在 STM32F7 上使用 DMA:
www.st.com/content/ccc/resource/technical/document/application_note/27/46/7c/ea/2d/91/40/a9/DM00046011.pdf/files/DM00046011.pdf/jcr:content/translations/en.DM00046011.pdf
第三章:任务信号和通信机制
在上一章中,我们介绍了任务。在结尾处,我们查看了一些关于系统中多个任务的抢占式调度的示例,以及任务将在它不是在等待某事(处于阻塞状态)并且可以执行某些有用操作时运行的事实。在本章中,我们将简要介绍任务信号和任务间通信的核心机制。这些原语对于事件驱动并行编程是基本的,而事件驱动并行编程是良好实现的基于实时操作系统的应用程序的基础。
我们不会直接深入 FreeRTOS API,而是将每个原语与一些图形示例和一些关于何时可以使用每种机制的提示一起展示。不用担心:在后面的章节中,我们将深入了解如何使用 API。现在,让我们专注于基础知识。
在本章中,我们将介绍以下主题:
-
RTOS 队列
-
RTOS 信号量
-
RTOS 互斥锁
技术要求
本章没有软件或硬件要求。
RTOS 队列
队列在概念上相当简单,但它们也非常强大和灵活,尤其是如果你传统上使用 C 语言在裸机上进行编程。在本质上,队列只是一个环形缓冲区。然而,这个缓冲区包含一些非常特殊的属性,例如原生的多线程安全性,每个队列可以持有任何类型的数据的灵活性,以及唤醒等待队列中出现项目的其他任务。默认情况下,数据使用先进先出(FIFO)顺序存储在队列中——首先放入队列的项目是首先从队列中移除的项目。
我们将首先查看队列在不同状态和不同使用方式(发送与接收)下的简单行为,然后继续讨论如何使用队列在任务之间传递信息。
简单队列发送
第一个队列示例仅仅是向队列中添加(也称为发送)一个有空间的项目:

当一个项目被添加到有可用空间的队列中时,添加操作会立即发生。因为队列中有可用空间,所以将项目发送到队列的任务会继续运行,除非有另一个更高优先级的任务正在等待队列中出现项目。
尽管与队列的交互通常发生在任务内部,但这并不是总是如此。有一些特殊情况,队列也可以从 ISR 内部访问(但该行为有不同的规则)。对于本章的示例,我们将假设任务是向队列发送和接收项目。
简单队列接收
在以下图中,一个任务被显示为从队列中接收一个项目:

当一个任务准备从队列中接收一个项目时,默认情况下,它将获取最老的项目。在这个例子中,由于队列中至少有一个项目,所以接收操作将立即处理,任务继续运行。
满队列发送
当队列满时,不会丢弃任何信息。相反,试图将项目发送到队列中的任务将等待直到预定的等待时间,以等待队列中有可用空间:

当队列满时,试图向队列发送项目的任务将等待直到队列中有空间可用,但只到指定的超时值。
在这个例子中,如果一项任务试图向一个满队列发送数据,并且它的超时时间是 10 毫秒——它将只等待 10 毫秒以等待队列中有空间可用。超时时间过后,调用将返回并通知调用代码发送失败。关于这种失败的处理方式由设置调用代码的程序员决定,并且会根据具体的使用情况而有所不同。对于真正非关键的功能,可以使用非常长的超时值。只是要注意,这会导致发送任务实际上永远等待队列中的空间变得可用(这显然不再是实时了)!
你的代码通常会被构建成尝试向队列发送数据不会超时。作为程序员,你需要根据具体情况确定可接受的时间长度。如果发生超时,你也负责确定其严重性和纠正措施。可能的纠正措施可能从什么也不做(比如视频通话中丢失的一帧)到紧急关闭。
空队列接收
另一个可能导致任务阻塞的情况是尝试从空队列中接收:

与等待空间变得可用的发送操作类似,从队列中接收的任务也有可能被延迟。在空队列的情况下,试图从队列中接收项目的任务将被阻塞,直到队列中出现项目。如果在超时时间之前没有可用项目,调用代码将被通知失败。再次强调,采取的具体行动会有所不同。
有时,会使用无限等待。你经常会遇到非常长的等待期,对于接收来自外部接口(如串行端口)输入的队列,这些接口可能不会持续发送数据。如果串行端口另一端的人类用户在一段时间内没有发送数据,这根本不会有任何问题。
另一方面,接收超时也可以用来确保你有足够的数据量来处理。让我们使用一个旨在以 10 Hz(每秒 10 次读取)提供新读数的传感器。如果你正在实现依赖于该传感器新鲜读数的算法,可以使用略大于 100 ms 的超时来触发错误。这个超时将保证算法始终在新鲜的传感器读数上操作。在这种情况下,超时可以用来触发某种纠正行动或通知传感器未按预期工作。
交互任务队列
现在我们已经覆盖了队列的简单行为,我们将看看它们如何用于在任务之间移动数据。队列的一个非常常见的用例是有一个任务填充队列,而另一个任务从同一个队列中读取。这通常很简单,但可能有一些细微差别,具体取决于系统的设置:

在前面的例子中,任务 1和任务 2都在与同一个队列交互。任务 1将项目发送到队列。只要任务 2的优先级高于任务 1,它就会立即接收该项目。
让我们考虑另一个在实践中经常发生的实例,即多个任务与队列交互。由于抢占式调度器始终运行具有最高优先级的任务,如果该任务始终有数据要写入队列,队列将在另一个任务有机会从队列中读取之前填满。以下是一个可能发生的情况的示例:

以下数字对应于时间轴上的索引:
-
任务 2试图从空队列中接收一个项目。没有可用项目,因此任务 2被阻塞。 -
任务 1向队列添加项目。由于它是系统中的最高优先级任务,任务 1会一直向队列添加项目,直到没有更多项目可添加,或者直到队列已满。 -
队列已满,因此
任务 1被阻塞。 -
任务 2由调度器提供上下文,因为它现在是可能运行的最高优先级任务。 -
一旦从队列中移除一个项目,
任务 1再次被提供上下文(这是系统中的最高优先级任务,它现在能够运行,因为它在等待队列中有空间可用时被阻塞)。在添加单个项目后,队列已满,任务 1被阻塞。 -
任务 2被提供上下文并从队列中接收一个项目:
上述情况的一个现实世界示例在第九章,“**任务间通信”部分中的通过队列传递数据*。Chapter_9/src/*mainQueueCom**positePassByValue.c*展示了确切的设置,并使用SystemView进行了彻底的经验性执行分析。

队列的另一个极其常见的用例是单个队列接受来自许多不同来源的输入。这对于像调试串行端口或日志文件这样的东西特别有用。许多不同的任务可以写入队列,而只有一个任务负责从队列接收数据并将其推送到共享资源。
当队列通常用于在任务之间传递数据时,信号量可以用于信号和同步任务。让我们接下来了解更多关于这一点。
RTOS 信号量
信号量是另一个非常直接但强大的结构。单词信号量源自希腊语——其大致的英文翻译是信号携带者,这是一个非常直观的方式来思考它们。信号量用于指示发生了某事;它们是事件的信号。信号量的以下是一些示例用例:
-
一个中断服务例程(ISR)完成了对外围设备的处理。它可能释放一个信号量,向任务提供信号,表明数据已准备好进一步处理。
-
一个任务达到了一个需要等待系统中的其他任务赶上才能继续前进的交叉点。在这种情况下,可以使用信号量来同步任务。
-
限制对受限资源的同时用户数量。
使用实时操作系统(RTOS)的一个方便之处在于信号量的预先存在。由于它们的功能基础(且关键),它们被包含在 RTOS 的每个实现中。有两种不同类型的信号量来覆盖这些需求——计数信号量和二进制信号量。
计数信号量
计数信号量通常用于管理具有对同时用户数量限制的共享资源。在创建时,它们可以被配置为持有最大值,称为上限。计数信号量的例子通常是数据库中的读者... 好吧,我们在这里讨论的是一个基于 MCU 的嵌入式系统,所以让我们保持我们的例子相关。如果你对数据库感兴趣,你可能更适合使用通用操作系统!对于我们的例子,让我们假设你正在实现一个基于套接字的通信驱动程序,而你的系统只有足够的内存来支持有限数量的同时套接字连接。
在下面的图中,我们有一个可以容纳两个同时套接字连接的共享网络资源。然而,有三个任务需要访问。计数信号量用于限制同时套接字连接的数量。每次任务完成对共享资源的处理(即其套接字关闭)时,它必须释放其信号量,以便另一个任务可以访问网络。如果一个任务意外地释放了一个已经达到最大计数的信号量,计数将保持不变:

上述图示演示了一个只能同时服务两个任务(尽管系统中有三个任务需要使用该资源)的共享资源示例。如果一个任务要使用受计数信号量保护的套接字,它必须首先从池中获取一个信号量。如果没有可用的信号量,那么该任务必须等待直到信号量变得可用:
-
初始时,创建一个最大(上限)为 2 且初始计数为 0 的信号量。
-
当
TaskA和TaskB尝试获取semphr时,它们立即成功。在这个时候,它们可以各自打开一个套接字并在网络上进行通信。 -
TaskC完成得稍晚一些,因此它需要等待semphr的计数小于 2,这时网络套接字才能被自由使用。 -
在
TaskB通过其套接字完成通信后,它归还信号量。 -
现在信号量可用后,
TaskC完成其获取操作,并被允许访问网络。 -
在
TaskC获取访问权限不久后,TaskB有另一条消息要发送,因此它尝试获取一个信号量,但需要等待一个信号量变得可用,所以它被置于睡眠状态。 -
当
TaskC在网络上进行通信时,TaskA完成,并归还其信号量。 -
TaskB被唤醒并完成其任务,这使得它能够开始通过网络进行通信。 -
在
TaskB被赋予其信号量后,TaskC完成其事务并归还它所持有的信号量。
在信号量等待中,实时操作系统(RTOS)与大多数其他信号量实现不同——任务在信号量等待期间可能会超时。如果一个任务未能及时获取信号量,它不得访问共享资源。相反,它必须采取替代措施。这种替代措施可以是多种多样的行动,从严重到足以触发紧急关闭序列,到轻微到只需在日志文件中提及或推送到调试串行端口以供以后分析。作为程序员,确定适当的行动方案取决于你,这有时可能会与其他学科进行一些困难的讨论。
二进制信号量
二进制信号量实际上只是最大计数为 1 的计数信号量。它们最常用于同步。当一个任务需要同步于一个事件时,它将尝试获取一个信号量,直到信号量变得可用或直到指定的超时时间已过。系统的另一个异步部分(无论是任务还是中断服务例程)将释放一个信号量。二进制信号量可以多次释放;不需要代码将它们返回。在以下示例中,TaskA 只释放信号量,而 TaskB 只获取信号量:

TaskB 在执行其任务之前被设置为等待一个信号(信号量):
-
初始时,
任务 B试图获取信号量,但它不可用,所以任务 B进入了睡眠状态。 -
在某个时候之后,
任务 A发出信号。 -
任务 B被唤醒(由调度器唤醒;这发生在后台),现在拥有了信号量。它将完成其所需的任务,直到完成。然而,请注意,任务 B不需要归还二进制信号量。相反,它只需再次等待它。 -
任务 B再次被阻塞,因为信号量不可用(就像第一次一样),因此它进入睡眠状态,直到信号量可用。 -
循环重复。
如果任务 B要“归还”二进制信号量,它将立即再次运行,而无需从任务 A那里获得许可。结果将是一个全速运行的循环,而不是从任务 A那里接收到信号。
接下来,我们将讨论一种具有一些额外属性的特殊类型的信号量,这些属性使其特别适合于保护可以从不同任务访问的项目——互斥锁。
RTOS 互斥锁
术语互斥锁是互斥的缩写。在共享资源和任务的情况下,互斥意味着,如果一个任务正在使用共享资源,那么该任务是唯一被允许使用该资源的任务——所有其他任务都需要等待。
如果这一切听起来很像二进制信号量,那是因为它就是。然而,它有一个额外的特性,我们很快就会介绍。首先,让我们看看使用二进制信号量提供互斥时的问题。
优先级反转
让我们看看在尝试使用二进制信号量提供互斥功能时出现的一个常见问题。
考虑三个任务,A、B 和 C,其中 A 具有最高优先级,B 具有中等优先级,C 具有最低优先级。任务 A 和 C 依赖于一个信号量来访问它们之间共享的资源。由于任务 A 是系统中的最高优先级任务,它应该总是在其他任务之前运行。然而,由于任务 A 和任务 C 都依赖于它们之间共享的资源(由二进制信号量保护),这里存在一个意外的依赖关系:

让我们一步一步地通过这个例子来看看这个场景是如何展开的:
-
任务 C(系统中的最低优先级任务)获取了一个二进制信号量并开始做一些工作。 -
在
任务 C完成其工作之前,任务 A(最高优先级任务)中断并尝试获取相同的信号量,但被迫等待,因为任务 C已经获取了信号量。 -
任务 B也抢占任务 C,因为任务 B的优先级高于任务 C。任务 B执行其所有工作然后进入睡眠状态。 -
任务 C使用共享资源完成剩余的工作,此时它将信号量归还。 -
任务 A最终能够运行。
Task A最终能够运行,但不是直到两个低优先级任务都运行过。Task C完成其使用共享资源的工作是不可避免的(除非可以进行设计更改以防止它访问与Task A相同的共享资源)。然而,尽管Task A在等待并且具有更高的优先级,Task B也有机会运行完成!这是优先级反转——系统中一个高优先级任务正在等待运行,但它被迫等待一个低优先级任务运行——在这种情况下,两个任务的优先级实际上是反转的。
互斥锁最小化优先级反转
之前我们提到,在 FreeRTOS 中,互斥锁是二进制信号量,但有一个重要的附加功能。这个重要的功能是优先级继承——互斥锁具有暂时改变任务优先级的能力,以避免在系统中造成重大延迟。这发生在调度器发现一个高优先级任务试图获取一个由低优先级任务持有的互斥锁时。在这种情况下,调度器将暂时提高低优先级任务的优先级,直到它释放互斥锁。此时,低优先级任务的优先级将恢复到优先级继承之前的优先级。让我们看看前一个图中使用互斥锁(而不是二进制信号量)实现的完全相同的例子:

让我们一步一步地通过这个例子来看这个场景是如何展开的:
-
Task A仍在等待Task C返回互斥锁。 -
将
Task C的优先级提高到与高优先级Task A相同。由于Task C持有互斥锁并且是高优先级任务,它将运行完成。 -
Task C返回互斥锁,其优先级降低到它持有互斥锁之前的状态,从而延迟了高优先级任务。 -
Task A获取互斥锁并完成其工作。 -
允许
Task B运行。
根据Task C使用共享资源所需的时间以及Task A的时间敏感性,这可能是引起严重关注的主要来源,也可能不是什么大问题。可以通过时间分析来确保Task A仍然能够按时完成任务,但跟踪所有可能导致优先级反转和其他高优先级异步事件的可能原因可能会很具挑战性。至少,用户应该利用为获取互斥锁提供的内置超时,并在互斥锁未能及时获取的情况下执行适当的替代操作。关于如何具体实现这一点的更多细节可以在第九章《任务间通信》中找到。
互斥锁和信号量是任务间信号的标准机制。它们在不同的实时操作系统(RTOSes)之间非常标准,提供了出色的灵活性。
摘要
本章介绍了队列、信号量和互斥锁。还从高层次讨论了这些 RTOS 应用核心构建块的一些常见用例,并突出了每个的微妙行为。本章中展示的图表应作为我们在后续章节中转向更复杂的真实世界示例时的参考点。
我们现在已经涵盖了 RTOS 的一些核心概念。在下一章中,我们将把注意力转向开发一个坚实的实时系统的另一个非常重要的步骤。这一步骤会影响固件运行的效率,并对系统性能产生重大影响——MCU 选择。
问题
在我们总结之际,这里有一份问题清单,供您测试对本章材料的理解。您将在附录的评估部分找到答案:
-
哪个 RTOS 原语最常用于在任务之间发送和接收数据?
-
队列是否可以与超过两个任务进行交互?
-
哪个实时操作系统(RTOS)原语通常用于任务之间的信号和同步?
-
哪种情况下可以使用计数信号量作为例子?
-
请列举二进制信号量和互斥锁之间的一项主要区别。
-
在保护任务间共享的资源时,应该使用二进制信号量还是互斥锁?
-
优先级反转是什么,为什么它对实时系统来说很危险?
第二部分:工具链设置
如果你曾经在选择使用哪种微控制器或 IDE 时感到不知所措,本节就是为你准备的。在本节中,你将了解不同 MCU 之间的区别,如何选择适合你工作流程的 IDE 或工具链,以及如何进行实时系统的调试和故障排除。STMCubeIDE 的设置说明在第五章选择 IDE中,SEGGER 调试工具的设置则在第六章实时系统调试工具中介绍。这两个工具都是处理示例所必需的。
本节包含以下章节:
-
第四章 选择合适的 MCU
-
第五章, 选择 IDE
-
第六章, 实时系统调试工具
第四章:选择正确的 MCU
本章是关于微控制器单元(MCU)选择的快速课程,针对没有深厚硬件背景的工程师。它并不试图成为你选择硬件时需要了解和考虑的绝对一切内容的详尽列表。它确实提供了许多区分和选择 MCU 设备的影响因素的介绍。到结束时,你将了解足够的关键考虑因素,以便有效地研究 MCU 并与团队中的硬件工程师讨论潜在候选人。通过增加硬件/固件协作并选择适合项目的正确 MCU,你将避免硬件重设计和进度延误。
我们将首先介绍在选择适合你项目的合适 MCU 时需要考虑的广泛因素。之后,将讨论不同开发硬件之间的权衡。提供 STM32 系列线的简要介绍,以展示供应商如何分组他们的产品系列。在本章结束时,我们将比较几个以 STM32 MCU 为核心的不同开发板(dev boards),以展示我们为什么使用我们选择的开发板!
本章将涵盖以下主题:
-
MCU 选择的重要性
-
MCU 考虑因素
-
开发板考虑因素
-
介绍 STM32 MCU 产品线
-
我们如何选择我们的开发板
技术要求
为了浏览几个网站,你需要本章的互联网访问权限。
MCU 选择的重要性
在阅读本节标题后,你可能会有这样的疑问:
“嘿!我以为这是一本关于如何使用 RTOS 编程微控制器的书——这些关于 MCU 选择的内容是什么?我是一个软件开发者!”
FreeRTOS 几乎完全针对 MCU。它主要是一个具有稳定 API 的调度内核,这使得它非常适合极低级别的设计。与具有几乎无限的虚拟寻址空间和比你知道的还要多的时钟周期的完整 CPU 系统不同,你将使用一个资源受限的系统。如果你在这种系统上开发固件,这意味着你将比编写软件时更接近硬件——反过来,这意味着你很可能比你的软件同行更可能“动手”,也就是说,你将需要做更多与逻辑分析仪探针引脚相关的工作。…DMM 永久地放在你的桌子上…学习如何焊接,以便在四脚扁平封装的 MCU 上焊接引脚…你明白这个意思!如果你来自纯软件背景,你需要学习一些东西,因为我们将要深入探讨软件和硬件之间的灰色区域——固件——这应该会很有趣!
固件和硬件联系非常紧密,这就是为什么在开发过程的早期就引入固件工程师如此重要的原因。在一些组织中,仍然只有一个人负责电气设计工作和编写固件。然而,有一种趋势正在推动各个学科在其专业领域内越来越专业化。即使在这样的情况下,也很重要,要尽早让多个团队成员参与进来,做出重要的设计决策,这样每个人都能意识到正在做出的权衡。
如果你不是直接负责选择 MCU 的人,那么有可能设计会被推给你。这几乎总是件坏事,因为它鼓励设计出次优的系统设计,以避免在系统的一些核心功能被发现后,由于硬件的重大重新设计而导致的进度延误。与其承诺进行重大的板级修订来解决主要的设计缺陷,许多团队都承受着压力,只是用一些代码来修复它。
因此,假设你在选择微控制器(MCU)方面有一些输入——即使你的参与只是“嘿,泰德,你对这个新项目的微控制器有什么看法?”这样的情况,那么责任就在你身上,你需要武装自己,具备足够的背景知识,以便形成明智的观点(或者至少提出明智的问题)。本章的目的并不是提供一个详尽的列表,列出你选择新项目硬件时需要知道和考虑的每一件事,但它确实旨在介绍许多区分和选择 MCU 设备的不同影响因素。在阅读本章时,还要记住的是,它仅适用于在 MCU 之间做出选择。正如我们在第一章,介绍实时系统中看到的,设计实时系统的方式不止一种——MCU 并不总是*是最好的选择。
为了限制本章的范围仅限于直接相关的内容,对于书中剩余部分提供的示例,我们将限制我们的讨论范围到基于 ARM Cortex-M 的设备中找到的功能。我们专注于 ARM Cortex-M 微控制器,因为这些基于 Cortex-M 内核的设备带来了一组非常实用的功能,使工程师能够使用实时操作系统(RTOS)创建中等到高度复杂的实时嵌入式系统,同时仍然能够以模块化的方式设计解决方案,以便模块可以在其他项目中重用。选择 STM32 微控制器是因为其受欢迎程度、广泛的微控制器系列、易于接近的集成电路(IC)封装以及包含的硬件外设。虽然本章我们专注于 STM32 产品,但请记住,市场上还有许多其他制造商提供了大量优秀的产品,而且大部分内容也适用于非 STM32(和非 ARM)产品。
MCU 考虑因素
在选择微控制器本身时,需要考虑一些因素,而不是开发板。假设项目包含完全定制的电子设备,所选择的微控制器没有限制,就像你只选择开发板时会有限制一样。学生和爱好者有时会人为地进一步限制自己,有时会忠诚于某些生态系统,并且只从那些生态系统中的开发板中选择(例如 Arduino 或 mBed)。虽然这些生态系统本身确实没有固有的错误,但如果你无法考虑其他解决方案或欣赏每件硬件为特定项目带来的独特优势,你将无法作为一个专业工程师成长。
核心考虑因素
首先,我们将讨论如何解决一些关键问题,这些问题将立即缩小潜在 MCU 候选人的范围:
-
它是否合适?
-
它能否运行我的所有代码?
-
它的成本是多少?
-
它是否容易获得?
让我们逐一回答这些问题。
物理尺寸
根据设计不同,微控制器的尺寸可能是一个重要因素。如果你正在开发可穿戴或便携式设备,尺寸可能排在你的首要位置。有时,预包装的微控制器可能太大,设计师不得不求助于板上芯片(即微控制器硅晶圆直接焊接在印刷电路板(PCB)上,而不是放置在单独的塑料封装中)。另一方面,大型机架式设备通常有足够的空间容纳任何适合完成工作的微控制器尺寸。
对于那些对设计自己的硬件感兴趣的人来说——封装类型将在 PCB 复杂性以及组装的便利性(尤其是在原型阶段)中发挥作用。如果你的原型将手工组装,任何鱼鳍式封装,如四边扁平封装(QFP),都是最易接近的。在 QFP 之后,无铅四边扁平封装(QFN)封装仍然容易手工焊接。球栅阵列(BGA)通常最好避免手工组装,除非你是焊接大师!
ROM
只读存储器(ROM)是同一家族中微控制器(MCU)的一个相当大的差异化因素,ROM 大小与价格密切相关。根据产品家族中可用的不同型号数量,可能会有多个具有非常相似外设集的 MCU。这些 MCU 可能具有相同的物理尺寸,但内存量却有很大差异。如果你的应用对成本敏感,但所需的 ROM 未知,请考虑以下方法:
-
选择一个提供兼容尺寸中多个闪存大小的 MCU 家族。
-
从具有家族中最大 ROM 的 MCU 开始开发。这为添加功能提供了最大的灵活性。
-
在确定最终映像大小后,可以在开始大规模生产之前选择具有较小闪存大小的确切 MCU。
当采取这种方法时,你需要确保为未来的功能留出足够的空间,假设你的产品将能够接收固件现场更新。此外,务必在型号之间检查外设分配——引脚兼容并不总是意味着固件兼容!
需要的只读存储器(ROM)量差异很大,这取决于需要加载到设备上的代码量。如果你一直在使用 8 位微控制器(MCU),那么当你转向 ARM 这样的 32 位架构时,可能会遇到一个令人不快的惊喜。在 32 位架构上实现与 8 位架构上实现类似的程序需要更多的闪存空间。好消息是,闪存大小一直在增长,所以几乎总是可以找到具有足够板载闪存的 MCU 来满足你的应用需求。将第三方库拉入你的代码库在闪存方面通常成本较高,所以如果你选择走这条路,请务必谨慎。
RAM
芯片上随机存取存储器(RAM)的量是另一个需要考虑的因素——它通常遵循特定设备上的闪存量。具有较大 ROM 的部件通常会有更多的 RAM。需要大量 RAM 的几个例子包括需要大缓冲区进行数据处理、复杂的网络堆栈、用于通信的深度缓冲区、GUI(特别是那些需要帧缓冲区的 GUI)以及运行虚拟机的任何解释型语言(即 MicroPython 和 Lua)。
例如,假设您的应用程序需要高分辨率显示屏。如果显示屏没有内置控制器和自己的帧缓冲区,那么您可能已经进入了外部 RAM 的领域。驱动这种类型显示屏所需的缓冲区大小可能会超过 MCU 上可用的 RAM。另一方面,如果您正在构建一个具有有限连接性和 UI 能力的简单控制系统,那么可能只需要少量 RAM。
此外,请注意,FreeRTOS 中的每个任务都需要自己的堆栈(通常在 Cortex-M 端口上至少需要 512 字节),因此如果需要大量任务,很快就会利用到几个 KB 的 RAM。
从固件工程师的角度来看,外部 RAM 似乎是一张免罪卡(谁不想将可用 RAM 增加近一个数量级)——但所有这些功能都需要付出代价。除非您的系统绝对需要它,否则最好避免在 MCU 的地址/数据总线上使用外部 RAM。它将需要额外的 PCB 空间,消耗更多电力,并最终推高 PCB 和物料清单(BOM)成本。由于长度调整要求和涉及的信号数量,添加用于访问外部 RAM 的外部高速并行总线会使 PCB 布局变得相当复杂。由于所有高速信号,设计也更有可能产生 EMI。尽管外部 RAM 提供了充足的空间,但通常比板载 RAM 慢一些,这可能导致链接器文件更加复杂(如果某些函数有非常严格的时序约束)。其他值得考虑的因素包括,如果您尝试通过使用数据缓存来加速外部 RAM 的访问,则需要正确设置 RAM 时序参数和缓存一致性问题(有关详细信息,请参阅进一步阅读部分)。
尽管有诸多缺点,但外部 RAM 能够实现许多功能,例如在 RAM 中缓存整个固件映像以进行升级、功能丰富的 GUI 框架、复杂的网络堆栈和复杂的信号处理技术。与其他任何要求一样,需要做出权衡。
CPU 时钟频率
由于我们将讨论限制在具有相同底层架构的 MCU 上,具有更快核心时钟频率的 MCU 将比具有较慢时钟频率的 MCU 更快地执行同一组纯软件函数。注意前述语句中的关键词纯——有时,某些板载硬件外设可以极大地影响执行速度,而这些外设与 CPU 时钟频率无关(例如,Cortex-M4 核心上可用的硬件浮点数和 DSP 功能)。
另一个需要注意的问题是设备的绝对最大时钟频率与应用于应用的实用时钟频率之间的对比。例如,一些 MCU 的最大时钟频率与生成 USB 外设所需的内部 48 MHz 时钟不兼容,因此如果 USB 外设也使用,则无法以最大速度运行。
中断处理
在 ARM Cortex-M 系列中,中断处理都非常相似。所有设备都包括一个嵌套向量中断控制器(NVIC),具有可重定位的向量表和一个外部中断控制器(EXTI)。特定于设备的考虑因素包括确切的外设中断及其如何映射到 NVIC,以及外部中断如何多路复用到 EXTI。
价格
根据应用的不同,BOM(物料清单)的成本可能是一个重要的驱动因素,也可能几乎不被考虑。通常,在高产量应用中,BOM 成本会受到越来越多的审查。然而,对于低产量产品,通常更明智的做法是更多地关注最小化产品的开发时间和努力,而不是追求尽可能低的 BOM 成本。通过专注于最小化低产量产品的工程努力和开发时间,产品可以更快地进入市场。这不仅使产品更快地产生收入,而且也减少了非重复工程(NRE)成本。较少的 NRE 使得正在开发的产品能够更快地实现投资回报率(ROI)。更快的 ROI 最终会让经理和 CEO 们非常高兴!在这些情况下,担心为每年只销售几十个的产品花费几美元的 BOM——以牺牲数周或数月的发展努力为代价——通常不是一个明智的权衡。
可用性
初级工程师经常忽视的一个方面是 MCU 的预期和保证的可用性。仅仅因为一个部件在项目开始时可以购买,并不意味着在整个最终产品销售期间都可以购买。在消费设备的情况下,这可能不是一个大问题。这是因为这些设备可以具有极高的产量,但任何单一版本的生产时间都是有限的(从几个月到一两年)。
将消费电子产品的计划性淘汰心态与工业、电信或航空航天领域的某些产品进行对比。在这些行业中,开发时间表可以以年为单位衡量,所需的支持期通常为十年或更长。这就是为什么零部件的可用性是一个非常现实的考虑因素。务必调查制造保证的可用性,并将这些与他们的历史、声誉和项目风险进行权衡——在完成设计 80%的时候发现 MCU 在预生产阶段无法采购,这可不是什么愉快的体验!
现在我们已经讨论了一些需要了解的初步考虑因素,我们将继续讨论嵌入式处理器的一些更独特的考虑因素——硬件外围设备。
硬件外围设备
与桌面世界的 CPU 相比,处理器本身通常是关注的中心,由于范围的增加,选择正确的 MCU 更为复杂。同一芯片上包含了许多不同的硬件组件,这使得我们能够针对速度、功耗、CPU 利用率或 BOM 成本进行优化。在高度受限的设计中,所有这些因素都可能发挥作用,并且需要做出权衡。
本节将介绍一些在基于 Cortex-M 的 MCU 上常见的硬件外围设备,并旨在提供对这些设备的非常简短的介绍,目的是让您了解为什么在设计中有每种类型的外围设备可能会有所帮助。
连接性
在今天不断增长的物联网(IoT)生态系统中,MCU 上具有板载网络功能对于项目来说可能是一个福音……只要存在正确的固件来驱动它。重要的是要认识到,拥有外围设备并不意味着拥有完整的功能。例如,仅仅因为 MCU 支持简化媒体独立接口(RMII)和网络物理层(PHY),并不意味着您可以立即获得完整的 TCP/IP 堆栈——所有这些固件功能都需要从其他地方获得。设备中可能内置的潜在连接性包括以太网、RMII、802.11(WiFi)、802.15.1(蓝牙)和 802.15.4(Zigbee、HART 等)。
当涉及到无线通信时,事情会变得稍微复杂一些,因为产品需要根据地理位置通过适当的机构的审批流程。预认证的射频(RF)模块可以用来最小化开发一个正确认证的最终产品所需的工作量和成本。
由于专门的 PCB 布局、法规要求和复杂的网络堆栈,便于无线通信的板载 MCU 外围设备对于低量产品来说并不像最初看起来那么有用。再次提醒,不要因为仅仅指定了一个具有硬件的部分而陷入虚假的成就感,因为无线通信堆栈可能非常复杂,无线认证测试成本高昂。
内存保护单元
内存保护单元(MPUs)用于确保代码只访问它被允许访问的 RAM 范围。当正确使用时,MPUs 可以确保更高的系统稳定性和增强的安全性,因为应用程序由于不太可能通过访问它不应访问的内存而产生意外后果。
FreeRTOS 包括对 MPU 保护任务的支持,我们将在第十五章,FreeRTOS 内存管理中介绍。
硬件浮点单元
如果你的应用程序将要处理大量的浮点数,一个硬件****浮点运算单元(FPU)可以非常有帮助。直到过去十年左右,浮点数在大多数基于 MCU 的嵌入式系统中通常最好避免使用。更快处理器的可用性开始改变这一点。现在,FPUs 通常在硬件中实现。多亏了 FPUs,许多不同的应用程序可以受益于使用浮点数学,而不会遭受与基于软件库实现通常相关的 CPU 性能惩罚。
单精度(32 位)FPUs 在 Cortex-M4 处理器上是可选的,而基于 Cortex-M7 的处理器增加了对双精度(64 位)浮点运算的可选硬件支持。
数字信号处理功能
除了基于硬件的浮点支持的性能提升外,基于 Cortex-M4 和 Cortex-M7 的 MCU 还包含可选的数字信号处理(DSP)功能,这些功能集成在硬件中,可以极大地加速一些复杂算法,并可能有助于减轻固件工程师的编码负担。
直接内存访问通道
直接内存访问(DMA)在需要高带宽或高度事件驱动的代码的各种情况下可以非常有用。DMA 控制器通常能够与 MCU 外设以及 RAM 的不同部分交互。它们负责填充外设寄存器和 RAM,而无需涉及 CPU。这些自主传输通过大大减少中断负载和上下文切换,可以显著节省 CPU 时间。
关于 DMA 外设需要注意的一点是,并非所有通道都始终映射到所有外设。某些通道的带宽可能比其他通道高。这在需要多个高带宽设备的系统中尤为重要。对于这些具有挑战性的系统,固件和硬件工程师共同工作以确保硬件设计不会在将来给固件带来障碍是非常重要的。
通信接口
我们已经介绍了相对于以太网和无线技术的外部网络连接。有许多不同的通信接口更传统地与嵌入式设备相关联,并且通常作为 MCU 上的硬件外设可用。用于与板载和非板载传感器和执行器通信的接口如下:
-
片上互连通信(I2C)
-
串行外设接口(SPI)
-
通用同步/异步收发器(USART)
在汽车和工业环境中,以下外设通常用于模块间通信:
-
USARTs
-
控制器局域网(CAN)
-
局部互连网络(LIN)
硬件加密引擎
如果您的应用程序需要外部连接,那么您的心思也应该专注于安全性。与 FPUs 使浮点运算更高效一样,一些 MCU 上提供了基于硬件的加密引擎,这将在公共网络上安全传输数据时大大减轻 CPU 的负担。
定时硬件
在 MCU 上通常包含多个不同的定时外设。这些外设本身通常包括输入捕获、输出比较和脉冲宽度调制(PWM)功能作为最低要求。一些设备还会包括用于与正交编码器接口的定时硬件。
输入捕获处理的是 MCU 数字输入状态变化的捕获时间。MCU 外设使用高频率计数器和硬件门来捕获信号转换(而不是依赖于多个 CPU 指令),因此它们能够以比固件更高的分辨率完成这项工作。通常会有多个输入捕获通道可用,这些通道可以并行使用。输出比较实际上是输入捕获的相反操作(输出一个具有精确时序特性的信号)——比较指的是用于确定何时执行转换的硬件比较器。
PWM 和输入捕获在控制系统中都非常常用,用于与传感器和执行器交互。一些执行器将以 PWM 作为输入。PWM 还可以用于提供二进制驱动器(如晶体管)的比例控制,这可以用来精确地改变加到负载上的功率。许多不同的编码器通常也会以 PWM 格式提供信息,这些信息可以通过 MCU 使用定时器外设的输入捕获模式读取。
正交编码器输入(QEIs)在运动反馈系统中非常有用。尽管可以使用多个输入捕获通道(或缓慢地,不使用任何专用硬件)实现类似的功能,但拥有专门的 QEI 硬件允许在高速输入率下进行极少的 CPU 干预。
集成模拟
数字到模拟转换器(DACs)和模拟到数字转换器(ADCs)用于在连续变化的模拟值及其相关数字表示之间进行转换。这些类型的板载外设通常比外部芯片的分辨率和频率低。然而,根据您系统的需求,它们可以非常有用。另一个有用的外设是板载比较器,它会在模拟值高于或低于给定阈值时向处理器发出信号。
一些更专业的设备(例如,Cypress PSoC)包括完全可重新配置的模拟外设(包括运算放大器、数模转换器(DAC)和模数转换器(ADC))以及灵活的数字外设,灵活地将非常广泛的功能集集成到信号芯片中。模拟器件和 Maxim 提供了一些更奇特的混合信号微控制器(MCU),它们倾向于将特定应用组件集成到与 MCU 相同的芯片上,使得针对特定最终产品的开发更加容易。你可以找到针对从工业过程控制、汽车距离感应、物联网传感器到电视遥控器应用等各个领域的非常广泛的应用特定 MCU。
如果一个混合信号问题有一个流行的用例,那么很可能有一个完全集成的硅片,它集成了大多数所需的模拟前端 MCU,以解决大部分问题。那么问题就变成了平衡 BOM 成本、详细规格、尺寸、开发时间和长期采购风险缓解。
专用触摸接口
由于触摸界面的普及率提高,一些 MCU 现在包含了完整的触摸控制器实现。这可以大大减少实现一个完全功能性和健壮的触摸界面所需的专业知识和努力。
显示接口
通常存在于具有较高性能、较大引脚数、复杂的显示接口甚至图形加速的设备中。在大量部件上可以找到并行 LCD/TFT 接口(例如,6800 和 8080),这些接口能够驱动价格低廉、高分辨率的显示器,甚至只有几行的显示器。硬件协议转换 IC 可以用来适应多种不同的显示标准,如 LVDS 和 HDMI。现在微控制器(MCU)能够提供丰富的用户体验,包括硬件加速和高效编写的中间件和驱动程序。CPU 负载也是完全可以接受的。
外部存储器支持
在引脚数较多的封装中,可以期待找到对静态随机存取存储器(SRAM)的支持。同步动态随机存取存储器(SDRAM)的支持,由板载控制器负责处理严格的时序要求和刷新周期,可以在高性能设备中找到。针对性能的设备通常会引入对四线 SPI 的支持。通常,外部 RAM——甚至四线 SPI 设备——可以被内存映射并类似于内部存储器使用,尽管会有性能损失。许多设备还配备了多媒体卡(MMC)和安全数字卡(SD 卡)控制器,因此可以轻松添加商品级消费级可移动存储。
实时时钟
硬件日历在一些设备上也是可用的;所需的所有东西只是一个 32 kHz 的晶振和一个备用电源,例如 CR2032 的碱性锂电池。通常,这种功能还会附带一定量的由电池供电的 RAM。
音频支持
通过Inter-IC Sound(I²S)提供高保真音频支持是常见的。你可以期待在这些总线上找到连接到 I²S 外设的 DMA 通道,以最小化 CPU 干预的数据密集型 DAC 的喂食和从 ADC 收集数据所需的量。
这就结束了我们在评估 MCU 时需要关注的硬件外设的长列表。接下来是一个对任何对设计电池或能量收集设备感兴趣的人都有特定兴趣的话题:功耗。
功耗
低功耗 MCU 已经是一个超过十年的趋势。然而,由于市场上出现了大量电池供电的基于物联网的设备,历史上是一种具有有限选项的专用用例(如 16 位的 MSP430)现在已经成为主流。现在,完整的 32 位 MCU 已经可用,它们可以在低功耗深度睡眠和高时钟速率、数据处理运行模式之间快速切换。
功耗效率
虽然听起来很简单,但确保某物消耗更少电力的一个相当好的方法就是将其关闭(别笑——这可能会因涉及的部件而变得非常复杂,多亏了各种漏电流!)。如果具有数十个外设的复杂 MCU 有任何希望实现高效能,就需要有一种方法来关闭不需要的功能,以最小化浪费的电力。这通常是通过关闭未使用的外设的时钟,并确保基于 CMOS 的 I/O 引脚不悬浮(记住,CMOS 设备中的转换消耗了最多的电力)来实现的。
数据表中通常还会找到另一个规格,即 CPU 时钟频率每 MHz 消耗多少功率——通常以µA/MHz 表示。如果每次唤醒期间的处理器处理量相当恒定,这为比较不同的 MCU 型号提供了另一个指标。
低功耗模式
针对低功耗应用的设备通常会有几个不同的关闭状态可供选择。这些状态将允许程序员在电流消耗、可用功能(如保持 RAM 内容完整和某些外设开启)、可触发的唤醒事件的中断数量以及唤醒时间之间进行权衡。幸运的是,许多低功耗物联网应用的操作范围相当有限,因此有时某个特定 MCU 中的新颖特性组合将非常适合特定的应用。
唤醒时间
如果一个设备具有惊人的低关断电流,但需要异常长的时间来唤醒并进入可用的运行状态,那么它可能不是适用于需要频繁唤醒的应用的最佳选择,因为会有大量时间被花费在使系统启动和运行上,而不是进行必要的处理然后进入休眠状态。
电源电压
较低的电源电压通常会导致较低的电流消耗。根据设计,可以在消除功率调节电路(由于效率低于 100%而消耗电流)和扩展特定电池单元的可用工作电压范围之间进行权衡。MCU 的电压要求(以及任何辅助电路)将是影响调节方面灵活性的一个驱动因素。此外,请注意,最大时钟频率通常与供电电压成比例,因此不要期望能够在最大指定频率和最低可能的供电电压下驱动 CPU。
项目中期迁移 MCU
有时,项目需求在初期可能无法完全知晓,或者第一天可能没有人对如何确切解决每一个详细问题有 100%的信心。如果你幸运地意识到存在高度的不确定性,那么提前规划总是比被意外所困要好。以下是一些选择属于更大家族或生态系统的 MCU 可以帮助减轻与项目不确定性相关的一些风险的领域。
引脚兼容性的重要性
在规划潜在的 MCU 转换时,如果可能的话,提前确定具有引脚兼容性的替代 MCU。例如,NXP LPC1850 的部件与 LPC 4350 MCU 引脚兼容。STM32 设备在家族(和封装)内部都是引脚兼容的,但偶尔也会与其他家族几乎引脚兼容(例如 STM32M4 和 STM32M7)。ST 定期发布针对那些已经“成长”超过一个 MCU 家族并需要更强大功能的工程师的迁移指南。如果提前选择了几个可能的候选者和替代品,PCB 上的简单跳线配置可能有助于在不同性能(和成本)显著不同的 MCU 之间进行迁移,从而有助于减少项目中期 PCB 返工所需的时间。
外设相似性
大多数 MCU,在给定系列中,将继承相同的外围 IP。硅供应商并不一定每次创建一个新的 MCU 系列时都会从头开始重新设计外围设备,因此对于属于特定供应商的外围设备,在寄存器映射和行为上通常存在大量的重叠。通常,如果你的应用程序最初只使用最基本的外围功能的一部分,那么你可能很幸运地能够使用大量相同的驱动程序,即使供应商决定在 MCU 系列之间大幅改变他们的 API。讽刺的是,有时,原始硬件在时间上的稳定性比其上层的抽象层更高。
MCU 系列的概念
许多硅供应商都有设备系列的概念,意法半导体(STM)也不例外——数据手册通常是针对整个设备系列编写的。STM 系列中设备之间最显著的区别通常是 RAM/ROM 和封装大小。然而,在更强大的设备中也会添加额外的外围设备——例如,更大的封装将开始包括并行 RAM 控制器。具有更多 RAM/ROM 的设备将包括更强大的定时器外围设备、更多的通信外围设备或特定领域的外围设备,例如加密模块。
在给定设备系列中的设备之间迁移应该很容易,因此建议从系列的某一端开始(通常建议从高端开始)并看看项目会走向何方。如果范围蔓延被控制在最小范围内,那么在所有主要功能开发完成后,可能可以轻松地将 MCU 降级,从而节省一些 BOM 成本。
这就结束了我们对原始 MCU 的考虑清单。然而,仅仅购买一个芯片并将其放在桌子上对我们编写固件并没有多大帮助。我们需要一种方法来供电设备,并与它通信——我们需要一个开发板!
开发板考虑事项
开发板是工程师在项目早期开发阶段使用的任何硬件。开发板不仅适用于 MCU;它们对许多不同类型的硬件都很有用——从运算放大器到现场可编程门阵列(FPGA)。
MCU 开发板应提供一些关键功能:
-
辅助电路,用于供电和运行 MCU
-
一种编程和与 MCU 通信的方法
-
用于轻松连接外部电路的连接器
-
可能包含一些有用的板载 IC,以测试一些外围设备
在评估 MCU 时,有许多不同的途径可以选择。我们目前正处于一个硬件便宜且普遍可用的时期。正因为如此,有大量的选项可供选择来评估硬件。一块原型硬件通常倾向于分为三个主要类别,每个类别都有其优势和劣势。当然,如果你有特定的要求,你也可以自己制作开发板或原型。
开发平台是什么以及为什么它很重要
就我们的目的而言,开发平台是一个产品生态系统,它允许在多个供应商之间实现高度抽象。平台的主要焦点是以尽可能少的努力提供大量功能,当主要目的是尽可能快地创建原型时,这是极好的。
为了在多个供应商之间提供大量功能,标准化接口、易用性和灵活性往往被强调。有了这些价值观,平台本身(正如它应该的那样)成为焦点,而特定设备的个别差异化特征往往被忽视,也就是说,除非你对它们进行特定编码,这需要额外的时间,并最终要求你更多地关注开发 针对 平台,而不是 与 平台一起开发。
生态系统围绕着平台发展,温和地引导平台用户进入工具、工作流程、最小公分母功能集和可用硬件。如果目标是尽可能快地制作出概念验证,这都很好。然而,如果使用平台方法要找到长期开发和生产级的解决方案,平台很可能会需要高质量、非常成熟和稳定。这通常意味着使用基于多个供应商提供的工业标准平台(如 SMARC、QSeven 和 COM Express),而不是当前创客空间中的“年度流行款”:

核心问题是,一个平台的主要兴趣通常在于使平台易于使用,以便获得更广泛的采用。这种易用性可能导致常见的接口(如前图所示),这在快速原型设计期间可以提高生产力,但会抽象掉底层硬件的相当大的差异。通常,平台特定的功能非常重要,以至于平台代码会简单地虚拟化接口以使其更易于访问,这通常伴随着非常显著的成本(即位打孔 PWM 或 SPI,以便它们可以被分配到特定的预定引脚)。因此,如果你选择使用平台来评估硬件(或具体来说,是微控制器),你应该意识到你很可能会评估的是该硬件上的平台及其实现,而不是硬件本身。
评估套件
当涉及到标准化尺寸与关注硬件本身之间的差异时,评估套件(评估套件)位于光谱的另一端。评估套件通常配备有最大的、功能最丰富的 MCU 型号,并且仅出于展示该硬件的目的而制造。这意味着它们通常不会在不同目标 MCU 之间共享相同的尺寸或连接器(参见图表)。因为每个 MCU 都有不同的主要功能和针对不同的市场。评估板将尽可能将外围设备连接到实际的连接器上(例如串行、以太网、SD 卡、CAN 总线以及多个 USB)。它们通常还包括一系列外围硬件,如 RAM、eMMC、按钮、滑块、电位计、显示屏和音频编解码器以驱动扬声器。它们几乎总是将所有有趣的 MCU 引脚连接到易于访问的引脚头上,这样开发者就可以快速尝试任何他们能想到的特定硬件配置。制造商通常还会在评估套件板上展示他们自己的其他非 MCU 硅芯片,以推动与自身产品相关的更多销售:

为了最好地展示设备的性能,评估套件还会附带大量的工作示例代码,允许工程师与包含的所有外围设备进行交互。与通常在基于平台的实现中找到的通用实现不同,这些示例针对的是要评估的目标设备的独特区分特性。在本章的第一部分,我们讨论了根据特定用例选择硬件。如果你在寻找针对设计挑战方面的特定解决方案,拥有一个指导性的示例代码,它展示了 MCU 的关键区分特性,可以节省大量时间(并且是启发性的),相比于从头开始实现所有这些功能来说。
所有这些功能都需要付出代价——完整的评估套件通常需要几百美元。然而,如果你的目标是快速评估具有特定目的的潜在 MCU,它们可以通过节省工程时间和降低风险来迅速收回成本。
低成本演示板
近年来,低成本演示板真正地发挥了它们的作用。价格已经大幅下降;制造商有时会将演示板以与它们上面放置的裸 IC 相同的成本出售(有时甚至比购买单个 IC 还要便宜!)。与硬件平台不同,这些板通常会有相似的尺寸,但并不一定有相同的连接器或引脚排列。
最近,市场上出现了更多低成本演示板,这些板模糊了平台和演示板(也称为演示板)之间的界限。得益于 Arduino®的普及,大多数低成本板至少会有一组与 Arduino 引脚兼容的引脚。然而,兼容引脚的可用性和拥有一个完全拥抱生态系统的开发板是两回事。演示板可能没有与这些引脚配套的软件;仅仅因为硬件存在并且可以插入板中,并不意味着你将自动获得用于驱动硬件的目标 MCU 的兼容库。这并不意味着它们不兼容,但要将它们启动并运行所需的努力将远大于简单地插入板子并跟随“hello world”演示。
一些制造商还在创建自己的标准化引脚,这些引脚在演示板之间是通用的,这至少在迁移到不同产品系列时是有帮助的(但显然仅限于该制造商)。ST Nucleo 和 NXP Freedom 标准化引脚是一些例子。为了使这些板更加用户友好,它们通常还会具备 mBed 兼容性。
现在你已经了解了不同类型的开发板,我们将更详细地研究单个制造商的微控制器系列——STM32 系列。
介绍 STM32 产品线
在过去的几年里,STM 已经开发了一系列相当广泛的 MCU。在这里,我们将讨论该产品组合中的一些主要部分是如何与本章“MCU 考虑因素”部分中讨论的考虑因素相对应的。大多数其他供应商也将他们的产品组织成主要部分,这通常使选择过程变得更容易一些。
主流
STM32 系列始于 2007 年的 STM32F1,这是一款基于 Cortex-M3 的 MCU。该产品线旨在服务于大多数高容量应用,在这些应用中,成本和性能必须平衡,且应用相对简单。STM32F0 和 STM32G0 部分是基于 Cortex-M0 和 Cortex-M0+的设备,旨在低成本应用。原始的 STM32F1 是基于 Cortex-M3 的,具有非常广泛的功能集,但与其他设备相比,其性能已经开始显得有些过时。
STM32F3 是 STM 首次尝试提供集成、更高精度的模拟外设(其他供应商提供的模拟组件精度高于 STM);然而,该系列在提供真正高性能模拟方面有所不足。它包括一个 16 位 sigma delta ADC,但有效位数(ENOB)仅声明为 14 位,略好于 STM 的 12 位逐次逼近(SAR)ADC 外设,这些外设通常包括在内。基于 Cortex-M4+的较新 STM32G4 家族具有最多的模拟外设,包括许多可编程增益运算放大器(PGA)、DAC、ADC 和几个比较器,但在撰写本文时没有提供集成的高精度 ADC。
注意 STM32G0 和 STM32G4 系列,它们通过 2020 年增加了产品线的广度——它们都是较新的系列,STM 可能会开始用许多更多设备来填充这些系列。
高性能
STM 的高性能 MCU 始于基于 Cortex-M4 的 STM32F4。Cortex-M4 与 Cortex-M3 非常相似,但它包括(可选的)硬件 32 位 FPU 和 DSP 指令,这两者都存在于所有 STM32F4 设备上。高性能系列中的所有设备都能够舒适地驱动无控制器显示器的非常吸引人的 GUI,只要所需的 RAM 可用(通常位于 MCU 外部)。由各种硬件外设(如 MIPI 显示串行接口(DSI)、通过 FMC 和 DMA 的内存传输以及使用Chrom-Adaptive Real-Time(Chrom-ART)加速器的某些基本图形加速)提供的加速,使得将大量与显示通信的工作卸载到各种外设成为可能,这样 CPU 就可以花时间执行其他任务。
这里快速概述了该家族的两个主要成员和一些显著特性:
| MCU 系列 | CPU | 特性 |
|---|---|---|
| STM32H7 | Cortex-M7 (480 MHz)Cortex-M4 (240 MHz) (opt) | STM 最高性能的 MCU64 位 FPU 和扩展 DSP 指令 |
| STM32F7 | Cortex-M7 (216 MHz) | 64 位 FPU 和扩展 DSP 指令 |
| STM32F2 | Cortex-M3 | 在性能与成本之间提供权衡高集成度(摄像头接口和 USB OTG) |
这类 MCU 可以被认为是跨界 MCU。它们足够强大,可以用于一些传统上为全功能 CPU 保留的应用任务,但它们仍然具有 MCU 的易用性。
异构多核方法
2019 年,STM 推出了 STM32MP 系列,这是公司首次进入应用处理器领域。该系列提供单核或双核 Cortex-A7,频率为 650 MHz,以及一个运行在 209 MHz 的单核 Cortex-M4。STM 似乎通过关注较低的核心数量和需要较少 PCB 层以及使用成本较低的制造技术的封装,将这些解决方案定位在市场的高容量、低成本部分。
从软件角度来看,STM 的 MCU 产品与新的微处理器单元(MPU)之间的主要区别在于,由于 MPU 具有内存管理单元(MMUs),它们能够通过主线 Linux 内核运行完整的操作系统,这开辟了一个完全不同的开源软件生态系统(您不必亲自编写)。
异构多核方法允许设计人员将设计的一部分分割开来,并在最适合的领域解决它们。例如,一个具有 GUI 和网络功能的专业过程控制器可以使用 Cortex-A7 利用 Linux 并访问 Qt 框架和复杂的网络堆栈,同时使用 Cortex-M4 处理所有实时控制方面。
μClinux 的实现已经可用于 STM32F4 和 STM32F7 MCU 多年,但由于这些设备上缺乏 MMU,通常会有相当大的性能损失。
当然,随着功能的增加,复杂性也会增加。与 MCU 不同,STM32MP 系列没有绕过集成外部 RAM 的方法,因此请准备好进行一些相当复杂的 PCB 布局(与独立 MCU 解决方案相比)。
低功耗
STM 的低功耗系列直接针对电池供电的设备。以下是所有不同系列成员的快速比较:
| MCU 版本 | CPU | 描述 |
|---|---|---|
| STM32L | Cortex-M0 | 该系列性能和 RAM 以及 ROM 空间最低,但提供相当好的效率。 |
| STM32L1 | Cortex-M3 | 具有更高的 ROM 容量,以增加功耗为代价提供更快的性能。 |
| STM32L4 和 STM32L4+ | Cortex-M4 | 具有增强的计算能力(STM32L4+还提供更快的时钟速度和更大的内部闪存存储)。 |
| STM32L5 | Cortex-M33 | 将性能和功耗相结合,并集成了最新的 ARM v8 架构。Cortex-M33 提供了额外的安全功能,如 Trust.Zone,并且比 Cortex-M4 执行指令更高效,这允许在保持功耗可控的同时提供额外的性能。 |
根据特定物联网应用的精确工作负载,使用低功耗 32 位 MCU,如上表中的那些,可能是合理的。然而,对于非常简单的应用,也应考虑低功耗的 8 位和 16 位 MCU。
无线
STM32WB 实现了 Cortex-M4,并配备了一个专用的 Cortex-M0+来运行蓝牙 BLE 堆栈。这一系列提供了大量的集成,从大容量闪存和 RAM 到混合信号模拟外设,包括触摸传感器和小段 LCD。还提供了各种安全特性,如加密算法和随机数生成器。由于使用这一系列设备的产品需要 FCC 认证,因此它们对于大量应用来说最有意义。
由于有广泛的 MCU 可供选择,STM可能有我们可以用来实验实时操作系统的东西!现在是时候继续选择要使用的开发板了。
我们是如何选择开发板的
现在我们已经讨论了选择 MCU 的重要考虑因素和一般类型的发展硬件,让我们看看本书中将要使用的开发板是如何选择的。STM 是我们唯一评估的制造商,以便将示例限制在易于消化的事物上。在实际的产品工程工作中,设计师应该重新审视所有可能的供应商。虽然每个人都有自己的跨供应商搜索偏好方式,但使用分销商网站是一种简单的方法。
感谢精心制作的以原型设计为导向的分销商网站(如 Digikey 和 Mouser),工程师能够跨多个不同供应商执行参数搜索和比较。这种方法的缺点是搜索仅限于特定分销商所携带的产品线。另一个潜在的缺点是参数搜索结果取决于分销商的数据录入准确性和分类。直接使用分销商网站的好处是,许多不同的供应商都集中在一个地方,可以立即检查产品可用性,并且可以轻松查看和筛选半真实世界的定价。
原型设计导向的分销商的定价被认为是半真实世界的,因为通常在产品进入全面生产后,使用以数量为导向的分销商或直接向供应商购买大量产品通常更经济。
需求
到目前为止,我们已经从理论层面涵盖了选择项目 MCU 的所有考虑因素。现在,是时候将所有这些付诸实践,并选择用于所有实际动手练习的 MCU。以下是一些需要注意的事项:
-
我们将选择开发板和 MCU——因此,一些需求将针对开发板,这对于需要长期生产的产品来说通常不是一个好主意。
-
需求将专门针对这本书——使某物成为好书的选择标准可能不适用于你正在工作的项目。显然,你的选择标准将根据你的项目进行调整。
必需品——即需求——如下:
-
您开发板上的目标微控制器将是一个基于 STM32 Cortex-M 的 CPU。
-
目标微控制器必须有一个内存保护单元(MPU)。
-
开发板必须有可见的方式来显示状态。
-
相对较低的成本(例如< 50 美元)。
那些理想中的特性——即所需的愿望——如下:
-
通用性:如果开发板可以与其他硬件一起使用,那将是非常好的。
-
通过 USB 进行虚拟通信可作为调试端口。
-
多核。
需求合理性
我们将把搜索范围限制在 STM32 系列,因为这是我们在这本书中使用的示例系列。在第十五章,“FreeRTOS 内存管理”中,我们将介绍如何防止任务访问它们不应访问的内存,这需要我们使用具有内存保护单元的部件。本书的一个目标是将硬件交互尽可能易于访问,这导致了下一个两个需求:状态显示和成本。
一种显示状态的方式可能转化为简单的 LED(理想情况下是多个)。应该有一些形式的反馈,以便程序员能够一目了然地看到正在发生的事情,以确保代码实际上正在做些什么。在真实的嵌入式环境中,这可能会采取额外的仪器形式,例如示波器、逻辑分析仪和万用表。为了使尽可能多的人能够访问,我们将明确避免使用这些工具。因此,而不是仅仅依赖外部工具和调试器,我们将寻找板载指示器。
那些理想中的特性不是硬性要求,但它们是系统在理想世界中应具备的期望品质。理想情况下,目标开发板也应是更大硬件生态系统的一部分,这样人们就可以使用他们可能已经拥有的现有硬件来进一步探索本书中的概念。如果目标微控制器上有一个 USB 端口,那将是非常棒的——这样我们就可以使用虚拟通信端口来输出调试信息,而不仅仅依赖于调试器。最后,在第十六章,“多处理器和多核系统”,我们将简要介绍一些关于使用多个 CPU 开发我们的板子的技巧。尽管这个主题值得单独成书(已经有很多从架构角度写的书),但有一些我们可以实际在硬件上运行的代码将会很棒。
选择开发板
好的——我们现在知道我们要找什么了,所以让我们开始吧!如前所述,分销商网站是一个很好的起点,因为它们提供了出色的参数搜索功能。如果你是硬件人员,使用相同的搜索引擎将减少寻找零件所需的时间。当然,这种方法并不完美,所以如果你正在寻找一个非常具体的零件,你可能最好直接访问你最喜欢的制造商的网站并在那里搜索:

这里是我们使用美国一家流行的分销商 DigiKey 缩小搜索范围的步骤示例:
-
从 DigiKey 的主页
www.digikey.com/,我们开始搜索 STM32,并从选择中选择了 MCU 和 DSP 的评估板。 -
我们只对目前可用的零件感兴趣(不是过时的)。
-
开发套件也必须备有库存。
这里是结果:

根据我们对 STM32 产品线的了解,已经选择了带有内存保护单元的 MCU。目前,这个标准提供了 73 个可用的开发平台,价格范围从 9-550 美元——相当广泛。我们已经覆盖了大多数硬性要求——除了板载指示器,这不太可能在搜索中出现。让我们看看是否可以通过包括一些期望来进一步缩小范围。
STM 将他们的微处理器称为微处理器单元(MPU),这创造了一个术语的过度使用,并在谈论内存保护单元(MPU)时也带来了一些歧义。当你浏览网站和文档时,你可能会遇到这个缩写的两种用法。
如果我们在寻找多核处理器,那么 STM32H7 和 STM32MP1 系列是有效的选择。结果证明,这些零件的发现板以相对合理的成本(考虑到它们包含的内容)80 美元出售,但我们理想中寻找的是接近 20 美元范围的硬件——不要让期望干扰了硬性要求!
将焦点缩小到不违反低于 50 美元定价要求的处理器,我们来到了 STM Nucleo 系列开发板。Nucleo 系列的所有产品都与 mBed 兼容,这将允许我们使用整个生态系统,如果我们选择使用的话。STM 对 Nucleo 的另一个认识是支持现有的流行平台是个好主意——因此,除了在专有引脚上几乎全部提供更相关的 MCU 引脚外,Nucleo 板还提供了各种 Arduino 风格的引脚。所有的 Nucleo 板还包括一个 ST-Link 板载编程器,其中一些可以重新编程以看起来与 SEGGER J-Link 相同,这是一个巨大的优点,并将消除购买额外硬件的需求:
| 制造商 | 零件编号 | 描述 | 相关特性 |
|---|---|---|---|
| NUCLEO-L432KC | Nucl32 平台 | Cortex-M4,一个用户 LED,用户 USB,Arduino Nano v3 兼容,未列为板载 SEGGER J-Link 兼容 | |
| NUCLEO-F401RE | Nucleo64 平台 | Cortex-M4,一个用户 LED,用户 USB 通过重新枚举,Arduino Uno v3 引脚,板载 SEGGER J-Link 兼容 | |
| NUCLEO-L4R5ZI | Nucleo144 平台 | Cortex-M4,三个用户 LED,专用用户 USB OTB,ST Morpho,Arduino Uno v3 引脚,板载 SEGGER J-Link 兼容 | |
| NUCLEO-F767ZI | Nucleo144 平台 | Cortex-M7,三个用户 LED,专用用户 USB,以太网,Arduino Uno v3 引脚,板载 SEGGER J-Link 兼容 |
我们的目标平台将是 Nucleo-F767ZI,它集成了最广泛的连接性,并提供了最大的调试灵活性。我们将在第六章 Debugging Tools for Real-Time Systems 中介绍如何重新闪存板载 ST-Link 以使用 SEGGER J-Link 固件。三个用户 LED 将使反馈固件状态变得非常简单,因为不需要配置任何额外的接口。内置以太网允许开发网络应用程序。对于我们的目标平台,一个多核 MCU 将非常方便,但没有一个符合我们的成本要求:

虽然在选择 MCU 或开发板时有很多选择,但这个过程并不需要令人畏惧,尤其是当你知道你的需求和需要做出的权衡时。
摘要
本章讨论了选择合适微控制器(MCU)的许多考虑因素,我们通过分析本书中使用的开发板选择过程中所做出的权衡来解释了选择过程。你现在应该理解了选择 MCU 的重要性,并且有足够的背景知识开始研究和选择适合你项目的 MCU。如果你是跨学科团队的一员,你将更有能力与你的同事就使用各种 MCU 的权衡进行交流。
在下一章中,我们将进行类似的练习,比较各种类别的集成开发环境(IDE),并选择一个合适的 IDE 来编写本书中找到的练习,从第七章 The FreeRTOS Scheduler 开始。
问题
在我们总结时,这里有一份问题列表,供你测试你对本章材料的了解。你将在附录的评估部分找到答案:
-
为什么固件工程师需要了解他们正在编程的 MCU 很重要?
-
在选择性能型 MCU 时,时钟速度是唯一起作用的因素:
-
正确
-
错误
-
-
今天的 MCU 除了 CPU 外还包含许多不同的硬件组件。这些硬件组件通常被称为什么?
-
电池
-
硬件外设
-
缺陷
-
-
列出使用平台方法进行开发的一个优点。
-
列出使用功能齐全的评估板进行开发的一个优点。
-
在设计低功耗应用时,列出两个重要的设备特性。
-
为什么选择了一款价格低廉的开发板用于这本书?
进一步阅读
-
AN4839:关于 STM32F7 设备上 1 级缓存系统的 STM 应用笔记:
www.st.com/content/ccc/resource/technical/document/application_note/group0/08/dd/25/9c/4d/83/43/12/DM00272913/files/DM00272913.pdf/jcr:content/translations/en.DM00272913.pdf -
关于 SEGGER ST-Link 板载调试器的更多信息:
www.segger.com/products/debug-probes/j-link/models/other-j-links/st-link-on-board/
第五章:选择 IDE
集成开发环境(IDE)有能力极大地帮助或阻碍开发。根据项目的具体目标,IDE 可能非常容易集成到工作流程中,或者简单地成为障碍。IDE 旨在具有较小的学习曲线,并且通常会提供一种简单的方法来从现有的驱动程序和中间件构建解决方案。
在本章中,我们将讨论如何选择 IDE,探讨不同类型的 IDE,并选择一个用于创建本书中使用的代码包中所有源代码的 IDE。
这里是一个我们将涵盖的主要主题的快速列表:
-
理想中的 IDE 选择标准
-
平台抽象化 IDE
-
开源/免费 IDE
-
专有 IDE
-
选择本书的 IDE
技术要求
本章没有软件或硬件要求(软件将在最后安装)。
理想中的 IDE 选择标准
选择 IDE 的决定可以在组织的许多不同层面上进行。单个工程师可能只使用 IDE 来完成一个项目。在这种情况下,他们可能会简单地选择他们熟悉的东西,或者该项目附带的东西是微控制器单元(MCU)。在光谱的另一端,整个部门可能会将 IDE 集成到他们的开发工作流程中。在这种情况下,这个决定可能会影响数十名工程师,并在未来几年内解决多个目标平台。
一些工程师根本不需要 IDE——相反,他们会使用他们最喜欢的文本编辑器和命令行编译器或链接器(例如 GCC 或 Clang),手工制作一些 makefile,然后开始编码。这也是一种完全可行的做法——这将带来极大的灵活性,减少对专有工具的依赖,当然应该加以考虑。
下面的章节中列出的 IDE 列表并非详尽无遗。列表的目的是提供各种 IDE 的广泛示例以及每个 IDE 的不同焦点。以下是一些需要考虑的快速要点:
-
语言支持:现在嵌入式 MCU 上的代码不再全部使用 C99(或汇编);有许多语言选项可供选择。
-
调试支持:除非你计划每次都切换到不同的工具,否则调试是必要的。你的 IDE 应该具备一些调试能力。许多 IDE 将依赖于GNU 调试器(GDB)作为底层调试协议,这意味着它们应该与支持 GDB 接口的任何调试硬件兼容。
-
线程感知调试:理想情况下,IDE 将具有线程感知调试功能。记住,每个任务都有自己的堆栈。默认情况下,大多数调试功能只会显示与当前程序计数器关联的堆栈,当尝试分析当前未运行的任何任务时,这会成为一个问题。
-
设备支持:选择一个可以识别您设备中硬件寄存器的 IDE(除非您不会用它进行调试)。
-
平台操作系统:即 Windows、Linux 或 macOS——您始终可以运行虚拟机,但通常在您首选的操作系统上本地运行 IDE 更为方便。
-
成本:工具的初始成本应考虑货币价格和将其集成到团队工作流程中所需的时间量。这个初始成本与组织/个人的当前状态紧密相关。例如,将熟悉的 IDE 设置起来并运行所需的时间(成本)通常非常低,但这种成本取决于团队熟悉的内容。高总拥有成本可能由许多不同的因素引起。一些 IDE 是免费提供的,但可用性波动(供应商提供的 IDE 通常属于此类)。如果 IDE 有很多错误,由于生产力下降,拥有成本可能会很高。最后,付费的专有解决方案通常需要年度维护协议以获得支持、最新硬件的更新和新版本。
-
与其他工具的集成:开发嵌入式系统由许多组件组成。拥有尽可能集成的 IDE 是有帮助的,但需要考虑的一些项目包括目标硬件、测试夹具、调试硬件、RTOS 固件、用户固件、目标中间件、辅助主机软件以帮助配置硬件(即 STM32Cube)、帮助您分析和调试代码的软件(即静态分析工具),以及测试框架。
-
易用性:理想情况下,IDE 将提供一个愉快的编码环境,并通过自动化交叉引用(如 IntelliSense)使代码创建更容易,从而提高生产力。
-
可用性:在一个完美的世界里,原始 IDE 应该是可用的、完全受支持的,并提供更新以充分利用整个产品或项目生命周期中的新硬件目标。对于长期项目,检查您计划使用的 IDE 的历史(以及许可模式)是一个好主意。如果 IDE 仅通过订阅提供(没有永久许可选项),那么总有一天它可能不再可用。确保始终有永久许可在手,可以使您无限期地运行 IDE,并确保您始终能够从源代码重新生成二进制文件。
-
生态系统:大多数 IDE 不仅包含 IDE 本身,还包括插件、中间件、论坛,有时甚至还有与之相关的整个开发者社区。
在接下来的几节中,我们将介绍几个不同的 IDE 概念组。以这种方式对 IDE 进行分组并不特别严格,但它确实有助于设定对它们的动机和用例的期望。有时,一个 IDE 可以放入多个组中,这也是完全可以接受的。我们将用来分类 IDE 的组如下:
-
免费微控制器供应商 IDE 和以硬件为中心的 IDEs
-
平台抽象化 IDEs
-
开源/免费 IDEs
-
专有集成开发环境(IDEs)
本章中展示的示例 IDE 追溯到 2019 年。虽然嵌入式系统固件开发工具的变化速度并不像其他软件学科那么快,但预计随着时间的推移,这个领域的外观会有所不同!
免费微控制器供应商 IDEs 和以硬件为中心的 IDEs
现在,较大的微控制器制造商通常会提供免费 IDE 的访问权限,以帮助降低潜在开发者的入门门槛。从历史上看,这些 IDE 提供的功能并不多,如果你每天使用它们,通常很难使用。然而,在过去的几年里,由于芯片制造商试图与竞争对手区分开来,IDE 的质量有了提升。有时,它们集成了额外的功能,可以帮助配置硬件和/或供应商提供的驱动程序,这在硬件开发、初始板级启动和早期固件开发期间很有帮助,此时硬件外设正在被锻炼并与系统其他部分集成。
由于这些工具不是硬件制造商的核心业务关注点,它们经常会随意更改。这使得供应商 IDE 成为长期项目的风险选择。
几乎为了证明这一点,STM 在撰写本书时更改了他们的 IDE 产品。所有示例都需要导入到新的软件中:STM32CubeIDE。
由于我们的目标是使用 STM32 微控制器,我们将查看 STM 提供的 IDE(截至撰写本文时,这是 STM32CubeIDE)。对于不同的微控制器供应商,你可以考虑他们的专有 IDE——例如,如果你在 NXP 微控制器上进行开发,你可能会考虑 MCUXpresso。
STM32CubeIDE
在 STM 的情况下,同一微控制器供应商提供了多个 IDE。在收购 Atollic 之后不久,STM 将 Atollic TrueStudio 的完全定制版本整合到他们的 STMCubeMX 应用程序中,从而产生了 STM32CubeIDE。尽管 Atollic TrueStudio 仍然可用,但它已被弃用,不推荐用于新设计。
这里是 STM32CubeIDE 的快速统计数据:
| 网站 | www.st.com/en/development-tools/stm32cubeide.html |
|---|---|
| 主机操作系统 | Windows、Linux 或 macOS |
| 调试器支持 | GDB、STLink、JLink、JTrace 等 |
| IDE 框架 | Eclipse |
| 编译器 | GCC、可扩展 |
| 成本 | 免费 |
| 许可类型 | 专有——免费软件 |
现在我们已经了解了仅针对一个微控制器设计的集成开发环境(IDEs),接下来我们将看看与供应商提供的 IDEs 极为相反的:平台抽象化的 IDEs。
平台抽象化的 IDEs
随着越来越复杂的微控制器(MCUs)的融合、对设备功能的期望不断膨胀以及开发周期的缩短,许多软件开发工具公司不得不专注于在硬件之上创建抽象,目的是使复杂设备的开发更加容易和快速。在市场上经过几年的发展后,最成功的平台和抽象往往能够独立生存。Mbed 和 Arduino 都拥有庞大的用户社区,许多用户创建的网站和博客都致力于每个平台。
由于平台一致性对于易用性至关重要,实现时通常会包括许多专注于易用性的功能,有时可能会牺牲性能和良好的设计实践。例如,一些硬件目标会暴露一个 API,例如 PWM 输出,尽管底层微控制器硬件没有支持该功能的外围设备。这为许多不同的硬件目标提供了更快的原型设计体验,因为 API 将无缝地将功能映射到软件例程。然而,这可能会对设备性能产生负面影响。有时程序员甚至没有意识到他们简单 API 调用背后所做出的复杂权衡。
有许多不同的因素决定了围绕平台进行项目是否是一个好主意。
在以下情况下,在平台上进行设计可能是好的:
-
平台几乎包含了您所需的所有功能:如果平台已经包含了所有主要部分,那么不确定性很小;所需的就是一些特定领域的代码。
-
您打算目标设备有一个满足您确切要求的开发板:这比尝试添加许多缺失的子电路并将其正确集成到现有平台代码中要减少许多不确定性。
-
团队中的大多数工程师都已经对该平台有深入的经验:深入的经验意味着他们已经添加了与项目所需定制类似的功能。
在以下情况下,在平台上进行设计可能会出现问题:
-
很少有团队成员有使用所需平台的经验:有些平台比其他平台更复杂——没有第一手经验可能会存在风险。
-
您打算使用的微控制器尚未被平台支持:通常有许多辅助要求需要添加微控制器支持到平台中,这些要求对您的项目没有价值。将新设备支持添加到现有的复杂平台中,对于一个非常简单的项目来说,比在裸金属或使用最小供应商提供的库上创建项目需要更多的努力。
-
黑盒调试困难:随着嵌入式工程师越来越远离硬件,理解系统为何以这种方式运行变得越来越困难,尤其是在需要挖掘多层他人代码(OPC)的情况下。
一个年轻的实时嵌入式工程师的专业发展可能会因为早期过多地投入时间和精力到平台上而受到严重影响。随着所有这些额外复杂性的增加,在实时系统中可靠地满足截止日期的风险和不确定性也随之增加。深入复杂的代码库以尝试追踪复杂的间歇性错误确实是一个挑战。如果没有坚实的基础的低级知识可以借鉴,这个挑战会变得更加严峻。
在接下来的几节中,我们将介绍一些平台抽象化 IDE 的选项。
ARM Mbed Studio
ARM Mbed 是一个专注于物联网的平台,提供非常庞大的中间件库和跨许多不同硬件供应商的一致开发环境。最初,Mbed 平台仅通过网站提供,但现在他们已经增加了 Mbed Studio——一个适用于 Windows 和 macOS 的离线 IDE。
这里是 ARM Mbed Studio 的简要统计信息:
| 网站 | os.mbed.com/studio/ |
|---|---|
| 主机操作系统 | Windows,macOS,或在线(Mbed 在线) |
| 调试器支持 | pyOCD 用于有限的图形调试或 GDB(仅控制台) |
| IDE 框架 | Theia |
| 编译器 | ARM Compiler 6,GCC,和 IAR |
| 成本 | 免费 |
| 许可类型 | Apache 2.0 |
由于 Mbed 是面向平台的,项目可以使用 Mbed IDE 设置,然后导出到各种离线 IDE,例如 ARM Keil uVision,或者基于 makefile 的项目,这些项目可以导入到 Eclipse 和 Visual Studio Code。如果你的项目需要包含的中间件提供的功能,并且实现良好,那么不需要重新发明轮子可以节省大量时间。
Arduino IDE
Arduino 平台是一个极其普及的平台,拥有庞大的硬件和软件生态系统。通常用于向新入门者介绍电子和编程,Arduino IDE 使用严格结构的库,为用户提供了 C/C++方言来编写草图。Arduino 的目标是让非程序员能够快速、轻松地进行原型设计。因此,它尽可能在库中隐藏了底层硬件的细节。
这里是 Arduino IDE 的简要统计信息:
| 网站 | www.arduino.cc/en/main/software |
|---|---|
| 主机操作系统 | Windows,macOS,Linux,和在线 |
| 调试器支持 | 无 |
| IDE 框架 | 专有 Java,处理 |
| 编译器 | avg-gcc,板级特定 |
| 成本 | 免费 |
| 许可类型 | GNU |
还有许多其他非 Arduino 提供的 IDE 可以用来编程 Arduino 平台。其中一些将具有额外的功能,并暴露更多底层 C/C++实现。
现在我们已经涵盖了完全抽象的 IDE,我们将转向更传统导向的 IDE,这些 IDE 是开源的和/或免费提供的。
开源/免费 IDE
自从 IBM 创建了 Eclipse 基金会以推广一个开源、高度可扩展的 IDE 以来,许多基于 Eclipse 的 IDE 已经出现。在本节中,我们将探讨其中两个这样的 IDE。近年来,微软开始高度重视开源项目,创建了免费的开源 Visual Studio Code 文本编辑器,该编辑器也包含在本节中。
AC6 System Workbench for STM32 (S4STM32)
AC6 是一家咨询公司,它贡献了一个针对 STM32 微控制器的基于 Eclipse 的 IDE。System Workbench 添加了一些对基于 STM 的发现板的支持,以帮助快速设置项目。AC6 还提供了适用于 Linux 的 System Workbench,如果你正在使用 STM32MP1 系列的多核设备之一开发应用程序,这可能很有用。
这里是 AC6 System Workbench for STM32 的快速统计数据:
| 网站 | ac6-tools.com/content.php/content_SW4MCU.xphp |
|---|---|
| 主机操作系统 | Windows、macOS 或 Linux |
| 调试器支持 | GDB |
| IDE 框架 | Eclipse |
| 编译器 | GCC |
| 成本 | 免费 |
| 许可证类型 | 商业免费 |
System Workbench 的另一个替代方案是从裸 Eclipse CDT 安装开始。
Eclipse CDT 和 GCC
你也可以选择从头开始自己构建基于 Eclipse 的 IDE。Eclipse CDT 是 C/C++开发的既定标准。你还需要提供一个编译器。ARM 提供了一个完整的 GCC 网站,用于从 Windows、Linux 和 macOS 交叉编译到 ARM Cortex-M 设备。它可以在developer.arm.com/tools-and-software/open-source-software/developer-tools/gnu-toolchain/gnu-rm找到。
这里是 Eclipse CDT 的快速统计数据:
| 网站 | www.eclipse.org/cdt/ |
|---|---|
| 主机操作系统 | Windows、macOS 或 Linux |
| 调试器支持 | GDB |
| IDE 框架 | Eclipse |
| 编译器 | GCC |
| 成本 | 免费 |
| 许可证类型 | Eclipse 公共许可证 (EPL) |
对于那些不关心 Eclipse IDE 的人来说,另一个替代方案存在,并且越来越受欢迎:Visual Studio Code。
Microsoft Visual Studio Code
2015 年,微软发布了 Visual Studio Code,这是一个提供添加扩展功能的文本编辑器。虽然表面上听起来相当直接,但可用的扩展足够多,可以提供非常令人尊重的 IDE 体验,包括 IntelliSense 和完整的调试功能。如果你习惯了基于 Visual Studio 的 IntelliSense 和调试,那么这个环境将会非常熟悉。
这里是 Visual Studio Code 的快速统计数据:
| 网站 | code.visualstudio.com/ |
|---|---|
| 主机操作系统 | Windows、macOS 或 Linux |
| 调试器支持 | GDB、ST-Link 和其他 |
| IDE 框架 | Visual Studio Code |
| 编译器 | 许多 |
| 成本 | 免费 |
| 许可类型 | MIT |
与 Eclipse CDT 类似,Visual Studio Code 将需要安装 GCC 以及一个扩展。为了正确设置 Visual Studio,请按照以下步骤操作:
-
要安装 Cortex-M 的 GCC,请访问
developer.arm.com/tools-and-software/open-source-software/developer-tools/gnu-toolchain/gnu-rm。 -
要安装 JLink 工具(用于连接调试探针),请访问
www.segger.com/downloads/jlink。 -
要安装
cortex-debug扩展,请访问marketplace.visualstudio.com/items?itemName=marus25.cortex-debug。
到目前为止,我们讨论的所有 IDE 都是免费的(在某些情况下,是开源的)。下一节将包括需要付费且主要闭源的 IDE。你可能会问:“为什么有人会想要这样的东西?”继续阅读,了解这些解决方案能提供什么。
专有 IDE
一度是用于为 MCU 交叉编译应用程序的规范,付费的专有 IDE 开始被免费的开源解决方案所超越。然而,免费选项的存在并不立即使付费选项过时。专有 IDE 的卖点在于它们提供了最广泛的设备支持,并且需要开发者最少关注。
设计即用即装,付费的专业级解决方案的卖点在于节省开发者时间。这些时间节省通常以三种主要形式出现:用于设置 MCU 的统一环境、统一的调试环境以及多个 MCU 供应商通用的供应商提供的中间件。
现在让 MCU 启动运行比以往任何时候都要容易,但一旦项目足够高级,开始定义 RAM 和 ROM 中的特定内存区域或在基于 Quad-SPI 的闪存中添加额外的可执行空间时,就需要进行一些额外的配置。最好的专业 IDE 将提供一些帮助(通过 GUI),这使得这些配置比需要深入研究 scatter 文件和基于汇编的启动代码要容易一些(尽管这些是优秀的技能!)
与通过 GUI 快速配置 MCU 的能力相似,专业级 IDE 中的调试器支持通常也非常简单,通常限于从下拉列表中选择调试器,并可能微调一些设置。
如果您阅读了不同 MCU 可能拥有的所有选项,那么相同的 MCU 可能不会适合您进行的每一个项目,这也许并不令人惊讶。能够在保持统一界面的同时快速在 MCU 系列(甚至供应商)之间切换是一个巨大的优势。然而,陷入基于平台的方法,其中硬件接口开始被定义(以及固件 API),也可能有所限制(即 Arduino 或 MBed 硬件定义)。
使用知名公司的精心编写的中间件可以让你摆脱硬件平台只关注日间外围设备的倾向。它将重点从访问特定平台的引脚转移到访问正确抽象的 MCU 外围设备。这种区别虽然微妙但非常重要,尤其是在设计灵活性方面。精心编写的中间件将提供一致的 MCU 外围设备抽象,以及更复杂的中间件。
使用付费工具的缺点是货币成本,这需要与延迟产品发布的开发时间、劳动力和机会成本进行评估。您是否有使用中间件而不是重新发明固件轮子所能节省的时间的想法?拥有一个对任何您选择的处理器都能一致工作的 IDE 能节省多少时间?一些基本的投资回报率(ROI)计算,将现金支出与开发者时间的诚实和准确估计进行比较,通常会将中等复杂项目的天平倾向于购买中间件。当然,这是假设有现金购买软件工具。
ARM/Keil uVision
Keil 最初在 20 世纪 80 年代开发了第一个针对 8 位 8051 架构的 C 编译器。公司后来转向支持其他核心,并最终被 ARM 收购。他们目前提供 ARM Cortex-M 设备中最高效的编译器之一(Clang/LLVM)。uVision IDE 的免费版本可用,但代码空间限制为 32 KB。IDE 的各个级别可通过多种许可选项(如永久许可、基于订阅的许可等)获得。通过软件包添加代码模块,这简化了快速设置项目的过程。作为顶级产品,提供了一套非常全面的中间件堆栈,其中包括对不同实时操作系统的抽象以及所有支持的微控制器上的统一 API。
下面是 uVision 的快速统计数据:
| 网站 | www2.keil.com/mdk5/uvision/ |
|---|---|
| 宿主操作系统 | Windows |
| 调试器支持 | 许多 |
| IDE 框架 | 专有 |
| 编译器 | armcc, armClang, GCC |
| 成本 | 免费-$$ |
| 许可类型 | 专有 |
FreeRTOS 任务感知调试不可用——Keil uVision 对其免费提供的 CMSIS RTX RTOS 有详尽的支撑。uVision MDK 中的代码编辑器也亟需更新。
与 Keil uVision 类似,IAR 嵌入式工作台是另一个长期存在的嵌入式 IDE。
IAR 嵌入式工作台
通常,IAR 嵌入式工作台与 Keil uVision 具有非常相似的功能集。一个主要区别是 IAR 没有集成模块化软件包的高级功能。与 uVision 相比,IAR 的高级调试功能更容易访问和直观。代码编辑器同样令人失望。
下面是 IAR 嵌入式工作台的快速统计数据:
| 网站 | www.iar.com/iar-embedded-workbench/ |
|---|---|
| 宿主操作系统 | Windows |
| 调试器支持 | 许多 |
| IDE 框架 | 专有 |
| 编译器 | 专有 |
| 成本 | $$–$$ |
| 许可类型 | 专有 |
现在我们已经涵盖了老牌产品,接下来我们将探讨最近可用的产品,从 Rowley CrossWorks 开始。
Rowley CrossWorks
Rowley Crossworks 是比 Keil 和 IAR 略便宜的入门级产品。中间件许可与 IDE 分开。IDE 内部不支持 FreeRTOS 感知的任务调试;相反,支持CrossWorks 任务库(CTL)RTOS 解决方案。
下面是 CrossWorks 的快速统计数据:
| 网站 | www.rowley.co.uk/ |
|---|---|
| 宿主操作系统 | Windows、macOS 或 Linux |
| 调试器支持 | 许多 |
| IDE 框架 | 专有 |
| 编译器 | GCC, LLVM |
| 成本 | \(–\)$$ |
| 许可类型 | 专有 |
接下来是一个由以调试硬件闻名的公司创建的 IDE:SEGGER 嵌入式工作室。
SEGGER 嵌入式工作室
SEGGER——我们将使用的调试探针制造商——也提供许多软件产品,包括他们自己的 IDE(和 RTOS)。它对非商业用途免费提供,没有任何限制。他们还提供完整的中间件堆栈,该堆栈的许可证是独立于 IDE 的。FreeRTOS 感知调试可以直接在 IDE 中通过适当的插件进行。
这里是嵌入式工作室的快速统计数据:
| 网站 | www.segger.com/products/development-tools/embedded-studio/ |
|---|---|
| 主机操作系统 | Windows、macOS 或 Linux |
| 调试器支持 | SEGGER |
| IDE 框架 | 专有 |
| 编译器 | GCC、LLVM |
| 成本 | 非商业用途免费或$$-$$ |
| 许可证类型 | 专有,JLink 作为许可证狗 |
我们将以一个有趣的观点结束付费 IDE 的列表:SysProgs Visual GDB。
SysProgs Visual GDB
Visual GDB 实际上不是一个 IDE。它是 Microsoft Visual Studio 和 Visual Studio Code 的插件。它已经存在了一段时间(自 2012 年以来)。Visual GDB 的主要目的是为与 GDB 启用调试器和 GNU make 实用程序交互提供一个一致的 UI(Visual Studio)。其主要目标用户是熟悉 Visual Studio 作为开发环境并希望在嵌入式工作中继续使用该环境的程序员。
这里是 Visual GDB 的快速统计数据:
| 网站 | sysprogs.com/ |
|---|---|
| 主机操作系统 | Windows、macOS 或 Linux |
| 调试器支持 | 是 |
| IDE 框架 | Visual Studio、Visual Studio Code |
| 编译器 | GCC、ARM |
| 多核调试 | 是 |
| 成本 | $ |
| 许可证类型 | 专有 |
Visual GDB 提供与图形配置实用程序 STM Cube 以及 Arduino 项目的集成,因此从不同的开发框架迁移可能更容易一些。
接下来,我们将选择一个专门用于我们用例的 IDE,即开发编码练习。
选择本书中使用的 IDE
现在我们已经对几个不同的 IDE 进行了分类,是时候考虑哪一种将被用于本书剩余部分涵盖的示例代码了。为了保持低成本的主题,以降低入门门槛,我们将关注那些不需要任何货币投资的 IDE——任何免费提供给非专业使用(无时间或代码限制)的东西都可以考虑。这立即排除了 Keil uVision、IAR 嵌入式工作台和 SysProgs Visual GDB。Keil 有一个免费版本,但代码限制在 32 KB,但我们可能会很快用完,这取决于我们选择在示例中包含多少中间件。
由于本书的大部分内容也涵盖了使用 J-Link 探针进行调试,我们希望有一个支持 J-Link 或 GDB 的 IDE,在理想的世界里,IDE 还应支持任务意识 FreeRTOS 调试、实时变量监视等。FreeRTOS 内核意识调试并不是决定性的,正如我们将在下一章中看到的,因为 SEGGER Ozone 包含这一功能。
最后,IDE 应该是跨平台的,以促进任何勇敢尝试这段旅程的人的易于采用。考虑到这一套标准,我们只剩下有限的选择,如下所示:
| 潜在的 IDE | 可用免费版本 | 无代码大小限制 | 支持 SEGGER J-Link | FreeRTOS 内核意识调试 | 跨平台 |
|---|---|---|---|---|---|
| Keil uVision | ✓ | X | ✓ | X | X |
| IAR | X | N/A | ✓ | X | |
| Visual GDB | X | N/A | ✓ | ✓ | ✓ |
| Rowley CrossWorks | X | N/A | ✓ | ✓ | ✓ |
| VS Code | ✓ | ✓ | ✓ | ✓ | ✓ |
| Eclipse CDT | ✓ | ✓ | ✓ | ✓ | ✓ |
| AC6 S4STM32 | ✓ | ✓ | ✓ | ✓ | ✓ |
| Arduino IDE | ✓ | ✓ | ✓ | X | ✓ |
| ARM MBed Studio | ✓ | ✓ | ✓ | X | ✓ |
| STM32CubeIDE | ✓ | ✓ | ✓ | ✓ | ✓ |
| SEGGER Embedded Studio | ✓ | ✓ | ✓ | ✓ | ✓ |
那么,我们可以从这张表和之前的观察中得出哪些主要观点?
-
Eclipse CDT 是一个潜在的选择,但由于与其他解决方案相比需要额外的设置,因此它稍微不那么受欢迎。
-
VS Code 是一个可扩展的代码编辑器(开箱即用),类似于 Eclipse。需要额外的插件。
-
STM32But IDE 承诺提供专业级的调试能力和多任务 RTOS 意识的调试。
-
SEGGER Embedded Studio 承诺提供与 STM32CubeIDE 非常相似的功能集。
我们将使用 STM32CubeIDE 进行代码示例。由于 STM32CubeIDE 还包含 STM32 系列微控制器的代码生成器,让我们看看使用代码生成工具的一些优点,以及需要做出的权衡。
考虑 STM32Cube
STM32CubeIDE 是两个组件的合并——IDE 和 STM32 微控制器的 STMCubeMX 图形配置和代码生成工具。CubeMX 组件在开发周期的几个不同点可能很有用。让我们谈谈相关的开发周期阶段,确定 CubeMX 如何帮助,以及权衡是什么。
设备选择
大多数现代微控制器(MCU)都有将外设映射到多个不同引脚的选项。然而,每个引脚通常由几个不同的外设共享。因此,在一个引脚受限的设备上,可能存在所需的外设可用(存在于微控制器上),但不可访问(无法映射到物理引脚)。硬件设计师可以快速评估 STM32 微控制器的各个型号是否提供了特定应用所需的必要外设组合。能够在多个芯片上快速准确地执行这些评估可以节省大量时间。通常,设计师在做出此类决定之前需要熟悉每个芯片的数据表。CubeMX 绝对不能替代适当的尽职调查,但它确实有助于快速缩小潜在设备的范围。
STM32 微控制器上的每个外设都可以单独关闭,这可以节省电力。随着电池供电(以及能量收集)物联网设备的普及,降低功耗是一个热门话题。降低功耗的另一种方法是降低芯片的时钟频率。CubeMX 允许工程师快速计算芯片在特定配置下的功耗。在调查潜在微控制器时,速度和准确性都很重要。通过在 CubeMX 中输入外设/时钟配置来获取准确的功耗估计,比翻阅数据表并从头创建电子表格要快得多。
一旦选择了目标微控制器并制造了定制硬件,就到了启动那块新硬件的时候了。
硬件启动
硬件启动是指首次开启定制设计的硬件,并在其上进行一定程度的验证。与开发/评估板相比,定制硬件通常会有很多不同之处(毕竟它是定制的!)。可能不同的一个领域是时钟硬件。STM32 的时钟树相当复杂——单个时钟源为许多不同的子系统供电。时钟频率在过程中通过乘法和除法器被修改。CubeMX 包含一个图形向导,可以帮助正确配置 STM32 时钟树并生成初始化代码,以便快速启动芯片。
还需要早期的固件工作来验证硬件是否正常工作。始终检查微控制器是否可以配置为访问板上存在的所有所需的外部电路是一个好主意。通常,快速评估硬件的可行性比等待固件的所有方面都完全完善更有利于每个人。
当需要使用 STM32 微控制器上包含的复杂外设时,CubeMX 可以用来快速设置从内部外设到微控制器外部引脚的引脚映射。它还包含简单的菜单驱动界面,用于选择外设应该如何配置。初始化代码会自动生成,使用 STM 的硬件抽象层(HAL)驱动程序。相关外设中断也会被配置和为用户提供占位符。这使得嵌入式工程师能够尽可能快地完成验证。
在所有硬件都经过验证之后,将是时候添加位于底层驱动程序和应用固件之间的额外固件层(中间件)了。
中间件设置
STM 多年来与许多不同的中间件提供商合作,使客户引入额外功能的过程更加直接。例如,在 CubeMX 中可以通过几个下拉菜单选择 FreeRTOS 原语。可以设置 FAT 文件系统,以及 TCP/IP 堆栈、JPEG 图像库和 Mbed TLS。不要误解,这个工具不会像熟练的程序员那样执行高级配置,但作为最低限度,它为评估不熟悉的库提供了一个坚实的起点。一些工程师可能会发现初始配置直接符合他们的需求,这意味着有更多时间专注于解决方案的其他部分。
因此,既然我们已经选定了设备,验证了硬件,并设置了某些中间件堆栈,那么现在应该是时候开始编写最终应用程序的代码了!嗯……还不完全是。使用提供的所有这些代码会带来一些问题——让我们来看看。
代码生成权衡
虽然所有这些功能在理论上听起来都很令人难以置信,但开发人员对 CubeMX 及其在现实世界中的应用有着混合的感觉。这些担忧和权衡大多涉及工具如何融入工作流程;有时,挑战源于可用性问题。
从可用性的角度来看,CubeMX通常工作得很好;有时,它会生成无效的代码,根本无法按预期工作。这似乎是在它最初发布时更多的问题。偶尔,会发货的版本会创建无法编译的项目。然而,作为最低限度,它始终为配置较新 STM32 设备上可用的高级外设提供了一个出色的参考点。
工程师在将 CubeMX 集成到工作流程中时面临的挑战,是任何生成与用户代码紧密耦合的代码的实用工具的典型问题。最初,该工具可以用来创建一个庞大的代码库,可以快速搭建并提供所需功能的大部分。然而,随着项目的进展,调整几乎是不可避免的;将自定义用户代码与自动生成的 CubeMX 代码分开可能会变得很麻烦。您可能会发现自己陷入复制粘贴的循环,不断地从一边复制到另一边的有效代码片段。这是我们行业中的一个普遍做法。嵌入式固件工程师迫切需要摆脱复制粘贴的无穷循环。第十二章,创建良好抽象架构的技巧,讨论了在采用这些类型的工作流程时正在做出哪些权衡。它还提供了一些关于如何设置代码库以实现长期增长的建议,而不是腐烂。
就像上面所说的,我们示例中使用的代码部分将使用 STMCubeMX 生成的代码作为起点进行实现。STM32 HAL 在行业中广泛使用,因此之前编写过 STM32 的人很可能熟悉它。请记住,本书中的示例代码旨在易于理解。它旨在突出如何实现 RTOS 概念,而不是作为未来扩展的基础。使用接近 STM32 CubeMX 生成的代码的主要目的是让您更容易开始自己的实验。
设置我们的 IDE
为了编译和运行以下章节中的示例代码,需要安装 STM32CubeIDE 并将源代码库导入。STM32CubeIDE 需要安装,源代码库也需要导入。
安装 STM32CubeIDE
要安装 STM32CubeIDE,请遵循以下两个简单步骤:
-
从
www.st.com/en/development-tools/stm32cubeide.html下载 STM32CubeIDE。 -
使用默认选项进行安装。
现在 STM32CubeIDE 已经安装,我们需要导入源树。让我们看看如何做。
将源树导入 STM32CubeIDE
在安装 STM32CubeIDE 后,您需要将源树导入到 Eclipse 工作区。工作区是 Eclipse 术语,用于表示相关项目的集合:
由于 STM32CubeIDE 基于 Eclipse IDE,如果您以前使用过 Eclipse,您会发现以下说明很熟悉。
-
从
github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers下载或克隆 GitHub 仓库:-
最好的做法是将仓库的路径保持得尽可能短,不要有空格;即,
c:\projects。 -
示例中使用的基 git 路径是
c:\projects\packBookRTOS。
-
-
打开 STM32CubeIDE。
-
导入整个仓库:
-
转到菜单:文件 | 导入。
-
选择“通用”|“将现有项目导入工作区”|“下一步”。
-
浏览并选择包含仓库的文件夹(
c:\projects\packBookRTOS),选择后应类似于以下内容:
-

-
- 点击完成。下一步按钮始终是灰色。
-
在这一点上,项目资源管理器面板将显示所有导入的章节(以下截图仅显示第五章,选择 IDE和第六章,实时系统调试工具的代码,因为它们是目前唯一编写的示例):

- 为了确保一切安装正确,右键点击
Chapter5_6并选择构建。控制台窗口中的输出应类似于以下内容:

恭喜!现在您应该能够构建本书中包含的 FreeRTOS 示例项目了!
您可能已经注意到,Eclipse 项目并不完全以与磁盘上组织方式相同的方式包含文件夹(即,驱动程序和中间件不是文件系统上 Chapter5_6 的子文件夹)。这是故意为之,以便在项目/章节之间重用通用代码。这一概念在第十二章,《创建良好抽象架构的建议》中有更深入的介绍。
摘要
在本章中,我们介绍了 IDE 的概念以及为什么您可能选择使用它。我们列出了一些考虑因素,以及 IDE 的分类和最佳使用建议。所有这些材料都用于选择本书中使用的 IDE。最后,我们审视了 STMCubeMX,并讨论了它在项目不同阶段的使用。
在设计嵌入式系统代码开发的工作流程(包括选择软件工具)时,对可能做出的权衡有良好的理解,将帮助您做出明智的决定,这可以极大地影响生产力。
在下一章中,我们将继续探讨用于提高基于 FreeRTOS 的嵌入式固件项目生产力的工具。下一组工具允许您以极其方便的方式实际看到您的代码是如何运行的。
问题
-
对于每个 MCU/语言组合,都有一个最佳的 IDE:
-
真的
-
假的
-
-
专业级工作必须使用付费 IDE:
-
真的
-
假的
-
-
供应商提供的 IDE 总是用于该供应商硬件的最佳 IDE:
-
真的
-
假的
-
-
软件生成的代码总是优于人工编写的代码:
-
真的
-
假的
-
-
本书所选的 IDE 是因为它具有最佳的长久可用性和最广泛的设备兼容性:
-
真的
-
假的
-
-
列出三个 STMCubeMX 在开发过程中有帮助的发展阶段。
进一步阅读
- STM32CubeIDE 用户指南可在 https://www.st.com/resource/en/user_manual/dm00598966-stm32cubeide-quick-start-guide-stmicroelectronics.pdf找到。
第六章:实时系统的调试工具
在严肃的嵌入式系统开发中,严肃的调试工具至关重要。基于复杂 RTOS 的系统可能有许多任务和数十个需要及时完成的 ISR。有了合适的工具,判断一切是否正常工作(或为什么不正常)会容易得多。如果您一直使用偶尔的打印语句或闪烁的 LED 进行故障排除,那么您将有一个惊喜!
我们将在本书的剩余部分大量使用 Ozone 和 SystemView,但首先,我们需要设置它们并查看快速介绍。在本章的末尾,我们将探讨其他调试工具,以及减少最初编写时产生的错误数量的技术。
简而言之,在本章中我们将涵盖以下内容:
-
优秀调试工具的重要性
-
使用 SEGGER J-Link
-
使用 SEGGER Ozone
-
使用 SEGGER SystemView
-
其他优秀工具
技术要求
在本章中,将安装和配置几款软件。以下是您应该已经准备好的物品:
-
一块 Nucleo F767 开发板
-
一条 micro-USB 线
-
一台 Windows PC(ST-Link Reflash 实用程序只需要 Windows 操作系统)
-
STM32CubeIDE(ST-Link Reflash 实用程序需要 ST-Link 驱动程序)
本章的所有源代码都可以从github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers/tree/master/Chapters5_6下载。
优秀调试工具的重要性
在开发任何软件时,很容易在不考虑所有细节的情况下开始编写代码。多亏了代码生成工具和第三方库,我们能够非常快速地开发一个功能丰富的应用程序,并在相当短的时间内将其运行在实际硬件上。然而,当涉及到确保系统的每个部分 100%正常工作时,事情就变得有点困难了。如果一个系统建立得太快,并且组件在集成之前没有得到适当的测试,那么可能会有一些部分大多数时候都能正常工作,但并不总是如此。
在嵌入式系统中,通常只有底层应用程序的少数部分是可见的。从用户的角度来看,评估整个系统的健康状况可能具有挑战性。历史上,对于嵌入式工作来说,良好的调试工具比非嵌入式工作要少。到处放置打印语句只能走那么远,会导致时序问题,等等。闪烁的 LED 既麻烦又提供不了太多见解。通过硬件分析信号可以帮助验证症状,但并不总是隔离问题的根本原因。在没有帮助可视化执行的工具的情况下,试图弄清楚在事件驱动系统中实际运行的代码(以及何时运行)是非常具有挑战性的。
这就是为什么拥有各种熟悉的工具在您手中极为有用。它允许您自信地专注于开发应用程序的小部分。自信来自于严格验证每个功能,在开发过程中以及与系统其他部分的集成过程中。然而,为了执行验证,我们需要在代码的不同部分保持透明度(而不仅仅是系统外部可观察的部分)。在验证过程中,很多时候会出现需要观察任务间执行的情况。
有两个重要的领域有助于我们实现系统透明度和可观察的任务关系目标:基于实时操作系统的调试和实时操作系统可视化。
基于实时操作系统的调试
在用于裸机(例如,没有操作系统)编码的传统调试设置中,只有一个堆栈可以观察。由于编程模型是一个单超级循环和一些中断,这并不是一个大问题。在任何时候,系统的状态可以通过以下方式识别:
-
知道程序计数器(PC)所在的函数
-
知道哪些中断是激活的
-
查看关键全局变量的值
-
观察和展开堆栈
在基于实时操作系统的系统中,基本方法非常相似,但编程模型扩展到包括多个并行运行的任务。记住,每个任务实际上是一个隔离的无穷循环。由于每个任务都有自己的堆栈并且可能处于不同的操作状态,因此需要一些额外的信息来识别整体系统状态:
-
了解每个任务当前的运行状态
-
知道 PC 所在的任务和函数
-
了解哪些中断是激活的
-
查看关键全局变量的值
-
观察和展开每个任务的堆栈
由于嵌入式系统的限制性,堆栈使用通常是一个关注点,因为 MCU 的 RAM 有限。在裸机应用程序中,只有一个堆栈。在实时操作系统应用程序中,每个任务都有自己的堆栈,这意味着我们有更多要监控的内容。使用提供实时操作系统堆栈信息的调试系统有助于快速评估系统中每个任务的堆栈使用情况。
监控事件响应的最坏情况性能也是实时系统开发的一个关键方面。我们必须确保系统将及时响应关键事件。
有许多不同的方法来解决这个问题。假设事件起源于 MCU 外部的硬件信号(这在大多数情况下是正确的),可以使用逻辑分析仪或示波器来监控该信号。可以在应用程序中插入代码在事件被处理后切换 MCU 上的引脚,并监控时间差。根据系统、测试设备的访问权限以及相关事件,这种方法可能很方便。
另一种方法是结合 RTOS 中的 仪器 使用软件。使用这种方法时,会在 RTOS 中添加一些小的钩子,当事件发生时通知监控系统。然后,这些事件会被传输出 MCU 并发送到运行查看程序的开发 PC 上。这本书将重点关注这种方法——使用 SEGGER SystemView。这允许以非常少的开发工作收集大量的信息和统计数据。这种方法的一个小缺点是,由于它是一种纯软件/固件方法,因此增加了一点点不确定性。它依赖于 MCU 记录事件发生的时间,这意味着如果中断服务被显著延迟,则可能无法准确记录。此外,它还强烈依赖于 RAM 或 CPU 周期的可用性。在没有足够 RAM 的高负载系统中,这种方法可能会得出不可靠的结论。然而,这些缺点都有解决方案,并且在大多数系统中不会遇到。
RTOS 可视化
能够看到哪些任务正在运行以及它们是如何交互的也很重要。在抢占式调度环境中,任务之间可能会形成复杂的关系。例如,为了处理一个事件,可能需要几个任务相互交互。除此之外,可能还有更多任务都在争夺处理器时间。在这种情况下,一个设计不佳且持续错过截止日期的系统可能只会从用户的角度被视为 缓慢。通过任务可视化,程序员可以真正地 看到 系统中所有任务之间的关系,这有助于分析。
我们将在第八章 保护数据和同步任务中通过 mainSemPriorityInversion.c 演示,通过一个真实世界的例子来可视化此类场景。
在解开复杂的任务间关系时,能够轻松地辨别任务在一段时间内的状态非常有帮助。SEGGER SystemView 也将用于可视化任务间关系。
为了对运行中的系统进行深入分析,我们需要一种方法来连接 MCU 并从中获取信息。在 Cortex-M MCU 上,这最有效地通过外部调试探针来完成。
使用 SEGGER J-Link
调试探针是一种允许计算机与 MCU 的非易失性闪存进行通信和编程的设备。它与 MCU 上的特殊硬件(在 ARM Cortex-M 处理器上称为 Coresight)进行通信。SEGGER J-Link 和 J-Trace 系列调试探针在行业中非常受欢迎。SEGGER 还提供免费集成的有用软件。这些工具的可用性和配套软件的质量使得它们非常适合在本书中使用。
如果你计划使用付费 IDE,IDE 供应商可能有自己的专有调试探针。许多优秀的软件功能可能与其硬件绑定。例如,ARM Keil uVision MDK 与 ARM Ulink 探针集成,IAR 提供他们的 I-Jet 调试探针。此类 IDE 还与第三方探针集成,但在做出购买决定之前,请注意可能存在的权衡。
在选择 SEGGER 调试探针时有很多选项——我们将简要介绍目前可用的选项,并查看每个选项的硬件要求。
硬件选项
SEGGER 有许多不同的硬件选项,覆盖了广泛的定价和能力。要获取完整和当前的列表,请访问他们的网站www.segger.com/products/debug-probes/j-link/models/model-overview/。
模型通常分为两大类:具备完整 Cortex-M 迹线支持的调试器和不具备的。
Segger J-Trace
具备完整迹线支持的调试器被称为 J-Trace。Cortex 嵌入式迹线宏单元(Cortex ETM)是 MCU 内部的一个额外硬件组件,它允许记录所有已执行的指令。将所有这些信息从 MCU 传输出去需要一些额外的引脚来时钟数据输出(一个时钟线和 1-4 个数据线)。能够追踪 MCU 执行的所有指令使得功能如代码覆盖率成为可能,这提供了关于已执行代码量的洞察(逐行)。确切知道哪些代码行已被执行以及何时执行,这给了我们机会看到程序大部分时间花在了哪里。当我们知道哪些单独的代码行被执行得最频繁时,在需要改进性能时,就有可能优化这部分代码。
为了充分利用高级迹线功能,以下所有条件都是必需的:
-
MCU 必须具备 ETM 硬件。
-
特定的 MCU 封装必须将 ETM 信号引出到引脚上。
-
外设配置不得与其他功能共享 ETM 信号。
-
系统电路必须设计成包含 ETM 信号和连接器。
用于调试和 ETM 信号的常用连接器是一个 0.05 英寸间距的排针,其引脚配置如下(迹线信号已突出显示):

当然,所有这些功能都需要付出代价。J-Trace 模型在 SEGGER 的产品线中处于高端,无论是在功能还是价格方面(通常超过 1000 美元)。除非你正在开发完全定制的硬件,否则也请预期需要支付一个完整的评估板(超过 200 美元),而不是本书中使用的低成本开发硬件。虽然这些成本对于新产品开发期间的全额工程预算通常是完全合理的,但对于个人来说过于昂贵,难以广泛获取。
SEGGER J-Link
SEGGER J-Link 在许多不同的形式中存在,并已发展到包括几个型号。通常,高端型号提供更快的时钟速度和更丰富的体验(更快的下载、响应式调试等)。一些EDU型号以极低折扣出售,用于教育目的(因此有EDU的标识)。这些型号功能齐全,但不能用于商业目的。
Cortex-M 最常用的连接器是一个 0.05" 前距的排针,具有以下引脚配置。请注意,此连接器的引脚配置与 Debug+Trace 连接器的第一个 10 个引脚相同(参见图表)。

SEGGER 在设计不依赖于底层硬件的软件接口方面做得非常出色。正因为如此,他们的软件工具无需修改即可在不同的硬件调试器模型上运行。这也导致了本书中我们将使用的硬件选项——板载 SEGGER J-Link。
板载 SEGGER J-Link
我们在练习中将使用的 ST-Link 具体硬件变体实际上不是由 SEGGER 制造的。它是 Nucleo 开发板上已经包含的 ST-Link 电路。Nucleo 板有两个独立的子电路:编程硬件和目标硬件。
编程硬件子电路通常被称为 ST-Link。这个编程硬件实际上是一个负责与 PC 通信并编程目标硬件(STM32F767)的 STM MCU。由于 Nucleo 硬件主要针对 ARM Mbed 生态系统,ST-Link MCU 被编程为固件,该固件实现了 ST-Link 和 Mbed 功能:

为了将 Nucleo 板上的编程硬件作为 SEGGER JLink 使用,我们将用 SEGGER J-Link 板载固件替换其固件。
安装 J-Link
详细安装说明可在 SEGGER 的www.segger.com/products/debug-probes/j-link/models/other-j-links/st-link-on-board/找到。这里也包含了一些方便的注意事项。为了将板载 ST-Link 转换为 J-Link,我们将下载并安装两个软件:J-Link 工具和 ST-Link 刷新实用程序。您应该已经从上一章中 STM32CubeIDE 安装中安装了必要的 ST-Link 驱动程序:
仅当需要使用 J-Link Reflash 实用程序(它作为*.exe文件分发)时,才需要 Windows PC。如果您不使用 Windows PC 进行开发且未安装 STM32CubeIDE,请确保安装 ST-Link 的 USB 驱动程序(以下列表中的可选步骤 1)。
-
如果您尚未安装 STM32CubeIDE,请从
www.st.com/en/development-tools/stsw-link009.html下载并安装 ST-Link 驱动程序(此步骤为可选)。 -
从
www.segger.com/downloads/jlink下载适合您操作系统的 J-Link 工具。 -
安装 J-Link 工具——默认选项即可。
-
从
www.segger.com/downloads/jlink#STLink_Reflash下载 SEGGER J-Link 刷新实用程序(仅适用于 Windows 操作系统)。 -
解压
STLinkReflash_<version>.zip文件的内容——它将包含两个文件:-
JLinkARM.dll -
STLinkReflash.exe
-
现在,我们将 ST-Link 转换为 J-Link。
将 ST-Link 转换为 J-Link
按照以下步骤将 J-Link 固件上传到 Nucleo 开发板上的 ST-Link:
-
将微型 USB 线插入 Nucleo 板上的
CN1并连接到您的 Windows PC。 -
打开
STLinkReflash_<version>.exe。 -
阅读并接受两个许可协议。
-
选择第一个选项:升级到 J-Link。
Nucleo 板上的调试硬件现在实际上是 SEGGER J-Link!
现在有了 J-Link,我们将能够使用其他 SEGGER 软件工具,如 Ozone 和 SystemView,来调试和可视化我们的应用。
使用 SEGGER Ozone
SEGGER Ozone 是一款用于调试已编写应用的软件。Ozone 与创建应用所使用的底层编程环境无关。它可以在许多不同的模式下使用,但我们将重点关注导入*.elf文件并与源代码交叉引用,为使用任何工具链创建的项目提供 FreeRTOS 感知的调试功能。让我们快速了解一下在 Ozone 中将要处理的文件类型。
示例中使用的文件类型
在编程和调试嵌入式系统时,会用到几种文件类型。这些文件类型适用于许多不同的处理器和软件产品,并不局限于 Cortex-M 微控制器或本书中使用的软件。
可执行和链接格式(ELF)文件是一种具有存储比直接 *.bin 或 *.hex 文件更多内容的执行格式。*.elf 文件与 *.hex 文件类似,它们包含加载到目标 MCU 上的完整功能项目所需的全部二进制机器代码。*.elf 文件还包含指向原始源代码文件名和行号的链接。Ozone 等软件使用这些链接在调试应用程序时显示源代码:
-
*.bin:一个直接的二进制文件(只有 1 和 0)。这种文件格式可以直接“烧录”到 MCU 的内部闪存中,从指定的地址开始。 -
*.hex:通常是摩托罗拉 S-record 格式的变体。这种基于 ASCII 的文件格式包含绝对内存地址及其内容。 -
*.elf:包含可执行代码以及一个用于交叉引用每个内存段到源文件的标题。这意味着单个 ELF 文件包含足够的信息来编程目标 MCU,并且可以交叉引用创建二进制内存段所使用的所有源代码。
ELF 文件不包含实际使用的源代码,它只包含指向内存段与原始源代码交叉引用的绝对文件路径。这就是我们能够在 Ozone 中打开 *.elf 文件并在调试时逐步执行 C 源代码的原因。
*.svd:包含将寄存器和描述映射到目标设备内存映射的信息。通过提供准确的*.svd文件,Ozone 将能够显示在调试 MCU 外设代码时非常有用的外设视图。
*.svd 文件是一种通常与支持您的 MCU 的 IDE 一起提供的文件。例如,STM32Cube IDE 中 *.svd 文件的位置是
C:\ST\STM32CubeIDE_1.2.0\STM32CubeIDE\plugins\com.st.stm32cube.ide.mcu.productdb.debug_1.2.0.201912201802\resources\cmsis\STMicroelectronics_CMSIS_SVD.
在嵌入式系统开发中,还有其他一些文件类型被使用。这绝对不是一份详尽的列表——只是我们在示例项目中会使用到的一些。
安装 SEGGER Ozone
要安装 SEGGER Ozone,请按照以下两个简单步骤操作:
-
下载 SEGGER Ozone:
www.segger.com/downloads/jlink/ -
使用默认选项进行安装。
现在,让我们来介绍创建一个支持 FreeRTOS 的 Ozone 项目的必要步骤,并快速浏览一些有趣的功能。
创建 Ozone 项目
由于 Ozone 完全独立于编程环境,为了使用它进行调试,需要进行一些配置,这些配置将在以下步骤中介绍:
本书源代码树中包含的所有项目都已为它们创建了 Ozone 项目。以下步骤仅供参考——您只需为您的未来项目执行这些步骤。本书中所有项目的 Ozone 项目文件,即*.jdebug文件,都已包含。
-
首次打开时,选择“创建新项目”提示。
-
对于“设备”字段,选择 STM32F767ZI。
-
对于外设,输入
STM32F7x7.svd文件的目录和位置:

-
在“连接设置”对话框屏幕上,默认值是可以接受的。
-
在“程序文件”对话框屏幕上,导航到由 TrueStudio 生成的
.elf文件。它应该位于您的项目“调试”文件夹中:

-
保存
*.jdebug项目文件并关闭 Ozone。 -
使用文本编辑器打开
*.jdebug文件。 -
在
*.jdebug项目文件中添加一行以启用 FreeRTOS 插件(仅添加加粗的最后一行):
void OnProjectLoad (void) {
//
// Dialog-generated settings
//
Project.SetDevice ("STM32F767ZI");
Project.SetHostIF ("USB", "");
Project.SetTargetIF ("JTAG");
Project.SetTIFSpeed ("50 MHz");
Project.AddSvdFile ("C:\Program Files (x86)\Atollic\TrueSTUDIO for STM32 9.3.0\ide\plugins\com.atollic.truestudio.tsp.stm32_1.0.0.20190212-0734\tsp\sfr\STM32F7x7.svd");
Project.SetOSPlugin("FreeRTOSPlugin_CM7");
将*.svd文件复制到用于存储源代码或构建工具的位置是个好主意,因为 IDE 的安装目录可能会随时间和机器而改变。
这些步骤可以修改为设置任何由 SEGGER 系列调试器支持的 MCU 的 Ozone。
将 Ozone 附加到 MCU
是时候卷起袖子,动手实践了!如果您有一些硬件正在运行,接下来的部分将更有意义,您可以跟随操作并进行一些探索。让我们开始设置一切:
-
打开 STM32Cube IDE 并打开
Chapter5_6项目。 -
右键单击
Chapter5_6并选择构建。这将编译项目为*.elf文件(即C:\projects\packtBookRTOS\Chapters5_6\Debug\Chapter5_6.elf)。 -
打开 Ozone。
-
从向导中选择“选择打开现有项目”。
-
选择
C:\projects\packtBookRTOS\Chapters5_6\Chapters5_6.jdebug。 -
使用 Ozone 将代码下载到 MCU(单击电源按钮):

- 按下播放按钮以启动应用程序(您应该看到红色、蓝色和绿色 LED 闪烁)。
如果您的路径与创建*.jdebug文件时使用的路径不同,您需要重新打开.elf文件(转到“文件 | 打开”并选择在步骤 2中构建的文件)。
这些相同的六个步骤可以重复用于本书中包含的任何项目。您也可以通过打开不同的*.elf文件为其他项目创建.jdebug文件的副本。
您可能想将此页面添加到书签。您将在本书剩余的 50 多个示例程序中遵循相同的步骤!
查看任务
通过启用 FreeRTOS 任务视图,可以快速查看任务概览。在开发 RTOS 应用程序时,使用这些任务可能非常有益:
-
在 MCU 程序启动后,通过单击暂停按钮暂停执行。
-
现在,导航到“查看 | FreeRTOS 任务视图”:

此视图可以一目了然地显示许多有用的信息:
-
任务名称和优先级。
-
超时:一个被阻塞的任务在被强制退出阻塞状态之前有多少个滴答。
-
每个任务的堆栈使用情况(默认情况下只显示当前堆栈使用情况)。在先前的屏幕截图中(通过 N/A 看到),最大堆栈使用被禁用——(配置 FreeRTOS 以监控最大堆栈使用的详细信息将在第十七章,故障排除技巧和下一步行动中介绍)。
-
互斥锁计数:一个任务当前持有的互斥锁数量。
-
通知:每个任务通知的详细信息。
在开发基于实时操作系统的应用程序时,对系统中所有任务的鸟瞰图可以提供巨大的帮助——尤其是在开发的初期阶段。
基于任务的堆栈分析
使用非实时操作系统感知工具调试实时操作系统的一个挑战是分析每个任务的调用堆栈。当系统停止时,每个任务都有自己的调用堆栈。在特定时间点需要分析多个任务的调用堆栈是很常见的。Ozone 通过结合使用 FreeRTOS 任务视图和调用堆栈视图来提供这种功能。
在打开两个视图后,FreeRTOS 任务视图中的每个任务都可以双击以在调用堆栈视图中揭示该任务的当前调用堆栈。要按函数逐个揭示每个任务的局部变量,请打开本地数据视图。在此视图中,将显示调用堆栈中当前突出显示的函数的局部变量。
这里展示了结合基于任务的调用堆栈分析与局部变量视图的示例:

注意以下从该截图中的信息:
-
当 MCU 停止时,它处于“IDLE”任务状态(在状态列中通过执行来显示)。
-
双击“task 3”显示“task 3”的调用堆栈。目前,
vTaskDelay位于堆栈顶部。 -
双击
StartTask3会更新本地数据窗口,以显示StartTask3中所有局部变量的值。 -
StartTask3的局部变量显示了StartTask3中所有局部变量的当前值。
SEGGER Ozone 提供了对系统中所有运行任务的基于任务的调用堆栈视图和前瞻视图。这种组合为我们提供了一款强大的工具,可以深入到系统中每个运行任务的细微细节。但当我们需要系统的更大视角时会发生什么?我们不是逐个查看每个任务,而是更愿意查看系统任务之间的交互。这是 SEGGER SystemView 可以提供帮助的领域。
使用 SEGGER SystemView
SEGGER SystemView 是另一个可以与 SEGGER 调试探针一起使用的软件工具。它提供了一种可视化系统任务和中断流的方法。SystemView 通过在项目中添加少量代码来实现。FreeRTOS 已经具有Trace Hook Macros,这是专门为添加此类第三方功能而设计的。
与 Ozone 不同,SystemView 没有编程或调试功能,它只是一个查看器。
安装 SystemView
要使您的系统通过 SystemView 可见,需要两个主要步骤。软件需要安装,源代码必须被配置,以便它可以通过调试接口通信其状态。
SystemView 安装
要安装 SystemView,请按照以下步骤操作:
-
下载适用于您的操作系统(OS)的 SystemView。这是主要的二进制安装程序(
www.segger.com/downloads/free-utilities)。 -
使用默认选项安装。
源代码配置
为了使 SystemView 能够显示系统上运行的任务的可视化,它必须提供有关任务名称、优先级和任务当前状态等信息。FreeRTOS 中存在几乎满足 SystemView 所需的所有钩子。一些配置文件用于设置 FreeRTOS 中现有的跟踪钩子和 SystemView 使用的映射。需要收集信息,这就是特定 RTOS 配置和 SystemView 目标源发挥作用的地方:
本书附带源代码已经执行了所有这些修改,因此,如果您想将 SystemView 功能添加到您自己的 FreeRTOS 项目中,这些步骤仅是必要的。
-
从
www.segger.com/downloads/free-utilities下载 SystemView FreeRTOS V10 配置(使用了 v 2.52d 版本)并将FreeRTOSV10_Core.patch应用到 FreeRTOS 源树中,使用您首选的 diff 工具。 -
从
www.segger.com/downloads/free-utilities下载并集成 SystemView 目标源(使用了 v 2.52h 版本)。-
将所有源文件复制到您的源树中的
.\SEGGER文件夹,并在编译和链接时包含它们。在我们的源树中,SEGGER文件夹位于.\Middlewares\Third_Party\SEGGER。 -
将
SystemView Target Sources\Sample\FreeRTOSV10\SEGGER_SYSVIEW_FreeRTOS.c/h复制到SEGGER文件夹中,并在编译和链接时包含它。 -
将
.\Sample\FreeRTOSV10\Config\SEGGER_SYSVIEW_Config_FreeRTOS.c复制到SEGGER文件夹中,并在编译和链接时包含它。
-
-
对
FreeRTOSConfig.h做出以下更改:-
在文件末尾添加对
SEGGER_SYSVIEW_FreeRTOS.h的包含:#include "SEGGER_SYSVIEW_FREERTOS.h"。 -
添加
#define INCLUDE_xTaskGetIdleTaskHandle 1。 -
添加
#define INCLUDE_pxTaskGetStackStart 1。
-
-
在
main.c中做出以下更改:-
包含
#include <SEGGER_SYSVIEW.h>。 -
在初始化之后和启动调度器之前添加对
SEGGER_SYSVIEW_Conf()的调用。
-
由于 SystemView 是在每个任务的上下文中调用的,你可能会发现需要增加最小任务堆栈大小以避免堆栈溢出。这是因为 SystemView 库需要在目标 MCU 上运行一小段代码(这增加了调用深度和放置在堆栈上的函数数量)。有关如何调试堆栈溢出(以及如何避免它们)的所有详细信息,请参阅第十七章故障排除技巧和下一步。
使用 SystemView
在整理完 SystemView 的源代码后,使用该应用程序非常简单。要开始捕获,请确保你有运行中的目标,并且你的调试器和 MCU 连接到计算机:
-
点击播放按钮。
-
选择适当的目标设备设置(如下所示):

SystemView 需要一个运行中的目标。它不会显示停止的 MCU(因为它没有运行,所以没有事件可以显示)。确保板上的 LED 闪烁;如果它们没有闪烁,请按照将 Ozone 连接到 MCU部分的步骤操作。
- 几秒钟后,事件将流进左上角的日志事件视图中,你将看到任务在时间线中当前执行的实时图形视图:

-
时间线显示了任务执行的视觉表示,包括不同的状态。
-
事件视图显示了事件列表。选中的事件与时间线相链接。
-
上下文视图显示了所有事件的统计数据。
-
终端可以用来显示代码中的 printf-like 消息。
还有更多有用的功能,这些功能将在探索代码时介绍。
我们终于完成了开发软件的安装!如果你一直跟随着,你现在拥有了一个完全可操作的 IDE、一个 RTOS 可视化解决方案以及一个功能强大的 RTOS 感知调试系统。让我们看看在嵌入式系统开发过程中还有哪些其他工具可能是有用的。
其他优秀工具
本章介绍的工具当然不是调试和故障排除嵌入式系统可用的唯一工具。还有许多其他工具和技术,我们无法涵盖(或者由于本书中使用的工具的具体限制,它们不适合)。这些主题在下一节中提到,并在章节末尾的进一步阅读部分提供了额外的链接。
测试驱动开发
由于本章的标题以单词 debugging 开头,因此提及理想情况下不编写有缺陷代码的调试方式似乎是合适的。单元测试不是一款单独的软件,而是 测试驱动开发(TDD)——一种开发方法,它颠倒了嵌入式工程师传统上开发系统的方式。
与编写大量无效代码然后进行调试不同,测试驱动开发从编写测试开始。编写测试后,再编写生产代码,直到测试通过。这种方法往往导致代码既可测试又易于重构。由于使用这种方法对单个函数进行测试,因此生成的生产代码与底层硬件的关联性要小得多(因为测试与真实硬件关联的代码并不容易)。强制在这一级别编写测试往往会导致松散耦合的架构,这在第十三章使用队列创建松散耦合中有详细讨论。第十三章中提到的使用队列创建松散耦合的技术与单元测试和 TDD 结合使用效果非常好。
通常,TDD 在嵌入式系统中并不那么受欢迎。但如果你对这个话题感兴趣并想了解更多,可以查看专门针对该主题编写的书籍——嵌入式 C 的测试驱动开发(Test Driven Development for Embedded C)由 J*ames Grenning 撰写。
静态分析
静态分析是减少代码库中渗入的缺陷数量的另一种方法。“静态”一词指的是代码不需要执行即可进行此分析。静态分析器寻找常见的编程错误,这些错误在语法上是正确的(例如,它们可以编译),但可能会生成有缺陷的代码(例如,越界数组访问等),并提供相关的警告。
市面上有许多静态分析的商业软件包,以及一些免费提供的软件。Cppcheck 包含在 STM32CubeIDE 中(只需在项目上右键单击并选择运行 C/C++ 代码分析)。本章末尾包含了一个来自 Clang 项目的 免费开源软件(FOSS)静态分析器的链接。PVS-Studio 分析器是商业软件包的一个例子,它可以免费用于非商业项目。
Percepio Tracealyzer
Percepio Tracealyzer 是一款类似于 SEGGER SystemView 的工具,它帮助开发者可视化系统执行。Tracealyzer 的设置比 SystemView 更简单,并且提供了比 SystemView 更注重美学的用户体验。然而,由于它由不同的公司提供,因此软件的成本不包括 SEGGER 调试探头的购买。您可以在percepio.com/tracealyzer/了解更多关于 Tracealyzer 的信息。
传统测试设备
在所有用于在计算机屏幕上可视化 RTOS 行为的软件出现之前,这项任务将落到更传统的测试设备上。
逻辑分析仪自 MCU 首次出现以来就一直存在,并且仍然是嵌入式系统工程师工具箱中最通用的工具之一。使用逻辑分析仪,可以直接测量输入进入系统时和系统提供输出时的时间,以及每个任务之间的时间。查看进出 MCU 的原始低级信号提供了一种对系统中的问题时什么不对的直观感受,这是屏幕上的十六进制数字所无法提供的。习惯性地在硬件级别进行仪器化还有另一个优点——时序和其它异常行为往往在直接寻找之前就能被发现。
如果您刚开始接触嵌入式系统,您还希望获得手持式数字多用表(DMM)和示波器来测量模拟信号。
摘要
在本章中,我们讨论了为什么拥有优秀的调试工具很重要。我们将用于分析系统行为的精确工具(SEGGER Ozone 和 SystemView)已经介绍。您也被指导如何设置这些工具以供未来项目使用。在最后,我们简要提到了一些本书不会涵盖的其他工具,只是为了提高对这些工具的认识。
现在我们已经涵盖了 MCU 和 IDE 的选择,并且我们已经将所有工具整理妥当,我们有了足够的背景知识来进入 RTOS 应用开发的真正核心。
使用这个工具集将帮助您在深入探讨即将到来的章节中的工作示例时,对 RTOS 的行为和编程有更深入的理解。您还将能够使用这个相同的工具集在未来创建高性能的定制实时应用程序。
在下一章中,我们将开始编写一些代码,并更详细地介绍 FreeRTOS 调度器。
问题
-
使用本书中的工具需要购买 J-Link 硬件。
-
正确
-
错误
-
-
评估实时系统有效性的唯一方法就是等待并看看是否因为错过截止日期而出现问题。
-
-
正确
-
错误
-
-
由于 RTOS 每个任务都有一个堆栈,因此使用调试器进行调试是不可能的,因为只有主系统堆栈是可见的。
-
-
正确
-
错误
-
-
确保系统完全功能的方法是在项目结束时一次性编写所有代码并调试。
-
-
正确
-
错误
-
-
被称为每个单独模块都进行测试的测试风格是什么?
-
单元测试
-
集成测试
-
系统测试
-
黑盒测试
-
-
在开发生产代码之前编写测试的术语是什么?
进一步阅读
-
《嵌入式 C 的测试驱动开发》James Grenning著
-
SEGGER Ozone 手册 (UM08025):
www.segger.com/downloads/jlink/UM08025 -
SEGGER SystemView 手册 (UM08027):
www.segger.com/downloads/jlink/UM08027 -
Clang 静态分析器:
clang-analyzer.llvm.org -
PVS-Studio 分析器:
www.viva64.com/en/pvs-studio/
第三部分:RTOS 应用示例
本节将涵盖 RTOS 中最常用的组件。你将利用第一部分学到的知识,通过实际硬件的动手示例来应用这些知识。你将学习如何启动和运行调度器,以及如何创建任务、队列、信号量、互斥锁等。为了最大限度地利用本节内容,请确保在深入研究之前,你的开发板、IDE 和调试工具链都已准备就绪!
本节包含以下章节:
-
第七章,FreeRTOS 调度器
-
第八章,保护数据和同步任务
-
第九章,任务间通信
第七章:FreeRTOS 调度器
FreeRTOS 调度器负责处理所有任务切换决策。您可以使用 RTOS 做的最基本的事情包括创建几个任务然后启动调度器——这正是本章我们将要做的。经过一些练习后,创建任务和启动调度器将变得您非常熟悉的事情。尽管这很简单,但并不总是顺利进行(尤其是在您的前几次尝试中),因此我们还将介绍一些常见问题和解决方法。到那时,您将能够从头开始设置自己的 RTOS 应用程序,并了解如何排除常见问题。
我们将首先介绍创建 FreeRTOS 任务的两种不同方法以及每种方法的优势。然后,我们将介绍如何启动调度器以及确保其运行时需要注意的事项。接下来,我们将简要介绍内存管理选项。之后,我们将更详细地探讨任务状态,并介绍一些优化应用程序以有效使用任务状态的技巧。最后,将提供一些故障排除技巧。
本章我们将涵盖以下内容:
-
创建任务并启动调度器
-
删除任务
-
尝试运行代码
-
任务内存分配
-
理解 FreeRTOS 任务状态
-
解决启动问题
技术要求
为了执行本章的练习,您需要以下内容:
-
Nucleo F767 开发板
-
Micro USB 线
-
STM32CubeIDE 及其源代码
-
SEGGER JLink、Ozone 和 SystemView 已安装
关于 STM32CubeIDE 及其源代码的安装说明,请参阅 第五章,选择 IDE。对于 SEGGER JLink、Ozone 和 SystemView,请参阅 第六章,实时系统调试工具。
您可以在此处找到本章的代码文件:github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers/tree/master/Chapter_7。对于文本中可找到的代码片段的单独文件,请访问 src 文件夹。
您可以通过下载整个树并将其导入 Eclipse 项目来构建可以与 STM32F767 Nucleo 一起运行的实时项目。为此,请访问 github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers。
创建任务并启动调度器
为了使 RTOS 应用程序运行起来,需要发生几件事情:
-
MCU 硬件需要初始化。
-
需要定义任务函数。
-
需要创建 RTOS 任务并将它们映射到在 步骤 2 中定义的函数。
-
RTOS 调度器必须启动。
在启动调度器之后,可以创建额外的任务。如果你不确定任务是什么,或者为什么你想使用它,请查阅 第二章,理解 RTOS 任务。
让我们逐一分析这些步骤。
硬件初始化
在我们可以对 RTOS 做任何事情之前,我们需要确保我们的硬件配置正确。这通常包括确保 GPIO 线处于正确的状态、配置外部 RAM、配置关键外设和外部电路、执行内置测试等活动。在我们的所有示例中,可以通过调用 HWInit() 来执行 MCU 硬件初始化,它执行所有基本硬件初始化所需的操作:
int main(void)
{
HWInit();
在本章中,我们将开发一个闪烁几个 LED 灯的应用程序。让我们定义我们将要编程的行为,并查看我们的各个任务函数的样子。
定义任务函数
每个任务,即 RedTask、BlueTask 和 GreenTask,都与一个函数相关联。记住——任务实际上只是一个具有自己的堆栈和优先级的无限循环 while。让我们逐一介绍它们。
GreenTask 在绿色 LED 亮起的情况下睡眠一段时间(1.5 秒),然后删除自己。这里有几个值得注意的事项,其中一些如下:
-
通常,一个任务将包含一个无限循环
while,这样它就不会返回。GreenTask仍然不返回,因为它会删除自己。 -
你可以通过查看 Nucleo 板来轻松确认
vTaskDelete不允许在函数调用之后执行。绿灯只会亮起 1.5 秒,然后永久关闭。请看以下示例,这是main_taskCreation.c的摘录:
void GreenTask(void *argument)
{
SEGGER_SYSVIEW_PrintfHost("Task1 running \
while Green LED is on\n");
GreenLed.On();
vTaskDelay(1500/ portTICK_PERIOD_MS);
GreenLed.Off();
//a task can delete itself by passing NULL to vTaskDelete
vTaskDelete(NULL);
//task never get's here
GreenLed.On();
}
main_taskCreation.c 的完整源代码可在 github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers/blob/master/Chapter_7/Src/main_taskCreation.c 获取。
BlueTask 由于无限循环 while,会无限期地快速闪烁蓝色 LED。然而,由于 RedTask 在 1 秒后删除 BlueTask,蓝色 LED 的闪烁会被截断。这可以在以下示例中看到,这是 Chapter_7/Src/main_taskCreation.c 的摘录:
void BlueTask( void* argument )
{
while(1)
{
SEGGER_SYSVIEW_PrintfHost("BlueTaskRunning\n");
BlueLed.On();
vTaskDelay(200 / portTICK_PERIOD_MS);
BlueLed.Off();
vTaskDelay(200 / portTICK_PERIOD_MS);
}
}
RedTask 在第一次运行时删除 BlueTask,然后无限期地闪烁红色 LED。这可以在以下 Chapter_7/Src/main_taskCreation.c 的摘录中看到:
void RedTask( void* argument )
{
uint8_t firstRun = 1;
while(1)
{
lookBusy();
SEGGER_SYSVIEW_PrintfHost("RedTaskRunning\n");
RedLed.On();
vTaskDelay(500/ portTICK_PERIOD_MS);
RedLed.Off();
vTaskDelay(500/ portTICK_PERIOD_MS);
if(firstRun == 1)
{
vTaskDelete(blueTaskHandle);
firstRun = 0;
}
}
}
因此,前面的函数看起来并不特别——它们确实不特别。它们只是标准的 C 函数,其中有两个包含无限循环 while。我们如何将这些普通的旧函数转换为 FreeRTOS 任务呢?
创建任务
这是 FreeRTOS 任务创建的原型:
BaseType_t xTaskCreate( TaskFunction_t pvTaskCode,
const char * const pcName,
configSTACK_DEPTH_TYPE usStackDepth,
void *pvParameters,
UBaseType_t uxPriority,
TaskHandle_t *pxCreatedTask);
在我们的例子中,对前面的原型的调用如下所示:
retVal = xTaskCreate(Task1, "task1", StackSizeWords, NULL, tskIDLE_PRIORITY + 2, tskHandlePtr);
这个函数调用可能比预期的要长一些——让我们将其分解:
-
Task1:实现组成任务的无限while循环的函数名称。 -
"task1":这是一个用于在调试期间引用任务的友好名称(这是在 Ozone 和 SystemView 等工具中显示的字符串)。 -
StackSizeWords:为任务栈保留的字数。 -
NULL:可以传递给底层函数的指针。确保在调度器启动后任务最终运行时,指针仍然有效。 -
tskIDLE_PRIORITY + 2:这是正在创建的任务的优先级。这个特定的调用将优先级设置为比 IDLE 任务的优先级高两级(当没有其他任务运行时运行的任务)。 -
TaskHandlePtr:这是一个指向TaskHandle_t数据类型的指针(这是一个可以传递给其他任务的句柄,以便程序化地引用任务)。 -
返回值:
**x**TaskCreation的x前缀表示它返回某些内容。在这种情况下,根据堆空间是否成功分配,返回pdPASS或errCOULD_NOT_ALLOCATE_REQUIRED_MEMORY。你必须检查这个返回值!
在启动调度器之前,至少需要创建一个任务。因为启动调度器的调用不会返回,所以在调用启动调度器之后,将无法从main中启动任务。一旦调度器启动,任务就可以根据需要创建新的任务。
现在我们已经对创建任务所需的输入参数有了很好的了解,让我们来看看为什么检查返回值是如此重要。
检查返回值
在main函数中创建一些任务并在启动调度器之前,检查每个任务创建时的返回值是必要的。当然,有许多方法可以实现这一点。让我们看看其中的两种:
- 第一种方法是使用包含内联无限
while循环的if语句包裹调用:
if( xTaskCreate(GreenTask, "GreenTask", STACK_SIZE, NULL,
tskIDLE_PRIORITY + 2, NULL) != pdPASS){ while(1) }
- 第二种方法是使用 ASSERT 而不是无限
while循环。如果你的项目有 ASSERT 支持,那么使用 ASSERT 会比使用无限while循环更好。由于我们的项目已经包含了 HAL,我们可以使用assert_param宏:
retVal = xTaskCreate(BlueTask, "BlueTask", STACK_SIZE, NULL, tskIDLE_PRIORITY + 1, &blueTaskHandle);
assert_param(retVal == pdPASS);
assert_param是一个 STM 提供的宏,用于检查条件是否为真。如果条件评估为假,则调用assert_failed。在我们的实现中,assert_failed会打印出失败的函数名称和行号,并进入一个无限while循环:
void assert_failed(uint8_t *file, uint32_t line)
{
SEGGER_SYSVIEW_PrintfHost("Assertion Failed:file %s \
on line %d\r\n", file, line);
while(1);
}
你将在第十七章“故障排除技巧和下一步”中了解更多关于使用断言以及如何配置它们的信息。
现在我们已经创建了一些任务,让我们启动调度器,让我们的硬件上的代码运行,并观察一些灯光闪烁!
启动调度器
在我们有这么多创建任务选项的情况下,你可能认为启动调度器会是一件复杂的事情。但你会发现它非常简单:
//starts the FreeRTOS scheduler - doesn't
//return if successful
vTaskStartScheduler();
是的,只需一行代码,没有输入参数!
函数名前的v表示它返回 void。实际上,这个函数永远不会返回——除非有问题。这是vTaskStartScheduler()被调用的时候,程序从传统的单超级循环过渡到多任务实时操作系统(RTOS)。
在启动调度器后,我们需要考虑和理解任务的不同状态,以便我们可以正确地调试和调整我们的系统。
以下是我们通过各种示例构建的main()函数的全部内容。此摘录来自main_taskCreation.c:
int main(void)
{
HWInit();
if (xTaskCreate(GreenTask, "GreenTask",
STACK_SIZE, NULL,
tskIDLE_PRIORITY + 2, NULL) != pdPASS)
{ while(1); }
assert_param(xTaskCreate(BlueTask, "BlueTask", STACK_SIZE,NULL,
tskIDLE_PRIORITY + 1, &blueTaskHandle) == pdPASS);
xTaskCreateStatic( RedTask, "RedTask", STACK_SIZE, NULL,
tskIDLE_PRIORITY + 1,
RedTaskStack, &RedTaskTCB);
//start the scheduler - shouldn't return unless there's a problem
vTaskStartScheduler();
while(1){}
}
现在我们已经学会了如何创建任务并启动调度器,在这个例子中需要覆盖的最后细节是如何删除任务。
删除任务
在某些情况下,让一个任务运行,并在它完成所有需要做的事情后从系统中移除,可能是有利的。例如,在一些具有相当复杂的启动例程的系统中,可能有利于在任务内部运行一些后期初始化代码。在这种情况下,初始化代码会运行,但不需要无限循环。如果任务被保留,它仍然会有其栈和 TCB,浪费 FreeRTOS 堆空间。删除任务将释放任务的栈和 TCB,使 RAM 可用于重用。
所有关键的初始化代码都应该在调度器启动之前运行。
任务会自行删除
在任务完成有用工作后删除任务的最简单方法是,在任务内部调用vTaskDelete()并传递一个NULL参数,如下所示:
void GreenTask(void *argument)
{
SEGGER_SYSVIEW_PrintfHost("Task1 running \
while Green LED is on\n");
GreenLed.On();
vTaskDelay(1500/ portTICK_PERIOD_MS);
GreenLed.Off();
//a task can delete itself by passing NULL to vTaskDelete
vTaskDelete(NULL);
//task never get's here
GreenLed.On();
}
这将立即终止任务代码。当 IDLE 任务运行时,与 TCB 和任务栈关联的 FreeRTOS 堆上的内存将被释放。
在这个例子中,绿色 LED 将开启 1.5 秒然后关闭。如代码中所述,vTaskDelete()之后的指令永远不会被执行。
从另一个任务中删除任务
为了从另一个任务中删除任务,需要将blueTaskHandle传递给xTaskCreate并填充其值。然后,blueTaskHandle可以被其他任务用来删除BlueTask,如下所示:
TaskHandle_t blueTaskHandle;
int main(void)
{
HWInit();
assert_param( xTaskCreate(BlueTask, "BlueTask", STACK_SIZE,
NULL, tskIDLE_PRIORITY + 1, &blueTaskHandle) ==
pdPASS);
xTaskCreateStatic( RedTask, "RedTask", STACK_SIZE, NULL,
tskIDLE_PRIORITY + 1, RedTaskStack,
&RedTaskTCB);
vTaskStartScheduler();
while(1);
}
void RedTask( void* argument )
{
vTaskDelete(blueTaskHandle);
}
在main.c中的实际代码会导致蓝色 LED 闪烁约 1 秒,然后被RedTask删除。此时,蓝色 LED 停止闪烁(因为控制 LED 开关的任务不再运行)。
在决定删除任务之前,有一些事情需要记住:
-
使用的堆实现必须支持释放内存(有关详细信息,请参阅第十五章,FreeRTOS 内存管理)。
-
任何嵌入式堆实现,如果不断添加和删除不同大小的元素,高度使用的堆可能会变得碎片化。
-
#define configTaskDelete必须在FreeRTOSConfig.h中设置为true。
就这样!我们现在有一个 FreeRTOS 应用程序。让我们编译一切并将程序映像编程到 Nucleo 板上。
尝试运行代码
现在你已经学会了如何设置几个任务,让我们来看看如何在我们的硬件上运行它们。运行示例,使用断点观察执行,并在 SystemView 中筛选跟踪将大大增强你对实时操作系统行为的直觉。
让我们实验一下前面的代码:
- 打开
Chapter_7 STM32CubeIDE项目并将TaskCreationBuild设置为活动构建:

-
右键单击项目并选择“构建配置”。
-
选择所需的构建配置(
TaskCreationBuild包含main_taskCreation.c)。 -
选择“构建项目”以构建活动配置。
之后,尝试使用 Ozone 加载和单步执行程序(有关如何操作的详细信息已在 第六章,实时系统调试工具 中介绍)。SystemView 也可以用来实时观察任务的运行。以下是一个快速查看正在发生什么的鸟瞰图示例:

让我们一步一步地过一遍:
-
GreenTask睡眠 1.5 秒,然后删除自己,以后不再运行(注意GreenTask行中没有额外的滴答线)。 -
BlueTask在被RedTask删除之前执行 1 秒。 -
RedTask持续闪烁红色 LED。 -
RedTask删除BlueTask。删除不是微不足道的——我们可以从注释中看到删除BlueTask需要 7.4 毫秒。
恭喜你,你刚刚完成了编写、编译、加载和分析实时操作系统应用程序!什么?!你还没有在硬件上实际运行应用程序?!真的吗?如果你真的想学习,你应该认真考虑购买一块 Nucleo 板,这样你就可以在实际硬件上运行示例。本书中的所有示例都是完整的项目,可以直接使用!
我们在这里略过的一件事是为什么对 xTaskCreate() 的调用可能会失败。这是一个非常好的问题——让我们来找出答案!
任务内存分配
xTaskCreate() 的一个参数定义了任务的堆栈大小。但用于此堆栈的 RAM 从哪里来?有两种选择——动态分配的内存和静态分配的内存。
动态内存分配通过堆实现。FreeRTOS 端口包含有关堆如何实现的几个不同选项。第十五章,FreeRTOS 内存管理提供了如何为特定项目选择合适的堆实现的详细信息。目前,假设堆可用即可。
静态分配在程序生命周期内永久为变量保留 RAM。让我们看看每种方法的样子。
堆分配的任务
本节开头的调用使用了堆来存储栈:
xTaskCreate(Task1, "task1", StackSizeWords, TaskHandlePtr, tskIDLE_PRIORITY + 2, NULL);
xTaskCreate() 是两种调用方法中较简单的一种。它将为 Task1 的栈和 任务控制块(TCB)使用 FreeRTOS 堆中的内存。
静态分配的任务
不使用 FreeRTOS 堆创建的任务需要程序员在创建任务之前为任务的栈和 TCB 进行分配。任务创建的静态版本是 xTaskCreateStatic()。
xTaskCreateStatic() 的 FreeRTOS 原型如下:
TaskHandle_t xTaskCreateStatic( TaskFunction_t pxTaskCode,
const char * const pcName,
const uint32_t ulStackDepth,
void * const pvParameters,
UBaseType_t uxPriority,
StackType_t * const puxStackBuffer,
StaticTask_t * const pxTaskBuffer );
让我们看看在我们的示例中如何使用它,它创建了一个具有静态分配栈的任务:
static StackType_t RedTaskStack[STACK_SIZE];
static StaticTask_t RedTaskTCB;
xTaskCreateStatic( RedTask, "RedTask", STACK_SIZE, NULL,
tskIDLE_PRIORITY + 1,
RedTaskStack, &RedTaskTCB);
与 xTaskCreate() 不同,只要 RedTaskStack 或 RedTaskTCB 不是 NULL,xTaskCreateStatic() 就会保证总是创建任务。只要你的工具链的链接器能在 RAM 中找到空间来存储变量,任务就会成功创建。
如果你想使用前面的代码,必须在 FreeRTOSConfig.h 中将 configSUPPORT_STATIC_ALLOCATION 设置为 1。
内存保护任务创建
任务也可以在内存保护环境中创建,这保证了任务只能访问为其专门分配的内存。FreeRTOS 的实现可以利用板载的 MPU 硬件。
请参阅第四章,选择合适的 MCU,以获取有关 MPU 的详细信息。你还可以在第十五章,FreeRTOS 内存管理中找到如何使用 MPU 的详细示例。
任务创建总结
由于创建任务有多种不同的方式,你可能想知道应该使用哪一种。所有实现都有其优点和缺点,并且这确实取决于几个因素。下表展示了创建任务的三种方式的总结,其相对优点通过箭头表示——⇑ 表示更好,⇓ 表示更差,⇔ 表示中性:
| 特性 | 堆 | MPU 堆 | 静态 |
|---|---|---|---|
| 易用性 | ⇑ | ⇓ | ⇔ |
| 灵活性 | ⇑ | ⇓ | ⇔ |
| 安全性 | ⇓ | ⇑ | ⇔ |
| 法规遵从性 | ⇓ | ⇑ | ⇔ |
如我们所见,没有明确的答案来决定使用哪个系统。然而,如果你的微控制器(MCU)没有板载的 MPU,那么将无法选择使用 MPU 变体。
FreeRTOS 基于堆的方法是三种选择中最容易编码的,也是最具灵活性的。这种灵活性来自于任务可以被删除,而不仅仅是被遗忘。静态创建的任务是下一个最容易的,只需要额外两行代码来指定 TCB 和任务堆栈。由于无法释放由静态变量定义的内存,因此它们不如前者灵活。在某些法规环境中,静态创建可能更受欢迎,因为这些环境完全禁止使用堆,尽管在大多数情况下,最常用的 FreeRTOS 基于堆的方法是可以接受的——特别是堆实现 1、2 和 3。
什么是堆实现? 不要担心,我们将在第十五章 FreeRTOS 内存管理中详细学习 FreeRTOS 的堆选项。
MPU(内存保护单元)变体是三种中最复杂的,但也是最安全的,因为 MPU 保证了任务不会超出其允许的内存范围。
使用静态定义的堆栈和 TCB(任务控制块)的优点是,链接器可以分析整个程序的内存占用。这确保了如果程序编译并适应了 MCU 的硬件限制,它不会因为堆空间不足而无法运行。基于堆的任务创建可能导致程序编译成功,但在运行时出现错误,导致整个应用程序无法运行。在其他情况下,应用程序可能运行一段时间后,由于堆内存不足而失败。
理解 FreeRTOS 任务状态
如第二章理解 RTOS 任务中所述,所有任务之间的上下文切换都是在后台进行的,这对负责实现任务的开发者来说非常方便。这是因为它使他们免于在每个试图平衡系统负载的任务中添加代码。虽然任务代码并没有明确地执行任务状态转换,但它确实与内核交互。对 FreeRTOS API 的调用会导致内核调度器运行,负责在必要的状态之间转换任务。
理解不同的任务状态
下面的状态图中显示的每个转换都是由你的代码发出的 API 调用或调度器采取的行动引起的。这是一个简化的图形概述,包括可能的状态和转换,以及每个状态的描述:

让我们逐一来看。
运行中
运行状态的任务正在执行工作;它是唯一处于上下文中的任务。它将一直运行,直到它调用一个 API 导致其进入Blocked状态,或者由于优先级更高(或具有相同优先级的分时任务)而被调度器切换出上下文。可能导致任务从Running状态移动到Blocked状态的 API 调用示例包括尝试从空队列中读取或尝试获取不可用的互斥锁。
准备就绪
处于就绪状态的任务只是在等待调度器赋予它们处理器上下文,以便它们可以运行。例如,如果任务 A已经进入Blocked状态,等待它所等待的队列中添加一个项目,那么任务 A将进入Ready状态。调度器将评估任务 A是否是系统中最高的优先级且准备就绪的任务。如果任务 A是准备就绪的最高优先级任务,它将被赋予处理器上下文并转换为Running状态。请注意,任务可以具有相同的优先级。在这种情况下,调度器将通过使用轮询调度方案在Ready和Running状态之间切换它们(有关此示例,请参阅第二章,理解 RTOS 任务)。
阻塞
Blocked状态的任务是正在等待某物的任务。任务从Blocked状态退出有两种方式:要么一个事件触发任务从Blocked状态到Ready状态的转换,要么发生超时,将任务置于Ready状态。
这是 RTOS 的一个非常重要的特性:每个阻塞调用都有时间限制。也就是说,任务在等待事件时只会阻塞,直到程序员指定它可以阻塞的时间。这是 RTOS 固件编程和通用应用程序编程之间的重要区别。例如,尝试获取一个互斥锁,如果在该指定时间内互斥锁不可用,则该尝试将失败。对于接受并推送数据到队列的 API 调用以及 FreeRTOS 中所有其他非中断 API 调用也是如此。
当任务处于Blocked状态时,它不会消耗任何处理器时间。当调度器将任务从Blocked状态转换出来时,它将被移动到Ready状态,允许调用任务在它成为系统中最高的优先级任务时运行。
暂停
Suspended状态是一个有点特殊的情况,因为它需要显式调用 FreeRTOS API 来进入和退出。一旦任务进入Suspended状态(通过vTaskSuspend() API 调用),它将被调度器忽略,直到执行vTaskResume() API 调用。这种状态导致调度器实际上忽略任务,直到通过显式 API 调用将其移动到Ready状态。就像Blocked状态一样,Suspended状态不会消耗任何处理器时间。
现在我们已经了解了各种任务状态以及它们如何与 RTOS 的不同部分交互,我们可以学习如何优化应用程序,使其能够高效地使用任务。
优化任务状态
通过深思熟虑的优化可以最小化任务在运行状态的时间。由于任务只有在运行状态下才会消耗显著的 CPU 时间,因此通常最好将时间花在合法的工作上。
正如你所看到的,轮询事件是有效的,但通常是不必要的 CPU 周期浪费。如果与任务优先级平衡得当,系统可以设计为对重要事件做出响应,同时最大限度地减少 CPU 时间。以这种方式优化应用程序可能有几个不同的原因。
优化以减少 CPU 时间
通常,RTOS 被用于许多不同的活动几乎同时发生。当一个任务需要因为事件发生而采取行动时,有几种方法可以监控该事件。
轮询是指连续读取一个值以捕获一个转换。一个例子就是等待新的 ADC 读数。一个轮询读取可能看起来像这样:
uint_fast8_t freshAdcReading = 0;
while(!freshAdcReading)
{
freshAdcReading = checkAdc();
}
虽然这段代码会检测到新的 ADC 读数发生,但它也会使任务持续处于运行状态。如果这成为系统中优先级最高的任务,这将饿死其他任务对 CPU 时间的获取。这是因为没有任何东西能迫使任务离开运行状态——它持续检查新的值。
为了最小化任务在运行状态(持续轮询以检测变化)所花费的时间,我们可以使用 MCU 中包含的硬件来执行相同的检查,而不需要 CPU 干预。例如,中断服务例程(ISR)和直接内存访问(DMA)都可以用来将 CPU 的一些工作卸载到 MCU 中包含的不同硬件外设。一个 ISR 可以与 RTOS 原语接口,以便在有价值的工作需要完成时通知任务,从而消除对 CPU 密集型轮询的需求。第八章,保护数据和同步任务,将更详细地介绍轮询,以及多个高效的替代方案。
优化以提高性能
有时,有严格的时序要求需要低量的抖动。其他时候,可能需要使用需要大量吞吐量的外设。虽然可能在高优先级任务中轮询以满足这些时序要求,但通常在 ISR 中实现所需的功能更可靠(也更高效)。也可能通过使用 DMA 完全不涉及处理器。这两种选项都防止任务在轮询循环上浪费无用的 CPU 周期,并允许它们有更多时间用于有用的工作。
请查看第二章中的“DMA 介绍”部分,以复习 DMA。中断也包含在内。
由于中断和 DMA 可以在 RTOS 完全以下运行(不需要任何内核干预),它们可以对创建确定性系统产生显著积极的影响。我们将在第十章“驱动器和中断服务例程”中详细探讨如何编写这些类型的驱动程序。
优化以最小化功耗
由于电池供电和能量收集应用的普遍存在,程序员有另一个确保系统使用尽可能少的 CPU 周期的理由。在创建节能解决方案时,也存在类似的想法,但重点通常不是最大化确定性,而是节省 CPU 周期并以较慢的时钟速率运行。
FreeRTOS 中有一个额外的功能,可用于在此空间进行实验——无滴答的 IDLE 任务。这是以牺牲时间精度为代价,减少内核运行的频率。通常,如果内核被设置为 1 ms 的滴答率(等待最多每毫秒检查一次下一次活动),它将以 1 kHz 的频率唤醒并运行代码。在无滴答 IDLE 任务的情况下,内核仅在必要时唤醒。
现在我们已经讨论了一些如何改进已运行系统的起点,让我们将注意力转向更严重的事情:一个根本无法启动的系统!
故障排除启动问题
假设你正在做一个项目,事情并没有按计划进行。你并没有得到闪烁的灯光作为奖励,而是被迫盯着一个非常不闪烁的硬件设备。在这个阶段,通常最好是启动调试器,而不是随意猜测可能的问题并随机更改代码部分。
我的所有任务都没有运行!
在开发的早期阶段,大多数启动问题都是由 FreeRTOS 堆中未分配足够空间引起的。通常会有两种症状由此产生。
任务创建失败
在以下情况下,代码在运行调度器之前会卡住(没有灯光闪烁)。执行以下步骤以确定原因:
-
使用调试器,逐步执行任务创建,直到找到有问题的任务。这很容易做到,因为所有创建任务的尝试只有在任务成功创建的情况下才会进展。
-
在这种情况下,你会看到在创建
BlueTask时xTaskCreate没有返回pdPASS。以下代码请求为BlueTask分配 50 KB 的堆栈:
int main(void)
{
HWInit();
if (xTaskCreate(GreenTask, "GreenTask",
STACK_SIZE, NULL,
tskIDLE_PRIORITY + 2, NULL) != pdPASS)
{ while(1); }
//code won't progress past assert_failed (called by
//assert_param on failed assertions)
retval = (xTaskCreate(BlueTask, "BlueTask",
STACK_SIZE*100,NULL,
tskIDLE_PRIORITY + 1, &blueTaskHandle);
assert_param(retVal == pdPASS);
你可以在这里找到此示例的完整源代码:github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers/blob/master/Chapter_7/Src/main_FailedStartup.c。
这是assert_failed的代码。无限while循环使得使用调试探针追踪有问题的行变得非常容易,并查看调用堆栈:
void assert_failed(uint8_t *file, uint32_t line)
{
SEGGER_SYSVIEW_PrintfHost("Assertion Failed:file %s \
on line %d\r\n", file, line);
while(1);
}
- 使用 Ozone 调用堆栈,失败的断言可以追溯到在
main_FailedStartup.c的第 37 行创建BlueTask:

- 在确定失败的原因是未能创建的任务后,是时候考虑通过修改
FreeRTOSConfig.h来增加 FreeRTOS 的堆空间了。这是通过修改configTOTAL_HEAP_SIZE来完成的(目前设置为 15 KB)。此摘录取自Chapter_7/Inc/FreeRTOSConfig.h***:
#define configTOTAL_HEAP_SIZE ((size_t)15360)
与以单词(例如,configMINIMAL_STACK_SIZE)指定的堆栈大小规范不同,它作为参数传递给xTaskCreate,configTOTAL_HEAP_SIZE是以字节为单位的。
在增加configTOTAL_HEAP_SIZE时需要小心。请参阅重要注意事项部分,了解需要考虑的事项。
调度器意外返回
也可能出现vStartScheduler返回此问题的情况:
//start the scheduler - shouldn't return unless there's a problem
vTaskStartScheduler();
//if you've wound up here, there is likely
//an issue with overrunning the freeRTOS heap
while(1)
{
}
这只是同一潜在问题的另一个症状——堆空间不足。调度器定义了一个需要configMINIMAL_STACK_SIZE个堆空间单词的 IDLE 任务(加上 TCB 的空间)。
如果你正在阅读这一部分,因为你实际上有一个无法启动的程序,并且你没有遇到这些症状中的任何一种,请不要担心!这本书的后面有一个专门的章节,专门为你准备。查看第十七章,故障排除技巧和下一步行动。它实际上是从这本书示例代码创建过程中遇到的真实问题中创建出来的。
如果你的应用程序拒绝启动,还有一些其他考虑因素需要考虑。
重要注意事项
基于 MCU 的嵌入式系统中的 RAM 通常是一种稀缺资源。当增加 FreeRTOS 可用的堆空间(configTOTAL_HEAP_SIZE)时,你将减少非 RTOS 代码可用的 RAM 量。
在考虑通过configTOTAL_HEAP_SIZE增加 FreeRTOS 可用的堆空间时,有几个因素需要注意:
-
如果已经定义了一个较大尺寸的非 RTOS 堆栈——即任何不在任务内部运行的代码所使用的堆栈(通常在启动文件中配置)。初始化代码将使用这个堆栈,所以如果有任何深层函数调用,这个堆栈将无法特别小。在调度器启动之前初始化的 USB 堆栈可能是罪魁祸首。在内存受限的系统上,一个可能的解决方案是将膨胀的初始化代码移动到一个具有足够大堆栈的任务中。这可能允许进一步最小化非 RTOS 堆栈。
-
中断服务例程(ISRs)也将使用非实时操作系统(RTOS)堆栈,但它们在整个程序运行期间都需要它。
-
考虑使用静态分配的任务,因为在程序运行时可以保证有足够的 RAM。
关于内存分配的更深入讨论可以在第十五章,FreeRTOS 内存管理中找到。
摘要
在本章中,我们介绍了定义任务的不同方式以及如何启动 FreeRTOS 调度器。在这个过程中,我们还介绍了一些使用 Ozone、SystemView 和 STM32CubeIDE(或任何基于 Eclipse CDT 的 IDE)的示例。所有这些信息都被用来创建一个实时演示,将有关任务创建的 RTOS 概念与在嵌入式硬件上实际加载和分析代码的机制联系起来。还有一些关于如何不监控事件的建议(轮询)。
在下一章中,我们将介绍您应该用于事件监控的内容。我们将通过示例涵盖实现任务间信号和同步的多种方式。将会有大量的代码和许多使用 Nucleo 板进行的手动分析。将有大量的代码和许多使用 Nucleo 板进行的手动分析。
问题
在我们结束本章内容时,这里有一份问题列表,以便您可以测试自己对本章材料的了解。您将在附录的评估部分找到答案:
-
启动 FreeRTOS 任务时有哪些选项可用?
-
调用
xTaskCreate()时需要检查返回值。-
正确
-
错误
-
-
调用
vTaskStartScheduler()时需要检查返回值。-
正确
-
错误
-
-
因为 RTOS 是臃肿的中间件,FreeRTOS 需要巨大的堆来存储所有任务栈,无论任务执行什么功能。
-
正确
-
错误
-
-
一旦任务启动,就无法将其移除。
-
正确
-
错误
-
进一步阅读
- Free RTOS 定制(
FreeRTOSConfig.h):www.freertos.org/a00110.htm
第八章:保护数据和同步任务
竞态条件、数据损坏和错过实时截止日期有什么共同之处?好吧,首先,它们都是在并行操作时容易犯的错误。这些错误(部分)可以通过使用正确的工具来避免。
本章涵盖了用于同步任务和保护共享数据的一些机制。本章中的所有解释都将包含使用 Ozone 和 SystemView 执行的示例代码和分析。
首先,我们将探讨信号量和互斥锁之间的区别。然后,您将了解何时、如何以及为什么使用信号量。您还将了解竞态条件和了解互斥锁如何避免此类情况。示例代码将贯穿始终。将使用可以在 Nucleo 开发板上运行和分析的实时代码引入并修复竞态条件概念。最后,将介绍 FreeRTOS 软件定时器,并讨论基于 RTOS 的软件定时器和 MCU 硬件外围定时器的常见实际应用案例。
本章我们将涵盖以下主题:
-
使用信号量
-
使用互斥锁
-
避免竞态条件
-
使用软件定时器
技术要求
要完成本章的动手练习,您将需要以下内容:
-
Nucleo F767 开发板
-
Micro USB 线
-
ST/Atollic STM32CubeIDE 及其源代码(有关这些说明,请参阅第五章,选择 IDE – 设置我们的 IDE)
-
SEGGER JLink、Ozone 和 SystemView (第六章,实时系统调试工具)
构建本章中的示例最简单的方法是一次性构建所有 Eclipse 配置,然后使用 Ozone 加载和查看它们。为此,请按照以下步骤操作:
-
在 STM32CubeIDE 中,右键单击项目。
-
选择构建。
-
选择构建所有。所有示例都将构建到它们自己的命名子目录中(这可能需要一段时间)。
-
在 Ozone 中,您现在可以快速加载每个
<exampleName>.elf文件。有关如何操作的说明,请参阅第六章,实时系统调试工具。链接到可执行文件的正确源文件将自动显示。
本章的所有源代码都可以在github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers/tree/master/Chapter_8找到。
使用信号量
我们已经多次提到,任务应该被编程为并行运行。这意味着,默认情况下,它们在时间上没有相互关系。不能假设任务相对于彼此的执行位置——除非它们被显式同步。信号量是用于在任务之间提供同步的一种机制。
通过信号量进行同步
以下是我们之前在第二章中讨论的抽象示例的图示,任务信号和通信机制:

上述图示显示了TaskB正在等待来自TaskA的信号量。每次TaskB获取到所需的信号量时,它将继续其循环。TaskA会重复地give信号量,这实际上同步了TaskB的运行。现在我们已经搭建了完整的发展环境,让我们看看实际的代码是什么样的。然后,我们将在硬件上运行它,闪烁几个 LED,以了解在现实世界中这种行为是什么样的。
设置代码
首先,需要创建信号量,并存储其句柄(或指针),以便在任务之间使用。以下摘录来自mainSemExample.c:
//create storage for a pointer to a semaphore
SemaphoreHandle_t semPtr = NULL;
int main(void)
{
//.... init code removed.... //
//create a semaphore using the FreeRTOS Heap
semPtr = xSemaphoreCreateBinary(); //ensure pointer is valid (semaphore created successfully)
assert_param(semPtr != NULL);
信号量指针,即semPtr,需要放置在一个可以被需要访问信号量的其他函数访问的位置。例如,不要在函数内部将semPtr声明为局部变量——它将无法被其他函数访问,并且一旦函数返回,它就会超出作用域。
为了查看源代码的情况以及了解系统是如何反应的,我们将几个不同的 LED 与任务 A 和任务 B 关联起来。
Task A将在闪烁循环中每运行五次时切换绿色 LED 并give信号量,如下面的mainSemExample.c摘录所示:
void GreenTaskA( void* argument )
{
uint_fast8_t count = 0;
while(1)
{
//every 5 times through the loop, give the semaphore
if(++count >= 5)
{
count = 0;
SEGGER_SYSVIEW_PrintfHost("Task A (green LED) gives semPtr");
xSemaphoreGive(semPtr);
}
GreenLed.On();
vTaskDelay(100/portTICK_PERIOD_MS);
GreenLed.Off();
vTaskDelay(100/portTICK_PERIOD_MS);
}
}
另一方面,Task B在成功take到信号量后,将快速闪烁蓝色 LED 三次,如下面的mainSemExample.c摘录所示:
/**
* wait to receive semPtr and triple blink the Blue LED
*/
void BlueTaskB( void* argument )
{
while(1)
{
if(xSemaphoreTake(semPtr, portMAX_DELAY) == pdPASS)
{
//triple blink the Blue LED
for(uint_fast8_t i = 0; i < 3; i++)
{
BlueLed.On();
vTaskDelay(50/portTICK_PERIOD_MS);
BlueLed.Off();
vTaskDelay(50/portTICK_PERIOD_MS);
}
}
else
{
// This is the code that will be executed if we time out
// waiting for the semaphore to be given
}
}
}
太好了!现在我们的代码准备好了,让我们看看这种行为是什么样的。
FreeRTOS 通过使用portMAX_DELAY允许在某些情况下进行无限期延迟。只要FreeRTOSConfig.h中存在#define INCLUDE_vTaskSuspend 1,调用任务将被无限期挂起,并且可以安全地忽略xSemaphoreTake()的返回值。当vTaskSuspend()未定义为 1 时,portMAX_DELAY将导致非常长的延迟(在我们的系统中为 0xFFFFFFF RTOS 滴答,约 49.7 天),但不是无限期。
理解行为
这是使用 SystemView 查看时的示例外观:

注意以下内容:
-
使用信号量进行阻塞是高效的,因为每个任务只使用了 0.01%的 CPU 时间。
-
由于任务正在等待信号量而阻塞的任务,将不会运行,直到它可用。即使它是系统中优先级最高的任务,并且没有其他任务处于
READY状态,也是如此。
既然你已经看到了使用信号量同步任务的效率方法,让我们看看另一种使用轮询实现相同行为的方法。
浪费周期——通过轮询进行同步
以下示例与从板外观察 LED 时的行为完全相同——LED 的可观察模式与上一个示例完全相同。区别在于连续读取相同变量所使用的 CPU 时间量。
设置代码
下面是更新后的GreenTaskA()——只有一行发生了变化。这段摘录来自mainPolledExample.c:
void GreenTaskA( void* argument )
{
uint_fast8_t count = 0;
while(1)
{
//every 5 times through the loop, set the flag
if(++count >= 5)
{
count = 0;
SEGGER_SYSVIEW_PrintfHost("Task A (green LED) sets flag");
flag = 1; //set 'flag' to 1 to "signal" BlueTaskB to run
我们不是调用xSemaphoreGive(),而是简单地设置flag变量为1。
对BlueTaskB()也进行了类似的微小更改,用轮询flag的while循环替换了xSemaphoreTake()。这可以在以下来自mainPolledExample.c的摘录中看到:
void BlueTaskB( void* argument )
{
while(1)
{
SEGGER_SYSVIEW_PrintfHost("Task B (Blue LED) starts "\
"polling on flag");
//repeateadly poll on flag. As soon as it is non-zero,
//blink the blue LED 3 times
while(!flag); SEGGER_SYSVIEW_PrintfHost("Task B (Blue LED) received flag");
这些就是所需的唯一更改。BlueTaskB()将等待(无限期地)直到flag被设置为非0的值。
要运行此示例,请使用Chapter_8/polledExample文件中的构建配置。
理解行为
由于更改很少,我们可能不会期望在新的代码下,MCU 的行为会有太大的差异。然而,SystemView 的输出告诉我们一个不同的故事:

注意以下内容:
-
BlueTaskB现在正在使用 100%的 CPU 时间来轮询flag(70%的 CPU 负载较低,因为任务在闪烁 LED 时处于睡眠状态)。 -
即使
BlueTaskB正在占用 CPU,GreenTaskA仍然持续运行,因为它具有更高的优先级。如果GreenTaskA的优先级低于BlueTaskB,它将无法获得 CPU。
因此,通过轮询变量来同步任务确实按预期工作,但有一些副作用:CPU 利用率增加,对任务优先级的强烈依赖。当然,有方法可以减少BlueTaskB的 CPU 负载。我们可以在轮询之间添加延迟,如下所示:
while(!flag)
{
vTaskDelay(1);
}
这将把BlueTaskB的 CPU 负载降低到大约 5%。但是,请注意,这个延迟也保证了BlueTaskB在最坏情况下的延迟至少为 1 个 RTOS 滴答周期(在我们的设置中为 1 毫秒)。
时间限制信号量
之前,我们提到 RTOS 的一个重要方面是它们提供了一种时间限制操作的方法;也就是说,它们可以保证调用不会使任务执行超过期望的时间。RTOS 不保证操作的成功及时性。它只承诺调用将在一定时间内返回。让我们再次看看获取信号量的调用:
BaseType_t xSemaphoreTake( SemaphoreHandle_t xSemaphore,
TickType_t xTicksToWait );
从前面的代码中,我们可以看到以下内容:
-
semPtr只是一个指向信号量的指针。 -
maxDelay是这个调用中有趣的部分——它指定了等待信号量的最大时间(以 RTOS tick 单位计)。 -
返回值是
pdPASS(信号量及时获取)或pdFALSE(信号量未及时获取)。检查这个返回值非常重要。
如果成功获取信号量,返回值将是 pdPASS。这是任务将继续的唯一情况,因为给出了信号量。如果返回值不是 pdPASS,则 xSemaphoreTake() 调用失败,可能是由于超时或编程错误(例如传递无效的 SemaphoreHandle_t)。让我们通过一个例子更深入地了解这一点。
设置代码
在这个例子中,我们将使用开发板上的所有三个 LED 来指示不同的状态:
-
绿色 LED:
GreenTaskA()以稳定的 5 赫兹频率闪烁,占空比为 50%。 -
蓝色 LED:当
TaskB()在 500 毫秒内收到信号量时,快速闪烁三次。 -
红色 LED:在
xSemaphoreTake()超时后开启。只要在开始等待信号量后的 500 毫秒内收到信号量,它就会保持开启状态,直到被TaskB()重置。
在许多系统中,错过截止日期可能是一个(重大)关注的问题。这完全取决于你正在实施的内容。这个例子只是一个简单的循环,当错过截止日期时会有红灯亮起。然而,其他系统可能需要采取(紧急)程序来防止错过截止日期导致重大故障/损坏。
GreenTaskA() 有两个职责:
-
闪烁绿色 LED
-
在伪随机间隔内发出 信号量
这些职责可以在以下代码中看到:
void GreenTaskA( void* argument )
{
uint_fast8_t count = 0;
while(1)
{
uint8_t numLoops = StmRand(3,7);
if(++count >= numLoops)
{
count = 0;
xSemaphoreGive(semPtr);
}
greenBlink();
}
}
TaskB() 也具有两个职责:
-
闪烁蓝色 LED(只要信号量在 500 毫秒内出现)。
-
如果信号量在 500 毫秒内没有出现,则开启红色 LED。红色 LED 将保持开启状态,直到在开始等待信号量后的 500 毫秒内成功获取信号量:
void TaskB( void* argument )
{
while(1)
{
//'take' the semaphore with a 500mS timeout
if(xSemaphoreTake(semPtr, 500/portTICK_PERIOD_MS) == pdPASS)
{
//received semPtr in time
RedLed.Off();
blueTripleBlink();
}
else
{
//this code is called when the
//semaphore wasn't taken in time
RedLed.On();
}
}
}
这种设置保证了 TaskB() 至少每 500 毫秒会采取一些行动。
理解行为
当使用 SystemView 构建和加载 semaphoreTimeBound 构建配置中包含的固件时,你会看到以下类似的内容:

注意以下内容:
-
标记 1表示
TaskB在 500 ms 内没有接收到信号量。注意,TaskB没有后续执行——它立即返回再次获取信号量。 -
标记 2表示
TaskB在 500 ms 内接收到了信号量。从图表中我们可以看到实际上是在大约 200 ms。TaskB通道中的周期性线条(在前面的图像中圈出)是蓝色 LED 的开启和关闭。 -
在闪烁蓝色 LED 后,
TaskB返回等待信号量。
日志消息由时序中的蓝色i图标表示,这有助于在可视化行为的同时将代码中的描述性注释关联起来。双击蓝色框会自动将终端跳转到相关的日志消息。
你会注意到蓝色 LED 并不总是闪烁——偶尔,红色 LED 会闪烁。每次红色 LED 闪烁,这表明在 500 ms 内没有获取到semPtr。这表明代码正在尝试获取一个信号量,将其作为在放弃信号量之前可接受的最高时间上限,这可能会触发一个错误条件。
作为练习,看看你是否能捕获红色闪烁并使用终端输出(在右侧)和时序输出(在底部)跟踪超时发生的位置——从TaskB尝试获取信号量到红色 LED 闪烁之间经过了多少时间?现在,修改源代码中的 500 ms 超时,使用 Ozone 编译并上传,观察 SystemView 中的变化。
计数信号量
虽然二进制信号量只能有 0 到 1 之间的值,但计数信号量可以有更宽的范围。计数信号量的某些用例包括通信堆栈中的同时连接或内存池中的静态缓冲区。
例如,假设我们有一个支持多个同时 TCP 会话的 TCP/IP 堆栈,但 MCU 只有足够的 RAM 来支持三个同时 TCP 会话。这将是一个计数信号量的完美用例。
此应用程序的计数信号量需要定义为最大计数为3,初始值为3(有三个 TCP 会话可用):
SemaphoreHandle_t semPtr = NULL;
semPtr = xSemaphoreCreateCounting( /*max count*/3, /*init count*/ 3);
if(semPtr != NULL)
请求打开 TCP 会话的代码会获取semPtr,将其计数减少 1:
if(xSemaphoreTake( semPtr, /*timeoutTicks*/100) == pdPASS)
{
//resources for TCP session are available
}
else
{
//timed out waiting for session to become available
}
每当关闭一个 TCP 会话时,关闭会话的代码会释放semPtr,将其计数增加 1:
xSemaphoreGive( semPtr );
通过使用计数信号量,你可以控制对有限数量的可用 TCP 会话的访问。通过这样做,我们实现了两个目标:
-
限制同时 TCP 会话的数量,从而控制资源使用。
-
为创建 TCP 会话提供时间限制的访问。这意味着代码能够指定它将等待会话可用多长时间。
计数信号量在控制对多个实例可用的共享资源的访问时非常有用。
优先级反转(如何不使用信号量)
由于信号量用于同步多个任务并保护共享资源,这意味着我们可以使用它们来保护两个任务之间共享的数据吗?由于每个任务都需要知道何时可以安全地访问数据,因此任务需要同步,对吧?这种方法的危险在于信号量没有任务优先级的概念。一个高优先级任务在等待一个被低优先级任务持有的信号量时将会等待,无论系统中可能发生什么。这里将展示一个为什么这可能会成为问题的例子。
这里是我们之前在第三章,任务信号和通信机制中讨论的概念示例:

这个序列的主要问题是步骤 3和步骤 4。如果有一个高优先级(TaskA)的任务正在等待信号量,TaskB不应该能够抢占TaskC。让我们通过一些真实的代码和观察其行为来查看这个例子!
设置代码
对于实际示例,我们将保持与之前讨论的理论示例完全相同的函数名。共享资源将是用于闪烁 LED 的函数。
共享 LED只是一个例子。在实践中,你经常会发现任务之间共享的数据需要被保护。还有可能多个任务尝试使用相同的硬件外设,在这种情况下,可能需要保护对该资源的访问。
为了提供一些视觉反馈,我们还将一些 LED 分配给各种任务。让我们看看代码。
任务 A(最高优先级)
任务 A 负责闪烁绿色 LED,但只有在semPtr被获取之后(在请求后的 200 毫秒内)。以下摘录来自mainSemPriorityInversion.c:
while(1)
{
//'take' the semaphore with a 200mS timeout
SEGGER_SYSVIEW_PrintfHost("attempt to take semPtr");
if(xSemaphoreTake(semPtr, 200/portTICK_PERIOD_MS) == pdPASS)
{
RedLed.Off();
SEGGER_SYSVIEW_PrintfHost("received semPtr");
blinkTwice(&GreenLed);
xSemaphoreGive(semPtr);
}
else
{
//this code is called when the
//semaphore wasn't taken in time
SEGGER_SYSVIEW_PrintfHost("FAILED to receive "
"semphr in time");
RedLed.On();
}
//sleep for a bit to let other tasks run
vTaskDelay(StmRand(10,30));
}
这个任务是本例的主要焦点,所以请确保你对在指定时间内获取信号量的条件语句有扎实的理解。信号量并不总是能及时获取。
任务 B(中等优先级)
任务 B 定期使用 CPU。以下摘录来自mainSemPriorityInversion.c:
uint32_t counter = 0;
while(1)
{
SEGGER_SYSVIEW_PrintfHost("starting iteration %ui", counter);
vTaskDelay(StmRand(75,150));
lookBusy(StmRand(250000, 750000));
}
这个任务在 75 到 150 个 tick 之间睡眠(这不会消耗 CPU 周期),然后使用lookBusy()函数进行可变周期的忙等待。请注意,TaskB是中等优先级任务。
任务 C(低优先级)
任务 C 负责闪烁蓝色 LED,但只有在semPtr被获取之后(在请求后的 200 毫秒内)。以下摘录来自mainSemPriorityInversion.c:
while(1)
{
//'take' the semaphore with a 200mS timeout
SEGGER_SYSVIEW_PrintfHost("attempt to take semPtr");
if(xSemaphoreTake(semPtr, 200/portTICK_PERIOD_MS) == pdPASS)
{
RedLed.Off();
SEGGER_SYSVIEW_PrintfHost("received semPtr");
blinkTwice(&BlueLed);
xSemaphoreGive(semPtr);
}
else
{
//this code is called when the semaphore wasn't taken in time
SEGGER_SYSVIEW_PrintfHost("FAILED to receive "
"semphr in time");
RedLed.On();
}
}
TaskC()依赖于与TaskA()相同的信号量。唯一的区别是TaskC()正在闪烁蓝色 LED 以指示信号量已被成功获取。
理解行为
使用 Ozone,加载Chapter8_semaphorePriorityInversion.elf并启动处理器。然后,打开 SystemView 并观察运行时行为,这将在下面进行分析。
在查看这个跟踪时,有几个关键方面需要记住:
-
TaskA是系统中的最高优先级任务。理想情况下,如果TaskA准备好运行,它应该正在运行。因为TaskA与一个低优先级任务(TaskC)共享资源,所以当TaskC运行时(如果TaskC持有资源),它将被延迟。 -
当
TaskA可以运行时,TaskB不应该运行,因为TaskA具有更高的优先级。 -
我们使用了 SystemView 的终端输出(以及打开了红色 LED)来提供通知,当
TaskA或TaskC未能及时获取semPtr时:
SEGGER_SYSVIEW_PrintfHost("FAILED to receive "
"semphr in time");
这在 SystemView 中的样子如下:

这张图中的数字与理论示例相匹配,所以如果你一直密切跟踪,你可能已经知道预期结果是什么:
-
TaskC(系统中的最低优先级任务)获取了一个二进制信号量并开始做一些工作(闪烁蓝色 LED)。 -
在
TaskC完成其工作之前,TaskB做一些工作。 -
最高优先级任务(
TaskA)中断并尝试获取相同的信号量,但被迫等待,因为TaskC已经获取了信号量。 -
TaskA在 200 毫秒后超时,因为TaskC没有机会运行(更高优先级的任务TaskB正在运行)。由于失败,它点亮了红色 LED。
当一个低优先级任务(TaskB)正在运行,而一个高优先级任务(TaskA)准备运行但正在等待共享资源时,这种情况被称为优先级反转。这是避免使用信号量来保护共享资源的原因之一。
如果你仔细查看示例代码,你会意识到信号量被获取了,然后持有信号量的任务被置于睡眠状态...永远不要在真实系统中这样做。记住,这是一个为了明显失败而设计的人为的例子。有关临界区的更多信息,请参阅使用互斥锁部分。
幸运的是,有一个 RTOS 原语是专门设计用来保护共享资源,同时最大限度地减少优先级反转的影响——互斥锁。
使用互斥锁
互斥锁代表互斥——它们被明确设计用于在应该互斥访问共享资源的情况下使用——这意味着共享资源一次只能被一段代码使用。在本质上,互斥锁是具有一个(非常重要)区别的二进制信号量:优先级继承。在先前的例子中,我们看到最高优先级任务在等待两个低优先级任务完成,这导致了优先级反转。互斥锁通过所谓的优先级继承来解决这个问题。
当一个高优先级任务尝试获取互斥锁并被阻塞时,调度器会将持有互斥锁的任务的优先级提升到与阻塞任务相同的级别。这保证了高优先级任务将尽快获取互斥锁并运行。
解决优先级反转问题
让我们再次尝试保护共享资源,但这次,我们将使用互斥锁而不是信号量。使用互斥锁应该有助于 最小化 优先级反转,因为它将有效地防止中等优先级任务运行。
设置代码
在这个示例中只有两个显著的不同点:
-
我们将使用
xSemaphoreCreateMutex()而不是xSemaphoreCreateBinarySemaphore()。 -
不需要初始的
xSemaphoreGive()调用,因为互斥锁将初始化为值 1。互斥锁的设计是为了在需要时获取,然后返回。
这是我们的更新示例,唯一的重大变化。这段摘录可以在 mainMutexExample.c 中找到:
mutexPtr = xSemaphoreCreateMutex();
assert_param(mutexPtr != NULL);
与 semPtr 到 mutexPtr 变量名更改相关的某些名称更改,但在功能上没有不同。
理解行为
使用 Ozone,加载 Chapter8_mutexExample.elf 并运行 MCU。查看板子时可以期待以下情况:
-
你会看到绿色和蓝色 LED 双重闪烁。由于互斥锁的存在,每种颜色的 LED 闪烁不会相互重叠。
-
时不时地只会出现几个红色 LED 闪烁。这种减少是由于
TaskB不被允许优先于TaskC(并阻塞TaskA)。这比之前好多了,但为什么我们偶尔还会看到红色?
通过打开 SystemView,我们会看到以下类似的内容:

通过查看终端消息,你会注意到 TaskA —— 系统中优先级最高的任务 —— 从未错过任何互斥锁。这是我们所期待的,因为它在系统中的优先级高于其他所有任务。为什么 TaskC 偶尔会错过互斥锁(导致红色 LED)?
-
TaskC尝试获取互斥锁,但它被TaskA持有。 -
TaskA返回互斥锁,但它立即又被拿走。这是由于TaskA在调用互斥锁之间的延迟量是可变的。当没有延迟时,TaskC不被允许在TaskA返回互斥锁并尝试再次获取它之间运行。这是合理的,因为TaskA的优先级更高(尽管这可能在你的系统中不是所希望的)。 -
TaskC超时,等待互斥锁。
因此,我们已经改进了我们的条件。最高优先级的任务 TaskA 不再错过任何互斥锁。但使用互斥锁时有哪些最佳实践要遵循?继续阅读以了解详情。
避免互斥锁获取失败
虽然互斥锁有助于提供对某些优先级反转的保护,但我们可以采取额外的步骤来确保互斥锁不会成为一个不必要的拐杖。被互斥锁保护的代码部分被称为临界区:
if(xSemaphoreTake(mutexPtr, 200/portTICK_PERIOD_MS) == pdPASS)
{
//critical section is here
//KEEP THIS AS SHORT AS POSSIBLE
xSemaphoreGive(mutexPtr);
}
采取措施确保这个临界区尽可能短,将在几个方面有所帮助:
-
临界区的时间越短,共享数据就越容易访问。互斥锁被持有的时间越短,另一个任务在时间上获得访问权限的可能性就越大。
-
最小化低优先级任务持有互斥锁的时间,也可以最小化它们在高优先级(如果它们有高优先级)时花费的时间。
-
如果低优先级任务阻止了高优先级任务运行,高优先级任务在快速响应事件方面将具有更多的可变性(也称为抖动)。
避免在长函数的开始处获取互斥锁的诱惑。相反,在整个函数中访问数据,并在退出之前返回互斥锁:
if(xSemaphoreTake(mutexPtr, 200/portTICK_PERIOD_MS) == pdPASS)
{
//critical section starts here
uint32_t aVariable, returnValue;
aVariable = PerformSomeOperation(someOtherVarNotProtectedbyMutexPtr);
returnValue = callAnotherFunction(aVariable);
protectedData = returnValue; //critical section ends here
xSemaphoreGive(mutexPtr);
}
之前的代码可以被重写以最小化临界区。这仍然实现了与为protectedData提供互斥相同的目标,但减少了互斥锁被持有的时间:
uint32_t aVariable, returnValue;
aVariable = PerformSomeOperation(someOtherVarNotProtectedbyMutexPtr);
returnValue = callAnotherFunction(aVariable);
if(xSemaphoreTake(mutexPtr, 200/portTICK_PERIOD_MS) == pdPASS)
{
//critical section starts here
protectedData = returnValue; //critical section ends here
xSemaphoreGive(mutexPtr);
}
在先前的示例中,没有列出else语句,以防操作没有及时完成。记住,理解错过截止日期的后果并采取适当的行动是极其重要的。如果你对所需的时序(以及错过它的影响)没有很好的理解,那么是时候召集团队进行讨论了。
现在我们对互斥锁有了基本的了解,我们将看看它们如何被用来保护多个任务之间共享的数据。
避免竞争条件
那么,我们在什么时候需要使用互斥锁和信号量呢?只要多个任务之间存在共享资源,就应该使用互斥锁或信号量。标准二进制信号量可以用于资源保护,所以在某些特殊情况下(例如从中断服务例程访问信号量),信号量可能是可取的。然而,你必须理解等待信号量将如何影响系统。
我们将在第十章中看到一个使用信号量保护共享资源的示例,驱动程序和中断服务例程。
我们在之前的示例中看到了互斥锁的作用,但如果没有互斥锁,我们只想让蓝色或绿色 LED 中的一个在任意时刻亮起,会是什么样子?
失败的共享资源示例
在我们之前的互斥锁示例中,LED 是互斥锁保护的共享资源。一次只能有一个 LED 闪烁——绿色或蓝色。它会在下一次双闪烁之前完成整个双闪烁。
让我们通过一个更现实的例子来看看为什么这很重要。在现实世界中,你经常会发现共享数据结构和硬件外围设备是需要保护的最常见的资源。
当结构体包含多个必须相互关联的数据时,以原子方式访问数据结构非常重要。一个例子是多轴加速度计提供 X、Y 和 Z 轴的三个读数。在高速环境中,确保所有三个读数相互关联对于准确确定设备随时间的变化非常重要:
struct AccelReadings
{
uint16_t X;
uint16_t Y;
uint16_t Z;
};
struct AccelReadings sharedData;
Task1() 负责更新结构体中的数据:
void Task1( void* args)
{
while(1)
{
updateValues();
sharedData.X = newXValue;
sharedData.Y = newYValue;
sharedData.Z = newZValue;
}
}
另一方面,Task2() 负责从结构体中读取数据:
void Task2( void* args)
{
uint16_t myX, myY, myZ;
while(1)
{
myX = sharedData.X;
myY = sharedData.Y;
myZ = sharedData.Z;
calculation(myX, myY, myZ);
}
}
如果其中一个读数与其他读数没有正确关联,我们最终会得到设备运动的错误估计。Task1 可能正在尝试更新所有三个读数,但在获取访问权限的过程中,Task2 出现并尝试读取值。因此,由于数据正在更新中,Task2 收到的数据表示是错误的:

可以通过将所有对共享数据的访问放在临界区中来保护对数据结构的访问。我们可以通过在访问周围包装互斥锁来实现这一点:
void Task1( void* args)
{
while(1)
{
updateValues();
if(xSemaphoreTake(mutexPtr, timeout) == pdPASS)
{
sharedData.X = newXValue; //critical section start
sharedData.Y = newYValue;
sharedData.Z = newZValue; //critical section end
xSemaphoreGive(mutexPtr);
}
else { /* report failure */}
}
}
包装读访问也很重要:
void Task2( void* args)
{
uint16_t myX, myY, myZ;
while(1)
{
if(xSemaphoreTake(mutexPtr, timeout) == pdPASS)
{
myX = sharedData.X; //critical section start
myY = sharedData.Y;
myZ = sharedData.Z; //critical section end
xSemaphoreGive(mutexPtr);
//keep the critical section short
calculation(myX, myY, myZ);
}
else{ /* report failure */ }
}
}
现在数据保护已经介绍完毕,我们将再次审视任务间的同步问题。之前曾使用信号量来完成这项任务,但如果你需要以一致的速度执行操作,FreeRTOS 软件定时器可能是一个解决方案。
使用软件定时器
正如其名所示,软件定时器是使用软件实现的定时器。在 MCU 中,拥有许多不同的硬件外围定时器是非常常见的。这些定时器通常具有高分辨率,并且具有许多不同的模式和功能,用于从 CPU 中卸载工作。然而,硬件定时器有两个缺点:
-
由于它们是 MCU 的一部分,你需要创建一个抽象层来防止你的代码与底层 MCU 硬件紧密耦合。不同的 MCU 将会有略微不同的定时器实现。正因为如此,代码很容易依赖于底层硬件。
-
它们通常需要比使用 RTOS 已经提供的基于软件的定时器更多的开发时间来设置。
软件定时器通过软件实现多个定时器通道来减轻这种耦合,因此,应用程序不需要依赖于特定的硬件,它可以在 RTOS 支持的任何平台上使用(无需修改),这非常方便。
我们可以使用一些技术来减少固件与底层硬件的紧密耦合。第十二章,创建良好抽象架构的技巧将概述一些可以用来消除硬件和固件之间紧密耦合的技术。
您可能已经注意到了 SystemView 截图中的一个名为TmrSvc的任务。这是软件定时器服务任务。软件定时器作为 FreeRTOS 任务实现,使用了许多相同的底层原语。它们有一些配置选项,所有这些都可以在FreeRTOSConfig.h中设置:
/* Software timer definitions. */
#define configUSE_TIMERS 1
#define configTIMER_TASK_PRIORITY ( 2 )
#define configTIMER_QUEUE_LENGTH 10
#define configTIMER_TASK_STACK_DEPTH 256
为了能够访问软件定时器,configUSE_TIMERS必须定义为1。如前所述的片段所示,定时器任务的优先级、队列长度(可用定时器的数量)和堆栈深度都可以通过FreeRTOSConfig.h进行配置。
但是软件定时器是 FreeRTOS 的功能——为什么我需要担心堆栈深度?!
在使用软件定时器时,需要注意的一点是:定时器触发时执行的代码是在软件定时器任务上下文中执行的。这意味着两件事:
-
每个回调函数都在
TmrSvc任务的堆栈上执行。在回调中使用任何 RAM(即局部变量)都将来自TmrSvc任务。 -
任何执行较长时间的操作都会阻塞其他软件定时器的运行,因此将传递给软件定时器的回调函数视为中断服务例程(ISR)一样处理——不要故意延迟任务,并尽可能保持一切尽可能简短。
熟悉软件定时器的最佳方式是实际在一个真实系统中使用它们。
设置代码
让我们看看几个简单的例子,看看软件定时器是如何工作的。使用软件定时器主要有两种方式:单次触发和重复触发。我们将通过示例来介绍每种方式。
单次触发定时器
单次触发的定时器只会触发一次。这类定时器在硬件和软件中都很常见,当需要固定延迟时非常有用。当您希望在固定延迟后执行一小段代码,而不通过vTaskDelay()阻塞调用代码时,可以使用单次触发定时器。要设置单次触发定时器,必须指定定时器回调并创建定时器。
以下是从mainSoftwareTimers.c的摘录:
- 声明一个
Timer回调函数,该函数可以传递给xTimerCreate()。当定时器触发时,将执行此回调。请注意,回调在定时器任务中执行,因此它需要是非阻塞的!
void oneShotCallBack( TimerHandle_t xTimer );
-
创建一个定时器。参数定义定时器是单次触发定时器还是重复定时器(在 FreeRTOS 中,重复定时器会自动重载)。
-
进行一些尽职调查检查,以确保定时器创建成功,方法是检查句柄是否不是
NULL。 -
发出一个对
xTimerStart()的调用,并确保uxAutoReload标志设置为false(再次,xTimerCreate()的原型如下):
TimerHandle_t xTimerCreate ( const char * const pcTimerName,
const TickType_t xTimerPeriod,
const UBaseType_t uxAutoReload,
void * const pvTimerID,
TimerCallbackFunction_t pxCallbackFunction );
- 因此,要创建一个单次定时器,我们需要将
uxAutoReload设置为false:
TimerHandle_t oneShotHandle =
xTimerCreate( "myOneShotTimer", //name for timer
2200/portTICK_PERIOD_MS, //period of timer in ticks
pdFALSE, //auto-reload flag
NULL, //unique ID for timer
oneShotCallBack); //callback function
assert_param(oneShotHandle != NULL); //ensure creation
xTimerStart(oneShotHandle, 0); //start with scheduler
oneShotCallBack()将在 1 秒后简单地关闭蓝色 LED:
void oneShotCallBack( TimerHandle_t xTimer )
{
BlueLed.Off();
}
记住,在软件定时器内部执行的代码必须保持简短。所有软件定时器回调都是序列化的(如果一个回调执行长时间操作,可能会延迟其他回调的执行)。
重复定时器
重复定时器与单次定时器类似,但它们不是只被调用一次,而是被反复调用。重复定时器启动后,其回调函数将在启动后的每个xTimerPeriod周期后重复执行。由于重复定时器是在TmrSvc任务中执行的,因此它们可以为需要定期运行的短的非阻塞函数提供一个轻量级的任务替代方案。关于堆栈使用和执行时间方面的考虑与单次定时器相同。
对于重复定时器,步骤基本上是相同的:只需将自动重载标志的值设置为pdTRUE。
让我们看看mainSoftwareTimers.c中的代码:
TimerHandle_t repeatHandle =
xTimerCreate( "myRepeatTimer", //name for timer
500 /portTICK_PERIOD_MS, //period of timer in ticks
pdTRUE, //auto-reload flag
NULL, //unique ID for timer
repeatCallBack); //callback function
assert_param(repeatHandle != NULL);
xTimerStart(repeatHandle , 0);
重复定时器将切换绿色 LED:
void repeatCallBack( TimerHandle_t xTimer )
{
static uint32_t counter = 0;
if(counter++ % 2)
{
GreenLed.On();
}
else
{
GreenLed.Off();
}
}
在前面的代码中,使用静态变量来为counter变量赋值,以便其值在函数调用之间保持不变,同时仍然隐藏该变量,使其不在repeatCallBack()函数之外的所有代码中可见。
理解行为
执行复位操作后,你会看到蓝色 LED 点亮。要启动 FreeRTOS 调度器和定时器,请按下位于板子左下角的蓝色USER按钮,即B1。2.2 秒后,蓝色 LED 会熄灭。这只会发生一次,因为蓝色 LED 已被设置为单次定时器。绿色 LED 每 500 毫秒切换一次,因为它被设置为重复定时器。
让我们看看 SystemView 终端的输出。在终端中,所有时间都是相对于 RTOS 调度器的开始。蓝色 LED 单次定时器只执行一次,在 2.2 秒时执行,而绿色 LED 每 500 毫秒切换一次:

同样的信息也显示在时间轴上。请注意,时间相对于时间轴上的光标;它们不是绝对时间,就像在终端中那样:

现在我们已经知道了如何设置软件定时器并理解了它们的行为,让我们讨论一下它们何时可以使用。
软件定时器指南
软件定时器非常有用,尤其是它们设置起来非常简单。由于它们在 FreeRTOS 中的编码方式,它们相当轻量级——在使用时不需要大量的代码或 CPU 资源。
示例用例
这里有一些用例可以帮助你:
-
为了定期执行一个动作(自动重载模式)。例如,定时器回调函数可以向报告任务发送信号量,以提供有关系统的定期更新。
-
在未来的某个时刻仅执行一次事件,同时不阻塞调用任务(如果使用
vTaskDelay()则会发生这种情况)。
考虑因素
请记住以下考虑因素:
-
定时器服务任务优先级可以通过在
FreeRTOSConfig.h中设置configTIMER_TASK_PRIORITY来配置。 -
定时器创建后可以修改,可以重新启动,也可以删除。
-
定时器可以创建为静态(类似于静态任务创建)以避免从 FreeRTOS 堆中动态分配。
-
所有回调都在软件定时器服务任务中执行 —— 它们必须保持简短且不阻塞!
局限性
那么,软件定时器有什么不好呢?只要记住以下几点,就不会太多:
-
抖动:由于回调是在任务上下文中执行的,它们的精确执行时间将取决于系统中的所有中断以及任何更高优先级的任务。FreeRTOS 允许通过调整所使用的定时器任务的优先级来调整这一点(这必须与其他任务的响应性相平衡)。
-
单优先级:所有软件定时器回调都在同一任务中执行。
-
分辨率:软件定时器的分辨率仅与 FreeRTOS 滴答率(在大多数端口中定义为 1 ms)一样精确。
如果需要更低的抖动或更高的分辨率,可能使用带有中断服务例程(ISRs)的硬件定时器而不是软件定时器是有意义的。
摘要
在本章中,我们讨论了同步任务和保护任务之间共享数据的许多不同方面。我们还介绍了信号量、互斥锁和软件定时器。然后,我们通过为这些类型编写一些代码并使用我们的 Nucleo 开发板和 SystemView 深入分析代码行为来亲自动手。
现在,您已经掌握了一些解决同步问题的工具,例如一个任务通知另一个任务事件已发生(信号量)。这意味着您可以通过在互斥锁中正确封装访问来安全地在任务之间共享数据。您也知道如何在执行简单操作时节省一些 RAM,即通过使用软件定时器进行小周期性操作,而不是专用任务。
在下一章中,我们将介绍更多用于任务间通信和为许多基于 RTOS 的应用程序提供基础的至关重要的 RTOS 原语。
问题
在我们结束本章内容之际,这里有一系列问题供您测试对本章内容的理解。您将在附录的评估部分找到答案:
-
信号量最有用的用途是什么?
-
为什么使用信号量进行数据保护是危险的?
-
mutex 代表什么?
-
为什么互斥锁更适合保护共享数据?
-
在实时操作系统(RTOS)中,由于许多软件定时器的实例可用,因此不需要任何其他类型的定时器。
-
正确
-
错误
-
进一步阅读
-
一篇微软论文,提供了关于信号量问题的更多细节:
www.microsoft.com/en-us/research/publication/implementing-condition-variables-with-semaphores/ -
彼得·库普曼关于竞态条件的内容:
course.ece.cmu.edu/~ece642/lectures/26_raceconditions.pdf
第九章:任务间通信
现在我们能够创建任务,是时候开始在它们之间传递数据了。毕竟,你很少会遇到完全独立运行的并行任务系统;通常,你需要在系统中的不同任务之间传递一些数据。这就是任务间通信发挥作用的地方。
在 FreeRTOS 中,可以使用队列和直接任务通知来实现任务间通信。在本章中,我们将通过示例介绍队列的几个不同用例,并讨论每个用例的优缺点。我们将查看有关在队列中等待项目出现时阻塞的任务的所有细节,以及超时。在查看队列之后,我们将转向任务通知,并了解为什么我们应该使用它们以及何时使用。
简而言之,我们将涵盖以下主题:
-
通过值传递数据通过队列
-
通过引用传递数据
-
直接任务通知
技术要求
要完成本章的练习,你需要以下内容:
-
Nucleo F767 开发板
-
Micro-USB 线
-
STM32CubeIDE 和源代码(见第五章,选择 IDE – 设置我们的 IDE)
-
SEGGER JLink、Ozone 和 SystemView(见第六章,实时系统调试工具)
构建示例的最简单方法是同时构建所有 Eclipse 配置,然后使用 Ozone 加载和查看它们:
-
在STM32CubeIDE中,右键单击项目。
-
选择“构建”。
-
选择“构建所有”。所有示例都将构建到它们自己的命名子目录中(这可能需要一段时间)。
-
在 Ozone*中,你现在可以快速加载每个
<exampleName>.elf文件——见Chapter6中的说明,了解如何进行此操作。正确链接到可执行文件中的源文件将自动显示。
本章中的所有示例代码都可以从https://github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers/tree/master/Chapter_9下载。每个main*.c文件都在Chapter_9项目内部有自己的基于 Eclipse 的配置,准备好编译并加载到 Nucleo 板上。
通过值传递数据通过队列
就像信号量和互斥锁一样,队列是在跨多个异步执行的任务操作时最广泛使用(和实现)的结构之一。它们几乎可以在每个操作系统中找到,因此了解如何使用它们是非常有益的。我们将探讨几种不同的使用队列和与之交互的方法,以影响任务的状态。
在以下示例中,我们将学习如何将队列用作向 LED 状态机发送命令的手段。首先,我们将检查一个非常简单的用例,将单个单字节值传递到队列中并对其操作。
通过值传递一个字节
在这个例子中,设置了一个单个uint8_t来传递单个枚举,(LED_CMDS),一次定义一个 LED 的状态或所有 LED 的状态(开/关)。以下是本例中涵盖的内容摘要:
-
ledCmdQueue:一个单字节值(uint8_t)的队列,表示定义 LED 状态的枚举。 -
recvTask:这个任务从队列中接收一个字节,执行所需的操作,然后立即尝试从队列中接收下一个字节。 -
sendingTask:这个任务通过一个简单的循环将枚举值发送到队列中,每次发送之间有 200 毫秒的延迟(这样 LED 的开关动作就可见了)。
那么,让我们开始吧:
- 设置一个
enum来帮助我们描述传递到队列中的值:
以下是从mainQueueSimplePassByValue.c中摘录的内容:
typedef enum
{
ALL_OFF = 0,
RED_ON = 1,
RED_OFF = 2,
BLUE_ON = 3,
BLUE_OFF= 4,
GREEN_ON = 5,
GREEN_OFF = 6,
ALL_ON = 7
}LED_CMDS;
- 与信号量的初始化范例类似,队列必须首先创建并存储其句柄,以便以后可以使用它来访问队列。定义一个句柄,用于指向用于传递
uint8_t实例的队列:
static QueueHandle_t ledCmdQueue = NULL;
- 使用
xQueueCreate()函数创建队列(在继续之前验证其成功创建):
QueueHandle_t xQueueCreate( UBaseType_t uxQueueLength,
UBaseType_t uxItemSize );
快速概述一下我们在这里看到的内容:
-
uxQueueLength:队列可以容纳的最大元素数 -
uxItemSize:队列中每个元素的大小(以字节为单位) -
返回值:创建的队列的句柄(或错误时返回
NULL)
我们对xQueueCreate的调用将如下所示:
ledCmdQueue = xQueueCreate(2, sizeof(uint8_t));
assert_param(ledCmdQueue != NULL);
让我们概述一下我们在这里看到的内容:
-
队列最多可以容纳
2个元素。 -
每个元素的大小足以存储
uint8_t(一个字节足以存储我们明确定义的任何枚举的值)。 -
xQueueCreate返回创建的队列的句柄,该句柄存储在ledCmdQueue中。这个“句柄”是一个全局变量,将被各种任务在访问队列时使用。
recvTask()的开始如下所示:
void recvTask( void* NotUsed )
{
uint8_t nextCmd = 0;
while(1)
{
if(xQueueReceive(ledCmdQueue, &nextCmd, portMAX_DELAY) == pdTRUE)
{
switch(nextCmd)
{
case ALL_OFF:
RedLed.Off();
GreenLed.Off();
BlueLed.Off();
break;
case GREEN_ON:
GreenLed.On();
break;
让我们仔细看看前面代码中突出显示的实际队列接收行:
if(xQueueReceive(ledCmdQueue, &nextCmd, portMAX_DELAY) == pdTRUE)
-
使用句柄
ledCmdQueue来访问队列。 -
在栈上定义了一个局部
uint8_t,nextCmd。传递这个变量的地址(一个指针)。xQueueReceive将下一个LED_CMD枚举(存储在队列中的字节)复制到nextCmd中。 -
对于此访问使用无限超时——也就是说,如果队列中没有添加任何内容,此函数将永远不会返回(这与互斥锁和信号量 API 调用的超时相同)。
由于延迟时间是无限的,所以if( <...> == pdTRUE)是多余的;然而,提前设置错误处理是一个好主意,这样如果以后定义了非无限超时,就不会忘记错误状态。xQueueReceive()也可能因其他原因失败(例如,无效的队列句柄)。
sendingTask是一个简单的while循环,它使用对枚举值的先验知识将不同的LED_CMDS值传递到ledCmdQueue中:
void sendingTask( void* NotUsed )
{
while(1)
{
for(int i = 0; i < 8; i++)
{
uint8_t ledCmd = (LED_CMDS) i;
xQueueSend(ledCmdQueue, &ledCmd, portMAX_DELAY);
vTaskDelay(200/portTICK_PERIOD_MS);
}
}
}
发送方的 xQueueSend() 的参数几乎与接收方的 xQueueReceive() 相同,唯一的区别是我们这次是向队列发送数据:
xQueueSend(ledCmdQueue, &ledCmd, portMAX_DELAY);
-
ledCmdQueue:发送数据的队列句柄 -
&ledCmd:要传递到队列中的数据的地址 -
portMax_DELAY:等待队列空间变为可用(如果队列已满)的 RTOS 指针数
类似于在达到超时值之前队列中没有内容时 xQueueReceive 的超时,如果队列在指定的超时时间内仍然满载且项目未添加到队列中,xQueueSend 的调用可能会超时。如果你的应用程序有一个非无限的超时(在几乎所有情况下都应该是这样),你需要考虑在这种情况下应该发生什么。可能的行动方案包括简单地丢弃数据项(它将永远丢失)到抛出一个断言并进入某种紧急/恐慌状态,并执行紧急关闭。在某些情况下,重启也很流行。确切的行为通常将由你正在工作的项目/产品的类型决定。
随意构建并下载 queueSimplePassByValue 到 Nucleo 开发板上。你会注意到 LED 会遵循由 LED_CMDS 枚举定义的模式:ALL_OFF、RED_ON、RED_OFF、BLUE_ON、BLUE_OFF、GREEN_ON、GREEN_OFF、ALL_ON,每个状态转换之间间隔 200 毫秒。
但如果我们决定我们想要同时操作多个 LED,我们可以向现有的 LED_CMDS 枚举中添加更多值,例如 RED_ON_BLUE_ON_GREEN_OFF,但这将涉及大量的容易出错的输入,特别是如果我们有超过 3 个 LED(8 个 LED 需要覆盖每个 LED 开/关的所有组合,枚举值达到 256)。相反,让我们看看我们如何使用 struct 来描述 LED 命令并通过我们的队列传递它。
通过值传递复合数据类型
FreeRTOS 队列(以及大多数其他 FreeRTOS API 函数)接受 void* 作为操作的数据类型的参数。这样做是为了尽可能高效地为应用程序编写者提供灵活性。由于 void* 只是一个指向 任何事物 的指针,并且队列中元素的大小在创建时就已经定义,因此队列可以用来在任务之间传递任何东西。
使用 void* 与队列交互就像一把双刃剑。它提供了最大的灵活性,但也提供了你将错误的数据类型传递到队列中的真实可能性,而且编译器可能不会发出警告。你必须跟踪每个队列中存储的数据类型!
我们将利用这种灵活性,传递一个由 uint8_t 实例的 struct 组成的复合数据类型,每个实例只有一位宽,以描述所有三个 LED 的状态:
来自 mainQueueCompositePassByValue.c 的摘录:
typedef struct
{
uint8_t redLEDState : 1; //specify this variable as 1 bit wide
uint8_t blueLEDState : 1; //specify this variable as 1 bit wide
uint8_t greenLEDState : 1; //specify this variable as 1 bit wide
uint32_t msDelayTime; //min number of mS to remain in this state
}LedStates_t;
我们还将创建一个能够容纳整个 LedStates_t 结构体八份副本的队列:
ledCmdQueue = xQueueCreate(8, sizeof(LedStates_t));
与上一个示例类似,recvTask 等待从 ledCmdQueue 队列中可用一个项目,然后对其进行操作(根据需要打开/关闭 LED):
mainQueueCompositePassByValue.c recvTask:
if(xQueueReceive(ledCmdQueue, &nextCmd, portMAX_DELAY) == pdTRUE)
{
if(nextCmd.redLEDState == 1)
RedLed.On();
else
RedLed.Off();
if(nextCmd.blueLEDState == 1)
BlueLed.On();
else
BlueLed.Off();
if(nextCmd.greenLEDState == 1)
GreenLed.On();
else
GreenLed.Off();
}
vTaskDelay(nextCmd.msDelayTime/portTICK_PERIOD_MS);
下面是 recvTask 的主要循环的职责:
-
每次从队列中可用一个元素时,都会评估结构体的每个字段,并采取适当的操作。所有三个 LED 都通过发送到队列的单个命令进行更新。
-
新创建的
msDelayTime字段也被评估(它用于在任务再次尝试从队列接收之前添加延迟)。这就是使系统足够慢,以便 LED 状态可见的原因。
mainQueueCompositePassByValue.c sendingTask:
while(1)
{
nextStates.redLEDState = 1;
nextStates.greenLEDState = 1;
nextStates.blueLEDState = 1;
nextStates.msDelayTime = 100;
xQueueSend(ledCmdQueue, &nextStates, portMAX_DELAY);
nextStates.blueLEDState = 0; //turn off just the blue LED
nextStates.msDelayTime = 1500;
xQueueSend(ledCmdQueue, &nextStates, portMAX_DELAY);
nextStates.greenLEDState = 0;//turn off just the green LED
nextStates.msDelayTime = 200;
xQueueSend(ledCmdQueue, &nextStates, portMAX_DELAY);
nextStates.redLEDState = 0;
xQueueSend(ledCmdQueue, &nextStates, portMAX_DELAY);
}
sendingTask 的循环向 ledCmdQueue 发送几个命令——以下是详细信息:
-
sendingTask看起来与之前略有不同。现在,由于传递了一个结构体,我们可以访问每个字段,在将nextStates发送到队列之前设置多个字段。 -
每次调用
xQueueSend时,都会将nextStates的内容复制到队列中,然后再继续。一旦xQueueSend()成功返回,nextStates的值就会被复制到队列存储中;nextStates不需要保留。
为了说明 nextStates 的值被复制到队列中的这一点,此示例更改了任务的优先级,以便在 recvTask 清空队列之前,由 sendingTask 将队列填满。这是通过给 sendingTask 比 revcTask 更高的优先级来实现的。以下是我们的任务定义的外观(断言在代码中存在但在此处未显示以减少混乱):
xTaskCreate(recvTask, "recvTask", STACK_SIZE, NULL, tskIDLE_PRIORITY + 1,
NULL);
xTaskCreate(sendingTask, "sendingTask", STACK_SIZE, NULL,
configMAX_PRIORITIES – 1, NULL);
sendingTask 被配置为系统中的最高优先级。configMAX_PRIORITIES 在 Chapter9/Inc/FreeRTOSConfig.h 中定义,并且是可用的优先级数量。FreeRTOS 任务优先级被设置为 0 是系统中的最低优先级任务,而系统中的最高优先级是 configMAX_PRIORITIES - 1。
这种优先级设置允许 sendingTask 重复向队列发送数据,直到它填满(因为 sendingTask 具有更高的优先级)。队列填满后,sendingTask 将阻塞,并允许 recvTask 从队列中移除一个项目。让我们更详细地看看这是如何实现的。
理解队列如何影响执行
任务优先级与队列等原语协同工作,以定义系统的行为。这在抢占式实时操作系统应用中尤为重要,因为上下文总是基于优先级给出的。程序化的队列交互需要考虑任务优先级以实现所需的操作。优先级需要仔细选择,以便与单个任务的设计协同工作。
在这个例子中,为sendingTask选择了无限等待时间,以便它可以填满队列。
下面是一个描述前面设置动作的图示:

看一下这个使用 Ozone 逐步执行代码并理解其行为的示例。我们可以逐步通过sendingTask while循环的几个迭代,观察每个任务中的ledCmdQueue数据结构和断点设置:
-
确保你已经构建了
queueCompositePassByValue配置。 -
双击
Chapter_9\Chapter_9.jdebug打开 Ozone。 -
前往文件 | 打开 | Chapter_9\queueCompositePassByValue\Chapter9_QueuePassCompositeByValue.elf。
-
打开全局变量视图,在逐步执行代码时观察
ledCmdQueue。 -
在
recvTask中设置断点,以便在从队列中移除项目时停止调试器。 -
当
recvTask第一次运行时,你会注意到uxMessagesWaiting的值将是8(队列已满):

在遇到严重问题之前熟悉你正在使用的任何调试器总是一个好主意。一种第二本能的熟悉程度可以让你将注意力集中在手头的问题上,而不是使用的工具。
关于示例的重要注意事项
之前示例的主要目的是说明以下要点:
-
队列可以用来存储任意数据。
-
队列以有趣的方式与任务优先级交互。
为了简化行为并使示例更容易理解,我们做出了一些权衡:
-
从队列接收任务的优先级较低:在实践中,你需要平衡从队列接收任务的优先级(以保持低延迟并防止队列填满)与系统中其他事件的优先级。
-
使用了长队列来执行命令:深度队列与从其接收的低优先级任务结合将创建系统中的延迟。由于低任务优先级和长队列长度的组合,这个例子包含了几个秒的队列命令。由于深度/优先级的组合,添加到队列中的元素将在添加后几秒钟才执行。
-
向队列发送项目时使用了无限超时:这将导致
sendTask()无限期地等待一个槽位变得可用。在这种情况下,这是我们想要的(为了简单起见),但在实际的时间关键系统中,你需要记住任务在发生错误之前能够等待多长时间。
我们还没有完全探索队列的灵活性。接下来,我们将查看通过引用向队列传递数据的特殊情况。
通过引用通过队列传递数据
由于队列的数据类型是任意的,我们也有能力通过引用而不是通过值传递数据。这与通过引用传递函数参数的方式类似。
何时通过引用传递
由于队列会复制其持有的任何内容,如果被队列化的数据结构很大,按值传递它将是不高效的:
-
从队列中发送和接收数据会强制复制队列元素。
-
如果队列中包含大量结构,则结果队列对于大型数据项会变得非常大。
因此,当需要队列化大型项目时,通过引用传递项目是一个好主意。以下是一个较大结构体的示例。在编译器填充此结构体后,它最终的大小为 264 字节:
mainQueueCompositePassByReference.c:
#define MAX_MSG_LEN 256
typedef struct
{
uint32_t redLEDState : 1;
uint32_t blueLEDState : 1;
uint32_t greenLEDState : 1;
uint32_t msDelayTime; //min number of mS to remain in this state
//an array for storing strings of up to 256 char
char message[MAX_MSG_LEN];
}LedStates_t;
而不是每次向ledCmdQueue添加或从其中移除项目时都复制 264 字节,我们可以将ledCmdQueue定义为保存指向LedStates_t的指针(在 Cortex-M 上为 4 字节):
ledCmdQueue = xQueueCreate(8, **sizeof(LedStates_t*****)**);
让我们看看按值传递和通过引用传递之间的区别:
按值传递:
-
ledCmdQueue大小: ~ 2 KB(264 字节 * 8 个元素)。 -
每次调用
xQueueSend()或xQueueReceive()时,会复制 264 字节。 -
添加到队列中的
LedStates_t原始副本可以立即丢弃(队列内已存在完整的副本)。
通过引用传递:
-
ledCmdQueue大小: 32 字节(4 字节 * 8 个元素)。 -
每次调用
xQueueSend()或xQueueReceive()时,会复制 4 字节(指针的大小)。 -
添加到队列中的
LedStates_t原始副本必须保留,直到不再需要(这是系统中的唯一副本;只有原始结构的指针被队列化)。
当通过引用传递时,我们是在提高效率、(可能)减少 RAM 消耗和更复杂的代码之间做出权衡。额外的复杂性来自于确保原始值在整个需要的时间内保持有效。这种方法与将结构体作为参数传递给函数的引用传递非常相似。
可以创建一些LedStates_t的实例:
static LedStates_t ledState1 = {1, 0, 0, 1000,
"The quick brown fox jumped over the lazy dog.
The Red LED is on."};
static LedStates_t ledState2 = {0, 1, 0, 1000,
"Another string. The Blue LED is on"};
使用 Ozone,我们可以轻松查看我们创建的内容:
-
ledCmdQeue的uxItemSize是 4 字节,正如我们预期的那样,因为队列正在保存指向LedStates_t的指针。 -
ledState1和ledState2的实际大小都是 264 字节,正如预期的那样:

要向队列发送项目,请按照以下步骤操作:
- 创建变量的指针并将其地址传递进去:
void sendingTask( void* NotUsed )
{
LedStates_t* state1Ptr = &ledState1;
LedStates_t* state2Ptr = &ledState2;
while(1)
{
xQueueSend(ledCmdQueue, &state1Ptr, portMAX_DELAY);
xQueueSend(ledCmdQueue, &state2Ptr, portMAX_DELAY);
}
}
- 要从工作队列接收项目,只需定义正确数据类型的指针并传递指针的地址:
void recvTask( void* NotUsed )
{
LedStates_t* nextCmd;
while(1)
{
if(xQueueReceive(ledCmdQueue, &nextCmd, portMAX_DELAY) ==
pdTRUE)
{
if(nextCmd->redLEDState == 1)
RedLed.On();
当操作从队列中取出的项目时,请记住你有一个需要解引用的指针(即nextCmd->redLEDState)。
现在来说说注意点...
重要注意事项
通过引用传递数据结构在移动大数据结构时可能比按值传递更高效,但需要注意以下几点:
-
保持数据类型一致:由于队列的参数是
void*数据类型,编译器将无法警告你正在提供一个结构体的地址而不是指针的地址。 -
保留队列中的数据:与通过值传递数据不同,当队列持有数据的指针时,传递给队列的底层数据需要保留直到它被使用。这有以下影响:
-
数据不能存在于栈上——没有局部函数变量!虽然这可以实现,但在调用链的中间定义变量并将指针推入队列通常是一个坏主意。当接收任务从队列中拉出指针时,发送任务的栈可能已经改变。即使你能在某些情况下(例如,当接收任务比发送任务具有更高的优先级时)实现这一点,你也将创建一个非常脆弱的系统,它可能在未来的某个微妙的方式上崩溃。
-
对于底层变量,必须有一个稳定的存储位置。全局和静态分配的变量都是可接受的。如果你想要限制对变量的访问,可以在函数内部使用静态分配。这将使变量保持在内存中,就像它是全局变量一样,但限制对其的访问:
-
void func( void )
{
static struct MyBigStruct myVar;
-
- 你应该为变量动态分配空间(如果应用程序中接受动态分配)。有关内存管理(包括动态分配)的详细信息,请参阅第十五章,FreeRTOS 内存管理。
-
谁拥有数据?当队列有一个结构体的副本时,队列拥有这个副本。一旦项目从队列中移除,它就会消失。这与持有数据指针的队列形成对比。当指针从队列中移除时,数据仍然存在于其原始位置。数据所有权需要非常明确。接收队列指针的任务将成为新的所有者(并负责释放如果使用过的话动态分配的内存)吗?原始发送指针的任务仍然保持所有权吗?这些都是需要提前考虑的重要问题。
既然我们已经讨论了传递大量数据(尽可能避免!),让我们谈谈传递少量数据的高效方法。
直接任务通知
由于其灵活性,队列是实时操作系统(RTOS)的一个优秀的工作马。有时,所有这些灵活性都不需要,我们更希望有一个更轻量级的替代方案。直接任务通知与其他讨论的通信机制类似,不同之处在于它们不需要在 RAM 中首先实例化通信对象。它们也比信号量或队列(快 35%到 45%)更快。
它们确实有一些限制,最大的两个限制是每次只能通知一个任务,并且通知可以由中断服务例程(ISR)发送但不能接收。
直接任务通知有两个主要组成部分:通知本身(它在解除任务阻塞时的行为非常类似于信号量或队列)和一个 32 位通知值。通知值是可选的,有几个不同的用途。通知器可以选择覆盖整个值或使用通知值作为位字段并设置单个位。设置单个位对于在不依赖基于队列的更复杂命令驱动实现的情况下,让任务意识到不同的行为非常有用。
以我们的 LED 为例。如果我们想创建一个简单的 LED 处理程序,它能快速响应更改请求,那么就不需要多元素队列;我们可以利用内置的 32 位宽通知值。
如果你认为任务通知听起来有点像信号量,你就对了!任务通知也可以用作比信号量更快的替代品。
让我们通过一个示例来看看任务通知如何被用来发布命令并将信息传递给一个任务。
使用任务通知传递简单数据
在这个例子中,我们的目标是让recvTask设置 LED 状态,它在这一章的整个过程中一直在做这件事。这次,recvTask将一次只执行一个状态更改,而不是允许未来 LED 状态的多个副本堆积起来并在将来某个时间执行。
由于通知值是内置在任务中的,因此不需要创建额外的队列——我们只需要确保我们存储了recvTask的任务句柄,这将在我们发送通知时使用。
让我们通过查看一些mainTaskNotifications.c的摘录来看看我们是如何做到这一点的:
- 在
main函数外部,我们将定义一些位掩码和一个任务句柄:
#define RED_LED_MASK 0x0001
#define BLUE_LED_MASK 0x0002
#define GREEN_LED_MASK 0x0004
static xTaskHandle recvTaskHandle = NULL;
- 在
main函数内部,我们将创建recvTask并传递一个句柄来填充:
retVal = xTaskCreate(recvTask, "recvTask", STACK_SIZE, NULL,
tskIDLE_PRIORITY + 2, &recvTaskHandle);
assert_param( retVal == pdPASS);
assert_param(recvTaskHandle != NULL);
- 接收通知的任务被设置为等待下一个传入的通知,然后评估每个 LED 的掩码,相应地打开/关闭 LED:
void recvTask( void* NotUsed )
{
while(1)
{
uint32_t notificationvalue = ulTaskNotifyTake( pdTRUE,
portMAX_DELAY );
if((notificationvalue & RED_LED_MASK) != 0)
RedLed.On();
else
RedLed.Off();
- 发送任务被设置为发送一个通知,覆盖可能存在的任何现有通知。这导致
xTaskNotify始终返回pdTRUE:
void sendingTask( void* NotUsed )
{
while(1)
{
xTaskNotify( recvTaskHandle, RED_LED_MASK,
eSetValueWithOverwrite);
vTaskDelay(200);
这个例子可以使用directTaskNotification配置构建并上传到 Nucleo。当通知被发送到recvTask时,它将依次闪烁每个 LED。
任务通知的其他选项
从task.h中的eNotifyAction枚举中,我们可以看到通知任务的其他选项包括以下内容:
eNoAction = 0, /* Notify the task without updating its notify value. */
eSetBits, /* Set bits in the task's notification value. */
eIncrement, /* Increment the task's notification value. */
eSetValueWithOverwrite, /* Set the task's notification value to a specific value even if the previous value has not yet been read by the task. */
eSetValueWithoutOverwrite /* Set the task's notification value if the previous value has been read by the task. */
使用这些选项可以创建一些额外的灵活性,例如使用通知作为二进制和计数信号量。请注意,这些选项中的一些会改变xTaskNotify的返回方式,因此在某些情况下需要检查返回值。
比较直接任务通知与队列
与队列相比,任务通知具有以下特性:
-
它们总是有正好一个 32 位整数的存储容量。
-
它们不提供等待将通知推送到忙碌任务的方法;它将覆盖现有的通知或立即返回而不写入。
-
它们只能与一个接收器一起使用(因为通知值存储在接收任务中)。
-
它们更快。
让我们看看一个使用 SystemView 的实际例子,比较我们刚刚编写的直接通知代码和第一个队列实现。
当执行xQueueSend时,mainQueueSimplePassByValue.c中的队列实现看起来是这样的:

当调用xTaskNotify时,直接任务通知看起来是这样的:

从前面的截图我们可以看出,在特定用例中,直接任务通知比使用队列快 25-35%。使用直接任务通知时,存储队列也没有 RAM 开销。
摘要
你现在已经学会了如何在各种场景下使用队列,例如通过值和引用传递简单和复合元素。你了解使用队列存储对象引用的优缺点,以及何时适合使用这种方法。我们还涵盖了队列、任务和任务优先级之间的一些详细交互。最后,我们通过一个简单的现实世界例子展示了如何使用任务通知来高效地驱动一个小型状态机。
随着你越来越习惯于使用 RTOS 解决各种问题,你会发现使用队列和任务通知的新颖和创造性的方法。任务、队列、信号量和互斥锁确实是 RTOS 应用程序的构建块,并将帮助你走得很远。
尽管如此,我们还没有完全完成这些元素——关于在 ISR 上下文中使用所有这些原语还有很多更高级的材料要介绍,这正是接下来的内容!
问题
在我们总结之前,这里有一份问题列表,供你测试对本章材料的理解。你将在附录的评估部分找到答案:
-
可以将哪些数据类型传递给队列?
-
当任务在等待时尝试操作队列会发生什么?
-
在将引用传递给队列时,需要考虑哪些因素?
-
任务通知可以完全替代队列:
-
真的
-
假的
-
-
任务通知可以发送任何类型的数据:
-
真的
-
假的
-
-
与队列相比,任务通知的优点是什么?
进一步阅读
-
FreeRTOSConfig.h中所有常量的解释:www.freertos.org/a00110.html -
FreeRTOS 直接任务通知:
www.freertos.org/RTOS-task-notifications.html
第四部分:高级 RTOS 技术
通过在前一节的概念基础上构建,你将学习如何利用硬件加速和固件技术获得最佳实时性能。在深入研究高效的驱动程序实现之后,你将学习如何构建一个健壮且可测试的代码库,这将是一个令人愉快的合作项目,并且可以跨越多种硬件设计。了解你应该注意的高级考虑因素,例如 RTOS API 中的各种选项以及你可以设计到系统中的核心或处理器的数量。最后,你将获得一些故障排除技巧和推荐的下一步行动。
本节包含以下章节:
-
第十章, 驱动程序和中断服务例程
-
第十一章,* 在任务间共享硬件外围设备*
-
第十二章,* 创建良好抽象架构的技巧*
-
第十三章,* 使用队列创建松散耦合*
-
第十四章, 选择 RTOS API
-
第十五章,* FreeRTOS 内存管理*
-
第十六章,* 多处理器和多核系统*
-
第十七章,* 故障排除技巧和下一步行动*
第十章:驱动程序和中断服务例程(ISRs)
与微控制器单元(MCU)的外设交互在许多应用中非常重要。在本章中,我们将讨论几种不同的实现外设驱动程序的方法。到目前为止,我们一直使用闪烁的 LED 作为与我们的开发板交互的手段。这即将改变。随着我们寻求对外设驱动程序有更深入的了解,我们将开始关注实现通用通信外设——通用异步收发传输器(UART)驱动程序的不同方式。当我们从一个 UART 向另一个 UART 传输数据时,我们将揭示外设和直接内存访问(DMA)硬件在创建高效驱动程序实现时的重要作用。
我们将首先通过在任务中实现一个极其简单的轮询只接收驱动程序来探索 UART 外设。在查看该驱动程序的性能后,我们将仔细研究中断服务例程(ISRs)以及它们与 RTOS 内核交互的不同方式。驱动程序将使用中断重新实现。之后,我们将添加对基于 DMA 的驱动程序的支持。最后,我们将探讨几种不同的方法,了解驱动程序如何与系统其他部分交互,并查看 FreeRTOS 的新功能——流缓冲区。在本章中,我们将使用 SystemView 密切关注整体系统性能。到本章结束时,你应该对编写可以利用 RTOS 功能来提高可用性的驱动程序时需要做出的权衡有一个稳固的理解。
本章将涵盖以下主题:
-
介绍 UART
-
创建轮询 UART 驱动程序
-
区分任务和中断服务例程
-
创建基于中断服务例程的驱动程序
-
创建基于 DMA 的驱动程序
-
FreeRTOS 流缓冲区
-
选择驱动程序模型
-
使用第三方库(STM HAL)
技术要求
要完成本章的练习,你需要以下内容:
-
Nucleo F767 开发板
-
跳线——20 到 22 AWG(约 0.65 毫米)实心线
-
Micro-USB 线缆
-
STM32CubeIDE 和源代码(在第五章的设置我们的 IDE部分中的说明,选择 IDE)
-
SEGGER J-Link、Ozone 和 SystemView(在第六章的实时系统调试工具部分中的说明)
本章中使用的所有源代码均可在github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers/tree/master/Chapter_10找到。
介绍 UART
正如我们在第四章中简要介绍的,选择合适的 MCU,缩写 UART 代表 通用异步收发器/发送器。UART 硬件通过在预定速率下调制信号线的电压来传输数据字节:

UART 的 异步 特性意味着不需要额外的时钟线来监控单个位转换。相反,硬件被设置为以特定频率(波特率)转换每个位。UART 硬件还在每个传输的数据包的开始和结束处添加一些额外的帧。起始位和停止位表示数据包的开始和结束。这些位(以及可选的奇偶校验位)由硬件用于帮助保证数据包的有效性(通常长度为 8 位)。
UART 硬件的更通用形式是 USART(通用同步/异步接收发送器)。USARTs 能够以同步(添加时钟信号)或异步(不添加时钟信号)的方式传输数据。
UARTs 常常用于不同芯片和系统之间的通信。它们构成了许多不同通信解决方案的基础,例如 RS232、RS422、RS485、Modbus 等。UARTs 也可以用于多处理器通信以及与同一系统中的不同芯片通信——例如,WiFi 和蓝牙收发器。
在本章中,我们将开发几个 UART 驱动程序的迭代版本。为了能够观察系统行为,我们将把 Nucleo 开发板上的两个 UART 连接起来,如下面的图所示。图中的两个连接将把 UART4 的发送信号连接到 USART2 的接收信号。同样,它们将 USART2 的 Tx 连接到 UART4 的 Rx。这将允许 UARTs 之间的双向通信。连接应使用预接线的 跳线 或 20-22 AWG (~0.65 mm) 实心线:

现在连接已经完成,让我们更详细地看看在我们可以考虑在这个芯片上的外设之间传输数据之前还需要发生什么。
设置 UART
如以下简化框图所示,设置 UART 进行通信时涉及一些组件。UART 需要正确配置以在正确的波特率、奇偶校验设置、流量控制和停止位下传输。与 UART 交互的其他硬件也需要正确配置:

这里有一份需要执行的步骤列表,以设置 UART4。虽然我们以 UART4 为例,但相同的步骤将适用于大多数其他连接到 MCU 引脚的外设:
- 配置 GPIO 线。由于 MCU 上的每个 GPIO 引脚都可以与许多不同的外设共享,因此它们必须配置为连接到所需的外设(在这种情况下,是 UART)。在这个例子中,我们将介绍将 PC10 和 PC11 连接到 UART4 信号的过程:
你可以在 STM 的 STM32F767xx 数据手册第三部分,引脚配置和引脚描述中了解更多关于 STM32F7xx 系列 MCU 引脚的信息DoCID 029041。数据手册通常包含特定 MCU 型号的信息,而参考手册将包含整个 MCU 系列的一般信息。以下摘录来自数据表中的一个表格,显示了交替功能引脚映射:

-
引用所需的端口和位。(在这种情况下,我们将设置端口
C位11以映射到UART4_Rx功能)。 -
找到所需的引脚交替功能(
UART4_Rx)。 -
找到配置 GPIO 寄存器时使用的交替功能号(
AF8)。 -
设置适当的 GPIO 寄存器以正确配置硬件并将所需的 UART 外设映射到物理引脚。
为了简单起见,这里使用了 STM 提供的HAL函数。当调用HAL_GPIO_Init时,将写入适当的 GPIO 寄存器。我们只需要填写一个GPIO_InitTypeDef结构体,并传递HAL_GPIO_Init的引用;在下面的代码中,端口C上的10GPIO 引脚和11GPIO 引脚都被初始化为交替推挽功能。它们还通过将交替功能成员设置为AF8(在第 4 步中确定)映射到UART4:
GPIO_InitTypeDef GPIO_InitStruct = {0};
//PC10 is UART4_TX PC11 is UART4_RX
GPIO_InitStruct.Pin = GPIO_PIN_10 | GPIO_PIN_11;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Alternate = GPIO_AF8_UART4;
HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);
- 启用必要的外设时钟。由于每个外设时钟默认关闭(为了节能),必须通过写入复位和时钟控制(RCC)寄存器来打开 UART 的外设时钟。以下行也来自
HAL:
__UART4_CLK_ENABLE();
-
通过配置嵌套向量中断控制器(NVIC)中的设置(如果使用它们)来配置中断——适当的情况下,将在示例中包含详细信息。
-
配置 DMA(如果使用它)——适当的情况下,将在示例中包含详细信息。
-
配置外设所需的设置,例如波特率、奇偶校验、流量控制等。
以下代码是从BSP/UartQuickDirtyInit.c中的STM_UartInit函数的摘录,其中Baudrate和STM_UART_PERIPH是STM_UartInit的输入参数,这使得配置具有类似设置的多个 UART 外设变得非常容易,无需每次都重复以下代码:
HAL_StatusTypeDef retVal;
UART_HandleTypeDef uartInitStruct;
uartInitStruct.Instance = STM_UART_PERIPH;
uartInitStruct.Init.BaudRate = Baudrate;
uartInitStruct.Init.WordLength = UART_WORDLENGTH_8B;
uartInitStruct.Init.StopBits = UART_STOPBITS_1;
uartInitStruct.Init.Parity = UART_PARITY_NONE;
uartInitStruct.Init.Mode = UART_MODE_TX_RX;
uartInitStruct.Init.HwFlowCtl = UART_HWCONTROL_NONE;
uartInitStruct.Init.OverSampling = UART_OVERSAMPLING_16;
uartInitStruct.Init.OneBitSampling = UART_ONE_BIT_SAMPLE_DISABLE;
uartInitStruct.hdmatx = DmaTx;
uartInitStruct.hdmarx = DmaRx;
uartInitStruct.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_NO_INIT;
retVal = HAL_UART_Init(&uartInitStruct);
assert_param(retVal == HAL_OK);
- 根据所需的传输方法(例如轮询、中断驱动或 DMA),可能需要一些额外的设置;这种设置通常在开始传输之前立即执行。
让我们通过创建一个简单的驱动程序来读取进入 USART2 的数据,看看这一切是如何发挥作用的。
创建轮询 UART 驱动程序
在编写低级驱动程序时,必须阅读数据手册以了解外设的工作方式。即使你不是从头开始编写低级驱动程序,了解你将要工作的硬件也是一个好主意。你对硬件的了解越多,诊断意外行为以及创建高效解决方案就越容易。
你可以在STM32F76xxx参考手册(USART)的第三十四章中了解更多关于我们正在工作的 UART 外设的信息。
我们的第一个驱动程序将采取极其简单的方法从 UART 获取数据并将其放入一个可以轻松监控和由系统中的任何任务消费的队列中。通过监控 UART 外设的接收未空(RXNE)位和中断状态寄存器(ISR),驱动程序可以确定何时一个新字节准备好从 UART 的接收数据寄存器(RDR)传输到队列中。为了使这个过程尽可能简单,while循环被放置在一个任务(polledUartReceive)中,这将允许其他更高优先级的任务运行。
以下是从Chapter_10/Src/mainUartPolled.c摘录的内容:
void polledUartReceive( void* NotUsed )
{
uint8_t nextByte;
//setup UART
STM_UartInit(USART2, 9600, NULL, NULL);
while(1)
{
while(!(USART2->ISR & USART_ISR_RXNE_Msk)); nextByte = USART2->RDR; xQueueSend(uart2_BytesReceived, &nextByte, 0); }
}
在这个例子中还有一个简单的任务;它监控队列并打印出接收到的任何内容:
void uartPrintOutTask( void* NotUsed)
{
char nextByte;
while(1)
{
xQueueReceive(uart2_BytesReceived, &nextByte, portMAX_DELAY);
SEGGER_SYSVIEW_PrintfHost("%c", nextByte);
}
}
现在我们已经准备好了驱动程序,让我们看看它的性能如何。
分析性能
之前的代码(uartPolled)可以编程到 MCU 中,我们可以使用 SEGGER SystemView 查看性能:

在使用 SystemView 查看执行后,我们很快意识到——尽管易于编程,但这个驱动程序的效率极其低下:
-
SystemView 报告称,此驱动程序正在使用超过 96%的 CPU 资源。
-
队列在 960 Hz 的频率下被调用(考虑到初始波特率为 9,600 波特,这是完全合理的)。
我们可以看到,虽然易于实现,但这个解决方案带来了显著的性能损失——而这一切都是在服务一个相当慢的外设。通过轮询服务外设的驱动程序有权衡之处。
轮询驱动程序的优缺点
使用轮询驱动程序的一些优点如下:
-
它很容易编程。
-
任何任务都可以立即访问队列中的数据。
同时,这种方法也存在许多问题:
-
它必须是系统中优先级最高的任务之一。
-
如果不在高优先级下执行,数据丢失的可能性很高。
-
这对 CPU 周期来说极其浪费。
在这个例子中,我们只以 9,600 波特率传输数据。诚然,大部分时间都花在了RXNE位上,但将每个字节作为接收到的队列中的数据传输也是相当昂贵的(与将字节推入简单的基于数组的缓冲区相比)。为了更直观地说明这一点,STM32F767 上的 USART2 在 216 MHz 的频率下运行,其最大波特率为 27 Mbaud,这意味着我们几乎需要每秒将每个字符添加到共享队列中近 300 万次(目前每秒添加不到 1,000 个字符)。由于队列添加需要 7 µS(即使 CPU 不做其他任何事情,我们每秒也最多能将 143,000 个字符传输到队列中),因此在这种硬件上通过队列快速传输这么多数据是不可行的。
更重要的是,由于我们可能每毫秒只接收一个新字符,因此几乎没有机会加快这种轮询方法。如果任何其他任务执行时间超过 2 ms,外围设备可能会被溢出(新字节在读取前被接收并覆盖缓冲区)。由于这些限制,轮询驱动程序在非常特定的环境中最有用。
轮询驱动程序的使用
在某些情况下,轮询驱动程序特别有用:
-
系统验证:在进行初始系统验证时,这是完全可以接受的,但在那个开发阶段,是否应该使用实时操作系统(RTOS)还有待商榷。如果应用程序确实是单一用途的,在等待数据传输的过程中没有其他事情可做,并且没有考虑电源问题,这也是一种可接受的方法。
-
特殊情况:偶尔,可能需要为有限的范围编写非常特殊用途的代码。例如,外围设备可能需要以极低的延迟进行服务。在其他情况下,轮询的事件可能发生得非常快。当事件以纳秒或微秒(ns 或µs)的顺序发生(而不是像上一个例子中的毫秒 ms),简单地轮询事件通常比创建更复杂的同步方案更有意义。在事件驱动系统中,添加阻塞调用必须仔细考虑并明确记录。
相反,如果某个事件发生得非常频繁,并且没有特定的时序限制,轮询方法也可能完全可行。
虽然这个示例中的驱动程序专注于接收端,在基于轮询的驱动程序很少被接受的情况下,但它们更常用于传输数据。这是因为字符之间的空间通常是可接受的,因为它不会导致数据丢失。这允许驱动程序以较低的优先级运行,以便系统中的其他任务有机会运行。在某些情况下,使用轮询传输驱动程序是有合理理由的,该驱动程序在传输过程中会阻塞:
-
使用驱动程序的代码必须在传输完成之前阻塞。
-
传输是一小部分数据。
-
数据速率相当高(因此传输所需时间相对较短)。
如果所有这些条件都满足,那么简单地使用轮询方法而不是更复杂的基于中断或 DMA 的方法可能是有意义的,后者将需要使用回调和可能的任务同步机制。然而,根据您选择如何构建您的驱动程序,也可能同时拥有阻塞调用的便利性,但又不至于有轮询传输浪费 CPU 周期的低效性。为了利用任何非轮询方法,我们需要开发另一项技能——编程 ISRs。
区分任务和 ISRs
在我们开始编写利用中断的外设驱动程序之前,让我们快速看一下中断与 FreeRTOS 任务相比如何。
任务和 ISRs 之间有许多相似之处:
-
它们都提供了一种实现 并行 代码执行的方法。
-
它们都只在需要时运行。
-
两种情况都可以用 C/C++ 编写(ISRs 通常不再需要用汇编代码编写)。
但任务和 ISRs 之间也有很多不同之处:
-
ISRs 由硬件引入上下文;任务通过 RTOS 内核获得上下文:任务总是由 FreeRTOS 内核引入上下文。另一方面,中断是由 MCU 中的硬件生成的。通常有几种不同的方式来配置中断的生成(和屏蔽)。
-
ISRs 必须尽可能快地退出;任务则更加宽容:FreeRTOS 任务通常被设置为以类似无限
while循环的方式运行——它们将使用原语(如队列和信号量)与系统同步,并根据它们的优先级切换上下文。在光谱的另一端是 ISRs,它们应该通常被编码为快速退出。这种 快速退出 确保系统可以响应其他 ISRs,从而保持系统的响应性,并确保不会因为单个例程占用 CPU 而错过任何中断。 -
ISR 函数不接受输入参数;任务可以:与任务不同,ISR 永远不会接受输入参数。由于中断是由硬件状态触发的,ISR 最重要的任务是读取硬件状态(通过内存映射寄存器)并采取适当的行动。例如,当 UART 接收一个字节的数据时,可以生成中断。在这种情况下,ISR 将读取状态寄存器,读取(并存储)接收到的字节到一个静态变量中,并清除中断。
大多数(但并非全部)STM32 硬件上的外设,在读取某些寄存器时将自动清除中断标志。无论中断是如何被清除的,确保中断不再挂起是很重要的——否则,中断将连续触发,你将始终在执行相关的中断服务例程(ISR)!
-
ISR 只能访问 FreeRTOS API 的有限 ISR 特定子集:FreeRTOS 是以一种提供灵活性同时平衡便利性、安全性和性能的方式编写的。从任务中访问数据结构,如队列,非常灵活(例如,向队列发出 API 调用的任务可以轻松地阻塞任何时间段)。ISR 有一组额外的函数可用于在队列上操作,但这些函数的功能有限(例如,不能阻塞——调用总是立即返回)。这提供了一定程度的安全性,因为程序员不能在 ISR 内部调用一个会阻塞的函数。在 ISR 内部调用非 ISR API 函数将导致 FreeRTOS 触发
configASSERT。 -
ISR 可以完全独立于所有 RTOS 代码运行:有许多情况下,ISR 在如此低级别上运行,它甚至不需要访问任何 FreeRTOS API。在这种情况下,ISR 将像通常没有 RTOS 存在时那样执行。内核永远不会介入(并且没有任务会中断执行)。这使得创建灵活的解决方案变得非常方便,这些解决方案将高性能 ISR(完全在 RTOS 之下运行)与极其方便的任务相结合。
-
所有 ISR 共享相同的系统堆栈;每个任务都有一个专用的堆栈:每个任务都接收一个私有堆栈,但所有 ISR 共享相同的系统堆栈。这值得注意,因为当你编写 ISR 时,你需要确保为它们保留足够的堆栈空间,以便它们可以运行(可能同时运行),如果它们是嵌套的。
既然我们已经讨论了任务和 ISR 之间的区别,让我们看看它们如何结合使用来创建非常强大的事件驱动代码。
从中断中使用 FreeRTOS API
到目前为止,大多数 FreeRTOS 原语都有它们 API 的中断安全版本。例如,xQueueSend()有一个等效的中断安全版本,xQueueSendFromISR()。ISR 安全版本和标准调用之间有一些差异:
-
FromISR变体不会阻塞。例如,如果xQueueSendFromISR遇到满队列,它将立即返回。 -
FromISR变体需要额外的参数,BaseType_t *pxHigherPriorityTaskWoken,这将指示是否需要在中断之后立即将更高优先级的任务切换到上下文中。 -
只有优先级低于
FreeRTOSConfig.h中定义的configMAX_API_CALL_INTERRUPT_PRIORITY的中断才能调用 FreeRTOS API 函数(以下图示为例)。
以下是对 FreeRTOSConfig.h 和 main_XXX.c 文件如何配置本书中示例中断的概述。以下是一些值得注意的项目:
-
main_XXX.c在执行所有 STM HAL 初始化后调用NVIC_SetPriorityGrouping(0)(HAL在初始化时设置优先级分组)。这允许使用所有 4 位来设置优先级,从而产生 16 个优先级级别。 -
FreeRTOSConfig.h用于设置 FreeRTOS API 调用与 NVIC 优先级之间的关系。Cortex-M7 将255定义为最低优先级,而0为最高优先级。由于 STM32F7 只实现了 4 位,这 4 位将被移位到 4 个最高有效位;较低的 4 位不会影响操作(见以下图示):-
configKERNEL_INTERRUPT_PRIORITY定义了我们系统中最低优先级的中断(以及 FreeRTOS 任务的 ISR 优先级,因为调度器是在 SysTick 中断内被调用的)。由于 4 位可以产生从0(最高优先级)到15(最低优先级)的可能范围,因此使用的最低 NVIC 优先级将是15。在设置configKERNEL_INTERRUPT_PRIORITY时,15需要左移到 8 位表示(直接用于 CortexM 寄存器)中,即(15 << 4) | 0x0F = 0xFF或255。由于最低 4 位是无关紧要的,0xF0(十进制 240)也是可接受的。 -
configMAX_SYSCALL_INTERRUPT_PRIORITY定义了允许调用 FreeRTOS API 的(逻辑上)最高优先级中断。在我们的示例中,这被设置为5。左移以填充 8 位给出值为0x50或0x5F(十进制 80 或 95):
-

如前图所示,有些情况下中断服务例程(ISR)可以配置为以高于 RTOS 可能执行的所有操作的优先级执行。当配置为 0 到 4 NVIC 优先级时,ISR 与传统的“裸机”ISR 相同。
在通过调用NVIC_SetPriority(优先级为<= 5)启用中断之前,确保正确配置中断优先级是非常重要的。如果一个优先级逻辑上高于configMAX_SYSCALL_INTERRUPT_PRIORITY的中断调用 FreeRTOS API 函数,你将遇到configASSERT失败(有关configASSERT的更多详细信息,请参阅第十七章,故障排除提示和下一步操作)。
现在我们已经了解了任务和中断服务例程(ISR)之间的区别,以及在使用 FreeRTOS API 函数时的一些基本规则,让我们再次审视轮询驱动程序,看看它如何能更有效地实现。
创建基于 ISR 的驱动程序
在 UART 驱动程序的第一迭代中,一个任务轮询 UART 外设寄存器以确定是否已接收到新的字节。这种持续的轮询导致任务消耗了> 95%的 CPU 周期。基于任务的驱动程序所做的最有意义的工作是将数据字节从 UART 外设传输到队列中。
在这个驱动程序的这一迭代中,我们不会使用任务来持续轮询 UART 寄存器,而是设置UART2外设和 NVIC,以便在接收到新字节时提供中断。
基于队列的驱动程序
首先,让我们看看如何更有效地实现轮询驱动程序(之前是通过在任务中轮询 UART 寄存器来实现的)。在这个实现中,我们不会使用任务来重复轮询 UART 寄存器,而是使用一个函数来设置外设使用中断并启动传输。完整的 ISR 函数原型可以在启动文件中找到(对于我们在示例中使用的 STM32F767,此文件是Chapter_10/startup_stm32f767xx.s)。
在startup_stm32f767xx.s中的每个*_IRQHandler实例都用于将函数名映射到中断向量表中的一个地址。在 ARM Cortex-M0+、-M3、-M4 和-M7 设备上,这个向量表可以在运行时通过偏移量重新定位。有关这些概念更多信息的链接,请参阅进一步阅读。
此示例有四个主要组件:
-
uartPrintOutTask:此函数初始化 USART2 和相关硬件,启动接收,然后打印放入uart2_BytesReceived队列中的任何内容。 -
startReceiveInt:为 USART2 设置基于中断的接收。 -
USART2_IRQHandler:当 USART2 外设发生中断时发出 ISR。 -
startUart4Traffic:启动从 UART4 发送的连续数据流,由 USART2 接收(前提是跳线设置正确)。
让我们详细看看每个组件。本节中的所有摘录均来自Chapter_10/Src/mainUartInterruptQueue.c。
uartPrintOutTask
本例中唯一的任务是uartPrintOutTask:
void uartPrintOutTask( void* NotUsed)
{
char nextByte;
STM_UartInit(USART2, 9600, NULL, NULL);
startReceiveInt();
while(1)
{
xQueueReceive(uart2_BytesReceived, &nextByte, portMAX_DELAY);
SEGGER_SYSVIEW_PrintfHost("%c", nextByte);
}
}
uartPrintOutTask执行以下操作:
-
通过调用
STM_UartInit执行所有外设硬件初始化。 -
通过调用
startReceiveInt开始基于中断的接收。 -
消费 并打印每个字符,当它通过调用
xQueueReceive添加到uart2_BytesReceived队列时。
startReceiveInt
startReceiveInt 函数启动了一个基于中断的接收:
static bool rxInProgress = false;
void startReceiveInt( void )
{
rxInProgress = true;
USART2->CR3 |= USART_CR3_EIE; //enable error interrupts
//enable peripheral and Rx not empty interrupts
USART2->CR1 |= (USART_CR1_UE | USART_CR1_RXNEIE);
NVIC_SetPriority(USART2_IRQn, 6);
NVIC_EnableIRQ(USART2_IRQn);
}
startReceiveInt 设置了接收 USART2 数据所需的所有内容:
-
rxInProgress是一个由中断服务例程 (ISR) 使用的标志,用于指示接收正在进行。ISR (USART2_IRQHandler()) 不会尝试向队列写入,直到rxInProgress为真。 -
USART2 被配置为生成
接收和错误中断,然后被启用。 -
NVIC_SetPriority函数(由 CMSIS 在Drivers/CMSIS/Include/corex_cm7.h中定义)用于设置中断优先级。由于此中断将调用 FreeRTOS API 函数,因此此优先级必须设置在FreeRTOSConfig.h中定义的configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY的或以下。在 ARM CortexM 处理器上,较小的数字表示较高的逻辑优先级——在本例中,#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5,因此将优先级6分配给USART2_IRQn将足够允许 ISR 调用 FreeRTOS 提供的 ISR 安全函数 (xQueueSendFromISR)。 -
最后,通过调用
NVIC_EnableIRQ启用 USART2 生成的中断请求。如果未调用NVIC_EnableIRQ,USART2 仍然会生成请求,但中断控制器(NVIC 中的“IC”)不会将程序计数器向量到 ISR(USART2_IRQHandler将永远不会被调用)。
在本例中,与本章中几乎所有代码一样,我们直接写入硬件外设寄存器,而不是使用大量的抽象。这样做是为了保持关注 RTOS 如何与 MCU 交互。如果你将代码重用作为目标之一,你需要提供一定程度的抽象层(或者如果你使用 STM HAL 代码,则需要提供抽象层)。有关这方面的指南可以在 第十二章,创建良好抽象架构的技巧 中找到。
USART2_IRQHandler
下面是 USART2_IRQHandler 的代码:
void USART2_IRQHandler( void )
{
portBASE_TYPE xHigherPriorityTaskWoken = pdFALSE;
SEGGER_SYSVIEW_RecordEnterISR();
//error flag clearing omitted for brevity
if( USART2->ISR & USART_ISR_RXNE_Msk)
{
uint8_t tempVal = (uint8_t) USART2->RDR;
if(rxInProgress)
{
xQueueSendFromISR(uart2_BytesReceived, &tempVal,
&xHigherPriorityTaskWoken);
}
SEGGER_SYSVIEW_RecordExitISR();
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
让我们更详细地看看 ISR 的每个组成部分:
-
通过直接读取 USART 寄存器来确定接收是否为空 (
RXNE) 是否被设置。如果是,接收数据寄存器 (RDR) 的内容将被存储到一个临时变量 (tempVal) 中——这次读取会清除中断标志。如果接收正在进行,tempVal将被发送到队列中。 -
在进入和退出时调用
SEGGER_SYSVIEW_RecordEnterISR和SEGGER_SYSVIEW_RecordExitISR,这使 SEGGER SystemView 能够看到中断,并在系统中显示与其他任务一起的所有任务。 -
xHigherPriorityTaskWoken变量初始化为 false。这个变量传递给xQueueSendFromISR函数,用于确定一个高优先级任务(高于当前的非 ISR 上下文中的任务)是否因为正在等待一个空队列而阻塞。在这种情况下,xHigherPriorityTaskWoken将被设置为 true,表示在 ISR 退出后应立即唤醒一个高优先级任务。当调用portYIELD_FROM_ISR时,如果xHigherPriorityTaskWoken为 true,调度器将立即切换到高优先级任务。
现在 ISR 已经编写好了,我们需要确保它实际上会在适当的时间被硬件调用。
链接 ISR 的技巧
当从头开始编写中断服务例程(ISR)(就像我们在上一个示例中所做的那样)时,可能会成为意外麻烦来源的一个区域是确保你的 ISR 被正确链接(并执行)。也就是说,即使你已经正确设置了外设以生成中断,你的新 ISR 可能永远不会被调用,因为它没有被正确命名(相反,默认实现,定义在启动文件中,可能会被调用)。以下是一些确保这个闪亮的新 ISR 可以被找到并与应用程序的其他部分正确链接的技巧:
-
STM32 的
*_IRQHandler函数名称通常包含数据表中的确切外设名称作为子字符串。例如,USART2 映射到USART2_IRQHandler(注意"S"),而 UART4 映射到UART4_IRQHandler(外设或函数名称中没有"S")。 -
当为 ISR 编写新实现时,复制并粘贴启动文件中的确切
_IQRHandler名称是一个好主意。这减少了打字错误的可能性,这可能会引起调试困难! -
STM 启动文件为每个中断实现默认处理程序作为一个无限循环。如果你注意到你的应用程序变得无响应,可能是因为你已启用了一个中断,而你的
*_IRQHandler定义没有被正确链接。 -
如果你碰巧在一个 C++文件中实现
*_IRQHandler,请确保使用extern "C"以防止名称修饰。例如,USART2 的定义将写成extern "C" void USART2_IRQHandler(void)。这也意味着 ISR 定义必须不在类内部。
在实现 ISR 时,请花时间并确保细节(如确切的名称)正确无误。在确保 ISR 在预期时被调用之前,不要急于尝试调试应用程序的其他部分。在 ISR 中使用断点是完成这一点的绝佳方式。
startUart4Traffic
在本例中需要探索的最后一个组件是如何将数据发送到 UART2。这些示例旨在模拟外部数据被 USART2 接收。为了在不添加额外硬件的情况下实现这一点,我们在本章早期将 UART4 的 Tx 和 USART2 的 RX 引脚连接在一起。对startUart4Traffic()的调用是一个TimerHandler原型。一个单次定时器被启动并设置为在应用程序启动后 5 秒触发。
执行所有繁重工作的函数是SetupUart4ExternalSim()。它设置了一个连续的循环 DMA 传输(无需 CPU 干预)来重复传输字符串data from uart4。本章稍后将详细介绍使用 DMA 的完整示例——目前,我们只需要意识到数据正在发送到 USART2,而不涉及 CPU。
startUart4Traffic()创建了一个连续的字节流,这些字节将从 UART4 的 Tx 引脚传输到 UART2 的 Rx 引脚(没有流量控制)。根据所选的波特率和接收代码执行所需的时间,我们预计在某些示例中,接收端最终会丢失一个字节。在运行自己的示例时请记住这一点。有关选择适当驱动程序类型更详细的信息,请参阅选择驱动程序模型部分。
性能分析
现在,让我们通过编译mainUartInterruptQueue,将其加载到 MCU 上,并使用 SystemView 分析实际执行来查看此实现的性能:

这次,情况看起来好得多。以下是前一个屏幕截图中的几个值得注意的项目:
-
负责处理 USART2 Rx 接收到的数据的 ISR 仅消耗大约 1.6%的 CPU(比我们使用轮询方法时看到的 96%要好得多)。
-
我们仍然以每秒 960 字节的速度接收数据——与之前相同。
-
这里显示的小刻度是
tempVal通过调用 FreeRTOS API 函数xQueueSendFromISR添加到uart2_BytesReceived的确切时间点。 -
我们可以在这里看到
portYIELD_FROM_ISR的效果。uartPrint任务中的浅蓝色部分表示任务准备就绪。这是因为uartPrint任务由于队列中有项目而准备就绪。对portYIELD_FROM_ISR的调用迫使调度器立即评估哪个任务应该被带入上下文。绿色部分(从大约 21 uS 开始)是 SystemView 表示任务处于运行状态的方式。 -
在
uartPrint任务开始运行后,它从队列中移除下一个字符,并使用SEGGER_SYSVIEW_PrintfHost打印它。
通过从基于轮询的驱动程序切换到基于中断的驱动程序,我们显著降低了 CPU 的负载。此外,使用基于中断的驱动程序的系统可以在通过 USART2 接收数据的同时运行其他任务。此驱动程序还使用基于队列的方法,提供了一个非常方便的环形缓冲区,允许字符连续接收并添加到队列中,然后在高层任务方便时读取。
接下来,我们将通过一个类似但不使用队列的驱动程序的例子来进行分析。
基于缓冲区的驱动程序
有时,传输的确切大小可以提前知道。在这种情况下,可以传递一个现有的缓冲区给驱动程序,并用它来代替队列。让我们看看一个基于缓冲区的驱动程序的例子,其中确切的字节数是提前知道的。这个例子中的硬件设置与前面的例子相同——我们将专注于通过 USART2 接收数据。
与使用队列不同,uartPrintOutTask 将为其 startReceiveInt 函数提供自己的缓冲区。通过 USART2 接收到的数据将直接放置在本地缓冲区中,直到添加了所需数量的字节,然后中断服务例程(ISR)将通过信号量提供完成通知。整个信息将作为一个字符串打印出来,而不是像接收时那样逐字节打印(这在上一个例子中已经这样做过)。
就像前面的例子一样,有四个主要组件。然而,它们的责任略有不同:
-
startReceiveInt:为 USART2 设置基于中断的接收并配置用于传输的中断服务例程所需的相关变量。 -
uartPrintOutTask:此函数初始化 USART2 及相关硬件,启动接收,并等待完成(截止时间为 100 毫秒)。完整的信息要么被打印出来,要么发生超时并打印错误。 -
USART2_IRQHandler:当 USART2 外设发生中断时发出中断服务例程(ISR)。 -
startUart4Traffic:启动从 UART4 发送并由 USART2 接收的连续数据流(前提是跳线设置正确)。
让我们详细查看每个组件。本节中所有摘录均来自 Chapter_10/Src/mainUartInterruptBuffer.c。
startReceiveInt
startReceiveInt 函数与用于基于队列的驱动程序的函数非常相似:
static bool rxInProgress = false;
static uint_fast16_t rxLen = 0;
static uint8_t* rxBuff = NULL;
static uint_fast16_t rxItr = 0;
int32_t startReceiveInt( uint8_t* Buffer, uint_fast16_t Len )
{
if(!rxInProgress && (Buffer != NULL))
{
rxInProgress = true;
rxLen = Len;
rxBuff = Buffer;
rxItr = 0;
USART2->CR3 |= USART_CR3_EIE; //enable error interrupts
USART2->CR1 |= (USART_CR1_UE | USART_CR1_RXNEIE);
NVIC_SetPriority(USART2_IRQn, 6);
NVIC_EnableIRQ(USART2_IRQn);
return 0;
}
return -1;
}
在此设置中,以下是一些显著的不同点:
-
此变体接受一个指向缓冲区(
Buffer)的指针以及所需的传输长度(Len)。使用这些参数初始化了几个全局变量rxBuff和rxLen(这些将由中断服务例程使用)。 -
rxInProgress用于确定接收是否已经在进行中(如果是,则返回-1)。 -
用于索引缓冲区的迭代器(
rxItr)初始化为0。
startReceiveInt 函数的所有剩余功能与本章前面 基于队列的驱动 部分中介绍的示例完全相同。
uartPrintOutTask
负责打印 USART2 接收到的数据的uartPrintOutTask函数在这个例子中稍微复杂一些。此示例还可以比较接收到的数据与预期长度,以及一些基本的错误检测:
- 缓冲区和长度变量被初始化,UART 外设被设置:
void uartPrintOutTask( void* NotUsed)
{
uint8_t rxData[20];
uint8_t expectedLen = 16;
memset((void*)rxData, 0, 20);
STM_UartInit(USART2, 9600, NULL, NULL);
-
然后,
while循环的主体通过调用startReceiveInt开始接收,并等待rxDone信号量,最多等待 100 个 RTOS tick 以完成传输。 -
如果传输及时完成,接收到的总字节数将与
expectedLen进行比较: -
如果存在正确的字节数量,
rxData的内容将被打印。否则,将打印一条消息,解释差异的原因:
while(1)
{
startReceiveInt(rxData, expectedLen);
if(xSemaphoreTake(rxDone, 100) == pdPASS)
{
if(expectedLen == rxItr)
{
SEGGER_SYSVIEW_PrintfHost("received: ");
SEGGER_SYSVIEW_Print((char*)rxData);
}
else
{
SEGGER_SYSVIEW_PrintfHost("expected %i bytes received"
"%i", expectedLen, rxItr);
while循环的其余部分和函数简单地打印timeout,如果信号量在 100 个 tick 内没有被获取。
USART2_IRQHandler
由于需要跟踪队列中的位置,这个 ISR 也稍微复杂一些:
USART2_IRQHandler使用私有全局变量,因为它们需要被 ISR 访问,并且由USART2_IRQHandler和startReceiveInt使用:
static bool rxInProgress = false;
static uint_fast16_t rxLen = 0;
static uint8_t* rxBuff = NULL;
static uint_fast16_t rxItr = 0;
- 在这个 ISR 中,存储
xHigherPriorityTaskWoken和 SEGGER SystemView 跟踪的范式与上一个例子相同:
void USART2_IRQHandler( void )
{
portBASE_TYPE xHigherPriorityTaskWoken = pdFALSE;
SEGGER_SYSVIEW_RecordEnterISR();
- 接下来,通过读取中断状态寄存器(
USART2->ISR)中的溢出(ORE)、噪声错误(NE)、帧错误(FE)和奇偶校验错误(PE)位来检查错误。
如果存在错误,它将通过写入中断清除寄存器(USART2->ICR)和释放rxDone信号量来清除。调用者代码的责任是通过查看rxItr变量(如下一代码块所示)来检查缓冲区中的位数,以确保成功接收了正确的位数:
if( USART2->ISR & ( USART_ISR_ORE_Msk |
USART_ISR_NE_Msk |
USART_ISR_FE_Msk |
USART_ISR_PE_Msk ))
{
USART2->ICR |= (USART_ICR_FECF |
USART_ICR_PECF |
USART_ICR_NCF |
USART_ICR_ORECF);
if(rxInProgress)
{
rxInProgress = false;
xSemaphoreGiveFromISR(rxDone,
&xHigherPriorityTaskWoken);
}
}
- 接下来,ISR 检查是否接收到了新的字节(通过读取
USART2->ISR中的RXNE位)。如果有一个新的字节可用,它将被推入rxBuff缓冲区,并且rxItr迭代器递增。
在缓冲区中添加了所需数量的字节后,释放rxDone信号量以通知uartPrintOutTask:
if( USART2->ISR & USART_ISR_RXNE_Msk)
{
uint8_t tempVal = (uint8_t) USART2->RDR;
if(rxInProgress)
{
rxBuff[rxItr++] = tempVal;
if(rxItr >= rxLen)
{
rxInProgress = false;
xSemaphoreGiveFromISR(rxDone, &xHigherPriorityTaskWoken);
}
}
}
SEGGER_SYSVIEW_RecordExitISR();
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
不要忘记在 ISR 中设置断点,以确保它被调用。
startUart4Traffic
与上一个例子相同,这个函数设置 DMA 传输,将数据从 UART4 的 Tx 引脚推送到 USART2 的 Rx 引脚。
性能分析
现在,让我们看看这个驱动程序实现的表现。有几个方面需要考虑。除非传输完成,否则 ISR 通常只会将一个字节传输到rxBuff。在这种情况下,中断非常短,完成时间不到 3 微秒:

在接收所有 16 个字节之后,ISR 的执行变得更有趣,看起来与上一个例子有些相似:

下面是从前面的屏幕截图中的几个值得注意的点:
-
在所有字节都放入
rxBuff之后,ISR 使用xSemaphoreGiveFromISR从 ISR 中释放rxDone信号量。 -
在中断执行后,通过获取可用的信号量(
xSemaphoreTake(rxDone,100))来解除任务阻塞。 -
打印出
rxBuff的确切内容。请注意,每一行都包含整个字符串,而不是单个字符。这是因为这个实现收集了一个完整的缓冲区数据,然后使用信号量来指示完成。
最后,让我们看一下 CPU 使用的完整统计:

下面是从前面的屏幕截图中的几个值得注意的项目:
-
对于这个实现,ISR 使用了 CPU 的 0.34%(而不是在 ISR 内部将每个字符推入队列时的 1.56%)。
-
FreeRTOS 调度器现在只使用 CPU 的 0.06%,而不是 0.94%(每次向队列添加项目时,调度器都会运行以确定是否应该解除任务阻塞)。
-
USART2 ISR 的频率保持在 960 Hz,与之前的例子完全相同,但现在
print任务的频率已经降低到只有 60 Hz,因为只有当 16 个字节被传输到rxBuff后,uartPrint任务才会运行。
如我们所见,这个驱动程序的 ISR 实现比基于队列的方法使用的 CPU 周期更少。根据使用情况,它可能是一个有吸引力的替代方案。这类驱动程序通常在非 RTOS(实时操作系统)系统中找到,其中回调函数将用于代替信号量。这种方法足够灵活,可以通过在回调中放置一个信号量来与或没有 RTOS 一起使用。虽然稍微复杂一些,但这对于代码库来说,是看到大量不同应用中重用的一种最灵活的方法。
总结一下,到目前为止,使用 ISR 实现的两种驱动程序变体如下:
-
基于队列的驱动程序:通过逐个字符将接收到的数据推入队列,将传入的数据传递给任务。
-
基于缓冲区的驱动程序:将传入的数据传递给由调用函数预先分配的单个缓冲区。
表面上看,似乎很荒谬,有两个不同的实现,它们都从外围设备接收传入的数据并将其呈现给代码的更高层。重要的是要认识到,对于同一硬件的这两种不同的驱动程序变体,在实现、效率和最终提供给更高层代码的接口方面都存在差异。它们可能都在从 UART 外围设备移动字节,但它们为更高层代码提供了截然不同的编程模型。这些不同的编程模型各自适合解决不同类型的问题。
接下来,我们将看看如何使用 MCU 内部的另一块硬件来减轻在移动大量数据时 CPU 的负担。
创建基于 DMA 的驱动程序
我们看到,与轮询方法相比,基于中断的驱动程序在 CPU 利用率方面有相当大的改进。但对于需要每秒数百万次传输的高数据率应用程序呢?通过尽可能少地让 CPU 参与,将大部分数据传输工作推到 MCU 内的专用外设硬件上,可以进一步提高效率的下一步。
在第二章 理解 RTOS 任务 中简要介绍了 DMA,以防在深入研究此示例之前需要复习。
在这个例子中,我们将通过创建一个使用与基于中断的驱动程序相同的基于缓冲区的接口的驱动程序来操作。唯一的区别将是使用 DMA 硬件将字节从外设的读取数据寄存器(RDR)传输到我们的缓冲区。由于我们已经很好地掌握了从其他驱动程序配置 USART2 外设,因此这个变体的首要任务是找出如何将数据从USART2->RDR传输到 DMA 控制器,然后进入内存。
配置 DMA 外设
STM32F767 有两个 DMA 控制器。每个控制器有 10 个通道和 8 个流,用于将 DMA 请求从 MCU 的一个位置映射到另一个位置。在 STM32F767 硬件上,流可以执行以下操作:
-
可以将其视为从地址到地址的流动数据的方式
-
可以在从外设到 RAM 或从 RAM 到外设之间传输数据
-
可以在 RAM 之间传输数据
-
只能在任何给定时刻在两点之间传输数据
每个流最多有 10 个通道,用于将外设寄存器映射到给定的流。为了配置 DMA 控制器以处理 USART2 的接收请求,我们将参考STM32F7xx RM0410参考手册中的表 27:

在这个表中,我们可以看到 DMA1 通道 4,流 5 是处理USART2_RX请求的适当设置。如果我们还对处理发送端的请求感兴趣,通道 4,流 6 也需要设置。
现在我们知道了通道和流号,我们可以添加一些初始化代码来设置 DMA1 和 USART2 外设:
-
DMA1_Stream5将用于将 USART2 的接收数据寄存器中的数据直接传输到 RAM 中的缓冲区。 -
USART2不会启用中断(因为 DMA 将执行所有从外设寄存器到 RAM 的传输)。 -
DMA1_Stream5将配置为在缓冲区完全填满后触发中断。
下面的几个片段来自Chapter_10/src/mainUartDMABuff.c中的setupUSART2DMA函数:
- 首先,启用 DMA 外设的时钟,设置中断优先级,并在 NVIC 中启用中断:
void setupUSART2DMA( void )
{
__HAL_RCC_DMA1_CLK_ENABLE();
NVIC_SetPriority(DMA1_Stream5_IRQn, 6);
NVIC_EnableIRQ(DMA1_Stream5_IRQn);
- 接下来,通过填写一个
DMA_HandleTypeDef结构(usart2DmaRx)并使用HAL_DMA_Init()来配置 DMA 流:
HAL_StatusTypeDef retVal;
memset(&usart2DmaRx, 0, sizeof(usart2DmaRx));
usart2DmaRx.Instance = DMA1_Stream5; //stream 5 is for USART2 Rx
//channel 4 is for USART2 Rx/Tx
usart2DmaRx.Init.Channel = DMA_CHANNEL_4;
//transfering out of memory and into the peripheral register
usart2DmaRx.Init.Direction = DMA_PERIPH_TO_MEMORY;
usart2DmaRx.Init.FIFOMode = DMA_FIFOMODE_DISABLE; //no FIFO
//transfer 1 at a time
usart2DmaRx.Init.MemBurst = DMA_MBURST_SINGLE;
usart2DmaRx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
//increment 1 byte at a time
usart2DmaRx.Init.MemInc = DMA_MINC_ENABLE;
//flow control mode set to normal
usart2DmaRx.Init.Mode = DMA_NORMAL;
//write 1 at a time to the peripheral
usart2DmaRx.Init.PeriphBurst = DMA_PBURST_SINGLE;
//always keep the peripheral address the same (the RX data
//register is always in the same location)
usart2DmaRx.Init.PeriphInc = DMA_PINC_DISABLE;
usart2DmaRx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
usart2DmaRx.Init.Priority = DMA_PRIORITY_HIGH;
retVal = HAL_DMA_Init(&usart2DmaRx);
assert_param( retVal == HAL_OK );
//enable transfer complete interrupts
DMA1_Stream5->CR |= DMA_SxCR_TCIE;
//set the DMA receive mode flag in the USART
USART2->CR3 |= USART_CR3_DMAR_Msk;
HAL 初始化对其传递的值提供一些合理性检查。以下是最直接相关的部分:
-
将
DMA1_Stream5设置为实例。所有使用usart2DmaRx结构体的调用都将引用流5。 -
通道
4连接到流5。 -
启用内存递增。DMA 硬件将在传输后自动递增内存地址,填充缓冲区。
-
每次传输后不递增外设地址——USART2 接收数据寄存器 (
RDR) 的地址永远不会改变。 -
为
DMA1_Stream5启用传输完成中断。 -
USART2 已设置为
DMA 接收模式。在 USART 外设配置中设置此位是必要的,以表示外设的接收寄存器将被映射到 DMA 控制器。
关于如何使用此结构体的更多详细信息,可以通过查看 stm32f7xx_hal_dma.h 中的 DMA_HandleTypeDef 结构体定义(第 168 行)和 stm32f7xx_hal_dma.c 中的 HAL_DMA_Init()(第 172 行)来找到。将 HAL 代码使用的寄存器与 STM32F76xxx RM0410 参考手册的第八部分(第 245 页)进行交叉引用。这种相同的技术通常对于理解 HAL 代码如何使用单个函数参数和结构体成员是非常有效的。
现在初始 DMA 配置已完成,我们可以探索一些不同的中断实现,使用 DMA 而不是中断。
基于缓冲区的 DMA 驱动程序
这是一个具有与 A 缓冲区驱动程序 部分中相同功能的驱动程序实现。区别在于,DMA 版本的驱动程序在每次接收到字节时不会中断应用程序。唯一生成的 中断 是当整个传输完成时。为了实现此驱动程序,我们只需要添加以下中断服务例程(ISR):
void DMA1_Stream5_IRQHandler(void)
{
portBASE_TYPE xHigherPriorityTaskWoken = pdFALSE;
SEGGER_SYSVIEW_RecordEnterISR();
if(rxInProgress && (DMA1->HISR & DMA_HISR_TCIF5))
{
rxInProgress = false;
DMA1->HIFCR |= DMA_HIFCR_CTCIF5;
xSemaphoreGiveFromISR(rxDone, &xHigherPriorityTaskWoken);
}
SEGGER_SYSVIEW_RecordExitISR();
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
驱动程序的重要部分已加粗。如果接收正在进行中(基于 rxInProgress 的值和传输完成标志 DMA_HISR_TCIF5),则以下操作发生:
-
清除 DMA 中断标志。
-
发送
rxDone信号量。
当使用基于 DMA 的传输时,这已经足够了,因为 DMA 控制器处理与缓冲区相关的所有账目。此时,其余代码的功能与 中断 版本完全相同(唯一的区别是服务中断所花费的 CPU 时间更少)。
性能分析
让我们比较基于 DMA 的实现与中断驱动方法的性能:

这次,我们可以对整体系统行为做出以下观察:
-
(DMA) 中断服务例程现在在 9,600 波特率下消耗的 CPU 周期小于 0.1%。
-
调度器 CPU 的消耗仍然非常低。
-
ISR 的频率已降低到仅 60 赫兹(从 960 赫兹)。这是因为,而不是为每个字节创建一个中断,只有在 16 字节传输结束时才生成一个中断。空闲任务也被上下文切换得少得多。虽然在这些简单的示例中这似乎微不足道,但在具有许多任务和中断的大型应用程序中,过多的上下文切换可能成为一个非常真实的问题。
整体流程与基于中断缓冲区的方法相似,唯一的区别在于,当整个传输完成时,只有一个中断服务例程(ISR)被执行(而不是每个字节传输一个中断):

从前面的屏幕截图,我们可以观察到以下情况:
-
DMA ISR 在所有 16 字节传输到缓冲区后执行一次。截图中的箭头 1 所指的标记显示了信号量。
-
ISR 唤醒了阻塞的
uartPrint函数。箭头 2 指向了在截图中所指的信号量被获取的地方。 -
两个信息框显示控制台打印消息生成的地方(在最后一个字节接收后约 35 和 40 微秒)。其余时间这个任务花在重新初始化缓冲区和设置下一次传输上。
这里是所有处理器活动的更宽视角。请注意,唯一的活动大约每 16 毫秒发生一次(在所有字节都传输到内存之后):

基于完全 DMA 方法的实际能力在快速传输大量数据时最为宝贵。以下示例显示了相同的数据集(仅 16 字节)以 256,400 波特率(在没有错误的情况下可靠实现的最快速度)进行传输。
在示例中,可以通过修改main<exampleame>.c中的#define BAUDRATE来轻松更改波特率。它们被配置为单个更改将修改 USART2 和 UART4 的波特率。
以下是在 256,000 波特率下进行传输的示例。缓冲区中每 624 微秒就可用一组新的 16 字节:

通过将波特率从 9,600 提高到 256,000,我们的 CPU 使用率从大约 0.5%增加到大约 11%。这与波特率 26 倍的提高相一致——所有函数调用都与波特率成比例:

注意以下情况:
-
DMA 中断消耗了 2.29%。
-
我们的
uartPrint任务是 CPU 周期的最大消费者(略超过 6%)。
尽管我们已经证明使用 DMA 可以快速高效地传输数据,但当前的设置并没有中断驱动的队列解决方案那样的便利性。任务依赖于整个块的传输,而不是使用队列。这可能是合适的,也可能是不便的,这取决于高级代码的目标。
当基于队列的驱动 API 而不是基于缓冲区的驱动 API(如我们在这里实现的)编写基于字符的协议时,通常会更容易实现。然而,我们在基于队列的驱动部分看到,队列很快就会变得计算成本高昂。每个字节被添加到队列中大约需要 30 微秒。以 256,000 波特率传输数据将消耗掉 UART 中断服务例程(ISR)中大部分可用的 CPU(每个 40 微秒接收一个新字节,处理它需要 30 微秒)。
在过去,如果你真的需要实现一个面向字符的驱动程序,你可以自己实现一个高度高效的环形缓冲区实现,并直接从低级 ISR 中提供(绕过大多数 FreeRTOS 原语以节省时间)。然而,从 FreeROTS 10 开始,还有一个替代方案——流缓冲区。
流缓冲区(FreeRTOS 10+)
流缓冲区结合了基于队列系统的便利性和与我们之前创建的原始缓冲区实现相近的速度。它们有一些灵活性限制,这些限制与任务通知系统与信号量相比的限制相似。流缓冲区一次只能由一个发送者和一个接收者使用。 否则,如果它们要被多个任务使用,将需要外部保护(例如互斥锁)。
流缓冲区的编程模型与队列非常相似,除了函数不是每次限制于排队一个项目,而是可以一次排队多个项目(这在排队数据块时可以节省大量的 CPU 时间)。在这个例子中,我们将通过 UART 接收的高效 DMA 环形缓冲区实现来探索流缓冲区。
本驱动程序示例的目标如下:
-
为驱动程序的用户提供一个易于使用的基于字符的队列。
-
在高数据速率下保持效率。
-
总是准备好接收数据。
那么,让我们开始吧!
使用流缓冲区 API
首先,让我们看看在这个例子中uartPrintOutTask如何使用流缓冲区 API 的示例。以下摘录来自mainUartDMAStreamBufferCont.c。
下面是xSttreamBufferCreate()函数定义的示例:
StreamBufferHandle_t xStreamBufferCreate( size_t xBufferSizeBytes,
size_t xTriggerLevelBytes);
注意以下前述代码中的内容:
-
xBufferSizeBytes是缓冲区能够容纳的字节数。 -
xTriggerLevelBytes是在调用xStreamBufferReceive()之前需要在流中可用的字节数(否则,将发生超时)。
以下示例代码设置了一个流缓冲区:
#define NUM_BYTES 100
#define MIN_NUM_BYTES 2
StreamBufferHandle_t rxStream = NULL;
rxStream = xStreamBufferCreate( NUM_BYTES , MIN_NUM_BYTES);
assert_param(rxStream != NULL);
在前面的代码片段中,我们可以观察到以下内容:
-
rxStream能够容纳NUM_BYTES(100 字节)。 -
每次任务阻止数据被添加到流中时,它将不会解除阻塞,直到流中至少有
MIN_NUM_BYTES(2 字节)可用。在这个例子中,对xStreamBufferReceive的调用将阻塞,直到流中至少有 2 字节可用(或者发生超时)。 -
如果使用 FreeRTOS 堆,请确保通过检查返回的句柄是否为
NULL来确认有足够的空间用于分配流缓冲区。
从流缓冲区接收数据的函数是xStreamBufferReceive():
size_t xStreamBufferReceive( StreamBufferHandle_t xStreamBuffer,
void *pvRxData,
size_t xBufferLengthBytes,
TickType_t xTicksToWait );
下面是一个从流缓冲区接收数据的简单示例:
void uartPrintOutTask( void* NotUsed)
{
static const uint8_t maxBytesReceived = 16;
uint8_t rxBufferedData[maxBytesReceived];
//initialization code omitted for brevity
while(1)
{
uint8_t numBytes = xStreamBufferReceive( rxStream,
rxBufferedData,
maxBytesReceived,
100 );
if(numBytes > 0)
{
SEGGER_SYSVIEW_PrintfHost("received: ");
SEGGER_SYSVIEW_Print((char*)rxBufferedData);
}
else
{
SEGGER_SYSVIEW_PrintfHost("timeout");
...
在前面的代码片段中,请注意以下内容:
-
rxStream:StreamBuffer的指针/句柄。 -
rxBufferedData:字节将被复制到的本地缓冲区。 -
maxBytesReceived:将复制到rxBufferedData中的最大字节数。 -
超时是
100个滴答(xStreamBufferReceive()将在至少xTriggerLevelBytes(在这个例子中是2)可用或 100 个滴答已过之后返回)。
对xStreamBufferReceive()的调用行为与对xQueueReceive()的调用类似,它们都会在数据可用时阻塞。然而,对xStreamBufferReceive()的调用将阻塞,直到最小数量的字节(在调用xStreamBufferCreate()时定义)或指定的滴答数已过。
在这个例子中,对xStreamBufferReceive()的调用将阻塞,直到以下条件之一满足:
-
缓冲区中的字节数超过了
MIN_NUM_BYTES(在这个例子中是2)。如果还有更多的字节可用,它们将被移动到rxBufferedData——但不超过maxBytesReceived字节(在这个例子中是16)。 -
发生超时。流中的所有可用字节都移动到
rxBufferedData。xStreamBuffereReceive()返回放置到rxBufferedData中的确切字节数(在这个例子中是0或1)。
既然我们已经对接收方有了很好的了解,让我们来看看驱动程序本身的某些细节。
设置双缓冲 DMA
如我们之前所见,使用 DMA 可以非常有益于减少 CPU 使用率(相对于中断)。然而,上一个例子中没有涵盖的一个特性是持续填充队列(驱动程序需要在进行数据接收之前进行基于块的调用)。在这个例子中的驱动程序将不断将数据传输到流缓冲区,而不需要调用它的代码进行任何干预。也就是说,驱动程序将始终接收字节并将它们推入流缓冲区。
对于基于 DMA 的系统,持续接收数据会带来两个有趣的问题:
-
如何处理溢出——当缓冲区已完全填满,但高速数据仍然可以进入时。
-
如何在缓冲区完全填满之前终止传输。DMA 传输通常需要在传输开始之前指定字节数。然而,我们需要一种方法在数据停止接收时停止传输,并将该数据复制到流缓冲区。
将使用 DMA 双缓冲来确保我们的驱动程序始终能够接收数据(即使单个缓冲区已被填满)。在先前的例子中,一个缓冲区被填满并生成中断,然后直接操作数据,在重新启动传输之前。使用双缓冲,添加第二个缓冲区。在 DMA 控制器填满第一个缓冲区后,它将自动开始填充第二个缓冲区:

在第一个缓冲区被填满并生成中断后,ISR 可以安全地操作第一个缓冲区rxData1中的数据,同时第二个缓冲区rxData2被填满。在我们的例子中,我们是在 ISR 内部将数据传输到 FreeRTOS 流缓冲区。
重要的是要注意,xStreamBufferSendFromISR()会将数据的副本添加到流缓冲区,而不是引用。因此,在这个例子中,只要 DMA ISR 对xStreamBufferSendFromISR()的调用在rxData2被填满之前执行,数据就可以无损失地可用。这与传统的裸机双缓冲实现不同,因为不需要调用xStreamBufferReceive()的高级代码从rxData1中提取数据,在rxData2被填满之前:

即使你在为没有明确双缓冲模式的 MCU 编程,大多数 DMA 控制器也将具有循环模式,包括半传输和全传输中断。在这种情况下,可以通过在每个缓冲区的一半被填满后生成一个中断来达到相同的功能。
通过将地址写入DMA_SxM1AR寄存器(需要一些类型转换以防止编译器大声抱怨我们在写入一个指向 32 位内存地址的指针)来设置二级缓冲区rxData2:
//setup second address for double buffered mode
DMA1_Stream5->M1AR = (uint32_t) rxData2;
很有趣的是,STM HAL 不直接支持双缓冲模式。实际上,对HAL_DMA_Start的调用会明确禁用该模式。因此,需要通过寄存器进行一些手动设置(在HAL处理大部分基础工作之后):
//NOTE: HAL_DMA_Start explicitly disables double buffer mode
// so we'll explicitly enable double buffer mode later when
// the actual transfer is started
if(HAL_DMA_Start(&usart2DmaRx, (uint32_t)&(USART2->RDR), (uint32_t)rxData1,
RX_BUFF_LEN) != HAL_OK)
{
return -1;
}
//disable the stream and controller so we can setup dual buffers
__HAL_DMA_DISABLE(&usart2DmaRx);
//set the double buffer mode
DMA1_Stream5->CR |= DMA_SxCR_DBM;
//re-enable the stream and controller
__HAL_DMA_ENABLE(&usart2DmaRx);
DMA1_Stream5->CR |= DMA_SxCR_EN;
在 DMA 流启用后,UART 被启用,这将开始传输(这与前面的示例相同)。
填充流缓冲区
流缓冲区将由 DMA 中断服务例程(ISR)内部填充:
void DMA1_Stream5_IRQHandler(void)
{
uint16_t numWritten = 0;
uint8_t* currBuffPtr = NULL;
portBASE_TYPE xHigherPriorityTaskWoken = pdFALSE;
SEGGER_SYSVIEW_RecordEnterISR();
if(rxInProgress && (DMA1->HISR & DMA_HISR_TCIF5))
{
if(DMA1_Stream5->CR & DMA_SxCR_CT)
currBuffPtr = rxData1;
else
currBuffPtr = rxData2;
numWritten = xStreamBufferSendFromISR( rxStream,
currBuffPtr,
RX_BUFF_LEN,
&xHigherPriorityTaskWoken);
while(numWritten != RX_BUFF_LEN);
DMA1->HIFCR |= DMA_HIFCR_CTCIF5;
}
SEGGER_SYSVIEW_RecordExitISR();
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
在这个 ISR 中,有一些值得注意的项目:
-
if(rxInProgress && (DMA1->HISR & DMA_HISR_TCIF5)):这一行防止在调度器启动之前写入流缓冲区。即使 ISR 在调度器启动之前执行,rxInProgress也不会为真,直到一切初始化完成。检查传输完成标志DMA_HISR_TCIF5可以确保确实完成了一次传输(而不是因为错误而进入 ISR)。 -
DMA1_Stream5->CR & DMA_SxCR_CT:检查当前目标位。由于此位指示 DMA 控制器当前正在填充哪个目标缓冲区(DMA_SxM0AR或DMA_SxM1AR),我们将选择另一个并推入流缓冲区。 -
xStreamBufferSendFromISR的调用一次将rxBuff1或rxBuff2(每个长度为RX_BUFF_LEN)的全部内容推入rxStream。
需要记住以下几点:
-
数据是通过值(而非引用)传输到流中的。也就是说,FreeRTOS 正在使用
memcpy来复制所有移动到流缓冲区的数据(在移除数据时再次复制)。缓冲区越大,复制所需的时间就越长——还会使用额外的 RAM。 -
在某些情况下,而不是在中断内部执行复制,可能更倾向于发出信号量或任务通知,并在高优先级任务中执行复制——例如,如果正在填充大缓冲区。然而,你需要保证其他中断不会使执行
xStreamBufferSend的任务饿死,否则数据将会丢失。 -
使用 DMA 时存在权衡。更大的缓冲区意味着更少的数据传输中断,但同时也意味着延迟的增加。缓冲区越大,数据在缓冲区中停留的时间就越长,直到被处理。
-
此实现仅适用于连续数据流——如果数据流停止,最后的 DMA 传输将永远不会完成。
将从外围设备推送到内存中的数据的方法在数据连续流式传输时效果非常好。它也可以在接收已知字节数的消息时表现得极为出色。然而,还有一些方法可以改进它。
改进流缓冲区
为了处理间歇性数据流,有两种可能的方法(针对此特定设置):
-
此 MCU 上的 USART 外围设备能够检测到“空闲线”,并在检测到空闲线时通过设置
USART_CR1:IDLEE位来生成中断。 -
USART 外围设备还有一个
接收超时,可以在检测到指定数量的位时间(0-16,777,215)内没有检测到起始位后生成中断。-
此超时在
USART_RTOR:RTO[23:0]寄存器中指定。 -
该功能可以通过
USART_CR2:RTOEN启用,并且可以通过USART_CR1:RTOIE启用中断。
-
这两个功能都可以用来生成 USART 中断,缩短 DMA 传输,并将数据传输到流缓冲区。
对于极高的波特率,在使用空闲线路方法时需要格外小心,因为产生的中断数量仅受波特率限制。如果存在字符间间隔(每个发送字符之间的空闲时间),你将得到一个中断驱动的方案(比正常情况有更多的开销)。
另一方面,使用接收超时功能意味着在处理传入数据之前会有额外的延迟。通常,这里没有一种“一刀切”的解决方案。
分析性能
那么,这种 DMA 流缓冲区实现与基于 ISR 的队列实现相比如何?好吧,一方面,没有比较……基于 ISR 的实现无法在 256,400 波特率下工作。在这个波特率下,每个字符的接收间隔为 39 uS。由于 ISR 的执行大约需要 18 us,我们根本无法在丢弃数据的情况下可靠地运行printUartTask():

注意,在空闲任务上绝对没有花费时间——CPU 完全被尝试跟上 UART2 传入的数据所消耗。
如以下屏幕截图所示,当处理器设置为使用每字符执行一次的中断在 256,400 波特率接收数据时,数据偶尔会丢失:

现在,为了比较,这里是用流缓冲区和 DMA 实现的(几乎)等效实现:

流缓冲区和 DMA 的组合释放了相当多的 CPU 时间;基于队列的中断服务例程(ISR)实现消耗了>100%的 CPU。正如以下处理分解所示,使用 DMA 的流缓冲区的总 CPU 使用率约为 10%:

注意以下内容:
-
基于 DMA/流缓冲区的解决方案几乎为其他任务留下了 90%的 CPU 周期。
-
花在打印调试语句(和从队列中取出字节)上的时间比服务 DMA ISR 的时间要多。
-
多字节流缓冲区事务还消除了大量的上下文切换(注意调度器只利用了大约 1%的 CPU),这将为其他处理任务留下更多连续的时间。
因此,现在我们已经处理了每种驱动类型的非常简单的示例,那么你应该实现哪一个呢?
选择驱动器模型
选择给定系统的最佳驱动器取决于几个不同的因素:
-
调用代码是如何设计的?
-
可以接受多少延迟?
-
数据移动有多快?
-
它是什么类型的设备?
让我们逐一回答这些问题。
调用代码是如何设计的?
使用驱动器的高级代码的预期设计是什么?它将操作传入的每个字符或字节吗?或者,对于高级代码来说,将传输批量到字节的块/帧中更有意义吗?
基于队列的驱动程序在处理未知数量(或流)的数据时非常有用,这些数据可以在任何时间点到来。它们也非常适合处理单个字节的代码——uartPrintOutTask就是一个很好的例子:
while(1)
{
xQueueReceive(uart2_BytesReceived, &nextByte, portMAX_DELAY);
//do something with the byte received
SEGGER_SYSVIEW_PrintfHost("%c", nextByte);
}
虽然环形缓冲区实现(如前述代码中的实现)非常适合流数据,但其他代码自然倾向于操作数据块。例如,如果我们的高级代码旨在通过串行端口读取第九章中定义的结构之一,即任务间通信:
以下摘录来自Chapter_9/MainQueueCompositePassByValue.c:
typedef struct
{
uint8_t redLEDState : 1;
uint8_t blueLEDState : 1;
uint8_t greenLEDState : 1;
uint32_t msDelayTime;
}LedStates_t;
与操作单个字节相比,接收方一次性拉取整个结构体的实例要方便得多。以下代码旨在从队列中接收LedStates_t的整个副本。在接收到结构体之后,可以通过引用结构体的成员来操作它,例如在这个例子中检查redLEDState:
LedStates_t nextCmd;
while(1)
{
if(xQueueReceive(ledCmdQueue, &nextCmd, portMAX_DELAY) == pdTRUE)
{
if(nextCmd.redLEDState == 1)
RedLed.On();
else
. . .
这可以通过序列化数据结构并将其通过通信介质传递来实现。我们的LedStates_t结构可以序列化为 5 字节的块。所有三个红色、绿色和蓝色状态值都可以打包到一个字节的 3 位中,延迟时间将占用 4 字节:

序列化本身是一个广泛的话题。在可移植性、易用性、代码脆弱性和速度之间需要做出权衡。关于所有这些点的讨论超出了本章的范围。关于端序和最佳序列化/反序列化此特定数据结构的方法的细节已被故意忽略在图中。主要收获是,结构体可以用 5 字节的块来表示。
在这种情况下,底层外设驱动程序以 5 字节为一个缓冲区进行操作是有意义的,因此将传输分组为 5 字节的缓冲区方法比字节流更自然。以下伪代码概述了基于我们在上一节中编写的基于缓冲区的驱动程序的方案:
uint8_t ledCmdBuff[5];
startReceiveInt(ledCmdBuff, 5);
//wait for reception to complete
xSemaphoreTake(cmdReceived, portMAX_DELAY);
//populate an led command with data received from the serial port
LedStates_t ledCmd = parseMsg(ledCmdBuff);
//send the command to the queue
xQueueSend(ledCmdQueue, &ledCmd, portMAX_DELAY);
在类似前述情况的情况下,我们已经介绍了两种可以提供高效实现的不同方法:
-
基于缓冲区的驱动程序(每次接收 5 字节)
-
流缓冲区(接收方可以配置为每次获取 5 字节)
FreeRTOS 消息缓冲区也可以用来代替流缓冲区,以提供更灵活的解决方案。消息缓冲区建立在流缓冲区之上,但具有更灵活的阻塞配置。它们允许在每次receive调用时配置不同的消息大小,因此相同的缓冲区可以用来将接收分组为每次 5 字节(或任何其他所需大小)。在使用流缓冲区的情况下,消息大小在创建流缓冲区时通过设置xStreamBufferCreate中的xTriggerLevelBytes参数而被严格定义。与流缓冲区不同,消息缓冲区将只返回完整的消息,而不是单个字节。
可以接受多少延迟?
根据要实现的特定功能,可能需要最小的延迟。在这种情况下,基于缓冲区的实现有时可能具有轻微的优势。它们允许调用代码被设置为极高的优先级,而不会在应用程序的其他部分引起显著的任务上下文切换。
在基于缓冲区的设置中,消息的最后一个字节传输完成后,任务将被通知并立即运行。这比让高优先级任务进行字节解析消息要好,因为它会在每次接收到一个字节时不断中断其他任务。在基于字节的队列方法中,如果传入的消息非常重要,等待队列的任务需要被设置为非常高的优先级。这种组合与缓冲区方法相比,会导致相当多的任务上下文切换,因为缓冲区方法在传输完成后只有一个信号量(或直接任务通知)。
有时,时间约束非常严格,既不能接受队列,也不能接受整个块传输(字节可能需要随着接收到来进行处理)。这种方法有时还会消除对中间缓冲区的需求。在这些情况下,可以编写一个完全定制的 ISR,但它不容易重用。尽可能避免将业务逻辑(非立即用于服务外设的应用级逻辑)放入 ISR 中。这会复杂化测试并减少代码重用。在编写了几个月(或几年)这样的代码之后,你可能会发现你已经有几十个看起来几乎相同但行为略有不同的 ISR,这在需要修改高级代码时可能会造成有缺陷的系统。
数据移动有多快?
虽然非常方便,但队列是传递单个字节在系统中的相当昂贵的方式。即使基于中断的驱动程序也有处理传入数据的时间限制。我们的例子使用了 9600 波特率的传输。字符在被接收后 40 微秒内被传输到队列中,但波特率是 115,200 波特时会发生什么?现在,每个字符需要在不低于 9 微秒内添加到队列中。每个中断需要 40 微秒的驱动程序在这里是不可接受的,因此使用简单的队列方法不是一个可行的选项。
我们看到,使用 DMA 的流缓冲区实现是队列的可行替代方案。对于高速、连续的数据流,使用某种类型的双缓冲技术至关重要。当与高度有效的 RTOS 原语(如流缓冲区或消息缓冲区)结合使用时,这成为了一种特别方便的技术。
当速度较高时,基于中断和 DMA 的驱动程序将数据直接移动到原始内存缓冲区也是相当可行的,但它们没有队列-like 界面的便利性。
你正在与哪种类型的设备接口?
一些外围设备和外部设备会自然倾向于一种或另一种实现。在接收异步数据时,队列是一个相当自然的选择,因为它们提供了一个易于捕获不断传入数据的机制。UARTs、USB 虚拟串行通信、网络流和定时器捕获都自然地使用字节级队列实现(至少在最低级别)。
基于同步的设备,如串行外设接口(SPI)和集成电路间(I2C),由于事先知道字节数,因此在主侧使用基于块的传输很容易实现(主需要提供发送和接收的字节所需的时钟信号)。
何时使用基于队列的驱动程序
以下是一些使用队列作为驱动程序接口的优势案例:
-
当外围/应用程序需要接收未知长度的数据时
-
当数据必须异步接收请求时
-
当驱动程序应该从多个来源接收数据而不阻塞调用者时
-
当数据速率足够慢,允许每个中断至少有 10 的微秒(当在硬件上实现时,例如这个例子)
何时使用基于缓冲区的驱动程序
以下是一些原始缓冲区驱动程序极其有用的案例:
-
当需要大缓冲区时,因为将一次性接收大量数据
-
在基于事务的通信协议期间,尤其是在事先知道接收到的数据长度时
何时使用流缓冲区
流缓冲区提供的速度接近原始缓冲区,但增加了提供高效队列 API 的好处。它们通常可以在标准队列会被使用的地方使用(只要只有一个消费者任务)。流缓冲区在许多情况下也足够高效,可以替代原始缓冲区。正如我们在mainUartDMAStreamBufferCont.c示例中看到的那样,它们可以与循环 DMA 传输结合使用,提供真正的连续数据捕获,而不需要使用大量的 CPU 周期。
这些只是你在创建驱动程序时可能会遇到的一些考虑因素;它们主要针对通信外设(因为我们的示例涵盖了这一点)。在选择使用第三方库和驱动程序时,也有一些考虑因素需要考虑。
使用第三方库(STM HAL)
如果你一直密切关注,你可能已经注意到了一些事情:
-
STM HAL(供应商提供的硬件抽象层)用于初始外设配置。这是因为 HAL 在使外设配置变得容易方面做得非常好。使用 STM Cube 等工具生成一些模板代码作为参考,以便首次与新型芯片交互时也非常方便。
-
当需要实现中断驱动的交易细节时,我们一直直接调用 MCU 外设寄存器,而不是让 HAL 为我们管理交易。这样做有几个原因:
-
我们希望更接近硬件,以便更好地理解系统中的实际工作方式。
-
一些设置没有得到 HAL 的直接支持,例如 DMA 双缓冲。
-
通常,你应该使用你(或你的项目/公司)感到舒适的供应商提供的代码量。如果代码编写良好且工作可靠,那么通常没有太多理由不使用它。
话虽如此,以下是在使用供应商提供的驱动程序时可能遇到的一些潜在问题:
-
它们可能使用轮询而不是中断或 DMA。
-
将其与中断绑定可能很繁琐或不灵活。
-
可能会有很多额外的开销,因为许多芯片/用例可能已经被驱动程序覆盖(它们需要解决每个人的问题,而不仅仅是你的)。
-
完全掌握和理解复杂的 API 可能比直接与外设硬件工作(对于简单的外设)需要更长的时间。
以下是一些编写裸机驱动程序的例子:
-
当供应商的驱动程序出现故障/有错误时
-
当速度很重要时
-
当需要特殊配置时
-
作为学习练习
理想情况下,在第三方驱动程序和您自己的驱动程序之间切换应该是无缝的。如果不是这样,这意味着高级代码与硬件紧密耦合。这种紧密耦合对于足够小的一次性和废弃项目来说是完全可以接受的,但如果您试图长期开发代码库,投资于创建松散耦合的架构将会有回报。拥有松散耦合(消除精确驱动程序实现与高级代码之间的依赖关系)也为单个组件的实现提供了灵活性。松散耦合确保在自定义驱动程序和第三方驱动程序之间切换不需要对高级代码进行重大重写。松散耦合还使得在隔离状态下测试代码库的小部分成为可能——有关详细信息,请参阅第十二章,创建良好抽象架构的技巧。
摘要
在本章中,我们介绍了三种实现低级驱动程序的方法,这些驱动程序与 MCU 中的硬件外设接口。通过示例涵盖了中断、轮询和基于 DMA 的驱动程序,并使用 SEGGER SystemView 分析了它们的性能并进行比较。我们还介绍了 FreeRTOS 与 ISRs 交互的三个不同方式:信号量、队列和流缓冲区。还讨论了选择实现选项的考虑因素,以及何时使用第三方外设驱动程序(STM HAL)以及何时“自行开发”最佳。
为了充分利用本章内容,我们鼓励您在实际硬件上运行它。开发板的选择(部分原因)是希望您可能能够访问 Arduino 外壳。在运行示例之后,下一步的绝佳步骤是开发一个外壳或另一件实际硬件的驱动程序。
当谈到驱动程序实现时,本章只是冰山一角。在创建高效实现时,可以使用许多额外的方法和技巧,从使用本章未展示的不同 RTOS 原语到配置 MCU 特定的功能。您的设计不需要受限于供应商提供的功能。
您现在应该对低级驱动程序的不同实现方式有了稳固的理解。在下一章中,我们将探讨如何将这些驱动程序安全地跨多个任务呈现给高级代码。提供驱动程序的便捷访问使得开发最终应用程序变得快速灵活。
问题
在我们总结之际,这里有一份问题列表,供您测试对本章内容的理解。您将在附录的评估部分找到答案:
-
哪种类型的驱动程序编写和使用的难度更大?
-
轮询
-
中断驱动
-
-
判断对错:在 FreeRTOS 中,是否可以从任何中断服务例程(ISR)中调用任何 RTOS 函数?
-
是
-
否
-
-
判断对错:在使用实时操作系统(RTOS)时,中断始终在与调度器争夺 CPU 时间?
-
是
-
否
-
-
在传输大量高速数据时,哪种外围设备驱动技术需要的 CPU 资源最少?
-
投票
-
中断
-
DMA
-
-
DMA 代表什么?
-
列举一个使用基于原始缓冲区的驱动程序不是好主意的情况。
进一步阅读
-
RM0410 STM32F76xxx参考手册的第四章(USART)
-
B1.5.4,在Arm®v7-M Architecture参考手册的异常优先级和抢占部分
-
FreeRTOS.org 对 CortexM 优先级的解释,见
www.freertos.org/RTOS-Cortex-M3-M4.html
第十一章:在任务间共享硬件外设
在上一章中,我们探讨了创建驱动程序的好几个例子,但它们仅被单个任务使用。由于我们正在创建一个多任务异步系统,需要考虑一些额外的因素以确保我们的驱动程序暴露的外设可以安全地被多个任务使用。为多个任务准备驱动程序需要考虑许多额外的因素。
因此,本章首先阐述了在多任务、实时环境中共享外设的陷阱。在理解我们要解决的问题之后,我们将研究如何将驱动程序封装起来,以便提供一个易于使用且在多个任务中安全使用的抽象层。我们将使用 STM32 USB 堆栈来实现一个通信设备类(CDC),以提供交互式的虚拟串行端口(VPC)。与上一章不同,上一章采用了极端低级的驱动程序开发方法,本章则侧重于在现有的驱动程序堆栈之上编写线程安全的代码。
简而言之,我们将涵盖以下主题:
-
理解共享外设
-
介绍 STM USB 驱动程序堆栈
-
开发 StreamBuffer USB 虚拟串行端口
-
使用互斥锁进行访问控制
技术要求
为了完成本章的动手实验,你需要以下工具:
-
Nucleo F767 开发板
-
Micro-USB 线(x2)
-
STM32CubeIDE 和源代码(第五章,选择 IDE,在设置我们的 IDE部分中提供说明)
-
SEGGER JLink、Ozone 和 SystemView(第六章,实时系统调试工具)
-
STM USB 虚拟串行端口驱动程序:
-
Windows:驱动程序应自动从 Windows 更新中安装(
www.st.com/en/development-tools/stsw-stm32102.html)。 -
Linux/ macOS:这些系统使用内置的虚拟串行端口驱动程序。
-
-
串行终端客户端:
-
Tera Term(或类似)(Windows)
-
minicom(或类似)(Linux / macOS)
-
miniterm.py(跨平台串行客户端,也包含在第十三章,使用队列创建松散耦合)中使用的 Python 模块
-
本章的所有源代码均可在github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers/tree/master/Chapter_11找到。
理解共享外设
硬件外围设备类似于任何其他共享资源。当有多个任务需要访问单个资源时,需要创建某种仲裁机制来保证任务间对资源的有序访问。在前一章中,我们关注了开发低级外围驱动程序的不同方法。提供了一些关于驱动程序选择的指导,并建议驱动程序提供的适当接口应根据驱动程序在系统中的使用方式来确定(第十章,驱动程序和中断服务例程,在选择驱动程序模型部分)。
在第三章中,任务信号和通信机制概念上介绍了共享资源。
在现实世界的应用中,共享外围设备的例子有很多。通信外围设备如 SPI、I2C、USART 和以太网外围设备都可以由多个任务同时使用,前提是应用的定时约束允许这样做,并且驱动程序以提供安全并发访问的方式编写。由于所有阻塞的 RTOS 调用都可以设置时间限制,因此很容易检测到访问共享外围设备是否导致定时问题。
重要的是要记住,在多个任务之间共享单个外围设备会导致延迟和定时不确定性。
在某些对定时要求严格的场合,最好避免共享外围设备,而是使用专用硬件。这也是为什么有多个基于总线的外围设备可用,包括 SPI、USART 和 I2C 的原因之一。尽管这些通信总线的硬件完全能够处理多个设备,但有时最好使用专用外围设备。
在其他情况下,一个硬件设备的驱动程序可能非常特定,出于性能考虑,最好为它分配一个完整的外围设备。通常,高带宽的外围设备会属于这一类别。一个例子是中等带宽的 ADC,每秒采样数千或数万个数据点。与这类设备交互的最高效方式是尽可能使用 DMA,将数据从通信总线(如 SPI)直接传输到 RAM。
定义外围设备驱动程序
本章提供了在现实世界情况下与驱动程序交互的完整示例。选择 USB 虚拟 COM 端口是因为它不需要任何额外的硬件,除了另一条微 USB 线。
我们的目标是使使用 USB CDC 与 Nucleo 板交互变得尽可能高效。交互的理想特性包括以下内容:
-
能够从多个任务中轻松写入 USB 虚拟 COM 端口。
-
高效的事件驱动执行(尽可能避免无用的轮询)。
-
数据应立即通过 USB 发送(尽可能避免延迟发送)。
-
调用应该是非阻塞的(任务可以添加要发送的数据,而无需等待实际的事务)。
-
任务可以选择在数据被丢弃之前等待空间可用的时长。
这些设计决策将产生几个影响:
-
传输时间不确定性:虽然数据以非阻塞方式排队,但传输的确切时间不能保证。这在这个特定示例中不是问题,但如果这是用于对时间敏感的交互,则可能是问题。USB CDC 一开始就不是非常适合对时间要求极为敏感的应用。
-
缓冲区大小和延迟之间的权衡:为了为传输大消息提供足够的空间,队列可以做得更大。然而,数据从大队列中退出所需的时间比从小队列中退出所需的时间更长。如果延迟或时间是一个考虑因素,则需要考虑这段时间。
-
RAM 使用:队列需要额外的 RAM,这超出了 USB 缓冲区已经需要的 RAM。
-
效率:此驱动程序代表了易用性和效率之间的权衡。实际上有两个缓冲区——USB 使用的缓冲区和队列。为了提供易用性,数据将被值复制两次,一次进入队列,一次进入 USB 传输缓冲区。根据所需的带宽,这可能会带来显著的性能限制。
首先,让我们从高层次上了解一下 STM USB 设备驱动程序堆栈,以便更好地理解我们在与 STM 提供的 CDC 驱动程序接口时拥有的选项。
介绍 STM USB 驱动程序堆栈
STM32CubeMX 被用作起点,以生成具有 CDC 支持的 USB 设备驱动程序堆栈。以下是重要的 USB 源文件及其相对于存储库根目录的位置概述:github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers/tree/master/
- 低级 HAL USB 文件:
DRIVERS\STM32F7XX_HAL_DRIVER\
|--Inc
| |stm32f7xx_ll_usb.h
|--Src
|stm32f7xx_ll_usb.c
stm32f7xx_ll_usb.c/h文件是最低级别的文件,提供了访问 USB 硬件外设的接口。这些文件被 STM 提供的 USB 驱动程序堆栈中间件使用。
- STM USB 设备堆栈:
MIDDLEWARE\ST\
|--STM32_USB_Device_Library
|----Class
| |----CDC
| |----Inc
| | usbd_cdc.h
| ----Src
| usbd_cdc.c
|----Core
|----Inc
| usbd_core.h
| usbd_ctlreq.h
| usbd_def.h
| usbd_ioreq.h
|----Src
usbd_core.c
usbd_ctlreq.c
usbd_ioreq.c
前述文件实现了核心 USB 设备和 CDC 类功能。这些也是由 STM 提供的。这些提供了处理 USB 事务和枚举所需的大部分功能。
- 与 USB 库的大部分交互将在 CDC 接口级别进行,在 BSP 文件夹中:
BSP\
Nucleo_F767ZI_Init.c
Nucleo_F767ZI_Init.h
usbd_cdc_if.c
usbd_cdc_if.h
usbd_conf.c
usbd_conf.h
usbd_desc.c
usbd_desc.h
usb_device.c
usb_device.h
下面是每个源文件对及其用途的简要描述。这些文件在 USB 开发过程中最有可能被修改:
-
Nucleo_F767ZI_Init.c/h:MCU 的初始化代码,针对特定硬件。时钟和单个引脚配置等功能在这里发生。 -
usbd_cdc_if.c/h:(STM Cube 生成)。包含 USB 设备 CDC 接口函数。CDC_Transmit_FS()用于从 MCU 向 USB 主机(本例中的 PC)传输数据。CDC_Receive_FS()用于从 USB 主机接收数据。 -
usbd_conf.c/h:(STM Cube 生成)。用于将stm32f7xx_hal_pcd.c(USB 外设控制驱动程序)的函数和所需回调映射到stm32f7xx_ll_usb.c(低级 USB 外设接口驱动程序)。 -
usbd_desc.c/h:(STM Cube 生成)。定义在 USB 枚举期间使用的 USB 设备描述符。这是定义产品标识符和厂商标识符的地方(PID,VID)。 -
usb_device.c/h:(STM Cube 生成)。包含初始化 USB 堆栈的顶级函数。此文件包含MX_USB_DEVICE_Init(),用于初始化整个 USB 设备驱动程序堆栈。MX_USB_DEVICE_Init()应该在所有较低级别的时钟和引脚初始化完成之后调用(Nucleo_F767ZI_Init.c中的HWInit()执行此初始化)。
现在我们已经对代码的结构有了大致的了解,让我们创建一个简单的示例来更好地理解如何与之交互。
使用库存的 CDC 驱动程序
mainRawCDC.c包含少量代码来配置 MCU 硬件和 USB 设备堆栈。当将微型 USB 线缆插入 CN1(并连接到 USB 主机,如 PC)并通过 CN13 供电时,它将允许 MCU 作为虚拟 COM 端口通过 USB 进行枚举。它将尝试通过 USB 发送两条消息:test和message:
- USB 堆栈通过使用
MX_USB_Device_Init()函数在硬件完全初始化后初始化:
int main(void)
{
HWInit();=
MX_USB_DEVICE_Init();
- 有一个单独的任务通过 USB 输出两个字符串,在第二次传输后使用简单的调用
usbd_cdc_if.c:CDC_Transmit_FS强制延迟 100 个 tick:
void usbPrintOutTask( void* NotUsed)
{
while(1)
{
SEGGER_SYSVIEW_PrintfHost("print test over USB");
CDC_Transmit_FS((uint8_t*)"test\n", 5);
SEGGER_SYSVIEW_PrintfHost("print message over USB");
CDC_Transmit_FS((uint8_t*)"message\n", 8);
vTaskDelay(100);
}
}
- 在将此应用程序编译并加载到我们的目标板上之后,我们可以通过打开终端模拟器(本例中为 Tera Term)来观察 USB 端口的输出。你可能会看到以下截图类似的内容:

由于我们输出的是包含测试和消息的单行,我们希望虚拟串行端口包含相同的序列,但存在多个test行,并不总是随后跟有message行。
从 SystemView 中观察这个相同的应用程序运行,我们可以看到代码的执行顺序是我们预期的:

在仔细检查CDC_Transmit_FS之后,我们可以看到有一个返回值应该被检查。CDC_Transmit_FS首先检查在用新数据覆盖传输缓冲区之前,是否已经有一个传输正在进行。以下是CDC_Transmit_FS(由 STM Cube 自动生成)的内容:
uint8_t result = USBD_OK;
/* USER CODE BEGIN 7 */
USBD_CDC_HandleTypeDef *hcdc =
(USBD_CDC_HandleTypeDef*)hUsbDeviceFS.pClassData
if (hcdc->TxState != 0){
return USBD_BUSY;
}
USBD_CDC_SetTxBuffer(&hUsbDeviceFS, Buf, Len);
result = USBD_CDC_TransmitPacket(&hUsbDeviceFS);
/* USER CODE END 7 */
return result;
只有在没有正在进行的传输(由hcdc->TxState指示)时,才会进行数据传输。因此,为了确保所有消息都得到传输,我们这里有几个选择。
- 我们可以简单地用条件语句包裹对
CDC_Transmit_FS的每一个调用,以检查传输是否成功:
int count = 10;
while(count > 0){
count--;
if(CDC_Transmit_FS((uint8_t*)"test\n", 5) == USBD_OK)
break;
else
vTaskDelay(2);
}
这种方法有几个缺点:
-
-
在尝试连续发送多个消息时,速度较慢(因为每次尝试之间的延迟)。
-
如果移除延迟,将极度浪费 CPU 资源,因为代码将基本上在传输完成时进行轮询。
-
这是不希望看到的复杂性。通过强制调用代码评估低级 USB 事务是否有效,我们在可能非常简单的事情上添加了一个循环和嵌套条件语句。这将增加代码编写错误的概率并降低可读性。
-
-
我们可以基于
usbd_cdc_if.c编写一个新的包装器,使用 FreeRTOS 流缓冲区有效地将数据移动到 USB 堆栈。这种方法有几个注意事项:-
为了保持调用代码简单,我们将容忍丢失的数据(如果流缓冲区空间不足)。
-
为了支持来自多个任务的调用,我们需要使用互斥锁保护对流缓冲区的访问。
-
流缓冲区将实际上创建一个重复的缓冲区,从而消耗额外的 RAM。
-
-
我们可以使用 FreeRTOS 队列代替流缓冲区。如第十章,驱动程序和中断服务例程)中所示,使用队列(相对于流缓冲区)时,由于每次只移动一个字节,因此会收到性能上的影响。然而,当在任务间使用时,队列不需要被互斥锁封装。
最佳解决方案取决于许多因素(有关考虑因素的列表见第十章,驱动程序和中断服务例程)。在此示例中,我们将使用流缓冲区实现。缓冲区所需的额外空间有足够的空间。这里的代码仅用于支持偶尔的短消息,而不是一个完全可靠的数据通道。这种限制主要是为了最小化复杂性,使示例更容易阅读。
现在我们来看看选项 2 和 3 相对于已存在的 STM HAL 驱动程序的样子:

对于这个驱动程序,我们将修改由 ST 提供的 HAL 生成的代码(usbd_cdc_if.c)作为起点。其功能将被我们新创建的VirtualCommDriver.c所取代。这将在下一节中详细介绍。
我们还将对 STM 提供的 CDC 中间件(usbd_cdc.c/h)进行非常小的修改,以启用一种非轮询方法来确定传输何时完成。usbd_cdc.h 中的 USBD_CDC_HandleTypeDef 结构体已经有一个名为 TxState 的变量,可以通过轮询来确定传输何时完成。但是,为了提高效率,我们希望避免轮询。为了实现这一点,我们将在结构体中添加另一个成员——一个在传输完成时将被调用的函数指针:usbd_cdc.h(粗体新增内容):
typedef struct
{
uint32_t data[CDC_DATA_HS_MAX_PACKET_SIZE / 4U]; /* Force 32bits
alignment */
uint8_t CmdOpCode;
uint8_t CmdLength;
uint8_t *RxBuffer;
uint8_t *TxBuffer;
uint32_t RxLength;
uint32_t TxLength;
//adding a function pointer for an optional call back function
//when transmission is complete
void (*TxCallBack)( void );
__IO uint32_t TxState;
__IO uint32_t RxState;
}
USBD_CDC_HandleTypeDef;
然后,我们将向 usbd_cdc.c 添加以下代码(粗体新增内容):
}
else
{
hcdc->TxState = 0U;
if(hcdc->TxCallBack != NULL)
{
hcdc->TxCallBack();
}
}
return USBD_OK;
}
如果提供了 TxCallBack 函数指针(通过非空值指示),则执行该函数。这发生在 CDC 结构体中的 TxState 被设置为 0 时。TxCallBack 也在 USBD_CDC_Init() 中初始化为 NULL。
修改 STM 提供的驱动程序将使迁移到 HAL 的不同版本变得更加困难。必须权衡这些考虑与它们提供的任何优势。
注意:HAL 和 STMCubeIDE 的较新版本包括对 TxCallBack 的支持,因此如果你是从 ST 最新发布的代码开始,这个修改将不再必要。
开发 StreamBuffer USB 虚拟串行端口
VirtualComDriver.c 位于顶层 Drivers 文件夹中(因为我们很可能在未来的章节中使用它)。它在这里可用:github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers/tree/master/Drivers/HandsOnRTOS/
首先,我们将逐一介绍创建的每个函数及其用途。
公共函数
VirtualComDriver.c 当前有三个公开可用的函数:
-
TransmitUsbDataLossy -
TransmitUsbData -
VirtualCommInit
TransmitUsbDataLossy 简单地是一个围绕流缓冲区函数调用的包装器。它使用了一个中断服务例程(ISR)安全的变体,这保证了它不会阻塞(但可能也不会将所有数据复制到缓冲区中)。返回的是复制到缓冲区中的字节数。在这种情况下,是否完成数据复制到缓冲区由调用代码来决定:
int32_t TransmitUsbDataLossy(uint8_t const* Buff, uint16_t Len)
{
int32_t numBytesCopied = xStreamBufferSendFromISR( txStream, Buff, Len,
NULL);
return numBytesCopied;
}
TransmitUsbData 提供了更多的便利性。它将阻塞最多两个滴答等待缓冲区中有空间可用。如果缓冲区在初始传输过程中部分填满,这将被分成两个调用。在第二次调用 xStreamBufferSend 时,很可能会有足够的空间可用。在大多数情况下,使用这种方法丢失的数据非常少:
int32_t TransmitUsbData(uint8_t const* Buff, uint16_t Len)
{
int32_t numBytesCopied = xStreamBufferSend( txStream, Buff, Len, 1);
if(numBytesCopied != Len)
{
numBytesCopied += xStreamBufferSend( txStream, Buff+numBytesCopied,
Len-numBytesCopied, 1);
}
return numBytesCopied;
}
VirtualCommInit 执行 USB 栈和必要的 FreeRTOS 任务所需的所有设置。流缓冲区正在以触发级别 1 初始化,以最小化 TransmitUsbData 被调用与数据移动到 USB 栈之间的延迟。此值可以与 xStreamBufferReceive 中使用的最大阻塞时间一起调整,以确保同时传输更大的数据块,从而实现更好的效率:
void VirtualCommInit( void )
{
BaseType_t retVal;
MX_USB_DEVICE_Init();
txStream = xStreamBufferCreate( txBuffLen, 1);
assert_param( txStream != NULL);
retVal = xTaskCreate(usbTask, "usbTask", 1024, NULL,
configMAX_PRIORITIES, &usbTaskHandle);
assert_param(retVal == pdPASS);
}
这些都是公开可用的函数。通过稍微修改与流缓冲区的交互,此驱动程序可以针对许多不同的用例进行优化。其余的功能由不可公开访问的函数提供。
私有函数
usbTask 是一个私有函数,负责我们 CDC 覆盖的初始设置。它还监控流缓冲区和任务通知,对 STM 提供的 CDC 实现进行必要的调用。
在开始主循环之前,有一些项目需要初始化:
- 任务必须等待所有底层外设和 USB 栈初始化完成。这是因为任务将访问由 USB CDC 栈创建的数据结构:
USBD_CDC_HandleTypeDef *hcdc = NULL;
while(hcdc == NULL)
{
hcdc = (USBD_CDC_HandleTypeDef*)hUsbDeviceFS.pClassData;
vTaskDelay(10);
}
- 如果传输尚未进行,则提供任务通知。通知也被接受,这允许在传输正在进行时以高效的方式阻塞:
if (hcdc->TxState == 0)
{
xTaskNotify( usbTaskHandle, 1, eSetValueWithOverwrite);
}
ulTaskNotifyTake( pdTRUE, portMAX_DELAY );
usbTxComplete是当传输完成时将被执行的回调函数。USB CDC 栈已准备好接受更多要传输的数据。将TxCallBack变量设置为usbTxComplete配置usbd_cdc.c使用的结构,允许我们的函数在正确的时间被调用:
hcdc->TxCallBack = usbTxComplete;
usbTxComplete很短,只包含几行代码,用于提供任务通知并强制进行上下文切换以进行评估(因此usbTask将尽可能快地被解除阻塞):
void usbTxComplete( void )
{
portBASE_TYPE xHigherPriorityTaskWoken = pdFALSE;
xTaskNotifyFromISR( usbTaskHandle, 1, eSetValueWithOverwrite,
&xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
在 USB 中断服务例程(ISR)中执行由 TxCallBack 指向的函数,因此回调中执行的任何代码都必须非常简短,只能调用 FreeRTOS 函数的 ISR 安全版本,并且其优先级必须正确配置。
usbTask的无限while循环部分如下:
while(1)
{
SEGGER_SYSVIEW_PrintfHost("waiting for txStream");
uint8_t numBytes = xStreamBufferReceive( txStream, usbTxBuff,
txBuffLen, portMAX_DELAY);
if(numBytes > 0)
{
SEGGER_SYSVIEW_PrintfHost("pulled %d bytes from txStream",
numBytes);
USBD_CDC_SetTxBuffer(&hUsbDeviceFS, usbTxBuff, numBytes);
USBD_CDC_TransmitPacket(&hUsbDeviceFS);
ulTaskNotifyTake( pdTRUE, portMAX_DELAY );
SEGGER_SYSVIEW_PrintfHost("tx complete");
}
}
任务通知提供了一种高效的方式来控制传输,而不需要轮询:
-
每当传输完成时,回调(
usbTxComplete)将从 USB 栈中执行。usbTxComplete将提供通知,这将解除usbTask的阻塞,此时它将转到流缓冲区并尽可能在一次调用中收集尽可能多的数据,将所有可用数据复制到usbTxBuff(最多numBytes字节)中。 -
如果传输完成,
usbTask将无限期地阻塞,直到流缓冲区(txStream)中出现数据。在阻塞期间,usbTask不会消耗任何 CPU 时间,但它也会在数据可用时自动解除阻塞。
此方法提供了一种非常高效的数据排队方式,同时提供良好的吞吐量和低延迟。向队列添加数据的任何任务都不需要阻塞或等待其数据被传输。
将所有这些放在一起
这里有很多事情在进行中,有多个异步事件源。以下是所有这些函数如何组合在一起的时序图:

以下是前一个图中的一些值得注意的项目:
-
对
TransmitUsbData和TransmitUsbDataLossy的调用是非阻塞的。如果空间可用,数据将被传输到流缓冲区txStream,并返回复制的字节数。在极高负载下,当缓冲区被填满时,可能将部分消息复制到缓冲区中。 -
在通过
USBD_CDC_TransmitPacket发送数据包之前,需要发生两件事:-
usbTask必须接收到一个任务通知,表明可以发送数据。 -
数据必须在
txStream中可用。
-
-
传输开始后,USB 堆栈将在
stm32f7xx_it.c中的OTG_FS_IRQHandler被调用,直到传输完成,此时将调用由TxCallBack指向的函数(usbTxComplete)。此回调在 USB 中断服务例程(ISR)内部执行,因此必须使用 ISR 安全版本的vTaskNotify(vTaskNotifyFromISR)。
在 mainStreamBuffer.c 中(可在 github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers/tree/master/Chapter_11/Src/mainUsbStreamBuffer.c 获取),一旦完成硬件初始化,虚拟串口通过单行初始化:
int main(void)
{
HWInit();
VirtualCommInit();
在 mainStreamBuffer.c 中创建了一个单独的任务来将数据推送到 USB:
void usbPrintOutTask( void* NotUsed)
{
const uint8_t testString[] = "test\n";
const uint8_t messageString[] = "message\n";
while(1)
{
SEGGER_SYSVIEW_PrintfHost("add \"test\" to txStream");
TransmitUsbDataLossy(testString, sizeof(testString));
SEGGER_SYSVIEW_PrintfHost("add \"message\" to txStream");
TransmitUsbDataLossy(messageString, sizeof(messageString));
vTaskDelay(2);
}
}
这导致输出交替变化,正如我们所预期的那样,归功于流缓冲区提供的缓冲:

现在我们来看一个使用 SystemView 的单个传输示例:

所有任务和中断服务例程(ISRs)都按照升序优先级排列。在右侧的 SystemView 终端上的数字与时间线上的相应数字相对应:
-
第一项,test\n 被添加到缓冲区。
usbTask现在准备运行(由蓝色框表示)。 -
第二项,message\n 被添加到缓冲区。在
usbPrint任务阻塞后,调度器将usbTask带入上下文。 -
所有 15 个字节都从流缓冲区
txStream复制到本地的usbTxBuff。此缓冲区通过USBD_CDC_SetTxBuffer被喂入 USB 堆栈,并通过USBD_CDC_TransmitPacket启动传输。USB 堆栈负责传输并在完成后发出回调(usbTxComplete)。此回调向usbTask发送任务通知,表示传输已完成。 -
usbTask接收任务通知并继续循环。 -
usbTask开始等待txStream中有可用数据。
这个一般序列每 2 毫秒重复一次,这相当于每秒传输大约 1,000 行。请注意,延迟是为了使分析更容易。可以使用无损耗的TransmitUsbData()代替,但看到确切地发生了什么要困难一些:

总 CPU 时间消耗大约为 10%,大部分时间花在usbTask和usbPrint上。
如果我们想要最小化 CPU 使用率,以牺牲在消息首次打印和通过线路传输之间的延迟为代价,可以进行以下更改:
以下是从VirtualCommDriver.c的摘录:
- 将用于初始化
txStream的触发值从 1 增加到 500。这将导致缓冲区在返回数据之前尝试收集 500 个字节:
void VirtualCommInit( void )
{
MX_USB_DEVICE_Init();
txStream = xStreamBufferCreate( txBuffLen, 500);
- 将在流中等待数据可用的最大时间从无限超时减少到 100 个滴答。这将保证流至少每 100 个滴答(在当前配置下恰好是 100 毫秒)清空一次。这最小化了上下文切换以及
usbTask需要运行的频率。它还允许一次传输更多数据到 USB 堆栈:
uint8_t numBytes = xStreamBufferReceive( txStream, usbTxBuff,
txBuffLen, 100);
将流缓冲区的触发值从 1 增加到 500 字节,并将可用块时间从 1 增加到 100 个滴答将usbTask的 CPU 使用率降低了 94%:

现在,这也意味着我们也有延迟的增加——从调用TransmitUsbDataLossy到消息通过 USB 电缆传输所需的时间。因此,需要做出权衡。在这个简单的例子中,使用场景只是简单的打印输出,由人查看文本,10 Hz 可能已经足够快了。
现在我们已经编写了大部分 USB 驱动程序,我们可以添加一些额外的安全措施来确保VirtualCommDriver可以在多个任务之间安全使用。
使用互斥锁进行访问控制
由于我们使用流缓冲区实现了我们的驱动程序,如果我们希望有多个任务写入它,则必须通过互斥锁来保护对流缓冲区的访问。大多数其他 FreeRTOS 原语,如队列,没有这种限制;它们可以在多个任务之间安全使用,无需额外努力。让我们看看扩展 VirtualCommDriver 使其能够被多个任务使用需要哪些改动。
扩展 VirtualCommDriver
为了使 VirtuCommPortDriver 的用户使用尽可能简单,我们可以在函数调用本身中包含所有互斥锁处理,而不是要求函数的用户管理互斥锁。
创建了一个额外的文件VirtualCommDriverMultiTask.c来展示这一点:
- 定义并创建了一个互斥锁,以及在此源文件中跨多个函数所需的全部其他变量:
#define txBuffLen 2048
uint8_t vcom_usbTxBuff[txBuffLen];
StreamBufferHandle_t vcom_txStream = NULL;
TaskHandle_t vcom_usbTaskHandle = NULL;
SemaphoreHandle_t vcom_mutexPtr = NULL;
为了防止为每个包含VirtualComDriverMultitTask的编译单元创建多个此互斥锁的副本,这次我们不会将我们的私有全局变量定义为具有静态作用域。由于 C 中没有命名空间,我们将名称前缀为vcom_,以尝试避免与其他全局变量发生命名冲突。
- 互斥锁在
VirtualCommInit()中初始化:
vcom_mutexPtr = xSemaphoreCreateMutex();
assert_param(vcom_mutexPtr != NULL);
- 定义了一个新的
TransmitUsbData()函数。它现在包括一个最大延迟(以毫秒为单位):
int32_t TransmitUsbData(uint8_t const* Buff, uint16_t Len, int32_t DelayMs)
- 定义一些变量以帮助跟踪经过的时间:
const uint32_t delayTicks = DelayMs / portTICK_PERIOD_MS;
const uint32_t startingTime = xTaskGetTickCount();
uint32_t endingTime = startingTime + delayTicks;
之前的xStreamBufferSend调用被包含在互斥锁vcom_mutexPtr中。remainingTime在每次阻塞的 FreeRTOS API 调用后更新,以准确限制在此函数中花费的最大时间:
if(xSemaphoreTake(vcom_mutexPtr, delayTicks ) == pdPASS)
{
uint32_t remainingTime = endingTime - xTaskGetTickCount();
numBytesCopied = xStreamBufferSend( vcom_txStream, Buff, Len,
remainingTime);
if(numBytesCopied != Len)
{
remainingTime = endingTime - xTaskGetTickCount();
numBytesCopied += xStreamBufferSend( vcom_txStream,
Buff+numBytesCopied,
Len-numBytesCopied,
remainingTime);
}
xSemaphoreGive(vcom_mutexPtr);
}
创建了一个新的主文件mainUsbStreamBufferMultiTask来展示用法:
- 创建了
usbPrintOutTask。它接受一个数字作为参数,以便容易区分哪个任务正在写入:
void usbPrintOutTask( void* Number)
{
#define TESTSIZE 10
char testString[TESTSIZE];
memset(testString, 0, TESTSIZE);
snprintf(testString, TESTSIZE, "task %i\n", (int) Number);
while(1)
{
TransmitUsbData((uint8_t*)testString, sizeof(testString), 100);
vTaskDelay(2);
}
}
- 创建了两个
usbPrintOutTask实例,传入数字1和2。将类型转换为(void*)可以防止编译器报错:
retVal = xTaskCreate( usbPrintOutTask, "usbprint1",
STACK_SIZE, (void*)1, tskIDLE_PRIORITY + 2,
NULL);
assert_param( retVal == pdPASS);
retVal = xTaskCreate( usbPrintOutTask, "usbprint2",
STACK_SIZE, (void*)2, tskIDLE_PRIORITY +
2,
NULL);
assert_param( retVal == pdPASS);
现在,多个任务能够通过 USB 发送数据。每次调用TransmitUsbData可能阻塞的时间由每次函数调用指定。
保证原子事务
有时,发送一条消息并确信响应是针对该消息的,这是很有必要的。在这些情况下,可以在更高层次使用互斥锁。这允许将消息组聚集在一起。当这种技术特别有用的情况是一个外设为多个物理 IC 服务,这些 IC 分布在多个任务中:

在前面的图中,相同的外设(SPI1)用于服务两个不同的 IC。尽管 SPI 外设是共享的,但每个 IC 都有独立的片选线(CS1 和 CS2)。还有两个完全独立的驱动程序用于这些设备(一个是 ADC,另一个是 DAC)。在这种情况下,可以使用互斥锁将发送到同一设备的多条消息分组,以便它们在正确的片选线激活时同时发生;如果 ADC 在 CS2 被置位时接收数据(DAC 会接收数据),事情就不会顺利。
当以下所有条件都存在时,这种方法可以很好地工作:
-
单个传输很快。
-
外设具有低延迟。
-
转移发生的灵活性(至少几毫秒,如果不是 10 毫秒)。
共享硬件与其他共享资源没有太大区别。这里还没有讨论过许多其他现实世界的例子。
摘要
在本章中,我们深入探讨了创建一个高效接口,该接口用于一个非常方便使用的复杂驱动程序堆栈。使用流缓冲区,我们分析了降低延迟和最小化 CPU 使用之间的权衡。在基本接口就位后,它被扩展到可以在多个任务中使用。我们还看到了一个示例,展示了如何使用互斥锁来确保多阶段事务在多个任务之间共享外设时保持原子性。
在所有示例中,我们关注的是性能与易用性以及编码工作量之间的权衡。现在您已经很好地理解了为什么需要做出这些设计决策,您应该能够就您自己的代码库和实现做出明智的决策。当您准备实现设计时,您也将对确保对共享外设无竞争条件访问所需的步骤有坚实的理解。
到目前为止,我们一直在讨论创建驱动程序时的权衡,以便我们编写的东西尽可能接近我们用例的完美。如果(在新的项目开始时)我们不需要每次都通过复制、粘贴和修改所有这些驱动程序来重新发明轮子,那岂不是很好?我们不必不断地引入低级、难以发现的错误,而可以简单地引入我们所知道的一切有效内容,然后开始添加新项目所需的新功能?在良好的系统架构下,这种工作流程是完全可能的!在下一章中,我们将介绍创建灵活且不受复制粘贴修改陷阱困扰的固件架构的几个技巧。
问题
在我们总结之际,这里有一份问题列表,供您测试对本章节内容的理解。您将在附录的评估部分找到答案:
-
总是最好最小化使用的硬件外设数量:
-
正确
-
错误
-
-
当在多个任务之间共享硬件外设时,唯一关心的是创建线程安全的代码,确保一次只有一个任务可以访问外设:
-
正确
-
错误
-
-
在创建流缓冲区时,我们允许做出哪些权衡?
-
延迟
-
CPU 效率
-
所需 RAM 大小
-
以上所有
-
-
流缓冲区可以直接由多个任务使用:
-
正确
-
错误
-
-
在整个多阶段消息期间,可以用来创建线程安全原子访问外设的机制之一是什么?
第十二章:创建良好抽象架构的技巧
在整本书中,我们提供了简单且复杂度最低的示例。我们的重点是保持代码清晰易读,以说明正在处理的特定实时操作系统(RTOS)概念,并尽可能使与硬件的交互易于理解。然而,在现实世界中,最适合长期开发的代码库是那些允许开发者快速灵活且坚定地达成目标的代码库。本章提供了关于如何构建、创建、扩展和维护一个足够灵活的代码库的建议,以便长期使用。我们将通过清理早期章节中开发的一些代码,并增加灵活性和更好的可移植性到不同的硬件,来探索这些概念。
本章对任何对在多个项目中重用代码感兴趣的人都有价值。虽然这里提出的概念绝对不是原创的,但它们仅关注嵌入式系统中的固件。所涵盖的概念适用于裸机系统,以及高度可重用的基于 RTOS 的任务系统。通过遵循这里的指南,您将能够创建一个灵活的代码库,它能够适应许多不同的项目,无论它运行在什么硬件上。以这种方式构建代码库的另一个副作用(或直接意图)是代码的可测试性极高。
在本章中,我们将涵盖以下主题:
-
理解抽象
-
编写可重用代码
-
组织源代码
技术要求
要运行本章中介绍的代码,您需要以下内容:
-
Nucleo F767 开发板
-
微型 USB 线
-
STM32CubeIDE 和源代码(第五章,选择 IDE部分下的设置我们的 IDE说明)
-
SEGGER J-Link、Ozone 和 SystemView(第六章,实时系统调试工具中的说明)
本章的所有源代码可在github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers/tree/master/Chapter_12找到。
理解抽象
如果我们的目标是创建一个可以长期使用的代码库,我们需要灵活性。源代码(就像产品功能集和商业策略一样)并不是从石头上雕刻出来的——它往往会随着时间的推移而改变形态。如果我们的源代码要具有灵活性,它需要能够改变和适应。只有这样,它才能为实施产品(或整个产品线)的不同功能集提供一个坚实的基础,因为推动其发展的商业环境在变化。抽象是灵活性的核心原则。
在我们的上下文中,抽象意味着用一个可以应用于许多不同实例的表示来表示复杂实现的单个实例。例如,让我们再次看看第一章中的另一个早期例子,“实时系统简介”:

该图本身是闭环控制系统所需硬件的抽象表示。图中没有显示 ADC、驱动电路和微控制器单元(MCU)的确切部件编号;它们可以是任何东西。
在创建灵活的代码库时使用抽象至少有两个主要原因:
-
理解抽象很快。
-
抽象提供了灵活性。
理解抽象很快
理解代码中写得好的抽象与理解简单的流程图相似。正如你在观察流程图(而不是原理图)时不需要理解每个互连和电阻值一样,阅读一个抽象的、注释良好的头文件几乎可以提供使用任何底层实现所需的所有信息。没有必要陷入每个实现的细节和特性中。
这种有限的鸟瞰图意味着未来的开发者更有可能消费代码,因为它是以定义良好、文档齐全和一致的方式呈现的。掌握抽象所需的总知识和时间比从头开始实现相同功能所需的时间要少得多。
有抽象的例子
如果你看到了以下函数调用的调用,你很可能有机会猜出这个函数的功能,即使没有任何适当的注释:
bufferX[i] = adcX->ReadAdcValue();
bufferY[i] = adcY->ReadAdcValue();
bufferZ[i] = adcZ->ReadAdcValue();
上述代码相当自解释——我们在读取 ADC 值并将它们存储在 3 个不同的缓冲区中。如果我们所有的获取 ADC 读数的调用都使用相同的ReadAdcValue()调用约定并描述性地命名 ADC 通道,理解代码就快而简单。
没有抽象的例子
反之,想象一下,如果你被给出了以下代码行(它们在功能上与前面的代码等效):
bufferX[i] = adc_avg(0, 1);
bufferY[i] = adc_avg(1, 1);
bufferZ[i] = HAL_ADC_GetValue(adc2_ch0_h);
这立即引发了一些问题,比如传递给adc_avg()和HAL_ADC_GetValue()的参数是什么。至少,我们可能需要找到相关的函数原型并阅读它们:
/**
* return an average of numSamp samples collected
* by the ADC who's channel is defined by chNum
* @param chNum channel number of the ADC
* @param numSamp number of samples to average
* @retval avera
**/
uint32_t adc_avg(uint8_t chNum, uint16_t numSamp);
好吧,所以adc_avg()将 ADC 通道作为第一个参数,将平均样本数作为第二个参数——将1传递给第二个参数提供单个读取。那么,关于对HAL_ADC_GetValue(adc2_ch0_h)的另一个调用呢?我们最好去找它的原型:
/**
* @brief Gets the converted value from
* data register of regular channel.
* @param hadc pointer to a ADC_HandleTypeDef
* structure that contains
* the configuration information for the
* specified ADC.
* @retval Converted value
*/
uint32_t HAL_ADC_GetValue(ADC_HandleTypeDef* hadc)
结果表明adc2_ch0_h是一个句柄——可能是指 ADC2 STM32 外设上的通道0...现在,那个原理图在哪里...所有线路都连接正确吗?通道0真的应该存储在bufferZ中吗?这似乎有点奇怪...
好吧,这可能会有些牵强,但如果你编程时间足够长,你可能已经看到了更糟糕的情况。这里的要点是,一个好的抽象提供的一致性使得阅读代码比试图追踪和理解每个特定实现的细节要快得多和容易得多。
抽象提供灵活性
由于适当的抽象并没有直接与实现绑定,因此为功能创建抽象提供了在实现方式上的灵活性,尽管该功能的接口是一致的。在下面的图中,有五种不同的 ADC 值物理实现——都由相同的简单抽象表示,即int32_t ReadAdcValue(void);:

尽管函数调用保持一致,但 ADC 的实现可能会有很大的不同。仅在这个图中,就有五种不同的方式让 ADC 通过ReadAdcValue函数提供数据。ADC 可能位于本地通信总线(如 I2C、SPI 或 UART)上。它可能是一个存在于 MCU 本身的内部 ADC。或者,ADC 读取可能来自外部网络的远程节点。由于存在一致的抽象接口,底层实现并不那么重要。接口的消费者不需要关心配置 ADC、收集读取等所需的所有细节;ADC 只需要调用ReadAdcValue来访问最新的读取。
当然,这里需要考虑很多因素,例如阅读的时效性、收集速度的快慢、底层阅读的分辨率和缩放等。这些细节需要由每个实现抽象的提供者和消费者提供。自然,由于各种原因,这个层次的抽象在某些情况下可能不合适,这需要根据具体情况逐一评估。例如,如果每次读取新的数据时都需要运行一个算法,那么盲目地异步轮询ReadAdValue是不可靠的。
真实世界中有很多抽象的例子。假设你是一家制造许多不同产品的组织的开发者,这些产品都包含类似的核心组件。例如,如果你正在设计一个过程控制器系列,你很可能会与 ADC、DAC 和通信堆栈进行交互。每个控制器可能都有略微不同的用户界面功能集,但底层核心组件可以共享。ADC、DAC 和算法的驱动程序都可以共享通用代码。通过在多个产品之间共享通用代码,开发者只需编写一次通用代码。随着时间的推移,客户界面功能集发生变化时,可以根据需要替换个别组件,只要它们之间是松散耦合的。即使底层的 MCU 不需要相同,只要其硬件足够抽象,也是如此。
让我们以控制器中的 ADC 为例,更深入地了解一下。控制算法使用 ADC 读数的最简单方法是从设备中获取原始读数并直接使用它们。为了减少源文件的数量,ADC、通信外设和算法的驱动程序可以被合并到一个源文件中。
注意,对于精度应用,即使不考虑代码的优雅性和抽象,直接使用原始读数也存在许多问题。确保一致的缩放和偏移以及提供灵活的分辨率都更容易,当代码不直接与原始单位(ADC 计数)接口时。
当代码空间和 RAM 非常宝贵,或者只需要一个快速且简单的单次使用,或者只需要一个概念验证时,这种方法可能是可接受的。结果架构可能看起来像以下这样:

在查看这个架构时,应该有几个要点跳出来:
-
algorithm.c文件与特定总线上特定 MCU 和特定 ADC 紧密耦合。 -
如果 MCU 或 ADC 中的任何一个发生变化,都需要创建一个新的
algorithm.c版本。 -
MCU 和 ADC IC 链接之间的视觉相似性非常像链条。这不是偶然的。这种代码将
algorithm.c内部的任何算法与底层硬件紧密绑定,这种方式非常不灵活。
也有一些副作用可能不那么明显:
-
algorithm.c将非常难以(可能甚至不可能)独立于硬件运行。这使得在隔离状态下测试算法变得非常困难。这也使得测试所有仅在硬件出现问题时才会出现的边缘情况和错误条件变得非常困难。 -
algorithm.c的直接有用寿命将限制在这个单个 MCU 和特定 ADC 上。要支持额外的 MCU 或 ADC IC,需要使用#define函数;否则,整个文件需要复制并修改。
另一方面,algorithm.c可以编写成不直接依赖于底层硬件。相反,它可以依赖于 ADC 的抽象接口。在这种情况下,我们的架构看起来更像这样:

在这个变体中需要观察的核心要点如下:
-
algorithm.c不直接依赖于任何特定的硬件配置。不同的 ADC 和 MCU 可以互换使用,前提是它们正确实现了所需的接口。这意味着它可以移动到完全不同的平台,并且无需修改即可使用。 -
链条已被绳索取代,这些绳索将抽象与其底层实现绑定在一起,而不是将
algorithm.c紧密绑定到底层硬件。 -
只有实现与硬件紧密绑定。
一些不那么明显但同样值得提到的要点如下:
-
ADC 驱动器并非完全耦合到硬件。虽然这个特定的驱动器可能只支持单个 ADC,但 ADC 硬件本身对于使代码工作并非必需。可以通过模拟 SPI 流量来模拟硬件。这允许独立于底层硬件测试 ADC 驱动器。 -
SPI 驱动器和ADC 驱动器可以在其他应用中使用而无需重新编写。这对于编写可重用代码来说是一个很大的优势;它足够灵活,可以重新利用而无需额外的工作(或副作用)。
现在我们已经覆盖了一些抽象的例子,让我们考虑为什么使用抽象对于项目可能很重要。
抽象为什么重要
如果以下要点适用,确保您的架构正在使用抽象非常重要:
-
常用组件将在其他项目中重用。
-
可移植到不同硬件是可取的。
-
代码将进行单元测试。
-
团队将并行工作
对于是更大代码库一部分的项目,上述四个点通常都是可取的,因为它们都促进了中期上市时间的减少。它们还导致代码库的长期维护成本降低:
-
对于抽象,一次性创建高质量的文档比彻底记录每一块复杂的意大利面代码要容易得多,这些代码以略有不同的方式重新实现了相同的功能。
-
抽象提供了将硬件从项目中使用的许多其他接口中干净地解耦的方法。
-
抽象硬件接口使得单元测试代码更容易(允许程序员在他们的开发机器上而不是在目标硬件上运行单元测试)。
-
单元测试类似于一种始终更新的文档类型(如果它们定期运行)。它们提供了代码意图做什么的真相来源。它们还在进行更改或提供新实现时提供了一个安全网,确保没有忘记或意外更改任何内容。
-
一致的抽象使得代码库更容易被新团队成员快速理解。代码库中的每个项目都比上一个项目稍微熟悉一些,因为它们之间有很大的共同性和一致性。
-
松散耦合的代码更容易修改。理解一个良好封装的模块的心理负担远低于尝试理解跨越项目多个部分的庞大实现。对良好封装的模块的修改更有可能正确进行且没有副作用(尤其是在使用单元测试的情况下)。
当不使用抽象时,以下症状通常会发生:
-
新开发者很难进行修改,因为每次修改都会产生连锁反应。
-
新开发者需要很长时间才能足够理解一段代码,以便能够舒适地对其进行修改。
-
并行开发非常困难。
-
代码紧密耦合到特定的硬件平台。
对于需要抽象的真实世界示例,我们不需要再往远处看,FreeRTOS 本身就是一个例子。FreeRTOS 将所有设备特定的功能封装在两个文件中,port.c和portmacros.h。为了支持新的硬件平台,只需要创建/修改这些文件。FreeRTOS 的其他所有文件都只有一个副本,跨数十个不同硬件平台的端口共享。像 FatFs、lwIP 以及许多其他库也使用了硬件抽象;这是它们能够合理地为大量硬件提供支持的唯一方式。
识别代码复用的机会
在确定是否应该使用形式化的抽象(如果抽象尚未存在)时没有绝对规则可循。然而,有一些提示,如下所示:
-
如果你正在编写可以被多个项目使用的代码:应该通过抽象(前述章节中的 ADC 驱动程序和算法就是此类抽象的例子)与底层硬件进行接口。否则,代码将绑定到为其编写的特定硬件上。
-
如果你的代码与特定供应商的 API 交互:在其上方创建一个轻量级的抽象层将减少供应商锁定。在接口被普遍使用并设置之后,你将开始倾向于使特定供应商的 API 符合你的代码库,这使得尝试不同的实现变得快速且简单。这也有助于保护你的大部分代码免受供应商随时间对 API 所做的更改的影响。
-
如果模块位于堆栈中心并与其他子模块交互:使用形式化的接口将减少与其他模块的耦合,使得将来替换它们变得更容易。
关于代码复用的一个常见误解是,创建代码的副本与复用代码是相同的。如果已经创建了代码的副本,实际上它并没有被复用——让我们看看原因。
避免复制粘贴修改陷阱
因此,我们有一段经过验证的代码表现良好,而且我们有一个新的项目即将启动。我们应该如何创建这个新项目——只是复制工作项目并开始修改?毕竟,如果代码被复制,它就被重用了,对吧?随着时间的推移,创建这样的代码库副本可能会无意中积累大量的技术债务。问题不在于复制和修改代码的行为,而在于试图维护所有副本。
下面是查看algorithm.c在六个项目中的单体架构可能的样子。假设实际算法在所有六个项目中都是相同的:

下面是图中的一些主要点:
-
由于有六个文件副本,所以无法判断实际使用的算法是否相同。
-
在某些情况下,
algorithm.c是用不同的硬件实现的。由于这些更改是在algorithm.c中进行的,所以很难不详细检查每个文件就判断所实现的算法是否真正相同。
现在,让我们来看看我们示例中复制粘贴修改的缺点:
-
如果
Algo存在错误,它需要在六个不同的地方进行修复。 -
测试
Algo的潜在修复需要为每个项目单独验证。唯一能够判断修复是否纠正了错误的方法可能是在实际硬件“系统内”进行测试;这可能是一个非常耗时的工作,并且可能技术上难以覆盖所有边缘情况。 -
分叉的
Algo函数可能会随着时间的推移而发生变化(可能是无意中);这将进一步使维护复杂化,因为检查实现之间的差异将变得更加困难。 -
由于六个项目之间存在细微的差异,错误更难找到、理解和修复。
-
创建项目 7 可能会伴随着很高的不确定性(很难确切地说
Algo将引入哪些特性,SPI或ADC驱动程序将带来哪些复杂性/错误,等等)。 -
如果
MCU1变得过时,algorithm.c的移植需要发生四次。
所有这些重复都可以通过为通用组件创建一致的、可重用的抽象来避免:
-
每个通用组件都需要有一个一致的接口。
-
任何打算重用的代码都应该使用接口而不是实现(
Algo将使用 ADC 接口)。 -
通用驱动程序、接口和中间件应该只有一个副本。
-
通过使用板级支持包(BSPs)提供实现,这些包为所需的接口提供实现。
如果使用前面的指南设计相同的算法,我们可能会有更类似于以下的样子:

下面是图中的一些主要点:
-
algorithm.c只有一个副本——很明显,在所有六个项目中使用的算法是相同的。 -
尽管有六个项目,但只有四个 BSP 文件夹——
BSP1在三个项目中已被重用。 -
ADC接口在公共位置(接口)中指定。 -
BSP 定义了 ADC 的实现,它与特定硬件相关联。这些实现被
main.c使用,并传递给algorithm.c。 -
Algo引用的是ADC接口,而不是特定的实现。 -
MCU1和MCU2的I2C和SPI驱动程序只有一个副本。 -
基于 SPI 的 ADC 的驱动程序只有一个副本。
-
基于 I2C 的 ADC 的驱动程序只有一个副本。
重用代码有以下优点:
-
如果
Algo存在错误,它只需要在一个地方修复。 -
尽管最终集成测试
Algo仍然需要在系统内使用真实硬件(但可能只需要在四个 BSP 上进行,而不是所有六个项目),但大部分的测试和开发可以通过模拟 ADC 接口来完成,这既快又简单。 -
由于只有一个副本,
Algo不可能随着时间的推移而发生变化。是否在项目之间使用了不同的算法总是很容易看出。 -
由于依赖项之间的相互依赖性降低,错误更容易被发现、理解和修复。
Algo中的错误保证会在所有六个项目中出现(因为只有一个副本)。然而,由于在开发过程中测试Algo更容易,所以它出现的可能性较小,这得益于接口。 -
由于其他六个项目的一致性,创建项目 7 可能会非常快速和高效,具有很高的确定性。
-
如果
MCU1过时,由于它没有直接依赖于 MCU——只有ADC接口,因此甚至不需要移植algorithm.c。相反,需要选择/开发不同的 BSP。
对于需要编写以支持类似但不同的硬件的极低级别代码,复制粘贴修改是一个例外。这通常是直接与 MCU 外围硬件寄存器接口的驱动级代码。当两个 MCU 系列共享相同的外围设备,只有细微差别时,尝试开发通用代码以实现它们可能会很有吸引力,但这通常会让每个人(包括原始作者和维护开发者)感到更加困惑。
在这些情况下,强制现有代码支持不同的硬件可能会非常耗时且容易出错,尤其是随着代码的陈旧和更多硬件平台的增加。最终,如果代码库足够老,新的硬件目标将会有显著的变化,以至于将那些更改纳入现有的底层代码将不再可行。只要底层驱动程序符合相同的接口,它们在长期内仍然具有相当大的价值。保持这种底层代码易于理解和无错误是最高的优先级,其次是符合一致的接口。
既然我们已经对抽象有了很好的理解,让我们更深入地看看一些现实世界的例子,了解如何编写易于重用的代码。
编写可重用代码
当你刚开始创建抽象时,可能很难确切知道应该抽象什么,以及应该直接使用什么。为了使代码完全可重用,模块应只执行一个功能并引用其他功能部分的接口。任何特定于硬件的调用都必须通过接口进行,而不是直接处理硬件。这适用于访问实际硬件(如特定引脚)以及 MCU 特定的 API(如 STM32 HAL)。
编写可重用驱动程序
在嵌入式开发中,存在几种不同级别的驱动程序,它们相当常见。MCU 外设驱动程序是用于为 MCU 上包含的硬件提供便利 API 的驱动程序。这些类型的驱动程序在第十章,驱动程序和中断服务例程中进行了开发。另一种常用的驱动程序是针对特定 IC 的驱动程序,这在先前的 ADC 示例中有所提及:

外设驱动程序位于硬件之上。IC 驱动程序位于堆栈中的外设驱动程序之上(并且通常使用它们)。如果一个 IC 驱动程序旨在跨多个 MCU 工作,它必须使用对底层 MCU 硬件完全无知的接口。例如,STM32 HAL 可以被视为一种外设驱动程序,但它不提供针对外设的 MCU 独立抽象。为了创建跨 MCU 可移植的 IC 驱动程序,它们必须只访问 MCU 独立接口。
开发 LED 接口
为了详细说明初始概念,让我们看看一个简单的驱动程序,自从本书中引入的第一个示例以来我们就一直在使用它——一个 LED 驱动程序。从早期章节中的第一个示例开始,我们就使用了简化版的驱动 Nucleo 板上 LED 的接口。这个接口位于 BSP\Nucleo_F767ZI_GPIO.c/h.。这段代码通过名为 LED 的结构体完全抽象了 LED 与底层硬件的关系。LED 结构体有两个函数指针:On 和 Off。正如预期的那样,这两个函数的目的是打开和关闭 LED。这种做法的美丽之处在于,调用代码根本不需要关心 LED 的实现。每个 LED 可能都有完全不同的硬件接口。它可能需要正逻辑或负逻辑来驱动外部晶体管,或者位于某种类型的串行总线上。LED 甚至可能位于一个远程面板上,需要通过 远程过程调用 (RPCs) 到另一个完全不同的板。然而,无论 LED 是如何打开和关闭的,接口都是相同的。
为了尽量保持简单,Nucleo_F767ZI_GPIO.c/h 在头文件中定义了 LED 结构体。随着我们通过这个当前示例的进展,我们将从头文件中提取接口定义,使其完全独立,不需要任何外部依赖。没有依赖将保证我们可以将新的接口定义移动到完全不同的平台,而无需任何特定于特定 MCU 的代码。
我们新的、独立的 LED 接口将被称为 iLED。
小写 "i" 是一些 C++ 程序员用来表示只包含虚拟函数的类的一种约定,这实际上是一个接口定义。由于我们在这本书中只处理 C(不是 C++),我们将坚持使用结构体和函数指针来提供必要的解耦。这里概述的方法在概念上类似于 C++ 中的纯虚类。
接口定义在新的 Interfaces***/***iLed.h 文件中;内容的核心如下:
typedef void (*iLedFunc)(void);
typedef struct
{
//On turns on the LED - regardless of the driver logic
const iLedFunc On;
//Off turns off the LED, regardless of the driver logic
const iLedFunc Off;
}iLed;
让我们具体分析一下前面的定义中正在发生的事情:
-
我们创建了一个新的类型:
iLedFunc。现在,typedef void (*iLedFunc)(void);将iLedFunc类型定义为指向一个不接受任何参数且不返回任何内容的函数的函数指针。 -
iLed结构体被定义为任何其他结构体——我们现在可以创建这个结构体的实例。我们定义一个结构体是为了方便将所有的函数指针捆绑在一起,并传递对周围结构体的引用。 -
每个
iLedFunc成员都被定义为const,因此它只能在定义时设置一次。这保护了我们(或其他开发者)免受意外覆盖函数指针值(这可能是灾难性的)的风险。编译器将捕获对On或Off函数指针的任何写入尝试,并抛出错误。
确保定义接口的头文件尽可能少地包含依赖项非常重要,以保持其尽可能松散耦合。这个文件包含的依赖项越多,未来的灵活性就越小。
接口定义到此结束。前面的代码没有提供任何功能;它只定义了一个接口。为了创建 iLed 接口的实现,我们还需要两个额外的文件。
以下是从 ledImplementation.h 的摘录:
#include <iLed.h>
extern iLed BlueLed;
extern iLed GreenLed;
extern iLed RedLed;
此头文件引入了 iLed.h 接口定义并声明了三个 iLed 实例,分别是 BlueLed、GreenLed 和 RedLed。这些 iLed 接口的实现可以被任何包含 ledImplementation.h 的代码片段使用。extern 关键字确保无论多少不同的代码模块使用 ledImplementation.h,都只会创建一个副本。
接下来,我们需要为 iLed 实例提供定义;这是在 ledImplementation.c 中完成的。
这里只展示了 GreenLed 的代码。BlueLed 和 RedLed 的实现仅在它们设置的 GPIO 引脚上有所不同:
void GreenOn ( void ) {HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET);}
void GreenOff ( void ) {HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0,
GPIO_PIN_RESET);}
iLed GreenLed = { GreenOn, GreenOff };
分解来看,我们可以观察到以下内容:
-
GreenOn定义了一个内联函数,用于在 Nucleo 开发板上打开绿色 LED。它不接受任何参数也不返回任何内容,因此它可以作为iLedFunc使用,如前述代码中定义的那样。 -
GreenOff定义了一个内联函数,用于在 Nucleo 开发板上关闭绿色 LED。它也可以用作iLedFunc。 -
创建了一个名为
GreenLed的iLed实例。在初始化期间传递了iLedFunc函数指针GreenOn和GreenOff。iLed中定义的函数顺序至关重要。由于On在iLed结构体中首先定义,因此传递的第一个函数指针(GreenOn)将被分配给On。
到目前为止,唯一依赖于特定硬件的代码是 ledImplementation.c。
现在可以将指向 GreenLed 的指针传递给不同的代码片段,这些代码片段仅引入 iLed.h——它们不会以任何方式绑定到 HAL_GPIO_WritePin。一个例子是 hardwareAgnosticLedDriver.c/h。
以下是从 hardwareAgnosticLedDriver.h 的摘录:
#include <iLed.h>
void doLedStuff( iLed* LedPtr );
此硬件无关驱动程序所需的唯一 include 函数是 iLed.h。
为了使 hardwareAgnosticLedDriver.h 真正实现硬件无关,它不能包含任何特定硬件的文件。它必须仅通过硬件无关的接口访问硬件,例如 iLed。
以下是一个简单的示例,它只是简单地打开或关闭单个 LED。摘录来自 hardwareAgnosticLedDriver.c:
void doLedStuff( iLed* LedPtr )
{
if( LedPtr != NULL )
{
if(LedPtr->On != NULL)
{
LedPtr->On();
}
if( LedPtr->Off != NULL )
{
LedPtr->Off();
}
}
}
分解来看,我们可以观察到以下内容:
-
doLedStuff接收一个指向iLed类型变量的指针作为参数。这允许将iLed接口的任何实现传递给doLedStuff,从而在实现On和Off函数时提供完全的灵活性,而不会将hardwareAgnosticLedDriver绑定到任何特定硬件。 -
如果你的接口定义支持通过设置指针为
NULL来省略功能,那么它们需要被检查以确保它们没有被设置为NULL。根据设计,这些检查可能不是必需的,因为On和Off的值只能在初始化期间设置。 -
通过使用
LedPtr指针并像调用任何其他函数一样调用它们来调用On和Off的实际实现。
使用doLedStuff的完整示例可以在mainLedAbstraction.c中找到:
#include <ledImplementation.h>
#include <hardwareAgnosticLedDriver.h>
HWInit();
while(1)
{
doLedStuff(&GreenLed);
doLedStuff(&RedLed);
doLedStuff(&BlueLed);
}
分解来看,我们可以观察到以下内容:
-
GreenLed、RedLed和BlueLed的实现是通过包含ledImplementation.h引入的。 -
doLedStuff是通过包含hardwareAgnosticLedDriver.h引入的。 -
我们通过传递指向所需
iLed实例的指针来提供doLedStuff的实现。在这个例子中,我们通过将GreenLed、RedLed和BlueLed实现传递给doLedStuff来切换开发板上的每个绿色、红色和蓝色 LED。
这个示例只是简单地切换单个 LED,但复杂性是任意的。通过拥有定义良好的接口,可以创建接受接口实例指针的任务。这些任务可以在多个项目中重用,而无需修改它们的所有内容——当需要支持新的硬件时,只需创建接口的新实现即可。当硬件无关的任务实现了相当数量的代码时,这可以显著减少项目所花费的总时间。
让我们看看一个简单示例,将接口的实例传递到任务中。
重用包含任务的代码
RTOS 任务非常适合重用,因为(当编写得很好时)它们提供单一功能的功能,可以很容易地与其他系统必须执行的功能进行优先级排序。为了使它们能够长期轻松重用,它们需要尽可能少地直接与底层平台相关联。使用前面描述的接口对于这个目的非常有效,因为接口完全封装了所需的功能,同时将其与底层实现解耦。为了进一步简化 FreeRTOS 任务的设置,任务的创建可以封装在初始化函数中。
mainLedTask.c使用ledTask.c/h来展示这个示例。以下摘录来自ledTask.h:
#include <iLed.h>
#include <FreeRTOS.h>
#include <task.h>
TaskHandle_t LedTaskInit( iLed* LedPtr, uint8_t Priority, uint16_t
StackSize);
关于这个简单头文件的一些重要说明如下:
-
只包含必要的文件,即用于 FreeRTOS 和
iLed.h的文件,这些文件都不直接依赖于任何特定的硬件实现。 -
任务优先级作为初始化函数的参数引入。这对于灵活性很重要,因为随着时间的推移,LED 任务可能需要相对于系统其他功能的不同的优先级。
-
StackSize也是参数化的——这是必需的,因为根据LedPtr的底层实现,生成的任务可能需要使用不同数量的堆栈空间。 -
LedTaskInit返回TaskHandle_t,调用代码可以使用它来控制或删除生成的任务。
ledTask.c包含LedTaskInit的定义:
TaskHandle_t LedTaskInit(iLed* LedPtr, uint8_t Priority, uint16_t StackSize)
{
TaskHandle_t ledTaskHandle = NULL;
if(LedPtr == NULL){while(1);}
if(xTaskCreate(ledTask, "ledTask", StackSize, LedPtr, Priority,
&ledTaskHandle) != pdPASS){while(1);}
return ledTaskHandle;
}
此初始化函数执行与我们在main中通常看到的功能相同,但现在,它被整洁地封装到一个单独的文件中,可以在多个项目中使用。由LedTaskInit处理的函数包括以下内容:
-
检查
LedPtr是否不是NULL。 -
创建一个运行
ledTask函数的任务,并将LedPtr传递给它,这为该ledTask实例提供了iLed接口的特定实现。ledTask使用指定的Priority任务和StackSize创建。 -
在
LedTaskInit返回创建的任务句柄之前,验证它是否已成功创建。
ledTask.c也包含ledTask的实际代码:
void ledTask( void* LedPtr)
{
iLed* led = (iLed*) LedPtr;
while(1)
{
led->On();
vTaskDelay(100);
led->Off();
vTaskDelay(100);
}
}
首先,LedPtr需要从void*转换为iLed*。在此转换之后,我们能够调用我们的iLed接口的函数。底层硬件调用将取决于LedPtr的实现。这也是允许在初始化期间有StackSize变量的原因——在某些情况下,LedPtr可能有更复杂的实现,这可能需要更大的堆栈。
多亏了LedTaskInit,将LedPtr实现映射到任务中的任务创建变得极其简单。
以下是从mainLedTask.c摘录的内容:
int main(void)
{
HWInit();
SEGGER_SYSVIEW_Conf();
//ensure proper priority grouping for freeRTOS
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4);
LedTaskInit(&GreenLed, tskIDLE_PRIORITY+1, 128);
LedTaskInit(&BlueLed, tskIDLE_PRIORITY+2, 128);
LedTaskInit(&RedLed, tskIDLE_PRIORITY+3, 128);
vTaskStartScheduler();
GreenLed、BlueLed和RedLed被传递到LedTaskInit以创建三个具有不同优先级和可能不同堆栈大小的独立任务。所有特定于硬件的代码都已被保留在ledTask.c/h之外。当使用此技术处理复杂任务时,可以实现显著的时间节省和信心增加。沿着增加我们编写的代码的信心这一方向,让我们快速看一下提供抽象接口是如何使测试任务变得更容易的。
测试灵活的代码
由于iLed接口不直接依赖于任何硬件,因此将其替代实现推送到ledTask非常容易。我们不必传递iLed的实际硬件实现之一,而可以传递任何我们喜欢的内容到LedTaskInit(用于集成级测试)或ledTask(用于单元测试)。在这些情况下,实现可能会在调用时设置测试环境中的变量。例如,On在调用时可以将布尔值设置为TRUE,而Off可以将相同的布尔值设置为FALSE。这些类型的测试可以用来验证任务逻辑,而无需任何硬件,前提是在开发机上设置了编译器和替代环境。FreeRTOS 端口存在于桌面操作系统上,允许测试任务的相对优先级(没有实时保证)。这种方式无法测试特定的时序依赖,但它确实允许开发者对代码的中间层有相当大的信心。
请参阅进一步阅读部分,了解更详细地介绍单元测试的文章。
现在我们已经了解了如何编写可重用的代码,我们需要确保它以允许在多个项目中使用的方式存储,而不会创建不必要的副本或产生奇怪的跨项目依赖。
组织源代码
如果代码库打算随着时间的推移而演变和增长,一个良好的源代码树就极其重要。如果项目打算作为原子实体独立存在,彼此之间从不交互,那么在源代码控制方面制定策略就几乎没有理由;但如果代码重用是一个目标,那么明确了解特定项目应该如何与通用代码结合在一起是必须的。
选择源文件的位置
任何可能被用于最初创建它的项目之外的代码都应该位于一个通用位置(不与特定项目绑定)。即使代码最初是针对特定项目编写的,一旦被多个项目使用,就应该尽快将其移动。通用代码的片段对每个团队来说都可能不同,但可能包括以下内容:
-
板级支持包(BSPs):通常为每个板创建多个固件。本书代码库中的 BSP 文件夹没有子文件夹(主要是因为在那里,代码只支持单个平台)。如果本书支持多个平台,BSP 文件夹可能会包含一个
Nucleo_F767子文件夹。 -
内部通用代码:这可以包括针对特定领域定制的算法或用于多个产品或项目间常用的集成电路(IC)的驱动程序。这里的任何代码都应该能够很好地抽象化,并在多个微控制器(MCU)上使用。
-
第三方通用代码:如果多个项目包含第三方源代码,它们应该放在一个中心位置。例如 FreeRTOS 和其他中间件可以保存在这个中心位置。
-
MCU 特定代码:每个 MCU 系列理想情况下应该有自己的文件夹。这可能会包括 STM32 HAL 以及为该 MCU 开发的任何自定义外设驱动程序。理想情况下,这些 MCU 特定目录中引用的大多数代码将通过通用接口(如本章开头 ADC 示例中所示)来完成。
-
接口定义:如果广泛使用接口,将它们全部放在一个地方非常方便。
-
项目文件夹:每个项目可能都有自己的文件夹(有时包含子项目)。理想情况下,项目不会引用其他项目的代码——只有保存在公共区域的代码。如果项目开始有相互依赖性,退一步评估原因,并判断是否将那些依赖移动到公共位置是有意义的。
具体的文件夹结构可能会依赖于你的团队版本控制系统和分支策略。
应对变化
拥有众多项目中通用的代码的一个最大的缺点是变化的含义。目录结构的变化可能是最具挑战性的,特别是如果有很多项目。尽管痛苦,但随着团队需求和策略的变化,这种重构通常在一段时间后是必要的。进行定期检查和标记你的仓库应该就足够了,以确保目录重构变化虽然费时,但并不特别危险。
如果你来自高级语言,并听到“接口”这个词,你可能会立即想到第一次使用时就固定下来的东西。尽管保持接口一致性通常是好的,但有一些灵活性来更改它们(尤其是在刚开始时)。在这个特定用例中,内部接口比公共 API 更具宽容性,原因有几个:
-
几乎所有基于低级 MCU 的应用都将对给定的 接口 进行编译时检查。没有动态加载的库会在接口随时间变化时神秘地停止正常工作——(大多数)错误将在编译时被发现。
-
这些接口通常是内部的,具有对其使用位置的完全可见性,这使得评估潜在变化的影响成为可能。
对单个文件(如共享算法)的更改也是常见的关注点。在这里最好的建议是评估你所做的更改是否仍然提供相同的功能,或者它应该是一个扩展或全新的功能。有时,在孤立的项目中工作不会强迫我们明确地做出这些决定,但一旦那块代码被多个项目共享,风险就更高了。
摘要
在阅读本章后,你应该对代码重用为什么重要以及如何实现它有一个很好的理解。我们探讨了在嵌入式环境中使用抽象的细节,并创建了完全硬件无关的接口,这些接口增加了代码的灵活性。我们还学习了如何结合任务使用这些接口来增加项目间的代码重用。最后,我们简要讨论了存储共享源代码的一些方面。
到目前为止,你应该有足够的知识来开始思考如何将这些原则应用到自己的代码库和项目中。随着你的代码库开始拥有更多在项目间重用的公共代码,你将开始享受到共享代码库的好处,如更少的错误、更易于维护的代码和缩短的开发时间。记住,要擅长创建具有抽象接口的可重用代码需要实践。并不是所有的实现都需要同时迁移到完全可重用的组件,但开始这段旅程是很重要的。
现在我们对抽象有了背景知识,在下一章中,我们将通过更深入地研究队列如何用于提供松散耦合的架构来继续构建灵活的架构。
问题
在我们总结时,这里有一份问题列表,供你用来测试你对本章材料的理解。你将在附录的评估部分找到答案:
-
创建抽象只是一种可以使用完整桌面操作系统来完成的事情:
-
True
-
False
-
-
只有面向对象的代码,如 C++,才能从定义良好的接口中受益:
-
True
-
False
-
-
提供了四个关于抽象重要性的例子。请说出其中一个。
-
将代码复制到新项目中是重用代码的最佳方式:
-
True
-
False
-
-
任务非常具体;它们不能在项目之间重用:
-
True
-
False
-
进一步阅读
-
要深入了解多层驱动程序,请参阅 TinyOS TEP101,它使用分层方法来处理驱动程序。本章中描述的接口方法与 TinyOS HPL、HAL 和 HIL 方法非常契合:[
github.com/tinyos/tinyos-main/blob/master/doc/txt/tep101.txt -
这里有一些额外的资源,应该会对你有所帮助:
第十三章:使用队列创建松散耦合
现在我们已经讨论了如何为灵活性而架构源代码的方法,我们将更进一步,探讨如何使用队列来提供自然的数据交换接口定义。
在本章中,我们将开发一个简单的命令队列,可以从多个物理接口访问。到本章结束时,你将深刻理解为什么使用通用的队列定义是可取的,以及如何实现一个极其灵活的命令队列的两端。这将帮助你创建与底层硬件或物理接口无关的灵活架构。
在本章中,我们将涵盖以下主题:
-
理解队列作为接口
-
创建命令队列
-
为新目标重用队列定义
技术要求
为了完成本章中包含的动手实验,你需要以下设备:
-
Nucleo F767 开发板
-
Micro-USB 线
-
STM32CubeIDE 和源代码(有关说明,请访问第五章中的设置我们的 IDE部分,选择 IDE*)
-
SEGGER J-Link、Ozone 和 SystemView(有关说明,请访问第六章,实时系统调试工具)
-
Python >= 3.8
本章的所有源代码均可在github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers/tree/master/Chapter_13找到。
理解队列作为接口
如果你刚刚完成了上一章的阅读,你可能会注意到有许多技术可以用来一次性创建高质量的代码,然后在整个多个项目中重用相同的代码。就像使用抽象层是一种技术,可以增加代码在多个硬件平台间重用的可能性一样,使用队列作为接口也能增加代码被用于多个项目的可能性。
本章中提出的概念不仅限于队列——它们也适用于流和消息缓冲区。然而,由于队列自 FreeRTOS 开始以来就存在(并且是最常见的原始数据结构),我们将在示例中使用它们。让我们看看为什么使用队列是一个好主意。
队列是出色的接口定义
队列提供了一个非常清晰的抽象层次。为了通过队列传递数据并在另一侧获得期望的行为,所有数据都必须存在,并且发送方和接收方必须对数据格式有一个共同的理解。这条清晰的界限迫使人们进行深思熟虑,以确定需要传达的确切内容。有时,在实现单个功能时,这种积极思考的水平并不存在。队列提供的界限迫使人们进一步思考所需的确切信息及其格式。负责任的开发者更有可能确保这些类型的明确接口得到彻底的文档记录。
当将队列视为子系统的接口时,记录将要提供的功能以及使用子系统所需的精确格式是有益的。通常,接口定义得越好,重用的可能性就越大,因为它们更容易被理解。
队列增加了灵活性
队列以其简洁性而美丽——发送方将某物放入队列,而监控队列的任何任务都将接收数据并对其采取行动。发送方和接收方任务需要共享的唯一东西是用于与队列交互的代码以及通过队列流动的数据的定义。由于共享资源的列表如此之短,当使用队列时,自然会起到解耦的作用。
由于队列提供的清晰分离,功能的确切实现可能会随时间而改变。只要队列接口不变,同样的功能可以用许多不同的方式实现,这不会立即影响发送方。
这也意味着数据可以从任何地方发送到队列中;对物理接口没有明确的要求——只需要数据格式。这意味着队列可以被设计成从许多不同的接口接收相同的数据流,这可以提供系统级的灵活性。功能不需要绑定到特定的物理接口(如以太网、USB、UART、SPI、CAN 等)。
队列使测试变得更容易
与硬件抽象提供易于插入测试数据的位置的方式类似,队列也提供了极佳的测试数据输入点。这为开发中的代码提供了非常方便的测试数据输入点。前一部分提到的实现灵活性也适用于此处。如果一段代码正在向队列发送数据并期望从另一个队列获得响应,实际实现不一定需要使用——可以通过响应命令来模拟。这种方法使得在没有完全实现的功能(例如硬件或子系统仍在开发中)的情况下,开发代码的另一部分成为可能。这种方法在运行单元级测试时也非常有用;被测试的代码可以很容易地从系统其余部分隔离出来。
现在我们已经讨论了一些使用队列作为接口的原因,让我们通过一个示例来看看它是如何实现的。
创建命令队列
为了了解队列如何用于保持架构松散耦合,我们将查看一个通过 USB 接收命令并点亮 LED 的应用程序。虽然示例应用程序本身非常简单,但这里提出的概念具有极高的可扩展性。因此,无论命令只有几个还是几百个,都可以使用相同的方法来保持架构的灵活性。
此应用程序还展示了如何保持高级代码与底层硬件松散耦合的另一个示例。它确保 LED 命令代码仅使用定义的接口来访问脉冲宽度调制(PWM)实现,而不是直接与 MCU 寄存器/HAL 交互。该架构由以下主要组件组成:
-
USB 驱动程序:这是之前示例中已经使用过的相同的 USB 堆栈。
VirtualCommDriverMultiTask.c/h已扩展以提供额外的流缓冲区,以有效地从 PC 接收数据(Drivers/HandsOnRTOS/VirtualCommDriverMultiTask.c/h)。 -
iPWM:为了描述非常简单的 PWM 功能(在
Chapter_13/Inc/iPWM.h中定义),已创建了一个额外的接口定义(iPWM)。 -
PWM 实现:Nucleo 硬件的三个
iPWM接口实现可以在Chapter13/Src/pwmImplementation.c和Chapter13/Src/pwmImplementation.h中找到。 -
LED 命令执行器:使用指向
iPWM实现指针的状态机来驱动 LED 状态(Chapter_13/Src/ledCmdExecutor.c)。 -
main:将所有队列、驱动程序和接口连接在一起并启动 FreeRTOS 调度器的main函数(Chapter_13/Src/mainColorSelector.c)。
我们将详细讨论所有这些部分是如何组合在一起的以及它们的职责细节;但首先,让我们讨论将要放置在命令队列中的内容。
决定队列内容
当使用队列作为将命令传递到系统不同部分的方式时,重要的是要考虑队列实际上应该包含什么,而不仅仅是物理意义上可能通过线缆传递的内容。尽管队列可能用于持有具有头部和尾部信息的数据流的有效负载,但队列的实际内容通常只会包含解析后的有效负载,而不是整个消息。
使用这种方法可以在未来提供更大的灵活性,以便将队列重新定向到其他物理层工作。
由于 LedCmdExecution() 主要将操作 iPWM 指针以与 LED 接口,因此队列持有可以直接由 iPWM 使用的数据类型是方便的。
来自 Chapter13/Inc/iPWM.h 的 iPWM 定义如下:
typedef void (*iPwmDutyCycleFunc)( float DutyCycle );
typedef struct
{
const iPwmDutyCycleFunc SetDutyCycle;
}iPWM;
这个结构体(目前)只包含一个单个函数指针:iPwmDutyCycleFunc。iPwmDutyCycleFunc 被定义为常量指针——在 iPWM 结构体初始化之后,指针不能被更改。这有助于保证指针不会被覆盖,因此不需要不断检查以确保它不是 NULL。
将函数指针包装在如 iPWM 这样的结构体中,可以在最小化重构的同时增加额外的功能。我们将能够传递一个指向 iPWM 结构体的单个指针到函数中,而不是单独的函数指针。
如果你正在创建一个将与其他开发者共享的 接口 定义,那么在团队中协调和沟通更改非常重要!
DutyCycle 参数定义为 float,这使得在与具有不同底层分辨率的硬件接口时保持接口一致性变得容易。在我们的实现中,MCU 的定时器 (TIM) 外设将被配置为具有 16 位分辨率,但实际与 iPWM 接口的代码不需要关心可用的分辨率;它只需将所需的输出从 0.00(关闭)映射到 100.00(开启)即可。
对于大多数应用来说,int32_t 比浮点数更受欢迎,因为它有统一的表示形式,并且更容易序列化。这里使用浮点数是为了更容易看到数据模型与通信之间的差异。此外,大多数人倾向于将 PWM 视为一个百分比,这自然地映射到 float。
在决定 LedCmd 包含哪些数据时有两个主要考虑因素:
-
ledCmdExecutor将直接处理iPWM,因此将浮点数存储在LedCmd中是有意义的。 -
我们还希望我们的 LED 控制器具有不同的操作模式,因此它还需要一种传递这些信息的方式。这里只有少数几个命令,所以一个
uint8_t8 位无符号整数是一个很好的选择。每个cmdNum情况将由enum表示(稍后展示)。
这导致了 LedCmd 的以下结构:
typedef struct
{
uint8_t cmdNum;
float red;
float green;
float blue;
}LedCmd;
LED 命令执行器的主要接口将是一个LedCmd队列。状态变化将通过在队列中写入新值来执行。
由于这个结构只有 13 个字节,我们将直接按值传递。通过引用(结构指针)传递会更快,但它也复杂化了数据的所有权。这些权衡在第九章,任务间通信中进行了讨论。
现在我们已经定义了数据模型,我们可以看看这个应用程序的其余组件。
定义架构
命令执行器架构由三个主要块组成;每个块异步于其他块执行并通过队列和流缓冲区进行通信:
-
LED 命令执行器:
ledCmdExecutor.c中的LedCmdExecution从ledCmdQueue接收数据,并通过iPWM(每个颜色一个)指针激活 LED。LedCmdExecution是一个 FreeRTOS 任务,在创建时以CmdExecArgs作为参数。 -
帧协议解码:
mainColorSelector.c从由 USB 虚拟通信驱动程序填充的流缓冲区接收原始数据,确保有效的帧结构,并填充LedCmd队列。 -
USB 虚拟通信驱动程序:USB 堆栈分布在许多文件中;主要用户入口点是
VirtualCommDriverMultiTask.c。
下面是所有这些主要组件如何堆叠和协同工作的视觉表示。主要块列在左侧,而它们操作的数据表示在右侧:

让我们更详细地看看这些组件。
ledCmdExecutor
ledCmdExecutor.c实现了一个简单的状态机,其状态在接收到队列中的命令时被修改。
可用的命令通过LED_CMD_NUM显式枚举。每个命令都已被赋予一个便于理解枚举,并附有明确的定义。枚举被显式定义,以便在 PC 端正确枚举。我们还需要确保分配的数字 <= 255,因为我们只会在帧中为命令号分配 1 个字节:
typedef enum
{
CMD_ALL_OFF = 0,
CMD_ALL_ON = 1,
CMD_SET_INTENSITY = 2,
CMD_BLINK = 3
}LED_CMD_NUM;
唯一的公共函数是LedCmdExecution,它将被用作 FreeRTOS 任务:void LedCmdExecution(void* Args)。
void* Args实际上有一个类型为CmdExecArgs。然而,FreeRTOS 任务函数签名需要一个void*类型的单个参数。传递给LedCmdExecution的实际数据类型是这个结构的指针:
typedef struct
{
QueueHandle_t ledCmdQueue;
iPWM * redPWM;
iPWM * bluePWM;
iPWM * greenPWM;
}CmdExecArgs;
传递所有引用允许创建和同时运行多个任务实例。它还提供了对底层iPWM实现的极度松耦合。
LedCmdExecution有几个局部变量来跟踪状态:
LED_CMD_NUM currCmdNum = CMD_ALL_OFF;
bool ledsOn = false;
LedCmd nextLedCmd;
param_assert(Args == NULL);
CmdExecArgs args = *(CmdExecArgs*)Args;
让我们更详细地看看这些变量:
-
currCmdNum:当前正在执行命令的本地存储。 -
ledsOn:由blink命令使用的本地存储,用于跟踪状态。 -
nextLedCmd:存储从队列中来的下一个命令。 -
args: 一个局部变量,包含通过我们的任务void* Args参数传入的参数(注意显式的类型转换和检查以确保没有传入NULL)。
为了确保没有指针发生变化,我们正在创建一个局部副本。这也可以通过定义CmdExecArgs结构体来包含只能初始化时设置的const变量,以节省一些空间来实现。
此主循环有两个职责。第一个职责,如以下代码所示,是将ledCmdQueue中的值复制到nextLedCmd读取中,设置适当的局部变量和 LED 的占空比。
ledCmdExecutor.c是主循环的一部分:
if(xQueueReceive(args.ledCmdQueue, &nextLedCmd, 250) == pdTRUE)
{
switch(nextLedCmd.cmdNum)
{
case CMD_SET_INTENSITY:
currCmdNum = CMD_SET_INTENSITY;
setDutyCycles( &args, nextLedCmd.red,
nextLedCmd.green, nextLedCmd.blue);
break;
case CMD_BLINK:
currCmdNum = CMD_BLINK;
blinkingLedsOn = true;
setDutyCycles(&args, nextLedCmd.red,
nextLedCmd.green, nextLedCmd.blue);
break;
//additional cases not shown
}
}
主循环的第二部分,如以下代码所示,在 250 个 tick(250 ms,因为我们的配置使用 1 kHz 的 tick)内没有从ledCmdQueue接收到命令时执行。此代码在最后命令的占空比和OFF之间切换 LED:
ledCmdExecutor.c是主循环的第二部分:
else if (currCmdNum == CMD_BLINK)
{
//if there is no new command and we should be blinking
if(blinkingLedsOn)
{
blinkingLedsOn = false;
setDutyCycles(&args, 0, 0, 0);
}
else
{
blinkingLedsOn = true;
setDutyCycles( &args, nextLedCmd.red,
nextLedCmd.green, nextLedCmd.blue);
}
}
最后,setDutyCycles辅助函数使用iPWM指针来激活 LED 的 PWM 占空比。在主循环之前已经验证了iPWM指针不是NULL,因此不需要在这里重复检查:
void setDutyCycles( const CmdExecArgs* Args, float RedDuty, float GreenDuty, float BlueDuty)
{
Args->redPWM->SetDutyCycle(RedDuty);
Args->greenPWM->SetDutyCycle(GreenDuty);
Args->bluePWM->SetDutyCycle(BlueDuty);
}
这就完成了我们 LED 命令执行器的高级功能。创建此类任务的主要目的是为了说明创建一个极其松散耦合且可扩展的系统的方法。虽然以这种方式切换几个 LED 很愚蠢,但这种设计模式完全可以扩展到复杂系统,并且可以在不同的硬件上使用而无需修改。
现在我们已经对代码在高级上做了了解,让我们看看LedCmd结构体是如何填充的。
帧解码
当 USB 上的数据到来时,USB 堆栈将其放置在流缓冲区中。可以从前面的GetUsbRxStreamBuff()在Drivers/HandsOnRTOS/VirtualCommDriverMultiTask.c中访问传入数据的StreamBuffer函数:
StreamBufferHandle_t const * GetUsbRxStreamBuff( void )
{
return &vcom_rxStream;
}
此函数返回一个指向StreamBufferHandle_t的常量指针。这样做是为了让调用代码可以直接访问流缓冲区,但不能更改指针的值。
该协议本身是一个严格二进制流,以 0x02 开始,以 CRC-32 校验和结束,以小端字节顺序传输:

有许多不同的方式来序列化数据。这里为了简单起见选择了一个简单的二进制流。应该考虑以下几点:
-
0x02头是一个方便的分隔符,可以用来找到(可能的)帧的开始。它并不足够独特,因为消息中的任何其他字节也可以是0x02(它是一个二进制流,不是 ASCII)。末尾的 CRC-32 提供了帧正确接收的保证。 -
由于每个 LED 值正好有 1 个字节,我们可以用 0-255 来表示 0-100%的占空比,并且我们保证有有效的、范围内的参数,无需任何额外的检查。
-
这种简单的帧方法非常僵化,并且完全没有任何灵活性。当我们需要通过电线发送其他东西时,我们就回到了起点。如果需要灵活性(并且更复杂),就需要一个更灵活的序列化方法。
frameDecoder函数定义在mainColorSelector.c中:
void frameDecoder( void* NotUsed)
{
LedCmd incomingCmd;
#define FRAME_LEN 9
uint8_t frame[FRAME_LEN];
while(1)
{
memset(frame, 0, FRAME_LEN);
while(frame[0] != 0x02)
{
xStreamBufferReceive( *GetUsbRxStreamBuff(), frame, 1,
portMAX_DELAY);
}
xStreamBufferReceive( *GetUsbRxStreamBuff(),
&frame[1],
FRAME_LEN-1,
portMAX_DELAY);
if(CheckCRC(frame, FRAME_LEN))
{
incomingCmd.cmdNum = frame[1];
incomingCmd.red = frame[2]/255.0 * 100;
incomingCmd.green = frame[3]/255.0 * 100;
incomingCmd.blue = frame[4]/255.0 * 100;
xQueueSend(ledCmdQueue, &incomingCmd, 100);
}
}
}
让我们逐行分析:
- 创建了两个局部变量,
incomingCmd和frame。incomingCmd用于存储完全解析的命令。frame是一个字节缓冲区,用于在函数解析/验证时存储恰好一个帧的数据:
LedCmd incomingCmd;
#define FRAME_LEN 9
uint8_t frame[FRAME_LEN];
- 在循环开始时,
frame的内容被清除。仅清除第一个字节是严格必要的,这样我们才能准确地检测到0x02,因为帧是二进制并且有明确的长度(只有可变长度的字符串需要以空字符终止)。然而,如果在调试过程中查看变量,看到0对于未填充的字节来说非常方便:
memset(frame, 0, FRAME_LEN);
- 从
StreamBuffer函数中复制一个字节到帧中,直到检测到0x02。这应该表示帧的开始(除非我们不幸在帧的中间开始获取数据,而有效载荷或 CRC 中的二进制值为0x02):
while(frame[0] != 0x02)
{
xStreamBufferReceive( *GetUsbRxStreamBuff(), frame, 1,
portMAX_DELAY);
}
- 帧的剩余字节从
StreamBuffer接收。它们被放置在frame数组正确的索引位置:
xStreamBufferReceive( *GetUsbRxStreamBuff(), &frame[1],
FRAME_LEN-1, portMAX_DELAY);
- 对整个帧的 CRC 进行评估。如果 CRC 无效,则丢弃这些数据,并开始寻找下一个帧的开始:
if(CheckCRC(frame, FRAME_LEN))
- 如果帧是完整的,
incomingCmd将填充帧中的值:
incomingCmd.cmdNum = frame[1];
incomingCmd.red = frame[2]/255.0 * 100;
incomingCmd.green = frame[3]/255.0 * 100;
incomingCmd.blue = frame[4]/255.0 * 100;
- 被填充的命令被发送到队列中,该队列正被
LedCmdExecutor()监视。在命令被丢弃之前,可能需要等待多达100个 tick,以等待队列中有可用空间:
xQueueSend(ledCmdQueue, &incomingCmd, 100);
重要的一点是,没有任何帧协议被放置在LedCmd中,它将通过队列发送——只有有效载荷。这允许在数据入队之前有更多的灵活性,正如我们将在为新的目标重用队列定义部分中看到的那样。
选择队列中可用的槽位数可以对应用程序对传入命令的响应产生重要影响。可用的槽位越多,命令在执行前发生显著延迟的可能性就越高。对于需要更多确定性来决定何时(以及是否)执行命令的系统,将队列长度限制为仅一个槽位并基于命令是否成功入队进行协议级确认是一个好主意。
现在我们已经看到了帧是如何被解码的,剩下的唯一一个谜团是如何将数据放入 USB 接收流缓冲区中。
USB 虚拟通信驱动程序
USB 的接收StreamBuffer由Drivers/HandsOnRTOS/usbd_cdc_if.c中的CDC_Receive_FS()填充。这看起来与第十一章中的代码类似,在任务间共享硬件外围设备,其中驱动程序的发送端被开发出来:
static int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len)
{
/* USER CODE BEGIN 6 */
portBASE_TYPE xHigherPriorityTaskWoken = pdFALSE;
USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]);
xStreamBufferSendFromISR( *GetUsbRxStreamBuff(),
Buf,
*Len,
&xHigherPriorityTaskWoken);
USBD_CDC_ReceivePacket(&hUsbDeviceFS);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
return (USBD_OK);
/* USER CODE END 6 */
}
使用流缓冲区而不是队列,允许从 USB 堆栈的内部缓冲区复制更大的内存块,同时提供一个类似队列的接口,该接口在从其中复制字节数量方面具有灵活性。这种灵活性是编写协议层如此直接的一个原因。
记住,由于使用了流缓冲区,因此只能指定一个任务作为指定的读取器。否则,必须对流缓冲区的访问进行同步(即,通过互斥锁)。
这就完成了这个例子中所有 MCU 端代码的封装。由于这个例子依赖于 USB 上的二进制协议,让我们看看代码是如何使用的。
使用代码
选择这个例子的一个目标是为了有一个易于接近、与实际应用相关的用例。大多数情况下,我们开发的嵌入式系统的用例并不包括有人在终端模拟器上键入。为此,使用 Python 创建了一个非常简单的 GUI,以便简单地向 Nucleo 板发送命令。脚本位于Chapter_13/PythonColorSelectorUI/colorSelector.py。
还包括了一个 Windows 可执行文件(Chapter_13/PythonColorSelectorUI/colorSelector.exe)。.exe不需要安装 Python。对于其他操作系统,您需要安装Chapter_13/PythonColorSelectorUI/requirements.txt中列出的必需软件包,并运行 Python 3 解释器来使用脚本:
- 首先,您需要选择 STM 虚拟通信端口:

- 在端口成功打开后,使用滑块和各种按钮来控制 Nucleo 开发板上的 LED。在每次 UI 更新事件中构建一个命令帧,并立即通过 USB 发送到 MCU。显示的是最后发送帧的 ASCII 编码十六进制转储:

或者,也可以使用能够发送二进制数据的终端应用程序(例如,Windows 上的 RealTerm)。
因此,我们有一些闪烁的灯光和一个(不那么)闪亮的用户界面。让我们来谈谈这个练习的真正收获——通过以我们这样做的方式使用队列,我们为我们的应用程序构建了灵活性。
重复使用队列定义以用于新目标
表面上,可能难以欣赏这种设置有多灵活。在命令输入方面,我们能够从任何地方获取命令,而不仅仅是 USB 上的二进制帧协议。由于放入队列中的数据被抽象化,不包括任何特定协议的信息,因此底层协议可以更改,而无需对下游进行任何更改。
让我们看看几个例子:
- 我们可以为解析传入的数据编写不同的例程,该例程使用以逗号分隔的 ASCII 字符串,其中占空比由 0 到 100 之间的百分比表示,并以换行符结束的基于字符串的枚举:
BLINK, 20, 30, 100\n。这将导致以下值被放置在ledCmdQueue中:
LedCmd cmd = {.cmdNum=3, .red=20, .blue=30, .green=100};
xQueueSend(ledCmdQueue, &cmd, 100);
-
底层接口可以完全改变(从 USB 到 UART、SPI、I2C、以太网、物联网框架等)。
-
命令可以是非序列化的数据源的形式(例如离散的占空比或 MCU 的物理引脚):

没有任何理由让队列仅限于由单个任务填充。如果从系统设计角度来看是有意义的,命令执行器的队列可以由任何数量的同时源填充。将数据输入系统的可能性确实是无限的,这是一个好消息——特别是如果你正在开发一个相当复杂的将在多个系统中使用的产品。你可以自由地投资时间编写一次高质量的代码,因为确切的代码将在多个应用程序中使用,因为它足够灵活,可以适应同时保持一致的接口。
ledCmd 执行器有两个组件提供了灵活性——队列接口和iPWM接口。
队列接口
在LedCmdExecution()任务启动后,它与系统中的高级代码的唯一交互是通过其命令队列。正因为如此,我们可以自由地更改底层实现,而不会直接影响到高级代码(只要通过队列传递的数据具有相同的意义)。
例如,闪烁可以以不同的方式实现,而向队列提供数据的任何代码都不需要更改。由于通过队列传递数据的要求仅为uint8_t和三个浮点数,我们也可以完全重写LedCmdExecution(例如,没有iPWM接口)的实现。这种更改只会影响启动任务的单个文件——在这个例子中是mainColorSelector.c。任何直接处理队列的其他文件将不受影响。如果有多个源向队列提供数据(例如 USB、I2C、物联网框架等),即使完全更改了底层的LedCmdExecution实现,这些源也不需要修改。
iPWM 接口
在这个实现中,我们通过使用灵活的接口来驱动 LED(iPWM)进一步提高了灵活性。由于所有调用(如setDutyCycles)都是由灵活的接口定义的(而不是直接与硬件交互),我们可以自由地用任何其他iPWM实现替换这里包含的 MCU 的TIM外围实现。
例如,iPWM接口另一端的 LED 可能是一个通过串行接口驱动的可寻址 LED,它需要一个串行数据流而不是 PWM。它甚至可能位于远程位置,并需要另一个协议来激活它。只要它可以表示为 0 到 100 之间的百分比,就可以通过此代码来控制。
现在——现实地讲——在现实世界中,你不太可能为了闪烁几个 LED 而费这么大的劲!记住,这是一个具有相当简单功能性的示例,所以我们能够将注意力集中在实际的架构元素上。在实践中,灵活的架构为构建长期存在的、可适应的代码库提供了基础。
所有这些灵活性都伴随着一个警告——始终要小心你正在设计的系统,并确保它们满足其主要要求。总会有一些权衡,无论是性能、初始开发时间、BOM 成本、代码优雅性、闪存空间还是可维护性之间的权衡。毕竟,一个美丽可扩展的设计如果无法适应可用的 ROM 和 RAM,对任何人都没有好处!
摘要
在本章中,你获得了创建简单端到端命令执行器架构的第一手经验。此时,你应该非常熟悉创建队列,并且已经开始更深入地了解它们如何被用来实现特定的设计目标,例如灵活性。你可以将这些技术的变体应用于许多实际项目。如果你特别有冒险精神,可以自由地实现建议的协议之一或添加另一个接口的入口点(例如 UART)。
在下一章中,我们将改变节奏,讨论 FreeRTOS 的可用的 API,探讨何时以及为什么你可能更喜欢其中一个而不是另一个。
问题
在我们总结时,这里有一份问题列表,供你测试对本章材料的了解。你将在附录的评估部分找到答案:
-
队列减少了设计灵活性,因为它们创建了一个必须遵守的、对数据传输的严格定义:
-
正确
-
错误
-
-
队列与其他抽象技术不兼容;它们必须只包含简单的数据类型:
-
正确
-
错误
-
-
当使用从串行端口获取的命令的队列时,队列是否应该包含与底层序列化数据流完全相同的信息和格式?为什么?
-
列出一个原因,说明为什么通过值传递数据到队列比通过引用传递更简单。
-
列出一个原因,说明为什么在实时嵌入式系统中仔细考虑队列的深度是必要的?
第十四章:选择 RTOS API
到目前为止,我们一直在所有示例中使用本地的 FreeRTOS API。然而,这并不是使用 FreeRTOS 的唯一 API。有时,在开发代码时可能会有次要目标——它可能需要在其他项目(这些项目使用基于其他 MCU 的嵌入式操作系统)中重用。有时,代码需要与功能齐全的操作系统互操作。你也可能希望利用为全操作系统之前开发的代码。为了支持这些目标,除了本机 API 之外,还有两个其他 API 值得考虑——CMSIS-RTOS 和 POSIX。
在本章中,我们将研究这三个 API 在基于 FreeRTOS 创建应用程序时的功能、权衡和限制。
本章涵盖了以下主题:
-
理解通用的 RTOS API
-
比较 FreeRTOS 和 CMSIS-RTOS
-
比较 FreeRTOS 和 POSIX
-
决定使用哪个 API
技术要求
要完成本章的动手练习,你需要以下设备:
-
一块 Nucleo F767 开发板
-
一条微型 USB 线
-
STM32CubeIDE 和源代码(有关说明,请访问第五章,选择 IDE,并阅读设置我们的 IDE部分)
-
SEGGER JLink、Ozone 和 SystemView(有关说明,请阅读第六章,实时系统的调试工具)
本章的所有源代码均可在以下链接中找到:github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers/tree/master/Chapter_14。
理解通用 RTOS API
RTOS API 定义了用户在使用 RTOS 时与之交互的编程接口。本机 API 公开 RTOS 的所有功能。到目前为止,在这本书中,我们只使用了本机 FreeRTOS API。这样做是为了使查找给定函数的帮助更容易,并排除在 FreeRTOS 和通用 API 之间存在表现不佳的包装层的任何可能性。然而,这并不是 FreeRTOS 的唯一 API 选项。还有可用的通用 API,可以用来与 RTOS 功能接口——但它们不是绑定到特定 RTOS,而是可以在多个操作系统之间使用。
这些通用 API 通常作为本机 RTOS API(RTX 是例外,它只有 CMSIS-RTOS API)之上的包装层实现。在这里,我们可以看到典型 API 在通用ARM固件堆栈中的位置:

如前图中的箭头所示,没有单一的抽象可以阻止用户代码访问最低级别的功能。每一层都添加了另一个潜在的 API,而较低级别的功能仍然可用。
有两个通用的 API 可以用来访问 FreeRTOS 功能的一部分:
-
CMSIS-RTOS:ARM 定义了一个针对 MCU 的无供应商 API,称为 Cortex 微控制器软件接口-RTOS(CMSIS-RTOS)。
-
POSIX:可移植操作系统接口(POSIX)是另一个在多个供应商和硬件中常见的通用接口的例子。这个 API 更常用于全通用操作系统,如 Linux。
我们将在本章中深入讨论这些通用 API。但首先,让我们看看它们的优缺点。
通用 API 的优点
使用通用的 RTOS API,如 CMSIS-RTOS 或 POSIX,为程序员和中间件供应商提供了几个好处。程序员可以编写一次代码,然后在多个 MCU 上运行,只需对应用程序代码进行少量或没有更改,就可以根据需要更改 RTOS。中间件供应商也能够编写他们的代码以与单个 API 交互,然后支持多个 RTOS 和硬件。
如您可能从之前的图中注意到的那样,CMSIS-RTOS 和 POSIX API 不需要独占访问 FreeRTOS。由于这些 API 作为层在原生 FreeRTOS API 之上实现,代码可以同时使用更通用的 API 或原生 RTOS API。因此,应用程序的部分部分使用 CMSIS-RTOS 接口,而其他部分使用原生 FreeRTOS API 是完全可以接受的。
例如,如果 GUI 提供商发布了他们的代码并与 CMSIS-RTOS 交互,那么就没有什么可以阻止他们使用原生 FreeRTOS API 进行额外开发。GUI 供应商的代码可以使用 CMSIS-RTOS 带入,而系统中的其他代码则使用原生 FreeRTOS API,而不需要 CMSIS-RTOS 包装器。
虽然有这些好处,但似乎通用 API 会是解决所有问题的明显答案。但这并不正确。
通用 API 的缺点
一个通用 API 在统一性方面获得的收益,它就会在特定性方面失去。一个通用、一刀切的实现需要足够通用,以便适用于大多数 RTOS。这导致独特的部分被排除在标准化接口之外,有时甚至包括一些非常有趣的功能。
由于 RTOS 供应商本身并不总是提供 CMSIS-RTOS 的支持,因此存在 CMSIS-RTOS 版本落后于 RTOS 发布周期的可能性。这意味着 RTOS 更新可能不会像原生 API 那样频繁地包含到 CMSIS-RTOS 中。
如果遇到问题,获得支持也存在问题——RTOS 供应商通常更愿意(并且有能力)帮助解决他们实际提供的代码。通常,对于 RTOS 供应商没有编写的抽象,获得支持会非常困难——因为它们可能不熟悉它,而且抽象本身可能包含基 RTOS 代码中不存在的错误/功能。
现在我们已经对通用 RTOS API 有一个大致的了解,让我们更深入地了解并比较 FreeRTOS 和 CMSIS-RTOS API。
比较 FreeRTOS 和 CMSIS-RTOS
有一种常见的误解,即存在一个名为 CMSIS-RTOS 的 RTOS。实际上,CMSIS-RTOS 只是一个 API 定义。它的实现主要是底层 RTOS 的粘合层,但在两个之间存在功能差异的地方,将存在一些粘合代码来映射功能。
ARM 开发 CMSIS-RTOS 的目的是与开发 CMSIS 时的目标相同:添加一个一致的抽象层,以减少供应商锁定。原始 CMSIS 的目的是通过为中间件提供访问常见 Cortex-M 功能的一致方法来减少硅供应商锁定。它实现了这一目标——对于它支持的数千个基于 Cortex-M 的 MCU,只有 FreeRTOS 端口的几个变体。同样,ARM 现在正试图通过使 RTOS 本身更容易更换来减少 RTOS 供应商锁定——通过提供一个供应商无关的一致 API(CMSIS-RTOS)。
本章主要介绍 CMSIS-RTOS,但这里的信息仅针对 CMSIS-RTOS 的当前版本,即 CMSIS-RTOS v2(与 CMSIS-RTOS v1 的 API 不同)。CMSIS-RTOS v2 也常被称为 CMSIS-RTOS2。本章所引用的确切版本是 CMSIS-RTOS 2.1.3。
一些主要的 FreeRTOS 功能也由 CMSIS-RTOS 暴露。以下是一个简要概述(更多详细信息请参阅“CMSIS-RTOS 和 FreeRTOS 函数交叉引用”部分):
-
任务:这是创建和删除具有静态和动态分配堆栈的任务的功能。
-
信号量/互斥锁:CMSIS-RTOS 中存在二进制和计数信号量以及互斥锁。
-
队列:队列 API 在 FreeRTOS 原生 API 和 CMSIS-RTOS API 之间非常相似。
-
软件定时器:软件定时器 API 在 FreeRTOS 原生 API 和 CMSIS-RTOS API 之间非常相似。
-
事件组:用于同步多个任务。
-
内核/调度器控制:这两个 API 都有启动/停止任务和监控系统的能力。
FreeRTOS 和 CMSIS-RTOS 的功能集并不完全重叠。有一些 FreeRTOS 的功能在 CMSIS-RTOS 中不可用:
-
流和消息缓冲区:灵活且高效的队列替代方案。
-
队列集:用于在多个队列或信号量上阻塞。
-
协程:当 RAM 不足以运行多个任务时,运行多个函数的显式时间共享解决方案
同样,CMSIS-RTOS 也有一些功能在现成的 FreeRTOS 版本中不可用,主要是 MemoryPools。有关 CMSIS-RTOS2 函数的当前列表,请参阅 arm-software.github.io/CMSIS-FreeRTOS/General/html/functionOverview.html#rtos_api2。
关于 ST Cube CMSIS-RTOS 的特别说明
重要的是要注意,当使用 ST Cube 开发应用程序时,CMSIS-RTOS 版本适配层 cmsis_os2.c 是从 ARM 编写的原始 API 分支出来的。许多更改都与 CMSIS-RTOS 层如何与系统时钟交互有关。有关原始 ARM 提供的 CMSIS-FreeRTOS 实现的文档,请访问 arm-software.github.io/CMSIS-FreeRTOS。
迁移过程中的注意事项
与使用 FreeRTOS API 相比,使用 CMSIS-RTOS API 编程有一些值得注意的差异。
CMSIS-RTOS 任务创建函数接受以 字节 为单位的堆栈大小,而 FreeRTOS 中是以 字 为单位的。因此,在 FreeRTOS 中使用堆栈大小为 128 字的 xTaskCreate 调用等同于调用 CMSIS-RTOS 的 osThreadNew 并以 512 字节作为参数。
CMSIS-RTOS 的功能比 FreeRTOS 少,但那些函数依赖于属性结构体作为输入。例如,在 FreeRTOS 中,有许多具有 FromISR 等效函数的函数系列。FromISR 变体通常不会阻塞——如果从 ISR 内部调用 RTOS API,它们需要被调用,但它们也可以在其他地方有选择性地使用。在 CMSIS-RTOS 层中,ISR 上下文会自动检测。FromISR API 会自动使用,取决于调用者是在 ISR 上下文还是应用上下文中执行。portYIELD_FROM_ISR 也会自动调用。这里的简单性是以忽略 ISR 调用内指定的任何阻塞延迟为代价的,因为 FromISR 变体都是非阻塞的(因为永远不希望在 ISR 内部阻塞数毫秒是不好的主意)。这与 FreeRTOS 方法中防止在 ISR 上下文中误用 RTOS API 相比——一个 configASSERT 实例将失败,导致无限循环并停止整个应用程序。
关于从 ISR 上下文防止滥用 RTOS API 功能,CMSIS-RTOS 在其函数被从 ISR 上下文中误用时将返回错误代码。在 FreeRTOS 中,同样的误用通常会导致一个失败的configASSERT实例,并带有详细的注释,这将使整个程序停止。只要程序员负责任并严格检查返回值,这些错误就会被检测到。FreeRTOS 在错误方面更为直言不讳,因为它不允许程序继续执行(在这种情况下,几乎总是会在 FreeRTOS 源代码中找到解释误配置原因和提出解决方案的详细注释)。
CMSIS-RTOS 和 FreeRTOS 函数的交叉引用
这里是 CMSIS-RTOS 函数及其相关 FreeRTOS 函数的完整比较。如果你对了解各种 FreeRTOS 函数如何在 CMSIS-RTOS API 中被调用感兴趣,现在可以随意浏览这些表格。否则,在代码在 CMSIS-RTOS 和 FreeRTOS API 之间移植时,请将这些表格作为参考。
延迟函数
延迟函数在这两个 API 之间映射得很好:
| CMSIS-RTOS 名称 | 调用的 FreeRTOS 函数 | 注释 |
|---|---|---|
osDelay |
vTaskDelay |
osDelay的值以毫秒或滴答数表示,具体取决于你相信的文档和注释。如果使用非 1 kHz 的Systick频率,务必检查你的 CMSIS-RTOS 对osDelay()的实现! |
osDelayUntil |
vTaskDelayUntil, xTaskGetTickCount |
这些基本的延迟函数工作方式非常相似——需要记住的最大区别是 CMSIS-RTOS 指定osDelay以毫秒为单位,而不是 FreeRTOS 中的滴答数。
EventFlags
CMSIS-RTOS 中的oseventFlags映射到 FreeRTOS 中的EventGroups。当 CMSIS-RTOS 函数在 ISR 内部被调用时,将自动使用 FreeRTOS API 的FromISR变体:
| CMSIS-RTOS 名称 | 调用的 FreeRTOS 函数 | 注释 |
|---|---|---|
oseventFlagsClear |
xEventGroupsClearBits, xEventGroupGetBitsFromISR |
|
osEventFlagsDelete |
vEventGroupDelete |
|
osEventFlagsGet |
xEventGroupGetBits, xEventGroupGetBitsFromISR |
|
osEventFlagsNew |
xEventGroupCreateStatic, xEventGroupCreate |
|
osEventFlagsSet |
xEventGroupSetBits, xEventGroupSetBitsFromISR |
|
osEventFlagsWait |
xEventGroupWaitBits |
CMSIS-RTOS 中的 EventFlags 与 FreeRTOS 中的 EventGroups 工作方式相似,几乎是一对一映射。
内核控制和信息
内核接口相似,尽管 STM 提供的某些定时器实现并不那么直观,特别是osKernelGetSysTimerCount和osKernelGetSysTimerCount。此外,如果 ISR 上下文中有问题,某些函数将返回错误:
-
osKernelInitialize -
osKernelRestoreLock -
osKernelStart3 -
osKernelUnlock
请特别注意本表中的注释:
| CMSIS-RTOS 名称 | 调用的 FreeRTOS 函数 | 注释 |
|---|---|---|
osKernelGetInfo |
表示 FreeRTOS 版本的静态字符串 | |
osKernelGetState |
xTaskGetSchedulerState |
|
osKernelGetSysTimerCount |
xTaskGetTickCount |
这返回 xTaskGetTickCount() * (SysClockFreq / configTICK_RATE_HZ)。 |
osKernelGetSysTimerFreq |
ST HAL SystemCoreClock 全局变量 | |
osKernelGetTickCount |
xTaskGetTickCount |
|
osKernelGetTickFreq |
configTICK_RATE_HZ |
这不是SysTick频率(即1 kHz)(返回SysClockFreq,即 160 MHz)。 |
osKernelInitialize |
vPortDefineHeapRegions(仅当使用Heap5时) |
|
osKernelLock |
xTaskGetSchedulerState, vTaskSuspendAll |
|
osKernelRestoreLock |
xTaskGetSchedulerState, vTaskSuspendAll |
|
osKernelStart |
vTaskStartScheduler |
|
osKernelUnlock |
xTaskGetSchedulerState, xTaskResumeAll |
在使用 STM 提供的 CMSIS-RTOS 端口和原生 FreeRTOS API 在面向内核的函数之间移动时,请注意时间单位之间的细微差异。
消息队列
消息队列相当相似。在 CMSIS-RTOS 中,所有队列都通过名称注册,这可以提供更丰富的调试体验。此外,CMSIS-RTOS 支持通过作为函数参数传递的属性进行静态分配。
从 ISR 内部调用的任何函数都将自动强制使用FromISR等效函数,并通过调用portYIELD_FROM_ISR完成 ISR。这导致任何阻塞时间实际上被设置为0。因此,例如,如果队列没有可用空间,则即使在 ISR 内部指定了阻塞超时,对osMessageQueuePut的调用也将立即从 ISR 返回:
| CMSIS-RTOS 名称 | 调用的 FreeRTOS 函数 | 注意 |
|---|---|---|
osMessageQueueDelete |
vQueueUnregisterQueue, vQueueDelete |
|
osMessageQueueGet |
xQueueReceive |
如果在 ISR 内部,则自动调用FromISR变体,并自动调用portYIELD_FROM_ISR。 |
osMessageQueueGetCapacity |
pxQueue->uxLength |
|
osMessageQueueGetCount |
uxQueueMessagesWaiting, uxQueueMessagesWaitingFromISR |
|
osMessageQueueGetMsgSize |
pxQueue->uxItemSize |
|
osMessageQueueGetSpace |
uxQueueSpacesAvailable |
如果此函数在 ISR 中执行,则自动调用taskENTER_CRITICAL_FROM_ISR。 |
osMessageQueueNew |
xQueueCreateStatic, xQueueCreate |
|
osMessageQueuePut |
xQueueSendToBack, xQueueSendToBackFromISR |
在 STM 端口中忽略msg_prior参数。 |
osMessageQueueReset |
xQueueReset |
CMSIS-RTOS 和 FreeRTOS 之间的队列非常相似,但值得注意的是,CMSIS-RTOS 没有xQueueSendToFront的等效项,因此无法使用 CMSIS-RTOS 将项目放置在队列的前面。
互斥锁和信号量
互斥锁在这两个 API 之间也相当相似,但需要注意以下几点:
-
在 CMSIS-RTOS 中,递归互斥量 API 函数会根据创建的互斥量类型自动调用。
-
在 CMSIS-RTOS 中,通过作为函数参数传递的属性支持静态分配。
-
在 ISR 上下文中调用
osMutexAcquire、osMutexRelease、osMutexDelete和osMutexRelease时,将始终通过返回osErrorISR失败。 -
从 ISR 内部调用
osMutexGetOwner和osMutexNew时,将始终返回NULL。
考虑到这些点,以下是 CMSIS-RTOS 和 FreeRTOS API 中互斥量之间的关系:
| CMSIS-RTOS 名称 | 调用的 FreeRTOS 函数 | 注意事项 |
|---|---|---|
osMutexAcquire |
xSemaphoreTake, xSemaphoreTakeRecursive |
当互斥量是递归的时,会自动调用takeRecursive变体。 |
osMutexRelease |
xSemaphoreGive, xSemaphoreGiveRecursive |
当互斥量是递归的时,会自动调用takeRecursive变体。 |
osMutexDelete |
vSemaphoreDelete, vQueueUnregisterQueue |
|
osMutexGetOwner |
xSemaphoreGetMutexHolder |
如果在 ISR 内部调用,则始终返回NULL,这与互斥量可用时的预期行为相同。 |
osMutexNew |
xSemaphoreCreateRecursiveMutexStatic, xSemaphoreCreateMutexStatic,xSemaphoreCreateRecursiveMutex, xSemaphoreCreateMutex, vQueueAddToRegistry |
根据传递给函数的osMutexAttr_t指针的值,创建不同的互斥量类型。 |
osMutexRelease |
xSemaphoreGiveRecursive, xSemaphoreGive |
虽然在 API 之间互斥功能非常相似,但实现方式却相当不同。FreeRTOS 使用许多不同的函数来创建互斥量,而 CMSIS-RTOS 通过向少量函数添加参数来实现相同的功能。它还会记录互斥量类型,并自动调用递归互斥量适当的 FreeRTOS 函数。
信号量
当需要时,会自动使用信号量函数的FromISR等效函数。静态和动态分配的信号量,以及二进制和计数信号量,都是使用osSemaphoreNew创建的。
在这里,FreeRTOS 底层使用队列实现信号量的事实显而易见,这可以从使用队列 API 提取信号量信息中看出:
| CMSIS-RTOS 名称 | 调用的 FreeRTOS 函数 | 注意事项 |
|---|---|---|
osSemaphoreAcquire |
xSemaphoreTakeFromISR, xSemaphoreTake, portYIELD_FROM_ISR |
考虑到自动 ISR 上下文。 |
osSemaphoreDelete |
vSemaphoreDelete, vQueueUnregisterQueue |
|
osSemaphoreGetCount |
osSemaphoreGetCount, uxQueueMessagesWaitingFromISR |
|
osSemaphoreNew |
xSemaphoreCreateBinaryStatic, xSemaphoreCreateBinary, xSemaphoreCreateCountingStatic, xSemaphoreCreateCounting, xSemaphoreGive, vQueueAddToRegistry |
所有信号量类型都是使用此函数创建的。除非指定初始计数为0,否则信号量会自动释放。 |
osSemaphoreRelease |
xSemaphoreGive, xSemaphoreGiveFromISR |
通常,信号量功能在 CMSIS-RTOS 和 FreeRTOS 之间映射得非常清晰,尽管函数名不同。
线程标志
应独立审查 CMSIS-RTOS 线程标志的使用(提供了详细文档的链接)。正如从 FreeRTOS 调用的函数中可以看到,它们建立在TaskNotifications之上。同样,当在 ISR 上下文中调用时,会自动替换 ISR 安全的等效函数:
| CMSIS-RTOS 名称 | FreeRTOS 调用的函数 | 注意事项 |
|---|---|---|
osThreadFlagsClear |
xTaskGetCurrentTaskHandle, xTaskNotifyAndQuery, xTaskNotify |
www.keil.com/pack/doc/CMSIS/RTOS2/html/group__CMSIS__RTOS__ThreadFlagsMgmt.html |
osThreadFlagsGet |
xTaskGetCurrentTaskHandle, xTaskNotifyAndQuery |
|
osThreadFlagsSet |
xTaskNotifyFromISR, xTaskNotifyAndQueryFromISR, portYIELD_FROM_ISR, xTaskNotify, xTaskNotifyAndQuery |
|
osThreadFlagsWait |
xTaskNotifyWait |
ThreadFlags和TaskNotifications在两个 API 之间具有最大的潜在行为差异。这大部分将取决于它们在特定应用中的使用方式,因此在尝试将TaskNotifications移植到ThreadFlags之前,最好详细审查ThreadFlags的文档。
线程控制/信息
CMSIS-RTOS 的基本线程 API 在 CMSIS-RTOS 和 FreeRTOS 之间非常相似,除了 CMSIS-RTOS 的osThreadGetStackSize在 FreeRTOS 中没有等效之外。其他一些小的差异包括添加了osThreadEnumerate,它在列出系统中的任务时使用几个 FreeRTOS 函数,以及状态名称的不同(CMSIS-RTOS 缺少suspend状态)。在 CMSIS-RTOS 中,静态和动态线程/任务堆栈分配都通过同一个函数osThreadNew支持。
如果在使用 FreeRTOS Heap1 实现(下一章将讨论)时调用osThreadTerminate,将会进入一个没有延迟的无穷循环。
注意,CMSIS-RTOS v2 的osThreadAttr_t.osThreadPriority需要 56 个不同的任务优先级!因此,FreeRTOSConfig.h中的configMAX_PRIORITIES必须设置为 56,否则osThreadNew()的实现需要缩放以适应可用的优先级数量:
| CMSIS-RTOS 名称 | FreeRTOS 调用的函数 | 注意事项 |
|---|---|---|
osThreadEnumerate |
vTaskSuspendAll, uxTaskGetNumberOfTasks, uxTaskGetSystemState, xTaskResumeAll |
这将挂起系统并填充一个任务句柄数组。 |
osThreadExit |
vTaskDelete |
如果使用HEAP1,这将结束当前线程。此函数将导致调用者进入一个紧密的无穷循环,消耗尽可能多的 CPU 周期,这取决于调用者的优先级。 |
osThreadGetCount |
uxTaskGetNumberOfTasks |
|
osThreadGetId xTaskGetCurrentTaskHandle |
||
osThreadGetName pcTaskGetName |
||
osThreadGetPriority uxTaskPriorityGet |
||
osThreadGetStackSize 总是返回 0 [github.com/ARM-software/CMSISFreeRTOS/issues/14](https://github.com/ARM-software/CMSISFreeRTOS/issues/14) |
||
osThreadGetStackSpace uxTaskGetStackHighWaterMark |
||
osThreadGetState eTaskGetState |
**FreeRTOS 任务状态** **CMSIS-RTOS**
eRunning osThreadRunning
eReady osThreadReady
eBlocked osThreadBlocked
eSuspended
eDeleted osThreadTerminated
eInvalid osThreadError
|
osThreadNew xTaskCreateStatic,xTaskCreate |
|---|
osThreadResume vTaskResume |
osThreadSetPriority vTaskPrioritySet |
osThreadSuspend vTaskSuspend |
osThreadTerminate vTaskDelete 如果使用 Heap1,此函数返回 osError。 |
osThreadYield taskYIELD |
大多数线程控制都是简单的 1:1 映射,因此它们在两个 API 之间替换起来非常直接。
计时器
计时器是等效的,静态和动态分配都由相同的 osTimerNew 函数定义:
**CMSIS-RTOS 名称** **FreeRTOS 调用的函数** **注意** |
|---|
osTimerDelete xTimerDelete 如果使用 Heap1,此函数返回 osError。它还释放了由要删除的计时器使用的 TimerCallback_t*。 |
osTimerGetName pcTimerGetName |
osTimerIsRunning xTimerIsTimerActive |
osTimerNew xTimerCreateStatic, xTimerCreate 为 TimerCallback_t 自动分配。 |
osTimerStart xTimerChangePeriod |
osTimerStop xTimerStop |
两个 API 中的计时器非常相似,但请注意不要尝试使用 osTimerDelete 与 Heap1 一起使用。
内存池
内存池是嵌入式 RTOS 中常见的流行动态分配技术。FreeRTOS 目前没有提供内存池实现。在早期开发中,做出了一项设计决策,即消除它,因为它增加了额外的用户界面复杂性,并且浪费了太多的 RAM。
ARM 和 ST 选择不在 FreeRTOS 之上提供任何内存池实现。
这就完成了我们对 CMSIS-RTOS 和 FreeRTOS API 的完整交叉引用。这应该有助于快速确定你需要注意的差异。虽然 CMSIS-RTOS 可以与不同供应商的 RTOS 一起使用,但它并不包含 FreeRTOS 提供的所有功能(例如流缓冲区)。
现在我们已经看到了原生 FreeRTOS API 和 CMSIS-RTOS v2 API 之间的比较,让我们看看一个使用 CMSSI-RTOS v2 的应用程序示例。
创建一个简单的 CMSIS-RTOS v2 应用程序
带着对原生 FreeRTOS API 和 CMSIS-RTOS v2 API 之间差异的理解,我们可以开发一个仅依赖于 CMSIS-RTOS API 而不是 FreeRTOS API 的裸机应用程序,其中包含两个闪烁 LED 的任务。此应用程序的目标是开发仅依赖于 CMSIS-RTOS API 的代码。这里找到的所有代码都位于main_taskCreation_CMSIS_RTOSV2.c中。
此示例与第七章中找到的类似,FreeRTOS 调度器;此示例仅设置任务并闪烁 LED。请按照以下步骤操作:
- 使用
osKernelInitialize(void)初始化 RTOS,在继续之前检查返回值:
osStatus_t status;
status = osKernelInitialize();
assert(status == osOK);
- 由于 CMSIS-RTOS 使用结构体传递线程属性,因此请从
cmsis_os2.h中填充一个osThreadAttr_t结构:
/// Attributes structure for thread.
typedef struct {
const char *name; ///< name of the thread
uint32_t attr_bits; ///< attribute bits
void *cb_mem; ///< memory for control block
///< size of provided memory for control block
uint32_t cb_size;
void *stack_mem; ///< memory for stack
uint32_t stack_size; ///< size of stack
///< initial thread priority (default: osPriorityNormal)
osPriority_t priority;
TZ_ModuleId_t tz_module; ///< TrustZone module identifier
uint32_t reserved; ///< reserved (must be 0)
} osThreadAttr_t;
注意:与 FreeRTOS 栈大小不同,FreeRTOS 栈大小是以堆栈将消耗的字数定义的(Cortex-M7 为 4 字节),CMSIS-RTOS 的大小始终以字节定义。以前,当使用 FreeRTOS API 时,我们使用 128 字节作为栈大小。在这里,为了达到相同的栈大小,我们将使用 128 * 4 = 512 字节。
#define STACK_SIZE 512
osThreadAttr_t greenThreadAtrribs = { .name = "GreenTask",
.attr_bits = osThreadDetached,
.cb_mem = NULL,
.cb_size = 0,
.stack_mem = NULL,
.stack_size = STACK_SIZE,
.priority = osPriorityNormal,
.tz_module = 0,
.reserved = 0};
在前面的代码中,我们可以看到以下内容:
-
-
仅支持
osThreadDetachted用于attr_bits。 -
需要创建的第一个任务将使用动态分配,因此控制块和与堆栈相关的变量(
cb_mem, cb_size, stack_mem, stack_size)将被设置为0和NULL。 -
这里将使用正常优先级。
-
Cortex-M7 微控制器(STM32F759)没有信任区域。
-
- 通过调用
osThreadNew()并传递实现所需线程的函数指针、任何任务参数以及指向osThreadAttr_t结构的指针来创建线程。osThreadNew()的原型如下:
osThreadId_t osThreadNew ( osThreadFunc_t func,
void *argument,
const osThreadAttr_t *attr);
这里是实际调用osThreadNew()的代码,它创建GreenTask线程。再次提醒,在继续之前,请确保线程已成功创建:
greenTaskThreadID = osThreadNew( GreenTask, NULL,
&greenThreadAtrribs);
assert(greenTaskThreadID != NULL);
GreenTask函数将闪烁绿色 LED(开启 200 毫秒,关闭 200 毫秒):
void GreenTask(void *argument)
{
while(1)
{
GreenLed.On();
osDelay(200);
GreenLed.Off();
osDelay(200);
}
}
值得注意的是,与 FreeRTOS 的vTaskDelay()不同,其中延迟取决于底层滴答频率,CMSIS-RTOS 的osDelay()根据 Keil/ARM 文档建议指定为毫秒。然而,文档还提到该参数为滴答。由于滴答不一定是 1 毫秒,请确保检查您在cmsis_os2.c中实现的osDelay()。例如,从 STM 获得的cmsis_os2.c副本中,没有在滴答和毫秒之间进行转换。
- 启动调度器:
status = osKernelStart();
assert(status == osOK);
此调用在成功时不应返回。
main_taskCreation_CMSIS_RTOSV2.c还包含了一个示例,展示了如何使用静态分配的内存启动任务控制块和任务堆栈。
静态分配需要计算底层 RTOS 控制块(例如 StaticTask_t)的大小,这些控制块是针对底层 RTOS 特定的。为了减少代码与底层 RTOS 的耦合,应该使用一个额外的头文件来封装所有 RTOS 特定的大小。在这个例子中,这个文件被命名为 RTOS_Dependencies.h。
从静态分配的内存中创建的任务使用与之前相同的 osThreadCreate() 函数调用。这次,cb_mem, cb_size, stack_mem, stack_size 变量将被填充为指针和大小。
- 定义一个数组,该数组将用作任务堆栈:
#define STACK_SIZE 512
static uint8_t RedTask_Stack[STACK_SIZE];
- 在
RTOS_Dependencies.h中填充用于静态任务的 FreeRTOS 任务控制块的大小:
#define TCB_SIZE (sizeof(StaticTask_t))
- 定义一个足够大的数组来存储任务控制块:
uint8_t RedTask_TCB[TCB_SIZE];
- 创建一个包含所有名称、指针和任务优先级的
osThreadAttr_t结构体:
osThreadAttr_t redThreadAtrribs = { .name = "RedTask",
.attr_bits = osThreadDetached,
.cb_mem = RedTask_TCB,
.cb_size = TCB_SIZE,
.stack_mem = RedTask_Stack,
.stack_size = STACK_SIZE,
.priority = osPriorityNormal,
.tz_module = 0,
.reserved = 0};
- 创建
RedTask线程,确保在继续之前它已经成功创建:
redTaskThreadID = osThreadNew( RedTask, NULL, &redThreadAtrribs);
assert(redTaskThreadID != NULL);
main_taskCreate_CMSIS_RTOSV2.c 可以编译并烧录到 Nucleo 板上,用作实验 CMSIS-RTOSv2 API 的起点。您可以使用这个基本程序来启动对 CMSIS-RTOSv2 API 的进一步实验。
现在我们已经了解了 FreeRTOS 常用的以 MCU 为主的 API,让我们继续探讨一个自 1980 年代以来一直存在且仍然活跃的标准。
FreeRTOS 和 POSIX
可移植操作系统接口(POSIX)的开发是为了提供一个统一的接口来与操作系统交互,使得代码在不同系统之间更加可移植。
在撰写本文时,FreeRTOS 为 POSIX API 的一个子集提供了一个测试版实现。已经(部分)移植的 POSIX 头文件列表如下:
-
errno.h -
fcntl.h -
mqueue.h -
mqueue.h -
sched.h -
semaphore.h -
signal.h -
sys/types.h -
time.h -
unistd.h
通常来说,线程、队列、互斥锁、信号量、定时器、睡眠以及一些时钟函数是由端口实现的。这个功能集有时足以覆盖足够的实际用例,从而使得将已经编写为 POSIX 兼容的应用程序移植到支持 FreeRTOS 的 MCU 上成为可能。请注意,FreeRTOS 本身不提供文件系统,除非有额外的中间件,因此任何需要文件系统访问的应用程序在功能正常之前都需要一些额外的组件。
让我们看看使用 POSIX API 的最小应用程序是什么样的。
创建一个简单的 FreeRTOS POSIX 应用程序
与 CMSIS API 示例类似,POSIX API 示例将只是在不同间隔下闪烁两个 LED。
注意,当 FreeRTOS POSIX 从 FreeRTOS Labs 移出后,下载位置(以及相应的说明)可能会发生变化。
首先,需要下载 POSIX 包装器并将其引入到源代码树中。执行以下步骤:
-
下载 FreeRTOS Labs 发行版(
www.freertos.org/a00104.html)。前往www.freertos.org/FreeRTOS-Plus/FreeRTOS_Plus_POSIX/index.html获取最新的下载说明。 -
将选定的
FreeRTOS_POSIX文件导入到源树中。在示例中,它们位于Middleware\Third_Party\FreeRTOS\FreeRTOS_POSIX。 -
通过修改 STM32CubeIDE 中的项目属性,向编译器和链接器添加必要的
include路径:

- 确保将以下
#define行添加到Inc/FreeRTOSConfig.h:
#define configUSE_POSIX_ERRNO 1
#define configUSE_APPLICATION_TASK_TAG 1
现在 POSIX API 可用后,我们将在 main_task_Creation_POSIX.c 中使用 pthreads 和 sleep:
- 引入必要的头文件:
// FreeRTOS POSIX includes
#include <FreeRTOS_POSIX.h>
#include <FreeRTOS_POSIX/pthread.h>
#include <FreeRTOS_POSIX/unistd.h>
- 定义必要的函数原型:
void GreenTask(void *argument);
void RedTask(void *argument);
void lookBusy( void );
- 定义全局变量以存储线程 ID:
pthread_t greenThreadId, redThreadId;
- 使用
pthread_create()创建线程/任务:
int pthread_create( pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
这里有一些关于前面代码的信息:
-
-
thread:指向pthread_t结构的指针,它将由pthread_create()填充 -
attr:指向包含线程属性的结构的指针 -
start_routine:指向实现线程的函数的指针 -
arg:传递给线程函数的参数 -
成功时返回
0,失败时返回errrno(失败时pthread_t *thread的内容将是未定义的)
-
在这里,使用之前声明的函数启动两个线程 – GreenTask() 和 RedTask():
retVal = pthread_create( &greenThreadId, NULL, GreenTask, NULL);
assert(retVal == 0);
retVal = pthread_create( &redThreadId, NULL, RedTask, NULL);
assert(retVal == 0);
- 启动调度器:
vTaskStartScheduler();
当调度器启动时,GreenTask() 和 ReadTask() 将根据需要切换到上下文。让我们快速看一下这些函数中的每一个。
GreenTask() 使用从 unistd.h 中引入的 sleep(),现在 sleep() 将强制任务阻塞所需的时间(在这种情况下,LED 打开后 1 秒和关闭后 1 秒):
void GreenTask(void *argument)
{
while(1)
{
GreenLed.On();
sleep(1);
GreenLed.Off();
sleep(1);
}
}
RedTask() 类似,在关闭红色 LED 后睡眠 2 秒:
void RedTask( void* argument )
{
while(1)
{
lookBusy();
RedLed.On();
sleep(1);
RedLed.Off();
sleep(2);
}
}
现在 TaskCreation_POSIX 可以编译并加载到 Nucleo 板上。你可以自由地将其作为实验 POSIX API 更多部分的起点。接下来,让我们看看你可能想要使用 POSIX API 的一些原因。
使用 POSIX API 的优缺点
考虑使用 FreeRTOS 的 POSIX API 有两个主要的原因:
-
通用操作系统的可移植性:根据定义,POSIX 的目标是可移植性。有许多通用操作系统旨在在符合 POSIX 的 MMU CPU 上运行。日益增多的是,也有针对符合 POSIX 的 MCU 的几个轻量级操作系统。如果你的目标是让代码库在这些类型的系统上运行,POSIX API 是使用的接口。它是 FreeRTOS 唯一允许代码可移植到完整操作系统的 API(而不是实时内核)。
-
第三方 POSIX 库:许多开源库都是通过 POSIX 接口编写的。拥有引入一些 POSIX 兼容的第三方代码的能力(只要它只访问 FreeRTOS 已移植的部分),有可能快速提升项目的功能。
当然,使用 POSIX API 也有一些缺点:
- 仍在测试阶段:在撰写本文时(2020 年初),POSIX API 仍在 FreeRTOS 实验室。以下是来自
freertos.org的解释:
POSIX 库和文档位于 FreeRTOS 实验室。FreeRTOS 实验室下载目录中的库功能齐全,但正在优化或重构以提高内存使用、模块化、文档、演示可用性和测试覆盖率。它们作为 FreeRTOS-Labs 下载的一部分提供:
www.freertos.org/a00104.html。
- 仅限于 POSIX API 可能会降低效率:在运行在 MCU 和 CPU 上的多种不同操作系统之间具有可移植性的代码将付出一定的代价。任何你想移植到支持 POSIX 的任何平台的代码都需要只包含 POSIX 功能(由 FreeRTOS 实现)。由于只有 FreeRTOS API 的一小部分通过 POSIX 暴露,你将放弃一些更高效的实现。如果你旨在拥有仅使用 POSIX API 的超可移植代码,一些最耗时和 CPU 效率最高的功能(如流缓冲区和直接任务通知)将不可用。
将 POSIX API 提供给嵌入式开发者以简化第三方代码的添加是一个令人兴奋的发展。它有可能非常快速地将大量功能引入嵌入式空间。但请记住:尽管今天的 MCU 非常强大,但它们不是通用处理器。你需要注意所有代码的交互和资源需求,尤其是对有实时要求的系统。
因此,我们在选择与 FreeRTOS 交互时使用的 API 方面有三个主要选项。在选择它们时应该考虑哪些因素?
决定使用哪个 API
决定使用哪个 API 主要取决于你希望你的代码可移植到哪个地方以及团队成员有哪些经验。例如,如果你对尝试不同的 Cortex-M RTOS 供应商感兴趣,CMSIS-RTOS 是一个自然的选择。它将允许引入不同的操作系统,而无需更改应用层代码。
同样,如果你的应用程序代码需要在功能齐全的 CPU 上的 Linux 环境以及 MCU 上运行,FreeRTOS 的 POSIX 实现将非常有意义。
由于这两个 API 都是建立在原生 FreeRTOS API 之上的,因此你仍然可以使用任何所需的特定于 FreeRTOS 的功能。以下章节应提供一些考虑要点,并帮助你决定何时选择每个 API。通常情况下,没有绝对的对错选择,只是需要做出一系列权衡。
何时使用原生 FreeRTOS API
有一些情况下,仅使用原生 FreeRTOS API 是有利的:
-
代码一致性:如果现有的代码库已经使用原生 FreeRTOS API,那么在它之上编写增加额外复杂度(和不同 API)的新代码几乎没有好处。尽管功能相似,但实际函数签名和数据结构是不同的。由于这些差异,新旧代码之间 API 的不一致性可能会让不熟悉代码库的程序员感到非常困惑。
-
支持:如果你想使用的 API 不是由 RTOS 的同一作者编写的,那么 RTOS 供应商很可能无法/愿意为出现的问题提供支持(因为问题可能只与通用 API 包装器层相关,而不是底层的 RTOS)。当你刚开始使用 RTOS 时,如果你引用的是他们的代码而不是第三方包装器,你可能会发现更容易获得支持(无论是供应商还是论坛)。
-
简单性:当询问 RTOS 供应商使用哪个 API 时,通常的回答将是 "我们编写的原生 API"。表面上,这可能看起来有点自私。毕竟,如果你使用他们的原生 API,将代码移植到另一个供应商的操作系统中可能不会那么容易。然而,这个建议的深层含义远不止于此。每个 RTOS 供应商通常对其代码(和 API)所选择的风格有很强的偏好。将这个原生 API 与另一个 API 粘合可能是一种范式转变。有时,这个额外的粘合层非常薄,几乎不被注意。有时,它可能变成一个粘稠的混乱,需要在原生 API 上编写相当多的额外代码,使熟悉原生 API 的开发者感到更加困惑。
-
代码空间:由于每个通用 API 都是原生 FreeRTOS API 的包装器,它们将需要少量的额外代码空间。在较大的 32 位 MCU 上,这通常不会成为考虑因素。
何时使用 CMSIS-RTOS API
当你想让你的代码可移植到其他基于 ARM 的 MCU 时,请使用 CMSIS-RTOS。一些旨在针对 MCU 并支持 CMSIS-RTOS API 的其他 RTOS 包括以下内容:
-
Micrium uCOS
-
Express Logic ThreadX
-
Keil RTX
-
Zephyr 项目
通过仅使用 CMSIS-RTOS API 提供的函数,你的代码可以在任何兼容的操作系统中运行,而无需修改。
何时使用 POSIX API
当你想让你的代码可移植到这些操作系统,或者如果你想在 MCU 项目中包含依赖 POSIX API 的库时,请使用 POSIX 端口:
-
Linux
-
Android
-
Zephyr
-
Nuttx (POSIX)
-
黑莓 QNX
虽然上面列出的每个 POSIX 兼容的操作系统都实现了 POSIX 的一部分,但并非所有功能集都会必然相交。当编写旨在在多个目标上运行的代码时,需要采取 最小公倍数 方法 - 确保只使用所有目标平台都普遍可用的最少功能。
值得注意的是,由于 POSIX 兼容的开源应用程序是为功能齐全的 PC 设计的,它们可能使用不适合 MCU 的库(例如,使用核心 FreeRTOS 内核不存在的文件系统)。
摘要
在本章中,我们介绍了三种可以与 FreeRTOS 一起使用的 API - 原生 FreeRTOS API、CMSIS-RTOS 和 POSIX。你现在应该熟悉所有可用的 API,了解它们为什么存在,以及何时使用每个 API 是合适的。向前看,你将能够根据你特定项目的需求做出明智的 API 选择。
在下一章中,我们将从讨论如何以高级别与 FreeRTOS 交互转向讨论内存分配的一些低级细节。
问题
在我们结束之前,这里有一些问题供你测试你对本章材料的了解。你将在附录的 评估 部分找到答案:
-
CMSIS-RTOS 是什么,哪个供应商提供其实现?
-
列举一个大量使用 POSIX 的常见操作系统。
-
在 CMSIS-RTOS 和 FreeRTOS API 之间明智地选择很重要,因为一次只能使用一个:
-
True
-
False
-
-
通过使用 POSIX API,任何为 Linux 编写的程序都可以轻松移植到 FreeRTOS 上运行:
-
True
-
False
-
进一步阅读
-
CMSIS-RTOS v2 API 文档:
www.keil.com/pack/doc/CMSIS/RTOS2/html/ -
FreeRTOS POSIX API -
www.freertos.org/FreeRTOS-Plus/FreeRTOS_Plus_POSIX/index.html -
FreeRTOS POSIX 端口函数的详细列表
-
STM32 F7 Nucleo-144 开发板的 Zephyr POSIX 实现文档:
docs.zephyrproject.org/latest/boards/arm/nucleo_f767zi/doc/index.html
第十五章:FreeRTOS 内存管理
到目前为止,我们已经通过许多创建 FreeRTOS 原语的示例;然而,当这些原语最初创建时,并没有太多关于内存来源的解释。在本章中,我们将了解内存的确切来源,以及何时以及如何分配。选择何时以及如何分配内存允许我们在编码便利性、时序确定性、潜在法规要求以及代码标准之间进行权衡。我们将通过查看可以采取的不同措施来确保应用程序的健壮性来结束本章。
简而言之,本章涵盖了以下内容:
-
理解内存分配
-
FreeRTOS 原语的静态和动态分配
-
比较 FreeRTOS 堆实现
-
替换
malloc和free -
实现 FreeRTOS 内存钩子
-
使用内存保护单元(MPU)
技术要求
要完成本章的动手练习,你需要以下内容:
-
Nucleo F767 开发板
-
一条 Micro-USB 线
-
STM32CubeIDE 和源代码(参见第五章,选择 IDE部分下的设置我们的 IDE说明)
-
SEGGER JLink、Ozone 和 SystemView(参见第六章,实时系统调试工具中的说明)
本章的所有源代码均可在github.com/PacktPublishing/Hands-On-RTOS-with-Microcontrollers/tree/master/Chapter_15获取。
理解内存分配
内存分配并不一定是开发者考虑应用开发中最喜欢的主题之一——它并不那么吸引人。动态分配内存——即在需要时分配内存,而不是在程序开始时——是常态。在面向桌面的开发中,内存通常在需要时可用,因此不会过多考虑;它只是一个malloc调用而已。当它完成时,它将通过free进行释放。
与桌面环境中无忧无虑的动态内存分配方案不同,使用 MCU 的深度嵌入式系统程序员通常需要更加小心地考虑如何(以及是否)动态分配内存。在嵌入式系统中,法规、RAM 和时序约束都可能影响内存是否以及如何进行动态分配。
许多高可靠性和安全关键性的编码标准,如 MISRA-C,不允许使用动态分配。在这种情况下,使用静态分配仍然是完全可接受的。一些编码标准禁止在所有任务创建后进行动态分配(例如,JPL 的《安全关键编码十规则》)。在这种情况下,静态分配或 FreeRTOS 的heap_1.c实现是合理的。
在某些平台上,RAM 可能会受到严重限制。表面上,这似乎是动态内存分配的完美用例;毕竟,如果内存有限,当它不再使用时可以将其归还!然而,在实践中,当有限的堆空间可用时,事情并不总是这么顺利。当使用小堆为具有不同生命周期的任意大小对象分配空间时,最终往往会发生碎片化(这将在稍后的示例中更深入地讨论)。
最后,对高度确定性的定时需求也可能限制动态分配的选项。如果代码的一部分有严格的定时约束,有时避免使用动态分配比尝试模拟 malloc 调用的最坏情况定时测试要容易。还值得再次注意的是,malloc 并不保证成功,尤其是在内存有限的嵌入式系统中。在内存受限的多线程系统中,大量动态分配可能会创建一些非常复杂的使用案例,这些案例在运行时可能会失败。彻底测试这样的系统是一个非常大的挑战。
在了解了为什么内存分配在受限嵌入式系统中如此重要之后,让我们更仔细地看看在几个不同的用例中内存是从哪里来的。
静态内存
静态内存的生命周期是整个程序运行期间。全局变量,以及使用 static 说明符在函数内部声明的任何变量,都将放置到静态内存中,并且它们的生命周期与程序相同。
例如,globalVar 和 staticVar 都位于静态内存中,并且在整个程序生命周期内保持不变。staticVar 的初始化仅在程序初始加载时发生一次:
uint8_t globalVar = 12;
void myFunc( void )
{
static uint8_t staticVar = 0;
...
}
当变量被声明为静态时,内存的可用性得到保证。程序定义的所有全局和静态变量都在链接阶段放置到它们的位置。只要内存量已正确配置,链接器保证这些变量有足够的空间。
然而,缺点是静态变量具有如此长的生命周期,静态变量将始终占用空间,即使它们没有被使用。
栈内存
栈用于函数作用域的存储。每次调用函数时,该函数的信息(如参数和局部变量)都会放置到栈上。当函数退出时,放置到栈上的所有信息都会被移除(这就是为什么将局部变量的指针传递出去是一个问题)。在 FreeRTOS 中,每个任务都有自己的私有栈,其大小在任务创建时确定。
由于堆栈有如此有序的访问方式,它们不可能像堆那样变得碎片化。然而,通过在堆栈上放置比其大小允许更多的信息,是有可能溢出堆栈的。
在 Cortex-M 中,还有一个额外的堆栈——主堆栈。主堆栈由中断服务例程(ISRs)和 FreeRTOS 内核使用。内核和 ISRs 在特权模式下执行,该模式会修改主堆栈指针(MSP)。任务在进程堆栈上执行并使用进程堆栈指针(PSP)。所有堆栈指针操作都由硬件和内核根据当前正在执行的是内核、中断还是任务(进程)来处理。这不是 RTOS API 用户通常需要担心的事情。
堆栈和堆的初始化发生在 Chapter_*\startup\startup_stm32f767xx.s。主堆栈的确切大小在链接脚本 STM32F767ZI_FLASH.ld 中定义。如果需要,可以通过修改 _Min_Heap_Size 或 _Min_Stack_Size 来调整在 FreeRTOS 调度器启动之前系统可用的堆栈和堆的大小:
_Min_Heap_Size = 0x200; /* required amount of heap */
_Min_Stack_Size = 0x400; /* required amount of stack */
最好尽量保持这两个堆栈的大小最小,因为在这里使用的任何 RAM 都将无法用于任务。这些堆栈/堆只用于在调度器启动之前运行的代码,以及中断服务例程(ISRs)。这不是任何任务使用的相同堆栈。
有时,你可能会遇到需要运行一些内存密集型初始化代码的问题(USB 堆栈是这种情况的一个好例子)。如果初始化函数在任务之外(在调度器启动之前)被调用,那么它们将使用主堆栈。为了使这个堆栈尽可能小,并允许为任务使用更多的内存,将内存密集型初始化代码移入任务中。这将允许 RTOS 堆拥有在初始化后本将未使用的额外 RAM,因为如果增加了主堆栈的大小。
FreeRTOS 内核操作进程堆栈指针(PSP)以指向具有上下文(处于运行状态)的任务堆栈。
在大多数情况下,你不需要立即关注各种堆栈指针——它们由内核和 C 运行时处理。如果你正在开发将在 RTOS 和 bare metal(即,引导加载程序)之间转换的代码,那么你需要了解如何/何时正确切换当前堆栈指针。
在堆栈方面需要记住的最重要的事情是,它们必须足够大,以容纳任务将执行的 deepest call level 的所有局部变量。我们将在 Keeping an eye on stack space 部分讨论获取这个信息的方法。
堆内存
堆是当调用 malloc 进行动态分配时使用的内存部分。它也是当通过调用 xTaskCreate() 创建时,FreeRTOS 任务堆栈和 任务控制块 (TCB) 存储的地方。
在一个 MCU FreeRTOS 系统中,通常会创建两个堆:
-
系统堆:在前面描述的启动和链接脚本中定义。这在使用为 RTOS 原语分配空间时将 不 可用于最终应用程序代码。
-
FreeRTOS 堆:在创建任务和其他原语时使用,并在 Inc*FreeRTOSConfig.h*** 中定义。可以通过调整以下行来调整大小:
#define configTOTAL_HEAP_SIZE ((size_t)15360)
目前,这一行定义了一个 15 KB 的堆。这个堆必须足够大,以容纳以下内容:
-
使用
xTaskCreate创建的所有任务的堆栈(和 TCBs) -
使用
x*Create创建的队列、信号量、互斥锁、事件组和软件定时器
这里是所有不同变量来源的视觉表示:

FreeRTOS 原语和堆栈有两个可能的存储位置:
-
当调用
xTaskCreateStatic()时传递给任务的堆栈和 TCB 的静态分配空间 -
当调用
xTaskCreate()时创建的堆栈/TCB 的动态分配空间
C 堆仅用于创建时未使用 FreeRTOS 堆实现的项目,而 C 堆栈仅在调度器启动之前以及由 ISRs 使用。在使用 RTOS 时,最好尽可能最小化 C 堆的大小,或者完全不要。这将留下更多的可用 RAM 来分配给 RTOS 堆或静态变量。
堆碎片化
在有限 RAM 的嵌入式系统中,堆碎片化可能是一个非常严重的问题。当项目被加载到堆中并在不同的时间点移除时,堆就会变得碎片化。问题是,如果许多被移除的项目不是相邻的,就不一定会有一个更大的连续空间区域可用:

在前面的例子中,项目 8 的空间无法成功分配。尽管有足够的空闲空间,但并没有足够的 连续 空间来容纳项目 8 的大小。这尤其成问题,因为它只会在运行时发生,并且在某些情况下,这些情况取决于堆中项目分配和释放的大小和时机。
现在我们已经涵盖了内存分配的基础知识,让我们看看 FreeRTOS 原语可以以不同的方式创建并放置在静态或堆内存中的几种方法。
FreeRTOS 原语的静态和动态分配
关于创建任务的机制在第七章,FreeRTOS 调度器中有所介绍。在这里,我们只关注内存来源及其生命周期的差异。这将有助于阐明选择不同分配方案的含义。
任务内存可以是动态分配或静态分配。动态分配允许通过调用vTaskDelete()来返回任务使用的内存,如果任务不再需要运行(有关详细信息,请参阅第七章,FreeRTOS 调度器)。动态分配可以在程序的任何点发生,而静态分配则在程序开始之前发生。FreeRTOS API 调用的静态变体遵循相同的初始化方案——标准调用使用动态分配(从 FreeRTOS 堆中拉取内存)。所有名称中包含CreateStatic的 FreeRTOS API 函数(如xTaskCreateStatic)都接受额外的参数来引用预分配的内存。与动态分配方法相反,传递给*CreateStatic变体的内存通常是静态分配的缓冲区,这些缓冲区在整个程序的生命周期中都是存在的。
虽然*CreateStatic API 变体的命名暗示内存是静态的,但这实际上并不是一个要求。例如,你可以在栈上分配缓冲区内存,并将指针传递给*CreateStatic API 函数调用;然而,你需要确保所创建的基本类型的生命周期仅限于该函数!你还可以发现,使用 FreeRTOS 堆以外的分配方案分配内存可能很有用,在这种情况下,你也可以使用*CreateStatic API 变体。如果你选择使用这两种方法中的任何一种,为了避免内存损坏,你需要详细了解正在创建的 FreeRTOS 基本类型和分配的内存的生命周期!
动态分配示例
几乎所有展示的代码都使用了动态分配来创建 FreeRTOS 基本类型(任务、队列、互斥锁等)。在我们查看使用静态分配创建基本类型的差异之前,这里有两个例子作为快速复习。
创建任务
当使用动态分配的内存创建任务时,调用将类似于以下内容(有关与内存分配无关的参数的更多详细信息,请参阅第七章,FreeRTOS 调度器):
BaseType_t retVal = xTaskCreate( Task1, "task1", StackSizeWords, NULL,
tskIDLE_PRIORITY + 2, tskHandlePtr);
assert_param(retVal != pdPASS);
关于此调用,有一些与内存分配相关的相关信息需要注意:
-
xTaskCreate的调用可能会失败。这是因为无法保证在 FreeRTOS 堆上有足够的空间来存储任务的堆栈和 TCB。唯一确保其成功创建的方法是检查返回值,retVal。 -
与堆栈有关的唯一参数是请求的堆栈大小。
以这种方式创建时,如果任务适当地终止自己,它可以调用 xTaskDelete(NULL),与任务堆栈和 TCB 相关的内存将可用于重用。
关于动态分配的以下是一些需要注意的点:
-
如果没有堆空间可用,原语创建可能在运行时失败。
-
FreeRTOS 为原语分配的所有内存将在任务删除时自动释放(只要不使用
Heap_1,并且在FreeRTOSConfig.h中将INCLUDE_vTaskDelete设置为1)。这不包括实际任务中由 用户 代码动态分配的内存;RTOS 对用户代码启动的任何动态分配一无所知。释放此代码的责任在于您,您需要在适当的时候释放此代码。 -
在
FreeRTOSConfig.h中,必须将configSUPPORT_DYNAMIC_ALLOCATION设置为 1,以便动态分配可用:

当使用动态分配创建任务时,任务使用的所有内存、任务的堆栈和 TCB 都是从 FreeRTOS 堆中分配的,如前图所示。
接下来,让我们看看创建队列的不同方法。
创建队列
关于如何使用动态分配的内存创建队列的详细解释和工作示例,请参阅第九章,通过队列按值传递数据 中的 任务间通信 部分,第九章。作为一个快速回顾,要创建一个长度为 LED_CMD_QUEUE_LEN、包含 uint8_t 类型元素的队列,我们需要执行以下步骤:
- 创建队列:
ledCmdQueue = xQueueCreate(LED_CMD_QUEUE_LEN,
sizeof(uint8_t));
- 通过检查句柄
ledCmdQueue不是NULL来验证队列是否成功创建:
assert_param(ledCmdQueue != NULL);
现在我们已经回顾了一些动态分配的示例(这些示例将在运行时从 FreeRTOS 堆中拉取内存),让我们继续讨论静态分配(这将在编译/链接期间预留内存,在应用程序运行之前)。
静态分配示例
FreeRTOS 还有一种创建不需要我们动态分配内存的原语的方法。这是一个使用静态分配内存创建原语的示例。
创建任务
要使用预分配的堆栈和 TCB(不需要动态分配)创建任务,可以使用以下类似的调用:
StackType_t GreenTaskStack[STACK_SIZE];
StaticTask_t GreenTaskTCB;
TaskHandle_t greenHandle = NULL;
greenHandle = xTaskCreateStatic( GreenTask, "GreenTask", STACK_SIZE,
NULL, tskIDLE_PRIORITY + 2,
GreenTaskStack, &GreenTaskTCB);
assert_param( greenHandle != NULL );
与之前的动态分配方法相比,这种静态分配有几个显著的不同点:
-
与返回值
pdPASS不同,xTaskCreateStatic函数返回一个任务句柄。 -
使用
xTaskCreateStatic创建任务总是会成功,前提是堆栈指针和 TCB 指针非空。 -
作为检查
TaskHandle_t、StackType_t和StaticTask_t的替代方案,可以检查StackType_t;只要它们不是NULL,任务总是会成功创建。 -
即使任务是用
xTaskCreateStatic创建的,任务也可以被删除。FreeRTOS 只会采取必要的步骤将任务从调度器中移除;释放相关内存是调用者的责任。
当我们使用之前的调用时,这是任务堆栈和 TCB 所在的位置:

静态创建在内存分配方面比其名称所暗示的提供了更多的灵活性。严格来说,对 vTaskDelete 的调用只会将静态创建的任务从调度器中移除。由于 FreeRTOS 将不再访问该任务的堆栈或 TCB 中的内存,因此可以安全地将此内存用于其他目的。从堆栈内存而不是静态内存分配堆栈和 TCB 是可能的。使用 xTaskCreateStatic 创建的任务删除的示例可以在 main_staticTask_Delete.c 中找到。
创建队列
现在,让我们看看使用静态内存为缓冲区和队列结构创建队列的步骤。此代码摘自 mainStaticQueueCreation.c:
- 定义一个变量来保存 FreeRTOS 使用的队列结构:
static StaticQueue_t queueStructure;
-
创建一个适当大小的原始数组,用于保存队列内容:
-
可以使用目标数据类型的一个简单的 C 数组;在这种情况下,我们的队列将持有
uint8_t数据类型。 -
使用
#define来定义数组长度:
-
#define LED_CMD_QUEUE_LEN 2
static uint8_t queueStorage[LED_CMD_QUEUE_LEN];
- 在之前定义的数组相同长度内创建队列:
ledCmdQueue = xQueueCreateStatic(LED_CMD_QUEUE_LEN, sizeof(uint8_t), queueStorage, &queueStructure );
这里是参数的分解:
-
-
LED_CMD_QUEUE_LEN: 队列中的元素数量 -
sizeof(uint8_t): 每个元素的大小(以字节为单位) -
queueStorage: 用于在队列中存储元素的原始数组(仅由 FreeRTOS 使用) -
queueStructure: 指向StatisQueue_t的指针,这是 FreeRTOS 内部使用的队列结构
-
- 检查队列句柄
ledCmdQueue,以确保队列被正确创建,通过验证它不是NULL。与动态分配的队列不同,这个调用不太可能失败,但进行这个检查可以确保如果队列被更改为动态分配,错误仍然会被捕获:
assert_param(ledCmdQueue != NULL);
- 将所有内容组合在一起:
static QueueHandle_t ledCmdQueue = NULL;
static StaticQueue_t queueStructure;
#define LED_CMD_QUEUE_LEN 2
static uint8_t queueStorage[LED_CMD_QUEUE_LEN];
ledCmdQueue = xQueueCreateStatic(LED_CMD_QUEUE_LEN,
sizeof(uint8_t),
queueStorage, &queueStructure );
assert_param(ledCmdQueue != NULL);
使用静态分配创建队列和使用动态分配创建队列之间的唯一区别是内存的提供方式——两个调用都返回队列句柄。现在我们已经看到了创建队列和任务而不使用动态分配内存的示例,如果我们有不进行动态分配的要求会发生什么?
消除所有动态分配
在我们看到的多数示例中,我们专注于在创建 FreeRTOS 原语时使用动态分配方案变体。这主要是为了使用方便和简洁,使我们能够专注于核心 RTOS 概念,而不是担心内存的确切来源以及我们如何访问它。
所有 FreeRTOS 原语都可以使用动态分配的内存或预分配的内存来创建。为了避免所有动态分配,只需使用CreateStatic版本的create函数,就像我们在前面的例子中创建任务时做的那样。对于队列、互斥锁、信号量、流缓冲区、消息缓冲区、事件组和定时器,都存在CreateStatic版本。它们与它们的动态对应物具有相同的参数,但还需要传递一个指向预分配内存的指针。CreateStatic等效版本在运行时不需要进行任何内存分配。
你会考虑使用静态等效物,以下是一些原因:
-
它们保证永远不会因为内存不足而失败。
-
所需要的所有检查以确保内存可用都发生在链接阶段(在创建应用程序二进制文件之前)。如果内存不可用,它将在链接时失败,而不是在运行时。
-
许多针对安全关键应用的标准禁止使用动态分配的内存。
-
内部嵌入式 C 编码标准偶尔也会禁止使用动态分配。
内存碎片化也可以添加到这个列表中,但这并不是一个问题,除非内存被释放(例如,可以使用heap_1来消除堆碎片化问题)。
现在我们已经了解了动态分配和静态分配之间的区别,让我们深入了解 FreeRTOS 的动态分配方案——五种堆实现。接下来,我们将看到这些不同的定义在文件中是什么样的(全局变量、静态分配等)。我们还将理解主栈和基于任务的栈之间的区别,以及它们在 FreeRTOS 堆中的位置。
比较 FreeRTOS 堆实现
由于 FreeRTOS 针对如此广泛的 MCU 和应用程序,它提供了五种不同的动态分配方案,所有这些方案都是通过堆实现的。不同的堆实现允许不同的堆功能级别。它们包含在portable/MemMang目录中,作为heap_1.c、heap_2.c、heap_3.c、heap_4.c和heap_5.c。
关于内存池的说明:
许多其他实时操作系统(RTOS)将内存池作为动态内存分配的实现方式。内存池通过仅分配和释放固定大小的块来实现动态分配。通过固定块大小,在内存受限的环境中可以避免碎片化问题。
内存池的缺点是,块的大小需要针对每个特定应用程序进行调整。如果太大,它们会浪费宝贵的 RAM;太小,它们将无法容纳大项。为了使用户更容易操作并避免浪费 RAM,理查德·巴里选择在 FreeRTOS 中仅使用堆进行动态分配。
为了确保项目在编译后正确链接,重要的是只让链接器看到一种堆实现。这可以通过删除未使用的文件或不在链接器可用的文件列表中包含未使用的堆文件来实现。对于本书,Middleware\Third_Party\FreeRTOS\Source\portable\MemMang 中的额外文件已被删除。然而,对于本章,所有原始实现都包含在 Chapter_15\Src\MemMang 中:这是唯一一个使用除 heap_4.c 之外堆的示例的地方。
所有各种堆选项都存在,以便项目能够获得所需的确切功能,而无需更多(在程序空间或配置方面)。它们还允许在灵活性和确定性定时之间进行权衡。以下是一系列各种堆选项:
-
heap_1: 只允许分配——不允许释放。这最适合那些在初始创建后不释放任何内容的简单应用程序。此实现,连同heap_2,提供了最确定的定时,因为这两个堆永远不会执行搜索相邻空闲块以组合的操作。 -
heap_2: 允许分配和释放,但不会组合相邻的空闲块。这限制了适当的使用场景,仅适用于那些可以知道/保证每次重新使用相同数量的相同大小的项的应用程序。此堆实现对于明确使用vPortMalloc和vPortFree的应用程序(例如,自行动态分配内存的应用程序)来说并不是一个好的选择,除非在确保只使用可能大小的一小部分方面有非常高的纪律性。 -
heap_3: 包装标准的malloc/free实现以提供线程安全。 -
heap_4: 与heap_2相同,但组合相邻的空闲空间。通过提供一个绝对地址,允许定位整个堆。非常适合需要使用动态分配的应用程序。 -
heap_5: 与heap_4相同,但允许创建一个分布在不同非连续内存区域上的堆——例如,堆可以分散在内部和外部 RAM 上。
下面是所有堆实现之间的快速比较:
| 堆名称 | 线程安全 | 分配 | 释放 | 组合相邻空闲空间 | 多个内存区域 | 确定性 |
|---|---|---|---|---|---|---|
heap_1.c |
✓ | ✓ | ↑ | |||
heap_2.c |
✓ | ✓ | ✓ | ↑ | ||
heap_3.c |
✓ | ✓ | ✓ | ✓* | ? | |
heap_4.c |
✓ | ✓ | ✓ | ✓ | → | |
heap_5.c |
✓ | ✓ | ✓ | ✓ | ✓ | → |
std C lib |
? | ✓ | ✓ | ✓* | ? |
(*) 大多数(如果不是全部)包含的堆实现将组合空闲空间。
由于确定性取决于我们恰好使用的 C 库实现,因此在这里提供一般性指导是不可能的。通常,通用堆实现是为了最小化碎片化而创建的,这需要额外的 CPU 资源(时间),并且会降低时间确定的确定性,这取决于移动了多少内存。
每个 C 语言的实现可能对动态分配的处理方式不同。有些实现会让添加线程安全变得和定义__mallock_lock和__malloc_unlock的实现一样简单,在这种情况下,只需要一个互斥锁即可。在其他情况下,它们可能需要几个实现来实施互斥锁功能。
选择你的 RTOS 堆实现
那么,你如何选择使用哪种堆实现呢?首先,你需要确保你能够使用动态分配(许多针对安全关键型应用的标准禁止使用它)。如果你不需要释放分配的内存,那么heap_1.c可能是一个潜在的选择(同样,完全避免使用堆也是一个选择)。
从编码的角度来看,使用heap_1和静态分配之间的主要区别在于内存可用性检查的执行时机。当使用*CreateStatic变体时,你将在链接时被告知没有足够的内存来支持新创建的原语。这需要在每次创建原语时添加几行额外的代码(以分配原语使用的缓冲区)。当使用heap_1时,只要执行检查(见第七章,FreeRTOS 调度器)以确定任务创建成功,那么检查将在运行时执行。许多适合heap_1实现的程序也会在启动调度器之前创建所有必需的任务。以这种方式使用动态内存分配与静态分配并没有太大的区别;它只是将检查从链接时间移动到运行时,同时减少了创建每个 RTOS 原语所需的代码量。
如果你正在开发一个只需要释放一种数据类型的程序,heap_2可能是一个选择。如果你选择走这条路,你需要非常小心地记录下这个限制,以便未来的代码维护者了解。未能理解heap_2的有限用途可能会导致内存碎片化。在最坏的情况下,碎片化可能发生在应用程序运行了一段时间之后,并且可能直到最终代码发布和硬件部署时才会发生。
当使用动态内存时,可以使用 heap_3、heap_4 或 heap_5。如前所述,heap_3 简单地包装可用的任何 C 运行时 malloc 和 free 实现,使其线程安全,以便可以被多个任务使用。这意味着其行为将取决于底层运行时实现。如果您的系统有多个不同、非连续的内存位置(例如,内部和外部 RAM),则可以使用 heap_5 将所有这些位置组合成一个堆;否则,heap_4 提供与 heap_5 相同的分配、释放和相邻块整理能力。这些都是两种通用堆实现。由于它们包括整理空闲块的代码,它们在释放内存时可能会运行不同时间段。一般来说,最好避免在需要高度确定性的代码中调用 vPortMalloc 和 vPortFree。在 heap_4 和 heap_5 中,调用 vPortFree 将具有最大的时间变化性,因为这是相邻块整理发生的时候。
通常,避免动态分配可以帮助以更少的努力提供更健壮的代码——如果内存从未被释放,内存泄漏和碎片化是不可能的。在另一端,如果您的应用程序使用了标准库函数,例如 printf 和字符串操作,您可能需要替换随 malloc 和 free 一起提供的版本,以使用线程安全的实现。让我们快速看一下确保应用程序的其他部分不会最终使用非线程安全的堆实现所涉及的内容。
替换 malloc 和 free
许多 C 运行时都会附带 malloc 的实现,但嵌入式、面向特定版本的运行时不一定默认是线程安全的。由于每个 C 运行时都不同,使 malloc 具有线程安全性的步骤也会有所不同。本书中使用的包含的 STM 工具链将 newlib-nano 作为 C 运行时库。以下是一些关于 newlib-nano 的注意事项:
-
newlib-nano使用malloc和realloc来实现stdio.h的功能(即printf)。 -
FreeRTOS 堆实现不支持
realloc。 -
FreeRTOSConfig.h包含了configUSE_NEWLIB_REENTRANT设置,以使newlib具有线程安全性,但它需要与所有存根的适当实现一起使用。这将允许您以线程安全的方式使用基于 newlib 的printf、strtok等功能。此选项还使对malloc和free的一般用例调用在任何地方都是安全的,无需您显式使用pvPortMalloc和vPortFree。
请参阅“进一步阅读”部分中的 Dave Nadler 链接,以获取更多信息以及如何在 FreeRTOS 项目中使用 GNU 工具链安全地使用newlib的详细说明。
幸运的是,这本书中包含的示例代码中没有对原始malloc的调用。通常,STM HAL USB CDC 实现会包含对malloc的调用,但这个调用被转换为一个静态定义的变量,这使得我们可以简单地使用 FreeRTOS 附带提供的堆实现。
STM 提供的 USB 堆栈中的malloc调用特别危险,因为它发生在 USB 中断中,这使得在malloc期间保证线程安全特别困难。这是因为,对于每次对malloc的调用,都需要在任务内部以及调用malloc的中断(在这种情况下是 USB)内部禁用中断。为了避免这种麻烦,动态分配被完全移除。
既然我们已经了解了使用动态分配的不同安全选项,让我们来看看 FreeRTOS 为我们提供的其他一些工具,用于报告我们的栈和堆的健康状况。
实现 FreeRTOS 内存钩子
当许多人第一次开始使用实时操作系统(RTOS)编程时,一个直接的挑战是找出如何为每个任务正确地设置栈的大小。这可能导致开发过程中的某些挫折,因为当栈溢出时,症状可能从异常行为到整个系统崩溃不等。
关注栈空间
vApplicationStackOverflowHook提供了一种非常简单的方法来消除大多数异常行为并停止应用程序。当在FreeRTOSConfig.h中启用configCHECK_FOR_STACK_OVERFLOW #define时,每当 FreeRTOS 检测到栈溢出时,vApplicationStackOverflowHook将被调用。
configCHECK_FOR_STACK_OVERFLOW有两个可能的值:
-
#define configCHECK_FOR_STACK_OVERFLOW 1:在任务退出时检查栈指针的位置。 -
#define configCHECK_FOR_STACK_OVERFLOW 2:在栈中填充一个已知的模式,并在退出时检查该模式。
第一种方法在任务退出运行状态时检查任务栈指针。如果栈指针指向一个无效位置(栈不应该在那里),则发生了溢出:

这种方法非常快,但它有可能错过一些栈溢出——例如,如果栈已经超出了最初分配的空间,但在检查时栈指针恰好指向一个有效位置,那么溢出就会被错过。为了解决这个问题,还有一个第二种方法可用。
当将configCHECK_FOR_STACK_OVERFLOW设置为 2 时,将使用方法 1,但还会采用第二种方法。不是简单地检查任务退出运行状态后栈指针的位置,而是在退出时对栈的顶部 16 个字节进行水印和分析。这样,如果在任务运行期间栈溢出并且顶部 16 个字节中的数据被修改,就可以检测到溢出:

此方法有助于确保,即使在任务执行期间的任何时刻发生了堆栈溢出(或几乎发生了),只要溢出通过了堆栈的上 16 个字,它就会被检测到。
虽然这些方法对于捕获堆栈溢出很有用,但它们并不完美——例如,如果一个数组在任务堆栈上声明并扩展到堆栈的末尾,只有数组的末尾被修改,那么堆栈溢出将不会被检测到。
因此,为了实现一个简单的钩子,在堆栈溢出发生时停止执行,我们将采取以下简单步骤:
- 在
FreeRTOSConfig.h中定义配置标志:
#define configCHECK_FOR_STACK_OVERFLOW 2
- 在一个
*.c文件中,添加堆栈溢出钩子:
void vApplicationStackOverflowHook( void )
{
__disable_irq();
while(1);
}
这种非常简单的方法禁用所有中断并执行一个无限循环,毫无疑问地表明出了问题。此时,可以使用调试器来分析哪个堆栈发生了溢出。
关注堆空间
如果你的应用程序经常使用 FreeRTOS 堆,那么你应该强烈考虑使用 configUSE_MALLOC_FAILED_HOOK 配置和相关的钩子 vApplicationMallocFailedHook。此钩子会在调用 pvMalloc() 失败时被调用。
当然,当你这样做的时候,你正在做一个负责任的程序员,检查 malloc 的返回值并处理这些错误情况……所以这个钩子可能是多余的。
设置此钩子的步骤与上一个钩子相同:
- 在
FreeRTOSConfig.h中添加以下内容:
#define configUSE_MALLOC_FAILED_HOOK 1
- 在一个
*.c文件中,添加失败的malloc钩子:
void vApplicationMallocFailedHook( void )
{
__disable_irq();
while(1);
}
此外,还有两个有用的 API 函数可以定期调用,以帮助获得可用空间的一般感觉:
-
xPortGetFreeHeapSize() -
xPortGetMinimumEverFreeHeapSize()
这些函数返回可用堆空间和记录过的最少空闲堆空间。然而,它们并不提供任何关于空闲空间是否被分割成小块的线索。
那么,如果这些安全措施中没有一项能给你足够的信心,确保你的每个任务都能与其他系统部分良好地协作,会发生什么?继续阅读!
使用内存保护单元(MPU)
内存保护单元(MPU)在硬件级别持续监控内存访问,以确保只有合法的内存访问发生;否则,会引发中断并立即采取行动。这允许许多常见的错误(否则可能一段时间内不会被注意到)立即被检测到。
使用 MPU 时,即使 vApplicationStackOverflowHook 无法检测到,像堆栈溢出这样使堆流向为另一个任务保留的内存空间的问题也会立即被捕获。当使用 MPU 时,缓冲区溢出和指针错误也会被立即阻止,这使得应用程序更加健壮。
STM32F767 MCU 包含一个 MPU。为了使用它,必须使用启用 MPU 的端口:GCC\ARM_CM4_MPU。这样,可以通过使用 xTaskCreateRestricted 创建受限制的任务,它包含以下附加参数:
typedef struct xTASK_PARAMTERS
{
pdTASK_CODE pvTaskCode;
const signed char * const pcName;
unsigned short usStackDepth;
void *pvParameters;
unsigned portBASE_TYPE uxPriority;
portSTACK_TYPE *puxStackBuffer;
xMemoryRegion xRegions[ portNUM_CONFIGURABLE_REGIONS ];
} xTaskParameters;
受限制的任务具有有限的执行和内存访问权限。
xTaskCreate 可以用来创建标准用户模式任务或特权模式任务。在特权模式下,任务可以访问整个内存映射,而在用户模式下,它只能访问自己的闪存和未配置为仅特权访问的 RAM。
为了使所有这些内容结合在一起,FreeRTOS 的 MPU 端口还需要在链接器文件中定义变量:
| 变量名 | 描述 |
|---|---|
__FLASH_segment_start__ |
闪存的起始地址 |
__FLASH_segment_end__ |
闪存的结束地址 |
__privileged_functions_end__ |
privileged_functions 命名部分的结束地址 |
__SRAM_segment_start__ |
SRAM 存储器的起始地址 |
__SRAM_segment_end__ |
SRAM 存储器的结束地址 |
__privileged_data_start__ |
privileged_data 部分的起始地址 |
__privileged_data_end__ |
privileged_data 部分的结束地址 |
这些变量将被放置在 *.LD 文件中。
恭喜!你现在可以使用 MPU 来保护无效数据访问,准备开发你的应用程序了。
摘要
在本章中,我们介绍了静态和动态内存分配,FreeRTOS 中所有可用的堆实现,以及如何实现内存钩子,以便我们可以监视我们的堆栈和堆。通过了解使用不同分配方案时需要做出的权衡,你将能够为你的每个未来项目选择最合适的方法。
在下一章中,我们将讨论在多核环境中使用 FreeRTOS 的细节。
问题
在我们总结之际,这里有一份问题列表,供你测试对本章内容的了解。你将在附录的 评估 部分找到答案:
-
使用 FreeRTOS,动态分配内存非常安全,因为它可以防止堆碎片化:
-
True
-
False
-
-
FreeRTOS 需要动态分配的内存来运行:
-
True
-
False
-
-
FreeRTOS 中有多少种不同的堆实现?
-
列出两个可以用来通知你堆栈或堆问题的钩子函数。
-
MPU 用于什么?
进一步阅读
-
《10 条规则:开发安全关键代码的法则》 由 Gerard J. Holzmann 撰写:
web.eecs.umich.edu/~imarkov/10rules.pdf -
Dave Nadler – newlib 和 FreeRTOS 重入:
www.nadler.com/embedded/newlibAndFreeRTOS.html -
FreeRTOS 栈溢出检查:
www.freertos.org/Stacks-and-stack-overflow-checking.html
第十六章:多处理器和多核系统
到目前为止,我们已经讨论了许多编程单个微控制器单元(MCU)的不同方法。但如果手头的任务需要比单个核心 MCU 能提供的更多处理能力呢?如果系统的机械约束要求使用多个物理分布在系统中的 MCU 来协同完成任务呢?如果可靠性至关重要,单个处理器的故障会导致系统灾难性故障呢?所有这些情况都需要使用多个处理核心,在某些情况下,甚至需要多个 MCU。
本章探讨了多核和多处理器解决方案及其不同的应用。首先,我们将探讨可能推动多核/处理器解决方案的不同设计要求。然后,我们将更深入地探讨 FreeRTOS 在多核/处理器系统中的不同使用方式。最后,将提出一些关于选择处理器间通信方案的建议。
简而言之,我们将涵盖以下主题:
-
介绍多核和多处理器系统
-
探索多核系统
-
探索多处理器系统
-
探索处理器间通信
-
在多核和多处理器系统之间进行选择
技术要求
本章没有技术要求。
介绍多核和多处理器系统
首先,让我们明确一下术语。多核设计是指单个芯片内部有多个 CPU,其中至少有一些内存是核心间共享的:

多核部件范围非常广泛,从具有多个相同 CPU 核心的大型 64 位部件到 ARM big.LITTLE 架构,该架构在同一封装中结合了高带宽 CPU 和节能的 MCU。最近,多核 MCU 也变得更加常见。图形处理单元(GPU)也可以归类到多核类别。
多处理器系统是指设计中包含多个处理器芯片。在我们的讨论中,这些芯片可以位于同一印刷电路板组装(PCBA)上,或者分布在一个更大系统中的不同 PCBA 上:

多核和处理器拓扑结构可以在许多不同的地方找到,例如智能手机、小型网络传感设备、工业自动化设备、测试设备、医疗设备、家用电器,当然,还包括一系列计算设备,如台式机、笔记本电脑等。
使用这两种不同的拓扑结构有很多不同的驱动因素,而不仅仅是需要更多或更快的处理能力。有时,系统需要立即上线,而无需等待完整的通用操作系统(GPOS)启动。偶尔,通过将系统功能分割到多个核心(和代码库)中,可以更容易地满足监管要求,这样只需要部分总代码(或系统)通过严格的审查。在系统中可能存在一些电磁考虑因素(如到电机/执行器的长线或敏感的模拟信号),最好通过在物理上靠近的处理器来解决。在高可靠性系统中,冗余非常常见。
现在我们对术语有了大致的了解,让我们深入了解这些系统的更多细节和用例,从多核设计开始。
探索多核系统
首先,让我们介绍几种不同的多核系统类型。它们主要有两种配置/架构:异构和同构。异构系统是具有多个核心,但它们在某些方面是不同的。与此相对的是同构系统,其中所有 CPU 都可以同等对待并互换使用。
异构多核系统
异构多核系统在同一设备中至少包含两个处理核心,并且包括核心的处理器架构或核心访问共享资源(如系统内存、外围设备或 I/O)的方式的不同。例如,在频谱的较低端,我们可以在同一芯片上拥有多个 MCU 核心。NXP 的 LPC54100 系列集成了 Cortex-M0+和 Cortex-M4,两者都运行在 150 MHz,封装在同一芯片中。
在这个设备中,MCU 核心是不同的,但它们连接到系统外围设备的方式是相同的——除了指令和数据总线,这些总线仅在 Cortex-M4 上可用:

我们可以使用这些系统以不同的方式:
-
将硬实时操作与更通用的计算分离:M0+可以处理低级别的外围或硬件访问/控制,而 M4 处理所需的更高级功能,例如 GUI 和连接性。
-
节能设计:低级别控制和接口在低功耗的 M0+上执行,仅在需要计算密集型操作时激活 M4。
由于 LPC54100 有两个 MCU 核心,我们将关注裸机编程(无操作系统)以及不需要完整内存管理单元(MMU)的操作系统,如 FreeRTOS。在两个核心上运行不同的(或多个相同操作系统的副本)被称为不对称多处理。
“不对称”这个名字来源于两个核心被不同对待的事实——它们之间存在不对称。这与在基于桌面的操作系统上使用的对称多核方法大不相同,在那种方法中,各个核心都被同等对待。对称多核系统将在“同构多核系统”部分中介绍。
例如,我们可以在两个核心的每个核心上运行多个 FreeRTOS 副本:

在这种配置中,两个核心完全独立地运行。尽管 FreeRTOS 在两个核心上运行,但核心之间没有共享的闪存程序空间——每个核心都有一个与其他核心独立的固件映像。RAM 的行为方式相同——完整的 RAM 内存空间对两个核心都是可用的,但默认情况下,每个核心将接收自己的栈、堆、全局变量等区域。
因此,每个核心都在运行自己的程序——这两个程序如何协调彼此的活动?我们需要一种方式来回传递信息——但如何?
核间通信
核间信息共享是可能的,但受到与其他任何多线程环境相同的并发访问考虑因素的影响,这就是为什么通常在芯片上包含邮箱硬件。这种硬件专门用于促进两个核心之间的通信。邮箱通常具有以下功能:
-
硬件互斥功能:用于保护两个核心之间共享的 RAM。这个想法与纯软件环境中的互斥锁相同——它们用于提供对共享资源的互斥访问。
-
中断到/自每个核心:这些中断可以在核心向共享内存区域写入数据后触发,通知其他核心有消息/数据可用。
旧应用程序扩展
我们不仅限于在两个核心上运行 FreeRTOS——可以在核心之间混合或匹配任何 RTOS 或裸机。假设已经存在一个裸机旧应用程序,但需要一些新的附加功能来利用新的机会。例如,为了保持竞争力,设备可能需要进行“翻新”并添加 GUI、Web 前端或物联网堆栈。新功能可以独立于底层旧代码开发,从而让旧代码保持大体完整且不受干扰。
例如,旧代码可以在 Cortex-M0+上运行,而新功能则添加到 Cortex-M4 中:

在这样的设置中,是否使用共享 RAM 作为核心之间的数据交换,将很大程度上取决于团队修改遗留代码库的舒适度以及应用程序的结构。例如,与其修改现有的代码库以在访问共享数据结构之前使用适当的邮箱实现的互斥锁,不如使用现有的硬件接口作为数据传输机制,将辅助 CPU 更像是一个外部客户端。由于许多遗留系统使用 UART 作为系统的主要接口,因此可以使用这些数据流作为处理器之间的接口,将修改遗留代码的影响降到最低:

这种方法通过使用较慢的接口(物理外设比简单的内存传输慢且 CPU 密集型)以及将信号路由到处理器外部来避免对现有代码库进行重大修改。尽管这种方法远非理想,但它可以在投入大量工程努力于更优雅的解决方案之前,用来测试新机会的可行性:

这种类型的方法允许团队专注于为现有系统(其核心功能不需要改变)开发新的接口,对原始系统的影响最小。
根据具体情况,也可能更有意义将遗留代码留在原始 MCU 上,而不是将其移植到新 MCU 的核心内部。每个项目可能都会有其自身的约束条件,以指导这一决策。尽管从非常高的层面来看,所有这些都可能看起来像是一项简单的任务,但每个项目通常都有一些需要考虑的隐藏复杂性。
高需求硬实时系统
在 NXP LPC54100 这样的异构多核谱的另一端,将是一个如 NXP i.Mx8 这样的设备,它包含两个 Cortex-A72,四个 Cortex-A53,两个 Cortex-M4F,一个 DSP 和两个 GPU。这种类型的系统通常用于需要极高计算密集型操作的地方,以及需要与硬件进行低延迟或硬实时交互的地方。计算机视觉、AI、目标自适应机器学习和高级闭环控制系统都是 i.Mx8 的合理应用。因此,与其将 i.Mx8(或类似的 CPU)集成到产品中,为什么不使用更通用的计算解决方案来满足这种计算能力要求呢?毕竟,通用计算机已经拥有 GPU 和多核 CPU 十年或更长时间了,对吧?
在某些系统中,运行更通用的计算硬件和操作系统可能是完全可接受的。然而,当存在硬实时需求(如果错过了实时截止日期,系统被认为已经失败)时,一个通用型操作系统(GPOS)将不足以满足需求。使用像 i.Mx8 这样的设备而不是仅仅在 CPU/GPU 组合之上使用 GPOS 的一个令人信服的理由是,它使用了能够处理硬实时任务的低延迟核心,如 Cortex-M4,这些核心具有极低的延迟和极高的可靠性。高吞吐量硬件用于执行计算上繁重的操作,在这些操作中,吞吐量很重要,但可以容忍更高的延迟和更低的确定性:

较小的基于 MCU 的核心在执行与传感器和执行器等硬件的低级交换方面非常出色。需要使用专用定时外设的时序敏感操作最好留给 MCU 硬件处理。例如,一个电机控制系统可能需要直接控制 H 桥并从使用晦涩/专有定时格式的编码器读取数据。使用具有专用定时硬件的 MCU 来实现这一点相当直接。用于电机控制和高分辨率定时捕获的差分 PWM 信号和带死区插入的信号都是相当常见的功能。所有这些紧密控制、低延迟的控制结构都可以使用 MCU 及其专用外设(无论是在裸机还是在实时操作系统上)实现,然后可以将高级命令暴露给 GPOS。具体到 i.Mx8,我们现在可以使用 MCU 执行非常低级、时序敏感的操作,同时使用高性能的 Cortex-A 处理器、DSP 和 GPU 执行计算机视觉、机器学习和人工智能所需的高级、大规模并行操作。
异构系统并不仅限于嵌入式系统!异构拓扑结构在几十年的非常大型计算集群中就已经存在,但我们仍然专注于与嵌入式空间最相关的例子。
因此,既然我们已经讨论了一些异构多核系统的例子,那么同构多核系统又如何呢?
同构多核系统
如同名字所暗示的那样,同构多核系统是指所有核心都相同的一种系统。这类多核系统传统上在桌面计算中找到。与异构系统不同,异构系统中的每个核心都针对执行几种类型的任务非常出色(正如异构系统那样),这里有多达多个完全相同的核心。而不是用特定的任务编程特定的核心,所有核心都被同等对待。这种类型的做法被称为对称多处理(系统中的所有核心之间都有对称性);它们都被同等对待。在对称系统中,核心将暴露给单个内核,而不是分成多个内核/调度器。
即使在不对称的多处理器设置中,也可能存在对称的组件。例如,前面提到的 i.Mx8 通常会将 Cortex-A53 核心设置为对称多处理配置,其中所有四个核心都可供单个内核(并且以相同的方式处理)进行调度。
但当需要在不同物理位置使用处理器时怎么办?或者如果单个处理器因为可用的引脚数量有限而限制了其功能,又会如何?
探索多处理器系统
与多核系统在分割固件功能并提供并行执行方面非常出色的方式类似,多处理器系统在许多情况下由于各种原因都很有用。让我们看看几个例子。
分布式系统
嵌入式系统通常与物理世界有大量的交互。与数字领域不同,在数字领域,1 和 0 可以毫无顾忌地发送到世界各地,而物理世界对敏感的模拟信号来说是一个残酷的地方——最小化穿越的距离可能是关键的。将模拟处理尽可能靠近其源头是一个好主意。对于一个具有模拟组件的混合信号系统,这意味着尽可能缩短信号路径,并将敏感的模拟信号处理并转换为尽可能靠近源头的数字表示:

在中等到高功率系统中,减少携带电流的电线穿越的距离,以控制电机、电磁阀和其他执行器,将减少系统的辐射电磁发射(总是一个好主意)。如果所讨论的 I/O 在物理上从系统其他部分分离出来,包括在附近放置一个 MCU,这是一种将敏感信号的数字化局部化的优秀方法,这使得系统对电磁干扰(EMI)的抵抗力更强,同时最大限度地减少布线量。在高振动和运动环境中,更少的电线意味着更少的潜在机械故障点,这导致更高的可靠性、更少的停机时间和更少的维修请求。
并行开发
使用多个处理器也使得在系统的实际开发中提供并行级别变得非常容易。由于团队通常会发现专注于一个定义良好的子系统是最容易的,因此创建多个子系统使得运行真正的并行开发(并减少整体进度)成为可能。每个子系统都可以由其自己的处理器和通信接口以及一个明确的子系统责任列表来界定:

这种方法也有鼓励每个团队独立全面测试其系统的优势,在开发过程中记录接口和功能。最后,它倾向于将集成过程中的任何惊喜降到最低,因为团队被迫在开始开发之前对整个架构进行更多思考。
设计重用
随着处理器开始连接大量的 I/O,它们可能仍然有足够的处理资源可用,但可用引脚却不足。这时,需要做出一个决定。提供端口扩展的 IC 是可用的,但应该使用它们吗?如果你在设计考虑重用的系统,重要的是要看看是否可以采用子系统方法,而不是创建一个巨大的单体设计,其中所有硬件和固件都交织在一起,紧密耦合。有时,当单个 MCU 的引脚容量达到极限时,这可能表明 MCU 正在执行几个不同子系统的功能。通常,如果将这些子系统分解并单独开发,它们可以无需修改地直接应用于未来的产品,这可以大大降低未来项目的风险和进度。
高可靠性系统
高可靠性系统通常会包括多个核心或处理器来执行其关键功能。然而,他们并不是使用额外的处理能力来运行单个并行操作,而是设置了某种程度的冗余。实现冗余的方式有很多种。创建冗余系统的一条途径是让核心同步运行。每个处理器的结果都会被仔细检查,以检测任何差异。如果发现问题,该核心(或处理器)将被离线并重置,运行一系列测试以确保它能够正确恢复——然后,它将被重新投入使用。
在这样的系统中,可能需要考虑环境因素,例如运行电机、电磁阀或其他执行器产生的 EMI。有时环境噪声的来源更为特殊,例如太阳辐射,这对于高空和太空系统通常是一个关注点。
既然我们已经探讨了在系统中拥有多个处理器可能有益的原因,那么让我们来看看如何让所有这些处理器相互通信。
探索处理器间通信
在分布式系统的背景下简要提到了处理器间通信。让我们看看在选择合适的处理器间总线时需要考虑的一些因素。
选择合适的通信介质
在选择处理器之间使用的通信介质时,有许多考虑因素,我们可以将其分为几个不同的主要类别。
第一点是时间。在实时系统中,时间考虑因素通常是至关重要的。如果一个节点之间发送的消息没有按时且完整地到达目的地,可能会产生严重的后果:
-
延迟:发送消息并接收响应需要多长时间?能够快速响应子系统之间的通信通常非常重要。
-
最大抖动:延迟有多少可变性?每个系统都有自己的要求,即可以接受的变异性。
-
错误检测/可靠性:通信介质是否提供了一种确定消息是否正确且及时接收的方法?
-
吞吐量:可以通过通信介质传输多少数据?对于包含控制数据的通信介质,吞吐量通常以消息来衡量,而不是原始数据(如 KB/秒或 MB/秒)。通常,最大可靠性和最小延迟会以原始数据传输吞吐量为代价——每个消息都会包含额外的开销和握手。
下一个考虑类别的重点是物理要求。有时,物理要求非常重要,而有时它们可能几乎不是限制。以下是一些简单的考虑点:
-
抗噪声干扰性:通信通道是否需要通过一个电气噪声环境?需要什么类型的电缆来进行适当的 EMI 屏蔽?
-
系统中的节点数量:完整的系统中需要多少个节点?由于电气限制,大多数标准都会对连接数量有一个上限。
-
距离:运行需要多长?它将是 PCB 内部的芯片到芯片的短距离运行,还是建筑物之间的长距离运行?分布式系统对不同开发者和行业可能有不同的含义。
-
所需的周边设备:可以接受多少额外的电路?可以容忍哪些类型/尺寸的连接器?
然后,我们面临的是开发团队/项目限制。每个团队和项目都是相当独特的,但有一些共同的主题应该被涵盖:
-
复杂性:需要多少代码才能使协议运行起来?所需的外部电路是否经过验证是功能性的?我们的团队是否认为解决方案提供的功能值得所需实现它所需的开发时间?
-
现有熟悉度:团队中是否有人之前使用过这种通信方案,并且这种经验是否与当前项目/产品直接相关?我们需要学习一些更适合的新东西,而不是使用我们已经熟悉但并非最佳解决方案的东西吗?
-
预算:这种通信方案是否需要任何昂贵的组件,例如特殊的集成电路、连接器或专有堆栈?在解决方案的某些方面购买或外包部分实现是否值得?
如您从长长的考虑因素列表中可以想象到的,没有一种通用的通信机制能够完美适用于所有应用。这就是为什么我们有这么多可供选择的原因。
例如,虽然工业以太网通信解决方案可能提供出色的延迟和噪声性能,但需要专用硬件的事实将使其不适合许多不需要明确要求这种硬件的应用。另一方面,低性能的串行协议,如 RS-232,可能非常容易实现,但可能会产生不可接受的电磁干扰,并且在高速使用时容易受到噪声的影响。另一方面,完整 TCP/IP 堆栈的复杂性可能会让许多潜在的采用者望而却步,除非团队中有人已经熟悉它,并且目标平台上有现成的驱动程序堆栈。
通信标准
从之前的考虑因素列表中,我们可以看出,选择处理器间通信的方法并不是一刀切。为了提供一个关于可用选项的概念,以下是一些常用总线示例,以及一些关于它们如何在多处理器系统中可能有用的简要评论。这个列表远非详尽无遗。此外,每种标准在不同的环境下都有其自身的优点。
控制器局域网
控制器局域网(CAN)是汽车行业许多子系统的通信骨干。CAN 的优点包括其健壮的物理层、优先级消息方案和多主总线仲裁。许多微控制器都包含专门的 CAN 外设,这有助于简化实现。由于扩展帧的数据字段可能仅包含多达 8 字节,因此 CAN 最适合短消息。
以太网
几乎所有中等到高性能的微控制器都提供了以太网支持,需要外部物理层、磁性和连接器进行硬件实现。这里的难点在于确保有合适的网络协议堆栈可用。这种方法的优点是,它为在 TCP 和 UDP 之上运行的流行协议提供了广泛的选择,以及易于获取且价格低廉的硬件,如果需要,可以用来构建完整的网络。
与 Modbus 类似,以太网通常会被选为外部接口,而不是处理器间总线。根据系统架构和硬件可用性,可能没有理由不能将其用于两者。
内部互连通信总线
内部互连通信总线(I2C)最常用于与低带宽外设通信,如传感器和 EEPROM。通常,MCU 将被配置为 I2C 总线主设备,带有一个或多个从 I2C。然而,许多 MCU 包含可以用于实现 I2C 主从任一侧的 I2C 控制器。I2C 协议的许多方面使其非确定性,例如,从机可以保持时钟线直到它们准备好接收更多数据(时钟拉伸)和多主仲裁。
局部互连网络
局部互连网络(LIN)是一个常用的汽车网络子系统,当完整的 CAN 过于复杂或昂贵而难以实现时,最多支持 16 个节点。LIN 物理层比 CAN 的容错性差,但它也更确定,因为只能有一个总线主设备。STM32 USARTS 通常会在外设中内置一些有用的 LIN 模式功能,但仍然需要外部 PHY IC。
Modbus
Modbus是一个历史上运行在 RS-485 物理层之上的协议,在工业领域作为外部协议非常流行(尽管现在,该协议通常运行在 TCP 之上)。Modbus 是一个相当简单的以寄存器为导向的协议。
串行外设接口
串行外设接口(SPI)也可以作为一个易于实现、高度确定性的处理器间通信介质,特别适用于从机的精度不足以实现异步串行端口上高波特率所需的紧密公差时。所有针对自定义异步协议的缺点在基于 SPI 的自定义协议中也同样存在,额外的约束是,从设备将根据主设备需要从从机(们)快速获得响应的速度来施加硬实时约束。
由于 SPI 时钟由主设备驱动,因此它是唯一可以启动传输的设备。例如,如果从机需要在接收到主设备命令后的 30 µS 内准备好响应,而从机需要 31 S,那么传输很可能是无用的。这使得 SPI 在需要紧密确定性时非常吸引人,但在其他情况下不必要地难以实现。根据环境,MCU 的板载 SPI 外设可能需要与外部差分收发器一起使用,以提高信号完整性。
USB 作为处理器间通信总线
现在更多中等到高性能的 MCU 包括 USB 主机,它作为处理器间通信总线变得越来越可行。USB 在特定应用中是否可行取决于节点数量以及完整的 USB 堆栈和能够利用它的开发者的可用性。虽然本书中使用的 USB 虚拟通信类不是确定性的,因为它使用了批量端点,但可以使用中断传输在 USB 上实现确定性的传输调度,因为它们在枚举期间以定义的速率被主机轮询。例如,在高速 USB 链路上(这通常需要外部 PHY),这相当于每 125 µS 轮询一次高达 1 KB 的消息。
在本节中,我们只是触及了处理器间通信的可能性——还有许多其他选项可供选择,每个选项都有其自己的特性、优势和劣势,具体取决于你项目的需求。
现在我们已经对多核和多处理器系统有了良好的理解,包括一些常见的拓扑结构以及处理器之间的一些通信方式,让我们退一步来评估是否需要多核或多处理器设计。
在多核和处理器系统之间进行选择
随着每月都有更强大的 MCU 和 CPU 被宣布,可供选择的选择几乎无穷无尽。多核 MCU 变得越来越普遍。但真正的问题是——你的设计中是否真的需要多个核心或多个处理器?是的,它们很容易获得,但最终这会帮助还是伤害设计呢?
何时使用多核 MCU
有几种情况下,多核 MCU 是绝佳的选择:
-
当需要真正的并行处理且空间受限时
-
当需要紧密耦合的并行执行线程时
如果你的设计空间受限,需要真正的并行处理,或者两个并行进程之间的通信速度极为关键,多核 MCU 可能是最佳选择。如果应用需要从多个核心进行并行处理,而无法使用 MCU 上已经存在的其他硬件实现——例如,并行运行多个 CPU 密集型算法——多核 MCU 可能最适合该应用。
然而,重要的是要意识到一些缺点和替代方案。多核 MCU 可能比离散 MCU 更难以替换(无论是寻找替代品还是移植代码)。应用是否真的需要在 CPU 级别进行并行执行,或者只是需要在并行执行某些操作(例如通信)?如果需要实现并行功能,而这些功能可以使用专用外设硬件(例如,使用连接到硬件外设的 DMA 填充通信缓冲区)实现,那么实现并行功能可以在不使用第二个核心的情况下完成。
多核 MCU 的一些潜在替代方案如下:
-
将一些处理任务卸载到硬件外设
-
确保尽可能多地使用 DMA。
-
多个 MCU
何时使用多处理器系统
在各种情况下,多处理器系统都很有用,例如以下情况:
-
当可能重用子系统时
-
当有多个团队可以并行处理一个大型项目时
-
当设备较大且物理上分散时
-
当 EMI(电磁干扰)考虑至关重要时
然而,尽管多处理器系统很有用,但它们确实有一些潜在的缺点:
-
与单个 MCU 相比,额外的延迟。
-
实时多处理器通信可能会变得复杂且耗时。
-
需要额外的前期规划以确保正确开发子系统。
摘要
在本章中,你被介绍了多核和多处理器系统,我们讨论了每种系统的几个例子。你现在应该了解它们之间的区别,以及在设计系统时使用这两种方法的适当性。还介绍了几个处理器间通信方案的例子,以及它们与嵌入式实时系统相关的亮点和优势。
多核和多处理器拓扑结构的好处在于,一旦你对并发系统设计的构建块有了扎实的理解(我们已经讨论过),创建具有更多核心的系统就只是将硬件放置在并发处理和抽象化影响最大的位置的问题。
在下一章中,我们将讨论你在开发过程中可能会遇到的一些问题以及一些潜在的解决方案。
问题
在我们总结的时候,这里有一份问题列表,供你测试对本章材料的理解。你将在附录的评估部分找到答案:
-
多核架构和多处理器架构之间的区别是什么?
-
在非对称多处理架构中,可以使用操作系统混合和裸机编程。
-
正确
-
错误
-
-
在选择处理器间通信总线时,应始终使用具有最高可用传输速率的总线。
-
正确
-
错误
-
-
是否应该避免使用多处理器解决方案,因为它们会增加架构的复杂性?
进一步阅读
-
NXP AN11609—LPC5410x 双核使用:
www.nxp.com/docs/en/data-sheet/LPC5410X.pdf -
Keil—USB 概念:
www.keil.com/pack/doc/mw/USB/html/_u_s_b__concepts.html
第十七章:故障排除技巧和下一步操作
本章探讨了分析并解决基于 RTOS 的系统的一些最有用的技巧和工具。在开发过程中定期检查您的系统,以及在故障排除时采取一些标准步骤,可以在评估有问题的系统时节省大量时间——事情并不总是按计划进行!在介绍了一些技巧之后,我们将探讨我们可以采取的一些下一步行动,以继续学习和提高我们的嵌入式编程技能。
在本章中,我们将涵盖以下主题:
-
有用技巧
-
使用断言
-
下一步操作
技术要求
本章不需要硬件或软件。
有用技巧
如果您只使用过裸机编程方法,那么开始使用 RTOS 可能会相当不同,尤其是如果您也从 8 位 MCU 切换到 32 位 MCU,如本书中使用的 STM32F7,那么更是如此。以下是一些可以帮助您保持项目进度并在出现问题时解决问题的技巧。
使用工具分析线程
能够清楚地了解系统中所有线程正在做什么对新手和专家都有很大帮助。工具在这方面特别有用。使用可视化工具,如 SEGGER SystemView 或 Percepio Tracealyzer,在理解系统中各种任务和中断之间的交互时可能非常有价值(有关详细信息,请参阅第六章,实时系统调试工具)。
拥有一个 RTOS 感知调试器也非常有帮助,因为它允许我们堆叠多个任务的分析。这个调试器可以是您 IDE 的一部分,也可以是像 SEGGER Ozone 这样的独立调试器(请参阅第五章,选择 IDE和第六章,实时系统调试工具)。
关注内存使用
在使用 RTOS 时,内存使用是一个非常重要的考虑因素。与具有单个堆栈的超循环不同——堆栈和堆栈会消耗任何剩余的 RAM——每个 FreeRTOS 任务的堆栈需要显式设置大小。在第十五章,FreeRTOS 内存管理,关注堆栈空间部分中,我们向您展示了如何观察可用的堆栈空间,以及如果检测到溢出如何实现钩子。
如果您的应用程序正在使用动态内存分配,您应强烈考虑启用并实施 FreeRTOS 提供的失败 MALLOC 钩子。这已在第十五章,FreeRTOS 内存管理,关注堆空间部分中介绍。
栈溢出检查
如果你有可用的内存保护单元,利用它是一个极好的主意,因为它将比任何基于软件的解决方案更可靠地检测到诸如堆栈溢出之类的访问违规(参见第十五章,FreeRTOS 内存管理,使用内存保护单元部分)。
另一种监视堆栈的方法是设置堆栈监控,这在第十五章,FreeRTOS 内存管理,监视堆栈空间部分也有介绍。
在下一节中,我们将介绍如何通过使用断言来调试具有堆栈溢出并检查内存的系统。
修复 SystemView 丢失的数据
在本书中我们查看的示例中,SystemView 显示我们可以通过在 MCU 上运行代码来存储事件到本地缓冲区,从而进行数据可视化流。然后,缓冲区的内容通过调试硬件传输到 PC 进行查看。有时,在高负载期间,你会在跟踪中看到大红色块,如下面的截图所示:

这些块表明 SystemView 检测到了丢失的数据包。可以通过以下任何一种方式来减少丢失数据包的频率:
- 增加 MCU 上 SystemView 缓冲区的大小。
SEGGER_SYSVIEW_Conf.h在 132 行定义了缓冲区。重要的是要注意,由于此缓冲区位于 MCU 上,增加缓冲区的大小将减少其他代码可用的内存。
#define SEGGER_SYSVIEW_RTT_BUFFER_SIZE
-
在目标接口和速度下增加调试器的时钟速度。在某些情况下,支持更快时钟的调试器可能会有所帮助(例如,专门的 SEGGER J-Link 或 J-Trace)。
-
在 SystemView 运行时减少对调试硬件的流量。为此,例如,你可以在打开的调试会话中关闭任何实时跟踪窗口(如 Ozone 或 STM32CubeIDE)。
在下一节中,我们将学习如何通过使用断言来调试我们的系统。
使用断言
断言是捕捉那些“根本不应该发生”的情况的绝佳工具。它们为我们提供了一种简单的方式来检查假设。请参阅第七章,FreeRTOS 调度器,创建任务 – 检查返回值部分,了解如何添加简单的断言以防止系统处于不可接受状态时运行代码的示例。
FreeRTOS 的断言构造的特殊版本是configAssert。
configAssert
configAssert在 FreeRTOS 中被用作防止配置不当系统的一种方式。有时,它会在 ISR 内部调用 API 的非中断版本时被触发。通常,中断内部的代码会尝试调用 FreeRTOS API,但其逻辑优先级高于 RTOS 允许的优先级。
与其让应用程序在未定义的行为下运行,FreeRTOS 会定期测试一组断言以确保所有先决条件都已满足。单独来看,这些检查有助于防止系统完全失控,而无法找出问题所在。相反,当出现无效条件时,系统会立即停止。FreeRTOS 还包含了关于断言失败的根本原因的详细文档(有时会链接到基于网络的文档)。
千万不要通过任何方式掩盖configAssert。它们通常是存在严重配置问题的第一个通知。禁用断言只会使根本问题更加复杂,使得以后更难找到。
让我们通过一个例子来了解系统因configAssert而停止时的正常症状可能是什么样,以及可以采取的步骤来诊断和解决根本问题。
使用 configAssert()调试挂起系统
当你第一次启动代码库并创建一些示例代码来介绍 SystemView 时,需要解决几个问题。
这里是我们的例子:
确保所有代码在语法上正确,LED 闪烁后,是时候将 SystemView 连接到运行中的应用程序,并获取一些时序图。第一次连接 SystemView 时,会显示一些事件,但随后系统变得无响应:
-
LED 停止闪烁
-
在 SystemView 中没有显示任何其他事件,如下面的截图所示:

让我们在几个步骤中诊断并解决根本问题。
收集数据
有时候,猜测可能发生的事情或对系统做出假设是很诱人的。而不会做这两件事,我们只需将调试器连接到系统,看看问题是什么。
由于 SEGGER Ozone 在连接运行中的系统而不修改其状态方面特别出色,我们能够连接到挂起的应用程序而不会破坏任何东西。这允许我们在应用程序崩溃后开始调试,即使它之前没有通过调试器运行。这在产品开发期间非常有用,因为它允许我们正常运行系统,而无需不断从调试器启动。让我们学习如何做到这一点:
-
使用与目标上运行相同的代码设置 Ozone。注意,开发板必须通过 USB 连接(有关详细信息,请参阅第六章,实时系统调试工具)。
-
然后,选择附加到运行程序:

- 在附加并暂停执行后,我们会看到以下屏幕,并立即能够进行一些观察:

注意以下内容:
-
由于断言失败,我们花费了所有的时间在一个无限循环中,因此 LED 停止闪烁。
-
通过查看调用栈,我们可以看到有问题的函数是
SEGGER_SYSVIEW_RecordSystime,它显然调用了名为_cbGetTime的函数,而_cbGetTime又调用了xTaskGetTickCountFromISR。 -
阅读第 760 行以上的详细注释,听起来可能有一些配置错误的 NVIC 优先级位。
-
ulMaxPROGROUPValue的最大可接受值(可以通过悬停在所选变量上查看)是1。
既然我们知道哪个断言失败了,现在是时候找出它为什么失败的根本原因了。
深入挖掘 – SystemView 数据断点
到目前为止,我们已经确定了处理器卡住的地方,但还没有发现任何帮助我们确定需要更改什么才能使系统再次运行的信息。以下是我们需要采取的步骤来找出问题的根本原因:
- 让我们再次查看断言。在这里,我们的目标是找出它为什么失败的确切原因。运行以下命令:
configASSERT( ( portAIRCR_REG & portPRIORITY_GROUP_MASK ) <= ulMaxPRIGROUPValue );
- 使用 SystemView 的内存查看器,分析
port.c中portAIRCR_REG的值:

- 由于这是一个硬编码的内存位置,我们可以设置数据断点,每次写入该内存位置时都会暂停执行。这可以是一种快速跟踪变量所有访问方式的方法,而无需尝试搜索代码:

- 在重启 MCU 后,写入断点立即被触发。尽管程序计数器指向
HAL_InitTick,但实际写入0xE000ED0C地址的数据是在上一个函数中完成的,即HAL_NVIC_SetPriorityGrouping。这正是我们所期望的,因为断言与中断优先级组相关:

- 在代码中快速搜索
NVIC_PRIORITYGROUP_4可以在stm32f7xx_hal_cortex.c中找到以下注释:
* @arg NVIC_PRIORITYGROUP_4: 4 bits for preemption priority
* 0 bits for subpriority
优先级分组:中断控制器(NVIC)允许定义每个中断优先级的位在定义中断抢占优先级的位和定义中断子优先级的位之间分割。为了简单起见,所有位都必须定义为抢占优先级位。如果这不是这样(如果某些位代表子优先级),以下断言将失败。
根据这些信息,子优先级应该有 0 个位。那么,为什么 portAIRCR_REG 中的优先级位值不为零呢?
根据 ARM® Cortex® -M7 设备通用用户指南,我们可以看到,为了达到 0 个子优先级位,AIRCR 寄存器与 0x00000700 进行掩码后的值必须为 0(当我们查看内存中的值时,它有 3 的值):

这里是同一手册中关于PRIGROUP的解释。注意,PRIGROUP必须设置为 0b000 以有 0 个子优先级位:

这确实需要进一步调查...为什么PRIOGROUP的值是 3 而不是 0?让我们再次查看那个configAssert()行:
configASSERT( ( portAIRCR_REG & portPRIORITY_GROUP_MASK ) <= ulMaxPRIGROUPValue );
注意以下port.c中ulMaxPRIOGROUPValue的定义。它被定义为静态的,这意味着它在内存中有一个永久的位置:
#if( configASSERT_DEFINED == 1 )
static uint8_t ucMaxSysCallPriority = 0;
static uint32_t ulMaxPRIGROUPValue = 0;
让我们为ulMaxPRIGROUPValue设置另一个数据断点,并再次重启 MCU,但这次,我们将每次访问时都进行监视:
-
如预期,
BaseType_t xPortStartScheduler(void)函数在port.c中访问了某个内容。 -
关于数据访问断点的好奇之处在于,它在程序计数器位于
SEGGER_RTT.c时被触发,这看起来不太对,因为ulMaxPRIGROUPValue在port.c的xPortStartScheduler中是私有作用域。 -
查看调试器——问题就在眼前:
-
ulMaxPRIGROUPValue静态变量存储在0x2000 0750。 -
数据写入断点在栈指针为
0x200 0740时被触发。 -
栈已被越界:
-

我们刚刚发现了一个栈溢出。它表现为对静态变量的写入(恰好触发了一个与系统无关部分的configAssert)。这种完全出乎意料的行为是栈溢出的常见副作用。
目前,main.c中每个栈的最小值已被设置为 128 个单词(1 个单词=4 字节),因此将其增加到 256 个单词(1 KB)为我们提供了足够的余量。
这个例子相当典型地展示了当向先前运行良好的现有任务添加功能时会发生什么。如果新功能需要调用更多函数(每个函数都有自己的局部变量),这些变量将消耗栈空间。在这个例子中,这个问题是在向现有任务添加 SEGGER 打印功能后才出现的。因为没有额外的栈空间可用,任务溢出了栈并破坏了另一个任务使用的内存。
如果我们设置了栈溢出钩子,这个问题很可能会被发现——如果使用 MPU 端口,它肯定会被发现。
下一步
现在你已经通读了这本书,并对每个现成的示例进行了尝试——等等...你还没有运行示例吗?!现在是时候开始运行它们了!之所以包含这些示例,是因为实际操作经验将有助于加深这些概念的理解,同时为你提供宝贵的实践机会和可用于自己项目的开发环境。
因此,假设你已经运行了包含的示例,要更深入地了解 FreeRTOS,一个很好的下一步是阅读理查德·巴里的书籍,《Mastering the FreeRTOS™ Real-Time Kernel》。这本书侧重于如何应用开始嵌入式系统开发并建立未来发展的坚实基础所需的一般知识。然而,《Mastering FreeRTOS》专注于 FreeRTOS 的具体细节,并为每个 API 提供示例。拥有一个硬件环境、对基础知识的初步理解以及调试/可视化工具将帮助你最大限度地利用他的书籍。在系统运行起来之后,Mastering the FreeRTOS™ Real-Time Kernel中提供的代码可以很容易地使用真实硬件和可视化调试系统进行测试和实验。
当我们谈论构建坚实基础的话题时,你可能会想要考虑熟悉测试驱动开发。正如我们在第十二章《创建良好抽象架构的技巧》和第十三章《使用队列创建松耦合》中所做的那样,开始创建松耦合代码,测试这些子系统是自然的下一步。詹姆斯·格林宁在他的网站上提供了许多资源(blog.wingman-sw.com),特别是针对嵌入式 C/C++的测试驱动开发资源。其他针对嵌入式 C 的 TDD 资源包括马特·切尔诺斯基的网站(www.electronvector.com/)和独特的Throw the Switch(www.throwtheswitch.org/)。一个由数十年的实践经验积累而成的全面嵌入式资源是杰克·甘斯勒的网站,你可以通过www.ganssle.com/访问。
摘要
在这一章的最后一章中,我们介绍了一些技巧,这些技巧将帮助你平滑 RTOS 旅程中的一些颠簸,以及一些建议的下一步行动。
就这样,各位!我希望你们喜欢这个使用 FreeRTOS、STM32 和 SEGGER 工具开发实时嵌入式系统固件的动手介绍。现在,是时候出去开始理解系统、解决问题和分析你的解决方案了!我很乐意听听你是如何应用这本书中学到的知识的——在 LinkedIn、Twitter 或 GitHub 上给我留言!如果你真的很喜欢这本书,并认为其他人也会喜欢它,考虑留下评论——它们有助于传播信息!
问题
在我们结束这本书的时候,这里有一份问题列表,供你测试你对本章材料的了解。你将在附录的评估部分找到答案:
-
当你在系统中添加中断或使用新的 RTOS 原语后,系统崩溃了,你应该采取哪些步骤?
-
列举一个在开发实时操作系统(RTOS)时,由固件引起的意外行为(问题)的常见原因。
-
由于您的系统没有输出数据的方式(没有暴露的串行端口或通信接口),将无法进行调试。
-
正确
-
错误
-
第十八章:评估
第一章
-
No. 具有实时要求的系统简单来说就是动作需要是确定的。时间要求由每个系统的需求决定。
-
No. 实现实时性能有几种不同的方法。
-
No.
-
任何对给定事件有确定响应的系统都可以被认为是实时的。
-
大多数工业控制、闭环控制系统、无人机飞行控制器、防抱死制动系统(ABS)、发动机控制单元(ECU)、喷墨打印机、测试设备(如示波器和网络分析仪)等等。
-
基于微控制器(MCU)的实时操作系统(RTOS)的强项是中等复杂度的系统。
第一章
-
上述两种选项。
-
错误。
-
复杂的超循环往往在执行循环所需的时间上有很大的变化。这可能导致系统中的确定性较差,因为没有简单的方法来提供一种让高优先级工作在循环中优先于其他所有操作的手段。
-
中断和直接内存访问(DMA)都可以用来提高超级循环对外部事件的响应。它们允许在超级循环周期内无需轮询即可服务硬件外设。
-
系统中只有一个超循环正在运行。它共享系统堆栈。然而,任务各自接收它们自己的专用堆栈。每个任务都接收一个优先级,这与没有优先级概念的超级循环不同。
-
优先级。
-
预先占先的调度器试图确保具有最高优先级的任务总是正在执行的任务。
第三章
-
队列。
-
是的。
-
信号量。
-
网络堆栈或任何必须强制执行最大同时用户数量的地方。
-
优先级继承。
-
互斥锁。
-
优先级反转允许低优先级任务优先于高优先级任务。这是危险的,因为它增加了高优先级任务错过截止日期的可能性。
第四章
-
固件编程,尤其是对于微控制器(MCU),非常接近底层,这意味着它非常接近硬件。通常有硬件特定的功能,固件工程师必须熟悉这些功能才能从 MCU 中获得最佳性能。
-
错误。
-
硬件外设。
-
快速原型设计、现有硬件、社区、不同微控制器(MCU)之间的一致性高级 API。
-
评估板通常展示了产品的关键差异化特性。它们也被设计得尽可能完整,便于访问设备的各个方面。
-
睡眠电流、唤醒时间、功耗(uA/MHz)、低功耗模式的功能以及电源电压。
-
为了使其对尽可能多的读者都易于访问——所以请确保购买一个并使用真实硬件上的练习!
第五章
-
错误。理想的集成开发环境(IDE)将反映个人/组织偏好。一个非常适合某个团队或工作流程的特定 IDE 可能不适合其他地方。
-
错误。许多免费可用的集成开发环境非常适合专业嵌入式系统开发。
-
错误。供应商提供的集成开发环境在质量上可能会有很大差异。小心不要过于依赖供应商的 IDE,尤其是如果您更喜欢使用其他供应商的 MCU。
-
错误。至少,我们期望软件生成的代码第一次就能在语法上正确。除此之外,代码生成的好坏取决于提供它的前端,这通常比底层代码库演变得更慢(因此您稍后仍需要编写自定义代码)。
-
错误。本书的 IDE 是根据成本选择的,并且只考虑了与 STM32 设备的兼容性。
-
设备选择、硬件启动和中间件集成。为什么它在这些领域都有用,在考虑 STMCube部分中进行了说明。
第六章
-
错误。在本章中,Nucleo 开发板上的 ST-Link 被重新烧录以提供与 J-Link 相同的功能。
-
错误。有许多方法可以验证实时系统的时序要求。Segger SystemView 提供了一种测量响应时间的方法,查看系统输入和输出也可以通过传统的逻辑分析仪来实现。
-
错误。RTOS 感知调试器提供了查看系统中所有堆栈的能力。这也是任何基于 Open GDB 的调试使用 Eclipse 时的一个选项,如前一章所述。
-
错误。您编写的每个模块都应该尽可能彻底地进行测试,以最小化在集成模块和进行系统级测试时出现的任何惊喜和复杂交互。
-
单元测试。在单元测试中,每个模块在开发过程中都会被测试。集成测试是在多个模块“集成”后确保它们按预期工作的测试。系统测试是在所有内容都集成后测试整个系统(通常在集成之后)。黑盒测试是一种测试风格,它对“黑盒”内部的系统一无所知,并且仅将输出与给定的输入集的预期行为进行比较。
-
测试驱动开发(TDD)。
第七章
-
有两种选项——
xTaskCreate()和xTaskCreateStatic()。 -
正确。如果所需的内存不可用,
xTaskCreate()可能会失败。 -
正确。如果空闲任务的所需内存不可用,
vTaskStartScheduler()可能会失败。 -
错误。每个任务所需的 RAM 为 64 字节加上任务堆栈大小。确切的堆栈大小需求完全取决于您的代码,而不是 FreeRTOS。
-
错误。可以通过调用
vTaskDelete()来删除任务,前提是使用兼容的堆(有关详细信息,请参阅第十五章,FreeRTOS 内存管理)。
第八章
-
同步;共享资源保护。
-
优先级反转(访问优先级反转(如何不使用信号量)部分以获取详细信息)
-
MUTutual EXclusion,指的是对共享资源访问的控制方式。
-
它通过自动提高等待高优先级任务所持有的互斥锁的低优先级任务的优先级,确保高优先级任务尽可能少地阻塞,从而限制优先级反转。
-
错误。尽管易于使用,但软件定时器有其局限性,包括抖动和频率。
第九章
-
由于底层有
void*输入参数,队列可以持有任何数据类型。 -
正在等待向队列发送数据的任务被置于阻塞状态(如果指定了
portMAX_DELAY,则挂起)。 -
提到了三个考虑因素:底层值的拥有权、确保将正确的数据类型传递到队列中,以及确保数据保持完整(通过不在易变堆栈上放置它)。
-
错误。任务通知只存储单个
uint32_t,并允许一个具有已知任务句柄的单个任务被解除阻塞。队列能够存储任何数据类型,并且可以在多个任意任务之间使用。 -
错误。任务通知只存储单个
uint32_t。 -
速度和 RAM 效率。
第十章
-
中断驱动的驱动程序更复杂,因为至少涉及三段代码(设置代码、ISR 代码和回调代码)。使用轮询驱动程序,所有这些操作都是串行发生的。
-
错误。只有以
FromISR结尾的函数才可以在中断服务例程(ISR)中调用。 -
错误。由于调度器应配置为从最低优先级中断运行,因此中断优先于调度器。
-
DMA – 它使用硬件在外围设备和内存之间传输数据,无需任何 CPU 干预。
-
直接内存访问。
-
在任何时间点尝试接收数据,使用原始缓冲区很难做得很好。当接收未知长度的数据时,原始缓冲区也可能变得有点复杂。
第十一章
-
错误。
-
错误。在决定共享硬件外设是否可接受之前,必须考虑诸如增加延迟和降低确定性以及更少的通信带宽等时间权衡。
-
所有上述内容。
-
错误。流缓冲区可以被单个写者和单个读者使用。这些写者和读者不需要是同一个任务。如果有多个写者或多个读者,那么需要一个同步机制(例如互斥锁)。
-
互斥锁。
第十二章
-
错误。抽象在最小的 MCU 中也是有用的。
-
错误。本章介绍了实现一致接口的方法。
-
可能的答案包括以下内容:
-
常用组件将在其他项目中重用。
-
适应不同硬件的便携性是可取的。
-
代码将被单元测试。
-
团队将并行工作。
-
-
错误。有关更多详细信息,请参阅避免复制粘贴修改陷阱部分。
-
错误。当正确编写时,任务可以是跨项目重用的优秀候选者(有关更多详细信息,请参阅重用包含任务的代码部分)。
第十三章
-
错误。队列创建了一个明确的接口,从而将组件彼此解耦。
-
错误。任何数据类型都可以放入队列中。
-
不,省略底层格式化允许队列项的生产者有更多的灵活性。如果数据没有绑定到特定格式,则可以修改格式而不会影响队列或从队列中出来的数据消费者。
-
可能的答案包括以下:
-
由于创建了副本,因此不需要考虑队列项的生存期。
-
如果通过值传递到队列中,则不需要考虑队列项的作用域。
-
如果通过引用传递项目,则需要清楚地了解谁拥有该项目,以及谁负责释放与其相关的资源。
-
-
可能的答案包括以下:
-
深队列引入的延迟
-
由于请求在队列中等待而不是立即执行(或拒绝)而引起的非确定性行为
-
内存限制
-
第十四章
-
CMSIS-RTOS代表Cortex 微控制器软件接口标准 - 实时操作系统。CMSIS-RTOS 规范由 ARM 编写,但许多供应商可以选择在其 RTOS 中提供符合 CMSIS-RTOS 的接口。
-
Linux 和 Android。
-
错误。
-
错误。
第十五章
-
错误。
-
错误。
-
有五种实现:
heap_1.c至heap_5.c。 -
vApplicationStackOverflowHook和vApplicationMallocFailedHook。 -
MPU代表内存保护单元。它用于防止非法内存访问,特别是作为划分任务的方式,以便它们只能访问自己的内存空间。
第十六章
-
多核意味着同一 IC 上的多个核心,而多处理器意味着同一设计中的多个处理器(IC)。
-
正确。非对称架构不需要以相同的方式处理各种处理核心,因此可以使用任何操作系统和裸机编程语言的组合(在硬件限制范围内)。
-
错误。在选择特定应用的最佳总线时需要考虑许多方面,因为每个应用都将有其独特的环境和需求。
-
需要权衡额外的复杂性以及不执行相同工作的可能性。当开发可重用子系统时,在适当的情况下可以创造相当大的成本节约。它们在重用时几乎没有或没有非重复工程(NRE)成本。
第十七章
-
你应该做以下:
-
连接调试器。
-
找出程序停止的地方。
-
如果是
configASSERT,请阅读围绕断言的注释。如果它在调度器启动之前失败,你很可能已经溢出了 FreeRTOS 堆。
-
-
以下任何一个:
-
任务堆栈溢出
-
优先级错误的 ISR
-
堆大小不足
-
-
错误。存在调试工具,如 Segger SystemView,它们提供 printf 风格的输出以及用于观察代码行为的仪器功能。


浙公网安备 33010602011771号