嵌入式系统中的-C---全-

嵌入式系统中的 C++(全)

原文:zh.annas-archive.org/md5/42f6e091a77038cf461472c618ba332e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

C++是一种通用、多范式的编程语言,支持过程式、面向对象和在一定程度上支持函数式编程范式。它最初是带有类的 C 语言,但随着时间的推移,它已经转变为一种现代语言,能够在不牺牲性能的情况下编写高度表达性的代码。尽管如此,C 仍然是嵌入式开发中的主导语言,这主要归因于其简单性和较平缓的学习曲线。

然而,C 语言的简单性往往使得编写复杂系统过于冗长,增加了开发者的认知负担,并使代码更容易出错。这正是 C++的优势所在。凭借泛型编程、运行时和编译时多态性、编译时计算以及增强的类型和内存安全性等特性,它是嵌入式系统开发的绝佳选择。

关于 C++的神话,如代码膨胀和运行时开销,仍然广泛存在。本书首先驳斥这些误解,并引导您了解 C++的基础知识。然后,它将重点转向更高级的现代 C++概念,并将它们应用于解决嵌入式开发中的实际问题。

本书的目标是通过精心挑选的示例和运用良好的软件开发实践,向您展示现代 C++在嵌入式系统中的有效应用。

本书面向的对象

本书面向的是那些在日常工作中主要使用 C 语言,并希望发现现代 C++的嵌入式开发者。虽然期望您对 C++有一定的了解,但不是必需的,因为本书也涵盖了 C++的基础知识。

本书涵盖的内容

第一章破解关于 C++的常见神话,探讨了关于 C++的广泛误解,并系统地予以驳斥。您还将了解 C++的历史和零开销原则。

第二章资源受限嵌入式系统中的挑战,探讨了资源受限嵌入式系统面临的设计挑战,重点关注性能分析技术和内存管理。它还展示了如何避免可能存在问题的语言特性,如异常和 RTTI。

第三章嵌入式 C++生态系统,回顾了嵌入式领域中用于 C++开发的工具,包括工具链、静态分析器、性能分析工具和测试框架。

第四章设置 C++嵌入式项目的开发环境,指导您如何设置现代 C++嵌入式项目的开发环境,包括使用模拟器在虚拟环境中测试您的代码。

第五章类 – C++应用程序的构建块,指导您了解 C++中的类,包括存储持续时间、初始化、继承和动态多态性。

第六章超越类 – 基本的 C++概念,涵盖了 C++的基本特性,如命名空间和函数重载。它还讨论了与 C 的互操作性,并介绍了标准库容器和算法。

第七章强化固件 – 实用的 C++错误处理方法,探讨了 C++中的各种错误处理技术,包括错误代码、断言和全局处理程序。它还解释了异常的机制以及它们是如何工作的。

第八章使用模板构建通用和可重用代码,介绍了模板和概念。它还提供了对模板元编程和编译时多态性的介绍。

第九章使用强类型提高类型安全性,讨论了 C++中的隐式和显式类型转换,并介绍了强类型的概念。一个来自嵌入式库的实用示例演示了如何提高类型安全性。

第十章使用 Lambda 编写表达性代码,介绍了 Lambda,并展示了如何在命令设计模式中使用它们来实现一个表达性中断管理器。

第十一章编译时计算,探讨了 C++的编译时计算能力,并演示了如何使用它们构建一个在编译时生成查找表的信号发生器库。

第十二章编写 C++ HAL,演示了在 C++中使用模板元编程确保类型安全来实施 HAL 的实现。

第十三章与 C 库一起工作,展示了如何在 C++项目中有效地使用 C 库。它通过使用文件系统 C 库的示例演示了 RAII 原则。

第十四章使用序列器增强超级循环,展示了如何使用序列器来改进基于简单超级循环的设计。它还介绍了嵌入式模板库ETL)及其在编译时已知固定大小的容器类模板。

第十五章实用模式 – 构建温度发布者,指导你通过观察者设计模式,并演示了如何在恒温器和 HVAC 控制器等系统中应用它。

第十六章设计可伸缩的有限状态机,探讨了实现有限状态机的不同方法。它从基本的枚举-开关方法开始,引入了状态设计模式,然后介绍了 Boost.SML 库。

第十七章库和框架,突出了 C++标准模板库中对于在受限系统中进行固件开发有用的部分。它还介绍了 CIB 和 Pigweed 库。

第十八章跨平台开发,讨论了在嵌入式软件中实现可移植性和可测试性的良好软件设计的重要性。

为了充分利用这本书

书中的许多示例都可以在 Compiler Explorer (godbolt.org/)中运行。使用它来观察编译器的汇编输出。通过实验示例,调整它们,并用不同的优化级别和编译器标志编译它们,以了解这些更改如何影响编译器输出。

大多数示例也可以在 Renode 模拟器中运行。本书附带的 Docker 容器包括 GCC 工具链和 Renode 模拟器,使您能够在嵌入式目标模拟中运行代码。

本书涵盖的软件/硬件 操作系统要求
Docker Windows, macOS, 或 Linux

如果您正在使用本书的数字版,我们建议您自己输入代码或从本书的 GitHub 仓库(下一节中有一个链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。

下载示例代码文件

该书的代码包托管在 GitHub 上,地址为github.com/PacktPublishing/Cpp-in-Embedded-Systems。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/gbp/9781835881149

使用的约定

本书中使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“PT100类也是TemperatureSensor类,TemperatureController类有一个TemperatureSensor成员(对象)和一个PidController类。”

代码块设置为如下:

#define N 20
int buffer[N];
for(int i = 0; i < N; i ++) {
    printf("%d ", buffer[i]);
} 

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

The output of this simple program might be surprising:
resistance = 3.00 

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。例如:“现在,我们需要通过在执行面板中点击按钮来添加 Google Test 库。”

警告或重要提示如下所示。

小贴士和技巧如下所示。

联系我们

我们始终欢迎读者的反馈。

一般反馈:请通过电子邮件feedback@packtpub.com发送,并在邮件主题中提及本书的标题。如果您对本书的任何方面有疑问,请通过电子邮件questions@packtpub.com联系我们。

勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将非常感谢。请访问 www.packtpub.com/submit-errata,点击 提交勘误 并填写表格。

盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com 联系我们,并附上材料的链接。

如果您有兴趣成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com/

分享您的想法

读完 C++ in Embedded Systems 后,我们非常乐意听到您的想法!请点击此处直接进入这本书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/embeddedsystems

Discord 二维码

下载这本书的免费 PDF 版本

感谢您购买这本书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走吗?

您选择的设备是否与您的电子书购买不兼容?

不要担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM-free PDF 版本。

在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

按照以下简单步骤获取福利:

  1. 扫描下面的二维码或访问以下链接

二维码描述自动生成

packt.link/free-ebook/9781835881149

  1. 提交您的购买证明

  2. 就这些!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件。

第一部分

嵌入式开发中的 C++简介

本书首先探讨关于 C++的常见神话并对其进行驳斥。你将深入了解 C++的历史,并发展对零开销原则的理解。此外,你将考察嵌入式系统中的设计挑战,并学习如何使用 C++来应对这些挑战。本部分还涵盖了嵌入式 C++生态系统,并指导你设置 C++嵌入式项目的开发环境,包括配置工具链、构建系统和模拟器。

本部分包含以下章节:

  • 第一章破解关于 C++的常见神话

  • 第二章有限资源嵌入式系统挑战

  • 第三章嵌入式 C++生态系统

  • 第四章设置 C++嵌入式项目的开发环境

第一章:澄清关于 C++的常见误解

为微控制器和嵌入式系统编写软件具有挑战性。为了充分利用资源受限的系统,嵌入式开发者需要对平台架构有良好的了解。他们需要了解可用的资源,包括处理器能力、可用内存和外设。通过内存映射外设直接访问硬件的需求使得C成为嵌入式系统半个世纪以来的首选语言。

任何编程语言的目标都是将应用特定的抽象转换为可转换为机器代码的代码。例如,面向商业的通用语言COBOL)用于银行应用,而Fortran用于科学研究和大型的数学计算。另一方面,C 是一种通用编程语言,常用于操作系统OSs)和嵌入式系统应用。

C 是一种语法简单、易于学习的语言。语法简单意味着它无法表达复杂的思想。与抽象这些细节的高级语言相比,C 允许进行复杂操作,但需要更明确和详细的代码来管理复杂性。

在 20 世纪 70 年代末,高级语言的性能无法达到 C 的水平。这促使丹麦计算机科学家 Bjarne Stroustrup 开始研究带类的 C,它是 C++的前身。如今,C++是一种以性能为设计目标的泛型语言。C++的起源仍然是某些神话的来源,这常常导致人们在嵌入式系统编程中对其犹豫不决。本章将向你介绍这些神话,并对其进行澄清。本章将涵盖以下主题:

  • C++简史

  • 带类的 C

  • 肥大和运行时开销

技术要求

为了充分利用本章内容,我强烈建议你在阅读示例时使用编译器探索器(godbolt.org/)。选择 GCC 作为你的编译器,并针对 x86 架构。这将允许你看到标准输出(stdio)结果,并更好地观察代码的行为。本章的示例可在 GitHub 上找到(github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter01)。

C++简史

在 20 世纪 60 年代中期,模拟编程语言SIMULA将类和对象引入了软件开发的世界。是一种抽象,它允许我们以简洁的方式在编程中表示现实世界概念,使代码更易于人类阅读。在嵌入式开发中,UARTSPITemperatureSensorPidControllerTemperatureController是一些可以以类形式实现的概念。SIMULA 还引入了类之间的层次关系。例如,PT100类也是TemperatureSensor类,而TemperatureController类有一个TemperatureSensor成员实例(对象)和一个PidController。这被称为面向对象编程OOP)。

在反思编程语言的演变时,C++的创造者 Bjarne Stroustrup 分享了他设计 C++的方法。Stroustrup 的目标是在高级抽象和低级效率之间架起桥梁。他说了以下内容:

我的想法非常简单。从 SIMULA 中汲取一般抽象的想法,以利于人类表示事物,使人类能够理解,同时使用低级的东西,当时最好的语言是 C,这是在贝尔实验室由 Dennis Ritchie 完成的。将这两个想法结合起来,以便可以进行高级抽象,同时足够高效且足够接近硬件,以处理真正要求高的计算任务。

C++最初是由 Bjarne Stroustrup 以 C with Classes 开始,演变成一种现代编程语言,它仍然提供了对硬件和内存映射外设的直接访问。使用强大的抽象,C++使得编写表达性和高度模块化的代码成为可能。C++是一种通用、多范式的语言,支持过程式、面向对象编程,并在一定程度上支持函数式编程范式。

虽然 C 语言仍然是嵌入式开发的首选语言,占嵌入式项目的 60%,但 C++的采用率稳步增长。在嵌入式开发领域的估计使用率为 20-30%,C++提供了类、改进的类型安全和编译时计算等功能。

尽管 C++提供了许多功能,但在嵌入式编程中,C 语言仍然占据主导地位。有许多原因,本章将讨论其中的一些。C++比 C 语言更复杂,这使得初学者开发者更难上手。C 语言更容易学习,并使得初学者开发者能够更快地参与到项目中。

C 语言的简洁性很好,因为它允许初学者开发者更快地开始为项目做出贡献,但它也使得编写复杂的逻辑过于冗长。这通常会导致代码库更大,因为缺乏表现力。这就是 C++介入的地方,它提供了更高的抽象层次,如果得到充分利用,可以使代码更容易阅读和理解。

C++未被更广泛采用的其他原因与关于 C++的神话有关。人们仍然认为 C++仅仅是“带有类的 C”,或者由于标准库中的动态内存分配,使用 C++对于安全性至关重要的系统是完全不可接受的,或者它会产生膨胀代码并增加空间和时间开销。本章将在嵌入式开发背景下解决一些关于 C++最普遍的神话。让我们揭开这些神话,为嵌入式系统中的 C++带来新的光芒!

C with Classes

从历史的角度来看,C++最初是 C with Classes。第一个 C++编译器Cfront将 C++转换为 C,但这已经是很久以前的事情了。随着时间的推移,C 和 C++分别发展,现在由不同的语言标准定义。C 保持了其简洁性,而 C++已经发展成为一门现代语言,它能够为问题提供抽象解决方案,而不牺牲性能水平。但 C++有时仍然被称为 C with Classes,这暗示 C++除了类之外没有增加任何价值。

C++11 标准于 2011 年发布,是 C++的第二大版本。它包含了许多使语言现代化的功能,如基于范围的循环、lambda 和constexpr。随后的版本,C++14、C++17、C++20 和 C++23,继续使语言现代化并引入了使 C with Classes 仅仅成为现代 C++的一个遥远前辈的功能。

Modern C++

为了证明 C++不仅仅是 C with Classes,让我们探索几个简短的 C 代码示例及其现代 C++等价物。让我们从一个简单的示例开始,即从整数缓冲区打印元素:

#define N 20
int buffer[N];
for(int i = 0; i < N; i ++) {
    printf("%d ", buffer[i]);
} 

上述 C 代码可以转换为以下 C++代码:

std::array<int, 20> buffer;
for(const auto& element : buffer) {
    printf("%d ", element);
} 

我们首先注意到的是 C++版本的长度更短。它包含的单词更少,并且比 C 代码更接近英语。它更容易阅读。现在,如果你来自 C 背景并且没有接触过高级语言,第一个版本可能看起来更容易阅读,但让我们比较一下。我们首先注意到的是 C 代码定义了常量N,它决定了buffer的大小。这个常量用于定义buffer,并作为for循环的边界。

C++11 中引入的基于范围的循环消除了在循环停止条件中使用容器大小的认知负担。大小信息已经包含在std::array容器中,基于范围的循环利用这个容器轻松地遍历数组。此外,没有对缓冲区的索引,因为元素是通过常量引用访问的,确保在循环内部不会修改元素。

让我们看看一些简单的 C 代码,它将array_a整数中的所有元素复制到array_b,如果元素小于10

int w_idx = 0;
for(int i = 0; i < sizeof(array_a)/sizeof(int); i++) {
    if(array_a[i] < 10) {
        array_b[w_idx++] = array_a[i];
    }
} 

下面是具有相同功能的 C++代码:

auto less_than_10 =  [](auto x) -> bool {
    return x < 10;
};
std::copy_if(std::begin(array_a), std::end(array_a), std::begin(array_b), less_than_10); 

而不是手动遍历array_a并将超过10的元素复制到array_b中,我们可以使用 C++标准模板库中的copy_if函数。std::copy_if的前两个参数是迭代器,它们定义了在array_a中要考虑的元素范围:第一个迭代器指向数组的开始,第二个迭代器指向最后一个元素之后的位子。第三个参数是指向array_b起始位置的迭代器,第四个是less_than_10 lambda 表达式。

Lambda 表达式是一个匿名函数对象,可以在调用它的位置声明,或者将其作为参数传递给函数。请注意,Lambda 将在第十章中更详细地介绍。在std::copy_if的情况下,less_than_10 lambda 用于确定array_a中的元素是否要复制到array_b。我们也可以定义一个独立的less_than_10函数,该函数接受一个整数并返回一个布尔值,如果它大于 10,但使用 lambda,我们可以将此功能编写得接近我们将其传递给算法的位置,这使得代码更加紧凑和表达。

泛型类型

之前的示例使用了std::array标准库容器。它是一个类模板,它包装了一个 C 风格数组及其大小信息。请注意,模板将在第八章中更详细地介绍。当你使用具有特定底层类型和大小的std::array时,编译器在实例化的过程中定义了一个新类型。

std::array<int, 10>创建了一个容器类型,它有一个底层大小为10的整数 C 风格数组。std::array<int, 20>是一个容器类型,它有一个底层大小为20的整数 C 风格数组。std::array<int, 10>std::array<int, 20>是不同的类型。它们具有相同的底层类型,但大小不同。

std::array<float, 10>会产生第三种类型,因为它与std::array<int, 10>在底层类型上不同。使用不同的参数会产生不同的类型。模板类型是泛型类型,只有在实例化时才会成为具体类型。

为了更好地理解泛型类型并欣赏它们,让我们检查 C 语言中环形缓冲区的实现,并将其与 C++中基于模板的解决方案进行比较。

C 语言中的环形缓冲区

环形循环缓冲区是嵌入式编程中常用的数据结构。它通常通过一组函数实现,这些函数围绕一个数组,使用写和读索引来访问数组的元素。count变量用于数组空间管理。接口由 push 和 pop 函数组成,这里将进行解释:

  • 推送函数用于将元素存储在环形缓冲区中。在每次推送时,数据元素存储在数组中,并增加写入索引。如果写入索引等于数据数组中的元素数量,则将其重置为 0。

  • 弹出函数用于从环形缓冲区中检索一个元素。在每次弹出时,如果底层数组不为空,我们返回数组中由读取索引索引的元素。我们增加读取索引。

在每次推送时,我们增加count变量,在弹出时减少它。如果计数等于数据数组的大小,我们需要将读取索引向前移动。

让我们定义我们想在 C 模块中实现的环形缓冲区的实现要求:

  • 它不应该使用动态内存分配

  • 当缓冲区满时,我们将覆盖最旧的元素

  • 为存储数据到缓冲区并检索它提供推送和弹出功能

  • 整数将被存储在环形缓冲区中

这里是满足先前要求的 C 语言简单解决方案:

#include <stdio.h>
#define BUFFER_SIZE 5
typedef struct {
int arr[BUFFER_SIZE]; // Array to store int values directly
size_t write_idx;     // Index of the next element to write (push)
size_t read_idx;      // Index of the next element to read (pop)
size_t count;         // Number of elements in the buffer
} int_ring_buffer;
void int_ring_buffer_init(int_ring_buffer *rb) {
  rb->write_idx = 0;
  rb->read_idx = 0;
  rb->count = 0;
}
void int_ring_buffer_push(int_ring_buffer *rb, int value) {
  rb->arr[rb->write_idx] = value;
  rb->write_idx = (rb->write_idx + 1) % BUFFER_SIZE;
  if (rb->count < BUFFER_SIZE) {
    rb->count++;
  } else {
    // Buffer is full, move read_idx forward
    rb->read_idx = (rb->read_idx + 1) % BUFFER_SIZE;
  }
}
int int_ring_buffer_pop(int_ring_buffer *rb) {
  if (rb->count == 0) {
    return 0;
  }
  int value = rb->arr[rb->read_idx];
  rb->read_idx = (rb->read_idx + 1) % BUFFER_SIZE;
  rb->count--;
  return value;
}
int main() {
  int_ring_buffer rb;
  int_ring_buffer_init(&rb);
  for (int i = 0; i < 10; i++) {
    int_ring_buffer_push(&rb, i);
  }
  while (rb.count > 0) {
    int value = int_ring_buffer_pop(&rb);
    printf("%d\n", value);
  }
  return 0;
} 

我们使用for循环来初始化缓冲区。由于缓冲区大小为5,值从59将存储在缓冲区中,因为环形缓冲区会覆盖现有数据。现在,如果我们想在环形缓冲区中存储浮点数、字符或用户定义的数据结构怎么办?我们可以为不同类型实现相同的逻辑,并创建一组新的数据结构和函数,称为float_ring_bufferchar_ring_buffer。我们能否创建一个可以存储不同数据类型并使用相同函数的解决方案?

我们可以使用unsigned char数组作为不同数据类型的存储,并使用void指针将不同数据类型传递给推送和弹出函数。唯一缺少的是知道数据类型的大小,我们可以通过向ring_buffer结构添加size_t elem_size成员来解决:

#include <stdio.h>
#include <string.h>
#define BUFFER_SIZE 20 // Total bytes available in the buffer
typedef struct {
unsigned char data[BUFFER_SIZE]; // Array to store byte values
size_t write_idx;                // Index of the next byte to write
size_t read_idx;                 // Index of the next byte to read
size_t count;     // Number of bytes currently used in the buffer
size_t elem_size; // Size of each element in bytes
} ring_buffer;
void ring_buffer_init(ring_buffer *rb, size_t elem_size) {
  rb->write_idx = 0;
  rb->read_idx = 0;
  rb->count = 0;
  rb->elem_size = elem_size;
}
void ring_buffer_push(ring_buffer *rb, void *value) {
  if (rb->count + rb->elem_size <= BUFFER_SIZE) {
    rb->count += rb->elem_size;
  } else {
    rb->read_idx = (rb->read_idx + rb->elem_size) % BUFFER_SIZE;
  }
  memcpy(&rb->data[rb->write_idx], value, rb->elem_size);
  rb->write_idx = (rb->write_idx + rb->elem_size) % BUFFER_SIZE;
}
int ring_buffer_pop(ring_buffer *rb, void *value) {
  if (rb->count < rb->elem_size) {
    // Not enough data to pop
return 0;
  }
  memcpy(value, &rb->data[rb->read_idx], rb->elem_size);
  rb->read_idx = (rb->read_idx + rb->elem_size) % BUFFER_SIZE;
  rb->count -= rb->elem_size;
  return 1; // Success
}
int main() {
  ring_buffer rb;
  ring_buffer_init(&rb, sizeof(int)); // Initialize buffer for int values
for (int i = 0; i < 10; i++) {
    int val = i;
    ring_buffer_push(&rb, &val);
  }
  int pop_value;
  while (ring_buffer_pop(&rb, &pop_value)) {
    printf("%d\n", pop_value);
  }
  return 0;
} 

此环形缓冲区解决方案可以用于存储不同数据类型。由于我们避免了使用动态内存分配,并且data缓冲区大小是在编译时确定的,因此我们在定义环形缓冲区不同实例所需的内存大小时不够灵活。我们遇到的另一个问题是类型安全。我们可以轻松地用指向浮点数的指针调用ring_buffer_push,用指向整数的指针调用ring_buffer_pop。编译器无法解决这个问题,灾难的可能性是真实的。此外,通过使用void指针,我们增加了一层间接引用,因为我们必须依赖内存从数据缓冲区检索数据。

我们能否解决类型安全问题,并使在 C 中定义环形缓冲区的大小成为可能?我们可以使用标记粘贴(##)运算符为不同类型和大小创建一组函数,使用宏。在跳入使用此技术实现的环形缓冲区实现之前,让我们快速通过使用##运算符的简单示例:

#include <stdio.h>
// Macro to define a function for summing two numbers
#define DEFINE_SUM_FUNCTION(TYPE) \
TYPE sum_##TYPE(TYPE a, TYPE b) { \
    return a + b; \
}
// Define sum functions for int and float
DEFINE_SUM_FUNCTION(int)
DEFINE_SUM_FUNCTION(float)
int main() {
    int result_int = sum_int(5, 3);
    printf("Sum of integers: %d\n", result_int);
    float result_float = sum_float(3.5f, 2.5f);
    printf("Sum of floats: %.2f\n", result_float);
    return 0;
} 

DEFINE_SUM_FUNCTION(int)将创建一个接受并返回整数的sum_int函数。如果我们用float调用DEFINE_SUM_FUNCTION宏,它将导致创建sum_float。现在我们已经很好地理解了标记粘贴操作符,让我们继续环形缓冲区的实现:

#include <stdio.h>
#include <string.h>
// Macro to declare ring buffer type and functions for a specific type and size
#define DECLARE_RING_BUFFER(TYPE, SIZE) \
typedef struct { \
    TYPE data[SIZE]; \
    size_t write_idx; \
    size_t read_idx; \
    size_t count; \
} ring_buffer_##TYPE##_##SIZE; \
void ring_buffer_init_##TYPE##_##SIZE(ring_buffer_##TYPE##_##SIZE *rb) { \
    rb->write_idx = 0; \
    rb->read_idx = 0; \
    rb->count = 0; \
} \
void ring_buffer_push_##TYPE##_##SIZE(ring_buffer_##TYPE##_##SIZE *rb, TYPE value) { \
    rb->data[rb->write_idx] = value; \
    rb->write_idx = (rb->write_idx + 1) % SIZE; \
    if (rb->count < SIZE) { \
        rb->count++; \
    } else { \
        rb->read_idx = (rb->read_idx + 1) % SIZE; \
    } \
} \
int ring_buffer_pop_##TYPE##_##SIZE(ring_buffer_##TYPE##_##SIZE *rb, TYPE *value) { \
    if (rb->count == 0) { \
        return 0; /* Buffer is empty */ \
    } \
    *value = rb->data[rb->read_idx]; \
    rb->read_idx = (rb->read_idx + 1) % SIZE; \
    rb->count--; \
    return 1; /* Success */ \
}
// Example usage with int type and size 5
DECLARE_RING_BUFFER(int, 5) // Declare the ring buffer type and functions for integers
int main() {
    ring_buffer_int_5 rb;
    ring_buffer_init_int_5(&rb); // Initialize the ring buffer
// Push values into the ring buffer
for (int i = 0; i < 10; ++i) {
        ring_buffer_push_int_5(&rb, i);
    }
    // Pop values from the ring buffer and print them
int value;
    while (ring_buffer_pop_int_5(&rb, &value)) {
        printf("%d\n", value);
    }
    return 0;
} 

现在,这个解决方案解决了我们的类型安全和定义环形缓冲区大小的难题,但它在实现和使用时都存在可读性问题。我们需要在任意函数之外“调用”DECLARE_RING_BUFFER,因为它基本上是一个定义了一组函数的宏。我们还需要了解它所执行的操作以及它将生成的函数的签名。我们可以通过模板做得更好。让我们看看 C++中环形缓冲区的实现是什么样的。

C++中的环形缓冲区

让我们使用模板制作一个通用的环形缓冲区实现。我们可以使用std::array类模板作为底层类型,并将我们的推入和弹出逻辑围绕它包装。以下是在 C++中ring_buffer类型可能看起来如何的代码示例:

#include <array>
#include <cstdio>
template <class T, std::size_t N> struct ring_buffer {
  std::array<T, N> arr;
  std::size_t write_idx = 0; // Index of the next element to write (push)
  std::size_t read_idx = 0;  // Index of the next element to read (pop)
  std::size_t count = 0;     // Number of elements in the buffer
void push(T t) {
    arr.at(write_idx) = t;
    write_idx = (write_idx + 1) % N;
    if (count < N) {
      count++;
    } else {
      // buffer is full, move forward read_idx
      read_idx = (read_idx + 1) % N;
    }
  }
  T pop() {
    if (count == 0) {
      // Buffer is empty, return a default-constructed T.
return T{};
    }
    T value = arr.at(read_idx);
    read_idx = (read_idx + 1) % N;
    --count;
    return value;
  }
  bool is_empty() const { return count == 0; }
};
int main() {
  ring_buffer<int, 5> rb;
  for (int i = 0; i < 10; ++i) {
    rb.push(i);
  }
  while (!rb.is_empty()) {
    printf("%d\n", rb.pop());
  }
  return 0;
} 

使用模板在 C++中实现的环形缓冲区比 C 中基于标记粘贴的解决方案更易于阅读和使用。ring_buffer模板类可以用来实例化具有不同大小的整数、float 或其他底层类型的环形缓冲区类型。相同的推入和弹出逻辑可以应用于具有不同底层类型的环形缓冲区。我们可以通过模板将DRY原则应用于不同的类型。模板使得泛型类型的实现变得简单,这在 C 中相当具有挑战性和冗长。

模板也被用于模板元编程TMP),这是一种编程技术,其中编译器使用模板生成临时源代码,然后编译器将其与源代码的其余部分合并,并最终编译。TMP 最著名的例子之一是在编译时计算阶乘。TMP 是一种高级技术,将在第八章中介绍。现代 C++还引入了constexpr指定符,这是一种更易于初学者使用的编译时计算技术。

constexpr

C++11 引入了constexpr指定符,它声明了在编译时评估函数或变量的值是可能的。指定符随着时间的推移而演变,扩展了其功能。一个constexpr变量必须立即初始化,并且其类型必须是literal类型(int、float 等)。这就是我们声明constexpr变量的方式:

constexpr double pi = 3.14159265359; 

使用constexpr指定符是声明 C++中编译时常量的首选方法,而不是使用 C 风格的宏方法。让我们分析一个使用 C 风格宏的简单示例:

#include <cstdio>
#define VOLTAGE 3300
#define CURRENT 1000
int main () {
    const float resistance = VOLTAGE / CURRENT;
    printf("resistance = %.2f\r\n", resistance);
    return 0;
} 
The output of this simple program might be surprising:
resistance = 3.00 

VOLTAGECURRENT都被解析为整数字面量,除法的结果也是如此。使用f后缀声明浮点字面量,在这个例子中省略了。使用constexpr来定义编译时常量更安全,因为它允许我们指定常量的类型。这就是我们如何使用constexpr编写相同示例的方法:

#include <cstdio>
constexpr float voltage = 3300;
constexpr float current = 1000;
int main () {
    const float resistance = voltage / current;
    printf("resistance = %.2f\r\n", resistance);
    return 0;
} 
This would result in
resistance = 3.30 

这个简单的例子表明,constexpr编译时常量比传统的 C 风格宏常量更安全、更容易阅读。constexpr指定符的另一个主要用途是向编译器暗示一个函数可以在编译时评估。一个constexpr函数必须满足的一些要求如下:

  • 返回类型必须是一个字面量类型

  • 函数的每个参数必须是一个字面量类型

  • 如果constexpr函数不是一个构造函数,它需要恰好有一个return语句

让我们考察一个使用constexpr函数的简单例子:

int square(int a) {
    return a*a;
}
int main () {
    int ret = square(2);
    return ret;
} 

为了更好地理解底层发生了什么,我们将检查前面代码的汇编输出。汇编代码非常接近机器代码,或者将在我们的目标上执行的指令,因此检查它给我们提供了处理器执行的工作(指令数)的估计。以下是在没有优化的情况下,使用 ARM GCC 编译器为 ARM 架构编译前面程序生成的汇编输出:

square(int):
        push    {r7}
        sub sp, sp, #12
add r7, sp, #0
str r0, [r7, #4]
        ldr r3, [r7, #4]
        mul r3, r3, r3
mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr
main:
push    {r7, lr}
        sub sp, sp, #8
add r7, sp, #0
movs r0, #2
bl      square(int)
        str r0, [r7, #4]
        ldr r3, [r7, #4]
        mov r0, r3
adds r7, r7, #8
mov sp, r7
pop     {r7, pc} 

生成的汇编代码正在执行以下操作:

  • 操作栈指针

  • 调用square函数

  • r0返回的值存储到r7地址中,偏移量为4

  • 从偏移量为4r7地址中加载值到r3

  • r3的值移动到r0,这是 ARM 调用约定用于存储返回值的指定寄存器

我们可以看到输出二进制文件中存在一些不必要的操作,这既增加了二进制文件的大小,又影响了性能。这个例子是有效的 C 和有效的 C++代码,使用 C 和 C++编译器编译它将产生相同的汇编代码。

如果我们为square函数使用constexpr指定符,我们是在指示编译器它在编译时可以评估它:

constexpr int square(int a) {
    return a*a;
}
int main() {
    constexpr int val = square(2);
    return ret;
} 

这段代码导致square(2)表达式的编译时评估,使val整数成为一个constexpr变量,即编译时常量。以下是将生成的汇编代码:

main:
push    {r7}
        sub sp, sp, #12
add r7, sp, #0
movs r3, #4
str r3, [r7, #4]
        movs r3, #4
mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr 

如我们所见,程序返回的值是4,这是square(2)编译时计算的结果。生成的汇编代码中没有square函数,只有编译器为我们执行的计算结果。这个简单的例子展示了编译时计算的力量。当我们知道所有计算参数时,我们可以将重计算从运行时移到编译时,这通常是可能的。这种方法可以用来生成查找表或复杂的数学信号,这将在本书的后续章节中演示。

自从 C with Classes 以来,C++已经走了很长的路。本章中的示例展示了 C++相对于 C 所能提供的功能——表达性强、可读性高、紧凑的代码;标准模板库容器;算法;用户定义的泛型类型;以及编译时计算,仅举几例。我希望我已经成功地打破了 C++只是 C 带类这一神话。关于 C++的下一个常见神话是它会产生臃肿的代码并增加运行时开销。让我们继续打破关于 C++的神话!

肿胀和运行时开销

bloatware这个术语描述的是在设备上预装操作系统的不需要的软件。在编程世界中,不需要的软件描述的是框架、库或语言构造本身插入到二进制中的代码。在 C++中,被指责为导致代码臃肿的语言构造是构造函数、析构函数和模板。我们将通过检查从 C++代码生成的汇编输出来分析这些误解。

构造函数和析构函数

当你提到 C++时,非 C++开发者首先想到的可能是它是一种面向对象的语言,并且你必然会实例化对象。对象是类的实例。它们是占用内存的变量。称为构造函数的特殊函数用于构建或实例化对象。

构造函数用于初始化对象,包括类成员的初始化,而析构函数用于清理资源。它们与对象的生存周期紧密相关。对象通过构造函数创建,当对象变量超出作用域时,会调用析构函数

构造函数和析构函数都会增加二进制文件的大小并增加运行时开销,因为它们的执行需要时间。我们将通过一个简单的类示例来检查构造函数和析构函数的影响,该类有一个私有成员、一个构造函数、一个析构函数和一个获取器:

class MyClass
{
    private:
         int num;
    public:
        MyClass(int t_num):num(t_num){}
        ~MyClass(){}
        int getNum() const {
            return num;
        }
};
int main () {
   MyClass obj(1);
   return obj.getNum();
} 

MyClass是一个非常简单的类,它有一个私有成员,我们通过构造函数设置它。我们可以通过获取器访问它,为了保险起见,我们还声明了一个空的析构函数。以下是没有启用优化编译的上述代码的汇编等价代码:

MyClass::MyClass(int) [base object constructor]:
        push    {r7}
        sub sp, sp, #12
add r7, sp, #0
str r0, [r7, #4]
        str r1, [r7]
        ldr r3, [r7, #4]
        ldr r2, [r7]
        str r2, [r3]
        ldr r3, [r7, #4]
        mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr
MyClass::~MyClass() [base object destructor]:
        push    {r7}
        sub sp, sp, #12
add r7, sp, #0
str r0, [r7, #4]
        ldr r3, [r7, #4]
        mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr
MyClass::getNum() const:
        push    {r7}
        sub sp, sp, #12
add r7, sp, #0
str r0, [r7, #4]
        ldr r3, [r7, #4]
        ldr r3, [r3]
        mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr
main:
push    {r4, r7, lr}
        sub sp, sp, #12
add r7, sp, #0
adds r3, r7, #4
movs r1, #1
mov r0, r3
bl      MyClass::MyClass(int) [complete object constructor]
        adds r3, r7, #4
mov r0, r3
bl      MyClass::getNum() const
        mov r4, r0
nop
adds r3, r7, #4
mov r0, r3
bl      MyClass::~MyClass() [complete object destructor]
        mov r3, r4
mov r0, r3
adds r7, r7, #12
mov sp, r7
pop     {r4, r7, pc} 

如果你不懂汇编,不必担心。我们可以看到有一些用于函数的标签和大量的指令。对于一个简单的类抽象来说,这有很多指令;这是我们不想在我们的二进制文件中出现的冗余代码。更精确地说,我们有 59 行汇编代码。如果我们启用优化,生成的汇编代码将只有几行长,但让我们不进行优化来分析这个问题。我们首先注意到的是析构函数没有做任何有用的事情。如果我们从 C++ 代码中移除它,生成的汇编代码将是 44 行长:

MyClass::MyClass(int) [base object constructor]:
        push    {r7}
        sub sp, sp, #12
add r7, sp, #0
str r0, [r7, #4]
        str r1, [r7]
        ldr r3, [r7, #4]
        ldr r2, [r7]
        str r2, [r3]
        ldr r3, [r7, #4]
        mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr
MyClass::getNum() const:
        push    {r7}
        sub sp, sp, #12
add r7, sp, #0
str r0, [r7, #4]
        ldr r3, [r7, #4]
        ldr r3, [r3]
        mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr
main:
push    {r7, lr}
        sub sp, sp, #8
add r7, sp, #0
adds r3, r7, #4
movs r1, #1
mov r0, r3
bl      MyClass::MyClass(int) [complete object constructor]
        adds r3, r7, #4
mov r0, r3
bl      MyClass::getNum() const
        mov r3, r0
nop
mov r0, r3
adds r7, r7, #8
mov sp, r7
pop     {r7, pc} 

如我们所见,没有调用析构函数,二进制文件中也没有析构函数代码。教训是你不为不使用的东西付费。这是 C++ 的设计原则之一。通过删除析构函数,编译器不需要为它生成任何代码,也不需要在对象变量超出作用域时调用它。

我们必须认识到的是,C++ 不是一个面向对象的编程语言。它是一种多范式语言。它是过程性的、面向对象的、泛型的,甚至在某种程度上是函数式的。如果我们想有只能通过构造函数设置的私有成员,那么我们需要为此付出代价。C++ 中的结构体默认是公有成员,所以让我们将 MyClass 类改为没有构造函数的 MyClass 结构体:

struct MyClass
{
    int num;
};
int main () {
   MyClass obj(1);

   return obj.num;
} 

设置器和获取器函数在面向对象范式中很常见,但 C++ 不是一个(仅仅是)面向对象的编程语言,我们也不必局限于使用设置器和获取器。当我们移除 getNum 获取器时,我们有一个只有一个成员的非常基本的结构体示例。生成的汇编代码只有 14 行长:

main:
push    {r7}
        sub sp, sp, #12
add r7, sp, #0
movs r3, #1
str r3, [r7, #4]
        ldr r3, [r7, #4]
        mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr 

尽管这个例子很简单,但其目的是确立两个基本事实:

  • 你不需要为不使用的东西付费

  • 使用 C++ 并不意味着你必然被绑定到面向对象(OOP)范式

如果我们想使用诸如构造函数和析构函数之类的抽象,我们必须为二进制大小付出代价。在 C++ 中,不实例化对象而使用类型(类和结构体)可以为嵌入式软件设计提供比传统面向对象方法更显著的好处。我们将在接下来的章节中通过详细的例子来探讨这一点。

在这个和之前的例子中,我们以禁用优化的方式编译了 C++ 代码,并能够看到生成的汇编代码结果中存在可以移除的不必要操作。让我们检查最后一个例子在启用 O3 优化级别时的汇编代码:

main:
movs r0, #1
bx lr 

上述汇编是包含类、构造函数、析构函数和获取函数的原例程输出。生成的程序只有两条指令。obj 变量的 num 成员值存储在 r0 寄存器中作为返回值。汇编代码去除了所有与栈操作和将值存储在偏移量为 4 的栈指针中的 r3 相关的必要指令,并将它重新加载到 r3,然后移动到 r0。生成的汇编代码只有几行。

移除不必要的指令是优化过程的工作。然而,在嵌入式项目中,有些人声称优化会破坏代码,因此优化常常被避免。但这真的吗?

优化

未优化的代码会导致不必要的指令影响二进制大小和性能。然而,许多嵌入式项目仍然使用禁用优化的方式构建,因为开发者 不相信编译器,并担心它将 破坏程序。这确实有一定的道理,但事实是,这种情况发生在程序结构不佳时。如果程序包含未定义的行为,则程序结构不佳。

未定义行为的最佳例子之一是带符号的 整数溢出。标准没有定义如果你在你的平台上将 1 添加到带符号整数的最大值会发生什么。编译后的程序不需要执行任何有意义的操作。程序结构不佳。让我们检查以下代码:

#include <cstdio>
#include <limits>
int foo(int x) {
    int y = x + 1;
    return y > x;
}
int main() {
    if(foo(std::numeric_limits<int>::max())) {
        printf("X is larger than X + 1\r\n");
    }
    else {
        printf("X is NOT larger than X + 1\. Oh nooo !\r\n");
    }
    return 0;
} 

使用 GCC 为 x86 和 Arm Cortex-M4 编译代码将产生相同的结果。如果程序未启用优化编译,foo 函数返回 0,你可以在输出中看到 X 不大于 X + 1. 哦不!。编译器执行整数溢出,如果我们传递最大整数值给 foo,它将返回 0。请注意,标准没有指定这一点,这种行为取决于编译器。

如果我们启用优化编译程序,输出将是 X 大于 X + 1, 这意味着 foo 返回 1。让我们检查使用优化编译的程序汇编输出:

foo(int):
        movs r0, #1
bx lr
.LC0:
.ascii "X is larger then X + 1\015\000"
main:
push    {r3, lr}
        movw    r0, #:lower16:.LC0
        movt r0, #:upper16:.LC0
        bl      puts
        movs r0, #0
pop     {r3, pc} 

如我们所见,foo 不执行任何计算。编译器假设程序结构良好,并且没有未定义的行为。foo 总是返回 1。确保程序中没有未定义的行为是开发者的责任。这正是优化会破坏程序的神话仍然存在的原因。将未定义的行为归咎于编译器不处理它更容易。

当然,如果使用优化,编译器中可能存在一个错误,会破坏程序的功能,而如果禁用优化,程序则可以正常工作。这种情况非常罕见,但并非没有发生过,这就是为什么存在诸如单元和集成测试之类的验证技术,以确保代码的功能,无论是否启用优化。

优化通过从机器代码中删除不必要的指令来减少二进制大小并提高性能。未定义行为是编译器依赖的,必须由开发者处理以确保程序结构良好。应实施单元和集成测试等技术来验证程序的功能,以减轻编译器损坏程序的风险。优化过程对于在 C++代码中使用抽象同时保持最小的二进制大小和最大性能至关重要。本书的其余部分我们将使用最高的优化级别O3

我们将要检查的下一个代码膨胀的嫌疑者是模板。它们是如何导致代码膨胀的,它们又给我们的嵌入式代码库带来了什么价值?

模板

使用不同参数实例化模板将导致编译器生成不同的类型,这实际上会增加二进制大小。这是可以预料的。我们在使用占位符操作符和宏在 C 中实现环形缓冲区的泛型实现时也有完全相同的情况。一个替代方案是类型擦除,我们在 C 实现中使用空指针。如果我们施加静态数据分配的限制,它会在灵活性上受损,并且由于指针间接引用而影响性能。

使用泛型类型是设计选择之一。我们可以使用它们,并为此付出二进制大小增加的代价,但如果我们分别实现不同数据类型的环形缓冲区(例如ring_buffer_intring_buffer_float等),这也会发生。维护单个模板类型比在代码库的几个不同地方修复相同的错误要容易得多。泛型类型的使用不会导致二进制大小超过等效单个类型实现的尺寸。让我们通过ring_buffer示例来检查模板对二进制大小的影响:

int main() {
#ifdef USE_TEMPLATES
  ring_buffer<int, 10> buffer1;
  ring_buffer<float, 10> buffer2;
#else
  ring_buffer_int buffer1;
  ring_buffer_float buffer2;
#endif
for (int i = 0; i < 20; i++) {
    buffer1.push(i);
    buffer2.push(i + 0.2f);
  }
  for (int i = 0; i < 10; i++) {
    printf("%d, %.2f\r\n", buffer1.pop(), buffer2.pop());
  }
  return 0;
} 

如果使用USE_TEMPLATES定义构建程序,它将使用泛型ring_buffer type,否则将使用ring_buffer_intring_buffer_float类型。如果我们使用没有启用优化的 GCC 构建此示例,模板版本将导致稍微更大的二进制大小(24 字节)。这是由于使用模板版本时符号表中的符号更大。如果我们从目标文件中删除符号表,它们将具有相同的大小。此外,使用O3构建两个版本将产生相同的二进制大小。

泛型类型不会比我们手动编写实例化类型作为单独类型时增加的二进制大小更多。模板由于在不同编译单元中实例化具体类型而影响构建时间,如果需要,有技术可以避免这种情况。所有与具有相同参数的实例化类型相关的函数都将导致二进制中只有一个函数,因为链接器将删除重复的符号。

RTTI 和异常

C++中的运行时类型信息RTTI)是一种允许在运行时确定对象类型的机制。大多数编译器使用虚表来实现 RTTI。每个具有至少一个虚函数的多态类都有一个虚表,其中包含运行时类型识别的类型信息。RTTI 既增加了时间成本也增加了空间成本。如果使用类型识别,它会增加二进制文件大小并影响运行时性能。这就是为什么编译器有禁用 RTTI 的方法。让我们通过一个基类和派生类的简单例子来考察:

#include <cstdio>
struct Base {
    virtual void print () {
        printf("Base\r\n");
    }
};
struct Derived : public Base {
    void print () override {
        printf("Derived\r\n");
    }
};
void printer (Base &base) {
    base.print();
}
int main() {
    Base base;
    Derived derived;
    printer(base);
    printer(derived);
  return 0;
} 

程序的输出如下:

Base
Derived 

具有虚函数的类有用于动态分发的 v 表。动态分发是一个选择多态函数实现的过程。printer函数接受Base类的引用。根据传递给printer的引用类型(BaseDerived),动态分发过程将选择BaseDerived类中的print方法。v 表也用于存储类型信息。

通过使用作为 RTTI 机制一部分的dynamic_cast,我们可以使用对超类引用或指针来找到类型信息。让我们修改前一个例子中的printer方法:

void printer (Base &base) {
    base.print();
    if(Derived *derived = dynamic_cast<Derived*>(&base); derived!=nullptr) {
        printf("We found Base using RTTI!\r\n");
    }
} 

输出如下:

Base
Derived
We found Base using RTTI! 

正如我们之前提到的,RTTI 可以被禁用。在 GCC 中,我们可以通过向编译器传递-fno-rtti标志来实现这一点。如果我们尝试使用这个标志编译修改后的示例,编译器将报错error: dynamic_cast' not permitted with '-fno-rtti'。如果我们将printer方法恢复到原始实现,删除if语句,并分别启用和禁用 RTTI 来构建它,我们可以注意到当 RTTI 启用时,二进制文件的大小更大。RTTI 在特定场景下很有用,但它会给资源受限的设备增加巨大的开销,因此我们将它保持禁用状态。

另一个在 C++嵌入式项目中经常禁用的 C++特性是异常。异常是一种基于 try-catch 块的错误处理机制。让我们通过一个简单的例子来利用异常来更好地理解它们:

#include <cstdio>
struct A {
  A() { printf("A is created!\r\n"); }
  ~A() { printf("A is destroyed!\r\n"); }
};
struct B {
  B() { printf("B is created!\r\n"); }
  ~B() { printf("B is destroyed!\r\n"); }
};
void bar() {
    B b;
    throw 0;
}
void foo() {
  A a;
  bar();
  A a1;
}
int main() {
  try {
    foo();
  } catch (int &p) {
    printf("Catching an exception!\r\n");
  }
  return 0;
} 

程序的输出如下:

A is created!
B is created!
B is destroyed!
A is destroyed!
Catching an exception! 

在这个简单的例子中,footry 块中被调用。它创建了一个局部对象 a 并调用 barbar 函数创建了一个局部对象 b 并抛出一个异常。在输出中,我们看到 AB 被创建,然后 B 被销毁,接着 A 被销毁,最后我们看到 catch 块被执行。这被称为栈展开,为了使其发生,标准实现通常最常用的是 unwind tables,它们存储有关捕获处理程序、将被调用的析构函数等信息。unwind tables 可以变得很大且复杂,这增加了应用程序的内存占用,并由于运行时用于异常处理的机制而引入了非确定性。这就是为什么异常通常在嵌入式系统项目中被禁用。

摘要

C++ 遵循零开销原则。唯一不遵循此原则的两个语言特性是 RTTI 和异常,这也是为什么编译器支持一个开关来关闭它们。

零开销原则基于我们在本章中确立的两个陈述:

  • 你不需要为不使用的功能付费

  • 你使用的功能与你可以合理手动编写的功能一样高效

在大多数嵌入式项目中,RTTI 和异常都被禁用,所以你不需要为它们付费。使用泛型类型和模板是一种设计选择,并且不比手动编写单个类型(如 ring_buffer_intring_buffer_float 等)更昂贵,但它允许你重用不同类型的代码逻辑,使代码更易于阅读和维护。

在高风险系统中工作不是禁用编译器优化能力的理由。无论我们是在启用或禁用优化的程序中构建,代码功能都需要经过验证。当启用优化时,最常见的错误来源是未定义行为。理解未定义行为并防止它取决于开发者。

现代 C++是一种对嵌入式世界有很多贡献的语言。本书的使命是帮助你发现 C++以及它可以为你的嵌入式项目做什么,所以让我们踏上发现 C++并利用它来解决嵌入式领域问题的道路。

在下一章中,我们将讨论嵌入式系统中的资源限制挑战和 C++中的动态内存管理。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/embeddedsystems

Discord 二维码

第二章:资源受限嵌入式系统中的挑战

如果你正在阅读这本书,那么你很可能对嵌入式系统有很好的了解。嵌入式系统有许多定义,虽然以下定义可能不是最常见的,但它捕捉到了其他定义所共有的本质。嵌入式系统是为特定用途而设计的专用计算系统,具有有限的责任范围,与通用计算系统形成对比。嵌入式系统可以嵌入到更大的电子或机械系统中,或者作为独立设备运行。

嵌入式系统和通用计算设备之间的界限有时是模糊的。我们都可以同意,控制烤面包机或飞机泵的系统是嵌入式系统。手机和早期的智能手机也被认为是嵌入式系统。如今,智能手机更接近通用计算设备的定义。在本书中,我们将专注于使用现代 C++在小型嵌入式系统或资源受限的嵌入式系统上进行固件开发。

资源受限的嵌入式系统通常用于安全关键的应用。它们有责任及时控制一个过程,并且不能失败,因为失败可能意味着人类生命的丧失。在本章中,我们将讨论对安全关键设备软件开发施加的限制以及 C++使用的含义。我们将学习如何减轻这些担忧。

在本章中,我们将讨论以下主要主题:

  • 安全关键和硬实时嵌入式系统

  • 动态内存管理

  • 禁用不想要的 C++特性

技术要求

为了充分利用本章内容,我强烈建议你在阅读示例时使用编译器探索器(godbolt.org/)。选择 GCC 作为你的编译器,并针对 x86 架构。这将允许你看到标准输出(stdio)结果,更好地观察代码的行为。由于我们使用了大量的现代 C++特性,请确保选择 C++23 标准,通过在编译器选项框中添加-std=c++23

本章的示例可在 GitHub 上找到(github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter02)。

安全关键和硬实时嵌入式系统

安全关键嵌入式系统是那些故障可能导致财产或环境损坏、人员受伤甚至生命丧失的系统。这些系统的故障是不可接受的。汽车中的刹车、转向系统和安全气囊是安全关键系统的良好例子。这些系统的正确运行对于车辆的安全运行至关重要。

接下来,我们将分析汽车安全气囊控制单元的实时要求。

安全气囊控制单元和实时要求

安全关键型嵌入式系统通常强制执行严格的实时性要求,这意味着任何错过截止时间都会导致系统故障。气囊控制单元ACU)从加速度计和压力传感器收集数据,运行一个处理收集到的数据的算法,并检测侧面、正面和追尾碰撞。在检测到碰撞后,ACU 控制不同约束系统的部署,包括气囊和座椅安全带张紧器。

ACU 的实现必须能够应对不同的场景,例如传感器和电子设备故障。这些问题可以通过冗余传感器、比较传感器数据、比较数据与阈值以及自检来缓解。最重要的是,ACUs 需要满足时间要求,因为它们只有几毫秒的时间来收集数据、做出决策并启动约束系统的部署。

如果 ACU 未能及时检测到碰撞,它就会失效,但即使它部署约束系统稍晚一点,也会造成比 ACU 根本未启动部署更大的伤害给驾驶员和乘客。这就是为什么 ACU 必须满足严格的实时性要求,而在固件方面,这意味着所有最坏情况的执行时间都必须可预测。

延迟气囊部署的影响是许多关于乘客受伤的研究的主题。以下摘录是来自论文《关于气囊部署时间对正面车辆碰撞中乘客受伤水平影响的研究》的结论部分,该论文发表在 MATEC Web of Conferences 184(1):01007 上,作者为 Alexandru Ionut Radu、Corneliu Cofaru、Bogdan Tolea 和 Dragoș Sorin Dima,概述了延迟气囊部署的模拟结果:

“研究发现,在正面碰撞事件中增加气囊部署时间延迟,乘客头部受伤的概率会增加高达 46%。当气囊点燃时,减少乘客头部与仪表盘/方向盘之间的距离会导致气体膨胀力传递到乘客头部,产生额外的加速度,并使乘客向后移动,增加头部与头枕之间的碰撞伤害潜力。因此,在气囊部署延迟为 0 毫秒时观察到 8%的受伤概率增加,而 100 毫秒的延迟导致头部加速度值增加 54%。因此,气囊的作用被逆转,它不再具有缓冲碰撞的作用,而是产生伤害。”

下图(来源:www.researchgate.net/publication/326715516_Study_regarding_the_influence_of_airbag_deployment_time_on_the_occupant_injury_level_during_a_frontal_vehicle_collision)展示了碰撞和延迟安全气囊部署的图形说明:

图 2.1 – 延迟部署约束系统的碰撞模拟

图 2.1 – 延迟部署约束系统的碰撞模拟

图 2.1 有效说明了如果 ACU 无法满足硬实时要求并产生延迟结果的情况。该图取自论文《关于正面车辆碰撞中安全气囊部署时间对乘员伤害水平影响的研究》。

有多个原因可能导致 ACU 失败并导致无延迟或延迟部署:

  • 传感器故障

  • 电子设备故障

  • 碰撞检测算法失败

  • 固件未能按时完成任务

通过冗余、数据完整性检查、交叉比较、启动和运行时自检来减轻传感器和电子设备故障。这给固件及其正确运行增加了额外的压力。碰撞检测算法可能由于基于不良模型构建或超出固件职责的其他因素而失败。固件的职责是按时向算法提供传感器的数据,在设定的时间窗口内及时执行它,并根据算法的输出采取行动。

测量固件性能和非确定性

我们如何确保固件将在规定的实时要求内运行所有功能?我们进行测量。我们可以测量不同的指标,例如性能分析、对外部事件的响应和 A-B 定时。性能分析将告诉我们程序在哪些函数上花费了最多时间。对外部事件的响应将表明系统对外部事件(如中断或通信总线上的消息)做出响应所需的时间。

A-B 定时和实时执行

处理实时要求时最重要的指标是 A-B 定时。我们测量固件从 A 点到 B 点执行程序所需的时间。A-B 定时可以测量函数的持续时间,但不一定。我们可以用它来测量不同的事情。从 A 点到 B 点可能需要不同的时间,这取决于系统的状态和输入。

进行 A-B 测量的简单方法是通过切换通用输入输出GPIO)并使用示波器测量 GPIO 变化之间的时间。这是一个简单且效果良好的解决方案,但它不具可扩展性,因为我们可能需要一个 GPIO 来测量每个我们想要测量的函数,或者我们可能需要一次只测量一个函数。我们还可以使用微控制器单元(MCU)的内部定时器进行精确测量,并通过 UART 端口输出该信息。这需要我们仅为了测量目的而利用通用定时器。大多数微控制器都有专门的仪器和配置文件单元。

一些基于 ARM 的微控制器具有数据观察点和跟踪DWT)单元。DWT 用于数据跟踪和系统配置文件分析,包括以下内容:

  • 程序计数器PC)采样

  • 循环计数

DWT 生成事件并通过仪器跟踪宏单元ITM)单元输出它们。ITM 单元还可以用于输出固件本身生成的数据,以printf风格。ITM 缓冲数据并将其发送到 ITM 接收器。单线输出SWO)可以用作 ITM 接收器。

我们可以这样利用 DWT 和 ITM 进行配置文件分析:

  1. DWT 可以生成 PC 的周期性采样,并使用 ITM 将它们通过 SWO 发送。

  2. 在主机上,我们捕获和分析接收到的数据。

  3. 通过为我们固件使用链接器映射文件,我们可以生成程序中每个函数花费时间的分布。

这可以帮助我们看到哪个函数花费了最多时间。对于 A-B 时间测量来说,它并不特别有用,但它允许我们在不直接设置 DWT 和 ITM 单元的情况下,看到程序花费最多时间的地方。

使用 GCC 进行软件测试

GNU 编译器集合GCC)通过使用-finstrument-functions标志来支持软件测试,该标志用于测试函数的入口和退出。这会在每个函数中插入具有以下签名的entryexit调用:

__attribute__((no_instrument_function))
void __cyg_profile_func_enter(void *this_fn, void *call_site)
{
}
__attribute__((no_instrument_function))
void __cyg_profile_func_exit(void *this_fn, void *call_site)
{
} 

我们可以利用 DWT 和 ITM 在__cyg_profile_func_enter__cyg_profile_func_exit函数中发送时钟周期数,并在主机上分析它以进行 A-B 时间测量。以下是一个简化的entryexit函数实现的示例:

extern "C" {
__attribute__((no_instrument_function))
void __cyg_profile_func_enter(void *this_fn, void *call_site)
{
    printf("entry, %p, %d", this_fn, DWT_CYCCNT);
}
__attribute__((no_instrument_function))
void __cyg_profile_func_exit(void *this_fn, void *call_site)
{
    printf("exit, %p, %d", this_fn, DWT_CYCCNT);
}
} 

上述实现使用extern "C"作为entryexit测试函数的链接语言指定符,因为它们由编译器与 C 库链接。示例还假设printf被重定向以使用 ITM 作为输出,并且 DWT 中的周期计数器寄存器已启动。

另一个选项是使用 ITM 的时间戳功能,并从 entryexit 仪器函数发送时间戳和函数地址。借助链接器映射文件,我们随后可以重建函数调用的顺序和返回。存在用于发送跟踪的专用格式,例如通用跟踪格式CTF),以及称为跟踪查看器的桌面工具,这些工具可以让我们简化软件仪器化。CTF 是一种开放格式,用于在数据包中序列化一个事件,该数据包包含一个或多个字段。专用工具,如 barectf (barectf.org/docs/barectf/3.1/index.html) 用于简化 CTF 数据包的生成。

事件使用 YAML Ain’t Markup LanguageYAML)配置文件进行描述。barectf 使用配置文件生成一个包含跟踪函数的简单 C 库。这些函数用于源代码中我们想要发出跟踪的地方。

CTF 跟踪可以通过不同的传输层发送,例如 ITM 或串行。可以使用工具如 Babeltrace (babeltrace.org) 和 TraceCompass (eclipse.dev/tracecompass) 分析跟踪。还有其他工具可以简化跟踪生成、传输和查看,例如 SEGGER SystemView。在目标侧,SEGGER 提供了一个小型的软件模块,用于调用跟踪函数。跟踪通过 SEGGER 的 实时传输RTT)协议使用 SWD 发送,并在 SystemView 中进行分析。

我们介绍了 A-B 定时的基本方法。还有更多高级技术,它们通常取决于目标能力,因为有一些更高级的跟踪单元可以用于 A-B 测量。

固件中的确定性与非确定性

如果我们使用 A-B 定时方法来测量函数的持续时间,并且对于相同的输入具有相同的持续时间和函数输出,我们称该函数是确定性的。如果一个函数依赖于全局状态,并且对于相同的输入测量的持续时间不同,我们称它是非确定性的

C++ 中的默认动态内存分配器往往是非确定性的。分配的持续时间取决于分配器的当前全局状态和分配算法的复杂性。我们可以使用不同的全局状态对相同的输入进行持续时间测量,但很难评估所有可能的全局状态,并保证使用默认分配器的最坏情况执行时间WCET)。

动态内存分配的非确定性行为是安全性关键系统的一个问题。另一个问题是它可能会失败。如果没有更多的可用内存或内存碎片化,分配可能会失败。这就是为什么许多安全编码标准,如汽车行业软件可靠性协会MISRA)和汽车开放系统架构AUTOSAR),都反对使用动态内存。

我们将探讨动态内存管理的影响和安全性关键问题。

动态内存管理

C++标准为对象定义了以下存储持续时间:

  • 自动存储持续时间:具有自动存储持续时间的对象在程序进入和退出定义它们的代码块时自动创建和销毁。这些通常是函数内的局部变量,除了声明为staticexternthread_local的变量。

  • 静态存储持续时间:具有静态存储持续时间的对象在程序开始时分配,在程序结束时释放。所有在命名空间作用域内声明的对象(包括全局命名空间)都具有这种静态持续时间,以及使用staticextern声明的对象。

  • 线程存储持续时间:在 C++11 中引入,具有线程存储持续时间的对象与定义它们的线程一起创建和销毁,允许每个线程都有自己的变量实例。它们使用thread_local说明符声明。

  • 动态存储持续时间:具有动态存储持续时间的对象使用动态内存分配函数(在 C++中为newdelete)显式创建和销毁,使软件开发者能够控制这些对象的生命周期。

动态存储为软件开发者提供了极大的灵活性,使得他们能够完全控制一个对象的生命周期。权力越大,责任越大。对象使用new运算符动态分配,并使用delete释放。每个动态分配的对象必须恰好释放一次,并且在释放后不应再被访问。这是一条简单的规则,但未能遵循它会导致一系列问题,例如以下所述:

  • 当动态分配的内存未被正确释放时,会发生内存泄漏。随着时间的推移,这种未使用的内存会积累,可能耗尽系统资源。

  • 悬挂指针发生在指针仍然引用一个已经被释放的内存位置时。访问这样的指针会导致未定义的行为。

  • 当已经释放的内存再次被删除时,会发生双重释放错误,导致未定义的行为。

动态内存管理中的另一个问题是内存碎片化。

内存碎片化

内存碎片化发生在随着时间的推移,空闲内存被分成小块的非连续块时,即使总共有足够的空闲内存,也难以或无法分配大块内存。主要有两种类型:

  • 外部碎片化:当总内存足够满足分配请求,但由于碎片化而没有足够大的单个连续块时,就会发生这种情况。这在内存分配和释放频繁且大小差异显著的系统中最常见。

  • 内部碎片化:当分配的内存块大于请求的内存时,会导致分配块内的空间浪费。这在使用具有固定大小内存块或内存池的分配器以及旨在提供 WCET 的分配器时发生。

内存碎片化导致内存使用效率低下,降低性能或防止进一步分配,即使在看起来有足够内存可用的情况下,也会导致内存不足的情况。让我们在以下图中可视化动态内存分配保留的内存区域:

图 2.2 – 用于动态分配的内存区域

图 2.2 – 用于动态分配的内存区域

图 2.2中,每个块代表在分配过程中分配的内存单元。未分配的区域或使用delete运算符释放的区域是空的。尽管有足够的内存可用,但如果请求分配四个内存单元,由于内存碎片化而没有四个连续的内存块可用,分配将失败。

默认内存分配器的非确定性行为和内存不足的情况是关键安全系统的主要关注点。MISRA 和 AUTOSAR 为在关键安全系统中使用 C++提供了编码指南。

MISRA 是一个为汽车行业使用的电子组件开发的软件提供指南的组织。它是汽车制造商、组件供应商和工程咨询公司之间的合作。MISRA 产生标准也用于航空航天、国防、太空、医疗和其他行业。

AUTOSAR 是由汽车制造商、供应商以及来自电子、半导体和软件行业的其他公司组成的全球发展伙伴关系。AUTOSAR 还制定了关于在关键和安全相关系统中使用 C++的指南。

C++中动态内存管理的安全关键指南

MISRA C++ 2008,它涵盖了 C++03 标准,禁止使用动态内存分配,而 AUTOSAR 的关于在关键和安全相关系统中使用 C++14 语言的指南规定了以下规则:

  • 规则 A18-5-5(必需,工具链,部分自动化)

“内存管理函数应确保以下内容:(a)存在最坏情况执行时间的结果,具有确定性行为,(b)避免内存碎片化,(c)避免内存耗尽,(d)避免不匹配的分配或释放,(e)不依赖于内核的非确定性行为调用。”

  • 规则 A18-5-6(必需,验证/工具链,非自动化)

“应进行一项分析,以分析动态内存管理的故障模式。特别是,应分析以下故障模式:(a)由于不存在最坏情况执行时间而产生的非确定性行为,(b)内存碎片化,(c)内存耗尽,(d)分配和释放不匹配,(e)依赖于对内核的非确定性调用。”

现在严格遵循这两条规则是一项极其困难的任务。我们可以编写一个具有确定 WCET(最坏情况执行时间)并最小化碎片化的自定义分配器,但如何编写一个避免内存耗尽的分配器?或者,如果发生这种情况,我们如何确保系统的非故障?每次调用分配器都需要验证操作的成功,并在失败的情况下,以某种方式减轻其影响。或者我们需要能够准确估计分配器所需的内存量,以确保在任何情况下都不会在运行时耗尽内存。这给我们的软件设计增加了全新的复杂性,并且比通过允许动态内存分配增加的复杂性还要多。

动态内存分配策略的一种折中方法是允许在启动时使用,但在系统运行时不允许。这是联合攻击战斗机空中车辆 C++编码标准所使用的策略。MISRA C++ 2023 也建议在系统运行时不要使用动态内存分配,并作为缓解策略,建议在启动时使用。

C++标准库大量使用动态内存分配。异常处理机制实现也经常使用动态分配。在放弃在嵌入式项目中使用标准库的想法之前,让我们发现std::vector容器的工作原理,并看看 C++提供了什么来缓解我们的担忧。

C++标准库中的动态内存管理

我们引入了std::vector作为标准库中的一个使用动态内存分配的容器。vector是一个模板类,我们可以指定底层类型。它连续存储元素,我们可以使用data方法直接访问底层的连续存储。

以下代码示例演示了向量的使用:

 std::vector<std::uint8_t> vec;
  constexpr std::size_t n_elem = 8;
  for (std::uint8_t i = 0; i < n_elem; i++) {
    vec.push_back(i);
  }
  const auto print_array = [](uint8_t *arr, std::size_t n) {
    for (std::size_t i = 0; i < n; i++) {
      printf("%d ", arr[i]);
    }
    printf("\r\n");
  };
  print_array(vec.data(), n_elem); 

我们创建了一个以uint8_t为底层类型的向量,并使用push_back方法添加了从08的值。示例还演示了对底层连续存储的指针的访问,我们将它作为参数传递给了print_array lambda。

vector的通常分配策略是在第一次插入时分配一个元素,然后每次达到其容量时加倍。存储从08的值将导致 4 个分配请求,如下面的图所示:

图 2.3 – 向量分配请求

图 2.3 – 向量分配请求

图 2**.3 描述了向量的分配请求。为了检查任何平台的向量实现,我们可以重载 newdelete 运算符并监控分配请求:

void *operator new(std::size_t count) {
  printf("%s, size = %ld\r\n", __PRETTY_FUNCTION__, count);
  return std::malloc(count);
}
void operator delete(void *ptr) noexcept {
  printf("%s\r\n", __PRETTY_FUNCTION__);
  std::free(ptr);
} 

new 重载运算符将分配调用传递给 malloc,并打印出调用者请求的大小。delete 重载运算符仅打印出函数签名,以便我们可以看到它何时被调用。一些使用 GCC 的标准库实现使用 malloc 实现了 new 运算符。我们的向量分配调用将产生以下输出:

void* operator new(std::size_t), size = 1
void* operator new(std::size_t), size = 2
void operator delete(void*)
void* operator new(std::size_t), size = 4
void operator delete(void*)
void* operator new(std::size_t), size = 8
void operator delete(void*) 

上述结果使用 GCC 编译器获得,对于 x86_64 和 Arm Cortex-M4 平台都是相同的。当向量填满可用内存时,它将请求分配当前使用内存的两倍数量。然后,它将数据从原始存储复制到新获得的内存中。之后,它删除之前使用的存储,正如我们从前面的生成输出中可以看到的那样。

重载 newdelete 运算符将允许我们全局地改变分配机制,以满足要求确定性的 WTEC 和避免内存不足场景的安全关键指南,这相当具有挑战性。

如果事先知道元素的数量,可以通过使用 reserve 方法优化向量的分配请求:

 vec.reserve(8); 

使用 reserve 方法将使向量请求八个元素,并且只有当我们超出八个元素时,它才会请求更多内存。这使得它对于在启动时允许动态分配,并且我们可以保证在任何时刻元素的数量都将保持在预留内存内的项目非常有用。如果我们向向量中添加第九个元素,它将进行另一个分配请求,请求足以容纳 16 个元素的内存。

C++ 标准库还使得容器可以使用局部分配器。让我们看看向量的声明:

template<
    class T,
    class Allocator = std::allocator<T>
> class vector; 

我们可以看到第二个模板参数是 Allocator,默认参数是 std::allocator,它使用 newdelete 运算符。C++17 引入了 std::pmr::polymorphic_allocator,这是一个根据其构建的 std::pmr::memory_resource 类型表现出不同分配行为的分配器。

可以通过提供一个初始的静态分配的缓冲区来构建一个内存资源,它被称为 std::pmr::monotonic_buffer_resource。单调缓冲区是为了性能而构建的,并且仅在它被销毁时释放内存。使用静态分配的缓冲区初始化它使其适合嵌入式应用。让我们看看我们如何使用它来创建一个向量:

 using namespace std;
  using namespace std::pmr;
  array<uint8_t, sizeof(uint8_t) * 8> buffer{0};
  monotonic_buffer_resource mbr{buffer.data(), buffer.size()};
  polymorphic_allocator<uint8_t> pa{&mbr};
  std::pmr::vector<uint8_t> vec{pa}; 

在前面的例子中,我们做了以下操作:

  1. 创建一个 std::array 容器,其底层类型为 uint8_t

  2. 构建一个单调缓冲区,并为其提供我们刚刚创建的数组作为初始缓冲区。

  3. 使用单调缓冲区创建一个多态分配器,我们用它来创建一个向量。

请注意,该向量来自std::pmr命名空间,它只是std::vector的部分特化,如下所示:

namespace pmr {
    template< class T >
    using vector = std::vector<T, std::pmr::polymorphic_allocator<T>>;
} 

利用单调缓冲区创建的向量将在缓冲区提供的空间中分配内存。让我们通过以下示例来检查此类向量的行为,该示例由之前解释的代码构建而成:

#include <cstdio>
#include <cstdlib>
#include <array>
#include <memory_resource>
#include <vector>
#include <new>
void *operator new(std::size_t count, std::align_val_t al) {
  printf("%s, size = %ld\r\n", __PRETTY_FUNCTION__, count);
  return std::malloc(count);
}
int main() {
  using namespace std;
  using namespace std::pmr;
  constexpr size_t n_elem = 8;
  array<uint8_t, sizeof(uint8_t) * 8> buffer{0};
  monotonic_buffer_resource mbr{buffer.data(), buffer.size()};
  polymorphic_allocator<uint8_t> pa{&mbr};
  std::pmr::vector<uint8_t> vec{pa};
  //vec.reserve(n_elem);
for (uint8_t i = 0; i < n_elem; i++) {
    vec.push_back(i);
  }
  for (uint8_t data : buffer) {
    printf("%d ", data);
  }
  printf("\r\n");
  return 0;
} 

前面的程序将提供以下输出:

void* operator new(std::size_t, std::align_val_t), size = 64
0 0 1 0 1 2 3 0 

我们看到,尽管我们使用了单调缓冲区,程序仍然调用了new运算符。您会注意到对reserve方法的调用被注释掉了。这将导致向量扩展策略,如之前所述。当单调缓冲区的初始内存被使用时,它将退回到上游内存资源指针。默认的上游内存资源将使用newdelete运算符。

如果我们打印用作monotonic_buffer_resource初始存储的缓冲区,我们可以看到向量正在分配第一个元素并将0存储到其中,然后将其翻倍并存储01,然后再次翻倍,存储0123。当它尝试再次翻倍时,单调缓冲区将无法满足分配请求,并将退回到使用默认分配器,该分配器依赖于newdelete运算符。我们可以在以下图中可视化这一点:

图 2.4 – 单调缓冲区资源使用的缓冲区状态

图 2.4 – 单调缓冲区资源使用的缓冲区状态

图 2.4 描述了单调缓冲区资源使用的内部状态。我们可以看到,单调缓冲区资源没有以任何方式释放内存。在分配缓冲区请求时,如果缓冲区中有足够的空间来容纳请求的元素数量,它将返回初始缓冲区中最后一个可用元素的指针。

您会注意到,在此示例中使用的new运算符的签名与之前使用的不同。实际上,标准库定义了不同版本的new和匹配的delete运算符,并且没有检查很难确定标准库容器使用的是哪个版本。这使得全局重载它们并替换实现为自定义版本变得更加具有挑战性,使得局部分配器通常是一个更好的选择。

使用在栈上初始化的缓冲区作为单调缓冲区的多态分配器可能是一个减轻在处理标准 C++库中的容器时动态内存管理强加的一些问题的好选项。我们在向量上展示的方法可以用于标准库中的其他容器,例如listmap,也可以用于库中的其他类型,例如basic_string

缓解动态内存分配的担忧是可能的,但它仍然带来了一些挑战。如果你想要绝对确定你的 C++程序没有调用new运算符,有方法可以确保这一点。让我们探索我们如何禁用不想要的 C++功能。

禁用不想要的 C++功能

你可能已经注意到,我们使用 C 标准库中的printf在标准输出上打印调试信息,而不是使用 C++标准库中的std::cout。原因有两个——std::cout全局对象的实现有一个很大的内存占用,并且它使用动态内存分配。C++与 C 标准库很好地协同工作,使用printf是资源受限系统的良好替代方案。

我们已经讨论了异常处理机制,它通常依赖于动态内存分配。在 C++中禁用异常就像向编译器传递适当的标志一样简单。在 GCC 的情况下,该标志是–fno-exceptions。对于运行时类型信息RTTI)也是如此。我们可以使用–fno-rtti标志来禁用它。

禁用异常将在抛出异常时调用std::terminate。我们可以用我们自己的实现替换默认的终止处理程序,并适当地处理它,如下面的例子所示:

#include <cstdio>
#include <cstdlib>
#include <exception>
#include <array>
int main() {
  constexpr auto my_terminate_handler = []() {
    printf("This is my_terminate_handler\r\n");
    std::abort();
  };
  std::set_terminate(my_terminate_handler);
  std::array<int, 4> arr;
  for (int i = 0; i < 5; i++) {
   arr.at(i) = i;
  }
  return 0;
} 

之前的例子展示了如何通过我们自己的实现使用std::set_terminate来设置终止处理程序。这允许我们处理在运行时不应发生的情况,并尝试从中恢复或优雅地终止它们。C++中的一些功能或行为不能通过编译器标志禁用,但还有其他方法来处理它们,

正如我们之前看到的,我们可以重新定义全局newdelete运算符。我们还可以删除它们,如果在调用new的软件组件中使用,这将导致编译失败,从而有效地防止任何需要的动态内存分配尝试:

#include <cstdio>
#include <vector>
#include <new>
void *operator new(std::size_t count) = delete;
void *operator new[](std::size_t count) = delete;
void *operator new(std::size_t count, std::align_val_t al) = delete;
void *operator new[](std::size_t count, std::align_val_t al) = delete;
void *operator new(std::size_t count, const std::nothrow_t &tag) = delete;
void *operator new[](std::size_t count, const std::nothrow_t &tag) = delete;
void *operator new(std::size_t count, std::align_val_t al, const std::nothrow_t &) = delete;
void *operator new[](std::size_t count, std::align_val_t al,const std::nothrow_t &) = delete;
int main() {
  std::vector<int> vec;
  vec.push_back(123);
  printf("vec[0] = %d\r\n", vec[0]);
  return 0;
} 

之前的例子将因以下编译器消息(以及其他消息)而失败:

/usr/include/c++/13/bits/new_allocator.h:143:59: error: use of deleted function 'void* operator new(std::size_t, std::align_val_t)'
  143 |             return static_cast<_Tp*>(_GLIBCXX_OPERATOR_NEW (__n * sizeof(_Tp), 

通过删除new运算符,我们可以使尝试使用动态内存管理的 C++程序的编译失败。如果我们想要确保我们的程序没有使用动态内存管理,这很有用。

摘要

C++允许极大的灵活性。资源受限的嵌入式系统和安全关键性指南可以对某些 C++功能的用法施加一些限制,例如异常处理、RTTI 以及标准 C++库中容器和其他模块使用动态内存分配。C++承认这些担忧,并提供机制来禁用不想要的特性。在本章中,我们学习了通过本地分配器和重载全局newdelete运算符来缓解动态内存分配担忧的不同策略。

学习曲线陡峭,但值得付出努力,因此让我们继续我们的旅程,探索嵌入式系统中的 C++。

在下一章中,我们将探讨嵌入式开发中的 C++ 生态系统。

第三章:嵌入式 C++生态系统

每个嵌入式系统的核心都有一块微控制器。从基本核心到更现代核心的转变反映了技术的演变。微控制器领域非常广泛,从经济实惠的 8 位和 16 位核心到基于现代 32 位 Arm 和 RISC-V®的微控制器。这种多样的架构影响了工具和编译器的开发。虽然一些制造商选择专注于 C 语言支持,但许多制造商已经认识到 C++的重要性,并在他们的工具链中提供了良好的 C++开发支持。

由于嵌入式系统非常广泛,不可能涵盖所有可用的架构和供应商,我们将重点关注 Arm® Cortex®-M 作为现代微控制器和片上系统SoCs)的主要架构之一。我们将探讨提供对 Arm Cortex-M C++开发支持的可用开发环境和工具链。我们还将探讨诸如静态分析器等工具,学习如何对嵌入式目标进行性能分析,并介绍单元测试等方法。

在本章中,我们将涵盖以下主要主题:

  • 编译器和开发环境

  • 静态分析器

  • 单元测试

  • 性能分析

技术要求

为了充分利用本章内容,我强烈建议在阅读示例时使用编译器探索器(godbolt.org/)。选择 GCC 作为编译器,并针对 x86 架构。这将允许您查看标准输出(stdio)结果,并更好地观察代码的行为。由于我们使用了大量的现代 C++特性,请确保选择 C++23 标准,通过在编译器选项框中添加-std=c++23来实现。

本章的示例可在 GitHub 上找到(github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter03)。

编译器和开发环境

C++在嵌入式系统中的采用受到编译器支持的影响。虽然大多数编译器支持 C 语言,但 C++的支持较慢。如今,根据目标架构和功能安全要求,有各种编译器和工具链可供选择。对 32 位架构如 Arm Cortex-M 的支持通常很好,但支持水平取决于工具链供应商和功能安全要求。

许多供应商提供符合不同行业安全标准的认证编译器的工具功能安全版本。功能安全标准旨在确保软件即使在硬件故障或操作错误的情况下也能正确且安全地运行。IEC 61508 是功能安全国际伞形安全标准,以下是一些行业的标准:

  • ISO 26262:汽车安全标准

  • EN 50128:欧洲铁路安全标准

  • IEC 62304:医疗软件的国际标准

  • IEC 60730-1:家用电器自动电气控制

在选择新项目的编译器时,功能性安全要求是我们清单上的首要事项之一。如果需要合格的编译器,那么我们将局限于提供符合特定标准合格编译器的商业版本。

虽然许多为嵌入式开发提供工具的供应商提供其工具和编译器的功能性安全版本,但也有用于嵌入式系统开发的免费开发环境和开源编译器,可用于非关键应用。

开发环境打包了不同的工具,以使开发过程无缝,并允许您专注于开发。这些工具也可以单独使用,并根据个人或组织偏好进行定制。用于嵌入式开发的工具,无论是单独使用还是集成到开发环境中,如下列出:

  • 代码编辑器:这可以是一个基本的文本编辑器,也可以是一个更高级的工具,如 Vim 或支持语法高亮、自动完成、跨不同源文件进行代码导航和重构等插件的 Visual Studio Code。

  • 编译器和链接器:这些用于将代码转换为目标文件并将它们链接到可烧录到目标的可执行和二进制文件。一些最受欢迎的 C++ 编译器是 GCC、Clang、Arm Compiler for Embedded 和 IAR C/C++ Compiler。

  • 调试器:用于烧录和调试目标。调试系统的一部分是调试器探头和与探头通信以调试连接目标的软件。

  • 构建系统:使用诸如 GNU Make 和 Ninja 等工具来控制编译和链接的过程。CMake 和 Bazel 用于构建自动化和依赖管理。

  • 静态分析工具:这些工具用于分析源代码。根据功能,它们可以检测一些未定义的行为,例如越界访问、未初始化的变量、空指针解引用等。专门的静态分析工具可以检查代码是否符合 MISRA 或 AUTOSAR 标准。

  • 运行时分析器:这些是目标功能、软件仪器和调试器探头组合,用于测量函数执行时间并分析软件的性能。

大多数嵌入式集成开发环境IDE)提供以下功能:

  • 项目创建和组织

  • 构建自动化

  • 调试

一些开发环境集成了更高级的代码分析功能,如下所示:

  • 静态分析

  • 性能分析和性能分析

我们将在下一页介绍一些行业中最常用的开发环境和编译器。

Arm Keil MDK 和 Arm Compiler for Embedded

Arm® Keil® MDK是一套用于在(主要是)Arm Cortex-M 微控制器上进行嵌入式开发的工具集,它包括以下内容:

  • Keil Studio,一套 VS Code 的扩展

  • Keil μVision,一个基于传统 Windows®的 IDE

  • Arm 编译器嵌入式版,一个 C 和 C++编译器

  • Arm 虚拟硬件

Keil StudioKeil μVision都提供了嵌入式开发所需的 IDE 功能,包括针对不同目标的配置、构建和目标调试。

Keil μVision 提供了对 PC-Lint 的支持,这是一个静态的 C 和 C++分析器,而 VS Code(Keil Studio)可以配置为使用clang-tidycppcheck

Keil μVision 集成了 Keil 模拟器,允许在 PC 上的模拟目标上运行固件,并且它还包含了一个作为μVision 调试器一部分的集成分析器。

Arm Keil MDK 附带 Arm 虚拟硬件固定虚拟平台,这是 Arm 的云平台,允许你在模拟目标上运行二进制文件,并为模拟环境中的 CI/CD 提供基础设施。

对于非商业用途(社区版),Keil MDK 有一个基本版本,以及两个商业版本(基本版和专业版),具体取决于可用的功能。只有专业商业版提供了功能安全支持和扩展维护。接下来,我们将介绍 Arm 编译器嵌入式版,这是一个与 MDK 一起提供的 C 和 C++编译器,它还包括链接器和标准库。

Arm 编译器嵌入式版是 Arm 提供的一个 C 和 C++编译器。Arm 还提供了一个根据 IEC 61508、ISO 26262、EN 50128 和 IEC 62304 安全标准认证的功能安全FuSa)版本的编译器。

FuSa 版本仅在 MDK 的最高版——专业版中提供。

Arm 编译器嵌入式版包括以下工具链组件:

  • armclang,一个基于低级虚拟机LLVM)的编译器

  • armlink,一个将对象和库组合起来生成可执行文件的工具

  • Arm C 库

  • 基于 LLVM libc++项目的 Arm C++库

Arm 编译器支持 C++17 标准,而最新版本的 Arm 编译器嵌入式 FuSa 6.16 支持 C++14。尽管我们撰写这本书的时候是 2024 年,但最新版本的 C++标准的支持速度仍然很慢。在 C++17 之后,C++20 和 C++23 也相继发布。

商业编译器对最新 C++标准的支持仍然相当缓慢,这使得这些环境中最新的语言特性不可用。

IAR C/C++编译器和 IAR 嵌入式工作台 Arm

IAR 嵌入式工作台®是用于 Arm Cortex-M、Cortex-R 和 Cortex-A 核心的开发环境(IAR 代表 Ingenjörsfirma Anders Rundgren)。它集成了以下工具:

  • IDE,包括调试器和分析器

  • IAR C/C++编译器

  • IAR C-STAT®,一个静态分析器

  • IAR C-RUN®,一个运行时分析工具

IAR 嵌入式工作台是用于 Arm Cortex-M 核心开发的全面解决方案。IDE 中包含标准工具,如调试器,但也提供了更高级的嵌入式工具,如性能分析器和在模拟器中运行固件。

IAR 提供 C-STAT,这是一种静态分析工具,可以对 MISRAC++2008 等安全编码标准进行静态分析。

IAR 还提供了 C-RUN,这是一种运行时分析工具,通过对你的代码进行仪器化,涵盖了堆检查、边界检查、缓冲区溢出、整数溢出和其他运行时检查。

IAR C/C++编译器从 9.30.1 版本开始支持 C++17。IAR 嵌入式工作台(针对 Arm)的 FuSa 版本,版本 9.50.3(2024 年 2 月),也提供了 C++17 支持。

IAR C/C++编译器和 Arm 编译器是嵌入式开发的商业选项。除了可以期望的商业项目支持外,这些工具的优势在于它们为安全关键项目提供了安全认证版本。

一些微控制器供应商提供他们自己的开发环境版本,通常基于 Eclipse®,为他们的产品提供额外的支持。

供应商支持的 IDE 和 GCC

商业开发环境的替代方案是供应商支持的环境,这些环境主要基于 Eclipse 和GNU 编译器集合GCC)工具以及GNU 项目调试器(GDB)进行调试。例如,ST®的 STM32CubeIDE 和 NXP®的 MCUXpresso。

这些工具包含代码配置器用户界面,可以生成用于 GPIO 配置、时钟设置和外设驱动程序初始化的 C 代码。

一些供应商,如 Nordic Semiconductor®,选择了 VS Code 作为其 IDE 解决方案的基础。他们提供了 GPIO 配置和调试的插件。VS Code 是一个现代代码编辑器,允许开发者使用 IntelliSense 等插件进行代码补全、参数信息、语法高亮等功能,以增强开发体验。

GCC

GCC 是通用中最常用的 C 和 C++编译器之一。它是免费软件,也是非关键应用中最受欢迎的编译器,这些应用不需要合格的编译器。然而,即使是 GCC 也可以是合格的。认证过程包括编译和运行测试程序,并将输出与预期结果进行比较。所有发现的问题都必须记录下来,并必须建立一种流程来减轻这些问题。

除了编译器外,GCC 还包括汇编器和链接器,为用户提供所谓的驱动程序(C 语言的gcc和 C++的g++)。当调用时,驱动程序运行预处理、编译、汇编和链接。以下图示展示了 GCC 编译过程:

图 3.1 – GCC 编译过程

图 3.1 – GCC 编译过程

图 3.1中,我们看到当使用 GCC 编译单个文件main.cpp时会发生什么:

  1. GCC 首先运行预处理器,添加所有由 #include 指令指定的头文件,并在翻译单元中展开宏。

  2. 预处理器阶段的输出将通过编译器运行,生成汇编代码。

  3. 汇编阶段的输出是一个目标文件。

  4. 最后,链接器将目标文件与 C 和 C++ 标准库链接,并生成 ELF 文件。

GCC 驱动程序可以通过提供额外的参数来提供中间阶段的输出。要将预处理器输出重定向到标准输出,可以使用 -E 标志:

arm-none-eabi-g++ -E main.cpp 

如果 main.cpp 包含了 C 标准输入输出 (cstdio) 库,前面的命令将产生一个冗长的输出。您可以编写一个简单的 hello world 程序,并通过运行前面的命令亲自查看,或者您可以使用编译器探索器。

编译器探索器

编译器探索器 (github.com/compiler-explorer/compiler-explorer) 是一个交互式在线编译器,它显示了编译的 C++、Rust、Go 和其他代码的汇编输出。您可以在网上尝试它 (godbolt.org/)。这是一个非常棒的工具,默认情况下显示汇编输出,并且可以用来探索不同编译器和编译器标志下的不同语言特性。

让我们使用编译器探索器来探索 GCC 编译过程。我们将选择 ARM GCC 11.2.1 (none) 作为我们的编译器,并为其提供 -E 标志。ARM GCC 11.2.1 (none),或 arm-none-eabi-gcc,是用于 Cortex-M 架构的 GCC。在下面的图中,我们可以看到编译器探索器中的预处理器输出:

图 3.2 – 编译器探索器:预处理器输出

图 3.2 – 编译器探索器:预处理器输出

图 3.2 中,我们可以看到预处理器为我们简单的 hello world 示例增加了正好 800 行。预处理器遍历 cstdio 文件,解析所有预处理器指令,并将结果粘贴到翻译单元中,从而产生了 808 行代码。

编译器探索器的默认视图是汇编输出,我们可以通过简单地从上一个示例中移除 –E 标志来获取它,如下面的图所示:

图 3.3 – 编译器探索器:汇编输出

图 3.3 – 编译器探索器:汇编输出

图 3.3 中,我们可以看到 GCC 编译过程的生成汇编输出。我们可以看到优化过程用 puts 函数替换了 printf 函数。我们也看不到 puts 函数的主体,因为这个函数是我们链接的 C 标准库的一部分。该过程的下一步如下:

  1. 编译器将生成汇编代码的目标代码。

  2. 链接器将生成的目标代码与包含 puts 实现的 C 标准库链接(以及其他函数)。

在这个简单的例子中,我们经历了 GCC 的编译过程,这不会产生我们可以在微控制器上运行的代码,因为我们还需要执行以下步骤:

  1. 添加时钟和硬件外设初始化代码。

  2. 为我们的目标设置架构和指令集的编译器标志。

  3. 添加包含复位处理程序和 C 和 C++运行时初始化的启动汇编脚本。

  4. 添加一个链接脚本,为目标定义不同的内存区域,包括 RAM 和 Flash 区域。

  5. 为链接器添加指令,以便链接到特定的 C 和 C++标准库。

GCC 编译过程的最后阶段的输出,即链接阶段,是一个可执行和链接格式ELF)文件。ELF 文件使用 objdump 工具转换为二进制或十六进制格式,因为二进制和十六进制格式通常用于闪存过程,以便加载到目标设备上。

从版本 10 开始,GCC 集成了静态分析器,可以通过 –fanalyzer 编译器标志来启用。

静态分析器

静态分析器是遍历源代码并检测代码潜在问题的工具,例如检测未定义行为,或者检查代码是否符合 MISRA®或 AUTOSAR®等安全标准。并非所有静态分析器都具有相同的功能,只有商业版本支持安全标准检查。静态分析器可以检测到的一些问题如下:

  • 使用未初始化的数据

  • 越界数组访问

  • 空指针解引用

  • 除以零

  • 删除后使用、双重删除和其他内存管理问题

我们可以通过向 GCC 驱动程序提供 –fanalyzer 标志来启用 GCC 的静态分析器。以下是一个简单的求和函数的例子,该函数接受一个 std::array<int, 4> 常量引用并返回以下示例中的总和:

#include <array>
int sum(const std::array<int, 4> &arr) {
    int ret;
    for(int elem: arr) {
        ret += elem;
    }
    return ret;
} 

前一个例子的问题是我们没有将 ret 变量初始化为零。在 sum 函数中变量在栈上的分配过程中,ret 变量的值将被填充为分配位置上的任何内容,从而导致未定义行为。我们可以在编译器探索器中添加 -fanalyzer 标志并打开编译器输出,如图下所示截图:

图 3.4 – 编译器探索器:静态分析器,使用未初始化的值

图 3.4 – 编译器探索器:静态分析器,使用未初始化的值

图 3**.4 中,我们可以看到编译器输出在一个新的平面上,这是通过点击 输出(0/42) 启用的。我们可以看到静态分析器已经识别出我们正在使用未初始化的变量,并发出了警告。GCC,像许多其他编译器一样,可以发出编译器警告,也可以检测代码中的不同问题,包括未初始化的变量。我们可以使用 -Wall-Wextra-Wpedantic 等标志启用常规编译器警告,但在这个情况下,它们不会捕获未初始化的变量。

我们可以在以下屏幕截图中看到这一点:

图 3.5 – Compiler Explorer:GCC 警告,未初始化值

图 3.5 – Compiler Explorer:GCC 警告,未初始化值

图 3.5中,我们可以看到 GCC 在常规编译器警告中并未对未初始化的数据发出警告。使用–fanalyzer标志启用静态分析器可以帮助检测问题,但也要记住静态分析需要更多时间,这可能在大型代码库中成为一个问题。还有一个 GCC 标志–Wuninitialized,它应该会对未初始化的变量生成警告。在这个特定的例子中,它只会在程序使用不同于 0 的优化标志(例如,-O2)编译时生成警告。

不同的编译器有不同的功能,包括检测代码中的问题。如果我们使用clang编译器编译这个例子(在 Compiler Explorer 中将编译器切换到armv7-a clang 11.0.1),我们会看到clang编译器会检测到这个未初始化变量的问题并发出警告。此外,静态分析器有不同的功能,因此运行代码通过几个静态分析器是一个好习惯,因为一个可能检测到其他分析器无法检测到的问题,反之亦然。

这里是静态分析器在检测越界访问时的另一个示例:

图 3.6 – Compiler Explorer:静态分析器,越界访问

图 3.6 – Compiler Explorer:静态分析器,越界访问

图 3.6中,我们正在尝试访问一个有四个元素的数组的第五个元素,这将导致未定义的行为。这个问题被 GCC 的静态分析器捕获,并发出描述性的警告。在 GCC 中,警告可以被当作错误处理,这将导致编译失败并且不会生成 ELF 文件。要将警告当作错误处理,只需在 GCC 驱动程序调用中添加-Werror编译器标志。

还有其他常用的静态分析器,最著名的是clang-tidycppcheckclang-tidy可以通过 Compiler Explorer 中的添加工具选项启用。clang-tidy(clang.llvm.org/extra/clang-tidy/)和cppcheck(cppcheck.sourceforge.io/)都易于安装和使用,如前所述,通常使用几个静态分析器来捕捉代码的不同问题是一个好主意。

静态分析器非常适合捕捉常见的编程错误和代码中可能的问题,或者确保代码符合安全标准,但它们不能保证代码确实做了它应该做的事情。为了验证我们固件的实际功能,我们可以在目标上手动测试,或者我们可以使用单元测试为我们的代码的各个部分编写测试用例。

单元测试

单元测试是通过使用提供测试设置、运行和报告基础设施的测试框架来测试代码单元的过程。那么,什么是代码单元呢?这取决于我们想要测试什么;它可以是函数或软件模块,或者我们可以将单元测试视为对工作单元的测试。当用户按下按钮时,固件需要做什么,或者如果我们通过蓝牙低功耗BLE)连接接收到特定的数据包时,它需要做什么?

根据单元测试的粒度,我们可以在个体层面上测试固件的各个组件及其交互,以确保其正常功能。单元测试在与其他软件组件隔离的情况下测试代码单元或工作单元。这迫使我们关注这些单元的功能,并在开发过程中更容易地分配组件之间的责任,从而产生更健壮的软件。

由于生成的二进制文件大小,大多数 C++测试框架不适合在小型、嵌入式目标上运行,尤其是由于标准库中ostream的使用。这使我们有了在主机机器上而不是在嵌入式目标上运行单元测试的选择。这并不是说单元测试不能在嵌入式目标上运行。在目标上运行测试需要更多时间,因为所有测试都需要为目标编译并闪存到目标中,我们还需要在主机机器上有一个捕获报告的机制来读取测试结果。

在主机机器上运行目标外的测试是一种常见做法。然而,这种方法存在一些担忧,因为测试是在不同的架构上运行的,甚至数据类型的大小也可能不同。为了解决这个问题,可以强制使用固定宽度的数据类型(例如,uint8_tint32_t)。此外,主机和目标机器使用的编译器之间可能存在差异,因此建议使用相同的编译器版本。在主机机器上运行测试更快、更简单,但架构和设置之间的差异可能会对测试结果产生影响。存在手动目标测试、系统和集成测试,这些测试可以发现代码功能中的潜在问题,并作为功能验证的额外层。

对于 C++,存在不同的测试框架,以下是一些最常用的:

  • Google Test

  • Catch2

  • Boost.Test

  • CppUTest

我们可以在编译器探索器中轻松尝试它们,通过添加相关的库。首先要做的是添加一个仅执行面板,如图所示:

图 3.7 – 编译器探索器:执行面板

图 3.7 – 编译器探索器:执行面板

图 3.7中,我们添加了一个执行面板,并选择x86-64 gcc 13.2作为编译器。现在,我们需要通过在执行面板中点击按钮来添加 Google Test 库。它将打开一个新窗口,我们可以在此搜索库并将其包含在内,如图所示:

图 3.8 – 编译器探索器:包含库

图 3.8 – 编译器探索器:包含库

图 3.8中,我们搜索 Google Test 库,并通过在下拉菜单中选择版本将其添加到项目中。让我们看看如何使用 Google Test 测试第一章中提到的通用环形缓冲区实现。以下是与环形缓冲区实现和几个简单测试相关的代码:

#include <array>
#include <cstdio>
#include "gtest/gtest.h"
template <class T, std::size_t N> struct ring_buffer {
  std::array<T, N> arr;
  std::size_t write_idx = 0;
  std::size_t read_idx = 0;
  std::size_t count = 0;
  void push(T t) {
    arr.at(write_idx) = t;
    write_idx = (write_idx + 1) % N;
    if (count < N) {
      count++;
    } else {
      read_idx = (read_idx + 1) % N;
    }
  }
  T pop() {
    if (count == 0) {
      return T{};
    }
    T value = arr.at(read_idx);
    read_idx = (read_idx + 1) % N;
    --count;
    return value;
  }
  bool is_empty() const {
      return count == 0;
  }
  std::size_t get_count() const {
      return count;
  }
};
TEST(RingBufferInt, PushPop) {
    ring_buffer<int, 2> rb;
    rb.push(1);
    rb.push(2);
    EXPECT_EQ(rb.pop(), 1);
    EXPECT_EQ(rb.pop(), 2);
}
TEST(RingBufferInt, GetCount) {
    ring_buffer<int, 20> rb;
    for(int i = 0; i < 50; i++) {
        rb.push(i);
    }
    EXPECT_EQ(rb.get_count(), 20);
    for(int i = 0; i < 10; i++) {
        rb.pop();
    }
    EXPECT_EQ(rb.get_count(), 10);
}
int main() {
  testing::InitGoogleTest();
  return RUN_ALL_TESTS();
} 

在前面的例子中,环形缓冲区实现与第一章相同,增加了get_count方法,该方法返回缓冲区当前持有的元素数量。我们使用TEST宏定义了一个测试套件,名为RingBufferInt。我们指定了两个测试,分别命名为PushPopGetCount

PushPop测试中,我们正在测试环形缓冲区的pushpop功能,确保pop将使用EXPECT_EQ宏按正确顺序返回推送的值。

GetCount测试中,我们通过以下场景检查缓冲区持有的元素数量是否与预期功能匹配:

  1. 我们首先向缓冲区推送 50 个值,该缓冲区最多可以容纳 20 个值,确保get_count将返回20

  2. 然后,我们从缓冲区中弹出 10 个值,并检查计数是否等于10

运行前面的程序将在标准输出上生成 Google Test 的报告,如图所示:

图 3.9 – 编译器探索器:Google Test 执行

图 3.9 – 编译器探索器:Google Test 执行

图 3.9中,我们可以在执行面板中看到测试的结果。TEST宏将确保测试自动注册到框架中,因此我们不需要手动添加。这使我们能够专注于编写利用框架提供的基础设施的测试。Google Test 提供了更多功能,这个例子只是对其能力的一瞥。

编写单元测试让我们思考我们的代码如何与系统中的其他软件模块交互。通过关注代码单元,我们可以编写松散耦合的代码,使我们的软件更加灵活和健壮。单元测试对于像测试驱动开发TDD)这样的开发技术至关重要,它要求我们在编写代码之前编写测试。在编写单元测试后,我们编写实际代码只是为了通过测试,然后添加更多测试,重构实现,并迭代这个过程。

单元测试是验证我们代码功能的有效工具,无论我们在目标设备还是主机平台上运行它们。然而,它们并没有告诉我们很多关于固件性能的信息。为了做到这一点,我们需要在目标设备上运行生产固件,并使用性能分析工具来测量性能。

性能分析

在目标设备上运行代码并进行性能分析是确保关键功能最坏情况执行时间WCET)的最佳方法,并在必要时进行必要的优化。

性能分析的挑战在于它是一种侵入性操作,因为代码源需要修改或插桩以启用可以告诉我们更多关于目标内部发生情况的跟踪。

性能分析依赖于目标设备的能力。一些核心集成了用于跟踪的单元,正如我们在上一章中看到的,这提供了最小侵入性的性能分析。此外,一些目标设备具有特殊的接口,允许通过连接到主机的高级调试和跟踪探针进行高速跟踪数据传输。以下图示展示了用于某些 Cortex-M 目标设备的性能分析基础设施示例:

图 3.10 – Arm 目标设备通过调试探针连接到主机机

图 3.10 – 通过调试探针连接到主机机的 Arm 目标设备

图 3.10中,我们可以看到一个通过调试探针连接到主机机的 Arm 目标设备。性能分析或跟踪数据流可以通过以下步骤进行描述:

  1. 使用 DWT 对程序计数器PC)进行采样并生成一个事件。

  2. ITM 通过单线输出 SWO将 DWT 生成的事件和插桩代码发送到调试探针。

  3. 调试探针将跟踪数据通过 USB 传输到主机上的捕获软件。

  4. 捕获软件通常是更大软件包的一部分,可以分析和可视化捕获到的接收数据。

为了获得关于函数执行时间的精确信息,源代码需要通过添加生成跟踪数据的指令来进行代码插桩。我们在上一章中看到了如何使用 GCC 编译器的功能为每个函数的入口和退出添加指令来实现这一点。这些数据可以通过 ITM 发送到在主机上运行的性能分析软件。这种方法具有很好的准确性,但通过向代码中添加指令,我们为了测量目的而降低了性能。

PC 采样可能比代码插桩更不侵入,但它的准确性较低,只能用于检测固件中的瓶颈,而无需精确的计时信息。

一些 Arm 核心集成了嵌入式跟踪宏单元ETM)。ETM 记录指令执行,生成跟踪数据,并将其发送到连接的探头。有了指令跟踪数据,性能分析器可以精确测量函数的执行时间,并为每个函数调用创建调用图,就像代码仪器化一样。ETM 使得在不产生仪器化成本的情况下进行代码性能分析成为可能。

代码仪器化仍然是一个非常常见的方法,因为它对目标集成的跟踪能力依赖较少。SEGGER 的 SystemView 是嵌入式目标性能分析器的一个例子。正如我们在上一章中简要讨论的,我们需要在目标上使用 SEGGER 的 SystemView 和 RTT 库来启用跟踪生成。在下面的内容中,您可以看到 SystemView 生成的数据:

图 3.11 – SystemView

图 3.11 – SystemView

图 3.11中,我们可以看到来自仪器化固件的功能名称,包括最小和最大运行时间。性能分析代码可以帮助优化固件中时间关键部分,从而确保系统的时序要求。

摘要

在本章中,我们发现了嵌入式领域 C++开发的可用工具。有各种各样的开发环境和编译器。虽然商业解决方案提供保证支持,并且有工具的功能安全版本,但免费工具也很常见,如果需要甚至可以合格。

静态分析器可以帮助避免常见的编程问题,并确保符合安全指南。通过使用单元测试,我们可以验证我们固件的功能,而性能分析器可以帮助检测瓶颈、测量 WCET 以及确保时序要求。

在下一章中,我们将使用选定的免费工具为嵌入式应用创建 C++的开发环境。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/embeddedsystems

第四章:设置 C++嵌入式项目的开发环境

在上一章中,我们探讨了嵌入式工具生态系统,并回顾了行业中最广泛使用的工具。现在,我们将为现代嵌入式开发环境和其各个组件设定要求。然后,我们将设置我们的开发环境,以便运行本书剩余部分提供的示例。

集成环境的主要卖点之一是它们易于使用。它们通过简单的安装步骤为您提供所需的一切。另一方面,定制环境需要单独安装所有组件,包括每个组件的所有依赖项。确保可重复构建和可靠的调试环境非常重要,因此容器化定制环境具有重要意义。

您将获得一个用于本书中使用的开发环境的 Docker 容器,但我们将单独分析其所有组件。了解我们日常工作中使用的工具对于理解和控制其背后的流程是必要的。

在本章中,我们将涵盖以下主要主题:

  • 现代软件开发环境的要求

  • 容器化开发环境

  • 容器化开发环境和 Visual Studio Code

技术要求

对于本章,您需要安装 Docker(www.docker.com/)。 请遵循为您的特定操作系统提供的安装说明。本章将指导您完成下载和运行具有预配置开发环境的容器的基本步骤。对于更高级的 Docker 使用,请参阅他们网站上可用的官方 Docker 文档。

本章的代码可在 GitHub 上找到(github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter04)。。)

现代软件开发环境的要求

固件开发与其他任何形式的软件开发没有区别,我们使用的工具对于有效工作至关重要。为了尽可能使本书和示例易于访问,我们设定的第一个要求是使用免费工具。编译器是每个开发要求的基础和最重要的部分,因此让我们定义要求并选择适合我们需求的编译器。

编译器

由于我们正在探索现代 C++,因此我们需要支持 C++23 标准的编译器。ARM GNU Toolchain(基于 GCC)的最新版本是 13.2;它支持 C++23 且免费。它也是 ARM 开发中最常用的免费编译器工具链,使其成为我们的编译器的完美选择。

ARM GNU 工具链包含 C 和 C++ 编译器,GNU 调试器GDB),我们将用它进行调试,以及其他有用的工具,如 objcopyobjdumpsize 等,可以从 developer.arm.com/downloads/-/arm-gnu-toolchain-downloads. 下载。我们需要的 Arm Cortex-M 架构是 arm-none-eabi

ARM GNU 工具链 arm-none-eabi 可用于所有常见的主机架构:

  • GNU/Linux x86_64 和 AArch64 主机架构

  • 仅适用于 Windows x86 主机架构(兼容 x86_64

  • macOS x86_64 和 Apple Silicon

编译单个文件或少数几个文件就像在终端运行几个命令一样简单,但构建即使是简单的嵌入式项目也涉及以下步骤:

  1. 编译所有 C 和 C++ 源文件、包含主函数的文件,以及至少来自 硬件抽象层HAL)的几个文件。你将在 第十二章 中了解更多关于 HAL 的内容。

    1. 设置编译器包含路径。

    2. 设置编译器 C 和 C++ 标志。

    3. 设置编译器定义宏。

    4. 编译启动汇编脚本。

    5. 设置链接器选项,包括链接器脚本、静态库、CPU 架构和指令集以及标准库选项。

在这样做之后,我们必须将 ELF 文件转换为闪存程序常用的其他格式,例如 binhex

在终端手动运行所有这些任务将是一个繁琐的过程,因此我们开发环境的下一个要求是 构建自动化。构建自动化的第一个候选者是 make 工具。它是一个常用的工具,用于自动化不同行业中大量软件项目。它非常适合这项任务,但它是一个语法古怪的老工具。然而,我们可以使用 CMake,这是一个更灵活的工具,具有更现代的语法,可以为我们生成 Makefiles。

构建自动化

CMake 不是一个实际的构建自动化工具,但它为其他自动化工具生成文件,例如 make 工具。它是跨平台的、免费的、开源的构建自动化过程软件,涉及测试、打包和安装软件。它通过使用编译器无关的方法来实现。

我们将使用 CMake 来帮助我们为 make 工具生成目标,该工具将执行以下操作:

  • 配置源文件,包括路径和链接器设置以构建 ELF 文件

  • 将 ELF 文件转换为十六进制和二进制格式

  • 启动模拟器并加载生成的 ELF 文件

我们将使用构建自动化工具不仅来构建固件,还要启动将运行固件的模拟器。

模拟器

为了使本书对更广泛的读者群体可访问,我们将使用 模拟器 来运行为 ARM Cortex M 目标编译的示例。Renode (github.com/renode/renode) 是一个开源的模拟框架,对 ARM 目标有良好的支持。

Renode 允许您运行具有多个目标的模拟,并在它们之间模拟无线和有线连接。我们将使用一个简单场景,其中涉及在单个目标上运行模拟。Renode 还可以启动 GDB 服务器,允许您连接到它并调试目标。

我们将使用高度可配置的 Visual Studio Code 集成模拟执行和调试,以及编译器和构建自动化。

代码编辑器

Visual Studio Code 是一个现代且灵活的代码编辑器。它为我们提供了将所有工具集成到单个环境中的所有扩展。我们将在 Visual Studio Code 中安装以下扩展:

  • C/C++:此扩展提供语法高亮、代码自动完成和代码导航

  • Cortex-Debug:此扩展允许通过 GDB 进行调试

  • CS 128 Clang-Tidy:此扩展将 clang-tidy 集成到 Visual Studio Code 中

  • Dev Containers:此扩展将连接到正在运行的容器,并用于开发目的

我们将基于 Docker 容器构建我们的开发环境。Visual Studio Code 将连接到该容器并使用它。

容器化开发环境

Visual Studio Code Dev Containers 扩展允许 Visual Studio Code 连接到一个正在运行的 Docker 容器,并使用其中安装的所有工具。要使用此功能,我们需要构建一个容器。

我们将使用 Docker 构建以下工具的容器:

  • ARM GNU Toolchain 版本 13.2

  • CMake 和 make 工具

  • Renode 版本 1.14

请确保您已按照官方网站上提供的说明在主机机器上安装了 Docker (docs.docker.com)。

您可以在本书的 GitHub 仓库中找到用于构建容器的 Dockerfile (github.com/PacktPublishing/Cpp-in-Embedded-Systems),位于 Chapter04 文件夹中。

您还可以从 Docker Hub (hub.docker.com/) 下载一个镜像。您可以使用以下命令拉取它:

$ docker pull mahmutbegovic/cpp_in_embedded_systems:latest 

请确保按照您平台上的说明启动了 Docker 守护进程;它们可在官方网站上找到。在下载镜像后,使用以下命令启动 Docker:

$ docker run -d -it --name dev_env mahmutbegovic/cpp_in_embedded_systems 

这将在分离和交互模式下启动 Docker 容器。如果您已经使用 docker run 命令创建了一个 Docker 容器,您需要通过运行以下命令来启动它:

$ docker start dev_env 

要访问已启动容器的 bash,我们可以使用以下命令:

$ docker exec -it dev_env /bin/bash 

如以下截图所示,我们可以运行各种命令以确保容器中已安装编译器、调试器、模拟器和其它工具:

图 4.1 – 开发环境容器 bash

图 4.1 – 开发环境容器 bash

图 4.1显示了使用我们用来检查已安装工具版本的命令所期望的输出。

我们可以使用运行中的容器作为一个自包含的环境。让我们首先通过克隆项目 GitHub 仓库(github.com/PacktPublishing/Cpp-in-Embedded-Systems)::)开始

$ git clone https://github.com/PacktPublishing/Cpp-in-Embedded-Systems.git 

完成这些后,转到Chapter04/bare文件夹。这个文件夹包含我们将要在 Renode 中运行的 STM32F072 的Hello, World!示例固件。项目组织成以下文件夹:

  • app: 包含业务层代码,包括main.cpp

  • hal: 包含 HAL C++代码

  • 平台: 包含特定平台的代码,包括 C 语言中的 ST 提供的 HAL 层、CMSIS、启动脚本和链接脚本

  • renode_scripts: 包含 Renode 模拟器脚本

在项目文件夹中,你也会看到CMakeLists.txt,这是一个我们将用来指定如何构建固件的 CMake 文件。让我们通过一个例子来学习如何使用 CMake。

使用 CMake 构建 Hello, World!程序

我们可以使用 CMake 指定工具链、源文件、编译器包含路径和编译器标志。在 CMake 文件中,我们必须做的第一件事是指定正在使用的 CMake 版本,如下所示:

cmake_minimum_required(VERSION 3.13) 

CMake 是一个强大的工具,它允许我们编写高度灵活的构建文件。我们可以在单独的文件中编写工具链细节并将它们包含在主项目文件中,这样我们就可以为不同的架构重用它们。然而,在我们的例子中,我们在主 CMake 文件中包含工具链细节。以下行指定了各种工具链组件:

set(CMAKE_C_COMPILER “arm-none-eabi-gcc”)

set(CMAKE_CXX_COMPILER “arm-none-eabi-g++”)

set(CMAKE_ASM_COMPILER “arm-none-eabi-gcc”)

使用CMAKE_C_COMPILERCMAKE_CXX_COMPILERCMAKE_ASM_COMPILER CMake 变量,我们分别指定 C、C++和汇编编译器的路径。我们需要使用所有三个,因为我们的项目包含用 C 编写的 ST 提供的 HAL、我们的 C++代码和一个汇编启动脚本。

现在,我们必须通过在CMakeLists.txt文件中运行以下行来指定各种编译器选项和预处理器宏:

set(CDEFS “-DUSE_HAL_DRIVER -DSTM32F072xB”)

set(MCU “-mcpu=cortex-m0 -mthumb”)

set(COMMON_FLAGS “${MCU} ${CDEFS} -fdata-sections -ffunction-sections -Wno-address-of-packed-member -Wall -Wextra -Wno-unused-parameter”)

set(CMAKE_C_FLAGS “${COMMON_FLAGS}”)

set(CMAKE_CXX_FLAGS “${COMMON_FLAGS} -Wno-register -fno-exceptions -fno-rtti -fno-threadsafe-statics”)

在这里,我们设置了 USE_HAL_DRIVERSTM32F072xB 编译时宏,这些宏由 ST 的 HAL 使用。然后,我们设置了用于 C 和 C++ 文件的编译器标志:

  • -mcpu=cortex-m0-mthumb:架构特定的标志。

  • -fdata-sections: 此选项告诉编译器将数据项放置在结果对象文件中的自己的部分。这可以用于优化目的(删除未使用的部分)。

  • -ffunction-sections: 与 -fdata-sections 类似,但用于函数。每个函数都获得自己的部分,允许链接器可能丢弃未使用的函数。

  • -Wno-address-of-packed-member: 抑制与结构体打包成员的地址相关的警告。

  • -Wall: 启用所有推荐的正常操作中的常见警告消息。

  • -Wextra: 启用 -Wall 未启用的额外警告标志。

    • -Wno-unused-parameter: 禁用关于函数中未使用参数的警告。

然后,我们设置 C++ 特定的编译器标志:

  • -Wno-register: 禁用关于使用 register 关键字的警告,该关键字在现代 C++ 中已弃用,但可能在旧代码中使用

  • -fno-exceptions: 禁用 C++ 中的异常支持

  • -fno-rtti: 禁用 运行时类型信息RTTI

  • -fno-threadsafe-statics: 防止编译器使用额外的代码来确保静态局部变量以线程安全的方式初始化

我们 CMake 文件的下一部分是项目特定的:我们必须声明一个新的项目,给它一个名称,启用我们想要使用的语言,并指定 CMake 目标、源文件和链接器选项。

这是我们的 C++(混合 C)项目的基本设置编译器设置:

project(bare VERSION 1.0.6)
enable_language(C CXX ASM)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED True)
# global include directories
include_directories(
  ${CMAKE_SOURCE_DIR}/platform/inc
  ${CMAKE_SOURCE_DIR}/platform/CMSIS/Device/ST/STM32F0xx/Include
${CMAKE_SOURCE_DIR}/platform/CMSIS/Include
${CMAKE_SOURCE_DIR}/platform/STM32F0xx_HAL_Driver/Inc
  ${CMAKE_SOURCE_DIR}/app/inc
  ${CMAKE_SOURCE_DIR}/hal/uart/inc
  ${CMAKE_SOURCE_DIR}/hal/inc
  )
set(EXECUTABLE ${PROJECT_NAME}.elf)
add_executable(
  ${EXECUTABLE}
  platform/STM32F0xx_HAL_Driver/Src/stm32f0xx_hal.c
  platform/STM32F0xx_HAL_Driver/Src/stm32f0xx_hal_cortex.c
  platform/STM32F0xx_HAL_Driver/Src/stm32f0xx_hal_gpio.c
  platform/STM32F0xx_HAL_Driver/Src/stm32f0xx_hal_rcc.c
  platform/STM32F0xx_HAL_Driver/Src/stm32f0xx_hal_uart.c
  platform/STM32F0xx_HAL_Driver/Src/stm32f0xx_hal_uart_ex.c
  platform/startup_stm32f072xb.s
  platform/src/stm32f0xx_hal_msp.c
  platform/src/stm32f0xx_it.c
  platform/src/system_stm32f0xx.c
  app/src/main.cpp
  hal/uart/src/uart_stm32.cpp
  ) 

在前面的 CMake 代码中,我们有 TARGET。这代表一个由 CMake 构建的对象实体,无论是整个固件(可执行文件)还是静态库。在我们的例子中,目标是整个固件,目标名称是通过项目名称和 .elf 后缀创建的,这意味着 CMake 将为我们创建一个 bare.elf 目标。

剩下的步骤是使用以下行指定链接器选项:

target_link_options(
  ${EXECUTABLE}
  PUBLIC
  -T${CMAKE_SOURCE_DIR}/platform/STM32F072C8Tx_FLASH.ld
  -mcpu=cortex-m0
  -mthumb
  -specs=nano.specs
  -Wl,--no-warn-rwx-segments
  -Wl,-Map=${PROJECT_NAME}.map,--cref
  -Wl,--gc-sections) 

这里,我们指定了要使用的链接脚本——即 STM32F072C8Tx_FLASH.ld——设置目标 CPU 和指令集,并指定要创建的新-lib nano 系统库和映射文件。

现在,让我们使用 CMake 构建固件。

使用 CMake 构建固件

这里,我们将创建一个构建文件夹,并使用以下命令在 Debug 模式下配置构建:

$ cd Cpp-in-Embedded-Systems/Chapter04/bare/
$ mkdir build && cd build
$ cmake .. -DCMAKE_BUILD_TYPE=Debug 

如果你使用 ls –l 列出构建文件夹中的文件,你会看到 CMake 生成的 Makefile,它用于构建固件。让我们运行它来构建固件:

$ make -j4 

你应该看到以下输出:

图 4.2 – 构建固件

图 4.2 – 构建固件

图 4.2 显示了构建固件的结果。我们可以使用以下命令在 Renode 中运行生成的 ELF 文件,bare.elf

$ make run_in_renode 

这将使用 renode_scripts 文件夹中的 stm32f072.resc Renode 脚本启动模拟器。脚本将使用 STM32F072 目标架构创建一个新的 Renode 机器,并用 bare.elf 文件加载它。我们将看到以下内容作为终端输出的部分:

图 4.3 – 在 Renode 中运行固件

图 4.3 – 在 Renode 中运行固件

图 4.3 显示了在 Renode 控制台模式下(禁用 GUI)运行的模拟器输出。要停止模拟,请输入 q 并按 Enter

请记住,如果你停止或重置 Docker 容器,包括克隆的 GitHub 仓库在内的所有更改都将丢失。为了防止这种情况发生,你需要使用 docker commit 命令保存它们。

到目前为止,我们有一个包含在 Docker 容器中的相当的开发环境。然而,为了充分利用它,我们必须将其连接到 Visual Studio Code。

容器化开发环境和 Visual Studio Code

要开始,请安装 Visual Studio Code (code.visualstudio.com/)。完成此操作后,转到 Extensions 并搜索并安装以下扩展:

  • C/C++

  • Cortex-Debug

  • CS 128 Clang-Tidy

  • Dev Containers

完成这些操作后,打开 View| Command Palette (Ctrl + Shift + P),找到 Dev Containers: Attach to Running Container 并选择 dev_env。这应该会打开一个新的 Visual Studio Code 窗口,其中容器的名称位于左下角的状态栏中:

图 4.4 – Visual Studio Code 连接到正在运行的容器

图 4.4 – Visual Studio Code 连接到正在运行的容器

图 4.4 显示 Visual Studio Code 已成功连接到正在运行的容器。现在,让我们打开位于 /workspace/Cpp-in-Embedded-Systems/Chapter04/bare 的项目文件夹。在 EXPLORER 视图中打开 main.cpp 文件,并在第 23 行设置断点,如下面的截图所示:

图 4.5 – 在 Visual Studio Code 中设置断点

图 4.5 – 在 Visual Studio Code 中设置断点

在设置断点,如图 图 4.5 所示后,选择 Run| Start Debugging (F5)。这将执行以下操作:

  • 以调试模式配置项目

  • 启动模拟器和加载 ELF

  • 将 GDB 客户端连接到模拟器中运行的 GDB 服务器

  • 允许你在模拟器中调试目标运行程序

如果一切设置正确,程序流程将在第 23 行停止,你将看到以下输出:

图 4.6 – Visual Studio Code 程序流程

图 4.6 – Visual Studio Code 程序流程

图 4.6 显示程序流程在第 23 行停止。我们可以切换到 TERMINAL 视图来查看 Renode 的输出。Renode 处于控制台模式,它也会显示 UART。让我们切换到 TERMINAL 视图并按 Continue (F5)。你应该会看到以下输出:

图 4.7 – Visual Studio Code Renode 输出

图 4.7 – Visual Studio Code Renode 输出

图 4.7 中,我们可以看到 Visual Studio Code 的 终端 视图中的 Renode 输出。为了能够调试汇编文件,我们需要在 Visual Studio Code 中执行以下操作:

  1. 前往 文件|首选项|设置

  2. 搜索“允许在任何地方设置断点”并选择相关复选框。

现在,我们可以在 platform/startup_stm32f072xb.s 的第 87 行设置断点,停止调试会话,然后再次运行。程序流程应该停止,如下所示:

图 4.8 – Visual Studio Code 汇编调试

图 4.8 – Visual Studio Code 汇编调试

图 4.8 中,我们可以看到程序流程在汇编启动脚本的第 87 行执行了 SystemInit 函数,在 main 函数之前。如果我们使用 进入 (F11),程序流程将进入 SystemInit 函数,Visual Studio Code 将打开 platform/src/system_stm32f0xx.c 文件。如果我们继续使用 单步执行 (F10),最终会进入 main 函数。这表明 main 不是第一个被调用的函数。

注意,startup_stm32f072xb.s 中的 Reset_Handler 是固件的入口点。这在链接脚本(platform/STM32F072C8Tx_FLASH.ld)中定义。它执行以下操作:

  • 初始化堆栈指针:它从堆栈的末尾(_estack)设置初始堆栈指针。

  • 复制数据:它将初始化值从闪存复制到 SRAM 的数据部分,以确保初始化的全局/静态变量被正确设置。

  • 零 BSS:通过将其设置为零来清除 BSS 部分,这对于未初始化的全局/静态变量是必需的。

  • 调用 SystemInitSystemInit 函数用于设置默认系统时钟(系统时钟源、PLL 乘数和除数因子、AHB/APBx 预分频器以及闪存设置)。

  • 调用 __libc_init_array__libc_init_array 函数用于初始化 C++ 程序中的静态构造函数或在 C 程序中运行初始化函数。

  • 调用 main:此操作结束启动脚本的活动,并将程序流程转移到 main 函数。

现在我们已经完全设置了现代开发环境,我们准备好深入学习嵌入式系统的 C++ 了。Renode 模拟器使我们能够高效地运行、测试和调试我们的固件,消除了在开发初期阶段需要物理硬件的需求。这为嵌入式系统学习和测试提供了一个灵活且高效的解决方案。

摘要

在本章中,我们定义了嵌入式系统 C++ 开发环境的组件。我们使用 Docker 容器遍历了所有组件,并将其连接到 Visual Studio Code,以实现无缝的开发体验和调试。

我们还使用 CMake 设置了编译器标志,通过 Renode 模拟器运行了固件,并通过调试器逐步执行相关的汇编启动脚本,学习了如何设置我们的 C 和 C++运行时环境。

在下一章中,我们将使用本章创建的开发环境来学习更多关于 C++中的类。

第二部分

C++基础知识

在嵌入式开发中介绍完 C++之后,本书将重点转向为新手和经验有限的读者覆盖 C++基础知识。这部分深入探讨了核心语言特性,例如类,包括继承和运行时多态,以及其他基本概念。它还探讨了 C++中可用的各种错误处理机制,包括异常的使用。

本部分包含以下章节:

  • 第五章类 – C++应用程序的构建块

  • 第六章超越类 – 基本的 C++概念

  • 第七章强化固件 – 实用的 C++错误处理方法

第五章:类 – C++应用程序的构建块

在 C++中是组织代码成逻辑单元的手段。它们允许我们将数据以及对这些数据进行操作的函数按照蓝图进行结构化。这些蓝图可以用来构建类的实例,即所谓的对象。我们可以通过初始化对象来赋予它们数据,通过调用它们上的函数或方法来操作它们,将它们存储在容器中,或者将它们的引用传递给其他类的对象,以实现系统不同部分之间的交互。

类是 C++应用程序的基本构建块。它们帮助我们以具有独立责任、反映与其他系统部分依赖和交互的单元组织代码。它们可以组合或扩展,使我们能够重用其功能并添加额外的功能。我们使用它们来抽象嵌入式系统不同部分,包括低级组件,如通用异步收发传输器UART)驱动程序和库,或业务逻辑组件,如蜂窝调制解调器库。

本章的目标是深入探讨 C++类,并学习我们如何使用它们来编写更好的代码。在本章中,我们将涵盖以下主要主题:

  • 封装

  • 存储持续时间和初始化

  • 继承和动态多态

技术要求

为了充分利用本章内容,我强烈建议在阅读示例时使用编译器探索器(godbolt.org/)。选择 GCC 作为您的编译器,并针对 x86 架构。这将允许您看到标准输出(stdio)结果,并更好地观察代码的行为。由于我们使用了大量的现代 C++特性,请确保在编译器选项框中添加-std=c++23以选择 C++23 标准。

本章的示例可在 GitHub 上找到(github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter05)。

封装

封装是一种编程概念,它将代码组织成包含数据和操作这些数据的函数的单元。它与面向对象编程OOP)并不严格相关,并且常用于其他编程范式。封装允许我们将代码解耦成具有单一职责的单元,使得代码更容易推理,提高可读性,并便于维护。

在面向对象编程的术语中,封装还可以指隐藏对象成员或限制外部对这些成员的访问。在 C++中,这可以通过使用访问说明符来实现。C++有以下说明符:

  • 公共

  • 私有

  • 受保护的

公共私有是最常用的修饰符。它们赋予我们控制类接口的能力,即控制哪些类成员对类的用户可用。以下示例演示了如何定义具有公共和私有访问部分的类,展示了封装的概念:

#include <cstdint>
class uart {
public:
    uart(std::uint32_t baud = 9600): baudrate_(baud) {}
    void init() {
        write_brr(calculate_uartdiv());
    }
private:
    std::uint32_t baudrate_;
    std::uint8_t calculate_uartdiv() {
        return baudrate_ / 32000;
    }
    void write_brr(std::uint8_t) {}
};
int main () {
    uart uart1(115200);
    uart1.init();
    return 0;
} 

在这个例子中,uart 类有公共和私有访问部分。让我们一步一步地分析代码:

  • public 部分包括一个构造函数,用于初始化 baudrate_ 私有成员变量

  • 我们在公共部分还有一个 init 方法,在其中我们使用 write_brr 私有方法将一个值写入一个波特率寄存器BRR),这是 STM32 平台特有的

  • 写入到 BRR 寄存器的值是在 calculate_uartdiv 私有方法中计算的

如我们所见,uart 类中具有公共访问修饰符的方法可以使用私有成员变量和方法。然而,如果我们尝试在 uart1 对象上使用 write_brr,如 uart1.write_brr(5),则程序的编译将失败。

私有访问修饰符允许我们隐藏类(在这种情况下,main 函数)用户的方法和数据。这有助于我们在 C++ 中为类定义一个清晰的接口。通过控制用户可以使用的哪些方法,我们不仅保护了类,也保护了用户免受不受欢迎的行为。

这个例子旨在解释 C++ 中的访问修饰符,但让我们也用它来解释 init 方法。如果我们已经有了构造函数,为什么还需要它?

init 的目的是允许我们完全控制硬件的初始化。对象也可以作为全局或静态变量构造。静态和全局对象的初始化是在到达 main 函数并初始化硬件之前完成的。这就是为什么在嵌入式项目中常见的 init 方法。使用它,我们可以确保所有硬件外设都按正确的顺序初始化。

C++ 中类的默认访问修饰符是私有,因此我们可以将上一个示例中的 uart 类定义写为以下内容:

class uart {
    std::uint32_t baudrate_;
    std::uint8_t calculate_uartdiv();
    void write_brr(std::uint8_t);
public:
    uart(std::uint32_t baud = 9600);
    void init();
}; 

我们选择明确定义私有访问部分。我们将其放在 public 部分之后,因为公开可访问的成员是类的接口,当你阅读代码和类定义时,你首先想看到的是接口。你想要看到如何与类交互,以及哪些方法是公共接口的一部分,你可以使用它们。

在这个例子中,我们只有一个数据成员 baudrate_。它是私有的,uart 类的用户设置它的唯一选项是通过构造函数。对于我们要公开的数据成员,定义设置器和获取器是一种常见的做法。

设置器和获取器

uart 类中,我们可以为 baudrate_ 成员定义如下设置器和获取器:

 std::uint32_t get_baudrate() const{
        return baudrate_;
    }
    void set_baudrate(baudrate) {
        baudrate_ = baudrate;
    } 

现在,这使我们能够通过公共接口设置和获取 baudrate 值,但这些简单的设置器和获取器并没有为我们的接口增加任何价值。它们只是暴露了 baudrate_ 成员。如果我们将它放在公共访问指定符下,效果是一样的。设置器和获取器应该有一个明确的目的。例如,设置器可以包含验证逻辑,如下所示:

 void set_baudrate(baudrate) {
        if (baudrate <= c_max_baudrate) {
            baudrate_ = baudrate;
        } else {
            baudrate = c_max_baudrate;
        }
    } 

在修改后的设置器中,我们对要设置的值进行合理性检查,并且只有在这样做有意义的情况下才设置私有成员,否则将其设置为系统支持的最高波特率 (c_max_baudrate)。这只是一个例子;在 UART 初始化后更改波特率可能没有意义。

通过设置器和获取器暴露数据成员在一定程度上破坏了封装。封装的想法是隐藏实现细节,而数据成员是实现细节。因此,设置器和特别是获取器应该谨慎使用,并且只有在它们有明确意义时才使用。

我们可以使用 C++ 中的类来封装仅有的功能,而不包含数据,或者数据是类中所有用户共同拥有的。为此,我们可以使用静态方法。

静态方法

静态方法 是使用 static 关键字声明的 C++ 方法,它们可以在不实例化对象的情况下访问。在 uart 类示例中,除了构造函数外,我们还有 init 方法,它是公共接口的一部分。我们通过调用之前使用单参数构造函数创建的对象上的此方法来使用它,并提供波特率。我们也可以设计 uart 类为具有所有静态方法的类型,并如下使用它:

#include <cstdint>
class uart {
public:
    static void init(std::uint32_t baudrate) {
        write_brr(calculate_uartdiv(baudrate));
    }
private:
    static std::uint8_t calculate_uartdiv(std::uint32_t baudrate) {
        return baudrate / 32000;
    }
    static void write_brr(std::uint8_t) {}
};
int main () {
    uart::init(115200);
    return 0;
} 

正如你所见,我们移除了单参数构造函数,并将所有方法声明为静态。我们还移除了 baudrate_ 私有数据成员,并直接从 init 方法传递给 calculate_uartdiv 方法。现在我们有一个可以在不创建对象实例的情况下使用的类型。我们通过在类名后跟一个双冒号和方法名来调用 init 方法,如 main 函数中所示。值得注意的是,静态方法只能使用类中的静态数据成员和其他静态函数,因为非静态成员需要对象实例化。

我们可以使用命名空间在 C++ 中将函数分组到一个共同的 单元 中。然而,将它们分组到类型中是有用的,因为我们可以将类型作为模板参数传递。我们将在本书的后面讨论命名空间和模板,以更好地理解这种方法的优点。命名空间将在 第六章 中讨论,模板将在 第八章 中讨论。

在 C++ 中,我们还可以使用 struct 关键字来定义一个类型。结构体成员的默认访问级别是公共的。从历史上看,结构体用于与 C 兼容,因此可以为在 C 和 C++ 程序中使用的库编写头文件。在这种情况下,我们将在 C 和 C++ 程序之间共享的结构体只能包含公共数据类型,不能有作为成员的方法。

结构体

结构体在 C++ 中通常用于只包含我们希望公开提供给用户的具有数据成员的类型。它们与类大致相同,区别在于默认访问级别,结构体的默认访问级别是公共的。

这里是一个只包含数据成员的结构体示例:

struct accelerometer_data {
    std::uint32_t x;
    std::uint32_t y;
    std::uint32_t z;
}; 

accelerometer_data 可以由一个 sensor 类生成,存储在一个 ring_buffer 类中,并由一个 sensor_fusion 类使用。accelerometer_data 类的成员是从 xyz 轴的值,并且它们对该类的用户是公开的。

在这种情况下,我们只使用 accelerometer_data 结构体作为数据持有者,并将与此数据相关的行为实现在其他地方。这只是一个示例。在简单结构体中结构化数据与使用具有数据和复杂行为的类之间的设计选择取决于具体的应用。

结构体也用于将函数分组为类型。它们通常都被声明为静态,并公开提供给用户。在这种情况下,使用结构体而不是类是方便的,因为默认访问指定符是公共的,这也反映了我们的意图,因为结构体通常用于所有成员都是公开的情况下。

除了公共和私有访问指定符之外,C++ 中还有一个受保护的指定符。受保护的指定符与继承有关,将在本章后面解释。

现在我们来讨论构造函数和 C++ 中变量和对象的初始化。对象初始化是一个重要的任务,未能正确执行可能会在程序中引起问题。我们将讨论对象初始化的不同选项,并分析潜在的问题以及如何避免它们。

存储持续时间和初始化

C++ 中具有自动存储期的对象在声明时初始化,在退出变量作用域时销毁。对象也可以具有静态存储期。对象的成员数据也可以有静态存储指定符,并且对这些成员的初始化有一些规则。我们将首先介绍非静态成员的初始化。

非静态成员初始化

初始化非静态类成员有不同的方法。当我们讨论初始化和 C++ 时,首先想到的是构造函数。虽然构造函数是强大的 C++ 功能,允许我们对初始化有很好的控制,但让我们从默认成员初始化器开始。

默认成员初始化

自 C++11 以来,我们可以在类定义中直接为成员设置默认值,如下所示:

class my_class{
    int a = 4;
    int *ptr = nullptr;
} 

如果我们使用任何预 C++11 标准编译此代码片段,它将无法编译。默认成员初始化器允许我们在类定义中为类成员设置默认值,这提高了可读性,并使我们免于在多个构造函数中设置相同的成员变量。这对于设置指针的默认值尤其有用。

如果我们没有为 ptr 使用默认初始化器,它将加载内存中的某个随机值。解引用这样的指针会导致从随机位置读取或写入,可能引发严重故障。这种假设情况会被编译器或静态分析器检测到,因为它们会报告使用未初始化的值,这是未定义的行为。尽管如此,这显示了使用默认值初始化成员变量的重要性,而默认成员初始化器是完成此任务的选项之一。

构造函数和成员初始化列表

构造函数是类定义中的无名称方法,不能被显式调用。它们在对象初始化时被调用。一个可以无参数调用的构造函数被称为默认构造函数。我们在 uart 类的示例中已经看到了一个:

 uart(std::uint32_t baud = 9600): baudrate_(baud) {
    // empty constructor body
    } 

尽管这个构造函数有一个参数,但我们使用了默认参数,如果它无参数被调用,这个参数将被提供给构造函数。如果在调用点没有提供参数,baud 参数将使用默认值 9600

当我们想要使用默认构造函数时,我们使用以下语法:

 uart uart1; 

这也被称为默认初始化,当对象声明时没有初始化器时执行。请注意,这里没有括号,因为这会导致语法歧义,并且编译器会将其解释为函数声明。

 uart uart1(); 

前一行会被编译器解释为声明一个名为 uart1 的函数,该函数返回 uart 类的对象,并且不接受任何参数。这就是为什么我们在使用默认构造函数时没有使用括号的原因。

由于我们的 uart 类构造函数也可以接受参数,我们可以使用直接初始化语法,并为构造函数提供一个参数,如下所示:

 uart uart1(115200); 

这将调用 uart 类构造函数,并为 baud 参数提供一个 115200 的值。虽然我们已经解释了与默认构造函数语法相关的细微差别,但我们仍然需要解释 baudrate_ 成员变量的初始化。在这种情况下,我们使用成员初始化列表。它指定在冒号字符之后和复合语句的开括号之前,作为 baudrate_(baud)。在我们的例子中,成员初始化列表中只有一个条目;如果有更多,它们用逗号分隔,如下例所示:

class sensor {
public:
    sensor(uart &u, std::uint32_t read_interval):
                uart_(u),
                read_interval_(read_interval) {}
private:
    uart &uart_;
    const std::uint32_t read_interval_;
};
int main() {
    uart uart1;
    sensor sensor1(uart1, 500);
    return 0;
} 

在前面的代码中,我们在sensor构造函数的成员初始化列表中初始化了对uart的引用和read_interval_无符号整数。

需要注意的重要事项是uart类对象的引用。在 C++中,引用类似于 C 中的指针;也就是说,它们指向一个已经创建的对象。然而,它们在声明时需要初始化,并且不能重新赋值以指向另一个对象。引用和const限定成员必须使用成员初始化列表进行初始化。

构造函数可以有零个或多个参数。如果一个构造函数有一个参数并且没有使用显式指定符声明,它被称为转换构造函数。

转换构造函数和显式指定符

转换构造函数允许编译器将其参数的类型隐式转换为类的类型。为了更好地理解这一点,让我们看一下以下示例:

#include <cstdio>
#include <student>
struct uart {
    uart(std::uint32_t baud = 9600): baudrate_(baud) {}
    std::uint32_t baudrate_;
};
void uart_consumer(uart u) {
   printf("Uart baudrate is %d\r\n", u.baudrate_);
}
int main() {
    uart uart1;
    uart_consumer(uart1);
    uart_consumer(115200);
    return 0;
} 

本例中有趣的部分是使用115200参数调用uart_consumer函数。uart_consumer函数期望以uart类的对象作为参数,但由于隐式转换规则和现有的转换构造函数,编译器使用115200作为参数构造了一个uart类的对象,导致程序输出以下内容:

Uart baudrate is 9600
Uart baudrate is 115200 

隐式转换可能是不安全的,并且通常是不希望的。为了防止这种情况,我们可以使用显式指定符声明一个构造函数,如下所示:

 explicit uart(std::uint32_t baud = 9600): baudrate_(baud) {} 

使用显式构造函数编译前面的示例将导致编译错误:

<source>:19:19: error: could not convert '115200' from 'int' to 'uart'
   19 |     uart_consumer(115200); 

通过将构造函数声明为显式,我们可以确保我们的类的用户不会创建可能导致程序中不希望的行为的潜在隐式转换的情况。但如果我们想防止使用浮点类型调用我们的构造函数呢?这可能不是一个很好的例子,但你可以想象一个期望uint8_t类型的构造函数,有人用uint32_t参数调用它。

我们可以删除特定的构造函数,这将导致编译失败。我们可以在类声明中使用以下语法来完成此操作:

 uart(float) = delete; 

使用浮点类型调用构造函数将导致以下编译错误:

<source>:12:25: error: use of deleted function 'uart::uart(float)'
   12 |     uart uart1(100000.0f); 

我们还可以使用花括号列表初始化,这限制了转换并防止了浮点数到整数的转换。我们可以如下使用它:

 uart uart1{100000.0f}; 

此调用将导致以下编译错误:

<source>:11:25: error: narrowing conversion of '1.0e+5f' from 'float' to 'uint8_t' {aka 'unsigned char'} [-Wnarrowing]
   11 |     uart uart1{100000.0f}; 

列表初始化限制了隐式转换,并有助于在编译时检测问题。

类数据成员可以使用static关键字声明,并且对它们的初始化有一些特殊规则。

静态成员初始化

静态成员与类或结构体的对象无关。它们是具有静态存储期的变量,可以由类的任何对象访问。让我们通过一个简单的例子来更好地理解静态成员以及我们如何初始化它们:

#include <cstdio>
struct object_counter {
    static int cnt;
    object_counter() {
        cnt++;
    }
    ~object_counter() {
        cnt--;
    }
};
int object_counter::cnt = 0;
int main() {
    {
        object_counter obj1;
        object_counter obj2;
        object_counter obj3;
        printf("Number of existing objects in this scope is: %d\r\n",
 object_counter::cnt);
    }
    printf("Number of existing objects in this scope is: %d\r\n", 
 object_counter::cnt);
    return 0;
} 

在这个例子中,我们有一个简单的 object_counter 结构体。该结构体有一个静态数据成员,即 cnt 整数。在构造函数中,我们增加这个计数器变量,在析构函数中,我们减少它。在 main 函数中,我们在一个未命名的范围内创建了三个 object_counter 对象。

当程序流程退出未命名的范围时,将调用析构函数。我们在范围内部和离开它之后打印现有对象的数量。在未命名的范围内,cnt 的值应该等于 3,因为我们创建了三个对象,当我们退出它,并且析构函数减少 cnt 变量时,它应该为 0。以下示例的输出如下:

Number of existing objects in this scope is: 3
Number of existing objects in this scope is: 0 

输出显示 cnt 静态变量的行为正如我们所预测的那样。在这种情况下,我们在类声明中声明了一个静态变量,但使用以下行定义它:

int object_counter::cnt = 0; 

根据 C++17 标准,可以在结构体(或类)定义中使用 inline 说明符声明静态变量,并提供初始化器,如下所示:

struct object_counter {
    inline static int cnt = 0;
    ...
}; 

这使得代码更加简洁,更容易使用,因为我们不需要在类定义外部定义变量,并且更容易阅读。

我们已经介绍了 C++ 中类的基础知识,包括访问说明符、初始化方法和构造函数。现在,我们将看到如何通过继承和动态多态来重用类。

继承和动态多态

在 C++ 中,我们可以通过继承来扩展类的功能,而无需修改它。继承是建立类之间层次关系的例子;例如,ADXL345 是一个加速度计。让我们通过一个简单的例子来演示 C++ 中的继承:

#include <cstdio>
class A {
public:
    void method_1() {
        printf("Class A, method1\r\n");
    }
    void method_2() {
        printf("Class A, method2\r\n");
    }
protected:
    void method_protected() {
        printf("Class A, method_protected\r\n");
    }
};
class B : public A{
public:
    void method_1() {
        printf("Class B, method1\r\n");
    }
    void method_3() {
        printf("Class B, method3\r\n");
        A::method_2();
        A::method_protected();
    }
};
int main() {
    B b;
    b.method_1();
    b.method_2();
    b.method_3();
    printf("-----------------\r\n");
    A &a = b;
    a.method_1();
    a.method_2();
    return 0;
} 

在这个例子中,class Bclass A 继承了私有和受保护的成员。class A 是基类,class B 从它派生。派生类可以访问基类的公共和受保护成员。在 main 函数中,我们创建了一个 class B 的对象,并调用了 method_1method_2method_3 方法。这部分代码的输出如下所示:

Class B, method1
Class A, method2
Class B, method3
Class A, method2
Class A, method_protected 

main 函数的第一行,我们看到对对象 bmethod_1 函数的调用执行了 class B 中定义的 method_1,尽管它继承自 class A,而 class A 也定义了 method_1。这被称为 静态绑定,因为调用 method_1 的决定是在 class A 中定义的,并且由编译器做出。

派生类 class B 的对象包含基类 class A 的对象。如果我们对对象 b 调用 method_2 方法,编译器将在 class B 中找不到定义,但由于类 B 继承自类 A,编译器将调用对象 amethod_2 方法,而对象 a 是对象 b 的一部分。

method_3 中,我们看到我们可以从派生类中调用基类的函数。我们还可以看到我们可以调用基类的受保护方法。这是私有访问说明符的一个用例;它允许对派生类进行访问。

我们可以将派生类的对象赋值给基类引用。我们也可以对指针做同样的事情。以下是方法调用结果:

Class A, method1
Class A, method2 

对基类引用调用 method_1 将导致调用 class A 中定义的 method_1。这是静态绑定作用的一个例子。但如果我们想让对基类引用或指针的调用在派生类中执行函数呢?我们为什么要这样做?让我们首先解决“如何做”的问题。C++ 通过虚函数提供了一种动态绑定的机制。

虚函数

在我们的例子中,我们将类型为 A& 的引用赋值给 class B 的对象。如果我们想让对这个引用(A& a)的 method_1 调用执行 class B 中定义的 method_1 函数,我们可以在 class A 中将 method_1 声明为虚函数,如下所示:

class A {
public:
    virtual void method_1() {
        printf("Class A, method1\r\n");
    }
...
}; 

现在,对绑定到 class B 对象的 class A 引用上的 method_1 调用将导致调用 class B 中定义的 method_1,正如我们在输出中看到的那样:

Class B, method1
Class A, method2 

这里,我们看到 method_1 调用的输出与 class B 中此方法的定义相匹配。我们说 class B 覆盖了 class A 中的 method_1,对此有一个特殊的术语,如下所示:

class B: public A {
public:
    void method_1() override {
        printf("Class B, method1\r\n");
    }
...
}; 

override 关键字让编译器知道我们有意覆盖基类中的虚方法。如果我们覆盖的方法没有被声明为虚方法,编译器将引发错误。

C++ 中的虚函数通常使用虚表来实现。这是编译器为我们完成的工作。它创建一个虚表,存储每个虚函数的指针,这些指针指向覆盖的实现。

虚函数实现

每个覆盖虚函数的类都有一个虚表。你可以把它想象成一个隐藏的功能指针表。类的每个对象都有一个指向这个表的指针。这个指针在运行时用于访问表并找到在对象上要调用的正确函数。让我们稍微修改一下 class Aclass B,以便更好地理解这一点。以下是被修改的 class Aclass B 的代码:

class A {
public:
    void method_1() virtual{
        printf("Class A, method1\r\n");
    }
    void method_2() virtual{
        printf("Class A, method2\r\n");
    }
};
class B : public A{
public:
    void method_2() override{
        printf("Class B, method2\r\n");
    }
 }; 

我们修改了 class Aclass B,使得 class A 有两个虚方法,method_1method_2class B 只覆盖 method_2。编译器将为 class B 生成一个虚表和一个指针,每个 class B 的对象都将持有这个指针。虚指针指向生成的虚表。

这可以如下可视化:

图 5.1 – 虚表

图 5.1 – 虚表

图 5.1 展示了在 C++ 中使用虚表和虚指针实现虚函数的可能实现。如果我们对一个 类 B 对象的引用调用 method_2,它将跟随虚指针到虚表,并选择指向 类 Bmethod_2 实现的函数指针,即重写的虚函数。这种机制发生在运行时。有一个间接层来获取重写的函数,这导致了空间和时间开销。

在 C++ 中,我们可以定义一个虚函数为纯虚函数。如果一个类有一个纯虚函数,它被称为 抽象类,并且不能被实例化。派生类必须重写纯虚函数,否则它们也是抽象类。让我们通过以下代码示例来了解:

class A {
public:
    virtual void method_1() = 0;
};
class B : public A{
};
int main() {
    B b;
    return 0;
} 

这个程序将无法编译,因为 类 B 没有重写从 类 A 继承来的 method_1 虚函数。抽象类将某些行为(方法)的实现责任转移到派生类。所有方法都是虚方法的类被称为接口。

继承定义了类之间的层次关系,我们可以说 类 B类 A 的子类,就像猫是动物一样。我们可以在 统一建模语言UML)图中表示这种关系。

UML 类图

UML 图用于描述软件组件。如果它们描述了类之间的关系,它们被称为 UML 类图。以下图示展示了这样一个图:

图 5.2 – 类 A 和类 B 关系的 UML 图

图 5.2 – 类 A 和类 B 关系的 UML 图

图 5.2 展示了一个 UML 类图,可视化 AB 之间的层次关系。连接 BA 的空心、未填充的三角形箭头指向 A 表示 BA 的子类。这个 UML 图还显示了两个类中都有的方法。

UML 图对于描述设计模式很有用,我们将在本书中使用它们来帮助我们可视化代码示例中软件组件之间的关系。

我们已经学习了继承是什么以及我们如何使用虚函数来实现动态绑定。让我们回到为什么我们需要这些机制以及我们如何使用它们来创建更好的软件的问题。本章我们学习的机制提供了动态(运行时)多态性的手段。

动态多态性

多态性 是一种机制,它使不同类型具有单一接口。它可以是静态的或动态的。C++ 中的动态多态性是通过继承和虚函数实现的。这种多态性也称为 子类型化,因为它基于基类的接口处理子类型或派生类。

多态允许我们使用单个接口来实现不同的功能。让我们通过一个 GSM 库的例子来了解一下。GSM 模拟器通常通过 UART 接口与主机微控制器通信。一个微控制器可能有多个 UART 外设,例如 STM32 上的 UART 和 低功耗通用异步收发传输器 (LPUART)。我们可能还希望在不同的微控制器上使用此库。

我们可以为不同平台上的不同 UART 实现定义一个通用接口,并在我们的 GSM 库中使用此接口。UART 的实现将由我们使用 GSM 库的平台提供,并且它将实现通用 UART 接口。我们可以使用 UML 类图来可视化我们的库设计,如下图所示:

图 5.3 – GSM 库和 UART 接口的 UML 图

图 5.3 – GSM 库和 UART 接口的 UML 图

在 *图 5**.3 中,我们看到 gsm_libuartuart_stm32 类之间的关系。GSM 库的功能在 gsm_lib 类中实现,它使用 uart 接口。uart 接口由 uart_stm32 类实现。GSM 库的功能很复杂,但让我们通过一个非常简化的代码示例来展示这三个类之间的关系以及它们是如何协同工作的。以下是一个简化的示例:

#include <span>
#include <cstdio>
#include <cstdint>
class uart {
public:
    virtual void init(std::uint32_t baudrate) = 0;
    virtual void write(std::span<const char> data) = 0;
};
class uart_stm32 : public uart{
public:
    void init(std::uint32_t baudrate = 9600) override { 
        printf("uart_stm32::init: setting baudrate to %d\r\n", baudrate);
    } 
    void write(std::span<const char> data) override {
        printf("uart_stm32::write: ");
        for(auto ch: data) {
            putc(ch, stdout);
        }
    }
};
class gsm_lib{
    public:
        gsm_lib(uart &u) : uart_(u) {}
        void init() {
            printf("gsm_lib::init: sending AT command\r\n");
            uart_.write("AT");
        }
    private:
        uart &uart_;
};
int main() {
    uart_stm32 uart_stm32_obj;
    uart_stm32_obj.init(115200);
    gsm_lib gsm(uart_stm32_obj);
    gsm.init();
    return 0;
} 

在这个代码示例中,我们看到 uart 类有两个纯虚函数,这使得它成为一个接口类。这个接口被 uart_stm32 类继承并实现。在 main 函数中,我们创建了一个 uart_stm32 类的对象,其引用被传递到 gsm_lib 类的构造函数中,在那里它被用来初始化一个指向 uart 接口的私有成员引用。

您也可以在上一章中提到的模拟器环境中运行此程序。它位于 Chapter05/gsm_lib 文件夹中。

使用 UART 接口设计的 GSM 库使我们能够拥有一个灵活的库,我们可以在不同的平台上使用。这种设计还允许我们通过提供用作夹具的 UART 实现来调试库与 GSM 模拟器之间的通信,该实现将重定向读取和写入操作,并同时记录它们。

摘要

在本章中,我们介绍了 C++ 中类的基础知识。我们学习了成员访问说明符、初始化对象的不同方式以及继承。我们还更详细地了解了虚函数,并学习了如何使用它们来实现动态多态。

在下一章中,我们将更多地讨论 C++ 中的其他基本概念,例如命名空间、函数重载和标准库。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/embeddedsystems

第六章:超越类 – 基本 C++概念

从历史上看,C++ 是从 C 语言加上类开始的,这使得类成为具有 C 背景的开发者要学习的第一个概念。在前一章中,我们详细介绍了类,在继续探讨更高级的概念之前,我们将介绍其他使 C++ 远远超出具有类的 C 的基本 C++ 概念。

在我们继续探讨更高级的主题之前,探索使 C++ 独特的其他基本概念是很重要的。在本章中,我们将涵盖以下主要主题:

  • 命名空间

  • 函数重载

  • 与 C 的互操作性

  • 引用

  • 标准库容器和算法

技术要求

为了充分利用本章内容,我强烈建议你在阅读示例时使用 Compiler Explorer (godbolt.org/)。选择 GCC 作为你的编译器,并针对 x86 架构。这将允许你看到标准输出(stdio)结果,并更好地观察代码的行为。由于我们使用的是现代 C++ 功能,请确保选择 C++23 标准,通过在编译器选项框中添加 -std=c++23

Compiler Explorer 使得尝试代码、调整代码并立即看到它如何影响输出和生成的汇编变得容易。示例可在 GitHub 上找到 (github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter06)。

命名空间

C++ 中的命名空间用作访问类型名称、函数、变量等的作用域指定符。它们允许我们在使用许多软件组件且经常有相似标识符的大型代码库中更容易地区分类型和函数名称。

在 C 语言中,我们通常会给类型和函数添加前缀,以便更容易区分,例如:

typedef struct hal_uart_stm32{
    UART_HandleTypeDef huart_;
    USART_TypeDef *instance_; 
} hal_uart_stm32;
void hal_init();
uint32_t hal_get_ms(); 

在 C++ 中,我们可以使用命名空间而不是 C 风格的标识符前缀来组织代码的逻辑组,如下面的示例所示:

namespace hal {
void init();
std::uint32_t tick_count;
std::uint32_t get_ms() {
    return tick_count;
}
class uart_stm32 {
private:
    UART_HandleTypeDef huart_;
    USART_TypeDef *instance_; 
};
}; 

hal 命名空间的所有成员都可以在命名空间内部无修饰地访问。要访问 hal 命名空间中的标识符,在命名空间外部的代码中,我们使用命名空间作为限定符,后跟作用域解析运算符(::),如下面的示例所示:

hal::init();
std::uint32_t time_now = hal::get_ms(); 

在这个例子中,除了 hal 命名空间外,我们还看到了 std 命名空间,我们在前面的例子中使用过它。C++ 标准库类型和函数在 std 命名空间中声明。

我们可以使用 using 指令来访问无修饰的标识符,如下面的示例所示:

using std::array;
array<int, 4> arr; 

using 指令也可以用于整个命名空间,如下面的示例所示:

using namespace std;
array<int, 4> arr;
vector<int> vec; 

建议谨慎使用 using 指令,特别是与 std 一起使用,用于有限的作用域,或者更好的做法是仅引入单个标识符。

同一个命名空间可以在不同的头文件中使用来声明标识符。例如,std::vectorvector.h 头文件中声明,而 std::arrayarray.h 头文件中声明。这允许我们将属于同一逻辑组的来自不同头文件的代码组织在命名空间中。

未在显式命名空间内声明的函数和类型是全局命名空间的一部分。将所有代码组织在命名空间中是一种良好的做法。唯一不能在命名空间内声明而必须位于全局命名空间中的函数是 main。要访问全局命名空间中的标识符,我们使用作用域解析运算符,如下面的示例所示:

const int ret_val = 0;
int main() {
    return ::ret_val;
} 

代码行 return ::ret_val; 使用了作用域解析运算符 ::,但没有指定命名空间。这意味着它引用的是全局命名空间。因此,::ret_val 访问的是在函数或类外部定义的 ret_val 变量——即在全局作用域中。

未命名的命名空间

命名空间可以不使用名称限定符进行声明。这允许我们声明属于它们声明的翻译单元本地的函数和类型。在下面的示例中,我们可以看到一个未命名的命名空间的例子:

namespace {
constexpr std::size_t c_max_retries;
std::size_t counter;
}; 

在代码中,我们有一个包含一些变量声明的未命名的命名空间。它们具有内部链接,这意味着它们不能被来自其他翻译单元的代码访问。我们可以在 C 和 C++中使用 static 存储指定符来实现相同的效果。

嵌套命名空间

命名空间也可以嵌套。我们可以在一个命名空间内部有另一个命名空间,如下面的示例所示:

namespace sensors {
namespace environmental {
class temperature {
};
class humidity {
};
};
namespace indoor_air_quality{
class c02{
};
class pm2_5{
};
};
}; 

在这个例子中,我们已经在命名空间中组织了传感器。我们有一个顶级命名空间 sensors,它包含两个命名空间:environmentalindoor_air_quality。C++17 标准允许我们编写命名空间,如下面的示例所示:

namespace sensors::environmental {
class temperature {
};
class humidity {
};
}; 

命名空间是使代码更易读的好方法,因为它们允许我们保持标识符短,而不需要 C 风格的前缀。

函数重载

在上一章中,当我们讨论继承时,我们提到了静态绑定。我们看到了可以为属于不同类的函数使用相同的函数名。然而,我们也可以为不同的函数参数使用相同的函数名,如下面的示例所示:

#include <cstdio>
void print(int a) {
    printf("Int %d\r\n", a);
}
void print(float a) {
    printf("Float %2.f\r\n", a);
}
int main() {
    print(2);
    print(2.f);
    return 0;
} 

在这个例子中,我们有两个 print 函数。其中一个有一个 int 类型的参数,另一个有一个 float 类型的参数。在调用位置,编译器将根据传递给函数调用的参数选择一个 print 函数。

在同一作用域内具有相同名称的函数称为重载函数。我们不需要为这两个函数使用两个不同的名称,如 print_intprint_float,我们可以为这两个函数使用相同的名称,让编译器决定调用哪个函数。

为了区分两个重载的 print 函数——一个接受 int 参数,另一个接受 float——编译器采用了一种称为 名称修饰 的技术。名称修饰通过将额外的信息,如参数类型,编码到函数名称中,来修改函数名称。这确保了每个重载函数在编译代码中都有一个唯一的符号。如果我们检查上一个示例的汇编输出,我们可以观察到这些修饰过的名称:

_Z5printi:
        mov     r1, r0
        ldr     r0, .L2
        b       printf
_Z5printf:
        vcvt.f64.f32    d16, s0
        ldr     r0, .L5
        vmov    r2, r3, d16
        b       printf 

我们可以看到编译器将 _Z5printi_Z5printf 标签分配给了具有 intfloat 参数的 print 函数。这使得它能够根据参数匹配来调度函数调用。

重载函数可以有不同数量的参数。不能使用返回类型进行函数重载。具有相同名称和相同参数的两个函数不能有不同的返回类型。以下代码将导致编译错误:

int print(int a);
void print(int a); 

这段代码将被编译器视为函数重新声明,并导致错误。

函数重载是 C++的一个基本但强大的特性,它提供了一种在编译时或静态多态的机制。

与 C 的互操作性

你能够在 Renode 模拟器中运行的上一章的代码示例使用了 C++和 C 代码。我们使用了供应商提供的 HAL 库和 Arm 的 通用微控制器软件接口标准 (CMSIS),两者都是用 C 编写的,并包含在 platform 文件夹中。

如果你查看 CMakeLists.txt 文件以及其中的 add_executable 函数,你会看到列出了来自 platform 文件夹的 C 文件以及仅有的几个 C++文件。构建项目将提供以下控制台输出:

[  7%] Building C object CMakeFiles/bare.elf.dir/platform/STM32F0xx_HAL_Driver/Src/stm32f0xx_hal.c.o
[ 15%] Building C object CMakeFiles/bare.elf.dir/platform/STM32F0xx_HAL_Driver/Src/stm32f0xx_hal_cortex.c.o
[ 23%] Building C object CMakeFiles/bare.elf.dir/platform/STM32F0xx_HAL_Driver/Src/stm32f0xx_hal_gpio.c.o
[ 30%] Building C object CMakeFiles/bare.elf.dir/platform/STM32F0xx_HAL_Driver/Src/stm32f0xx_hal_rcc.c.o
[ 38%] Building C object CMakeFiles/bare.elf.dir/platform/STM32F0xx_HAL_Driver/Src/stm32f0xx_hal_uart.c.o
[ 46%] Building C object CMakeFiles/bare.elf.dir/platform/STM32F0xx_HAL_Driver/Src/stm32f0xx_hal_uart_ex.c.o
[ 53%] Building ASM object CMakeFiles/bare.elf.dir/platform/startup_stm32f072xb.s.o
[ 61%] Building C object CMakeFiles/bare.elf.dir/platform/src/stm32f0xx_hal_msp.c.o
[ 69%] Building C object CMakeFiles/bare.elf.dir/platform/src/stm32f0xx_it.c.o
[ 76%] Building C object CMakeFiles/bare.elf.dir/platform/src/system_stm32f0xx.c.o
[ 84%] Building CXX object CMakeFiles/bare.elf.dir/app/src/main.cpp.o
[ 92%] Building CXX object CMakeFiles/bare.elf.dir/hal/uart/src/uart_stm32.cpp.o
[100%] Linking CXX executable bare.elf 

每个 C 和 C++文件都被视为一个翻译单元,并由各自的 C 和 C++编译器分别单独构建。编译完成后,C 和 C++目标文件将被链接成一个单一的 ELF 文件。

C++中的外部和语言链接

可以从其他翻译单元引用的变量和函数具有 外部链接。这允许它们与在其他文件中提供的代码链接,前提是编译器可以访问声明。它们还有一个称为 语言链接 的属性。这个属性允许 C++与 C 代码链接。在 C++中使用 C 语言链接的语法如下:

extern "C" {
void c_func();
} 

使用 C 语言链接的声明将根据 C 语言链接约定进行链接,以防止名称修饰(以及其他事项),确保与 C 翻译单元内编译的代码正确链接。

C++中的 C 标准库

C++封装了 C 标准库,并提供与 C 语言版本同名但带有 c 前缀且无扩展名的头文件。例如,C 语言头文件 <stdlib.h> 的 C++等价文件是 <cstdlib>

在 GCC 中,实现 C++包装器包括 C 标准库头文件;例如,<cstdio>包括<stdio.h>。如果你深入研究<stdio.h>,你可以看到它使用__BEGIN_DECLS__END_DECLS宏保护函数声明。以下是这些宏的定义:

/* C++ needs to know that types and declarations are C, not C++.  */
#ifdef    __cplusplus
# define __BEGIN_DECLS    extern "C" {
# define __END_DECLS    }
#else
# define __BEGIN_DECLS
# define __END_DECLS
#endif 

在这里,我们可以看到标准 C 库头文件通过添加语言链接指定符来处理 C++兼容性,如果使用 C++编译器。这种做法也被许多微控制器供应商提供的许多 HAL 实现所采用。如果你打开platform/STM32F0xx_HAL_Driver/Inc中的任何 C 头文件,你会看到当它们被 C++编译器访问时,声明被 C 语言链接指定符保护,如下所示:

#ifdef __cplusplus
extern "C" {
#endif
// Declarations
#ifdef __cplusplus
}
#endif 

C 库通常被 C++程序使用,尤其是在嵌入式领域,因此总是用语言链接指定符保护它们是个好主意。如果我们在一个 C++程序中使用 C 库,并且头文件没有内部保护,我们可以在include位置保护头文件,如下所示:

extern "C" {
#include "c_library.h"
} 

C 语言的语言链接指定符确保了使用 C 代码的 C++代码的正确链接,这在嵌入式项目中通常是这种情况。

引用

在前一章中,我们简要提到了引用,但没有详细解释。引用是对象的别名;也就是说,它们指向对象,因此它们必须立即初始化。它们不是对象,所以没有指向引用的指针或引用数组。

C++中有两种不同的引用类型:左值右值引用。

值类别

C++表达式要么是左值要么是右值值类别。值类别有更详细的划分,但我们将保持这个简单的类别,它有一个历史起源。

左值通常出现在赋值表达式的左侧,但这并不总是如此。左值有一个程序可以访问的地址。以下是一些左值的示例:

void bar();
int a = 42; // a is lvalue
int b = a; // a can also appear on the right side
int * p = &a; // pointer p is lvalue
void(*bar_ptr)() = bar; // func pointer bar_ptr is lvalue 

右值通常出现在赋值表达式的右侧。例如,字面量、不返回引用的函数调用和内置运算符调用。我们可以把它们看作是临时值。以下是一个右值的示例:

int a = 42; // 42 is rvalue
int b = a + 16; // a + 16 is rvalue
std::size_t size = sizeof(int); // sizeof(int) is rvalue 

这里还有一个完整的示例,帮助你更好地理解右值:

#include <cstdio>
struct my_struct {
    int a_;
    my_struct() : a_(0) {}
    my_struct(int a) : a_(a) {}
};
int main() {
    printf("a_ = %d\r\n", my_struct().a_);
    printf("a_ = %d\r\n", (my_struct()=my_struct(16)).a_);
    return 0;
} 

在前面的例子中,我们可以看到赋值运算符左侧的my_struct()右值表达式。示例的输出如下:

a_ = 0
a_ = 16 

在第一个printf调用中,我们调用my_struct的构造函数,它返回一个临时对象,并访问a_成员。在下一行,我们有以下表达式:my_struct()=my_struct(16)。在这个表达式的左侧,我们有一个对默认构造函数的调用,它返回一个临时对象。然后我们将构造函数接受int的结果赋值给左侧的临时对象,这将把一个临时对象复制到另一个临时对象中。

左值引用

左值引用用于现有对象的别名。它们也可以是 const 限定。我们通过在类型名称中添加&来声明它们。以下代码演示了左值引用的用法:

#include <cstdio>
int main() {
    int a = 42;
    int& a_ref = a;
    const int& a_const_ref = a;
    printf("a = %d\r\n", a);
    a_ref = 16;
    printf("a = %d\r\n", a);
    // a_const_ref = 16; compiler error
    return 0;
} 

如示例所示,我们可以使用引用来操作对象。在常量引用的情况下,任何尝试更改值的操作都将导致编译器错误。

右值引用

右值引用用于扩展临时右值的生命周期。我们通过在类型名称旁边使用&&来声明它们。以下是一些右值引用的示例用法:

int&& a = 42;
int b = 0;
// int&& b_ref = b; compiler error
int&& b_ref = b + 10; // ok, b + 10 is rvalue 

右值引用不能绑定到左值。尝试这样做将导致编译器错误。右值引用对于资源管理很重要,并且它们用于移动语义,这允许资源从一个对象移动到另一个对象。

如果我们查看std::vectorpush_back方法的文档,我们将看到两个声明:

void push_back( const T& value );
void push_back( T&& value ); 

第一个声明用于通过复制value来初始化新的向量成员。带有右值引用的第二个声明将移动value,这意味着新的向量成员将接管value对象从动态分配的资源。让我们看一下以下示例,以了解移动语义的基本知识:

#include <string>
#include <vector>
#include <cstdio>
int main()
{
    std::string str = "Hello world, this is move semantics demo!!!";
    printf("str.data address is %p\r\n", (void*)str.data());
    std::vector<std::string> v;
    v.push_back(str);
    printf("str after copy is <%s>\r\n", str.data());
    v.push_back(std::move(str));
    //v.push_back(static_cast<std::string&&>(str));
    printf("str after move is <%s>\r\n", str.data());

    for(const auto & s:v) {
        printf("s is <%s>\r\n", s.data());
        printf("s.data address is %p\r\n", (void*)s.data());
    }
    return 0;
} 

在这个示例中,我们对std::vector<std::string>push_back方法进行了两次调用。第一次调用v.push_back(str);str复制到向量中。在此操作之后,原始的str保持不变,这由输出得到证实:

str.data address is 0x84c2b0
str after copy is <Hello world, this is move semantics demo!!!> 

第二次调用v.push_back(std::move(str));使用std::movestr转换为右值引用。这向编译器表明str的资源可以被移动而不是复制。因此,str的内部数据被转移到向量中的新字符串,而str被留下处于有效但未指定的状态,通常变为空:

str after move is <>
s is <Hello world, this is move semantics demo!!!>
s.data address is 0x84d330
s is <Hello world, this is move semantics demo!!!>
s.data address is 0x84c2b0 

在前面的输出中,我们还使用s.data()str.data()打印了字符串底层字符数组的地址。以下是发生的情况:

  • 原始的str其数据位于地址0x84c2b0

  • 在将字符串str复制到向量中后,第一个元素v[0]拥有其数据的不同地址的副本(0x84d330),这证实了一个深拷贝已被创建

移动之后,向量中的第二个元素v[1]现在指向原始数据地址0x84c2b0。这表明str的内部数据被移动到v[1]而没有复制。这只是移动语义的一瞥;还有更多内容,但由于它主要用于管理动态分配的资源,我们不会更详细地介绍它。

标准库容器和算法

我们已经在之前的章节中讨论了一些 C++库中的容器,例如std::vectorstd::array。由于std::vector依赖于动态内存分配,std::array通常在嵌入式应用中是首选的容器。

数组

标准库中的数组在栈上分配一个连续的内存块。我们可以将数组视为一个简单的包装器,它包含一个 C 风格数组的类型,并在其中包含数组的大小。它是一个模板类型,使用底层数据类型和大小实例化。

我们可以使用一个方法来访问数组成员,如果使用越界索引访问,它将抛出一个异常。这使得它比 C 风格数组更安全,因为它允许我们在运行时捕获越界访问错误并处理它们。如果禁用了异常,我们可以设置一个全局终止处理程序来执行我们的功能。我们有机会在本书的第二章中看到这一点,当时我们正在讨论异常。

我们可以使用 std::array 创建一个类似向量的容器,我们可以使用它与容器适配器,如 std::stackstd::priority 队列。我们将我们的新类型称为 fixed_vector。它将继承自 std::array 并实现 push_backpop_backemptyend 方法。以下是使用标准库中的数组实现我们的新类型的示例:

template <typename T, size_t S> class fixed_vector : public std::array<T, S> {
  public:
    void push_back(const T &el) {
        if(cnt_ < S) {
            this->at(cnt_) = el;
            ++cnt_;
        }
    }
    T &back() {
        return this->at(cnt_-1);
    }
    void pop_back() {
        if(cnt_) {
            --cnt_;
        }
    }
    auto end() {
        return std::array<T, S>::begin() + cnt_;
    }
    bool empty() const {
        return cnt_ == 0;
    }
  private:
    size_t cnt_ = 0;
}; 

我们的新类型 fixed_vector 利用底层的 std::array 并实现 push_back 函数来向数组的末尾添加元素。如果我们尝试添加比可能更多的元素,它将静默失败。此行为可以根据应用程序的要求进行调整。它还实现了 back 方法,该方法返回对最后一个元素的左值引用,以及 pop_back,它递减用于跟踪容器中存储的元素数量的私有成员 cnt_

我们可以使用我们的新容器类型 fixed_vector 作为容器适配器(如栈和优先队列)的底层容器类型。

容器适配器

栈是一个简单的后进先出(LIFO)容器适配器,优先队列在插入元素时会对其进行排序。我们可以在以下示例中看到如何使用 fixed_vector

int main() {
    std::priority_queue<int, fixed_vector<int, 10>> pq;
    pq.push(10);
    pq.push(4);
    pq.push(8);
    pq.push(1);
    pq.push(2);
    printf("Popping elements from priority queue: ");
    while(!pq.empty()) {
       printf("%d ", pq.top());
       pq.pop();
    }
    std::stack<int, fixed_vector<int, 10>> st;
    st.push(10);
    st.push(4);
    st.push(8);
    st.push(1);
    st.push(2);
    printf("\r\nPopping elements from stack (LIFO): ");
    while(!st.empty()) {
       printf("%d ", st.top());
       st.pop();
    }
    return 0;
} 

在这个例子中,我们使用 fixed_vector 实例化 std::stackstd::priority_queue 模板类型。如果我们运行这个程序,我们将得到以下输出:

Popping elements from priority queue: 10 8 4 2 1
Popping elements from stack (LIFO): 2 1 8 4 10 

如您从输出中看到的,优先队列中的元素是排序的,而栈中的元素是按照后进先出(LIFO)原则弹出的。

标准库提供了各种容器,我们刚刚触及了它提供的可能性的一角。它还提供了在容器上操作的算法。

算法

C++ 标准库提供了包含在 algorithm 头文件中的大量模板算法函数,这些函数与不同的容器类型配合良好。我们现在将介绍其中的一些。

std::copy 和 std::copy_if

std::copystd::copy_if 用于将元素从一个容器复制到另一个容器。std::copy_if 还接受一个谓词函数,用于控制是否复制成员,如下面的示例所示:

#include <cstdio>
#include <vector>
#include <array>
#include <algorithm>
#include <numeric>
void print_container(const auto& container) {
    for(auto& elem: container) {
       printf("%d ", elem);
    }
       printf("\r\n");
}
int main() {
    std::array<int, 10> src{0};
    std::array<int, 10> dst{0};
    std::iota(src.begin(), src.end(), 0);
    std::copy_if(src.begin(), src.end(), dst.begin(),[] 
        (int x) {return x > 3;});
    print_container(src);
    print_container(dst);
    return 0;
} 

在这个例子中,我们使用 std::iota 从数值头文件初始化 src 数组,以递增的值开始,从 0 开始。然后,我们使用 std::copy_ifsrc 数组中所有大于 3 的元素复制到 dst 数组中。

std::sort

std::sort 用于对容器中的元素进行排序。在下面的例子中,我们将生成随机元素并对其进行排序:

int main() {
    std::array<int, 10> src{0};
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<> distrib(1, 6);
    auto rand = & -> int {
        return distrib(gen);
    };
    std::transform(src.begin(), src.end(), src.begin(), rand);
    print_container(src);
    std::sort(src.begin(), src.end());
    print_container(src);
    return 0;
} 

在这个例子中,我们使用 std::transform 来填充 src 数组,它将 rand lambda 应用到 src 数组的每个成员上。我们使用了 random 头文件中的类型来生成介于 1 和 6 之间的随机数。在我们用随机数填充数组之后,我们使用 std::sort 对其进行排序。这个程序的可能的输出如下所示:

6 6 1 1 6 5 4 4 1 1
1 1 1 1 4 4 5 6 6 6 

我们首先看到排序和应用 std::sort 之前的数组中的值。我们本可以用 for 循环来填充初始数组,但我们利用这个机会在这里展示了 std::transform

这些是从 C++ 标准库中的一些算法;还有更多可以用来有效地解决容器中常见任务的算法。

摘要

在本章中,我们介绍了 C++ 的基础知识,例如命名空间、函数重载、引用以及标准库容器和算法。我们还学习了如何在 C++ 程序中实现和使用 C 兼容性。

在下一章中,我们将学习 C++ 中的错误处理机制。

第七章:加强固件 - 实用的 C++错误处理方法

为了确保固件正常工作,我们必须处理来自供应商特定代码、项目中使用的库以及我们自己的代码的错误。错误代码是 C 中的标准错误处理机制,它们也在 C++中使用。然而,C++为我们提供了其他工具,最显著的是异常,由于大型二进制足迹和非确定性,这些异常通常在嵌入式项目中被避免。尽管如此,我们将在本章中讨论 C++中的异常,以展示它们在错误处理过程中的好处。

除了异常之外,C++还提供了更多用于错误处理的选项,这些选项也将在本章中讨论。本章的目标是理解错误代码的潜在问题,并了解如何在 C++中减轻这些问题。

在本章中,我们将涵盖以下主要主题:

  • 错误代码和断言

  • 异常

  • std::optionalstd::expected

技术要求

为了充分利用本章内容,我强烈建议在阅读示例时使用 Compiler Explorer(godbolt.org/)。选择 GCC 作为您的编译器,并针对 x86 架构。这将允许您看到标准输出(stdio)结果,并更好地观察代码的行为。由于我们使用现代 C++特性,请确保选择 C++23 标准,通过在编译器选项框中添加-std=c++23

Compiler Explorer 使得尝试代码、调整代码并立即看到它如何影响输出和生成的汇编变得容易。示例可在 GitHub 上找到(github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter07)。

错误代码和断言

错误代码是 C 中报告和处理错误的一种常见方式。它们在 C++中仍然被使用。一个失败的函数通过枚举代码报告错误,这些代码由调用者检查并适当处理。让我们从调用者和被调用者的角度分析错误代码是如何工作的。

返回错误的函数必须有一个暴露给调用者的错误列表。这个列表在软件生命周期中维护,并且可能会发生变化。枚举错误代码可以添加、删除或修改。调用者必须知道被调用者返回的错误代码,并且需要处理它们。或者,如果它不知道如何处理错误,它应该将错误进一步传播到调用栈中。

让我们观察一个返回错误的简单函数示例,并分析这对使用此函数的代码有何影响:

enum class error {
    Ok,
    Error1,
    Error2,
    Unknown
};
error h() {
    return error::Error1;
}
error g() {
    auto err = h();
    if(err!=error::Ok) {
        if(err == error::Error1) {
       // handle error directly
        }
        else if(err == error::Error2) {
            // propagate this error
return err;
        }
        else {
            // unknown error
return error::Unknown;
        }
    }
    return error::Ok;
}
void f() {
    auto err = g();
    if(err==error::Ok) {
        printf("Succes\r\n");
    }
    else {
        // handle errors
    }
} 

在前面的示例中,h函数返回了一个enum class error的错误。g函数调用h函数并执行以下步骤:

  1. 检查h是否返回了一个与error::Ok不同的错误。这表明h函数没有完成其任务,并且存在应该被处理的错误。

  2. 如果h返回了一个错误,检查它是否是error::Error1。在这种情况下,g知道如何处理这个错误,并处理它。

  3. 如果h返回error::Error2g无法处理它,并将它向上传递到调用栈。

  4. 返回error::Ok以向上传递到调用栈,表示一切顺利。

函数gf调用,而f也需要了解enum class error中定义的错误。它应该处理它们或将它们向上传递到调用栈。

错误代码依赖于设计契约。调用者必须检查被调用者是否返回了错误,如果是,它需要处理它或将它向上传递到调用栈。现在,我们可以识别出这种简单方法的一些潜在问题:

  • 我们不能通过调用者强制执行错误处理。它可能只是丢弃返回值。

  • 调用者可能会忘记处理一些错误情况。

  • 调用者可能会忘记将错误向上传递到调用栈。

这些是严重的设计缺陷,给代码开发增加了额外的负担。如果我们忘记处理某个错误,就没有逃逸路径。程序将处于未知状态,这可能导致不希望的行为。

我们可以使用nodiscard属性来解决第一个问题。它可以与函数声明或枚举声明一起使用。在我们的情况下,我们可以用它与enum class error声明一起使用,如下所示:

enum class [[nodiscard]] error {
    Ok,
    Error1,
    Error2,
    Unknown
}; 

当调用返回enum class错误的函数时,如果丢弃返回值,编译器会鼓励发出警告。如果我们从我们的示例中调用gh函数,GCC 将发出类似于以下警告:

<source>:48:6: warning: ignoring returned value of type 'error', declared with attribute 'nodiscard' [-Wunused-result] 

如果我们将编译器设置为将所有警告视为错误,这将破坏编译过程,并迫使我们使用代码中的返回值。尽管nodiscard属性很有用并且应该用于类似用例,但它并不是我们问题的完整解决方案。它将强制使用返回值,但调用者仍然可能未能检查所有可能的错误代码并正确处理它们。

几乎每个应用程序都有一些无法恢复的错误类型,唯一合理的事情是记录它们,向用户显示(如果可能),并终止程序,因为继续这样的程序状态是没有意义的。对于这些类型的错误,我们可以使用全局错误处理器,因为它们太重要了,不能任其处于野生状态且可能不会被调用者处理。

全局错误处理器

全局错误处理器可以实施为自由函数。它们在系统范围内用于处理无法恢复的错误,以及在需要由于错误的严重性而停止固件执行时。

让我们看看一个使用加速度计的固件示例。如果与加速度计的 I²C 通信出现任何问题,继续代码执行是没有意义的——固件将向用户显示一条消息并终止:

#include <cstdio>
#include <cstdint>
#include <cstdlib>
int i2c_read(uint8_t *data, size_t len) {
    return 0;
}
namespace error {
    struct i2c_failed{};
    struct spi_failed{};
    void handler(i2c_failed err) {
        printf("I2C error!\r\n");
        exit(1);
    }
    void handler(spi_failed err) {
        printf("SPI error!\r\n");
        exit(1);
    }
};
class accelerometer {
public:
    struct data {
        int16_t x;
        int16_t y;
        int16_t z;
    };
    data get_data() {
        uint8_t buff[6];
        if(i2c_read(buff, 6) != 6) {
            error::handler(error::i2c_failed{});
        }
        return data{};
    }
};
int main () {
    accelerometer accel;
    auto data = accel.get_data();
    return 0;
} 

在前面的例子中,我们有一个accelerometer类,它有一个get_data方法,该方法使用从供应商特定的 HAL 中导入的 C 语言的i2c_read函数(让我们假设这是这种情况)。

i2c_read函数返回读取的字节数。在我们的例子中,返回值被模拟为0,这样我们就可以模拟加速度计(或 I²C 总线)的错误行为。如果i2c_read返回与请求的字节数不同的数字,get_data将调用error::handler

我们使用标签分派机制实现了一个错误处理器。我们通过所谓的标签或空类型重载了error::handler函数。在我们的例子中,我们有两个标签,i2c_failedspi_failed,以及两个重载的错误处理器。与使用enum定义错误代码相比,标签分派有几个优点:

  • 我们需要在代码中使用的每个标签上重载错误处理器。每个错误类型都单独实现错误处理器。这增加了代码的可读性。

  • 如果我们调用了一个未重载的错误处理器,编译将失败,迫使我们实现它。

在我们的例子中,错误处理器将使用printf函数打印一条消息,并调用exit函数,从而有效地终止程序。在现实世界的情况下,我们如何处理错误取决于应用程序。例如,对于医疗设备,如果错误后关键操作变得不安全,我们首先尝试从错误中恢复。

如果恢复失败,系统将进入关键错误状态,通知医疗人员,并优雅地终止治疗操作。

在 I²C 总线上发生错误,或者更普遍地说,与外部设备通信失败,必须通过健壮的错误处理机制适当地处理。

另一方面,有一些条件表明存在编程错误——这些是在代码正确的情况下不应发生的情况。这包括违反先决条件,例如由于代码中的逻辑错误,输入参数超出预期边界。在这种情况下继续执行可能导致未定义的行为或系统不稳定。为了在开发期间检测这些编程错误,我们使用断言。

断言

断言主要用于开发期间,通过验证代码中特定点的某些条件是否成立来检测编程错误。当出现意外条件时,它们通过停止执行来帮助识别逻辑错误和错误的假设。标准库中的<cassert>定义了一个宏断言。它用于检查逻辑表达式,如果逻辑表达式为假,则打印诊断信息并调用std::abort,从而有效地终止程序。

为了更好地理解断言以及如何使用它们,让我们看一下以下代码示例:

#include <cassert>
#include <cstdint>
enum class option : std::uint8_t {
    Option1 = 0,
    Option2,
    Option3,
    Last
};
option uint8_to_option(uint8_t num) {
    assert(num < static_cast<uint8_t>(option::Last));
    return static_cast<option>(num);
}
int main() {
    const option opt = uint8_to_option(3);
    return 0;
} 

在前面的示例中,我们已定义了以 uint8_t 作为底层类型的 option 枚举类。我们将使用它来允许用户通过网络接口选择一个选项,并确保从 uint8_toption 枚举的转换始终正确。如果接收到的 uint8_t 参数不小于 option::Last,则 uint8_to_option 函数将断言。

在示例中,我们用参数 3 调用了 uint8_to_option,这并不小于 option::Last,这意味着断言宏将打印以下诊断信息,并通过调用 std::abort 来终止程序:

assertion "num < static_cast<uint8_t>(option::Last)" failed: file "/home/amar/projects/Cpp-in-Embedded Systems/Chapter07/error_handling/app/src/main.cpp", line 21, function: option uint8_to_option(uint8_t) 

现在,这是一个相当长的调试语句。让我们看看 assert 宏的定义:

#define assert(expr)                             \
     (static_cast <bool> (expr)                  \
      ? void (0)                                 \
      : __assert_fail (#expr, 
__ASSERT_FILE,            \
                       __ASSERT_LINE,            \
                       __ASSERT_FUNCTION)) 

我们可以看到表达式被转换为 bool 类型,如果表达式为真,三元运算符不执行任何操作;如果表达式为假,它将调用 __assert_fail 函数。assert 宏将表达式作为字符串字面量传递,将文件名作为字符串字面量传递,传递行号,还将函数名作为字符串字面量传递。所有这些字符串字面量都必须存储在二进制文件中,占用宝贵的内存。

断言可以通过在包含 <cassert> 之前定义 NDEBUG 宏来禁用,如下所示:

#define NDEBUG
#include <cassert> 

我们也可以使用构建系统来定义 NDEBUG。如果 <cassert> 包含之前定义了 NDEBUG,则 assert 宏将不执行任何操作。这个选项留给我们使用,以防我们想要禁用断言,因为它们最常用于调试构建,而在生产构建中被禁用。它们应该在安全关键软件验证之前被禁用。

标准库中实现的 assert 宏不适合嵌入式系统,因为它包含了文件名、函数名和 assert 表达式作为字符串字面量,最终存储在嵌入式目标的闪存中。此外,断言主要用于调试期间,它们通常在生产构建中被禁用。尽管如此,在生产构建中启用断言仍然有一些好处,因为如果它们在表达式评估为 false 时记录数据,它们可以提供宝贵的调试信息。

我们将检查使用断言记录信息的替代方法。正如我们已经得出的结论,默认的断言宏实现不适合嵌入式目标,尽管它包含了对调试有用的信息:文件名、函数名和行号。我们不需要一个描述断言宏行在代码中确切位置的冗长字符串,我们可以简单地记录程序计数器,并使用映射文件和 addr2line 工具将地址转换为确切的行。我们可以在以下代码中看到一个简单的宏定义和一个辅助函数来实现这一点:

void log_pc_and_halt(std::uint32_t pc) {
    printf("Assert at 0x%08lX\r\n", pc);
    while(true) {}
}
#define light_assert(expr)         \
        (static_cast<bool> (expr)  \
        ? void (0)                 \
        : log_pc_and_halt(hal::get_pc())    \
        ) 

我们定义了一个名为light_assert的宏,它不是调用__assert_failed,而是调用log_pc_and_halt。它将hal::get_pc的返回值作为参数传递给log_pc_and_halt。要查看此代码的实际效果,你可以查看Chapter07/error_handling项目中的示例。

本章的项目配置允许你配置它使用不同的主 C++文件,并使用 CMake 配置将要使用哪个文件。让我们使用以下命令启动我们的 Docker 容器:

$ docker start dev_env
$ docker exec -it dev_env /bin/bash 

这应该让我们进入 Docker 终端。运行ls –l以确保Cpp-in-Embedded-Systems仓库已克隆。如果没有,使用以下命令克隆它:

$ git clone https://github.com/PacktPublishing/Cpp-in-Embedded-Systems.git 

启动 Visual Studio Code,将其附加到正在运行的容器,并按照第四章中所述打开Chapter07/error_handling 项目,然后在 Visual Studio Code 终端中运行以下命令,或者直接在容器终端中运行它们:

$ cd Chapter07/error_handling
$ cmake -B build -DCMAKE_BUILD_TYPE=Debug -DMAIN_CPP_FILE_NAME=main_assert.cpp
$ cmake --build build --target run_in_renode 

前面的命令将使用app/src/main_assert.cpp文件构建固件,并在 Renode 模拟器中运行它。你应该在终端看到类似的输出:

14:11:06.6293 [INFO] usart2: [host: 0.31s (+0.31s)|virt: 0s (+0s)] Assert example
14:11:06.6455 [INFO] usart2: [host: 0.32s (+15.87ms)|virt: 0.11ms (+0.11ms)] Assert at 0x08000F74 

正如我们所见,断言评估表达式为假,并打印出0x08000F74程序计数器值。我们可以使用以下命令将此值转换为源文件的行:

$ arm-none-eabi-addr2line --exe bare.elf 0x08000F74 

这将产生以下输出:

/workspace/Cpp-in-Embedded-Systems/Chapter07/error_handling/app/src/main_assert.cpp:30 (discriminator 1) 

正如你所见,我们能够通过这种方法获取断言源的确切行,并且只需记录 4 字节的数据(地址)。在这个实现中,log_pc_and_halt只是打印出地址。在生产实现中,我们可以将地址存储在非易失性存储器中,并用于事后调试。

hal::get_pc()函数使用inline指定符声明。我们使用inline作为对编译器的提示,将函数的指令直接插入到调用点,即不进行函数调用。编译器不一定需要遵守我们的意图,这可以通过使用O0优化级别构建此示例来观察到。

练习题!

作为练习,编辑CMakeLists.txt中的CMAKE_C_FLAGS_DEBUGCMAKE_CXX_FLAGS_DEBUG,并将Og替换为O0。构建并运行程序,然后在输出上运行addr2line实用程序。为了减轻这一担忧,你可以定义一个宏来替代hal::get_pc()函数。

我们使用断言来捕获编程错误——如果代码正确,这些情况永远不会发生。它们通常用于验证关键函数内部的内部假设和不变性。断言的主要目的是用于调试;它们帮助开发者在开发阶段找到并修复错误。然而,正如我们所看到的,定制的断言也可以在生产构建中提供宝贵的洞察力,用于事后分析。虽然断言在开发过程中用于检测编程错误很有用,但它们不能替代生产代码中的正确错误处理。错误代码可能很繁琐,因为它们需要手动将错误传播到调用栈。C++提供了异常作为这些问题的解决方案,提供了一种结构化的方式来处理错误,而不会在代码中添加错误检查逻辑。

接下来,我们将深入了解 C++ 异常,以更好地理解它们从错误处理角度提供的优势。

异常

C++中的异常是基于抛出和捕获任意类型对象的原理的错误处理机制。从标准库中抛出的所有异常都源自于在 <exception> 头文件中定义的 std::exception 类。我们将可能抛出异常的代码放在 try 块中,并在 catch 子句中定义我们想要捕获的异常类型,如下面的示例所示:

 std::array<int, 4> arr;
    try {
      arr.at(5) = 6;
    }
    catch(std::out_of_range &e) {
      printf("Array out of range!\r\n");
    } 

在前面的示例中,我们定义了 std::array arr,一个包含四个成员的整数数组。在 try 块中,我们尝试访问索引为 5 的元素,这显然超出了定义的范围,at 方法将抛出 std::out_of_range 异常。为了运行此示例,请转到 Chapter07/error_handling 文件夹,确保已删除 build 文件夹,并运行以下命令:

$ mkdir build && cd build
$ cmake .. -DCMAKE_BUILD_TYPE=Debug -DMAIN_CPP_FILE_NAME=main_exceptions.cpp
$ make –j4
$ make run_in_renode 

你应该在终端中看到打印出 Array out of range!

现在,在构建示例时,你可能已经注意到二进制文件的大小达到了惊人的 88 KB。发生了什么?

为了启用异常,除了使用 -fexceptions 编译器标志外,我们还必须禁用之前示例中使用的 nano 规范。Nano 规范定义了 C 标准库 newlib-nano 的使用以及大小优化的 libstdc++libsupc++ 库。这些库在没有异常支持的情况下构建,如果我们使用它们,任何尝试抛出异常都将导致调用 std::abort。通过禁用 nano 规范,我们使用了一个未优化的 C++ 标准库,这导致了 88 KB 的二进制文件大小。可以从带有异常支持的源构建大小优化的标准 C++ 库,这将有助于减少二进制文件的大小。

如果没有捕获到异常,将调用 std::terminate_handler。我们可以使用 std::set_terminate 函数替换默认的处理程序,如下面的示例所示:

 std::set_terminate([]() {
        printf("My terminate handler!\r\n");
        while(true){}
    }); 

在上述示例中,我们提供了一个 lambda 作为终止处理程序。作为一个练习,尝试使用超出范围的索引访问前一个示例中的数组,但不在try块中。这应该会触发终止处理程序,并调用我们传递给std::set_terminate函数的 lambda。

异常沿着调用栈向上传播。让我们通过以下示例来演示异常传播:

template <class T, std::size_t N> struct ring_buffer {
  std::array<T, N> arr;
  std::size_t write_idx = 0;
  void push(T t) {
    arr.at(write_idx++) = t;
  }
};
int main()
{
    ring_buffer<int, 4> rb;
    try {
      for(int i = 0; i < 6; i++) {
        rb.push(i);
      }
    }
    catch(std::out_of_range &e) {
      printf("Ring buffer out of range!\r\n");
    }
    return 0;
} 

上述示例基于前几章中使用的环形缓冲区,该缓冲区使用std::array作为底层容器。在push方法中,它没有检查写入索引,这意味着如果我们调用push方法超过N次,数组的at方法将抛出异常。在push方法中抛出了异常,但没有try-catch块,它只在main函数的catch块中被捕获。

您可以使用以下说明在 Renode 模拟器中运行前面的示例。启动 Visual Studio Code,将其附加到正在运行的容器,按照第四章中所述打开Chapter07/error_handling 项目,然后在 Visual Studio Code 终端中运行以下命令,或者在容器终端中直接运行它们:

$ cd Chapter07/error_handling
$ cmake -B build -DCMAKE_BUILD_TYPE=Debug -DMAIN_CPP_FILE_NAME=main_exceptions.cpp
$ cmake --build build --target run_in_renode 

异常传播对于我们不希望手动使用错误代码在软件层之间传播的错误类型非常有用。然而,异常的问题在于它们与错误代码不同,在函数声明中是不可见的。我们需要依赖于良好的文档来了解哪个函数抛出错误以及错误在哪里被处理。

有一种说法是,异常用于非常罕见的异常错误。但什么是异常错误?这取决于库、应用程序和用例。很难概括。加速度计的读取失败可能是一个可恢复的错误,可以通过重置它来解决。我们可以在失败的 I²C 总线通信上抛出异常,并且捕获这个错误的上层可能决定尝试重置加速度计。

如果通过 DAC 未能控制升压稳压器输出可能也是可恢复的,但因为我们正在实施医疗设备,所以我们可能希望终止程序,这可能是防止对用户造成任何损害的最佳行动。在这种情况下,我们希望尽可能快地做出反应,异常传播和堆栈展开可能不是期望的,因此我们将依赖于全局处理程序或断言。

异常伴随着代价,包括闪存和 RAM 内存消耗,并且执行时间不能总是得到保证,如果我们正在处理硬实时系统,这会成为一个问题。但它们也解决了错误传播的问题,并强制执行错误处理。如果没有为特定类型提供catch子句,std::terminate_handler将被调用,程序将不会继续执行。

错误代码和异常可以共存,并且通常如此。嵌入式 C++ 项目通常使用 C 库或遗留的 C++ 代码,这些代码通常使用错误代码。我们可以通过将它们用于非常罕见的错误来从异常中受益,这为我们的固件增加了额外的鲁棒性。然而,是否使用它们的决定受到可用内存资源和我们所从事的项目类型的影

接下来,我们将介绍 C++ 的 std::optionalstd::expected 模板类,这些类用作函数的返回类型。

std::optionalstd::expected

C++17 引入了 std::optional,这是一个模板类,它要么有一个值,要么什么也没有。这在函数可能返回或不返回值的情况下非常有用。为了更好地理解它,让我们通过以下示例来了解:

#include <cstdio>
#include <optional>
struct sensor {
    struct data {
        int x;
        int y;
    };
    static inline bool ret_val = true;
    static std::optional<data> get_data() {
        ret_val = !ret_val;
        if(ret_val) {
            return data{4, 5};
        }
        else {
            return std::nullopt;
        }
    }
};
int main()
{
    const auto get_data_from_main = [] () {
        auto result = sensor::get_data();
        if(result) {
            printf("x = %d, y = %d\r\n", (*result).x, (*result).y);
        }
        else {
            printf("No data!\r\n");
        }
    };
    get_data_from_main();
    get_data_from_main();
    return 0;
} 

在前面的示例中,我们有一个具有 get_data 方法的 sensor 结构体,该方法在某些条件满足时返回一个值。否则,它不返回值。传感器不在错误状态,它只是还没有准备好数据。为此,我们使用 std::optional<data> 来声明传感器可能返回或不返回 data 结构体。我们使用 ret_val 布尔值来模拟 get_data 函数每第二次调用时数据就绪。

main 函数中,我们创建了 get_data_from_main lambda 表达式,它调用传感器的 get_data 方法。std::optional<data> 返回值在 if 语句中被转换为布尔值。如果它被转换为 true,则表示它包含数据,否则它不包含任何数据。我们通过解引用 result 对象来访问 data 类型。

C++ 23 引入了 std::expected<T, E>,这是一个模板类,它要么包含类 T 的预期对象,要么包含类 E 的意外对象。为了更好地理解这一点,让我们通过以下示例来了解:

#include <cstdio>
#include <expected>
struct ble_light_bulb {
    enum class error {
        disconnected,
        timeout
    };
    struct config {
        int r;
        int g;
        int b;
    };
    bool ret_val;
    std::expected<config, error> get_config() {
        ret_val = !ret_val;
        if(ret_val) {
            return config {10, 20, 30};
        }
        else {
            return std::unexpected(error::timeout);
        }
    }
};
int main()
{  
    ble_light_bulb bulb;
    const auto get_config_from_main = [&bulb]() {
        auto result = bulb.get_config();
        if(result.has_value()) {
            auto conf = result.value();
            printf("Config r %d, g %d, b %d\r\n", conf.r, conf.g, conf.b);
        } else {
            auto err = result.error();
            using bulb_error = ble_light_bulb::error;
            if(err == bulb_error::disconnected) {
                printf("The bulb is disconnected!\r\n");
            }
            else if(err == bulb_error::timeout) {
                printf("Timeout!\r\n");
            }
        }
    };
    get_config_from_main();
    get_config_from_main();
    return 0;
} 

在前面的示例中,我们有一个 ble_light_bulb 结构体,一个带有 get_config 方法的 BLE(蓝牙低功耗)灯泡,该方法通过 BLE 连接从灯泡读取一些配置数据。此方法返回 configerror。在 main 函数中,我们定义了 get_config_from_main lambda 表达式,它调用 ble_light_bulb 对象上的 get_config 方法。我们使用预期返回对象上的 has_value 方法来检查它是否包含预期的值。我们使用 value 方法来访问预期的值或使用 error 方法来访问 error 对象。

您可以使用以下说明在 Renode 模拟器中运行前面的示例。启动 Visual Studio Code,将其附加到正在运行的容器,按照 第四章 中所述打开 Chapter07/error_handling project,然后在 Visual Studio Code 终端中运行以下命令,或者直接在容器终端中运行它们:

$ cd Chapter07/error_handling
$ cmake -B build -DCMAKE_BUILD_TYPE=Debug -DMAIN_CPP_FILE_NAME=main_expected.cpp
$ cmake --build build --target run_in_renode 

摘要

在本章中,我们分析了 C++ 中的不同错误处理策略。我们讨论了错误代码、全局处理程序、断言、异常、std::optionalstd::expected。我们学习了每种方法的优缺点,以及在哪些情况下应用它们是有意义的。

在下一章中,我们将更详细地介绍模板。

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

嵌入式系统

Discord 二维码

第三部分

C++高级概念

在基础之上,本部分介绍了更高级的概念,如模板,包括静态多态和编译时计算。它还指导你如何在 C++中提高类型安全性,并使用 Lambda 表达式编写表达性代码。这些高级技术通过实际示例进行教学。

本部分包含以下章节:

  • 第八章使用模板构建通用和可重用代码

  • 第九章通过强类型提高类型安全性

  • 第十章使用 Lambda 表达式编写表达性代码

  • 第十一章编译时计算

第八章:使用模板构建通用和可重用代码

在本书的先前示例中,我们已经使用了类模板,但没有对其进行详细解释。到现在为止,你应该对 C++中的模板有基本的了解,并且知道如何使用标准库中的模板容器类来专门化具有不同底层类型的容器。我们还介绍了std::optionalstd::expected模板类,我们可以使用它们来处理函数的不同返回类型。

正如你所看到的,模板在 C++标准库中被广泛使用。它们允许我们对不同类型实现相同的功能,使我们的代码可重用和通用,这是 C++的一个优点。模板是一个极其复杂的话题;关于 C++模板和元编程的整本书都已经被写出来了。本章将帮助你更详细地了解 C++中的模板。

在本章中,我们将涵盖以下主要主题:

  • 模板基础

  • 元编程

  • 概念

  • 编译时多态

技术要求

为了充分利用本章内容,我强烈建议你在阅读示例时使用 Compiler Explorer (godbolt.org/)。选择 GCC 作为 x86 架构的编译器。这将允许你看到标准输出并更好地观察代码的行为。由于我们使用现代 C++,请确保选择 C++23 标准,通过在编译器选项框中添加-std=c++23来实现。

Compiler Explorer 使得尝试代码、调整代码并立即看到它如何影响输出和生成的汇编代码变得容易。本章的示例可以在 GitHub 上找到(github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter08)。

模板基础

“模板”这个词的一个定义是“一个用于指导正在制作的零件形状的量规、图案或模具(如薄板或板)。”这个定义可以应用于 C++中的模板。

在 C++中,模板充当函数和类的模式或模具,允许创建实际的函数和类。从这个角度来看,模板本身不是真正的函数或类型;相反,它们作为生成具体函数和类型的指南。为了更好地理解这个定义,让我们看一下以下代码示例:

#include <cstdio>
template<typename T>
T add(T a, T b) {
   return a + b;
}
int main() {
    int result_int = add(1, 4);
    float result_float = add(1.11f, 1.91f);
    printf("result_int = %d\r\n", result_int);
    printf("result_float = %.2f\r\n", result_float);
    return 0;
} 

在这个示例中,我们有一个模板函数add,其模板类型参数为T。在main函数中,我们看到对add函数的两次调用:

  • 第一个模板接受整数参数,并将返回值存储在result_int

  • 第二个模板接受浮点数参数,并将返回值存储在result_float浮点变量中

现在,我们之前说过,模板类型和函数不是真正的类型和函数,那么如果它不是一个真正的函数,我们如何调用模板函数呢?

调用模板函数

在这个例子中,当编译器看到对模板函数的调用时,它会推断模板参数,并用类型 int 替换第一个调用中的模板参数 T,在第二个调用中用 float 替换。在参数推断之后,模板被实例化;也就是说,编译器创建了两个 add 函数实例:一个接受整数作为参数,另一个接受浮点数。我们可以在前面示例的汇编输出中看到这一点:

_Z3addIiET_S0_S0_:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     DWORD PTR [rbp-8], esi
        mov     edx, DWORD PTR [rbp-4]
        mov     eax, DWORD PTR [rbp-8]
        add     eax, edx
        pop     rbp
        ret
_Z3addIfET_S0_S0_:
        push    rbp
        mov     rbp, rsp
        movss   DWORD PTR s[rbp-4], xmm0
        movss   DWORD PTR [rbp-8], xmm1
        movss   xmm0, DWORD PTR [rbp-4]
        addss   xmm0, DWORD PTR [rbp-8]
        pop     rbp
        ret 

在前面的汇编输出中,我们看到有两个 add 函数实例:_Z3addIiET_S0_S0_,接受整数,和 _Z3addIfET_S0_S0_,接受浮点数。编译器在确定了此函数调用点的模板参数后,从 add 模板函数中实例化了这两个函数。这是 C++ 中模板的基本工作原理。

add 模板函数的例子中,编译器将为每个定义了 operator+ 的类型实例化一个新的函数。那么,如果我们尝试对一个没有定义 operator+ 的类型调用 add 模板函数会发生什么?让我们看一下以下例子:

struct point {
    int x;
    int y;
};
int main() {
    point a{1, 2};
    point b{2, 1};
    auto c = add(a, b);
    return 0;
} 

在前面的例子中,我们定义了一个 point 结构体,对于它没有定义 operator+,并且调用了 add 模板函数。这将导致编译器错误,类似于下面显示的错误:

<source>: In instantiation of 'T add(T, T) [with T = point]':
<source>:25:17:   required from here
   25 |     auto c = add(a, b);
      |              ~~~^~~~~~
<source>:6:13: error: no match for 'operator+' (operand types are 'point' and 'point')
    6 |    return a + b;
      |           ~~^~~ 

那么,发生了什么?当编译器尝试使用 add 模板并使用 point 作为类型 T 实例化函数时,由于 no match for 'operator+' (operand types are 'point' and 'point'),编译失败。我们可以通过如下定义 point 结构体的 operator+ 来解决这个问题:

struct point {
    int x;
    int y;
    point operator+(const point& other) const {
        return point{x + other.x, y + other.y};
    }
    void print() {
        printf("x = %d, y = %d\r\n", x, y);
    }
}; 

在前面的实现中,我们为 point 结构体定义了 operator+,并且还定义了 print 函数,这将帮助我们打印点。在此更改之后,我们可以成功编译示例。

如果出于某种原因,我们想让 add 函数在类型 point 上使用时表现得与直接应用 operator+ 不同,会怎样?比如说,我们想在求和后同时将 xy 增加 1。我们可以使用模板特化来实现这一点。

模板特化

模板特化 允许我们向编译器提供特定类型的模板函数的实现,如下面的例子所示,特化了 add 函数以适用于类型 point

template<>
point add<point>(point a, point b) {
   return point{a.x+b.x+1, a.y+b.y+1};
} 

在这种情况下,当使用类型 point 的参数调用 add 函数时,编译器会跳过泛型模板实例化,而是使用这个专门的版本。这允许我们针对点对象特别定制函数的行为,当两个点实例相加时,每个坐标都会增加 1。现在让我们看一下完整的 main 函数:

int main() {
    point a{1, 2};
    point b{2, 1};
    auto c = add(a, b);
    c.print();
    static_assert(std::is_same_v<decltype(c), point>);
    return 0;
} 

如果我们运行之前步骤中的模板特化示例,我们将得到以下输出:

x = 4, y = 4 

编译器为point类型使用了函数特化。模板特化使模板成为一个灵活的工具,允许我们在需要时向编译器提供自定义实现。

在前面的例子中,我们可以看到对于变量c,我们使用了auto作为类型指定符。auto关键字是在 C++11 中引入的,当使用时,编译器会从初始化表达式中推导出变量的实际类型。为了确认变量c推导出的类型是point,我们使用了static_assert,它执行编译时断言检查。

作为static_assert的参数,我们使用元编程库中的类型特性std::is_same_v,它检查两个类型是否相同,如果相同则评估为true。我们使用decltype指定符确定c的类型,它会在编译时检索表达式的类型。这允许我们验证为c推导出的类型确实是point。如果这个断言失败,编译器将生成错误。

模板元编程

模板元编程涉及使用模板编写在编译时根据模板参数中使用的类型生成不同函数、类型和常量的代码。模板元编程是现代 C++库中广泛使用的高级技术。它可能令人难以理解,所以如果它看起来很难理解,那完全没问题。把这仅仅看作是对这个有趣主题的介绍和探索。

让我们回到add模板函数的例子。如果我们想强制这个模板函数只用于整数和浮点数等算术类型,我们能做些什么呢?

来自元编程库的<type_traits>头文件为我们提供了std::enable_if模板类型,它接受两个参数,一个布尔值和一个类型。如果布尔值为真,结果类型将有一个公共typedef成员type。让我们看看以下例子:

#include <type_traits>
template<typename T>
std::enable_if<true, T>::type
add(T a, T b) {
   return a + b;
} 

在前面的例子中,我们用std::enable_if代替了add模板函数的返回类型。因为我们设置了布尔参数为true,它将有一个公共typedef类型T,这意味着add函数模板的返回类型将是T

我们将使用类型特性类模板std::is_arithmetic<T>扩展这个例子,它将有一个名为value的公共布尔值,如果T是算术类型则设置为true。前面的例子将产生以下代码:

template<typename T>
std::enable_if<std::is_arithmetic<T>::value, T>::type
add(T a, T b) {
   return a + b;
} 

在前面的例子中,我们不是将true硬编码为std::enable_if的条件,而是使用了std::is_arithmetic<T>::value。让我们看看使用这个模板函数和前面例子中的point类型的main函数:

int main() {
    auto a = add(1, 2); // OK
    auto b = add(1.1, 2.1); // OK
    point p_a{1, 2};
    point p_b{2, 1}; 
    auto p_c = add(p_a, p_b); // compile-error
    return 0;
} 

如果我们尝试编译这段代码,编译将失败,并显示一个包含以下内容的冗长错误消息:

<source>: In function 'int main()':
<source>:30:17: error: no matching function for call to 'add(point&, point&)'
  30 |     auto c = add(p_a, p_b); // compile-error
     |              ~~~^~~~~~~~~~
<source>:30:17: note: there is 1 candidate
<source>:19:1: note: candidate 1: 'template<class T> typename std::enable_if<std::is_arithmetic<_Tp>::value, T>::type add(T, T)'
  19 | add(T a, T b) {
     | ^~~
<source>:19:1: note: template argument deduction/substitution failed:
<source>: In substitution of 'template<class T> typename std::enable_if<std::is_arithmetic<_Tp>::value, T>::type add(T, T) [with T = point]':
<source>:30:17:   required from here
  30 |     auto c = add(p_a, p_b); // compile-error
     |              ~~~^~~~~~~~~~
<source>:19:1: error: no type named 'type' in 'struct std::enable_if<false, point>'
  19 | add(T a, T b) {
     | ^~~ 

前面的编译器错误看起来很吓人,很难阅读。这是模板臭名昭著的问题之一。在我们解决这个问题之前,让我们专注于分析这个案例中发生了什么。

模板参数推导/替换失败,因为 std::is_arithmetic<point>::value 结果为 false,这意味着 std::enable_if 模板类型将不会有公共 typedef type T。实际上,任何尝试在这个例子中使用非算术类型的 add 模板函数都将导致编译器错误,即使该类型定义了 operator+。我们可以将 std::enable_if 视为 C++ 中模板函数的启用器或禁用器。

让我们修改 add 模板函数,使其打印求和操作的结果。由于整数和浮点数都是算术类型,我们需要对它们进行不同的处理。我们可以使用 std::enable_if 并创建两个模板函数,使用 std::is_integralstd::is_floating_point 类型特性,如下例所示:

template<typename T>
std::enable_if<std::is_integral<T>::value, T>::type
add(T a, T b) {
    T result = a + b;
    printf("%d + %d = %d\r\n", a, b, result);
    return result;
}
template<typename T>
std::enable_if<std::is_floating_point<T>::value, T>::type
add(T a, T b) {
    T result = a + b;
    printf("%.2f + %.2f = %.2f\r\n", a, b, result);
    return result;
} 

如你所记,std::enable_if 是一个模板启用器或禁用器,意味着它将启用整数类型的第一个模板函数,并使用 printf%d 格式说明符打印它们。对于整数类型的第二个模板函数,模板替换将失败,但不会被视为错误,因为第一个模板对于整数参数有一个有效的函数候选者。这个原则被称为 替换失败不是错误SFINAE)。对于浮点类型,第一个模板函数将被禁用,但第二个将被启用。

现在,我们使用的示例函数非常简单,但让我们暂时假设 add 函数模板正在执行一个繁重的任务,并且整数和浮点数版本之间的唯一区别是我们如何打印结果。因此,如果我们使用两个不同的函数模板,我们将复制大量的相同代码。我们可以通过使用 constexpr if 来避免这种情况,它将在编译时启用或禁用代码中的某些路径。让我们看看一个修改后的示例:

std::enable_if_t<std::is_arithmetic_v<T>, T>
add(T a, T b) {
    T result = a + b;
    if constexpr (std::is_integral_v<T>) {
        printf("%d + %d = %d\r\n", a, b, result);
    } else if constexpr (std::is_floating_point_v<T>) {
        printf("%.2f + %.2f = %.2f\r\n", a, b, result);
    }
    return a + b;
} 

在前面的例子中,我们使用了 constexpr if 语句根据 std::is_integral_v<T>std::is_floating_point_v<T> 表达式的编译时评估来启用程序的某些路径。constexpr if 是在 C++17 中引入的。你还可以注意到,我们使用了类型特性的别名作为 std::enable_if_t<T>,它等价于 std::enable_if<T>::type,以及 std::is_floating_point_v<T>,它等价于 std::is_floating_point<T>::value

在这个例子中,我们使用了类型特性和 std::enable_if 来仅对算术类型启用 add 函数模板。C++20 引入了概念,我们可以用它来对模板类型施加限制。

概念

概念是模板参数要求的命名集合。它们在编译时评估,并在重载解析期间用于选择最合适的函数重载;也就是说,它们用于确定哪个函数模板将被实例化和编译。

我们将创建一个用于算术类型的概念,并在我们的add模板函数中使用它,如下所示:

template<typename T>
concept Arithmetic = std::is_arithmetic_v<T>;
template<Arithmetic T>
T add(T a, T b) {
    T result = a + b;
    if constexpr (std::is_integral_v<T>) {
        printf("%d + %d = %d\r\n", a, b, result);
    } else if constexpr (std::is_floating_point_v<T>) {
        printf("%.2f + %.2f = %.2f\r\n", a, b, result);
    }
    return a + b;
} 

在前面的代码中,我们创建了Arithmetic概念,并在add函数模板中使用它来对T模板类型提出要求。现在add模板函数更容易阅读。从模板声明中可以看出,类型T必须满足Arithmetic概念的要求,这使得代码更容易阅读和理解。

概念不仅使代码更容易阅读,还提高了编译器错误的可读性。如果我们尝试在point类型上调用函数模板add,我们现在会得到一个类似于以下错误的错误:

<source>: In function 'int main()':
<source>:41:17: error: no matching function for call to 'add(point&, point&)'
  41 |     auto c = add(p_a, p_b); // compile-error
     |              ~~~^~~~~~~~~~
<source>:41:17: note: there is 1 candidate
<source>:22:3: note: candidate 1: 'template<class T>  requires  Arithmetic<T> T add(T, T)'
  22 | T add(T a, T b) {
     |   ^~~
<source>:22:3: note: template argument deduction/substitution failed:
<source>:22:3: note: constraints not satisfied
<source>: In substitution of 'template<class T>  requires  Arithmetic<T> T add(T, T) [with T = point]':
<source>:41:17:   required from here
  41 |     auto c = add(p_a, p_b); // compile-error
     |              ~~~^~~~~~~~~~
<source>:18:9:   required for the satisfaction of 'Arithmetic<T>' [with T = point]
<source>:18:27: note: the expression 'is_arithmetic_v<T> [with T = point]' evaluated to 'false'
  18 | concept Arithmetic = std::is_arithmetic_v<T>;
     |                      ~~~~~^~~~~~~~~~~~~~~~~~ 

之前的编译器错误比我们之前没有使用概念时的错误更容易阅读和理解发生了什么。我们可以轻松地追踪错误的起源,即Arithmetic概念对point类型施加的约束没有得到满足。

接下来,我们将继续讨论编译时多态,并看看我们如何利用概念来帮助我们强制执行强接口。

编译时多态

第五章中,我们讨论了动态的或运行时多态。我们用它来定义uart的接口,该接口由uart_stm32类实现。gsm_lib类只依赖于uart接口,而不是具体的实现,该实现包含在uart_stm32中。这被称为松耦合,使我们能够为gsm_lib类拥有可移植的代码。

我们可以轻松地在不同的硬件平台上为gsm_lib提供另一个uart接口实现。这个原则被称为依赖倒置。它表示高层模块(类)不应该依赖于低层模块,而两者都应该依赖于抽象(接口)。我们可以通过在 C++中使用继承和虚函数来实现这个原则。

虚函数会导致间接引用,从而增加运行时开销和实现所需的二进制大小。它们允许运行时调度函数调用,但这也带来了代价。在嵌入式应用中,我们通常知道所有我们的类型,这意味着我们可以使用模板和重载解析来进行静态或编译时调度函数调用。

使用类模板进行编译时多态

我们可以将gsm_lib制作成一个类模板,它有一个参数,我们将用它来指定uart类型,如下面的示例所示:

#include <span>
#include <cstdio>
#include <cstdint>
class uart_stm32 {
public:
    void init(std::uint32_t baudrate = 9600) {
        printf("uart_stm32::init: setting baudrate to %d\r\n", baudrate);
    }
    void write(std::span<const char> data) {
        printf("uart_stm32::write: ");
        for(auto ch: data) {
            putc(ch, stdout);
        }
    }
};
template<typename T>
class gsm_lib{
public:
    gsm_lib(T &u) : uart_(u) {}
    void init() {
        printf("gsm_lib::init: sending AT command\r\n");
        uart_.write("AT");
    }
private:
    T &uart_;
};
int main() {
    uart_stm32 uart_stm32_obj;
    uart_stm32_obj.init(115200);
    gsm_lib gsm(uart_stm32_obj);
    gsm.init();
    return 0;
} 

在上述示例中,编译器将使用uart_stm32类作为模板参数实例化gsm_lib模板类。这将导致在gsm_lib代码中使用uart_stm32类的对象引用。我们仍然可以通过使用提供所有编译所需方法的不同类型来轻松重用gsm_lib。在这个例子中,与gsm_lib类模板一起使用的类型必须提供一个接受std::span<char>作为其参数的write方法。但这同时也意味着任何具有此类方法的类型都将允许我们编译代码。

动态多态需要接口类在具体类中实现并在高级代码中使用。当阅读代码时,它使代码的预期行为变得清晰。我们能否使用模板做类似的事情?结果是我们可以的。我们可以使用奇特重复的模板模式CRTP)来实现编译时子类型多态。

奇特重复的模板模式 (CRTP)

CRTP 是 C++的一种惯用法,其中派生类使用一个以自身作为基类的模板类实例化。是的,听起来很复杂,所以让我们通过代码更好地理解这一点:

template<typename U>
class uart_interface {
public:
    void init(std::uint32_t baudrate = 9600) {
       static_cast<U*>(this)->initImpl(baudrate);
    }
};
class uart_stm32 : public uart_interface<uart_stm32> {
public:
    void initImpl(std::uint32_t baudrate = 9600) {
        printf("uart_stm32::init: setting baudrate to %d\r\n", baudrate);
    }
}; 

上述代码实现了 CRTP。uart_stm32派生类从使用uart_stm32类本身实例化的uart_interface类模板继承。基类模板公开了一个接口,它可以通过对this(指向自身的指针)使用static_cast来访问派生类。它提供了init方法,该方法在uart_stm32类的对象上调用initImpl

CRTP 允许我们在基类中定义我们的接口并在派生类中实现它,类似于我们用于运行时多态的继承机制。为了确保此接口在gsm_lib中使用,我们需要使用概念创建类型约束,如下所示:

template<typename T>
concept TheUart = std::derived_from<T, uart_interface<T>>; 

上述代码是我们将用于限制gsm_lib类模板接受类型的概念。它将仅接受由该类型本身实例化的uart_interface类模板派生的类型。以下是一个完整的代码示例:

#include <span>
#include <cstdio>
#include <cstdint>
template<typename U>
class uart_interface {
public:
    void init(std::uint32_t baudrate = 9600) {
       static_cast<U*>(this)->initImpl(baudrate);
    }
    void write(std::span<const char> data) {
       static_cast<U*>(this)->writeImpl(data);
    }
};
class uart_stm32 : public uart_interface<uart_stm32> {
public:
    void initImpl(std::uint32_t baudrate = 9600) {
        printf("uart_stm32::init: setting baudrate to %d\r\n", baudrate);
    }
    void writeImpl(std::span<const char> data) {
        printf("uart_stm32::write: ");
        for(auto ch: data) {
            putc(ch, stdout);
        }
    }
};
template<typename T>
concept TheUart = std::derived_from<T, uart_interface<T>>;
template<TheUart T>
class gsm_lib{
public:
    gsm_lib(T &u) : uart_(u) {}
    void init() {
        printf("gsm_lib::init: sending AT command\r\n");
        uart_.write("AT");
    }
private:
    T &uart_;
};
int main() {
    uart_stm32 uart_stm32_obj;
    uart_stm32_obj.init(115200);
    gsm_lib gsm(uart_stm32_obj);
    gsm.init();
    return 0;
} 

在上述代码中,我们使用 CRTP 实现了编译时或静态子类型多态。uart_stm32是一个具体类,它依赖于由uart_interface类模板定义的接口。我们使用TheUart概念来约束gsm_lib中从uart_interface派生的类型的高级代码。我们通过 CRTP 和概念实现了依赖反转,并且它得到了清晰的定义。

与继承(运行时多态)相比,编译时多态的主要优势是静态绑定;也就是说,没有虚拟函数。这以模板语法为代价,可能会使代码更难阅读和理解。

摘要

在本章中,我们介绍了模板基础、模板元编程、概念以及编译时多态。虽然模板是一个包含许多更深层次概念的先进主题,但本章旨在为新学习者提供一个坚实的起点。通过理解这里涵盖的基本原理,你应该能够很好地探索模板的更复杂方面,并在嵌入式系统编程中充分利用它们的全部潜力。

在下一章中,我们将讨论 C++ 中的类型安全。

第九章:使用强类型提高类型安全

C++是一种静态类型语言,这意味着每个表达式在编译时都会被分配一个类型,要么是由开发者(在大多数情况下),要么是在使用关键字 auto 时由编译器推导出来。尽管如此,这并不意味着它是一个类型安全的语言。

C++和 C 都允许具有可变数量的参数的函数(va_arg),或变长函数和类型转换,并支持隐式类型转换。与 C++和 C 的性能相关的这些底层功能往往是程序中 bug 的来源。在本章中,我们将介绍用于在 C++中提高类型安全性的良好实践。

类型安全是安全关键系统程序的一个重要方面。这就是为什么像 MISRA 和 AUTOSAR 这样的组织提供的编码标准会限制使用违反类型安全的特性。在本章中,我们将涵盖以下主要内容:

  • 隐式转换

  • 显式转换

  • 强类型

技术要求

为了充分利用本章内容,我强烈建议你在阅读示例时使用编译器探索器(godbolt.org/)。将 GCC 作为你的 x86 架构的编译器。这将允许你看到标准输出(stdio)结果,更好地观察代码的行为。由于我们使用了大量的现代 C++特性,请确保在编译器选项框中添加-std=c++23以选择 C++23 标准。

编译器探索器使得尝试代码、调整代码并立即看到它如何影响输出和生成的汇编变得容易。大多数示例也可以在 Arm Cortex-M0 目标的 Renode 模拟器上运行,并在 GitHub 上提供(github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter09))。

隐式转换

当你调用一个期望整数参数的函数,但你传递了一个浮点数作为参数时,编译器会愉快地编译程序。同样,如果你将整数数组传递给一个期望整数指针的函数,程序也会编译。这些场景在 C 和 C++中都变得如此正常,以至于它们通常被默认接受,而不考虑编译过程中的实际情况。

在这两种描述的场景中,编译器正在进行隐式转换。在第一种场景中,它将浮点数转换为整数,在第二种场景中,它传递数组的第一个元素的指针,这个过程被称为数组到指针退化

虽然隐式转换使代码更简洁、更容易编写,但它们也打开了与类型安全相关的一系列问题的门。将浮点数转换为整数会导致精度损失,而假设数组总是像指针一样行为可能导致对数组边界的错误解释,这可能导致缓冲区溢出或其他内存问题。

隐式转换在以下情况下执行:

  • 当一个函数以与参数类型不同的类型调用时。例如:

    #include <cstdio>
    void print_int(int value) {
        printf("value = %d\n", value);
    }
    int main() {
        float pi = 3.14f;
     // int implicitly converts to float
     print_int(pi);
     return 0;
    } 
    
  • 当返回语句中指定的值类型与函数声明中指定的类型不同时。例如:

    int get_int() {
        float pi = 3.14;
     // float implicitly converts to int
     return pi;
    } 
    
  • 在具有不同算术类型操作数的二元运算符表达式中。例如:

    #include <cstdio>
    int main() {
        int int_value = 5;
        float float_value = 4.2;
     // int converts to float
     auto result = int_value + float_value;
     printf("result = %f\n", result);
    
     return 0;
    } 
    
  • 在将整数类型作为switch语句的目标时。例如:

    char input = 'B';
    // implicit conversion from char to int
    switch (input) {
     case 65:
     printf("Input is 'A'\n");
     break;
     case 66:
     printf("Input is 'B'\n");
     break;
     default:
     printf("Unknown input");
    } 
    
  • if语句中,类型可以被转换为bool类型。例如:

    #include <cstdio>
    int main() {
        int int_value = 10;
     // int implicitly converts to bool
     if (int_value) {
     printf("true\n");
     }
     return 0;
    } 
    

编译器处理不同类型的隐式转换,其中一些最重要的转换包括:

  • 数字提升和转换

  • 数组到指针转换

  • 函数到指针转换

接下来,我们将通过示例讨论上述隐式转换。

数字提升和转换

算术类型可以被提升或转换为其他算术类型。类型提升不会改变值或丢失精度。std::uint8_t可以被提升为int,或者float可以被提升为double。如果一个类型可以被完全转换为目标类型,而不丢失精度,那么它正在进行提升。

算术运算符不接受小于int的类型。当作为算术运算符的操作数传递时,算术类型可以被提升。根据它们的类型,整数和浮点数的提升有一些特定的规则:

  • 布尔提升:如果bool类型设置为false,则提升为int类型,值为0;如果设置为true,则提升为int类型,值为1

  • 其他整型类型,包括位域,将被转换为以下列表中最小的类型,该类型可以表示转换类型的所有值:

    • int

    • unsigned int

    • long

    • unsigned long

    • long long

    • unsigned long long

  • float类型可以被提升为double类型。

为了更好地理解整数提升规则,我们将通过下一个例子进行说明:

#include <cstdint>
#include <type_traits>
int main() {
    std::uint8_t a = 1;
    std::uint16_t b = 42;
    auto res1 = a + b;
    static_assert(std::is_same_v<int, decltype(res1)>);
    return 0;
} 

在上述例子中,我们添加了uint8_tuint16_t。根据提升规则,这两种类型都将提升为int,因为它们可以被int完全表示。加法的结果存储在变量res1中,该变量被声明为auto,这意味着编译器将推导其类型。我们期望它是一个int,我们使用static_assertstd::is_same_v来验证这一点。

在这个例子中,两种类型都被提升到了相同的类型。如果我们提升后有不同的类型,那么它们将根据常规算术转换规则转换为一种公共类型。

常规算术转换的目标是将类型转换为一种公共类型,这同时也是结果类型。常规算术转换有一些规则:

  • 如果两种类型都是有符号或无符号整数,那么公共类型是具有更高整数转换等级的类型。等级按降序排列如下(无符号整数的等级对应于匹配的有符号整数的等级):

    • long long

    • long

    • int

    • short

    • signed char

  • 如果其中一个类型是有符号整数,另一个是无符号整数,则适用以下规则:

    • 如果无符号类型的整数转换等级大于或等于有符号类型,则公共类型是无符号类型。

    • 否则,如果有符号类型可以表示无符号类型的所有值,则公共类型是有符号类型。

    • 否则,公共类型是有符号整数的无符号整数类型。

  • 如果其中一个类型是浮点类型,另一个是整数,则整数转换为该浮点类型。

  • 如果两种类型都是浮点类型但浮点转换等级不同,则将转换等级较低的类型转换为另一个类型。浮点转换等级按降序排列如下:

    • long double

    • double

    • float

让我们通过以下示例来更好地理解常规算术转换的规则:

#include <type_traits>
int main() {
    struct bitfield{
        long long a:31;
    };
    bitfield b {4};
    int c = 1;
    auto res1 = b.a + c;  
    static_assert(sizeof(int) == 4);
    static_assert(sizeof(long long) == 8);
    static_assert(std::is_same_v<int, decltype(res1)>);
    long e = 5;
    auto res2 = e - b.a;
    static_assert(std::is_same_v<long, decltype(res2)>);
    return 0;
} 

在上述示例中,我们有一个 31 位的 bitfield,其底层类型为 long long。我们首先将 b.a 和类型为 int 的变量 c 相加。如果我们在一个 int 的大小为 4 字节的平台上,位字段将被提升为 int,尽管底层类型 long long 的大小为 8 字节。提升后的位字段将加到 int 类型的 c 上,因此这个操作的最终结果也将是 int,我们可以通过使用 std::is_same_v 检查 res1 的类型来验证这一点。

在示例的第二部分,我们从 long 类型的 e 中减去位字段。在这种情况下,位字段首先提升为 int;然后,根据常规算术转换的规则,它被转换为 long,这意味着结果类型也将是 long

你可以从本书的 GitHub 仓库运行上述示例。它位于 Chapter09/type_safety 目录下,你可以使用以下命令构建和运行它:

$ cmake -B build -DMAIN_CPP_FILE_NAME="main_usual_arithmetic_conversion.cpp"
$ cmake --build build --target run_in_renode 

程序成功构建的事实就足以确认常规算术转换的结果,因为我们使用了 static_assert 来验证它。

现在,让我们看看一个可能令人惊讶的结果的示例:

#include <cstdio>
int main() {
    int a = -4;
    unsigned int b = 3;
    if(a + b > 0) {
        printf("%d + % u is greater than 0\r\n", a, b);
    }
    return 0;
} 

如果你运行这个示例,if 子句中的表达式将评估为真。根据常规算术转换的规则,有符号的 int a 将被转换为 unsigned int,这意味着表达式 a + b 确实大于 0。在算术表达式中混合无符号和有符号类型可能会由于隐式转换而导致不期望的行为和潜在的错误。

我们可以使用 GCC 的编译器标志 –Wconversion-Wsign-conversion 来使其在隐式转换可能改变值和符号时发出警告。然而,在算术表达式中混合有符号和无符号类型应该避免,因为这可能导致错误的结果。

接下来,我们将讨论数组到指针转换及其影响。

数组到指针转换

一个数组可以被隐式转换为指针。生成的指针指向数组的第一个元素。许多在数据数组上工作的 C 和 C++函数都是设计有指针和大小参数的。这些接口基于合同设计。合同如下:

  • 调用者将传递一个指向数组第一个元素的指针

  • 调用者将传递数组的大小

这是一个简单的约定,但没有办法强制执行。让我们看看以下简单的例子:

#include <cstdio> 
void print_ints(int * arr, std::size_t len) {
    for(std::size_t i = 0; i < len; i++) {
        printf("%d\r\n", arr[i]);
    }
}
 int main() { 
    int array_ints[3] = {1, 2, 3};
    print_ints(array_ints, 3);
    return 0; 
} 

在上面的例子中,我们有print_ints函数,它有一个指向int的指针arrlen,一个std::size_t参数。在main函数中,我们通过传递array_ints,一个包含 3 个整数的数组,以及3作为参数来调用print_ints函数。数组array_ints将被隐式转换为指向其第一个元素的指针。print_ints函数有几个潜在问题:

  • 它期望我们传递给它的指针是有效的。它不会验证这一点。

  • 它期望它接收的len参数是它操作的数组的实际大小。调用者可能传递一个可能导致越界访问的大小。

  • 由于它直接操作指针,如果在函数中使用指针算术,总有可能发生越界访问。

为了消除这些潜在问题,在 C++中,我们不是使用指向数据数组的指针来工作,而是可以使用类模板std::span。它是一个连续对象序列的包装器,序列的第一个元素位于位置零。它可以由 C 风格数组构造,它有size方法,我们可以在它上面使用基于范围的for循环。让我们用std::span而不是指针重写之前的例子:

#include <cstdio> 
#include <span>
void print_ints(const std::span<int> arr) {
    for(int elem: arr) {
        printf("%d\r\n", elem);
    }
}
int main() { 
    int arr[3] = {1, 2, 3};
    print_ints(arr);
    return 0; 
} 

在上面的例子中,我们可以看到函数print_ints现在看起来简单多了。它接受整数的std::span,并使用基于范围的 for 循环遍历元素。在调用位置,我们现在只需传递arr,一个包含 3 个整数的数组。它会被隐式转换为std::span

类模板std::span也有size方法、操作符[]以及beginend迭代器,这意味着我们可以将其用于标准库算法。我们还可以从span构造子 span。它可以由 C 风格数组构造,也可以由容器如std::arraystd::vector构造。它是解决通常依赖于指针和大小参数的接口潜在问题的绝佳解决方案。

函数到指针的转换

一个函数可以被隐式转换为该函数的指针。以下示例演示了这一点:

#include <cstdio> 
#include <type_traits>
void print_hello() {
    printf("Hello!\r\n");
}
int main() { 
    void(*fptr)() = print_hello;
    fptr();
    fptr = &print_hello;
    (*fptr)();
    static_assert(std::is_same_v<decltype(fptr), void(*)()>);
    static_assert(std::is_same_v<decltype(print_hello), void()>);
    return 0; 
} 

在上述示例中,我们将函数 print_hello 赋值给函数指针 fptr。在 C++ 中,我们不需要使用地址运算符与函数名一起使用来将其赋值给函数指针。同样,我们不需要取消引用函数指针来通过它调用函数。尽管如此,print_hellofptr 是两种不同的类型,我们使用 static_assertis_same 类型特性来确认这一点。

C++ 中的隐式转换使编写代码更加容易。有时它们可能导致不期望的行为和程序中的潜在问题。为了减轻这些担忧,我们可以在需要时显式转换类型。

接下来,我们将介绍显式转换。

显式转换

C++ 支持使用 C 风格的类型转换显式转换,也支持函数式类型转换和以下类型转换运算符:

  • const_cast

  • static_cast

  • dynamic_cast

  • reinterpret_cast

我们将介绍类型转换运算符,从 const_cast 开始。

const_cast

const_cast 用于移除 const 属性以与非 const 正确函数一起工作。我们将通过以下示例来更好地理解它:

#include <cstdio>
void print_num(int & num) {
    printf("num is %d\r\n", num);
}
int main() {
    const int num = 42;
    print_num(const_cast<int&>(num));
    int & num_ref = const_cast<int&>(num);
    num_ref = 16;
    return num;
} 

在上述示例中,我们使用了 const_cast 在两种不同的场景中。我们首先使用它来从 const int num 中移除 const 属性,以便将其传递给 print_num 函数。print_num 函数有一个单个参数 – 一个对 int 的非 const 引用。正如我们所知,这个函数不会尝试修改引用所绑定到的对象,因此我们决定移除 const 属性,这样我们就可以将 const int 的引用传递给它,而不会导致编译器生成错误。

然后,我们使用了 const_cast 来从 num 中移除 const 属性,以便将其赋值给非 const 引用 num_ref。如果你在 Compiler Explorer 中运行此示例,你将看到以下输出:

Program returned: 42
num is 42 

程序返回了 42,也就是说,num 的值是 42,尽管我们试图通过 num_ref 将其设置为 16。这是因为通过非 const 引用或指针修改 const 变量是未定义的行为。

const_cast 主要用于与非 const 正确函数接口。尽管如此,这很危险,应该避免使用,因为我们无法保证我们传递给 const-cast-away 指针或引用的函数不会尝试修改指针所指向的对象或引用所绑定到的对象。接下来,我们将介绍 static_cast

static_cast

在 C++ 中,最常用的类型转换运算符是 static_cast,它用于以下场景:

  • 将基类指针向上转换为派生类指针,或将派生类指针向下转换为基类指针

  • 要丢弃一个值表达式

  • 在已知转换路径的类型之间进行转换,例如 int 到 float,enum 到 int,int 到 enum

我们将通过以下示例来介绍 static_cast 的几种用法:

#include <cstdio>
struct Base {
    void hi() {
        printf("Hi from Base\r\n");
    }
};
struct Derived : public Base {
    void hi() {
        printf("Hi from Derived\r\n");
    }
};
int main() {
    // unsigned to signed int 
int a = -4; 
    unsigned int b = 3; 
    if(a + static_cast<int>(b) > 0) { 
        printf("%d + %d is greater than 0\r\n", a, b); 
    } 
    else {
        printf("%d + %d is not greater than 0\r\n", a,b); 
    }
    // discard an expression
int c;
    static_cast<void>(c);
    Derived derived;
    // implicit upcast
    Base * base_ptr = &derived;
    base_ptr->hi();
    // downcast
    Derived *derived_p = static_cast<Derived*>(base_ptr);
    derived_p->hi();
    return 0;
} 

如果我们运行上面的示例,我们将得到以下输出:

-4 + 3 is not greater than 0
Hi from Base
Hi from Derived 

在上述示例中,我们使用 static_castunsigned int 转换为有符号的 int,这有助于缓解由隐式转换引入的混合符号整数比较问题。然而,我们仍需要确保转换是安全的,因为 static_cast 不会进行任何运行时检查。

使用 static_cast 将变量 c 转换为 void 是一种用于抑制编译器关于未使用变量警告的技术。这表明我们了解该变量,但我们故意不使用它。

在上述示例的另一部分中,我们可以看到 Derived 类对象的地址可以隐式转换为 Base 类指针。如果我们在一个指向 Derived 类对象的 Base 类指针上调用函数 hi,我们实际上会调用在 Base 类中定义的 hi 函数。然后我们使用 static_castBase 指针向下转型为 Derived 指针。

使用 static_cast 进行向下转型可能很危险,因为 static_cast 不会进行任何运行时检查以确保指针实际上指向转换的类型。Derived 类的对象也是 Base 类的对象,但反之则不成立——Base 不是 Derived。以下示例演示了为什么这很危险:

#include <cstdio>
struct Base {
    void hi() {
 printf("Hi from Base\r\n");
 }
};
struct Derived : public Base {
    void hi() {
 printf("Hi from Derived, x = %d\r\n", x);
 }
    int x = 42;
};
int main() {
 Base base;
 Derived *derived_ptr = static_cast<Derived*>(&base);
 derived_ptr->hi();
 return 0;
} 

在此代码中,我们试图在基类对象上访问 Derived 类的成员 x。由于我们使用了 static_cast,编译器将不会报错,这将导致未定义行为,因为基类没有成员 x。该程序的可能输出如下所示:

Hi from Derived, x = 1574921984 

为了避免这个问题,我们可以使用 dynamic_cast,我们将在下一节中介绍。

dynamic_cast

dynamic_cast 执行类型的运行时检查,并在 Base 指针实际上不指向 Derived 类对象的情况下将结果设置为 nullptr。我们将通过一个示例来更好地理解它:

#include <cstdio>
struct Base {
    virtual void hi() {
        printf("Hi from Base\r\n");
    }
};
struct Derived : public Base {
    void hi() override {
        printf("Hi from Derived\r\n");
    }
    void derived_only() {
        printf("Derived only method\r\n");
    }
};
void process(Base *base) {
    base->hi();
    if(auto ptr = dynamic_cast<Derived*>(base); ptr ! = nullptr) 
    {
        ptr->derived_only();
    }
}
int main() {
    Base base;
    Derived derived;
    Base * base_ptr = &derived;
    process(&base);
    process(base_ptr);
    return 0;
} 

在上述示例中,我们有一个带有 Base 类指针参数的 process 函数。该函数使用 dynamic_castBase 指针向下转型为 Derived 指针。在带有初始化器的 if 语句 中,我们使用 dynamic_cast<Derived*>Base 指针的结果初始化 ptr。在 if 语句的条件中,我们检查 ptr 是否与 nullptr 不同,如果是,则可以安全地将其用作指向 Derived 类对象的指针。接下来,我们将介绍 reinterpret_cast

reinterpret_cast

reinterpret_cast 用于通过重新解释底层位来在类型之间进行转换。它可以在以下情况下使用:

  • 将指针转换为足够大的整数,以容纳其所有值。

  • 将整数值转换为指针。将指针转换为整数再转换回其原始类型保证具有原始值,并且可以安全地解引用。

  • 要在不同类型之间转换指针,例如在 T1T2 之间。只有当结果指针是 charunsigned charstd::byteT1 时,指向 T2 的结果指针才能安全地解引用。

  • 要将函数指针 F1 转换为指向不同函数 F2 的指针。将 F2 转换回 F1 将导致指向 F1 的指针。

为了更好地理解 reinterpret_cast,我们将通过以下示例:

#include <cstdio>
#include <cstdint>
int fun() {
    printf("fun\r\n");
    return 42;
}
int main() {
    float f = 3.14f;
    // initialize pointer to an int with float address
auto a = reinterpret_cast<int*>(&f);
    printf("a = %d\r\n", *a);
    // the above is same as:
    a = static_cast<int*>(static_cast<void*>(&f));
    printf("a = %d\r\n", *a);
    // casting back to float pointer
auto fptr = reinterpret_cast<float*>(a);
    printf("f = %.2f\r\n", *fptr);
    // converting a pointer to integer
auto int_val = reinterpret_cast<std::uintptr_t>(fptr);
    printf("Address of float f is 0x%8X\r\n", int_val);
    auto fun_void_ptr = reinterpret_cast<void(*)()>(fun);
    // undefined behavior
fun_void_ptr();
    auto fun_int_ptr = reinterpret_cast<int(*)()>(fun);
    // safe call
printf("fun_int_ptr returns %d\r\n", fun_int_ptr());
    return 0;
} 

您可以从本书的 GitHub 仓库运行上述示例。它位于 Chapter09/type_safety 目录下,您可以使用以下命令构建和运行它:

$ cmake -B build -DMAIN_CPP_FILE_NAME="main_reinterpret_cast.cpp"
$ cmake --build build --target run_in_renode 

在 Renode 中运行示例将提供以下输出:

a = 1078523331
a = 1078523331
f = 3.14
Address of float f is 0x20003F18
fun
fun
fun_int_ptr returns 42 

上述示例演示了 reinterpret_cast 的用法。我们首先使用 reinterpret_cast<int*>(&f) 通过浮点地址初始化了一个指向整数的指针,这相当于使用 static_cast,即 static_cast<int*>(static_cast<void*>(&f))。我们打印了解引用整型指针的值,它是 1078523331。这是 float 变量 f 中包含的实际位模式。它是 3.14 的 IEEE-754 浮点表示。

然而,根据 C++ 标准,使用浮点地址初始化的整型指针的解引用不是定义良好的行为。这被称为类型欺骗——将一个类型的对象当作另一个类型处理。使用 reinterpret_cast 进行类型欺骗是常见的,尽管它引入了未定义的行为,但在大多数平台上它确实产生了预期的结果。在通过这个示例之后,我们将讨论替代方案。

如果我们将指向整数的指针转换回指向浮点数的指针,则可以安全地解引用结果指针。

接下来,我们将指针转换为浮点整数以打印它包含的地址。我们使用了 std::uintptr_t,这是一种能够容纳指向 void 的指针的整型。在此之后,我们初始化了 fun_void_ptr —— 一个指向返回 void 的函数的指针,该函数名为 fun,它返回 int。我们对 fun_void_ptr 指针进行了调用,它打印了预期的输出,但仍然是未定义的。将 fun_void_ptr 转换为与函数 fun 签名匹配的指针——fun_int_ptr——将使通过结果指针调用 fun 变得安全。

接下来,我们将通过 C++ 中的类型欺骗和替代方案来使用 reinterpret_cast

类型欺骗

尽管使用 reinterpret_cast 进行类型欺骗是常见的做法,尽管它引入了未定义的行为。别名规则决定了我们在 C++ 中如何访问一个对象,简单来说,我们可以通过一个指针及其 const 版本、包含该对象的 struct 或 union 以及通过 charunsigned charstd::byte 来访问一个对象。

我们将通过以下示例来更好地理解 C++ 中的类型欺骗:

#include <cstdio>
#include <cstdint>
#include <cstring>
namespace {
struct my_struct {
    int a;
    char c;
};
void print_my_struct (const my_struct & str) {
    printf("a = %d, c = %c\r\n", str.a, str.c);
}
void process_data(const char * data) {
    const auto *pstr = reinterpret_cast<const my_struct *>(data);
    printf("%s\r\n", __func__);
    print_my_struct(pstr[0]);
    print_my_struct(pstr[1]);
}
void process_data_memcpy(const char * data) {
    my_struct my_structs[2];
    std::memcpy(my_structs, data, sizeof(my_structs));
    printf("%s\r\n", __func__);
    print_my_struct(my_structs[0]);
    print_my_struct(my_structs[1]);
}
};
int main() {
    int i = 42;
    auto * i_ptr = reinterpret_cast<char*>(&i);
    if(i_ptr[0]==42) {
        printf("Little endian!\r\n");
    }
    else {
        printf("Big endian!\r\n");
    }
    my_struct my_structs_arr[] = {{4, 'a'}, {5, 'b'}};
    char arr[128];
    std::memcpy(&arr, my_structs_arr, sizeof(my_structs_arr));
    process_data(arr);
    process_data_memcpy(arr);
    return 0;
} 

您可以从本书的 GitHub 仓库运行上述示例。它位于 Chapter09/type_safety 目录下,您可以使用以下命令构建和运行它:

$ cmake -B build -DMAIN_CPP_FILE_NAME="main_type_punning.cpp"
$ cmake --build build --target run_in_renode 

在 Renode 中运行示例将提供以下输出:

Little endian!
process_data
a = 4, c = a
a = 5, c = b
process_data_memcpy
a = 4, c = a
a = 5, c = b 

在上面的示例中,我们使用了 reinterpret_cast 将整数 i 作为 chars 数组来处理。通过检查所提及数组第一个元素的值,我们可以确定我们是在大端还是小端系统上。根据别名规则,这是一种有效的方法,但将 chars 数组作为其他类型处理将是未定义的行为。我们在 void process_data 函数中这样做,在该函数中我们将 chars 数组重新解释为 my_struct 对象的数组。程序输出正如我们所预期的那样,尽管我们引入了未定义的行为。为了减轻这个问题,我们可以使用 std::memcpy

类型转换 - 正确的方法

使用 std::memcpy 是 C++ 中类型转换的唯一(截至 C++23)可用选项。在上面的示例中,我们在 process_data_memcpy 函数中展示了这一点。通常会有关于字节复制的担忧,使用额外的内存和运行时开销,但事实是 memcpy 的调用通常会被编译器优化掉。您可以通过在 Compiler Explorer 中运行上述示例并尝试不同的优化级别来验证这一点。

C++20 引入了 std::bit_cast,它也可以用于类型转换,如下面的示例所示:

#include <cstdio>
#include <bit>
int main() {
    float f = 3.14f;
    auto a = std::bit_cast<int>(f);
    printf("a = %d\r\n", a);
    return 0;
} 

上面的程序输出如下:

a = 1078523331 

上面的示例和程序输出展示了 std::bit_cast 用于类型转换的用法。std::bit_cast 将返回一个对象。我们指定要转换到的类型作为模板参数。这将是 std::bit_cast 的返回类型。转换类型的尺寸和我们转换到的类型必须相同。这意味着 std::bit_cast 不是一个将一种类型的数组解释为另一种类型数组的选项,为此我们仍然需要使用 std::memcpy

接下来,我们将看到如何使用 C++ 中的强类型来提高类型安全性。

强类型

当我们谈论类型安全性时,我们也应该讨论使用常用类型(如整数和浮点数)来表示物理单位(如时间、长度和体积)的接口的安全性。让我们看看以下来自供应商 SDK 的函数:

/**
  * @brief Start the direct connection establishment procedure.
A LE_Create_Connection call will be made to the controller by GAP with the initiator filter policy set to "ignore whitelist and
process connectable advertising packets only for the specified
device".
  * @param LE_Scan_Interval This is defined as the time interval from when the Controller started its last LE scan until it begins the subsequent LE scan.
Time = N * 0.625 msec.
  * Values:
  - 0x0004 (2.500 ms)  ... 0x4000 (10240.000 ms)
  * @param LE_Scan_Window Amount of time for the duration of the LE scan. LE_Scan_Window
shall be less than or equal to LE_Scan_Interval.
Time = N * 0.625 msec.
  * Values:
  - 0x0004 (2.500 ms)  ... 0x4000 (10240.000 ms)
  * @param Peer_Address_Type The address type of the peer device.
  * Values:
  - 0x00: Public Device Address
  - 0x01: Random Device Address
  * @param Peer_Address Public Device Address or Random Device Address of the device
to be connected.
    * @param Conn_Interval_Min Minimum value for the connection event interval. This shall be less than or equal to Conn_Interval_Max.
Time = N * 1.25 msec.
  * Values:
  - 0x0006 (7.50 ms)  ... 0x0C80 (4000.00 ms)
  * @param Conn_Interval_Max Maximum value for the connection event interval. This shall be
greater than or equal to Conn_Interval_Min.
Time = N * 1.25 msec.
  * Values:
  - 0x0006 (7.50 ms)  ... 0x0C80 (4000.00 ms)
  * @param Conn_Latency Slave latency for the connection in number of connection events.
  * Values:
  - 0x0000 ... 0x01F3
  * @param Supervision_Timeout Supervision timeout for the LE Link.
It shall be a multiple of 10 ms and larger than (1 + connSlaveLatency) * connInterval * 2.
Time = N * 10 msec.
  * Values:
  - 0x000A (100 ms)  ... 0x0C80 (32000 ms)
  * @param Minimum_CE_Length Information parameter about the minimum length of connection needed for this LE connection.
Time = N * 0.625 msec.
  * Values:
  - 0x0000 (0.000 ms)  ... 0xFFFF (40959.375 ms)
  * @param Maximum_CE_Length Information parameter about the maximum length of connection needed
for this LE connection.
Time = N * 0.625 msec.
  * Values:
  - 0x0000 (0.000 ms)  ... 0xFFFF (40959.375 ms)
  * @retval Value indicating success or error code.
*/
tBleStatus aci_gap_create_connection(
 uint16_t LE_Scan_Interval,
 uint16_t LE_Scan_Window,
 uint8_t Peer_Address_Type,
 uint8_t Peer_Address[6],
 uint16_t Conn_Interval_Min,
 uint16_t Conn_Interval_Max,
 uint16_t Conn_Latency,
 uint16_t Supervision_Timeout,
 uint16_t Minimum_CE_Length,
 uint16_t Maximum_CE_Length); 

这是一个文档良好的函数。尽管如此,理解它接受的参数及其确切单位仍然需要大量的努力。大多数参数代表时间,但以不同的方式表示。

LE_Scan_IntervalLE_Scan_WindowConn_Interval_MinConn_Interval_MaxSupervision_TimeoutMinimum_CE_LengthMaximum_CE_Length 都是时间相关的参数,但它们代表不同的单位。它们是 0.625、1.25 或 10 毫秒的倍数。上述函数的供应商还提供了以下宏:

#define CONN_L(x) ((int)((x) / 0.625f))
#define CONN_P(x) ((int)((x) / 1.25f)) 

下面是使用提供的宏调用上述函数的示例:

tBleStatus status = aci_gap_create_connection(CONN_L(80), CONN_L(120), PUBLIC_ADDR, mac_addr, CONN_P(50), CONN_P(60), 0, SUPERV_TIMEOUT, CONN_L(10), CONN_L(15)); 

宏定义有助于提高可读性,但向此函数传递错误值的问题仍然存在。很容易出错,交换CONN_LCONN_P宏,从而在程序中引入难以发现的错误。我们本可以用uint16_t,但可以定义并使用类型conn_lconn_p。如果我们用这些修正来包装函数,我们将得到以下包装函数:

tBleStatus aci_gap_create_connection_wrapper(
                            conn_l LE_Scan_Interval,
                            conn_l LE_Scan_Window,
 uint8_t Peer_Address_Type,
 uint8_t Peer_Address[6],
                            conn_p Conn_Interval_Min,
                            conn_p Conn_Interval_Max,
 uint16_t Conn_Latency,
 uint16_t Supervision_Timeout,
                            conn_l Minimum_CE_Length,
                            conn_l Maximum_CE_Length); 

在上述示例中,我们使用conn_lconn_p类型而不是uint16_t,我们将如下定义这些类型:

class conn_l {
private:
    uint16_t time_;
public:
 explicit conn_l(float time_ms) : time_(time_ms/0.625f){}
    uint16_t & get() {return time_;}
};
class conn_p {
private:
    uint16_t time_;
public:
 explicit conn_p(float time_ms) : time_(time_ms/1.25f){}
    uint16_t & get() {return time_;}
}; 

使用上述强类型conn_lconn_p,我们可以像下面这样调用包装函数:

 tBleStatus stat = aci_gap_create_connection_wrapper(
            conn_l(80),
            conn_l(120),
            PUBLIC_ADDR,
            nullptr,
            conn_p(50),
            conn_p(60),
            0,
            SUPERV_TIMEOUT,
            conn_l(10),
            conn_l(15)
    ); 

通过在conn_lconn_p类型的构造函数前使用关键字explicit,我们确保编译器不会从整数类型进行隐式转换。这使得无法传递可以用来构造conn_lconn_p的整数或浮点数到aci_gap_create_connection_wrapper

您可以从书的 GitHub 仓库运行整个示例。它位于Chapter09/type_safety下,您可以使用以下命令构建和运行它:

$ cmake -B build -DMAIN_CPP_FILE_NAME="main_strong_types.cpp"
$ cmake --build build --target run_in_renode 

成功编译示例意味着我们向aci_gap_create_connection_wrapper传递了所有正确的参数。作为一个练习,尝试用整数值而不是conn_lconn_p参数来传递,看看它们如何阻止编译器进行隐式转换。之后,尝试从conn_lconn_p构造函数中移除explicit关键字,看看会发生什么。

我们可以通过引入一个表示时间持续时间的强类型time来进一步改进示例,并将其作为conn_lconn_p类型的私有成员。代码将如下所示:

class time {
private:
    uint16_t time_in_ms_;
public:
 explicit time(uint16_t time_in_ms) : time_in_ms_(time_in_ms){}
    uint16_t & get_ms() {return time_in_ms_;}
};
time operator""_ms(unsigned long long t) {
    return time(t);
}
class conn_l {
private:
    uint16_t val_;
public:
 explicit conn_l(time t) : val_(t.get_ms()/0.625f){}
    uint16_t & get() {return val_;}
};
class conn_p {
private:
    uint16_t val_;
public:
 explicit conn_p(time t) : val_(t.get_ms()/1.25f){}
    uint16_t & get() {return val_;}
}; 

在上述示例中,我们创建了一个强类型时间,并将其用作conn_lconn_p类型的私有成员。我们还使用operator""_ms创建了一个用户定义字面量,以使以下函数调用成为可能:

 tBleStatus stat = aci_gap_create_connection_wrapper(
            conn_l(80_ms),
            conn_l(120_ms),
            PUBLIC_ADDR,
            nullptr,
            conn_p(50_ms),
            conn_p(60_ms),
            0_ms,
            4000_ms,
            conn_l(10_ms),
            conn_l(15_ms)
    ); 

在上述示例中,我们使用用户定义字面量operator""_ms来创建强类型时间的对象,这些对象用于实例化conn_lconn_p对象。

以上对原始接口的更改提高了代码的可读性和编译时错误检测。使用强类型,我们使向函数传递错误值变得更加困难,从而增加了代码库的类型安全性。

摘要

类型安全性是任何用于关键应用的编程语言的重要方面。理解隐式转换的潜在问题对于减轻类型安全性问题至关重要。类型欺骗是 C++中另一个值得特别注意的领域,我们学习了如何正确处理它。我们还学习了如何使用强类型来减轻向具有相同类型的参数传递错误值的问题。

接下来,我们将介绍 C++中的 lambda 表达式。

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

packt.link/embeddedsystems

二维码

第十章:使用 Lambda 表达式编写表达性代码

C++ 中的 Lambda 表达式允许我们编写封装功能并将周围状态捕获到可调用对象中的短代码块。我们可以在可调用对象上使用 operator() 来执行其中实现的功能。

Lambda 表达式的常见用途包括将函数对象(也称为函数对象 – 一个覆盖 operator() 的类的对象)传递给标准库算法,或任何期望函数对象的代码,封装通常仅在单个函数中使用的代码块,以及变量初始化。它们能够局部化功能而不需要单独的函数或类方法,使 C++ 能够编写更干净、更具有表达性的代码。

在嵌入式开发中,Lambda 表达式特别适用于定义对定时器或外部中断的反应动作、调度任务以及类似的事件驱动机制。本章的目标是学习如何使用 Lambda 表达式编写具有表达性的 C++ 代码。在本章中,我们将涵盖以下主要主题:

  • Lambda 表达式基础

  • 使用 std::function 存储 Lambda 表达式

  • std::function 和动态内存分配

技术要求

为了充分利用本章内容,我强烈建议在阅读示例时使用 Compiler Explorer (godbolt.org/)。选择 GCC 作为您的编译器,并选择 x86 架构。这将允许您看到标准输出(stdio)结果,并更好地观察代码的行为。由于我们使用了大量的现代 C++ 功能,请确保通过在编译器选项框中添加 -std=c++23 来选择 C++23 标准。

Compiler Explorer 使得尝试代码、调整代码并立即看到它如何影响输出和生成的汇编变得容易。大多数示例也可以在 ARM Cortex-M0 目标的 Renode 模拟器上运行,并在 GitHub 上提供(github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter10)。

Lambda 表达式基础

Lambda 表达式,或称为 Lambda,是在 C++11 中引入的。它们用于在 C++ 中创建未命名的闭包类型的实例。闭包存储一个未命名的函数,并且可以通过值或引用捕获其作用域中的变量。我们可以在 Lambda 实例上调用 operator (),并指定 Lambda 定义中的参数,从而有效地调用底层未命名的函数。为了与 C 语言进行比较,Lambda 可以像函数指针一样调用。

现在,我们将深入一个示例来展示如何在 C++ 中使用 Lambda 表达式,并解释有关 Lambda 捕获的细节。让我们处理以下示例:

#include <cstdio>
#include <array>
#include <algorithm>
int main() {
    std::array<int, 4> arr{5, 3, 4, 1};
    const auto print_arr = &arr {
        printf("%s\r\n", message);
        for(auto elem : arr) {
            printf("%d, ", elem);
        }
        printf("\r\n");
    };
    print_arr("Unsorted array:");
    std::sort(arr.begin(), arr.end(), [](int a, int b) {
       return a < b;});
    print_arr("Sorted in ascending order:");
    std::sort(arr.begin(), arr.end(), [](int a, int b) {
       return a > b;});
    print_arr("Sorted in descending order:");
    return 0;
} 

运行上述示例,我们将得到以下输出:

Unsorted array:
5, 3, 4, 1,
Sorted in ascending order:
1, 3, 4, 5,
Sorted in descending order:
5, 4, 3, 1, 

我们看到的是 Lambda print_arr 的输出,该 Lambda 用于打印在主函数中定义的数组 arr。让我们详细分析 print_arr Lambda:

  • [&arr] 语法通过引用从周围作用域捕获变量 arr。这意味着 lambda 可以在其主体中直接访问和使用 arr。

  • 我们可以通过值捕获变量,或者通过引用捕获,如果我们像 print_arr lambda 那样在变量名前加上 &

  • 通过引用捕获 [&arr] 允许 lambda 在其定义之后看到对 arr 在 lambda 外部所做的任何更改。如果我们通过值捕获,lambda 将有自己的 arr 副本。

  • main 中定义 print_arr 为 lambda,我们封装了打印数组的函数功能,而不需要创建一个单独的函数。这使相关代码放在一起,并提高了可读性。

在相同的示例中,我们使用了 lambda 作为 std::sort 算法的谓词函数,首先按升序排序数组 arr,然后按降序排序。我们将更详细地介绍 lambda 的这个用例:

  • std::sort 算法根据提供的比较器重新排列 arr 的元素。

  • lambda [](int a, int b) { return a < b; } 作为 std::sort 的比较函数。它接受两个整数,如果第一个小于第二个则返回 true,从而实现升序排序。

  • lambda [](int a, int b) { return a > b; } 如果第一个整数大于第二个则返回 true,从而实现降序排序。

std::sort 的调用位置直接定义比较器使代码更加简洁。无需查看代码的其他部分,就可以立即清楚地了解数组是如何排序的。

在使用 lambda 与 std::sort 算法的情况下,lambda 都很小且简单,这使得推断它们返回的内容变得容易。保持 lambda 短小直接被视为一种良好的实践,因为它提高了可读性,并使代码意图对他人来说一目了然。我们还可以在以下示例中显式指定 lambda 的返回类型:

 auto greater_than = [](int a, int b) -> bool {
        return a > b;
    }; 

在这里,我们显式地定义了返回类型。这是可选的,可以在我们想要明确 lambda 返回的类型时使用。此外,请注意,这个 lambda 的捕获子句是空方括号 []。这表示 lambda 没有从周围作用域捕获任何变量。

当 lambda 通过引用捕获变量时,需要注意的是,这引入了生命周期依赖性——这意味着引用所绑定到的对象必须在调用 lambda 时存在——否则,我们将使用所谓的悬垂引用,这是未定义的行为。这在异步操作中尤其是一个问题——也就是说,当 lambda 被传递给函数并在稍后调用时。接下来,我们将学习如何使用 std::function 存储 lambda 以异步使用它们。

使用 std::function 存储 lambda

std::function 是一个类模板,允许我们存储、复制和调用可调用对象,如函数指针和 lambda。我们将通过一个简单的代码示例来演示这一点:

#include <cstdio>
#include <cstdint>
#include <functional>
int main() {
    std::function<void()> fun;
    fun = []() {
        printf("This is a lambda!\r\n");
    }; 
    fun();
    std::uint32_t reg = 0x12345678;
    fun = [reg]() {
        printf("Reg content 0x%8X\r\n", reg);
    };
    reg = 0;
    fun();
    return 0;
} 

让我们通过一个例子来了解:

  • main 函数中,我们首先创建一个类型为 std::function<void()> 的对象 fun。这指定了 fun 可以存储任何返回 void 且不带参数的可调用对象。这包括函数指针、lambda 表达式或任何具有匹配签名的 operator() 的对象。

  • 然后我们将一个 lambda 表达式赋值给 fun 并调用它,它将消息“这是一个 lambda!”打印到控制台。

  • 接下来,我们将另一个 lambda 表达式赋值给 fun 对象。这次 lambda 表达式通过值捕获周围作用域中的 uint32_t reg 并打印它。通过值捕获意味着 lambda 表达式在定义时创建了 reg 的副本。

  • 在调用存储在 fun 中的可调用对象之前,我们将 reg 的值更改为 0,以显示它是通过值捕获的。调用 fun 打印 Reg content 0x12345678

让我们用一个更有趣的例子来使用 std::function,我们将使用它来存储一个 GPIO 中断的回调。以下是代码:

#include <cstdio>
#include <cstdint>
#include <functional>
namespace hal
{
class gpio
{
public:
    gpio(const std::function<void()> & on_press) {
        if(on_press) {
            on_press_ = on_press;
        }
    }
    void execute_interrupt_handler () const {
        if(on_press_) {
            on_press_();
        }
    }
private:
    std::function<void()> on_press_ = nullptr;
};
}; // namespace hal
int main () {
    hal::gpio button1([]() {
        printf("Button1 pressed!\r\n");
    });
    // invoke stored lambda
    button1.execute_interrupt_handler();
    return 0;
} 

在上面的代码中,我们创建了一个 hal::gpio 类来表示 GPIO:

该类存储 std::function<void()> on_press_,它可以持有任何可调用对象,如 lambda 函数。它被初始化为 nullptr,以表示它不持有任何可调用对象。

  • 它提供了 execute_interrupt_handler 方法,该方法检查 on_press_ 是否评估为 true,即它存储了一个可调用对象,并在必要时执行它。

main 函数中,我们创建 button1,一个 hal::button 类的对象:

  • 我们向构造函数提供一个简单的 lambda 表达式,它会打印 Button1 pressed!。

  • 接下来,我们调用 execute_interrupt_handler 方法,它调用存储的 lambda 表达式,程序打印 Button1 pressed!

在实际的固件中,我们会在中断服务中调用方法 execute_interrupt_handler

上述代码是命令模式应用的示例,它在 C++ 中通过 std::function 和 lambda 表达式以简单和表达的方式实现。

命令模式

命令模式是一种行为设计模式,用于捕获一个函数调用及其所需参数 – 允许我们延迟执行这些函数。

我们将讨论命令模式的经典定义。让我们从模式的 UML 图开始,然后进行解释:

图 10.1 – 命令模式 – UML 图

图 10.1 – 命令模式 – UML 图

图 10**.1 描述了命令模式的 UML 图。我们在上述图中注意到以下实体。

具有虚拟 execute 方法的 command 接口和 concrete_command 接口的具体实现。

  • receiver,在 concrete_command 实现中通过引用存储。它执行一个带 params 参数的 action

  • invoker,它存储对 command 接口的引用并执行一个 command

  • client 创建一个 receiver 并将其传递给 concrete_command 的构造函数。它将创建的 concrete_command 的引用传递给 invoker

通过使用命令接口,我们能够创建不同的具体命令并将它们提供给调用者。而不是使用命令接口和具体命令,我们可以使用类模板 std::function 和 lambda 表达式来实现相同的目的。

在我们之前的例子中,我们创建了 hal::gpio 类作为命令模式的调用者。它有一个 std::function<void()> 成员 – 等同于命令接口。具体命令是我们存储在 std::function<void()> 中的 lambda 表达式。

receiver 是 lambda 体 – 在我们的例子中是 printf 函数 – 而 clientmain 函数。客户端创建一个接收器(hal::gpio button1)并向它提供一个具体的命令(lambda 表达式)。我们直接从 main 函数中调用 execute_interrupt_handler

接下来,我们将扩展这个例子,从 STM32 平台的中断处理程序中调用 execute_interrupt_handler。该设计将支持来自多个引脚的中断。我们将引入 gpio_interrupt_manager 实体,它将负责注册调用者并在它们上调用 execute_interrupt_handler 方法。

GPIO 中断管理器

我们希望利用 std::function 类模板和 lambda 表达式来在固件中实现创建 GPIO 中断处理程序的表达式方式,如下面的代码所示:

const hal::gpio_stm32<hal::port_a> button1(hal::pin::p4, [](){
 printf("Button1 pressed!\r\n");
}); 

在上面的代码中,我们从一个由 hal::port_a 参数化的类模板 hal::gpio_stm32 中创建了一个 button1 对象。我们提供了一个带有 hal::pin::p4 和 lambda 表达式的构造函数,该 lambda 表达式将在中断上执行。这是一个目标,一个用于编写中断处理程序的表达式接口,同时也允许我们通过 lambda 表达式捕获所需的周围变量。

从上面的代码中,我们可以看到我们正在配置的引脚和端口以及将在中断上执行的回调。我们将创建的机制将处理将中断处理程序注册到我们将命名为 gpio_interrupt_manager 的中央实体。在我们继续设计之前,请按照以下说明在 Renode 中运行完整示例。

  1. 启动 Visual Studio Code,将其附加到正在运行的容器,按照 第四章 中所述打开 Chapter10/lambdas 项目,并在 Visual Studio Code 终端中运行以下命令,或者在容器终端中直接运行它们:

    $ cd Chapter10/lambdas
    $ cmake -B build -DCMAKE_BUILD_TYPE=Debug -DMAIN_CPP_FILE_NAME=main_std_function_command_pattern.cpp
    $ cmake --build build --target run_in_renode 
    
  2. 在 Renode 中,我们可以使用以下命令来模拟 button1button2 的按键和释放:

    gpioPortA.button1 PressAndRelease
    gpioPortA.button2 PressAndRelease 
    
  3. 输入上述命令应在 Renode 控制台中产生以下输出:

    Button1 pressed!
    Button2 pressed! 
    

如您所见,在 lambda 中提供的操作是由按钮生成的中断调用的。让我们通过这个例子的 UML 图来了解它是如何工作的:

图 10.2 – GPIO 中断管理器 UML 图

图 10.2 – GPIO 中断管理器 UML 图

在 *图 10**.2 中,我们看到 GPIO 中断管理器的 UML 图。它基于命令模式。我们使用 std::function<void()> 代替命令接口和 lambda 表达式作为具体命令。调用者是 hal::gpio 抽象类,它在成员 on_press 中存储 lambda。它如以下代码所示在构造函数中将自己注册到 gpio_interrupt_manager

gpio::gpio(const std::function<void()> & on_press) {
   on_press_ = on_press;
   gpio_interrupt_manager::register_interrupt_handler(this)
} 

gpio_interrupt_manager 是一个简单的结构体。它作为中断处理机制的中央实体,具有以下特性:

  • 它包含一个 hal::gpio 指针数组 – std::array<gpio*, c_gpio_handlers_num> gpio_handlers

  • 它提供了一个静态方法来注册一个 hal::gpio 指针 – void register_interrupt_handler(gpio * pin)

  • 它提供了一个静态方法,用于执行存储在数组中的中断处理程序 – void execute_interrupt_handlers()

方法 execute_interrupt_handlers 如下所示从中断服务例程中调用:

extern "C" void EXTI4_15_IRQHandler(void) {
    gpio_interrupt_manager::execute_interrupt_handlers();
} 

EXTI4_15_IRQHandler 是在向量表中定义的中断服务例程(在 platform/startup_stm32f072xb.s 中定义)。这就是为什么我们使用了 "C" 语言链接并将其实现为一个全局函数。execute_interrupt_handlers 方法遍历 hal::gpio 指针数组,并在它们上调用 execute_interrupt_handler 方法,如下所示:

void gpio_interrupt_manager::execute_interrupt_handlers() {
    for(std::size_t i = 0; i < w_idx; i++) {
        gpio_handlers[i]->execute_interrupt_handler();
    }
} 

hal::gpio 是一个具有以下特性的抽象类:

  • 它实现了之前看到的 gpio_interrupt_manager 所使用的 execute_interrupt_handler 方法。

  • 它定义了一个纯虚方法 [[nodiscard]] virtual bool is_interrupt_generated() const = 0。此方法需要由实现特定平台功能的派生类重写。

  • 它定义了一个虚拟方法 virtual void clear_interrupt_flag() const = 0。此方法需要由实现特定平台功能的派生类重写。

下面展示了 execute_interrupt_handler 的代码:

void gpio::execute_interrupt_handler () const {
    if(is_interrupt_generated()){
        clear_interrupt_flag();
        if(on_press_) {
            on_press_();
        }
    }
} 

execute_interrupt_handler 方法实现了以下功能:

  • 它使用虚拟方法 is_interrupt_generated 检查中断是否应由当前对象处理。此方法必须由派生类重写。派生类具有确定生成中断是否需要由当前对象处理所需的数据。

  • 如果中断应由当前对象处理,则使用虚拟方法 clear_interrupt_flag 清除中断标志,如果它存储了一个可调用对象,则调用 on_press_

hal::gpio_stm32 是从 hal::gpio 派生出的类模板。我们用端口作为参数实例化它,并实现了平台特定的操作,例如使用供应商提供的 C HAL 库进行 GPIO 初始化。

在此示例中,我们使用 port_a 结构体实例化了 hal::gpio_stm32,其中包含 void init_clock() 静态函数。这允许我们在模板参数上调用静态方法,而不是将端口定义为 enum,在运行时进行检查,并调用特定端口的时钟初始化函数。

hal::gpio_stm32 类模板使用 hal::gpio 作为基类:

  • 构造函数接受一个枚举引脚和一个对 std::function<void()> 对象的 const 引用,我们使用它来在初始化列表中初始化基类。

  • [[nodiscard]] bool is_interrupt_generated() const – 重写的方法使用供应商提供的 C HAL 来确定是否由构造函数通过的对象提供的引脚生成了中断。

  • void clear_interrupt_flag() const – 重写的方法实现了用于清除中断标志的平台特定代码。

这总结了 GPIO 中断管理器的实现,并解释了其设计。您可以在书籍 GitHub 仓库的 Chapter10/lambdas 文件夹中找到提供的源代码中的其他实现细节。

接下来,我们将讨论使用 std::function 对动态内存分配的影响。

std::function 和动态内存分配

std::function 需要存储 lambda 所捕获的所有变量和引用。这种行为是实现定义的,并且实现通常使用堆,即动态内存分配来存储大量变量。如果捕获的数据量小(在某些平台上,16 字节),它将被存储在栈上。这被称为小对象优化。为了演示 std::function 类模板在捕获数据时的行为,我们将通过以下示例进行说明:

#include <cstdio>
#include <cstdint>
#include <cstdlib>
#include <functional>
void *operator new(std::size_t count) {
  printf("%s, size = %ld\r\n", __PRETTY_FUNCTION__, count);
  return std::malloc(count);
}
void operator delete(void *ptr) noexcept {
  printf("%s\r\n", __PRETTY_FUNCTION__);
  std::free(ptr);
}
int main () {
    std::function<void()> func;
    auto arr = []() {
        constexpr std::size_t c_array_size = 6;
        std::array<int, c_array_size> ar{};
        for(int i = 0; i < ar.size(); i++) {
            ar[i] = i;
        }
        return ar;
    }();
    auto array_printer = [arr]() {
        for(int elem: arr) {
            printf("%d, ", elem);
        }
        printf("\r\n");
    };
    func = array_printer;
    // invoke stored lambda
func();
    return 0;
} 

在上述示例中,我们已经重写了 newdelete 操作符,以显示存储捕获了 6 个整数的 lambda 将会调用动态内存分配。如果您使用 x86-64 GCC 14.2 在 Compiler Explorer 中运行上述示例,您将看到以下输出:

void* operator new(std::size_t), size = 24
0, 1, 2, 3, 4,
void operator delete(void*) 

此示例还演示了通过 lambda 生成数组成员来初始化变量 arr。如果您将 constexpr std::size_t c_array_size 改为 4,您将注意到 newdelete 操作符不再被调用,这意味着在这种情况下,捕获的数据存储在栈上。

为了解决这个问题,我们可以将 lambda 对象的 std::reference_wrapper 赋值给 std::function<void()> fun,而不是像以下代码行中那样赋值给对象本身:

 func = std::ref(array_printer); 

这将使 std::function 对象使用对 lambda 对象的引用包装器,而不是复制它并存储 lambda 所捕获的所有变量。使用这种方法,我们必须注意 lambda 对象的生存期,这意味着如果它超出作用域,并且我们尝试通过 std::function 对象调用它,程序将出现未定义的行为。

我们也可以使用普通的函数指针来存储 lambda 表达式,但前提是它们不捕获周围作用域中的任何内容,如下面的示例所示:

#include <cstdio>
#include <functional>
int main () {
    void(*fun)(void);
    fun = []() {
        printf("Lambda!\r\n");
    };
    fun();
    return 0;
} 

在上述示例中,我们将 lambda 表达式赋值给函数指针,使其在某些应用中成为将 lambda 表达式存储到std::function类模板的可行替代方案。这也使得将非捕获 lambda 表达式传递给期望函数指针的 C 函数成为可能。

摘要

Lambda 表达式和std::function是强大的现代 C++工具,允许我们编写表达性代码并以优雅的方式实现设计模式,例如命令模式。我们学习了从周围作用域捕获数据的不同方法——通过值或引用。我们还探讨了命令模式设计模式,并学习了如何将其应用于 GPIO 中断管理器。

在下一章中,我们将介绍 C++中的编译时计算。

第十一章:编译时计算

编译时计算指的是编译器在编译时执行函数的能力,而不是将它们转换为机器代码。这意味着复杂运算的结果可以由编译器计算并存储在运行时使用的变量中。编译器只能在函数的所有参数在编译时已知的情况下在编译时执行函数。

我们可以在 C++固件中使用编译时计算来计算复杂的数学运算,生成查找表和数组,并在运行时使用生成的值。在编译时执行这些操作将节省宝贵的内存和处理器(空间和时间)资源,并将它们用于其他更重要的操作。

本章的目标是学习如何使用 C++中的编译时计算将复杂操作移至编译时,并节省宝贵的资源。在本章中,我们将涵盖以下主要主题:

  • 模板

  • constexpr指定符

  • consteval指定符

技术要求

为了充分利用本章内容,我强烈建议你在阅读示例时使用编译器探索器(godbolt.org/)。选择 GCC 作为你的 x86 架构编译器。这将允许你看到标准输出(stdio)结果,并更好地观察代码的行为。由于我们使用了大量的现代 C++特性,请确保通过在编译器选项框中添加-std=c++23来选择 C++23 标准。

编译器探索器使得尝试代码、调整它并立即看到它如何影响输出和生成的汇编代码变得容易。大多数示例也可以在 ARM Cortex-M0 目标上的 Renode 模拟器中运行,并在 GitHub 上提供(github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter11)。

模板

C++中编译时计算的第一个可用机制是模板元编程(TMP)。使用 TMP,我们可以将操作的结果存储在类型中,如下面的计算阶乘的示例所示:

template <unsigned int N>
`struct` factorial {
    static const unsigned int value = N * factorial<N-1>::value;
};
template <>
`struct` factorial<0> {
    static const unsigned int value = 1;
};
int main () {
    const int fact = factorial<5>::value;
    return fact;
} 

如果你在这个示例中运行编译器探索器(即使没有优化),你会看到它返回 120。生成的汇编代码很短,不包含任何函数调用。它只是在main函数中将值 120 放入返回寄存器中,这意味着阶乘计算是在编译时完成的。你可以在这里看到生成的汇编代码:

main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 120
mov eax, 120
pop rbp
ret 

在前面的示例中,我们执行了以下步骤:

  • 我们定义了一个类模板factorial。它有一个无符号整型N作为参数,并且只有一个成员:static const unsigned int value = N * factorial<N-1>::value

  • 在成员value的赋值表达式中,我们在模板级别使用递归,因为我们通过将N乘以使用N – 1实例化的阶乘的value来计算它。

  • 我们为 0 定义了阶乘模板特化,使其成为一个停止递归的基本类型,这意味着 factorial<0>::value 将包含 1

为了更好地理解模板级别的递归,我们将为前面的示例写下整个递归链:

  • factorial<5>::value = 5 * factorial<4>::value;

  • factorial<4>::value = 4 * factorial<3>::value;

  • factorial<3>::value = 3 * factorial<2>::value;

  • factorial<2>::value = 2 * factorial<1>::value;

  • factorial<1>::value = 1 * factorial<0>::value;

  • factorial<0>::value = 1;

如果我们将 factorial<0> 的基本值替换为 1,向上回溯,我们有以下内容:

  • factorial<1>::value = 1 * 1 = 1

  • factorial<2>::value = 2 * 1 = 2

  • factorial<3>::value = 3 * 2 = 6

  • factorial<4>::value = 4 * 6 = 24

  • factorial<5>::value = 5 * 24 = 120

main 函数通过访问 factorial<5>::value 来计算 5 的阶乘,并将其返回。递归通过为 factorial<0> 定制的模板终止,该模板提供了基本情况。最终结果是程序返回 120,即 5 的阶乘。

虽然 TMP 允许编译时计算,但它通常涉及复杂的递归模式,这些模式可能难以阅读和维护。为了解决这些挑战,C++11 引入了 constexpr 指示符,它已成为编译时计算的优选机制。

constexpr 指示符

使用 constexpr 指示符,我们声明可以在编译时评估变量和函数。在编译时可以评估的内容有限。一个 constexpr 变量必须满足以下要求:

  • 它需要是 literal 类型,以下之一:

    • 标量类型,如算术类型、枚举和指针

    • 引用类型

    • literal 类型的数组

  • 满足特定要求的类(例如,一个平凡的 constexpr 析构函数,其所有非静态数据成员都是 literal 类型,或者至少有一个 constexpr 构造函数)。

  • 它必须立即初始化。

  • 其初始化的整个表达式需要是一个常量表达式。

让我们通过以下示例更好地理解对 constexpr 变量的要求:

#include <cmath>
int main () {
    `constexpr` int ret = round(sin(3.14));
    return ret;
} 

如果你使用 x86-64 GCC 14.2 编译器在 Compiler Explorer 中运行此示例,并且没有启用优化,我们可以观察到以下内容:

  • 程序返回 0

  • 生成的汇编代码很小,它只是将 0 移动到返回寄存器。

  • 如果你更改 ret 变量的初始化,使正弦函数以 3.14/2 作为参数,程序将返回 1

现在,如果我们尝试在 Compiler Explorer 中将编译器更改为 x86-64 clang 18.1.0,我们将得到以下编译器错误:

<source>:4:19: error: constexpr variable 'ret' must be initialized by a constant expression
    4 |     constexpr int ret = round(sin(3.14));
      |                   ^     ~~~~~~~~~~~~~~~~
<source>:4:31: note: non-constexpr function 'sin' cannot be used in a constant expression
    4 |     constexpr int ret = round(sin(3.14)); 

编译器报告说我们违反了规则,即其初始化的整个表达式需要是一个常量表达式,因为在表达式round(sin(3.14))中的函数sin不是constexpr。这是因为 Clang 对数学函数的实现不是constexpr,而 GCC 将其实现为constexpr函数。在新的 C++26 标准中,许多数学函数都将被实现为constexpr函数。

尽管即将到来的 C++26 标准强制要求数学函数应该是constexpr,但我们将利用当前的 GCC 实现,因为我们在这本书的例子中使用的编译器是用于我们的 STM32 目标。所有constexpr函数都必须满足以下要求:

  • 它的return类型必须是literal类型。

  • 它的每个参数都必须是literal类型。

  • 如果一个函数不是构造函数,它必须只有一个return语句。

为了更好地理解constexpr函数,让我们在下面的例子中将阶乘算法实现为一个constexpr函数:

constexpr unsigned int factorial(unsigned int n) {
    unsigned int prod = 1;
    while(n > 0) {
        prod *= n;
        n--;
    }
    return prod;
}
int main () {
    constexpr int calc_val = 5;
    constexpr unsigned int ret = factorial(calc_val);
    return ret;
} 

在这个例子中,我们将阶乘算法实现为一个简单的constexpr函数。与基于 TMP 的解决方案相比,这个代码对许多有 C 背景的开发者来说看起来很熟悉。在模板级别没有递归和奇怪的语法。C++11 的constexpr函数仍然依赖于递归,但 C++14 放宽了对constexpr函数的限制,并允许使用局部变量和循环。

如果我们在编译器探索器中使用x86-64 GCC 14.2编译器运行前面的例子,并且没有启用优化,我们可以观察到以下情况:

  • 程序返回 120。

  • 生成的汇编代码很小,它只是将 120 移动到返回寄存器。

  • 生成的汇编代码中没有factorial函数,这意味着编译器在编译时执行了这个函数。我们提供了一个带有常量表达式参数的阶乘函数,编译器在编译时评估了这个函数。

  • 如果我们从calc_valret变量的声明中移除constexpr指定符,我们将在生成的汇编调用中看到factorial函数,在main函数中,我们将看到对这个函数的调用,这意味着在这种情况下,factorial函数是在运行时执行的,在固件的情况下,它将是二进制的一部分。

如我们从本例中可以看到,constexpr函数可以在编译时和运行时执行,具体取决于我们提供给它的参数。接下来,我们将通过实际例子来了解如何在固件开发中应用constexpr指定符。

示例 1 – MAC 地址解析器

介质访问控制MAC)地址用于不同通信栈的 MAC 层,包括以太网、Wi-Fi 和蓝牙。在这里,我们将创建一个 48 位 MAC 地址编译时解析器,它将帮助我们将常见的以冒号分隔的十六进制数字格式的 MAC 地址转换为 uint8_t 数组,这在软件栈中通常使用。代码如下所示:

#include <array>
#include <cstdint>
#include <string_view>
#include <charconv>
`struct` mac_address {
    static constexpr std::size_t c_bytes_num = 6;
    static constexpr std::size_t c_mac_addr_str_size = 17;

    std::array<uint8_t, c_bytes_num> bytes{};
    bool is_valid = false;
    constexpr mac_address(std::string_view str) {
        if (str.size() != c_mac_addr_str_size) {
            return;
        }
        for (size_t i = 0; i < c_bytes_num; ++i) {
            const std::string_view byte_str = str.substr(i * 3, 2);
            uint8_t value = 0;
            auto result = std::from_chars(byte_str.data(), byte_str.data() 
 + byte_str.size(), value, 16);
            if (result.ec != std::errc()) {
                return;
            }
            bytes[i] = value;
        }
        is_valid = true;
    }
};
int main () {
    constexpr mac_address addr("00:11:22:33:44:55");
    static_assert(addr.is_valid);
    return addr.bytes.at(5);
} 

main 函数中,我们通过提供一个构造函数 "00:11:22:33:44:55" 来创建 struct mac_address 的实例。如果我们使用 x86-64 GCC 14.2 编译器,在 Compiler Explorer 中运行前面的示例,并且没有启用优化,我们可以观察到以下内容:

  • 程序以十进制数 85 返回。将其转换为十六进制格式,我们将得到 0x55,这对应于 MAC 地址 00:11:22:33:44:55 的最后一个字节。

  • 生成的汇编代码很小。它使用我们在构造函数中使用的 MAC 地址的字节填充栈。没有对构造函数的调用,这意味着它在编译时执行。

  • 如果我们将构造函数中提供的 MAC 地址更改为 "000:11:22:33:44:55""G0:11:22:33:44:55",编译器将由于 static_assert(addr.is_valid) 失败而生成错误。

让我们现在更详细地解释 struct mac_address

  • struct 包含成员 std::array<uint8_t, c_bytes_num> bytesbool is_valid。它不包含任何方法,除了构造函数。

  • 构造函数接受 std::string_view 类模板,它封装了对提供的字符串字面量第一个元素的指针及其大小。

  • 构造函数使用 string_view 对象上的 susbstr 方法创建子串视图,并使用 std::from_char 将它们转换为 uint8_t 值,这些值存储在 bytes 数组中。

  • 如果没有错误,构造函数将 bool is_valid 设置为 true。使用 static_assert,我们可以在编译时验证提供的 MAC 地址字符串字面量是否成功转换。我们无法在 constexpr 函数中使用断言。另一种选择是抛出异常,这将导致编译时错误,但我们决定不为我们的嵌入式目标使用异常。

您也可以在 STM32 目标的 Renode 模拟器中运行前面的示例。启动 Visual Studio Code,将其附加到正在运行的容器,并打开如第四章所述的 Chapter11/compile_time 项目,然后在 Visual Studio Code 终端中运行以下命令,或者在容器终端中直接运行:

$ cd Chapter11/compile_time
$ cmake -B build -DCMAKE_BUILD_TYPE=MinSizeRel -DMAIN_CPP_FILE_NAME=main_constexpr_mac_address.cpp
$ cmake --build build --target run_in_renode 

这里是来自 main_constexpr_mac_address.cpp 文件的 main 函数的一部分:

constexpr mac_address addr("00:11:22:33:44:55");
static_assert(addr.is_valid);
const std::array<uint8_t, 6> addr_arr{0x00, 0x11, 0x22, 0x33, 0x44, 0x55};
const auto & mac_ref = addr.bytes;
//const auto & mac_ref = addr_arr;
printf("%02X:%02X:%02X:%02X:%02X:%02X\r\n", mac_ref[0], mac_ref[1], mac_ref[2], mac_ref[3], mac_ref[4], mac_ref[5]); 

为了确认将字符串字面量转换为数组的所有工作都是在编译时完成的,您可以绑定引用mac_refaddr_arr,并比较两种情况下的二进制大小。它们都是 6,564 字节,这意味着constexpr构造函数没有包含在二进制文件中,因为它实际上是在编译时由编译器执行的。

接下来,我们将通过一个示例来展示如何使用 C++中的constexpr函数创建温度热敏电阻的查找表。

示例 2 – 生成查找表

热敏电阻是电阻随温度变化的电阻器。它们在嵌入式系统中被广泛使用。它们通常具有非线性曲线。有不同方法来近似将热敏电阻的模拟-数字转换器(ADC)读数转换为温度。最常用的方法之一是贝塔系数。它是通过测量热敏电阻在两个温度点的电阻来计算的。它用于使用以下方程计算温度:

图片

在此方程中,T[0]是 25°C(298.15K)的室温,R[0]是室温下热敏电阻的电阻。使用贝塔系数(由制造商提供的常数)是对热敏电阻曲线的简化,因为它只依赖于在两个点测量曲线。

Steinhart-Hart 方程提供了一种更精确的曲线拟合方法,因为它依赖于通过在四个温度点测量热敏电阻计算出的四个系数。方程如下所示:

图片

系数ABCD是在测量热敏电阻在四个不同温度点的温度后计算的——这意味着这些是制造商为热敏电阻提供的常数。使用 Steinhart-Hart 方程计算的温度是以开尔文为单位的。Steinhart-Hart 方程的缺点是它在小型嵌入式目标中计算量较大。

在本例中,我们将使用 Steinhart-Hart 方程创建一个查找表,并依靠它通过读取我们嵌入式目标中的 ADC 值来确定温度。从方程中我们可以看出,温度是电阻和给定常数的函数。对于选定的电阻范围和选定的分辨率,我们将生成一个温度值的查找表。然后,我们将模拟读取热敏电阻的电阻值,并在查找表中搜索以确定温度。

我们将选择一个电阻范围,这是我们想要基于查找表的基础,以及我们想要使用的点的数量。为此,我们需要一个功能,它将生成给定范围内的均匀分布的数字数组,也称为线性空间。接下来,我们将使用这个线性空间作为信号发生器的参数。让我们从以下实现开始:

  1. 下面是展示线性空间生成器的代码:

    #include <array>
    #include <cstdio>
    template <typename T, std::size_t N>
    `struct` signal : public std::array<T, N> {
      constexpr signal() {}
      constexpr signal(T begin, T end) {
        static_assert(N > 1, "N must be bigger than 1"); 
        float step = (end - begin) / (N - 1);
        for (std::size_t i = 0; i < N; i++) {
          this->at(i) = begin + i * step;
        }
      }
    };
    int main() {
        constexpr signal<float, 10> x_axis(0, 9);
        for(auto elem: x_axis) {
            printf("%.2f, ", elem);
        }
        printf("\r\n");
        return 0;
    } 
    

如果我们运行此程序,它将打印 0 到 10 范围内的 10 个数字,如下所示:

0.00, 1.00, 2.00, 3.00, 4.00, 5.00, 6.00, 7.00, 8.00, 9.00, 

打印的数字是在编译时由signal struct生成的。为了将此与我们的示例联系起来,想象这些是我们想要使用 Steinhart-Hart 方程计算温度的电阻值。让我们详细了解一下实现过程:

  • signal是一个类模板。模板参数是typename Tstd::size_t N。它们决定了struct所基于的数组类型。

  • structstd::array<T, N>派生。我们基于std::array来能够轻松使用基于范围的 for 循环和标准库算法。

  • constexpr构造函数中,我们使用static_assert确保N大于 1,并在beginend之间填充底层数组的等间距点。

  • main函数中,我们为struct signal提供float10作为模板参数,并将09作为构造函数的beginend点,用于线性空间。我们使用基于范围的 for 循环遍历编译时生成的对象x_axis的元素并打印它们。

  1. 接下来,我们将使用一个额外的构造函数扩展信号struct,这个构造函数允许我们根据另一个信号和一个 lambda 表达式创建一个信号。下面是创建新构造函数的代码示例:

    template <typename T, std::size_t N>
    `struct` signal : public std::array<T, N> {
    // ...
    constexpr signal(const signal &sig, auto fun) {
        for (std::size_t i = 0; i < N; i++) {
          this->at(i) = fun(sig.at(i));
        }
      }
    }; 
    

在此构造函数中,我们通过调用传递的fun函数来初始化新信号中的元素,该函数是传递的信号sig的元素。

  1. 现在,我们可以创建一个新的信号,如下所示:

    int main() {
        const auto print_signal = [](auto sig) {
            for(auto elem: sig) {
                printf("%.2f, ", elem);
            }
            printf("\r\n");
        };
        constexpr signal<float, 10> x_axis(0, 9);
        print_signal(x_axis);
        auto sine = signal(x_axis, [](float x){ return std::sin(x);});
        print_signal(sine);
        return 0;
    } 
    

如果你正在使用编译器探索器跟随示例,请确保包含<cmath>头文件,因为我们使用了std::sin函数。运行它将给出以下输出:

0.00, 1.00, 2.00, 3.00, 4.00, 5.00, 6.00, 7.00, 8.00, 9.00,
0.00, 0.84, 0.91, 0.14, -0.76, -0.96, -0.28, 0.66, 0.99, 0.41, 

在此代码中,我们通过传递x_axis和 lambda 表达式[](int x){return std::sin(x);}到新创建的构造函数,创建了一个名为sine的新信号。

为了将此与示例联系起来,现在我们可以使用简单的数学函数(如std::sin)和从步骤 1 生成的信号构造函数生成的线性空间生成查找表。

生成查找表

要生成更复杂的功能,我们需要使用更多功能扩展signal类:

  1. 首先,我们将重载运算符*/,以便将信号乘以常数,并将常数除以信号的元素。代码如下所示:

    template <typename T, std::size_t N>
    `struct` signal : public std::array<T, N> {
    // ...
    constexpr signal operator*(const T &t) const {
        return signal(*this, &
                      { return elem * t; });
      };
      constexpr signal operator/(const T &t) const {
        return signal(*this, &
                      { return elem / t; });
      };
     }; 
    

在此代码中,我们重载了运算符*/,使得信号可以与标量进行乘法和除法,如下所示:

auto result = sig * 2.0f; 

上述代码将创建一个名为result的新信号,它将是信号sig的每个元素与标量2.0相乘的结果。

  1. 同样,我们可以通过除以标量来创建一个新的信号,如下所示:

    auto result = sig / 2.0f; 
    

此代码将创建一个名为result的新信号,它将是信号sig的每个元素除以标量2.0的结果。

  1. 为了支持运算符 */ 左侧的标量,我们需要实现全局运算符 operator*operator/。我们将通过将它们声明为 struct signal 的友元来实现,如下所示:

    template <typename T, std::size_t N>
    struct signal : public std::array<T, N> {
    // ...
    friend constexpr signal operator*(const T &t, const signal &sig)
      {
        return sig * t;
      }
      friend constexpr signal operator/(const T &t, const signal &sig)
      {
        signal ret;
        for (std::size_t i = 0; i < N; i++) {
          ret.at(i) = t / sig.at(i);
        }
        return ret;
      }
    }; 
    

此代码中的友元函数 operator* 允许当标量位于左侧时进行标量乘法(标量 * 信号),这是仅使用成员函数无法实现的。由于乘法具有交换性(a * b = b * a),我们只需调用成员函数 operator* 并返回结果(return sig * t)。

  1. 在友元函数 operator/ 中,我们执行以下步骤:

    1. 创建一个新的信号,ret

    2. 遍历信号 sig 的元素,并对每个元素,将标量 t 除以该元素。

    3. 我们返回信号 ret

  2. 通过将运算符 */ 都作为全局函数和成员函数重载,我们现在可以创建如下示例中的信号:

    int main() {
        // ...
    constexpr signal<float, 10> x_axis(0, 9);
        print_signal(x_axis);
        auto linear_fun = 2.f * x_axis;
        print_signal(linear_fun);
        auto linear_fun2 = linear_fun / 2.f;
        print_signal(linear_fun2);
        return 0;
    } 
    

此代码将产生以下输出:

0.00, 1.00, 2.00, 3.00, 4.00, 5.00, 6.00, 7.00, 8.00, 9.00,
0.00, 2.00, 4.00, 6.00, 8.00, 10.00, 12.00, 14.00, 16.00, 18.00,
0.00, 1.00, 2.00, 3.00, 4.00, 5.00, 6.00, 7.00, 8.00, 9.00, 

如我们从输出中看到的,最初创建的 x_axis,表示从 0 到 9.00 的线性空间,有 10 个点,被乘以 2.0 以创建 linear_fun。然后我们将 linear_fun 除以 2.0 以创建 linear_fun2,它与 x_axis 匹配。

  1. 为了能够写出完整的 Steinhart-Hart 方程,我们还需要重载运算符 +-,如下所示:

    template <typename T, std::size_t N>
    struct signal : public std::array<T, N> {
    // ...
    constexpr signal operator+(const T &t) const {
        return signal(*this, &
                      { return elem + t; });
      };
      constexpr signal operator-(const T &t) const {
        return signal(*this, &
                      { return elem - t; });
      };
      constexpr signal operator+(const signal &sig) const {
        signal ret;
        for (std::size_t i = 0; i < N; i++)
        {
          ret.at(i) = this->at(i) + sig.at(i);
        }
        return ret;
      };
      friend constexpr signal operator+(const T &t, const signal &sig)
      {
        return sig + t;
      }
    }; 
    

在此代码中,我们重载了以下运算符:

  • 成员 constexpr signal operator+(const T &t),允许我们将标量添加到信号(信号 + 标量)

  • 成员 constexpr signal operator-(const T &t),允许我们从信号中减去标量(信号 - 标量)

  • 成员 constexpr signal operator+(const signal &sig),允许我们逐元素相加两个信号(signal1 + signal2)

  • 全局 constexpr signal operator+(const T &t, const signal &sig),允许我们将信号添加到标量(标量 + 信号)

编写表示 Steinhart-Hart 方程的信号

现在我们已经拥有了编写表示 Steinhart-Hart 方程的信号的所需所有元素,如下所示:

int main()
{
  constexpr float A = 1.18090254918130e-3;
  constexpr float B = 2.16884014794388e-4;
  constexpr float C = 1.90058756197216e-6;
  constexpr float D = 1.83161892641824e-8;
  constexpr int c_lut_points = 50;
  constexpr signal<float, c_lut_points> resistance(1e3, 10e3);
  constexpr auto temperature_k =
  1 / (A +
  B * signal(resistance, [](float x)
                    { return std::log(x); }) +
  C * signal(resistance, [](float x)
                    { return std::pow(std::log(x), 2); }) +
  D * signal(resistance, [](float x)
                    { return std::pow(std::log(x), 3); }));
  constexpr auto temperature_celsius = temperature_k - 273.15f;
  std::ofstream file("out.csv");
  file << "Resistance[Ohm], Temperature[Celsius]\n";
  for (int i = 0; i < c_lut_points; i++) {
    file << resistance[i] << ", " << temperature_celsius[i] << "\n";
  }
  return 0;
} 

此代码通过以下步骤生成 Steinhart-Hart 方程的点:

  1. 定义 ABCD 系数。

  2. 在 50 个点之间创建 1 到 10 kOhms 的电阻值。

  3. 使用生成的电阻信号的点计算开尔文温度值。我们将温度转换为摄氏度,通过减去 273.15。

  4. 将生成的电阻和温度信号的值保存到 CSV 文件中(文件操作需要包含 <fstream> 头文件)。

你可以在 Docker 容器中运行完整的示例。启动 Visual Studio Code,将其附加到正在运行的容器,并打开如第四章所述的 Chapter11/signal_generator 项目,然后在 Visual Studio Code 终端中运行以下命令,或者在容器终端中直接运行它们:

$ cd Chapter11/signal_generator
$ cmake -B build
$ cmake --build build
$ ./build/signal_gen 

运行示例将生成一个 CSV 文件(out.csv)。我们可以使用以下终端命令从创建的 CSV 文件生成图像:

$ graph out.csv -o curve.png 

我们可以使用 docker cp 命令从主机机器传输生成的图像:

$ docker cp dev_env:/workspace/Cpp-in-Embedded-Systems/Chapter11/signal_generator/curve.png 

此命令将生成的图像 curve.png 传输到主机机器。我们也可以在这里看到相同的图像:

图 11.1 – Steinhart-Hart 曲线

图 11.1 – Steinhart-Hart 曲线

图 11**.1 展示了计算出的 Steinhart-Hart 曲线。使用 signal struct 在编译时生成了电阻和温度的值。接下来,我们将使用生成的曲线在 Renode 中读取模拟热敏电阻的 ADC 电压。以下是显示热敏电阻如何连接到微控制器的电路图像:

图 11.2 – 热敏电阻电路

图 11.2 – 热敏电阻电路

图 11**.2 展示了一个带有热敏电阻的分压器。如果我们测量 ADC 引脚上的电压,我们可以使用以下方程计算热敏电阻的电阻:

在前面的方程中:

  • R[T] 是热敏电阻的计算电阻。

  • R[2] 是已知值的电阻的电阻。

  • V[CC] 是电源电压。

  • V[ADC] 是 ADC 测量的电压。

我们可以使用 C++ 中的简单 struct 来模拟分压器,如下所示:

struct voltage_divider {
        units::resistance r2;
        units::voltage vcc;
        units::resistance get_r1(units::voltage vadc) {
            return r2 * (vcc/vadc - 1);
        }
    };
voltage_divider divider{10e3_Ohm, 3.3_V}; 

此代码展示了 struct voltage_divider。我们将详细探讨其细节:

  • 它使用在 units 命名空间中定义的强类型电阻和电压。您可以在项目文件夹 Chapter11/compile_time/util 中检查这些强类型的实现细节。

  • 我们使用列表初始化创建 voltage_divider 对象,如 voltage_divider divider{10e3_Ohm, 3.3_V}10e3_Ohm3.3_Vresistancevoltage 类型的用户定义文字。

  • struct 有一个单独的方法,units::resistance get_r1(units::voltage vadc)。它根据提供的 ADC 电压从分压器电路计算 R1 值。在我们的例子中,这是热敏电阻的电阻。

分析使用示例固件代码

接下来,我们将从 Chapter11/compile_time/app/src/main_lookup_table.cpp 中的 main 函数的循环中查看固件代码。如下所示:

auto adc_val = adc.get_reading();
if(adc_val) {
  auto adc_val_voltage = *adc_val;
  auto thermistor_r = divider.get_r1(adc_val_voltage);
  auto it = std::lower_bound(resistance.begin(),    
                resistance.end(), thermistor_r.get());
  if(it != resistance.end()) {
     std::size_t pos = std::distance(resistance.begin(), it);
     float temperature = temperature_celsius.at(pos);
     printf("%d mV, %d Ohm, %d.%d C\r\n",
           static_cast<int>(adc_val_voltage.get_mili()),
           static_cast<int>(thermistor_r.get()),
           static_cast<int>(temperature),
           static_cast<int>(10*(temperature-std::floor(temperature))) );
    }
  }
hal::time::delay_ms(200); 

让我们详细分析此代码:

  1. 我们在对象 adc 上调用 get_reading 方法。它属于 hal::adc_stm32 类型,并返回 std::expected<units::voltage, adc::error>。这是一种错误处理技术,我们在 第七章 中讨论过。您可以在项目文件夹 Chapter11/compile_time/hal/adc 中检查 adc_stm32 类的实现细节。

  2. 如果 get_reading 调用成功,我们将返回的对象解引用以获取电压,并将其传递给 voltage_dividerget_r1 方法来计算热敏电阻的值。

  3. 接下来,我们使用算法std::lower_bound来获取在计算热敏电阻值之前,resistance信号中第一个未排序元素的迭代器。如果我们找到这样的元素,我们使用std::distance计算其位置,并使用索引temperature_celsius获取温度值。

  4. 最后,我们打印出 ADC 的电压、热敏电阻的阻值和温度值。请注意,我们使用ints打印温度的浮点值,因为打印浮点值会增加固件的二进制大小。

要在 STM32 目标上的 Renode 模拟器中运行固件,请启动 Visual Studio Code,将其附加到正在运行的容器,并打开如第四章中所述的Chapter11/compile_time项目,然后在 Visual Studio Code 终端中运行以下命令,或者在容器终端中直接运行它们:

$ cd Chapter11/compile_time
$ cmake -B build -DCMAKE_BUILD_TYPE=MinSizeRel -DMAIN_CPP_FILE_NAME=main_lookup_table.cpp
$ cmake --build build --target run_in_renode 

要在 Renode 终端模拟 ADC 上的电压,请输入以下命令:

$ adc FeedVoltageSampleToChannel 0 1700 3 

上述命令将连续三次读取向 ADC 提供 1700 mV 的电压。这将产生以下输出:

1699 mV, 9412 Ohm, 26.2 C 

此命令显示,当 ADC 上的值为 1700 mV 时,我们计算出的热敏电阻值为 9412 欧姆,从而得出温度为 26.2⁰C。作为一个练习,向模拟中输入不同的 ADC 电压值,并将结果与之前步骤中的曲线图进行比较。

constexpr指定符是 C++中的一个灵活工具,允许我们在编译时和运行时运行函数。如果我们想确保函数仅在编译时评估,我们可以使用 consteval 指定符。

consteval 指定符

consteval 指定符只能应用于函数。它指定了一个函数是一个所谓的即时函数,并且对它的每次调用都必须在编译时产生一个常量。让我们通过以下简单的例子来了解:

constexpr int square(int x) {
    return x*x;
}
int main() {
    constexpr int arg = 2;
    int ret = square(arg);
    return ret;
} 

如果你使用x86-64 GCC 14.2编译器在编译器探索器中运行此示例,并且没有启用优化,我们可以观察到以下内容:

  • 程序返回 4。

  • 生成的汇编代码很小,它只是将 4 移动到返回寄存器。

  • 从变量arg中移除constexpr指定符将导致生成函数 square 并在main函数中调用它。

现在,让我们将函数squareconstexpr指定符更改为consteval,如下所示:

consteval int square(int x) {
    return x*x;
}
int main() {
    constexpr int arg = 2;
    int ret = square(arg);
    return ret;
} 

如果你在编译器探索器中运行程序,它将返回4并生成小的汇编代码。然而,如果我们现在从变量arg中移除constexpr指定符,编译将失败,并出现以下错误:

<source>: In function 'int main()':
<source>:7:21: error: call to consteval function 'square(arg)' is not a constant expression
    7 |     int ret = square(arg);
      |               ~~~~~~^~~~~
<source>:7:21: error: the value of 'arg' is not usable in a constant expression
<source>:6:9: note: 'int arg' is not const
    6 |     int arg = 2;
      |         ^~~ 

consteval 指定符确保函数仅在编译时评估。这防止了函数在运行时意外运行,这可能会发生在constexpr函数中。

摘要

在本章中,我们探讨了 C++ 中的编译时计算技术。我们介绍了 TMP 的基础知识,并深入解释了 constexpr 指示符,通过嵌入式系统相关的示例进行说明。

通过本章的知识,你可以生成查找表,并将可读地址、UUID 以及类似数据转换为通信栈使用的数组,这一切都在编译时完成。这允许你编写生成复杂数学信号的代码,而不会消耗额外的内存或处理时间。

接下来,我们将介绍在 C++ 中编写 HAL 所使用的技巧。

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/embeddedsystems

第四部分

将 C++应用于解决嵌入式领域问题

本部分侧重于通过解决嵌入式领域的问题来应用你所学到的所有知识。你将学习如何编写类型安全的、编译时检查的 HAL,了解如何有效地与 C 库一起工作,并研究适配器、状态和命令等设计模式。你还将学习如何应用 RAII 来管理如文件系统等资源。本部分以嵌入式开发中有用的库和框架概述以及 SOLID 原则的探讨作为结尾。

本部分包含以下章节:

  • 第十二章编写 C++ HAL

  • 第十三章使用 C 库

  • 第十四章使用序列器增强超级循环

  • 第十五章实用模式 - 构建温度发布者

  • 第十六章设计可扩展的有限状态机

  • 第十七章库和框架

  • 第十八章跨平台开发

第十二章:编写 C++ HAL

硬件抽象层HAL)是嵌入式项目中的核心软件组件。它通过提供一个易于使用的接口来简化与硬件外围设备的交互,该接口抽象了硬件细节。HAL 管理内存映射外围寄存器的读写,允许您使用 GPIO、定时器和串行通信接口等外围设备,而无需直接处理低级硬件的细节。它通常支持同一系列内的多个设备。

通过使用 HAL,固件可以在不同设备和同一供应商的类似系列设备之间变得更加便携。它隐藏了内存映射外围设备的寄存器布局,使得在各种设备上重用驱动程序和业务逻辑变得更加容易。HAL 处理特定平台的细节,使开发者能够专注于应用程序而不是硬件的细微差别。它还管理不同系列微控制器MCU)之间的差异。

建议使用供应商提供的 HAL,通常以 C 库的形式提供,因为它们经过充分测试并定期维护。然而,在某些情况下,可能需要直接与内存映射外围设备工作,因此在本章中,我们将探讨 C++技术,这些技术可以帮助您编写更安全、更易于表达的 HAL。在本章中,我们将涵盖以下主题:

  • 内存映射外围设备

  • 定时器

技术要求

本章的示例可在 GitHub 上找到(github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter12)。为了充分利用本章内容,请在 Renode 模拟器中运行示例。

内存映射外围设备

内存映射外围设备允许程序通过读取和写入特定内存地址来控制硬件设备。外围寄存器和 RAM 都映射到相同的地址空间,使得与硬件寄存器的通信变得如同写入和读取指向这些位置的指针一样简单。

在本书前面的示例中,我们使用了一个用 C 语言编写的 ST 提供的 HAL,它通过通用微控制器软件接口标准CMSIS)头文件来控制硬件外围设备。

CMSIS 是一个针对基于 Arm Cortex 的微控制器的供应商独立 HAL 和软件库集合。由 Arm 开发,它标准化了硬件访问和配置,简化了软件开发,并提高了不同制造商之间的代码可移植性。每个微控制器供应商都提供自己的 CMSIS 实现,将核心 API 和驱动程序适配到其特定设备。接下来,我们将探讨 STM32F072 微控制器的内存映射外围设备的 CMSIS 实现。

CMSIS 内存映射外围设备

CMSIS 中的寄存器访问通过指向描述寄存器布局的结构体的指针进行建模。CMSIS 定义了表示内存映射外围设备指针的宏。

根据 CMSIS 命名约定,结构体使用外设名称缩写和_TypeDef后缀命名。复位和时钟控制RCC)外设结构体命名为RCC_TypeDef。它在示例项目中定义在platform/CMSIS/Device/ST/STM32F0xx/Include/stm32f072xb.h文件中,如下所示:

typedef struct
{
  __IO uint32_t CR;         /* Address offset: 0x00 */
  __IO uint32_t CFGR;      /* Address offset: 0x04 */
  __IO uint32_t CIR;       /* Address offset: 0x08 */
  __IO uint32_t APB2RSTR;  /* Address offset: 0x0C */
  __IO uint32_t APB1RSTR;  /* Address offset: 0x10 */
  __IO uint32_t AHBENR;    /* Address offset: 0x14 */ 
  __IO uint32_t APB2ENR;   /* Address offset: 0x18 */ 
  __IO uint32_t APB1ENR;   /* Address offset: 0x1C */  
  __IO uint32_t BDCR;      /* Address offset: 0x20 */  
  __IO uint32_t CSR;       /* Address offset: 0x24 */   
  __IO uint32_t AHBRSTR;   /* Address offset: 0x28 */  
  __IO uint32_t CFGR2;     /* Address offset: 0x2C */
  __IO uint32_t CFGR3;     /* Address offset: 0x30 */
  __IO uint32_t CR2;       /* Address offset: 0x34 */
} RCC_TypeDef; 

在同一个头文件中,除了RCC_TypeDef结构体外,还定义了以下宏:

#define PERIPH_BASE           0x40000000UL
/*!< Peripheral memory map */
#define APBPERIPH_BASE        PERIPH_BASE
#define AHBPERIPH_BASE       (PERIPH_BASE + 0x00020000UL)
/*!< AHB peripherals */
#define RCC_BASE            (AHBPERIPH_BASE + 0x00001000UL)
/*!< Peripheral_declaration */
#define RCC                 ((RCC_TypeDef *) RCC_BASE) 
SystemInit function:
/* Set HSION bit */
RCC->CR |= (uint32_t)0x00000001U; 

在此代码中,我们正在设置时钟控制寄存器(CR)或RCC外设的 HSION 位,我们知道我们在做这件事是因为代码中的注释。此外,没有任何东西阻止我们将CR设置为任何随机值。以下是RCC外设的时钟配置寄存器(CFGR)的用法示例:

/* Reset SW[1:0], HPRE[3:0], PPRE[2:0], ADCPRE, MCOSEL[2:0], MCOPRE[2:0] and PLLNODIV bits */
RCC->CFGR &= (uint32_t)0x08FFB80CU; 

此代码设置了 PLL 分频、各种预分频器和时钟设置。从十六进制值0x08FFB80CU中并不明显可以看出应用了哪些设置。

尽管这种方法很常见,但使用寄存器结构体和指向外设基地址的指针来模拟对外设的访问有几个问题:

  • 第一个问题是可读性降低。我们可以以十六进制格式写入任意的uint32_t值,这使得代码变得毫无意义,并需要我们查阅微控制器的参考手册。

  • 由于我们可以向寄存器写入任何我们想要的值,我们很容易写错甚至随机写入值。

  • 在结构体中,外设的各个寄存器必须按照它们的内存布局顺序排列。使用名为RESERVERDn的成员用于在结构体中添加空间以调整外设寄存器的地址,并防止填充。

  • CMSIS 头文件可能包含宏定义,用于访问寄存器中单个设置的位掩码,这简化了对外设寄存器的访问。然而,这些宏并没有使代码更安全,只是更容易使用。

让我们看看如何利用 C++来解决这些问题,使代码更安全、更易读。

C++中的内存映射外设

我们将利用在前几章中学到的知识,创建一个表达性强且类型安全的接口来访问 C++中的内存映射外设。我们将创建一个具有以下特性的接口:

  • 对硬件寄存器的读写访问控制

  • 对寄存器的类型安全写入

  • 表达性强且易于使用

让我们从实现一个表示内存映射寄存器的接口的基本示例开始,这个接口的功能将与 CMSIS 方法相匹配。代码如下所示:

struct read_access{};
struct write_access{};
struct read_write_access : read_access, write_access {};
template<std::uintptr_t Address, typename Access = read_write_access, typename T = std::uint32_t>
struct reg {
template <typename Access_ = Access>
static std::enable_if_t<std::is_base_of_v<read_access, Access_>, T> 
read()
{
    return *reinterpret_cast<volatile T*>(Address);
}
template <typename Access_ = Access>
static std::enable_if_t<std::is_base_of_v<write_access, Access_>, void>
write(T val)
{
    *reinterpret_cast<volatile T*>(Address) = val;
}
}; 

在此代码中,类模板reg模拟了一个硬件寄存器。它有以下模板参数:

  • uintptr_t Address:硬件寄存器的内存地址

  • typename Access:寄存器的访问权限(默认为read_write_access

  • typename T:与寄存器大小匹配的数据类型(默认为std::uint32_t

类模板 reg 有两个静态方法:readwrite。这些方法分别用于从寄存器读取和写入。这两个方法都通过 SFINAE 在编译时启用或禁用,我们已经在 第八章 中讨论过。我们使用的访问控制类型如下:

  • struct read_access

  • struct write_access

  • struct read_write_access: 这个结构体从 read_accesswrite_access 继承

为了在编译时使用 SFINAE 启用和禁用 writeread 方法,我们将这两个方法都设计为模板函数。这允许我们在这些方法的返回类型中使用类模板 enable_if 来根据提供的条件启用或禁用它们。

writeread 的模板参数都是 Access_,默认为 Access。它通过使替换依赖于函数本身的模板参数来确保 SFINAE 正确工作。

我们使用 std::enable_if_t<std::is_base_of_v<read_access, Access_>, T> 启用 read 方法。这意味着如果 std::is_base_of_v<read_access, Access_> 为真(即,如果 Access_ 是从 read_access 继承或与 read_access 相同),则 std::enable_if_t 解析为 T,函数被启用。否则,它会导致替换失败,函数不会被包含在重载集中。我们以类似的方式启用 write 方法,通过检查 Access_ 类型是否从 write_access 继承或与之相同。

我们使用 reinterpret_cast<volatile T*> 将整数模板参数 Address 转换为指向类型 T(默认为 std::uint32_t)的 volatile 变量的指针。volatile 关键字通知编译器该内存位置的值可能在程序控制之外任何时候改变——由硬件引起。这防止编译器应用可能省略对该地址必要读取或写入的某些优化。

没有使用 volatile,编译器可能会假设从同一地址的多次读取会产生相同的值,或者写入该地址的顺序可以重排,甚至可以省略,这可能导致与硬件交互时出现不正确的行为。

正如我们在 第九章 中讨论的那样,使用 reinterpret_cast 将整数转换为指针在 C++ 中被认为是实现定义的行为。这意味着 C++ 标准没有指定它应该如何工作,不同的编译器或平台可能会有不同的处理方式。直接写入特定的内存位置固有不安全性,并且依赖于不一定能在所有系统间保证可移植性的行为。因此,我们需要谨慎考虑此解决方案的可移植性,因为某些平台可能以不同的方式实现指针转换。

这里是使用类模板 reg 的几个示例:

using rcc = reg<0x40021000>;
auto val = rcc::read(); // ok
rcc::write(0xDEADBEEF); // ok
using rcc_read = reg<0x40021000, read_access>;
auto val = rcc_read::read(); // ok
rcc_read::write(0xDEADBEEF); // compiler-error, no write access
using rcc_write = reg<0x40021000, write_access>;
auto val = rcc_write::read(); // compiler-error, no read access
rcc_write::write(0xDEADBEEF); // ok 

这些示例演示了使用实现接口访问内存映射外设的用法。当使用类模板 reg 定义类型时,我们向它提供寄存器的地址以及 write 访问权限,如果我们正在处理只写或只读寄存器。默认访问类型允许我们同时具有读和写权限。

前面的解决方案与 CMSIS 方法一样有效。您可以通过在 Renode 中运行完整示例并比较二进制大小来实验完整的示例。启动 Visual Studio Code,将其附加到正在运行的容器,按照 第四章 中所述打开 Chapter12/cpp_hal 项目,在 Visual Studio Code 终端中运行以下命令,或者在容器终端中直接运行它们:

$ cmake -B build -DCMAKE_BUILD_TYPE=Release -DMAIN_CPP_FILE_NAME=main_basic_reg.cpp
$ cmake --build build --target run_in_renode 

我们当前的解决方案仍然允许我们向寄存器写入任意值。为了解决这个问题,我们将使用基于 enum 类的强类型来建模用于在寄存器中设置不同设置的位字段。

C++ 中的类型安全的内存映射外设

为了防止使用类模板 reg 对寄存器进行任意写入,我们将添加一个新的静态方法 set,它将只接受满足某些标准的类型。我们将通过创建 BitFieldConcept 来建模这些类型。我们在 第八章 中介绍了概念。此外,我们将移除对 write 方法的公共访问权限,并将其放在 private 部分。修改后的代码如下:

template<typename BitField, typename Reg, typename T>
concept BitFieldConcept =
    std::is_same_v<Reg, typename BitField::reg> &&
    std::is_enum_v<typename BitField::value> &&
    std::is_same_v<std::underlying_type_t<typename
BitField::value>, T>;
template<std::uintptr_t Address, typename Access = read_write_access, typename T = std::uint32_t>
struct reg {
using RegType = T;
     // Type alias for the current instantiation
using ThisReg = reg<Address, Access, T>;
template<typename BitField>
requires `BitFieldConcept`<BitField, ThisReg, T>
static void set(BitField::value bits_val)
{
    auto reg_value = read();
    reg_value &= ~BitField::c_mask;
    reg_value |= (static_cast<T>(bits_val) <<
          BitField::c_position) & BitField::c_mask;
    write(reg_value);
}
template <typename Access_ = Access>
static std::enable_if_t<std::is_base_of_v<read_access, Access_>, T> 
read()
{
    return *reinterpret_cast<volatile T*>(Address);
}
private:

template <typename Access_ = Access>
static std::enable_if_t<std::is_base_of_v<write_access, Access_>, void> 
write(T val)
{
    *reinterpret_cast<volatile T*>(Address) = val;
}
}; 

模板方法 set 有一个单独的模板参数 – 类型 BitField。我们使用 BitFieldConceptBitField 强制以下要求:

  • Reg 必须与 BitField::reg 相同。这确保了位字段与正确的寄存器相关联。

  • BitField::value 必须是一个 enum

  • BitField::value enum 的底层类型必须是 T。这确保了 enum 表示的值可以适合寄存器。

set 函数参数是 BitField::value bits_val。该函数本身很简单,并执行以下操作:

  • 读取当前寄存器值

  • 清除由 BitField::c_mask 指定的位

  • 通过将 bits_val 移位到正确的位置(BitField::c_position)并应用掩码来设置新位

  • 将修改后的值写回寄存器

要使用 set 函数,我们需要定义描述寄存器位字段并满足 BitFieldConcept 强制要求的类型。

建模 RCC 寄存器中的 HSION 和 HSITRIM 位字段

让我们检查 STM32F0x2 参考手册文档中定义的 RCC CR 寄存器中的位字段,如图 图 12.1 所示:

图 12.1 – RCC CR 寄存器

图 12.1 – RCC CR 寄存器

图 12.1 展示了 RCC CR 寄存器中的位字段。让我们定义一个名为 hsion 的结构体,它描述了 RCC CR 寄存器中的 HSI 时钟 enable 位字段。它只在位置 0 有一个位,因此我们可以将其建模如下:

using rcc = reg<0x40021000>;
struct hsion {
    using reg = rcc;
    using T = reg::RegType;
    static constexpr T c_position = 0U;
    static constexpr T c_mask = (1U << c_position);
    enum class value : T {
        disable = 0U,
        enable  = 1U,
    };
}; 

在此代码中,我们通过提供 RCC 寄存器的地址将类型 rcc 声明为类模板 reg 的一个实例。然后,我们创建一个具有以下属性的 hsion 结构:

  • 一个公共 typedef 成员 reg,我们将其设置为 rcc。这“映射”了 hsionrcc 寄存器,归功于 BitFieldConcept

  • constexpr 变量 c_positionc_mask,用于通过 set 方法进行位操作。

  • 定义 enabledisableenumvalue

我们可以使用 hsion 结构通过以下代码启用或禁用 HSI 时钟:

rcc::set<hsion>(hsion::value::enable);
rcc::set<hsion>(hsion::value::disable); 

此代码允许我们安全地在寄存器中设置位。它也非常清晰:语法 rcc::set<hsion>(hsion::value::enable); 明确传达了意图——在 rcc 寄存器上将 hsion 位字段设置为 enable

如我们在 *图 12**.1 中所见,CR 寄存器中定义的大多数位字段是 enable/disable 位。例外情况包括:

  • HSICAL[7:0]: HSI 时钟校准:这些位在启动时自动初始化,并且可以通过 HSITRIM 设置由软件进行调整。

  • HSITRIM[4:0]: HSI 时钟微调:这些位提供了额外的用户可编程微调值,并将其添加到 HSICAL[7:0] 位。此设置允许对电压和温度变化进行调整,这些变化可能会影响 HSI 频率。

HSICAL 位在启动时初始化,这意味着我们不应该修改它们。HSITRIM 位是用户可编程的,并占用 5 位。在 BitFieldenum 中定义所有 5 位的组合在实践上并不实用,因此我们将通过提供模板参数的方式来处理这个问题,如代码所示:

template<auto Bits>
struct `hsi_trim` {
    using reg = rcc;
    using T = reg::RegType;
    `static_assert`(std::is_same_v<T, decltype(Bits)>);
    static constexpr T c_position = 3;
    static constexpr T c_mask = (0x1F << c_position);
    `static_assert`(Bits <= 0x1F);
    enum class value : T {
        val = Bits
    };
}; 

在此代码中,我们定义了具有自动模板参数 Bits 的类模板 hsitrimauto 关键字用于表示我们正在使用非类型模板参数。我们使用 static_assert 来确保提供的参数 Bitsdecltype(Bits))的类型与底层寄存器类型相同,以满足 BitFieldConcept 强加的要求。

我们使用 Bitsenum 类值 val 进行编码。这将在类型本身中编码值,并使其能够与 reg 结构的 set 方法一起使用。我们还利用 static_assert 来确保提供的值适合分配的位数数量——static_assert(Bits <= 0x1F)。再次,我们正在利用编译时操作来确保类型安全。以下是一个使用 hsitrim 结构的示例:

rcc::set<hsi_trim<0xFLU>>(hsi_trim<0xFLU>::value::val); 

此代码将 rcc 寄存器中的 hstrim 值设置为 0xF。您可以在 Renode 中尝试完整的示例。启动 Visual Studio Code,将其附加到正在运行的容器,打开 Chapter12/cpp_hal 项目,如 第四章 中所述,然后在 Visual Studio Code 终端中运行以下命令,或者在容器终端中直接运行它们:

$ cmake -B build -DCMAKE_BUILD_TYPE=Release -DMAIN_CPP_FILE_NAME=main_type_safe_reg.cpp
$ cmake --build build --target run_in_renode 

hsion 和 hsi_trim 的通用版本

为了使具有单个位(启用/禁用)的单个位字段(如hsion)可重用,我们将定义类模板reg_bits_enable_disable,如下所示:

template<typename Reg, uint32_t Pos>
struct `reg_bits`_enable_disable {
    using reg = Reg;
    using T = reg::RegType;
    static constexpr T c_position = Pos;
    static constexpr T c_mask = (0x1UL << c_position);
    enum class value : T {
        disable = 0,
        enable = 1
    };
}; 

此定义的模板类型reg_bits_enable_disable可以用来定义hsion类型,如下面的代码所示:

using hsion = reg_bits_enable_disable<rcc, 0U>; 

接下来,我们将创建用于设置具有值的多个字段(如hsi_trim)的类型的一般版本。我们将称之为reg_bits,代码如下所示:

template<auto Bits, typename Reg, uint32_t Mask, uint32_t Pos = 0>
struct reg_bits {
    using reg = Reg; using T = reg::RegType;
    static_assert(std::is_same_v<T, decltype(Bits)>);
    static constexpr T c_position = Pos;
    static constexpr T c_mask = (Mask << c_position);
    static_assert(Bits <= Mask);
    enum class value : T {
        val = Bits
    };
}; 

我们可以使用通用类型reg_bits来定义hsi_trim模板类型,如下所示:

template<auto Bits>
using hsi_trim = reg_bits<Bits, rcc, 0x1F, 3U>; 

接下来,我们将探讨如何使用 C++创建类似但也有一些实现差异的外设模板。

计时器

STM32F072有多个计时器,包括 TIM2 和 TIM3。TIM2 是一个 32 位计时器,TIM3 是一个 16 位计时器。

我们将创建一个依赖于包含计时器特定细节的计时器特性结构的模板类计时器。以下是计时器特性结构的代码:

struct timer2_traits {
    constexpr static std::uintptr_t base_address = 0x40000000;
    constexpr static IRQn_Type irqn = TIM2_IRQn;
    constexpr static std::uint32_t arr_bit_mask = 0xFFFFFFFF;
};
struct timer3_traits {
    constexpr static std::uintptr_t base_address = 0x40000400;
    constexpr static IRQn_Type irqn = TIM3_IRQn;
    constexpr static std::uint32_t arr_bit_mask = 0xFFFF;
}; 

在此代码中,timer2_traitstimer3_traits是封装 TIM2 和 TIM3 计时器硬件特定细节的特性结构。它们具有以下成员:

  • base_address:计时器寄存器映射的基内存地址

  • irqn:与计时器相关联的中断请求号

  • arr_bit_mask:自动重载寄存器(ARR)的位掩码:

    • 对于 TIM2,它是0xFFFFFFFF(32 位计时器)。

    • 对于 TIM3,它是0xFFFF(16 位计时器)。

接下来,让我们看看模板类计时器:

template <typename TimerTraits>
struct timer {
    constexpr static std::uintptr_t base_address =
                                    TimerTraits::base_address;
    using cr1 = reg<base_address + 0x00>;
    using dier = reg<base_address + 0x0C>;
    using sr = reg<base_address + 0x10>;
    using psc = reg<base_address + 0x28>;
    using arr = reg<base_address + 0x2C>;

    template<auto Bits>
    using psc_bits = reg_bits<Bits, psc, static_cast<uint32_t>(0xFFFF)>;
    template<auto Bits>
    using arr_bits = reg_bits<Bits, arr, TimerTraits::arr_bit_mask>;
    using uie = reg_bits_enable_disable<dier, 0UL>;
    using cen = reg_bits_enable_disable<cr1, 0UL>;
    using uif = reg_bits_enable_disable<sr, 0UL>;
    template<std::uint32_t Period>
 static void start() {
        // a magic number prescaler value
// for 1ms timer resolution
constexpr std::uint32_t prescaler = 9999;
        constexpr std::uint32_t auto_reload = Period - 1;
        psc::template set<psc_bits<prescaler>>
                    (psc_bits<prescaler>::value::val);
        arr::template set<arr_bits<auto_reload>>
                    (arr_bits<auto_reload>::value::val);
        dier::template set<uie>(uie::value::enable);
        NVIC_SetPriority(TimerTraits::irqn, 1);
        NVIC_EnableIRQ(TimerTraits::irqn);
        cr1::template set<cen>(cen::value::enable);
    }
}; 

在此代码中,我们定义了一个模板类计时器,其模板参数为TimerTraits – 一个提供硬件特定常数的特性类。计时器类模板提供了一个通用的接口来配置和控制计时器,通过TimerTraits针对每个特定计时器进行定制。

请注意,为了简化示例,这是设置 STM32 计时器外设所需的最小代码。

在计时器类内部,我们定义寄存器类型别名,如下所示:

constexpr static std::uintptr_t base_address = TimerTraits::base_address;
using cr1 = reg<base_address + 0x00>;
using dier = reg<base_address + 0x0C>;
using sr = reg<base_address + 0x10>;
using psc = reg<base_address + 0x28>;
using arr = reg<base_address + 0x2C>; 

这些类型别名代表计时器的硬件寄存器,每个映射到特定的内存地址。每个寄存器都是reg类模板的实例化,它提供了对硬件寄存器的读写访问。

接下来,我们为BitFields定义类型别名:

template<auto Bits>
using psc_bits = reg_bits<Bits, psc, static_cast<uint32_t> (0xFFFF)>;
template<auto Bits>
using arr_bits = reg_bits<Bits, arr, TimerTraits::arr_bit_mask>;
using uie = reg_bits_enable_disable<dier, 0UL>;
using cen = reg_bits_enable_disable<cr1, 0UL>;
using uif = reg_bits_enable_disable<sr, 0UL>; 

在此代码中,我们使用类模板reg_bitsreg_bits_enable_disable实例化位字段。

最后,我们在类模板计时器中定义了模板静态方法start。此static函数使用所需的周期设置计时器并启动它。代码执行以下步骤:

  1. 计算预分频器和自动重载值。该函数使用模板参数 Period 来计算这些值。

  2. 设置预分频器(PSC)和自动重载(ARR)寄存器。

  3. 在 DIER 寄存器上启用更新中断。它使用uie位字段在 DIER 寄存器中启用更新中断。

  4. 使用 CMSIS 函数配置 NVIC 以启用计时器中断。

  5. 开始计时器。它使用 cen 位字段在 CR1 寄存器中启用计时器计数器。

现在我们来看看我们如何使用提供的计时器模板类:

using timer2 = timer<timer2_traits>;
using timer3 = timer<timer3_traits>;
extern "C" void TIM2_IRQHandler(void)
{
    if (timer2::sr::read() & TIM_SR_UIF)
    {
        timer2::sr::set<timer2::uif> (timer2::uif::value::disable);
        printf("TIM2 IRQ..\r\n");
    }
}
extern "C" void TIM3_IRQHandler(void)
{
    if (timer3::sr::read() & TIM_SR_UIF)
    {
        timer3::sr::set<timer3::uif> (timer3::uif::value::disable);
        printf("TIM3 IRQ..\r\n");
    }
}
int main()
{
    timer2::start<1000>();
    timer3::start<500>();
    while(true)
    {
    }
} 

在此代码中,我们创建了类型别名 timer2timer3,并为 TIM2TIM3 中断实现了中断请求 (IRQ) 函数。在 IRQ 中,我们清除中断标志。我们在 main 函数中调用 timer2timer3 类型的启动函数。

你可以在 Renode 中运行完整示例。启动 Visual Studio Code,将其附加到正在运行的容器,打开如第四章所述的 Chapter12/cpp_hal 项目,并在 Visual Studio Code 终端中运行以下命令,或者在容器终端中直接运行它们:

$ cmake -B build -DCMAKE_BUILD_TYPE=Release -DMAIN_CPP_FILE_NAME=main_timer_peripheral.cpp
$ cmake --build build --target run_in_renode 

在本节中,我们学习了如何通过利用 C++ 模板和特性类创建一个基于模板的通用计时器接口。通过定义封装 TIM2 和 TIM3 定时器硬件特定细节的 TimerTraits 结构(timer2_traitstimer3_traits),我们可以实例化一个灵活的 timer 类模板,该模板抽象了不同计时器的配置和控制。这种方法提供了两个主要好处:它通过模板在编译时强制正确使用,从而增加了类型安全性;并且由于模板和 constexpr 的使用,生成的代码与传统 C HAL 实现一样高效,因为模板的使用和 constexpr 允许编译器彻底优化代码。

摘要

在本章中,我们学习了可以应用于创建更安全的 C++ HAL 代码的技术。我们涵盖了内存映射外设的实现。该设计利用模板和诸如 SFINAE 等高级技术,这些技术我们在第八章中发现了。我们将前几章中嵌入式系统领域的知识应用于实践。

我们还学习了如何设计实现通用行为并依赖于特性类以提供具体细节的类。我们开发的代码与手编写的(基于 CMSIS 的)解决方案一样高效,这得益于模板的使用和编译时计算,使得编译器能够进行优化。

在下一章中,我们将介绍如何在 C++ 中使用 C 库。

第十三章:与 C 库协同工作

第六章中,我们讨论了 C 和 C++之间的互操作性。我们学习了语言链接以及如何使用它将 C 库包含在 C++项目中。从技术角度来看,这就是我们在 C++中使用 C 所需的一切。

在本章中,我们将重点关注将 C 库集成到 C++项目中以增强代码灵活性的软件开发技术。由于许多 C++项目仍然依赖于供应商提供的 C 硬件抽象层HALs),我们将集中讨论如何有效地将这些 C 库集成到我们的项目中。

此外,本章还将涵盖资源获取即初始化RAII)范式,并解释为什么它在嵌入式系统中特别有益。通过自动管理资源分配和释放,RAII 大大降低了泄漏和其他资源误用问题的风险,这在资源受限的嵌入式环境中尤为重要。

在本章中,我们将涵盖以下主要内容:

  • 在 C++项目中使用 C HAL

  • 静态类

  • 使用 RAII 封装LittleFs C 库

技术要求

本章的示例可在 GitHub 上找到(github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter13)。为了充分利用本章内容,请在 Renode 模拟器中运行示例。

在 C++项目中使用 C HAL

第十二章中,我们探讨了使用 C++进行 HAL 开发的优点。然而,尽管有这些优点,目标供应商提供的 HALs 是以 C 库的形式。这些库在全球数百万台设备上经过了彻底测试,供应商通常维护得很好,提供定期更新。因此,使用它们而不是在 C++中重新实现 HAL 更有意义。

接下来,我们将为 UART 外设创建一个基于接口的设计,这将为我们提供一个更灵活的软件设计,并允许我们将使用 UART 接口的组件与底层细节解耦。

用于灵活软件设计的 UART 接口

第五章中,我们讨论了接口对于灵活软件设计的重要性。在那里,我们有一个由uart_stm32类实现的uart接口类。gsm_lib类依赖于uart接口,这意味着我们可以与不同的uart接口实现重用它。

来自第五章uart_stm32类为了演示目的具有简单的实现。它使用 C 标准库中的printfputc函数在标准输出上写入消息。现在,我们将通过实际实现uart_stm32类,该类已在书中 GitHub 仓库的所有示例中使用,使我们能够在 Renode 模拟器中看到输出。让我们从以下代码所示的uart接口class开始:

#include <cstdint>
#include <span>
namespace hal
{
class uart
{
  public:
    virtual void init(std::uint32_t baudrate) = 0;
    virtual void write(std::span<const char> data) = 0;
};
}; // namespace hal 

uart 接口是一个简单的类,包含两个虚拟方法:

  • virtual void init(std::uint32_t baudrate): 用于使用单个参数 baudrate 初始化 UART 外设的函数。

  • virtual void write(std::span<const char> data): 用于通过 UART 外设发送数据的函数。它有一个 std::span<const char> 参数,与通常的 C 方法中使用数据缓冲区指针和长度的方法不同。使用 std::span 提高了代码的内存安全性。

接下来,让我们通过 uart_stm32 类的定义来了解其实现:

#include <span>
#include <cstdint>
#include <uart.hpp>
#include <stm32f0xx_hal.h>
#include <stm32f072xb.h>
namespace hal
{
class uart_stm32 : public uart
{
  public:
    uart_stm32(USART_TypeDef *inst);
    void init(std::uint32_t baudrate = c_baudrate_default);
    void write(std::span<const char> data) override;
  private:
    UART_HandleTypeDef huart_;
    USART_TypeDef *instance_;
    std::uint32_t baudrate_;
    `static` constexpr std::uint32_t c_baudrate_default = 115200;
};
}; // namespace hal 

uart_stm32 类定义中,我们可以注意到以下内容:

  • 重写了 uart 接口的虚拟方法 initwrite

  • 接受 USART_TypeDef 指针的构造函数。此类型是一个 struct,它描述了 CMSIS 头文件 stm32f072xb.h 中 UART 外设寄存器布局。

  • 在私有成员中,我们看到 UART_HandleTypeDef,这是一个在 stm32f0xx_hal_uart.h 文件中定义的类型,由 ST HAL 提供。

接下来,让我们通过这段代码中的 uart_stm32 类的构造函数和方法的实现来了解其实现过程:

hal::uart_stm32::uart_stm32(USART_TypeDef *inst): instance_(inst)
{
} 

在此代码中,我们看到 uart_stm32 构造函数的实现。它只是使用初始化列表语法设置私有成员 USART_TypeDef *instance_。CMSIS 定义了宏 USART1USART2USART3USART4,它们指定了这些外设的地址,我们可以使用它们来初始化 uart_stm32 对象。

uart 接口定义了 init 方法,因为 UART 外设初始化依赖于其他硬件初始化(例如时钟配置)。如果我们将在构造函数中实现初始化,那么如果有人定义了一个 globalstaticuart_stm32 对象,我们可能会遇到问题。init 方法如下所示:

void hal::uart_stm32::init(std::uint32_t baudrate)
{
    huart_.Instance = instance_;
    huart_.Init.BaudRate = baudrate;
    huart_.Init.WordLength = UART_WORDLENGTH_8B;
    huart_.Init.StopBits = UART_STOPBITS_1;
    huart_.Init.Parity = UART_PARITY_NONE;
    huart_.Init.Mode = UART_MODE_TX_RX;
    huart_.Init.HwFlowCtl = UART_HWCONTROL_NONE;
    huart_.Init.OverSampling = UART_OVERSAMPLING_16;
    huart_.Init.OneBitSampling = UART_ONE_BIT_SAMPLE_DISABLE;
    huart_.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_NO_INIT;
    huart_.MspInitCallback = nullptr;
    HAL_UART_Init(&huart_);
} 

init 方法中,我们使用以下配置初始化成员 UART_HandleTypeDef huart_

  • instance_: 构造函数中选择的 UART 外设的地址

  • baudrate

  • 8 位字长

  • 1 个停止位

  • 同时启用 TX 和 RX 模式

  • 无硬件控制

我们还将 MspInitCallback 设置为 nullptr。我们调用 ST HAL 的 HAL_UART_Init 函数,并提供 huart_ 的指针。请注意,为了示例的简单性,这里没有错误处理。错误处理是一个重要的步骤,HAL 的返回代码应该在代码中适当处理。

接下来,我们将通过以下内容了解 write 方法的实现:

void hal::uart_stm32::write(std::span<const char> data)
{
    // we must cast away constness due to ST HAL’s API
char * data_ptr = const_cast<char *>(data.data());
    HAL_UART_Transmit(&huart_,
                     reinterpret_cast<uint8_t *(data_ptr),
                     data.size(),
                     HAL_MAX_DELAY);
} 

write 方法中,我们通过传递 std::span<const char> data 参数中的数据指针和数据大小,调用 ST HAL 的 HAL_UART_Transmit 函数。值得注意的是,我们需要取消 const 属性,因为 C 的 HAL_UART_Transmit 函数不接受指向数据的 const 指针。只有在确定我们将指针传递给取消 const 属性的函数不会修改其内容时,这样做才是安全的。

接下来,我们将从软件设计和使用的模式的角度分析这种方法。

适配器模式中的 UART 接口

本例中所有软件组件之间的关系(uart接口、接口的实现uart_stm32和 ST HAL)可以用以下 UML 图表示:

图 13.1 – 类图

图 13.1 – uart_stm32类图

图 13.1中,我们看到uart_stm32类的 UML 类图。这个类有效地实现了适配器设计模式,这是一种结构设计模式,用于允许具有不兼容接口的类协同工作。适配器模式涉及创建一个适配器类,它包装现有的类(或模块),并提供客户端期望的新接口。

在我们的例子中,即使stm32f0xx_hal_uart是一个 C 模块而不是 C++类,uart_stm32类通过封装基于 C 的 HAL 代码并通过 C++ uart接口暴露它来充当适配器。这种适配允许系统中的其他类或客户端,如GSM库,使用标准化的 C++接口与 UART 硬件交互,而不必关心底层的 C 实现细节。

让我们从uart接口客户端的角度来分析这种方法,例如在gsm_lib类中实现的GSM库,其定义如下:

class gsm_lib{
    public:
        gsm_lib(hal::uart &u) : uart_(u) {}
        // other methods
private:
        hal::uart &uart_;
}; 

在这段代码中,我们看到uart接口的一个简单客户端示例 – gsm_lib – 它有一个构造函数,用于初始化引用hal::uart &uart_。这种方法被称为依赖注入gsm_lib类的依赖外部构建并通过构造函数作为引用提供给类。根据接口的依赖也允许松耦合,这带来了以下好处:

  • gsm_libuart接口的实现细节不感兴趣。它不需要了解波特率、硬件设置等。

  • gsm_lib与特定目标无关。我们可以通过在这些平台上实现uart接口来在不同的平台上重用它。

  • gsm_lib的软件测试很容易,因为我们可以在测试中使用模拟的uart接口,并用模拟对象实例化gsm_lib对象。

uart_stm32类中,我们不是直接使用 C HAL 库,而是可以将 C 库中的函数包装在一个所谓的static类中,该类具有所有参数的直接映射。

引入静态类

我们在这里将要讨论的static类概念在 C++语言标准中并不存在。我们是从像 C#这样的语言中借用的它,在 C#中,它被定义为只包含static成员和方法的类。它不能被实例化。在 C#中,使用static关键字来声明一个static类。

在 C++中,可以通过定义一个具有所有static方法和成员的类并删除默认构造函数来创建一个static类。删除构造函数确保无法创建类的实例,在编译时强制执行。禁用实例化向用户明确表示:这是一个 static 类。你使用的函数不依赖于任何特定实例的状态,因为没有实例存在。如果存在任何内部状态,它是共享的,并将影响所有使用该类的用户

我们将修改之前的示例,创建一个uart_c_hal static类来封装 UART C HAL 函数,如下所示:

`struct` uart_c_hal {
    uart_c_hal() = delete;
    static inline HAL_StatusTypeDef init(UART_HandleTypeDef *huart)
    {
        return HAL_UART_Init(huart);
    }
    static inline HAL_StatusTypeDef transmit(UART_HandleTypeDef *huart,
uint8_t *pData,
uint16_t Size,
uint32_t Timeout)
    {
        return HAL_UART_Transmit(huart, pData, Size, Timeout);
    }
}; 

在此代码中,我们简单地将uart_c_hal类的static方法中的 C 函数进行了映射。接下来,我们将修改uart_stm32类以使用uart_c_hal,如下所示:

template <typename HalUart>
class uart_stm32 : public uart
{
  public:
    uart_stm32(USART_TypeDef *inst) : instance_(inst) {}
    void init(std::uint32_t baudrate = c_baudrate_default) override {
      huart_.Instance = instance_;
      huart_.Init.BaudRate = baudrate;
      // ...
// init huart_ `struct`
      HalUart::init(&huart_);
    }
    void write(std::span<const char> data) override {
      // we must cast away costness due to ST HAL’s API
char * data_ptr = const_cast<char *>(data.data());
      HalUart::transmit(&huart_,
  reinterpret_cast<uint8_t *(data_ptr),
         data.size(),
   HAL_MAX_DELAY);
    }
  private:
    UART_HandleTypeDef huart_;
    USART_TypeDef *instance_;
    std::uint32_t baudrate_;
    static constexpr std::uint32_t c_baudrate_default = 115200;
}; 

在此代码中,我们看到uart_stm32现在是一个模板类,它使用了模板参数HalUart中的inittransmit方法。现在我们可以使用类模板,如下所示:

uart_stm32<uart_c_hal> uart(USART2);
uart.init();
gsm_lib gsm(uart); 

uart_stm32类模板仍然实现了uart接口,这意味着我们仍然可以使用它与gsm_lib类一起使用。在static类中封装 C HAL 函数并将uart_stm32调整为通过模板参数使用它,解耦了 C HAL 与uart_stm32实现。这使得可以在目标之外测试uart_stm32类,因为它不再依赖于特定平台的代码了。

静态类是在 C++项目中使用 C 库的一种方式。它们允许我们将 C 库中的函数封装在可以通过模板参数传递给 C++类的类型中,使代码更加灵活且易于测试。

接下来,我们将看到如何应用 RAII 技术来有效地封装little fail-safe (littlefs) 文件系统 C 库。

使用 RAII 封装 littlefs C 库

RAII 是一种简单而强大的 C++技术,用于通过对象的生命周期来管理资源。资源可以代表不同的事物。资源在对象的生命周期开始时获取,在对象的生命周期结束时释放。

该技术用于管理如动态分配内存等资源。为确保内存被释放并避免内存泄漏,建议仅在类内部使用动态分配。当对象被实例化时,构造函数将分配内存,当对象超出作用域时,析构函数将释放分配的内存。

RAII 技术可以应用于动态分配内存以外的其他资源,例如文件,我们将将其应用于littlefs文件系统库(github.com/littlefs-project/littlefs)。我们将从对littlefs的简要概述开始——这是一个为微控制器设计的文件系统。

LittleFS – 用于微控制器的文件系统

littlefs文件系统是为具有以下特点的微控制器设计的:

  • 断电恢复性:它被构建来处理意外的断电。在断电的情况下,它将回退到最后已知的好状态。

  • 动态磨损均衡:它针对闪存进行了优化,提供跨动态块的磨损均衡。它还包括检测和绕过坏块机制,确保长期可靠性能。

  • 有限 RAM/ROM:它针对低内存使用进行了优化。无论文件系统大小如何,RAM 消耗保持恒定,没有无界递归。动态内存限制在可配置的缓冲区中,可以设置为 static

我们将首先介绍 littlefs 的基本用法,然后看看我们如何在 C++ 包装类中应用 RAII。我们将通过以下示例使用 littlefs

  • 格式化和挂载文件系统。

  • 创建一个文件,向其中写入一些内容,然后关闭它。

  • 打开一个文件,从中读取内容,然后关闭它。

完整示例包含在 Chapter13/lfs_raii/app/src/main.cpp 中。让我们从以下代码开始,它格式化和挂载文件系统,如下所示:

lfs_t lfs;
const lfs_config * lfs_ramfs_cfg = get_ramfs_lfs_config();
lfs_format(&lfs, lfs_ramfs_cfg);
lfs_mount(&lfs, lfs_ramfs_cfg); 

此代码执行以下步骤:

  • 它声明了一个名为 lfs 的文件系统对象,类型为 lfs_t。此对象将用于与 littlefs 文件系统交互。它包含文件系统的状态,并且对于所有后续的文件系统操作都是必需的。

  • 函数 get_ramfs_lfs_config() 返回一个指向 lfs_config 结构的指针,该结构包含 littlefs 在 RAM 存储介质上运行所需的所有配置参数。这包括读取、写入和擦除的功能指针,以及如块大小、块计数和缓存大小等参数。在项目设置中,我们使用 RAM 的一部分作为存储介质。基于 RAM 的 littlefs 配置定义在 C 文件 Chapter13/lfs_raii/app/src/lfs_ramfs.c 中。

  • 格式化存储介质,以便与 littlefs 一起使用。lfs_format 函数在存储介质上初始化文件系统结构。此过程擦除任何现有数据并设置必要的元数据结构。格式化通常在第一次使用文件系统之前或重置时进行一次。

  • 它挂载文件系统,使其准备好进行文件操作。lfs_mount 函数根据存储介质上的现有结构在 RAM 中初始化文件系统状态。在执行任何读取或写入等文件操作之前,此步骤是必需的。

接下来,让我们看看如何创建一个文件并向其中写入一些数据。代码如下所示:

lfs_file_t file;
if(lfs_file_open(&lfs, &file, “song.txt”, LFS_O_WRONLY | LFS_O_CREAT) >= 0)
{
 const char * file_content = “These are some lyrics!”;
 lfs_file_write(&lfs,
 &file,
 reinterpret_cast<const void *>(file_content),
 strlen(file_content));
    lfs_file_close(&lfs, &file);
} 

此代码执行以下步骤:

  • 声明了一个名为 file 的文件对象,类型为 lfs_file_t。此对象代表 littlefs 文件系统中的一个文件。它包含文件的状态,并且对于执行读取和写入等文件操作是必需的。

  • 使用函数 lfs_file_open 尝试打开名为 “song.txt” 的文件进行写入。该函数提供了以下参数:

    • &lfs:指向之前初始化和挂载的文件系统对象的指针。

    • &file:指向将关联到打开文件的文件对象的指针。

    • “song.txt”:要打开的文件名。

    • LFS_O_WRONLY | LFS_O_CREAT:指定以只写模式打开文件,如果文件不存在则创建文件。

    • 如果 lfs_file_open 函数返回非负值,则代码尝试使用 lfs_file_write 函数向其写入一些数据。

  • 我们将写入的内容声明为 file_content 字符串字面量。

  • 函数 lfs_file_write 提供以下参数:

    • &lfs:指向文件系统对象的指针。

    • &file:指向与打开文件关联的文件对象的指针。

    • reinterpret_cast<const void *>(file_content):将字符字符串转换为函数所需的 const void* 指针。

    • strlen(file_content):要写入的字节数,基于字符串的长度计算。

  • 在写入后关闭文件以确保数据完整性。lfs_file_close 将任何挂起的写入刷新到存储介质,并释放与文件关联的资源。

在将数据写入文件后,我们将尝试以读取模式打开相同的文件并从中读取数据。读取文件的代码如下所示:

if(lfs_file_open(&lfs, &file, “song.txt”, LFS_O_RDONLY)>= 0) {
    std::array<char, 64> buff = {0};
 lfs_file_read(&lfs,
               &file,
               reinterpret_cast<void *>(buff.data()),
               buff.size() - 1);
    printf(“This is content from the file\r\n%s\r\n”, buff.data());
    lfs_file_close(&lfs, &file);
} 

此代码执行以下步骤:

  • 尝试使用带有标志 LFS_O_RDONLY 的函数 lfs_file_open 打开文件 “song.txt” 以进行只读访问。

  • 如果 lfs_file_open 函数返回非负值,则代码尝试从打开的文件中读取数据。

  • std::array<char, 64> buff = {0} 声明了一个名为 buff 的数组,大小固定为 64 个字符,并将所有元素初始化为零(‘\0’),确保如果将其作为 C 字符串处理,则缓冲区为空终止。

  • 使用函数 lfs_file_readbuff 数组中读取打开的文件数据。该函数提供了以下参数:

    • &lfs:指向文件系统对象的指针。

    • &file:指向与打开文件关联的文件对象的指针。

    • reinterpret_cast<const void *>(buff.data()):将 buff 的底层数据数组指针转换为函数所需的 const void* 指针。

  • buff.size() – 1:从文件中读取的字节数。减去 1 为字符串末尾的空终止符(‘\0’)保留空间。

  • 读取数据后关闭文件以确保数据完整性。

你可以在 Renode 模拟器中运行完整示例。启动 Visual Studio Code,将其附加到正在运行的容器,打开 Chapter13/lfs_raii 项目,如 第四章 中所述,然后在 Visual Studio Code 终端中运行以下命令,或者在容器终端中直接运行:

$ cd Chapter13/lfs_raii
$ cmake -B build
$ cmake --build build --target run_in_renode 

引入基于 RAII 的 C++ 包装器

现在,我们将使用 RAII 技术将 littlefs 功能包装在一个简单的 C++ 包装器中。我们将创建一个包含 lfsfile 类型的 fs 命名空间。让我们从以下所示的 lfs struct 代码开始:

namespace fs{
`struct` lfs {
    lfs() = delete;
    static inline lfs_t fs_lfs;
    static void init() {
        const lfs_config * lfs_ramfs_cfg = get_ramfs_lfs_config();
        lfs_format(&fs_lfs, lfs_ramfs_cfg);
        lfs_mount(&fs_lfs, lfs_ramfs_cfg);
    }   
};
}; 

struct lfs的目的如下:

  • 持有一个名为fs_lfslfs_t类型文件系统对象的实例,用于与littlefs文件系统交互。

  • 实现用于通过调用lfs_formatlfs_mount函数来初始化文件系统的static方法init。必须在执行任何文件操作之前调用init方法。

接下来,让我们看看file类的定义:

namespace fs{
class file {
public:
    file(const char * filename, int flags = LFS_O_RDONLY);
    ~file();
    [[nodiscard]] bool is_open() const;
    int read(std::span<char> buff);
    void write(std::span<const char> buff);
private:
    bool is_open_ = false;
    lfs_file_t file_;
};
}; 

此代码展示了类文件的特性和数据成员。接下来,我们将逐一介绍它们,从下面所示的构造函数开始:

file(const char * filename, int flags = LFS_O_RDONLY) {
 if(lfs_file_open(&lfs::fs_lfs, &file_, filename, flags) >= 0) {
 is_open_ = true;
 }
} 

下面所示的file构造函数打开一个指定filenameflags的文件。如果文件打开成功,则将is_open_设置为 true。接下来,让我们看看下面所示的析构函数:

~file() {
if(is_open_) {
        printf(“Closing file in destructor.\r\n”);
        lfs_file_close(&lfs::fs_lfs, &file_);
 }
} 

下面所示的析构函数将在文件已打开时关闭文件。它调用lfs_file_close来关闭文件并释放资源。构造函数和析构函数实现了 RAII 技术——创建对象将获取资源,当对象的生命周期结束时,析构函数将释放它们。接下来,让我们看看读取和写入方法:

int read(std::span<char> buff) {
return lfs_file_read(&lfs::fs_lfs,
 &file_,
                     reinterpret_cast<void *>(buff.data()),
 buff.size() - 1);
}
int write(std::span<const char> buff) {
return lfs_file_write(&lfs::fs_lfs,
 &file_,
                      reinterpret_cast<const void *>(buff.data()),
 buff.size());
} 

readwrite方法是对lfs_file_readlfs_file_write函数的简单包装。readwrite都使用std::span作为函数参数,以提高类型安全性和更好的灵活性,因为我们只需提供std::array即可。

使用 RAII 进行更清晰的文件管理

现在,我们将看到如何使用fsfile包装器与littlefs文件系统一起工作。代码如下所示:

fs::lfs::init();
{
    fs::file song_file(“song.txt”, LFS_O_WRONLY | LFS_O_CREAT);
 if(song_file.is_open()) {
 song_file.write(“These are some lyrics!”);
 // destructor is called on song_file object
 // ensuring the file is closed
} 

我们首先通过调用fs::lfs::init()初始化文件系统。接下来,我们引入局部作用域以演示对析构函数的调用并执行后续步骤:

  • 以写入模式打开“song.txt”(如果不存在则创建它)。

  • 如果文件成功打开,则在文件中写入一个字符串字面量。

  • 当退出作用域时,将调用析构函数,确保文件被关闭。

接下来,我们将打开文件并从中读取数据。代码如下所示:

fs::file song_file(“song.txt”);
std::array<char, 64> buff = {0};
if(song_file.is_open()) {
 song_file.read(buff);
 printf(“This is content from the file\r\n%s\r\n”,
 buff.data());
} 

此代码执行以下步骤:

  • 以读取模式打开“song.txt”

  • 声明std::array<char, 64> buff,初始化为零。

  • 如果文件打开成功,则从文件中读取buff中的数据。

您可以在 Renode 模拟器中运行完整示例。启动 Visual Studio Code,将其附加到正在运行的容器,打开Chapter13/lfs_raii项目,如第四章中所述,然后在 Visual Studio Code 终端中运行以下命令,或者在容器终端中直接运行它们:

$ cd Chapter13/lfs_raii
$ cmake -B build -DMAIN_CPP_FILE_NAME=main_lfs_raii.cpp
$ cmake --build build --target run_in_renode 

我们为littlefs库编写的简单 C++包装器应用了 RAII 原则,确保在对象的生命周期结束时调用析构函数,从而正确处理资源。这确保了即使在代码中有多个返回路径的情况下,文件也会被关闭。它还简化了开发体验,因为代码更简洁、更清晰。使用std::span增加了安全性。

摘要

在本章中,我们介绍了在 C++项目中使用 C 库的几种技术。通过将 C 代码封装在 C++类中,我们可以在松散耦合的软件模块中更好地组织我们的代码。C++增加了类型安全性,编译时特性使我们能够轻松地将 C 封装器组织在static类中。

应用 RAII(资源获取即初始化)非常简单,它为我们提供了一个强大的机制来处理资源管理,正如我们在littlefs文件系统示例中所见。

在下一章中,我们将探讨裸机固件中的超级循环,并看看我们如何通过 C++中的序列器等机制来增强它。

加入我们的 Discord 社区

加入我们的 Discord 空间,与作者和其他读者进行讨论:

嵌入式系统

Discord 二维码

第十四章:使用序列器增强超级循环

超级循环是裸机固件的基本软件架构。它是一个无限循环,根据在中断服务例程ISR)中设置的标志执行任务(函数)。随着业务逻辑复杂性的增加,超级循环的大小也会增加,这可能会迅速变成一团糟。为了在裸机约束(没有操作系统)内解决这个问题,我们可以使用序列器。

序列器以有组织的方式存储和执行任务(函数)。我们不是在 ISR 中设置一个标志,在超级循环中检查它,如果设置了标志就执行一个函数,而是简单地从 ISR 中将一个任务添加到序列器中。然后超级循环运行序列器,执行添加的任务。序列器中的任务可以被优先级排序,因此序列器会首先执行优先级较高的任务。

在本章中,我们将通过以下主要主题来介绍序列器的设计和实现:

  • 超级循环和序列器的动机

  • 设计序列器

  • 存储可调用对象

  • 实现序列器

技术要求

本章的示例可在 GitHub 上找到(github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter14)。为了充分利用本章内容,请在 Renode 模拟器中运行示例。

超级循环和序列器的动机

在我们深入到序列器的设计和实现之前,我们将首先分析超级循环的限制。在通常的超级循环场景中,我们检查由 ISR 设置的标志。下面是一个超级循环的示例伪代码:

bool data_read_ready = false;
bool data_send_timeout = false;
int main() {
    // initialize hardware
while(1) {
        if(data_read_ready) {
            sensor_data_read_and_buffer();
            data_read_ready = false;
        }
        if(data_send_timeout) {
            data_send_from_buffer();
            data_send_timeout = false;
        }
        if(!data_read_ready && !data_send_timeout) {
            enter_sleep();
        }
    }
} 

在前面的伪代码中,我们执行以下步骤:

  1. 检查布尔标志data_read_ready,如果它被设置,则执行函数sensor_data_read_and_buffer。然后我们重置data_read_ready标志。

  2. 检查布尔标志data_send_timeout,如果它被设置,则执行函数data_send_from_buffer。然后我们重置data_send_timeout标志。

  3. data_read_readydata_send_timeout标志都由 ISR 设置。在我们的例子中,这可能是计时器的 ISR。

  4. 最后,我们检查两个标志是否都为假,如果是,则进入睡眠模式。

我们讨论的例子很简单,但随着标志数量的增加,超级循环的大小、全局变量(标志)的数量以及出现错误的可能性(如重置标志或忘记将其包含在if语句中,这提供了进入睡眠模式的条件)也会增加。

现在,假设我们想要优先执行超级循环中的函数。使用当前的方法会很困难。添加一个优先级变量并在if语句中检查它可能最初有效,但代码会很快变得混乱且难以维护。

为了解决裸机环境中的超级循环问题,我们将利用序列器。我们不会定义全局标志并在中断服务例程中设置它们,而是将从中断服务例程中将任务添加到序列器中。每个任务都将包括优先级信息,使序列器能够根据它们的优先级在内部队列中组织它们。

在主循环中,序列器会反复运行。它通过始终从队列中选取优先级最高的任务并首先执行,以保持任务管理高效且有序。

接下来,我们将继续进行序列器的设计。

设计序列器

我们将基于我们在 第十章 中介绍的命令模式来设计序列器。在命令模式中,序列器将扮演调用者的角色。在我们的设计中,我们将使用术语 task 而不是 command。这个 task 等同于一个函数 – 它代表了一个特定的功能单元 – 而不是操作系统定义的任务。

图 14.1 – 序列器设计 – UML 图

图 14.1 – 序列器设计 – UML 图

图 14.1 展示了一个序列器的 UML 图。我们可以看到它扮演了前面描述的命令模式中的序列器角色。与命令接口和具体命令不同,这个 UML 设计使用了 std::function 类模板(我们在 第十章GPIO 中断管理器 示例中也使用了相同的方法)。

sequencer 类包含一个任务数组,用于存储可调用对象。序列器提供了一个简单的接口,仅包含两个方法:

  • void add(task t): 用于将任务添加到序列器中的方法

  • void run(): 用于获取具有最高优先级的任务,执行它,并将其从序列器中移除的方法

在我们进入序列器方法的实现之前,我们首先回顾一下 task 类以及存储任务的 std::array 的替代方案。task 类代表一个将被序列器根据优先级执行的功能单元。它具有以下成员:

  • std::function<void()> the_task_: 将要执行的实际可调用对象

  • std::uint8_t priority_: 根据该优先级对任务进行排序,以便在序列器的存储中排序

下面是实现 task 类的代码:

template<typename CallableHolder>
class task {
public:
    constexpr static std::uint8_t c_prio_default = 250;
    constexpr static std::uint8_t c_prio_max = 255;
    constexpr static std::uint8_t c_prio_min = 0;
    task(CallableHolder the_task, std::uint8_t prio = c_prio_default) :
        the_task_(the_task), priority_(prio) {}
    void execute() {
        if(the_task_) {
            the_task_();
        }
    }
    bool operator<(const task &rhs) const
    {
        return priority_ < rhs.priority_;
    }
private:
    CallableHolder the_task_;
    std::uint8_t priority_ = c_prio_default;
}; 

此代码将任务实现为一个类模板,使我们能够使用不同的可调用持有者。我们在书中之前介绍的是 std::function。类模板 task 具有以下成员:

  • 一个构造函数,用于初始化 the_task_ 成员,其类型为 CallableHolder

  • void execute() 方法,它调用 the_task_ 上的 operator()

  • operator<, 用于按优先级比较任务

此代码演示了类模板 task 的使用:

 using callable_holder = std::function<void()>;
    auto fun_a = []() {
        printf("High priority task!\r\n");
    };
    task<callable_holder> task_a(fun_a, 255);
    auto fun_b = []() {
        printf("Low priority task!\r\n");
    };
    task<callable_holder> task_b(fun_b, 20);
    if(task_a < task_b) {
        task_b.execute();
    }
    else {
        task_a.execute();
    } 

在此示例中,我们使用std::function<void()>实例化类模板task。我们创建了两个对象task_atask_b,然后通过使用operator<来比较它们,执行优先级更高的一个。在此示例中,任务对象使用 lambda 初始化,这些 lambda 内部存储在std::function<void()>中。如果您运行前面的示例,您将看到以下输出:

High priority task! 

如您所见,由于重载的operator<,优先级更高的任务被执行。

第十章中,我们了解到类模板std::function可以求助于动态内存分配来存储捕获的 lambda。为了减轻这一担忧,我们将介绍嵌入式模板库ETL),这是一个定义了一组容器和算法的库,其操作是确定的且不使用动态内存分配。ETL 将在第十七章中进一步讨论。

存储一个可调用对象

我们可以使用来自 ETL 的etl::delegate代替std::function – 一个可调用持有者。它的一个限制是它不与捕获的 lambda 一起工作。这可能会影响代码的表达性,但它为我们提供了等效的功能,使我们能够捕获不同的可调用对象。以下代码演示了使用类模板tasketl::delegate

 using callable_etl = etl::delegate<void()>;
    using task_etl = task<callable_etl>;
    class test {
    public:
        test(int x) : x_(x) {}
        void print() const {
            printf("This is a test, x = %d.\r\n", x_);
        }
        void static print_static() {
            printf("This is a static method in test.\r\n");
        }
    private:
        int x_ = 0;
    };
    test test_1(42);
    task_etl task_member_fun(callable_etl::create<test, &test::print>
(test_1));
    task_member_fun.execute();
    task_etl task_static_fun(callable_etl::create<test::print_static>());
    task_static_fun.execute();
    task_etl task_lambda([](){
        printf("This is non capturing lambda!\r\n");
    });
    task_lambda.execute(); 

此代码演示了我们如何使用etl::delegate来存储一个可调用对象:

  • callable_etl::create<test, &test::print>(test_1) 使用模板方法create(通过实例化的类test和其成员print)创建etl::delegate

  • callable_etl::create<test::print_static>() 使用模板方法create(通过实例化的静态方法print_static)创建etl::delegate

  • task_lambda([](){ printf("This is non capturing lambda!\r\n");}); 使用提供的非捕获 lambda 初始化etl::delegate

运行前面的示例将产生以下输出:

This is a test, x = 42.
This is a static method in test.
This is non capturing lambda! 

您可以在 Renode 模拟器中运行完整示例。启动 Visual Studio Code,将其附加到正在运行的容器,打开Chapter14/sequencer项目,如第四章中所述,然后在 Visual Studio Code 终端中运行以下命令,或者在容器终端中直接运行:

cmake -B build -DCMAKE_BUILD_TYPE=MinSizeRel
cmake --build build --target run_in_renode 

我们有可调用存储的替代实现 – 标准库中的std::function,或者更嵌入式友好的 ETL 中的etl::delegate。接下来,让我们考虑在序列器内部存储任务的容器选项。

图 14.1的 UML 图中,序列器使用std::array来存储任务。这意味着根据优先级对数组元素进行排序是由序列器本身处理的。我们不必手动实现这一点,可以使用来自标准库的容器适配器std::priority_queue

std::priority_queue是一个模板类,用作另一个容器的适配器,该容器提供随机访问迭代器和以下方法:

  • front()

  • push_back()

  • pop_back()

我们可以使用标准库中的 std::vector,因为它满足 std::priority_queue 强加的所有要求。如您所知,std::vector 使用动态内存分配,这使其不适合大多数嵌入式应用。

ETL 提供了一个具有类似标准库实现的固定大小向量的实现。这使得它与优先队列兼容。此代码演示了使用 etl::vectorstd::priority_queue

 std::priority_queue<int, etl::vector<int, 6>> pq{};
    pq.push(12);
    pq.push(6);
    pq.push(16);
    pq.push(8);
    pq.push(1);
    pq.push(10);
    printf("priority queue elements:\r\n");
    while(!pq.empty()) {
        printf("top element: %d, size: %d\r\n", pq.top(), pq.size());
        pq.pop();
    } 

此代码执行以下步骤:

  1. std::priority_queue<int, etl::vector<int, 6>> pq{} 定义了一个优先队列 pq,其底层容器为 etl::vector<int, 6>,这是一个大小为 6 的固定大小向量。

  2. pq.push(12) 将元素 (12) 插入优先队列 pq 并对队列进行排序。

  3. 使用 push 方法,我们在队列中添加了 5 个更多元素 – 6168110

  4. 使用 while(!pq.empty()),我们运行一个 while 循环,直到优先队列为空。

  5. while 循环内部,我们打印最高元素,使用 top() 方法访问,并使用 size() 方法打印大小。然后,我们使用 pop() 从队列中弹出最高元素。

运行前面的代码将产生以下输出:

priority queue elements:
top element: 16, size: 6
top element: 12, size: 5
top element: 10, size: 4
top element: 8, size: 3
top element: 6, size: 2
top element: 1, size: 1 

如您从输出中看到的,优先队列中的元素是排序的。这使得它成为存储可以因重载 operator< 而排序的任务的好解决方案。您可以在 Renode 模拟器中运行完整示例。启动 Visual Studio Code,将其附加到正在运行的容器,打开 Chapter14/sequencer 项目,如 第四章 中所述,并在 Visual Studio Code 终端中运行以下命令,或者在容器终端中直接运行它们:

cmake -B build -DCMAKE_BUILD_TYPE=MinSizeRel
-DMAIN_CPP_FILE_NAME=main_pq.cpp
cmake --build build --target run_in_renode 

现在我们已经拥有了实现顺序器所需的所有元素,我们将继续进行实现。

实现顺序器

在本章中,我们介绍了 etl::delegatestd::function 的替代方案和来自 ETL 的固定大小向量实现。由于 ETL 避免动态内存分配,我们将使用这些组件来实现顺序器。以下是更新后的 UML 图:

图 14.2 – 使用 ETL 组件的 UML 顺序图

图 14.2 – 使用 ETL 组件的 UML 顺序图

图 14.2 展示了一个使用代理和向量 ETL 组件以及标准库中的优先队列的 UML 顺序图。此代码实现了 顺序器

template<typename Task, std::size_t Size>
struct sequencer {
    sequencer() = delete;
    static void add(Task task) {
        if(pq.size() < Size) {
            __disable_irq();
            pq.push(task);
            __enable_irq();
        }
    }
    static void run() {
        if(!pq.empty()) {
            __disable_irq();
            auto task = pq.top();
            pq.pop();
            __enable_irq();
            task.execute();
        }
    }
private:
    static inline std::priority_queue<Task, etl::vector<Task, Size>> pq{};
}; 

在此代码中,顺序器 被实现为一个静态模板类,TaskSize 作为模板参数。这允许我们使用基于 std::functionetl::function 的任务,并定义 ETL 向量的尺寸。顺序器 有以下成员:

  • static inline std::priority_queue<Task, etl::vector<Task, Size>> pq{}:基于 ETL 向量的私有静态优先队列。

  • static void add(Task task):一个静态方法,用于使用push方法将任务添加到队列中,通过禁用和启用中断来保护,因为它可以从 ISR 中调用。

  • static void run():一个静态方法,用于从队列中取出顶部元素并执行它。对队列的访问通过禁用和启用中断来保护。

下面是使用序列器的示例:

 using callable_etl = etl::delegate<void()>;
    using task_etl = task<callable_etl>;
    class test {
    public:
        test(int x) : x_(x) {}
        void print() const {
            printf("This is a test, x = %d.\r\n", x_);
        }
        void static print_static() {
            printf("This is a static method in test.\r\n");
        }
    private:
        int x_ = 0;
    };
    test test_1(42);
    task_etl task_member_fun(callable_etl::create<test, &test::print>
(test_1), 20);
    task_etl task_static_fun(callable_etl::create<test::print_static>(), 30);
    task_etl task_lambda([](){
        printf("This is non capturing lambda!\r\n");
    }, 10);
    using seq = sequencer<task_etl, 16>;
    seq::add(task_member_fun);
    seq::add(task_static_fun);
    seq::add(task_lambda);
    while(true)
    {
        seq::run();
    } 

在此代码中,我们执行以下操作:

  • 实例化基于etl::delegate的任务task_member_funtask_static_funtask_lambda

  • 我们使用序列器的add方法将任务添加到序列器中。

  • 我们使用run()方法在主while循环中运行序列器。

运行前面的代码将产生以下输出:

This is a static method in test.
This is a test, x = 42.
This is non capturing lambda! 

如此代码所示,任务将根据分配的优先级执行。您可以在 Renode 中运行完整示例。启动 Visual Studio Code,将其附加到正在运行的容器,打开Chapter14/sequencer项目,如第四章中所述,然后在 Visual Studio Code 终端中运行以下命令,或者在容器终端中直接运行它们:

cmake -B build -DCMAKE_BUILD_TYPE=MinSizeRel
-DMAIN_CPP_FILE_NAME=main_seq.cpp
cmake --build build --target run_in_renode 

在模拟器中运行示例应提供相同的控制台输出。我邀请您通过添加来自计时器或外部中断的任务来探索序列器。

序列器通过以严格顺序、优先级的方式组织任务,提供了一个比超级循环更好的替代方案。通过任务实现确保确定性行为。例如,在实时需求的情况下,每个任务都必须包括内部监控,以确保它满足必要的实时约束。

摘要

在本章中,我们探讨了基本超级循环的常见问题,这促使我们转向序列器设计。我们详细介绍了序列器设计,并引入了 ETL 组件etl::delegate——一个可调用的持有者,它是std::function的替代品——以及固定大小的向量,它们都是嵌入式应用的绝佳选择,因为它们不使用动态内存分配。

在下一章中,我们将学习观察者模式并将其应用于温度读取应用。

第十五章:实用模式 - 构建温度发布者

设计模式是解决常见问题的工具。到目前为止,我们在本书中已经介绍了一些设计模式,例如命令模式和适配器模式。在本章中,我们将介绍观察者模式并将其应用于嵌入式系统中的常见问题——处理系统不同部分的温度读数。

我们将首先探讨观察者模式及其在运行时的实现方式。当多个组件需要响应来自中央数据源的变化时,此模式特别有用。想象一下,在嵌入式设备中的一个温度传感器会向多个监听器报告变化。这可能是智能恒温器、工业机器监控器或 HVAC 控制板的一部分——每个部分都有屏幕、记录器或风扇控制器等组件,它们会对温度更新做出反应。

接下来,我们将使用现代 C++技术,如变长模板和折叠表达式,将相同的模式转换为编译时实现。通过利用这些技术,我们可以在编译时生成高度优化的代码,避免与运行时多态相关的虚拟调度。这种方法导致内存占用更小,代码运行更快,更适合资源有限的系统。

在本章中,我们将涵盖以下主要内容:

  • 观察者模式

  • 运行时实现

  • 编译时实现

技术要求

为了充分利用本章内容,我强烈建议您在阅读示例时使用 Compiler Explorer (godbolt.org/)。添加一个执行面板,使用 GCC 作为 x86 架构的编译器。这将允许您查看标准输出并更好地观察代码的行为。由于我们使用了大量的现代 C++特性,请确保选择 C++23 标准,通过在编译器选项框中添加-std=c++23,并将优化级别设置为-O3。此外,添加一个使用ARM gcc 11.2.1 (none)的编译器面板,以检查示例的汇编输出。

您可以在第四章中设置的 Docker 容器中的 Renode 模拟器中尝试本章的示例。请确保 Docker 容器正在运行。

您可以在 GitHub 上找到本章的文件,地址为github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter15/observer

观察者模式

观察者模式常用于事件驱动系统,用于向已订阅的对象发布事件,通常通过调用它们的方法来实现。发布事件的对象称为主题发布者。从发布者接收事件的对象称为观察者订阅者。从现在起,我们将使用发布者订阅者这两个术语。

发布者有一个内部的订阅者列表,并提供了一个接口来注册和注销内部列表中的订阅者。它还提供了一个notify方法,该方法由其客户端使用,进而调用订阅者的update方法——这就是我们说发布者通知订阅者的原因。

一个在嵌入式系统中常见的发布者-订阅者机制的例子是一个温度发布者,它定期通知记录器、显示器和数据发送器。在我们继续此示例的实现之前,我们首先将查看观察者模式的 UML 图。

图 15.1 – 观察者模式的 UML 图

图 15.1 – 观察者模式的 UML 图

图 15.1展示了观察者模式的 UML 类图。在图中,我们可以看到publisher类有以下成员:

  • etl::vector<subscribers_, 8>:指向订阅者接口的指针的内部列表,我们将使用 ETL 中的vector

  • register_sub(subscriber *):用于注册订阅者的方法。register关键字在 C++中是保留的,用作存储指定符,所以我们使用register_sub作为此方法的名称。

  • unregister(subscriber *):用于注销订阅者的方法。

  • notify(float):发布者客户端用来触发订阅者更新的方法。

subscriber接口类有一个纯虚方法——void update(float)。该方法在subscriber类的具体实现中被重写。为了展示其作用,我们将继续进行观察者模式的运行时实现。

运行时实现

我们将通过温度发布者的例子来讲解观察者模式的运行时实现。订阅者将是一个记录器、显示器和数据发送器。订阅者接口和具体订阅者的代码如下所示:

#include <cstdio>
#include “etl/vector.h”
#include <algorithm>
class subscriber {
public:
    virtual void update(float) = 0;
    virtual ~subscriber() = default;
};
class display : public subscriber {
public:
    void update(float temp) override {
        printf(“Displaying temperature %.2f \r\n”, temp);
    }
};
class data_sender : public subscriber {
public:
    void update(float temp) override {
        printf(“Sending temperature %.2f \r\n”, temp);
    }
};
class logger : public subscriber {
public:
    void update(float temp) override {
        printf(“Logging temperature %.2f \r\n”, temp);
    }
}; 

上述代码定义了subscriber接口和具体的订阅者类:displaydata_senderlogger。具体类覆盖了接口类中的纯虚update方法。为了简化示例,所有具体实现都打印温度到标准输出。

使用接口类允许发布者依赖于接口。发布者维护一个指向订阅者接口的指针的内部容器。这使得通过基接口类上的指针添加不同的订阅者接口实现成为可能。publisher类的代码如下所示:

class publisher {
public:
    void register_sub(subscriber * sub) {
        if(std::find(subs_.begin(), subs_.end(), sub) == subs_.end())
        {
            subs_.push_back(sub);
        }
    }
    void unregister(subscriber * sub) {
        if(auto it = std::find(subs_.begin(), subs_.end(),
                                  sub); it != subs_.end())
        {
            subs_.erase(it);
        }
    }
    void notify(float value) {
        for(auto sub: subs_) {
            sub->update(value);
        }
    }
private:
    etl::vector<subscriber*, 8> subs_;
}; 

在前面的publisher类中,我们可以看到以下成员:

  • etl::vector<subscriber*, 8> subs_:用于维护订阅者的私有容器。如果您在 Compiler Explorer 中运行此示例,请确保使用选项添加 ETL 库。

  • void register_sub(subscriber * sub): 用于注册订阅者的方法。它使用 std::find 算法检查订阅者是否已经被添加。

  • void unregister(subscriber * sub): 用于注销订阅者的方法。它使用 std::find 算法检查在调用方法 erase 移除之前订阅者是否已被添加。如果 std::find 返回的迭代器与 subs_.end() 不同,则提供 erase 方法。

  • void notify(float value): 遍历已注册的订阅者,并在它们上调用 update 方法。

现在,让我们看看如何在以下代码中使用前面的发布者和订阅者:

int main() {   
    logger temp_logger;
    display temp_display;
    data_sender temp_data_sender;
    publisher temp_publisher;
    temp_publisher.register_sub(&temp_logger);
    temp_publisher.register_sub(&temp_display);
    temp_publisher.notify(24.02f);
    temp_publisher.unregister(&temp_logger);
    temp_publisher.register_sub(&temp_data_sender);
    temp_publisher.notify(44.02f);
    return 0;
} 

在代码中,我们执行以下步骤:

  1. 实例化以下具体订阅者:temp_loggertemp_displaytemp_data_sender

  2. 实例化发布者 temp_publisher

  3. 注册订阅者 temp_loggertemp_display

  4. temp_publisher 上调用 notify(24.02f)

在这些步骤之后,我们期望得到以下输出:

Logging temperature 24.02
Displaying temperature 24.02 

接下来,我们执行以下步骤:

  1. 注销订阅者 temp_logger

  2. 注册订阅者 temp_data_sender

  3. temp_publisher 上调用 notify(44.02f)

在这些步骤之后,我们期望得到以下输出:

Displaying temperature 44.02
Sending temperature 44.02 

作为一项练习,创建一个新的订阅者类 eeprom_writer,当温度低于或高于设定的阈值时记录温度。

你可以在 Renode 中运行完整的示例。启动 Visual Studio Code,将其附加到正在运行的容器,按照第四章中所述打开 Chapter15/observer 项目,并在 Visual Studio Code 终端或直接在容器终端中运行以下命令:

$ cmake –B build
$ cmake --build build --target run_in_renode 

接下来,我们将介绍观察者模式的编译时实现。

编译时实现

在大多数嵌入式应用中,我们知道在编译时系统行为的信息很多。这意味着当使用观察者模式时,我们已经知道所有订阅者。如果我们假设订阅者只注册一次且不会注销,我们可以创建观察者模式的编译时版本。

为了启用此功能,我们首先分解使编译时实现可行的关键 C++17 特性。

利用变长模板

我们将基于变长模板来实现。我们将从一个简化的实现开始,以解释变长模板、参数包和折叠表达式——这些是 C++特性,将使我们能够创建观察者模式的编译时版本。让我们从以下代码开始:

#include <cstdio>
struct display {
    static void update(float temp) {
        printf(“Displaying temperature %.2f \r\n”, temp);
    }
};
struct data_sender {
    static void update(float temp) {
        printf(“Sending temperature %.2f \r\n”, temp);
    }
};
struct logger {
    static void update(float temp) {
        printf(“Logging temperature %.2f \r\n”, temp);
    }
};
template <typename... Subs>
struct publisher {
    static void notify(float temp) {
        (Subs::update(temp), ...);
    }
};
int main() {
    using temp_publisher = publisher<display,
    data_sender,
    logger>;
    temp_publisher::notify(23.47);
    return 0;
} 

在上面的代码中,我们有订阅者结构体 displaydata_senderlogger。所有结构体都实现了静态方法 update,该方法接受 temperature 作为参数并打印它。

结构 publisher 是一个可变参数类模板。一个可变参数模板是一个至少有一个参数包的模板。一个模板参数包是一个接受零个或多个模板参数的模板参数。typename... Subs 是一个名为 Subs 的类型模板参数包,这意味着我们可以用零个或多个不同的类型实例化 publisher 结构。总结一下:

  • publisher 是一个可变参数类模板,因为它有一个模板参数包 typename... Subs

  • 我们可以用提供的变量数量的类型作为模板参数来实例化它。这是向发布者注册订阅者的方法。

main 函数中,我们创建了别名 temp_publisher 作为 publisher<display, data_sender, logger>。我们在这个别名上调用 notify 方法,这将导致通过模板参数包提供的类型中的更新函数被调用,这是由于 notify 方法中的折叠表达式。

拼图最后的碎片是折叠表达式 (Subs::update(temp), ...)。这是一个使用逗号运算符作为折叠运算符的折叠表达式。它展开为:(display::update(temp), data_sender::update(temp), logger::update(temp))

折叠表达式确保首先调用 display::update(temp),然后是 data_sender::update(temp),最后是 logger::update(temp)。逗号运算符的操作数评估顺序是严格从左到右。每个 update(temp) 调用都会返回一个值(可能是 void)。

逗号运算符丢弃除了最后一个之外的所有返回值,所以只有最后的 logger::update(temp) 决定了折叠的结果。如果它们都返回 void,整个表达式也返回 void

折叠表达式是在 C++17 中引入的,使用逗号运算符是调用参数包中每个类型的函数的简洁方式。在那之前,需要递归才能遍历类型并调用它们上的函数。

当在 Compiler Explorer 中检查反汇编输出时,你会注意到生成的汇编代码相对简短,总共大约 30 行,如下所示:

.LC0:
.ascii “Displaying temperature %.2f \015\012\000”
.LC1:
.ascii “Sending temperature %.2f \015\012\000”
.LC2:
.ascii “Logging temperature %.2f \015\012\000”
main:
push    {r4, r5, r6, lr}
        mov r4, #-536870912
ldr r5, .L3
        mov r2, r4
mov r3, r5
ldr r0, .L3+4
bl      printf
        mov r2, r4
mov r3, r5
ldr r0, .L3+8
bl      printf
        mov r2, r4
mov r3, r5
ldr r0, .L3+12
bl      printf
        mov r0, #0
pop     {r4, r5, r6, lr}
        bx lr
.L3:
.word 1077377105
.word   .LC0
        .word   .LC1
        .word   .LC2 

在这段汇编代码中,我们可以看到没有从 displaydata_senderlogger 结构体调用静态更新方法。这意味着编译器能够优化这些调用,包括订阅者的注册和对发布者 notify 方法的调用,从而直接调用 printf 函数。

结果是小的内存占用和快速的性能。这个例子演示了零成本抽象设计原则:我们为发布者和订阅者提供了抽象,但没有任何开销,因为编译器能够优化代码,使其尽可能高效,就像它是手工编写的一样。

使用相同的优化级别(-O3)比较编译时实现和运行时实现的汇编输出。很明显,编译时实现使用的内存更少,速度更快,因为编译器优化掉了大部分函数调用,并且没有由虚函数引起的间接调用。

当我们分析汇编代码时,让我们利用这个机会更好地理解折叠表达式。为了防止 GCC 优化掉对update方法的调用,我们可以使用__attribute__((noinline))函数属性,例如static void __attribute__((noinline)) update(float temp)。将此属性添加到displaydata_senderlogger结构的静态update方法中,并观察生成的汇编代码。您将看到main函数中对notify方法的调用如何导致参数包展开并生成对displaydata_senderlogger结构update方法的调用。

您可以在 Renode 中运行完整的示例。启动 Visual Studio Code,将其附加到正在运行的容器,按照第四章中描述的方式打开Chapter15/observer项目,然后在 Visual Studio Code 终端中运行以下命令,或者在容器终端中直接运行它们:

$ cmake -B build
-DMAIN_CPP_FILE_NAME=main_observer_ct_basic.cpp
$ cmake --build build --target run_in_renode 

简化的观察者模式编译时实现有几个限制:

  • 订阅者只能被注册。

  • 当发布者实例化时,所有订阅者都会被注册。发布者实例化后不能注册。

接下来,我们将解决最后一个问题,因为将所有订阅者注册在一行代码中可能很繁琐,并不总是实用。这将为我们提供一个更灵活的编译时设计。

改进编译时实现

我们不会改变发布者模板结构的接口。相反,我们将允许它接收其他发布者作为参数。下面的代码是:

template<typename T>
concept Updatable = requires (T, float f) {
    { T::update(f) } -> std::same_as<void>;
};
template<typename T>
concept Notifiable = requires (T, float f) {
    { T::notify(f) } -> std::same_as<void>;
};
template <typename... Subs>
struct publisher {
    static void notify(float temp) {
        (call_update_or_notify<Subs>(temp), ...);
    }
private:
    template<typename T>
 static void call_update_or_notify(float temp) {
        if constexpr (Updatable<T>) {
            T::update(temp);
        } else if constexpr (Notifiable<T>) {
            T::notify(temp);
        }
        else {
            static_assert(false, “Type is not Updatable or Notifiable”);
        }
    }
}; 

在上面的代码中,我们定义了以下概念:

  • Updatable:这描述了一个具有接受浮点数的静态方法update的类型

  • Notifiable:这描述了一个具有接受浮点数的静态方法notify的类型

我们在第八章中更详细地介绍了概念。变长模板类publisher有一个新方法——call_update_or_notify。它在notify方法中使用折叠表达式和逗号运算符在参数包typename... Subs中的每个类型上调用。

call_update_or_notify方法中,我们使用if constexpr在编译时检查类型是否为UpdatableNotifiable,并分别调用其上的updatenotify静态方法。

下面是使用观察者模式新版本的示例:

 using temp_publisher = publisher<display, data_sender>;
    temp_publisher::notify(23.47);
    using temp_publisher_new = publisher<temp_publisher, logger>;
    temp_publisher_new::notify(42.42); 

在上面的代码中,我们通过提供变长类模板publisher的类型displaydata_sender来实例化temp_publisher,这两个订阅者都是Updatable

接下来,我们通过提供之前实例化的temp_publisher和订阅者loggerpublisher来实例化temp_publisher_new。以下是上述示例的输出:

Displaying temperature 23.47
Sending temperature 23.47
Displaying temperature 42.42
Sending temperature 42.42
Logging temperature 42.42 

你可以在 Renode 中运行完整示例。启动 Visual Studio Code,将其附加到正在运行的容器,按照第四章中所述打开Chapter15/observer项目,并在 Visual Studio Code 终端中运行以下命令,或者直接在容器终端中运行它们:

$ cmake -B build -DMAIN_CPP_FILE_NAME=main_observer_ct.cpp
$ cmake --build build --target run_in_renode 

这种观察者模式的实现使我们能够以更灵活的方式注册订阅者。为了使其更通用,作为一个练习,你可以修改它,使得notify方法能够接受可变数量的参数。

摘要

在本章中,我们探讨了观察者模式,包括运行时和编译时实现。

编译时实现是利用我们在编译时对应用程序的了解。它基于变长模板类和折叠表达式。结果是代码非常紧凑且运行速度快,因为我们既不在容器中存储订阅者的信息,也不需要遍历容器来调用update方法。

在下一章中,我们将介绍有限状态机(FSM)以及在 C++中实现状态模式。

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

packt.link/embeddedsystems

第十六章:设计可扩展的有限状态机

有限状态机FSM)是一个抽象的计算模块,用于表示在任何给定时间可以处于有限多个状态之一的状态系统。FSM可以在给定输入的情况下从一个状态转换到另一个状态,并且在转换过程中可以执行一个动作。

在控制理论中,有 Moore 和 Mealy 机器的分类。Moore 的FSM输出只依赖于状态,也就是说,FSM只使用入口动作。Mealy 的FSM输出依赖于输入和当前状态,也就是说,它执行的动作由当前状态和输入共同决定。

本章中我们将涵盖的FSM是 Moore 和 Mealy FSM的组合,因为它们支持在转换期间执行的动作以及仅依赖于当前状态的动作。FSM也称为统一建模语言UML)状态机,并在嵌入式系统的实际应用中用于描述和控制机器。例如,FSM通常用于控制洗衣机、电梯系统或网络设备的通信协议,以基于各种输入管理复杂的操作序列。理解FSM将帮助您设计更可预测和可维护的嵌入式系统。

在本章中,我们将涵盖以下主要主题:

  • FSM – 一个简单的实现

  • FSM – 使用状态模式实现

  • 使用标签分派实现状态模式

  • Boost SML(状态机语言)

技术要求

为了充分利用本章内容,我强烈建议在阅读示例时使用 Compiler Explorer(godbolt.org/)。选择 GCC 作为您的编译器,并针对 x86 架构。这将允许您看到标准输出(stdio)结果,并更好地观察代码的行为。由于我们使用了大量的现代 C++特性,请确保在编译器选项框中添加-std=c++23以选择 C++23 标准。

Compiler Explorer 使得尝试代码、调整代码并立即看到它如何影响输出和生成的汇编变得容易。大多数示例也可以在 ARM Cortex M0 目标上的 Renode 模拟器中运行,并在 GitHub 上提供(github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter16)。

FSM – 一个简单的实现

我们将直接进入一个处理FSMBluetooth Low EnergyBLE)设备连接状态的示例,分析其不足之处,并看看我们如何可以使用状态设计模式来改进它。

为了清晰和更容易理解,示例FSM将被简化。我们将有三个状态 – idle(空闲)、advertising(广播)和connected(连接)。以下是示例FSM的状态图:

图 16.1 – BLE 设备连接状态图

图 16.1 – BLE 设备连接状态图

图 16.1 描述了 BLE 设备连接的FSM状态图。该图展示了状态之间的转换以及以下描述的动作:

  • 默认状态是idle。在ble_button_pressed事件发生时,它转换到advertising状态。在转换过程中,执行start_advertising动作。简单来说,这意味着如果设备处于idle状态,并且用户按下指定的按钮,它将开始广播并改变状态。

  • advertising状态,FSM可以在connection_request事件发生时转换到connected状态,或者在timer_expired状态下返回idle状态,同时通过执行stop_advertising动作停止广播。

  • 当处于connected状态时,在执行disconnect动作的同时,FSM只能由ble_button_pressed事件转换到idle状态。

请记住,这是一个为了示例目的而极度简化的FSM,而在现实生活中的FSM将包括更多的状态和事件,以正确描述 BLE 设备的连接行为。

FSM也可以使用状态转换表来描述。此表显示了FSM根据当前状态和输入(接收的事件)移动到的状态,以及它在转换期间执行的动作。以下是本章中分析的 BLE 设备FSM的转换表:

当前状态 事件 下一个状态 动作
idle ble_button_pressed advertising start_advertising
advertising timer_expired idle stop_advertising
advertising connection_request connected
connected ble_button_pressed idle disconnect

表 16.1 – BLE 设备状态转换表

表 16.1 通过列出行中的转换来描述 BLE 设备的FSM。它作为状态图的替代,用于描述FSM的行为。我们将首先通过定义状态和事件来实现这个FSM

描述状态和事件

我们将使用enum类型来表示状态和事件,如下面的代码所示:

`enum` class ble_state {
    idle,
    advertising,
    connected
};
`enum` class ble_event {
    ble_button_pressed,
    connection_request,
    timer_expired
}; 

上述enum类型描述了我们的 BLE 设备FSM的状态和事件。

跟踪当前状态和处理事件 – FSM

接下来,我们将定义一个名为ble_fsm的类,该类将跟踪当前状态并提供一个公共方法handle_event,我们将使用它向FSM提供事件。代码如下:

class ble_fsm {
public:
    void handle_event(ble_event event);
    ble_state get_state() const {
        return current_state_;
    }
private:
    ble_state current_state_ = ble_state::idle;
    void start_advertising() {
        printf("Action: start_advertising()\n");
    }
    void stop_advertising() {
        printf("Action: stop_advertising()\n");
    }
    void disconnect() {
        printf("Action: disconnect()\n");
    }
}; 

在上面的代码中,我们定义了具有以下成员的ble_fsm类:

  • ble_state current_state_ – 默认值为ble_state::idle的私有成员。我们使用它来跟踪当前状态,初始值设置为idle

  • void start_advertising() – 用于实现动作的私有方法。

  • void stop_advertising() – 用于实现动作的私有方法。

  • void disconnect() – 用于实现动作的私有方法。

  • ble_state get_state() const – 一个私有方法,用于检索当前状态。

  • void handle_event(ble_event event) – 一个公共方法,用于通过执行操作并根据 current_event_ 改变当前状态来响应事件。

handle_event 方法实现了 FSM 的实际行为,其代码如下所示:

void ble_fsm::handle_event(ble_event event) {
switch (current_state_) {
    case ble_state::idle:
    if (event == ble_event::ble_button_pressed)
    {
        start_advertising();
        current_state_ = ble_state::advertising;
    }
    break;
    case ble_state::advertising:
    if (event == ble_event::connection_request)
    {
        current_state_ = ble_state::connected;
    }
    else if (event == ble_event::timer_expired)
    {
        stop_advertising();
        current_state_ = ble_state::idle;
    }
    break;

    case ble_state::connected:
    if (event ==ble_event::ble_button_pressed)
    {
        disconnect();
        current_state_ = ble_state::idle;
    }
    break;

    default:
    break;
}
} 

上述代码显示了 ble_fsm 类的 handle_event 方法的实现。它使用 switch 语句在 current_state_ 上进行 switch,根据它处理事件并接收事件。事件通过调用适当的操作并按照 FSM 描述的状态进行状态转换来处理。

接下来,我们将看到如何使用 ble_fsm 类。

使用 ble_fsm 类

我们首先定义一个辅助函数 state_to_string,用于调试我们的 FSM。代码如下所示:

static const char* state_to_string(ble_state state) {
    switch (state) {
        case ble_state::idle:        return "`idle`";
        case ble_state::advertising: return "advertising";
        case ble_state::connected:   return "connected";
        default:                     return "unknown";
    }
} 

state_to_string 函数返回一个给定状态 enum 的字符串字面量。

接下来,让我们看看如何使用 ble_fsm 类,如下面的代码所示:

int main() {
    ble_fsm my_ble_fsm;
    const auto print_current_state = [&]() {
        printf("Current State: %s\n",
            state_to_string(my_ble_fsm.get_state()));
    };
    print_current_state();
    my_ble_fsm.handle_event(ble_event::ble_button_pressed);
    print_current_state();
    my_ble_fsm.handle_event(ble_event::connection_request);
    print_current_state();
    my_ble_fsm.handle_event(ble_event::ble_button_pressed);
    print_current_state();

    return 0;
} 

上述 main 函数中的代码创建了一个 ble_fsm 类型的对象 my_ble_fsm,并按照以下顺序向其中提供事件:

  1. 它首先将 ble_event::ble_button_pressed 传递给 FSMhandle_event 方法。FSM 的初始状态是 idle,在此事件之后,它将过渡到 advertising 状态。

  2. 接下来,它将 ble_event::connection_request 事件传递给 FSM,这将使其过渡到 connected 状态。

  3. 最后,它第二次将 ble_event::ble_button_pressed 事件传递给 FSM,使其返回到 idle 状态。

上述代码使用 state_to_string 函数从状态 enum 获取字符串字面量,并使用它来打印在向 FSM 提供事件后的当前状态。

分析输出

运行完整示例将提供以下输出:

Current State: idle
Action: start_advertising()
Current State: advertising
Current State: connected
Action: disconnect()
Current State: idle 

上述输出显示了 FSM 状态和执行的操作。

您可以从书籍的 GitHub 仓库中的 Renode 模拟器运行完整示例。它位于 Chapter16/fsm 目录下,您可以使用以下命令构建和运行它:

$ cmake –B build
$ cmake --build build --target run_in_renode 

我们刚才讨论的 FSM 实现方法对于简单的 FSM 来说效果很好。在实际应用中,FSM 通常更复杂——它们有更多的状态、操作和事件。ble_fsm 中的 handle_event 方法由于使用了 switch-caseif-else 逻辑,因此扩展性不佳。添加更多状态,以及处理更多事件和操作,使得代码可读性降低,维护难度增加。

接下来,我们将看到如何利用状态设计模式来缓解这些问题。

FSM – 使用状态模式实现

在基于我们的开关方法的基础上,我们现在将使用状态设计模式重构 BLE 设备连接 FSM。这种模式是“以状态为中心”的,意味着每个状态都被封装为其自己的类。一个常见的基类接口将允许 FSM 在容器中存储指向这些具体状态类的指针。

在典型的FSM中,状态在运行时动态变化,以响应外部中断和定时器。在我们的示例中,我们将继续使用枚举来区分状态,并将当前状态存储在私有成员变量中。这种基于枚举的方法仍然与状态模式很好地工作,因为它允许我们快速定位并在FSM管理的具体状态对象之间切换。我们将从状态类接口开始实现。

理解状态类接口

state类接口如下所示:

class state {
public:
    virtual ble_state handle_event(ble_event event) = 0;
    virtual ble_state get_state_`enum`() = 0;
}; 

在前面的代码中,我们可以看到状态接口简单且有两个纯虚方法:

  • virtual ble_state handle_event(ble_event event) – 一个旨在由派生类实现的方法,用于处理实际事件。它返回一个ble_state枚举以向FSM信号新状态。如果处理事件不会导致转换,则应返回对应于当前状态的枚举。

  • virtual ble_state get_state_enum() – 一个用于返回对应于实际状态的ble_state枚举的方法。

接下来,我们将介绍具体状态类的实现:idleadvertisingconnected。我们将从idle类开始,如下所示代码所示:

class idle : public state{
public:
    ble_state handle_event(ble_event event) {
        if (event == ble_event::ble_button_pressed) {
            start_advertising();
            return ble_state::advertising;
        }
        return get_state_enum();
    }
    ble_state get_state_enum() {
       return ble_state::idle;
    }
private:
    void start_advertising() {
        printf("Action: start_advertising()\n");
    }
}; 

在前面的代码中,我们可以看到idle类实现了在state接口类中定义的纯虚方法:

  • ble_state handle_event(ble_event event)idle类检查接收到的事件是否为ble_event::ble_button_pressed,如果是,则调用start_advertising并返回ble_state::advertising枚举。在接收到任何其他事件的情况下,它返回由get_state_enum提供的状态。

  • ble_state get_state_enum() – 这返回与idle类对应的ble_state枚举,即ble_state::idle

接下来,我们将通过以下代码查看派生类advertising

class advertising : public state{
public:
    ble_state handle_event(ble_event event) {
        if (event == ble_event::connection_request) {
            return ble_state::connected;
        }
        if (event == ble_event::timer_expired) {
            stop_advertising();
            return ble_state::idle;
        }
        return get_state_enum();
    }
    ble_state get_state_enum() {
       return ble_state::advertising;
    }
private:
    void stop_advertising() {
        printf("Action: stop_advertising()\n");
    }
}; 

在此代码中,advertising类通过适当地处理事件实现了在state接口类中定义的纯虚方法。

接下来,我们将介绍connected的具体类:

class connected : public state{
public:
    ble_state handle_event(ble_event event) {
        if (event == ble_event::ble_button_pressed) {
            disconnect();
            return ble_state::idle;
        }
        return get_state_enum();
    }
    ble_state get_state_enum() {
       return ble_state::connected;
    }
private:
    void disconnect() {
        printf("Action: disconnect()\n");
    }
}; 

如前所述的代码所示,connected类实现了状态接口,并适当地实现了handle_eventget_state_enum虚方法。

接下来,我们将重构ble_fsm类,以使用状态类接口在容器中存储具体类对象的指针。

重构 ble_fsm 类

我们将从以下代码所示的ble_fsm类的重构开始:

class ble_fsm {
public:
    void handle_event(ble_event event) {
        if(auto the_state = get_the_state(current_state_)) { 
            current_state_ = the_state->handle_event(event);
        }
    }
    ble_state get_state() const {
        return current_state_;
    }
    void add_state(state *the_state) {
        states_.push_back(the_state);
    }
private:
    ble_state current_state_ = ble_state::idle;
    etl::vector<state*, 3> states_;
    state* get_the_state(ble_state state_enum); }; 

让我们分解ble_fsm类的实现:

  • ble_state current_state_ – 一个具有默认值ble_state::idle的私有成员。我们使用它来跟踪当前状态,就像之前一样。

  • etl::vector<state*, 3> states_ – 一个用于存储状态接口指针的容器。如果您使用编译器探索器跟随此示例,您可以将其替换为std::vector(并包含<vector>头文件)。

  • state* get_the_state(ble_state state_enum) – 一个用于使用ble_state enum获取实际状态的私有方法。

  • void handle_event(ble_event event) – 一个用于处理事件的公共方法。它调用提供current_state_get_the_state方法来获取实际状态对象的指针。如果指针有效,它将在状态对象上调用handle_event并存储返回值在current_state_中。

接下来,让我们回顾一下get_the_state方法实现,如下所示:

state* ble_fsm::get_the_state(ble_state state_enum) {
const auto is_state_enum = & {
        return the_state->get_state_enum() == state_enum;
};
auto it = std::find_if(states_.begin(), states_.end(), is_state_enum);
if (it != states_.end()) {
    return *it;
}
return nullptr;
} 

get_the_state方法中,我们使用std::find_if函数(来自<algorithm>头文件)来搜索一个指向匹配给定state_enumstate对象的指针。搜索使用is_state_enumlambda 作为谓词,它比较每个状态的enum值。如果找到匹配的状态,方法返回指向它的指针;否则,返回nullptr

接下来,让我们看看如何使用重构的ble_fsm类、state接口以及具体的idleadvertisingconnected类来实现FSM

实现状态模式

接下来,我们将看到如何在以下代码中使用上述状态模式的实现:

int main() {
    ble_fsm my_ble_fsm;
    idle idle_s;
    advertising advertising_s;
    connected connected_s;
    my_ble_fsm.add_state(&idle_s);
    my_ble_fsm.add_state(&advertising_s);
    my_ble_fsm.add_state(&connected_s);
    const auto print_current_state = [&]() {
        printf("Current State: %s\n",
            state_to_string(my_ble_fsm.get_state()));
    };
    print_current_state();
    my_ble_fsm.handle_event(ble_event::ble_button_pressed);
    print_current_state();
    my_ble_fsm.handle_event(ble_event::connection_request);
    print_current_state();
    my_ble_fsm.handle_event(ble_event::ble_button_pressed);
    print_current_state();

    return 0;
} 

在此代码中,我们看到在创建ble_fsm类型的对象my_ble_fsm之后,我们创建了具体状态的实例:idleadvertisingconnected。然后,我们使用add_state方法将具体状态的指针添加到my_ble_fsm对象中。接下来,我们像在初始实现中那样使用FSM并给它提供事件。

您可以从书中 GitHub 仓库的 Renode 模拟器中运行完整的示例。它位于Chapter16/fsm目录下,您可以使用以下命令构建和运行它:

$ cmake –B build -DMAIN_CPP_FILE_NAME=main_fsm_state_pattern.cpp
$ cmake --build build --target run_in_renode 

我们刚才讨论的例子是使用状态设计模式。接下来,我们将讨论状态设计模式的通用形式。

状态设计模式

让我们回顾一下 BLE 设备连接FSM的 UML 图,如图图 16.2所示:

图 16.2 – BLE 设备连接状态机-FSM UML 图

图 16.2 – BLE 设备连接状态机-FSM UML 图

图 16.2展示了 BLE 设备连接FSM的 UML 图。我们已经讨论了如何将状态设计模式应用于FSM实现。让我们总结一下:

  • FSM类在容器中持有对状态类接口的指针。

  • FSM跟踪当前状态。

  • FSMhandle_event调用委托给当前的具体状态。

  • 具体状态实现状态接口。

  • 具体状态实现动作并在处理事件时适当地调用它们。

  • 具体状态从handle_event方法返回一个新的状态。这允许FSM更新当前状态。

状态设计模式是一种简单而有效的模式,它允许我们将复杂的 switch 语句分解成更易于管理的代码。然而,正如我们之前在示例中看到的那样,具体状态通过if-else逻辑处理事件。随着FSM复杂性的增加,处理函数也可能变得杂乱。为了减轻这种情况,我们可以应用标签调度技术。

使用标签调度实现状态模式

在之前的示例(前几节中),事件处理程序中的程序流程是在运行时使用if-else逻辑确定的。接下来,我们将使用标签调度技术将不同事件的处理解耦到单独的方法中。我们将不再依赖于ble_event枚举,而是创建空类型作为事件,如下面的代码所示:

struct ble_button_pressed{};
struct connection_request{};
struct timer_expired{}; 

现在,类state将为每个定义的事件重载handle_event虚拟方法,如下所示:

class state {
public:
    virtual ble_state handle_event(ble_button_pressed) {
        return get_state_enum();
    }
    virtual ble_state handle_event(connection_request) {
        return get_state_enum();
    }
    virtual ble_state handle_event(timer_expired) {
        return get_state_enum();
    }
    virtual ble_state get_state_enum() = 0;
}; 

在此代码中,我们可以看到类state不再是接口,而是一个抽象类(因为并非所有虚拟方法都是纯方法)。它为类型ble_button_pressedconnection_requesttimer_expired重载了handle_event函数。它通过返回由get_state_enum生成的值来实现所有重载,这是一个纯虚拟方法,将由派生类实现,即具体状态。

接下来,让我们看看advertising类的实现:

class advertising : public state{
public:
    ble_state handle_event(connection_request cr){
        return ble_state::connected;
    }
    ble_state handle_event(timer_expired te){
        stop_advertising();
        return ble_state::idle;
    }
    ble_state get_state_enum() {
       return ble_state::advertising;
    }
private:
    void stop_advertising() {
        printf("Action: stop_advertising()\n");
    }
}; 

在此代码中,我们可以看到advertising类实现了以下虚拟方法的重载:

  • ble_state handle_event(connection_request cr) 返回 ble_state::connected

  • ble_state handle_event(timer_expired te) 调用 stop_advertising 并返回 ble_state::idle

通过使用重载函数,我们可以在单独的方法中实现不同事件的处理,并通过调用handle_event并传递不同类型来轻松地调度对这些方法的调用。为了完成实现,我们还需要在FSM中对所有可能的事件重载handle_event方法。我们可以通过将其制作成模板方法来实现这一点,如下面的代码所示:

class ble_fsm {
public:
    template<typename E>
 void handle_event(E event) {
        if(auto the_state = get_the_state(current_state_))
        {
            current_state_= the_state->handle_event(event);
        }
    }
//...
}; 

上述代码显示了ble_fsm类的模板方法handle_event,这使我们的标签调度技术应用完整。

您可以在书中 GitHub 仓库的 Renode 模拟器中运行完整示例。它位于Chapter16/fsm目录下,您可以使用以下命令构建和运行它:

$ cmake –B build
-DMAIN_CPP_FILE_NAME=main_fsm_state_pattern_tag_dispatch.cpp
$ cmake --build build --target run_in_renode 

到目前为止,我们在本章中看到了三种在 C++中实现FSM的方法。我们从一个简单的基于 switch 和 if-else 的方法开始,应用了状态设计模式,然后利用了标签调度。每一步都为我们提供了更多的设计灵活性——使代码更易于阅读和管理,这对于处理复杂的FSM非常重要。

实现一个 FSM 的其他方法基于状态转换表,它在一个地方描述转换。Boost 状态机语言SML)使用基于表的策略来使用描述性语法描述 FSM

Boost SML

Boost SML 是一个高度表达的 C++14 单头库,用于实现 FSMs(有限状态机)。我们将直接通过实现相同的 BLE 设备连接 FSM 来使用它。以下是代码:

#include "sml.hpp"
namespace sml = boost::sml;
struct ble_button_pressed{};
struct connection_request{};
struct timer_expired{};
constexpr auto start_advertising = [](){
    printf("Action: start_advertising()\n");
};
constexpr auto stop_advertising = [](){
    printf("Action: stop_advertising()\n");
};
constexpr auto disconnect = [](){
    printf("Action: disconnect()\n");
};
struct ble_fsm {
  auto operator()() const {
    using namespace sml;
        return make_transition_table(
        *"idle"_s + event<ble_button_pressed>
        / start_advertising                          = "advertising"_s,
        "advertising"_s  + event<connection_request> = "connected"_s,
        "advertising"_s  + event<timer_expired>     
        / stop_advertising                           = "idle"_s,
        "connected"_s + event<ble_button_pressed>
        / disconnect                                 = "idle"_s
    );
  }
}; 

让我们分解这个示例:

  • 事件被建模为结构体,与我们的标签分派实现相同。

  • 动作被定义为 constexpr lambda 表达式。

  • 我们将 ble_fsm 类型定义为具有重载 operator() 的结构体,该操作符从 sml 命名空间调用 make_transition_table 返回结果。

make_transition_table 中的代码允许 SML 提取转换定义,在其中,我们使用以下语法:src_state + event [ guard ] / action = dst_state。以下是语法的分解:

  • src_state – 这是转换开始的初始状态。

  • + event – 这是触发检查可能转换的事件。如果事件到达且守卫满足条件,则进行转换。

  • [ guard ] – 守卫是一个可选的布尔谓词,必须评估为真才能发生转换。如果省略,则指定事件无条件发生转换。

  • / action – 当状态转换发生时,动作是一个可选的 lambda 表达式,用于执行操作。

  • = dst_state – 如果发生转换,FSM 将进入的目标状态。

转换语法是 SML 的精髓。通过在 operator() 内写入多行这些规则,我们以声明性和可读性的方式完全描述了 FSM 的行为。

现在我们来看看如何使用 Boost SML 中讨论的 FSM

 sm<ble_fsm> my_ble_fsm{};
    const auto print_current_state = [&]() {
        printf("Current State: ");
        if(my_ble_fsm.is("idle"_s)) {
            printf("idle\n");
        }
        if(my_ble_fsm.is("advertising"_s)) {
            printf("advertising\n");
        }
        if(my_ble_fsm.is("connected"_s)) {
            printf("connected\n");
        }
    };
    print_current_state();
    my_ble_fsm.process_event(ble_button_pressed{});
    print_current_state();
    my_ble_fsm.process_event(connection_request{});
    print_current_state();
    my_ble_fsm.process_event(ble_button_pressed{});
    print_current_state(); 

在此代码中,我们创建了一个 my_ble_fsm 类型的对象。然后,我们使用 process_event 方法向其发送事件。您可以从书籍的 GitHub 仓库中的 Renode 模拟器运行完整示例。它位于 Chapter16/fsm 目录下,您可以使用以下命令构建和运行它:

$ cmake –B build -DMAIN_CPP_FILE_NAME=main_fsm_boost_sml.cpp
$ cmake --build build --target run_in_renode 

Boost SML 是一个高度表达的库,它减少了之前 FSM 实现中的样板代码。它还提供了诸如守卫变量和组合状态等功能。以下是您可以探索更多信息的项目链接:github.com/boost-ext/sml

Boost SML 不仅是一个高度表达性的库,而且性能也非常出色,这得益于它使用编译时模板元编程来积极优化代码。事件调度依赖于标签调度(在编译时解决)与最小运行时查找相结合,避免了昂贵的分支或间接引用。这种方法通常优于基于手动 switch-enum的解决方案和基于状态模式的实现(这些实现会带来虚拟调用开销)。具体性能比较,请参阅以下链接的基准测试:github.com/boost-ext/sml?tab=readme-ov-file#benchmark

摘要

在本章中,我们从基于简单 switch-case 方法的实现开始,讨论了状态模式、标签调度以及使用 Boost SML 库进行高度表达性代码的实现。

最基本的基于 switch 的实现适用于具有有限状态和转换的小型FSM。当FSM的复杂性增加时,阅读和管理变得困难。转向基于状态模式的解决方案可以提高代码的可读性,并使更改更容易。Boost SML 提供了终极的表达性,为我们提供了一个可读性强的语法,使我们能够以简洁的方式编写非常复杂的FSM

在下一章中,我们将概述可用于嵌入式系统开发的 C++库和框架。

第十七章:库和框架

虽然 C++标准库提供了大量的容器和算法,但某些方面——例如动态内存分配——在受限环境中可能会带来挑战。在第二章中,我们探讨了这些问题及其解决方法。然而,像嵌入式模板库ETL)这样的专用库提供了确定性行为和固定内存占用,非常适合嵌入式系统。

嵌入式应用程序依赖于供应商提供的作为 C 库的硬件抽象层HALs)。在第十二章中,我们探讨了如何使用接口将应用层 C++代码与底层基于 C 的硬件交互解耦。将整个 HAL 用 C++包装是一项大量工作,但幸运的是,有像 Google 的 Pigweed 这样的项目正在处理这个问题,同时为嵌入式开发提供了额外的功能和灵活性。

第十一章中,我们探讨了 C++如何在编译时执行计算,从而减少内存占用。在第十五章中,我们学习了观察者模式并检查了其编译时实现。英特尔公司的编译时初始化和构建CIB)将这些想法提升到了更高的层次,使得在编译时配置固件应用成为一种声明式方法。在本章中,我们将介绍以下 C++库:

  • 标准库

  • 嵌入式模板库

  • 猪草

  • 编译时初始化和构建

技术要求

您可以在第四章中设置的 Docker 容器中的 Renode 模拟器中尝试本章的示例。请确保 Docker 容器正在运行。

您可以在 GitHub 上找到本章的文件,地址为github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter17

标准库

C++标准定义了两种标准库实现类型——带宿主环境和独立环境:

  • 独立实现旨在在没有依赖操作系统通常提供的服务的情况下运行,例如文件系统访问或多线程支持。因此,C++标准仅指定了必须由独立实现提供的标准库头文件的一个有限子集。

  • 带宿主环境的实现需要一个全局定义的主函数,环境负责在启动时调用此函数。在独立实现中,启动例程和程序的入口点是实现定义的,这允许开发者有更大的灵活性来指定其应用程序的初始化和执行流程。

虽然标准在全局定义的 main 函数方面对托管和独立实现进行了明确的区分,但本书中的一些示例配置模糊了两者之间的界限。

GCC 中的独立和托管实现

尽管我们在一个独立的环境中操作(没有操作系统),本书中的一些示例使用了 C++ 标准库的组件(例如,std::function),这些组件通常与托管实现相关联。这是可能的,因为:

  • 正如我们在 第四章 中观察到的,我们在链接器脚本中将程序入口点设置为 Reset_Handler

  • 在汇编器启动脚本中实现的 Reset_Handler 执行低级初始化并显式调用 main

  • 我们使用 nano 规范(第七章),链接到一个大小优化的 C++ 标准库子集。这允许有限地使用托管功能,如 std::function,同时避免对操作系统的依赖。

这种混合方法利用 GCC 的灵活性,在裸机环境中结合独立执行(自定义入口点,没有操作系统)和托管库功能(标准头文件,实用工具)。

要明确请求 GCC 使用标准库的独立实现,应使用编译器标志 -ffreestanding。C++ 标准库提供了许多“按需付费”的组件,即使在资源受限的环境中也非常有用。在前几章中,你已经与标准库的许多部分一起工作过,因此你对它的功能有了坚实的理解。在这里,我们将概述最适合资源受限环境的部分,并指出哪些部分应谨慎使用或避免使用。

数值和数学

嵌入式系统通常用于自动化和过程控制,需要精确控制数值类型、它们的范围和数学运算。C++ 标准库提供了 <cstdint><limits><cmath> 头文件来定义固定宽度的整数、查询数值限制和执行数学计算,有助于确保在资源受限环境中可预测的行为、可移植性和效率。

<cstdint>

<cstdint> 头文件提供了固定宽度的整数类型,如 std::int8_tstd::uint32_t,以及其他在 stdint.h 中定义的知名 C 类型。这些类型在嵌入式环境中非常有用,在这些环境中,整数的大小和位宽对于直接访问硬件寄存器、可预测的溢出行为和内存使用考虑非常重要。通过使用它们,你明确记录了变量大小的意图,从而提高了代码的可移植性,并防止在不同原生整数宽度平台之间迁移时出现潜在惊喜。

<limits>

该头文件提供了 std::numeric_limits 模板,它描述了基本数值类型(如最小和最大值、符号和精度)的性质。这在嵌入式环境中处理溢出时特别有用。典型用法发生在编译时或通过编译器的简单内联,从而产生最小的运行时开销。通过使用 std::numeric_limits::max() 等函数,您避免了散布魔法常数或架构特定的假设,有助于可移植性和可维护性。

<cmath> 头文件提供了标准数学函数,如 std::sinstd::cosstd::sqrt 等。在嵌入式环境中,尤其是在没有浮点硬件的环境中,这些函数在运行时性能和代码大小方面可能相对昂贵。仔细考虑您是否真的需要浮点数学,如果是这样,是否近似或定点例程可能足够且更高效。

容器和算法

嵌入式系统通常管理结构化数据,并在资源受限的紧密约束下需要高效地处理它。C++ 标准库提供了容器和算法头文件,如 <array><span><algorithm>,以组织数据并执行常见的操作,如搜索、排序和转换,从而实现更易读和可维护的代码。

std::array

在标准库中,唯一避免动态分配的固定大小容器是 std::array。我们在第一章中讨论泛型类型时提到了它。在同一章中,我们将环形缓冲区实现基于 std::array,这使得我们可以使用相同的泛型代码创建不同类型和大小的环形缓冲区。

std::array 通常被实现为一个围绕 C 风格数组的包装器。除了是一个泛型类型外,它还提供了基于索引访问的 at 方法,该方法具有运行时边界检查,使其成为原始数组的更安全替代品。如果请求超出边界的索引,at 方法将抛出异常。如果禁用了异常,它可能会调用 std::terminatestd::abort,具体取决于库的实现。这些行为应根据您的系统要求通过实现适当的终止和信号处理程序来处理。

std:: priority_queue

std::priority_queue 是一个提供优先队列功能的容器适配器。默认情况下,它使用 std::vector 作为底层容器。然而,如第十四章所示,您可以将其替换为来自 ETL 的 etl::vector,从而避免动态内存分配的问题。

std:: span

第九章所示,std::span 是围绕连续对象序列的轻量级、非拥有包装器,其中第一个元素位于位置 0。它提供了诸如 size() 方法、用于元素访问的 operator[] 以及 begin()end() 迭代器等基本功能,使其能够无缝地与标准库算法集成。

std::span 可以从 C 风格数组以及容器如 std::arraystd::vectoretl::vector 中构造。这使得它成为使用单独的指针和大小参数的实用替代品,这在将 C++ 代码与 C 库(如 HAL 中使用的库)接口时特别有用。

迭代器

迭代器是抽象,它们像通用指针一样工作,提供了一种统一的方式来遍历和访问容器内的元素。例如,标准库容器实现了 begin()end() 方法,它们返回标记序列起始和结束位置的迭代器。这种一致的接口允许算法在多种容器类型上以通用方式工作,增强代码的可重用性和清晰性。

让我们通过以下使用 std::array 的示例来了解:

#include <array>
#include <algorithm>
#include <cstdio>
int main() {
    std::array<int, 5> arr = {5, 3, 4, 1, 2};
    std::array<int, 5>::iterator start = arr.begin();
    auto finish = arr.end();
    std::sort(start, finish);
    for (auto it = arr.begin(); it != arr.end(); ++it) {
        printf("%d ", *it);
    }
    printf("\n");
    return 0;
} 

此示例演示了如何使用标准库容器与迭代器一起使用:

  • 迭代器 start 明确声明为 std::array<int, 5>::iterator 以展示完整的类型名,而迭代器 finish 使用 auto 声明以提高简洁性,允许编译器推断其类型。

  • 使用从 arr.begin()arr.end() 获得的迭代器 startfinish 应用 std::sort 算法,以升序对数组进行排序。

  • 循环使用 auto 声明迭代器 it,这使得代码更加简洁。循环遍历排序后的数组,并使用 printf 打印每个元素。

迭代器用于遍历容器。它们不仅促进了通用编程,还使得在不更改算法逻辑的情况下轻松切换容器类型变得容易。

算法

标准库中的算法提供了一种在不同容器中解决常见问题的统一方式,使代码更具表达性和易于维护。它们允许您使用统一接口执行搜索、排序、复制和累积数据等操作。以下列出了一些最常用的算法:

  • std::sort:默认按升序对元素范围进行排序,使用小于运算符进行比较。它还可以接受自定义比较器,根据不同的标准进行排序,例如降序或特定对象属性。

  • std::find:在范围内搜索给定值的第一个出现,并返回指向它的迭代器。如果找不到该值,则返回结束迭代器,表示搜索失败。

  • std::for_each:将指定的函数或 lambda 应用于范围内的每个元素。

  • std::copy:将一个范围的元素复制到另一个目标范围。

  • std::copy_if:仅复制满足指定谓词的元素,这使得在复制数据时过滤数据变得很有用。

  • std::minstd::max:分别返回两个值中的较小或较大值,默认使用小于运算符(或提供的比较函数)。它们在只需要比较两个值的最小或最大值时非常有用。

  • std::min_elementstd::max_element:返回范围中最小或最大元素的迭代器。当您需要在一个容器中找到极端值的位罝时(而不是仅仅比较两个值),这些非常有用。

  • std::accumulate:遍历一个范围,并使用二进制运算(默认为加法)将元素与初始值组合。这允许对值求和、计算乘积或执行您定义的任何自定义聚合。

模板元编程

第八章所述,C++类型特性是编译时谓词和转换,允许编译器根据类型的属性强制约束。它们用于编写通用、健壮的代码,而不会产生运行时开销。在第十二章中,我们使用类型特性创建类型安全的寄存器访问,防止编译时无效类型的使用,并降低细微错误的几率。

在本节中提到的章节中,我们利用了一些具体的类型特性:

  • std::enable_if:根据布尔编译时表达式启用或禁用函数模板

  • std::is_same:检查两个类型是否完全相同

  • std::is_enum:检查一个类型是否是枚举类型

  • std::underlying_type:检索枚举的基本整数类型

  • std::is_arithmetic:检查一个类型是否是算术类型(整型或浮点型)

  • std::is_integral:检查一个类型是否是整型

  • std::is_floating_point:检查一个类型是否是浮点类型

在嵌入式应用程序中应避免的标准库部分

标准库中的许多容器,如 std::vectorstd::liststd::string,使用动态内存分配。如果您的嵌入式应用程序不允许动态内存分配,应避免使用这些。

包含在头文件 <iostream> 中的 iostream 库需要大量的内存资源,并且依赖于动态分配。这就是为什么我们使用了 <cstdio> 头文件和 printf 函数来进行控制台输出。

第十章中,我们介绍了 <functional> 头文件中的 std::function。在那里,我们概述了在某些情况下,std::function 可以使用动态内存分配,这意味着如果使用,应谨慎使用。请注意,std::function 在独立实现中不可用。

接下来,我们将简要概述 ETL,它补充了在受限嵌入式环境中的标准库。

嵌入式模板库

第二章中,我们了解到std::vector默认使用动态内存分配。我们还了解到,我们可以使用std::polymorphic_allocator和一个单调缓冲区使其使用静态分配的内存。这种方法仍然不是万无一失的,因为即使在采用这种方法的情况下,std::vector在某些情况下仍然可能求助于动态内存分配。

为了解决标准库在嵌入式环境中所提出的挑战,ETL 提供了一套模板容器和算法,它们紧密模仿标准库对应物的接口,但针对资源有限的系统进行了定制。

固定大小的容器

ETL 的主要优势之一是其容器(如etl::vectoretl::listetl::string等)允许你在编译时指定一个固定的最大大小。容器实现确保在运行时不会执行动态内存分配,因为内存是在前端作为原子或静态存储预留的。

由于 ETL 容器旨在模仿标准库容器,并且它们实现了迭代器,因此它们可以与标准库中的大多数算法和容器适配器一起使用。这使我们能够利用标准库中的组件,而无需担心动态分配。

由于 C++11 中引入了std::array,ETL 为不支持 C++11 的平台提供了etl::array

使用etl::delegate存储可调用对象

第十四章所示,你可以使用etl::delegate代替std::function来存储可调用对象。然而,etl::delegate是非所有权的,因此你必须小心处理潜在的悬垂引用。

ETL 提供的其他实用工具

除了固定大小的容器和etl::delegate之外,ETL 还提供了一些实用工具,例如消息框架——一组消息、消息路由器、消息总线以及有限状态机。它还提供了 CRC 计算、校验和以及哈希函数。

ETL 允许你配置错误处理。它可以配置为抛出异常或将错误发送到用户定义的处理程序。这提供了更大的灵活性和基于项目需求的配置。

你可以在www.etlcpp.com/网站上了解更多关于 ETL 的信息。

接下来,我们将讨论 Pigweed——由 Google 开发的一组轻量级、模块化的 C++库,用于嵌入式系统,提供日志记录、断言和蓝牙连接等组件,以简化开发并提高代码重用性。

Pigweed

嵌入式系统开发中最大的挑战之一是可移植性。为了使代码真正可移植,它必须依赖于接口。为了在不同的硬件目标上运行,需要有人在不同的目标上实现这些接口。在各个项目和设备之间维护一致的接口可能很困难。Google 的 Pigweed 项目旨在通过提供嵌入式应用的软件模块来解决这一问题,其中许多目标已经实现了硬件接口。

Pigweed 旨在用于复杂项目和大型团队。除了硬件接口外,它还:

  • 打包了基于它们的软件模块,例如日志记录、串行通信(SPI、I2C 和 UART)、蓝牙主机控制器接口HCI)、交互式控制台、远程过程调用RPC)系统等。

  • 提供了标准库组件的嵌入式友好替代方案:固定大小的字符串和容器。

  • 开箱即用管理整个工具链,简化了开发环境的设置。

  • 提供了一个完整的框架——pw_system——它将 Pigweed 中的许多模块组合在一起,构建了一个具有 RPC、日志记录等功能的运行系统。

如您所见,Pigweed 不仅是一个库——它是一个完整的开发生态系统。它可以作为一个框架使用,但您也可以挑选适合您需求的单个模块。正如文档网站pigweed.dev/上所述——Pigweed 仍处于早期阶段;一些模块仍在开发中,而一些则已稳定并用于市场上的一些设备。与任何库一样,您需要评估它是否适合在您的项目中使用。

我们将进行 Pigweed 的 Sense 教程,以展示其一些功能——主要是交互式控制台和RPC系统。

Pigweed 的 Sense 教程

Sense 项目是一个演示项目,它利用了许多 Pigweed 组件,并展示了它们是如何协同工作的。

Sense 是一个简化版的空气质量传感器,它只包含完整产品的一些功能。目标是让您通过以下步骤获得使用 Pigweed 的实际经验:

  1. 首先,确保 Docker 守护进程正在运行。以网络主机模式启动一个 Docker 镜像并将其附加到 Bash。在 Linux 环境中,您可以使用以下命令:

    $ sudo systemctl start docker
    $ docker run --network=host -d -it --name dev_env mahmutbegovic/cpp_in_embedded_systems
    $ docker exec -it dev_env /bin/bash 
    

对于基于 Windows 的主机,请使用以下命令来转发运行教程所需的端口:

$ docker run -d -it --name dev_env -p 33000:33000 -p 8080:8080 mahmutbegovic/cpp_in_embedded_systems 
  1. 接下来,克隆 Sense 仓库:

    $ git clone https://pigweed.googlesource.com/pigweed/showcase/sense 
    
  2. 接下来,启动 Visual Studio Code,连接到正在运行的容器,并打开/workspace/sense文件夹。如果您在Visual Studio Code中看到一个弹出消息建议安装 Pigweed 扩展,请接受它;否则,转到扩展,搜索Pigweed并安装它。

图 17.1 – Visual Studio Code 扩展

图 17.1 – Visual Studio Code 扩展

图 17.1展示了 Visual Studio Code Pigweed 扩展。

  1. 安装扩展后,转到资源管理器视图并展开 BAZEL 构建目标 节点。点击 刷新目标列表 按钮。

图 17.2 – BAZEL 构建目标节点

图 17.2 – BAZEL 构建目标节点

刷新目标列表可能需要 30 秒到几分钟不等。Pigweed 使用 Bazel 进行构建自动化。刷新后的目标列表应类似于以下内容:

图 17.3 – BAZEL 构建目标

图 17.3 – BAZEL 构建目标

图 17.3 描述了 Bazel 构建目标。

  1. 接下来,展开 //apps/blinky 节点。

图 17.4 – //apps/blinky 目标

图 17.4 – //apps/blinky 目标

  1. 现在,我们将构建一个在主机上运行的应用程序版本。右键单击 simulator_blinky (host_device_simulator_binary),然后点击 构建目标。构建可能需要大约 10 分钟。完成后,你应该会看到类似以下的消息:

图 17.5 – 构建成功

图 17.5 – 构建成功

  1. 构建成功后,我们将启动应用程序。右键单击 simulator_blinky (host_device_simulator_binary),然后选择 运行目标。如果成功,你应该会在终端中看到以下消息:在端口 33000 上等待连接

  2. 接下来,右键单击 simulator_console (native_binary),然后选择 运行目标。这将构建一个控制台并将其连接到正在运行的模拟器。如果成功,你应该会看到以下屏幕:

图 17.6 – 在终端视图中运行的交互式控制台

图 17.6 – 在终端视图中运行的交互式控制台

图 17.6 中,你可以看到在 Visual Studio Code 的终端视图中运行的交互式控制台。

  1. 为了使控制台更容易使用,右键单击 Run //apps/blinky:simulator_console 并选择 将终端移动到新窗口。这将把控制台移动到单独的窗口,如图所示:

图 17.7 – 在单独窗口中运行的交互式控制台

图 17.7 – 在单独窗口中运行的交互式控制台

图 17.7 中,右上角的 设备日志 窗格中,我们可以看到来自模拟设备(在主机上运行的应用程序)的日志。它每秒发送 LED 闪烁 消息。

  1. 接下来,我们将使用 RPC 协议向设备发送消息,以检索设备测量的温度。在左下角的窗格中输入以下命令 – Python Repl

    $ device.rpcs.board.Board.OnboardTemp() 
    

你应该会看到以下响应:

$ (Status.OK, board.OnboardTempResponse(temp=20.0)) 
  1. 接下来,发送一条将切换 LED 的消息:

    $ device.rpcs.blinky.Blinky.Blink(interval_ms=200, blink_count=3) 
    

此调用将使 LED 以 200 毫秒的间隔闪烁三次,然后停止 LED 闪烁 消息。这表明我们也可以向 RPC 调用提供参数。

接下来,我们将更详细地介绍 Pigweed 的 RPC。

RPC 和 Protocol Buffers

Pigweed 的 RPC 系统基于 Protocol Buffers – 一种用于数据序列化的平台无关机制。Protocol Buffers 是一种具有自己语法的语言,它可以编译成我们的 Sense 设备上的 C++ 等目标语言以及我们在 Python Read Eval Print LoopREPL)中使用的 Python 代码。

那么,为什么在嵌入式应用程序中使用像 Protocol Buffers 这样的额外抽象层呢?标准化序列化给您的项目带来了一些好处:

  • 紧凑的二进制消息 – 它们添加的额外开销非常小。

  • 系统不同部分之间精确的合同(.proto 文件),确保所有各方都同意交换数据的结构和含义。

  • 通过修改 proto 文件可以管理通信协议的更新。

简而言之,而不是在多个代码库(C++ 和 Python)中编写序列化和反序列化代码并维护它们,您在 proto 文件中编写通信协议,并使用 Protocol Buffers 编译器生成用于序列化的 C++ 和 Python 代码。

让我们检查 modules/blinky/blinky.proto 文件的一部分,该部分描述了在 Pigweed 的 Sense 教程 部分中使用的 Blinky 服务,该服务在以下代码中以 200 毫秒的间隔闪烁 LED 三次:

syntax = "proto3";
package blinky;
import "pw_protobuf_protos/common.proto";
service Blinky {
// Toggles the LED on or off.
rpc ToggleLed(pw.protobuf.Empty) returns(pw.protobuf.Empty);
// Blinks the board LED a specified number of times.
rpc Blink(BlinkRequest) returns (pw.protobuf.Empty);
}
message BlinkRequest {
// The interval at which to blink the LED, in milliseconds. uint32 interval_ms = 1;
// The number of times to blink the LED.
optional uint32 blink_count = 2;
} 

此 proto 文件定义了一个名为 Blinky 的服务,用于控制 LED,使用 Protocol Buffers 版本 3(syntax = "proto3")。它导入了一个 common proto 文件并定义了两个方法:

  • ToggleLed: 一个简单的使用空请求和响应切换 LED 开关的方法。

  • Blink: 一种通过可配置的 interval_msoptional blink_countBlinkRequest 的成员)闪烁 LED 的方法。使用 optional 关键字意味着在调用方法时可以省略此参数。

这是对 blinky.proto 文件的简要说明。更详细的 Protocol Buffers 指南可以在以下网站上找到:protobuf.dev/programming-guides/proto3/

对于 blinky proto 文件中的每个服务,Pigweed 的代码生成器将生成相应的 C++ 类。生成的 Blinky 类位于文件包的专用 pw_rpc::nanopb 子命名空间内:blinky::pw_rpc::nanopb::Blinky::Service

生成的类作为必须继承以实现服务方法的基类。它基于派生类进行模板化。BlinkyService 类实现了基类。以下代码是其定义的一部分,来自 modules/blinky/service.h 文件:

class BlinkyService final : public ::blinky::pw_rpc::nanopb::Blinky::Service {
public:
    pw::Status ToggleLed(const pw_protobuf_Empty&, pw_protobuf_Empty&);
    pw::Status Blink(const blinky_BlinkRequest& request, pw_protobuf_Empty&);
private:
    Blinky blinky_;
}; 

BlinkyService 在生成的 RPC 接口与控制 LED 的具体实现之间架起桥梁。它有一个私有的 blinky_ 对象,其类型为 Blinky,用于控制 LED,如以下代码块中 modules/blinky/service.cc 文件中 ToggleLedBlink 方法的实现所示:

pw::Status BlinkyService::ToggleLed(
const pw_protobuf_Empty&,
pw_protobuf_Empty&)
{
    blinky_.Toggle();
    return pw::OkStatus();
}
pw::Status BlinkyService::Blink(
const blinky_BlinkRequest& request,
pw_protobuf_Empty&) 
{
    uint32_t interval_ms = request.interval_ms;
    uint32_t blink_count = request.has_blink_count;
    return blinky_.Blink(blink_count, interval_ms);
} 

在此代码中,ToggleLedBlink方法使用blinky_对象来控制 LED。当通过传输层接收到blinky服务的二进制 proto 消息时,它们被转换为对用于控制硬件的代码的实际调用,这是 RPC 的本质。

作为练习,通过添加BlinkTwice方法来扩展blinky服务。你已经知道需要修改的文件——proto 文件和BlinkyService实现文件。

Pigweed 使用nanopb (github.com/nanopb/nanopb)在 C 文件中编译 proto 文件,然后将其封装在 C++中。为微控制器特别设计的纯 C++实现协议缓冲区是嵌入式 Proto。它是一个面向对象的实现,只使用静态内存分配。它是在遵循 MISRA C++指南的基础上开发的。这些特性使得嵌入式 Proto 适用于具有广泛要求的各种应用,从低内存使用到安全性考虑。你可以在 GitHub 页面上了解更多信息:github.com/Embedded-AMS/EmbeddedProto

Pigweed 的学习曲线很陡峭,应该根据您的系统要求仔细评估。由于学习成本较高,它更适合大型、更复杂的项目。此外,评估硬件支持,并考虑一些模块可能引入的内存开销。

与 Pigweed 相比,Intel 的CIB库利用了 C++的编译时能力。这种方法在增强灵活性和表现力的同时,最小化了内存开销。接下来,我们将介绍 CIB 库。

编译时初始化和构建

在嵌入式系统中,C++的一个主要优势是它能够在编译时执行计算。在大多数情况下,我们在编译之前对应用程序有相当的了解,这使我们能够在编译时对其进行配置。Intel 的 CIB 库在编译时提供了一个声明性接口来配置固件组件。

正如你在第十五章中看到的,观察者设计模式在事件驱动系统中被广泛使用,用于将事件源(发布者)与对那些事件做出反应的实体(观察者或订阅者)解耦。通过使用订阅者接口,观察者可以注册自己到事件源,然后事件源会通知它们变化或事件,而无需了解观察者实现的具体细节。

这种解耦使得系统设计在灵活性、模块化方面更加出色,因为组件可以添加、删除或修改,而无需将它们紧密耦合到事件生成器。CIB 库利用了这一特性,它通过实现编译时观察者模式来提供配置固件应用程序的声明性接口。通过在编译时解决依赖关系并建立事件驱动关系,CIB 消除了运行时开销,同时保持组件松散耦合和高效互联。

我们将从一个简单的温度发布者示例开始探索 CIB 库。整个示例可在 github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter17/cib 找到。您可以使用以下命令运行它:

$ cmake –B build
$ cmake --build build --target run_in_renode 

您可以使用 app/src/main.cpp 来跟踪示例,因为撰写本文时,CIB 在 Compiler Explorer 中不可作为库使用。

在温度发布者示例中使用 CIB

让我们从以下步骤开始:

  1. 我们首先需要声明一个服务(发布者),它是一个继承自 callback::service 的空结构体,callback::service 是一个变长类模板,提供了将被订阅者接受的类型,如下代码所示:

    struct send_temperature : public callback::service<float> {}; 
    
  2. 接下来,我们将创建订阅者(在 CIB 库的上下文中也称为组件)display_temperature_componentdata_sender_component,如下代码所示:

    struct display_temperature_component {
    constexpr static auto display_temperature = [](float temperature) {
        printf("Temperature is %.2f C\r\n", temperature);
    };
    constexpr static auto config = cib::config(
        cib::extend<send_temperature>(display_temperature)
    );
    };
    struct data_sender_component {
    constexpr static auto send_temp = [](float temp) {
        printf("Sending temperature %.2f C\r\n", temp);
    };
    constexpr static auto config = cib::config(
        cib::extend<send_temperature>(send_temp)
    );
    }; 
    

上述代码定义了两个组件,它们执行以下操作:

  • constexpr 狼形函数 display_temperaturesend_temp 中为 send_temperature 服务提供处理程序。

  • 定义通过它们扩展服务(订阅事件)的 constexpr static auto config 成员。

配置成员是变长模板类 cib::config 的实例,并由 CIB 库在编译时连接应用程序,即连接服务(事件生成器、发布者)与扩展这些服务的软件组件(观察者)。编译时初始化和构建过程由 cib::nexus 执行,它需要提供项目配置。以下是此简单项目的配置代码:

struct my_project {
constexpr static auto config = cib::config(
    cib::exports<send_temperature>,

    cib::components<display_temperature_component,
                    data_sender_component>
);
}; 

此项目配置是一个简单的结构体 my_project,具有 constexpr 成员 config,它提供了以下内容:

  • cib::exports<send_temperature>:用于声明服务(发布者)

  • cib::components<display_temperature_component, data_sender_component>:用于声明可以扩展服务的软件组件

  1. 接下来,让我们看看如何在以下代码中的固件应用程序中使用所有这些功能:

    int main() {
        cib::nexus<my_project> nexus{};
        nexus.init();
        for(int i = 0; i < 3; i++)
        {
            nexus.service<send_temperature>(42.0f);
        }
        return 0;
    } 
    

在此代码中,我们执行以下步骤:

  • cib::nexus<my_project> nexus{};:创建由项目配置 my_project 提供的类模板 cib::nexus 的实例。

  • nexus.init();:初始化 Nexus。

  • nexus.service<send_temperature>(42.0f);: 访问服务并提供浮点参数(温度)。这将触发调用扩展 send_temperature 服务的组件中的 lambda。

扩展温度发布器示例

接下来,我们将通过添加两个组件扩展这个简单的示例 – 一个虚拟温度传感器和名为 temperature_sensor_componenti2c 的 I2C 组件。我们还将引入两个新的服务 – runtime_initmain_loop

  1. 让我们从定义此代码中的新服务开始:

    struct runtime_init : public flow::service<> {};
    struct main_loop : public callback::service<> {}; 
    

在这里,我们定义了两个服务:

  • runtime_init: 继承自变长类模板 flow::service,允许我们按顺序执行操作。

  • main_loop: 继承自 callback::service,它将在主 while 循环中被调用。

  1. 我们现在将转向 I2C 组件的实现,如下所示:

    struct i2c {
    constexpr static auto init = flow::action<"i2c_init">(
        [](){
            printf("I2C init ...\r\n");
        });
    constexpr static auto config = cib::config(
        cib::extend<runtime_init>(*init)
    );
    }; 
    

此代码定义了一个新的组件 – i2c – 作为具有以下内容的结构体:

  • constexpr static auto init: 一个包装在 flow::action 中的 lambda,它实现了 I2C 外设的初始化。

  • constexpr static auto config: 将上述操作添加到 runtime_init 流服务中。* 操作符显式地将操作添加到流程中。没有它,操作会被引用但不会添加,导致编译时错误。

  1. 接下来,让我们通过以下代码查看温度传感器组件:

    struct temperature_sensor_component {
    constexpr static auto init = flow::action<"temp_sensor_init">(
        []() {
            printf("Initializing temperature sensor ... \r\n");
        });
    constexpr static auto read_temperature = []() {
        float temperature = 23.4f;
        cib::service<send_temperature>(temperature);
    };
    constexpr static auto config = cib::config(
    
        cib::extend<main_loop>(read_temperature),
        cib::extend<runtime_init>(i2c::init >> *init)
    );
    }; 
    

上述代码展示了具有以下成员的 temperature_sensor_component 结构体:

  • constexpr static auto init: 一个实现温度传感器初始化的 flow_action

  • constexpr static auto read_temperature: 一个 lambda,它实现温度传感器的周期性读取,并使用 cib::service<read_temperature> 发布读取值。

  • constexpr static auto config: 使用 read_temperature lambda 扩展 main_loop 服务,并使用 runtime_init 流中的 i2c::init >> *init 扩展 runtime_init 流,表示 i2c::initinit 之前。

  1. 接下来,我们需要修改 my_project 结构体以导出新的服务和添加新的组件,如下所示:

    struct my_project {
    constexpr static auto config = cib::config(
        cib::exports<runtime_init,
                     main_loop,
                     send_temperature>,
    
        cib::components<i2c,
                        temperature_sensor_component,
                        display_temperature_component,
                        data_sender_component>
    );
    }; 
    

在此代码中,我们简单地添加了:

  • runtime_initmain_loop 服务添加到 cib::exports

  • i2ctemperature_sensor_component 添加到 cib::components

  1. 最后,让我们看看新的 main 函数,如下所示:

    int main() {
        cib::nexus<my_project> nexus{};
        nexus.init();
        nexus.service<runtime_init>();
        for(int i = 0; i < 3; i++)
        {
            nexus.service<main_loop>();
        }
        return 0;
    } 
    

如前所述,我们首先创建一个 cib::nexus 实例并初始化它。然后,我们执行以下步骤:

  1. nexus.service<runtime_init>(): 这将运行 runtime_init 流中的所有操作,并确保操作的指定顺序。

  2. nexus.service<main_loop>(): 这是在主循环中执行的所有扩展此服务的 lambda 的调用。

这种结构对于许多固件应用来说是典型的:初始化所有组件(包括硬件外设),然后在主循环中反复调用相关服务。对应用的任何更改都是在my_project结构中以声明方式进行的——通过扩展服务和添加或删除组件。所有初始化都在组件自身中执行,这意味着主函数不需要了解各个组件及其依赖的细节。

CIB 库还包括日志、中断、消息和字符串常量库——所有这些都利用了 C++的编译时计算。你可以在 GitHub 上找到有关 CIB 的更多信息:github.com/intel/compile-time-init-build

你可以在 Renode 中运行完整的 CIB 示例。启动 Visual Studio Code,将其附加到正在运行的容器,打开Chapter17/cib项目,如第四章中所述,然后在 Visual Studio Code 终端中运行以下命令,或者直接在容器终端中运行:

$ cmake –B build
$ cmake --build build --target run_in_renode 

运行上述示例将生成以下输出:

I2C init ...
Initializing temperature sensor ...
Sending temperature 23.40 C
Temperature is 23.40 C
Sending temperature 23.40 C
Temperature is 23.40 C
Sending temperature 23.40 C
Temperature is 23.40 C 

本例演示了在具有松散耦合组件的事件驱动系统中使用 CIB 库的情况,其中一些组件生成事件,而另一些则对它们做出反应。发布者和订阅者的连接是在编译时发生的,这最小化了内存占用并减少了运行时开销,而声明性的项目配置则提高了可读性。

摘要

在本章中,我们概述了本书中使用的库——C++标准库和 ETL。你还了解了谷歌的 Pigweed 库及其功能和英特尔 CIB 库。

在下一章中,我们将介绍跨平台开发。

第十八章:跨平台开发

在前面的章节中,我们探讨了为嵌入式系统设计和实现软件组件的实际示例。每个示例都展示了良好的软件设计实践,并指导您使用现代 C++技术进行实现。

我们在整本书中遵循的设计实践帮助我们创建了可移植的、跨平台的代码。编写跨平台代码很重要,因为它使得软件组件可以在不同的硬件配置中重用。随着我们结束这次旅程,让我们回顾一下前面章节中展示的关键实践。

在本章中,我们将涵盖以下主题:

  • 编写可移植代码的重要性

  • SOLID 设计原则

  • 可测试性

技术需求

本章重点介绍跨平台开发。这里展示的代码可以在多个平台上运行,包括常见的桌面架构。

您可以使用 Compiler Explorer (godbolt.org/) 来运行示例。所有源代码都可在 GitHub 上找到,网址为github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter18

编写可移植代码的重要性

硬件项目成熟、发展并适应市场需求和供应链条件。在 2020 年至 2022 年期间,全球半导体行业面临严重的供应链危机,主要是由 COVID-19 大流行引发的,并因几个因素而加剧。封锁扰乱了生产,而对电子产品(例如笔记本电脑、服务器)的需求激增与汽车行业的误判相撞。汽车制造商最初取消了芯片订单,然后在需求反弹时急忙补货。

因此,许多组件变得稀缺、价格过高或完全不可用。产品必须通过替换电子组件(如传感器、驱动程序、通信模块甚至微控制器)来适应。这反过来又需要修改固件以匹配新的硬件。

对于编写良好的固件,这种适应相对简单,只需实现特定的硬件接口。例如,如果一个产品使用了加速度计并需要替换它,一个设计良好的固件架构只需实现新组件的接口,而无需更改业务逻辑。

跨平台代码也可以在主机上的模拟环境中运行。在第17 章中,我们在主机上运行了 Pigweed 的演示应用程序。这是可能的,多亏了 Pigweed 良好的接口设计,它允许主机实现低级硬件接口。相同的业务应用程序代码可以在多个目标上运行,包括主机,其中输入和输出是模拟的。

结构良好的代码更容易阅读、修改和维护。良好的设计原则即使在需求变化的情况下也能保持项目的灵活性。接下来,我们将探讨五个 SOLID 原则。

SOLID 设计原则

本书中的示例与SOLID设计原则相一致,这些原则最初由 Robert C. Martin 在他的 2000 年论文《设计原则与设计模式》中描述。它们作为编写代码的公认指南,这些代码随着时间的推移保持可适应性和易于工作。尽管 SOLID 原则最初是在面向对象编程中引入的,但它们关注于创建模块化、可维护和可扩展的代码,可以在更广泛的软件开发环境中应用。SOLID 助记符中的每个字母代表一个原则:

  • 单一职责原则 (SRP): 一个类应该只有一个职责,使其只有一个改变的理由。

  • 开闭原则 (OCP): 一个类应该对扩展开放但对修改封闭。通过动态或静态多态扩展类来添加新功能,而不是修改它。

  • 里氏替换原则 (LSP): 派生类应该可以在不破坏软件行为的情况下替换其父类。

  • 接口隔离原则 (ISP): 接口类应该保持小巧和简洁,以便派生类只实现它们需要的那些方法。

  • 依赖倒置原则 (DIP): 高级模块(例如,加速度计)不应依赖于低级模块(例如,I2C)。两者都应依赖于抽象(接口)而不是具体实现。

接下来,我们将通过一个设计加速度计接口的示例,解释如何使用它,并展示它如何与 SOLID 原则相一致以及为什么这种一致性很重要。首先,我们将设计一个加速度计接口类。代码如下:

#include <cstdio>
#include <cstdint>
class accelerometer {
public:
struct data {
    float x;
    float y;
    float z;
};
enum class sampling_rate {
    c_20_hz,
    c_50_hz,
    c_100_hz,
};
enum error {
      ok,
      not_supported
};
virtual error set_sampling_rate(sampling_rate) = 0;
virtual data get_data() = 0;
}; 

前面代码中显示的接口类加速度计将由adxl_345类实现,该类将使用 i2c 接口与实际的加速度计硬件(ADXL345 集成电路是一个具有 I2C 数字接口的小型加速度计)进行通信。此外,我们将在 STM32 平台上运行代码,因此我们将创建一个(模拟的)i2c 接口实现 – i2c_stm32。代码如下:

class i2c {
public:
virtual void write() = 0;
};
class i2c_stm32 : public i2c {
public:
void write() override {
    printf("i2c::write...\r\n");
}
};
class adxl_345 : public accelerometer {
public:
adxl_345(i2c &i2c_obj) : i2c_(i2c_obj) {}
error set_sampling_rate(sampling_rate) override {
    printf("adxl_345: setting sampling rate\r\n");
    i2c_.write();
    return error::ok;
}
data get_data() override {
    return data{0.02f, 0.981f, 0.03f};
}
private:
i2c &i2c_;
}; 

接下来,我们将设计一个简单的tap_detection_algo类,该类使用加速度计接口来收集运动数据并识别短促的突然运动,通常称为轻敲。轻敲是加速度的快速峰值,可以用作用户输入或触发应用程序中的事件。以下代码展示了轻敲检测类的模板:

class tap_detection_algo {
public:
tap_detection_algo(accelerometer &accel) : accel_(accel) {
    auto err = accel_.set_sampling_rate(
        accelerometer::sampling_rate::c_100_hz);
    if(err == accelerometer::error::not_supported) {
    // try another sampling rate and adapt
    }
}
bool run () {
    auto accel_data = accel_.get_data();
    printf("algo: x = %.2f, y = %.2f, z = %.2f\r\n", accel_data.x, 
                                                     accel_data.y,
                                                     accel_data.z);
    // process data
return false;
}
private:
    accelerometer &accel_;
}; 

最后,我们将为main函数编写代码,该代码实例化一个加速度计并运行轻敲检测算法:

int main() {
    i2c_stm32 i2c1;
    adxl_345 accel(i2c1);

    tap_detection_algo algo(accel);
    algo.run();

    return 0;
} 

以下 UML 图展示了前面的代码:

图 18.1 – 轻敲检测算法 UML 图

图 18.1 – 轻敲检测算法 UML 图

图 18**.1 展示了我们设计的软件组件的架构。UML 图中显示的类的代码被简化了,它用于演示以下 SOLID 原则。

单一职责原则(SRP)

accelerometer 类是一个接口类,包含所有虚拟方法。它的单一职责是定义一个将被高级组件使用并由具体的加速度计实现(如 adxl_345)实现的接口。

adxl_345 类实现了加速度计接口,它只负责在串行接口(如 I2C 或 SPI)上与 ADXL 345 加速度计进行通信。此类变更的唯一原因是与传感器本身在更高协议级别上的通信相关的错误修复,而不是串行总线本身。

i2c 类是一个接口类,负责为不同的 I2C 外设实现定义接口,而 i2c_stm32 实现了这个接口。具体实现变更的唯一原因是与串行硬件外设相关的错误修复或优化。

tap_detection_algo 类接收加速度计数据,并使用收集到的数据实现敲击检测算法。更改此类的唯一原因是修复或优化算法。

开放/封闭原则(OCP)

基于接口的 I2C 和加速度计组件设计使我们能够在不修改任何现有代码的情况下扩展软件。例如,如果我们想在德州仪器的微控制器上运行此代码,我们只需要为该平台实现 i2c 接口。同样,如果我们更换加速度计传感器(例如,更换为 ST LSDO6),我们只需要为新传感器实现加速度计接口。

Liskov 替换原则(LSP)

LSP(里氏替换原则)是由 Barbara Liskov 在 1987 年提出的。LSP 专注于在基类及其子类之间设计健壮的合同。任何依赖于基类合同的客户端代码,在使用任何派生类时都应正常工作,而不应有意外行为。

在此示例中,如果 adxl_345 在请求不支持的采样率时静默失败,而不是以尊重基类合同的方式处理(例如,返回错误状态),则会发生合同违规。

接口隔离原则(ISP)

ISP(接口隔离原则)是关于将大型、单一接口拆分为更专注的接口,以便每个类只实现它实际需要的方法。违反此原则的一个例子是拥有一个广泛的惯性测量单元(IMU)接口,该接口包括陀螺仪和磁力计功能,而 adxl_345 只是一个加速度计,被迫提供它无法有意义支持的方法。

依赖倒置原则(DIP)

我们讨论的示例代码清楚地展示了 依赖倒置原则 (DIP)。通过使用基于接口的设计,软件组件被干净地解耦:

  • tap_detection_algo 类依赖于 accelerometer 接口,该接口由 adxl_345 实现

  • adxl_345 类依赖于 i2c 接口,该接口由 i2c_stm32 实现

SOLID 原则使我们能够编写高度解耦的软件,并创建可重用、硬件无关的代码。解耦的代码更灵活,并且更容易添加新功能。

作为练习,在不修改现有类的情况下添加加速度计数据记录功能。

良好的软件设计也提高了软件的可测试性,我们将在下一部分探讨。

可测试性

基于接口的设计导致解耦的软件,这提高了可测试性。让我们分析前面的例子,看看解耦设计如何帮助测试。我们将重点关注敲击检测算法。

在这个例子中,我们创建了一个简单的算法,当任何轴上当前样本与上一个样本之间的差异超过预定义的阈值时,它会检测到敲击。这个过于简化的实现如下所示:

#include <cmath>
#include <algorithm>
class tap_detection_algo {
public:
tap_detection_algo(accelerometer &accel)
                    : accel_(accel), first_sample_(true) {}
bool run() {
    auto current = accel_.get_data();
    if (first_sample_) {
        prev_ = current;
        first_sample_ = false;
        return false;
    }
    bool tap = (std::fabs(current.x - prev_.x) > c_threshold) ||
           (std::fabs(current.y - prev_.y) > c_threshold) ||
               (std::fabs(current.z - prev_.z) > c_threshold);
    prev_ = current;	
    return tap;
}
private:
static constexpr float c_threshold = 0.5f;
accelerometer &accel_;
accelerometer::data prev_;
bool first_sample_ = true;
}; 

前面的代码实现了一个简单的敲击检测算法。它接受一个加速度计参考,并在每次调用 run() 时检索当前传感器数据。如果是第一个样本,它存储该值并返回 false(未检测到敲击)。在后续调用中,它比较每个轴上的当前读数与上一个读数。如果任何轴上的绝对差异超过一个常数阈值,它通过返回 true 信号敲击,然后更新上一个样本。

对于单元测试,我们将创建一个 fake_accel 类来模拟一系列加速度计读数。这样,我们可以控制输入数据以检查 tap_detection_algo 是否工作。fake_accel 类的代码如下所示:

class fake_accel : public accelerometer {
public:
fake_accel(const std::vector<data>& samples)
: samples_(samples), index_(0) {}
error set_sampling_rate(sampling_rate) override {
    return error::ok;
}
data get_data() override {
    if (index_ < samples_.size()) {
        return samples_[index_++];
    }
    return samples_.back();
}
private:
std::vector<data> samples_;
size_t index_;
}; 

这个类,fake_accel,是加速度计接口的测试替身。它通过以下方式模拟加速度计数据:

  • 通过其构造函数接受预定义数据样本的 vector

  • 实现 set_sampling_rate 以始终返回成功的结果。

  • 通过 get_data() 按顺序返回每个样本,并且一旦所有样本都使用完毕,它就重复返回最后一个样本。

这使其适用于测试依赖于加速度计读数的组件。让我们看看如何使用它来测试以下代码中所示的 GoogleTest 框架中的敲击检测算法:

TEST(TapDetectionAlgoTest, DetectTapOnSuddenChange) {
std::vector<accelerometer::data> samples = {
    {0.0f, 1.0f, 0.0f}, // initial reading
    {0.0f, 1.0f, 0.0f}, // no change -> false
    {0.0f, 2.0f, 0.0f} // significant change
};
fake_accel fakeAccel(samples);
tap_detection_algo algo(fakeAccel);
EXPECT_FALSE(algo.run());
EXPECT_FALSE(algo.run());
EXPECT_TRUE(algo.run());
} 

此测试验证敲击检测算法是否正确地将加速度计数据的突然变化识别为敲击。测试设置了一个包含三个样本的假加速度计:

  • 第一个样本:{0.0f, 1.0f, 0.0f} – 用于初始化(无敲击检测)。

  • 第二个样本:{0.0f, 1.0f, 0.0f} – 与第一个样本相比没有变化,因此没有检测到敲击。

  • 第三个样本:{0.0f, 2.0f, 0.0f} – y 轴上的显著变化(1.0 的差异,超过了 0.5 的阈值)触发了敲击检测。

测试预期前两次对 run() 的调用返回 false,第三次调用返回 true。多亏了基于接口的设计,我们可以将 fake_accel 引用传递给 tap_detection_algo 构造函数,因为 fake_accel 实现了 accelerometer 接口。我们向 fake_accel 构造函数提供一个 vector 容器,其中包含要输入算法的样本。这使得我们能够轻松地使用测试数据集测试算法。

完整示例可以在 GitHub 上找到 (github.com/PacktPublishing/Cpp-in-Embedded-Systems/tree/main/Chapter18)。确保在运行时将 GoogleTest 库添加到 Compiler Explorer。

摘要

在本章中,我们学习了为什么编写可移植的、跨平台的代码对于嵌入式开发很重要。它允许您轻松重用软件组件并适应硬件变化,并提高可测试性。

您还学习了 SOLID 原则及其在嵌入式系统软件组件设计中的应用。代码可读性和灵活性是优秀软件设计的一些最重要的特性。

我们人类阅读代码,阅读您代码的人可能是未来的您。因此,编写易于阅读的代码应该是优先事项。只有在绝对需要时才牺牲可读性并优化性能。具有灵活的代码允许您轻松适应变化或添加新功能。

通过本章,我们的旅程即将结束。我们开始探索关于 C++ 的常见神话,并对其进行驳斥。从那里,我们涵盖了现代 C++ 的许多重要方面,并学习了如何在嵌入式应用程序开发中应用它们。

我们探讨了如何使用 lambda 表达式编写表达性代码,并利用编译时计算来生成查找表,节省内存和处理能力。我们还利用 C++ 类型安全性来实现类型安全的 HAL。

接下来,我们学习了如何应用设计模式,如适配器、观察者和状态,来解决嵌入式系统中的典型问题。我们探讨了 C++ 标准库、ETL、Pigweed 和 cib,并学习了如何在嵌入式应用程序中使用它们。

在本书的所有示例中,我们专注于编写可读的、可维护的、松耦合的代码,以加强我们的软件设计和开发技能。

希望您喜欢这次旅程,并祝您编码愉快!

加入我们的 Discord 社区

加入我们的 Discord 社区空间,与作者和其他读者进行讨论:

嵌入式系统

Discord 二维码

Packt 标志

packtpub.com

订阅我们的在线数字图书馆,以获得超过 7,000 本书和视频的完整访问权限,以及行业领先的工具,帮助您规划个人发展并推进您的职业生涯。更多信息,请访问我们的网站。

为什么订阅?

  • 使用来自 4,000 多位行业专业人士的实用电子书和视频,节省学习时间,更多时间用于编码

  • 通过为您量身定制的技能计划提高学习效果

  • 每月免费获得一本电子书或视频

  • 完全可搜索,便于轻松访问关键信息

  • 复制粘贴、打印和收藏内容

www.packtpub.com,您还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。

您可能还会喜欢的其他书籍

如果您喜欢这本书,您可能还会对 Packt 的以下其他书籍感兴趣:

嵌入式系统架构,第二版

Daniele Lacamera

ISBN: 978-1-80323-954-5

  • 参与嵌入式产品的设计和定义阶段

  • 掌握为 ARM Cortex-M 微控制器编写代码

  • 建立嵌入式开发实验室并优化工作流程

  • 使用 TLS 保护嵌入式系统

  • 揭示通信接口背后的架构之谜

  • 理解物联网中连接和分布式设备的设计和开发模式

  • 掌握多任务并行执行模式和实时操作系统

  • 熟悉可信执行环境 (TEE)

裸机嵌入式 C 编程

Israel Gbati

ISBN: 978-1-83546-081-8

  • 解码微控制器数据表,实现精确的固件开发

  • 掌握寄存器操作,以优化基于 Arm 的微控制器固件创建

  • 了解如何自信地导航硬件复杂性

  • 了解如何在不任何帮助的情况下编写优化的固件

  • 通过练习创建 GPIO、定时器、ADC、UART、SPI、I2C、DMA 等裸机驱动程序

  • 使用电源管理技术设计节能的嵌入式系统

Packt 正在寻找像您这样的作者

如果您有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们已与成千上万的开发人员和科技专业人士合作,就像您一样,帮助他们将见解分享给全球科技社区。您可以提交一般申请,申请我们正在招募作者的特定热门话题,或提交您自己的想法。

分享您的想法

您已完成 嵌入式系统中的 C++,我们非常乐意听到您的想法!如果您从亚马逊购买了这本书,请点击此处直接转到该书的亚马逊评论页面并分享您的反馈或在该购买网站上留下评论。

您的评论对我们和科技社区都非常重要,它将帮助我们确保我们提供高质量的内容。

posted @ 2025-10-27 08:52  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报