Quantum-Leaps-现代嵌入式系统编程笔记-全-

Quantum Leaps 现代嵌入式系统编程笔记(全)

0:入门指南 🚀

在本节课中,我们将学习如何开始现代嵌入式系统编程课程。我们将介绍课程的核心目标、所需的软硬件准备,以及如何设置开发环境。课程旨在从基础开始,深入讲解嵌入式微控制器的编程实践。

课程概述

欢迎来到现代嵌入式系统编程课程。在本课程中,你将学习如何以现代方式对嵌入式微控制器进行编程。内容从基础知识开始,一直延伸到当代的嵌入式编程实践。

本课程的独特之处在于,它会频繁深入到机器层面,向你展示嵌入式微控制器内部发生的具体过程。这种更深层次的理解将使你能够更高效、更自信地应用相关概念。

我的名字是 Miro Samek,我是一名拥有超过30年经验的嵌入式软件工程师。我热爱教学,我的视频课程、书籍、文章和会议演讲帮助了许多开发者提升技能、通过面试并获得嵌入式编程职位。

课程的相关性与先决条件

这门嵌入式编程课程自2013年持续至今。人们常问,它是否仍然具有现实意义。答案是肯定的,甚至可能比最初更为重要,主要有两个原因。

首先,本课程专注于嵌入式编程中那些永不过时的核心和基础概念。其次,本课程基于当前主流的 ARM Cortex-M 架构,该架构的普及度已大幅提升。掌握 ARM Cortex-M 是雇主最看重的技能之一。

关于本课程的先决条件,虽然我从基础开始讲解,但这部分内容简短且专注于 C 语言编程的嵌入式方面。因此,你可能需要额外学习 C 编程语言来补充本课程。了解 CPU 的工作原理也会很有帮助。

硬件与软件准备

为了从本课程中获得最大收益,你应该跟随课程并在你的电脑上运行讨论的项目。为此,你需要一些硬件(一块嵌入式开发板)和软件(一套嵌入式开发工具链)。

本课程主要使用的嵌入式开发板是德州仪器基于 ARM Cortex-M4F CPU 的 Tiva C Launchpad。这块板子价格低廉、功能齐全,并内置了硬件调试器,支持单步调试和检查 CPU 内部状态,这对本课程至关重要。该板仍在生产,可从众多分销商处购买。

课程下载内容现在也包含基于 ARM Cortex-M0 CPU 的较新的 STM32 Nucleo-C031C6 开发板的项目版本。这块板子同样价格低廉、功能齐全,并内置了功能更丰富的硬件调试器。未来可能会添加其他类似的低成本 ARM Cortex-M 开发板。

此外,课程最初的几节课会使用模拟器,因此你不需要立即拥有硬件。然而,后续涉及与微控制器外设交互的课程则需要一块实际的嵌入式开发板。

硬件驱动安装

当你首次收到嵌入式开发板并将其插入 USB 端口后,需要检查 USB 驱动是否正确安装。通过右键点击 Windows 图标并选择“设备管理器”选项来打开设备管理器。

对于 Tiva C 开发板,正确的驱动应该是“Stellaris In-Circuit Debug Interface”。如果你看到其他内容,则需要重新安装驱动。为此,你首先需要下载正确的 USB 驱动,可以按照开发板包装上的说明,或从本视频课程的配套页面 statemachine.com/videocourse 下载。

下载后,将驱动解压到磁盘上的某个位置。接下来,右键点击设备描述,选择“更新驱动程序”。选择“浏览我的计算机以查找驱动程序”,并指向你解压驱动的目录。

如果因任何原因 USB 安装不成功,你可以随时点击“卸载设备”,然后再次尝试更新驱动程序。上述过程对于给定的开发板只需执行一次,但当你获得不同的开发板时(例如 Nucleo 板需要使用 ST-LINK 驱动),则需要重复此过程。

软件开发工具链

就软件而言,你需要一套嵌入式开发工具链。本课程最初使用的是 IAR Embedded Workbench for ARM,这是一个专业的工具集,拥有良好的编译器和稳定的调试器。多年来,该工具曾提供免费的、有大小限制的 Kickstart 许可证。但最近情况发生了变化,目前唯一剩下的免费选项似乎是两周的评估许可证。

因此,本课程的项目下载已更新,现在包含适用于多个工具集的版本,包括我稍后介绍的 Keil MDK。但回到 IAR,如果你希望使用与视频第1至18课相同的工具集,可以下载 IAR 评估版。安装过程简单直接,尽管需要一些时间。我建议将工具集安装到没有空格和特殊字符的目录名中,例如,我将我的 IAR 工具集安装在 C:\tools\iar 目录。

首次启动 IAR Embedded Workbench 后,你可能需要申请许可证。你需要向 IAR 注册并通过电子邮件接收许可证密钥。获得后,你需要将其输入许可证对话框中。

Keil MDK(微控制器开发套件)是本视频课程中使用的另一个专业开发工具集。与 IAR Embedded Workbench 不同,Keil MDK 提供了越来越宽松的许可,包括免费的 Keil MDK v6 社区版。本课程从第21课开始使用 Keil MDK,但所有课程(包括最初使用 IAR Embedded Workbench 或 TI Code Composer Studio 的第1至21课)都已添加了 Keil 版本的项目。

对于本课程,你需要下载 Keil Microvision。

在下一页,点击“Download Keil MDK”。接下来,你需要登录 ARM 开发者门户。如果你没有账户,必须先注册。

登录后,你最终可以下载最新的 MDK 版本。MDK 安装过程简单直接,MDK 工具集和所谓的 MDK 软件包的默认安装位置是可以接受的。

安装的最后一步会启动 Keil 软件包安装程序,但你现在可以关闭它,因为当你打开一个 Keil Microvision 项目时,软件包的用途会更清晰。我稍后会解释如何获取本课程的项目,但为了完成工具集安装,让我们通过双击文件资源管理器中的项目文件来打开一个 Keil 项目,例如第1课的项目。

当这样的项目首次打开时,你会看到一个对话框,提示所需的设备系列软件包缺失。这意味着项目使用的设备(本例中是 TM4C)的信息工具集尚未获取。当你点击“安装”时,软件包安装程序会自动下载并安装该特定设备的信息。

接下来你需要处理的问题是许可证安装。选择菜单“File” -> “License Management”,在对话框中点击“Get License via Internet”。这将带你到 ARM Keil 网站,你需要填写一个冗长的注册表格,但最终你应该会通过电子邮件收到你的 MDK 社区版许可证。

你需要解决的最后一个问题涉及 Tiva C Launchpad 板上的 Stellaris In-Circuit Debugger。在项目视图中,右键点击“Debug”目标菜单,选择“Options for Target ‘Debug’”。接下来,点击“Debug”选项卡并展开下拉列表。Stellaris ICDI 选项缺失,因为 Keil MDK 默认不再支持它。然而,你可以通过从本视频课程的配套网页下载 MDK 扩展来添加 Stellaris ICDI 支持。

下载后,只需运行提供的安装程序即可。现在,当你为 Tiva C 打开任何 Keil 项目时,Stellaris ICDI 调试器就可用。

获取课程项目

在视频的最后,我想描述一下如何下载本课程各课的项目。同时,我需要解释项目的新结构,因为它与视频中显示的内容相比已经发生了变化。

所有课程的项目都可以从 state-machine.com/videocourse 下载,这是本课程的主要资源。此外,一个日益重要的资源是 GitHub 仓库,它也托管了所有项目。

项目采用分层结构组织,以适应多种嵌入式开发板和工具集,并为未来提供可扩展性。例如,第4课的项目位于 lesson-04 目录中。在里面,你可以找到以嵌入式开发板和嵌入式工具集命名的子目录。

Tiva C 的项目缩写为 tm4c123,而 STM32 Nucleo 板缩写为 stm32c031。后面跟着嵌入式工具集的名称,如 iarkeil。在这些子目录中,你可以找到对应工具集的实际项目、源代码以及项目所需的所有其他代码。这使得项目自包含且没有外部依赖。

特殊情况是模拟器项目,它们不需要物理开发板,尽管它们是为具体目标(通常是 Tiva C)准备的。

总结

本节课我们一起学习了现代嵌入式系统编程课程的入门知识。我们了解了课程的目标、相关性和先决条件,详细介绍了所需的硬件(如 Tiva C Launchpad 和 STM32 Nucleo 开发板)和软件(如 IAR 和 Keil 工具链)的准备步骤,包括驱动安装和许可证获取。最后,我们说明了如何下载和浏览结构化的课程项目文件。希望这些信息能帮助你顺利开始学习,并以全新的视角思考嵌入式编程。

1:计算机如何计数 🔢

在本节课中,我们将学习计算机如何计数。我们将从创建一个简单的C语言项目开始,逐步探索变量、内存、寄存器以及计算机内部数字的表示方式,最终在真实的硬件上运行我们的程序。


项目创建与配置

首先,我们需要创建一个新的项目。启动IAR Embedded Workbench,选择 Project -> Create New Project 菜单。展开C项目类型,选择 main 模板,然后点击 OK

在弹出的文件浏览器中,选择项目存放的目录。建议为整个课程创建一个主目录(例如 C:\embedded_programming),并在其中为每节课创建子目录。为本节课创建一个名为 lesson_01 的子目录,进入该目录,将项目命名为 project,然后点击 Save

项目创建后,会生成一个 main.c 文件。在深入代码之前,我们需要配置一些项目参数。

点击 Project -> Options 菜单。在 Target 标签页下,选择处理器型号。点击 Device 旁边的选择按钮,找到并选择 Texas Instruments -> LM4F120H5QR 设备。

接下来,选择 C/C++ Compiler 类别。确保默认语言是 C,并且 C dialect 设置为 C99。本课程将使用这个较新的C语言标准。

最后,点击 Optimization 标签页,确保优化级别设置为 Low。在课程初期,我们的程序在高优化级别下可能无法正确运行,直到我们学会如何编写可被高度优化的代码。

为了获得更好的编程体验,我们还可以定制开发环境。点击 Tools -> Options 菜单。在这里,你可以更改字体(例如,我更喜欢 Lucida Console 等宽字体)。在 Editor 部分,将缩进设置为4个空格,并选择使用空格而非制表符。强烈建议在代码中避免使用制表符,因为它在不同设备(如打印机)上的显示效果不一致。


编写第一个程序

现在,让我们查看并修改IAR工具集生成的代码。首先,验证这是一个有效的C程序。通过编译代码来完成验证,编译过程由称为编译器的程序执行。

Project 菜单中,选择 Make 选项(或使用快捷键 F7)。由于是首次构建此项目,工具会要求输入工作空间名称。输入一个通用名称,如 workspace,然后按回车。

构建完成后,显示 0 errors0 warnings。恭喜你,你的第一个合法的C程序诞生了!😊

接下来,按照我偏好的风格重新格式化生成的代码:使用节省行数的花括号放置方式和4个空格的缩进。编译器本身不关心格式,即使代码写成一行长串也能接受。但良好的格式对于需要阅读和维护代码的人来说至关重要。

当然,并非你对代码的所有修改都是合法的。例如,让我们故意输入一些非法内容,然后再次按 F7 编译。这次,编译器会报告错误。双击错误报告,工具会直接定位到代码中的问题位置,这非常方便。修复问题后,再次按 F7 让编译器检查,这是一个好习惯。

我想从一开始就让你相信,编译器是你最好的朋友,它时刻关注着你的代码。你需要做的就是经常按 F7,给它机会来帮助你。


变量与计数

现在,让我们定义一个计数器变量,用它来展示计算机如何计数。变量是计算机内存中用于存储值(如数字)的位置。在C语言中,你必须先定义变量才能使用它。

定义变量需要指定其类型、名称,并可选择性地赋予初始值。

int counter = 0; // 定义一个名为counter的整数变量,并初始化为0

根据我的建议,让我们立即检查编译器是否接受这个变量定义。按 F7 编译。虽然没有错误,但有一个警告:变量 counter 已声明但从未被引用。确实如此,我们继续。

现在,让我们将变量 counter 从其当前值增加1。C语言有一个特殊的运算符 ++ 用于此目的,称为“前自增”。

++counter; // 将counter的值增加1

像往常一样,让编译器检查代码是否仍然可以编译。按 F7

为了观察计算机如何计数,让我们再增加几次计数器。

++counter;
++counter;
++counter;

最后按一次 F7 编译。


在模拟器中运行与调试

现在,我将展示如何运行这个程序。首先,确保项目配置为使用模拟器。你可以通过顶部菜单栏是否存在 Simulator 菜单来判断,或者双击检查:点击 Project -> Options,在 Debugger 类别下,应该看到选择了 Simulator

你有两种方式运行程序:通过 Project -> Download and Debug 菜单,或者使用工具栏按钮(我将使用后者)。此时,IAR工具集切换到调试器模式。

确保以下调试器视图可见:Disassembly(反汇编)、Memory(内存)、Registers(寄存器)和 Locals(局部变量)。让我们重新排列调试器窗口,以便更好地观察模拟的ARM Cortex-M4处理器内部。

首先,查看 Disassembly 视图。这个视图显示了编译器从你的程序生成的机器指令。处理器停在突出显示的指令处,这是你的 main 函数的开始。计算机内部的一切,包括机器指令,都只是数字。指令右侧的符号是所谓的指令助记符,由调试器添加以提高可读性。指令左侧的数字列是指令的内存地址。内存地址是分配给内存中字节的简单数字。

为了让你相信指令确实是内存中的数字,让我们看看 Memory 视图。你可以将内存想象成一个巨大的字节表,这些字节从0开始顺序编号。这些称为地址的顺序数字显示在内存视图左侧的数字列中。

为了更好地识别内存中的机器指令,让我们将视图单位改为 2 times units,因为大多数ARM Cortex指令在内存中占用两个字节。现在,你应该能轻松识别指令了。例如,在地址 0x40 处,你看到的数字与反汇编视图中的相同。地址 0x420x50 的指令也是如此。

好了,让我们逐行单步执行代码。在代码视图中点击,然后点击 Step Into 按钮。如你所见,当前指令前进一步,counter 变量的值变为0。同时,如果你仔细观察,PC 寄存器的值已变为 0x42PC 代表程序计数器,因为它对指令进行计数。

现在再次点击 Step Into 按钮执行下一条指令。这次,变量 counter 的值增加到1。Locals 视图还告诉你,counter 变量位于 R1 寄存器中。确实,当你查看寄存器视图时,可以看到 R1 的值是1。


理解寄存器

那么,寄存器到底是什么?如果你曾经使用过简单的计算器,你应该已经有了很好的概念,因为微处理器中的寄存器与计算器的存储寄存器非常相似。通常,你可以对计算器的存储寄存器进行加、回忆和清除操作。

ARM Cortex-M处理器有16个这样的寄存器,命名为 R0R15,其中 R15PC 的另一个名称。所有这些寄存器都可以保存32位的数字。这些寄存器的重要性在于,机器指令可以直接操作它们,通常只需一个时钟周期。你已经看到了两个操作寄存器的指令示例:将0移动到 R1,以及给 R1 加1。

让我们继续单步执行代码,观察 counter 变量递增。一切似乎都按预期工作,但当计数器达到10时,发生了有趣的事情。

如你所记,counter 位于 R1 寄存器中,但在值为10时,Locals 视图和 Registers 视图似乎不同步,因为 R1 显示的值是 0xA

这需要一些解释。Locals 视图以十进制系统显示 counter 的值,这是人类因为通常有10个手指而采用的系统。然而,C程序员在工作一段时间后会长出16根手指。例如,这是我编程20多年后的手部照片。


程序员发现使用十六进制系统工作更加自然,因为它能完美映射到所有计算机底层的二进制系统。正如你在十进制、二进制和十六进制的比较中所见,一个十六进制数字代表一组4位,称为半字节。相比之下,十进制系统对于大于9的数字需要两位数字。

在每个十六进制数字前看起来奇怪的 0x 前缀,是C语言中编码十六进制数字的约定。

在图片底部,你可以看到一个将上述表格应用于将32位字符串编码为十六进制的例子。这些位被分成四组,每组八位,称为字节。每个字节包含两个4位的半字节。正如我刚才解释的,半字节直接映射到十六进制数字。例如,半字节 1010 映射到十六进制数字 A,半字节 0101 映射到十六进制数字 5,依此类推。最终,整个32位字符串等价于十六进制 0x260F3E5A

现在,我希望调试视图对你来说更有意义了,因为它们大多显示十六进制数字(你可以通过 0x 前缀识别)。在让程序继续递增计数器之前,让我们稍微“作弊”一下,测试当数字变得非常大时会发生什么。

在调试器中这实际上非常容易做到,因为你可以通过单击任何变量并输入新值来手动更改它。输入 0x7FFFFFFF 并按回车。这被证明是一个很大的十进制数。现在,让你的程序将计数器递增1。哎呀,发生了非常奇怪的事情。你的计数器在十进制中变成了一个巨大的负数,而在 R1 寄存器中它是十六进制的 0x80000000


有符号整数与二进制补码

这又需要一些解释。变量 counter 被声明为 int,在C语言中这是一个有符号数,意味着它可以保存正值和负值。事实证明,计算机以一种相当特殊的方式表示负数,称为二进制补码表示法。这个循环图解释了它的工作原理。

图中的每个箭头代表加1。对于小的正数,一切按预期工作,直到数字填满除最高位外的所有位。这是32位有符号整数可以表示的最大正值。当你将这个数字加1时,会溢出到最高位,此时数字变为负数。实际上,它变成了你可以用32位表示的最小负数。从那里开始,当你继续递增时,数值变得负得越来越少,直到你填满所有位,此时你达到-1。当你将-1递增时,你又得到0,循环重复。

回到我们的调试会话,让我们将 counter 值设置为-1,并观察 R1 寄存器的内容。接下来,让你的程序再将计数器递增一次,通过使计数器再次变为0来闭合循环。

X 按钮退出调试器。


课后作业

作为课后作业,我希望你研究无符号整数的计数。为此,你需要将 counter 变量的类型修改为 unsigned int,重新编译,并像本节课前面那样启动调试器进行观察。


在真实硬件上运行

最后,作为本课的最后一个步骤,我承诺向你展示如何在LaunchPad开发板上运行代码。为此,你需要修改项目选项。

点击 Project -> Options 菜单。在对话框中选择 Debugger 类别,然后从下拉列表中选择 TI Stellaris 选项。接下来,点击 Download 标签页,勾选 Use flash loader 选项以及 Verify download 选项。点击 OK

此时,你需要使用提供的USB线将LaunchPad板连接到计算机。如果这是你第一次连接该板,请留出一两分钟时间安装USB调试器的驱动程序。该板预装了一个闪烁LED的演示程序。

现在,你可以将代码下载到开发板的闪存中,并以与在模拟器中完全相同的方式开始调试。当你思考这一点时,这相当酷,并且只有在板载USB调试器和微控制器本身都有特殊电路为你提供此功能的情况下才可能实现。

请注意,你的程序被永久编程到微控制器内部,所以它会计数,但不会再闪烁LED。不过别失望,在未来的课程中,我们将让它闪烁,并实现更多功能。


总结

本节课我们一起学习了计算机如何计数。我们从创建和配置一个嵌入式C项目开始,探索了变量定义、编译过程以及调试器的使用。我们深入了解了内存地址、机器指令和寄存器,并观察了计算机内部数字的表示方式,特别是十进制、十六进制和二进制补码表示法。最后,我们成功地将程序下载到真实的硬件上运行。

在下一课中,我将展示如何用循环替换重复的指令,并向你展示各种改变代码控制流的方法。请保持关注,并访问 statemachine.com/quickstart 获取课堂笔记和项目文件下载。

2:如何改变代码的控制流

在本节课中,我们将学习如何改变程序代码的执行顺序,即控制流。我们将从最简单的线性执行开始,逐步引入循环和条件判断,使程序能够重复执行某些操作或根据条件做出决策。

上一节我们创建了一个简单的线性程序。本节中我们来看看如何通过循环和条件分支来改变这种线性的控制流。

项目准备

首先,从第1课的项目创建一个副本,并将其重命名为“lesson2”。如果你没有第1课的项目,可以从 statemachine.com/Quickstart 在线获取。

我强烈建议对可工作的项目进行频繁备份。软件开发的黄金法则是:始终保持软件可工作,只对其进行微小的增量修改。因此,当你完成一个可工作的版本时,请保存它。当你搞砸某一步时,你会庆幸自己这么做了。通常,回退到可工作的版本比尝试修复损坏的代码要容易得多。

进入“lesson2”目录,双击工作空间文件以打开IAR工具集。如果你没有安装IAR工具集,请返回第0课。

理解线性控制流

这是我们第1课创建的C程序。和所有C程序一样,它从 main 函数开始执行。在 main 函数内部,有一段非常简单的线性代码,控制流从上到下执行。

让我们在调试器中快速查看一下ARM处理器如何处理这种最简单的控制流。

确保调试器设置为模拟器模式,然后点击“下载并调试”按钮。让我快速提醒你在调试模式下看到的内容:

  • 汇编窗口显示机器指令。
  • 寄存器视图显示ARM Cortex-M寄存器的状态。
  • 今天对你来说最有趣的是程序计数器(PC)寄存器,它包含当前指令的地址,也就是反汇编视图中高亮显示的那条指令。

单步执行代码,一次执行一条机器指令,观察PC在每一步是如何变化的。请注意,你只执行了增加R1寄存器的指令,但没有专门用于增加PC的指令。相反,每条指令都会作为副作用来增加PC。这就是最简单的、从上到下的线性控制流,它被硬编码在指令本身中。

引入循环

在本课中,你将学习如何改变这种硬编码的控制流,使程序能够循环或有条件地跳过部分代码。这种控制流的改变将允许你避免重复代码并在运行时做出决策。

现在,让我们退出调试器并修改代码以使用循环。C语言中最简单的循环是 while 循环。你可以通过添加 while 关键字来创建它,后面跟着括号内的条件,再后面是循环体。

while (counter < 21) {
    counter++;
}

这段代码首先检查条件,如果为真,则执行循环体,然后返回检查条件。只有当条件为假时,循环才会退出。在这个特定例子中,我们恰好有21次计数器递增,因此为了执行相同次数的递增,条件是 counter < 21

让我们编译并在模拟器中运行这段代码。第一条指令将0移动到R0寄存器,R0现在用于保存 counter 变量。下一条 B 指令是非常有趣的分支指令,因为它修改了PC,从而跳过了一些指令。接下来的 CMP 指令将R0与数字21(你可以在指令本身中看到它被编码为十六进制0x15)进行比较。CMP 指令有一个非常有趣的副作用,它会修改APSR寄存器(应用程序状态寄存器)。具体来说,CMP 指令会设置APSR中的N位(负标志位),因为比较是以差值 R0 - 21 执行的,结果是负数。

B.LT 指令是你已经见过的分支指令的一个变体,但这一条是有条件的。具体来说,只有当APSR中的N位被设置时,B.LT 指令才会修改PC。否则,B.LT 指令会简单地顺序执行到下一条指令。

此时,一个好问题是:分支指令如何知道跳转到哪里?事实证明,这个信息被编码在指令中。

以下是ARM架构参考手册中的一页,它解释了所有B指令变体的编码。我们的指令以十六进制0xD开头,这意味着它使用T1编码。指令中的下一个半字节表示条件,十六进制0xB表示LT条件。最后,字节0xFC编码了PC应该改变多少,这被称为偏移量。偏移量是一个有符号的量,从第1课你应该记得,有符号数使用二进制补码表示。因此,字节0xFC代表-4。现在你可以计算PC的新值:取当前PC值0x7E并减去4,得到0x7A。这正是我们期望跳转到的地址。让我们通过执行 B.LT 指令来验证这一点。看,你是对的。PC向后跳转,因此你有了一个循环,你可以通过单步执行代码来验证。

请不要担心,我不会再花时间深入剖析指令,但我认为剖析 B.LT 指令非常有教育意义,因为它让你一窥ARM Cortex-M处理器的内部工作原理。

编译器优化与控制流效率

现在让我们回到控制流。我希望你已经注意到,反汇编的代码实现了一种与我描述的 while 循环不同的控制流。原始代码本应首先测试条件,如果条件不成立,则跳过循环体。而编译后的代码以一个无条件分支开始,并颠倒了循环体和条件测试的顺序。然而,当你仔细想想,这两种控制流是等价的,只是生成的代码更快,因为它在循环底部只有一个条件分支。

这个例子说明了两个要点:

  1. 一条C语句(如 while)可以生成多条机器指令,这些指令甚至不必分组在一起。
  2. 编译器非常聪明,并且比你更了解处理器。

非线性控制流对处理器执行代码的速度也有显著影响。作为嵌入式系统程序员,你需要意识到这一点。首先,存在循环开销,因为你现在需要执行额外的测试和跳转指令来处理循环。但更糟的是,跳转会因流水线停顿而导致额外的执行延迟。

让我解释一下。包括ARM Cortex-M在内的所有现代处理器都使用指令流水线来提高吞吐量。流水线就像一条装配线,处理器在多个处于不同完成阶段的指令上同时工作。这增加了在给定时间内可以处理的指令数量。每条指令被分成一系列独立的步骤,例如从内存中取指、解码和执行。这些步骤中的每一个都需要一个时钟周期来完成。当指令按顺序执行时,流水线以满容量工作。但是,当这种顺序被分支指令打乱时,流水线需要丢弃部分处理的指令,并在新指令处重新开始。这意味着流水线会停顿几个周期。

请注意,我并不是说你应该在程序中避免使用循环。我刚才讨论的影响只在时间关键的代码(例如中断处理)中才真正重要,对于大多数其他情况则无关紧要。然而,当你确实需要加快速度时,你现在知道该怎么做。你可以展开一些循环,要么完全展开,要么根据需要展开一部分。例如,你可以如下修改 while 循环:

while (counter < 21) {
    counter++;
    counter++;
    counter++;
}

你增加了单次循环中计数器递增的次数,并相应调整了循环条件。现在,当你执行代码时,你可以看到测试和分支发生的频率降低了,但你仍然执行了相同次数的21次递增。

使用条件判断

最后,对于本课,我想向你展示如何使用控制流在运行时做出决策。例如,假设你希望在 counter 变量的值变为奇数时做一些特殊的事情。

让我们通过多次按 Ctrl+Z 恢复到之前的版本,并开始编写 if 语句。

if ((counter & 1) != 0) {
    // 当counter为奇数时执行的代码
}

你以 if 关键字开始,后面跟着括号内的条件,再后面是条件为真时要执行的代码。用于测试计数器是否为奇数的条件表达式需要一些解释。& 符号代表按位与运算符,它在 counter 的每一位和第二个操作数 1 之间执行与操作。正如你在几个例子中看到的,第二个操作数 1 测试 counter 的最低有效位,当 counter 为偶数时该位为0,当 counter 为奇数时该位为1。!= 运算符表示“不等于”。

你还可以向 if 添加一个可选的 else 分支,该分支仅在条件为假时执行。

if ((counter & 1) != 0) {
    // 当counter为奇数时执行的代码
} else {
    // 当counter为偶数时执行的代码
}

我希望你现在已经注意到,C语言中的控制流语句可以嵌套,所以你可以在 while 循环中有一个 if 语句,依此类推。

总结

本节课中我们一起学习了如何改变代码的控制流。我们从理解最简单的线性执行开始,然后引入了 while 循环来实现重复操作,并了解了编译器如何优化循环结构。我们还探讨了分支指令对处理器流水线性能的影响。最后,我们学习了使用 if 语句进行条件判断,使程序能够在运行时根据条件做出决策。在下一课中,你将学习更多关于变量和指针的知识。

如果你喜欢这个频道,请订阅以保持关注。你也可以访问 statemachine.com/quickstart 获取课堂笔记和项目文件下载。

3:变量与指针

在本节课中,我们将学习C语言中的变量和指针。我们将探讨变量在内存中的存储位置,以及如何使用指针来访问和操作这些内存地址。通过实际操作和调试,你将理解指针的基本概念及其在嵌入式编程中的重要性。

准备工作

首先,我们需要复制上一课(第2课)的项目,并将其重命名为“第3课”。如果你没有第2课的项目,可以从 statemachine.com/Quickstart 在线获取。

进入新的“第3课”目录,双击工作区文件以打开IAR工具集。如果你没有IAR工具集,请返回第0课进行设置。

这是你在第2课中创建的C程序。我们先清理一下代码,然后在调试器中快速查看 counter 变量的存储位置和访问方式。

局部变量与全局变量

上一节我们介绍了如何查看局部变量。现在,让我们将变量定义移到 main 函数之外,重新编译,并回到调试器。

有趣的是,counter 变量不再显示在“Locals”窗口中。这是因为它不再是局部变量。要查看现在的 counter 变量,我们需要使用不同的视图。

选择“View”菜单,点击“Watch1”视图。当“Watch”窗口打开后,点击第一行并输入变量名 counter

如你所见,counter 变量的位置现在是一个以十六进制2开头的大数字。这个地址是此特定ARM Cortex-M微控制器中RAM内存的起始地址。因此,counter 变量现在存储在RAM中。

如果确实如此,那么该变量也应该直接在“Memory”窗口中可见。为了验证这一点,将内存视图设置为 0x20000000,并将内存设置更改为“4字节单位”,以便方便地查看4字节整数。

现在,让我们单步执行代码,并观察各个调试器视图。

请注意,STR 指令导致“Watch1”视图和“Memory”视图中的值从0变为1。现在,值变为2,然后是3,依此类推。

理解机器指令

让我们尝试理解编译器生成的用于访问内存中 counter 变量的机器指令。

第一个有趣的指令是 LDR.N,它代表从内存加载到寄存器。LDR.N 指令从标签 ??main?2 加载内容到 R0 寄存器。你可以向下滚动到此标签,查看正在加载的内容。

这看起来很熟悉。加载到 R0 的值是 counter 变量的地址。

执行 LDR.N 指令并观察 R0。下一条 LDR 指令再次加载 R0,但这次的值来自 R0 当前持有的地址,也就是 counter 变量的地址。

单步执行并验证 R0 现在的值为3。

ADD 指令执行实际工作,将 R0 加1,因此 R0 变为4。

下一条 LDR 指令将 counter 变量的地址加载到 R1

最后,STR 指令将 R0 寄存器的值存储到由 R1 寄存器指向的内存中。

请注意,指令执行后,“Watch1”和“Memory”视图如何变化。

内存访问模式与指针

至此,我希望你开始看到ARM处理器中访问内存的一般模式。ARM是所谓的精简指令集计算机架构的一个例子。在这种架构中,内存只能通过特殊的加载指令读取,然后所有数据操作必须在寄存器中进行,最后修改后的寄存器值可以通过特殊的存储指令存回内存。

这与复杂指令集计算机架构形成对比,例如个人计算机中古老的x86架构,其中一些复杂操作的操作数可以仍在内存中,而不需要在寄存器中。

但无论处理器架构如何,我希望你开始认识到内存地址的作用,因为每次访问内存都必须知道要从中加载数据或向其存储数据的地址。

这引出了一个有趣的问题:如果这些内存地址对CPU如此基本,它们是否可以在C语言层面以某种方式表示?答案是肯定的。在C语言中,地址可以存储在称为指针的变量中。

以下是一个C语言中指针变量的例子。像大多数C声明一样,解释它的最好方法是反向阅读。所以 p_int 是一个指针,这是类型后面的星号所表示的含义。换句话说,p_int 是一个可以保存整数变量地址的变量。

如果是这样,那么 p_int 应该能够保存,除其他外,整数 counter 变量的地址。确实,这在C语言中可以非常容易地实现。& 运算符给出 counter 变量的地址,这个地址可以合法地赋值给 p_int

最后,从指针获取给定地址存储的值也非常有用,这称为解引用指针。C语言中用于此操作的运算符是星号 *

*p_int 表示当前存储在 p_int 指针中的地址处的值,也就是 counter 变量的值。由于这种等价性,你可以用 *p_int 替换 counter,程序应该和以前一样工作。

让我们看看编译器是否接受这个程序。现在,让我们进入调试器并准备视图。这次,我们需要“Watch1”视图来查看 counter 变量,以及“Locals”视图来观察 p_int 指针。

在单步执行代码之前,让我们看一下反汇编视图,并将其与引入 p_int 指针之前的代码进行并排比较。

如你所见,将 counter 变量地址加载到 R0 寄存器的 LDR 指令已被移到顶部。并且另一条相同的指令副本已被完全移除。换句话说,引入 p_int 指针简化了机器代码并提高了其效率。

现在,你可以单步执行代码,并观察 counter 变量在“Watch1”视图和“Memory”视图中递增,与之前完全相同。这证实了指针确实是 counter 变量的别名。

最后,如果你想执行循环到结束,但对单步执行代码感到厌倦,可以在循环后设置一个断点,然后点击“Go”按钮以全速执行程序。在断点处停止后,你可以验证最终的 counter 值为21,符合预期。

指针的强大与危险

在本课的最后一步,我想演示指针的惊人力量。在这一点上,这将只是一个可怕的技巧,但它将向你展示在嵌入式编程中实际经常使用的一种技术。

如前所述,像 p_int 这样的指针变量保存一个整数的地址。但这可以是几乎任何地址,而不仅仅是 counter 变量的地址。如果是这样,那么让我们尝试将一个虚构的地址赋值给 p_int

在你的第一次尝试中,你可能会尝试使用一个十六进制数字表示的地址,就像你在调试器中看到的那样。但当你按F7编译时,编译器拒绝了代码。

在你的下一次尝试中,你可能会尝试通过在数字前加上 0x 前缀来使用无符号数字。但编译器也不喜欢这样。

在这一点上,你与编译器的协商真的破裂了。但C语言有一种通过使用类型转换来强制类型的机制。你通过在强制转换表达式前放置类型名称(在括号中)来执行这种类型转换。

现在编译器别无选择,只能接受它。现在,让我们解引用指针,并向其中写入一个容易识别的整数值,嵌入式程序员似乎喜欢用 0xDEADBEEF 来达到这个目的。

显然,这个技巧需要测试,但为了预先阻止你认为模拟器可以接受任何东西的反对意见,我想在Stellaris LaunchPad开发板上运行这段代码。所以,如果你有这块板子,请将其插入PC的USB接口。

接下来,将调试器设置为TI Stellaris接口。并且,别忘了在“Download”选项卡下勾选“Use flash loader”选项。如果你没有这块板子,只需跳过此步骤,在模拟器中跟随操作即可。

无论哪种方式,点击“Download and Debug”按钮进入调试器。

确保你在指针重新赋值处有一个断点,并使“Watch”视图可见,以便可以看到 counter 变量。

按下“Go”按钮运行到断点,然后在反汇编窗口中点击以一次单步执行一条机器指令。

执行 LDR 指令,它将虚构的地址加载到 R0 寄存器,并验证此地址出现在“Locals”视图的 p_int 变量中,以及“Register”视图的 R0 中。

执行下一条 LDR 指令,它将值 0xDEADBEEF 加载到 R1

最后,执行 STR 指令,它将 0xDEADBEEF 存储到 p_int 地址指向的内存中。

由于故意错位的虚构地址,其效果有点可怕。十六进制值 0xDEADBEEF 被部分写入 counter 变量,部分写入内存中的下一个字。Cortex-M4处理器接受了这个未对齐的地址,但Cortex-M0处理器会有问题。

所以现在你看到了指针作为一种强大的机制,如果使用不当,也可能很危险。

总结

本节课中,我们一起学习了变量在内存中的存储方式,以及指针的基本概念和操作。我们通过调试器观察了局部变量和全局变量的存储差异,理解了ARM架构下内存访问的加载-操作-存储模式。我们学习了如何声明指针、获取变量地址以及解引用指针。最后,我们通过一个示例看到了指针的强大功能和潜在危险,为下一课使用指针控制LED打下了基础。

在下一课中,你将运用这些知识来闪烁Stellaris LaunchPad开发板上的LED。

如果你喜欢这个频道,请订阅以保持关注。你也可以访问 statemachine.com/quickstart 获取课堂笔记和项目文件下载。

4:如何控制外部世界

在本节课中,我们将学习如何控制Stellaris Launchpad开发板上的LED灯。我们将通过直接操作微控制器的内存地址来实现这一目标,这是嵌入式编程的核心技能之一。

概述

上一节我们介绍了指针的基本概念。本节中,我们来看看如何利用指针来控制硬件。具体来说,我们将学习如何查找硬件寄存器地址,并通过向这些地址写入数据来点亮和熄灭LED。

准备工作

首先,建议你从本视频的课程笔记链接中下载开发板的用户手册。手册描述了如何通过USB线将开发板连接到电脑,并说明了板载用户LED(红、绿、蓝三色)是如何连接到微控制器的。

以下是关键连接信息:

  • LED的R、G、B组件分别由晶体管驱动。
  • 晶体管由输出信号LEDR、LEDG、LEDB控制。
  • 这些输出信号连接到微控制器的引脚PF1、PF2和PF3(“F”代表GPIO端口F)。

如果你没有开发板,可以在模拟器中跟随操作,但调试器视图会略有不同。

项目设置

像往常一样,我们从复制上一课(第3课)的项目开始,并将其重命名为“lesson4”。如果没有第3课的项目,可以从 statemachine.com/quickstart 获取。

  1. 进入新的“lesson4”目录,双击工作区文件以打开IAR工具集。
  2. 如果使用开发板,请将其连接到PC,并在调试器配置中确保选择了“TI Stellaris”接口,并在“Download”选项卡中勾选“Use flash loader”。
  3. 如果使用模拟器,请将调试器配置为“Simulator”。
  4. 如果使用开发板,请在“TI Stellaris”菜单中勾选“Reset will do system reset”选项,以确保开发板始终从干净复位状态启动。
  5. 最后,请完全重建项目,以防止IAR工具集错误地引入第3课的旧main.c文件。

理解内存映射

进入调试器,快速回顾处理器如何使用各种地址。低地址(从0开始)存储机器指令,即编译后的程序代码,永久存储在微控制器的闪存中。地址0x20000000开始用于存储变量,如计数器变量,这标志着随机存取存储器(RAM)的开始。RAM区域在地址0x20008000结束,这意味着微控制器拥有32KB的RAM。

为了控制LED,我们需要了解微控制器地址空间中所有“大陆”和“岛屿”(如RAM岛)的完整地图。描述这一内存映射的文档称为数据手册。建议你从课程笔记链接下载Launchpad板上特定LM4F微控制器的数据手册。数据手册通常很庞大(超过1200页),但无需通读,关键是学会快速查找所需信息。

例如,要查找内存映射,只需在数据手册中搜索字符串“memory map”。你会看到一个典型的现代ARM Cortex-M微控制器的线性32位地址空间。其中,0x000000000x0003FFFF是256KB的片上闪存(Flash)。0x20000000开始是RAM区域。在“外设大陆”中,可以找到GPIO端口。我们需要找到GPIO端口F来控制LED。继续向下查找内存映射,可以找到GPIO端口F的起始地址(例如0x40025000)。

启用硬件模块

回到IAR调试器,将GPIOF的起始地址粘贴到内存视图中。你可能会发现这个地址范围显示为空。这是因为现代微控制器为了省电,默认会通过“时钟门控”技术关闭某些硬件模块的时钟。因此,我们首先需要找到方法打开GPIOF模块的时钟。

回到数据手册开头,搜索“clock gating”。找到“GPIO时钟门控控制寄存器”部分。寄存器描述通常以位块图展示,位从0开始编号。类型标注中,“RO”表示只读,“RW”表示可读写,“WO”表示只写。逻辑相关的位组会在图下说明。对我们最重要的是第5位(bit 5),因为它控制着GPIO端口F的时钟使能。

复制该寄存器的基地址到剪贴板,注意需要加上偏移量0x608才能得到完整的寄存器地址。

回到调试器,打开“符号内存”视图。将时钟门控寄存器的完整地址(基地址+偏移量)粘贴进去。编辑该寄存器的值,设置第5位为1。根据第1课的知识,这对应于十六进制值0x20。输入后,GPIOF硬件模块就被唤醒了。

配置GPIO引脚

根据数据手册GPIO部分的说明,接下来需要配置GPIOF的位1、2和3(分别驱动红、蓝、绿色LED)为数字输出。

首先,设置引脚方向为输出。在GPIOF地址块内,找到地址0x40025400(方向寄存器),将位1、2和3设置为1。这对应于二进制1110,即十六进制0xE

其次,设置引脚功能为数字输出。在GPIOF地址块内,找到地址0x4002551C(数字功能使能寄存器),同样将位1、2和3设置为1。

控制LED

最后,通过GPIOF数据寄存器(位于0x400253FC)来控制LED。首先,通过向最低半字节写入0x2(设置位1)来点亮红色LED。写入0x0可以关闭LED。写入0x4(设置位2)点亮蓝色LED,写入0x8(设置位3)点亮绿色LED。

用C代码实现

核心的控制操作归结为向特定内存地址写入数字,这我们已经知道如何使用指针来实现。我们将使用第3课末尾介绍的指针技巧,因为它允许我们向任意选择的地址写入任意数字。

清理代码,只保留指针相关部分。实际上,我们甚至不需要单独的指针变量,因为可以直接解引用指针转换结果。

在第3课中,我们使用了指向int的指针,但ARM寄存器是无符号的,因此将指针类型改为unsigned int。将第3课中虚构的地址替换为我们用来打开GPIOF模块的时钟门控系统寄存器的地址。将整个指针转换用括号括起来,注意整个表达式是一个指向unsigned int的指针。因此,我们可以使用星号(*)操作符来解引用这个指针,如下一行代码所示。

现在,可以向该指针写入数据了。根据调试器中的实验,我们需要设置第5位,即向该寄存器写入十六进制值0x20。该值应为无符号数,我们使用U后缀来指明。

删除不再使用的指针变量p,按F7编译检查代码是否正确。

接下来,继续处理下一个寄存器:GPIOF引脚方向寄存器,需要写入0xE来设置位1、2和3。

最后,GPIO配置还需要在数字功能使能寄存器中设置位1、2和3。

完成这些后,就可以通过设置和清除GPIOF数据寄存器中的位1来打开和关闭红色LED。

实现闪烁效果

如果你想让LED真正闪烁,不能只开关一次,而需要持续进行。为此,可以将控制LED开关的代码包裹在一个无限循环中。条件为1while循环意味着条件始终为真,因此循环会永远运行。

编译此代码时,可能会收到一个警告,指出无限循环后的返回语句无法到达,这是正确的。

测试代码,发现设置时钟门控寄存器后,GPIOF模块按预期唤醒,红色LED点亮然后熄灭,无限循环似乎也在工作。但如果全速运行代码,LED却常亮而不闪烁。暂停代码并再次单步执行,一切正常。问题在于程序运行得太快,人眼无法察觉到LED的快速闪烁。我们需要减慢程序速度。

为此,可以使用第2课学到的计数while循环。这种循环会浪费大量CPU周期,但可以通过设置while条件中的上限来控制延迟时间。注意,需要在打开LED后和关闭LED后都添加延迟。

尝试增加延迟上限的数量级,直到LED以肉眼可见的频率稳定闪烁。

总结

本节课我们一起学习了如何通过直接操作内存映射寄存器来控制外部硬件(LED)。我们掌握了查找数据手册、理解寄存器描述、启用时钟门控、配置GPIO引脚方向与功能,以及最终通过写入数据寄存器来控制输出的完整流程。虽然只是让一个LED闪烁,但这却是嵌入式开发生涯中一个非常重要的里程碑。恭喜你!

在下一课中,你将学习如何使用预处理器和volatile关键字来改进这个闪烁程序。

5:C语言预处理器与volatile关键字

在本节课中,我们将学习如何利用C语言的预处理器和volatile关键字来改进Blinky程序,使代码更易读、更健壮。

概述

上一节我们通过直接操作寄存器实现了LED闪烁。本节中,我们将通过引入宏定义来替换晦涩的数字,并使用volatile关键字来确保代码在不同编译器优化级别下都能正确运行。

创建新项目

首先,复制上一课(第4课)的项目,并将其重命名为“lesson5”。如果你是中途加入本课程的,可以从statemachine.com/quickstart下载之前的项目文件。

进入新的lesson5目录,双击工作区文件以打开IAR工具集。如果你还没有安装IAR工具集,请回顾第0课的内容。

使用预处理器定义宏

这是你在第4课创建的程序。它能够成功让Stellaris LaunchPad开发板上的红色LED闪烁,但代码可读性很差,充满了难以理解的数字,并且没有注释说明。

为了提高代码的可读性,最好使用寄存器名称来代替这些神秘的数字。

实现这一目标的一种方法是使用C预处理器,它允许你将任何一段代码定义为一个宏。

例如,让我们为你要写入的第一个寄存器定义一个宏。新行以井号#开始,后跟define关键字和宏的名称。数据手册称该寄存器为“运行模式时钟门控控制寄存器(GPIO)”,因此我们将宏命名为RCGC_GPIO

在宏名称之后,你只需粘贴该宏将要替换的原始代码片段。一旦定义了宏,你就可以用它来代替原始的代码片段。

按F7检查编译器是否接受目前的代码。

C预处理器之所以这样命名,是因为在概念上,它是真正编译之前一个独立的、简单的文本替换步骤。预处理器会移除所有以井号#开头的行,因此编译器根本看不到它们。

例如,你可以将宏定义为任何内容。但只要它没有在代码中使用,就无关紧要,代码仍然可以编译。此外,预处理器只替换代码中实际使用的宏。因此,编译器看到的只是替换后的字符序列,而永远不会看到宏名本身。

这意味着宏不需要是C语言的任何完整元素。例如,宏FOO可能只是指针转换表达式的一部分。但只要在特定上下文中替换宏的文本有意义,编译器就会欣然接受,因为编译器确实无法区分。

这一切的推论是,你需要小心定义宏,以免它们的含义因替换的上下文而发生意外改变。例如,为了避免意外,最好将像*RCGC_GPIO这样的宏用括号括起来,这样在任何可能使用的上下文中,它都意味着指针解引用。

也可以使用其他宏来定义宏。例如,如果你按照数据手册的规定定义宏GPIO_F_BASE,就可以在其他宏的定义中使用它。

例如,用偏移量0x400定义端口方向寄存器GPIOF_DIR的宏。用偏移量0x51C定义数字使能寄存器GPIOF_DEN的宏。用偏移量0x3FC定义数据寄存器GPIOF_DATA的宏。

添加代码注释

最后,强烈建议为代码添加注释。注释仅对阅读代码的人有益,编译器会完全忽略它们。C99标准支持两种类型的注释:传统的C注释,由/*开始,*/结束;以及C++风格的注释,由//开始,到行尾结束。

注释可以放在任何可以合法放置空格的地方。实际上,所有注释在编译前都会被替换为一个空格。

这两种类型的注释也可以在宏定义中使用。

测试代码

现在,看看这段代码是否仍然能让LED闪烁会很有趣。我将使用这块Stellaris LaunchPad开发板进行测试。但如果你没有开发板,可以将调试器配置为模拟器并跟随操作。

很好,LED像以前一样闪烁。看来你所有的修改都生效了。

检查编译器优化

让我们详细检查编译器是如何翻译GPIOF_DATA宏的,以及它是否引入了任何开销。毕竟,你可能会担心现在CPU需要在运行时将基地址与偏移量相加。

但当你单步执行代码时,可以立即看到LDR.N指令直接将完整地址0x400253FC加载到寄存器r0中,没有执行任何加法运算。换句话说,代码和以前一样高效,因为编译器会在编译时折叠所有可计算的常量,避免在运行时进行不必要的计算。

最后,看看是哪一条指令实际点亮了LED,这非常有趣。结果是STR指令。换句话说,从CPU的角度来看,与外部世界通信从根本上来说非常简单,归结起来就是将特定值写入特定地址。

使用厂商提供的头文件

所以,你的程序仍然有效,并且和以前一样高效。但我不想让你留下必须自己为所有寄存器定义宏的印象。实际上,通常你不需要这样做,因为微控制器供应商(例如,Stellaris开发板的德州仪器公司)已经在一个单独的文件中为你提供了这些宏,我已将其复制到lesson5目录中。

你可以通过右键单击项目并选择“Add” -> “Add Files...”菜单选项将此文件添加到项目中。文件名是LM4F120H5QR.h,它正好对应你Stellaris LaunchPad开发板上的微控制器型号。文件扩展名.h意味着这是一个头文件,专门设计用于包含到.c文件(如你的main.c)中。

当你打开头文件时,可以看到它包含了一大堆与你自己定义的非常相似的宏。然而,头文件中使用的指针转换方式有显著不同,这需要一些解释。让我从头文件中抓取一个宏,并将其复制到main.c文件中进行比较。

第一个差异是指针类型。main.c中的宏使用unsigned int,而头文件使用unsigned long。我将在单独的课程中讨论数据类型,但现在我只想说,在像ARM处理器这样的32位机器上,int类型是32位宽,long类型也是。因此,unsigned intunsigned long是等效的。

所以,真正的区别是volatile限定符。它通知编译器,指针所指向的对象可能会自发改变。当你将一个对象声明为volatile时,你是在告诉编译器,即使程序中没有语句似乎要改变它,该对象也可能会改变。

例如,LaunchPad板上GPIOF寄存器中的2位连接到了用户开关。当用户按下或释放开关时,这些位可以改变,这显然不是由任何程序指令引起的。因此,GPIOF寄存器(实际上大多数其他I/O寄存器)都是易失的。

这很重要,因为编译器可以通过将非易失对象的值读入CPU寄存器、使用该寄存器工作一段时间、最终将寄存器中的值写回对象来优化对非易失对象的访问。编译器不允许对易失对象进行这种优化。每次源程序要求读取或写入易失对象时,编译器都必须执行该操作。因此,很明显,volatile限定符对于像GPIOF这样的I/O寄存器非常有用。

volatile关键字与优化

但它对于防止编译器可能进行的优化,对普通变量也很有用。例如,counter变量仅用于两个延时循环中。但从编译器的角度来看,这些循环对计算没有任何贡献,因为counter的最终值要么被覆盖,要么被丢弃。

在这种情况下,编译器被允许优化掉这两个延时循环。实际上,你可以通过允许更高级别的优化来轻松看到这一点:点击项目选项,选择C/C++编译器部分,点击优化选项卡,选择“High”优化级别,然后点击OK。

重新编译并在LaunchPad开发板上运行程序。正如你所见,LED点亮并一直保持亮起状态。当你单步执行代码时,可以看到打开和关闭LED的指令仍然存在,但它们之间的延时循环消失了。

但现在你知道了volatile关键字,也就知道了如何防止编译器优化掉延时循环。你需要将counter变量声明为volatile

顺便说一下,volatile关键字可以放在类型之前(如头文件中的宏那样),也可以放在类型之后。我建议将其放在类型之后。

让我们快速测试一下volatile修饰的counter是否确实解决了问题。是的,LED闪烁了。当你单步执行代码时,也可以看到延时循环。

包含头文件并替换宏

现在,让我们通过实际将.h头文件包含到主程序中来使用它。同样,你使用预处理器来实现这一点。要包含文件,你以井号#开始新行,后跟include关键字,然后是双引号内的文件名。

让我们将头文件与主程序并排放置,以便用头文件中的宏替换你到目前为止定义的所有宏。

微控制器供应商提供的头文件使用数据手册中的寄存器名称,因此你应该不难识别感兴趣的寄存器,例如GPIO_PORTF_DATA_RGPIO_PORTF_DIR_RGPIO_PORTF_DEN_R。如果你对寄存器的正确名称有任何疑问,可以随时检查其地址以确保这是你想要的。

替换所有宏后,你可以移除自己的定义并重新编译代码。

让我们最后一次测试代码,检查LED是否仍然闪烁。

总结

本节课关于C预处理器和volatile关键字的讲解到此结束。从现在起,你将能够编写在任何优化级别下都能正确运行的程序,恭喜你!

在下一课中,你将学习如何使用按位或|和与&运算符来闪烁复合LED的其他颜色,并且还将了解GPIO寄存器更高级的功能。

如果你喜欢本频道,请订阅以保持关注。你也可以访问statemachine.com/quickstart获取课堂笔记和项目文件下载。

6:C语言中的位运算符

在本节课中,我们将学习如何使用C语言中的位运算符,来控制LaunchPad开发板上复合LED的所有颜色。

概述

上一节我们学习了如何配置GPIO来控制LED。本节中,我们将探讨C语言中的位运算符,并利用它们来精确地设置或清除寄存器中的特定位,而不会影响其他位。这对于控制共享同一个寄存器的多个LED至关重要。

准备工作

首先,复制上一课(第5课)的项目,并将其重命名为“lesson 6”。如果你是刚刚加入本课程,可以从statemachine.com/quickstart下载之前的项目文件。

进入新的“lesson 6”目录,双击工作区文件以打开IAR工具链。如果你还没有安装IAR工具链,请回顾第0课的内容。

第5课的程序首先配置了连接到三色LED的GPIO引脚,然后进入一个无限循环。在循环中,它点亮红色LED,通过延迟循环等待片刻,然后熄灭红色LED,再次等待,并循环往复。结果是红色LED不断闪烁。

本节目标

本节课的目标是使用复合LED的其他颜色,例如蓝色和绿色。假设你想让蓝色LED常亮,同时让红色LED组件闪烁。如何实现呢?

第一步很简单:你需要在进入无限循环之前,点亮对应蓝色LED的GPIOF数据位2。

但在循环内部,当你点亮红色LED时,会遇到一个问题:当你设置红色LED的位1时,你也会清除寄存器中的所有其他位,包括蓝色LED的位2,因为所有LED的控制位都位于同一个寄存器中。

因此,你真正需要的是能够设置和清除单个位,而不会无意中干扰其他位的方法。这正是位运算符的用武之地。

认识C语言中的位运算符

让我们通过在代码中实验来学习C语言的位运算符。

首先,定义几个带有初始值的无符号整数变量,以及一个用于保存各种位运算结果的变量c

以下是位运算的示例:

  • c = a | b; 这是按位或(OR)运算。
  • c = a & b; 这是按位与(AND)运算。
  • c = a ^ b; 这是按位异或(XOR)运算。
  • c = ~a; 这是按位取反(NOT)运算,也称为一元补码运算。
  • c = a >> 1; 这是右移位运算。
  • c = a << 3; 这是左移位运算。

在编译和运行代码之前,请将优化级别设置为“无”,并将调试器设置为“模拟器”,因为此实验不需要实际的LaunchPad开发板。

现在,按F7编译代码。点击“下载并调试”按钮在调试器中运行此代码。单步执行变量abc的初始化,然后转到“Locals”窗口,将视图调整为二进制格式。

单步执行按位或表达式,并在变量c中检查结果。可以看到,按位或运算符对ab的每一位执行逻辑或运算。如果你还记得小学时的真值表,可以很容易地验证:0(假)或1(真)等于1(真),1或0等于1,1或1等于1,0或0等于0。

在反汇编窗口中,可以看到所有这些对两个操作数所有32位的或操作,仅由一条机器指令ORRS完成,这非常快速和高效。

按位与运算符对ab的每一位执行逻辑与运算。根据逻辑与的真值表:0与1等于0,1与0等于0,1与1等于1,0与0等于0。在反汇编窗口中,可以看到所有这些与操作由一条ANDS指令完成。

按位异或运算符对ab的每一位执行逻辑异或运算。可以验证:0异或1等于1,1异或0等于1,1异或1等于0,0异或0等于0。在反汇编窗口中,可以看到所有这些异或操作由一条EORS指令完成。

按位取反运算符是一元运算符,意味着它只作用于一个操作数,将每一个1位变为0,每一个0位变为1。在反汇编窗口中,可以看到按位取反由MVNS指令执行,它代表“Move Negative”。

右移位操作将第一个操作数的所有位向右移动第二个操作数指定的位数。例如,右移1位相当于整数除以2,你可以用计算器验证。在反汇编窗口中,可以看到右移位由LSRS指令执行。请注意,LSRS指令将0移入最高有效位。

左移位操作将第一个操作数的所有位向左移动第二个操作数指定的位数。例如,左移3位相当于整数乘以2的3次方(即8)。但需要注意,对于较大的第一个操作数,一些最高有效位可能会从左侧移出,这意味着移位后的结果可能无法再容纳在32位中。在反汇编窗口中,可以看到左移位由LSLS指令执行。请注意,LSLS指令将0移入最低有效位。

有符号数与无符号数的移位差异

现在你知道了位运算符在无符号数上的工作原理。然而,对于有符号数,右移位运算符的行为有显著不同。

让我们进行一个额外的实验:定义一个带符号整数x并用正值初始化,定义另一个带符号整数y并用负值初始化。然后,将x右移几位,最后将y右移相同的位数。

编译并测试。可以看到,正值的右移行为与之前完全相同,即将0移入最高有效位。比较zx的十进制值,右移3位相当于除以8(即2的3次方),符合预期。

然而,负值y的右移行为与之前完全不同,因为现在是将1移入最高有效位。你刚刚发现,对有符号整数进行右移时,如果移位前该位是0,则移入0;如果移位前该位是1,则移入1。这称为在二进制补码表示中对负值进行符号扩展(你在第1课中学过)。符号扩展对于保持右移与除以2的幂之间的对应关系是必要的。

确实,当你将值转换为十进制时,可以看到zy都是负数,并且z仍然等于y除以8(即2的3次方)。

当查看反汇编时,有符号整数与无符号整数右移的这种差异变得非常明显。编译器为有符号数的右移生成了ASRS指令(算术右移),而为无符号数的右移生成了LSRS指令(逻辑右移)。

作为嵌入式系统程序员,你需要透彻理解位运算符。事实上,在嵌入式编程的求职面试中,关于逻辑移位与算术移位等各种细节的问题经常出现。

更重要的是,位运算符非常有用,你将立即在LaunchPad程序中利用它们。

应用位运算符控制LED

首先,你现在可以使用位移运算符来定义控制各种LED颜色的GPIO位。例如:

  • 红色LED对应位1:#define LED_RED (1U << 1)
  • 蓝色LED对应位2:#define LED_BLUE (1U << 2)
  • 绿色LED对应位3:#define LED_GREEN (1U << 3)

请注意,这些位移表达式是编译时常量。因此,与直接定义LED_GREEN为十六进制数0x8相比,绝对没有额外的开销。但这样做的好处是,你可以立即看到位移量就是位号。对于低位,这种优势或许不那么明显,但对于高位,例如位18,要看出它等价于十六进制数0x40000并不容易,而在表达式(1U << 18)中则一目了然。这种定义位常量的方式为我节省了大量计算位数的时间,并防止了程序中的许多愚蠢错误,因此我强烈推荐它。

定义好LED颜色的宏之后,你可以替换掉那些晦涩的十六进制数,这大大提高了代码的可读性。实际上,你的代码变得自解释,注释也变得多余。

现在,让我们来解决在GPIOF中设置红色位而不熄灭蓝色位这个真正有趣的问题。为此,你可以在GPIOF寄存器的当前值和红色位之间使用按位或运算符。

这之所以有效,是因为GPIOF的任何位与LED_RED进行按位或运算时,在LED_RED为0的所有位上保留了原始的GPIOF位,并强制将第1位置为1。

请注意,这种方法仅在你可以实际读取和写入GPIOF寄存器时才有效,因此你需要在数据手册中确认该寄存器具有读写权限。

C语言为赋值操作提供了一种特殊的简写符号,其中左操作数也作为右操作数的第一个参数出现。你可以使用|=运算符,它与上一行的含义完全相同。

因此,最终,这是在GPIOF寄存器中设置红色LED位的最简洁代码:GPIOF_AHB->DATA_Bits[LED_RED] = LED_RED;。请记住这是C语言中设置位的编码习惯用法。

要清除GPIOF寄存器中的红色LED位,你需要使用按位与运算符和红色位的补码。这之所以有效,是因为GPIOF的任何位与~LED_RED进行按位与运算时,在~LED_RED为1的所有位上保留了原始的GPIOF位,并强制将第1位置为0。

同样,你可以使用&=运算符来更简洁地表示这个操作。请记住,这是C语言中清除位的编码习惯用法。

优化代码

现在你知道了编码习惯用法,可以退一步,更批判性地审视你的代码。例如,点亮蓝色LED的操作也是在GPIOF寄存器中设置一个位,因此应该用设置位的习惯用法来编码。实际上,上面的所有行也都在各种寄存器中执行设置位的操作,因此它们也应该用设置位的习惯用法来编码。

但请记住,你需要检查数据手册以确认这些寄存器具有读写权限。最后,你可以使用LED宏来进一步提高代码的可读性。

在重新编译之前,请转到项目选项,将优化级别设置回“高”,并将调试器设置为“TI Stellaris in-circuit debugger”。确保“Reset will do system reset”选项被勾选。

运行与调试

将程序加载到LaunchPad开发板并运行它。可以看到,蓝色LED一直亮着,而较暗的红色LED不断闪烁。

中断代码,并在设置和清除红色LED位的位置设置断点。可以看到,设置红色LED位是通过“加载-修改-存储”操作序列完成的,其中使用按位或机器指令来修改数据。

清除红色LED位则通过另一个“加载-修改-存储”序列完成。有趣的是,编译器为清除位生成了漂亮的代码,仅用一条BIC(位清除)指令。这非常了不起,因为编译器并没有字面地遵循你的代码去执行与补码位的按位与操作。相反,编译器清楚地理解了你清除位的意图,并为此生成了更好的代码。

我希望你记住这个例子,因为它表明,遵循像清除位这样的既定编码习惯用法,可以让编译器在更高层次上理解你的代码意图,而不仅仅是低层次的操作。

总结

本节课我们一起学习了C语言中的位运算符。现在你知道了如何在各种寄存器中设置、清除、翻转和移位位。恭喜你!

在下一课中,我将回答YouTube评论中关于GPIO数据寄存器的几个问题。这个主题实际上很好地补充了关于位运算符的讨论,因为Stellaris的GPIO数据寄存器展示了一种非常不同的、硬件辅助的操纵整组位的方法。

如果你喜欢这个频道,请订阅以保持关注。你也可以访问statemachine.com/quickstart获取课堂笔记和项目文件下载。

7:数组与指针运算

概述

在本节课中,我们将学习C语言中的数组和基本指针运算。你将学会如何应用这些概念,以利用Stellaris GPIO数据寄存器的更高级功能。这有望解答一些在YouTube视频课程评论中提出的问题。

准备工作

和往常一样,我们从复制上一课(第6课)的项目开始,并将其重命名为“lesson 7”。如果你刚刚加入本课程,可以从statemachine.com/quickstart下载之前的项目。

进入新的“lesson 7”目录,双击工作区文件以打开IAR工具集。如果你还没有IAR工具集,请返回第0课。

这是你在第6课创建的程序。让我们稍作清理,然后进入调试器。

回顾“读-修改-写”序列

如你所见,该程序使用“读-修改-写”序列来更改GPIO寄存器中单个位的值,而不影响其他位。例如,要设置控制红色LED的bit1,程序首先使用LDR指令读取GPIOF数据寄存器的当前值。接着,它使用按位或运算来设置bit1。最后,它使用STR指令将修改后的值写回。

“读-修改-写”序列是必要的,因为所有GPIO位都位于具有单个地址的单个字节中。

独立寻址的设想与中断问题

想象一下,如果每个位都可以通过自己唯一的地址单独访问,或者更好的是,GPIO位的每种可能组合都有自己唯一的地址。那么,向这个特定地址执行一次原子写操作,就可以改变选定的位,而不会影响任何其他GPIO位。

本节课,我将向你展示Stellaris GPIO硬件如何让你做到这一点,从而用一次原子写操作取代典型的“读-修改-写”序列。

但在解释如何做之前,我认为理解“为什么要这样做”很有趣。毕竟,“读-修改-写”序列在大多数情况下已经足够快。然而,在这种情况下,关键不在于速度,而在于让你能够真正独立于代码的任何部分(包括中断)来操作各个GPIO位。

我意识到我还没有讨论中断,我保证会讨论,因为这是一个引人入胜的主题,尤其是在嵌入式系统中。但现在,我只想说,中断是一种硬件支持的机制,允许处理器突然改变程序中的控制流。当中断发生时,处理器中的特殊硬件会改变程序计数器寄存器的值,使处理器突然开始执行另一段称为中断服务例程(ISR)的代码,该例程通常很短。当ISR结束时,处理器恢复执行原始代码,就像什么都没发生过一样。

有趣的情况是当ISR改变某些GPIO位时。如果中断恰好发生在“读-修改-写”周期的中间,即在主代码读取GPIO寄存器之后、但在它将修改后的值写回之前,那么在中断服务例程中对GPIO位所做的任何更改都将丢失。这是因为主代码仍将使用中断发生前的GPIO寄存器旧值。这是“读-修改-写”序列固有的问题。

因此,这就是Stellaris GPIO硬件设计者设计一种方法来避免“读-修改-写”序列并用单次原子写操作取而代之的主要原因。

Stellaris GPIO的硬件设计原理

以下是它的工作原理。GPIO位通过一组称为总线的导线连接到CPU。每个位都连接到专用的数据线和专用的地址线。只有当连接的地址线为1时,该位才能被改变。否则,无论所连接数据线的值如何,该位都不会受到影响。

例如,要隔离连接到LED的三个GPIO位,你需要写入以二进制0000 1110 00结尾的地址。请注意,两个最低有效地址线A0和A1未被使用,因为硬件要求所有地址都能被4整除。

你写入的数据决定了引脚的状态。例如,你可以在一次写操作中点亮红色LED、熄灭蓝色LED并点亮绿色LED。我希望这已经清楚地表明,这种硬件设计需要许多具有唯一地址的寄存器,因为不仅每个GPIO位都有自己的地址(为此你只需要8个寄存器),而且在这种方案中,每种位组合都有自己的地址。为了覆盖8个GPIO位所有可能的位组合,Stellaris GPIO提供了256个32位数据寄存器,起始地址为0x40025000

在之前的课程中,你只使用了这些寄存器中的最后一个,称为GPIO端口F数据R,对应于偏移量111111二进制,即0x3F十六进制。这个寄存器显然没有隔离任何位,并允许通过数据线更改所有8个GPIO位。

在本课中,我们将使用其他寄存器。

在C语言中访问GPIO寄存器

现在的问题是如何在C语言中访问所有这些GPIO寄存器。

一种方法是使用你在第3课中学到的“蛮力”方法直接硬编码地址。例如,要仅隔离对应于红色LED的bit1,你可以手动计算地址:从数据手册中的基地址开始,加上左移2位(以跳过两个未使用的地址位)的LED_RED位值。你需要将合成的地址强制转换为指针,然后解引用该指针。记住,这个地址只隔离一个位,你写入这个特定位的内容才重要,写入其他位的内容无关紧要。为了演示,让我们向LED_RED位写入1,向所有其他位写入0。

让我们按F7检查这个调用是否编译。现在,当然,在LaunchPad板上测试这个很有趣。如你所见,“读-修改-写”序列被简化为仅仅是对R2中地址(即GPIO基地址加偏移量8)的STR指令。当你单步执行代码时,你会看到红色LED亮起,其他LED保持不变,证明代码完全按照你的意图执行。

所以代码可以工作,但不是很优雅。你可以通过应用数组的概念来显著改进它。

数组简介

数组是一组占据连续内存位置的相同类型的变量,例如一组256个相同的GPIO数据寄存器。在C语言中,你可以通过在变量名后添加方括号内的元素数量来声明数组。例如,这是一个包含两个计数器的数组,每个都是volatile int类型。

你甚至可以使用数组初始化器一次性初始化整个数组,像这样。

现在,你可以通过元素的编号来引用它们,像使用普通变量一样使用数组元素。括号中的数字称为数组索引,在C语言中,数组的第一个元素始终是0,第二个元素是1,依此类推。

让我们按F7编译,看看编译器是否接受这个语法。

数组与指针的关系

数组与指针密切相关。C编译器将数组视为指向数组开头的指针。要获取索引为i的元素的指针,你只需将i加到数组指针上。因此,你可以写*(counter + 1)来代替counter[1]。这是一个简单指针运算的例子。

数组和指针之间的对应关系是双向的,因为每个指针也可以被视为一个数组。例如,标准的lm4f头文件定义了指针GPIO_PORTF_DATA_BITS_R。这个指针可以用来将所有256个GPIO数据寄存器当作一个数组来访问。因此,例如,要仅访问LED_RED位,你可以像这样索引到GPIO_PORTF_DATA_BITS_R中。

这完全等同于使用以下指针运算。

让我们进入调试器,看看这三种选项如何比较。如你所见,所有三种实现选项都写入存储在R4寄存器中的相同地址。

这个小实验表明,确实,所有三种替代方案都是等效的,并生成完全相同的机器代码。

地址运算与指针运算的区别

回到源代码。让我指出地址运算和指针运算之间非常重要的区别。

在第一种情况下,你首先执行地址运算,然后才将原始地址强制转换为unsigned long指针。在这种情况下,你必须将LED_RED值左移2位,以考虑GPIO寄存器的大小(4字节宽)。在第二种情况下,你使用指针运算,因为GPIO_PORTF_DATA_BITS_R是一个指向unsigned long的指针。在指针运算中,你不需要按元素大小缩放偏移量,因为这会自动为你完成。这必须是这样的,因为指针运算和数组索引是等价的。

在这三种选项中,我认为数组索引看起来最简洁,所以我将保留这个,并注释掉其他选项。

现在,让我们使用数组索引技术来熄灭红色LED。根据接线图,这需要向LED_RED位位置写入0。最后,我在开头也一致地使用数组索引来设置蓝色LED位。

最终程序与测试

这是使用快速、中断安全的GPIO位操作技术的最终程序。让我们在LaunchPad板上测试这个程序。首先,让我们全速运行并观察LED。

如你所见,程序像以前一样工作。当你中断进入代码时,你可以看到清除红色LED位只需要一条到R0中GPIO地址的STR指令。

所以你的程序已经近乎完美了,但事实证明,Stellaris LM4F微控制器可以做得更好。

切换到更快的AHB总线

正如你在数据手册中可以发现的,该微控制器不是有一个,而是有两个外设总线:高级外设总线(APB)和高级高性能总线(AHB)。GPIO端口连接到两者。APB是默认总线,这也是你到目前为止一直在使用的。但APB比AHB更旧、更慢,因此它仅为了向后兼容而保留。

所以在本课剩下的时间里,我将向你展示如何切换到更快、更好的AHB。

首先,你需要在数据手册中找到如何将GPIO从默认的APB切换到AHB。在系统控制部分,你会找到GPIOHBCTL寄存器,它正是做这个的。你会注意到端口F由位号5控制。

接下来,你转到lm4f头文件,寻找GPIO_HBCTL寄存器。你复制寄存器名称并在其中设置第5位。

最后,你需要将所有GPIO地址从APB地址范围(在数据手册中称为APB Aperture)更改为AHB Aperture。你再次在lm4f头文件中搜索GPIO_PORTF,你会发现一组所有带有_AHB后缀的寄存器。因此,你需要在程序中的所有GPIO端口F寄存器上添加此后缀。

让我们在LaunchPad板上测试这个最终版本。

总结

本节课关于C语言中数组和指针运算的内容到此结束。现在你是Stellaris GPIO的专家了,恭喜你。在下一课中,我将讨论C语言函数。如果你喜欢这个频道,请订阅以保持关注。你也可以访问statemachine.com/quickstart获取课堂笔记和项目文件下载。

8:C语言函数与调用栈

在本节课中,我们将学习C语言中函数的基本概念以及调用栈的工作原理。理解这些底层机制是掌握函数、中断和上下文切换等高级主题的关键。

概述

函数是C语言中用于封装可重用代码块的核心机制。调用栈则是支持函数调用和返回的底层硬件与软件基础设施。本节将重点解释函数如何调用其他函数,以及栈在这个过程中扮演的角色。

准备工作

上一节我们介绍了基本的程序流程控制。本节中,我们来看看如何通过函数来优化代码结构。

首先,复制上一课的项目并重命名为“lesson 8”。如果你刚刚加入本课程,可以从 statemachine.com/quickstart 下载之前的项目文件。

进入新的 lesson 8 目录,双击工作区文件以打开 IAR 工具链。如果你还没有安装 IAR 工具链,请返回第 0 课进行设置。

在进行清理工作之前,让我快速回顾一下当前程序的功能。

程序首先设置 LM4F 微控制器内部的寄存器,以控制连接到 GPIO 引脚的 LED。接着,点亮蓝色 LED,然后进入一个无限循环。在循环中,它点亮红色 LED,在一个延迟循环中等待,熄灭红色 LED,在另一个延迟循环中再次等待,然后跳回循环开始。

观察此阶段的程序,你会发现延迟循环的重复相当不优雅。这违反了“不要重复自己”的 DRY 原则。在编程中,应努力消除重复,以确保本应相同的代码部分不会失去同步。

今天,你将学习避免重复的主要技术之一:将一段代码转换为函数,然后根据需要多次调用该函数,而不是逐字重复相同的代码。

什么是函数?

C语言中的函数,在其他编程语言中也称为过程、子例程或子程序,是一段可以从程序中多个不同点执行的可重用代码。

要将一段代码转换为函数,需要为其指定一个名称、一个参数列表和一个返回类型。为了简单起见,我们的延迟函数将命名为 delay,它不接受任何参数,也不返回任何值。这三个元素——返回类型、名称和参数列表——统称为函数的签名

函数代码位于签名后面的花括号 {} 之间。

定义函数后,你可以根据需要轻松地多次调用它。调用函数的语法是函数名后跟括号内的参数。即使函数不接受任何参数,括号也是必需的。

调用函数意味着改变控制流,跳转到函数代码的开头,执行该代码,然后返回到调用之后的下一条指令。

让我们按 F7 检查此代码是否能编译。我相信你渴望在真实开发板上运行此代码,但在执行此操作之前,请按如下方式更改项目选项:

  • 将优化级别设置为“低”。因为在高级优化下,编译器非常智能,它会通过“内联”函数来消除函数调用开销,这本质上会逆转你到目前为止所做的操作。显然,你现在不希望发生这种情况。
  • 每当使用函数时,强烈建议勾选“需要原型”选项。

当你这次按 F7 尝试编译时,会得到一个错误:函数 delay 没有原型。函数原型是函数的签名,后面跟一个分号,而不是代码块。编译器必须在定义之前看到每个函数的原型。

顺便说一下,你的 delay 函数目前不接受任何参数。在 C 语言的旧标准中,你可能通过一个空的参数列表(而不是 void 参数列表)来编码。让我们现在尝试这样做。如你所见,代码不再编译。这是因为为了向后兼容,空的参数列表意味着参数未指定,可以是任何内容。而启用了“需要原型”选项后,编译器更加严格,不承认这种弱指定的原型。

好的,最后,你已准备好在 Stellaris 板上运行代码。首先要做的是检查你的程序是否仍像以前一样闪烁 LED。确实如此。当你停止代码时,会发现程序停在 delay 函数内部。这是可以预料的,因为你的程序 99.999% 的时间都在执行延迟循环。

函数调用机制

接下来有趣的事情是找出你的处理器实际如何调用 delay 函数。让我们设置一个断点并运行程序。

如你所见,对你的 delay 函数的调用最终归结为一条名为 BL 的指令。从之前关于流程控制的第 2 课中,你可能记得分支指令只是改变程序计数器 PC 的值。然而,BL 指令有一个额外的重要副作用:它将下一条指令的地址保存到 R14 寄存器,该寄存器也称为链接寄存器 LR。这样,LR 寄存器就记住了函数完成后要返回的代码位置。

让我们记住,BL 之后的下一条指令位于地址 0x9C。顺便说一下,请注意 BL 指令本身是 4 字节长,而大多数其他指令只有 2 字节长。所以你可以看到,称为 Thumb-2 的 ARM Cortex-M 处理器指令集主要由 2 字节指令组成,偶尔有 4 字节指令。

当你单步执行 BL 指令时,可以看到程序计数器确实跳转到了你的 delay 函数的开头,而 LR 更改为 0x9C。等等,它实际上变成了 0x9D。这当然很奇怪,因为所有 Thumb-2 指令必须在偶数地址对齐,而值 0x9D 是奇数。当我们看到函数如何返回时,我会在一分钟内解释这个奇怪的现象。

在此之前,让我指出关于函数代码的一些有趣之处。

栈与局部变量

函数首先调整 SP 寄存器。SP 代表栈指针,是 R13 寄存器的别名。栈是 C 调用栈机制的硬件实现,因此它是本课中要学习的最重要的寄存器。

C 栈只是 RAM 中的一个区域,只能从一端增长或缩小。这一端称为栈顶,SP 寄存器包含这个顶部地址。你可以通过将内存视图指向 SP 中存储的地址来轻松查看栈中的内容。为了查看这个栈,最好将内存视图调整为只显示一列。

在 ARM 处理器中,栈向低地址方向增长(在内存视图中向上),向高地址方向收缩(在内存视图中向下)。在其他处理器中,栈可能向相反方向增长。C 栈的一个很好的比喻是一摞盘子。你只能从栈顶添加或移除盘子。

现在你明白了,从 SP 减去 4 会使栈增长这个量,并在栈顶为局部变量 counter 创建空间。随后,这个变量被清零并递增一百万次。

现在,让我们在函数末尾设置一个断点,看看它如何返回。

函数返回

编译器在返回之前需要做的第一件事是精确地反转在函数入口处对栈执行的任何操作。在这种情况下,栈通过增加 4 字节来收缩,以释放最初为 counter 变量分配的空间。

如你所见,在当前的栈顶,counter 的最后一个值是 0xF4240,即十进制的 100 万,这是你的延迟循环的迭代次数。

下一条指令是实际从你的函数返回。返回是通过分支指令 BX 完成的,它代表“分支并交换”。该指令将程序计数器设置为指定寄存器(本例中为 LR)中的值。但是,并非 LR 中的所有位都传输到 PC。具体来说,PC 的最低有效位始终设置为 0,这是有道理的,因为返回地址必须是偶数。因此,LR 中的最低有效位不被用于寻址,而是被解释为指令集交换位。

如果此位为 1,处理器切换到 Thumb 指令集。如果此位为 0,则切换到 ARM 指令集。问题是 ARM Cortex-M 仅支持 Thumb-2 指令集,无法真正切换到 ARM。因此,在 Cortex-M 中,BX 指令的这种行为只是一种历史遗留。

让我们执行 BX 指令,看看它跳转到哪里。确实,我们最终到达地址 0x9C,这正是调用你的 delay 函数之后的下一条指令。

最后,为了看看会发生什么,让我们再次运行到 delay 函数的末尾,并将 LR 的最低有效位设置为 0。这应该将核心状态交换到 ARM,但 ARM 在 Cortex-M 内核上不受支持。嗯,如你所见,你最终进入了一个硬故障异常。我将在即将到来的关于中断的课程中讨论异常。但现在,我认为看看处理器如何处理不可能的条件会很有趣。

机器最终进入异常处理程序,这就像一个你可以为特定项目重新定义的函数。要退出异常处理程序,需要重置机器。

由于重置将你带回到 main 函数的开头,这是检查调用其他函数的函数的好机会。

非叶子函数

到目前为止,我希望你注意到 main 也是一个函数,就像你的 delay 函数一样。在你从 main 调用 delay 之前,main 是一个所谓的叶子函数,就像树上的叶子,因为它不调用任何其他函数。当你添加对 delay 的调用后,main 就不再是叶子函数了,它必须做一些特殊的事情来保存自己的返回地址。

正如你所记得的,返回地址保存在 LR 寄存器中,但该寄存器会被 BL 指令用新的返回地址覆盖。因此,任何执行 BL 的函数都必须以某种方式保存 LR 的先前值,以便能够返回到正确的位置。

问题当然是,保存链接寄存器的最佳位置在哪里?我希望你从代码中看到,这个地方就是PUSH 操作将指定的寄存器列表保存到栈上,并自动递减栈指针以使栈增长。

让我们通过执行 PUSH 指令来验证这一点。

总结一下,你发现栈有两个用途。首先,它保存被调用函数的局部变量。其次,它存储返回地址。

函数参数

最后,在本课中,我想向你展示函数参数的用途以及如何使用它们。

函数参数允许你在调用函数时指定局部变量的初始值,而每次调用都可以使用不同的参数值集。例如,你可能希望 delay 函数在每次调用时执行不同次数的迭代。

为了实现这一点,你可以指定一个整数参数 iter,它将在函数内部用作迭代限制。一旦函数接受一些参数,它的每次调用都必须为所有这些参数提供初始值。因此,如果你现在尝试编译程序,编译器将报告对 delay 函数的两次调用的错误,因为它们不再与原型匹配。这就是使用原型的美妙之处,因为现在每当你在每次函数调用时忘记提供正确数量和类型的参数时,编译器都可以警告你。

好的,让我们提供参数。对于第一次调用,我使用 100 万次迭代。但对于第二次调用,我只使用 50 万次,这样红色 LED 点亮的时间将是熄灭时间的两倍。

让我们在 LaunchPad 开发板上运行此代码。首先,移除所有断点并自由运行一段时间以观察 LED。确实,红色 LED 点亮的时间看起来大约是熄灭时间的两倍。

接下来,在对 delay 函数的调用处设置断点,以查看参数如何传递给函数。如你所见,BL 指令现在前面有一条将常量值加载到 R0 的指令。对于第二次调用 delay,这个常量是 0x7A120,即十进制的 500000。对于第一次调用,加载到 R0 的值是熟悉的 0xF4240,即十进制的 100 万。

所以正如你所见,在这两种情况下,参数 iter 都是在 R0 寄存器中传递的。现在让我们单步进入 delay 函数,看看它如何使用 iter 参数。确实,如你所见,参数 iter 位于 R0 中,而变量 counter 位于栈顶,因为它的地址与 SP 寄存器的值相同。

总结

本节课中我们一起学习了C语言函数与调用栈的基础知识。

函数至关重要,因为当你正确设计它们时,你可以忽略一项工作是如何完成的,而只专注于正在完成什么,这要简单得多。

但我们关于函数的学习还没有结束。在下一课中,我将更多地讨论栈和函数调用其他函数,包括函数递归调用自身。你还将学习更多关于函数参数以及非 void 返回类型的知识。最后,在底层,我希望能够介绍 ARM 过程调用标准。

如果你喜欢这个频道,请订阅以保持关注。你也可以访问 statemachine.com/quickstart 获取课堂笔记和项目文件下载。

9:模块、递归与AAPCS

在本节课中,我们将继续学习C语言中的函数。你将学会如何利用函数将程序分割到不同的文件中,编写你的第一个递归函数,并了解ARM过程调用标准(AAPCS)。

概述

上一节我们介绍了函数的基本概念,并创建了一个delay函数。本节中,我们将探索函数的更多高级特性,包括模块化编程、递归以及函数调用的底层约定。

将函数移至独立文件

首先,我们将delay函数从主文件移动到它自己的文件中,以实现代码的模块化。

  1. 创建一个新文件。
  2. delay函数的定义剪切并粘贴到这个新文件中。
  3. 将文件保存为delay.c到当前项目目录。
  4. 通过右键单击项目并选择“添加文件”,将delay.c添加到项目中。

此时编译项目,你会遇到一个错误,提示delay函数没有原型声明。

创建头文件

简单的解决方法是把原型从main.c复制到delay.c,但这违反了“不要重复自己”(DRY)的原则。正确的做法是创建一个独立的头文件。

  1. 创建一个新文件,将delay函数的原型剪切并粘贴进去。
  2. 将文件保存为delay.h
  3. main.cdelay.c中,使用#include "delay.h"来包含这个头文件,而不是重复编写原型代码。

为了防止头文件被多次包含,我们可以在头文件中使用预处理指令进行保护。

#ifndef __DELAY_H
#define __DELAY_H

void delay(unsigned int iter);

#endif /* __DELAY_H */

工作原理:当delay.h第一次被包含时,__DELAY_H宏尚未定义,因此预处理器会处理#ifndef#endif之间的所有内容,并定义__DELAY_H宏。如果同一个文件再次被包含,__DELAY_H宏已被定义,预处理器会跳过整个文件内容。

函数返回值

接下来,我们探索函数的另一个特性:返回值。我们将编写一个计算整数阶乘的函数。

首先定义函数原型:

unsigned int fact(unsigned int n);

这个函数接收一个无符号整数n作为参数,并返回n的阶乘值(即 1 * 2 * 3 * ... * n)。

定义了原型后,我们可以在定义函数之前就使用它。以下是几种调用方式:

  • 将返回值赋给变量
    unsigned volatile int x = fact(5);
    
  • 在表达式中使用
    x = fact(3) + fact(4);
    
  • 忽略返回值(如果函数有副作用):
    (void)fact(10); // 明确表示忽略返回值
    

编写递归函数

现在我们来定义fact函数。我们将使用递归的数学定义来实现它:

  • 0的阶乘是1。
  • n的阶乘是 n * (n-1的阶乘)。
unsigned int fact(unsigned int n) {
    if (n == (unsigned int)0) {
        return (unsigned int)1;
    }
    else {
        return n * fact(n - 1);
    }
}

这个函数通过return语句返回结果。函数fact在其定义内部调用了自身,这就是递归

递归与栈的工作原理

当程序运行时,每次函数调用都会在栈上分配空间,用于保存局部变量和返回地址。对于递归函数,每次递归调用都会在栈上创建新的帧,直到达到基线条件(n == 0)。然后,这些调用会依次返回,栈帧被逐个释放(栈“展开”)。

例如,计算fact(5)时,栈上会依次建立fact(5)fact(4)fact(3)fact(2)fact(1)fact(0)的调用帧。fact(0)返回1后,上一层的fact(1)用返回的1乘以自己的n(即1)得到1并返回,依此类推,最终fact(5)返回120。

ARM过程调用标准(AAPCS)

函数调用者和被调用函数之间需要遵循一套约定,这就是ARM应用程序过程调用标准(AAPCS)。它规定了寄存器在函数调用中的角色:

  • 调用者保存寄存器(Caller-saved)R0-R3, R12, LR。函数可以自由修改这些寄存器,调用者需要在调用前保存其中重要的值。
  • 被调用者保存寄存器(Callee-saved)R4-R11, SP。如果函数要使用这些寄存器,它必须在开始时将它们压入栈中,并在返回前恢复原值。

在我们的fact函数汇编代码中,可以看到它保存了R4LR寄存器,正是因为R4是被调用者需要保存的寄存器,而LR中存放的返回地址在后续的递归调用中会被覆盖。

关于递归的注意事项

虽然递归是演示函数调用和栈操作的绝佳例子,但在嵌入式编程中应谨慎使用深度递归。因为每一次递归调用都会消耗栈空间,在内存有限的嵌入式系统中可能导致栈溢出

对于像阶乘这样的计算,迭代版本或查找表通常是更高效、更安全的选择。

总结

本节课我们一起学习了:

  1. 模块化编程:如何将函数分离到独立的.c.h文件中,并使用头文件保护。
  2. 函数返回值:如何定义和调用有返回值的函数。
  3. 递归函数:编写了第一个递归函数fact,并观察了递归调用时栈的增长与收缩。
  4. AAPCS:了解了ARM函数调用约定中寄存器的保存规则。

然而,关于函数的学习还未结束。在下一课中,我们将深入学习函数参数(包括指针参数)、变量的作用域、基于栈的局部变量,并看看当栈溢出时会发生什么。

10:函数中的栈溢出及其他陷阱

在本节课中,我们将继续深入学习C语言中的函数。你将了解关于栈的更多知识,学习如何传递指针参数以及从函数返回指针值。同时,你将看到如果错误地使用函数,程序是如何崩溃的。

开发板与工具更新

在开始编码之前,我们先简要说明一下新版LaunchPad开发板和IAR EWARM工具集的更新。

首先,德州仪器发布了一款名为Tiva C系列LaunchPad的新版开发板。如果你现在按照第0课介绍的去购买,可能会看到Tiva LaunchPad,而不是Stellaris LaunchPad。好消息是,对于本课程的所有目的而言,Tiva LaunchPad与Stellaris LaunchPad是相同的。例如,我手头就有这两块板子,它们可以运行完全相同的代码。

其次,IAR也发布了新版本6.60的IAR EWARM。这个新版本支持新的Tiva产品线。新版本的安装过程与第0课中描述的6.50版本相同。如果你已安装旧版本,建议在安装新版本前先卸载旧版。

现在,让我们复制第9课的项目,并将其重命名为第10课,然后开始今天的学习。

栈溢出初体验

上一课中,我们创建了递归函数 fact 来计算整数参数 n 的阶乘。递归调用让我们观察了函数调用如何在栈上嵌套。今天,我们将“黑”掉这个函数,对栈施加压力直到其崩溃。

为了给栈施加压力,我们在函数内部添加一个局部无符号变量 foo。实际上,为了让它更大,我们将其改为一个大小为10的数组。

unsigned int foo[10];

编译此代码时,你会收到一个警告,提示 foo 未被引用。为了防止编译器将其优化掉,我们以某种方式使用它。在这个“黑”操作中,我们将 n 赋值给 foo[n],并在返回表达式中使用 foo[n] 代替 n

加载代码到Tiva LaunchPad上(尽管项目仍设置为Stellaris),以证明Tiva可以同样运行此代码。

上一课我们使用内存视图来观察栈。IAR调试器也提供了一个专用的栈视图。打开此视图,点击“View”菜单并选择“Stack -> Stack1”。

在点击运行按钮前,在 fact 函数内部的递归调用处设置一个断点。当触发此断点时,Stack1视图显示了数组 foo,这证实了该数组确实存在于栈上。

更有趣的是,当 fact 递归调用自身时,你可以看到栈上又增加了另一个 foo 数组的实例。随着递归的深入,栈的增长速度远快于没有 foo 数组的情况。

你可能会注意到每个 foo 实例都包含一些值。这些值来自RAM的先前使用。这些数据看起来像是Flash内存映像,很可能是编程器将代码烧录到Flash ROM时留下的。最重要的是,对于你的程序而言,栈上的这些内容是“垃圾”。你不能假设任何自动变量有特定的初始值,而必须显式地将每个自动变量初始化为你需要的值。

为了帮助你记住这个关键事实,让我们扩展一下之前提到的“脏盘子”比喻:C语言的栈就像一摞盘子,但它们都是脏的。在使用它们之前,你必须先清洗干净。

这个压力测试虽然“锤击”了栈,但尚未使其崩溃。让我们回到代码,通过将 foo 的大小再增加一个数量级到100,来更用力地“锤击”它。

unsigned int foo[100];

在运行代码前,我想向你展示IAR调试器中栈的另一个视图。请点击“View”菜单并选择“Call Stack”。顾名思义,调用栈视图显示了当前嵌套在栈上的所有函数调用。

确保 fact 函数内的断点仍然设置,然后运行代码。当触发断点时,你可以在栈上看到更大的 foo 数组,同时调用栈视图确认你正处于从 main 调用的 fact 函数内部。

现在,你可以看到 fact 函数开始递归调用自身,栈增长得非常快。当达到大约5层调用嵌套时,你的栈就完全耗尽了。栈指针正好位于RAM的起始地址,并且没有更多空间向更低的地址增长,因为那里没有内存。

继续执行,观察会发生什么。首先,你的程序会冻结并且不会再次触发断点。手动中断代码,你会看到栈指针已经低于有效的RAM起始地址(0x20000000),程序在一个名为 BusFault_Handler 的无限循环中挂起。

这需要一些解释。BusFault_Handler 不是你的代码,而是标准IAR启动代码中提供的所谓“异常处理程序”,它由链接器与你的主程序链接在一起。总线故障异常是CPU中实现的一种硬件机制,用于处理CPU被迫访问不存在内存的情况。IAR启动代码将总线故障异常(以及所有其他异常)实现为一个无限循环。但你实际上可以提供自己的代码来做其他事情,例如复位CPU。我将在关于启动代码的课程中展示如何定义自己的异常处理程序。

至此,恭喜你经历了第一次栈溢出。现在你知道它是什么感觉了,我希望你能养成一个习惯:当发现程序在硬件异常中挂起时,检查栈指针。

请注意,栈溢出也可能以其他方式失败,例如只破坏某些数据而没有耗尽内存,这可能更难检测和诊断。无论如何,你应该养成习惯,为你的特定应用程序适当地调整栈大小,以免栈溢出。

如何调整栈大小

要更改栈大小,请打开项目选项,在“Config”选项卡下选择“Linker”类别。勾选“Override default”,因为你将要更改默认的栈大小设置。

点击“Edit”按钮,选择“Stack/Heap Sizes”选项卡。默认栈大小是2KB(此处以十六进制指定,但你可以使用十进制)。我相信对于本阶段的所有项目,1KB的栈应该是足够的(当然,前提是你从阶乘函数中移除了那个巨大的数组)。

堆是用于动态内存分配(通过标准函数 mallocfree)的RAM区域。这在通用计算中非常有用,但在实时嵌入式编程中,堆通常弊大于利,你不应该使用它。在这种情况下,你应该将堆大小设置为0。

点击“Save”按钮后,你需要选择编辑后的IAR链接器脚本文件 project.icf 的保存位置。你需要保存这个文件,因为它不再是默认文件,现在包含了特定于你项目的设置。

栈损坏:一个更微妙的灾难

现在,让我们在代码中制造另一个更微妙的灾难。这次,你将损坏栈,并观察它是如何“爆炸”的。这是一个堪比福尔摩斯探案的神秘事件。

为了准备“犯罪现场”,请将 fact 函数中 foo 数组的大小改回6。然后,在 main 函数中,用参数7调用 fact,并在此调用处设置断点。

我希望你开始明白这是怎么回事。运行代码到断点,并从那里单步执行。

指令 push {r4, lr} 应该从上节课就很熟悉了。但从栈指针的减法操作是新的。这就是你的 foo 数组在栈上分配的方式。你会看到SP减少了0x18个字节(十进制24),这只是在栈上腾出空间,但没有浪费任何周期来清理这个空间。这就是为什么 foo 数组包含垃圾。

add 指令将 foo 数组的地址(恰好是当前栈顶)放入R1。str 指令将R0中的值 n 写入索引为 n(也是R0)的位置。逻辑左移两位是因为 foo 的每个元素占4个字节。

现在仔细观察 str 指令的效果,因为“犯罪”就发生在这里。foo 的最后一个有效索引是5。因此,索引7超出了 foo 末尾两个位置。这个位置恰好是保存的 lr 寄存器,它现在被损坏了,函数将无法正确返回。

所以,“犯罪”其实很简单:你越界索引了一个数组并损坏了栈。请注意,C语言允许你很容易地做到这一点,因为C不检查数组索引,并相信你知道自己在做什么。

然而,就像任何好的悬疑故事一样,有趣的不是“犯罪”本身,而是随之展开的故事。事实证明,这个故事在这里展开了数千个时钟周期,系统才最终失败。

显然,调试此类问题的艺术在于避免单步执行数千步,而应该学习如何策略性地设置断点。

第一个策略性的位置是 fact 函数的返回处。在此处停止是合乎逻辑的,因为你知道问题出在返回地址上。当你触发这个断点时,所有阶乘的递归调用都嵌套在栈上。这是栈的最大使用量,你可以验证没有栈溢出问题。

当你从这里继续执行时,随着每个嵌套调用返回,栈逐渐“展开”。我认为观看这个过程很美。

最后,你到达最后一个调用,栈变得非常小。add 指令从栈中移除 foo 数组的大小。最后的 pop {r4, pc} 指令执行最终返回。

请注意,即将恢复到PC的返回地址是7。这是我精心策划的损坏值,我特意将其设为奇数。因为如果它是偶数,pop 指令会在此处立即失败,CPU将进入异常,从而结束故事。如果你忘了为什么在Cortex-M上每个返回地址必须是奇数,请回顾第8课。

因此,通过我精心而“变态”的策划,pop 指令成功了,程序计数器被强制跳转到地址6。

坦白说,接下来发生的事情让我感到意外,因为反汇编视图实际上具有误导性。问题是这些低内存位置用于所谓的异常和中断向量表,这意味着那里是一堆32位的地址数据。但不知何故,CPU将这些数据当作合法的16位指令来执行,而反汇编器需要两步才能解析每个32位数据值。

纯属巧合,main 函数紧跟在Flash ROM中的向量表之后。因此,CPU现在开始执行真实的指令。第一条指令将寄存器压栈,但栈上已经保存了之前从 main 压入的寄存器,因为请记住,main 从未真正返回。

当你从这里继续执行时,最终会再次触发仍设置在阶乘函数返回处的断点。移除这个断点并继续。从现在开始,每次你点击“Continue”按钮,都会执行整个递归调用周期,损坏栈,并通过执行向量表这个“后门”重新进入 main 函数。

但请注意,栈在缓慢增长,因为 main 并没有真正返回,所以它不会从栈中弹出其栈帧。

最后,移除最后一个断点,让程序自由运行。此时,你应该知道它将如何结束:因为栈在增长,你最终会溢出它,CPU将进入总线故障异常。

随着这个谜团的解开,我希望你对损坏栈有了更多的敬畏。我的意思是,这可能会变得非常糟糕,非常快,一个失控的程序有时会在数千个CPU周期内损坏其状态。由于过程中可能存在许多巧合,这往往很难复现和调试。

函数参数与指针

在本节课的最后一部分,我想稍微转换一下话题,讨论函数参数,包括指针参数和从函数返回指针值。

让我们从一个实验开始,修改 delay 函数,使其参数 it 在大于0时递减。这个例子旨在向你展示函数参数就像局部变量一样,你可以修改它们。唯一的区别是参数由调用者初始化,而局部变量必须在函数内部初始化。

void delay(volatile unsigned int it) {
    while (it > 0) {
        --it;
    }
}

像往常一样,对于延时循环,将循环计数器声明为 volatile,以防止编译器将整个循环优化掉。

因为你改变了函数的签名,别忘了更新头文件中的函数原型。

在运行此程序前,将调用 delay 的方式改为传递变量 x 作为参数,而不是常量。在第一次调用 delay 处以及调用后立即设置断点。

在第一个断点处,验证变量 x 的值为100万。在第二个断点处(调用后立即),你可以看到 x 的值仍然是100万,即使 delay 函数已将其参数递减到0。

移除断点并运行程序,看看LaunchPad板上的LED是否仍在闪烁。确实如此。

这个小实验的结论是:C语言通过值传递函数参数。这意味着只有参数的值被复制到函数内部的变量中以进行初始化。函数内部使用的是这个副本,而不是原始参数。这意味着函数永远不会改变原始参数。

但有时你可能恰恰想要改变参数。经典的例子是交换操作 swap,它交换其参数 xy 的值。

你的第一次尝试可能是这样写 swap 函数:

void swap(int x, int y) {
    int tmp = x;
    x = y;
    y = tmp;
}

你还应该提供这个函数的原型。使用场景可能如下:

int x = 1;
int y = 2;
swap(x, y);

当然,这不起作用,因为 swap 函数无法改变参数。

这时你就需要用到指针作为参数。转换为指针很容易,C语法实际上帮助了你:只需将 xy 改为 *x*y。最后,你需要调整函数调用,传入 xy 的地址,因为现在函数签名要求的是指向整数的指针,而不仅仅是整数。

void swap(int *x, int *y) {
    int tmp = *x;
    *x = *y;
    *y = tmp;
}

// 调用方式
swap(&x, &y);

让我们快速测试一下这段代码。正如你所见,根据AAPCS标准,xy 的地址被准备在R0和R1中。x 的值被复制到用作临时变量的R2中。y 的值被加载到R3并存储到 x 的地址。最后,R2中的临时值被存储到 y 的地址。最终结果是,在 swap 返回后,xy 的值确实如你所愿地交换了。

从函数返回指针

最后,在本节课的最后一分钟,我想简要谈谈从函数返回指针。

但在开始之前,让我最终解释一下困扰我们很长时间的那个持续警告:警告提示 main 中的 return 语句不可达。这没问题,因为编译器很聪明,看到了 return 之前有一个无限的 while(1) 循环。

然而,main 的返回类型必须是 int,因为这是C标准要求的。同时,标准还要求每个具有非 void 返回类型的函数都必须显式返回该类型,因此不可能同时满足标准并避免IAR警告。到目前为止,我选择了标准合规性和可移植性,因为其他编译器(例如GCC)如果缺少 return 语句也会报告警告。

但为了最终获得完全干净的编译,我将在 main 中注释掉 return 0; 语句。

解决了这个问题后,让我们假设出于某种原因,你希望 swap 函数记住参数 xy 的原始顺序,并将其作为数组返回。

你第一次尝试实现此行为可能如下所示:

int * swap(int x, int y) {
    int tmp[2];
    tmp[0] = x;
    tmp[1] = y;
    // ... 交换逻辑(如果需要)...
    return tmp; // 警告:返回局部变量的地址
}

你还修改了返回类型以匹配 return 语句。在编译时,编译器会发出警告,但让我们暂时忽略它,因为你想看看为什么这是错误的。

相反,让我们在代码中使用新的 swap 函数。总体思路是不断交换LED点亮和熄灭的延时时间,使闪烁模式更有趣。

运行此代码。为了更好地观察,你需要查看比当前C栈视图更多一点的内容。因此,设置原始内存视图以显示栈指针周围的内存。

swap 返回处设置第一个有趣的断点。触发此断点后,验证栈包含 tmp 数组以及 xy。然而,当你单步跳出 swap 时,栈中只包含 xytmp 数组在原始内存视图中仍然可以识别,但现在它位于栈指针上方。因此,它不再显示在C栈视图中。

现在,在第二次调用 delay 处设置断点并运行。当你停在那里时,注意到R0中的参数 it 是0,而不是预期的500000。快速查看原始内存就能明白原因:在此期间,第一次对 delay 的调用使用了栈,并破坏了之前的值。

现在你明白了为什么返回指向局部变量的指针总是一个坏主意:因为这样的指针在函数返回后,其指向的内容总是位于栈指针上方(即已被释放的栈空间)。更专业的术语是:所有局部变量在函数返回时都超出了作用域,因此它们甚至不再存在,无法被访问。

这个问题的补救方法其实很简单:不要使用栈上的局部变量,而是使用不在栈上的局部变量。在C语言中,在局部变量前使用 static 关键字会告诉编译器在栈外分配该变量,使其生命周期超过函数的任何一次调用,因此即使在函数返回后也可以访问。

int * swap(int x, int y) {
    static int tmp[2]; // 使用 static 关键字
    tmp[0] = x;
    tmp[1] = y;
    // ... 交换逻辑 ...
    return tmp; // 现在这是安全的
}

进行此更改后,编译不会产生警告。当你运行此代码并在 swap 返回处停止时,可以看到 tmp 数组不再位于栈上,而是位于常规内存中,就在RAM的开头。这一次,第二次对 delay 的调用收到了正确的参数500000。

移除所有断点并运行程序,你可以看到LED按预期闪烁。

总结

本节课我们一起学习了使用函数时需要避免的陷阱。我们亲身体验了栈溢出,了解了它是如何发生的以及如何通过调整链接器设置来分配足够的栈空间。我们还深入探讨了栈损坏的微妙后果,它可能导致程序在数千个周期后才表现出异常行为,使得调试变得非常困难。此外,我们学习了C语言中值传递的参数机制,以及如何通过传递指针来让函数修改外部变量。最后,我们明白了为什么不能返回指向局部变量的指针,并学会了使用 static 关键字来安全地返回函数内部数据的地址。

在下一课中,你将学习C语言中的数据结构,以便能够开始使用Cortex微控制器软件接口标准(CMSIS)来访问硬件。

如果你喜欢这个频道,请订阅以保持关注。你也可以访问 state-machine.com/quickstart 获取课堂笔记和项目文件下载。

11:标准整数类型与类型混合

在本节课中,我们将学习C99标准引入的固定宽度整数类型(stdint.h),并探讨在表达式中混合不同整数类型时可能遇到的问题及其解决方案。理解这些概念对于编写可移植且健壮的嵌入式代码至关重要。


标准整数类型 (stdint.h)

在之前的课程中,我们一直使用C语言内置的整数类型,如 intunsigned。然而,这些内置类型的大小并未被C标准严格规定,这在不同架构的处理器上可能导致问题。例如,int 在32位ARM处理器上可能是32位,但在16位或8位处理器(如MSP430或AVR)上可能只有16位。

为了解决这个问题,C99标准引入了 stdint.h 头文件,它定义了一系列具有明确宽度的整数类型。对于嵌入式程序员来说,这是C99标准最有价值的特性之一。

以下是 stdint.h 中定义的最重要的六种类型:

  • int8_t: 有符号8位整数。
  • uint8_t: 无符号8位整数。
  • int16_t: 有符号16位整数。
  • uint16_t: 无符号16位整数。
  • int32_t: 有符号32位整数。
  • uint32_t: 无符号32位整数。

使用这些标准类型可以确保代码在不同处理器和编译器上具有相同的行为,因为编译器供应商会负责为你的CPU提供正确的定义。


类型大小与内存布局

现在,让我们使用标准固定宽度整数类型,并观察ARM Cortex-M处理器如何处理它们。首先,我们定义一些变量并检查它们的大小。

uint8_t  u8a;  // 无符号8位
int8_t   s8a;  // 有符号8位
uint16_t u16c; // 无符号16位
int32_t  s32d; // 有符号32位

我们可以使用 sizeof 运算符来验证类型的大小:

size_t size_u8 = sizeof(u8a);   // 结果为 1 (字节)
size_t size_u16 = sizeof(uint16_t); // 结果为 2 (字节)
size_t size_s32 = sizeof(int32_t);  // 结果为 4 (字节)

通过调试器观察内存,你会发现编译器可能会改变变量在内存中的顺序,这与代码中定义的顺序不同。因此,永远不要假设变量在内存中的布局顺序。

此外,ARM处理器使用特定的指令来读写不同宽度的数据:

  • LDRB / STRB: 读写字节(8位)。
  • LDRH / STRH: 读写半字(16位)。
  • LDR / STR: 读写字(32位)。

ARM通常采用小端字节序,这意味着多字节数据(如32位整数)的最低有效字节存储在最低的内存地址。


混合整数类型与隐式转换

在编程中,你不可避免地需要在表达式和赋值中混合使用不同的整数类型。C语言通过隐式转换自动处理这些情况,但其规则复杂且反直觉,常常导致开发者困惑和难以察觉的Bug。

上一节我们介绍了标准整数类型及其内存表示,本节中我们来看看在表达式中混合它们时会发生什么。

整数提升与计算精度

C语言在进行任何计算之前,总是自动将任何小于 int 的整数类型提升为内置的 intunsigned int 类型。这可能导致在较小位宽的处理器上出现溢出问题。

考虑以下示例:

uint32_t u32_result = 40000 + 30000;

在32位ARM机器上,int 是32位,因此计算在32位精度下进行,结果为70000。但在16位的MSP430上,int 只有16位,计算会发生溢出,结果会被截断(例如得到4464),尽管最终要赋值给一个32位变量。

解决方案是强制至少一个操作数在计算前提升到足够的精度:

uint32_t u32_result = (uint32_t)40000 + 30000; // 或对两个操作数都进行强制转换

有符号与无符号类型的混合

混合有符号和无符号类型是另一个常见的陷阱。C语言的规则是:当有符号和无符号操作数混合时,两者都会被提升为 unsigned int,计算结果也是无符号的。

考虑以下表达式:

int32_t s32_val = 10 - u16_c; // 假设 u16_c 是 uint16_t 类型,值为100

在32位ARM上,结果可能是预期的-90。但在16位MSP430上,由于提升和赋值时的符号扩展规则不同,s32_val 可能得到一个巨大的正数(如65446)。

最佳实践是避免混合有符号和无符号类型。如果必须混合,应进行显式类型转换:

int32_t s32_val = 10 - (int16_t)u16_c; // 显式转换为有符号类型

比较操作中的陷阱

比较操作也容易受类型混合影响。例如:

uint32_t u32_var = 10;
if (u32_var > -1) { ... }

你可能会认为这个比较总是为真,因为无符号数的最小值是0,大于-1。但实际上,-1 被提升为无符号整数后变成了最大值(0xFFFFFFFF),所以比较 (u32_var > 0xFFFFFFFF) 总是为假。

同样,通过显式转换可以解决这个问题:

if ((int32_t)u32_var > -1) { ... }

小整数上的位操作

对小整数(如 uint8_t)进行位操作时也需小心。考虑以下检查校验和的代码:

uint8_t u8_checksum = 0xFF;
if (~u8_checksum == 0) { ... } // 检查取反后是否为0

你可能期望比较为真,但 u8_checksum 首先被提升为 int,取反操作作用于整个整型,结果永远不会等于0。编译器甚至可能直接优化掉整个 if 语句。

解决方案是将结果强制转换回字节类型:

if ((uint8_t)(~u8_checksum) == 0) { ... }

总结

本节课中我们一起学习了C99标准中的固定宽度整数类型(stdint.h)及其重要性,它确保了代码在不同平台上的可移植性。我们还深入探讨了在表达式中混合不同整数类型(包括不同大小和有符号性)时,C语言复杂的隐式转换规则。这些规则常常导致反直觉的结果和隐蔽的Bug。

关键要点总结如下:

  1. 使用 stdint.h: 始终使用 int8_tuint32_t 等标准类型,而不是 intlong 等内置类型。
  2. 注意整数提升: 计算精度取决于操作数类型,而非赋值目标的类型。必要时使用显式类型转换。
  3. 避免混合有符号/无符号类型: 如果无法避免,务必进行显式转换以明确意图。
  4. 小心比较和位操作: 在这些场景中,隐式提升规则可能导致意外行为。

理解并妥善处理类型转换是编写可靠、可移植嵌入式C代码的关键技能。在下一节课中,我们将探讨C语言的结构体和CMSIS(Cortex微控制器软件接口标准)。

如果你喜欢本课程,请订阅以持续关注。你也可以访问 statemachine.com/quickstart 获取课堂笔记和项目文件下载。

12:C语言结构与CMSIS标准

在本节课中,我们将学习C语言中的结构体,并介绍如何使用CMSIS标准通过结构体来访问Cortex-M微控制器中的硬件寄存器。

概述

到目前为止,本课程中我们仅使用了标量数据类型(如内置整数)和上节课介绍的确切宽度整数类型。在第7课中,我们还学习了数组,这是一种将相同类型的变量组合在一起的方式。结构体是C语言中另一种将变量(可能是不同类型)组合在一起的机制。结构体的好处在于,它允许将一组相关的变量作为一个单元来处理,而不是作为单独的实体。在嵌入式系统中,结构体还允许你以优雅且直观的方式访问硬件。本节课我们将首先解释语法并给出一些C语言结构体的示例,然后探索其在硬件访问中的应用。

C语言结构体语法

为了解释结构体语法,我们将为嵌入式图形LCD显示屏创建几个结构体。最基本的对象是一个点,它将x坐标和y坐标组合在一起。

以下是一个结构体声明的示例:

struct Point {
    uint16_t x;
    uint8_t y;
};

关键字 struct 用于引入结构体声明。单词 struct 后面可以跟一个可选的名称,称为结构体标签,例如本例中的标签 Point。大括号内列出的变量称为成员。因此,这里的 xy 是结构体 Point 的成员。

结构体声明后可以立即跟一个变量列表。例如,你可以定义变量 papb,它们都是 Point 类型。实际上,如果在结构体后立即定义变量,甚至可以完全省略结构体标签。

然而,更常见的做法是定义一个结构体,后面不跟变量列表。这是C语言中唯一一种右大括号必须紧跟分号的情况。不跟变量列表的结构体声明不保留存储空间,但如果结构体有标签,你可以在以后使用它来声明变量。

尽管如此,这种使用结构体类型的方式仍然有些繁琐,因为你必须总是在结构体标签前重复 struct 关键字。这也不是C++中的做法,在C++中你不需要在类名前面重复 structclass 关键字。

但到现在,你应该知道解决方案了。在上节课中,我们学习了 typedef 指令,你可以用它来定义 Point 类型,如下所示:

typedef struct Point {
    uint16_t x;
    uint8_t y;
} Point;

现在,你可以在没有 struct 关键字的情况下定义 Point 类型的变量 p1p2

一个有趣的地方是,编译器同时接受了结构体标签和类型定义名都使用 Point。这是因为在C语言中,标签名与类型定义名、变量名或函数名占据不同的命名空间。

最后,还有一种方法可以通过在 typedef 内部使用无标签的结构体声明来完全消除 Point 名称的重复。在这种情况下,编译器显然不再识别 struct Point 类型,但它仍然接受 typedef Point 类型。

总结一下,我倾向于使用最后一种不带结构体标签的 typedef 形式。实际上,最新的安全C标准MISRA C 2012也推荐这样做,其咨询规则2.4建议项目不应包含未使用的标签声明。结构体标签名几乎从不需要,一个显著的例外是所谓的自引用结构,例如链表或树的节点。但这是结构体的一种高级用法,超出了本课程当前的范围。

访问结构体成员

现在你已经知道了声明结构体类型的所有不同形式,是时候了解你实际上可以用它们做什么了。首先,你可以访问单个结构体成员。在C语言中,这通常通过特殊的成员访问运算符(一个点 .)来实现。

例如,以下代码将获取结构体 Point 的大小并将其赋值给 p1.x 成员:

p1.x = sizeof(struct Point);

当然,你也可以在表达式中使用点运算符。例如,这里计算 p1.x 成员减去3,并将其赋值给 p1.y 成员:

p1.y = p1.x - 3U;

此时,你需要知道点运算符具有非常高的优先级,高于任何算术运算符(如减号)。这就是为什么你通常不需要在访问结构体成员时使用括号。

结构体在内存中的布局

现在,是时候深入机器代码,看看你的 Point 结构体在内存中实际是什么样子,以及它的真实大小是多少,因为它可能不是你预期的3字节(x占2字节,y占1字节)。

为了这部分课程,我将使用模拟器,但你也可以在LaunchPad开发板上运行代码。

清理 Watch1 视图并准备监视 p1 结构体变量。同时设置内存视图以显示 p1 地址周围的内存。

正如你在两个视图中看到的,p1 以全零开始。当你单步执行代码时,可以看到编译器将 p1.x 视为任何其他半字变量,并使用已经熟悉的 STRH 指令在其中存储一个值。但编译器显然认为 Point 结构体的大小是4字节,而不是3字节。

在表达式求值中,你可以再次看到 p1.y 被视为字节大小的变量,因为表达式的结果是用 STRB 指令存储的。

查看内存视图,你现在可以将 Point 结构体识别为地址 0x20000000 处的4个字节,后面跟着全零。值为4的 p1.x 成员位于结构体的开头,值为1的 p1.y 成员紧随其后。有趣的是,编译器在 p1.y 成员之后填充了一个字节。

为了研究结构体布局和填充,让我们反转成员的顺序并再次运行。

正如你所看到的,内存中成员的顺序也被反转了,因为 p1.y 现在位于比 p1.x 更低的地址。现在查看内存视图,你可以看到确实 p1.y 成员位于结构体的开头,后面跟着一个未使用的字节,然后是 p1.x 成员,因此结构体总大小为4字节。

与之前的运行相比,顺序的改变应该让你相信,编译器会完全按照你在结构体声明中键入的顺序来安排结构体成员。

然而,编译器可以并且有时会在你的结构体中插入额外的填充字节。第二个有趣的观察是,显然,ARM Cortex-M编译器宁愿浪费1字节内存,也不愿将 p1.x 半字成员放在奇数地址。明显的问题是:为什么?

为了研究最后一个问题,以某种方式强制编译器不浪费 yx 成员之间的字节会很有趣。这在标准C中是不可能的,但大多数嵌入式编译器提供了一些非标准扩展来紧密打包结构体成员,没有任何填充。

IAR编译器通过扩展关键字 __packed 提供了这个功能,你可以将其放在 struct 关键字前面。所以,让我们现在就做这个,看看会发生什么。

另外,为了避免 p1.y 的值为0(当结构体大小确实为3时会发生这种情况),让我们将其改为其他值。

Watch 视图中要注意的第一件有趣的事情是,这次 p1.x 成员被分配到了一个奇数的内存地址。在反汇编窗口中,请注意代码仍然非常高效。特别是,对 p1.x 的赋值仍然由 STRH 指令处理,即使 p1.x 在奇数地址。那么,这有什么大不了的?CPU处理这个结构体完全没问题。

为了看到真正的区别,让我们改变CPU。打开项目选项对话框,转到“General Options”,选择Cortex-M0内核而不是Cortex-M4F。接下来,转到“Debugger”部分,选择“TI Stellaris”接口,并确保选中“Use Flash Loaders”选项。

是的,没错。我真的打算在真实硬件上运行代码,而不仅仅是在模拟器中。事实上,我将在TI LaunchPad开发板上运行它,即使它装有Cortex-M4F处理器,而不是Cortex-M0。通过这种方式,我希望让你相信Cortex-M处理器是真正二进制兼容的。具体来说,从Cortex-M机器指令的图表中,你可以看到Cortex-M4识别Cortex-M3的所有指令,而Cortex-M3识别Cortex-M0/M1的所有指令。这就像一套嵌套娃娃,任何更大的娃娃都可以容纳所有更小的娃娃。

在代码被编程到LaunchPad开发板后,快速检查 p1.x 是否仍在奇数地址,并设置内存视图。

当我开始单步执行代码时,请注意,用于简单赋值 p1.x 的代码比以前更大。为了帮助你看到区别,我将之前Cortex-M4F的反汇编放在旁边。

正如你所看到的,为Cortex-M0编译的相同C源代码由两条 STRB 指令加上一条逻辑右移指令组成,而Cortex-M4仅用一条 STRH 指令就实现了相同的效果。

有趣的是,这并不是因为Cortex-M0没有 STRH 指令。事实上它有,但它此时不能使用它,因为Cortex-M0中的 STRH 指令不如Cortex-M4中的强大,无法访问分配在奇数地址的半字。

所以在这里,你第一次看到内存中数据的对齐对处理器很重要。作为一名嵌入式程序员,你绝对需要了解这一点,否则你将永远无法理解为什么编译器会在你的结构体中插入填充。编译器倾向于保持数据对齐,而不是浪费CPU周期来访问未对齐的数据。

你还可以在这里看到,打包结构体的访问效率可能不如常规的非打包结构体。换句话说,你只有在绝对需要避免填充时才需要明智地使用打包结构体。

为了结束在Cortex-M4 CPU上运行Cortex-M0代码的实验,我直接运行程序,以便你可以看到LaunchPad开发板像以前一样继续闪烁LED,因此它愉快地执行M0代码。

复杂结构体与指针

现在我想向你展示更多可以用结构体做的事情。首先,你可以创建更复杂的结构体,包括包含其他结构体的结构体。

例如,一个矩形窗口可能包含两个点,用于窗口的左上角和右下角。结构体也可以包含数组。例如,一个三角形结构体可能包含一个由三个角组成的数组,每个角都是一个点。所以你可以看到,你可以有结构体数组。

结构体类型的变量有时被称为实例。所以这里 wWindow 的一个实例,tTriangle 结构体的一个实例。

现在,关于访问这些复杂结构体的成员,以下是访问窗口和三角形结构体嵌套成员的示例:

w.topLeft.x = 10U;
t.corners[0].y = 20U;

请注意IAR编辑器如何通过显示给定结构体的所有可能成员来帮助你。

但赋值不仅限于基本标量类型。你也可以赋值整个结构体。例如,你可以将整个 p1 结构体赋值给 p2 结构体,或者将整个复杂的 Window 结构体赋值给另一个 Window 结构体。

然而,我希望你意识到,更复杂的结构体可能占用相当大的内存,因此看似无害的结构体赋值可能意味着将一大块内存从一个变量复制到另一个变量。

因此,与其将整个结构体从内存中的一个地方复制到另一个地方,通常更有效的方法是使用指向结构体的指针。正如你可能从第3课中记得的,指针类型是通过简单地在类型名后面加一个星号 * 来创建的。这正是你创建指向结构体类型的指针的方式。例如,pp 是指向 Point 类型的指针,而 wp 是指向 Window 类型的指针。

要初始化指针,你可以使用取地址运算符 & 获取变量的地址。例如,你可以将 pp 设置为指向 p1 点,或将 wp 设置为指向 w2 窗口。

下一个问题是如何使用指针访问结构体的成员。C语言提供了两种方式。首先,正如你在第3课中学到的,你可以在指针前应用星号运算符 * 以获取指针指向的对象。

所以如果 pp 是指向 Point 的指针,*pp 就是 Point 类型。从那里,你可以使用点运算符访问任何成员。请注意,(*pp) 周围的括号是必要的,因为点运算符的优先级非常高,甚至高于星号运算符。

以类似的方式,你可以解引用 wp 指针来设置 topLeft 成员。请注意,这里再次看到了整个结构体的赋值,因为 topLeft 成员和 *pp 都是 Point 类型。

但是指向结构体的指针在C语言中使用如此频繁,以至于该语言提供了另一种运算符 ->,作为通过指针访问结构体成员的更快方式。因此,以下两个赋值等同于之前的版本:

pp->x = 5U;
wp->topLeft = *pp;

现在让我们在调试器中看看如何访问这些更复杂的结构体。你可以保留Cortex-M0内核,但从 Point 结构体中移除 __packed 扩展关键字,以避免访问未对齐数据使情况复杂化。

单步执行到访问 Window 结构体的代码处,并将 w 变量放入监视窗口。确保所有成员都已展开并可见。

现在,单步执行反汇编,并注意一些有趣的事情。首先,你应该注意到 STRH 指令,这意味着它在Cortex-M0中可用,但仅当 Point 成员 x 对齐时。其次,请注意 STRH 指令后的方括号。这意味着 R0 寄存器的内容必须存储在 R1 寄存器中的地址加上2字节的偏移量处(在本例中)。这种带有给定基址寄存器额外偏移量的寻址模式对于访问结构体非常方便。事实上,它可能正是为此目的而设计的。

因为对于编译器来说,结构体不过是一堆偏移量,每个成员一个,从结构体的开头开始。在这里,你再次看到这种寻址模式用于 STRB 指令,以访问 Window 结构体中 bottomRight 点的 y 成员。正如你立即看到的,该成员距离 Window 结构体开头有4字节的偏移量。

让我们通过查看 w 变量在内存视图中的地址来确认这些偏移量。所以这里是整个结构体。这里是距离 topLeft.x 成员的2字节偏移量,这里是距离 bottomRight.y 成员的4字节偏移量。

使用结构体访问硬件寄存器

现在,我希望你真的准备好接受将结构体应用于访问嵌入式微控制器(如系统控制寄存器以启用或禁用各种外设,或GPIO寄存器,通过它们你可以在LaunchPad开发板上打开或关闭各种颜色的LED)中的硬件寄存器的想法。

请注意,到目前为止你访问硬件寄存器的方式相当原始,并且不涉及结构体。你使用了 lm4f.h 头文件,该文件将你的LM4F微控制器的硬件寄存器简单地定义为预处理器宏。

例如,GPIO_PORTF_AHB_DIR 寄存器被定义为对具有数据手册中寄存器硬编码地址的指针的解引用。所有其他寄存器都以类似的方式定义。

相比之下,现在的想法是设计一个C结构体,使其数据成员对应于给定硬件块(如系统控制或GPIO)内的所有寄存器。为此,你需要查阅数据手册。这里我有你的TI LaunchPad开发板上使用的特定TM4C微控制器的数据手册,该数据手册也与稍旧的LM4F LaunchPad上使用的LM4F微控制器相同。这个PDF可以从德州仪器直接获取,也可以从伴随本视频课程的 statemachine.com/quickstart 网页获取。

例如,在通用输入输出块的描述中,你可以找到“寄存器映射”部分。正如你在那里看到的,GPIO块内的所有寄存器都指定为从该块基地址开始的偏移量。因此,寄存器映射提供了C语言中GPIO结构体的蓝图,因为你从一分钟前的调试会话中记得,结构体只是一堆偏移量,每个成员一个。

因此,有了数据手册,我希望你开始明白如何编写GPIO块的C结构体。好消息是你不需要自己做,因为微控制器供应商已经完成了。事实上,我已经将 tm4c_scmsis.h 头文件复制到你当前的项目目录中,该文件包含在你的微控制器中找到的所有硬件块的结构体定义。

当你打开那个TM4C头文件并向下滚动一点,你可以看到它包含 typedef 结构体定义。例如,这里我们可以看到系统控制(SYSCTL)的结构体。紧接着下面,你可以看到GPIO的结构体。

作为一个快速练习,让我们将TM4C头文件中的结构体定义与数据手册中的GPIO寄存器映射进行比较。正如你所看到的,数据手册中的第一个寄存器是 GPIO_DATA,但当你仔细看时,你可以看到在映射中到下一个寄存器的偏移量有一个 0x400 的大间隙。这是因为 GPIO_DATA 不是单个寄存器,而是一组256个4字节寄存器,我在第7课中详细讨论过。在C结构体中,这256个数据寄存器组表示为一个包含255个 DATA_Bits 成员加上 DATA 结构体成员的数组。再次,请参考第7课,了解为什么这最后一个数据寄存器是特殊的。

GPIO映射中所有后续的寄存器都与头文件中C结构体的成员一一对应。我只想指出在 GPIO_AFSEL 寄存器之后偏移量中的间隙。这样的间隙在C结构体中表示为 RESERVED1 数组成员,以便后续成员精确对齐数据手册中指定的偏移量。

最后,我相信你很好奇C结构体中使用的类型。我希望你认出 uint32_t 固定宽度整数类型,这意味着所有寄存器都是32位宽,但 __IO__I__O 标识符看起来确实很奇怪。事实证明,这些是Cortex微控制器软件接口标准(CMSIS)中定义的预处理器宏,TM4C头文件是其中的一部分。

我马上会向你展示这些CMSIS宏的实际定义,但首先请注意,这些宏对应于数据手册中的第三列。其中 __IO 代表输入输出,对应于读写;__I 代表输入,对应于只读;__O 代表输出,对应于只写。

回到CMSIS,你可以看到TM4C头文件并不是完全独立的,而是包含了 core_scm4.h 头文件。这个头文件是CMSIS行业标准的一部分,并作为工具链的一个组成部分由IAR分发。

要查看 core_scm4.h 头文件,请转到你的IAR工具链安装目录。进入 arm\CMSIS\Include 并打开该文件。

所以这里是宏 __IO__I__O 的定义。这些宏实际上被定义为 volatile 关键字,我希望这对你来说是有意义的。这也意味着 volatile 限定符可以用于单个结构体成员。__I 宏还有额外的限定符 const,这意味着这样指定的变量是常量,不能被修改。我希望在未来的课程中涵盖 const 关键字的使用和程序的常量正确性概念。

关于 core_scm4.h 头文件,我只想向你展示它也包含定义硬件寄存器的结构体。例如,这里是嵌套向量中断控制器(NVIC)的结构体,它是每个Cortex-M内核的一部分,因此将其定义在CMSIS核心头文件中是有意义的。你将在未来关于中断的课程中使用NVIC。

有了所有这些信息,你终于可以重写你的代码,使用符合CMSIS标准的、通过结构体访问硬件的方式。

你需要理解的最后一块拼图是如何确保系统控制或GPIO结构体位于正确的基地址。这似乎是一个大问题,因为到目前为止,你只创建了 PointWindowTriangle 结构体的实例,编译器控制它们在内存中的放置。

但这个问题其实并不新鲜,你之前在第4课就已经遇到过。解决方案是使用指向结构体的指针,这些指针被初始化为数据手册中的硬编码基地址。

事实上,这正是TM4C头文件中所做的。在文件的末尾,你可以找到各种硬件块的 #define 基地址。在那下面,你有一堆 #define 硬编码指针指向各种结构体类型。例如,SYSCTL 宏定义了一个指向 SYSCTL_Type 结构体的指针,该指针硬编码到系统控制基地址。类似地,GPIOF_AHB 宏定义了一个指向 GPIO_Type 结构体的指针,该指针硬编码到GPIO端口F AHB基地址。

替换你访问硬件方式的第一步是将头文件名更改为 tm4c_scmsis.h。你也可以从项目中移除 lm4f.h 头文件。接下来,你使用新形式替换每个寄存器访问。例如,要替换第一个寄存器访问,你取 SYSCTL 指针并附加成员访问运算符。此时,IAR编辑器将通过显示结构体的所有成员来帮助你。你从列表中选择适当的寄存器。为了确定选择哪个,你可能需要查阅数据手册和 SYSCTL_Type 结构体定义。你以类似的方式处理所有其他寄存器。

GPIO端口F数据寄存器实际上是一个包含255个寄存器的数组,正如你几分钟前看到的。正如你所看到的,程序编译得很干净。

最后一件要做的事情是验证LED是否像以前一样闪烁。确实如此。

总结

本节课我们一起学习了C语言中的结构体以及CMSIS标准。我们探讨了结构体的声明、成员访问、内存布局和填充,以及如何使用结构体指针。更重要的是,我们了解了如何利用CMSIS标准定义的结构体,以一种更优雅、更直观的方式来访问Cortex-M微控制器中的硬件寄存器,这比直接使用硬编码地址的宏更加清晰和安全。

在下一课中,我将讨论函数指针,你将需要理解启动代码和中断处理程序。如果你喜欢这个频道,请订阅以保持关注。你也可以访问 statemachine.com/quickstart 获取课堂笔记和项目文件下载。

13:启动代码(第一部分)- CPU如何从复位到main函数 🚀

在本节课中,我们将学习嵌入式系统启动代码的工作原理。你将了解CPU从复位到执行main函数之间发生了什么,包括标准库启动代码如何初始化各种数据段,以及如何通过链接器映射文件查看这些信息。

概述

启动代码是嵌入式程序在main函数之前运行的一段特殊代码。它负责初始化硬件、设置内存(如栈、堆)以及为C语言环境准备数据段(如初始化全局变量、清零未初始化变量)。理解启动代码对于调试和编写可靠的嵌入式软件至关重要。

准备工作

首先,我们需要复制上一课(第12课)的项目,并将其重命名为“lesson13”。如果你刚刚加入本课程,可以从statemachine.com/quickstart下载之前的项目文件。

进入新的“lesson13”目录,双击工作空间文件以打开IAR工具集。本课使用的是IAR EWARM 7.10版本。

探索main函数之前的世界

到目前为止,我们调试时都是从main函数开始。现在,我们将探索main函数之前的世界。

打开项目选项对话框,在“Debugger”部分,取消勾选“Run to main”选项。这允许调试器在启动代码处停止,而不是直接跳到main

同时,确保选择了“TI ICDI Stellaris Debug Driver”,因为本课需要使用真实的Tiva C LaunchPad开发板,而不是模拟器。此外,请确认“Use Flash loaders”选项被选中。

最后,在“General Options”中,将设备从实验用的Cortex-M0改回你的Tiva C LaunchPad实际使用的TM4C设备。请注意,此设备使用硬件浮点单元(FPV4)。

现在,当你将代码下载到开发板并开始调试时,你会发现程序并非从main开始执行。相反,你遇到的第一个代码标签是__iar_program_start。同时请注意,除了栈指针(SP)被初始化为RAM中的一个合理值外,大多数寄存器都被初始化为0。我们将在课程后面了解这是如何发生的。

现在,让我们快速单步执行代码,了解发生了什么。

第一个有趣的指令是BL(带链接的分支),这是一个函数调用。这里被调用的函数是__iar_init_vfp,它用于初始化硬件浮点单元(FPU)。虽然我们不深入探讨FPU的细节,但需要知道,如果main函数或后续代码要使用FPU,就必须在启动早期初始化它。

第二个有趣的函数调用是?main。这不是一个合法的C函数名,但请记住,我们现在处于C语言规则不适用(如函数命名规则)的启动代码世界。这是IAR特定的启动代码,他们选择将其命名为?main。让我们单步进入这个函数。

在这里,你会看到另一个函数调用__low_level_init。这个函数旨在执行硬件的自定义初始化,这些初始化要么必须非常早进行,要么可以加速启动过程。例如,如果你计划提高CPU时钟速度,最好尽早进行,以便启动代码的其余部分执行得更快。

如果你没有定义自己的__low_level_init函数(目前你肯定没有),则会使用一个空的库版本。通过检查R0寄存器的值可以看到,__low_level_init返回一个值,该值决定是直接调用main函数,还是执行数据初始化。库版本返回一个非零值,因此会调用函数__iar_data_init3

我现在跳过这个函数,因为目前你的程序还没有所有有趣的数据段。在修改代码以包含所有数据段后,我将在下一次调试会话中单步执行数据初始化过程。

在这里,我只想通过展示启动代码最终调用main函数来结束这次运行。

理解数据段

现在,让我们修改代码,使其包含所有数据段。但首先,我需要为你澄清“数据段”这个术语。

也许最好的解释方式就是直接展示各种程序段。为此,你需要让链接器生成所谓的映射文件。

请打开项目选项对话框,进入“Linker”部分和“List”选项卡,勾选“Generate linker map file”选项。然后按F7重新构建代码。

让我们看一下生成的链接器映射文件。你可以在项目的输出文件夹中找到它。

乍一看,你可能会认为链接器映射文件是晦涩难懂的机器级乱码。确实如此,但对于嵌入式程序员来说,它也是一个包含宝贵信息的宝库。我强烈建议你不仅为所有项目生成映射文件,还要学会如何阅读和使用其中的信息。

例如,你应该始终知道你的程序在代码空间(ROM)和数据空间(RAM)方面有多大。要找出这些信息,请滚动到“MODULE SUMMARY”部分。

在这里,你可以看到按数据类型(如只读代码、只读数据和读写数据)以及目标模块(如debug.omain.o)分解的所有信息。

在底部,你可以找到总计:目前是470字节的只读代码、18字节的只读数据和1060字节的读写数据。读写数据的最大贡献者是链接器为栈生成的1024字节。

然而,目前映射文件最有趣的部分是“PLACEMENT SUMMARY”,它列出了所有的程序段。对于链接器来说,程序段只是一个具有符号名称的连续内存块。

例如,地址0到0x40之间的区域被命名为.intvec段。接下来的几个段都命名为.text,用于存放代码。最后,.rodata段用于存放只读数据。所有这些段都位于ROM地址范围内。

在下面,你会找到几个.bss段,它们存放未初始化的数据,这些数据需要在系统启动时被清零。

最后,在最底部,你可以找到CSTACK段,它存放栈,在启动期间保持未初始化状态。

如果你对.text(代码)或.bss(未初始化数据)等名称感到好奇,这些都是来自某些古老汇编语言的历史名称。例如,.bss过去的意思是“由符号开始的块”之类的,今天已经有了完全不同的含义。

但无论如何,重点是,你的程序还没有初始化数据段,即需要在启动期间用硬编码值进行特定初始化的数据段。

因此,在本课的下一步,我们将添加一些数据初始化,然后再次检查这将如何改变你的映射文件。

添加数据初始化

在本课程的前几课中,你已经看到可以在变量定义时为其赋予初始值。语法是在变量名后跟一个等号、一个表达式和一个分号。

例如,一个有符号16位整数x被初始化为-1,而无符号32位整数y被初始化为由#define定义的常量LED_REDLED_GREEN的二进制或运算结果。

你也可以初始化更复杂的变量,比如整个数组。在这种情况下,你需要将初始值用花括号括起来,并用逗号分隔。例如,这里是一个包含四个元素的有符号16位数组sqr的初始化。

当你提供数组初始化器时,可以省略数组大小,C编译器会根据初始化器推断大小。此外,初始化器可能比指定的数组大小包含更少的元素,在这种情况下,缺失的元素将被初始化为0。但是,初始化器不能包含比数组大小更多的元素,否则你会得到编译错误。

结构体的初始化类似于数组的初始化。同样,你需要将成员的初始值用花括号括起来,并用逗号分隔。例如,这里是Point结构体p1的初始化。

对于包含结构体的结构体(如Window),你只需嵌套成员结构体的初始化器。例如,这里是包含两个Point实例的Window实例w的初始化。

现在,回到映射文件,你可能已经注意到它发生了变化。

首先,在ROM地址范围内添加了一个新的Initializer bytes段。
其次,在RAM地址范围内创建了两个新的.data段,同时两个.bss段消失了。
第三,两个新.data段的组合大小是0xC字节,这与ROM中新Initializer bytes段的大小完全相同。

用图形表示,添加变量初始化导致以下变化:链接器在RAM中插入了一个新的.data段用于存放初始化数据。同时,链接器在ROM中创建了一个匹配的Initializer bytes段,其大小与.data段完全相同。

这对启动代码的启示是,它需要将Initializer bytes段从ROM复制到RAM中的.data段。

请注意,启动代码并不是按照你在代码中指定的方式初始化数据(例如,将两个字节复制到这里,四个字节复制到那里的单个变量)。相反,链接器专门重新排列了变量,以便所有初始化数据可以通过从Initializer bytes段到.data段的单次块复制来完成初始化。

再次查看启动代码

现在,既然你既有.data段中的初始化数据,也有.bss段中的未初始化数据,让我们再次查看启动代码。

首先,我将内存视图设置为RAM区域,并用0xFF填充内存,这样当字节被更改为0或其他值时,你可以清楚地看到。

接下来,快速单步执行启动代码,直到到达__iar_data_init3调用。这次我们将单步进入这个函数,看看它如何初始化数据。

这个标签表明第一步是清零初始化数据(即清零.bss段)。STR指令是数据清零算法的核心。该指令将R3的值存储到R2中的地址。如你所见,R3是0,而R2正是RAM中第一个.bss段的起始地址。

在内存视图中,你可以看到STR指令将零写入RAM中第一个.bss段开头的4字节字。

但我也想借此机会解释一下这条STR指令中使用的新寻址模式。请注意方括号后的#4常量。这个偏移量导致基址寄存器R2增加4个字节,正如你在寄存器视图中看到的那样。

你不应将这种寻址模式与括号内有常量的STR指令混淆,后者是在存储数据之前临时将偏移量加到基址上。这种带基址递增的新寻址模式特别适用于紧凑的循环。

因此,当你单步执行代码时,你可以看到代码如何围绕STR指令循环,每次循环清零.bss段中的下一个字。

在清零.bss段之后,启动代码继续将数据复制到.data段。实际的复制工作由LDR/STR指令对完成,它们使用了刚才为.bss段解释的寻址模式。

LDR指令的基址寄存器R2指向数据的源,即ROM中Initializer bytes段的起始位置。STR指令的基址寄存器R3指向数据的目标,即RAM中.data段的起始位置。同样,你可以看到代码循环并初始化整个数据段。

最终,启动完成,并调用main函数。

总结

总结一下,你刚刚看到的是IAR库中提供的符合标准的启动代码。当main函数被调用时,C标准要求所有已初始化的变量都具有其初始值,所有未初始化的变量都被设置为0。

然而,其他供应商的启动代码可能不符合C标准。具体来说,你可能会遇到不清理.bss段中未初始化变量的启动代码。例如,德州仪器DSP的启动代码通常在这方面不符合标准。

重点是,我强烈建议你使用我刚才展示的方法测试你的启动代码。如果你发现你的.bss段没有被清零,你可能需要显式地将所有先前未初始化的变量初始化为0。但这并不是最优的,因为你本质上将.bss段转换成了.data段,这需要在ROM中有一个匹配的Initializer bytes段。换句话说,你为了一堆零占用了ROM空间。

回到复位过程的起点

既然你对标准的C初始化序列有了相当好的概述,现在是时候回到复位过程的最开始了。启动序列的这一部分是处理器特定的,因此接下来的内容将特定于Tiva LaunchPad开发板上的ARM Cortex-M处理器。

让我们开始另一个调试会话,以回答两个基本问题:第一,栈指针SP是如何获得其初始值的?第二,程序计数器PC是如何最终指向__iar_program_start函数的?

回答这两个问题的线索在地址0处,这是ROM的起始位置。当你在反汇编窗口中查看这个地址时,可以看到地址0处是CSTACK$$Limit,地址0x4处是__iar_program_start。请注意,这些不是机器指令,它们只是内存中的字。代码指令稍后才开始,从main函数开始。

所以,这就是你的问题的答案:ARM Cortex-M处理器是硬连线的,复位后,它会将地址0处的字复制到SP寄存器,并将地址0x4处的字(除了最低有效位)复制到PC。加载到PC的任何值的最低有效位必须为1,因为该位指示处理器的Thumb模式,这是Cortex-M唯一支持的模式。这解释了为什么PC是0x218,而地址0x4处的值是0x219。我在之前的课程中已经讨论过这一点。

但我想指出一些更重要的事情:你刚刚在地址0处发现的就是所谓的向量表。向量表在你的微控制器数据手册中有描述,你可以在那里读到你已经知道的内容:它包含栈指针的复位值和PC的起始地址。但向量表包含的内容不止这些,它还包含你的处理器可以处理的所有异常和中断向量。当然,我需要在接下来的课程中解释这些是什么,但我希望你对接近这个迷人的主题感到兴奋。

向量表的实际布局显示在下一页。这张图是以传统方式绘制的,即地址0在底部,高地址在顶部。这恰好与你反汇编视图中的向量表上下颠倒。

所以,让我翻转向量表布局,并尝试与你的调试器视图进行匹配。如你所见,数据手册中的向量表现在与你的调试器视图匹配得相当好。数据手册中定义的所有异常向量都被初始化为Bault_Handler,标记为“Reserved”的向量为0。

然而,数据手册中的向量表显然比你反汇编视图中的要长得多。具体来说,标记为IRQ0、IRQ1等的向量在你的反汇编视图中不存在。这是因为IAR库提供的向量表是通用的。它只包含标准异常向量,这些向量定义在表的开头,并且是所有Cortex-M微控制器共有的。但是IAR表不包含特定于给定微控制器的中断向量(如IRQ0、IRQ1等),因此它无法真正处理任何中断。为此,你需要用特定的向量表替换通用的IAR向量表,该向量表必须完全匹配你的特定微控制器数据手册中定义的布局。

不过,在离开这个调试会话之前,让我们检查一下Bault_Handler代码,根据向量表,它应该在地址0x1DB。这段代码的实际位置是0x1DA,但到现在,你应该知道为什么地址必须是奇数,而实际代码在偶数地址。

所以,这里你也得到了答案,为什么IAR向量表中的所有异常向量似乎都设置为Bault_Handler。显然,IAR启动代码定义了所有异常处理程序,如PendSV、Debug Monitor、HardFault、MemManage和NMI。但它们都指向同一段代码。只知道这个公共地址,反汇编器无法区分各种异常处理程序,并选择只显示Bault_Handler,因为这是按字母顺序排列的第一个。

最后,与所有这些异常处理程序关联的IAR代码实际上是一条跳转到自身的分支指令。这意味着任何异常的发生都会导致CPU陷入一个紧凑的无限循环。这对于调试是有好处的,因为当你中断这样的代码时,你会发现它在异常处理程序内部循环。然而,这种原始策略对于生产代码是不可接受的,因为设备将显得完全锁定且无响应。这被称为拒绝服务攻击。

课程总结

本节课关于标准启动代码的内容到此结束。在下一课中,你将学习如何用能够处理微控制器中所有中断的真实向量表替换通用向量表。你还将编写可用于生产质量代码的异常处理程序。最后,你将开始为你的LaunchPad开发板构建板级支持包。所有这些都将为学习中断打下基础。

14:启动代码第二部分 - 嵌入式软件构建过程

在本节课中,我们将继续学习启动代码,并深入了解嵌入式软件的构建过程。你将学习如何用自己的向量表替换IAR库中的通用向量表。

概述

上一节我们介绍了ARM Cortex-M处理器启动时在地址0处的重要数据结构——向量表。然而,我们使用的IAR库提供的默认向量表并不完整。本节中,我们将学习嵌入式软件的构建过程,并在此基础上用自己的向量表替换默认版本。

嵌入式软件构建过程

嵌入式软件的构建通常在称为“主机”的桌面计算机上完成,而生成的程序则运行在“目标”嵌入式设备上。这种开发方式称为“交叉开发”,与在桌面计算机上开发和运行软件的“原生开发”形成鲜明对比。

以下是构建嵌入式项目的主要步骤:

  1. 编译:C语言编译器将源文件(如 main.cdelay.c)转换为目标文件(如 main.odelay.o)。
  2. 链接:链接器将项目中的所有目标文件、标准库及其他库,结合链接脚本,组合成最终的可执行程序。

目标文件包含的是“可重定位”的机器代码,这意味着代码中的地址引用尚未固定到内存中的具体位置。链接器的任务就是组合所有目标文件,解析模块间的引用,并最终确定所有地址。

深入理解目标文件

为了理解“可重定位代码”的含义,我们需要查看目标文件的结构。在你的项目中,目标文件位于 Debug\Obj 子目录中。

目标文件通常采用 ELF 格式。你可以使用工具(如IAR的 ielfdumparm.exe 或GCC的 objdump)来查看其内容。以下是一个使用IAR工具查看 main.o 内容的示例命令:

ielfdumparm.exe --all main.o > main.txt

打开生成的文本文件,你会看到ELF文件包含多个段,例如:

  • .text:存放代码。
  • .data:存放已初始化的数据。
  • .bss:存放未初始化的数据。

此外,文件中还包含链接器所需的符号信息以及调试器使用的调试信息。注意:不应通过目标文件的大小来评估代码尺寸,因为机器代码只是目标文件中的一小部分。评估代码尺寸的可靠依据是链接器生成的映射文件。

链接器如何工作

链接器的核心工作是解析符号引用。每个目标文件都提供它导出的符号(即它定义的函数或变量),也可能需要它导入的符号(即它使用但未定义的函数或变量)。

以下是链接器解析符号的基本过程:

  1. 链接器维护两个列表:已导出符号列表未定义符号列表
  2. 链接器按顺序处理项目中的目标文件。对于每个文件:
    • 将其导出的所有符号加入“已导出列表”。
    • 对于其导入的每个符号,在“已导出列表”中查找。如果找到,则解析该引用;如果未找到,则将该符号加入“未定义列表”。
  3. 处理完所有直接包含的目标文件后,链接器开始搜索库文件。
  4. 库是目标文件的集合。关键区别在于:库中的目标文件仅当它包含“未定义列表”中的符号时,才会被链接到最终镜像中。
  5. 链接器持续处理库,直到“未定义列表”为空,链接成功完成。如果搜索完所有库后列表仍不为空,则会产生链接错误。

一个重要的实践是:库中的目标文件应尽可能小巧,通常只包含一个函数或一个全局变量。这样可以确保只链接实际需要的代码,避免最终镜像不必要地膨胀。

替换默认向量表

理解了构建过程后,我们现在可以着手替换IAR库中的默认向量表。思路很简单:在我们自己的源代码模块中定义一个名为 __vector_table 的符号。由于项目中的目标文件会先于库被链接,因此链接器会使用我们定义的版本,而忽略库中的版本。

我们将创建一个新的C源文件,例如 startup_tm4c.c。但请注意,在启动初期,C语言运行环境(如栈、.data.bss 段的初始化)尚未建立,因此编写启动代码需要格外小心。幸运的是,ARM Cortex-M架构的设计减少了对底层汇编语言的需求。

我们的主要目标是定义一个全局数组作为向量表,并确保它被放置在内存的正确位置。

首先,我们需要知道默认向量表被放在了哪里。查看链接映射文件可以发现,它位于 .intvec 段,地址为0(ROM起始位置)。这个段的定义在链接脚本文件 project.icf 中。

为了将我们自己的数组放入 .intvec 段,我们需要使用编译器扩展来指定段。在IAR编译器中,语法如下:

__root const uint32_t __vector_table[] @ ".intvec" = {
    // 初始化值
};
  • __root 指示链接器必须保留此符号,即使它看起来未被引用。
  • const 关键字至关重要,它告诉编译器这个数组是常量,应放入只读存储器。
  • @ ".intvec" 是IAR的扩展语法,用于指定变量所在的段。

编译项目并检查映射文件,确认 __vector_table 现在来自 startup_tm4c.o,并且位于ROM地址0处。至此,我们成功替换了默认向量表。

重要提示:目前我们的向量表内容尚未正确初始化。为了安全起见,避免调试器挂起或无法编程开发板,请先用以下安全值初始化你的向量表:

__root const uint32_t __vector_table[] @ ".intvec" = {
    0x20004000, // 初始栈指针值(示例,需根据实际RAM调整)
    0x00000009, // 复位处理函数地址(示例,暂时用非法指令异常地址)
    // ... 其他异常向量暂时也用同样的安全值填充
};

总结

本节课我们一起学习了嵌入式软件的构建过程,包括编译、链接以及链接器解析符号的原理。基于这些知识,我们成功用自己的C模块替换了IAR库中的默认向量表,并通过链接脚本和编译器扩展将其正确放置在ROM的起始位置。

在下一节课中,我们将学习如何正确初始化向量表,包括设置正确的栈指针和填充微控制器支持的所有中断向量。

15:启动代码第三部分 - 异常与中断处理程序

在本节课中,我们将完成启动代码的学习。你将学习如何正确初始化向量表,包括设置正确的栈指针以及微控制器中所有可用的中断。你还将看到如何编写和测试异常处理程序,以确保它们能按预期工作。

项目准备

首先,复制第14课的项目并将其重命名为第15课。如果你刚刚加入本课程,可以从 statemachine.com/quickstart 下载之前的项目。

进入新的 lesson15 目录,双击工作空间文件以打开 IAR 工具集。如果你还没有 IAR 工具集,请回顾第0课的内容。

回顾与目标

上一节课结束时,我们向项目中添加了一个名为 Startup_TM4C.c 的新文件,并在其中将向量表定义为一个整数常量数组。上节课的重点是确保链接器使用这个新的向量表,而不是 IAR 库中的通用向量表,并将其定位在地址 0 的 .intvec 段中。

然而,新的向量表尚未正确初始化。因此,今天的首要任务就是提供正确的初始化。

初始化栈指针

根据 TM4C 数据手册,向量表的第一个值应该是栈指针的初始值(位于地址 0),第二个值是复位处理程序的地址(位于地址 4)。

为了与 IAR 链接器脚本编辑器(可以在其中设置 C 栈大小)保持兼容,我们需要找到一种方法来初始化栈指针。回顾第13课(仍使用 IAR 库的默认启动代码),可以看到向量表的第一个条目是 CSTACK$$Limit。这表明链接器知道 CSTACK$$Limit 这个符号。

IAR 链接器会为程序中定义的每个段生成 $$Base$$Limit 符号。对于 C 栈段,链接器会在内存中 C 栈段的开始处创建符号 CSTACK$$Base,在结束处创建符号 CSTACK$$Limit。在 ARM 处理器上,栈从高地址向低地址增长,因此初始栈指针需要设置为 CSTACK$$Limit 的地址。

回到代码中,我们尝试将向量表中的栈指针条目初始化为 CSTACK$$Limit 符号的地址。

// 在 Startup_TM4C.c 中
extern int CSTACK$$Limit; // 声明链接器生成的符号

const int vector_table[] __attribute__((section(".intvec"))) = {
    (int)&CSTACK$$Limit, // 栈指针初始值
    // ... 其他条目
};

编译时,编译器会报错 CSTACK$$Limit 未定义。这是因为段符号是由链接器在编译之后创建的,因此上游的 C 编译器并不知道它们。我们需要通过声明该符号为一个变量来告知编译器它的存在。这里使用 extern 关键字进行声明,而不是定义。

编译器可能还会提示类型不匹配(int* 不能初始化 int 类型),我们可以通过显式类型转换来解决。

(int)&CSTACK$$Limit,

编译和链接成功后,将代码加载到 LaunchPad 开发板。在反汇编视图中,可以看到地址 0 处是 CSTACK$$Limit,SP 寄存器的值看起来像是栈顶地址。通过检查链接器生成的 MAP 文件,可以确认 CSTACK$$Limit 被分配到了正确的地址。

为了证明与链接器脚本编辑器的兼容性未被破坏,可以通过 IDE 更改栈大小。重新构建项目后,CSTACK$$Limit 的地址会相应增加。再次将代码加载到开发板,检查 SP 寄存器的新值,确认栈指针初始化成功。

初始化复位处理程序

复位处理程序位于栈指针之后。当微控制器退出复位状态时,ARM Cortex-M 处理器会将此地址复制到 PC 寄存器,并从此处开始执行代码。

查看标准 IAR 库中复位处理程序的默认初始化,发现它被设置为 __iar_program_start,这是我们在第13课学过的启动代码。我们可以在自定义向量表中重用这个函数。

在 C 语言中,可以像获取变量地址一样获取函数地址。我们可以使用 &__iar_program_start 来初始化向量表。

// 声明函数原型
void __iar_program_start(void);

const int vector_table[] __attribute__((section(".intvec"))) = {
    (int)&CSTACK$$Limit,
    (int)&__iar_program_start, // 复位处理程序
    // ... 其他条目
};

同样,如果遇到类型错误,需要进行显式类型转换。值得注意的是,在 C 语言中,函数名后面不加括号也可以表示获取其地址(例如 __iar_program_start),但为了清晰起见,建议使用 & 操作符。

将代码加载到开发板,可以看到程序停在 __iar_program_start 处,向量表中也正确显示了 CSTACK$$Limit__iar_program_start。运行代码,LED 正常闪烁,说明代码工作正常。

初始化标准异常处理程序

复位处理程序之后是其他具有负 IRQ 编号的条目,例如 NMI、HardFault、MemoryManagement Fault、BusFault、UsageFault、SVCall、PendSV 和 SysTick。这些是 ARM Cortex-M 处理器共有的异常处理程序。

标准异常在向量表中的排列不是连续的,中间有标记为“保留”的间隙。在自定义向量表中必须保持这种精确布局。

初始化标准异常的方式与复位异常类似,但需要注意将所有保留槽初始化为 0。异常处理程序的函数原型不需要自己声明,它们都已在 TM4C_CMCS.h 头文件中提供。使用标准名称对于与实时操作系统或其他第三方软件组件集成非常重要。

因此,在启动代码中包含 TM4C_CMCS.h 头文件。

与来自标准 IAR 库的 __iar_program_start 复位处理程序不同,我们需要为其他异常处理程序(如 HardFault_Handler)编写代码。标准的编码方式是使用一个无限循环,当相应异常(如硬故障)发生时,CPU 会陷入其中。这对于调试很方便,但会导致最终产品出现“拒绝服务”问题。

更好的做法是调用一个名为 assert_failed 的通用错误处理函数。该函数适用于遇到不可恢复错误且不希望继续执行的情况。其目的是执行一些损害控制(最常见的是复位机器),并报告错误位置(如果可能,记录到错误日志中)。assert_failed 函数接受两个参数:指向常量文件名字符串的指针和发生调用的行号。

在硬故障处理程序中,可以这样调用:

void HardFault_Handler(void) {
    assert_failed("HardFault", __LINE__);
}

__LINE__ 是标准的预处理器宏,会展开为它出现位置的行号。其他故障处理程序可以以相同方式编码。

然而,向量表中还包含一些非故障异常,如 SVC_HandlerDebugMon_HandlerPendSV_HandlerSysTick_Handler。理想情况下,我们希望为这些处理程序提供在程序中其他地方定义的选项(如果需要的话)。如果未使用,则希望提供一个默认实现,以指示使用了未定义的处理程序。

IAR 编译器提供了一种非常方便的方法来为函数提供所谓的“弱别名”。例如,可以为向量表中的非故障异常处理程序设置弱别名:

#pragma weak SVC_Handler=Unused_Handler
#pragma weak DebugMon_Handler=Unused_Handler
#pragma weak PendSV_Handler=Unused_Handler
#pragma weak SysTick_Handler=Unused_Handler

弱别名意味着,如果某个符号在链接过程结束时仍未定义,则将使用提供的别名。例如,如果未使用 SVC_Handler,它将被替换为 Unused_Handler。但是,如果在项目中定义了该符号,则忽略弱别名,并且链接器不会报告多重定义的符号。当然,别名本身必须被定义。

我们需要在文件顶部提供 Unused_Handler 的原型,并定义它。Unused_Handler 可以简单地调用 assert_failed

现在尝试构建,启动代码编译成功,但链接器报告 assert_failed 未定义。这是预期的,因为我们需要将此函数添加到项目中。

创建板级支持包

建议创建一个新的板级支持包文件 BSP.c 并将其添加到项目中。由于该文件与具体电路板相关,它需要包含 TM4C_CMCS.h 头文件。我们将在这里放置板级特定的内容,如错误和断言处理策略,以及特定的中断处理程序。

关于 assert_failed 函数的定义,任何损害控制都取决于具体项目,因此需要在仔细设计错误恢复策略后重新审视此函数。最终,通常需要复位系统,CMSIS 标准为此提供了一个有用的函数 NVIC_SystemReset

// 在 BSP.c 中
#include "TM4C_CMCS.h"

void assert_failed(const char *file, int line) {
    // 此处可添加错误记录逻辑,例如记录到日志或闪存
    // ...
    NVIC_SystemReset(); // 复位系统
}

现在项目构建成功,没有错误和警告。像往常一样,在每一步增量之后,都需要检查代码在目标硬件上的表现。

在反汇编视图中,地址 0 处的向量表与 Startup_TM4C.c 中的初始化匹配。具体来说,故障处理程序和保留向量都完全匹配。非故障处理程序在反汇编中都显示为 DebugMon_Handler,因为它们都是同一个地址 Unused_Handler 的别名。IAR 调试器按字母顺序显示第一个名称,恰好是 DebugMon。运行代码,LED 仍然闪烁。

测试异常处理程序

为了测试别名机制,可以提供自己的非故障处理程序实现(例如 SysTick_Handler),以期望它代替别名被使用。代码构建成功后,在反汇编中可以看到自定义的 SysTick_Handler 而不是 DebugMon_Handler 别名。运行代码,LED 仍然闪烁。

然而,我们还没有测试任何故障处理程序或 assert_failed 函数的实现。我们需要通过“故障注入”来有意触发错误。

例如,可以在 delay 函数的开头设置断点。运行代码,当断点命中时,在栈推送指令之前,将 SP 寄存器更改为 0x20000000(这是 RAM 的起始地址)。然后非常小心地单步执行代码。当 SP 降到 RAM 起始地址以下时,程序会跳转到 HardFault_Handler

但问题出现了:HardFault_Handler 的第一条指令是另一个栈推送操作,这会导致另一个故障,从而重新进入硬故障处理程序,形成一个隐式的无限循环和拒绝服务。这正是我们想要避免的。

修复栈访问问题

显然,我们不希望在故障处理程序或 assert_failed 中访问栈,因为栈可能已损坏。IAR 提供了一个 C 语言扩展 __stackless,它告诉编译器不要为给定函数使用栈。被指定为 __stackless 的函数违反了编码约定,无法从中返回。但我们本来就不希望从故障处理程序或 assert_failed 返回,我们只想执行一些损害控制、记录错误并复位。

进行此更改后,再次构建和运行代码。首先检查 LED 是否仍在闪烁。然后像之前一样停止程序,在 delay 函数的开头设置断点并运行代码。断点命中后,将 SP 寄存器更改为 0x20000000。单步执行代码,这次创建的栈溢出导致了硬故障异常。但这一次,没有栈推送指令,代码继续执行并调用了 assert_failed 函数。最后,assert_failed 也不访问栈,它成功调用了 NVIC_SystemReset,复位成功,程序回到了 __iar_program_start 复位处理程序。

这样,代码就能正常工作,并且似乎不再受拒绝服务问题的影响。遗憾的是,许多硅供应商分发的启动代码示例可能足以用于调试,但不足以部署在产品中。本课程的目标是提供一套可以一直用到产品生产的启动代码。

添加中断处理程序

自定义向量表现在包含了所有标准异常,但仍需要添加处理器支持的所有中断处理程序(即数据手册中标记为 IRQ 中断请求的条目)。

数据手册中包含了这些中断的完整列表,其中也包含一些保留条目,必须保留表的精确布局。

将实际的中断处理程序添加到向量表中是一项繁琐的工作,但不需要任何新的技巧。我们可以直接复制预先准备好的列表。所有函数原型都在 TM4C_CMCS.h 头文件中提供。

最后的步骤是将所有中断处理程序别名到 Unused_Handler

// 例如,为所有中断向量设置弱别名
#pragma weak GPIOA_IRQHandler=Unused_Handler
#pragma weak GPIOB_IRQHandler=Unused_Handler
// ... 其他所有中断

最后一次系统构建显示代码编译和链接成功。

总结

本节课中,我们一起完成了启动代码的学习。我们学习了如何正确初始化向量表中的栈指针和复位处理程序,如何编写和测试标准异常处理程序以避免拒绝服务问题,以及如何为中断处理程序设置弱别名。我们还介绍了通过故障注入来测试不常执行的代码部分的重要性,并创建了板级支持包来管理板级特定的功能。

在下一节课中,我们将介绍中断。如果你喜欢本频道,请订阅以保持关注。你也可以访问 statemachine.com/quickstart 获取课堂笔记和项目文件下载。

16:中断(第一部分)—— 什么是中断及其工作原理

概述

在本节课中,我们将要学习嵌入式系统编程中的一个核心概念:中断。我们将了解什么是中断,它们如何工作,以及如何利用系统定时器中断来替代低效的轮询延迟,从而让CPU在等待期间可以执行其他任务。

从轮询到中断

上一节我们介绍了轮询的概念。在之前的Blinky程序中,我们使用了一个名为delay的函数,它通过让CPU进行大量空循环(“忙等待”)来实现延时。这种方法效率低下,因为它完全占用了CPU,使其无法处理其他工作。

这就像你整晚盯着时钟数秒,以防早上睡过头。更高效的方法是设置一个闹钟,在正确的时间响起,这样你就能在等待期间做其他事情。

微处理器也有类似的“闹钟”机制,称为中断。中断不仅可用于超时,还可用于响应用户按下按钮、数据通过通信接口到达、模数转换完成等多种条件。

“中断”这个名字非常贴切,因为它就像现实生活中的打断一样,会中断正在进行的活动,迫使你开始做其他事情来响应这个中断。

中断的硬件基础

为了理解中断,我们需要了解其背后的硬件支持。就像人需要耳朵来听闹钟一样,微处理器也需要专门的硬件来处理中断。仅靠软件是不够的。

在我们的Tiva C微控制器上,有一个名为系统定时器的硬件外设,简称SysTick。它是一个独立的硬件模块,由CPU时钟驱动,包含三个寄存器。

以下是SysTick定时器的核心工作原理:

  1. SysTick当前值寄存器:这是一个24位递减计数器,每个CPU时钟周期减1。
    • 代码中对应:SysTick->VAL
  2. SysTick重载寄存器:当计数器减到0时,会自动从此寄存器重新加载值,并继续递减。
    • 代码中对应:SysTick->LOAD
    • 通过向此寄存器写入不同的值,可以设置不同的中断间隔时间。
  3. SysTick控制与状态寄存器:用于配置定时器,例如启用计数器、启用中断等。
    • 代码中对应:SysTick->CTRL

当计数器从1减到0时,SysTick会产生一个中断信号发送给CPU。

CPU如何响应中断

CPU内部有专门的硬件来监听中断线。在每个指令执行完毕后,CPU会采样中断线的状态。

  • 只要中断线为低电平,CPU就继续取指并执行流水线中的下一条指令。
  • 当中断线变为高电平时,CPU的特殊硬件会强制其执行中断入口指令,这个过程称为抢占

需要注意的是,中断信号与正在执行的程序是异步的。中断线可以在任何时刻改变状态,甚至是在一条指令执行的中间,这与指令的执行完全异步。

中断入口指令通常是指令集中最长的指令之一。例如,ARM Cortex-M的中断入口至少需要12个时钟周期,而像MOVADD这样的简单指令可能只需1个周期。

代码实践:从轮询切换到中断

在开始配置中断之前,我们先修改现有的轮询代码,为过渡做准备。我们将循环中的逻辑改为每次只调用一次延时,并在延时前切换LED的状态。这样,循环体(除了延时)就正好是未来中断处理函数中需要执行的代码。

我们使用C语言的按位异或运算符来切换LED的状态:

GPIO_PORTF_DATA_R ^= LED_RED_BIT; // 切换红色LED的状态

从异或运算的真值表可以看出,如果LED位原来是0,它会变为1;如果原来是1,它会变为0。

接下来,我们配置SysTick以生成中断。首先,需要知道CPU的时钟频率。Tiva C LaunchPad默认使用板载16 MHz晶振。

我们在程序顶部定义这个常量:

#define CPU_CLOCK_HZ 16000000UL

然后,计算半秒对应的计数值并设置重载寄存器。由于计数器会递减到0,所以需要减去1:

SysTick->LOAD = (CPU_CLOCK_HZ / 2U) - 1U;

同时,我们需要在控制寄存器中启用SysTick计数器及其中断。

中断服务程序与全局中断使能

中断产生后,CPU会跳转到中断向量表中指定的地址执行代码,这个函数就是中断服务程序。我们在上一课已经为SysTick中断预留了一个空的处理函数。

现在,我们需要在其中添加逻辑:切换红色LED。这正是之前轮询循环中除了delay之外所做的操作。

还有一个关键步骤:必须全局启用CPU的中断。ARM Cortex-M CPU有一个特殊的PRIMASK位,软件必须清除此位,中断信号才能到达CPU。我们可以使用编译器内置函数来实现:

__enable_irq(); // 清除PRIMASK位,启用中断

总结

本节课中我们一起学习了中断的基本概念。我们了解到中断是一种让CPU能够异步响应外部事件的机制,它比简单的轮询(尤其是忙等待)高效得多。我们以SysTick系统定时器为例,介绍了中断所需的硬件支持(定时器外设和CPU中断处理单元),并一步步将Blinky程序从使用轮询延迟改为使用定时器中断来驱动LED闪烁。现在,CPU在LED闪烁的间隔期(while(1)循环)是空闲的,未来可以被用来执行其他任务或进入低功耗睡眠模式。

在下一课中,我们将深入代码内部,详细解释抢占的具体过程,并对比MSP430处理器上的中断机制,以帮助理解ARM Cortex-M架构的独特之处。

17:中断处理机制详解

在本节课中,我们将深入学习中断的工作原理,特别是通过对比ARM Cortex-M和MSP430处理器,来理解中断服务例程的进入和返回机制。我们将从实际操作和代码分析入手,让初学者能够清晰地掌握核心概念。

概述

上一节我们介绍了如何使用SysTick中断来重构Blinky程序。本节中,我们将以MSP430处理器为例,探究大多数CPU是如何处理中断的。你将看到中断服务例程(ISR)与普通C函数的区别,并理解中断的抢占和返回机制。

从调用SysTick中断开始

首先,我们修改上节课的代码,尝试直接从main函数中调用SysTick中断处理函数。同时,我们在while(1)循环中添加一些代码,让绿色LED快速闪烁,以模拟一个可以被中断抢占的线性代码段。

// 在main函数的while循环中
while (1) {
    GPIOPinWrite(GPIO_PORTF_BASE, GPIO_PIN_3, GPIO_PIN_3); // 绿色LED亮
    GPIOPinWrite(GPIO_PORTF_BASE, GPIO_PIN_3, 0);          // 绿色LED灭
}

编译并运行程序,设置断点。你会发现,无论是直接调用还是通过中断抢占,SysTick处理函数都能正确执行并切换红色LED。这是因为在ARM Cortex-M处理器中,中断处理函数可以被设计成普通的C函数。

MSP430的中断处理

然而,这种能力是ARM Cortex-M独有的特性。在大多数其他处理器(如MSP430)中,中断处理函数需要特殊的入口代码,并通过特殊的指令返回,因此不能是普通的C函数。

以下是MSP430上Blinky程序的中断处理函数示例:

#pragma vector=TIMER0_A0_VECTOR
__interrupt void Timer0_A0_ISR(void) {
    // 切换LED的代码
}

这里,__interrupt关键字和#pragma vector指令告诉编译器这是一个中断服务例程,而不是普通函数。这些扩展语法超出了标准C的范围,是特定于工具链和处理器的。

实验:手动触发中断

为了深入理解,我们设计一个实验,在调试器中手动触发MSP430的定时器中断。

  1. 运行程序并使其在while(1)循环中暂停。
  2. 找到定时器0的当前计数值寄存器(TA0R)。
  3. 将其值修改为比比较匹配寄存器(TA0CCR0)小1的值。
  4. 设置两个断点:一个在循环的下一条指令,另一个在定时器中断处理函数内部。

然后让程序自由运行。你会发现程序进入了中断处理函数,而不是执行循环中的下一条指令。这证明了中断成功发生了抢占。

中断的进入与返回机制

通过观察堆栈指针(SP)和内存内容,我们可以看清中断的细节。

当中断发生时:

  • 堆栈指针(SP) 会减少4个字节(对于16位的MSP430)。
  • CPU自动将程序计数器(PC)状态寄存器(SR)的值压入堆栈。
  • 进入中断后,状态寄存器(SR)被清除,这同时禁用了全局中断(GIE位)。

中断服务例程执行完毕后,通过RETI指令返回:

  • RETI指令从堆栈中恢复SR和PC的值。
  • 程序精确地返回到被抢占的指令点继续执行。
  • 全局中断使能位(GIE)也被恢复,CPU可以再次响应中断。

中断服务例程与普通函数的区别

中断服务例程与普通C函数还有一个重要区别:ISR必须保存和恢复更多的CPU寄存器

为了验证这一点,我们修改MSP430的定时器中断处理函数,使其调用另一个普通的C函数。

// 普通C函数
void LED_Toggle(void) {
    // 切换LED
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/qleap-mdn-embsys-prog/img/43c2010572a3400d93f5d179620ccc99_13.png)

// 中断服务例程
#pragma vector=TIMER0_A0_VECTOR
__interrupt void Timer0_A0_ISR(void) {
    LED_Toggle(); // 在ISR内部调用函数
}

对比编译后的汇编代码,你会发现:

  • 普通函数Timer0_A0_ISR(如果它被当作普通函数调用)的代码很简单。
  • 而作为中断服务例程的Timer0_A0_ISR,在调用LED_Toggle之前,编译器会自动生成代码来保存寄存器(如R12-R15),调用后再恢复它们,最后执行RETI指令返回。

这是因为中断是异步发生的,它可能在任何两条指令之间抢占CPU。编译器必须确保中断不会破坏主程序正在使用的寄存器内容。而普通函数调用是同步的,编译器在调用点已经知晓哪些寄存器可能被修改。

总结

本节课中,我们一起学习了中断处理的核心机制。通过对比ARM Cortex-M和典型的MSP430处理器,我们了解到:

  1. 中断进入:CPU自动保存关键上下文(如PC和状态寄存器)到堆栈,并跳转到中断向量表指定的地址。
  2. ISR特殊性:在大多数处理器中,中断服务例程不是普通C函数,它们使用特殊关键字,并通过特定的指令(如RETI)返回。
  3. 上下文保存:ISR需要保存和恢复比普通函数更多的寄存器,以确保异步抢占不会破坏主程序的运行状态。
  4. 调试技巧:学会手动触发中断是一个强大的调试手段,可以帮助你复现和定位嵌入式系统中棘手的间歇性问题。

下一节课,我们将回到ARM Cortex-M平台,详细分析它是如何以独特的方式高效处理中断的。

18:ARM Cortex-M中断工作原理

概述

在本节课中,我们将学习ARM Cortex-M处理器如何处理中断。我们将探讨为何中断处理程序可以是普通的C函数,以及处理器如何自动保存和恢复寄存器状态。通过实验和代码分析,我们将深入理解中断的进入和退出机制。

实验准备

上一节我们介绍了MSP430的中断机制,本节中我们来看看ARM Cortex-M是如何处理中断的。首先,我们需要复制上一课的项目并重命名。

以下是操作步骤:

  • 复制第17课的项目,重命名为“lesson18”。
  • 打开IAR工具集,进入新项目目录。
  • 双击工作区文件以加载项目。

触发中断

为了精确控制中断的发生时机,我们需要从调试器中手动触发SysTick中断。在ARM Cortex-M中,可以通过设置中断控制与状态寄存器(ICSR)中的PENDSTSET位来实现。

以下是具体步骤:

  • 将代码加载到Tiva LaunchPad开发板。
  • while(1)循环顶部设置断点。
  • 运行代码,当断点命中后,将断点移到下一条LDR.N指令。
  • 在SysTick中断处理程序中设置另一个断点。
  • 在寄存器面板中,找到系统控制块(SCB)下的ICSR寄存器。
  • PENDSTSET位(位26)设置为1,使SysTick中断进入挂起状态。

运行程序后,代码将在SysTick中断处理程序中的断点处停止,这验证了我们能够精确触发中断。

中断栈帧分析

在触发中断并进入处理程序后,我们可以观察栈内存的变化。ARM Cortex-M在中断进入时会自动将多个寄存器压栈,形成“中断栈帧”。

以下是中断栈帧(无FPU时)包含的寄存器:

  • xPSR:程序状态寄存器。
  • PC:程序计数器(返回地址)。
  • LR:链接寄存器。
  • R12:临时寄存器。
  • R3R2R1R0:通用寄存器。

通过对比内存视图和数据手册中的栈帧图,我们可以识别出这些被保存的寄存器值。特别值得注意的是,被保存的PC值指向while(1)循环中LDR.N指令的地址,这正是中断发生后的返回点。

中断与函数调用标准的关联

观察中断栈帧中保存的寄存器组(R0-R3R12LRPCxPSR),你会发现它们恰好与ARM应用过程调用标准(AAPCS)中规定由调用者保存的寄存器组形成互补。

AAPCS规定,函数必须保存寄存器R4-R11。而中断硬件自动保存了其余所有会被破坏的寄存器。这种设计使得一个普通的C函数完全可以作为中断处理程序使用,因为它只需要遵循AAPCS,保存R4-R11即可,其余工作由硬件完成。

中断返回机制

中断处理函数执行完毕后,通过标准的BX LR指令返回。然而,在中断进入时,硬件会将链接寄存器LR设置为一个特殊值(例如0xFFFFFFF9)。

BX LR指令执行时,处理器检测到LR中的这个特殊值,并不会将其作为普通的返回地址跳转,而是将其识别为“中断返回”信号。随后,硬件自动从栈中弹出中断帧,恢复所有寄存器,并将PC设置回被中断的指令处,从而完成中断返回。

栈对齐与性能

ARM Cortex-M硬件要求中断栈帧在8字节边界对齐,以实现高效的块寄存器传输。如果栈指针(SP)未对齐,硬件会在压栈时自动插入一个“填充字”以实现对齐。

以下是关于栈对齐的要点:

  • 目的:实现高速的寄存器压栈/出栈操作。
  • 表现:中断进入和退出仅需12个时钟周期。
  • 注意:编译器通常能保证栈对齐,但在涉及实时操作系统(RTOS)时需特别注意。

浮点单元(FPU)的影响

当启用FPU时,中断栈帧会显著增大,因为需要额外保存浮点寄存器状态(S0-S15FPSCR)。

以下是FPU启用后的变化:

  • 栈空间:中断栈帧从8个字增加到26个字,需要分配更大的栈空间。
  • 链接寄存器LR被设置为另一个特殊值(如0xFFFFFFE9),指示使用FPU扩展栈帧。
  • 性能:中断进入和退出的时间会变长。

因此,在使用FPU的应用中,必须确保有足够的栈空间,并权衡其带来的性能开销。

总结

本节课我们一起学习了ARM Cortex-M的中断处理机制。我们了解到,通过硬件自动保存与AAPCS互补的寄存器组,并使用特殊LR值来标识中断返回,ARM Cortex-M使得普通C函数能够直接作为中断服务例程使用。我们还探讨了栈对齐的重要性以及FPU对中断栈帧和性能的影响。理解这些底层机制,是编写高效、可靠嵌入式中断代码的基础。在下一课中,我们将探讨一个与中断密切相关的关键概念——竞态条件。

19:GNU-ARM工具链与Eclipse IDE

概述

在本节课中,我们将学习如何将开发工具链从IAR切换到免费的、无限制的GNU-ARM编译器以及基于Eclipse的集成开发环境。这是一个很好的机会来回顾代码,并了解代码可移植性在实际中的意义。

工具链切换与代码可移植性

工具链的切换实际上是一个很好的机会来回顾代码,并了解代码可移植性在实际中的意义。正如你将看到的,到目前为止你编写的大部分代码无需任何更改即可在新的ARM工具链上工作,这主要归功于对Cortex微控制器软件接口标准(CMSIS)的遵守。然而,启动代码和板级支持包中的一些IAR特定扩展必须替换为新编译器的等效项。

下载与安装开发工具集

今天,让我们从下载和安装用于新ARM工具链的开发工具集开始。实际上,有很多这样的工具集可供选择,但你需要寻找一个支持你开发板的工具集。在这里,最重要的因素是支持特定的调试器接口,对于Tiva C LaunchPad开发板来说,就是Stellaris ICDI。

由于该开发板来自德州仪器(TI),寻找工具集的合理地点是ti.com网站。在那里,你确实可以找到一个名为Code Composer Studio(CCS)的工具集。与现在通常的做法一样,我不会给你一个具体的、固定的下载CCS的URL,因为它很可能在几周后失效。我建议使用搜索框。

当你搜索“CCS”时,可以快速找到正确的网页。在下载部分,你可以看到CCS工具可以免费使用,没有限制,并且使用GNU GCC工具集。点击Windows下载,这将引导你进入注册页面,并需要填写一些表格以遵守出口管制规定。最终,你应该能够下载到Windows版的CCS安装程序。

你需要运行它并同意许可条款。在下一步中,你可以选择安装目录。你可以保留默认设置或选择自己的目标目录,但我强烈建议不要使用带有空格或任何非标准字符的目录名。

接下来的步骤允许你选择CCS组件。在这里,你需要展开“32-bit ARM MCUs”并选择“Tiva C Series support”。同时,你还需要明确选择“GCC ARM compiler”。你不需要再做其他选择,所以最后点击“Finish”开始安装,这可能需要几分钟时间。

配置工作空间与创建项目

当你启动Code Composer Studio时,它会询问你工作空间的位置。工作空间的概念在所有基于Eclipse的工具集中都很常见,旨在将相关的项目分组在一起。根据我的观察,大多数人倾向于为他们所做的所有事情使用一个默认的工作空间,但我建议你为不同的项目组使用单独的工作空间。

具体来说,我建议你为这个嵌入式编程课程使用一个专用工作空间。由于我将本课程的所有项目都保存在“embedded_programming”目录中,我也在那里创建了CCS工作空间,即在CCS子目录中。

现在,你终于准备好创建你的第一个项目了。这部分特定于这个将Eclipse重新打包为Code Composer Studio的特定版本,但所有基于Eclipse的集成开发环境中的一般过程都是相似的。

为新项目做的第一个选择是嵌入式目标。在这里,你需要选择Tiva C系列,以及该系列中安装在你的Tiva C LaunchPad开发板上的特定TM4C123 MCU。

接下来,你选择与目标的连接,对于你的LaunchPad来说,就是Stellaris ICDI。请注意,对这个特定调试器接口的支持是你最初选择CCS工具集的主要原因。

在下一步中,你需要为项目命名。这里我建议一个通用名称“lesson”,用于属于这个嵌入式编程课程的项目。这个名称将适用于所有后续课程,因为你将简单地克隆这个原始项目,而不是每次都从头开始创建一个新项目。

出于同样的原因,你也不能在默认位置(工作空间目录内)创建项目。相反,你在保存所有先前课程的目录中创建项目。在我的机器上是“embedded_programming”,但在你的计算机上可能不同。为了完成项目位置设置,你需要为这个特定项目添加“lesson19”子目录。

下一个也是最后一步至关重要。在这里,你需要选择GNU工具集,而不是默认的TI ARM编译器。当你点击“Finish”时,CCS将在工作空间和磁盘上的“lesson19”目录中创建你的“lesson”项目。

你可以通过点击顶部的锤子按钮来构建项目。正如你所看到的,构建过程成功完成,没有任何问题。

分析生成的代码

让我们快速浏览一下CCS为这个项目生成的代码。首先,你得到了一个带有空main函数的main.c文件。其次是启动代码,这是由芯片供应商提供的代码中非常典型的。不幸的是,它具有我在第13、14和15课中介绍过的所有缺点。

首先,这个启动代码使用了不符合CMSIS的专有异常名称。此外,向量表需要在你开始或停止使用给定的中断处理程序时进行编辑。例如,要使用SysTick处理程序中断,你需要修改向量表中的相应条目,并且还需要在文件顶部声明处理程序的原型。

最后,提供的异常处理程序实现包含了使CPU陷入死循环的无限循环。换句话说,如果任何这样的异常处理程序被执行,系统将冻结,用户会认为这是拒绝服务。这在任何生产级代码中都是不可接受的。

生成的代码还包含扩展名为.ld的文件,即链接器脚本。这个文件的目的是告诉链接器ROM和RAM在地址空间中的位置,以及在哪里放置各种程序段。你在第14课中看到了IAR工具集的链接器脚本示例。这里你有一个新工具集的链接器脚本。这又是一个常规的链接器脚本,与启动代码匹配。

其中,它将栈分配为RAM中的最后一个段。在我看来,这是一个错误,因为在ARM上栈是向低地址增长的。因此,栈溢出可能会损坏其上的RAM段。事实上,这似乎是臭名昭著的丰田意外加速案例中可能的原因。正如我在文章《我们是否在用栈溢出搬起石头砸自己的脚?》中描述的那样,我在本视频的评论区提供了这篇文章的链接。

改进生成的代码

看来CCS生成的代码并不是特别可用,但好消息是,你可以通过应用到目前为止学到的课程来修复所有问题。

那么第一件事就是将第18课的所有相关代码复制到新的第19课文件夹中。可用的文件是BSP.hBSP.cmain.cstartup_tm4c.c以及你的TM4C123 MCU的主头文件。

有趣的是,复制到lesson19文件夹的最终文件会立即显示在你的项目中。这是所有Eclipse项目的行为方式。项目目录中的所有源文件都会自动包含在项目中,你不需要像在IAR Embedded Workbench IDE中那样显式地添加它们。

然而,这个Eclipse策略也有其缺点。例如,现在项目中有两个启动文件,所以你需要删除其中一个。

让我们尝试构建这个项目。这次你得到了一些错误。第一个错误是编译器找不到包含文件core_cm4.h。这个文件是CMSIS的一部分,在Code Composer Studio中不像在IAR Embedded Workbench中那样直接可用。

这不是一个大问题,因为你可以轻松地自己提供CMSIS。在这里,我为你准备了一个目录CMSIS,其中包含子目录include中的核心头文件。你应该将CMSIS目录复制到你保存本视频课程课程的文件夹中,这样你就可以在所有后续课程中重用CMSIS。

当然,复制目录是不够的,因为你还需要告诉编译器在这个新目录CMSIS/include中查找包含文件。你可以通过项目属性对话框来实现这一点,通过右键单击项目并选择“Properties”弹出菜单来打开它。

具体来说,你需要在“GNU Compiler”组中选择“Directories”属性,在那里你会找到“Include paths”窗格。要添加一个新的包含目录,请单击加号按钮。第一种简单的方法是简单地浏览你的文件系统以找到CMSIS/include目录。但这样做的一个大缺点是,你添加了一个绝对包含路径,这只会在你的计算机上、这个特定的embedded_programming/CMSIS目录中有效。

一个更好的方法是创建一个相对包含路径,这将在任何计算机上都能工作。Eclipse IDE允许你通过系统变量创建相对路径。具体来说,在这些变量的列表中,你可以选择${PROJECT_LOC},这将创建相对于项目位置的路径。从项目位置,你需要向上走一级,然后附加CMSIS/include

关于在Windows上使用目录分隔符,你可以使用反斜杠或正斜杠。我使用正斜杠,因为它们似乎更通用。当你再次构建时,你会发现之前的包含错误消失了,但你得到了一堆新的错误。

事实证明,这些新错误大部分来自启动代码。这实际上不应该那么令人惊讶,因为这段代码是用IAR特定的C语言扩展编写的,而新编译器无法识别这些扩展。

因此,我为你准备了用GNU特定C语言扩展重写的启动代码。正如你稍后将看到的,启动代码必须与链接器脚本紧密匹配,所以我也包含了一个匹配的链接器脚本。要将这些文件包含在项目中,我只需将它们复制到lesson19目录。我需要覆盖之前的链接器脚本,并且还需要删除之前的启动代码。

当你切换到Eclipse IDE时,你可以看到项目立即更新了新文件。

审查新启动代码与链接器脚本

让我们快速回顾一下代码,以便你知道它是如何工作的。首先,与旧文件相比,新的、GNU特定的启动代码符合最新的CMSIS 4.30。

当你向下滚动到向量表时,你可以看到它有一个特殊的属性section(".isr_vector")。这是一个GNU特定的扩展,它告诉编译器将后面的符号(在本例中是向量表)放置在指定的段中。

要查看这个段在哪里,你可以打开新的GNU链接器脚本。你可以看到.isr_vector是ROM中的第一个段。而ROM段位于地址0x0,所以位于ROM开头的向量表也在0x0,这正是ARM CPU所需要的。

正如你希望从第15课中记住的那样,ARM向量表的第一个元素是初始栈顶,所以在这里你看到&符号表示__stack的地址,并转换为int。符号__stack_end再次来自链接器脚本。确实,当你向下滚动一点时,你可以看到.stack段是RAM中的第一个段。

与将栈作为RAM中最后一个段的常规方法相反,我建议将其作为第一个段。这样,栈溢出将无法损坏任何其他RAM段。作为一个额外的优势,溢出到RAM下方未映射内存的栈将通过执行硬故障异常自动检测到,所以你会知道它发生了。

说到栈,你可以通过调整链接器脚本顶部的符号STACK_SIZE来改变它的大小。

回到启动代码,符号__etext由链接器提供,但编译器不知道它。为了告诉编译器,你需要在启动文件顶部将__etext声明为外部变量。

向量表的其他元素是Cortex-M异常和中断的处理程序函数的地址。正如你所看到的,该表已经包含了所有具有符合CMSIS名称的特定处理程序,因此你根本不需要编辑代码来使用任何这些异常或中断。

同时,如果你不使用给定的异常或中断,它将被默认处理程序实现自动替换。这是通过一对GNU特定属性weakalias来实现的。

weak属性意味着一个符号定义可以被另一个定义覆盖,链接器将悄悄地丢弃弱定义,而不会报告符号被定义了两次。alias属性意味着如果符号未定义,则应改用别名符号。

例如,如果你在你的应用程序中定义了SysTick_Handler,链接器将采用你的非弱定义。如果你没有定义SysTick_Handler,链接器将采用它的默认处理程序别名,并且不会报告任何错误。

但请注意,并非所有处理程序都有别名。这是因为标准故障处理程序实际上是在这个启动代码中定义的,所以你不需要在你的应用程序中定义它们。但这些处理程序不是原始的、导致拒绝服务的无限循环。提供的实现使用内联汇编来小心避免任何栈的使用,因为此时栈可能已损坏。汇编代码将关于故障的信息存储在R0和R1寄存器中,然后分支到assert_handler,在那里你可以执行最后的损害控制。

在这里,你遇到了另一个GNU特定属性naked,它指示编译器不要为这个函数做任何栈操作。

修复剩余错误与构建项目

当你再次尝试构建时,仍然有一个错误,这次是在BSP.c中。到现在,你应该知道问题是什么了。扩展关键字__stackless是IAR用于无栈函数的扩展。在GNU中,你需要用属性naked替换它。

再次构建。这次,编译器抱怨__enable_interrupt,这是一个IAR内部函数。这个操作需要替换为__enable_irq

再次构建。哈利路亚!完全没有错误了。恭喜你,你刚刚完成了将深度嵌入式代码从一个工具集移植到另一个工具集的第一次尝试。

测试代码

是时候插入你的Tiva C LaunchPad开发板来测试代码了。要将代码下载到开发板的闪存并开始调试,请按顶部的“bug”按钮。调试器设置为在main函数的开头停止,它确实做到了。

按“go”按钮运行代码,观察LED每秒改变一次颜色。点击“pause”按钮中断代码,观察它在后台循环中停止。

顺便说一下,你现在看到的不同屏幕布局在Eclipse中被称为“调试透视图”。这是为了与你在编辑代码时看到的“编辑透视图”区分开来。调试透视图提供了你在IAR中遇到的所有调试器视图,例如反汇编视图。你也可以单步执行代码,并观察LED被打开和关闭。

你可以设置断点,例如,在SysTick_Handler内部,以查看这个中断被调用的频率。

要停止调试会话并返回到编辑透视图,请按顶部的“stop”按钮。

修改代码行为

作为本课程的最后一步,让我们修改从你之前的IAR项目中复制的代码的行为,例如,将切换的LED颜色从红色改为蓝色。构建项目并像之前一样开始调试。观察LED以新颜色闪烁。

总结

本节课介绍了将工具集切换到GNU-ARM和基于Eclipse的Code Composer Studio(CCS)。本课的启动代码和链接器脚本比芯片供应商分发的典型代码更接近生产质量。该代码符合CMSIS,并且将适用于任何基于GNU-ARM的工具集,而不仅仅是CCS。你可以轻松地将其适配到任何ARM Cortex-M微控制器。

如果你想了解更多关于使用免费的GNU-ARM工具集的信息,我推荐我的10部分文章《使用GNU构建裸机ARM系统》,该文章在2007年是Embedded.com上最受欢迎的文章。这篇文章讨论的是经典的ARM7/ARM9内核,但许多信息仍然适用于Cortex-M。我在本视频的YouTube描述中提供了这篇文章的链接。

本课程的项目下载将分为两部分:包含CCS项目和课程代码的常规lesson19.zip存档,以及包含CMSIS代码的CMSIS.zip存档。在下一课中,我将最终讨论竞争条件,这是为了有效地使用中断而绝对需要理解的一个概念。

20:竞态条件及其避免方法

在本节课中,我们将学习什么是竞态条件,为什么它们很危险,以及如何避免它们。我们将通过一个具体的例子来演示竞态条件的产生,并探讨两种主要的解决方案。

项目设置回顾

上一节我们介绍了基于Eclipse的Code Composer Studio开发工具。本节我们将在此基础上继续。

以下是项目目录结构:

  • CCS 子目录包含本视频课程各课的Eclipse工作空间,由Code Composer Studio创建。
  • CMSIS 子目录包含Cortex微控制器软件接口标准头文件。你需要从本视频课程的项目下载中获取并解压CMSIS压缩包来创建此目录。
  • lesson19 目录包含第19课的所有代码和CCS项目文件,同样可以从项目下载中获取。

当然,你也可以在此目录中存放其他课程的内容,但今天你只需要上述三个目录。

要为本次课程创建新项目,请复制之前的 lesson19 目录并将其重命名为 lesson20

使用基于Eclipse的IDE时,通常无法通过双击直接打开项目。相反,你需要先启动Eclipse IDE,然后导入新项目,稍后我将演示。

启动Code Composer Studio时,请确保选择我之前描述的 CCS 工作空间。

Eclipse最终打开后,应包含一个名为 lesson 的项目,这是你在上一课第19课中创建的。

在导入第20课的新项目之前,你需要从工作空间中删除旧项目,因为新项目也将具有相同的复制名称 lesson,而Eclipse不接受两个同名的项目。

删除项目时,请不要勾选“删除磁盘上的项目内容”复选框。

现在,你终于可以导入为第20课复制的项目了。选择“文件”>“导入”菜单,然后选择“现有项目到工作空间”。

点击“下一步”,浏览到 lesson20 目录。

你应该能看到 lesson20 目录中的 lesson 项目,点击“全选”和“完成”按钮。

完成所有这些操作后,你应该在Eclipse中打开了 lesson 项目,但这次这是对应于第20课的新项目。

代码分析与问题引入

让我们首先打开这里的两个源模块,即 main.cbsp.c,并将它们并排放置。

主代码包含惯用的无限 while(1) 循环,其中绿色LED被打开和关闭。

BSP代码包含SysTick中断服务例程,它被设置为每秒触发两次以切换蓝色LED。

对于本课,让我们修改主代码,注释掉通过GPIO寄存器的特殊 DATA_BITS 数组来打开和关闭绿色LED的两行代码,我在第7课中解释过这个数组。

今天,你将不使用 DATA_BITS 数组,而是简单地使用控制所有8个GPIO线的 DATA 寄存器。具体来说,要设置所需的位,你将读取 DATA 寄存器,将其与 LED_GREEN 位进行逻辑或运算,然后将结果写回 DATA 寄存器。你这样做是为了不干扰数据寄存器中控制绿色LED以外其他内容的另外七个位。顺便说一下,如果你不记得位运算符是如何工作的,请回到第6课。

要清除所需的位,你将读取 DATA 寄存器,将其与取反后的 LED_GREEN 位进行逻辑与运算,并将结果写回 DATA 寄存器。我特意使用了这种冗长的表达式写法,是为了向你展示GPIO位是通过读-修改-写操作序列来设置或清除的,但这些表达式也可以通过C语言中的 |=&= 运算符更简洁地书写。不过请记住,这些简短形式执行的是完全相同的读-修改-写操作序列。

当你构建程序并将其加载到Tiva C LaunchPad开发板上时,可以看到它像以前一样闪烁LED。

当你中断代码并单步执行主循环时,可以看到绿色LED按照你的编程精确地打开和关闭。

此外,当你在SysTick处理程序中设置断点时,可以看到它每次被调用时仍然会切换蓝色LED。

所以你的程序似乎像以前一样工作,对各个元素的检查也没有显示任何问题。有什么不喜欢的呢?

但如果你观察开发板一段时间,可能会注意到蓝色LED的闪烁不再像以前那样规律。具体来说,有时LED似乎会暂停一整秒甚至更长时间。

竞态条件演示

为了找出原因,让我们在关闭绿色LED的代码行设置一个断点,并打开一些新的调试器视图,例如反汇编和寄存器视图。

现在让我们在汇编代码中单步执行。

首先,GPIOF数据寄存器的地址被加载到R3和R2中,然后数据寄存器的值再次被加载到R2中。

最后,BIC 指令清除了R2寄存器中编码为立即数参数8的绿色LED位。请注意,所有这些机器指令都是由你的一行简短C代码生成的。

但在你执行 BIC 指令之前,让我们触发SysTick中断。我的意思是,中断随时可能发生。那么为什么不在这里呢?

要触发SysTick中断,请打开NVIC寄存器集。

滚动到 NVIC_ICSR

PENDSTSET 字段写入1,然后按回车键。

在你运行代码之前,恢复核心寄存器视图并设置一些断点。在 BIC 指令之后立即放置第一个断点,第二个断点放在SysTick中断处理程序中。这样你就会知道哪段代码先执行。你应该已经从之前关于中断的课程中熟悉了这个技巧。

最后,你已准备好通过点击“恢复”按钮来运行代码。你不能单步执行,因为这会禁用中断。

嗯,正如你所看到的,第一个命中的断点在SysTick处理程序内部,所以它一定是在 BIC 指令之后的另一个断点之前执行的。这意味着SysTick处理程序恰好在这个点抢占(中断)了之前的代码。

所以现在当你单步执行SysTick处理程序代码时,可以看到它打开了蓝色LED。

然后它返回,回到 BIC 指令。

现在 BIC 指令清除了R2中的绿色LED位,然后将R2存储到GPIO数据寄存器中。

哎呀!这最后一条存储指令同时熄灭了绿色和蓝色LED。这是一个问题,因为代码本应只关闭绿色LED,并且完全不改变任何其他GPIO位。

竞态条件解析

我希望你开始理解这里刚刚发生了什么。关闭绿色LED的代码是读-修改-写操作序列,但这个序列在读操作之后、写操作之前被中断了。中断通过打开蓝色LED改变了GPIO数据寄存器的状态。然而,被中断的代码并不知道这个变化,仍然使用了存储在R2中的GPIO数据寄存器的先前值。

你刚刚遇到的问题被称为竞态条件

当两个或多个可以相互抢占的代码段以某种方式访问共享资源,导致结果取决于这些代码段的执行顺序时,就会发生竞态条件。

当然,在一个简单的闪烁示例中,这样的竞态条件不是什么大问题,但它实际上可能是一个致命的错误。例如,想象一下,SysTick中断不是切换蓝色LED,而是打开核反应堆中的冷却系统以防止反应堆过热。

在这种情况下,SysTick中断刚刚打开了冷却系统,但主循环在几分之一微秒后又将其关闭。结果是冷却系统在反应堆熔毁时仍然关闭。

考虑到这一点,我希望你开始明白为什么竞态条件可能是个问题,但我不确定你是否完全理解它们有多么棘手。

问题在于,竞态条件似乎违背逻辑,因为每个单独的代码段都能正确工作。只有当这些代码段一起执行,并在你无法控制的各种位置相互抢占时,问题才会出现。

这导致错误往往是间歇性的、难以重现和难以隔离的,因为通常只有在狭窄的时间窗口内,抢占才会导致错误。这意味着你可能测试系统数小时或数周,从未注意到任何问题,但一些灾难性的竞态条件错误仍可能逃逸到最终产品中。

所有这些使得竞态条件成为你可能需要处理的最糟糕的错误类型。它们是使用中断的直接后果和巨大代价。似乎生活中没有什么好东西,甚至中断,是免费的。

避免竞态条件的策略

既然我已经充分强调了其严重性,我希望讨论两种消除竞态条件的主要策略。

第一种策略基于互斥的概念,这意味着你确保在访问共享资源时,只有一个并发代码段可以执行。

在你的Blinky程序中,你可以通过在打开蓝色LED和关闭蓝色LED的代码周围简单地禁用中断来实现互斥。这将阻止SysTick中断的抢占,从而消除竞态条件。

让我们在调试器中快速查看这个修改后的版本。

首先,内部函数 __disable_irq() 只生成一条机器指令 CPSID I。这里没有函数调用开销,这就是使用内部函数的好处。

类似地,__enable_irq() 也只生成一条指令 CPSIE I

这两条指令都只在一个时钟周期内执行,因此禁用中断的额外开销非常小。

顺便说一下,中断禁用和启用之间的代码段称为临界区

现在,让我们完全重复之前的测试:触发SysTick中断并设置断点以找出代码执行顺序。单步执行到 BIC 指令。

在NVIC中触发SysTick中断。

BIC 指令之后设置一个断点。

在SysTick处理程序中设置另一个断点。

通过点击“恢复”按钮运行应用程序。

正如你所看到的,这次SysTick没有抢占主代码,而是命中了 STR 指令处的断点。

然而,SysTick中断并没有丢失。一旦中断被启用,它就会触发。

当你单步执行中断代码时,可以看到它打开了蓝色LED。

中断然后返回到循环顶部的主代码。

总之,临界区的引入序列化了对共享资源(本例中是GPIO数据寄存器)的访问,并使其成为原子性的,即不可分割的。有了临界区,三段代码(SysTick中断和两个临界区)可以相互之前或之后运行,但不能在中间运行。这就是互斥访问的含义。

更优的解决方案:避免共享

但比互斥更好的是,从一开始就通过不共享任何资源来避免竞态条件。

所以让我注释掉今天的代码,留给你做实验,并恢复到原始代码。

这个原始代码不使用 DATA 寄存器,而是使用255个GPIO寄存器的 DATA_BITS 数组。

我几乎用了整整第7课来解释这个GPIO寄存器数组的工作原理,所以这里不再重复。

但今天的主要观点是,该数组为8个GPIO位的每一种可能组合提供了一个单独的寄存器。例如,寄存器 DATA_BITS[LED_GREEN] 与寄存器 DATA_BITS[LED_BLUE] 是不同的。这意味着没有共享公共的数据寄存器,并且正如你稍后将在汇编中看到的,也不需要读-修改-写操作序列。

设置给定位是通过对专用 DATA_BITS 寄存器的特定原子写操作完成的,清除给定位也是如此。

现在你终于明白为什么德州仪器的硬件工程师以这种特殊而复杂的方式设计GPIO寄存器了。他们这样做正是为了分离各种GPIO位,以避免共享的需要,从而消除软件中潜在的竞态条件。这些人在硬件上做了繁重的工作,以便你作为软件设计师的生活可以更轻松。

总结

本节课我们一起学习了竞态条件。我们了解了竞态条件是如何产生的,为什么它们是危险且难以调试的。我们探讨了两种主要的避免方法:通过禁用中断实现互斥访问的临界区,以及从根本上避免共享资源的设计(如使用专用的 DATA_BITS 寄存器)。硬件设计有时会提供避免软件竞态条件的机制,理解并利用这些机制是编写健壮嵌入式软件的关键。

在下一课中,我将讨论中断优先级以及在ARM Cortex-M处理器上禁用中断的其他方法。

21:前后台架构(超级循环)

在本节课中,我们将学习嵌入式系统中最普遍的一种软件架构——前后台架构,也称为超级循环或主循环+中断架构。这是理解所有其他嵌入式软件架构,特别是实时操作系统的基础。

概述

前后台架构是许多小型嵌入式系统的核心。它由两部分组成:一个在main函数中无限循环运行的后台,以及由中断服务程序构成的前台。中断可以抢占后台循环,但执行完毕后总是返回到被抢占的点。两者通过共享变量进行通信。

开发环境设置

上一节我们介绍了课程主题,本节中我们来看看如何搭建本次课程所需的开发环境。

本次课程使用ARM公司的Keil MDK工具链,替代了之前基于Eclipse的TI Code Composer Studio。你可以从ARM官网下载免费的MDK-Lite版本,它完全足够用于本课程的所有项目。

安装Keil MDK后,你需要从课程配套网站下载两个代码包:lesson21项目文件和将在后续课程中频繁使用的QPC框架。将它们解压到你的嵌入式编程目录中。

首次打开lesson21项目时,可能需要为TI Tiva C系列LaunchPad板安装对应的软件包。在Keil的Pack Installer中选择“Texas Instruments Tiva C Series TM4C123x Series”即可。

初始项目分析

现在,让我们打开并分析初始的“闪烁LED”项目。这个项目与第8课的项目类似,但有一个关键改进:延时函数现在基于系统节拍中断实现,提供了更精确的定时。

以下是新的BSP_Delay函数的核心实现逻辑:

void BSP_Delay(uint32_t ticks) {
    uint32_t start = BSP_TickCounter(); // 获取起始节拍计数
    while ((BSP_TickCounter() - start) < ticks) { // 循环等待指定节拍数
        // 空循环,等待时间到达
    }
}

系统节拍中断服务程序(SysTick ISR)以固定频率(例如每秒100次)触发,并递增一个静态的、声明为volatile的计数器变量l_tickCountervolatile关键字告知编译器此变量可能被中断意外修改,防止编译器进行错误的优化。

前后台架构详解

上一节我们分析了具体的代码实现,本节中我们来深入理解前后台架构的概念和特点。

正如其名,该架构包含两个主要部分:

  • 后台:在main函数中的无限循环(while(1)),负责处理主要的、非紧急的任务。
  • 前台:由各种中断服务程序构成,负责处理紧急的、对时间敏感的事件。

中断(前台)可以随时抢占后台循环的执行,但中断服务程序执行完毕后,CPU会返回到后台循环被中断的位置继续执行。

以下是该架构的关键特征:

  • 通信机制:前后台通过共享变量进行通信。
  • 临界区保护:访问共享变量时,必须通过短暂禁用中断来防止竞态条件。
  • 时序不确定性:后台循环中函数执行的时序不精确,因为它受代码条件分支和中断活动的影响。
  • 处理严格时序任务:任何有严格时间约束的操作都必须放在前台(中断)中执行,但这可能导致中断服务程序变长,并可能干扰后台循环和其他中断。

由于其简单性,前后台架构被广泛应用于消费电子、家用电器、玩具和遥控器等大批量嵌入式产品中。Arduino平台也采用了这种架构,只不过将其库函数背后。

代码重构与关注点分离

在分析了基础架构后,我们可以对代码进行重构,使其更清晰、更易于移植。

目前的main函数中混杂了应用逻辑(做什么)和硬件操作细节(如何做)。更好的做法是将它们分离:

  • 应用代码:专注于描述做什么(例如,打开LED,等待,关闭LED)。
  • 板级支持包:专注于实现如何做(例如,如何初始化MCU,如何控制特定引脚的电平)。

以下是重构步骤:

  1. 将初始化代码移到BSP_Init()函数中。
  2. 将LED控制代码移到BSP_LedOn()BSP_LedOff()函数中。
  3. 更新main.c,使其只调用这些BSP函数,并移除所有硬件相关的头文件和宏定义。

重构后,main函数变得非常简洁且自解释。要移植到新硬件,只需重写BSP.c文件,而应用代码无需任何改动。

阻塞式 vs. 非阻塞式编程

到目前为止,我们的后台代码采用的是阻塞式、顺序执行的模式。BSP_Delay函数会“忙等待”,直到指定的时间过去,期间CPU无法处理其他任务。

我们可以将代码改为非阻塞式、事件驱动的模式。在这种模式下,后台循环快速轮询检查事件(如定时器超时)是否发生,而不是停下来等待。这允许系统更及时地响应多个事件。

以下是事件驱动模式的一个简单状态机示例结构:

while (1) {
    uint32_t current_time = BSP_TickCounter();

    switch (state) {
        case LED_OFF:
            if ((current_time - start_time) >= OFF_DURATION) {
                BSP_LedOn();
                start_time = current_time;
                state = LED_ON;
            }
            break;
        case LED_ON:
            if ((current_time - start_time) >= ON_DURATION) {
                BSP_LedOff();
                start_time = current_time;
                state = LED_OFF;
            }
            break;
    }
    // 此处可以处理其他事件
}

非阻塞式代码的优点是响应更及时,能更好地处理并发事件。代价是代码结构变得更复杂,通常需要引入状态机来管理程序逻辑。许多实际项目由于缺乏清晰的状态机设计,最终会变成难以维护的“面条代码”。

总结

本节课中我们一起学习了嵌入式系统的基石——前后台架构。

  • 我们了解了其由前台中断后台主循环构成的基本原理。
  • 我们探讨了通过共享变量进行通信以及使用临界区保护的重要性。
  • 我们实践了通过板级支持包分离硬件细节与应用逻辑,提高了代码的可移植性。
  • 最后,我们对比了阻塞式/顺序执行非阻塞式/事件驱动两种编程范式,并看到了事件驱动代码在响应性上的优势及其在复杂度上的代价。

前后台架构是理解更复杂架构(如实时操作系统)的必经之路。在接下来的课程中,我们将以此为基础,开始探索实时操作系统的世界。

22:什么是实时操作系统 (RTOS)? 🎯

在本节课中,我们将学习实时操作系统(RTOS)的核心概念。我们将从上一课的前台/后台架构出发,探索如何通过RTOS内核实现多任务处理,即让多个后台循环(线程)看起来同时运行。我们将手动模拟上下文切换的过程,理解其背后的机制,为下一课自己动手实现一个RTOS内核打下基础。

课程准备 📋

为了跟上今天的课程,强烈建议你回顾第18课关于中断的内容。在软件方面,你需要第21课的目录,其中包含ARM Keil Microvision项目和QP/C框架目录。第21课详细说明了如何下载和安装所有软件,包括ARM Keil开发工具链。

准备工作完成后,请复制第21课的目录,并将其重命名为“lesson_22”。进入新的“lesson_22”目录,双击Microvision项目文件将其打开。

从单任务到多任务的挑战 🔄

上一课中,我们学习了基本的前台/后台架构,并看到了它既可以用顺序阻塞代码实现,也可以用事件驱动的非阻塞代码实现。在本课中,我们将专注于顺序架构,因此请恢复到顺序代码并删除事件驱动的替代方案。我们将在未来的课程中再回到事件驱动架构。

之前的顺序代码只是简单地闪烁LaunchPad开发板上的绿色LED。今天的挑战是扩展这个基本架构,使其也能独立且同时地闪烁板上的蓝色LED。

一个天真的尝试是直接复制绿色LED的代码,替换LED颜色,并可能更改开关延迟。当你编译并将代码加载到开发板后,你会发现它确实闪烁了两个LED,但并非同时进行。LED是按顺序开关的:先是绿色LED,然后是蓝色LED。这当然是你所创建的顺序代码的本质。

为了在保持代码简单顺序结构的同时,让LED真正独立闪烁,我们需要的不止一个,而是两个能够“同时”运行的后台循环。

创建多个后台循环 🧵

为了探索这种可能性,让我们创建两个主函数,分别称为 main_blinky1main_blinky2。每个函数都具有 while(1) 无限后台循环的常见结构。

现在,在原始的 main 程序中,你需要调用这些函数,这样编译器就不会将它们视为未使用的代码而优化掉。然而,你不能简单地一个接一个地调用它们,因为编译器很聪明,它会认为第二个调用是不可达的(因为第一个调用永不返回)。为了防止这种情况,你可以添加一个由 volatile 变量控制的 if 语句。

在运行此代码之前,请打开项目选项对话框,关闭浮点硬件单元的使用(就像在第18课中做的那样)。这是为了简化CPU处理中断的方式,这对后续讨论很重要。同时,请确保堆大小不为零,因为调试器需要一些堆空间来实现所谓的半主机功能。

手动上下文切换实验 🛠️

进行这些更改后,让我们打开调试器。当你自由运行代码时,可以看到它只闪烁来自 main_blinky2 函数的蓝色LED。现在,让我们在 SysTick 中断处理程序(bsp.c 中)的末尾设置一个断点。

正如上一课所记得的,我们配置了这个中断每秒触发100次。当命中断点时,打开内存视图并将其停靠在屏幕右侧。滚动内存到栈指针寄存器 SP 的地址。

在第18课中,我们学习了ARM Cortex-M异常(如中断)生成的特定栈帧。为了快速复习,我将从TivaC数据手册中获取中断栈帧的布局图,然后将其与你的栈内存视图对齐(记得要上下翻转,因为ARM栈向低内存地址增长)。

你可以看到,从顶部开始的第七个栈条目包含程序计数器 PC,这个值将在从中断返回时加载到 PC 寄存器中。例如,这里的 PC 将被加载为 0x40E。你可以通过单步执行 BX LR 指令并观察中断返回到哪里来轻松测试。

确实,它返回到地址 0x40E。这是一个常规的中断返回,回到了被抢占的确切位置。

但现在,当你的 SysTick 处理程序中的断点再次被命中时,让我们“作弊”一下:将栈上对应 PC 的条目更改为你的 main_blinky1 函数的地址(假设是 0x7C6)。当你这次执行从中断返回的指令 BX LR 时,可以看到你确实返回到了 main_blinky1。这意味着你返回到一个不同于原始抢占点的位置。

当你移除 SysTick 中断中的断点并让代码自由运行时,可以看到你现在正在闪烁绿色LED。当然,你可以重复这个过程,但这次切换回执行 main_blinky2,现在你闪烁的是蓝色LED。

然而,这里需要警告你,你刚刚所做的操作并不完全合法,并且在更复杂的代码中无法真正工作,我稍后会解释。但在这个阶段,这个练习已经允许你做出几个有趣的观察。

初步观察与RTOS核心概念 💡

首先,你可以看到,在多个后台循环之间切换CPU执行应该是可能的。其次,这个练习为你指出了实现这种CPU上下文切换的通用机制:利用处理器中已有的中断处理硬件。第三,这个练习说明了在单个CPU上实现多任务处理的核心理念:在像这里的 main_blinky1main_blinky2 这样的不同后台循环之间切换CPU。

到目前为止,在本课中你一直在手动进行切换,但这个过程可以通过一种称为实时操作系统内核(简称RTOS内核)的特殊软件实现自动化。

什么是RTOS内核? 🎼

RTOS内核的一个简单定义是:它是一种软件,通过允许你在单个CPU上运行多个称为线程或任务的后台循环,扩展了基本的前台/后台架构。

你应该学习的另一个术语是多线程或多任务处理,即频繁地将CPU上下文从一个线程切换到另一个线程,以制造出每个线程都独占整个CPU的假象。这两个定义都使用了“线程”这个术语,但希望你记住,这些线程本质上就是前台/后台架构中的后台循环。

在本课剩余的部分,让我回过头来解释为什么更改栈上的 PC 寄存器值是非法的,以及你真正需要做什么来干净地从一个线程切换到另一个线程。

为什么需要私有栈? 🧱

为了说明问题,让我用颜色来表示被中断保存和恢复的寄存器。例如,我用绿色表示 Blinky1 线程的寄存器,因为它闪烁绿色LED。

在常规中断抢占的情况下,你为 Blinky1 线程保存寄存器,并为同一个 Blinky1 线程恢复寄存器。只要你实际返回到 Blinky1 线程,一切都没问题。然而,当你手动修改返回地址时,你返回到的是 Blinky2 线程,但你恢复的仍然是最初为 Blinky1 线程保存的寄存器,这正是非法之处。对于极其简单的闪烁线程,它碰巧能工作,但对于使用更多寄存器的更复杂线程,它可能会并且将会出错。

所以现在你可能有了更好的修复思路。你需要为不同的线程保持独立的寄存器集。换句话说,为 Blinky1 保存的寄存器不能为 Blinky2 恢复,反之亦然。

这意味着你需要为每个线程使用一个单独的私有栈。这听起来可能很复杂,但实际上并非如此,你将在接下来的实验中看到。

为线程添加私有栈 🏗️

你可以很容易地为线程添加一个栈,因为它实际上不过是RAM中的一个区域和一个指向该栈当前顶部的指针。在C语言中,这样的内存区域可以表示为一个 uint32_t 类型的数组,对应CPU的32位寄存器,外加一个栈指针。

让我们将栈指针初始化为指向栈数组末尾之后的一个字,因为在ARM CPU上,栈是向下增长的(即从栈数组的末尾向开头增长)。你需要为 Blinky2 线程提供一个类似的栈。

现在,在 main 程序中,你不再需要调用线程函数,而是需要用一个人造的Cortex-M中断栈帧来预填充每个线程栈。目标是让栈看起来像是在调用线程函数之前刚刚被中断抢占了一样。

因此,你可以再次使用数据手册中的ARM异常帧布局作为模板。你从栈的高内存端开始,因为ARM栈从高内存向低内存增长。同时,ARM架构要求ISR栈帧在8字节边界对齐。这里的情况是,栈数组的大小为40个32位字,这正好对齐在8字节边界。这意味着对齐栈条目不是必需的。最后,ARM CPU使用满栈,这意味着栈指针指向最后一个使用的栈条目,而不是第一个空闲条目。

因此,要添加一个新的栈条目,你首先递减栈指针以到达第一个空闲位置,然后解引用它以将值写入此位置。你写入的第一个值是伪造的程序状态寄存器,你只需要设置其中的第24位。该位对应处理器的Thumb状态,Cortex-M处理器实际上不能处于任何其他状态(如ARM状态),但由于历史原因,xPSR 寄存器必须设置Thumb位。

栈上的下一个值是 PC。这是从中断返回的地址,正如你从之前的实验中看到的,它需要被设置为线程函数的地址。在本课程中我还没有解释,C语言允许你使用与获取变量地址完全相同的 & 运算符来获取函数的地址。应用于函数的地址运算符会产生一个函数指针,我将在未来关于状态机的课程中详细解释。现在,你只需要理解可以创建这样的指针,但必须将其转换为 uint32_t 以适合你的栈。

ISR栈帧中的其他寄存器对于正确调用线程函数并不重要,因为线程不会返回。但出于测试目的,你可以用与寄存器编号对应的数字来初始化栈,这将帮助你在调试器中轻松识别栈帧。

你以完全相同的方式为 Blinky2 线程初始化栈,只是将 PC 寄存器的值使用 Blinky2 线程函数的地址。最后,你需要防止 main 程序终止,因此添加一个空的 while(1) 循环,它将等待你开始切换线程。

使用私有栈进行手动切换 🔄

现在你可以打开调试器。自由运行程序,首先验证它不闪烁任何LED,因为它执行 main 中的空 while(1) 循环。但代码应该已经初始化了栈,你可以在内存视图中看到它们。

你也可以打开观察窗口,设置观察 sp_blinky1sp_blinky2 栈指针变量。现在是最有趣的部分:在你通常的 SysTick 中断末尾设置断点。当它被命中时,将CPU栈从原始的 main C栈切换到其中一个私有闪烁线程栈,比如 blinky1。为此,你只需手动将CPU的 SP 寄存器更改为 sp_blinky1 变量的值。

现在,当你单步执行 BX LR 中断返回指令时,可以看到你最终进入了 blinky1 线程。当你从 SysTick 移除断点时,绿色LED开始闪烁。

要切换到 Blinky2 线程,再次在 SysTick 末尾设置断点。这次你将把栈切换到 Blinky2,但在更改CPU中的 SP 寄存器之前,需要将CPU中 SP 的当前值复制到 sp_blinky1 栈指针变量中,因为这个值现在是 Blinky1 线程的当前栈顶,所以在切换离开该线程之前需要更新其栈指针。只有现在,你才能用下一个要执行的 blinky2 线程的栈顶覆盖 SP 寄存器。

当你单步执行 BX LR 指令时,可以看到现在你将返回到 Blinky2 线程。当你移除断点并再次运行代码时,可以看到蓝色LED开始闪烁,所以你确实在运行 blinky2 线程。

理解完整的切换流程 📈

现在,希望你掌握了要领。你手动切换CPU上下文的步骤是:

  1. SysTick 中断末尾中断。
  2. SP CPU寄存器的当前值复制到对应当前正在执行线程的栈指针变量中。
  3. 另一个线程的栈指针复制到 SP CPU寄存器中。

请注意,你不再需要操作内存中的栈内容。同时请注意,当你现在切换到 blinky1 线程时,你是在 SysTick 中断抢占它的确切位置恢复它,而不是在其线程函数的开头。例如,这里你返回到 BSP_tickCtr 函数中的一个特定位置,该函数又是从 BSP_delay 函数中的一个特定位置调用的,而 BSP_delay 又是从 main_blinky1 线程调用的。所有这些信息都保存在私有的 Blinky1 栈上。

当你自由运行代码时,可以看到两个线程现在独立执行。例如,这里 Blinky1 线程运行时,Blinky2 被抢占,蓝色LED保持点亮状态。

时序图说明了使用每个线程的独立私有栈进行上下文切换的新方法。正如你所见,你不再混合寄存器。相反,Blinky1 线程的寄存器存储在 Blinky1 栈上,随后从同一个 Blinky1 栈恢复。对于 Blinky2 或你可能添加到系统中的任何其他线程也是如此。

剩余问题:保存所有寄存器 🧩

所有这些看起来是实现上下文切换的一个很有前途的方法,但你还没有完全走出困境。剩下的问题是,你的上下文切换仍然可能破坏一些CPU寄存器,因此在恢复给定线程之前,CPU状态并没有被完全正确地恢复。

要理解原因,请回忆第18课,Cortex-M异常栈帧对应于ARM应用程序过程调用标准(AAPCS),因为它只存储允许被函数调用破坏的寄存器,但存储必须由函数调用保留的寄存器 R4R11

这对于中断服务例程(ISR)是可行的,因为ISR必须在返回到被抢占的代码之前运行完毕。例如,假设 Blinky1 线程代码使用了 R7 寄存器,正如你所见,它没有保存在Cortex-M ISR栈帧中。ISR可能也在使用 R7,但它必须在返回前保存并恢复它。只要ISR是线程被抢占期间执行的唯一代码,这就能正常工作。

但在你的情况下,ISR并不返回到被抢占的代码,而是返回到另一个线程 Blinky2。这个其他线程也可以使用 R7 寄存器,并且像任何函数一样,也有义务在返回时恢复 R7。但你并没有执行整个线程函数,而只是它的一部分。这段代码不需要遵守AAPCS,它可以改变 R7 中的值。结果是,当 Blinky1 恢复执行时,R7 寄存器可能已被破坏,这就是问题所在。

当然,同样的论点也适用于 R4R11 中的任何一个寄存器。解决方案是在ISR结束时,在将上下文从线程切换走之前,将剩余的8个寄存器 R4R11 保存到线程栈上。然后,在从ISR返回到该线程之前,必须从线程栈中将这些寄存器恢复到CPU寄存器。

不幸的是,这额外的八个寄存器给我们迄今为止一直在进行的手动上下文切换增加了大量繁琐的工作。

完整上下文切换算法 📝

完整的上下文切换算法如下:

  1. 保存当前线程上下文:在ISR末尾,你需要将额外的8个CPU寄存器 R11R4 保存到当前ISR栈帧的顶部。同时,你需要在将其保存到线程栈指针之前,从 SP 中减去 0x20(32字节)来调整 SP CPU寄存器的值。
  2. 恢复下一个线程上下文:在恢复下一个线程时,你需要将额外的寄存器 R11R4 从线程栈恢复到CPU寄存器。最后,在将其写入CPU的 SP 寄存器之前,需要给线程栈指针加上 0x20 字节。

繁琐的手动过程在软件中实现自动化时当然不是问题,而这正是下一课的主题,在那里你将开始构建自己的RTOS内核。

总结 📚

本节课中,我们一起学习了实时操作系统(RTOS)的核心概念。我们从扩展前台/后台架构的需求出发,探索了如何通过多线程实现看似并行的任务执行。我们手动模拟了上下文切换的过程,理解了使用私有栈保存每个线程完整状态(包括所有必须保存的寄存器 R4-R11)的必要性。最重要的是,我们推导出了RTOS内核进行上下文切换的精确算法。

你现在不仅理解了RTOS线程以及RTOS用于在多个线程之间切换CPU的机制,而且还制定出了上下文切换的精确算法,因此你已经准备好实现自己的RTOS内核了。希望你能加入下一课,一起体验这个有趣的实践过程。

23:自动化上下文切换

在本节课中,我们将学习如何自动化上下文切换过程,这是构建实时操作系统(RTOS)的核心步骤。我们将基于上一课手动推导的算法,开始构建一个名为“Miro”的极简RTOS内核。

概述

在上一课中,我们探讨了RTOS的核心思想:通过频繁地在多个后台循环(线程)之间切换CPU,来模拟并发执行。我们手动推导了上下文切换的算法。本节课的目标是将这个算法转化为实际的、可自动执行的代码。

创建Miro RTOS项目

首先,我们需要为我们的RTOS创建一个独立的项目结构。

以下是创建步骤:

  1. 复制上一课的目录并重命名为“lesson23”。
  2. 在新目录中打开Microvision项目。
  3. 添加一个名为“Miro”的新项目组。
  4. 在该组中创建两个文件:miro.h(头文件,包含API)和miro.c(源文件,包含实现)。

miro.h文件中,我们首先定义线程控制块(TCB)数据结构,用于表示一个线程。

typedef struct OS_thread {
    void *sp; // 私有栈指针
    // ... 未来可扩展其他成员
} OS_thread;

实现线程启动函数

接下来,我们需要一个函数来初始化线程栈,为线程的首次执行准备好寄存器上下文。

以下是线程启动函数 OS_thread_start 的实现要点:

  • 函数签名:接收TCB指针、线程处理函数指针、栈内存地址和大小。
  • 栈对齐:确保栈指针按ARM Cortex-M要求的8字节边界对齐。
  • 构建栈帧:在栈上按正确顺序放置初始寄存器值,包括程序计数器(PC)指向线程函数。
  • 栈预填充:用特定模式(如0xDEADBEEF)填充剩余栈空间,便于调试时观察栈使用情况。

void OS_thread_start(OS_thread *me,
                     OS_thread_handler thread_handler,
                     void *stack_sto,
                     uint32_t stack_size);

利用PendSV异常进行上下文切换

上下文切换需要在中断返回时进行。为了避免在每个中断服务程序(ISR)中重复编写切换代码,我们利用ARM Cortex-M的一个专用异常:PendSV

其工作原理如下:

  1. 在需要切换上下文时(例如,在SysTick中断的调度器中),我们通过设置系统控制块(SCB)中的ICSR寄存器的位28来触发PendSV异常。
  2. PendSV异常被设置为最低优先级,确保它不会抢占其他中断,只会在当前中断处理完毕后才执行。
  3. 在PendSV异常处理程序中,我们执行实际的上下文切换汇编代码。

我们需要在系统初始化时设置PendSV的优先级为最低。

// 在 miro.c 的 OS_init() 函数中
#define NVIC_SYSPRI14 (*((volatile uint32_t *)0xE000ED20))
#define NVIC_PENDSV_PRI (0xFF) // 最低优先级
NVIC_SYSPRI14 = (NVIC_SYSPRI14 & 0x00FFFFFF) | (NVIC_PENDSV_PRI << 16);

实现调度器与触发切换

现在,我们来实现触发上下文切换的调度器函数 OS_sched

以下是调度器 OS_sched 的核心逻辑:

  • 线程指针:维护两个全局的volatile指针:OS_curr(指向当前运行线程)和OS_next(指向下一个要运行的线程)。
  • 触发条件:当OS_nextOS_curr不同时,才触发PendSV异常。
  • 临界区保护:对OS_currOS_next的访问必须在禁用中断的临界区内进行,以防止竞态条件。
void OS_sched(void) {
    if (OS_next != OS_curr) {
        // 触发 PendSV 异常
        *((volatile uint32_t *)0xE000ED04) = (1 << 28);
    }
}
// 在 SysTick 中断中调用
void SysTick_Handler(void) {
    __disable_irq(); // 进入临界区
    OS_sched();
    __enable_irq();  // 退出临界区
}

编写PendSV处理程序(汇编)

最后,也是最关键的一步,是在PendSV异常处理程序中用汇编语言实现上下文切换。

上下文切换的步骤在PendSV处理程序中完成,其汇编逻辑如下:

  1. 判断:检查OS_curr是否为NULL(首次切换时)。
  2. 保存上下文:如果OS_curr非NULL,将寄存器R4-R11压入当前线程的栈中,然后更新其TCB中的栈指针(sp成员)。
  3. 恢复上下文:将OS_next线程TCB中的栈指针加载到CPU的SP寄存器,并更新OS_curr指针。
  4. 加载上下文:从新线程的栈中弹出寄存器R4-R11。
  5. 返回:启用中断并执行异常返回,CPU将自动从新线程的栈中加载剩余的寄存器(包括PC),从而跳转到新线程继续执行。
__asm void PendSV_Handler(void) {
    CPSID I                 // 禁用中断
    LDR R1, =OS_curr        // 加载 OS_curr 地址
    LDR R1, [R1]            // 加载 OS_curr 值
    CBZ R1, PendSV_restore  // 如果为0(首次),跳转到恢复部分
    // 保存当前线程上下文
    PUSH {R4-R11}           // 将R4-R11压入当前栈
    LDR R0, =OS_curr
    LDR R0, [R0]
    STR SP, [R0]            // 保存SP到 OS_curr->sp
PendSV_restore:
    // 恢复下一个线程上下文
    LDR R1, =OS_next
    LDR R1, [R1]            // R1 = OS_next
    LDR R0, [R1]            // R0 = OS_next->sp
    MOV SP, R0              // SP = OS_next->sp
    LDR R0, =OS_curr
    STR R1, [R0]            // OS_curr = OS_next
    // 加载新线程上下文
    POP {R4-R11}            // 从新栈弹出R4-R11
    CPSIE I                 // 启用中断
    BX LR                   // 异常返回,切换到新线程
}

测试与调试

完成编码后,我们在调试器中测试整个流程:手动设置OS_next指针来调度不同的线程,单步执行PendSV汇编代码,观察栈内容的变化和寄存器的保存/恢复,最终验证两个LED线程能够被成功切换并运行。

在首次测试中,我们遇到了一个Bug:线程启动函数中错误地对函数指针再次取址,导致栈上的PC值错误。修复后,上下文切换成功执行。

总结

本节课中,我们一起学习了如何自动化RTOS的上下文切换。我们构建了Miro RTOS的雏形,包括:

  1. 定义了线程控制块(TCB)。
  2. 实现了线程栈初始化函数(OS_thread_start)。
  3. 利用PendSV异常作为上下文切换的专用入口。
  4. 实现了调度器函数(OS_sched)来触发切换。
  5. 汇编语言编写了PendSV处理程序,完成了保存和恢复线程上下文的核心操作。

目前,调度(决定OS_next是谁)仍是手动的。在下一课中,我们将实现轮转调度策略,让我们的RTOS能够自动在多个线程之间进行时间片轮转,从而形成一个完整的、自动化的多任务系统。

24:使用轮询策略实现自动化调度 🔄

在本节课中,我们将学习如何为实时操作系统(RTOS)实现自动化的线程调度。我们将重点构建一个简单的轮询调度器,它能够以循环顺序运行多个线程。通过这个过程,我们还将对MiROS RTOS进行多项改进,并观察其高效的运行性能。

项目准备 🛠️

首先,我们需要复制上一课(第23课)的项目目录,并将其重命名为“lesson_24”。进入新目录后,双击打开其中的Microvision项目文件。

回顾上一课的内容,我们开始构建一个名为MiROS的最小化实时操作系统。目前,MiROS RTOS已经能够表示线程、启动线程,并能在不同线程之间切换上下文。然而,在OS_Sched函数中,选择下一个要运行的线程这一调度过程仍然是手动的。今天,我们将实现自动化调度,使MiROS能够真正全速运行你的线程。

实现自动化调度 🤖

对于只有两个线程(Blinky1和Blinky2)的特定情况,我们可以简单地使用if语句硬编码调度逻辑:如果当前线程是Blinky1,则将OS_next设置为Blinky2的地址;否则,将其设置为Blinky1的地址。

编译此代码时可能会失败,因为编译器无法识别Blinky1Blinky2标识符。我们可以通过提供外部声明来修复这个问题。通常,这类声明会放在头文件中,但现阶段我们只是为了测试自动化调度的基本思路。

代码正确编译后,将其加载到LaunchPad开发板上运行。可以看到,来自Blinky1线程的绿色LED和来自Blinky2线程的蓝色LED都在同时且独立地闪烁。这表明自动化调度是可行的,并且我们知道了预期的运行结果。

重构设计:避免硬编码 🧩

现在,我们尝试改进内部设计,避免在调度器中硬编码特定的线程。这个过程称为重构。我们的目标不是改变代码行为(它已经符合要求),而是优化其内部结构。

重构的方法有很多种,核心在于如何组织在OS_thread_start函数中启动的线程。一些RTOS使用链表来组织线程,然后由调度器遍历。但考虑到MiROS未来的发展方向,我建议采用一种简单的“蛮力”解决方案:将线程指针存储在一个预分配的数组OS_thread[]中。

一旦通过连续调用OS_thread_start将线程指针填入数组,调度器将以循环方式选择下一个要运行的线程。

首先,我们需要定义OS_thread数组,其大小设为32+1个线程。MiROS RTOS最多可以处理32个线程,这个限制在后续课程中会更清晰。RTOS还需要记录已启动的线程数量,我们将使用变量OS_thread_n来保存。最后,调度器需要记住当前在OS_thread数组中的索引,我们将使用变量OS_cur_idx,并在循环调度中递增和回绕该索引。

改进线程启动与断言 🔒

现在,每当在OS_thread_start中启动一个新线程时,其指针将被存储在OS_thread数组中,并且OS_thread_n计数器会递增。这里我们做了一个隐含假设:不会溢出线程数组。这种假设应该通过某种方式强制执行。

典型的方法是检查索引并在溢出时向调用者返回错误码。但调用者可能忽略此问题。对于这种情况,更好的方法是使用断言。C语言提供了标准的assert设施,但它在深度嵌入式编程中并不适用,因为我们没有屏幕来打印消息,也无法真正退出程序。

因此,我在这里使用了一个嵌入式系统友好的断言Q_ASSERT。它检查表达式,如果为假,则调用特殊的回调函数Q_onAssert。这个函数已经在bsp.c文件中定义,因为启动代码已经在使用断言。你应该根据具体项目仔细定制此函数,这是代码失败后的最后一道防线。当断言失败时,你应该尝试进行损害控制,并记录或输出断言的位置(由moduleloc参数提供)。之后,通常应该重置系统以避免拒绝服务故障。

为了使用嵌入式系统友好的断言,需要包含qassert.h头文件。该文件位于QPC的include目录中,因此需要确保该目录在你的包含搜索路径中。请确保从state-machine.com/quickstart网页下载并解压QPC。

在给定文件中使用断言,还需要在文件顶部使用Q_DEFINE_THIS_FILE宏来定义文件名。最后,我们可以使用qassert.h头文件中定义的Q_DIM宏来获取数组维度,而无需引入额外的符号名称。

实现轮询调度器 🔄

完成上述准备后,我们进入OS_Sched函数中最有趣的部分:实际的调度逻辑。

在这里,我们需要递增当前运行线程的索引(存储在OS_cur_idx变量中),并在索引达到线程数量时将其回绕到0。然后,通过将OS_next指针设置为OS_cur_idx索引处的线程,完成轮询调度。

至此,调度器就完成了。现在可以构建并运行代码。这种设计的优点是,不再需要在应用程序中硬编码线程,因为MiROS RTOS会注册每个新启动的线程,并自动将其纳入轮询调度。

线程的可组合性 🧱

为了验证添加新线程的便捷性,让我们创建另一个Blinky类型的线程。新的Blinky3线程将闪烁红色LED,并使用略有不同的开/关延时,以便与其他两个Blinky线程结合产生一些有趣的色彩模式。

可以看到,添加新线程的操作仅限于主文件,不需要更改任何现有线程或RTOS代码。线程的这种特性称为可组合性。请注意,只有在添加了RTOS之后,线程才变得可组合,因为如果没有RTOS,你无法轻松地将它们组合起来,使其看似同时且独立地运行。

改进初始化时间线与中断 ⏱️

接下来,我们需要改进MiROS RTOS的另一个方面:初始化时间线,特别是中断的配置和使能。

目前,代码已经在BSP_init函数中启动并使能了中断。这为时过早。因为如果在到达main函数末尾之前发生中断,该中断可能会触发上下文切换,从而将控制权从main函数夺走且不再返回。这意味着一些重要的初始化代码可能无法执行,一些线程可能无法启动。

正确的RTOS初始化时间线是:在所有线程都启动之后,才配置和启动中断。这意味着正确的位置是在main函数的末尾。这里也是那个丑陋的while(1)循环所在的位置,让我们用新的RTOS API OS_run()来替换它。

顾名思义,OS_run函数是你将控制权转移给RTOS并请求它运行你的线程的地方。此时,所有初始化都已完成,你已准备好接收中断。

OS_run的实现将从调用OS_onStartup回调函数开始。这里的“回调”意味着该函数不会在RTOS本身中定义,而是需要在应用程序中定义。OS_onStartup函数是你配置和使能中断的地方。接下来,OS_run函数将调用调度器来运行第一个线程。这个调用与你在SysTick处理程序中的调用相同,但这次你是在中断上下文之外调用调度器。

从之前关于RTOS的课程中我们知道,上下文切换只能在中断之后立即发生,因为整个堆栈布局假设线程是作为从异常返回而切换的。但在这里没问题,因为调度器并不直接执行上下文切换,而是触发PendSV异常,然后该异常正确地返回到下一个要运行的线程。PendSV异常将在中断重新使能后立即运行,因此控制权永远不会真正返回到OS_run,其后的任何代码也永远不会执行。

既然如此,我们可以使用一个总是失败的断言。可以将其编码为Q_ASSERT(0),但qassert.h头文件为这种情况提供了一个更具描述性的断言,称为Q_ERROR

最后,我们还需要在miros.h头文件中声明新的RTOS API的原型。现在尝试构建时,会因为缺少OS_onStartup回调函数而失败。这是一个很好的提醒,我们仍然需要在bsp.c文件中定义这个应用程序特定的函数。要获得OS_onStartup函数的主体,只需剪切并粘贴BSP_init函数中处理中断配置和使能的部分。最后一条使能中断的指令是多余的,因为OS_run函数无论如何都会禁用并重新使能中断。

这次,代码编译和链接没有错误或警告。让我们在调试器中快速单步执行代码的主要部分。在OS_run处设置断点,观察它如何禁用中断并调用调度器。调度器递增OS_cur_idx索引,检查回绕,并将OS_next设置为Blinky2线程的地址。下一个有趣的断点在PendSV处理程序内部,可以看到它如何返回到下一个线程(本例中是Blinky2)。最后,移除断点后,可以观察所有三种颜色的LED在三个Blinky线程同时运行时闪烁。

测量RTOS性能 ⚡

随着MiROS RTOS真正自主运行,在本节课的最后几分钟,你可能有兴趣了解它的运行速度。为了进行测量,我将使用一个混合信号示波器,其逻辑分析仪连接到Tiva-C LaunchPad开发板的以下引脚:红色LED、蓝色LED、绿色LED、几个接地引脚,以及用作测试引脚的PF4。

第一个视图显示信号D1到D4,它们对应PF1到PF4,线条颜色与所连接LED的颜色匹配。可以看到,随着LED闪烁,信号发生变化,但变化速度太慢,难以测量上下文切换时间。

我们需要在每个引脚上看到更快的持续活动,例如让引脚快速上下翻转,中间没有延迟。这可以通过简单地在线程处理程序中注释掉BSP_delay函数调用来实现。但还需要一个触发器来知道上下文切换何时发生。为此,我们将使用另一个测试引脚,比如尚未使用的PF4。

为了提供上下文切换的触发器,可以使用SysTick处理程序来驱动测试引脚上升和下降。由于测试引脚是输出引脚,需要在BSP_init函数中将其配置为输出。将此代码加载到开发板后,会得到一幅非常不同的画面:所有LED都以不同的强度发光,因为它们切换得太快,人眼无法看到单个闪光。

在逻辑分析仪中,可以看到引脚快速上下翻转,但也可以清楚地看到这些活动是互斥的,即一次只有一个引脚在切换,而其他引脚保持原样(要么高要么低)。还可以看到,活动的切换只发生在与测试引脚对应的D4线被激活时,因此让我们将触发器设置为D4的上升沿。

现在,上下文切换总是位于屏幕中央,我们可以方便地放大以查看细节。让我们进行几次测量。首先,测量线程的最后一次活动与触发器(即SysTick中断开始)之间的时间。为了看到测量值,我需要激活模拟视图。结果大约是400纳秒。

要将此值转换为CPU时钟周期数,需要将延迟乘以时钟频率。基本经验法则是:时钟频率每增加1MHz,每微秒就对应一个时钟周期。你的Tiva-C LaunchPad以50MHz运行,因此每微秒有50个时钟周期。将其乘以400纳秒(即0.4微秒),结果是20个时钟周期。

同样,可以测量在SysTick处理程序中花费的时间,结果大约是1.6微秒,对应80个时钟周期。最后,也许最有趣的测量是SysTick退出后、下一个线程开始翻转引脚之前的上下文切换时间,这个时间大约是1.5微秒,代表75个时钟周期。

挂起一个线程和恢复另一个线程之间的总时间大约是3.5微秒,代表175个时钟周期。最后一个测量值可用于估算RTOS的开销,即RTOS内部用于调度和上下文切换的CPU时间与总CPU时间的比率。这个比率是3.5微秒乘以每秒100次嘀嗒,再除以每秒100万微秒,结果仅为0.00035,甚至不到百分之0.1。即使你将系统嘀嗒频率增加到每秒1000次(即1kHz),RTOS的开销也仍然只有0.3%。因此,RTOS的开销相当小。

总结 📝

本节课我们学习了轮询调度。MiROS RTOS正在变得更好,但仍有巨大的改进空间。主要的改进机会是处理BSP_delay函数内部CPU周期的巨大浪费。在掌握了上下文切换魔法之后,你可以利用它将上下文从一个延迟的线程切换走,并仅在延迟结束后再切换回来。这种高效的等待称为阻塞,它将是下一节RTOS课程的主题。

25:线程的高效阻塞

在本节课中,我们将学习如何用高效的线程阻塞机制,取代之前低效的轮询等待事件方式。具体来说,我们将为Miros RTOS添加一个阻塞式延时函数,并探讨线程阻塞对RTOS设计带来的深远影响。

概述

上一节我们实现了RTOS的完全自主运行和简单的轮转调度。然而,线程内部仍然在使用轮询方式的BSP_delay函数。这种轮询会浪费大量CPU周期,而这些周期本可以用于其他更有用的任务。

本节我们将利用上下文切换这一新工具,实现一个完全不同的延时函数。线程在调用延时函数时,会主动让出CPU,进入阻塞状态,直到延时结束才恢复运行。从线程角度看,阻塞式延时和轮询式延时没有区别;但从系统整体角度看,CPU资源得以释放给其他就绪线程,效率大大提升。

从轮询到阻塞

轮询式延时的核心问题是CPU在空转等待。而有了上下文切换能力后,我们可以这样设计延时函数:

  1. 线程调用延时函数。
  2. 函数将线程标记为“未就绪”,并立即触发调度器进行上下文切换。
  3. CPU转而执行其他就绪线程。
  4. 延时时间到后(通常由系统时钟节拍中断服务程序管理),该线程被重新标记为“就绪”。
  5. 调度器在后续调度中会再次选中该线程运行。

这种将功能(如延时)从应用程序迁移到系统软件层,以更高效方式处理的趋势,在后续学习更高级的软件技术时会反复遇到。

线程的生命周期与状态

在实现阻塞式延时前,需要理解线程的一个新属性:就绪状态。一个处于阻塞状态的线程不应该被调度执行,因为它并未准备好运行。

我们可以用状态图来可视化线程的生命周期:

  • 休眠:线程对象和栈被创建,但尚未启动。
  • 就绪(包含两个子状态):
    • 运行:正在CPU上执行。单核系统中,同一时刻只有一个线程处于此状态。
    • 被抢占:曾经运行过,但被调度器切换出去,等待再次被调度。
  • 阻塞:线程主动等待某个事件(如延时结束),在此期间不参与调度。

线程从“运行”状态调用OS_delay()时,会主动进入“阻塞”状态。而从“阻塞”状态恢复“就绪”,则需要系统中心服务(OS_tick())来管理,因为阻塞的线程自身无法行动。

空闲线程的必要性

线程阻塞还带来了一个有趣的推论:系统必须有一个特殊的、永远就绪且不能阻塞的线程,即空闲线程

考虑一个只有两个线程(如Blinky1和Blinky2)的系统:

  • 当两个线程都就绪时,调度器可以运行其一。
  • 当一个线程阻塞时,调度器可以运行另一个。
  • 但如果两个线程同时阻塞,CPU仍需运行一个线程,而现有线程却都不就绪。

空闲线程就是为解决此问题而设。当没有其他线程就绪时(即系统处于空闲条件),调度器就运行空闲线程。因此,空闲线程不允许调用任何可能阻塞的函数。

代码实现步骤

以下是实现阻塞式延时的核心步骤:

1. 创建空闲线程

首先,在main.c中创建空闲线程,其线程例程结构与其他线程类似,但只调用一个回调函数OS_onIdle(),以便应用程序能在其中执行一些处理(如进入低功耗模式)。

// 在 main.c 中创建空闲线程
OS_thread idle_thread;
static uint32_t idle_stack[40];

void main(void) {
    // ... 硬件初始化
    OS_init(&idle_stack, sizeof(idle_stack));
    // ... 创建并启动其他应用线程
    OS_run();
}

空闲线程需要在OS_init()中启动,并将其栈空间分配交给应用程序决定。

2. 扩展线程控制块

我们需要为每个线程对象(OS_thread)添加两个新属性:

  • 超时计数器:用于实现延时。OS_delay()设置初值,OS_tick()每次递减,减到0时唤醒线程。
  • 就绪标志:但为了提高效率,我们不将标志放在每个线程对象中,而是使用一个全局的位图 OS_readySet

OS_readySet是一个32位的位掩码,每个位对应OS_thread数组中的一个线程(空闲线程除外,索引0)。例如:

  • 位0 对应 线程数组索引1
  • 位1 对应 线程数组索引2
  • ...
  • 位n-1 对应 线程数组索引n

这种设计的优势在于:

  • 检查系统是否空闲(OS_readySet == 0)只需一条指令。
  • 为后续实现基于优先级的调度奠定了基础(位号可代表优先级)。

3. 修改调度器算法

调度器OS_sched()需要避开未就绪的线程:

  1. 首先检查OS_readySet是否为0(空闲条件)。若是,则直接选择空闲线程(索引0)。
  2. 若不为0,则从当前线程索引的下一个开始,以轮转方式查找下一个就绪的线程。需要跳过空闲线程(索引0),并且只选择在OS_readySet中对应位被设置的线程。

4. 实现阻塞式延时函数 OS_delay()

OS_delay()函数的签名与轮询式BSP_delay()相同。

void OS_delay(uint32_t ticks) {
    Q_REQUIRE(OS_curr != (uint32_t)0); // 前提条件:不能从空闲线程调用

    __disable_irq();
    // 1. 设置当前线程的超时计数器
    OS_thread *me = OS_thread[OS_curr];
    me->timeout = ticks;
    // 2. 将当前线程标记为未就绪(清除OS_readySet中对应的位)
    OS_readySet &= ~(1U << (OS_curr - 1U));
    // 3. 触发调度,立即切换上下文
    OS_sched();
    __enable_irq();
}

5. 实现系统节拍服务 OS_tick()

OS_tick()通常由系统时钟中断(如SysTick)调用,负责管理所有线程的延时。

void OS_tick(void) {
    uint32_t idx;
    // 遍历所有线程(跳过空闲线程,索引0)
    for (idx = 1U; idx < OS_thread_num; ++idx) {
        OS_thread *th = OS_thread[idx];
        // 如果该线程的超时计数器不为0
        if (th->timeout != 0U) {
            --th->timeout; // 递减计数器
            // 如果计数器减到0
            if (th->timeout == 0U) {
                // 将线程标记为就绪(设置OS_readySet中对应的位)
                OS_readySet |= (1U << (idx - 1U));
            }
        }
    }
    // 注意:此处不需要调用OS_sched(),因为调度会在中断服务程序末尾自动发生
}

6. 最终调整与测试

  • 确保除空闲线程外,所有线程启动时都被正确标记为就绪。
  • 更新RTOS版本号。
  • 在头文件中添加新函数的原型。
  • 将应用程序中原来的BSP_delay()调用替换为OS_delay(),将BSP_tick()调用替换为OS_tick()

代码编译无误后,运行程序。表面上看,LED闪烁效果与之前相同。但通过调试器或逻辑分析仪观察会发现,OS_readySet大部分时间为0,CPU绝大多数时间都在执行空闲线程。只有当某个线程延时结束被唤醒时,才会短暂执行该线程,然后很快又回到空闲线程。

低功耗设计启示

空闲线程消耗了绝大部分CPU时间,这提示我们此处是实现低功耗的关键。对于电池供电的应用,可以在OS_onIdle()回调中让CPU进入低功耗睡眠模式。

ARM Cortex-M 微控制器提供了WFI(Wait For Interrupt)指令,可以停止CPU时钟直到中断发生。将其加入OS_onIdle()

void OS_onIdle(void) {
    __WFI(); // 等待中断,进入低功耗模式
}

加入此指令后,CPU在空闲时会真正停下来,仅当中断发生时才会唤醒,从而极大降低系统功耗。

总结

本节课中,我们一起学习了如何用高效的线程阻塞机制替代低效的轮询。我们为Miros RTOS添加了阻塞式延时功能,并深入探讨了由此引发的RTOS设计变化:

  1. 引入了线程状态:明确了“就绪”(包含运行和被抢占)与“阻塞”状态,并绘制了线程生命周期状态图。
  2. 创建了空闲线程:作为当所有应用线程都阻塞时,保证CPU始终有线程可执行的“最后防线”。
  3. 实现了核心机制
    • 使用OS_readySet位图高效管理线程就绪状态。
    • 修改调度器OS_sched(),使其能跳过未就绪线程。
    • 实现了主动阻塞的OS_delay()函数。
    • 实现了由系统中断驱动的OS_tick()服务,用于更新延时和唤醒线程。
  4. 连接到低功耗设计:指出空闲线程是放置CPU睡眠指令、实现系统低功耗的理想位置。

至此,我们的Miros RTOS实现了一个支持阻塞功能的轮转时间片调度器,这相当于计算机系统在20世纪60年代初期的技术水平。在下一节课中,我们将把RTOS带入70年代,实现基于优先级的抢占式调度

26:什么是“实时”?抢占式、基于优先级的调度

欢迎来到现代嵌入式系统编程课程。我是Miroslaw,在关于RTOS的第五课中,我将最终探讨“实时操作系统”名称中的“实时”方面。

具体来说,在本节课中,你将为Miros RTOS添加一个抢占式、基于优先级的调度器。该调度器在特定条件下,可以被数学证明能够满足实时截止时间的要求。

和往常一样,让我们从复制上一课(第25课)的目录并重命名为第26课开始。进入新的第26课目录,双击MicroVision项目文件以打开它。

概述

为了快速回顾,在上一课中,你为Miros RTOS添加了高效的阻塞延迟功能。然而,这个RTOS尚不名副其实,因为其轮询调度器还不是真正的实时调度器。因此,让我们从引入“实时”的概念开始今天的课程。

什么是实时?

到目前为止,在你所有的代码中,任何被执行的计算都被认为是同等有用的。你只关心计算是否正确,而不关心它是否在给定的时间内完成。实时性为计算增加了及时性的要求。

具体来说,一个执行得太晚(或太早)的计算,其有用性会降低,甚至可能像完全错误的计算一样有害。

上图以图形方式展示了计算的有用性随时间变化的函数关系。

在所谓的硬实时系统中,计算从触发事件开始到截止时间为止是有用的。截止时间之后,计算的有用性变为负无穷大,这意味着该计算不仅无用,甚至是有害的。错过截止时间意味着系统故障。例如,安全气囊展开得太晚不仅是无用的,更是灾难性的。

但也存在软实时系统,其中及时性也很重要,但截止时间不那么严格。例如,一条短信期望在20秒内送达,但即使更晚送达也仍然有用,尽管其有用性会随时间递减。在接下来的讨论中,我将专注于硬实时系统。

历史背景与周期性任务

从历史角度看,计算机在硬实时系统中的主流应用始于20世纪60年代和70年代。例如,阿波罗计划使用了两个相同的实时计算机,一个在指令舱,另一个在登月舱。下图是Margaret Hamilton的照片,她领导了大部分工作,旁边的代码清单堆展示了20世纪60年代和70年代初创建了多少实时软件。

在这些早期阶段,人们就意识到大多数实时系统以周期性方式运行,意味着触发事件和截止时间以特定周期重复。例如,在月球表面着陆航天器需要对火箭推进器进行精细调整,机载计算机必须每几毫秒执行一次。同样,工业过程需要从毫秒到数十秒不等的周期性控制。内燃机的电子控制需要与发动机转速同步的周期性控制,等等。

实验:观察当前RTOS的行为

为了更好地理解实时概念如何应用于周期性线程,让我们用你当前版本的Miros RTOS进行一些实验。

具体来说,你可以将系统时钟节拍率提高到每秒1000次,即每毫秒一个时钟节拍。接下来,你可以修改你的Blinky1Blinky2线程,让它们给CPU施加一些计算负载,因为目前它们只是打开和关闭LED,这只需要一微秒,之后线程就会阻塞并放弃CPU。

为了模拟更真实的CPU负载,你可以在for循环中不是只开关一次LED,而是开关几千次。这个特定的for循环将执行大约1.2毫秒,这有意设计得比你的系统时钟节拍(1毫秒)稍长。在for循环之后,Blinky1线程延迟一个时钟节拍,这将阻塞直到下一个系统滴答中断。这意味着while(1)循环体将以2毫秒的周期重复。


现在,让我介绍文献中常用的几个符号,用于描述像你的Blinky1这样的周期性实时线程。

  • 线程1的计算时间记为 C1,等于1.2毫秒。
  • 线程1的周期记为 T1,等于2毫秒。
  • 线程1的处理器利用率记为 U1,是计算时间与周期的比值。这是CPU执行线程1所花费时间的百分比,在本例中为60%。

类似地,你可以修改Blinky2线程,模拟一个比Blinky1运行时间长三倍(约3.6毫秒)的CPU负载。在for循环之后,Blinky2线程将延迟50毫秒,因此其总周期约为54毫秒。

在图表中,Blinky2的计算时间C2为3.6毫秒,其周期T2为54毫秒,CPU利用率U2为6.6%。

在构建代码之前,最后一步是注释掉OS_onIdle回调函数中的__WFI()(等待中断)指令。这将防止CPU停止,并允许你在逻辑分析仪视图中看到空闲线程中红色LED的切换。

代码构建正确,让我们将其加载到Tiva C LaunchPad开发板中,并使用逻辑分析仪观察其运行情况。

  • 标记为ISR的顶部轨迹对应系统滴答中断处理程序,它每1毫秒重复一次。这是你每秒1000次的快速系统时钟节拍。
  • 紧挨着标记为T1的轨迹对应Blinky1,大部分时间它运行约1.2毫秒,每2毫秒重复一次。
  • 标记为T2的轨迹对应Blinky2。这个线程运行约3到4毫秒(不计间隔),每55毫秒重复一次。
  • 标记为Idle的底部轨迹对应空闲线程,它只在没有其他线程或ISR运行时运行。

但逻辑分析仪视图中最有趣的部分是Blinky1Blinky2同时解除阻塞并准备运行的时刻。

当你放大时,可以看到Blinky1开始运行,但在下一个时钟节拍时被调度出去,此时Blinky2被调度运行。Blinky2也只运行一个节拍,然后Blinky1再次被调度进来以完成处理,之后它阻塞,所以Blinky2运行剩余的时间片。在随后的时钟节拍,Blinky1再次准备运行,但请注意,这是自上次激活以来的第三个节拍,这意味着Blinky1错过其2毫秒的截止时间一个节拍。

轮询调度器的问题

这种情况发生是因为Miros RTOS中当前的调度器仍然是轮询调度器。这种调度器是为分时系统设计的,其最重要的目标是在所有线程之间公平地共享CPU。因此,调度器在Blinky1耗尽时间片后夺走CPU,但这在硬实时系统中并不是你想要的。

例如,假设Blinky1代表一个控制登月舱在月球表面下降的重要线程。它必须每2毫秒运行一次,错过任何一个截止时间都可能导致灾难性的系统故障。在这种情况下,你不在乎CPU分配的公平性,你在乎的是满足2毫秒的硬实时截止时间。

为此,你需要一个不同的调度器,它能够以某种方式了解线程的重要性,以便在执行较低重要性的线程之前执行较高重要性的线程。

引入基于优先级的抢占式调度器

今天,你将实现这种实时调度器的最简单版本,称为具有静态优先级的、基于优先级的抢占式调度器。这意味着每个线程在启动时将被分配一个唯一的优先级号,并且此后该优先级不会改变。调度器的工作是始终运行准备就绪的最高优先级线程。

让我们在时序图中看看你的Blinky线程在这种基于优先级的调度器下将如何执行,以及时序与当前的轮询调度器有何不同。

只要线程T1是唯一准备就绪的线程(因为T2在OS_delay函数内阻塞),两种调度器下的执行是相同的。但是,一旦T2准备就绪运行,轮询调度器就会挂起T1并调度T2。此时,T1就失去了满足2毫秒截止时间的机会。

相比之下,基于优先级的调度器也看到T2准备就绪,但T1具有更高的优先级,因此调度器选择运行T1。因此,T1继续运行并轻松满足其2毫秒的截止时间。

T2线程只有在T1在OS_delay函数中自愿阻塞后才被调度,但一旦T1再次解除阻塞,调度器会立即切换回T1,因为它的优先级高于T2。只要T2最终也阻塞,这种情况就会重复。

但有趣的是,请注意,即使T2被T1的持续中断显著延迟,它最终也能完成并在其54毫秒的截止时间之前阻塞。这意味着基于优先级的调度器以这样一种方式执行T1和T2,使它们都能满足硬实时截止时间。

在Miros RTOS中实现基于优先级的调度器

现在,让我们在Miros RTOS中实际实现基于优先级的调度器。

首先,需要增加版本号,并将线程优先级添加到线程控制块(TCB)以及OS_thread_start函数中。优先级号将是一个小整数,范围从0到支持的线程数(在Miros RTOS中是32),因此它可以舒适地放入一个uint8_t类型。

在RTOS的实现中,你还需要增加版本号,并向OS_thread_start函数添加优先级参数。在这里,你还需要重新设计OS_thread[]数组的使用方式。

你可能还记得第25课,OS_thread[]数组保存了所有已启动线程对象的指针。在轮询调度器中,数组从索引0(保留给空闲线程)开始连续填充,直到索引OS_threadNum


对于基于优先级的调度器,不同之处在于,OS_thread[]数组的索引将是线程的优先级,并且这些优先级不需要是连续的,意味着OS_thread[]数组中可能存在间隙。例如,下图显示了优先级0的空闲线程、优先级2的Blinky2、优先级5的Blinky1和优先级N的线程N。

这意味着你将用作为函数参数传递的优先级号替换OS_threadNum索引。

同时,你需要将用户指定的优先级保存到线程控制块中。然而,为了避免OS_thread[]数组被过度预订,你现在必须确保优先级不仅范围正确,而且尚未被使用,即OS_thread[priority]索引处的指针仍然是0。

你可以将此断言移到函数顶部,并将其作为使用上一课介绍的Q_REQUIRE宏编码的前置条件。前置条件意味着它必须由函数的调用者满足,而不是由函数本身满足。例如,为每个线程分配唯一优先级是应用程序程序员的工作。

重新设计就绪集和调度逻辑

现在,让我们思考一下OS_readySet位掩码的使用。

对于基于优先级的调度,最有趣的信息是基于位掩码的当前值,找到准备就绪的最高优先级线程。此时,你需要决定你偏好的优先级编号方案。

出于历史原因,你可能会遇到的许多RTOS(如Nucleus、ThreadX、MicroC/OS、embOS等)使用反向优先级编号方案,其中优先级0对应最高优先级线程,更高的优先级编号对应更低优先级的线程。不用说,反向优先级编号方案在讨论更高和更低线程优先级时会导致持续的混淆。

当然,也可以使用简单的直接优先级编号方案,其中优先级0对应空闲线程,更高的优先级编号对应更高优先级的线程。Miros RTOS将使用这种简单的直接优先级编号约定。

找到准备就绪的最高优先级线程的优先级号的基本原理是:计算OS_readySet位掩码中直到第一个1位的前导零的数量,并从总位数(32)中减去它。

例如,如果Blinky2线程是唯一准备就绪的线程,OS_readySet位掩码将只有第1位为1,其余为0。在这种情况下,前导零的计数是30,通过从总位数32中减去它,可以转换为优先级号2。如果优先级为5的Blinky1线程准备就绪,OS_readySet位掩码将有27个前导零直到第一个1位,通过从32中减去它,转换为优先级号5。

一般情况下,对于优先级为n的准备就绪线程,前导零的计数将是32-n,因此优先级号再次计算为32减去OS_readySet位掩码中的前导零计数。该公式甚至适用于系统的空闲条件,即OS_readySet位掩码中的所有32位都为0,这导致优先级为0,即空闲线程的优先级。

从数学上讲,公式 32 - clz(x)(用于查找数字x中最高有效1位)是log₂(x)函数的整数近似,这就是为什么我将在Miros RTOS的C实现文件中将其编码为LOG2宏。当然,该算法的速度仅与计数前导零操作一样快,但事实证明,你的ARM Cortex-M4处理器在硬件中通过CLZ指令支持它。你实际上可以在Tiva C微控制器的数据手册中找到这条指令。

为了利用CLZ指令,你可以在编译器帮助中搜索CLZ。正如你所看到的,这个特定的编译器通过内部函数__clz支持它。

有了非常高效的LOG2操作,你现在可以实现基于优先级的调度器。和以前一样,当调度器检测到系统空闲条件时,它需要选择空闲线程。但现在你不再使用OS_curIdx变量,所以你直接设置OS_next = OS_thread[0],即空闲线程。

否则,如果有一些线程准备就绪,你使用带有OS_readySet参数的LOG2宏来找到准备就绪的最高优先级线程。请注意,LOG2宏保证产生一个介于0到32(含)之间的数字,因此可以直接用作OS_thread[]数组的索引,而无需进行范围检查。

此时,最好也断言OS_next指针不为0。

修改系统滴答和延迟服务

最后的更改是重新设计OS_tickOS_delay服务。这是必要的,因为这两个实现目前都假设OS_thread[]数组是连续填充到OS_threadNum级别的,但情况已不再如此。

OS_tick中,与其遍历整个OS_thread[]数组(可能存在大量未使用的优先级间隙),不如利用快速的LOG2操作。具体来说,你可以引入一个延迟线程位掩码OS_delaySet),它类似于OS_readySet,但保存的是延迟的线程。在编辑时,你可能还需要移除不再需要的变量OS_curIdxOS_threadNum

有了OS_delaySet位掩码,OS_tick函数将只迭代位掩码中的1位,而不是扫描所有位。但是,因为你需要从位掩码中移除已处理的位,所以你需要使用一个临时的位掩码工作集。只要工作集中有一些位被设置(意味着一些线程被延迟并需要在此刻处理),你就循环。

你首先使用快速的LOG2宏快速获取工作集中最高阶1位的编号,并用它索引到OS_thread[]数组。将获得的指针保存在临时变量t中。然后断言t指针不为0(意味着线程正在使用),并且该线程的超时不为0(因为它必须是一个延迟线程)。

接下来,你递减此线程的超时计数器,如果它变为零,则通过设置OS_readySet位掩码中相应的优先级位,使线程准备就绪运行。同时,你从OS_delaySet位掩码中移除相同的位,因为此线程不再延迟。最后,你总是从工作集中移除相同的优先级位,因为它现在已被处理。

为了避免明显的代码重复,你可以像这样引入一个临时变量bit

uint32_t bit = (1U << p); // p 是优先级

OS_delay函数中,你需要用当前线程的优先级号替换OS_curIdx变量。除了从就绪集中移除优先级位之外,该函数现在还需要将相同的位添加到OS_delaySet中,因为此线程现在正在变为延迟状态。

最后,为了避免代码重复,你可以像之前在OS_tick中那样引入一个临时变量bit

测试基于优先级的调度器

基于优先级的调度器实现现已准备就绪,让我们尝试构建代码。项目编译失败,因为OS_thread_start函数的签名已更改。使用基于优先级的调度器,你启动的每个线程都需要一个唯一的优先级才能运行。

为了与之前使用的图表保持一致,让我们为Blinky1分配优先级5,为Blinky2分配优先级2。正如你所看到的,优先级不需要是连续的。真正重要的是它们是唯一的,并且Blinky1的优先级高于Blinky2的优先级。

还有一个编译错误。你需要在启动空闲线程时显式添加优先级0。

代码构建干净,让我们看看它是如何工作的。首先,我相信你很好奇LOG2宏生成的代码。因此,让我们在OS_sched函数中使用该宏的地方设置一个断点。

正如你所看到的,这段汇编代码首先加载OS_thread[]数组和OS_readySet位掩码的地址,然后仅用两条指令就完成了最高优先级就绪线程的计算。CLZ指令在一个CPU周期内计算位掩码中前导零的数量并将其存储在R0中。类似地,RSB(反向减法)指令在一个CPU周期内将结果转换为优先级号并存储在R0中。在这种情况下,得到的优先级结果是5,你应该认出这是Blinky1的优先级。确实,OS_next变量被设置为Blinky1线程的地址。

当你最终自由运行代码时,可以观察板上LED的一些活动。但要真正看到你新的基于优先级的调度器如何工作,你需要使用逻辑分析仪。

正如你所看到的,Blinky1线程现在完全不受干扰地运行,即使Blinky2准备就绪运行。Blinky1总是满足其截止时间。Blinky2也是如此,尽管它被Blinky1抢占了几次。最后,空闲线程也会运行,但仅当没有其他线程或中断处于活动状态时。

很高兴注意到,这个分析轨迹与你之前设计并随后在Miros RTOS中实现的基于优先级的调度器的时序图完全匹配。

阻塞的重要性

此时,我想指出阻塞对于基于优先级的调度器的绝对关键重要性。一个高优先级线程可以运行任意长的时间,没有较低优先级的线程可以运行,直到高优先级线程阻塞并自愿放弃CPU。没有阻塞,较低优先级的线程将永远不会运行。例如,Blinky2和空闲线程只有在Blinky1阻塞时才运行;同样,空闲线程只有在Blinky1Blinky2都阻塞时才运行。

这与轮询调度器非常不同,轮询调度器会依次执行每个Blinky线程,即使它们根本没有阻塞。所以现在你明白为什么我必须在上一课(第25课)中实现高效的线程阻塞,然后才能在本课中引入实时基于优先级的调度。

优先级分配与速率单调分析

随着代码按预期工作,让我们现在关注在使用基于优先级的调度器时面临的最重要决策:如何为线程分配优先级。

对于像Blinky1Blinky2这样的两个线程,你只有两种可能性:Blinky1的优先级高于Blinky2,或者Blinky1的优先级低于Blinky2。正如你刚才看到的,第一种选择满足两个实时截止时间。另一方面,很容易看出,将Blinky1的优先级分配得低于Blinky2的第二种可能性,会导致Blinky2一准备就绪运行,Blinky1就错过其截止时间。

因此,这里出现的规则是:为周期较短(也意味着截止时间较短)的线程分配较高的优先级

事实证明,这条规则早在20世纪70年代就被发现了。具体来说,在1973年,C.L. Liu和James W. Layland发表了一篇开创性论文,题为《多道程序硬实时环境中的调度算法》。我在视频描述中提供了这篇文章的网络链接。

但基本上,Liu和Layland在这篇文章中描述道,你刚刚实现的简单静态优先级调度器,在特定条件下,可以被数学证明满足所有线程的所有硬实时截止时间。该方法后来被推广并称为速率单调分析速率单调调度,并在另一篇文章《实时系统的速率单调分析》中得到了很好的解释,我也在视频描述中提供了其网络链接。

我在这里提到它,是因为任何关于基于优先级的调度器的讨论,如果不提及RMA/RMS方法,都是不完整的。

术语“速率单调”源于将优先级分配给一组线程的方法,即作为周期性线程速率的单调函数。

在数学中,当一个函数保持或反转两个有序集合之间的顺序时,它被称为单调的。具体来说,RMA指的是将线程速率的递增顺序映射到线程优先级的递增顺序的优先级分配。因此,它只是你自己发现的简单规则的一个花哨名称。

但RMA当然不止于此,完整的讨论确实超出了这节简短课程的范围。对于今天,让我只总结RM/RMS方法最重要的指导原则。

  1. 始终单调地分配线程优先级,意味着具有较高速率的线程必须以比具有较低速率的线程更高的优先级运行。
  2. 你需要知道每个线程的CPU利用率,你将其计算为测量的执行时间Cₙ与线程周期Tₙ的比值。
  3. 你需要计算总CPU利用率,即所有单个CPU利用率因子的总和。

如果这个总利用率低于理论界限,则保证集合中的所有线程都能满足其截止时间。这早在1973年Liu和Layland的论文中就已经被数学证明了。

利用率界限U(n)取决于线程数n。对于大量线程,U(n)趋近于ln(2),即略低于0.7。因此,在实践中,如果你将CPU利用率保持在70%以下,你的线程集将是可调度的,意味着它们都将满足线程截止时间。

例如,你的Blinky1Blinky2线程的总CPU利用率如下:Blinky1为1.2毫秒/2毫秒,Blinky2为3.6毫秒/54毫秒。因此,总CPU利用率为0.66,低于理论界限。

当然,基本的RMA假设周期性线程以恒定时间执行,但该方法可以扩展到具有可变执行时间的非周期性线程。在这种情况下,你需要考虑最坏情况,即线程激活之间的最短时间和最长的执行时间。

此外,在实践中,通常只有少数最高优先级的线程具有硬实时截止时间,而其他线程只有软实时要求。在这种情况下,你对硬实时线程使用RMA,并将所有软实时线程的优先级设置得更低。

抢占式优先级调度的优势

抢占式、基于优先级的调度的美妙之处在于,高优先级线程总是可以立即抢占所有较低优先级的线程,因此高优先级线程对较低优先级线程的执行时间或周期的变化不敏感。换句话说,抢占式、基于优先级的调度器在时间域上解耦了线程。

由于所有这些原因,抢占式、基于优先级的调度器成为规范,并且在大多数实时操作系统中得到支持,直至今日。

总结

本节课关于实时计算和基于优先级的调度的内容到此结束。在下一课中,你将再前进十年,从20世纪70年代到80年代,那时商业RTOS成为主流,并添加了线程间同步和通信机制。

如果你喜欢这个频道,请订阅以保持关注。你也可以访问 statemachine.com/quickstart 获取课堂笔记和项目文件下载。

27:RTOS 第6部分 - 同步与通信

在本节课中,我们将学习实时操作系统(RTOS)中用于并发线程间同步与通信的核心机制。我们将从自制的“Miro RTOS”迁移到专业的“QXK RTOS”,并重点学习信号量(Semaphore)的工作原理与实践应用。

概述

上一节我们实现了基于优先级的抢占式调度器。然而,我们的线程目前仍像在独立轨道上运行的火车,彼此间没有交互。在实际系统中,线程的“轨道”会交叉,这就需要同步与通信机制来协调工作并避免冲突。本节课,我们将引入软件信号量这一经典同步机制,并完成从自制RTOS到专业RTOS(QXK)的迁移。

从 Miro RTOS 迁移到 QXK RTOS

实现信号量等线程间通信机制非常复杂且容易出错。因此,我们将放弃在自制RTOS上实现,转而使用QPC框架中包含的专业级QXK RTOS。这个过程称为“移植”。

以下是移植应用程序的主要步骤:

  1. 移除旧RTOS文件:从项目中删除 MiroS.hMiroS.c 文件,并将项目组重命名为“QPC”。
  2. 添加QPC源码:将QPC框架的源文件添加到项目中。这包括:
    • QP/source/qf 目录下的文件(用于事件驱动编程和状态机)。
    • QP/source/qxk 目录下的文件(用于QXK抢占式内核)。
    • 针对ARM Cortex-M和Keil工具链的移植文件(位于 QP/ports/arm-cm/qv/keil 目录下的 qxk_port.c)。
  3. 调整应用程序代码:修改应用程序代码以适配QXK的API。主要改动包括:
    • 包含头文件:将 #include “MiroS.h” 替换为 #include “qpc.h”
    • 更新数据类型:将 OS_thread 替换为 QXThread
    • 更新函数名:将 OS_delay() 替换为 QXThread_delay(),将 OS_init() 替换为 QF_init(),将 OS_run() 替换为 QF_run()
    • 调整线程启动方式:使用 QXThread_ctor() 构造函数初始化线程对象,然后使用 QXTHREAD_START() 宏启动线程。
    • 修改线程函数签名:线程函数需要增加一个参数(通常命名为 me),用于访问关联的线程对象。
  4. 更新板级支持包(BSP):在 bsp.c 中,将调度器调用替换为QXK的宏 QXK_ISR_EXIT(),并将系统节拍服务调用替换为 QF_TICK_X()
  5. 实现QXK回调函数:定义 QF_onStartup()QF_onIdle() 等回调函数,以替换Miro RTOS中的对应函数。特别注意在 QF_onStartup() 中正确设置系统节拍中断(SysTick)的优先级,使其成为“内核感知中断”。

完成上述步骤后,应用程序应能成功编译并运行,其行为与之前使用Miro RTOS时完全一致。

理解信号量

信号量是用于线程间同步的基础机制。其概念源于铁路信号灯:当信号灯关闭(红色)时,火车必须等待;当信号灯打开(绿色)时,火车可以通过。

在软件中,信号量是一个计数器,它跟踪“信号”发生的次数。线程可以“等待”(wait)信号量(尝试减少计数器),也可以“发送信号”(signal)给信号量(增加计数器)。如果线程等待时计数器为零,则该线程会被阻塞,直到有其他线程发送信号。

QXK中的二进制信号量(最大计数为1)常用于简单的线程间信号传递。

实践:使用信号量同步按钮与LED

我们将修改 Blinky2 线程,使其不再基于固定延时闪烁蓝色LED,而是等待开发板上的 SW1 按钮被按下后才开始闪烁。

以下是实现步骤:

  1. 定义并初始化信号量:在 main.c 中定义一个 QXSemaphore 类型的信号量对象(例如 SW1_sem),并在 main() 函数开始时使用 QXSemaphore_init() 将其初始化为二进制信号量,初始计数为0(表示无信号)。
    QXSemaphore SW1_sem; // 信号量对象
    int main() {
        // ... 其他初始化 ...
        QXSemaphore_init(&SW1_sem, 0U, 1U); // 初始计数0,最大计数1(二进制信号量)
        // ... 启动线程 ...
    }
    
  2. 在线程中等待信号量:在 Blinky2 线程函数的循环开始处,调用 QXSemaphore_wait() 来等待信号量。这将阻塞线程,直到信号量被发送信号。
    void Blinky2_thread(QXThread * const me) {
        while (1) {
            QXSemaphore_wait(&SW1_sem, QXTHREAD_NO_TIMEOUT); // 无限期等待
            BSP_ledBlueOn();
            // ... 短暂延时 ...
            BSP_ledBlueOff();
        }
    }
    
  3. 配置按钮并设置中断:在板级支持包(bsp.c)中,配置连接 SW1 按钮的GPIO引脚(例如 PF4)为输入模式,启用内部上拉电阻,并设置为下降沿触发中断。
  4. 在中断服务程序中发送信号:为GPIOF中断编写中断服务程序(ISR)。在ISR中,确认中断源来自 SW1 引脚后,调用 QXSemaphore_signal() 来发送信号给信号量。
    void GPIOF_IRQHandler(void) {
        QXK_ISR_ENTRY(); // 进入内核感知中断
        if ((GPIOF->RIS & SW1_PIN) != 0U) { // 检查是否是SW1引脚的中断
            GPIOF->ICR = SW1_PIN; // 清除中断标志
            QXSemaphore_signal(&SW1_sem); // 发送信号给信号量
        }
        QXK_ISR_EXIT(); // 退出中断,可能触发调度
    }
    
  5. 设置中断优先级:在 QF_onStartup() 回调中,将GPIOF中断的优先级设置为“内核感知”级别(低于 QF_AWARE_ISR_CMSIS_PRI),以确保它能安全调用QXK的API(如 QXSemaphore_signal)。

完成这些步骤后,Blinky2 线程的运行将与 SW1 按钮的按下动作同步。每次按下按钮(实际上,每次中断),Blinky2 线程就会执行一次循环(闪烁一次蓝色LED)。

深入理解信号量行为

在实际测试中,由于机械按钮的抖动(Bounce),一次物理按压可能产生多次中断,从而导致信号量被多次“发送信号”。结合线程的抢占式调度,这会产生不同的执行序列,生动地演示了信号量“令牌”模型的工作方式:

  • 信号(signal:向信号量添加一个令牌(如果未达最大计数)。
  • 等待(wait:从信号量中取出一个令牌(如果有),否则阻塞。

按钮抖动和线程调度的不确定性,可能导致 Blinky2 线程执行循环的次数与中断触发次数不完全一致,这正体现了并发编程中同步机制的复杂性和重要性。对于生产环境,必须为机械开关添加去抖动(Debouncing)逻辑。

总结

本节课中,我们一起学习了RTOS中线程同步与通信的基础。我们成功将应用程序从自制的Miro RTOS移植到了功能更完善的QXK RTOS。我们重点探讨了信号量这一核心同步原语,通过一个“按钮控制LED”的实例,理解了信号量的初始化、等待(wait)和发送信号(signal)操作,并分析了在抢占式调度和真实硬件事件(如按钮抖动)下信号量的行为。这为我们理解更复杂的资源共享与互斥机制打下了基础。在下一课中,我们将探讨如何让多个线程安全地共享资源。

28:互斥机制

在本节课中,我们将学习并发线程之间如何共享资源,以及RTOS提供的用于保护这些共享资源的互斥机制。

课程概述

在上一节课中,我们将Blinky线程移植到了专业的QPC框架和QXK RTOS内核上,并学习了使用信号量进行线程同步。然而,线程之间尚未进行交互。在实际应用中,线程需要通信、协调和协作。线程间交互最简单、最常用的方式是共享变量、函数以及外设等资源。

请注意,RTOS允许线程间共享变量和硬件寄存器,这与Windows或Linux等大型操作系统不同,后者中不同进程运行在独立的地址空间,难以共享资源。相反,RTOS中的并发线程非常轻量,且运行在同一地址空间。C编译器完全不知道上下文切换和栈指针的变化,因此将线程函数视为普通函数,允许它们访问任何可见的变量、内存和硬件寄存器。

然而,并发线程间的任何资源共享都可能导致冲突和竞争。因此,我们需要引入一些机制和规则来确保在任何给定时间只有一个线程使用共享资源。这些机制统称为互斥机制。

本节课,我们将学习现代RTOS中最重要的互斥机制。

引入资源共享与问题重现

首先,我们在Blinky线程中引入资源共享,以观察可能引发的问题。我们将使用第20课中关于竞争条件时提到的GPIO数据寄存器共享方式。

例如,实现LED切换操作可以读取GPIOF数据寄存器,与目标位(如蓝色LED位)进行异或操作,然后写回寄存器。这个操作可以用^=运算符简洁地表示:

GPIOF->DATA ^= LED_BLUE;

同样,绿色LED的切换操作也可以这样实现。LED开/关操作与切换操作的关键区别在于,开/关操作使用不同的寄存器,而切换操作使用同一个GPIOF数据寄存器。这就在调用切换操作的线程之间引入了数据寄存器的共享。

现在,将LED切换操作的原型添加到BSP头文件后,在Blinky线程中使用它们替代原来的LED开/关操作。注意,现在每个循环中只有一次LED状态改变,比之前两次(开和关)要快。因此,为了达到与之前相似的CPU利用率,需要增加循环迭代次数。测试表明,1900次迭代比较合适。

考虑到循环结束后LED的状态,偶数次切换后状态不变,奇数次切换后状态改变。为了在本节课中更好地观察,我们希望每个循环后LED状态都改变,因此显式使用奇数次切换。例如,1900+1在编译时求值,相当于1901,但+1更明确地表达了使用奇数次的意图。

构建软件,加载到开发板,并打开逻辑分析仪。监测到的信号从上到下按优先级排列:SW1开关状态(按下时触发GPIO中断)、中断触发的Blinky2线程中的信号量、切换绿色LED的最高优先级Blinky1线程、切换蓝色LED的较低优先级Blinky2线程、以及开关红色LED的最低优先级空闲线程。

逻辑分析仪触发器设置为SW1信号的下降沿。开始采集数据后,直到按下SW1开关才会触发。按下开关后,Blinky2线程开始运行,但由于Blinky1优先级更高,Blinky2每运行2毫秒就会被Blinky1抢占。

仔细观察Blinky1线程,它确实在每个活动周期后改变绿色LED的状态。但有时,特别是当Blinky2也在运行时,绿色LED的状态并未改变。例如,在某个点,预期绿色LED应从关闭切换到开启,但实际没有。

放大该点的轨迹,可以看到Blinky1线程实际上如预期改变了绿色LED的状态,但当Blinky2线程在Blinky1抢占后恢复运行时,绿色LED的状态又改变了。进一步放大到纳秒级别,可以看到绿色LED和蓝色LED的变化总是同时发生。这是两个LED在同一CPU指令中被改变的典型迹象,这在第20课关于竞争条件中已详细解释。

具体来说,低优先级的Blinky2线程在读取GPIOF数据寄存器后、写回之前,被SysTick中断抢占。在此期间,SysTick调度了Blinky1运行,它切换了绿色LED。当Blinky2恢复时,它最终将(过时的)值写入了数据寄存器,无意中切换了自己的蓝色LED和Blinky1的绿色LED。

本节课的相关性在于,竞争条件不仅可能发生在主代码和中断之间(如第20课),实际上在抢占式RTOS内核的任何两个并发线程之间更容易发生。

临界区机制

避免此类竞争条件的第一个机制是简单地在访问共享资源的代码周围禁用中断,这在第20课已解释过。这在RTOS中也有效,因为禁用中断会完全切断CPU与外部世界的联系,从而无法发生抢占。

但是,像第20课那样在临界区代码周围无条件地禁用和启用中断至少有两个缺点。首先,这与RTOS的中断禁用策略不一致。如上节课(第27课)所述,QXK RTOS内核通过选择性地仅禁用到某个中断优先级级别来实现零延迟策略。简单的临界区不加区分地禁用所有中断,从而为那些本不应被禁用的中断引入了额外的延迟。

其次,有时可能需要嵌套临界区。例如,如果一个函数调用内部隐藏了一个临界区,而该函数又从已建立的临界区内被调用。如果临界区设计为不可嵌套,则会过早重新启用中断,从而可能在应受临界区保护但未受保护的代码段中引发竞争条件。

QXK内核提供了一种临界区机制,它既符合零延迟中断禁用策略,又允许临界区嵌套。QXK临界区的实现如下:首先,需要定义一个自动变量来保存中断禁用状态。接着,调用QF_CRITICAL_ENTRY宏,并将该变量作为参数传入。该宏首先读取当前中断禁用状态并保存到提供的变量中,然后才禁用中断。在临界区结束时,调用QF_CRITICAL_EXIT宏,该宏将从提供的istatus参数恢复保存的中断禁用状态。这种临界区可以嵌套,因为它保存并恢复了原始的中断禁用状态。

现在,我们从Blinky线程中删除冗余的简单临界区,并为蓝色LED的切换函数也添加临界区。代码构建无误。

加载到开发板并打开逻辑分析仪。与之前运行相比,第一个明显的区别是Blinky1线程占用了更多CPU,这是由于LED切换函数内部临界区的额外开销。但现在,Blinky1线程总是按预期切换绿色LED的状态。无论如何尝试,现在都找不到绿色LED被错误切换的情况。当然,这样的测试不能证明没有其他bug,但竞争条件似乎已被消除。

总结来说,临界区是一种非常强大的互斥机制,RTOS本身也用它来保护其内部变量免受竞争条件影响。你也可以使用它,但正因为该机制如此强大,它只适用于非常短的代码段。任何超过几微秒(即只有少数几条机器指令)的操作都太长,可能会增加系统中的中断延迟。

信号量机制

接下来,我们考虑一种资源共享持续数百微秒的情况,这远超出使用临界区互斥机制的范围。例如,假设需要通过闪烁绿色LED以莫尔斯电码发送消息。

莫尔斯电码由点和划组成,点的持续时间是基本时间单位,设为约40微秒比较合适。划是3个点的时间。同一字母各部分之间的间隔是1个点的时间,字母之间的间隔是3个点的时间,单词之间的间隔是7个点的时间。

软件将发送两种类型的消息:紧急的SOS求救信号(需要在每2毫秒间隔内至少发出一次)和低优先级的测试消息(当系统空闲时,大约每5毫秒发出两次)。

消息编码在一个位掩码中,每个位代表一个点时间单位。例如,单词“SOS”和“TEST”的编码(包含字母内和字母间的正确停顿)可以表示为十六进制数字。

实现一个根据提供的莫尔斯电码调制绿色LED的函数BSP_morseCode。该函数接收消息位掩码,检查其最高有效位。如果位为1,则打开绿色LED;否则关闭。之后,函数忙等待一个点时间以保持LED状态。请注意,约40微秒的点时间太短,无法使用高效的阻塞式delay函数(该函数只能延迟系统时钟滴答间隔的整数倍)。微秒级延迟的唯一选择是忙等待循环。处理完该位后,位掩码左移一位,循环重复直到位掩码变为0,表示消息中没有更多字母。最后,发送完所有字母后,函数关闭绿色LED,并生成莫尔斯电码单词后所需的7个点时间的停顿。

将此实现添加到BSP,并记得将莫尔斯电码函数的原型添加到BSP头文件。然后在线程中使用该函数。最高优先级的Blinky1线程将发送紧急的SOS消息,然后延迟自身一个时钟滴答。低优先级的测试消息将由之前未使用的Blinky3线程发送,该线程将连续发送两个测试消息,然后延迟自身五个时钟滴答。

本练习的重点是,BSP_morseCode函数现在被两个并发线程(Blinky1和Blinky3)共享。请注意,此时共享函数没有防止并发访问的保护,因为首先需要看看没有这种保护会发生什么。另外,显然需要以某个低优先级启动Blinky3线程,本练习将使用优先级1,低于Blinky1和Blinky2。

重建代码并加载到Tiva C LaunchPad开发板。在逻辑分析仪视图中,触发器仍设置为按下SW1按钮,因此在按下开关前不会捕获任何数据。放大并观察绿色LED轨迹,可以识别出三点、三划、三点的SOS消息。该消息每2毫秒出现一次。但也可以看到绿色LED以不同模式闪烁的地方。例如,这里可以识别出1划(T)、1点(E)、3点(S),但测试消息最后的1划(T)却变成了划点点,这恰好是字母D。后面跟着三划(O)、三点(S),最后是7个点的停顿。所有这些组成了混乱的消息“TAS DOS”,这既不是“TEST”也不是“SOS”。随后的闪烁模式更加混乱。结果是,“TEST”和“SOS”消息冲突,两者都在过程中受损。因此,紧急的SOS消息没有按时发出,错过了其硬实时截止时间。

这里的问题显然是两个并发线程Blinky1和Blinky3共享绿色LED,没有任何防止冲突的保护。然而,由于绿色LED的共享现在持续近1毫秒(即100微秒),需要使用除临界区之外的其他保护机制。从上节课(第27课)中,你已经了解并使用过信号量。信号量在20世纪60年代发明,用于线程同步和互斥。

现在,不再使用之前的takeCounter,你需要一个新的信号量来保护莫尔斯电码函数,可以命名为morseSema,并且可以声明为static,因为它只在BSP.c文件作用域内使用。需要初始化信号量,类似于初始化SW1信号量。现在从计数1开始,意味着资源初始可用。接下来,在访问共享的绿色LED资源之前,等待信号量变为可用。这类似于汽车在十字路口等待绿灯。访问完共享资源后,发出信号量信号使其下次可用。请注意,由于信号量是二元的(一次只能持有一个令牌),一次只有一个线程可以通过信号量等待函数调用,这正是所需的互斥。

构建代码并加载到开发板。逻辑分析仪仍设置为在SW1信号下降沿触发,因此在按下按钮前不会发生任何事。一旦收集到轨迹并放大绿色LED信号,可以清晰地识别出三点、三划、三点的SOS消息。也可以识别出划点、三点、划的“TEST”消息,它显然是完整的,不再混乱。当测试消息插入时,下一个SOS消息显然被延迟了。但它仍然设法在截止时间前发出。因此,似乎已成功防止了Blinky1和Blinky3线程之间围绕绿色LED共享的冲突。确实,可以尝试多次,每次看起来都很好。直到出现问题。例如,这里绿色LED完全停止闪烁,显然只要Blinky2持续运行超过6毫秒。这意味着最高优先级的Blinky1线程被较低优先级的Blinky2线程阻止运行,而Blinky2完全不受干扰地运行。仔细检查发生了什么,可以看到按钮按下时绿色LED正在发送测试消息,这意味着低优先级的Blinky3正在控制中。这本身没问题,但当下一个系统时钟滴答阻塞最高优先级的Blinky1时,它立即在莫尔斯信号量上阻塞,因为信号量已被低优先级的Blinky3占用。这使得Blinky2可以随心所欲地运行,而Blinky1错过了其硬实时截止时间。

刚刚见证的问题称为无界优先级反转,是硬实时系统中的灾难性故障。这里的根本问题是信号量只是不知道它阻塞的线程的优先级,因此没有采取任何措施来防止优先级反转发生。这实际上并不奇怪,因为信号量发明于分时系统时代,那时根本没有使用线程优先级的概念。没有优先级概念,优先级反转就不会发生。直到信号量被强制用于基于优先级的RTOS内核时,它才成为一个已知问题。事实证明,经典信号量虽然仍适用于线程同步(如上节课所见),但在基于优先级的系统中并不是一个好的互斥机制。

选择性调度器锁定机制

这引出了本节课还想介绍的两种现代互斥机制。它们都能防止优先级反转以及许多其他棘手问题,如死锁。第一种机制是选择性调度器锁定,锁定到指定的优先级上限。

首先看看如何在应用程序中使用它,然后在逻辑分析仪视图和时序图中解释其工作原理。这里不再需要莫尔斯信号量,但与其删除之前的代码,不如将其注释掉,并添加关于所用机制的注释,以便以后可以试验本课讨论的所有选项。信号量不再需要初始化。最后,在信号量等待的地方,调用QXK_schedLock函数。该函数接受一个参数,即优先级上限。上限表示锁定调度器的级别,意味着所有低于或等于该上限的线程不会被调度,但任何高于该上限的线程照常调度。当然,运行在所有线程之上的ISR完全不受影响,因此对中断延迟没有影响。从这个解释中应该清楚,上限优先级必须至少与使用共享资源的最高优先级线程的优先级一样高。在本例中,最高优先级线程是Blinky1,优先级为5。

QXK_schedLock API只能从获取调度锁所有权的线程中调用。该函数返回调度锁的先前状态,就像临界区机制返回中断的先前状态一样。与临界区一样,调度锁允许嵌套(如果需要)。最后,拥有锁的线程不再发出信号量信号,而是使用QXK_schedUnlock API解锁调度器。通过调用此函数,线程从参数stat恢复先前的调度锁状态。

请注意,选择性调度器锁定是一种非阻塞的互斥机制,类似于临界区是非阻塞的,但现在只防止调度到指定优先级上限的线程,而更高优先级的线程和所有中断将继续不受干扰地运行。

现在,让我们通过逻辑分析仪看看这是如何工作的。首先,检查选择性调度器锁定是否防止了SOS和测试消息之间的冲突。消息看起来完整且没有相互干扰,因此互斥肯定有效。但现在还需要检查无界优先级反转是否仍然发生。为此,需要捕获按钮在测试消息期间按下的特殊情况。幸运的是,这种情况很快出现。如你所见,即使按钮被按下,优先级高于正在发送测试消息的Blinky3的Blinky2线程也没有抢占Blinky3。因为它现在低于优先级上限,所以显然没有被调度。相反,Blinky3不受干扰地发送测试消息,然后Blinky1才发送SOS消息。之后,Blinky2有机会运行,直到再次被Blinky1抢占。结论是,无界优先级反转已被防止。

时序图再次显示了信号量和选择性调度器锁定的相同场景。在经典信号量的情况下,时间A的按钮按下导致调度Blinky2线程,因为其优先级高于当前运行的Blinky3。在稍后的时间B,最高优先级的Blinky1被调度,但它立即在信号量上阻塞,因为信号量已被Blinky3占用。这使得Blinky2可以随心所欲地运行,产生了无界优先级反转问题。相反,在调度锁定的情况下,Blinky2线程在时间A没有被调度,因为Blinky2的优先级低于优先级上限。在稍后的时间B,Blinky1线程也没有被调度,因为它也没有高于上限。只有在Blinky3完成后,它释放了锁,调度器才选择最高优先级的就绪线程运行,即Blinky1。

总结来说,选择性调度器锁定是一种非常有效的非阻塞互斥机制,可以防止无界优先级反转。它在RTOS内部实现简单,因此非常高效。但许多RTOS只提供对所有线程的粗略调度锁定,这 unfortunately 过于普遍。现代RTOS内核,包括QXK,提供仅锁定到指定优先级上限的选择性调度锁定。这允许不参与给定资源共享的更高优先级线程完全不受干扰地运行。选择性调度锁定的唯一限制是,线程在持有锁时不能阻塞。在访问共享资源时阻塞(例如调用阻塞式delay或信号量等待API)被认为是不良实践,应避免,但在实际项目中偶尔会发生。在这种情况下,如果线程试图在拥有调度锁时阻塞,QXK将触发断言。在这种情况下,唯一的选项是本节课要讨论的最后一种互斥机制,称为互斥锁

互斥锁机制

术语“互斥锁”是“相互排斥”的缩写,有时也称为互斥信号量。但不要将互斥锁视为一种信号量。相反,将互斥锁视为RTOS对象,专门设计用于在最一般情况下保护并发线程之间共享的资源,其中线程可能在访问共享资源时阻塞。

为了演示互斥锁的使用并保持简单,我将仅用互斥锁替换调度锁,即使受保护的代码中没有阻塞。首先,需要定义一个互斥锁对象。接下来,互斥锁对象需要在使用前初始化。QXK内核提供了所谓的优先级上限互斥锁,需要使用与此互斥锁保护的资源相关的优先级上限进行初始化。与调度锁类似,此优先级上限必须至少与访问共享资源的最高优先级线程的优先级一样高。然而,在QXK中,上限优先级必须是唯一的,不能与线程已使用的优先级相同。因此,这里将比Blinky1高一级,即优先级6。这使得优先级上限互斥锁有点类似于线程,因此必须在调用QF_init函数后进行初始化。由于互斥锁的初始化发生在BSP_init中,需要确保它在QF_init之后被调用。这意味着需要反转main函数中的调用顺序。

完成此设置后,现在可以将互斥锁应用于保护莫尔斯电码生成。受保护的代码段以QXK_mutexLock API开始,此时线程成为互斥锁的所有者。保护以QXK_mutexUnlock调用结束,此时线程放弃互斥锁的所有权。互斥锁API中的锁定/解锁名称特意选择为与调度锁定相似,而与信号量API中的等待/信号名称不同。

与调度锁一样,首先构建并在逻辑分析仪中运行代码,然后用时序图解释细节。首要任务是验证互斥锁是否完成了防止SOS和测试消息之间冲突的工作。显然,它做到了。接下来,需要验证无界优先级反转是否发生,为此需要捕获按钮在测试消息期间按下的特殊情况。幸运的是,这种情况立即发生。如你所见,即使在这种情况下,测试消息也完整发出,Blinky2线程没有抢占低优先级的Blinky3。

现在看一下相应的时序图。首先,比较选择性调度锁定和优先级上限互斥锁。主要区别在于,在时间A,低优先级的Blinky3线程被提升到互斥锁的上限优先级。从那时起,Blinky3以上限优先级运行,这保护它不被Blinky2甚至Blinky1抢占。然而,归根结底,仅从线程执行来看(因为受保护的代码中没有阻塞),调度锁定和优先级上限互斥锁的时序图是相同的。

刚刚看到的QXK优先级上限互斥锁实现了所谓的优先级上限协议。但这并不是防止无界优先级反转的唯一方式。事实上,大多数RTOS使用不同的策略实现互斥锁,称为优先级继承协议。因此,尽管我无法展示工作示例(因为QXK不支持优先级继承),但我想简要解释两种策略之间的差异,并指出它们各自的优缺点。

在时序图中看不到的第一个区别是,优先级继承互斥锁不需要像上限优先级那样用任何特定优先级初始化,因为它会自动调整线程优先级。这导致了图中时间A的第一个区别,优先级上限协议立即将线程提升到上限优先级,但优先级继承在此时尚未改变线程优先级。因此,中优先级的Blinky2线程在按钮按下后立即抢占Blinky3。低优先级线程只有在高优先级线程开始运行并尝试锁定优先级继承互斥锁后才被提升,具体来说,低优先级线程继承了资源的高优先级竞争者的优先级。从那时起,时序图非常相似,但由于最初被中优先级线程抢占,高优先级线程在优先级继承下运行并完成得更晚,因此错过截止时间的风险更高。

优先级继承的另一个大缺点是,它通常导致比优先级上限多得多的昂贵上下文切换。例如,在刚刚讨论的场景中,优先级上限只导致两次上下文切换,而优先级继承使用了四次。

由于所有这些原因,以及硬实时系统更简单的时序分析,优先级上限协议是首选策略,这就是为什么它是QXK中唯一支持的策略。

课程总结

在本节课中,我们学习了资源共享以及RTOS内核中常见的互斥机制。这个主题很复杂,本节课仅仅触及了可能遇到的问题的表面。如果你想了解更多,视频描述下方提供了一些关于该主题的优秀文章链接。

基于传统抢占式RTOS的共享状态并发编程模型自20世纪80年代以来一直是主导方法,但它有几个负面影响。资源共享的一阶连锁效应是时间域和数据空间中的竞争条件和冲突。如果不加以保护,这些后果直接导致系统故障。为了避免此类故障,传统RTOS提供了一系列互斥机制来保护共享资源,但每种机制都会导致自身的负面影响。这些二阶影响大多对系统的实时性能产生负面影响,并可能导致错过截止时间,这在硬实时系统中再次意味着故障。

但共享状态并发模型不再是市场上的唯一选择。20世纪90年代带来了替代的实时软件架构,这些架构避免了资源共享,从而消除了对复杂互斥机制的需求。我将在未来的课程中讨论这些更现代的方法。

但在进入该主题之前,下节课我仍然需要讨论20世纪80年代发生的一个非常重要的进展,即面向对象编程。

29:面向对象编程第一部分 - C与C++中的封装(类)

在本节课中,我们将要学习面向对象编程的基础概念,特别是封装。我们将了解如何在不使用特定面向对象语言的情况下,通过C语言实现类的概念,并最终将其与C++中的原生类实现进行对比。通过这种方式,你将理解封装的核心思想及其在底层是如何工作的。


概述

面向对象编程是现代软件开发的重要基石。许多人认为它必须使用C++、Java或Python等语言,但实际上,OOP是一种基于封装继承多态三大概念的软件设计方法。本节课,我们将重点探讨封装,并学习如何在标准C语言中模拟类,以及如何在C++中实现真正的类。

上一节我们回顾了实时操作系统及其基于共享状态的并发编程模型。本节中,我们来看看如何将数据和操作封装到一个实体——类中。

封装的概念

封装与信息隐藏和抽象密切相关。你已经在板级支持包的设计中使用过这些概念。例如,在BSP模块中,你将“做什么”(头文件)与“如何做”(实现文件)分离开来,抽象了LED的操作细节,并将具体的实现方式对用户隐藏。

然而,这种基于模块的设计有一个重要限制:它只能处理固定数量的资源(如三个LED),难以轻松扩展以处理数量不定的对象(例如图形界面中的各种形状)。为了解决这个问题,我们需要引入类的概念。

在C语言中模拟类

在C语言中,我们可以使用结构体和一组相关的函数来模拟一个类。

以下是创建一个“形状”类接口的步骤:

  1. 定义数据结构:使用C结构体表示形状的属性,例如屏幕上的位置坐标。

    typedef struct {
        int16_t x;
        int16_t y;
    } Shape;
    
  2. 定义操作函数:提供一组专门操作Shape结构体的函数。通过编码约定,禁止直接访问结构体成员,所有操作必须通过这些函数进行。

    • 构造函数:用于初始化形状对象。
    • 移动函数:根据给定的偏移量移动形状。
    • 距离计算函数:计算两个形状之间的距离。

    这些函数都遵循两个约定:

    • 函数名以关联的结构体名称为前缀(例如Shape_ctor)。
    • 第一个参数是一个指向Shape结构体的指针(通常命名为me),用于指定函数操作的是哪个具体的形状实例。

    这个me指针对应于C++中的隐式this指针。

  3. 实现操作函数:在.c文件中实现这些函数的具体逻辑。

通过这种方式,我们创建了一个。类将数据(称为属性)和函数(称为操作)组合成一个实体。类图可以直观地展示一个类,方框顶部是类名,中间是属性,底部是操作。

类实现了封装,因为它只向外部展示操作构成的外壳,而内部数据和实现细节则被封装在壳内。

创建和使用对象

类就像饼干模具,可以用来创建任意数量的实例,这些实例称为对象

在C语言中,我们可以通过多种方式创建Shape对象:

  • 静态分配
  • 在函数栈上自动分配
  • 在堆上动态分配(使用malloc

创建对象后,必须在使用前调用构造函数进行初始化。之后,就可以通过相应的操作函数来操作这些对象了。

一个良好的类接口设计原则是:易于正确使用,难以错误使用。例如,尝试对声明为constShape对象调用修改其状态的moveBy操作,编译器会报错。

底层机制与调试

在底层,调用类操作(如构造函数)时,me指针通常会被放在R0寄存器中(遵循ARM AAPCS标准)。在操作函数内部,访问类属性是通过me指针的寄存器偏移寻址模式完成的,效率很高。

在调试时,me指针(或C++中的this指针)通常显示在局部变量窗口的顶部,方便开发者查看当前操作的对象属性。

在C++中实现类

C++直接支持类的概念,使得封装更加直观和严格。

将C模拟的类转换为真正的C++类涉及以下步骤:

  1. struct关键字替换为class
  2. 使用private:public:访问说明符来明确封装数据成员和公开成员函数。
  3. 构造函数名与类名相同,且没有返回值。C++会自动调用构造函数。
  4. 成员函数不再需要显式的me指针参数,编译器会提供隐式的this指针。
  5. 使用作用域解析运算符::来定义类成员函数。
  6. 使用构造函数初始化列表来初始化成员变量,这是更地道的C++方式。

在代码中使用C++类时:

  • 对象初始化在创建时直接完成(例如 Shape s1(1, 2);)。
  • 动态对象使用new操作符分配,并自动调用构造函数;使用delete释放。
  • 通过点运算符.或箭头运算符->来调用成员函数。

尽管语法不同,但C++编译器为这些操作生成的机器代码,与之前C语言模拟版本生成的代码是完全相同的。这表明,C++的类机制在底层本质上是一套语法糖和严格的类型检查。

封装与并发(RTOS)

封装本身并不能解决并发环境下的数据竞争问题。即使将共享对象(如一个全局的Shape对象)封装在类中,当它被多个RTOS线程(一个修改,一个读取)同时访问时,依然会发生竞态条件

这意味着,为了实现真正的并发安全封装,需要超越简单的类,采用主动对象设计模式。主动对象模式融合了并发编程、面向对象编程和事件驱动编程,这将在未来的课程中探讨。


总结

本节课中我们一起学习了面向对象编程的第一个核心概念——封装。我们探讨了如何通过结构体和相关函数在C语言中模拟类,并详细对比了在C++中实现真正类的语法和机制。我们看到,尽管语法不同,但两者的底层实现原理是相通的。重要的是,我们理解了封装主要是为了组织代码和隐藏实现细节,它本身并不解决并发访问的同步问题。在接下来的课程中,我们将继续学习面向对象编程的另外两个支柱:继承多态

30:面向对象编程第二部分 - 继承

在本节课中,我们将要学习面向对象编程的第二个核心概念:继承。我们将探讨如何在C语言中模拟继承,以及C++语言如何原生支持继承,并理解其背后的内存布局和设计思想。

概述

在上一节课中,我们介绍了面向对象编程的第一个概念——封装,并实现了表示LCD屏幕上图形的Shape类。然而,实际应用中我们需要更具体的图形,如矩形、圆形等。这些具体图形都拥有Shape类的通用属性(如位置)和操作(如移动)。为了避免重复代码,我们可以使用继承来基于已有的Shape类创建新的类。

在C语言中模拟继承

以下是实现Rectangle类以继承Shape类的步骤。

1. 创建头文件接口

首先,在rectangle.h头文件中定义Rectangle类。我们通过将Shape结构体作为第一个成员来模拟继承。

#ifndef RECTANGLE_H
#define RECTANGLE_H

#include "shape.h" // 包含基类定义

// Rectangle 属性结构体
typedef struct {
    Shape super; // 继承自 Shape 的成员(按约定命名为 super)
    int16_t width;
    int16_t height;
} Rectangle;

// 构造函数原型
void Rectangle_ctor(Rectangle * const me, int16_t x0, int16_t y0,
                    int16_t w0, int16_t h0);

// Rectangle 特有的操作
void Rectangle_draw(Rectangle const * const me);
uint32_t Rectangle_area(Rectangle const * const me);

#endif // RECTANGLE_H

2. 实现源文件

接着,在rectangle.c源文件中实现这些函数。构造函数需要先初始化基类部分。

#include "rectangle.h"

// Rectangle 构造函数
void Rectangle_ctor(Rectangle * const me, int16_t x0, int16_t y0,
                    int16_t w0, int16_t h0)
{
    // 首先调用基类 Shape 的构造函数
    Shape_ctor(&me->super, x0, y0);
    // 然后初始化 Rectangle 特有的属性
    me->width = w0;
    me->height = h0;
}

// 计算矩形面积
uint32_t Rectangle_area(Rectangle const * const me)
{
    return (uint32_t)me->width * (uint32_t)me->height;
}

// 绘制矩形(此处为伪代码)
void Rectangle_draw(Rectangle const * const me)
{
    // 伪代码:在LCD上绘制水平线和垂直线
    // drawHLine(me->super.x, me->super.y, me->width);
    // drawVLine(me->super.x, me->super.y, me->height);
    // ...
}

3. 使用Rectangle类

现在,我们可以在主程序中使用Rectangle类。由于C语言没有自动向上转型,我们需要显式地将Rectangle指针转换为Shape指针来调用继承的操作。

#include "rectangle.h"

int main() {
    Rectangle r1;
    // 初始化矩形对象
    Rectangle_ctor(&r1, 10, 20, 30, 40);

    // 调用Rectangle特有的操作
    Rectangle_draw(&r1);
    uint32_t a = Rectangle_area(&r1);

    // 调用从Shape继承的操作(需要显式向上转型)
    Shape_moveBy((Shape *)&r1, 5, 5); // 向上转型
    int16_t d = Shape_distanceFrom((Shape *)&r1, (Shape *)&r1);

    return 0;
}

4. 内存布局与向上转型

在C语言中,结构体的第一个成员的地址与结构体本身的地址相同。这意味着Rectangle结构体的起始地址就是其superShape类型)成员的地址。因此,将Rectangle*指针安全地转换为Shape*指针是可行的,这种转换称为向上转型

通过调试器查看内存,可以验证Rectangle对象的内存布局:首先是Shape的成员(x, y),紧接着是Rectangle特有的成员(width, height)。

在C++中实现继承

C++原生支持继承,语法更简洁,并且能自动处理向上转型。

1. 定义C++ Rectangle类

rectangle.h中,我们使用C++语法定义类。

#ifndef RECTANGLE_H
#define RECTANGLE_H

#include "shape.h" // 包含基类

class Rectangle : public Shape { // 公有继承自 Shape
private:
    int16_t width;
    int16_t height;

public:
    // 构造函数
    Rectangle(int16_t x0, int16_t y0, int16_t w0, int16_t h0);
    // Rectangle特有的操作
    void draw() const;
    uint32_t area() const;
};

#endif // RECTANGLE_H

2. 实现C++ Rectangle类

rectangle.cpp中实现成员函数。构造函数使用初始化列表来调用基类构造函数。

#include "rectangle.h"

// 构造函数:使用初始化列表调用基类构造函数
Rectangle::Rectangle(int16_t x0, int16_t y0, int16_t w0, int16_t h0)
    : Shape(x0, y0), // 调用基类构造函数
      width(w0),
      height(h0)
{
}

// 计算面积
uint32_t Rectangle::area() const
{
    return (uint32_t)width * (uint32_t)height;
}

// 绘制矩形
void Rectangle::draw() const
{
    // 可以直接访问从Shape继承的protected成员x和y
    // drawHLine(x, y, width);
    // drawVLine(x, y, height);
}

注意:为了使派生类能直接访问Shape的坐标xy,我们需要在Shape类中将它们声明为protected,而不是private

3. 使用C++ Rectangle类

在C++中,使用类更加直观,向上转型是自动的。

#include "rectangle.h"

int main() {
    // 创建并初始化Rectangle对象
    Rectangle r1(10, 20, 30, 40);

    // 调用Rectangle特有的操作
    r1.draw();
    uint32_t a = r1.area();

    // 调用从Shape继承的操作(自动向上转型)
    r1.moveBy(5, 5); // 无需显式转型
    int16_t d = r1.distanceFrom(r1);

    return 0;
}

继承的核心概念与类比

上一节我们介绍了如何在代码中实现继承,本节中我们来看看如何正确地理解继承关系。

理解继承时,应避免使用“家族树”的类比(如父类、子类),因为子类对象并不包含一个父类对象,而是本身就是一个父类对象。更准确的类比是生物分类学

例如:

  • 一只家猫(HouseCat是一只猫科动物(Felidae)。
  • 一只猫科动物是一个食肉动物(Carnivore)。
  • 一个食肉动物是一个哺乳动物(Mammal)。

高层类(如Mammal)定义的行为(如lactate哺乳)对低层类(如HouseCat)同样有意义。这就是“是一个”的关系。

继承与组合的区别

继承和组合(一个类包含另一个类的实例)都涉及代码复用,但有本质区别:

  • 继承:建立“是一个”关系(Rectangle 是一个 Shape)。派生类对象可以当作基类对象使用。
  • 组合:建立“有一个”关系(Car 有一个 Engine)。EngineCar的一部分,但Car不能当作Engine使用。

在C语言模拟中,只有将基类结构体作为派生类的第一个成员时,才能安全地进行向上转型,从而实现继承语义。如果将其放在其他位置,则只是组合。

总结

本节课中我们一起学习了面向对象编程的第二个核心概念——继承

我们了解到:

  1. 继承是一种代码复用机制,允许新类(派生类)基于已有类(基类)创建,并自动获得其属性和操作。
  2. C语言中,可以通过将基类结构体作为派生类结构体的第一个成员来模拟继承,并需要手动进行指针的向上转型。
  3. C++语言原生支持继承,语法简洁(使用:),并能自动处理向上转型,使代码更清晰。
  4. 继承建立了类之间的“是一个”关系,这与组合的“有一个”关系不同。
  5. 通过调试查看内存布局,我们验证了派生类对象在内存中起始部分就是其基类部分。

继承不仅实现了代码复用,还为下一个强大的OOP概念——多态——奠定了基础,这将是下一节课的主题。

31:C++中的多态性

在本节课中,我们将学习面向对象编程的核心概念之一:多态性。我们将探讨它在C++中如何工作,以及其背后的实现机制。

与之前讨论的封装和继承不同,多态性是纯粹的面向对象概念,在像C这样的传统过程式语言中没有直接对应物。因此,今天的计划与之前两节课相反:我们将首先了解什么是多态性以及它在C++中如何工作,然后基于这些知识,再来看如何在C语言中模拟它。

从继承到多态接口

在上一节课中,我们学习了类继承的概念,并创建了一个Rectangle类,它继承了Shape基类的所有属性和操作。但Rectangle也添加了自己的操作,例如在LCD屏幕上绘制矩形的draw操作和计算矩形表面积的area操作。

然而,这两个新增的操作对于Shape基类同样有意义,因为任何形状都可以被绘制在屏幕上并拥有表面积。唯一的问题是Shape类过于通用,在这个高层级上我们无法真正知道如何实现drawarea操作。

面向对象编程的核心在于分离接口(可以做什么)和实现(如何做)。本着这种精神,Shape类至少可以提供drawarea操作的接口,而将实现细节留到以后处理。

实现多态性

首先,我们简单地将areadraw的函数签名从Rectangle子类复制到Shape超类。同时,我们也从Rectangle复制这些操作的实现并适配到Shape。当然,在通用的Shape类高层级上,我们无法提供真正的实现,因为我们还不知道处理的是矩形、圆形、三角形还是线条。因此,我们只提供一些虚拟代码。

现在,当测试这个新的Shape接口时,我们利用上一节课学到的向上转型——将派生类指针转换为基类指针。在C++等面向对象语言中,这种向上转型总是安全的,并且会自动进行。

这开启了一个有趣的可能性:将指向Rectangle对象r1的指针向上转型为指向Shape的指针ps,然后在这个向上转型的指针上调用drawarea操作。编译无误后,在调试器中检查这种情况下调用了哪个操作。你会发现,调用的是Shape的实现,这并不奇怪,因为编译器根据指针的类型(Shape*)而非对象的类型(Rectangle)来解析函数。

但是,如果我们修改Shape类的声明,在drawarea操作前添加virtual关键字,情况就不同了。再次调试,你会发现这次调用的是Rectangle的实现。这意味着,调用现在根据对象的类型(Rectangle)而非指针的类型(Shape*)来选择实现。这就是多态性在起作用。

术语“多态性”本身源于希腊词根:poly(意为“多”)和morph(意为“形”)。因此,多态性意味着多种形式。确实,这里发生的情况是:同一个操作(如drawarea)可以根据指针所指向对象的类型而采取多种形式。

多态性的实际应用

多态性在实践中有什么用呢?它允许你在比没有它时更高的抽象层次上编写通用代码。

例如,假设你想在LCD屏幕上绘制一个由多个形状组成的图形。为简化起见,假设图形是一个指向各种形状对象的指针数组。现在,图形中的Shape指针可能实际指向不同的类型,但由于它们都继承自Shape基类,因此都可以安全地向上转型为Shape类型。

你可以在Shape类层级声明一个通用函数draw_graph,并在shape.cpp中实现它。draw_graph函数简单地遍历图形数组中的形状指针,直到遇到空指针。实现的关键点是graph[i]->draw()调用,它利用多态性根据对象的类型(而非明显是Shape*的指针类型)来正确选择draw实现。

方法重写与虚函数

在添加了virtual关键字后,编译器会提示Rectangle类中的drawarea声明隐式继承了virtual属性。这是因为多态性意味着Rectangle中的drawarea操作并非完全新增的操作;相反,它们仅仅是Shape基类中已指定并继承下来的drawarea操作的不同形式或不同方法。

引入一些面向对象术语:方法描述了同一操作的不同形式,子类中的不同方法被称为重写了从基类继承的原始方法。

回到代码,编译器注意到Rectangle中的drawarea操作只是基类中已声明操作的不同方法。作为同一操作的不同方法,它们自动成为virtual(即多态的),因此编译器强烈建议将这些方法显式声明为virtual。确实,在Rectangle子类的drawarea声明前添加virtual关键字可以消除警告。

扩展设计:添加Circle类

为了让你充分体会到基于多态性的设计的可扩展性,让我们创建另一个Shape的子类,例如CircleCircle子类与Rectangle类似,因此我们复制相关文件并进行适配。

在头文件中,重命名包含保护符和类名。属性方面,将widthheight替换为圆的radiusCircle构造函数以r0作为半径的初始值。Circle类还需要为从Shape继承的drawarea操作提供自己专门的方法,因此virtual声明保持不变。

在实现文件中,包含circle.h并再次替换类名。构造函数接收r0参数并用它初始化radius属性。Circledraw方法将调用原始的draw_ellipse函数。最后,Circlearea方法将计算圆的面积,公式为π乘以半径的平方。为简单起见,这里我们仅使用整数运算,并将π近似为3。

完成Circle类后,最后一步是在main.cpp中测试它。我们包含circle.h头文件,实例化一个具有给定xyradius值的Circle对象c1。然后将指向c1的指针添加到图形数组中,这样Circle对象c1就成为随后由draw_graph函数绘制的图形的一部分。

构建项目时,请注意只有circle.cppmain.cpp被重新编译,而包含draw_graph算法的shape.cpp并未重新编译。这意味着最终的可执行映像使用了在引入Circle类之前编译的draw_graph实现。当你运行程序并步入draw_graph函数时,可以看到它正确地调用了Circledraw方法(this指针指向c1对象),而其他的graph[i]->draw()虚调用则为Rectangle r1Shape ps3调用了正确的方法。这展示了多态性的威力和可扩展性。

虚函数调用机制

虚函数调用机制肯定与常规函数调用大不相同。为了比较两者,让我们在虚调用前添加一个常规函数调用。

请注意,在完整对象(如Rectangle r1)上调用的操作始终是常规的非虚函数调用,即使该操作本身被声明为virtual。这是因为对象的精确类型在编译时是已知的。顺便说一下,函数调用和函数体之间的连接称为调用绑定

在编译和链接时建立的连接称为早期绑定;这是像C这样的过程式语言中使用的唯一调用绑定。但在C++中,在指针(如这里的ps)上调用的虚操作建立了一种不同的调用绑定,因为对象的精确类型在编译时是未知的(指针可能是从派生类向上转型而来的)。这种类型的绑定称为晚期绑定动态绑定实时绑定

构建项目并将断点移到早期绑定调用处,最终看看它与晚期绑定有何不同。在反汇编视图中,你可以看到早期绑定调用包括将this指针的值移动到r0,然后使用带链接的分支指令BL来调用硬编码在BL指令本身的Rectangle::draw函数。

而晚期绑定的机器代码则显著不同。它首先从R6加载一个32位字(稍后用作r0中的this指针),因此这个32位字必定来自this对象。随后,这个字被用作基地址,所以来自this对象的这个字本身就是一个指针。等等,你肯定没有向ShapeRectangle类添加任何指针属性,所以让我们仔细看看RAM中的这个指针。

确实,你可以识别出从Shape继承的16位属性xy,以及Rectangle中添加的widthheight,但现在所有这些前面都有一个指针——这显然是在Shape基类中引入虚函数时由C++编译器添加的。因此,Rectangle对象的总体大小现在增加了一个指针。

关于Rectangle对象内部的这个指针,它看起来像是RAM中的一个地址。让我们在内存视图中查看这个地址。当你将内存视图切换到32位数量时,我希望你能认出,该地址处的第一个字非常类似于Rectangle::draw函数的地址(之前是0x250C)。实际上,它就是那个地址加1。

正如在本视频课程早期课程中多次解释的,Cortex-M中的程序地址存储为奇数,因为最低有效位不用于寻址,而是始终设置为1以表示CPU的Thumb状态。简而言之,该地址处的第一个字就是Rectangle::draw函数的地址。

那么该地址处的第二个字呢?让我们在反汇编窗口中查找它(记住要从地址中减去1)。这确实是Rectangle::area方法的地址,它是Rectangle类的第二个虚函数。

所有这些逆向工程可以总结如下:编译器在类属性的开头添加了一个指针,该指针指向RAM中的一个表,该表包含所有虚函数的地址。在文献中,虚函数表被称为vtable,存储在对象中指向它的指针被称为vptr

继续调试,你可以看到Rectangle::draw函数的地址从vtable加载到R1中,最后this指针在r0中设置。实际的函数调用由分支带链接和交换指令BLX R1执行,该指令跳转到R1中提供的地址。确实,当你步入BLX指令时,最终会进入Rectangle::draw方法。在这里检查this指针,你实际上可以看到对象的第一个属性(从Shape继承的切片)就是vptr。

开销与实现细节

总而言之,与早期绑定相比,晚期绑定的开销是:RAM中每个对象额外增加一个vptr,RAM中每个类一个vtable,以及每次函数调用多两条指令。对于所获得的功能来说,这并不是很大的开销,但仍然不可忽略,因此C++仅将此机制应用于显式指定为virtual的函数。这是为了防止不需要晚期绑定的函数产生开销。

其他面向对象语言(例如Java)将多态性视为如此基本的功能,以至于默认情况下所有函数都是virtual的。另外,请注意vptr/vtable双重间接寻址只是晚期绑定的一种可能实现,C++标准将其完全开放给编译器设计者。尽管如此,实际上所有针对各种CPU的现有C++实现都使用了你刚刚看到的vptr/vtable实现。

那么,今天剩下的最后一个问题是:谁设置了vptr?我的意思是,每个类在RAM中的常量vtable可以像代码本身一样轻松添加,但vptr需要在每个类的每个实例中设置,而这些实例通常在运行时分配(例如,作为函数中的自动对象)。

我希望此时你能猜到,建立vptr的理想位置是类的构造函数。当然,你在C++代码中没有对vptr做任何处理,这意味着C++编译器必须秘密地合成一些代码来为你完成这项工作。

作为一名嵌入式工程师,我相信你一定特别好奇任何这样的秘密代码。因此,让我们在Rectangle构造函数中设置一个断点并启动调试器。断点立即被命中,因为有一个静态分配的Rectangle实例,它甚至在main之前就被初始化了。当你展开this指针时,可以看到vptr仍未初始化。

步入后,你最终进入Shape的构造函数,它是从Rectangle构造函数的初始化列表调用的。此时this指针的类型变为Shape,但vptr仍未初始化。查看反汇编,你可以看到两行机器代码,它们显然设置了vptr。但是,等等,它指向哪个vtable?让我们从内存视图中检查vtable。例如,取表中的第二个条目并在反汇编中定位它。如前所述,你需要从vtable中的地址减去1,所以你正在寻找地址0x000024EA。你最终进入Shape::area方法,这意味着该vtable属于Shape类。

因此,在初始化的这个阶段,Rectangle实例仍被视为其Shape基类。但让我们继续逐步执行Shape属性的初始化,并返回到Rectangle的构造函数。在这里的汇编代码中,你遇到了类似的指令,它们显然将vptr重新初始化为一个不同的值。再次,让我们通过检查其第二个条目从内存视图中找出这是哪个vtable。现在你正在寻找地址0x00002502。你最终进入Rectangle::area方法,这意味着vtable现在属于Rectangle类。

如你所见,vptr在每个构造函数中被初始化并重新初始化,以指向该类的vtable。但构造函数调用的顺序是:超类总是在子类之前初始化,因此vptr最终指向最后一个派生类,这是正确的。

本节课中,我们一起学习了面向对象编程的核心概念——多态性。我们探讨了它在C++中的工作原理,包括虚函数、vtable和vptr的机制,以及早期绑定与晚期绑定的区别。多态性允许我们编写更通用、可扩展的代码,是构建复杂嵌入式系统的重要工具。在下一节课中,你将通过用C语言实现晚期绑定来测试对虚函数的理解。

32:C语言中的多态性

在本节课中,我们将继续学习面向对象编程,重点是多态性。与上一节在C++中探讨不同,本节我们将学习如何在符合标准的、可移植的C语言中手动实现多态性。这将巩固你对C++虚函数的理解,并揭示虚函数表(VTable)实现的一些额外细节。本节还将提供在嵌入式软件中使用面向对象编程的一些通用原则和指南。

概述

上一节我们学习了多态性的概念,即子类可以为从基类继承的同一操作提供不同的方法实现。我们了解到,在C++中,这是通过虚函数虚函数表机制实现的,它允许在运行时根据对象的实际类型(而非指针的类型)来解析调用,这被称为后期绑定

本节中,我们将一步步在C语言中模拟这一机制,包括如何声明虚函数表、设置虚函数指针(VPTR)以及实现后期绑定的调用。我们还将讨论何时应该以及何时不应该使用多态性。

C语言中实现多态性的步骤

以下是实现C语言多态性的核心步骤,我们将以Shape基类和Rectangle子类为例进行说明。

1. 在基类结构中添加虚函数指针(VPTR)

首先,我们需要在基类(如Shape)的属性结构体中显式添加一个指向虚函数表的指针作为第一个成员。

/* shape.h */
typedef struct ShapeVTable ShapeVTable; // 前向声明

typedef struct {
    ShapeVTable const *vptr; /* 虚函数表指针,指向常量区 */
    int16_t x;
    int16_t y;
} Shape;

这里我们使用了ShapeVTable结构体的前向声明,因为编译器此时只需要知道vptr是一个指针的大小。

2. 定义虚函数表(VTable)结构

虚函数表本质上是一个结构体,其成员是指向该类所有虚函数的函数指针。

/* shape.h */
struct ShapeVTable {
    void (*draw)(Shape const * const me);
    uint32_t (*area)(Shape const * const me);
};

声明函数指针时,需要写出完整的函数签名,并用括号将指针名(如*draw)括起来,以确保正确的运算符优先级。

3. 实现虚函数调用机制(后期绑定)

为了让用户能够调用虚函数,我们需要提供调用函数。有三种方式可以实现,推荐使用内联函数以降低开销。

方式一:普通成员函数(有调用开销)

/* shape.c */
void Shape_draw_vcall(Shape const * const me) {
    (*me->vptr->draw)(me);
}
uint32_t Shape_area_vcall(Shape const * const me) {
    return (*me->vptr->area)(me);
}

方式二:内联函数(C99标准,推荐)
将函数定义移到头文件中,并添加static inline关键字,编译器会尝试内联展开,消除函数调用开销。

/* shape.h */
static inline void Shape_draw_vcall(Shape const * const me) {
    (*me->vptr->draw)(me);
}
static inline uint32_t Shape_area_vcall(Shape const * const me) {
    return (*me->vptr->area)(me);
}

使用此方式需确保编译器支持C99模式。

方式三:宏(兼容C89)
对于不支持内联的老式编译器,可以使用宏,但类型安全性较差。

#define Shape_draw_vcall(me_) ((*(me_)->vptr->draw)((me_)))

无论哪种方式,其核心逻辑都是:通过对象的me指针找到其vptr,再通过vptr找到正确的函数指针并进行调用。me指针被使用了两次,确保了调用是针对具体对象而非指针类型的。

4. 初始化基类的虚函数表并设置VPTR

虚函数表应作为常量存储在ROM中,并在基类的构造函数中初始化。

/* shape.c */
#include “shape.h”

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/qleap-mdn-embsys-prog/img/904f985effe11bd10bc61ac6da1cf1b0_6.png)

/* 1. 声明虚函数(仅在本模块使用) */
static void Shape_draw(Shape const * const me);
static uint32_t Shape_area(Shape const * const me);

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/qleap-mdn-embsys-prog/img/904f985effe11bd10bc61ac6da1cf1b0_8.png)

/* 2. 定义并初始化常量虚函数表 */
static ShapeVTable const shape_vtable = {
    &Shape_draw,  /* 显式使用取地址运算符 */
    &Shape_area
};

/* 3. 基类构造函数 */
void Shape_ctor(Shape * const me, int16_t x, int16_t y) {
    me->vptr = &shape_vtable; /* 设置VPTR指向Shape的VTable */
    me->x = x;
    me->y = y;
}

/* 4. 基类虚函数实现(可能是空的或默认行为) */
static void Shape_draw(Shape const * const me) {
    (void)me; /* 避免未使用参数警告 */
    /* Shape太抽象,无法绘制 */
}
static uint32_t Shape_area(Shape const * const me) {
    (void)me;
    return 0U;
}

5. 在子类中重写虚函数表

每个子类都需要提供自己的虚函数表,并在其构造函数中重写从基类继承来的VPTR。

/* rectangle.c */
#include “rectangle.h”
#include “shape.h” /* 为了使用ShapeVTable类型 */

/* 1. 定义并初始化子类常量虚函数表 */
static ShapeVTable const rectangle_vtable = {
    (void (*)(Shape const * const))&Rectangle_draw,   /* 需要强制类型转换 */
    (uint32_t (*)(Shape const * const))&Rectangle_area
};

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/qleap-mdn-embsys-prog/img/904f985effe11bd10bc61ac6da1cf1b0_10.png)

/* 2. 子类构造函数 */
void Rectangle_ctor(Rectangle * const me,
                    int16_t x, int16_t y,
                    uint16_t width, uint16_t height)
{
    /* 首先调用基类构造函数 */
    Shape_ctor(&me->super, x, y);
    /* 然后重写VPTR,指向Rectangle自己的VTable */
    me->super.vptr = &rectangle_vtable;

    /* 初始化子类特有属性 */
    me->width = width;
    me->height = height;
}

/* 3. 子类虚函数的具体实现 */
void Rectangle_draw(Rectangle const * const me) {
    /* 绘制矩形的具体代码 */
}
uint32_t Rectangle_area(Rectangle const * const me) {
    return (uint32_t)me->width * (uint32_t)me->height;
}

关键点在于,子类的函数指针签名(接收Rectangle*)需要强制转换为基类VTable期望的签名(接收Shape*)。并且,必须在调用基类构造函数之后再重写vptr,因为基类构造函数会将其设置为自己的VTable。

6. 使用多态性

现在,我们可以像在C++中一样,使用基类指针来调用子类特有的方法了。

/* main.c */
#include “shape.h”
#include “rectangle.h”

void drawGraph(Shape const *graph[]) {
    uint32_t i;
    for (i = 0U; graph[i] != NULL; ++i) {
        Shape_draw_vcall(graph[i]); /* 多态调用! */
    }
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/qleap-mdn-embsys-prog/img/904f985effe11bd10bc61ac6da1cf1b0_12.png)

int main() {
    Shape s1;
    Rectangle r1;
    Shape *graph[4];

    Shape_ctor(&s1, 10, 20);
    Rectangle_ctor(&r1, 5, 5, 30, 40);

    graph[0] = &s1;        /* 基类对象 */
    graph[1] = (Shape*)&r1; /* 子类对象,需要向上转型 */
    graph[2] = NULL;

    drawGraph(graph); /* 将调用Shape_draw和Rectangle_draw */
    return 0;
}

多态性的替代实现与选择

我们上面实现的VPTR/VTable模式是仿照C++的经典方式。在文献和网络中,还存在其他技术。

一种常见的替代方案是将整个VTable嵌入每个对象,而不是通过指针间接引用。这样做的好处是虚函数调用语法更简洁(如me->vtable.draw(me)),类似于C++的obj.draw()。但缺点是显著增加了RAM使用量,因为每个对象都包含一份VTable副本,且VTable位于RAM而非ROM中。当对象数量很多时,内存开销会很大。

在Bruce Powell Douglas所著的《Design Patterns for Embedded Systems in C》一书中,就详细介绍了这种实现方法。

何时使用与避免使用多态性

了解多态性的实现后,更重要的是知道何时应用它。

何时使用多态性:
当你发现代码中充斥着基于对象类型的switchif-else判断时,就应该考虑使用多态性。例如,一个传统的draw函数可能如下所示:

void draw_shape(Shape const *s) {
    switch(s->kind) { // kind是一个枚举类型
        case RECTANGLE: draw_rectangle((Rectangle*)s); break;
        case CIRCLE: draw_circle((Circle*)s); break;
        // ... 每增加一种新形状,都要修改这里
    }
}

这种代码难以维护,添加或删除形状类型需要修改所有相关判断点。而使用虚函数,只需定义新的子类并实现其虚函数,所有通过基类指针进行的调用都会自动适配,无需修改现有代码,实现了“开闭原则”。

何时避免使用多态性:
当对象类型的选择不需要在运行时决定时,就不应使用多态性。一个典型的误用场景是管理一系列相似但略有不同的产品线(例如不同型号的医疗设备)。

如果每个产品最终只包含其专属的代码,那么根据产品类型进行的选择应该在编译时/链接时完成,而不是运行时。更好的方法是使用清晰的抽象接口(如板级支持包BSP),然后为每个产品提供不同的实现。通过物理设计(代码的目录和文件组织),在链接时为特定产品组合不同的模块。John Lakos的著作《Large-Scale C++ Software Design》对物理设计有极佳的阐述。

滥用运行时多态性来处理本应在编译时解决的问题,会导致代码充满条件逻辑,变得难以管理和测试。

总结

本节课我们一起学习了如何在C语言中手动实现面向对象编程中的多态性。我们详细探讨了以下内容:

  1. 通过添加虚函数指针(VPTR) 和定义虚函数表(VTable) 来模拟C++的机制。
  2. 实现了后期绑定的虚函数调用,并比较了内联函数与宏等不同实现方式的优劣。
  3. 在子类构造函数中重写VPTR以指向其自身的VTable,从而确保多态行为。
  4. 通过一个通用的drawGraph函数演示了多态性的强大之处。
  5. 分析了另一种将VTable嵌入对象的实现方案及其权衡。
  6. 最后,我们讨论了多态性的适用场景,指出其应用于需要运行时行为选择的情况,并警告了在产品线管理等编译时即可确定类型的场景中误用的风险。

通过本课,你不仅加深了对多态性底层机制的理解,也获得了在嵌入式C项目中应用或判断是否应用这一重要特性的实用指南。下一节课,我们将回归现代嵌入式系统编程的主线,开始学习在20世纪80年代成为主流的另一个重要趋势:事件驱动编程

33:事件驱动编程第一部分 - GUI示例

在本节课中,我们将要学习事件驱动编程的核心概念。我们将以图形用户界面(GUI)为例,探讨这种编程范式为何产生、如何工作,以及它与传统顺序编程的根本区别。通过分析一个简单的Windows应用程序,我们将理解事件循环、消息处理、异步事件传递以及控制反转等关键思想。

上一节我们介绍了课程概述,本节中我们来看看事件驱动编程的起源。

图形用户界面的兴起与编程挑战

图形用户界面在20世纪80年代的个人计算机革命中成为主流。从编程角度看,最具影响力的发明之一是20世纪60年代中期出现的鼠标。

但直到十年后的70年代中期,施乐帕洛阿尔托研究中心的工程师们才解决了如何实际实现这些想法的技术细节。

鼠标是一种指向设备,可以在显示屏上移动光标。大约在1973年,施乐帕洛阿尔托研究中心开发的Alto计算机引入了窗口概念,被选中的窗口会显示在其他窗口之上,就像将一张纸放在桌上一叠纸的最上面。

与当时基于命令行(如电传打字机或终端)的系统相比,图形用户界面的实现需要一次范式转变。

以下是命令行系统中的主要问题:唯一的输入设备是键盘,唯一的输出设备是屏幕底部,它会像电子电传打字机一样向上滚动。

因此,正如我在这段伪代码中试图说明的,软件仍然可以保持传统的顺序结构。基本上,软件等待按键,然后将字符回显到屏幕,接着处理按键,这可能会导致更多的屏幕输出。不存在“在哪里输出”的问题,因为它总是输出到屏幕底部。

但对于图形用户界面,情况则根本不同。首先,你现在有多个输入源:键盘和鼠标。

这是一个问题,我再次尝试用一段伪代码来说明。如果你明确等待键盘输入,那么你对鼠标输入就没有响应,反之亦然。所以,首先你需要找到一种方法,能够同时等待多个输入。

但假设你已经解决了这个阻塞调用的问题,接下来你必须检查多个输入中实际接收到的是哪一个。此外,对于键盘,你不再知道输出应该显示在屏幕的哪个位置,因为你必须知道屏幕的哪个部分是活动的(在今天的GUI编程中称为键盘焦点)。而对于鼠标,问题则更加根本。鼠标提供二维输入,即屏幕上的X和Y坐标,以及鼠标按钮的状态。

但原始坐标不足以采取有意义的行动。你还需要知道屏幕上这些坐标处是哪个对象。为此,你通常会调用GUI系统的服务。但由于这需要对每个鼠标输入都发生,你完全可以跳过这一步,总是让GUI系统根据当前鼠标坐标来查找对象。

这实际上意味着鼠标可以产生更多种类的输入,例如对象ID(如这段伪代码所示)。所有这些都取决于屏幕上不断变化的情况。

我希望你开始看到,图形用户界面引入了新的复杂度层级,这与命令行界面不在同一个层次上。事实上,GUI编程完全是另一种游戏。因此,GUI编程需要不同的思维方式也就不足为奇了。

实现可行解决方案的关键见解是聚焦于输入,这些输入被称为事件消息,例如按键、鼠标移动以及来自屏幕上对象(如按钮、桌面图标、滚动条等)的次级输入。聚焦于事件意味着事件驱动软件,而不是像顺序编码的命令行程序那样反过来。

为了理解这种事件驱动范式的真正含义及其工作原理,我准备了一个在Windows上运行的简单“Hello Win” GUI应用程序。

上一节我们探讨了GUI带来的挑战,本节中我们来看看一个具体的事件驱动程序示例。

一个简单的事件驱动Windows应用程序

你可以从本视频课程的配套网页 statemachine.com/quickstart 下载第33课的项目。在项目目录中,我点击了 HelloWin.sln 解决方案文件,在 Visual Studio 2019 Community 版中打开它。

即使开发环境相当现代,该软件也是用C语言编写的,使用的是微软在20世纪80年代开发的古老的Windows应用程序编程接口(API)。事实证明,与其他编程语言中更现代的API不同,这种用C语言编写的低级Win32 API以最简单、最直接的形式展示了事件驱动编程的核心概念。

让我们浏览一下这个简单的Windows应用程序,它是我根据Charles Petzold所著《Programming Windows》一书中的“Hello Windows”程序改编的。这本书于1988年首次出版,在当时成为了Windows编程的圣经。

代码从包含 Windows.h 头文件开始,该文件定义了Windows API的类型和常量。

接下来,你可以看到 WndProc 函数的原型,它用于初始化一个“虚函数表”,我稍后会详细讨论。但首先是 WinMain 函数。这是Windows GUI应用程序的主入口点,其作用与传统C环境中的 main 函数相同,只是在GUI环境中,入口点需要更多参数,因为GUI应用程序更复杂。

在这个简单的HelloWin应用程序中,其中一些参数甚至没有使用。

但接下来是有趣的部分:准备要向Windows注册的窗口类。我特意对初始化部分进行了格式化和注释,以便你能认出这里你正在进行一种面向对象编程,正如我在上一节关于C语言中OOP的第32课中解释的那样。

具体来说,你首先看到的是设置窗口类实例 wndclass 的属性,例如窗口样式、窗口的鼠标光标和窗口类名。但接下来,你或许能认出这是一个“虚函数”的赋值:针对此窗口类的窗口过程 WndProc

在这里,Windows API设计者使用了我在上一节关于C语言中面向对象编程的课程中提到的简单实现技术:将指向虚函数的指针直接嵌入到属性结构中。

接下来是调用Windows来注册预定义的窗口类。最后,CreateWindow 调用扮演了构造函数的角色,因为它基于刚刚注册的窗口类创建了一个窗口对象。

应用程序窗口创建后,它被显示在屏幕上并更新。但在所有这些初始化之后,WinMain 函数进入了事件循环(也称为消息循环或消息泵),程序在这里执行其真正的工作。这是每个事件驱动程序中最重要的部分。

事件循环具有非常特定的结构,包含两个主要步骤。首先,调用 GetMessage Windows API会阻塞并等待来自键盘、鼠标或屏幕的任何输入。当任何此类事件发生时,Windows系统会将其记录为一个消息对象,并将其放入该应用程序的消息队列中。GetMessage 然后解除阻塞,并将消息从队列复制到 msg 对象。这就是事件循环解决同时等待多个事件问题的方式,正如我之前试图在伪代码中说明的那样。

如果从 GetMessage 返回的状态是0,则意味着应用程序已关闭。因此,在这种情况下,需要通过执行 break 语句来终止事件循环。

否则,消息将被传递给 DispatchMessage Windows API,然后由它调用为当前窗口注册的 WndProc

你稍后会看到这个简单HelloWin应用程序的 WndProc。但在离开这段代码之前,让我总结一下事件循环的关键属性。

以下是事件循环的关键属性列表:

  • 异步事件传递:事件循环使用特殊的消息对象来记录应用程序可能感兴趣的所有事件。这些消息对象仅用于通信,可以方便地存储在事件队列中,稍后检索处理。由于消息队列的存在,事件既可以在循环等待它们时传递,也可以在循环忙于处理先前事件时传递。在任何情况下,Windows系统只记录事件为消息并将其放入消息队列,但不会等待事件的实际处理。这种事件传递类型称为异步,意味着事件生产者(此处是Windows系统)独立于事件消费者(此处是你的应用程序)执行。换句话说,这两个活动是异步的,即不同步。
  • 运行至完成DispatchMessage 调用必须在循环处理下一个事件之前完成并返回到事件循环。这意味着事件处理以运行至完成(RTC)的步骤进行,不能被任何其他事件的处理中断。
  • 控制反转:事件循环会调用你的应用程序代码(WndProc)。这与你习惯的方式相反,因为在之前所有关于实时操作系统的经验中(例如第20至26课),是你的应用程序调用RTOS的服务。但现在,事件驱动的Windows系统正在调用你的应用程序。事件循环的这个属性导致了与传统顺序编程相比的控制反转。这是所有事件驱动系统的关键特征,也是事件驱动编程的本质。控制反转真正意味着事件驱动应用程序,而不是反过来。

现在让我们看看这个GUI应用程序的 WndProc。首先,要理解这个函数签名,你需要看看我之前跳过的消息结构的声明。MSG 结构定义在 Winuser.h 头文件中,该文件由 Windows.h 包含。如你所见,WndProc 四个参数的类型与 MSG 结构的前四个属性完全相同。

我只是将第一个参数从 HWND(窗口句柄)重命名为 me,以更清楚地表明 WndProc 是窗口类的一个成员函数。这是第30课关于在C中实现类时引入的成员函数命名约定。

实际上,正如你在 WinMain 中看到的,WndProc 不仅仅是一个成员函数。它是一个虚成员函数,特定于为此应用程序向Windows注册的窗口类类型。

我还将 WndProc 的第二个参数从 message 重命名为 sig,因为这是消息中告知你记录在消息中的事件种类的部分。在现代事件驱动编程中,此信息称为事件的信号。所以我将其命名为 sig

最后,最后两个参数 wParamlParam 是事件参数,提供有关记录事件的附加信息。这些参数的含义取决于事件信号。

WndProc 内部,主要工作是处理接收到的消息。这需要首先确定你正在处理哪种消息。为此,Windows程序员使用基于消息整数信号作为控制表达式的 switch 语句。

每个 case 语句代表需要根据该信号处理的不同消息类型。case 语句的标签是各种消息信号的符号名称,这些名称同样列在 Winuser.h 头文件中。例如,WM_CREATE 信号对应数值1,在窗口创建时发送给 WndProc。类似地,WM_DESTROY 对应数值2,在窗口即将销毁时发送给 WndProc。在后一种情况下,WndProc 调用 PostQuitMessage API,该API将 WM_QUIT 消息插入程序的消息队列中。这反过来又会导致事件循环终止。这是一个有趣的例子,向你展示了应用程序可以异步地向自身发布事件。

但在所有情况下,WndProc 还会设置局部变量 status,记录处理状态。例如,当 WndProc 处理给定消息时,它将状态设置为 0(已处理)。然后状态被返回给Windows系统,因此存在双向通信:Windows告诉 WndProc 处理哪个消息,然后 WndProc 将处理状态报告回Windows。

现在,WndProc 可能需要处理的最重要的消息或许是 WM_PAINT 消息,当Windows系统确定窗口的部分或全部需要重新绘制时,会生成此消息。你的HelloWin应用程序通过在窗口矩形的中心绘制一些文本来处理此消息。所有这些细节对于今天来说并不那么重要,除了要注意绘制显示的是按键和鼠标移动计数器的当前值。

然而,更重要的是,这些计数器被定义为静态变量,因为它们必须在 WndProc 的多次调用和返回中持续存在。请注意,如果你将计数器定义为局部自动变量,它们会在每次返回时超出作用域。

现在,你可能好奇这些计数器实际上在哪里递增。所以,它们在这里。WM_KEYDOWN 计数器在 WM_KEYDOWN 消息中递增。WM_MOUSEMOVE 计数器在 WM_MOUSEMOVE 消息中递增。在这两种情况下,处理还必须包括调用 InvalidateRect API,以告诉Windows窗口矩形需要重新绘制,否则计数器的值不会立即更新。这是应用程序代码与Windows系统之间双向通信的另一个方面。

最后,default 情况处理 WndProc 没有在提供的 case 语句中明确选择处理的所有消息信号。在那个 default 情况下,WndProc 调用Windows提供的默认窗口过程。

这是一个非常有趣的设计,具有巨大的影响,因为GUI系统特有的外观和感觉就是这样产生的。为了解释我的意思,让我们运行这个HelloWin应用程序,看看它能做什么。

事实证明,该应用程序具有通常的窗口,带有窗口栏和标题。你可以调整窗口大小、移动它、最小化它、恢复它、最大化它。但你只显式编码了鼠标移动和按键的计数。然而,应用程序显然也能做所有其他事情,并且看起来和感觉上就像任何其他Windows应用程序。这都归功于默认窗口过程,它为你提供了这些其他行为。

你的 WndProc 中的 default 情况可能看起来一点也不令人印象深刻。但要欣赏它,你只需要浏览 Winuser.h 中定义的数百个Windows消息。大多数这些 WM_ 消息信号都会经过你的 WndProc,并且大多数都由Windows提供的默认窗口过程处理。然而,作为程序员,你可以完全不知道所有这些复杂性,因为你只需要知道你实际处理的少数几个消息。

思考这种设计的一个好方法是,它是按层次顺序分层的。层次结构的最低层是你的代码。它对每个事件都有优先处理权,因为每个事件首先发送到你的 WndProc。但当你的代码没有明确处理一个事件时,它不会被忽略,而是被传递给处于更高层次结构的窗口系统。这种分层设计的其他名称是“终极钩子”和“差异编程”。这两个名称含义完全相同,但强调其不同方面。“终极钩子”强调将你的代码附加或挂钩到每个事件的便利性。“差异编程”强调你只需要显式编程与默认行为的差异。

但在这一点上,我希望你开始意识到,你在之前的第29至32课中已经看到了类似的东西,在那里你学习了面向对象编程。具体来说,从OOP的角度来看,你可以将此设计视为一个类层次结构,其中Windows系统是基类,拥有数百个虚函数(每个消息信号一个),而像你的HelloWin或Microsoft Word这样的Windows应用程序则是子类,它们在各自的 WndProc 中覆盖选定的虚函数。

上一节我们分析了事件驱动程序的结构,本节中我们来看看事件驱动编程与顺序编程的关键区别。

事件驱动与顺序编程:阻塞的危害

但是,如果不将其与传统的顺序编程以及代码中阻塞的作用进行对比,任何关于事件驱动编程的介绍都无法给你正确的理解。这正是我想在本课最后几分钟简要解释的内容。

作为顺序编程的例子,让我从第27课关于实时操作系统(RTOS)中提取一些代码。例如,这里是顺序编码的 blinky3 线程,它在Tiva C LaunchPad开发板上闪烁红色LED。

那么,让我们尝试在你的事件驱动 WndProc 中做类似的事情。例如,假设你想在按下键盘上的任何键后短暂闪烁一个LED。从之前的代码回顾中,你知道在 WndProc 中的正确位置是处理按键的 WM_KEYDOWN 分支。

传统的顺序实现将是打开LED,然后等待(即阻塞)大约200毫秒以实际看到闪烁,然后关闭LED。Windows API实际上提供了一个等同于RTOS delay 服务的函数,称为 Sleep。Windows的 Sleep API会阻塞并等待指定的毫秒数。另外,你的Windows电脑上显然没有LED,所以你可以简单地将LED状态作为文本显示在窗口中心。

与所有其他情况一样,在更改要显示的文本后,你需要使窗口矩形无效以强制重新绘制窗口。在 Sleep 延迟之后,你将LED文本更改为“关”,并再次使窗口矩形无效。

作为其他状态变量,LED文本指针需要在 WndProc 中定义为静态。最后,你需要通过将LED文本添加到显示的字符串缓冲区来增强窗口的绘制。

现在,让我们简单地构建并运行这个程序。当你按一次键盘时,LED状态没有按预期改变,尽管键盘计数器递增了。所以你的LED代码没有像你想象的那样工作。

但等等,情况变得更糟:当你快速连续按下几个键时,程序会冻结,并且不会立即更新键盘计数器。然后,过了相当长一段时间后,键盘计数器突然跳增了一大截。实际上,当你按下几个键并同时晃动鼠标时,应用程序中没有任何反应,两个计数器都不会递增,直到键盘和鼠标计数器都突然跳增一大截。

所有这些显然都不好,因为应用程序看起来冻结且无响应。此外,显示的事件计数器的大幅跳跃也相当奇怪。

事实证明,这种行为是Windows内部异步事件发布和事件排队的结果。问题是 Sleep 延迟阻塞了 WndProc,阻止它快速返回到事件循环。当事件循环旋转得太慢时,键盘事件会在事件队列中累积。只有当所有带有阻塞延迟的 WM_KEYDOWN 消息最终都被处理后,事件循环才会解除阻塞并快速处理所有其他事件。因此,计数器值会突然跳跃。

这个问题是众所周知的,Windows程序员有一个名字来形容它:他们称这样的应用程序为“猪”。相信我,你不想成为一只猪。Windows程序的老规矩是,如果任何操作需要超过大约100毫秒,就应该通过使用事件将其分解成更短的部分。

这就解释了你的程序缺乏响应性和冻结的原因。但请记住,按键事件后的LED更新实际上并没有起作用。其原因甚至更有趣。

从事件驱动的角度来看,代码中的每个阻塞调用(如 Sleep)实际上意味着等待某个事件发生。那么解除阻塞就意味着该事件已经发生。解除阻塞后你得到的事件可能没有明确命名,但它仍然是在处理另一个事件(本例中是 WM_KEYDOWN)的过程中传递的。但这违反了事件处理的运行至完成语义,而事件驱动的Windows系统假设了这种语义。

具体来说,在阻塞 Sleep 之前调用 InvalidateRect 是无效的,因为此时 WndProc 没有返回给Windows。因此,Windows没有机会向 WndProc 发送 WM_PAINT 消息来实际更新LED状态。因此,你永远看不到更新。

所以,正如你所看到的,在事件驱动系统中使用顺序编程范式,尤其是阻塞,是一个坏主意,原因有二:首先,它会堵塞事件循环,破坏程序对所有事件的响应性,而不仅仅是那些阻塞一段时间的事件。其次,它违反了所有事件驱动系统普遍假设的运行至完成语义。

这是本课我希望你记住的最重要的要点:顺序编程和事件驱动编程是两种不同的范式,它们不能很好地混合。所以始终将它们分开。这意味着在事件驱动程序中,你需要使用真正的事件驱动解决方案来实现按键后LED闪烁的功能。

为此,你可以使用Windows专门为此目的设计的设施,称为定时器,而不是顺序阻塞使用 Sleep。你可以设置定时器在未来指定的毫秒数生成一个特殊事件,称为 WM_TIMER。这就是你在 WM_KEYDOWN 分支中真正需要做的全部,因为其余的处理随后会进入 WM_TIMER 分支。当然,你需要以通常的方式完成处理,唯一的额外步骤是调用 KillTimer,否则Windows定时器将按照编程的间隔周期性地持续到期。有趣的是,在这种情况下,你使用了 wParam 消息参数,该参数在此情况下保存了生成 WM_TIMER 消息的定时器的ID。

现在让我们看看这是如何工作的。首先,从单独的按键开始。如你所见,每次按键后,LED状态会短暂变为红色然后关闭。现在,让我们尝试连续按键爆发,同时晃动鼠标。你可以看到LED状态正确变化,并且事件计数器持续更新,因此应用程序保持响应。

本节课中我们一起学习了事件驱动编程的核心概念,包括事件循环、消息处理、异步传递、控制反转以及它与顺序编程的根本区别。我们还通过一个Windows GUI示例,看到了在事件驱动系统中使用阻塞的危害以及正确的解决方案。

总结

这结束了使用GUI和Windows API作为示例对事件驱动编程的快速介绍。你学到了相当多的新概念,这些概念总结在此图表中供你参考。要被称作事件驱动,一个程序必须具备此图表中列出的大多数特征,但或许将事件驱动程序与顺序程序最区分开来的属性是:应用程序级代码内部没有阻塞

我将在下一课中回到这个问题,在那里你将学习事件驱动编程如何应用于像你的Tiva C LaunchPad开发板这样的实时嵌入式系统。我希望你能加入我,享受这个乐趣。

35:事件驱动编程第二部分 - 并发与主动对象模式

概述

在本节课中,我们将学习如何将事件驱动编程的概念应用于实时嵌入式系统。我们将从传统的顺序编程和实时操作系统(RTOS)出发,逐步引入事件驱动概念,并探讨它们如何帮助解决并发编程中的常见问题。核心内容包括理解主动对象设计模式,以及如何在传统RTOS之上构建一个事件驱动的框架。


从顺序编程到事件驱动

上一节我们介绍了事件驱动编程在桌面GUI环境下的基本概念。本节中,我们来看看这些概念如何应用于实时嵌入式系统。

最熟悉且仍占主导地位的嵌入式系统编程风格是基于“超级循环”和RTOS的顺序编程。在这种设计中,线程通过阻塞等待特定事件来同步其执行。然而,一个被阻塞的线程对其他未明确等待的事件没有响应。因此,为了添加对新事件的处理,通常需要创建更多线程。

但线程数量的不断增长会迅速变得昂贵且难以管理。更重要的是,新线程可能需要访问与现有线程相同的数据、外设或其他资源,这导致了线程间的资源共享。资源共享会引发竞态条件,而RTOS提供的互斥机制(如互斥锁)虽然可以防止竞态条件,但往往会导致更多的阻塞,使时序分析变得复杂,并可能导致错过实时截止时间,最终导致系统故障。


并发编程的最佳实践

有经验的软件开发人员学会对阻塞保持警惕,并应用一系列最佳实践来大幅减少软件中的阻塞。以下是C++并发专家Herb Sutter提出的三项核心最佳实践:

以下是三项核心最佳实践:

  1. 保持数据隔离:尽可能将数据私有于线程。这意味着线程应避免与其他线程共享数据或资源,从而消除共享状态并发问题。没有共享,就不需要互斥。
  2. 通过异步消息进行线程间通信:这项实践包含了两个重要概念:消息(或事件)异步通信。事件对象是专门为通信设计的封装数据,携带“发生了什么”的信息。异步通信意味着事件的发送者只是将事件发布给接收者,但不会等待事件被处理,即不会阻塞。
  3. 围绕消息泵组织线程工作:这直接引用了事件驱动编程中的事件循环(也称为消息泵)概念。这项实践规定,超级循环的唯一允许结构是消息泵,从而将阻塞限制在循环中唯一特定的位置。

这些最佳实践共同确立了一种设计模式,即主动对象模式


主动对象模式详解

主动对象模式在“裸线程”之上提供了一个抽象层。在该模式中:

  • 像任何其他对象一样,主动对象拥有私有数据
  • 每个主动对象还拥有一个私有线程和一个私有事件队列
  • 与主动对象交互的唯一方式是通过向其事件队列发布事件。发布是异步的,意味着事件只是被放入队列,发送者不会等待事件被处理。
  • 事件处理发生在主动对象私有线程中运行的事件循环内。处理过程可能涉及向其他主动对象甚至自身发送次级事件。

从某种意义上说,主动对象是面向对象编程最严格的形式,因为异步通信使主动对象能够被真正封装。相比之下,C++、C#或Java提供的传统OOP封装在并发方面并没有真正封装任何东西。对象的任何操作都在调用者的线程中运行,其私有数据与全局数据一样面临竞态条件。要使操作线程安全,需要显式地用互斥机制(如互斥锁或监视器)进行保护,但这会导致额外的阻塞。

相反,主动对象的所有私有数据都因其只能从自身线程访问,而无需任何互斥机制,从而实现了真正的并发封装。这种并发封装不是编程语言特性,因此在C语言中实现它并不比在C++中更困难,但它需要遵循“不共享”原则的编程规范。基于事件的通信极大地帮助了这一点,因为与其共享资源,不如让一个专用的主动对象成为该资源的管理者或代理,系统的其余部分只能通过向此代理主动对象发布事件来访问资源。

主动对象模式很有价值,因为它自然地实现并自动强制执行了并发编程的最佳实践。


在传统RTOS上实现主动对象

现在,让我们看看如何在传统RTOS(以μC/OS-II为例)之上实现主动对象服务层(我们称之为μC/AO)。

事件与主动对象结构定义

首先,我们需要定义事件和主动对象的基本结构。

事件信号通常是一个小整数,用于枚举应用程序中各种有意义的发生。例如:

enum {
    INIT_SIG, // 保留信号:进入事件循环前发送
    TIMEOUT_SIG,
    BUTTON_PRESSED_SIG,
    BUTTON_RELEASED_SIG,
    USER_SIG // 用户可用信号的起始点
};

事件结构需要包含信号,并且需要可扩展以容纳任意事件参数。这可以通过继承(在C语言中通过结构体嵌套实现)来完成。

/* 事件基类 */
typedef struct EventTag {
    uint16_t sig; /* 事件信号 */
    /* 可扩展用于添加参数 */
} Event;

/* 示例:带参数的事件子类 */
typedef struct EthernetEventTag {
    Event super; /* 继承自Event */
    uint8_t packet[1500]; /* 事件参数 */
} EthernetEvent;

主动对象结构需要包含私有线程(在μC/OS-II中用优先级表示)、私有事件队列(用消息队列实现)以及一个指向其分发处理函数的指针(用于实现多态性)。

/* 主动对象基类(前向声明) */
typedef struct ActiveTag Active;

/* 分发处理函数指针类型 */
typedef void (*DispatchHandler)(Active *me, Event const *e);

/* 主动对象结构体 */
struct ActiveTag {
    uint8_t prio;          /* 私有线程优先级 */
    OS_EVENT *queue;       /* 私有事件队列(μC/OS-II消息队列) */
    DispatchHandler dispatch; /* 虚拟分发函数 */
    /* 子类可在此添加私有数据 */
};

主动对象服务实现

主动对象的核心服务包括构造、启动和事件发布。

启动操作 (Active_start) 创建内部消息队列和任务。关键的是,所有主动对象线程都运行同一个事件循环函数,这体现了事件驱动编程的控制反转特性。

static void Active_eventLoop(void *pdata) {
    Active *me = (Active *)pdata;
    Event const *e;

    /* 1. 初始化:分发INIT事件 */
    me->dispatch(me, &initEvent);

    /* 2. 事件循环(消息泵) */
    for (;;) {
        /* 唯一允许阻塞的地方:等待事件 */
        e = (Event const*)OSQPend(me->queue, 0, &err);

        /* 分发处理事件 */
        me->dispatch(me, e);
    }
}

事件发布操作 (Active_post) 是异步的,它只是将事件指针放入目标主动对象的队列中,然后立即返回,不等待处理。

void Active_post(Active *me, Event const *e) {
    INT8U err;
    OSQPost(me->queue, (void*)e);
}

应用示例:将按钮线程转换为主动对象

假设原有一个顺序编程的按钮线程,它阻塞等待信号量。转换为主动对象后:

  1. 创建按钮主动对象类:通过继承Active基类。
    typedef struct ButtonTag {
        Active super; /* 继承自Active */
        /* 可在此添加私有数据,如去抖状态 */
    } Button;
    
  2. 实现分发处理函数:使用switch语句基于事件信号进行处理。注意:在分发函数内部绝不阻塞
    void Button_dispatch(Button *me, Event const *e) {
        switch (e->sig) {
            case INIT_SIG:
                BSP_blueLedOff(); /* 初始状态 */
                break;
            case BUTTON_PRESSED_SIG:
                BSP_blueLedOn();
                /* 更新闪烁速度(原需互斥保护) */
                break;
            case BUTTON_RELEASED_SIG:
                BSP_blueLedOff();
                break;
        }
    }
    
  3. 修改板级支持包(BSP):将原来释放信号量的中断服务程序,改为向按钮主动对象发布事件。
    /* 在按钮按下中断中 */
    void ISR_buttonPressed(void) {
        static Event const pressedEvt = { BUTTON_PRESSED_SIG };
        Active_post(AO_Button, &pressedEvt); /* 异步发布 */
    }
    


处理时间事件:定时器

在顺序代码中,延时通过阻塞调用实现。在事件驱动代码中,延时对应着时间事件。我们需要扩展主动对象框架以支持时间事件。

时间事件类继承自Event,并添加了目标主动对象、倒计时计数器、间隔等成员。

typedef struct TimeEventTag {
    Event super;        /* 继承 */
    Active *act;        /* 目标主动对象 */
    uint32_t timeout;   /* 倒计时计数器 */
    uint32_t interval;  /* 重装间隔(0表示单次) */
} TimeEvent;

关键操作包括:

  • TimeEvent_arm():启动定时器,设置超时时间和间隔。
  • TimeEvent_disarm():解除定时器。
  • TimeEvent_tick():一个静态函数,应在系统时钟节拍中断中调用,用于递减所有已注册时间事件的计数器,并在超时时向对应主动对象发布事件。

应用示例:将闪烁线程转换为主动对象

闪烁线程需要周期性的时间事件来控制LED开关。

  1. 在闪烁主动对象中添加私有时间事件成员和状态标志
    typedef struct BlinkyTag {
        Active super;
        TimeEvent te;       /* 私有时间事件 */
        bool isLedOn;       /* LED状态标志 */
        uint32_t blinkTime; /* 闪烁周期(现为私有数据) */
    } Blinky;
    
  2. 在分发函数中处理TIMEOUT_SIG:根据isLedOn标志切换LED,并重新武装时间事件以触发下一次切换。
    case TIMEOUT_SIG:
        if (!me->isLedOn) {
            BSP_greenLedOn();
            me->isLedOn = true;
            TimeEvent_arm(&me->te, me->blinkTime, 0); // 亮一段时间
        } else {
            BSP_greenLedOff();
            me->isLedOn = false;
            TimeEvent_arm(&me->te, 3 * me->blinkTime, 0); // 灭三倍时间
        }
        break;
    
  3. INIT_SIG处理中初始化状态并启动第一个超时
    case INIT_SIG:
        me->isLedOn = false;
        BSP_greenLedOff();
        /* 直接“落入”TIMEOUT处理,启动定时器 */
        /* 注意:此处需要巧妙的结构设计或直接调用相同逻辑 */
        ```
    
    

通过这种方式,原来的共享变量blinkTime和相关的互斥锁可以被移除,因为该变量现在被封装在闪烁主动对象内部,消除了竞态条件和阻塞源。


事件驱动代码的可组合性

顺序超级循环由于缺乏响应性而难以组合,这是RTOS需要创建多个线程的根本原因。但事件驱动代码始终保持对所有事件的响应,这意味着它是可直接组合的

因此,我们可以将BlinkyButton两个主动对象的功能合并到一个主动对象中(例如BlinkyButton)。这样做的好处是:

  1. 消除了线程间通信(原来通过共享变量和互斥锁),简化了设计。
  2. 减少了内存开销(栈和队列缓冲区)。
  3. 功能逻辑集中在同一个事件分发函数中,更易于理解和维护。

合并后,原来的共享变量变成了合并后主动对象的私有成员,完全避免了并发访问问题。


总结

本节课我们一起学习了从传统的、基于阻塞和共享状态的RTOS顺序编程,向现代的、事件驱动的主动对象编程的范式转变。

我们了解到,主动对象模式通过强制实施数据隔离、异步通信和事件循环,提供了一种更安全、更可预测的并发编程方式。我们还在μC/OS-II之上动手构建了一个简单的主动对象框架(μC/AO),并实践了将顺序线程转换为主动对象,以及处理时间事件的方法。

尽管传统RTOS可用于实现事件驱动框架,但它并非最佳选择,因为它提供的许多基于阻塞的机制在主动对象编程中不仅无用,甚至可能有害。主动对象框架更侧重于事件的分发和处理,而非线程的调度与同步。

最终,事件驱动和主动对象模式为嵌入式系统,尤其是硬实时系统,提供了更具可组合性、更少阻塞、更易于进行时序分析的软件设计路径。

35:状态机(第一部分)- 什么是状态机?🤖

概述

在本节课中,我们将开始学习状态机。状态机是事件驱动编程和上节课介绍的主动对象模式的自然延伸,它能有效解决事件驱动系统中手动管理上下文(如使用标志位)所带来的问题。我们将探讨状态机的核心概念、其图形化表示,并动手将一个简单的“闪烁按钮”主动对象重构为状态机实现。

从事件驱动到状态机

上一节我们介绍了并发编程的最佳实践以及主动对象设计模式。我们构建了一个名为 MicroCAO 的框架,并实现了一个“闪烁按钮”应用。然而,该示例暴露了事件驱动方法的一个潜在问题:为了在事件之间记住绿色 LED 的状态,我们不得不引入一个名为 isLedOn 的布尔标志。

在传统的顺序代码(如使用 osDelay 阻塞的线程)中,这种上下文是自动通过调用栈维护的,但代价是需要大量 RAM。事件驱动代码无法阻塞,必须在每个事件处理后返回事件循环,因此失去了栈上下文,转而需要手动管理上下文(如使用标志和变量)。对于简单应用,这似乎可行,但在复杂的现实项目中,这种方法极易导致代码混乱、难以维护。

状态:相关历史的等价类

手动管理上下文反映了开发者的一个正确直觉:我们不需要记住所有过去事件的全部历史,只需记住那些影响系统未来行为的相关部分。系统未来行为只依赖于其“相关历史”。

例如,一个键盘控制器根据 Shift 键是否被按下来决定输出大写或小写字符。这个行为只依赖于 Shift 键的状态,而与之前按过其他哪些键无关。因此,键盘的整个相关历史可以简化为两个等价类:正常状态(输出小写)和Shift状态(输出大写)。

状态 就是系统过去历史的这样一个等价类,该等价类中的所有历史都导致系统未来行为完全相同。因此,状态是相关历史的最有效表示,它抽象掉了所有不相关的细节,只保留了影响未来的最小信息。

状态机与状态图

状态的概念自然引出了状态机的概念。一个状态机是所有可能状态的集合(即所有相关历史的等价类),以及状态之间转换的规则。这些规则称为转换,由那些能改变相关历史(即影响未来行为)的事件触发。

状态机的一个优点是它拥有直观的图形化表示——状态图。在现代统一建模语言(UML)中:

  • 状态 用圆角矩形表示,名称写在名称栏中。
  • 常规转换 用带箭头的线表示,线上标有触发事件。
  • 内部转换 不会导致状态改变,仅执行动作,它被列在状态框内部。
  • 动作可以在两种转换上执行,写在 / 字符之后。
  • 每个状态机都需要一个初始转换,指向状态机创建后的默认状态。

所有状态机形式体系都有一个通用特性:运行至完成(RTC)事件处理。RTC 意味着状态机一次只能处理一个事件,必须完全处理完当前事件,才能开始处理下一个。这与我们上节课学习的主动对象(以及所有事件驱动系统)的语义完全匹配。因此,状态机是定义主动对象行为的理想机制。

设计“闪烁按钮”状态机

现在,让我们为“闪烁按钮”主动对象设计一个状态机,以取代之前的 isLedOn 标志。

以下是设计过程:

  1. 初始转换:这是起点。初始转换的动作是:关闭绿色 LED,并设置一个在“关闭时间”后触发的超时事件。这些动作定义了默认状态是 LED 的“关闭”状态,因此我们将其命名为 off
  2. off 状态
    • TIMEOUT 事件到达时,需要开启绿色 LED 并为“开启时间”设置新的超时。这意味著系统不能再停留在 off 状态,因此需要一个新状态 on。我们创建一个指向 on 状态的转换,并执行上述动作。
    • 此外,off 状态还需要处理 BUTTON_PRESSEDBUTTON_RELEASED 事件。这些事件不改变 LED 的闪烁状态(即不改变 off 状态本身),因此使用内部转换处理:BUTTON_PRESSED 时开启蓝色 LED 并缩短绿色 LED 闪烁周期;BUTTON_RELEASED 时关闭蓝色 LED。
  3. on 状态
    • TIMEOUT 事件到达时,需要关闭绿色 LED 并为“关闭时间”设置新的超时。这导致状态转换回 off 状态。
    • on 状态同样需要处理 BUTTON_PRESSEDBUTTON_RELEASED 事件,其动作与在 off 状态中完全相同。目前我们只能重复这些内部转换,这提示了在更复杂系统中可能存在的问题,而分层状态机正是为解决此类代码重复而设计的。

在 C 语言中实现状态机

我们将采用最直接的方法在 C 语言中实现上述状态机,代码将放在主动对象的 dispatch 函数中。

首先,我们需要一个状态变量来记住当前状态。最直接的方式是使用枚举定义所有可能的状态:

typedef enum {
    OFF_STATE,
    ON_STATE
} BlinkyState;

这个状态变量取代了所有手动管理的上下文标志(如 isLedOn)。

以下是 dispatch 函数重构为状态机的核心逻辑:

  1. 处理初始转换:检查事件信号是否为 INIT_SIG。如果是,则执行初始动作(关闭蓝色 LED,设置初始超时),并将状态变量设置为 OFF_STATE
  2. 状态分发:使用 switch 语句,根据状态变量值跳转到不同的状态处理逻辑。
  3. 事件处理:在每个状态的处理部分,再使用一个 switch 语句,根据接收到的事件信号执行相应操作:
    • 对于常规转换(如 OFF_STATE 中收到 TIMEOUT 转换到 ON_STATE),执行动作后需要更新状态变量。
    • 对于内部转换(如处理 BUTTON_PRESSED),执行动作后不改变状态变量。

通过这种映射规则,代码与状态图之间建立了清晰的对应关系,这带来了可追溯性(便于验证和审查)和可扩展性(添加新状态或事件时,修改位置明确)。

总结

本节课我们一起学习了状态机的基础知识。我们了解到状态是系统相关历史的等价类,是管理事件驱动系统上下文的最有效方式。状态机通过状态图和 RTC 语义,为主动对象的行为提供了清晰的规范。我们还将一个简单的“闪烁按钮”示例从使用标志位的手动上下文管理,重构为基于显式状态机的实现,从而提高了代码的结构清晰度和可维护性。在下一节课中,我们将探讨状态机的其他变体,以及关于状态机的常见误解。

36:状态机第二部分 - 守卫条件

在本节课中,我们将学习状态机中的一个重要概念——守卫条件。这是一种使状态机行为更加灵活的机制,对于理解后续课程中将要介绍的其他状态机变体至关重要。

概述

上一节我们介绍了状态机的基础知识,并设计了一个简单的按钮控制LED闪烁的状态机。状态机能够根据事件类型和系统历史(即当前状态)来决定如何响应事件,这解决了嵌入式系统中一个非常常见的问题。然而,在实际应用中,系统的行为有时不仅取决于设计时已知的状态和事件,还可能依赖于运行时才确定的用户输入或其他条件。为了处理这种情况,UML状态机规范引入了选择伪状态守卫条件

本节中,我们将通过一个“定时炸弹”控制器实例,来学习如何应用守卫条件,使状态机能够根据运行时变量(如计数器)的值来动态决定其行为路径。

准备工作

首先,复制第35课的目录作为第36课的起点。进入新目录,双击 BlinkyButton.qm 模型文件,使用QM建模工具打开它。QM工具可以从 statemachine.com 网站作为QP框架的一部分免费下载。

设计“定时炸弹”状态机

假设我们要将开发板变成一个定时炸弹控制器,其行为逻辑如下:

  1. 复位后,绿色LED亮起,等待用户按下按钮。
  2. 按下按钮后,绿色LED熄灭,开始倒计时。
  3. 倒计时期间,红色LED闪烁三次(亮-灭为一个周期)。
  4. 三次闪烁结束后,“炸弹爆炸”,即所有三色LED同时亮起。

行为序列可概括为:绿灯亮 -> [按钮按下] -> 红闪 -> 红闪 -> 红闪 -> 全亮

我们将基于上一课的“BlinkyButton”模型来构建这个状态机。首先,将模型另存为 TimeBomb.qm,并将活动对象重命名为 TimeBomb

以下是构建状态机的步骤:

1. 初始状态与等待按钮
初始转换应点亮绿色LED,并进入一个名为 wait4button 的状态,在此状态等待按钮按下事件。

2. 处理按钮按下事件
wait4button 状态中,我们只关心 BUTTON_PRESSED 事件。该事件的处理动作包括:

  • 关闭绿色LED。
  • 点亮红色LED。
  • 启动一个0.5秒后触发的定时器事件。
  • 初始化闪烁计数器(例如设为3)。
  • 转换到 blink 状态。

3. 闪烁与暂停状态
blink 状态处理定时器超时事件,动作包括:

  • 关闭红色LED。
  • 重新启动0.5秒定时器。
  • 转换到 pause 状态。

pause 状态同样处理定时器超时事件。但这里出现了一个关键问题:超时后,状态机应该去哪里?简单地回到 blink 状态会形成无限循环。我们需要根据剩余的闪烁次数来决定下一步行为。

4. 引入守卫条件与选择伪状态
一个笨办法是重复设计三组 blinkpause 状态,但这显然不灵活且难以维护。更聪明的设计是引入一个计数器,并在 pause 状态使用选择伪状态守卫条件

pause 状态的超时转换中,我们首先执行动作:blinkCounter--(计数器减一)。然后,转换指向一个选择伪状态(在图中显示为菱形)。

从选择伪状态出发,可以定义多个带守卫条件的转换分支:

  • 分支1:守卫条件为 [blinkCounter > 0]。如果条件为真,则执行动作(点亮红色LED、启动0.5秒定时器),并转换回 blink 状态。
  • 分支2:使用互补守卫 [else]。如果上述条件为假(即计数器已为0),则执行动作(点亮所有LED),并转换到新的 boom 状态。

5. 爆炸状态
最后,添加 boom 状态。进入此状态后,所有LED保持点亮,状态机通常在此停止或等待复位。

手动实现状态机代码

我们将使用上一课学到的嵌套switch语句技术来手动编写代码。首先,在工程中将所有“BlinkyButton”重命名为“TimeBomb”。

1. 定义状态与变量
在活动对象头文件中,枚举所有状态,并声明私有变量 blinkCounter 以替代之前的 blinkTime

typedef enum {
    WAIT4BUTTON_STATE,
    BLINK_STATE,
    PAUSE_STATE,
    BOOM_STATE
} TimeBombState;

typedef struct {
    // ... 父类属性
    TimeBombState state; // 当前状态
    QTimeEvt timeEvt;    // 定时器事件
    uint8_t blinkCounter; // 闪烁计数器
} TimeBomb;

2. 实现状态机分发函数
状态机的逻辑实现在 dispatch 函数中。

  • 初始转换:点亮绿色LED,设置初始状态为 WAIT4BUTTON_STATE
  • WAIT4BUTTON_STATE 状态:处理 BUTTON_PRESSED 事件,执行相应动作并转换到 BLINK_STATE
  • BLINK_STATE 状态:处理 TIMEOUT 事件,关闭红色LED,重启定时器,转换到 PAUSE_STATE
  • PAUSE_STATE 状态:这是核心。处理 TIMEOUT 事件,首先递减计数器。然后,使用 if-else 语句实现守卫条件逻辑:
    case PAUSE_STATE:
        switch (e->sig) {
            case TIMEOUT_SIG:
                me->blinkCounter--; // 计数器减一
                // 守卫条件实现
                if (me->blinkCounter > 0) { // 对应 [blinkCounter > 0]
                    // 还有闪烁次数
                    BSP_ledRedOn();
                    QTimeEvt_armX(&me->timeEvt, BSP_TICKS_PER_SEC/2, 0);
                    me->state = BLINK_STATE; // 回到闪烁状态
                } else { // 对应 [else]
                    // 闪烁结束,爆炸
                    BSP_ledRedOn();
                    BSP_ledGreenOn();
                    BSP_ledBlueOn();
                    me->state = BOOM_STATE; // 进入爆炸状态
                }
                break;
            // ... 其他事件处理
        }
        break;
    
  • BOOM_STATE 状态:通常不处理任何事件,保持爆炸效果。

可以看到,选择伪状态和守卫条件在代码中直接映射为 if-else 语句。这揭示了状态机设计中的一个重要平衡:状态机本意是消除复杂的条件判断(“面条代码”),但守卫条件又不可避免地引入了条件判断。关键在于审慎使用守卫条件,尽可能将系统的稳定模式用状态和转换来刻画,只将真正需要运行时灵活决定的部分交给守卫条件处理。

测试与总结

将代码编译并下载到开发板进行测试。复位后,绿色LED应亮起。按下按钮,红色LED应精确闪烁指定的次数(例如3次),然后所有LED同时亮起。通过修改 blinkCounter 的初始值(例如改为10),可以轻松改变闪烁次数,而无需改动状态机结构,这体现了守卫条件带来的灵活性。

本节课我们一起学习了守卫条件的概念及其在状态机中的应用。我们了解到,守卫条件通过运行时评估的布尔表达式,为状态转换增加了动态决策能力。同时,我们也认识到应谨慎使用守卫条件,避免其过度使用导致代码重回复杂的条件逻辑迷宫。掌握何时用状态、何时用守卫,是成为高效状态机设计者的关键。

现在,你已经为下一课学习其他类型的状态机(如轮询驱动与事件驱动状态机的比较)做好了准备。

37:输入驱动型状态机

概述

在本节课中,我们将学习状态机的另一种重要类型——输入驱动型状态机。我们将探讨其硬件起源、工作原理、与事件驱动型状态机的区别,并通过一个实际的代码示例来理解其实现和应用场景。

状态机的硬件起源

上一节我们介绍了事件驱动型状态机,本节中我们来看看状态机概念的起源。要更广泛地理解状态机,我们需要回到它的起点。

状态机的概念起源于近70年前。在20世纪50年代,贝尔电话公司的乔治·摩尔和爱德华·米利发表了关于数字电路形式化综合方法的开创性论文。这些论文概述了状态机在设计此类电路中的实用性。

最重要的观察之一是,状态机起源于硬件设计,因为当时软件尚处于起步阶段。事实证明,这深刻地影响了状态机,以至于即使在今天,软件中使用的状态机仍然带有许多来自其硬件起源的痕迹。

组合逻辑与同步电路

摩尔和米利关注的是具有内部存储器或某种状态的数字电路。为了理解其挑战,我们首先考虑没有内部状态的数字电路。

这类电路称为组合逻辑电路,例如或门、与门、异或门、反相器以及这些元件的组合。例如,下图是一个执行两个输入A和B相加的组合逻辑电路。它产生两个输出:A加B的和以及加法产生的进位。

输入 A, B
输出 Sum, Carry

因为数字电路处理逻辑值,所以通常用真值表来描述它们。真值表完全描述了半加器电路的工作原理。输出仅完全依赖于输入,不需要记住任何过去的输入历史。

然而,考虑检测数字输入A的上升沿问题,即我们希望输出Y仅在输入A从0变为1时变为高电平。

问题:检测输入 A 的上升沿
条件:当 A 从 0 -> 1 时,输出 Y = 1

这个问题无法用任何纯组合逻辑解决,因为电路显然需要记住输入A之前的状态。

边沿检测电路示例

以下是完成此工作的电路示例。电路中最重要的新组件是D触发器,它只记住一位信息。这就是电路的状态。此外,现在有一个连接到触发器的周期性时钟信号,它控制触发器何时存储新信息。这个时钟为系统提供了一个全局事件,并决定了输入和输出何时被允许改变。

由时钟驱动的电路称为同步电路,摩尔和米利主要关注同步电路。有许多原因导致至今基本上只使用同步电路。

边沿检测电路还有两个纯组合逻辑块:一个用于根据当前输入和当前状态确定输出,另一个逻辑块用于确定下一个状态。

电路的工作原理可以再次用真值表描述,但这次表格还需要列出系统的先前状态和下一个状态。根据真值表,输出Y仅在A之前为0且现在为1时为1。

米利型与摩尔型状态机

乔治·摩尔和爱德华·米利原始文章的一个重要见解是通过命名系统的状态来抽象它们,然后应用自动机理论将真值表信息描述为状态图。

状态名称与触发器输出值之间的关联称为二进制编码。在这里,编码特别简单:状态名S0分配给触发器值0,S1分配给值1。

有了这种编码,你可以绘制出米利提出的状态图。状态用标有其名称的圆圈表示。从状态出发的箭头对应于真值表中的行,在米利表示法中,它们标有可能的输入值以及每种情况下产生的输出。

这个电路被称为米利状态机,因为输入和输出的组合逻辑之间存在直接连接。

然而,通常最好将输入与输出分离,只允许当前状态决定输出。这导致了更受限制的电路,称为摩尔状态机。

摩尔状态机通常比米利状态机需要更多的状态来执行等效功能。另一方面,输入A和输出Y的组合逻辑之间没有直接连接。只有当前状态(即触发器的输出)影响输出Y。

摩尔状态图略有不同,因为转换仅用输入标记,而输出被放置在状态内部,因为输出仅依赖于状态。

通用状态机结构

以下是具有内部状态的电路的通用结构。电路的核心组件是由多个D触发器组成的当前状态寄存器,它们都由公共时钟信号驱动。当前状态位和所有输入位都被馈送到组合逻辑块,该逻辑块决定下一个状态。然后,下一个状态被反馈到状态寄存器,在下一个时钟周期成为当前状态。当前状态也被馈送到另一个组合逻辑块以生成输出。此外,仅在米利机中,输入也被馈送到该输出逻辑块。

电路的同步性质反映在时序图中。同步意味着所有信号仅在时钟为高电平时才允许改变,并且在时钟为低电平时必须保持稳定。当然,这将所有一切都与时钟绑定,并且限制性很强。

没有时钟的电路是可能的,称为异步电路。事实上,原始的米利论文也讨论了这种电路。但它也提到了异步电路的最大问题:竞争条件。硬件中的竞争条件问题非常棘手,以至于至今几乎所有数字电子设备(包括所有嵌入式CPU)都是同步的。

但即使是同步电路也不能完全避免竞争条件。例如,米利状态机的输出上升得更早一个时钟周期,因为它直接响应输入,而不是等待状态改变。但这种更快的反应有时会导致竞争条件,因为电路的状态可能与输入在同一时钟周期内改变,因此这两个变化可能相互竞争。

由于这些原因,摩尔状态机通常更受青睐,尽管它们比米利机稍慢且稍复杂。

软件中的状态机

现在的问题是,所有这些与软件中使用的状态机有什么关系?如果你在网上搜索软件状态机,你很可能会看到类似这样的图。这些显然是状态机,但它们与本视频课程第35课和第36课中学到的事件驱动型状态机非常不同。

我指的不是状态表示从圆圈变为圆角矩形这种表面变化。我指的是驱动状态转换的真正差异,因为这些显然不是事件。相反,转换标有输入,以及由这些输入构建的表达式。例如,你可以看到包含逻辑或、与以及比较(如等于、大于或仅为真或始终)的表达式。

那么这是怎么回事?这些输入是什么?这一切都直接追溯到硬件状态机,在硬件中,输入只是位或位组。在软件中,可以改变的位组称为变量。例如,一位输入可以表示为一个布尔变量 pilot_lever,它可以取值 UPDOWN。另一个输入,如毫秒计数器,可以是一个16位变量,取值从0到2^16,等等。

但此时,我希望你注意到一些熟悉的东西。是的,这些输入的布尔表达式正是上一课第36课中学到的守卫条件。实际上,在更高级的工具中,例如MathWorks(制作MATLAB和Simulink的公司)的Stateflow,转换明确标有守卫条件,因为你可以看到特征性的方括号。

输入驱动型状态机的执行时机

下一个更大的问题是:这些状态机何时运行?何时等待?对于事件驱动型状态机,这一点非常清楚。事件驱动型状态机仅在有待处理的新事件时运行。此外,当没有可用事件时,事件驱动型状态机只是等待,意味着它什么都不做。

但输入驱动型状态机不同。它们“始终”或“周期性”运行,通常你可以从图表中知道其运行频率。有时状态机会在上下文中显示,例如在Simulink模型中,从这个更广泛的上下文中,你可以猜测状态机必须由某个时钟驱动,这意味着周期性执行。

但其他这种类型的状态机始终运行。例如,状态机经常直接从主函数的 while(1) 超级循环中执行。

你也在本视频课程中遇到过这样的状态机,在第21课关于前后台系统的内容中,我向你展示了闪烁实现的非阻塞状态机版本。

while(1) 超级循环执行的状态机占用所有可用的CPU周期,无论是否有事件需要处理。

术语与分类

关于这类状态机的术语,似乎没有完全确立。我已经使用了“输入驱动型状态机”这个术语。但我也见过其他名称,比如“控制器状态机”,因为这类状态机通常用于过程控制,如Simulink模型中所示。我还见过“周期性状态机”,因为它们通常周期性执行。然而,这个名称暗示了明确定义的执行周期性,而 while(1) 超级循环中的简单状态机可能以非常不规则的方式执行。

例如,当当前状态中的所有守卫条件评估为假时,状态机可能在几微秒内完成。但当某些守卫评估为真时,执行可能需要许多毫秒。这意味着有几个数量级的差异,很难谈论任何明确定义的执行周期。

这导致了用于描述这类状态机的另一个术语:“轮询状态机”。这个术语抓住了这样一个事实:这类状态机不断轮询输入,以发现需要处理的真正有趣的事件。换句话说,这类状态机结合了通过轮询输入发现事件和状态机逻辑。

从这个意义上说,你可以将状态机的输入视为“原始事件”。也就是说,输入是需要被轮询并在守卫表达式中组合以提炼出真正有趣事件的变量。

输入来源与共享数据问题

说到输入,它们通常外在于状态机,要么直接来源于外设寄存器,要么来源于中断。例如,状态机中的按钮输入是从Arduino函数 digitalRead 中的GPIO寄存器读取的。同样,变量 time 是从函数 millis 返回的毫秒计数器中读取的。毫秒计数器在Arduino的系统时钟滴答中断中更新。

这里的要点是,外部输入是在并发实体(如外设和中断)之间共享的变量。我真的希望到目前为止,在本视频课程中,你已经学会对任何此类共享保持非常谨慎的态度,因为任何与代码执行异步变化的东西都可能导致竞争条件、数据损坏和此类其他问题。

由于这些原因,输入驱动型状态机可以与前面提到的异步电路相比较,而事件驱动型状态机则与同步电路相比较。这只是一个类比,不是精确的等价,所以请不要过分引申。但这种比较的主要原因是驱动输入驱动型状态机的输入的异步性质。

外部输入可以在任何时候改变,包括在状态机处理期间。相比之下,事件驱动型状态机中的事件是排队的,并保证在整个运行到完成步骤期间不会改变。

示例:飞机起落架状态机

例如,考虑一篇流行文章《嵌入式状态机实现》中的输入驱动型状态机。该文章中用作示例的状态机控制飞机的起落架。它直接从 while(1) 超级循环调用,并且恰好实现为一组保存在数组中的函数,每个函数代表一个状态。我将在未来的课程中讨论各种状态机实现。

但今天,让我们只关注输入,其中包括 gear_lever 变量。状态 gear_down 中守卫条件的意图是通过测试 gear_lever 的当前值以及存储在局部变量 prev_gear_lever 中的先前值来检测该输入的上升沿。

现在,在 gear_down 状态的函数内部,如果 gear_lever 变量在中断中异步改变,或者直接从GPIO寄存器读取(例如),它在守卫条件被评估时可能是 DOWN,但在值存储到 prev_gear_lever 时可能变为 UP。如果发生这种情况,该输入的上升沿将被错过,守卫条件将永远不会评估为真。这架飞机将永远不会起飞。

请注意,无论 gear_lever 输入是直接访问还是可能通过类似Arduino的 digitalRead 的函数访问,都无关紧要。

解决方案:输入缓冲

解决此类问题的方法当然是缓冲输入。我的意思是将外部输入的值复制到局部变量中,这些局部变量保证在状态机的运行到完成步骤期间不会改变。

为了向你展示这在实践中如何工作,并最终为本课编写一些代码,让我们回到第21课关于前后台系统的内容。

实践:改造闪烁状态机

为了基于那节课构建今天的代码,将第21课的目录复制到第37课。进入新的第37课目录,双击项目文件在Keil Microvision IDE中打开它。

为了快速提醒你,在第21课结束时,你看到了直接在 while(1) 超级循环中实现的闪烁应用程序的非阻塞实现。这正是你今天正式学习的输入驱动型状态机。

这个输入驱动型状态机使用基于当前状态的常用 switch 语句编码,在每个状态的每个 case 中,你可以看到特征性的 if 语句。这些是输入驱动型状态机典型的守卫条件。

以下是状态机的逆向工程状态图。如你所见,动作仅与转换相关联。所以这是一个米利型状态机。

现在,状态机只有一个输入,由 BSP_TickCounter 函数提供。该函数读取 l_tickCounter 变量,该变量在SysTick处理程序中断中不断更新。

因此,tickCounter 输入确实相对于后台循环的执行是异步变化的。同样,在这个状态机代码中,你可以看到 tickCounter 输入在每个状态中被访问了两次,因此在每个访问点它可能不同,因为它是异步变化的。

在这个小的玩具示例中,这恰好是可以容忍的,但缓冲仍然会使它对任何未来的修改更加健壮。

还要注意,无论输入是直接访问还是通过像 BSP_TickCounter 这样的函数调用访问,输入的异步性质都不会改变。

第一步:缓冲输入

因此,首要任务是将 tickCounter 输入缓冲到局部变量 now 中,以便输入在状态机的每个运行到完成步骤中只读取一次。在设置局部变量之后,你现在可以用该局部变量 now 替换所有对 BSP_TickCounter 的直接引用。

让我们快速检查一下这是如何工作的,构建代码并直接加载到开发板上。绿色LED仍然快乐地闪烁。

扩展功能:添加按钮控制

现在,让我们通过添加类似于前两课第34课和第35课的闪烁按钮功能来扩展这个示例。这将给你一个机会添加第二个输入:SW1按钮。

今天的任务是对SW1按钮做出反应,按下时应点亮蓝色LED,释放时应关闭蓝色LED。

你首先通过添加访问SW1按钮的BSP函数来开始编码这个新功能。该函数的实现将直接读取GPIO寄存器,如果非零则返回1,否则返回0。

你还必须不要忘记定义SW1按钮位,并将SW1开关配置为启用了数字功能和上拉寄存器的GPIO输入。

现在,在实际的状态机内部,你添加自动变量 button 用于缓冲状态机的按钮输入。你通过调用新的 BSP_SW1 函数来初始化它。此外,为了检测按钮输入的变化,你需要记住按钮的先前值,存储在静态变量 prev_button 中。你将这个 prev_button 初始化为SW1按钮的非活动状态,即1。

现在,你添加守卫条件来检测下降沿,即先前按钮不为0且当前按钮输入为0的情况。作为动作,你点亮蓝色LED,并将当前按钮保存到 prev_button。由于这实际上是一个内部转换,你不改变状态变量。

否则,你添加守卫条件来检测上升沿,即先前按钮为0且当前按钮输入不为0的情况。作为动作,你关闭蓝色LED,并再次将当前按钮保存到 prev_button。同样,由于这实际上是一个内部转换,你不改变状态变量。

现在,你将相同的按钮反应复制并粘贴到另一个 ON 状态的 case 中。

输入驱动的按钮状态机现已准备就绪,因此你可以清理一些未使用的代码并构建项目。让我们检查一下它是如何工作的。

测试与逻辑分析仪观察

将代码加载到开发板后,你可以看到绿色LED仍然像以前一样闪烁,按下SW1按钮会点亮蓝色LED。

但为了更精确地观察,我将使用逻辑分析仪。在这个视图中,顶部标记为SW1的白色迹线显示按钮输入模式。下面的三条迹线分别显示红色、绿色和蓝色LED。触发器设置为SW1的下降沿,所以当我启动逻辑分析仪时,只有绿色LED按预期切换。

现在,当我按下并释放SW1按钮时,迹线触发并停止。如你所见,按下SW1会点亮蓝色LED,释放SW1按钮会关闭蓝色LED。这一切都完全符合预期。

但如果你仔细观察,你可以看到在这种情况下,SW1迹线在稳定到按下状态之前反弹了一次。这当然是你在第27课已经遇到过的电触点弹跳。

在这种特定情况下,你的状态机跟上了噪声信号,并设法点亮和关闭蓝色LED。但有时状态机跟不上弹跳,就像在这个释放按钮的情况下。这在你的特定应用中可能可以接受,也可能不可接受,但问题是你无法控制。这都是因为输入采样与输入驱动型状态机的执行速度耦合在一起,而执行速度可能根据状态机当时正在做什么而有很大差异。

输入驱动与事件驱动的转换

因此,在需要跟上输入变化的应用中,如果输入变化可能快于状态机最坏情况采样率,你可能需要将输入轮询与状态机执行解耦。

这种解耦为你提供了预处理原始输入的机会。例如,你可能需要对原始SW1按钮输入进行一些数字滤波,如去抖动。然后你需要添加一些输入缓冲或排队层,以免丢失输入。

但这越来越导向事件驱动型状态机。因为实际上,每个输入驱动型状态机都可以很容易地转换为事件驱动型状态机。

为了了解如何转换,只需回到代码并查看缓冲的输入。如果你简单地将它们包装到一个结构中,并命名其实例为 evt,你就创建了一个事件,其中输入成为事件参数。这种输入分组还有一个额外的好处,即将代表一致快照的输入打包在一起。

现在一个有趣的问题是这种事件的信号是什么。这取决于你的状态机何时以及如何运行。如果它只是尽可能频繁地运行,比如在 while(1) 超级循环中,你可能会称这个事件为通用“采样”。这是一个低质量的事件,重复率相当不稳定。

但要完成输入驱动型状态机到事件驱动的转换,你可以添加指向事件实例 evt 的指针 e。这样,所有输入现在都作为事件参数通过事件指针 e 访问。

我希望你能看到这段代码如何开始类似于前几课中事件驱动型状态机的分发函数。

让我们尝试构建这个版本并检查它是如何工作的。同样,绿色LED像以前一样闪烁。SW1按钮点亮和关闭蓝色LED。

但在逻辑分析仪中,你仍然可以捕捉到按钮输入弹跳和状态机不可靠响应的实例。当然,这是可以预料的,因为现在在事件内部的单次缓冲输入并没有改变状态机的执行速率。

核心观察:可靠性与环境

因此,这也许是本课最重要的观察:状态机的可靠性和健壮性不仅取决于其类型(输入驱动型与事件驱动型),还取决于状态机运行的环境。

这正是为什么我在第35课中在事件驱动的活动对象(带有事件排队)的上下文中介绍状态机。

但状态机应用形成了一个完整的谱系:从在 while(1) 超级循环中执行且没有任何输入缓冲的输入驱动型状态机,到具有通用事件和守卫条件的日益事件驱动的状态机,再到在具有完整事件排队的活动对象内部运行的完全事件驱动型状态机。

所有这些并不意味着输入驱动型状态机都是坏的。相反,在外部事件发现和排队困难或不切实际的情况下,输入驱动型状态机带来了一系列优势。

适用场景

例如,计算机游戏周期性地执行代码,以响应通用的“帧”事件,该事件每秒发生30或60次。在计算机游戏中,有趣事件的发现很大程度上是游戏本身的一部分,在程序外部执行是不切实际的。例如,一个有趣的事件可能是某个敌人的接近。这是输入驱动型或周期性状态机的理想应用。

另一个应用领域是机器人技术,同样,机器人软件以一定的帧速率周期性运行,从机器人传感器中发现有趣的输入很大程度上取决于机器人试图做什么以及周围发生了什么。

最后,输入驱动型状态机对于为事件驱动型状态机发现外部事件非常有用。例如,前面提到的开关去抖动在前面的第35课和第36课中进行了演示。事实证明,去抖动算法是一个非常特殊的输入驱动型摩尔型状态机,从系统滴答中断钩子中周期性运行。这实际上是32个同时运行的状态机,能够同时为32个开关去抖动。

但我必须将这个实现的讨论留到下一课,在那里我将专门关注各种状态机实现。

总结

本节课我们一起学习了输入驱动型状态机。我们探讨了其从硬件数字电路(米利机和摩尔机)起源的历史,理解了其基于输入变量和守卫条件进行轮询的工作原理。我们通过改造一个闪烁LED的状态机,添加了按钮控制功能,并演示了输入缓冲的重要性。最后,我们讨论了输入驱动型状态机与事件驱动型状态机的联系与区别,以及它们各自适用的场景(如游戏、机器人控制、作为事件发现器)。关键在于,状态机的可靠性不仅取决于其类型,更取决于其运行环境(如输入是否异步变化、是否有缓冲/排队机制)。

38:状态表与进入/退出动作

在本节课中,我们将学习状态机的另一种实现方式——状态表。我们将了解如何用C语言实现状态表,并扩展此实现以支持状态的进入和退出动作。

概述

到目前为止,在本系列关于状态机的课程中,我们已经学习了最直接的C语言实现方式:嵌套的switch语句。例如,第36课中的“定时炸弹”项目就是采用这种方式实现的。整个状态机被编码在一个调度函数中,由一个基于状态变量的switch语句构成。每个case处理一个独立的状态,在每个状态内部,又有一个基于事件信号的嵌套switch语句来处理该状态感兴趣的事件。

然而,这种实现方式并非最高效或最优雅的,它只是最直观、最常见的。今天,我们将学习另一种基于状态表的实现方式。状态表是状态机的一种流行表示方法,理解其工作原理至关重要。

从状态图到状态表

在上一课(第37课)中,我们看到了用于描述电子电路的米利型和摩尔型状态机的状态表。由于这些状态机的输入是逻辑信号,所以那些表是真值表。但我们可以很容易地将其推广到事件。

例如,如果我们为状态分配名称,并将事件命名为low(0)和high(1),就可以将一维真值表重写为二维状态表。在二维表中,状态沿垂直维度排列,事件沿水平维度排列。表中的每个单元格包含一个“下一状态”和“要执行的动作”对,对应于给定状态下给定事件的处理方式。

一维表和二维表包含完全相同的信息,并且都与状态图一一对应。

为“定时炸弹”项目构建状态表

现在,让我们将同样的技术应用于第36课的“定时炸弹”软件状态机,为其构建一个二维状态表。

首先,我们需要识别所有发送给“定时炸弹”的事件。这些事件在BSP.h头文件中枚举,包括BUTTON_PRESSEDBUTTON_RELEASEDTIMEOUT

接着,查看状态机中的状态。这些状态是:WAIT_FOR_BUTTONBLINKPAUSEBOOM

对于每个状态,我们检查它处理哪些事件,并在状态表的相应单元格中填入下一状态和动作。对于某个状态下未处理的事件,我们填入ignore(忽略),或者如果你认为该事件永远不应到达该状态,也可以填入error(错误)。

例如,WAIT_FOR_BUTTON状态处理BUTTON_PRESSED事件,我们就在对应单元格填入下一状态和动作。BLINK状态只处理TIMEOUT事件。

PAUSE状态中,存在一个问题:该状态有一个带守卫条件的转换,根据条件不同,可能转换到BLINK状态或BOOM状态。在状态表中,我们可以先填入这两个可能的下一状态并加上问号,然后在表格脚注中进一步解释。另一种方法是将整个守卫条件直接放入表中,但这会使表格变得杂乱。我们将在实际代码中看到如何解决这个问题。

在C语言中实现状态表

现在,我们进入本节课最有趣的部分:如何将状态表表示作为实际实现的基础。

首先,将第36课的目录复制到第38课,并在Microvision IDE中打开项目。

实现状态表的第一个问题是如何在代码中表示它。C语言直接支持二维数组,因此我们可以创建一个timeBomb_table数组,其第一维对应状态数量,第二维对应事件数量。

一个值得注意的技巧是,在状态枚举的末尾放置一个名为MAX_STATE的特殊常量,在BSP.h的事件信号枚举末尾放置一个名为MAX_SIG的常量。将这些MAX常量放在末尾,可以自动跟踪枚举内部的任何变化。

接下来,最关键的决定是关于状态表条目timeBomb_table中每个单元格的类型。有些实现让每个条目成为一个包含守卫条件、新状态、转换动作等一大堆成员的结构体。然而,这种每个单元格都包含大量信息的方式使得表格初始化非常复杂,占用大量内存,并且最重要的是,即使是一个简单的状态机实现也会被分割成数百个小函数。

因此,我推荐一个更简单的解决方案:让数组中的每个单元格只保存一个名为TimeBomb_action的动作函数指针。这个动作函数可以评估守卫条件,也可以在内部改变状态,因此不需要单独的下一状态信息。

TimeBomb_action是一个函数指针,当给定事件到达给定状态时被调用。我们通过typedef来定义这个函数指针类型。动作函数返回void类型。为了便于访问TimeBomb的数据成员,动作函数将接收一个指向TimeBomb对象的指针me。为了访问事件参数,它还将接收一个指向事件对象的指针e

有了这个定义,我们就可以开始填充状态表了。请注意,状态表在设计时是完全已知的,在运行时是固定的。因此,它可以且应该是const(常量)。这不仅更安全(因为编译器不允许你更改常量表),而且允许状态表被放置在ROM中,而不是占用宝贵的RAM空间。

常量对象必须在定义时立即初始化。在C语言中,实现这一点的唯一方法是使用C数组初始化器。为了简化工作并避免错误,我们可以在初始化器中添加注释,标注出BSP.h中枚举的所有信号标签和TimeBomb类中枚举的所有状态标签。

但这里必须非常小心,因为状态表数组的索引必须是连续的并从0开始。然而,BSP.h中的信号并不是从0开始,而是有一个USER_SIG常量的偏移。这个偏移量在microC/OS-IIos.h头文件中定义,其中第一个默认值为0的信号是INIT_SIG。因此,状态表中的完整信号列表必须在开头包含INIT_SIG

现在,我们可以根据之前设计的状态表来填充表格单元格,除了INIT_SIG信号只在TimeBombinit函数中的WAIT_FOR_BUTTON状态被处理。

对于每个被处理的信号-状态组合,我们插入一个函数指针,其命名格式为timeBomb_后跟状态名,再后跟信号名。例如,WAIT_FOR_BUTTON状态处理BUTTON_PRESSED事件,所以我们在这里插入指向函数timeBomb_wait_for_button_pressed的指针。如果某个信号-状态组合应该被忽略,则插入timeBomb_ignore函数指针。

最后一步是定义表中使用的所有函数。从timeBomb_init开始,我们提供与TimeBomb_action函数指针对应的原型,并从原始的顶层初始转换中复制动作。在初始化时,状态变量me->state必须在构造函数中初始化为WAIT_FOR_BUTTON状态,因为它将被用作状态表的索引。

其他动作函数,如timeBomb_wait_for_button_pressedtimeBomb_ignoretimeBomb_blink_timeout等,都以类似的方式实现,只需从原始的嵌套switch语句中复制相应的动作代码。

现在,我们可以清理TimeBomb_dispatch函数中的旧代码,并用状态表重新实现它。这里的代码非常简单和优雅:我们获取timeBomb_table,用me->state状态变量和e->sig信号作为索引,然后通过指针调用选定的动作函数,并将me指针和e指针作为参数传递给调用。

一个好的做法是,使用assert断言来确保用作数组索引的变量在有效范围内。作为最后的修饰,我们可以为所有TimeBomb函数添加static关键字,就像已经为dispatch函数所做的那样。这是一种良好的编程实践,因为static关键字限制了静态元素在给定模块内的可见性,防止了从其他地方意外访问它们。

引入进入和退出动作

在课程的第二部分,我想介绍状态机的另一个重大改进,它可以消除设计和代码中的许多烦人重复。

例如,在“定时炸弹”状态机中,BLINK状态可以通过两种不同的转换进入:从WAIT_FOR_BUTTONbutton pressed转换,以及从PAUSE的带守卫条件的timer转换。在这两个转换中,都重复了一组动作:red LED onarm timer event for 1/2 second。这是因为这些动作是BLINK状态所需的准备,因此无论以何种方式进入BLINK状态,都必须执行它们。

这些动作在逻辑上属于BLINK状态,如果能将这些动作显式地附加到该状态上,将会好得多。这正是状态的进入和退出动作允许我们做的事情。

具体来说,我们可以为BLINK状态提供一个进入动作,将那些在进入该状态时总是需要执行的动作放在那里。在UML表示法中,这是在状态形状内使用单词entry或字母E,后跟斜杠和动作列表。然后,我们可以从所有传入转换中移除这些动作。

但你不应该仅仅为了转换上的重复动作而应用进入和退出机制。事实上,你应该批判性地审视整个状态机,检查哪些动作更适合放在状态中而不是转换上。

例如,顶层初始转换中的动作green LED on在逻辑上属于WAIT_FOR_BUTTON状态,所以我们可以将其放入进入动作中,并从转换中移除它。

现在,在进入动作中所做的事情通常需要在退出动作中撤销。例如,进入WAIT_FOR_BUTTON时打开的绿色LED,在退出时(无论以何种方式退出)需要被关闭。因此,你应该将该动作从button press转换移动到状态的退出动作中。

类似地,如果BLINK状态在其进入动作中打开红色LED,那么它应该在其退出动作中将其关闭,而不是在转换上执行此操作。另一方面,同样发生在该转换上的定时事件武装动作,在逻辑上属于PAUSE状态,因为它决定了PAUSE状态将保持活动多长时间。

最后,模拟爆炸的“打开所有LED”动作在逻辑上属于BOOM状态的进入动作。

这样,我们就得到了一个“定时炸弹”状态机版本,它的大部分动作都在状态中执行,而不是在转换上。如果你还记得上一课,将动作与状态关联的状态机称为摩尔机,而将动作与转换关联的状态机称为米利机。从某种意义上说,我们刚刚将“定时炸弹”状态机从米利型转换为了摩尔型。

但我不会将硬件状态机中的米利/摩尔分类过分延伸到软件领域。因为通常,软件状态机同时具有米利机和摩尔机的特征。例如,你的“定时炸弹”仍然在转换上执行一些动作,比如PAUSE状态中timeout转换上的动作,这是可以的。

尽管如此,我数十年的软件状态机经验表明,将动作与状态关联的摩尔型状态机通常比将动作与转换关联的米利型状态机更好、更清晰、更健壮、更易于维护。

实现带进入/退出动作的状态表

现在,本节课的最后一步显然是使用状态表技术来实现你的“定时炸弹”状态机的摩尔版本。

当然,你有许多选项可以将进入和退出动作纳入状态表。一种可能性是应用你已经用于初始转换的相同思路:添加两个新的特殊信号,并在microC/OS-IIos.h文件中重新枚举它们。

现在,你需要立即将这些信号也添加到状态表中,否则表格将与事件枚举不匹配。然后,你需要为状态图中所有处理进入和退出动作的状态插入相应的动作函数。

例如,WAIT_FOR_BUTTON状态有一个进入动作和一个退出动作。BLINK状态也有进入和退出动作。另一方面,PAUSE状态只有进入动作,忽略退出动作。BOOM状态也是如此。

下一步是定义你已经放入状态表中的进入和退出动作函数。wait_for_button状态的进入动作打开绿色LED,退出动作关闭绿色LED。blink状态的进入动作打开红色LED并为1/2秒武装定时事件,退出动作关闭红色LED。pause状态的进入动作仅为1/2秒武装定时事件。boom状态的进入动作打开所有LED。

最后,在你的dispatch函数中,当发生转换时,你必须实际调用正确的退出和进入动作。但这里有一个问题:你如何知道发生了转换?

一个解决方案是让动作处理程序本身告诉你内部发生了什么。具体来说,一个动作处理程序可以返回一个枚举的状态信息,例如:

  • TRANS:告诉你发生了转换。
  • HANDLED:告诉你动作已处理但未发生转换。
  • IGNORED:告诉你事件被忽略。
  • INIT:告诉你发生了初始转换。

有了这个,你现在需要将状态返回类型添加到动作处理程序的签名中。当然,你需要回过头为所有TimeBomb动作处理程序添加状态返回。

从顶层开始,你需要在初始转换中返回INIT状态。从进入和退出动作中返回HANDLED状态。从改变状态的转换动作中返回TRANS状态。最后,从忽略动作中返回IGNORED状态。

有了这些准备,你就可以修改dispatch操作,以添加在转换时调用退出和进入动作的功能。

首先,定义一个状态变量以及一个prevState变量,用于在发生任何潜在转换之前保存当前状态。接下来,你从状态表中执行适当的动作,但现在将返回状态保存到你的变量中。然后,你检查报告的状态是否对应于转换。如果是,你断言新状态在有效范围内。然后,你调用状态表中对应于先前状态和EXIT信号的动作。此时,你传递给动作的事件是什么并不重要,因为退出动作不应该使用事件,它只是为了符合动作处理程序的签名而存在。因此,你可以在这里传递NULL指针。

退出先前状态后,你需要进入当前状态,因此调用状态表中对应于当前状态和ENTRY信号的动作。同样,你可以为事件参数传递NULL指针。

但你还没有完全完成,因为你仍然需要正确处理初始转换后的状态进入。所以,如果返回状态是INIT,你就像之前一样进入新状态,但不退出任何状态。

至此,代码修改完成。构建代码并加载到开发板进行测试,状态机应能正常工作。

总结

本节课我们一起学习了状态表以及状态的进入和退出动作。

状态表代表了一种不同类型的状态机实现策略,因为与纯代码的嵌套switch语句相比,状态表是数据驱动的。具体来说,实现围绕状态表数据结构展开,该结构在运行时通过状态和事件信号进行索引。其他数据驱动的状态机实现包括将状态表示为数据对象,然后在运行时相互连接和遍历以执行转换。

状态表示法的主要优点是高度规则的结构,迫使你考虑所有可能的状态和事件组合。此外,该实现提供了相对较好且确定性的运行时性能。

然而,该技术也有许多缺点。整个状态机代码被高度分割成大量的小动作处理程序。此外,状态表本身往往是稀疏的,有很多空白单元格,即使是在像“定时炸弹”这样极其简单的状态机案例中也是如此。这是因为事件信号被用作表的索引,而很难避免信号数值上的空白。例如,BUTTON_RELEASED事件在当前版本的“定时炸弹”中未被处理,但可能在系统的其他地方需要,并且通常很难为多个状态机优化信号的数值。

但也许状态表最大的缺点是,它们不鼓励添加新的状态和事件,因为这需要在表中添加整个新的列或行。开发人员认为这是很大的开销,因此倾向于避免这样做。相反,他们添加内部状态变量和守卫条件,这又回到了“面条式代码”,违背了使用状态机(正是为了避免“面条式代码”)的初衷。

在下一课中,我将向你展示另一种基于可重用事件处理器的状态机实现策略,我认为这是最优的。

39:状态机第5部分 - C语言最优实现

在本节课中,我们将学习如何用C语言实现一个最优化的状态机。我们将从回顾之前的状态机实现方法开始,分析其优缺点,然后逐步构建一个基于状态处理函数的、可读性强、性能优异且易于维护的解决方案。最后,我们会将这个方案集成到微控制器主动对象框架中。

概述与回顾

上一节我们介绍了状态表实现技术。本节中,我们将探索如何结合之前各种实现方法的优点,同时摒弃其缺点,最终得到一个最优化的状态机实现。

为了进行直接比较,我们将继续使用之前的“定时炸弹”示例。首先,让我们复制第38课的目录并重命名为第39课,然后在IDE中打开项目。

回顾一下,我们已经学习了C语言的几种状态机实现策略,例如嵌套switch语句和状态表技术。它们各有优缺点。今天的目标是混合搭配这些方法的优良特性,同时消除其弊端。

构想理想的状态机描述语言

在开始编码之前,先构想一个理想的状态机描述方式往往很有帮助。我们可以想象发明一种领域特定语言来指定状态机,而不是直接实现它。这种DSL将是状态图的文本表示。

例如,对于“定时炸弹”状态机,我们可以这样描述:

State: wait_for_button
  entry: me->timeout = 0;
  exit: // 无操作
  button_pressed -> transition(blink)

State: blink
  entry: me->timeout = 0; me->blink_ctr = 0;
  exit: // 无操作
  timeout -> transition(pause)

State: pause
  entry: // 无操作
  exit: // 无操作
  timeout -> if (me->timeout < 4) { transition(blink) } else { transition(boom) }

State: boom
  entry: BSP_boom(); // 模拟爆炸
  exit: // 无操作

Initial: transition(wait_for_button)

这种DSL以状态为中心,清晰地描述了入口动作、出口动作、事件触发和状态转换。实际上,存在类似的状态机编译器,如SMC。但我们的目标不是使用特殊编译器,而是将这种DSL的理念融入C语言程序中。

将DSL转化为C语言状态处理函数

观察上面的DSL,状态规格可以自然地转化为C语言函数。状态的主要职责是响应事件并执行动作代码,这正是函数的功能。

状态处理函数需要访问对象指针me和当前事件信号,因此其参数列表与之前状态表中的动作处理函数类似。根据我们在第29至32课介绍的C语言面向对象编程约定,成员函数需要添加类名前缀。

以下是状态处理函数签名的构想:

static QState TimeBomb_wait_for_button(TimeBomb * const me, QEvt const * const e);

其中,返回类型QState表示状态处理后的状态信息(例如,事件是否被处理、是否发生了状态转换)。

在函数内部,我们可以重用嵌套switch语句的结构:外层switch基于当前状态(现在已由函数本身代表),内层switch基于事件信号。入口和出口动作可以通过特殊的保留事件信号Q_ENTRY_SIGQ_EXIT_SIG来处理,这与状态表技术类似。

以下是wait_for_button状态处理函数的一个可能实现框架:

static QState TimeBomb_wait_for_button(TimeBomb * const me, QEvt const * const e) {
    QState status;
    switch (e->sig) {
        case Q_ENTRY_SIG: {
            me->timeout = 0;
            status = Q_HANDLED();
            break;
        }
        case Q_EXIT_SIG: {
            status = Q_HANDLED();
            break;
        }
        case BUTTON_PRESSED_SIG: {
            status = Q_TRAN(&TimeBomb_blink); // 转换到blink状态
            break;
        }
        default: {
            status = Q_SUPER(&QHsm_top); // 或者 Q_IGNORED()
            break;
        }
    }
    return status;
}

这里引入了一个关键概念:状态转换宏 Q_TRAN。它需要改变状态变量。由于我们的状态现在由函数指针表示,因此状态变量应该是一个指向状态处理函数的指针。

我们定义状态处理函数类型和状态变量:

typedef QState (*QStateHandler)(void * const me, QEvt const * const e); // 简化示意
QStateHandler state; // 状态变量

然后,Q_TRAN宏可以这样实现:

#define Q_TRAN(target_) \
    ((me->state = (QStateHandler)(target_)), Q_TRAN_STAT)

这里使用了C语言的逗号运算符,它先执行状态赋值,然后表达式的值取Q_TRAN_STAT,正好可以赋值给status变量。

我们需要在所有状态处理函数定义之前提供它们的函数原型声明。这个列表大致对应之前枚举所有状态的做法。

这种基于状态处理函数的方法,其粒度介于过于细碎的动作处理函数和过于庞大的嵌套switch实现之间。它不再需要可能稀疏且浪费空间的状态表,大大提高了可维护性。其最大的优势在于可读性,因为代码结构直接对应状态图的元素:状态、入口/出口动作、转换和守卫条件。

调整事件分发机制

接下来,我们需要调整上一课中的dispatch操作,使其能够与状态处理函数协同工作,而不是状态表。

主要更改包括:

  1. state变量的类型改为状态处理函数指针。
  2. 在调用当前状态处理函数时,直接通过函数指针调用,无需经过状态表索引。
  3. 当状态处理函数报告发生了状态转换时,需要先调用旧状态的出口动作,再调用新状态的入口动作。这里可以使用静态的、包含Q_EXIT_SIGQ_ENTRY_SIG的保留事件对象。
  4. 调整初始化的处理。

完成这些修改后,项目可以成功编译并在硬件上运行,验证了基本功能的正确性。

重构:将通用状态机管理集成到框架中

观察当前的dispatch操作,它完全是通用的,不包含任何“定时炸弹”状态机特有的逻辑。相同的实现可以用于任何其他状态机。这为我们提供了一个改进设计的机会:将通用的状态机管理功能集成到微控制器主动对象框架中。

当前框架设计是:QActive基类被应用层类(如TimeBomb)继承。我们可以利用继承,将公共元素(如图中蓝色的状态机管理部分)整合到基类中,避免重复代码。

我们可以设计更精细的类层次结构:添加一个QHsm(层次状态机)基类,专门负责状态机管理。然后让QActive类继承QHsm,而应用类(如TimeBomb)再继承QActive。这样做的好处是,QHsm也可以独立用于非主动对象的被动状态机(例如在中断服务例程中)。

以下是重构步骤:

  1. 在框架头文件中声明QHsm基类,包含状态变量state
  2. QHsm类编写构造函数(接收初始伪状态处理函数)、init操作(执行初始转换)和dispatch操作。
  3. 修改QActive类,使其继承QHsm。调整其构造函数,调用QHsm的构造函数。
  4. QActivestart操作中,调用QHsminit操作来初始化状态机。
  5. 由于状态变量现在位于QHsm基类中,需要修改Q_TRAN宏,对me指针进行向上转型到QHsm*,并转换目标函数指针的类型以匹配QHsm中的函数签名。

完成框架重构后,再对TimeBomb应用类进行相应调整:删除已移至框架的类型定义和状态变量,并更新构造函数。

最终,整个项目能够干净地编译,并且“定时炸弹”功能运行正常。

性能评估与比较

我们可以在调试器中利用Cortex-M4处理器的DWT周期计数器来量化新方法的执行速度。测量从button_pressed事件分发到状态机开始,到dispatch函数结束所花费的CPU时钟周期。

实验结果表明,新的状态处理函数方法执行一次转换大约需要226个周期(在40MHz CPU下约5.65微秒)。

作为比较:

  • 状态表方法(第38课):约206个周期,快约10%。
  • 嵌套switch语句方法(第36课):约146个周期,但该版本不支持入口/出口动作,因此不具备直接可比性。

结论是,新的状态处理函数方法在速度上几乎与状态表方法相当,同时具有更佳的可读性、可维护性,并且在内存占用上显著更优(无需大型稀疏状态表)。

其他方法:状态机编译器

作为补充,本节课还简要介绍了状态机编译器(如SMC)的方法。SMC使用自定义的DSL描述状态机,然后将其编译成C或C++代码。生成的代码通常基于“状态模式”,每个状态都是一个单独的类,结构较为复杂。

相比之下,我们的状态处理函数方法属于基于代码的策略,更加简单直观。SMC等工具在历史上曾流行,但直接使用C语言状态处理函数通常更直接、更易集成到现有工作流中。

总结

本节课中,我们一起学习了如何实现一个最优化的C语言状态机。我们从构想一个清晰的DSL开始,逐步将其转化为基于函数指针的状态处理函数实现。这种方法的核心优势在于:

  • 高可读性与可维护性:代码结构直接映射状态图元素。
  • 良好性能:执行速度接近最快的状态表方法。
  • 内存高效:无需存储稀疏的状态表。
  • 易于集成:我们将通用的状态机管理逻辑重构并集成到了微控制器主动对象框架中,提高了代码的复用性。

我们还将新方法的性能与嵌套switch和状态表方法进行了比较,验证了其综合优势。最后,我们简要了解了状态机编译器作为另一种实现途径。

这个最优实现现已内置到你的微控制器主动对象框架中。在下一课中,我们将进入更强大的现代层次状态机的学习。

40:什么是层次状态机? 🚀

在本节课中,我们将学习层次状态机的基本概念。我们将了解它与传统有限状态机的区别,探索如何在C语言中实现状态层次结构,并最终将我们的“定时炸弹”应用从一个简单的教学框架迁移到功能完整的专业QPC框架中。

概述

传统有限状态机在处理复杂逻辑时,常常面临“状态转移爆炸”的问题,即代码中会出现大量重复的转移逻辑。层次状态机通过引入状态的嵌套,允许子状态继承父状态的行为,从而优雅地解决了这个问题。本节课将带你初步了解这一强大的建模工具。

从传统状态机到层次状态机

上一节我们介绍了最优化的状态机实现。本节中,我们来看看传统状态机面临的一个主要挑战。

假设我们需要为“定时炸弹”应用增加一个“解除炸弹”的功能。无论炸弹处于何种状态(例如等待、计时、爆炸),用户都应能通过按下第二个按钮来解除它。

在状态图中,这意味着我们需要添加一个新的diffused状态,并在所有现有状态中都添加一个由button2_pressed事件触发的、指向该新状态的转移。

以下是这种修改带来的问题:

  • 代码重复:相同的转移逻辑需要在多个状态处理器中重复编写,违反了“不要重复自己”的软件开发原则。
  • 维护困难:如果需要修改这个公共转移,必须在所有地方进行相同的更改,极易出错或产生不一致。
  • 调试复杂:在多个相同转移上设置断点进行调试既繁琐又不可靠。

这个问题被称为状态转移爆炸,它使得传统状态机难以应对复杂问题。

层次状态机的解决方案

幸运的是,自20世纪80年代末David Harel发明“状态图”以来,就有了一个优雅的解决方案。其核心创新是引入了层次化嵌套的状态

  1. 创建父状态:我们可以将原有的四个状态(wait4button, blink, pause, boom)用一个名为armed的父状态(或称超状态)包裹起来。
  2. 转移上移:将公共的button2_pressed转移从各个子状态中移除,只保留在armed父状态中。
  3. 继承语义:状态嵌套的语义是,子状态会继承父状态的行为。当状态机处于armed的任何一个子状态时,如果发生button2_pressed事件,而子状态没有处理该事件,则该事件会被传播到父状态armed,从而触发转移到diffused状态。

这样,我们就用一个在父状态中定义的转移,替代了原来分散在四个子状态中的四个相同转移。状态层次结构通过捕获高层状态的公共行为,消除了代码重复。

这种机制类似于面向对象编程中的继承,被称为行为继承。子状态从其父状态继承事件处理逻辑。

在C代码中实现状态层次

现在,我们来看看如何在之前的最优状态机代码基础上,实现这种层次结构。

关键在于,我们需要一种方式让子状态处理器指明它的父状态(超状态)。最合适的地方是在处理事件的switch语句的default分支,因为这里表示当前状态未处理该事件。

我们引入一个SUPER()宏,其参数是父状态的处理函数。

// 在子状态处理器中(例如 timeBomb_wait4button)
switch (e->sig) {
    case TIMEOUT_SIG:
        // ... 处理超时
        return HANDLED_STATUS;
    // ... 其他事件处理
    default:
        // 将未处理的事件传递给父状态 armed
        return SUPER(&timeBomb_armed);
}

同时,我们需要一个顶层的隐式状态(例如HSM_TOP),作为所有最外层状态的父状态。armed状态和diffused状态的父状态都是HSM_TOP

然后,我们修改框架中的事件分发器(HSM_Dispatch),使其支持状态层次:当状态处理器返回SUPER_STATUS时,分发器需要循环地将同一事件传递给指定的父状态处理器,直到某个状态处理了该事件(返回HANDLED_STATUS或触发TRANSITION),或者到达了顶层状态HSM_TOP(返回IGNORED_STATUS)。

进入/退出动作与嵌套初始转移

在层次状态机中,状态的进入和退出动作语义变得更加丰富。从一个状态转移出去时,需要依次执行从当前子状态到父状态的退出动作链。反之,进入一个目标状态时,需要执行从父状态到目标子状态的进入动作链

此外,父状态可以有一个嵌套的初始转移,指向它的某个子状态。任何直接转移到该父状态的转移,最终都会通过这个初始转移进入指定的子状态。

例如,在armed状态中添加一个初始转移到wait4button子状态。那么,从diffused状态到armed状态的转移,最终会使状态机进入wait4button状态。

在代码中,嵌套初始转移使用一个特殊的事件信号(如Q_INIT_SIG)来编码。

// armed 状态处理器
static QState timeBomb_armed(QActive * const me, QEvt const * const e) {
    switch (e->sig) {
        case Q_INIT_SIG: { // 嵌套初始转移
            // 初始化进入 wait4button 子状态
            return TRANS(&timeBomb_wait4button);
        }
        case BUTTON2_PRESSED_SIG:
            // 转移到 diffused 状态
            return TRANS(&timeBomb_diffused);
        // ...
    }
    return SUPER(&HSM_TOP); // 父状态是顶层状态
}

完整、正确地处理这些进入/退出动作链和嵌套转移逻辑是复杂的,但好消息是,这些复杂性可以被封装在主动对象框架(如QPC)的内部实现中,对应用开发者是透明的。

迁移到专业的QPC框架

我们自制的教学框架microCAO在实现完整的层次状态机语义时已显吃力。因此,我们将应用迁移到专业的QPC框架,它提供了对层次状态机、主动对象以及自动代码生成的完整支持。

迁移过程类似于“重构”或“移植”,即更换应用的底层基础,同时尽量保持上层应用逻辑不变。主要步骤包括:

  1. 替换框架文件:在工程中移除microCAO的文件组,添加QPC框架的源文件组(包括QF状态机、QV内核及针对ARM-CMSIS和Keil编译器的移植层)。
  2. 更新包含路径和编译器设置
  3. 适配板级支持包:将BSP中使用的microCAO API调用替换为对应的QPC API(通常带有Q_QF_QActive_等前缀)。例如,替换事件派发、时间滴答处理、空闲回调等。
  4. 适配应用状态机代码:这是最有趣的部分。实际上,QPC框架采用了与我们之前所学完全相同的最优状态机实现策略。因此,迁移主要是进行全局的名称替换:
    • Active -> QActive
    • Tran -> Q_TRAN
    • Super -> Q_SUPER
    • Handled -> Q_HANDLED
    • Event -> QEvt
    • 等等...
      应用状态机的结构逻辑几乎无需改变。
  5. 测试功能:编译并加载到开发板,验证层次状态机的所有功能(事件继承、进入/退出动作链、嵌套初始转移)是否正常工作。

通过移植,我们的应用获得了工业级框架的鲁棒性和丰富功能,同时保留了清晰、高效的状态机代码结构。

总结

本节课我们一起学习了层次状态机的基础知识。我们认识到传统状态机因重复逻辑导致的“状态转移爆炸”问题,并了解了层次状态机如何通过状态嵌套和行为继承来优雅地解决它。我们探讨了在C代码中实现状态层次的基本方法,并成功将“定时炸弹”应用从教学框架迁移到了功能完备的QPC框架。

关键点在于,层次状态机并非难以手工编码,只要采用正确的实现模式,其复杂性是可控的。然而,手工编写状态机代码毕竟是重复性劳动。在下一节课中,我们将探索如何利用现代工具进行自动代码生成,从而进一步提高开发效率和可靠性。

41:状态机第7部分_自动代码生成

在本节课中,我们将学习如何从状态机模型自动生成代码。我们将探讨图形化建模与代码生成的优势,了解其在实际开发中的真正挑战,并通过一个具体的“定时炸弹”示例,演示如何使用QM工具从模型生成可运行的C代码。

概述

上一节我们手动编写了“定时炸弹”状态机的代码。本节中,我们将看看如何通过图形化建模工具QM来自动生成功能相同的代码。我们将从最终的手写代码出发,反向操作,了解如何构建模型以生成代码,并讨论建模与代码生成在现代嵌入式开发中的价值。

安装与设置QM工具

要使用QM的代码生成功能,首先需要下载并安装它。

以下是获取和安装QM的步骤:

  1. 访问本视频课程的配套网页 statemachine.com/video-course,在项目下载表中找到第41课所需的软件。
  2. 下载适用于您操作系统(Windows、Linux或Mac OS)的QP Bundle。本教程假设使用Windows环境。
  3. 运行下载的Windows安装程序。建议接受许可协议,并将QP Bundle安装到默认位置 C:\qp
  4. 在组件选择界面,建议保留所有默认选中的组件,包括QPC/C++框架、QM建模工具以及命令行编译器。
  5. 建议勾选“将QP Bundle安装目录添加到系统路径”的选项。
  6. 完成安装,过程需要一两分钟。

安装完成后,Windows资源管理器中的 .qm 模型文件会显示为红色球体图标。双击即可在QM中打开。你也可以通过桌面快捷方式启动QM,然后通过文件菜单打开模型,或将模型文件拖放到QM窗口。

准备模型:从伪代码到实际代码

现在,打开上一课“定时炸弹”的QM模型文件。在模型资源管理器中双击 TimeBomb 类以查看其状态图。

目前,状态图中的动作仅以伪代码表示。为了进行代码生成,需要将这些伪代码替换为实际的C语言代码。

操作方法是:同时打开包含你手写状态机代码的Keil µVision工程,将其中对应的C语言动作代码复制并粘贴回QM模型的相应位置。

例如,在 arming 状态的 entry 动作中,将伪代码替换为实际的C代码:

BSP_ledGreenOn();

你需要为模型中的每一个动作(entry、exit、transition)执行此操作。

完善类设计:属性与操作

除了状态机,还需要确保 TimeBomb 类拥有在动作代码中通过 me 指针访问的所有属性。

在你的代码中,使用了以下属性:

  • te:类型为 QTimeEvt 的QP时间事件。
  • blink_ctr:类型为 uint32_t 的计数器。

你可以在QM中为 TimeBomb 类添加这些属性。

此外,还可以为类添加操作(方法),例如构造函数。在添加操作时,可以指定返回类型和参数。QM会自动为所有类操作添加 me 指针参数,这是你在第29至32课中学到的C语言面向对象编程约定。

最后,在模型中提供操作的实际代码。至此,TimeBomb 类的逻辑设计(包括类结构、属性、操作和状态机)已全部完成。

物理设计:组织生成的代码

逻辑设计定义了系统的组成部分,但未规定代码如何组织到目录和文件中,这属于物理设计的范畴。

许多建模和代码生成工具对物理设计的支持有限,通常采用固定的文件结构(例如为每个类生成 .h.c 文件)。QM则不同,它完全支持物理设计,允许你自由组织生成的代码。

我们的目标是生成与手写代码结构相同的文件。当前手写代码包含 main.cbsp.hbsp.c。只有 main.c 文件包含了 TimeBomb 活动对象类及其状态机。因此,我们计划在模型文件所在目录下生成 main.c 文件。

以下是设置物理设计的步骤:

  1. 在模型中添加一个“目录”项。右键点击模型根项,选择“添加目录”。
  2. 在属性编辑器中,将目录的“路径”设置为 .(点号),表示该目录与模型文件位于同一位置。
  3. 右键点击新添加的目录,选择“添加文件”。
  4. 将文件命名为 main.c。QM会根据 .c 扩展名识别为C文件并启用语法高亮。
  5. 双击 main.c 文件项以编辑其内容。

反向工程:在模型中嵌入生成指令

我们将采用“反向工程”的方法:先将现有 main.c 文件的全部内容复制到QM的 main.c 文件项中。

然后,我们将文件中对应于 TimeBomb 类声明和定义的部分,替换为QM的生成指令。

  1. 生成类声明:找到手写代码中 TimeBomb 类的声明部分(通常是 typedef struct ...void TimeBomb_ctor(...);),将其替换为指令 $declare{TimeBomb}。你可以通过输入 $declare 并从模型资源管理器拖拽 TimeBomb 类进来,或者直接键入类名来完成。
  2. 生成类定义:找到手写代码中 TimeBomb 类状态机和其他操作的定义部分,将其替换为指令 $define{TimeBomb}。同样,可以通过拖拽或键入完成。

这样,main.c 文件项的内容就变成了:文件开头和结尾是你需要保留的原始代码(如 #include 指令、main 函数等),中间则是两条生成指令。QM在生成时,会将这些指令展开为相应的C代码。

生成与测试代码

现在,点击QM工具栏的生成按钮(或按F7快捷键)来生成代码。QM会在控制台输出日志,并仅将发生变化的文件写入磁盘。

生成完成后,Keil µVision IDE会检测到 main.c 文件被更改并提示重新加载。在生成的文件顶部,QM添加了注释,提醒你不要手动编辑此文件,因为所有更改在重新生成时都会丢失。从此,你只需在QM中修改模型并重新生成代码。

生成的代码在结构上与手写代码完全相同,因为它遵循底层QPC框架所确立的状态机实现策略。框架的存在正是代码生成成为可能的关键,它为标准化的模型元素编码提供了规则。

代码构建无误。将其加载到Tiva C LaunchPad开发板上进行测试,其行为(LED指示和按钮响应)与上一课手写代码的表现完全一致。这表明自动生成的代码功能正确。

为什么需要建模与代码生成?

在深入更多细节之前,有必要探讨一个根本问题:为什么要使用建模和代码生成?这不是增加开销、分散编码精力的行为吗?

来自更成熟工程学科(如机械、建筑)的论据表明,在构造汽车或房屋之前,必须先准备概念模型和详细图纸,这是不可想象的。可视化表示之所以流行,源于人类心理:我们大脑用于视觉处理的神经元数量至少是其他感官总和的十倍。因此,我们处理视觉信息的带宽更高,记忆也更持久。正所谓“一图胜千言”。

但并非任何图形都有效。为了利用我们的视觉智能,需要选择一种既直观又足够精确、能无歧义传递信息的可视化表示法。在软件领域,状态图脱颖而出,它直观、精确且具有“可构造性”,能有效用于代码生成。状态图的发明者David Harel曾分享过一个故事:一位空军飞行员只看了一会儿复杂的状态图,就指出了图表中的一个错误。这证明了状态图的直观性。

因此,建模的理由很充分:你不想错过利用额外脑力以及让更多利益相关者(不一定是软件开发人员)参与设计过程的机会。如果你的竞争对手利用了这些机会,而你没有,你就会处于劣势。

代码生成的质量与效率

开发者初次接触代码生成时,通常会担心生成代码的正确性、质量和效率。这与50多年前编译器刚出现时人们提出的问题如出一辙。如今,没人再质疑编译器生成的机器代码的效率。

实际上,正确性和效率在实践中通常不是问题。自动生成的代码往往是项目中最稳固的部分,而由于人为错误,手写部分通常问题更多。

代码生成实践中的真正挑战

那么,在日常编程实践中应用代码生成时,真正的挑战是什么?

以下是主要问题列表,我们将结合QM工具进行说明:

  1. 与工具“对抗”的感觉:当一些在没有工具时很简单的事情,在使用工具后变得复杂时,就会产生这种感觉。QM经过精心设计以最小化这种对抗。例如,你在QM中输入的动作代码直接就是C/C++代码,工具本身不会试图修改你的代码逻辑。相比之下,一些其他工具(如第39课提到的SMC)会对动作代码施加诸多限制(例如不允许直接赋值或访问属性),迫使开发者寻找变通方法,这增加了“对抗”感。

  2. 生成代码的完整性:生成的代码必须是完整的,无需任何修改即可与应用程序其余部分编译链接。生成带有“待办”注释的代码骨架是不够的。QM生成完整代码,并将生成的文件标记为只读,强制要求所有修改必须在模型中进行,然后重新生成代码。

  3. 与现有代码的集成难度:QM通过让你编写文件主体并插入生成指令的方式,使得在生成代码周围添加 #include 指令、宏定义或其他任何内容变得非常简单。这在其他工具中可能需要通过对话框或特殊的部署模型来完成。

  4. 生成粒度:QM允许以比类更细或更粗的粒度生成代码。例如,可以生成单个操作(如构造函数)的定义,也可以生成整个包的定义。对于状态机,可以生成单个状态的定义($define 指令递归生成一个状态及其所有子状态,$define1 指令则非递归地仅生成给定状态的定义),从而可以将大型状态机的定义拆分到多个文件中。

  5. 双向可追溯性

    • 从代码到模型:当生成的代码因模型错误而编译失败时,你需要快速在模型中定位并修复问题。QM在生成的每个元素前添加了特殊的建模注释。你可以在代码编辑器中选择一段代码(包含注释),复制到剪贴板,然后粘贴到QM中,QM会自动高亮对应的模型元素。
    • 从模型到代码:当你想在某个状态(如 boom 状态)的入口设置断点时,需要快速在生成代码中找到对应位置。在QM中高亮该状态,复制模型链接,然后在代码中搜索该注释文本即可定位。
      这种双向可追溯性是QPC框架所采用的优化状态机实现策略(第39课)的属性,对于功能安全标准非常重要,但并非所有代码生成器都能提供。
  6. 模型的版本控制与比较:模型将成为一种新的源代码。你需要像管理其他源代码一样,对模型进行版本控制、比较和合并。QM将模型以XML格式存储,你可以使用任何标准的代码比较工具(如WinMerge)来比较两个模型版本之间的差异。QM的XML格式设计得较为易读,并且可以选择在XML中保存建模注释,这些注释也可以复制回QM以可视化定位变更项。需要注意的是,XML文件本身也是只读的,所有模型更改都应通过QM工具进行。

  7. 支持大型项目和团队协作:建模在大型项目中能展现出最大优势。QM通过其独特的物理设计支持(对大型项目至关重要)和将模型拆分为可保存在单独文件中的外部包的能力来支持大型项目。这些外部包可以由子团队单独管理,并可以导入到多个模型中。

总结

本节课我们一起学习了自动代码生成的基础。我们通过一个实例,演示了如何从图形化状态机模型使用QM工具生成可执行的嵌入式C代码。

需要记住的三个要点是:

  1. 要想成功,建模必须与代码生成相结合。
  2. 除了过去笨重昂贵的大型工具,现在也有轻量级的建模工具,你不再需要与之“对抗”。
  3. 建模和代码生成在日常实践中的成功,依赖于许多细微的考量,这些考量共同促成了这种范式的转变。

从手动编码转向模型驱动开发,是一个提升抽象层次、提高设计可视化和团队协作效率的过程。正确使用工具和方法,可以显著提升嵌入式软件开发的可靠性与可维护性。

42:层次状态机语义详解

在本节课中,我们将深入学习层次状态机的语义。我们将通过一个包含多层状态嵌套的复杂状态机示例,详细剖析各种状态转换的执行顺序和动作触发规则。通过软件追踪输出,我们将清晰地看到状态机内部的工作流程。


概述

上一节我们介绍了如何使用QM工具自动生成状态机代码。本节我们将深入探讨层次状态机的核心语义,特别是当状态嵌套达到多层时,各种类型的转换(如初始转换、常规转换、内部转换)是如何被精确处理的。我们将通过一个精心设计的示例来观察状态进入、退出以及动作执行的完整链条。

状态机模型介绍

首先,我们打开QM工具中提供的 QHSM_TST 示例模型。这个状态机虽然不模拟任何真实系统,但其结构经过精心设计,包含了多达四层嵌套的所有可能转换拓扑,是学习语义的绝佳范例。

状态机包含六个嵌套状态:

  • 左侧分支:S -> S1 -> S1.1
  • 右侧分支:S -> S2 -> S2.1 -> S2.1.1

转换由事件 AI 触发,并按算法复杂度排序。所有状态和转换都附带有输出信息的动作,例如状态 S2 的进入动作会输出 S2-ENTRY。这种输出被称为软件追踪,是调试和验证代码执行路径的重要手段。

状态机还演示了守卫条件的使用,它依赖于一个名为 foo 的属性,该属性会在某些转换中被修改。

代码生成与执行追踪

我们按照上节课的方法,从该模型生成C代码。生成的代码采用了自第39课以来我们一直使用的优化实现。随后,我们在命令行中编译并运行生成的可执行文件。

运行程序将输出软件追踪信息。通过将这些输出与状态图关联,我们可以精确地看到状态机每一步的执行顺序。

以下是初始转换的追踪分析:

  1. 首先执行附着在转换本身上的动作 top-INIT。注意,此动作还将成员变量 foo 初始化为0。
  2. 初始转换可以穿越多层嵌套,此处它穿越了状态 S,直接以状态 S2显式目标
  3. 然而,被穿越的状态仍需正确进入。因此,接下来执行 S-ENTRY,然后是 S2-ENTRY
  4. 根据语义,转换需要向子状态“钻探”,直到没有嵌套的初始转换为止。因此,接着执行 S2_INIT,进入 S21S211
  5. S211 是一个叶状态(无子状态),运行到完成步骤在此结束,状态机达到稳定配置。

此时,我们可以通过键盘输入来派发事件。

转换语义深度解析

现在,我们通过派发不同事件来观察各种转换的语义。

事件G:跨层级转换

  1. 当前状态 S211 尝试处理事件 G,但未定义,事件被传递给超状态 S21
  2. S21 定义了由 G 触发的转换,执行动作 S21-G
  3. 接着执行转换链:首先退出源状态配置中直到最近公共祖先S)的所有状态(但不退出 S 本身),然后按相反顺序进入目标状态配置。
  4. 转换 GS1 为显式目标。由于 S1 有嵌套的初始转换,因此执行它并进入 S11

事件I:内部转换

  1. 事件 IS11 未处理,向上传递到 S1
  2. S1 定义了由 I 触发的内部转换,执行动作 S1-I
  3. 关键点:内部转换不引起任何状态改变,因此不会执行任何进入或退出动作。

事件A:自转换

  1. 事件 AS11 未处理,向上传递到 S1
  2. S1 定义了由 A 触发的自转换,执行动作 S1-A
  3. 自转换是常规转换,需要退出并重新进入状态。它退出到 S1 的最近公共祖先(S),然后重新进入 S1 及其子状态 S11
  4. 重要观察:在层次状态机中,自转换(会触发退出/进入)与内部转换(不会)有本质区别。自转换是一种重置特定状态上下文的惯用法。

事件D:守卫条件

  1. 首次派发 D 时,当前状态为 S11。转换 S11-D 的守卫条件 foo 为假,因此该转换被视为不存在,事件向上传递。
  2. 超状态 S1 的转换 S1-D 守卫条件为 !foo,为真,因此被触发,动作将 foo 设为1。
  3. 第二次派发 D 时,S11-D 的守卫条件 foo 已为真,因此直接触发此转换,执行的退出/进入动作更少(因为最近公共祖先变为 S1)。

事件E:继承行为的多变性

同一转换 E,当从不同的当前状态(如 S211S11)触发时,会导致完全不同的退出/进入动作序列。这展示了层次状态机中转换行为的上下文依赖性。

事件I(带守卫):事件传递控制

  1. 在状态 S21 中,转换 I 带有守卫 !foo。当 foo 为0时,转换触发。
  2. foo 被设为1后,再次派发 I,守卫 !foo 为假,事件继续向上传递到超状态 S
  3. S 中的内部转换 I 带有互补守卫 foo,为真,因此被触发。

实践练习:修改示例

为了更好地理解,我们复制 QHSM_TST 示例到本地项目目录并进行修改。

修复构建路径

新目录下的Makefile使用相对路径指向QP框架,需要调整。我们可以通过设置 QPC 环境变量或直接修改Makefile中的 QPC 变量来解决。

修改1:使用“else”守卫

在状态 S11 的转换 D 上,我们可以将普通守卫 [foo] 改为显式的 [else] 守卫。这样,当守卫条件为假时,事件会被完全忽略而不再向上传递,与之前的行为不同。

修改2:实现优雅终止

原状态机通过内部转换处理终止事件,导致直接退出,没有执行状态清理。我们可以将其改为指向一个 FINAL 终结状态的常规转换,并将退出动作移到 FINAL 的进入动作中。这样,按下退出键时,会先执行从当前状态到顶级状态的完整退出链,实现优雅清理。

总结

本节课我们一起深入学习了层次状态机的丰富语义。我们通过 QHSM_TST 示例看到:

  • 状态进入/退出动作保证了状态上下文的初始化和清理。
  • 事件处理遵循从当前状态逐级向上的搜索规则。
  • 内部转换自转换有明确区别。
  • 守卫条件可以灵活控制转换的触发与事件的传递。
  • 转换的最近公共祖先决定了退出/进入动作的范围。

这个示例包含了四层嵌套内所有可能的转换场景,是探索和验证状态机行为的宝贵工具。理解这些语义是设计健壮、清晰状态机的基础。

43:实时系统中的活动对象(第一部分)- 运行至完成与RMS/RMA 🚀

在本节课中,我们将探讨事件驱动编程中的活动对象,并分析它们在实时系统中的优势,特别是它们如何与速率单调调度(RMS/RMA)方法兼容,以实现可证明的硬实时行为。


概述

从第34课开始,我们一直在使用事件驱动的活动对象。本节课将把事件驱动编程、活动对象、状态机和实时操作系统(RTOS)等概念联系起来。我们将通过修改第27课的项目,将传统的阻塞线程转换为活动对象,并观察它们在预抢占式RTOS内核下的行为,以验证活动对象是否同样适用于RMS方法。


回顾第27课项目

首先,我们复制第27课的目录,并将其重命名为“lesson_43”。在项目目录中,我们打开项目文件。

该项目最初使用了QP框架中的预抢占式、基于优先级的RTOS内核QXK。它管理了两个传统线程:Blinky1Blinky2Blinky1线程以最高优先级运行,周期为2毫秒,以展示速率单调调度(RMS)原则。Blinky2线程以较低优先级运行,并在按下开关SW1时通过信号量被触发。


将线程转换为活动对象

我们的目标是将Blinky1Blinky2线程转换为活动对象。以下是转换的核心步骤。

创建Blinky1活动对象

首先,我们需要创建Blinky1活动对象类,它继承自QP框架中的QActive基类。

在C语言中,我们通过声明一个结构体来实现,其第一个成员是QActive类型的super

typedef struct {
    QActive super; // 继承QActive基类
    QTimeEvt timeEvt; // 时间事件,用于替代阻塞延时
} Blinky1;

Blinky1需要一个构造函数和一个简单的状态机。状态机包含一个顶层的初始伪状态和一个active状态。

构造函数必须初始化超类QActive和时间事件timeEvt。顶层的初始转换将时间事件设置为在2个时钟节拍后触发,并随后每2个节拍周期性地触发(系统时钟节拍为1毫秒一次),从而使Blinky1具有2毫秒的周期。

// 状态机初始伪状态中的动作
QTimeEvt_armX(&me->timeEvt, 2U, 2U); // 2个节拍后首次触发,之后每2个节拍触发

初始转换进入active状态。active状态只有一个由TIMEOUT_SIG信号触发的内部转换。

// active状态中处理TIMEOUT_SIG的内部转换
case TIMEOUT_SIG: {
    BSP_ledGreenOn();
    BSP_ledGreenOff();
    status_ = Q_HANDLED();
    break;
}

创建Blinky2活动对象

Blinky2活动对象的结构与Blinky1类似,但它处理按钮按下事件,而不是超时事件。

typedef struct {
    QActive super; // 继承QActive基类
} Blinky2;

其状态机的active状态处理BUTTON_PRESSED_SIG信号。

// active状态中处理BUTTON_PRESSED_SIG的内部转换
case BUTTON_PRESSED_SIG: {
    for (uint8_t i = 0U; i < 5U; ++i) {
        BSP_ledBlueOn();
        BSP_delay(BSP_TICKS_PER_SEC/10U);
        BSP_ledBlueOff();
        BSP_delay(BSP_TICKS_PER_SEC/10U);
    }
    status_ = Q_HANDLED();
    break;
}

关键变化:栈与事件队列

在转换过程中,一个重要的变化是我们不再需要为每个活动对象分配私有栈。

这是因为底层的QXK内核利用了活动对象的非阻塞特性,将它们作为“基本线程”执行。基本线程是运行至完成的激活单元,与传统的、在无限循环中阻塞的“扩展线程”不同。所有基本线程可以共享同一个栈,从而节省了大量宝贵的RAM空间。

然而,每个活动对象仍然需要一个事件队列缓冲区来接收事件。队列长度需要根据最坏情况场景进行配置,通常10个事件是一个安全的初始估计。

// 在main.c中创建事件队列和活动对象实例
static QEvt const *blinky1QSto[10]; // Blinky1的事件队列存储
static QEvt const *blinky2QSto[10]; // Blinky2的事件队列存储

Blinky1 blinky1; // Blinky1活动对象实例
Blinky2 blinky2; // Blinky2活动对象实例

启动活动对象时,我们使用QActive_start() API,并传入事件队列及其长度,而不再提供栈指针。

QActive_start(&blinky1.super,
              (QPriority)1U,
              blinky1QSto, Q_DIM(blinky1QSto),
              (void *)0, 0U,
              (QEvt *)0);

调整板级支持包(BSP)

我们需要在BSP中定义新引入的事件信号(如BUTTON_PRESSED_SIGTIMEOUT_SIG),并替换原有的信号量机制,改为向活动对象投递事件。

bsp.h中声明信号枚举:

enum {
    BUTTON_PRESSED_SIG = Q_USER_SIG, // 按钮按下信号
    TIMEOUT_SIG,                     // 超时信号
    // ... 其他信号
};

main.c中声明全局活动对象指针,并在中断服务例程(ISR)中将信号量触发改为投递事件:

// 替换原有的信号量触发
// QXK_ISR_EXIT(); // 旧代码
QACTIVE_POST(&blinky2.super, &buttonPressEvt, 0U); // 新代码:投递事件

测试与验证

完成代码修改并成功编译后,我们将程序加载到Tiva C LaunchPad开发板上,并使用逻辑分析仪观察其行为。

测试结果表明,转换后的系统行为与使用传统线程时完全一致:

  • Blinky1(高优先级)仍然每2毫秒周期性地运行,始终满足其硬实时截止时间。
  • 当按下按钮时,Blinky2(低优先级)被触发并运行,但会被Blinky1周期性地预抢占。
  • Blinky2最终会在处理下一个按钮按下事件之前,完成其当前的运行至完成步骤。

这说明了本节课的核心要点:活动对象和状态机的运行至完成语义,并不意味着状态机必须在单次激活中独占CPU直到完成。在预抢占式内核(如QXK)下,不同活动对象的运行至完成步骤可以在时间上交错执行。 内核完全负责处理这种交错,因此只要内核是预抢占式的,活动对象就可以像传统线程一样相互预抢占,并满足硬实时截止时间。

这意味着,当活动对象与预抢占式、基于优先级的实时内核结合时,它们完全适用于速率单调调度(RMS/RMA)方法。实际上,由于活动对象不共享资源(而是使用异步事件通信),它们可能比传统线程更适合硬实时系统和RMS方法。


总结

在本节课中,我们一起学习了:

  1. 如何将传统的RTOS线程转换为基于状态机的活动对象。
  2. 活动对象作为“基本线程”运行,共享栈空间,从而节省了系统RAM。
  3. 在预抢占式内核下,活动对象的运行至完成步骤可以被更高优先级的任务中断,但这并不影响其最终完成和实时性。
  4. 通过实验验证了活动对象与速率单调调度(RMS/RMA)的兼容性,它们能够像传统线程一样保证硬实时截止时间。

活动对象通过异步事件进行通信,避免了资源共享带来的复杂性,这使其在并发和实时系统中具有显著优势。关于活动对象在资源管理和并发方面的更多关键特性,我们将在下一节课中详细探讨。

44:实时系统中的活动对象(第二部分)——可变事件 🚀

在本节课中,我们将继续探讨实时系统中的活动对象。具体来说,我们将学习如何使用异步可变事件,并了解为何这种方法比使用传统实时操作系统(RTOS)的编程方式更适合硬实时系统。

概述

上一节我们介绍了活动对象在抢占式优先级调度器下的基本行为,并验证了它们符合速率单调调度(RMS)的要求。然而,那个例子过于简化,没有包含任何交互。本节中,我们将通过添加活动对象之间的交互,来观察这对其实时行为的影响,并重点学习如何安全地使用可变事件。

从共享全局变量开始

首先,我们尝试一种不推荐的方法:在活动对象之间共享全局变量。假设我们希望在每次按钮按下后,让 Blinky2 活动对象改变 Blinky1 的闪烁模式。

以下是实现步骤:

  1. 定义全局变量:创建两个全局变量 g_ticksg_iter,分别用于存储 Blinky1 的时钟节拍数和内部循环迭代次数。
  2. 修改 Blinky1:将 Blinky1 的闪烁模式改为由这些全局变量控制。定时事件需要设置为单次触发,并在每次超时后重新设置。
  3. 修改 Blinky2:在 Blinky2 的按钮按下事件处理程序中,修改这些全局变量的值,以改变 Blinky1 的模式。可以使用静态数组和序列计数器来循环设置不同的值。

运行此代码,表面上看功能正常。然而,这里存在一个竞态条件。如果按钮按下事件恰好在修改 g_ticks 之后、修改 g_iter 之前发生,Blinky1 将看到一组不一致的值(新的 g_ticks 和旧的 g_iter)。

为了更容易地暴露这个问题,我们可以在设置两个变量之间添加一个模拟工作负载的循环。测试时,Blinky1 的定时会变得混乱,甚至可能导致 CPU 被完全占用,造成拒绝服务

尝试互斥保护

既然问题源于资源共享,传统的解决方案是使用互斥机制。由于活动对象是非阻塞的,我们不能使用阻塞式的互斥量(Mutex),但可以使用非阻塞的调度器锁定

Blinky2 中修改共享变量的代码周围,添加调度器锁定。测试发现,系统不再崩溃,Blinky1 的模式也能正常切换。但是,在模式切换的瞬态过程中,Blinky1 的周期有时会超过其 2 毫秒的截止时间。

这是有界优先级反转的后果。当低优先级的 Blinky2 持有锁时,高优先级的 Blinky1 无法运行,这段时间应计入 Blinky1 的 CPU 利用率。这暂时使系统超出了 RMS 的可调度界限,从而导致截止时间被错过。

转向事件驱动方案

共享状态并发(无论有无互斥)会带来复杂性和实时性能问题。更好的方法是使用事件进行交互,从而完全避免共享。

我们需要创建一个能携带闪烁模式信息的特殊事件。以下是正确使用可变事件的步骤:

  1. 创建事件类:定义一个继承自 QEvt 的新事件类(例如 BlinkPatternEvt),并为其添加所需的参数(如 ticksiterations)。
  2. 动态分配事件:在 Blinky2 中,使用框架提供的 Q_NEW() 宏来动态分配一个 BlinkPatternEvt 事件。这能确保线程安全。
    BlinkPatternEvt *e = Q_NEW(BlinkPatternEvt, BLINK_PATTERN_SIG);
    e->ticks = ...;
    e->iterations = ...;
    
  3. 设置事件参数:填充事件的参数。
  4. 投递事件:将事件投递给 Blinky1 活动对象。
    QACTIVE_POST(&l_blinky2->super, (QEvt *)e, 0U);
    
  5. 接收与处理事件:在 Blinky1 中,添加对新事件信号 BLINK_PATTERN_SIG 的处理。在事件处理程序中,通过向下转型访问事件参数,并更新内部状态。
    case BLINK_PATTERN_SIG: {
        BlinkPatternEvt const *e = (BlinkPatternEvt const *)Q_EVT_CAST(BlinkPatternEvt);
        me->iterations = e->iterations;
        QTimeEvt_armX(&me->timeEvt, e->ticks, e->ticks);
        break;
    }
    
  6. 初始化事件池:框架需要存储动态事件。我们必须初始化一个确定性的固定大小事件池,并将其交给框架管理。
    static QF_MPOOL_EL(BlinkPatternEvt) l_smlPoolSto[10];
    QF_poolInit(l_smlPoolSto, sizeof(l_smlPoolSto), sizeof(l_smlPoolSto[0]));
    

这种方法实现了零拷贝事件传递。从应用角度看像是在复制事件,但底层框架通过引用计数和事件池进行高效管理。

动态事件的生命周期与所有权规则

为了确保零拷贝事件传递正常工作,应用程序必须遵守以下所有权规则:

  • 框架初始拥有:所有动态事件最初由框架拥有。
  • 生产者获取所有权:事件生产者(如 Blinky2)通过 Q_NEW() 获得事件的所有权,并有权写入事件。
  • 生产者转移所有权:生产者完成后,必须通过投递(POST)、发布(PUBLISH)或显式调用垃圾回收(GC)将所有权交还给框架。此后,生产者不得再访问或修改该事件
  • 消费者获得只读所有权:消费者活动对象在接收到事件时获得其只读所有权。
  • 消费者可转发事件:消费者可以投递或发布当前事件,且不会因此失去所有权。
  • 所有权在 RTC 步骤结束时结束:如果事件中的信息需要在未来的运行至完成步骤中使用,消费者必须将其保存到自己的属性中。

遵守这些规则,框架就能以确定性和线程安全的方式传递可变事件,提供优于传统共享状态并发方案的硬实时性能。

总结

本节课我们一起学习了在实时系统中使用活动对象和可变事件。我们首先看到了在活动对象间共享全局变量会引入竞态条件和实时性能问题。接着,尝试使用互斥保护(调度器锁定)虽然解决了数据一致性问题,但带来了有界优先级反转和截止时间错过的风险。最后,我们转向了正确的事件驱动方案:通过动态分配和传递可变事件来完全避免共享。我们学习了如何创建、投递、接收可变事件,并理解了框架提供的零拷贝事件传递抽象及其关键的所有权规则。这种方法消除了对互斥机制的需求,为硬实时系统提供了更优的性能和可靠性基础。

在下一节课中,我们将探讨软件追踪(或称日志记录)技术。

45:使用printf进行软件追踪

在本节课中,我们将学习一种最常见的软件追踪技术:使用 printf 进行调试。你将看到如何在你的 Tiva C LaunchPad 开发板上实现基于 printf 的软件追踪,并探索这种基础技术的一些缺点。

软件追踪的必要性

在任何实际项目中,编写、编译并成功链接代码只是第一步。系统仍然需要进行调试、测试和优化。到目前为止,本课程中使用的主要工具是单步调试器,通过设置断点,在断点命中后检查寄存器、变量和内存。

然而,单步调试器最大的问题是它会停止正在运行的系统,并完全改变其实时行为。这就像试图通过先杀死一个生物来研究它。我们真正需要的是能够观察软件在全速或接近全速运行时内部交互的方法。

为此,大多数软件开发人员转向了“printf调试”技术。在这种方法中,printf 语句被放置在代码各处(这称为“插桩”),以便代码本身报告其正在执行的操作。这种使用 printf 或类似函数的调试,是统称为“软件追踪”的技术类别中最基础的一种。

项目准备与目标

为了演示,让我们回到第41课的示例。复制第41课的目录并重命名为 lesson_45。进入该目录,在 Keil μVision IDE 中打开项目。

第41课中,我们创建了一个用于“定时炸弹”主动对象的分层状态机。该状态机由按钮按下和时间事件触发,通过控制LED的开关来模拟各种动作。

我们今天的任务是为代码添加插桩,使其能够在实时运行时输出关于已执行动作的追踪信息。

使用标准库 printf

面对这个问题,C程序员通常会转向标准C库函数 printf。该函数需要包含 stdio.h 头文件。printf 的第一个参数是格式字符串,它会将字符串复制到输出,并执行 % 替换。例如,格式字符串 printf("LED %s is %d\n", "red", 1); 将产生输出 LED red is 1\n,其中所有的 % 符号都被格式字符串后的参数值替换。

因此,我们可以在所有控制LED的函数中,以及在系统滴答定时器中断服务程序(SysTick ISR)中添加这样的 printf 插桩,以追踪按钮按下事件。

初次尝试与问题

添加 printf 语句后,项目可以无错误、无警告地编译和链接。但当你尝试运行时,代码会在到达 main 函数之前,在 __open 函数内部的某个硬编码断点处卡住。

这是因为 printf 功能需要更复杂的配置。由于 printf 是C库函数,第一步是启用 microlibmicrolib 是专为嵌入式微控制器设计的、非常轻量级的C标准库版本。

启用 microlib 后,代码可以到达 main 函数,这是一个显著的进步。但当你尝试运行代码时,仍然会命中一个硬编码断点,这次是在 fputc 函数中。fputc 是另一个被 printf 调用的C库函数。

microlib 库提供了所有标准函数的实现,但无法完全定义硬件相关的函数。因此,库没有完全不定义像 fputc 这样的函数,而是提供了带有硬编码断点的“哑”实现。

解决方案是在你的应用程序代码中提供这些函数的定义。根据第14课中关于启动代码的链接规则,链接器将使用你的函数,而不是库中的版本。

实现 fputc 函数

根据C语言文档,fputc 函数接收要发送的字符和一个文件指针。成功时,它返回发送的字符。我们需要决定将字符发送到哪里,但暂时先忽略这一点。

现在,在你的新 fputc 函数内部设置一个断点。运行代码,在命中断点后,你可以在调用栈中看到 fputcprintf 核心调用,而 printf 又被你的状态机代码调用。当你继续运行并多次命中断点时,可以看到 fputc 函数接收到了不同的字符。这一切看起来非常合理。

最后,当你移除断点并自由运行代码时,可以看到你的“定时炸弹”像以前一样工作,尽管它仍然没有产生任何追踪输出。

输出目标:ITM 与 UART

现在你需要完成 fputc 的工作,最终将字符发送到某个你可以看到输出的地方。你有几个选择。

第一个选项是将输出发送到调试器。具体来说,Cortex-M处理器有一个内置的ITM模块。ITM专门设计用于支持 printf 风格的调试。不幸的是,ITM需要一个比你的Tiva C LaunchPad板上的Stellaris ICDI更高级的硬件调试器探头。例如,ST-Link调试器就提供了基于ITM的追踪能力。

为了演示ITM选项,我使用了基于STM32 Nucleo-L152RE板(配备ST-Link硬件调试器)的适配项目。在该项目中,fputc 的实现调用了 ITM_SendChar 来将字符输出到ITM。你还需要在调试器设置中启用追踪并正确设置内核时钟频率。之后,你可以在调试器中打开“Debug (printf) Viewer”窗口来查看实时追踪输出。

虽然使用ITM编程相对简单,但该方法需要适当的硬件调试器探头,并且必须在软件调试器下运行代码才能显示输出。

因此,通常更好的解决方案是让微控制器自行产生追踪输出,无需任何外部硬件。这让我们回到Tiva C LaunchPad项目。具体来说,你需要使用微控制器中已有的通信硬件来实现 fputc。这个外设叫做UART。

为 Tiva C 实现 UART 输出

Tiva C有八个独立的UART外设,但UART0很特殊,因为它已经连接到USB电缆,并在主机PC上显示为虚拟COM端口。这意味着你不需要任何额外的接线来访问通过UART0传输的串行数据。

以下是用于通过UART0发送字符的 fputc 代码:

int fputc(int ch, FILE *f) {
    while((UART0->FR & UART_FR_TXFF) != 0) { /* 等待直到UART0空闲 */ }
    UART0->DR = ch; /* 发送字符 */
    return ch;
}

但发送字节很容易,UART需要一些非平凡的初始化。以下是初始化代码:

void UART0_Init(void) {
    SYSCTL->RCGCUART |= (1 << 0); /* 启用UART0时钟门控 */
    SYSCTL->RCGCGPIO |= (1 << 0); /* 启用GPIOA时钟门控 */
    /* 配置UART0 TX/RX引脚 */
    GPIOA->AFSEL |= (1 << 1) | (1 << 0);
    GPIOA->PCTL = (GPIOA->PCTL & ~0xFF) | (1 << 4) | (1 << 0);
    GPIOA->DEN |= (1 << 1) | (1 << 0);
    /* 配置UART0波特率 (115200) 和 8N1 操作 */
    UART0->CTL &= ~UART_CTL_UARTEN; /* 禁用UART */
    UART0->IBRD = 8; /* 整数波特率除数 */
    UART0->FBRD = 44; /* 小数波特率除数 */
    UART0->LCRH = UART_LCRH_WLEN_8; /* 8位数据,无奇偶校验,1停止位 */
    UART0->CTL |= UART_CTL_UARTEN; /* 启用UART */
}

你还需要从板级支持包(BSP)的初始化函数中调用 UART0_Init

现在,你可以将代码上传到开发板(无需进入调试器),然后使用串口终端工具(如Termite)连接到虚拟COM端口(波特率设置为115200)。按下开发板上的复位按钮,你可以在终端上看到来自状态机顶层初始转换的第一个打印输出。按下SW1按钮,你可以看到一系列“嘀嗒”声和最终的“砰!”,同时终端上会显示相应的追踪信息。

改进设计:条件编译与构建配置

以上只是初步实现。为了在实际中使用,你还可以显著改进设计。

首先,你应该能够轻松地在代码中激活和停用追踪。我并不是指在调试完成后删除插桩代码,因为你可能永远没有完全“完成”调试,未来维护代码时可能还需要这些插桩。因此,我的意思是保留插桩,但能轻松地激活和停用它。

一个相当简单的方法是用 #ifdef SPY#endif 条件编译指令包围所有与插桩相关的代码片段。然后,你可以通过定义宏 SPY 来同时激活所有这些代码部分,或者通过注释掉宏定义来停用它们。

但更好、更易维护的设计是将追踪插桩本身定义为预处理器宏,并使宏定义成为条件性的。以下是其工作原理:

#ifdef SPY
    #include <stdio.h>
    #define MY_PRINTF(...)    printf(__VA_ARGS__)
    #define MY_PRINTF_INIT()  UART0_Init()
#else
    #define MY_PRINTF(...)    (0)
    #define MY_PRINTF_INIT()  ((void)0)
#endif

在这里,MY_PRINTF 被定义为一个可变参数宏。当追踪启用时,它调用 printf;当追踪禁用时,它被定义为 (0),这通常不会生成任何代码。类似地,MY_PRINTF_INIT 在禁用时被定义为 ((void)0)

使用这些定义,你现在可以将所有 printf 函数调用实例替换为 MY_PRINTF 宏。支持 printf 的代码也需要进行条件编译。最后,UART初始化需要替换为 MY_PRINTF_INIT 宏。

你可以进一步改进和通用化代码。例如,将插桩宏的定义片段放入一个单独的头文件(如 my_printf.h)中,并在所有你想要插桩的模块中包含这个头文件。

SPY 宏定义移动到编译器选项中,可以确保它一致地应用于所有编译单元。

然而,最专业的方式是通过“构建配置”的概念将软件追踪集成到你的项目中。构建配置是设置和源代码的集合,具有独立的名称。例如,到目前为止,你一直在为方便调试而构建代码,因此使用了“Debug”构建配置。但也许调试的设置对于软件的最终发布版本并不是最优的,因此你可以定义一个单独的“Release”配置。大多数专业工具,包括Keil μVision,都支持独立的构建配置。

你可以创建一个名为“Spy”的构建配置,专门为软件追踪优化。在该配置的编译器设置中,定义 SPY 宏以启用代码中的追踪插桩。同时,你可以调整“Debug”构建配置,移除 SPY 宏定义以禁用追踪。这样,你可以完全独立地重建“Debug”和“Spy”配置。

printf 追踪的优缺点

printf 风格的追踪技术非常流行,几乎每个人都在使用它。例如,它本质上是Arduino世界中调试的唯一方式。

printf 风格追踪很方便,并且在应用程序中需要相对较少的代码。例如,要在具有不同通信机制的不同开发板上使 printf 工作,你只需要修改 fputc 和初始化这两个简单的函数。对于ITM调试,代码甚至更少。

但同时,printf 可能是你能想到的最昂贵的软件追踪技术。没有多少开发人员意识到 printf 会给最终二进制映像增加多少代码空间。

让我们快速比较一下“Debug”构建配置和“Spy”构建配置的映射文件。在映射文件的“Image component sizes”部分,可以看到库的贡献从Debug配置的124字节增加到了Spy配置的676字节。这意味着 printf 增加了大约552字节的代码。而这仅针对最简单的字符串和整数格式说明符。如果你在代码中使用了浮点格式,printf 代码的大小会膨胀到超过3700字节。

为了对比,整个QP框架只贡献了大约2.5KB的代码和只读数据。QP框架提供了分层状态机、主动对象、时间事件和一个实时内核。然而,所有这些都被 printf 的复杂性所掩盖。

但大的代码占用空间并不是 printf 的唯一代价。一个更大的问题是执行时间的开销。所有软件追踪技术都有一定程度的侵入性,但 printf 尤其糟糕。

为了观察其糟糕程度,我们可以使用第43课中介绍的廉价逻辑分析仪。为了更好地观察时序,我在代码中添加了两个GPIO测试引脚。一个引脚用于在SysTick ISR中标记进入和退出,另一个引脚用于计时 fputc 函数的执行。

加载代码后,使用逻辑分析仪和串口终端。设置分析仪在按下SW1按钮时触发。复位开发板,在终端上看到第一个打印输出。按下SW1按钮,在终端上看到打印信息,并在逻辑分析仪上捕获到几毫秒的波形。

分析仪跟踪显示,当产生 printf 追踪时,SysTick ISR的活动时间要长得多,达到0.78毫秒。这需要与正常情况下整个ISR仅需2微秒进行比较。这意味着 printf 将时间延长了近400倍。fputc 函数内部的活动更糟,显示所有三个 printf 消息的产生总共需要大约3.2毫秒。实际上,你可以计算传输的每个字节,每个字节发送需要87微秒。

总的来说,这相当糟糕。将数据格式化为ASCII字符是昂贵的,并且会产生大量字节,这些字节需要很长时间才能发送出去。但最糟糕的是,所有这些格式化和传输都会用代码堵塞时间关键路径。

总结

在本节课中,我们一起学习了使用 printf 进行软件追踪的基础知识。我们看到了如何在Tiva C LaunchPad开发板上通过UART实现 printf 输出,并了解了通过ITM进行输出的另一种方法。我们还探讨了如何通过条件编译和构建配置来改进设计,以便灵活地启用或禁用追踪功能。

然而,我们也深入分析了 printf 追踪技术的显著缺点:它会给固件带来巨大的代码空间开销(数百到数千字节)和严重的执行时间开销(可能将中断服务例程的执行时间延长数百倍),这在高实时性要求的系统中是不可接受的。

printf 虽然方便,但其侵入性使其不适合用于生产代码或对时序敏感的场景。在下一课中,我们将看到一种更智能的软件追踪方法,其侵入性比 printf 调试要小几个数量级。敬请期待。

46:使用二进制协议的软件追踪

在本节课中,我们将学习一种适用于实时嵌入式系统的成熟软件追踪系统——Q-Spy。我们将了解它如何通过二进制协议高效地记录和传输追踪数据,从而克服传统 printf 调试方法带来的高开销和侵入性问题。

概述

上一节我们介绍了使用 printf 进行软件追踪的基本方法及其局限性。本节中,我们将看看一个更先进的解决方案——Q-Spy。Q-Spy 是 QP 框架内置的追踪系统,它采用二进制协议,将数据记录与传输解耦,显著降低了在关键代码路径上的执行时间开销。

printf 到 Q-Spy

printf 风格的追踪将数据生成和通过 UART 等接口发送数据耦合在一起,这个过程非常耗时。想象一下,消防车正在赶往紧急现场(这对应你的关键代码路径),但消防员却停下来花一小时写报告给上司。软件追踪本身是有用的,就像报告一样,但问题在于执行的时机和方式。

更好的策略是:消防员佩戴随身摄像机快速记录细节(对应快速记录二进制数据到内存缓冲区),然后在紧急情况结束后(对应 CPU 空闲时),再查看录像并提交报告(对应将数据发送到主机)。Q-Spy 正是采用了这种策略。

配置 Q-Spy 追踪

首先,我们需要在项目中激活 Q-Spy 并替换掉原有的 printf 调用。

激活 Q-Spy 仪器

与上一课的简单追踪系统类似,Q-Spy 仪器通常处于非激活状态。你需要通过定义预处理器宏 Q_SPY 来激活它。

替换追踪宏

不再使用格式字符串,而是通过调用相应的宏直接将原始二进制数据插入内部内存缓冲区。

以下是替换追踪宏的示例:

// 替换前(使用自定义的 MyPrintf)
MyPrintf("SW1 %d\n", (int)SW1_SIG);

// 替换后(使用 Q-Spy)
QS_BEGIN_ID(SW1_SIG, 0U) // 开始一个追踪记录,参数用于运行时过滤
    QS_U8(0U, SW1_SIG);   // 输出一个 uint8_t 值(信号)
    QS_STR("SW1");        // 输出一个字符串
QS_END()                  // 结束追踪记录

整个追踪记录(在 Q-Spy 中称为 Trace Record)由 QS_BEGIN_IDQS_END 宏包围。QS_BEGIN_ID 中的参数用于运行时过滤。

板级支持包(BSP)代码更新

接下来,需要更新板级特定代码,用于软件追踪的初始化和向主机发送数据。这相当于上一课中的 MyPrintf_InitMyPrintf_PutC 实现。

主要区别在于:

  1. 提供追踪缓冲区:你必须在 RAM 中提供一个缓冲区,然后将其传递给 Q-Spy 函数 QS_INIT
  2. 更多功能:Q-Spy 实现了数据输入(用于接收主机命令),这需要一个小型 RAM 缓冲区、一个中断以及处理目标重置和各种命令的回调函数。
  3. 精确时间戳:Q-Spy 提供基于硬件计时器的精确时间戳,需要在 QS 回调函数中初始化和读取。
  4. 在空闲线程中发送数据:最耗时的数据发送工作应在底层实时内核的空闲线程中完成。本课程使用的 QV 内核支持空闲回调,我们将发送代码放在这里。关键改进是,现在只在 UART 就绪时才发送字节,而不再忙等待。

完成代码更新后,构建项目。你可能会遇到链接错误,提示缺少 Q-Spy 的源代码文件。

添加 Q-Spy 源代码

需要将 Q-Spy 的源代码文件添加到项目中。这些文件位于 QPC 框架的 qpc/qs/source 目录下。在 µVision IDE 中创建一个名为 "QS" 的新组,并将该目录下的所有文件添加进去。

重要:QS 组仅应在 Spy 构建配置中包含,在 Debug 配置中应排除。你可以在 µVision 的项目管理器中,针对特定配置(如 DBG),右键点击 QS 组,在选项中去掉 "Include in Target Build" 的勾选。

使用 Q-Spy 主机工具

构建并下载程序后,如果你使用通用的串口终端(如上一课用的 Termite),看到的输出将是乱码。这是因为 Q-Spy 输出的是二进制数据,而非人类可读的格式。

为了正确解析二进制 Q-Spy 数据并以可读格式显示,你需要一个名为 qs.exe 的特殊程序(类似于串口终端)。你可以从 Quantum Leaps 的 GitHub 仓库获取它。

在命令行中运行 qs.exe,并指定你的开发板虚拟串口号(例如 qs -c COM3)。重置开发板,你将在 Q-Spy 工具中看到格式清晰的追踪输出,包括每个记录的时间戳。

性能对比

为了展示 Q-Spy 的实时性能优势,我们可以在代码中添加测试引脚(Test Pin)的翻转操作,并使用逻辑分析仪进行测量。

例如,在系统滴答中断(SysTick ISR)中,在 Q-Spy 追踪记录前后分别拉高和拉低一个测试引脚。测量结果显示,生成 Q-Spy 追踪记录仅需约 8.8 微秒。相比之下,使用 printf 输出相同信息需要 799 微秒,耗时高出近 100 倍。这证明了 Q-Spy 方法对系统的侵入性要小得多。

Q-Spy 的预定义追踪记录

Q-Spy 的强大之处不仅在于用户自定义的追踪记录。QP 框架本身也有内置的仪器,可以产生预定义的追踪记录,这些记录比应用特定的记录效率更高。

软件追踪与事件驱动范式结合尤其强大,因为存在固有的控制反转。事件驱动基础设施(如 QP 框架)控制着应用程序,这意味着系统中几乎所有有趣的交互都必须经过框架。框架就像一个漏斗,提供了被仪器化的绝佳机会,可以在无需修改应用代码的情况下,输出关于应用程序行为的详细追踪信息。

要启用这些预定义记录,需要进行相应的配置。启用后,追踪输出将包含诸如 DISPATCHEXIT_STENTRY_ST 等状态机活动记录。不过,相关的数据(如原始内存地址)看起来是加密的。

符号信息与字典记录

为了在主机端更友好地显示二进制数据(如将地址转换为函数名、对象名),Q-Spy 需要符号信息。与其他一些从源代码中提取格式字符串的追踪系统不同,Q-Spy 采用了一种不同的方法:由目标机生成符号信息,从而确保永远不会与执行代码不同步。

通过查看 Q-Spy 输出,你可以识别出两种地址:以 0x2 开头的 RAM 地址(如活动对象)和以 0x0 开头的 ROM 地址(如状态处理函数)。此外,还有作为较小整数的信号值。

Q-Spy 允许你通过提供所谓的“字典追踪记录”来建立地址与符号名称之间的映射:

  1. 对象字典:提供对象地址的符号信息。
    QS_OBJ_DICT(&l_time_bomb); // l_time_bomb 是一个指针
    
  2. 信号字典:提供事件信号的符号信息。
    QS_SIG_DICT(SW1_SIG, &l_time_bomb); // 信号和关联的状态机指针
    
  3. 函数字典:提供状态处理函数地址的符号信息。你可以手动输入,也可以使用 QM 建模工具自动生成(在状态机属性编辑器中勾选“QS fun dict”选项)。

添加字典记录后,重建代码并运行,你会发现 Q-Spy 输出中的原始十六进制值大部分被替换成了符号名称。

Q-Spy 二进制数据协议

Q-Spy 的核心是其内部的二进制数据协议。与格式化的 ASCII 数据(如 printf)相比,二进制格式天然具有压缩性。例如,捕获的二进制文件大小可能是其对应可读文本文件大小的三分之一,这意味着在相同带宽下可以传输三倍多的追踪信息。

此外,二进制协议还能检测数据错误和间隙。它是一种 HDLC 类型的协议,允许在任何错误后立即重新同步,以最大限度地减少有用数据的丢失。在嵌入式端,该协议的关键特性是允许数据以任意块从内部缓冲区移除,完全独立于记录边界,而接收方仍能立即找到并正确解析单个记录。

总结

本节课我们一起学习了一种适用于实时嵌入式应用的软件追踪系统——Q-Spy。我们了解了它如何通过使用二进制协议和在空闲线程中传输数据,来显著降低对系统实时性能的侵入性。我们还探讨了如何启用框架的预定义追踪记录,以及如何通过字典记录提供符号信息,使输出更易于理解。最后,我们简要介绍了 Q-Spy 二进制协议的优势,包括数据压缩和强大的错误恢复能力。软件追踪是理解不透明的嵌入式系统内部运行状态的重要工具,而 Q-Spy 为此提供了一个高效、强大的解决方案。

47:断言与契约式设计(第一部分)

欢迎来到现代嵌入式系统编程课程。我是 Miro Samek。在本节课中,你将学习软件断言,以及在嵌入式编程背景下更正式的契约式设计方法。

🎼 这是关于该主题的两部分系列中的第一部分。

你将学习的这项技术,是交付高质量代码最宝贵、最有效的单一策略。在我的编程生涯中,断言和契约式设计对我的帮助超过了任何其他编程技术,甚至超过了我最爱的状态机。

不幸的是,断言似乎是有效软件中最重要的非实践。令人担忧的是,大量嵌入式开发者从未听说过断言,或者听说过但从未使用,或使用不当。

因此,今天在第一部分中,我的目标是解释断言和契约式设计是什么,更重要的是,它们不是什么,以及断言的正确用法。在第二部分,你将看到如何在嵌入式 C 或 C++ 中应用断言和契约式设计,以及断言在嵌入式系统中的其他实践方面。

什么是软件断言?

断言是布尔表达式,允许程序在运行时检查自身。当断言评估为真时,程序按预期运行。反之,断言评估为假则表明存在错误,程序继续运行已无意义。

C 标准提供了一个简单的断言工具来使用这些标准断言。你需要包含标准头文件 assert.h。然后,将布尔断言表达式指定为 assert 宏的参数。

例如,这里有一个 int_div 函数,用于执行 x 除以 y 的整数除法。但除法运算不允许除数为 0。因此,你断言 y 不为 0。然后打印 x 和 y 参数,并返回 x 除以 y 的结果。

#include <assert.h>
#include <stdio.h>

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/qleap-mdn-embsys-prog/img/aff84b6f9eabd27c1b2e6965bdd84ce9_2.png)

int int_div(int x, int y) {
    assert(y != 0); // 断言除数不为0
    printf("Dividing %d by %d\n", x, y);
    return x / y;
}

当然,你可以在一个文件中拥有多个这样的断言。这里还有另一个计算以 2 为底的整数对数的函数。你在关于位运算的课程中遇到过这个函数,当时它在调度器中用于快速找到最高优先级的就绪任务。无论如何,对数运算的参数不能为 0。因此你断言 x 不为 0。此外,这里的函数是用查找表实现的。因此你额外断言索引不会超出表的末尾。

int int_log2(int x) {
    static const int log2_table[16] = { /* ... 查找表数据 ... */ };
    assert(x != 0); // 断言参数不为0
    assert(x < 16); // 断言索引在表范围内
    return log2_table[x];
}

现在在 main 函数中,你只需用不同的参数调用 int_divint_log2 函数并打印结果。最后两次调用故意违反了断言,以便你能看到会发生什么。

接下来,我将演示如何在桌面计算机上使用 GCC 编译器从命令行构建和运行这个简单程序。代码将在第 47 课的 codes 目录中提供。如果你在 Windows 上工作(就像我在这里一样),你可能没有 GCC 编译器。但获取它的一个方法是下载适用于 Windows 的 QP 捆绑包,其中包含一个 MINGW GCC 工具集。


你可以使用 GCC 如下构建断言程序。当你运行 assert 可执行文件时,可以看到程序会一直运行直到断言失败,而打印输出会告诉你断言表达式以及发生失败的代码行。

在常规运行中,程序产生的输出和失败断言产生的错误信息会混合在一起。但是,当你将输出重定向到 stdoutstderr 流时,可以看到失败的断言将错误信息发送到 stderr 流,而程序的正常输出则发送到 stdout 流。


最后,你可以看到失败的断言显然会中止程序,因为最后的“end”输出没有产生。这与“断言失败后继续运行没有意义”的前提是一致的。

我想演示的标准断言的最后一个特性,是通过定义 NDEBUG 宏来禁用断言的能力。例如,通过编译器的命令行选项。这会导致断言宏扩展为空,因此断言不生成任何代码,也没有开销。当你这次运行可执行文件时,确实不再看到断言信息。有趣的是,现在程序打印出 0 的对数为 -1,因此它从之前导致断言失败的第一次错误中幸存了下来。但程序没有从除以 0 中幸存,而是静默中止了。你只能通过最后的“end”打印输出仍然缺失来猜测程序被中止了。

以上就是标准断言工具的全部内容。它非常简单,但不幸的是,不能直接应用于嵌入式系统,因为它们通常没有用于断言消息的 stderr 流,也无法像桌面系统那样中止。在本课后面,你将看到一个更适合嵌入式系统的实现。

但在那之前,我想讨论一下断言的正确用法。

断言的正确用法

要正确有效地使用断言,你必须清楚地区分程序错误和异常条件。

错误,也称为缺陷,是由于设计或实现错误导致的持久性缺陷。例如,除以 0、数组索引越界、解引用空指针或在初始化之前使用外设。当你的软件存在缺陷时,通常你无法合理地“处理”这种情况。相反,你应该专注于检测、报告并最终修复这个缺陷。此外,你通常无法继续执行。相反,你必须仔细设计一个损害控制策略。这就是断言的用武之地。

与错误相反,异常条件是在系统生命周期内可能合法出现的特定情况,但相对罕见,并且偏离了你软件的主要执行路径。例子包括不正确的用户输入、在本质上不可靠的连接(如无线连接)上的传输错误、异常或降级的操作模式等。在这些情况下,你不应该使用断言。相反,你必须使用常规代码仔细设计和实现处理此类异常条件的策略。

试图将错误当作异常条件来处理,与反过来做一样糟糕。这种编程风格被称为防御性编程。它旨在通过接受更广泛的输入或允许与程序状态不一致的操作顺序,使软件对错误更具鲁棒性。

例如,一个防御性编程的 int_div 函数不会使用断言,而是会通过返回某个虚假值(如 0xFFFF)来“处理”除数为 0 的情况。同样,一个防御性编程的 int_log2 会“处理”低于范围的值,比如返回 -1,高于范围的值则返回另一个编造的值,如 999,只有在其他情况下才返回查找表中的真实值。

另一个例子是用于在你的 O Tva C Launchpad 开发板上打开和关闭 LED 的板级支持包函数。通常,这些函数只是简单地写入 GPIO 寄存器,假设 GPIO 外设已经初始化。但如果是防御性编码,这些函数会检查 GPIO 是否已初始化,并在需要时静默执行初始化。

防御性编程经常被宣传为一种更好的编码风格,但不幸的是,它会隐藏缺陷,并且常常由于额外的复杂性而引入新的缺陷。也请注意,像对不正确的用户输入做出反应或车辆跛行模式这样的行为,总是有意设计和专用代码的结果。具体来说,期望这种行为能奇迹般地从防御性编程中产生是相当天真的。相反,防御性编程更有可能产生虚假、不正确的结果和不良行为。

回到断言,一个将断言提升到新水平的强大方法是契约式设计。它由 Bertrand Meyer 在 20 世纪 80 年代中期开创。视频描述中提供了 Bertrand Meyer 关于应用契约式设计的文章链接。

契约式设计将断言视为软件组件之间相互义务的规范,类似于人与人之间的契约。该方法的核心思想是将这些契约作为断言内在地嵌入到代码中,并在运行时自动验证它们。

契约式设计的视角帮助你真正理解软件断言。也就是说,断言不是错误处理机制。它们既不处理也不防止错误,就像人与人之间的契约不能防止欺诈一样。例如,断言除数不为 0 并不能真正防止用 0 作为除数调用 int_div 函数。同样,断言数组索引在范围内可能会给你一种你已经处理或防止了缺陷的温暖而模糊的感觉,但实际上你并没有。

然而,真正发生的是,你确实建立了一个契约,在其中明确规定了你的 int_log2 函数的参数必须在某个范围内。只要断言被启用,契约就会被自动检查,并且可以肯定的是,如果契约失败,程序将粗暴地中止。

起初,你可能会认为这一定是倒退的。断言不仅对处理(更不用说修复)缺陷毫无作用,而且实际上通过将每个断言条件(无论多么良性)都变成致命错误而使情况变得更糟。然而,请回想一下前面的讨论,处理缺陷的首要任务是检测它们,而不是隐藏它们。

为此,一个导致程序大声崩溃并精确指出哪个契约被违反的缺陷,比一个微妙的、在距离你本可以轻松检测到它的位置数百万条机器指令之后才间歇性显现的缺陷,要容易发现和修复得多。此外,正如你稍后将看到的,断言可以提供最后一道防线,并提供执行纠正行动和损害控制的机会。相比之下,防御性编程向缺陷投降,不提供这样的机会。

除了将断言视为契约之外,另一个有见地的类比是将软件中的断言对应于电路中的保险丝。

电气工程师在电路的各个位置插入保险丝,以引入受控的损害,在电路故障或被误操作时烧断保险丝。很难想象任何非平凡的电路(如家庭布线或汽车的电气系统)没有许多不同额定值的保险丝。

请注意,保险丝既不能防止也不能解决问题。因此,在问题的根本原因被消除之前,更换烧断的保险丝是没有帮助的。事实上,“缺陷”和“调试”这两个术语起源于在布线中发现的真实虫子。只有在移除虫子后,问题才得以解决。

这结束了关于断言和契约式设计的两部分系列的第一部分。

在第二部分,你将看到如何在嵌入式 C 或 C++ 中应用断言和契约式设计。

如果你喜欢这个频道,请给这个视频点赞并订阅以保持关注。你也可以访问 statemachine.com/video-course 获取课堂笔记和项目文件下载。最后,所有项目也可以在 GitHub 的 QuantumLeaps 仓库 modern-embedded-programming-course 中找到。

感谢观看。🎼

48:断言与契约式设计(第二部分)

在本节课中,我们将继续学习断言与契约式设计,重点探讨如何在嵌入式系统中应用它们。你将了解如何在嵌入式C语言中实现断言,并考虑在嵌入式系统中部署断言时的几个关键方面。

在上一节课中,我们学习了断言是什么,以及如何使用标准C库的 assert.h 来实现它们。我们还了解了契约式设计,以及如何正确使用断言来预防错误,而不是处理异常情况。

嵌入式友好的断言实现

本节中,我们来看看如何以一种对嵌入式系统友好的方式实现断言。

我们将快速回顾一个实现,它包含在QP框架中的单个头文件 qassert.h 里。这是一个自包含的文件,你也可以在自己的项目中使用它,而不依赖于整个QP框架。

通常我会直接查看源文件,但今天我想展示由Doxygen生成的文档,因为这通常是探索代码的更简单方式。文档位于QP/C手册中,可以在 statemachine.com 网站上通过产品/链接找到。

核心断言宏:Q_ASSERT_ID

以下是 qassert.h 中定义的核心通用断言宏 Q_ASSERT_ID

#define Q_ASSERT_ID(id_, test_) \
    ((test_) ? (void)0 : Q_onAssert((id_), Q_this_module_))

这个宏接受两个参数:一个在给定模块内唯一的ID号,以及一个需要检查的布尔表达式。宏本身使用三元运算符定义,这是标准做法。例如,你一直在使用的ARM编译器6中的标准 assert.h 头文件也使用三元运算符来定义所有版本的 assert 宏。

将断言定义为表达式而非语句的一个显著优点是,它可以安全地用在 if-else 控制流中。如果将其定义为 if 语句,可能会遇到“悬空else”问题。

Q_ASSERT_ID 的定义中,布尔表达式 test_ 被求值。如果为真,三元运算符返回一个被强制转换为 void 的零值,就像标准 assert 一样。否则,三元运算符会调用 Q_onAssert 函数,这将是你的自定义错误处理程序。

为了唯一标识特定的断言,Q_onAssert 回调函数接受ID参数和 Q_this_module 字符串。这与使用 __LINE____FILE__ 来标识断言的标准做法不同。为什么偏离常规路径?因为 __LINE__ 对于断言来说是一个相当不稳定的ID,增加或删除代码行会改变后续所有行号。正如稍后将讨论的,测试断言需要一个稳定的标识方式。同样,由 __FILE__ 生成的字符串通常取决于文件是如何指定给编译器的。为了避免所有这些问题,qassert.h 头文件提供了一个宏 Q_THIS_MODULE,你可以在每个模块的顶部定义自己的模块名称字符串。这个静态常量指针随后将在给定文件中的所有断言中使用。

Q_onAssert 函数被定义为永不返回,使用了 Q_NORETURN 宏。如果该宏未在其他地方定义,默认定义会使用C99的 _Noreturn 说明符。这可以帮助编译器进行优化,因为违反断言条件的执行路径是不可达的。此外,如果你使用静态分析工具,Q_onAssert 函数应被赋予“不返回”的语义,就像在QP框架中为PC-Lint静态分析工具所做的那样。这有助于工具更好地理解你的代码,并避免对断言条件发出诊断信息。

其他断言变体

qassert.h 还提供了许多其他断言变体,例如 Q_REQUIREQ_ENSUREQ_INVARIANT。这些分别对应前置条件、后置条件和不变式。这些宏的名称直接借鉴了原生支持契约式设计的Eiffel编程语言。

最有用和最常用的是前置条件,你可以使用宏 Q_REQUIRE_IDQ_REQUIRE 来指定。前置条件在函数内部指定,但它们阐明了调用这些函数的调用者的契约义务。因此,当前置条件断言失败时,你知道问题不在于函数本身,而在于对该函数的错误调用。使用前置条件断言而不仅仅是通用断言,是记录函数功能并提示问题可能出在哪里的绝佳方式。这样,前置条件应该成为函数文档的一个组成部分,就像参数列表一样,因为函数的调用者应该知道并满足这些前置条件。

其他断言变体,如后置条件和不变式,使用频率较低,但也为这些断言的目的提供了有价值的文档说明。

另一个常用的断言变体是 Q_ERROR,它直接调用 Q_onAssert。这对于代码中的错误路径非常有用,例如状态机中接收到不需要的事件。

断言处理程序的设计

关于 Q_onAssert 处理程序,在传统的仅用于调试的断言使用中,你可能可以使用一个简单的实现,比如一个无限循环。这样,当断言触发时,你可以通过调试器中断程序,并方便地发现它在 Q_onAssert 中循环。然后你可以检查调用堆栈,看看断言是在哪里触发的。

但是,你也可以在最终产品中启用断言,我稍后将强烈提倡这一点。在这种情况下,断言处理程序变得至关重要,因为它是在检测到错误后的最后一道防线。为了提供真正的保护,断言处理程序必须经过精心设计,以针对你的特定系统执行损害控制和纠正措施。

请记住,断言处理程序不应返回。因此,大多数情况下,它应该以重置系统结束。为此,CMSIS提供了函数 NVIC_SystemReset。同时,请记住断言处理程序是在你的系统已经受损后运行的。因此,断言处理程序不是你通常的代码,必须在各种故障条件下(如堆栈溢出、中断抢占等)仔细测试。对于这种测试,我强烈推荐你在本视频课程中几节课里看到的故障注入技术。例如,你可以故意更改堆栈指针SP寄存器来模拟堆栈溢出,然后引发断言失败。

在支持抛出和捕获异常(如C++)的编程语言中,断言处理程序通常会抛出异常。在我看来,这是错误的,原因有几个。首先,抛出异常只有在可以捕获该异常并合理处理、意图继续程序执行时才有意义。如果断言被正确使用,这些都不适用。其次,抛出异常涉及栈展开,这隐含地假设栈没有被破坏。保险丝类比的主要目的是认识到断言的力量源于其简单性。你应该尽可能保持断言处理程序的简单性,因为任何复杂性(如抛出异常)只会增加在已经受损的情况下发生故障的机会,从而破坏了有效损害控制的目的。

硬件故障与软件断言的统一处理

到目前为止,本节课只讨论了明确放置在源代码中的软件断言。但硬件也会检测到一些故障条件,例如,在关于启动代码的课程中,你看到了所谓的故障异常,如硬故障、内存管理故障和用法故障。传统上,这些条件被视为与断言相关,但就所有目的而言,硬件故障处理程序和断言处理程序具有相同的功能。

与软件断言不同,你无法在最终产品中禁用硬件故障,因此你不能将故障处理程序编码为无限循环。除非你的设备用户可以容忍永久性的服务拒绝。因此,既然无论如何都必须实现一个健壮的故障处理程序,你完全可以为硬件和软件断言开发一个通用的处理程序。这正是你在本课程大部分课程中使用的启动代码的结构方式。如你所见,故障处理程序会跳转到 assert_failed,该函数在堆栈溢出的情况下会小心地重置堆栈指针,然后调用熟悉的 Q_onAssert 处理程序。

断言的价值与部署建议

正如我在介绍中所说,断言比任何其他编程技术都更帮助了我。例如,如果你在Quantum Leaps免费支持论坛中搜索“assert”,你会找到23页的帖子。许多帖子以断言失败报告开始,并以“如果没有这个特定的断言,这个错误将多么难以发现和修复”的观察结束。

但你不需要只听我的一面之词。NASA JPL可靠软件实验室制定的安全关键代码开发规则之一,不仅要求使用断言,还要求达到大约每个函数两个断言的足够密度。此外,微软研究院发表了一篇论文,研究表明,断言密度低的商业软件项目在发布后的错误率远高于那些嵌入了许多断言的项目。

正如Bertrand Meyer所言,使用断言将彻底改变你对软件的方法,特别是你对错误的看法。使用以断言进行“进攻性”编程的软件,感觉与使用“防御性”编程的软件完全不同。在推荐的断言密度下,软件停止产生不良行为、拒绝服务或崩溃。所有错误都表现为断言失败。这种效果确实令人惊叹。断言中包含的完整性检查防止代码“四处游荡”。即使是硬件故障和损坏的比特也不会导致崩溃和烧毁,而是最终进入断言处理程序。一旦断言触发,将问题视为间歇性故障就困难得多。因此,所有错误都需要关注。你还有一个以唯一断言ID形式存在的记录来开始你的调查。这使得大多数错误更加透明。相比之下,测试防御性编程的代码是不确定的。你可能日夜运行测试,但你永远无法确定运行是否真正成功,或者程序可能整夜都在默默地产生垃圾。

正如我已经提到的,软件断言可以被禁用,并且有一种普遍做法是仅在调试期间使用它们,而在最终产品中完全禁用它们。例如,这是我昨天刚从一位从事医疗设备工作的工程师那里听到的:“我们发布的代码库中没有启用断言。目标是在开发过程中检测并修复断言失败。” 我完全同意第二句话:尽我们所能,在开发过程中修复所有错误,绝不让断言在最终产品中失败。但是,一旦断言被使用和测试过,我真的不明白禁用断言的逻辑。所有用于形成断言正确心智模型的类比也表明,禁用断言是荒谬的。如果你把断言看作保险丝,你会为了实际使用而用钉子、硬币和纸夹替换所有保险丝吗?如果你发现你即将驾驶的汽车或即将乘坐的飞机的保险丝盒是这样固定的,你会作何感想?这不会给你带来多少安全感,对吧?然而,这正是禁用断言对代码所做的事情。如果你把断言看作护栏,你更喜欢在有护栏还是没有护栏的山路上开车?我的意思是,你从未打算撞到护栏,所以你不需要它们,对吧?最后,如果你将断言视为保险单,你会在火灾季节前取消它吗?我的意思是,你并不真的想兑现这样的保单,对吧?但如果你的房子无论如何还是烧毁了,有一个损害控制机制肯定有帮助。我希望你明白了。

本节课关于嵌入式系统中断言与契约式设计的内容到此结束。

49:嵌入式单元测试 🧪

在本节课中,我们将探讨测试在软件开发中的核心作用,并学习如何在主机和嵌入式目标上进行单元测试。我们将介绍必要的工具,并通过一个名为Embedded Test(ET)的测试框架进行实践。

概述

软件开发本质上是创建复杂系统的过程。历史经验表明,任何复杂系统都无法通过一次性设计完成,而必须从一个简单、可工作的系统逐步演化而来。测试,特别是持续进行的测试,是驱动这一演化过程的关键选择机制。本节课将聚焦于单元测试,这是开发人员在编写代码时进行的最广泛的测试类型。

复杂系统的演化

上一节我们介绍了软件开发与复杂系统演化的相似性。本节中,我们来看看达尔文的自然选择理论如何启发软件开发。

在1859年达尔文发表《物种起源》之前,主流观点认为所有生物都是以其当前完美且最终的形式被一次性创造出来的。达尔文的自然选择进化论则提出了截然不同的解释:所有生物体都是通过从简单到复杂的渐进、累积的演化过程而产生的。

一个鲜为人知的事实是,在达尔文之后,另一位作者(即本课程讲师Miro Samek)发表了一篇名为《论软件的起源》的著作,将达尔文的思想推广到软件开发领域。该著作提出了三个重要观点:

  1. 一个能工作的复杂系统,总是从一个能工作的简单系统演化而来。
  2. 一个从零开始设计的复杂系统永远不会工作,也无法使其工作。你必须重新开始,从一个能工作的简单系统起步。
  3. (前两点的推论)在嵌入式系统中,除非所有部分都工作,否则没有任何部分能工作

不幸的是,在软件真正被发明时,人们仍然相信“大爆炸”式的设计方法,即进行大量的前期设计,最后进行集中测试。这种方法缺乏增量开发和持续选择这两个对创造复杂事物至关重要的演化要素,因此无法成功。

敏捷开发与测试驱动

在软件领域,通过选择进行演化的重要性被多次重新发现和遗忘。直到最近的“敏捷开发”运动,才完全承认并拥抱测试作为必须持续应用的主要选择机制。

测试不仅用于剔除不良的“适应”(即软件中的Bug),还用于指导整个开发过程。这种以测试为指导的方法也称为测试驱动开发(TDD)

其他敏捷最佳实践,如持续集成(CI)持续交付(CD),看似是最近的发明,实则只是承认了软件系统必须像任何复杂系统一样持续保持工作状态。如果它停止工作,使其再次正常运行就如同试图复活一个死去的生物。

人工选择与测试环境

既然我们已知演化与强选择是软件开发的唯一途径,那么问题就变成了:如何演化软件?测试应提供何种选择机制?

显然,软件开发没有亿万年的深进化时间,客户也不会接受在真实环境中测试软件的所有迭代版本。但我们可以使用人工选择,就像人类驯化所有动植物一样。人工选择在更短的时间尺度上工作,但它要求我们对自己想要选择什么以及如何精确选择负起全部责任。

为此,我们需要一个合适的软件演化起点(第一个可工作的版本),还需要为软件创建一个人工栖息地。这个人工栖息地取决于你希望执行的测试类型。

单元测试框架

本节课重点讨论单元测试,它涉及可以隔离并单独测试的最小粒度组件,例如C语言中的函数和模块。单元测试是(或至少应该是)原始开发人员在编写代码时进行的最广泛的测试类型。

一般来说,测试级别越低,所需的软件栖息地(即测试环境)就越广泛。对于单元测试,这个人工创建的栖息地称为测试工具测试框架

以下是几个可用于嵌入式软件的单元测试框架:

  • CppUTestUnity:两者都在James Grenning的著作《Test Driven Development for Embedded C》中使用。
  • Google Test (gtest):另一个流行的测试框架。

本节课将重点介绍一个独特的测试框架:Embedded Test (ET)。它比其他方案更简单,但可以运行Grenning的TDD书中描述的所有测试。ET使用C语言编写,但不像Unity那样需要测试运行器。它可以在主机计算机和嵌入式板上运行,只需最少的移植工作。

获取与设置ET

ET是采用宽松许可证的开源软件,你可以从GitHub获取。

假设你已经将ET下载为ZIP文件,请将其解压到你为本课程保存项目的目录中。解压后,将主目录重命名为 lesson49

进入 lesson49 目录,在Windows资源管理器中右键点击并选择“在此处打开终端”。

到目前为止,本课程只使用了集成开发环境(如Keil、IAR、TI CCS)。但单元测试通常直接从命令行执行。因此,今天你将使用终端来了解其工作原理。

ET自带示例,让我们从最基本的 basic 示例开始。它虽然简单,但展示了单元测试典型的代码组织方式:被测代码位于 src 子目录,测试代码位于 test 子目录。

首先进入 test 子目录,运行测试。在终端中输入 make 命令。这将调用 make 实用程序,执行当前目录下 Makefile 中规定的构建过程。构建完成后,make 会立即运行测试,这在单元测试中是惯例。

然而,你的机器上可能没有安装 make 实用程序或 GCC 编译器,因此运行 make 可能会失败。

安装构建工具(QTools)

你有几种选择来获取 make 和其他在构建和测试中常用的类Unix风格工具。

一个便捷的选择是使用 QTools collection for Windows,它专门设计为在一个简单的安装中提供所有必需的工具。

  1. 从GitHub的QTools发布页面下载最新的数字签名Windows安装程序(这是最简单的方式)。
  2. 运行安装程序。建议将其安装到没有空格或特殊字符的路径(例如 C:\qp)。
  3. 安装后,QTools的 bin 目录包含 make 等工具,mingw32 目录包含用于主机的GCC编译器,gnuarmeclipse 目录包含用于ARM板的交叉编译器。

如果使用安装程序,QTools目录会自动添加到你的系统 PATH 环境变量中。如果使用ZIP文件,则需要手动修改 PATH

分析测试示例

现在让我们回到基本的单元测试示例,查看被测代码和围绕它的测试。

被测代码非常简单,包含 sum.hsum.c 文件。sum 函数计算并返回两个整数参数的和。

位于 test 子目录中的测试文件更有趣:

  1. 它首先包含被测代码和ET测试框架头文件。
  2. 接着是 setUptearDown 函数,ET在每个测试前后执行它们。
  3. 然后是测试组,它提供这组测试的名称,并在终端中显示输出。
  4. 接下来是单个测试。它们以 TEST 宏开始,后跟测试描述。在测试内部,使用 VERIFY 宏来评估提供的布尔表达式。只有当该测试中所有 VERIFY 表达式都为真时,测试才通过。否则,测试在第一个失败的 VERIFY 处失败。

这个基本测试组还演示了如何跳过一个测试(ET不执行它),以及一个故意失败的测试,该测试会终止测试运行并打印出行号和失败的表达式。ET在一个测试失败后不会执行任何后续测试,因为系统可能处于未知状态。

ET与其他用C编写的单元测试框架(如之前提到的Unity)的一个显著区别是,ET不需要任何测试运行器。在ET中,单个测试只是具有自己作用域的代码块,它们都位于一个测试组函数内。

双目标策略

你可能已经注意到,我们一直在主机计算机上进行测试。James Grenning将这种策略称为双目标,意味着从第一天起,你的代码就被设计为在两个平台上运行:最终的嵌入式目标和你的开发主机。

双目标有时被误认为是在主机上使用QEMU等软件模拟嵌入式目标。但实际上,双目标更简单:你使用主机上的原生编译器(如MinGW GCC)构建嵌入式代码,并在主机上运行测试。

双目标策略有许多好处,例如更快的演化周期(避免了目标硬件瓶颈),以及更容易自动化基于主机的测试。但最重要的是,双目标会影响你的设计,因为为了在主机上测试嵌入式代码,你必须密切关注硬件和软件之间的边界。

在嵌入式目标上运行测试

然而,仅在主机上运行测试是不够的,至少偶尔需要在嵌入式目标上运行测试。ET测试框架专门设计得易于实现这一点。

回到基本的ET示例,除了用于在主机上构建和测试的 Makefile,你还可以找到分别用于在EK-TM4C和Nucleo-C031嵌入式板上测试的 .mak 文件。

在EK-TM4C (Tiva LaunchPad) 上测试:

  1. 将板子连接到计算机,并打开串行终端(如QTools提供的Termite)。
  2. 在终端中,进入 test 目录,运行命令:make -f ektm4c123gxl.mak
  3. 这使用QTools中的ARM交叉编译器构建相同的测试代码。构建后,make 会上传代码到目标板。由于LM Flash工具的一些问题,该Makefile会提示你手动复位板子。复位后,测试执行,串行终端显示测试运行结果,与主机上产生的输出相同。

在Nucleo-C031上测试:

  1. 连接Nucleo板,关闭之前的串行终端并重新打开以连接到新板子。
  2. 运行命令:make -f nucleoc031c6.mak
  3. 你可能会收到提示,要求提供USB驱动器盘符(因为Nucleo板在电脑上显示为USB驱动器,可通过复制二进制文件来编程)。根据你电脑上的盘符进行设置(例如 make -f nucleoc031c6.mak USB_DRIVE=G)。
  4. 该命令将构建、上传并自动在板子上执行基本测试。同样,输出到串行终端的结果与之前相同。

测试框架的工作原理

在嵌入式系统中,最大的问题是如何将信息(例如本例中的测试结果)从板子传输到主机。ET采用的解决方案与第45课中软件跟踪使用的方法类似:通过UART进行通信。这总是取决于特定的板子,因此在 test 目录中,有针对Tiva C和Nucleo板的板级支持包(BSP)

查看其中一个BSP,你会看到三个ET回调函数:

  • ET_onInit:初始化UART。
  • ET_onPrintChar:向UART传输一个字符。
  • ET_onExit:实现在所有测试完成后行为。嵌入式目标无法真正退出,因此此函数会进入一个无限循环,闪烁板载LED。

ET没有使用 printf,因为 printf 是一个庞大的函数,且使其与任何特定UART配合工作依赖于具体实现。ET的设计原则之一是小心避免对标准库或任何其他库的依赖。

但这些限制对主机计算机无关紧要。在ET目录下的 EThost.c 文件中,为这三个回调提供了实现,它使用了标准库中的 fputcexit 函数。

总结与要点

本节课我们一起学习了嵌入式软件测试的核心概念与实践。最重要的收获是:创建非平凡软件的唯一途径是演化它。软件开发的赢家是那些通过应用更好、更智能、更有效的测试技术来更快地消除缺陷,从而更快地演化软件的团队。

这种软件演化由人工选择引导,这需要为测试软件构建人工环境。本节课让你对单元测试的一种人工环境——单元测试框架——有了一个总体认识,但大多数此类框架的总体思路是相似的。

单元测试的新手常常困惑于到底在测试什么。第一印象可能认为测试只关乎被测代码和测试本身。但你必须意识到,所有测试都必然同时涉及被测代码和测试环境。因此,像TDD这样的方法论建议在没有被测代码的情况下开始这个过程。这个首次失败测试的目的,实际上是为了测试环境

最后,当你开始更系统地进行测试时,你会积累很多测试。一方面,这些测试对于检查你的软件是否持续工作以及新功能是否破坏了旧功能(称为回归测试)很有价值。另一方面,测试也是代码,你拥有的代码越多,进展就越慢。诀窍在于找到正确的平衡,并丢弃相关性较低的测试。保留所有测试是一个错误。


如果你喜欢本频道,请给本视频点赞并订阅以保持关注。你也可以访问 statemachine.com/video-course 获取课堂笔记和项目文件下载。最后,所有项目也可以在GitHub的 Quantum Leaps 仓库 “Modern Embedded Programming” 课程中找到。

感谢观看!

50:阻塞还是不阻塞,这是个问题!🚀

概述

在本节课中,我们将探讨嵌入式软件架构中最根本的问题:阻塞。我们将分析阻塞的两种形式,比较阻塞与非阻塞架构的优劣,并了解现代嵌入式开发中架构选择的演变趋势。


什么是阻塞?

阻塞是指代码执行过程中,为了等待某个事件(如时间到达、I/O操作完成)而暂停执行。它主要出现在两种形式中:忙等待轮询基于实时操作系统(RTOS)的上下文切换阻塞

忙等待轮询

忙等待轮询是初学者最常接触的形式。例如,在Arduino经典的“闪烁LED”示例中,核心功能依赖于delay()函数。

void loop() {
  digitalWrite(LED_BUILTIN, HIGH); // 打开LED
  delay(1000);                     // 阻塞等待1000毫秒
  digitalWrite(LED_BUILTIN, LOW);  // 关闭LED
  delay(1000);                     // 阻塞等待1000毫秒
}

delay()函数的内部实现是一个循环,持续检查时间是否到达,在此期间CPU被完全占用,无法执行其他任务。

RTOS中的阻塞

在RTOS中,任务可以通过调用如vTaskDelay()这样的函数来主动阻塞自己。虽然其内部机制(如任务切换、调度)与忙等待截然不同,但从应用开发者的视角看,其行为目的是一致的:让代码在当前位置等待。


为什么阻塞如此重要?

阻塞能力被认为是简化软件开发的最有价值特性之一,也是许多人选择使用RTOS的主要原因。它允许开发者以直观、顺序的方式编写代码,仿佛在独占地使用CPU。

然而,阻塞也是一把双刃剑。在简单的“超级循环”架构中,一旦某个函数发生阻塞,整个循环的进展都会被卡住,这会破坏其他任务的实时性。

上一节我们介绍了阻塞的基本概念,本节中我们来看看阻塞对软件架构可组合性的影响。

阻塞与可组合性

可组合性是指软件组件可以像积木一样方便地添加或移除,而整个系统仍能正常工作。在超级循环中,只要组件不阻塞,它们就基本具备可组合性。

但一旦引入任何形式的阻塞,可组合性就被破坏了。因为一个组件的阻塞会延迟所有后续组件的执行。为了解决这个问题,开发者通常需要将阻塞函数重构成非阻塞的状态机

以下是避免阻塞的一种常见方法:

  1. 使用非阻塞的时间检查(如Arduino的millis()函数)替代delay()
  2. 引入状态变量来记录程序当前所处的阶段。
  3. 在每次循环中,根据状态和条件判断来决定执行什么动作。

这本质上就是一个简易的状态机。


传统解决方案:RTOS与状态机

面对阻塞带来的可组合性问题,嵌入式领域发展出了两种主流的传统解决方案。

方案一:使用RTOS

RTOS的思路是将一个超级循环拆分成多个独立的循环,即线程任务。每个线程都拥有自己的私有栈。

  • 优点:每个线程都可以安全地阻塞,而不会影响其他线程,因为RTOS内核会在线程阻塞时进行切换。
  • 缺点:引入了多线程的复杂性、上下文切换的开销以及每个线程所需的额外栈内存。

方案二:使用状态机(非阻塞)

另一种方案是坚持使用超级循环,但彻底避免阻塞,将所有功能都实现为非阻塞的状态机

  • 优点:保持了单线程的简单性,没有上下文切换开销和额外的栈内存消耗。
  • 缺点:需要开发者手动管理状态,对于复杂逻辑,代码可能变得难以设计和维护。最常见的实现是“意大利面条式代码”或简单的输入驱动状态机。

现代架构的演变

近年来,嵌入式架构的选择发生了显著变化。阻塞不再被视为绝对的好特性,而现代的状态机技术也不再那么令人望而生畏。

趋势一:在RTOS中限制阻塞

即使在基于RTOS的系统中,并发专家也强烈建议限制阻塞的使用,转而采用事件驱动范式。例如,NASA JPL在其所有火星车上使用的架构就遵循这一原则。

在这种架构中,事件循环仅在顶层进行一次阻塞(等待事件),在事件处理过程中绝不阻塞。这大大提高了系统的响应性和可靠性,是任务关键型软件的首选。

趋势二:现代状态机的复兴

传统的、持续运行的输入驱动状态机确实存在开销。但事件驱动的状态机(如UML状态图)只在有事件时才运行,否则不占用CPU。它们与事件循环和主动对象设计模式完美结合,提供了强大的建模能力,同时保持了高效率。


超越传统:更高效的实时内核

一个自然的问题是:如果我们付费使用了RTOS的阻塞能力却尽量避免使用它,那为什么不选择更简单、更高效的内核呢?

确实存在更简单的实时内核,它们不支持自愿阻塞,但也不需要为每个线程分配独立的栈。这类内核在汽车等行业已被广泛使用。

在接下来的课程中,我将介绍两种这样的内核:

  1. 协作式单栈内核(QK):任务必须主动释放CPU。
  2. 完全可抢占的单栈内核(QK):支持基于优先级的抢占,并且完全兼容速率单调分析(RMA)方法。由于避免了阻塞,其实时性分析甚至比传统RTOS更简单。


总结

本节课我们一起学习了阻塞在嵌入式软件架构中的核心地位。

  • 阻塞是决定架构选择的最关键特性。
  • 传统RTOS通过多线程和阻塞来简化编程,但带来了复杂性和开销。
  • 非阻塞状态机保持了简单性,但对开发者要求更高。
  • 现代趋势是朝着事件驱动非阻塞架构发展,即使在RTOS中也是如此,这尤其适用于对可靠性要求极高的系统。
  • 新的单栈实时内核(如QK)提供了比传统RTOS更高的效率和更简单的实时分析,代表了嵌入式架构的重要创新方向。

架构的选择没有银弹,但理解“阻塞还是不阻塞”这个问题,是为你项目选择正确架构的第一步。

51:使用Doxygen和Specxygen生成可追溯文档 📚

在本节课中,我们将学习如何使用Doxygen和其扩展工具Specxygen来为软件项目生成可追溯的文档。我们将从基础的代码注释开始,逐步深入到如何创建符合功能安全标准(如IEC 61508)的、具备双向追溯性的需求、架构和设计文档。

概述:什么是可追溯文档?🔗

可追溯性是任何正式文档系统,尤其是用于管理功能安全的系统的基石。它明确地表示了不同工作产物之间的关系。例如,一个需求可以链接到实现该需求的代码元素,或者一个代码元素可以链接到测试它的单元测试。

IEC 61508功能安全标准强调了追溯性的双向性:

  • 向后追溯:从特定的工作产物开始,将其链接到上游产物。例如,一个代码产物可以链接到原始需求。
  • 向前追溯:从原始产物开始,将其链接到下游的工作项。例如,一个需求可以链接到实现该需求的源代码元素。

向后追溯是定义层次关系(如面向对象编程中的子类和超类)最自然和高效的方式。在大多数文档系统中,开发者需要显式地定义工作产物之间的向后追溯链接。

相比之下,向前追溯链接可以并且应该基于现有的向后追溯链接自动生成。向前追溯通常是递归的,这意味着如果A追溯到B,而B追溯到C,那么A也追溯到C。这种递归的向前追溯对于识别更改某个产物可能带来的后果(称为影响分析)至关重要。

最终,向后追溯和向前追溯共同构成了双向追溯性,这是功能安全认证所高度期望的。

从Doxygen开始:基础代码文档 📝

上一节我们介绍了可追溯性的概念,本节中我们来看看如何从基础的代码文档工具Doxygen入手。

Doxygen是一个能够从带有特殊格式注释的源代码中生成文档的工具。首先,我们需要安装Doxygen。

  1. 访问Doxygen官网并下载适用于您操作系统的版本(本教程以Windows为例)。
  2. 运行安装程序,安装过程非常简单直接。

安装完成后,我们可以为一个项目生成基础文档。假设我们有一个简单的C语言项目,包含头文件、源文件和测试文件。最初,项目对Doxygen一无所知。

我们可以使用Doxywizard(Doxygen的图形化向导)来配置项目:

  • 设置工作目录为项目根目录。
  • 输入项目名称、简介和版本号。
  • 在“扫描”选项卡中,设置源代码目录为当前目录(.),并勾选“递归扫描”以包含子目录。
  • 在“输出”选项卡中,保持默认的HTML和LaTeX输出格式。
  • 运行Doxygen,然后查看生成的HTML文档。

此时生成的文档仅包含函数和数据结构签名等基本信息。Doxygen还会在源代码中提供悬停提示,这对于快速探索新代码库很有帮助。

深入Doxygen:添加详细注释和项目文档 📄

上一节我们使用Doxygen生成了基础文档,本节中我们来看看如何通过添加特殊注释和外部文档文件来丰富文档内容。

为了使Doxygen能够处理注释,注释必须遵循特定格式。多行注释必须以 /**/*! 开头;单行注释则以 /////! 开头。在Doxygen注释内部,可以使用以 \@ 开头的命令来控制文档的生成。

以下是一些常用的Doxygen命令:

  • \brief:提供元素的简要描述。
  • \param:记录函数参数。
  • \page\mainpage:开始一个文档页面。

除了在代码中添加注释,我们还可以创建独立的 .docs 文件来编写项目概述、设计说明等非代码文档。这个文件通过一个大的Doxygen注释块构成,并使用 \mainpage 等命令来组织内容。

在Doxywizard的“专家”选项卡中,我们可以进一步配置项目,例如更改版本号、设置相对路径的Logo、添加图片路径以及启用HTML树状视图。

完成这些配置并重新运行Doxygen后,生成的文档将包含自定义的主页、更详细的函数描述以及便于导航的树状视图。

迈向可追溯性:在Doxygen中嵌入规范文档 🎯

上一节我们为项目添加了丰富的描述性文档,本节中我们来看看如何在Doxygen框架内初步实现可追溯性,例如嵌入软件需求规范。

目标是让需求等规范条目也能像代码一样,被Doxygen显示、引用和搜索。一种简单的方法是将工作项(如需求)表示为Doxygen的子章节(\subsection)。每个需求被赋予一个唯一的标识符,这个UID遵循编程语言标识符的命名规则(仅使用下划线,避免短横线或点)。

在规范文档中,我们可以使用 \ref 命令从其他工作项或代码项引用这些需求UID,从而建立显式提供的向后追溯链接。此外,可以将SRS文档作为主页的一个子页面进行集成。

通过命令行运行 doxygen 命令(确保Doxygen已在系统路径中),可以生成最终的HTML输出。在生成的文档中,需求条目清晰可见,并且可以通过搜索框快速定位。这实现了文档的基本可查找和可引用特性。

然而,目前的文档仅包含手动创建的向后追溯链接。手动维护向前追溯链接不仅工作量大,而且容易与向后追溯链接不同步,违反了DRY(不要重复自己)原则。理想情况下,向前追溯应该自动生成。

引入Specxygen:实现自动化双向追溯 🔄

上一节我们在纯Doxygen中实现了基本的可追溯性,本节中我们将介绍Specxygen,它是一个Doxygen扩展,专门用于自动生成和管理双向追溯性。

Specxygen通过引入一系列自定义命令,为工作项和代码项提供了更结构化的格式,并能自动生成向前追溯部分以及为追溯链接添加简要描述。

首先,需要从GitHub仓库下载Specxygen并解压到项目目录中。Specxygen的使用涉及以下几个关键变化:

  1. 结构化工作项:在规范文档中,使用Specxygen的自定义命令来定义工作项。例如:
    • \spec%UID{UID}{简要描述}\endspec 定义一个工作项。
    • \spec%UID_item{描述} 定义工作项的属性行(如描述)。
    • \spec%UID_bw_trace{brief}\tr{被追溯的UID} 定义向后追溯。
    • \spec%UID_fw_trace 作为一个占位符,指示Specxygen在此处自动生成向前追溯部分。

  1. 代码项追溯:在代码的Doxygen注释中,使用类似的自定义命令(如 \code%UID)来定义代码项的UID,以便被追溯。

  1. 配置与生成:创建一个 spec.json 配置文件,指定需要处理的文件、输出目录等。然后运行Python脚本 specgen.py 来处理文档。该脚本会解析自定义命令,生成包含自动添加的向前追溯和描述性文本的新文档文件(通常放在 specs/ 目录下)。

  1. 集成到Doxygen:更新Doxygen配置文件(Doxyfile),使其包含Specxygen的定义文件,并将输入源设置为原始文档和Specxygen生成的文档。最后,运行Doxygen生成最终输出。

Specxygen生成的文档具有更现代的样式,工作项格式统一,并且包含了完整的双向追溯链接,所有链接都可以点击导航。追溯是递归的,依赖层级通过缩进清晰表示。

为了方便,可以将运行Specxygen脚本和Doxygen的步骤合并到一个批处理文件(如 make.bat)中,一键生成HTML或PDF文档。

总结 🎉

本节课中,我们一起学习了如何利用Doxygen和Specxygen构建强大的自动化文档系统。

  • 我们从Doxygen的基础安装和代码注释开始,学会了生成基本的API文档。
  • 然后,我们探索了如何添加详细的项目文档和初步的规范条目。
  • 最后,我们引入了Specxygen,它通过自定义命令和自动化处理,实现了结构化的规范文档和完整的、自动生成的双向追溯性,为符合功能安全标准的开发提供了有力的免费工具支持。

虽然教程中的示例比较简单,但Specxygen有能力处理真实的复杂项目。你可以访问一些用Specxygen生成的真实项目文档以了解更多。

posted @ 2026-03-29 09:25  布客飞龙II  阅读(13)  评论(0)    收藏  举报