C++-现代嵌入式编程秘籍(全)
C++ 现代嵌入式编程秘籍(全)
原文:
annas-archive.org/md5/5f729908f617ac4c3bf4b93d739754a8
译者:飞龙
前言
长期以来,嵌入式系统的开发要求要么使用纯 C,要么使用汇编语言。这其中有很多充分的理由。硬件资源不足以运行用高级编程语言(如 C++、Java 或 Python)编写的应用程序,但更重要的是,没有真正的需要用这些语言编写软件。有限的硬件资源限制了软件的复杂性,嵌入式应用程序的功能保持相对简单,C 的功能足以实现它。
由于硬件发展的进步,如今越来越多的嵌入式系统由价格低廉但功能强大的 SoC 提供支持,能够运行诸如 Linux 之类的通用多任务操作系统。
不断增长的硬件能力需要更复杂的软件,越来越多的情况下 C++成为新嵌入式系统的首选语言。通过其“你不使用的部分不需要付费”的方法,它允许开发人员创建使用计算和内存资源的应用程序,就像用 C 编写的应用程序一样,但提供了更多处理复杂性和更安全的资源管理工具,如面向对象编程和 RAII 习惯用法。
经验丰富的嵌入式开发人员通常倾向于以一种类似习惯的方式用 C++编写代码,认为这种语言只是 C 的面向对象扩展,一个带有类的 C。然而,现代 C++有自己的最佳实践和概念,正确使用这些概念可以帮助开发人员避免常见陷阱,并允许他们在几行代码中完成很多工作。
另一方面,具有 C++经验的开发人员进入嵌入式系统的世界时,应该了解特定硬件平台和应用领域的要求、限制和能力,并相应地设计他们的 C++代码。
这本书的目标是弥合这一差距,并演示现代 C++的特性和最佳实践如何在嵌入式系统的背景下应用。
这本书是为谁写的
这本书是为那些想要在 C++中构建有效嵌入式程序的开发人员和电子硬件、软件和系统芯片工程师而写的。
嵌入式系统的世界是广阔的。这本书试图涵盖其中一种类型,即运行 Linux 操作系统的 SoC,如树莓派或 BeagleBoard,并简要涉及低级微控制器,如 Arduino。
预期读者熟悉 C++,但不需要深入了解 C++或有嵌入式系统经验。
这本书涵盖了什么
第一章《嵌入式系统基础》,定义了嵌入式系统是什么,它们与其他系统有何不同,为什么需要特定的编程技术,以及为什么 C++在嵌入式开发中是好的,在许多情况下是最佳选择。它概述了嵌入式开发人员在日常工作中遇到的约束和挑战:有限的系统资源和 CPU 性能,处理硬件错误和远程调试。
第二章《设置环境》,解释了嵌入式系统开发环境与 Web 或桌面应用程序开发的差异,并介绍了构建和目标系统、交叉编译和交叉工具包、串行控制台和远程 shell 的概念。它提供了为运行 Windows、macOS 或 Linux 的最常见桌面配置设置虚拟化构建和目标主机的实际步骤。
第三章《使用不同架构》,解释了如何在 C++代码中考虑目标系统的 CPU 架构和内存配置的重要差异。
第四章《处理中断》涵盖了中断和中断服务例程的低级概念。在现代操作系统中,即使是开发人员或设备驱动程序也必须使用操作系统提供的更高级别的 API。这就是为什么我们使用 8051 微控制器来探讨中断技术。
第五章《调试、日志记录和性能分析》涵盖了特定于基于 Linux 的嵌入式系统的调试技术,比如直接在目标板上运行 gdb、设置 gdbserver 进行远程调试,以及日志记录对于调试和故障根本原因分析的重要性。
第六章《内存管理》提供了几种内存分配的实用方法和最佳实践,对于嵌入式系统的开发人员将会很有帮助。我们讨论了为什么在嵌入式应用程序中要避免动态内存分配,以及可以考虑用于快速、确定性内存分配的替代方案。
第七章《多线程和同步》解释了如何使用 C++标准库提供的函数和类来实现高效的多线程应用程序,以充分利用现代多核 CPU 的所有性能。
第八章《通信和序列化》涵盖了进程间和系统间通信的概念、挑战和最佳实践,比如套接字、管道、共享内存以及使用 FlatBuffers 库进行内存高效序列化。将应用程序解耦为使用明确定义的异步协议相互通信的独立组件,是扩展软件系统的标准方式,同时保持其快速和容错性。
第九章《外围设备》解释了如何在 C++程序中使用各种外围设备。尽管大多数设备通信 API 不依赖于特定的编程语言,但我们将学习如何利用 C++的强大功能编写对开发人员方便并有助于防止常见资源泄漏错误的包装器。
第十章《降低功耗》探讨了编写节能应用程序和利用操作系统的功耗管理功能的最佳实践。它提供了几种适用于基于 Linux 的嵌入式系统的实用方法,但相同的概念也可以扩展到任何操作系统和任何平台。
第十一章《时间点和间隔》涵盖了与时间操作相关的各种主题,从测量间隔到添加延迟。我们将了解标准 C++ Chrono 库提供的 API,以及如何有效地使用它来构建可移植的嵌入式应用程序。
第十二章《错误处理和容错》探讨了用 C++编写的嵌入式应用程序的错误处理的可能实现和最佳实践。它解释了如何有效地使用 C++异常,并将其与传统错误代码和复杂返回类型等替代方案进行了比较。它涉及了基本的容错机制,如看门狗定时器和心跳。
第十三章《实时系统指南》涵盖了实时系统的具体内容。它简要描述了实时系统的定义以及存在哪些类型的实时系统。它包含了如何使应用程序的行为更加确定性的实用方法,这是实时系统的关键要求。
第十四章,安全关键系统的指南,解释了什么是安全关键系统,以及它们与其他嵌入式系统的不同之处。它涵盖了在开发安全关键系统时所需的开发方法和工具,从遵循形式化编码指南,如 MISRA、AUTOSAR 或 JSF,到使用静态代码分析或形式软件验证工具。
第十五章,微控制器编程,概述了为微控制器编写、编译和调试 C++代码的基本概念。我们将学习如何使用广泛使用的 Arduino 板来设置开发环境。
为了充分利用本书
嵌入式系统的开发意味着您的应用程序将与某种专用硬件进行交互——特定的 SoC 平台、特定的微控制器或特定的外围设备。有各种各样的可能硬件配置,以及需要与这些硬件设置一起工作的专用操作系统或集成开发环境。
本书的目标是让每个人都能开始学习嵌入式系统编程,而不需要在硬件上投入太多。这就是为什么大多数的示例都是针对在虚拟化的 Linux 环境或模拟器中工作。然而,有些示例可能需要物理硬件。这些示例被设计为在树莓派或 Arduino 上运行,这两种是最常用和价格相对便宜的平台。
本书涵盖的软件/硬件 | 操作系统要求 |
---|---|
Docker (www.docker.com/products/docker-desktop ) |
-
Microsoft Windows 10 专业版或企业版 64 位
-
macOS 10.13 或更新版本
-
Ubuntu Linux 16.04 或更新版本
-
Debian Linux Stretch(9)或 Buster(10)
-
Fedora Linux 30 或更新版本
|
QEMU (www.qemu.org/download/ ) |
---|
-
Windows 8 或更新版本(32 位或 64 位)
-
macOS 10.7 或更新版本
-
Linux(各种发行版)
|
树莓派 3 型 B+ | |
---|---|
Arduino UNO R3 或 ELEGOO UNO R3 |
如果您使用的是本书的数字版本,我们建议您自己输入代码或通过 GitHub 存储库访问代码(链接在下一节中提供)。这样做将有助于避免与复制和粘贴代码相关的任何潜在错误。
下载示例代码文件
您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support注册,直接将文件发送到您的邮箱。
您可以按照以下步骤下载代码文件:
-
在www.packt.com上登录或注册。
-
选择“支持”选项卡。
-
单击“代码下载”。
-
在搜索框中输入书名,然后按照屏幕上的说明操作。
文件下载后,请确保使用最新版本的以下软件解压缩文件夹:
-
Windows 上的 WinRAR/7-Zip
-
Mac 上的 Zipeg/iZip/UnRarX
-
Linux 上的 7-Zip/PeaZip
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Embedded-Programming-with-Modern-CPP-Cookbook
。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还提供来自丰富图书和视频目录的其他代码包,可在github.com/PacktPublishing/
上找到。请查看!
下载彩色图片
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:static.packt-cdn.com/downloads/9781838821043_ColorImages.pdf
。
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:"在gdbserver
下运行hello
应用程序。"
代码块设置如下:
#include <iostream>
int main() {
std::cout << "Hello, world!" << std::endl;
return 0;
}
当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:
#include <iostream>
int main() {
std::cout << "Hello, world!" << std::endl;
return 0;
}
任何命令行输入或输出都是这样写的:
$ docker run -ti -v $HOME/test:/mnt ubuntu:bionic
粗体:表示一个新术语、一个重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:"为了配置 CMake 的交叉编译,最好使用所谓的toolchain文件"
警告或重要说明看起来像这样。
提示和技巧看起来像这样。
章节
在本书中,您会经常看到几个标题(准备就绪、如何做...、它是如何工作的...、还有更多...和另请参阅)。
为了清晰地说明如何完成一个食谱,请按照以下各节使用:
准备就绪
本节告诉您在食谱中可以期待什么,并描述如何设置任何软件或食谱所需的任何初步设置。
如何做...
本节包含了遵循食谱所需的步骤。
它是如何工作的...
本节通常包括对前一节中发生的事情的详细解释。
还有更多...
本节包含了有关食谱的额外信息,以使您对食谱更加了解。
另请参阅
本节为食谱提供了其他有用信息的链接。
第一章:嵌入式系统基础
嵌入式系统是将硬件和软件组件结合起来解决更大系统或设备中的特定任务的计算机系统。与通用计算机不同,它们非常专业化和优化,只执行一个任务,但执行得非常出色。
它们无处不在,但我们很少注意到它们。您可以在几乎每个家用电器或小工具中找到它们,例如微波炉、电视机、网络附加存储或智能恒温器。您的汽车包含了几个相互连接的嵌入式系统,用于处理制动、燃油喷射和信息娱乐。
在本章中,我们将处理嵌入式系统的以下主题:
-
探索嵌入式系统
-
利用有限资源
-
性能影响
-
使用不同的架构
-
处理硬件错误
-
使用 C++进行嵌入式开发
-
远程部署软件
-
远程运行软件
-
日志记录和诊断
探索嵌入式系统
每个计算机系统都是为了解决更大系统或设备的特定问题而创建的嵌入式系统。即使您的通用 PC 或笔记本电脑也包含许多嵌入式系统。键盘、硬盘驱动器、网络卡或 Wi-Fi 模块——每个都是具有处理器(通常称为微控制器)和自己的软件(通常称为固件)的嵌入式系统。
现在让我们深入了解嵌入式系统的不同特性。
它们与桌面或 Web 应用程序有何不同?
与桌面或服务器相比,嵌入式系统最显著的特点是其紧密耦合的硬件和软件,专门用于完成特定任务。
嵌入式设备在各种物理和环境条件下工作。大多数嵌入式系统不是设计为仅在专用条件数据中心或办公室中工作。它们必须在无法控制的环境中正常工作,通常没有任何监督和维护。
由于它们是专业化的,硬件要求被精确计算,以尽可能地节约成本。因此,软件旨在利用可用资源的 100%,并且最小化或没有储备。
与常规桌面和服务器相比,嵌入式系统的硬件差异更大。每个系统的设计都是独特的。它们可能需要非常特定的 CPU 和将它们连接到存储器和外围硬件的电路图。
嵌入式系统旨在与外围硬件通信。嵌入式程序的主要部分是检查状态、读取输入、发送数据或控制外部设备。嵌入式系统通常没有用户界面。与在传统桌面或 Web 应用程序上进行相同操作相比,这使得开发、调试和诊断更加困难。
嵌入式系统类型
嵌入式系统涵盖了广泛的用例和技术,从用于自动驾驶或大规模存储系统的强大系统到用于控制灯泡或 LED 显示器的微型微控制器。
根据硬件的集成和专业化程度,嵌入式系统大致可以分为以下几类:
-
微控制器(MCUs)
-
片上系统(SoC)
-
特定应用集成电路(ASICs)
-
现场可编程门阵列(FPGAs)
微控制器
MCUs 是为嵌入式应用设计的通用集成电路。单个 MCU 芯片通常包含一个或多个 CPU、存储器和可编程输入/输出外设。它们的设计允许它们直接与传感器或执行器接口,而无需添加任何其他组件。
MCUs 广泛应用于汽车发动机控制系统、医疗设备、遥控器、办公设备、家用电器、电动工具和玩具。
它们的 CPU 从简单的 8 位处理器到更复杂的 32 位甚至 64 位处理器都有。
存在许多 MCUs;如今最常见的是以下几种:
-
英特尔 MCS-51 或 8051 MCU。
-
Atmel 的 AVR
-
来自 Microchip Technology 的可编程接口控制器(PIC)
-
各种基于 ARM 的 MCU
片上系统
SoC 是一种集成电路,它将解决特定类别问题所需的所有电子电路和部件集成在一个芯片上。
它可能包含数字、模拟或混合信号功能,取决于应用。在单个芯片中集成大多数电子部件的两个主要好处是:小型化和低功耗。与较少集成的硬件设计相比,SoC 需要明显更少的功耗。在硬件和软件层面对功耗的优化使其能够在没有外部电源的情况下工作数天、数月甚至数年。通常,它还集成了射频信号处理,加上其紧凑的物理尺寸,使其成为移动应用的理想解决方案。此外,SoC 通常用于汽车行业、可穿戴电子产品和物联网(IoT)。
图 1.1:树莓派 B+型号
树莓派系列单板计算机是基于 SoC 设计的系统的一个例子。B+型号建立在 Broadcom BCM2837B0 SoC 之上,具有集成的四核 1.4 GHz 基于 ARM 的 CPU,1 GB 内存,网络接口控制器和四个以太网接口。
该板具有四个 USB 接口,MicroSD 卡插槽用于引导操作系统和存储数据,以太网和 Wi-Fi 网络接口,HDMI 视频输出,以及一个 40 针 GPIO 头,用于连接自定义外围硬件。
它配备了 Linux 操作系统,是教育和 DIY 项目的绝佳选择。
特定应用集成电路
特定应用集成电路(ASICs)是由制造商定制用于特定用途的集成电路。定制是一个昂贵的过程,但允许它们满足通常对通用硬件解决方案不可行的要求。例如,现代高效的比特币矿工通常建立在专用 ASIC 芯片之上。
为了定义 ASIC 的功能,硬件设计师使用硬件描述语言之一,如 Verilog 或 VHDL。
现场可编程门阵列
与 SoCs、ASICs 和 MCUs 不同,现场可编程门阵列(FPGAs)是半导体器件,可以在制造后在硬件级别上重新编程。它们基于一组可配置逻辑块(CLBs),通过可编程互连连接。开发人员可以根据自己的需求编程互连以执行特定功能。FPGA 使用硬件定义语言(HDL)进行编程。它允许实现任何数字功能的组合,以便快速高效地处理大量数据。
使用有限资源
人们普遍错误地认为嵌入式系统是基于比常规台式机或服务器硬件慢得多的硬件。尽管这通常是情况,但并非总是如此。
一些特定的应用可能需要大量的计算能力或大量的内存。例如,自动驾驶需要处理来自各种传感器的大量数据,使用实时 AI 算法需要内存和 CPU 资源。另一个例子是利用大量内存和资源进行数据缓存、复制和加密的高端存储系统。
在任何情况下,嵌入式系统硬件都设计成最小化整个系统的成本。对嵌入式系统的软件工程师来说,资源是稀缺的。他们被期望利用所有可用资源,并严肃对待性能和内存优化。
考虑性能影响
大多数嵌入式应用都针对性能进行了优化。如前所述,目标 CPU 被选择为成本效益高,开发人员会提取其所有的计算能力。另一个因素是与外围硬件的通信。这通常需要精确和快速的反应时间。因此,对于像 Python 或 Java 这样的脚本、可解释、字节码语言,只有有限的空间。大多数嵌入式程序都是用编译成本机代码的语言编写的,主要是 C 和 C++。
为了实现最大性能,嵌入式程序利用编译器的所有性能优化能力。现代编译器在代码优化方面非常出色,以至于它们可以胜过由熟练开发人员用汇编语言编写的代码。
然而,工程师不能仅仅依赖编译器提供的性能优化。为了实现最大效率,他们必须考虑目标平台的具体情况。通常用于在 x86 平台上运行的桌面或服务器应用程序的编码实践,对于 ARM 或 MIPS 等不同架构可能是低效的。利用目标架构的特定特性通常会显著提高程序的性能。
与不同架构一起工作
桌面应用程序的开发人员通常很少关注硬件架构。首先,他们经常使用高级编程语言,隐藏了这些复杂性,但牺牲了一些性能。其次,在大多数情况下,他们的代码在 x86 架构上运行,并且他们经常认为其特性是理所当然的。例如,他们可能假设int
的大小是32
位,这在许多情况下是不正确的。
嵌入式开发人员处理更广泛的架构。即使他们不是用目标平台本地的汇编语言编写代码,他们也应该意识到所有 C 和 C++基本类型都依赖于架构;标准只保证int
至少是16
位。他们还应该了解特定架构的特性,如字节序和对齐,并考虑到浮点数或 64 位数字的操作,在 x86 架构上相对便宜,但在其他架构上可能更昂贵。
字节序
字节序定义了表示大数值的字节在内存中存储的顺序。
有两种字节序:
- 大端:最重要的字节被首先存储。
0x01020304
32 位值存储在ptr
地址如下:
内存中的偏移 | 值 |
---|---|
ptr |
0x01 |
ptr + 1 |
0x02 |
ptr + 2 |
0x03 |
ptr + 3 |
0x04 |
大端架构的例子包括 AVR32 和 Motorola 68000。
- 小端:最不重要的字节被首先存储。
0x01020304
32 位值存储在ptr
地址如下:
内存中的偏移 | 值 |
---|---|
ptr |
0x04 |
ptr + 1 |
0x03 |
ptr + 2 |
0x02 |
ptr + 3 |
0x01 |
x86 架构是小端的。
- 双端:硬件支持可切换的字节序。一些例子是 PowerPC、ARMv3 和前面的例子。
字节序在与其他系统交换数据时特别重要。如果开发人员按原样发送0x01020304
32 位整数,如果接收者的字节序与发送者的字节序不匹配,它可能被读取为0x04030201
。这就是为什么数据应该进行序列化。
这段 C++代码可以用来确定系统的字节序:
#include <iostream>
int main() {
union {
uint32_t i;
uint8_t c[4];
} data;
data.i = 0x01020304;
if (data.c[0] == 0x01) {
std::cout << "Big-endian" << std::endl;
} else {
std::cout << "Little-endian" << std::endl;
}
}
对齐
处理器不是按字节而是按内存字来读写数据——与其数据地址大小匹配的块。32 位处理器使用 32 位字,64 位处理器使用 64 位字,依此类推。
当字对齐时,读写是最有效的——数据地址是字大小的倍数。例如,对于 32 位架构,0x00000004
地址是对齐的,而0x00000005
是不对齐的。
编译器会自动对齐数据以实现最有效的数据访问。当涉及到结构时,对于不了解对齐的开发人员来说,结果可能会令人惊讶:
struct {
uint8_t c;
uint32_t i;
} a = {1, 1};
std::cout << sizeof(a) << std::endl;
前面的代码片段的输出是什么?uint8_t
的大小是1
,而uint32_t
的大小是4
。开发人员可能期望结构的大小是各个部分大小的总和。然而,结果高度取决于目标架构。
对于 x86,结果是8
。让我们在i
之前再添加一个uint8_t
字段:
struct {
uint8_t c;
uint8_t cc;
uint32_t i;
} a = {1, 1};
std::cout << sizeof(a) << std::endl;
结果仍然是8
!编译器通过添加填充字节根据对齐规则优化结构内数据字段的放置。这些规则是与架构相关的,对于其他架构,结果可能会有所不同。因此,结构不能在两个不同系统之间直接交换,而需要进行序列化,这将在第八章中更详细地解释,即通信和序列化。
除了 CPU,访问数据对齐对于通过硬件地址转换机制进行有效的内存映射也是至关重要的。现代操作系统使用 4 KB 内存块或页面来将进程虚拟地址空间映射到物理内存。将数据结构对齐到 4 KB 边界可以提高性能。
固定宽度整数类型
C 和 C++开发人员经常忘记基本数据类型(如char
、short
或int
)的大小是与架构相关的。为了使代码具有可移植性,嵌入式开发人员经常使用明确指定数据字段大小的固定大小整数类型。
最常用的数据类型如下:
宽度 | 有符号 | 无符号 |
---|---|---|
8 位 | int8_t |
uint8_t |
16 位 | int16_t |
uint16_t |
32 位 | int32_t |
uint32_t |
指针大小也取决于架构。开发人员经常需要访问数组的元素,由于数组在内部表示为指针,偏移量表示取决于指针大小。size_t
是一种特殊的数据类型,以一种与架构无关的方式表示偏移量和数据大小。
处理硬件错误
嵌入式开发人员工作的重要部分是处理硬件。与大多数应用程序开发人员不同,嵌入式开发人员不能依赖硬件。硬件因不同原因而失败,嵌入式开发人员必须区分纯粹的软件故障和由硬件故障或故障引起的软件故障。
早期版本的硬件
嵌入式系统基于专门设计和制造用于特定用例的专用硬件。这意味着在为嵌入式系统开发软件时,其硬件尚未稳定且经过充分测试。当软件开发人员在其代码行为中遇到错误时,这并不一定意味着存在软件错误,而可能是由于不正确工作的硬件引起的。
很难对这些问题进行分类。它们需要知识、直觉,有时需要使用示波器来将问题的根本原因缩小到硬件层面。
硬件是不可靠的
硬件本质上是不可靠的。每个硬件组件都有失败的可能性,开发人员应该意识到硬件随时可能出现故障。由于内存故障,存储在内存中的数据可能会损坏。由于外部噪音,通过通信渠道传输的消息可能会被更改。
嵌入式开发人员已经为这些情况做好了准备。他们使用校验和或循环冗余检查(CRC)码来检测并在可能的情况下纠正损坏的数据。
环境条件的影响
高温、低温、高湿度、振动、灰尘和其他环境因素都会显著影响硬件的性能和可靠性。虽然开发人员设计他们的软件来处理所有潜在的硬件错误,但在不同的环境中测试系统是常见的做法。此外,了解环境条件可以在解决问题的根本原因分析时提供重要线索。
在嵌入式开发中使用 C++
多年来,绝大多数嵌入式项目都是使用 C 编程语言开发的。这种语言非常适合嵌入式软件开发人员的需求。它提供了功能丰富和方便的语法,但与此同时,它相对低级,并且不会向开发人员隐藏平台特定的细节。
由于其多功能性、紧凑性和编译代码的高性能,它成为了嵌入式世界中的事实标准开发语言。C 语言的编译器存在于大多数,如果不是所有的架构中;它们被优化为生成比手动编写的机器代码更有效的代码。
随着嵌入式系统的复杂性不断增加,开发人员面临 C 语言的限制,其中最显著的是容易出错的资源管理和缺乏高级抽象。在 C 中开发复杂的应用程序需要大量的工作和时间。
与此同时,C++在不断发展,获得新的功能,并采用使其成为现代嵌入式系统开发人员的最佳选择的编程技术。这些新功能和技术如下:
-
你不用为你不使用的东西付费。
-
面向对象编程来处理代码复杂性。
-
资源获取即初始化(RAII)。
-
异常。
-
强大的标准库。
-
线程和内存模型作为语言规范的一部分。
你不用为你不使用的东西付费
C++的座右铭之一是你不用为你不使用的东西付费。这种语言比 C 语言还要多很多功能,但对于那些不被使用的功能,它承诺零开销。
例如,虚函数:
#include <iostream>
class A {
public:
void print() {
std::cout << "A" << std::endl;
}
};
class B: public A {
public:
void print() {
std::cout << "B" << std::endl;
}
};
int main() {
A* obj = new B;
obj->print();
}
尽管obj
指向B
类的对象,上面的代码将输出A
。为了使其按预期工作,开发人员添加了一个关键字——virtual
:
#include <iostream>
class A {
public:
virtual void print() {
std::cout << "A" << std::endl;
}
};
class B: public A {
public:
void print() {
std::cout << "B" << std::endl;
}
};
int main() {
A* obj = new B;
obj->print();
}
在这个改变之后,代码输出B
,这是大多数开发人员期望得到的结果。你可能会问为什么 C++不默认强制每个方法都是virtual
。这种方法是 Java 采用的,似乎没有任何不利之处。
原因是virtual
函数并不是免费的。函数解析是通过虚拟表在运行时执行的——这是一个函数指针数组。它会给函数调用时间增加一点开销。如果你不需要动态多态性,你就不用为它付费。这就是为什么 C++开发人员添加virtual
关键字,以明确同意会增加性能开销的功能。
面向对象编程来处理代码复杂性
随着嵌入式程序的复杂性随着时间的推移而增长,使用 C 语言提供的传统过程化方法来管理它们变得越来越困难。如果你看一下一个大型的 C 项目,比如 Linux 内核,你会发现它采用了许多面向对象编程的方面。
Linux 内核广泛使用封装,隐藏实现细节,并使用 C 结构提供对象接口。
虽然在 C 中编写面向对象的代码是可能的,但在 C++中进行这样的操作要容易得多,也更方便,因为编译器为开发人员做了所有繁重的工作。
资源获取即初始化
嵌入式开发人员经常使用操作系统提供的资源:内存、文件和网络套接字。C 开发人员使用 API 函数对资源进行获取和释放;例如,使用malloc
来申请一块内存,使用free
将其返回给系统。如果开发人员因某种原因忘记调用free
,这块内存就会泄漏。内存泄漏或资源泄漏通常是 C 编写的应用程序中的常见问题:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
int AppendString(const char* str) {
int fd = open("test.txt", O_CREAT|O_RDWR|O_APPEND);
if (fd < 0) {
printf("Can't open file\n");
return -1;
}
size_t len = strlen(str);
if (write(fd, str, len) < len) {
printf("Can't append a string to a file\n");
return -1;
}
close(fd);
return 0;
}
上述代码看起来是正确的,但它包含了几个严重的问题。如果write
函数返回错误或写入的数据少于请求的数据(这是正确的行为),AppendString
函数会记录错误并返回。然而,如果它忘记关闭文件描述符,就会发生内存泄漏。随着时间的推移,越来越多的文件描述符泄漏,最终程序达到打开文件描述符的限制,导致所有对open
函数的调用失败。
C++提供了一个强大的编程习惯,可以防止资源泄漏:RAII。资源在对象构造函数中分配,在对象析构函数中释放。这意味着只有在对象存活时才持有资源。当对象被销毁时,资源会自动释放:
#include <fstream>
void AppendString(const std::string& str) {
std::ofstream output("test.txt", std::ofstream::app);
if (!output.is_open()){
throw std::runtime_error("Can't open file");
}
output << str;
}
请注意,此函数不会显式调用close
。文件在输出对象的析构函数中关闭,当AppendString
函数返回时会自动调用该析构函数。
异常
传统上,C 开发人员使用错误代码来处理错误。这种方法需要程序员的大量注意力,并且是 C 程序中难以找到的错误的不断来源。很容易忽略或忽视缺少检查返回代码的情况,掩盖了错误:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <iostream>
#include <fstream>
char read_last_byte(const char* filename) {
char result = 0;
int fd = open(filename, O_RDONLY);
if (fd < 0) {
printf("Can't open file\n");
return -1;
}
lseek(fd, -1, SEEK_END);
size_t s = read(fd, &result, sizeof(result));
if (s != sizeof(result)) {
printf("Can't read from file: %lu\n", s);
close(fd);
return -1;
}
close(fd);
return result;
}
上述代码至少有两个与错误处理相关的问题。首先,未检查lseek
函数调用的结果。如果lseek
返回错误,函数将无法正确工作。第二个问题更微妙,但更重要且更难修复。read_last_byte
函数返回-1
表示错误,但它也是一个字节的有效值。无法区分文件的最后一个字节是0xFF
还是函数遇到了错误。为了正确处理这种情况,函数接口应重新定义如下:
int read_last_byte(const char* filename, char* result);
在发生错误的情况下,函数返回-1
,否则返回0
。结果存储在通过引用传递的char
变量中。虽然这个接口是正确的,但对开发人员来说并不像原来的接口那样方便。
一个最终会随机崩溃的程序可能被认为是这类错误的最佳结果。如果它继续工作,悄悄地损坏数据或生成不正确的结果将更糟。
除此之外,实现逻辑的代码和负责错误检查的代码交织在一起。代码变得难以阅读和理解,结果更容易出错。
尽管开发人员仍然可以继续使用返回代码,但现代 C++中错误处理的推荐方式是异常。正确设计和正确使用异常显著减少了错误处理的复杂性,使代码更易读和更健壮。
使用异常编写的相同函数在 C++中看起来更加清晰:
char read_last_byte2(const char* filename) {
char result = 0;
std::fstream file;
file.exceptions (
std::ifstream::failbit | std::ifstream::badbit );
file.open(filename);
file.seekg(-1, file.end);
file.read(&result, sizeof(result));
return result;
}
强大的标准库
C++带有功能丰富且强大的标准库。许多以前需要 C 开发人员使用第三方库的函数现在已经成为标准 C++库的一部分。这意味着更少的外部依赖,更稳定和可预测的行为,以及在硬件架构之间的更好可移植性。
C++标准库提供了建立在最常用的数据结构(如数组、二叉树和哈希表)之上的容器。这些容器是通用的,有效地满足了开发人员日常需求的大部分。开发人员不需要花费时间和精力创建自己的基本数据结构的实现,这通常容易出错。
容器被精心设计,以最小化对显式资源、分配或释放的需求,从而大大降低了内存或其他系统资源泄漏的可能性。
标准库还提供许多标准算法,如find
、sort
、replace
、二进制搜索、集合操作和排列。这些算法可以应用于任何公开的集成器接口的容器。结合标准容器,它们帮助开发人员专注于高级抽象,并在经过充分测试的功能之上构建它们,而只需最少量的额外代码。
线程和内存模型作为语言规范的一部分
C++11 标准引入了一个内存模型,清楚地定义了 C++程序在多线程环境中的行为。
对于 C 语言规范,内存模型不在范围内。语言本身不知道线程或并行执行语义。这取决于第三方库,例如 pthread,提供多线程应用程序所需的所有支持。
早期版本的 C++遵循了相同的原则。多线程不在语言规范的范围内。然而,支持指令重排序的多管线现代 CPU 需要编译器更确定的行为。
因此,C++的现代规范明确定义了线程类、各种类型的锁和互斥锁、条件变量和原子变量。这为嵌入式开发人员提供了一个强大的工具包,用于设计和实现能够利用现代多核 CPU 所有功能的应用程序。由于工具包是语言规范的一部分,这些应用程序具有确定的行为,并且可移植到所有支持的架构。
远程部署软件
嵌入式系统的软件部署通常是一个复杂的过程,应该经过精心设计、实施和测试。有两个主要挑战:
-
嵌入式系统通常部署在人类操作员难以或不切实际访问的地方。
-
如果软件部署失败,系统可能无法运行。这将需要技术熟练的技术人员和额外的工具来进行恢复。这是昂贵的,而且通常是不可能的。
连接到互联网的嵌入式系统的第一个挑战的解决方案是OTA(Over-the-Air)更新。系统定期连接到专用服务器,检查是否有可用的更新。如果找到软件的更新版本,它将被下载到设备并安装到持久内存中。
这种方法被智能手机、机顶盒、智能电视和连接到互联网的游戏机制造商广泛采用。
在设计 OTA 更新时,系统架构师应考虑影响整体解决方案的许多因素。例如,如果所有设备几乎同时检查更新,会在更新服务器上创建高峰负载,同时让它们在其他时间处于空闲状态。随机化检查时间可以使负载均匀分布。目标系统应设计为保留足够的持久内存以下载完整的更新映像,然后应用它。实现更新软件映像下载的代码应处理网络连接中断,并在连接恢复后恢复下载,而不是重新开始。OTA 更新的另一个重要因素是安全性。更新过程应仅接受真实的更新映像。更新由制造商进行加密签名,只有在设备上运行的安装程序接受签名匹配的映像。
嵌入式系统的开发人员知道更新可能因不同原因而失败;例如,在更新过程中断电。即使更新成功完成,新版本的软件可能不稳定,并在启动时崩溃。预期即使在这种情况下,系统也能够恢复。
这是通过分离主要软件组件和引导加载程序来实现的。引导加载程序验证主要组件的一致性,例如包含所有可执行文件、数据和脚本的操作系统内核和根文件系统。然后,它尝试运行操作系统。在失败的情况下,它切换到先前的版本,该版本应与新版本一起保存在持久内存中。硬件看门狗定时器用于检测和防止软件更新导致系统挂起的情况。
在软件开发和测试过程中使用 OTA 或完整的镜像重新刷写是不切实际的。它会显著减慢开发过程。工程师使用其他方式将他们的软件构建部署到开发系统,例如远程外壳或允许开发人员工作站和目标板之间共享文件的网络文件系统。
远程运行软件
嵌入式系统旨在使用特定的硬件和软件组件组合解决特定问题。这就是为什么系统中的所有软件组件都经过定制以实现这个目标。所有非必要的东西都被禁用,所有定制软件都集成到引导序列中。
用户不启动嵌入式程序;它们在系统启动时启动。然而,在开发过程中,工程师需要在不重新启动系统的情况下运行他们的应用程序。
这取决于目标平台的类型而有所不同。对于基于 SoC 并运行像 Linux 这样的抢占式多任务操作系统的足够强大的系统,可以使用远程 shell 来实现。
现代系统通常使用安全外壳(SSH)作为远程外壳。目标系统运行一个等待传入连接的 SSH 守护程序。开发人员使用客户端 SSH 程序,如 Linux 中的 SSH 或 Windows 中的 PuTTY,连接到目标系统以访问目标系统。一旦连接,他们可以像在本地计算机上一样使用嵌入式板上的 Linux shell 进行工作。
远程运行程序的常见工作流程如下:
-
使用交叉编译工具包在本地系统中构建可执行程序。
-
使用
scp
工具将其复制到远程系统。 -
使用 SSH 连接到远程系统,并从命令行运行可执行文件。
-
使用相同的 SSH 连接,分析程序输出。
-
当程序终止或被开发人员中断时,将其日志取回开发人员的工作站进行深入分析。
MCU 没有足够的资源来运行远程 shell。开发人员通常直接将编译后的代码上传到平台内存,并从特定的内存地址启动代码执行。
日志记录和诊断
日志记录和诊断是任何嵌入式项目的重要方面。
在许多情况下,使用交互式调试器是不可能或不切实际的。硬件状态可能在几毫秒内发生变化。程序在断点上停止后,开发人员没有足够的时间来分析它。收集详细的日志数据并使用工具进行分析和可视化是高性能、多线程、时间敏感的嵌入式系统的更好方法。
由于在大多数情况下资源是有限的,开发人员经常不得不做出权衡。一方面,他们需要收集尽可能多的数据来确定故障的根本原因——无论是软件还是硬件,故障发生时硬件组件的状态,以及系统处理的硬件和软件事件的准确时间。另一方面,日志可用空间有限,每次写日志都会影响整体性能。
解决方案是在设备上本地缓冲日志数据,并将其发送到远程系统进行详细分析。
这种方法对于嵌入式软件的开发效果很好。然而,部署系统的诊断需要更复杂的技术。
许多嵌入式系统脱机工作,不提供方便的内部日志访问。开发人员需要仔细设计和实施其他诊断和报告方式。如果系统没有显示器,LED 指示灯或蜂鸣器通常用于编码各种错误条件。它们足以提供有关故障类别的信息,但在大多数情况下无法提供必要的细节以确定根本原因。
嵌入式设备具有专用的诊断模式,用于测试硬件组件。在上电后,几乎任何设备或电器都会执行上电自检(POST),对硬件进行快速测试。这些测试应该快速进行,不涵盖所有测试场景。这就是为什么许多设备都有隐藏的服务模式,可以由开发人员或现场工程师激活,以执行更彻底的测试。
总结
在本章中,我们讨论了嵌入式软件的高级概述,以及它的不同之处,还了解了为什么以及如何在这个领域高效地使用 C++。
第二章:设置环境
要开始使用嵌入式系统,我们需要设置一个环境。与我们用于桌面开发的环境不同,嵌入式编程的环境需要两个系统:
-
构建系统:用于编写代码的系统
-
目标系统:您的代码将在其上运行的系统
在本章中,我们将学习如何设置这两个系统并将它们连接在一起。构建系统的配置可能会有很大的差异——可能有不同的操作系统、编译器和集成开发环境。目标系统配置的差异甚至更大,因为每个嵌入式系统都是独特的。此外,虽然您可以使用笔记本电脑或台式机作为构建系统,但您确实需要某种嵌入式板作为目标系统。
不可能涵盖所有可能的构建和目标系统的组合。相反,我们将学习如何使用一个流行的配置:
-
Ubuntu 18.04 作为构建系统
-
树莓派作为目标系统
我们将使用 Docker 在笔记本电脑或台式机上的虚拟环境中运行 Ubuntu。Docker 支持 Windows、macOS 和 Linux,但如果您已经使用 Linux,可以直接使用它,而无需在其上运行容器。
我们将使用Quick EMUlator(QEMU)来模拟树莓派板。这将教会我们如何在没有真实硬件访问权限的情况下为嵌入式板构建应用程序。在模拟环境中进行开发的初始阶段是常见的,在许多情况下,这是唯一可能的实际解决方案,因为在软件开发开始时,目标硬件可能不可用。
本章将涵盖以下主题:
-
在 Docker 容器中设置构建系统
-
使用模拟器
-
交叉编译
-
连接到嵌入式系统
-
调试嵌入式应用程序
-
使用 gdbserver 进行远程调试
-
使用 CMake 作为构建系统
在 Docker 容器中设置构建系统
在这个步骤中,我们将设置一个 Docker 容器,在您的台式机或笔记本电脑上运行 Ubuntu 18.04。无论您的机器上运行什么操作系统,Docker 都支持 Windows、macOS 和 Linux。作为这个步骤的结果,您将在主机操作系统中运行一个统一的、虚拟化的 Ubuntu Linux 构建系统。
如果您的操作系统已经运行 Ubuntu Linux,请随时跳到下一个步骤。
操作步骤如下...
我们将在笔记本电脑或台式机上安装 Docker 应用程序,然后使用 Ubuntu 的现成镜像在虚拟环境中运行这个操作系统:
-
在您的网络浏览器中,打开以下链接并按照说明为您的操作系统设置 Docker:
-
对于 Windows:
docs.docker.com/docker-for-windows/install/
-
打开一个终端窗口(Windows 中的命令提示符,macOS 中的终端应用程序)并运行以下命令以检查是否已正确安装:
$ docker --version
- 运行此命令使用 Ubuntu 镜像:
$ docker pull ubuntu:bionic
- 创建一个工作目录。在 macOS、Linux shell 或 Windows PowerShell 中运行以下命令:
$ mkdir ~/test
- 现在,在容器中运行下载的镜像:
$ docker run -ti -v $HOME/test:/mnt ubuntu:bionic
- 接下来,运行
uname -a
命令以获取有关系统的信息:
# uname -a
您现在处于一个虚拟的 Linux 环境中,我们将在本书的后续步骤中使用它。
它是如何工作的...
在第一步中,我们安装了 Docker——一个虚拟化环境,允许在 Windows、macOS 或 Linux 上运行一个隔离的 Linux 操作系统。这是一种方便的方式,可以统一地封装所使用的任何操作系统所需的所有库和程序,以便分发和部署容器。
安装 Docker 后,运行一个快速命令来检查是否已正确安装:
检查安装后,我们需要从 Docker 存储库中获取现成的 Ubuntu 镜像。Docker 镜像有标签;我们可以使用bionic
标签来找到 Ubuntu 18.04 版本:
镜像下载需要时间。一旦镜像被获取,我们可以创建一个目录,用于开发。目录内容将在您的操作系统和在 Docker 中运行的 Linux 之间共享。这样,您可以使用您喜欢的文本编辑器来编写代码,但仍然可以使用 Linux 构建工具将代码编译成二进制可执行文件。
然后,我们可以使用第 4 步中获取的 Ubuntu 镜像启动 Docker 容器。选项-v $HOME/test:/mnt
命令行使第 5 步中创建的文件夹对 Ubuntu 可见,作为/mnt
目录。这意味着您在~/test
目录中创建的所有文件都会自动出现在/mnt
中。-ti
选项使容器交互,让您访问 Linux shell 环境(bash):
最后,我们对.uname
容器进行了快速的健全性检查,它显示了有关 Linux 内核的信息,如下所示:
尽管您的内核确切版本可能不同,但我们可以看到我们正在运行 Linux,我们的架构是x86
。这意味着我们已经设置了我们的构建环境,我们将能够以统一的方式编译我们的代码,无论计算机上运行的操作系统是什么。但是,我们仍然无法运行编译后的代码,因为我们的目标架构是Acorn RISC Machines(ARM),而不是x86
。我们将在下一个步骤中学习如何设置模拟的 ARM 环境。
还有更多...
Docker 是一个功能强大且灵活的系统。此外,其存储库包含许多包含对大多数开发人员有用的工具的现成镜像。
访问hub.docker.com/search?q=&type=image
并浏览最受欢迎的镜像。您还可以使用关键字搜索镜像,例如嵌入式。
使用模拟器
并非总是可能或实际使用真实的嵌入式板—硬件尚未准备好,或板的数量有限。模拟器帮助开发人员使用尽可能接近目标系统的环境,但不依赖于硬件可用性。这也是开始学习嵌入式开发的最佳方式。
在本教程中,我们将学习如何设置 QEMU(硬件模拟器)并配置它以模拟运行 Debian Linux 的基于 ARM 的嵌入式系统。
如何做...
我们需要一个虚拟环境,与 Docker 不同,它可以模拟具有与计算机架构不同的处理器的处理器:
-
转到
www.qemu.org/download/
,并单击与您的操作系统匹配的选项卡—Linux、macOS 或 Windows—,然后按照安装说明进行操作。 -
创建一个测试目录,除非已经存在:
$ mkdir -p $HOME/raspberry
- 下载以下文件并复制到您在上一步中创建的
~/raspberry
目录中:
-
Raspbian Lite zip 存档:
downloads.raspberrypi.org/raspbian_lite/images/raspbian_lite-2019-07-12/2019-07-10-raspbian-buster-lite.zip
-
内核镜像:
github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/kernel-qemu-4.14.79-stretch
-
设备树 blob:
github.com/dhruvvyas90/qemu-rpi-kernel/raw/master/versatile-pb.dtb
-
将目录更改为
~/raspberry
并提取在上一步中下载的 Raspbian Lite zip 存档。它包含一个名为2019-07-10-raspbian-buster-lite.img
的单个文件。 -
打开一个终端窗口并运行 QEMU。对于 Windows 和 Linux,命令行如下:
$ qemu-system-arm -M versatilepb -dtb versatile-pb.dtb -cpu arm1176 -kernel kernel-qemu-4.14.79-stretch -m 256 -drive file=2019-07-10-raspbian-buster-lite.img,format=raw -append "rw console=ttyAMA0 rootfstype=ext4 root=/dev/sda2 loglevel=8" -net user,hostfwd=tcp::22023-:22,hostfwd=tcp::9090-:9090 -net nic -serial stdio
-
应该出现一个新窗口,显示 Linux 引导过程。几秒钟后,将显示登录提示。
-
使用
pi
作为用户名和raspberry
作为密码登录。然后,输入以下命令:
# uname -a
- 检查命令的输出。它指示我们的系统架构是
ARM
,而不是x86
。现在我们可以使用这个环境来测试为 ARM 平台构建的应用程序。
它是如何工作的...
在第一步中,我们安装了 QEMU 模拟器。没有可加载的代码映像,这个虚拟机没有太多用处。然后,我们可以获取运行 Linux 操作系统所需的三个映像:
-
Linux 根文件系统:包含 Raspbian Linux 的快照,用于树莓派设备
-
Linux 内核
-
设备树 blob:包含系统的硬件组件描述
一旦所有镜像都被获取并放入~/raspberry
目录中,我们就运行 QEMU,提供镜像路径作为命令行参数。此外,我们配置虚拟网络,这使我们能够从本机环境连接到虚拟环境中运行的 Linux 系统。
QEMU 启动后,我们可以看到一个带有 Linux 登录提示的窗口:
登录系统后,我们可以通过运行uname
命令进行快速健全性检查:
类似于我们在上一个配方中运行的健全性检查,在 Docker 容器中设置构建系统,这表明我们正在运行 Linux 操作系统,但在这种情况下,我们可以看到目标架构是ARM
。
还有更多...
QEMU 是一个强大的处理器模拟器,支持除 x86 和 ARM 之外的其他多种架构,如 PowerPC、SPARC64、SPARC32 和无锁流水级阶段微处理器(MIPS)。使其如此强大的一个方面是其灵活性,由于其许多配置选项。转到qemu.weilnetz.de/doc/qemu-doc.html
根据您的需求配置 QEMU。
微控制器供应商通常也提供模拟器和仿真器。在开始为特定硬件进行开发时,请检查可用的仿真选项,因为这可能会显着影响开发时间和精力。
交叉编译
我们已经知道嵌入式开发环境由两个系统组成:构建系统,您在其中编写和构建代码,以及运行代码的主机系统。
我们现在有两个虚拟化环境:
-
在 Docker 容器中的 Ubuntu Linux,这将是我们的构建系统
-
运行 Raspbian Linux 的 QEMU,这将是我们的主机系统
- 在这个配方中,我们将设置构建 Linux 应用程序所需的交叉编译工具,并构建一个简单的Hello, world!应用程序来测试设置。
做好准备
要设置交叉编译工具包,我们需要使用我们在Docker 容器中设置构建系统配方中设置的 Ubuntu Linux。
我们还需要~/test
目录来在我们的操作系统和 Ubuntu 容器之间交换我们的源代码。
如何做...
让我们首先创建一个简单的 C++程序,我们希望为我们的目标平台进行编译:
-
在
~/test
目录中创建一个名为hello.cpp
的文件。 -
使用您喜欢的文本编辑器将以下代码片段添加到其中:
#include <iostream>
int main() {
std::cout << "Hello, world!" << std::endl;
return 0;
}
-
现在我们有了
Hello, world!
程序的代码,我们需要编译它。 -
切换到 Ubuntu(我们的构建系统)控制台。
-
通过运行以下命令获取可用于安装的软件包的最新列表:
# apt update -y
- 从 Ubuntu 服务器获取软件包描述需要一些时间。运行以下命令安装交叉编译工具:
# apt install -y crossbuild-essential-armel
- 您将看到一个要安装的包的长列表。按Y确认安装。作为健全性检查,运行一个没有参数的交叉编译器:
# arm-linux-gnueabi-g++
- 更改目录到
/mnt
# cd /mnt
- 我们在第 1 步中创建的
hello.cpp
文件位于这里。现在让我们来构建它:
# arm-linux-gnueabi-g++ hello.cpp -o hello
-
这个命令生成一个名为
hello
的可执行文件。您可能想知道为什么它没有任何扩展名。在 Unix 系统中,扩展名是完全可选的,二进制可执行文件通常没有任何扩展名。尝试运行文件。它应该会出现错误。 -
让我们使用
file
工具生成关于可执行二进制文件的详细信息。
它是如何工作的...
在第一步中,我们创建了一个简单的Hello, World! C++程序。我们将其放入~/test
目录中,这样它就可以从运行 Linux 的 Docker 容器中访问。
要构建源代码,我们切换到了 Ubuntu shell。
如果我们尝试运行标准的 Linux g++编译器来构建它,我们将得到一个用于构建平台的可执行文件,即 x86。然而,我们需要一个用于 ARM 平台的可执行文件。为了构建它,我们需要一个可以在 x86 上运行的编译器版本,构建 ARM 代码。
作为预备步骤,我们需要更新 Ubuntu 软件包分发中可用软件包的信息:
我们可以通过运行apt-get install crossbuild-essential-armel
来安装这个编译器以及一组相关工具:
在第 9 步进行的快速健全性检查表明它已正确安装:
现在,我们需要使用交叉编译器构建hello.cpp
。它为 ARM 平台生成可执行文件,这就是为什么我们在第 12 步中尝试在构建系统中运行它失败的原因。
为了确保它确实是一个 ARM 可执行文件,我们需要运行file
命令。其输出如下:
如您所见,该二进制文件是为 ARM 平台构建的,这就是为什么它无法在构建系统上运行的原因。
还有更多...
许多交叉编译工具包适用于各种架构。其中一些可以在 Ubuntu 存储库中轻松获得;一些可能需要手动安装。
连接到嵌入式系统
在使用交叉编译器在构建系统上构建嵌入式应用程序之后,应将其传输到目标系统。在基于 Linux 的嵌入式系统上,最好的方法是使用网络连接和远程 shell。安全外壳(SSH)由于其安全性和多功能性而被广泛使用。它不仅允许您在远程主机上运行 shell 命令,还允许您使用加密和基于密钥的身份验证从一台机器复制文件到另一台机器。
在这个教程中,我们将学习如何使用安全拷贝将应用程序二进制文件复制到模拟的 ARM 系统中,使用 SSH 连接到它,并在 SSH 中运行可执行文件。
准备就绪
我们将使用我们在使用模拟器教程中设置的树莓派模拟器作为目标系统。此外,我们需要我们的 Ubuntu 构建系统和我们在交叉编译教程中构建的可执行文件hello
。
如何做...
我们将通过网络访问我们的目标系统。QEMU 为模拟机提供了一个虚拟网络接口,我们可以在不连接到真实网络的情况下使用它。为了这样做,我们需要找出一个要使用的 IP 地址,并确保 SSH 服务器在我们的虚拟环境中运行:
在您的本机操作系统环境中,找出您的机器的 IP 地址。打开一个终端窗口或 PowerShell。在 macOS 或 Linux 上运行ifconfig
,或在 Windows 上运行ipconfig
,并检查其输出。
在接下来的步骤中,我们将使用192.168.1.5
作为模板 IP 地址;您需要用您的实际 IP 地址替换它。
- 切换到树莓派模拟器并通过运行以下命令启用 SSH 服务:
$ sudo systemctl start ssh
- 切换到 Ubuntu 窗口并安装 SSH 客户端:
# apt install -y ssh
- 现在,我们可以将
hello
可执行文件复制到目标系统:
# scp -P22023 /mnt/hello pi@192.168.1.5:~
- 当要求输入密码时,输入
raspberry
。切换回树莓派模拟器窗口。检查我们刚刚复制的可执行文件是否存在:
$ ls hello
hello
- 现在,运行程序:
$ ./hello
正如我们所看到的,程序现在按预期运行。
工作原理...
在这个示例中,我们使用 SSH 在两个虚拟环境——Docker 和 QEMU——之间建立了数据交换。为此,我们需要在目标系统(QEMU)上运行并接受连接的 SSH 服务器,并在构建系统上启动连接的 SSH 客户端。
在第 2 步中,我们在构建系统上设置了 SSH 客户端。我们的目标系统在 QEMU 中运行,已经启动并运行了 SSH 服务器。在使用模拟器的步骤中,我们配置了 QEMU 以将主机端口22023
转发到虚拟机端口22
,即 SSH。
现在,我们可以使用scp
通过安全网络连接将文件从构建系统复制到目标系统。我们可以指定我们的系统 IP 地址(在第 1 步中发现)和端口22023
,作为scp
连接的参数,以连接到:
在我们复制文件之后,我们可以使用相同的 IP 地址、端口和用户名通过 SSH 登录到目标系统。它会打开一个类似于本地控制台的登录提示,并在授权后,我们会得到与本地终端相同的命令 shell。
我们在上一步中复制的hello
应用程序应该在home
目录中可用。我们通过运行ls
命令在第 5 步中检查了这一点。
最后,我们可以运行应用程序:
当我们尝试在构建系统上运行它时,我们收到了一个错误。现在,输出是Hello, world!
。这是我们所期望的,因为我们的应用程序是为 ARM 平台构建并在 ARM 平台上运行的。
还有更多...
尽管我们运行了连接到模拟系统的示例,但相同的步骤也适用于真实的嵌入式系统。即使目标系统没有显示器,也可以使用串行控制台连接设置 SSH。
在这个示例中,我们只是将文件复制到目标系统。除了复制,通常还会打开一个交互式 SSH 会话到嵌入式系统。通常,这比串行控制台更有效、更方便。它的建立方式与scp
类似:
# ssh pi@192.168.1.5 -p22023
SSH 提供各种身份验证机制。一旦启用并设置了公钥身份验证,就无需为每次复制或登录输入密码。这使得开发过程对开发人员来说更快速、更方便。
要了解更多关于 ss 密钥的信息,请访问www.ssh.com/ssh/key/
。
调试嵌入式应用程序
调试嵌入式应用程序在很大程度上取决于目标嵌入式系统的类型。微控制器制造商通常为他们的微控制器单元(MCU)提供专门的调试器,以及使用联合测试动作组(JTAG)协议进行远程调试的硬件支持。它允许开发人员在 MCU 开始执行指令后立即调试微控制器代码。
如果目标板运行 Linux,则调试的最实用方法是使用广泛的调试输出,并使用 GDB 作为交互式调试器。
在这个示例中,我们将学习如何在命令行调试器 GDB 中运行我们的应用程序。
准备就绪
我们已经学会了如何将可执行文件传输到目标系统。我们将使用连接到嵌入式系统的示例作为学习如何在目标系统上使用调试器的起点。
如何做...
我们已经学会了如何将应用程序复制到目标系统并在那里运行。现在,让我们学习如何在目标系统上使用 GDB 开始调试应用程序。在这个配方中,我们只会学习如何调用调试器并在调试器环境中运行应用程序。这将作为以后更高级和实用的调试技术的基础:
-
切换到
QEMU
窗口。 -
如果您还没有这样做,请使用
pi
作为用户名和raspberry
作为密码登录。 -
运行以下命令:
$ gdb ./hello
-
这将打开
gdb
命令行。 -
输入
run
来运行应用程序:
(gdb) run
-
您应该在输出中看到
Hello, world
。 -
现在,运行
quit
命令,或者只需输入q
:
(gdb) q
这将终止调试会话并将我们返回到 Linux shell。
工作原理...
我们用于仿真的 Raspberry Pi 映像预先安装了 GNU 调试器,因此我们可以立即使用它。
在home
用户目录中,我们应该找到hello
可执行文件,这是作为连接到嵌入式系统配方的一部分从我们的构建系统复制过来的。
我们运行gdb
,将hello
可执行文件的路径作为参数传递。这个命令打开了gdb
shell,但并没有运行应用程序本身。要运行它,我们输入run
命令:
应用程序运行,在屏幕上打印Hello world!
消息,然后终止。但是,我们仍然在调试器中。要退出调试器,我们输入quit
命令:
您可以看到命令行提示已经改变。这表明我们不再处于gdb
环境中。我们已经返回到 Raspberry Pi Linux 的默认 shell 环境,这是我们在运行 GDB 之前使用的环境。
还有更多...
在这种情况下,GNU 调试器是预先安装的,但可能不在您的真实目标系统中。如果它是基于 Debian 的,您可以通过运行以下命令来安装它:
# apt install gdb gdb-multiarch
在其他基于 Linux 的系统中,需要不同的命令来安装 GDB。在许多情况下,您需要从源代码构建并手动安装它,类似于我们在本章的配方中构建和测试的hello
应用程序。
在这个配方中,我们只学会了如何使用 GDB 运行应用程序,GDB 是一个具有许多命令、技术和最佳实践的复杂工具。我们将在第五章中讨论其中一些。
使用 gdbserver 进行远程调试
正如我们所讨论的,嵌入式开发环境通常涉及两个系统 - 构建系统和目标系统(或仿真器)。有时,由于远程通信的高延迟,目标系统上的交互式调试是不切实际的。
在这种情况下,开发人员可以使用 GDB 提供的远程调试支持。在这种设置中,使用gdbserver在目标系统上启动嵌入式应用程序。开发人员在构建系统上运行 GDB,并通过网络连接到 gdbserver。
在这个配方中,我们将学习如何使用 GDB 和 gdbserver 开始调试应用程序。
准备就绪
在连接到嵌入式系统配方中,我们学会了如何使我们的应用程序在目标系统上可用。我们将以此配方为起点,学习远程调试技术。
如何做...
我们将安装并运行 gdbserver 应用程序,这将允许我们在构建系统上运行 GDB 并将所有命令转发到目标系统。切换到 Raspberry Pi 仿真器窗口。
-
以
pi
身份登录,密码为raspberry
,除非您已经登录。 -
要安装 gdbserver,请运行以下命令:
# sudo apt-get install gdbserver
- 在
gdbserver
下运行hello
应用程序:
$ gdbserver 0.0.0.0:9090 ./hello
- 切换到构建系统终端并将目录更改为
/mnt/hello
:
# cd /mnt/hello
- 安装
gdb-multiarch
软件包,它提供了对 ARM 平台的必要支持:
# apt install -y gdb-multiarch
- 接下来,运行
gdb
:
# gdb-multiarch -q ./hello
- 通过在
gdb
命令行中输入以下命令来配置远程连接(确保您用实际 IP 地址替换192.168.1.5
):
target remote 192.168.1.5:9090
- 输入以下命令:
continue
程序现在将运行。
它是如何工作的...
在我们使用的 Raspberry Pi 镜像中,默认情况下未安装gdbserver
。因此,作为第一步,我们安装gdbserver
:
安装完成后,我们运行gdbserver
,将需要调试的应用程序的名称、IP 地址和要监听传入连接的端口作为参数传递给它。我们使用0.0.0.0
作为 IP 地址,表示我们希望接受任何 IP 地址上的连接:
然后,我们切换到我们的构建系统并在那里运行gdb
。但是,我们不直接在 GDB 中运行应用程序,而是指示gdb
使用提供的 IP 地址和端口启动与远程主机的连接:
之后,您在gdb
提示符下键入的所有命令都将传输到 gdbserver 并在那里执行。当我们运行应用程序时,即使我们运行 ARM 可执行文件,我们也将在构建系统的gdb
控制台中看到生成的输出:
解释很简单——二进制文件在远程 ARM 系统上运行:我们的 Raspberry Pi 模拟器。这是一种方便的调试应用程序的方式,允许您保持在构建系统更舒适的环境中。
还有更多...
确保您使用的 GDB 和 gdbserver 的版本匹配,否则它们之间可能会出现通信问题。
使用 CMake 作为构建系统
在以前的示例中,我们学习了如何编译由一个 C++文件组成的程序。然而,真实的应用程序通常具有更复杂的结构。它们可以包含多个源文件,依赖于其他库,并被分割成独立的项目。
我们需要一种方便地为任何类型的应用程序定义构建规则的方法。CMake 是最知名和广泛使用的工具之一,它允许开发人员定义高级规则并将它们转换为较低级别的构建系统,如 Unix make。
在本示例中,我们将学习如何设置 CMake 并为我们的Hello, world!应用程序创建一个简单的项目定义。
准备工作
如前所述,常见的嵌入式开发工作流程包括两个环境:构建系统和目标系统。CMake 是构建系统的一部分。我们将使用 Ubuntu 构建系统作为起点,该系统是作为在 Docker 容器中设置构建系统配方的结果创建的。
如何做...
- 我们的构建系统尚未安装 CMake。要安装它,请运行以下命令:
# apt install -y cmake
-
切换回本机操作系统环境。
-
在
~/test
目录中,创建一个子目录hello
。使用您喜欢的文本编辑器在hello
子目录中创建一个名为CMakeLists.txt
的文件。 -
输入以下行:
cmake_minimum_required(VERSION 3.5.1)
project(hello)
add_executable(hello hello.cpp)
-
保存文件并切换到 Ubuntu 控制台。
-
切换到
hello
目录:
# cd /mnt/hello
- 运行 CMake:
# mkdir build && cd build && cmake ..
- 现在,通过运行以下命令构建应用程序:
# make
- 使用
file
命令获取有关生成的可执行二进制文件的信息:
# file hello
- 如您所见,构建是本地的 x86 平台。我们需要添加交叉编译支持。切换回文本编辑器,打开
CMakeLists.txt
,并添加以下行:
set(CMAKE_C_COMPILER /usr/bin/arm-linux-gnueabi-gcc)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
-
保存并切换到 Ubuntu 终端。
-
再次运行
cmake
命令以重新生成构建文件:
# cmake ..
- 通过运行
make
来构建代码:
# make
- 再次检查生成的输出文件的类型:
# file hello
现在,我们使用 CMake 为我们的目标系统构建了一个可执行文件。
它是如何工作的...
首先,我们将 CMake 安装到我们的构建系统中。安装完成后,我们切换到本机环境创建CMakeLists.txt
。这个文件包含关于项目组成和属性的高级构建指令。
我们将项目命名为hello,它从名为hello.cpp
的源文件创建一个名为hello
的可执行文件。此外,我们指定了构建我们的应用程序所需的 CMake 的最低版本。
创建了项目定义之后,我们可以切换回构建系统 shell,并通过运行make
生成低级构建指令。
创建一个专用的构建目录来保存所有构建产物是一种常见的做法。通过这样做,编译器生成的目标文件或 CMake 生成的文件不会污染源代码目录。
在一个命令行中,我们创建一个build
目录,切换到新创建的目录,并运行 CMake。
我们将父目录作为参数传递,让 CMake 知道在哪里查找CMakeListst.txt
:
默认情况下,CMake 为传统的 Unix make
实用程序生成Makefile
文件。我们运行make
来实际构建应用程序:
它可以工作,但会导致为 x86 平台构建的可执行二进制文件,而我们的目标系统是 ARM:
为了解决这个问题,我们在我们的CMakeLists.txt
文件中添加了几个选项来配置交叉编译。再次重复构建步骤,我们得到了一个新的hello
二进制文件,现在是为 ARM 平台而构建的:
正如我们在file
命令的输出中所看到的,我们已经为 ARM 平台构建了可执行文件,而不是 x86,我们用作构建平台。这意味着这个程序将无法在构建机器上运行,但可以成功地复制到我们的目标平台并在那里运行。
还有更多...
配置 CMake 进行交叉编译的最佳方法是使用所谓的工具链文件。工具链文件定义了特定目标平台的构建规则的所有设置和参数,例如编译器前缀、编译标志以及目标平台上预先构建的库的位置。通过使用不同的工具链文件,可以为不同的目标平台重新构建应用程序。有关更多详细信息,请参阅 CMake 工具链文档cmake.org/cmake/help/v3.6/manual/cmake-toolchains.7.html
。
第三章:使用不同的架构
桌面应用程序的开发人员通常很少关注硬件架构。首先,他们经常使用高级编程语言,隐藏这些复杂性,以牺牲性能为代价。其次,在大多数情况下,他们的代码在 x86 架构上运行,并且他们经常认为其功能是理所当然的。例如,他们可能假设int
的大小为 32 位,但在许多情况下这是不正确的。
嵌入式开发人员处理更广泛的架构。即使他们不使用与目标平台本地的汇编语言编写代码,他们也应该知道所有 C 和 C++基本类型都是依赖于架构的;标准只保证 int 至少为 16 位。他们还应该了解特定架构的特性,如字节顺序和对齐,并考虑到在其他架构上执行浮点或 64 位数字的操作,这在 x86 架构上相对便宜,但在其他架构上可能更昂贵。
由于他们的目标是从嵌入式硬件中实现最大可能的性能,他们应该了解如何组织内存中的数据,以最有效地利用 CPU 缓存和操作系统分页机制。
在本章中,我们将涵盖以下主题:
-
探索固定宽度整数类型
-
使用
size_t
类型 -
检测平台的字节顺序
-
转换字节顺序
-
处理数据对齐
-
使用紧凑结构
-
使用缓存行对齐数据
通过研究这些主题,我们将学习如何调整我们的代码以针对平台实现最大性能和可移植性。
探索固定宽度整数类型
C 和 C++开发人员经常忘记基本数据类型如 char、short 和 int 的大小是依赖于架构的。与此同时,大多数硬件外设定义了关于用于数据交换的字段大小的特定要求。为了使代码与外部硬件或通信协议一起工作具有可移植性,嵌入式开发人员使用固定大小的整数类型,明确指定数据字段的大小。
一些最常用的数据类型如下:
宽度 | 有符号 | 无符号 |
---|---|---|
8 位 | int8_t |
uint8_t |
16 位 | int16_t |
uint16_t |
32 位 | int32_t |
uint32_t |
指针大小也取决于架构。开发人员经常需要处理数组的元素,由于数组在内部表示为指针,偏移表示取决于指针的大小。size_t
是一种特殊的数据类型,因为它以与架构无关的方式表示偏移和数据大小。
在本教程中,我们将学习如何在代码中使用固定大小的数据类型,使其在不同架构之间可移植。这样,我们可以使我们的应用程序更快地在其他目标平台上运行,并减少代码修改。
如何做到...
我们将创建一个模拟与外围设备进行数据交换的应用程序。按照以下步骤操作:
-
在您的工作目录中,即
~/test
,创建一个名为fixed_types
的子目录。 -
使用您喜欢的文本编辑器在
fixed_types
子目录中创建名为fixed_types.cpp
的文件。将以下代码片段复制到fixed_types.cpp
文件中:
#include <iostream>
void SendDataToDevice(void* buffer, uint32_t size) {
// This is a stub function to send data pointer by
// buffer.
std::cout << "Sending data chunk of size " << size << std::endl;
}
int main() {
char buffer[] = "Hello, world!";
uint32_t size = sizeof(buffer);
SendDataToDevice(&size, sizeof(size));
SendDataToDevice(buffer, size);
return 0;
}
- 在 loop 子目录中创建一个名为
CMakeLists.txt
的文件,并包含以下内容:
cmake_minimum_required(VERSION 3.5.1)
project(fixed_types)
add_executable(fixed_types fixed_types.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
-
构建应用程序并将生成的可执行二进制文件复制到目标系统。使用第二章中的步骤设置环境来完成。
-
切换到目标系统的终端。如有需要,请使用您的用户凭据登录。
-
运行二进制文件以查看其工作原理。
工作原理...
当您运行二进制文件时,您将看到以下输出:
在这个简单的程序中,我们正在模拟与外部设备的通信。由于我们没有真正的设备,SendDataToDevice
函数只是打印它应该发送到目标设备的数据的大小。
假设设备可以处理可变大小的数据块。每个数据块都以其大小作为前缀,并编码为 32 位无符号整数。可以描述如下:
大小 | 有效载荷 |
---|---|
0-4 字节 | 5 - N 字节,其中 N 是大小 |
在我们的代码中,我们将size
声明为uint32_t
:
uint32_t size = sizeof(buffer);
这意味着它将在每个平台上都占用 32 位 - 16 位、32 位或 64 位。
现在,我们将大小发送到设备:
SendDataToDevice(&size, sizeof(size));
SendDataToDevice
不会发送实际数据;相反,它会报告要发送的数据大小。正如我们所看到的,大小为4
字节,正如预期的那样:
Sending data chunk of size 4
假设我们声明int
数据类型,如下所示:
int size = sizeof(buffer);
在这种情况下,这段代码只能在 32 位和 64 位系统上工作,并且在 16 位系统上悄悄地产生不正确的结果,因为sizeof(int)
在这里是 16。
还有更多...
我们在这个示例中实现的代码并不是完全可移植的,因为它没有考虑 32 位字中字节的顺序。这个顺序被称为字节序,它的影响将在本章后面讨论。
使用size_t
类型
指针大小也取决于体系结构。开发人员经常需要处理数组的元素,由于数组在内部表示为指针,偏移量表示取决于指针的大小。
例如,在 32 位系统中,指针是 32 位,与int
相同。然而,在 64 位系统中,int
的大小仍然是 32 位,而指针是 64 位。
size_t
是一种特殊的数据类型,因为它以与体系结构无关的方式表示偏移量和数据大小。
在这个示例中,我们将学习如何在处理数组时使用size_t
。
如何做...
我们将创建一个处理可变大小数据缓冲区的应用程序。如果需要,我们需要能够访问目标平台提供的任何内存地址。按照以下步骤操作:
-
在您的工作目录,即
~/test
,创建一个名为sizet
的子目录。 -
使用您喜欢的文本编辑器在
sizet
子目录中创建一个名为sizet.cpp
的文件。将以下代码片段复制到sizet.cpp
文件中:
#include <iostream>
void StoreData(const char* buffer, size_t size) {
std::cout << "Store " << size << " bytes of data" << std::endl;
}
int main() {
char data[] = "Hello,\x1b\a\x03world!";
const char *buffer = data;
std::cout << "Size of buffer pointer is " << sizeof(buffer) << std::endl;
std::cout << "Size of int is " << sizeof(int) << std::endl;
std::cout << "Size of size_t is " << sizeof(size_t) << std::endl;
StoreData(data, sizeof(data));
return 0;
}
- 在子目录中创建一个名为
CMakeLists.txt
的文件,并包含以下内容:
cmake_minimum_required(VERSION 3.5.1)
project(sizet)
add_executable(sizet sizet.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
-
构建应用程序并将生成的可执行二进制文件复制到目标系统。使用第二章中的示例,设置环境来完成。
-
切换到目标系统的终端。根据需要使用您的用户凭据登录。
-
运行
sizet
应用程序可执行文件。
它是如何工作的...
在这个示例中,我们正在模拟一个将任意数据存储在文件或数据库中的函数。该函数接受数据指针和数据大小。但是我们应该使用什么类型来表示大小?如果我们在 64 位系统中使用无符号整数,我们就会人为地限制我们的函数处理的数据最多只能达到 4GB。
为了避免这种限制,我们使用size_t
作为size
的数据类型:
void StoreData(const char* buffer, size_t size) {
大多数标准库 API 接受索引和大小参数,也处理size_t
参数。例如,memcpy
C 函数,它将数据块从源缓冲区复制到目标缓冲区,声明如下:
void *memset(void *b, int c, size_t len);
运行上述代码会产生以下输出:
正如我们所看到的,在目标系统上指针的大小是 64 位,尽管int
的大小是 32 位。在我们的程序中使用size_t
允许它使用嵌入式板的所有内存。
还有更多...
C++标准定义了一个std::size_t
类型。它与普通的 C size_t
相同,只是它是在std
命名空间中定义的。在你的 C++代码中使用std::size_t
是更可取的,因为它是标准的一部分,但std::size_t
和size_t
都是可以互换的。
检测平台的字节顺序
字节顺序定义了表示大数值的字节在内存中存储的顺序。
有两种字节顺序:
- 大端:最重要的字节被先存储。一个 32 位的值,0x01020304,被存储在
ptr
地址上,如下所示:
内存偏移(字节) | 值 |
---|---|
ptr | 0x01 |
ptr + 1 | 0x02 |
ptr + 2 | ox03 |
ptr + 3 | 0x04 |
大端架构的例子包括 AVR32 和 Motorola 68000。
- 小端:最不重要的字节被先存储。一个 32 位的值,0x01020304,被存储在
ptr
地址上,如下所示:
内存偏移(字节) | 值 |
---|---|
ptr | 0x04 |
ptr + 1 | 0x03 |
ptr + 2 | 0x02 |
ptr + 3 | 0x01 |
x86 架构是小端的。
在与其他系统交换数据时,处理字节顺序尤为重要。如果开发人员将一个 32 位整数,比如 0x01020304,原样发送,如果接收者的字节顺序与发送者的字节顺序不匹配,它可能被读取为 0x04030201。这就是为什么数据应该被序列化的原因。
在这个配方中,我们将学习如何确定目标系统的字节顺序。
如何做...
我们将创建一个简单的程序,可以检测目标平台的字节顺序。按照以下步骤来做:
-
在你的工作目录,即
~/test
,创建一个名为endianness
的子目录。 -
使用你喜欢的文本编辑器在循环子目录中创建一个名为
loop.cpp
的文件。将以下代码片段复制到endianness.cpp
文件中:
#include <iostream>
int main() {
union {
uint32_t i;
uint8_t c[4];
} data;
data.i = 0x01020304;
if (data.c[0] == 0x01) {
std::cout << "Big-endian" << std::endl;
} else {
std::cout << "Little-endian" << std::endl;
}
}
- 在循环子目录中创建一个名为
CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(endianness)
add_executable(endianness endianness.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
-
构建应用程序并将生成的可执行二进制文件复制到目标系统。使用第二章中的配方,设置环境,来完成这个过程。
-
切换到目标系统的终端。如果需要,使用你的用户凭据登录。
-
运行二进制文件。
它是如何工作的...
在这个配方中,我们利用了 C 语言的union
函数的能力,将不同数据类型的表示映射到相同的内存空间。
我们定义了一个包含两个数据字段的联合体 - 一个 8 位整数数组和一个 32 位整数。这些数据字段共享相同的内存,因此对一个字段所做的更改会自动反映在另一个字段中:
union {
uint32_t i;
uint8_t c[4];
} data
接下来,我们给 32 位整数字段赋予一个特别设计的值,其中每个字节都是事先知道的,并且与其他任何字节都不同。我们使用值为一、二、三和四的字节来组成目标值。
当值被赋给 32 位字段i
时,它会自动将所有字段重写为c
字节数组字段。现在,我们可以读取数组的第一个元素,并根据我们读取的内容推断硬件平台的字节顺序。
如果值为一,这意味着第一个字节包含最重要的字节,因此架构是大端的。否则,它是小端的。当我们运行二进制文件时,它会产生以下输出:
正如我们所看到的,该程序检测到我们的系统是小端的。这种技术可以用来检测我们运行时的字节顺序,并相应地调整应用程序逻辑。
还有更多...
如今,大多数广泛使用的平台,如 x86 和Acorn RISC Machine(ARM),都是小端的。然而,你的代码不应该隐式地假设系统的字节顺序。
如果需要在同一系统上运行的应用程序之间交换数据,可以安全地使用目标平台的字节序。但是,如果您的应用程序需要与其他系统交换数据,无论是通过网络协议还是常见数据存储,都应考虑将二进制数据转换为通用字节序。
基于文本的数据格式不会受到字节序的影响。使用 JSON 格式进行数据表示,这样可以实现平台无关和人类可读的数据表示。
注意:在目标嵌入式平台上进行二进制表示和反向转换可能会很昂贵。
转换字节序
虽然序列化库处理字节序,但有时开发人员可能希望自己实现轻量级通信协议的情况。
虽然 C++标准库没有提供序列化函数,但开发人员可以利用这样一个事实:在二进制网络协议中,字节顺序是被定义的,并且始终是大端序。
标准库提供了一组函数,可用于在当前平台(硬件)和大端序(网络)字节顺序之间进行转换:
-
uint32_t
htonl (uint32_t
value): 将uint32_t
从硬件顺序转换为网络顺序 -
uint32_t
ntohl (uint32_t
value): 将uint32_t
从网络顺序转换为硬件顺序 -
uint16_t
htons (uint16_t
value): 将uint16_t
从硬件顺序转换为网络顺序 -
uint16_t
ntohl (uint16_t
value): 将uint16_t
从网络顺序转换为硬件顺序
开发人员可以使用这些函数在不同平台上运行的应用程序之间交换二进制数据。
在这个示例中,我们将学习如何对字符串进行编码,以便在可能具有相同或不同字节序的两个系统之间进行交换。
如何操作...
在这个示例中,我们将创建两个应用程序:发送方和接收方。发送方将为接收方编写数据,以平台无关的方式对其进行编码。按照以下步骤进行操作:
-
在您的工作目录中,即
~/test
,创建一个名为enconv
的子目录。 -
使用您喜欢的文本编辑器在
enconv
子目录中创建并编辑名为sender.cpp
的文件。包括所需的头文件,如下所示:
#include <stdexcept>
#include <arpa/inet.h>
#include <fcntl.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
- 然后,定义一个将数据写入文件描述符的函数:
void WriteData(int fd, const void* ptr, size_t size) {
size_t offset =0;
while (size) {
const char *buffer = (const char*)ptr + offset;
int written = write(fd, buffer, size);
if (written < 0) {
throw std::runtime_error("Can not write to file");
}
offset += written;
size -= written;
}
}
- 现在,我们需要定义一个格式化并写入消息的函数,以及调用它的主函数:
void WriteMessage(int fd, const char* str) {
uint32_t size = strlen(str);
uint32_t encoded_size = htonl(size);
WriteData(fd, &encoded_size, sizeof(encoded_size));
WriteData(fd, str, size);
}
int main(int argc, char** argv) {
int fd = open("envconv.data",
O_WRONLY|O_APPEND|O_CREAT, 0666);
for (int i = 1; i < argc; i++) {
WriteMessage(fd, argv[i]);
}
}
- 类似地,创建一个名为
receiver.cpp
的文件,并包含相同的头文件:
#include <stdexcept>
#include <arpa/inet.h>
#include <fcntl.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
- 添加以下代码,从文件描述符中读取数据:
void ReadData(int fd, void* ptr, size_t size) {
size_t offset =0;
while (size) {
char *buffer = (char*)ptr + offset;
int received = read(fd, buffer, size);
if (received < 0) {
throw std::runtime_error("Can not read from file");
} else if (received == 0) {
throw std::runtime_error("No more data");
}
offset += received;
size -= received;
}
}
- 现在,定义一个将消息读取出来的函数,以及调用它的主函数:
std::string ReadMessage(int fd) {
uint32_t encoded_size = 0;
ReadData(fd, &encoded_size, sizeof(encoded_size));
uint32_t size = ntohl(encoded_size);
auto data = std::make_unique<char[]>(size);
ReadData(fd, data.get(), size);
return std::string(data.get(), size);
}
int main(void) {
int fd = open("envconv.data", O_RDONLY, 0666);
while(true) {
try {
auto s = ReadMessage(fd);
std::cout << "Read: " << s << std::endl;
} catch(const std::runtime_error& e) {
std::cout << e.what() << std::endl;
break;
}
}
}
- 在 loop 子目录中创建一个名为
CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(conv)
add_executable(sender sender.cpp)
add_executable(receiver receiver.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++14")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
-
构建应用程序并将生成的两个可执行二进制文件
sender
和receiver
复制到目标系统。使用第二章中的设置环境的方法。 -
切换到目标系统的终端。如果需要,使用您的用户凭据登录。
-
运行
sender
二进制文件,并传递两个命令行参数:Hello
和Worlds
。这不会生成任何输出。 -
然后,运行接收方。
-
现在,检查用于数据交换的
sender
和receiver
文件的内容。它将以二进制格式呈现,因此我们需要使用xxd
工具将其转换为十六进制格式:
$ xxd envconv.data
0000000: 0000 0005 4865 6c6c 6f00 0000 0557 6f72 ....Hello....Wor
0000010: 6c64 ld
- 文件包含两个字符串
hello
和world
,前面是它们的大小。size
字段总是以大端序存储,与体系结构无关。这允许发送方和接收方在具有不同字节序的两台不同计算机上运行。
它是如何工作的...
在这个示例中,我们创建了两个二进制文件,sender 和 receiver,模拟了两个主机之间的数据交换。我们不能对它们的字节序做出任何假设,这就是为什么数据交换格式必须是明确的原因。
发送方和接收方交换可变大小的数据块。我们将每个块编码为 4 字节的整数,以定义即将到来的块大小,然后是块内容。
当发送方不在屏幕上生成任何输出时,它会将编码的数据块保存在文件中。当我们运行接收方时,它能够读取、解码并显示发送方保存的任何信息,如下面的屏幕截图所示:
虽然我们在本地以平台格式保留块大小,但在发送时需要将其转换为统一表示。我们使用htonl
函数来实现这一点:
uint32_t encoded_size = htonl(size);
此时,我们可以将编码后的大小写入输出流:
WriteData(fd, &encoded_size, sizeof(encoded_size));
块的内容如下:
WriteData(fd, str, size);
接收者反过来从输入流中读取大小:
uint32_t encoded_size = 0;
ReadData(fd, &encoded_size, sizeof(encoded_size));
大小被编码,直到接收者使用ntohl
函数将其转换为平台表示形式才能直接使用:
uint32_t size = ntohl(encoded_size);
只有在这样做之后,它才会知道接下来的块的大小,并且可以分配和读取它:
auto data = std::make_unique<char[]>(size);
ReadData(fd, data.get(), size);
由于序列化的data
大小始终表示为大端,读取函数不需要对数据写入的平台的字节顺序做出假设。它可以处理来自任何处理器架构的数据。
处理数据对齐
处理器不是按字节而是按内存字-与其数据地址大小匹配的块-读写数据。32 位处理器使用 32 位字,64 位处理器使用 64 位字,依此类推。
当字对齐时,读写效率最高-数据地址是字大小的倍数。例如,对于 32 位架构,地址 0x00000004 是对齐的,而 0x00000005 是不对齐的。在 x86 平台上,访问不对齐的数据比对齐的数据慢。然而,在 ARM 上,访问不对齐的数据会生成硬件异常并导致程序终止:
Compilers align data automatically. When it comes to structures, the result may be surprising for developers who are not aware of alignment.
struct {
uint8_t c;
uint32_t i;
} a = {1, 1};
std::cout << sizeof(a) << std::endl;
前面的代码片段的输出是什么?sizeof(uint8_t)
是 1,而sizeof(uint32_t)
是 4。开发人员可能期望结构的大小是各个大小的总和;然而,结果高度取决于目标架构。
对于 x86,结果是8
。在i
之前添加一个uint8_t
字段:
struct {
uint8_t c;
uint8_t cc;
uint32_t i;
} a = {1, 1};
std::cout << sizeof(a) << std::endl;
结果仍然是 8!编译器通过添加填充字节根据对齐规则优化结构内的数据字段的放置。这些规则依赖于架构,对于其他架构,结果可能不同。因此,结构不能在两个不同的系统之间直接交换,而需要序列化,这将在第八章中深入解释通信和序列化。
在这个示例中,我们将学习如何使用编译器隐式应用的规则来对齐数据以编写更节省内存的代码。
如何做...
我们将创建一个程序,该程序分配一个结构数组,并检查字段顺序如何影响内存消耗。按照以下步骤执行:
-
在您的工作目录中,即
~/test
,创建一个名为alignment
的子目录。 -
使用您喜欢的文本编辑器在循环子目录中创建一个名为
alignment.cpp
的文件。添加所需的头文件并定义两种数据类型,即Category
和ObjectMetadata1
:
#include <iostream>
enum class Category: uint8_t {
file, directory, socket
};
struct ObjectMetadata1 {
uint8_t access_flags;
uint32_t size;
uint32_t owner_id;
Category category;
};
- 现在,让我们定义另一个数据类型,称为
ObjectMetadata2
,以及使用所有这些的代码:
struct ObjectMetadata2 {
uint32_t size;
uint32_t owner_id;
uint8_t access_flags;
Category category;
};
int main() {
ObjectMetadata1 object_pool1[1000];
ObjectMetadata2 object_pool2[1000];
std::cout << "Poorly aligned:" << sizeof(object_pool1) << std::endl;
std::cout << "Well aligned:" << sizeof(object_pool2) << std::endl;
return 0;
}
- 在循环子目录中创建一个名为
CMakeLists.txt
的文件,并添加以下内容:
cmake_minimum_required(VERSION 3.5.1)
project(alignment)
add_executable(alignment alignment.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
-
构建应用程序并将生成的可执行二进制文件复制到目标系统。使用第二章中的配方设置环境来执行此操作。
-
切换到目标系统的终端。如果需要,使用您的用户凭据登录。
-
运行二进制文件。
它是如何工作的...
在我们的示例应用程序中,我们定义了两个数据结构,ObjectMetadata1
和ObjectMetadata2
,它们将保存有关文件对象的一些元数据。我们定义了代表对象的四个字段:
-
访问标志:代表文件访问类型的位的组合,例如读取、写入或执行。所有位字段都打包到一个单独的
uint8_t
字段中。 -
大小:作为 32 位无符号整数的对象大小。它将支持的对象大小限制为 4GB,但对于我们展示适当数据对齐的重要性来说是足够的。
-
所有者 ID:在我们系统中标识用户的 32 位整数。
-
类别:对象的类别。这可以是文件、目录或套接字。由于我们只定义了三个类别,
uint8_t
数据类型足以表示它们所有。这就是为什么我们使用enum
类来声明它们的原因:
enum class Category: uint8_t {
ObjectMetadata1
和ObjectMetadata2
都包含完全相同的字段;唯一的区别是它们在其结构中的排序方式。
现在,我们声明了两个对象池。两个池都包含 1,000 个对象;object_pool1
中包含ObjectMetadata1
结构中的元数据,而object_pool2
使用ObjectMetadata2
结构。现在,让我们检查应用程序的输出:
两个对象池在功能和性能方面是相同的。但是,如果我们检查它们占用了多少内存,我们可以看到一个显著的差异:object_pool1
比object_pool2
大 4KB。鉴于object_pool2
的大小为 12KB,我们浪费了 33%的内存,因为没有注意数据对齐。在处理数据结构时要注意对齐和填充,因为不正确的字段排序可能导致内存使用效率低下,就像object_pool2
的情况一样。使用这些简单的规则来组织数据字段,以保持它们正确对齐:
-
按照它们的大小对它们进行分组。
-
按照从最大到最小的数据类型对组进行排序。
良好对齐的数据结构速度快、内存效率高,并且不需要实现任何额外的代码。
还有更多...
每个硬件平台都有自己的对齐要求,其中一些是棘手的。您可能需要查阅目标平台编译器文档和最佳实践,以充分利用硬件。如果您的目标平台是 ARM,请考虑阅读 ARM 技术文章infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.faqs/ka15414.html
上的对齐期望。
虽然结构体内数据字段的正确对齐可以导致更紧凑的数据表示,但要注意性能影响。将一起使用的数据保持在同一内存区域中称为数据局部性,可能会显著提高数据访问性能。适合放入同一缓存行的数据元素可以比跨越缓存行边界的元素读取或写入得快得多。在许多情况下,更倾向于通过额外的内存使用来获得性能提升。我们将在使用缓存行对齐数据配方中更详细地讨论这种技术。
使用打包结构
在这个配方中,我们将学习如何定义结构,使其在数据成员之间没有填充字节。如果应用程序处理大量对象,这可能会显著减少应用程序使用的内存量。
请注意,这是有代价的。未对齐的内存访问速度较慢,导致性能不佳。对于某些架构,未对齐访问是被禁止的,因此需要 C++编译器生成比对齐访问更多的代码来访问数据字段。
尽管打包结构体可能会导致更有效的内存使用,但除非真的必要,否则避免使用这种技术。它有太多暗含的限制,可能会导致应用程序中难以发现的模糊问题。
将紧凑结构视为传输编码,并仅在应用程序外部存储、加载或交换数据时使用它们。但是,即使在这些情况下,使用适当的数据序列化也是更好的解决方案。
如何做...
在这个简单的应用程序中,我们将定义一个紧凑结构的数组,并查看这如何影响它所需的内存量。按照以下步骤操作:
-
在您的工作目录
~/test
中,创建alignment
子目录的副本。将其命名为packed_alignment
。 -
通过向每个结构的定义添加
__attribute__((packed))
来修改alignment.cpp
文件:
struct ObjectMetadata1 {
uint8_t access_flags;
uint32_t size;
uint32_t owner_id;
Category category;
} __attribute__((packed));
struct ObjectMetadata2 {
uint32_t size;
uint32_t owner_id;
uint8_t access_flags;
Category category;
} __attribute__((packed));
-
构建应用程序并将生成的可执行二进制文件复制到目标系统。使用第二章中的教程设置环境来操作。
-
切换到目标系统的终端。如果需要,使用您的用户凭据登录。
-
运行二进制文件。
它是如何工作的...
在这个教程中,我们通过向每个结构添加一个紧凑属性来修改了使用数据对齐教程中的代码:
} __attribute__((packed));
此属性指示编译器不要向结构添加填充字节,以符合目标平台的对齐要求。
运行上述代码会给我们以下输出:
如果编译器不添加填充字节,数据字段的顺序变得不重要。鉴于ObjectMetadata1
和ObjectMetadata2
结构具有完全相同的数据字段,它们在紧凑形式中的大小变得相同。
还有更多...
GNU 编译器集合
(GCC)通过其属性为开发人员提供了对数据布局的大量控制。您可以通过访问GCC 类型属性页面了解所有支持的属性及其含义。
其他编译器提供类似的功能,但它们的 API 可能不同。例如,Microsoft 编译器定义了#pragma pack
编译器指令来声明紧凑结构。更多细节可以在Pragma Pack Reference页面找到。
使用缓存行对齐数据
在这个教程中,我们将学习如何将数据结构与缓存行对齐。数据对齐可以显著影响系统的性能,特别是在多核系统中运行多线程应用程序的情况下。
首先,如果数据结构在同一个缓存行中,频繁访问一起使用的数据会更快。如果你的程序一直访问变量 A 和变量 B,处理器每次都需要使缓存失效并重新加载,如果它们不在同一行中。
其次,您不希望将不同线程独立使用的数据放在同一个缓存行中。如果同一个缓存行被不同的 CPU 核修改,这就需要缓存同步,这会影响使用共享数据的多线程应用程序的整体性能,因为在这种情况下,内存访问时间显著增加。
如何做...
我们将创建一个应用程序,使用四种不同的方法分配四个缓冲区,以学习如何对齐静态和动态分配的内存。按照以下步骤操作:
-
在您的工作目录
~/test
中创建一个名为cache_align
的子目录。 -
使用您喜欢的文本编辑器在
cache_align
子目录中创建一个名为cache_align.cpp
的文件。将以下代码片段复制到cache_align.cpp
文件中,以定义必要的常量和检测对齐的函数:
#include <stdlib.h>
#include <stdio.h>
constexpr int kAlignSize = 128;
constexpr int kAllocBytes = 128;
constexpr int overlap(void* ptr) {
size_t addr = (size_t)ptr;
return addr & (kAlignSize - 1);
}
- 现在,定义几个以不同方式分配的缓冲区:
int main() {
char static_buffer[kAllocBytes];
char* dynamic_buffer = new char[kAllocBytes];
alignas(kAlignSize) char aligned_static_buffer[kAllocBytes];
char* aligned_dynamic_buffer = nullptr;
if (posix_memalign((void**)&aligned_dynamic_buffer,
kAlignSize, kAllocBytes)) {
printf("Failed to allocate aligned memory buffer\n");
}
- 添加以下代码来使用它们:
printf("Static buffer address: %p (%d)\n", static_buffer,
overlap(static_buffer));
printf("Dynamic buffer address: %p (%d)\n", dynamic_buffer,
overlap(dynamic_buffer));
printf("Aligned static buffer address: %p (%d)\n", aligned_static_buffer,
overlap(aligned_static_buffer));
printf("Aligned dynamic buffer address: %p (%d)\n", aligned_dynamic_buffer,
overlap(aligned_dynamic_buffer));
delete[] dynamic_buffer;
free(aligned_dynamic_buffer);
return 0;
}
- 在 loop 子目录中创建一个名为
CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(cache_align)
add_executable(cache_align cache_align.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "-std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
-
构建应用程序并将生成的可执行二进制文件复制到目标系统。使用第二章中的配方,设置环境,来完成此操作。
-
切换到目标系统的终端。如果需要,使用您的用户凭据登录。
-
运行二进制文件。
工作原理...
在第一个代码片段中,我们创建了两对内存缓冲区。在每对中,第一个缓冲区分配给堆栈,而第二个缓冲区分配给堆。
第一对是使用标准 C++技术创建的。堆栈上的静态缓冲区声明为数组:
char static_buffer[kAllocBytes];
要创建动态缓冲区,我们使用new
C++关键字:
char* dynamic_buffer = new char[kAllocBytes];
在第二对中,我们创建了内存对齐的缓冲区。在堆栈上声明静态缓冲区与常规静态缓冲区类似。我们使用了一个额外的属性alignas
,这是 C++11 中引入的一种标准化和平台无关的内存对齐方式:
alignas(kAlignSize) char aligned_static_buffer[kAllocBytes];
此属性需要一个对齐大小作为参数。我们希望数据按缓存行边界对齐。根据平台的不同,缓存行大小可能不同。最常见的大小是 32、64 和 128 字节。使用 128 字节可以使我们的缓冲区对任何缓存行大小都对齐。
没有标准的方法来为动态缓冲区做同样的事情。为了在堆上分配内存,我们使用一个名为posix_memalign
的 C 函数。这仅在可移植操作系统接口(POSIX)系统(大多是类 Unix 系统)中可用,但这并不需要 C++11 标准的支持:
if (posix_memalign((void**)&aligned_dynamic_buffer,
kAlignSize, kAllocBytes)) {
posix_memalign
类似于malloc
,但有三个参数而不是一个。第二个参数是对齐大小,与对齐属性相同。第三个是要分配的内存大小。第一个参数用于返回分配内存的指针。与malloc
不同,posix_memalign
可能会失败,不仅是因为无法分配内存,还因为传递给函数的对齐大小不是 2 的幂。posix_memalign
返回一个错误代码作为其结果值,以帮助开发人员区分这两种情况。
我们定义了函数 overlap 来计算指针的非对齐部分,通过屏蔽所有对齐位:
size_t addr = (size_t)ptr;
return addr & (kAlignSize - 1);
当我们运行应用程序时,我们可以看到区别:
第一对中两个缓冲区的地址有非对齐部分,而第二对的地址是对齐的-非对齐部分为零。因此,对第二对缓冲区的元素进行随机访问更快,因为它们都同时在缓存中可用。
还有更多...
CPU 访问数据对齐对于通过硬件地址转换机制高效映射内存也至关重要。现代操作系统操作 4 KB 内存块或页面,以将进程的虚拟地址空间映射到物理内存。将数据结构对齐到 4 KB 边界可以带来性能提升。
我们在这个配方中描述的相同技术可以应用于将数据对齐到内存页边界。但是,请注意,posix_memalign
可能需要比请求的内存多两倍。对于较大的对齐块,这种内存开销增长可能是显著的。
第四章:处理中断
嵌入式应用程序的主要任务之一是与外部硬件外设通信。使用输出端口向外设发送数据很容易理解。但是,当涉及到读取时,情况变得更加复杂。
嵌入式开发人员必须知道何时可以读取数据。由于外围设备外部于处理器,这可能发生在任何时刻。
在本章中,我们将学习什么是中断以及如何处理中断。在以 8051 为目标平台的 8 位微控制器上,我们将学习以下主题:
-
如何实现基本中断处理
-
如何使用定时器中断从 MCU 的输出引脚生成信号
-
如何使用中断来计算 MCU 外部引脚上的事件
-
如何使用中断在串行通道上进行通信
通过完成以下示例,我们将学习这些主题:
-
实现中断服务例程
-
使用 8 位自动重装模式生成 5 kHz 方波信号
-
使用定时器 1 作为事件计数器来计算 1 Hz 脉冲
-
串行接收和发送数据
了解如何处理中断的核心概念将帮助您实现响应灵敏且节能的嵌入式应用程序。
然而,在此之前,我们将获取一些背景知识。
数据轮询
从外部源等待数据的第一种方法称为轮询。应用程序周期性地查询外部设备的输入端口,以检查是否有新数据。这种方法易于实现,但有显著的缺点。
首先,它浪费处理器资源。大多数轮询调用报告数据尚不可用,我们需要继续等待。由于这些调用不会导致某些数据处理,这是对计算资源的浪费。此外,轮询间隔应该足够短,以便快速响应外部事件。开发人员应该在处理器功率的有效利用和响应时间之间寻求折衷。
其次,它使程序的逻辑变得复杂。如果程序应该每 5 毫秒轮询一次事件,例如,那么它的任何子程序都不应该超过 5 毫秒。结果,开发人员人为地将代码分成更小的块,并组织它们之间的复杂切换,以允许轮询。
中断服务例程
中断是轮询的一种替代方法。一旦外部设备有新数据,它会在处理器中触发一个称为中断的事件。顾名思义,它会中断正常的执行指令流程。处理器保存其当前状态,并开始从不同的地址执行指令,直到遇到从中断返回的指令。然后,它读取保存的状态以继续执行从中断时刻开始的指令流。这种替代的指令序列称为中断服务例程(ISR)。
每个处理器都定义了自己的一组指令和约定来处理中断;然而,在处理中断时,它们都使用相同的一般方法:
-
中断由数字标识,从 0 开始。这些数字映射到硬件中断请求线(IRQ),这些线物理上对应于特定的处理器引脚。
-
当 IRQ 线被激活时,处理器使用其编号作为中断向量数组中的偏移量,以定位中断服务例程的地址。中断向量数组存储在内存中的固定地址上。
-
开发人员可以通过更新中断向量数组中的条目来定义或重新定义 ISR。
-
处理器可以被编程以启用或禁用中断,无论是针对特定的 IRQ 线还是一次性禁用所有中断。当中断被禁用时,处理器不会调用相应的 ISR,尽管可以读取 IRQ 线的状态。
-
IRQ 线可以编程触发中断,取决于物理引脚上的信号。这可以是信号的低电平、高电平,或者边沿(即从低到高或从高到低的过渡)。
ISR 的一般考虑
这种方法不会浪费处理器资源进行轮询,并且由于中断处理是在硬件级别执行的,因此提供了非常短的反应时间。然而,开发人员应该注意其具体情况,以避免未来出现关键或难以检测的问题。
首先,同时处理多个中断,或者在处理前一个中断的同时响应相同的中断,是很难实现的。这就是为什么 ISR 在中断被禁用时执行。这可以防止 ISR 被另一个中断打断,但也意味着待处理中断的反应时间可能会更长。更糟糕的是,如果中断不及时重新启用,这可能会导致数据或事件丢失。
为了避免这种情况,所有 ISR 都被编写为简短的。它们只做最少量的工作,以从设备中读取或确认数据。复杂的数据分析和处理是在 ISR 之外进行的。
8051 微控制器中断
8051 微控制器支持六个中断源-复位、两个硬件中断、两个定时器中断和一个串行通信中断:
中断号 | 描述 | 字节偏移 |
---|---|---|
复位 | 0 | |
0 | 外部中断 INT0 | 3 |
1 | 定时器 0(TF0) | 11 |
2 | 外部中断 INT1 | 19 |
3 | 定时器 1(TF1) | 27 |
4 | 串行 | 36 |
中断向量数组位于地址 0 处;除了复位之外,每个条目的大小为 8 字节。虽然最小的 ISR 可以适应 8 字节,但通常,条目包含将执行重定向到实际 ISR 的代码,该 ISR 位于其他地方。
复位入口是特殊的。它由复位信号激活,并立即跳转到主程序所在的地址。
8051 定义了一个称为中断使能(EA)的特殊寄存器,用于启用和禁用中断。它的 8 位分配如下:
位 | 名称 | 含义 |
---|---|---|
0 | EX0 | 外部中断 0 |
1 | ET0 | 定时器 0 中断 |
2 | EX1 | 外部中断 1 |
3 | ET1 | 定时器 1 中断 |
4 | ES | 串口中断 |
5 | - | 未使用 |
6 | - | 未使用 |
7 | EA | 全局中断控制 |
将这些位设置为 1 会启用相应的中断,设置为 0 会禁用它们。EA 位启用或禁用所有中断。
实现中断服务例程
在这个配方中,我们将学习如何为 8051 微控制器定义中断服务例程。
如何做...
按照以下步骤完成这个配方:
-
切换到我们在第二章中设置的构建系统,设置环境。
-
确保安装了 8051 仿真器:
# apt install -y mcu8051ide
-
启动
mcu8051ide
并创建一个名为Test
的新项目。 -
创建一个名为
test.c
的新文件,并将以下代码片段放入其中。这会为每个定时器中断增加一个内部counter
:
#include<mcs51reg.h>
volatile int Counter = 0;
void timer0_ISR (void) __interrupt(1) /*interrupt no. 1 for Timer0 */
{
Counter++;
}
void main(void)
{
TMOD = 0x03;
TH0 = 0x0;
TL0 = 0x0;
ET0 = 1;
TR0 = 1;
EA = 1;
while (1); /* do nothing */
}
- 选择工具|编译来构建代码。消息窗口将显示以下输出:
Starting compiler ...
cd "/home/dev"
sdcc -mmcs51 --iram-size 128 --xram-size 0 --code-size 4096 --nooverlay --noinduction --verbose --debug -V --std-sdcc89 --model-small "test.c"
sdcc: Calling preprocessor...
+ /usr/bin/sdcpp -nostdinc -Wall -obj-ext=.rel -D__SDCC_NOOVERLAY -DSDCC_NOOVERLAY -D__SDCC_MODEL_SMALL -DSDCC_MODEL_SMALL -D__SDCC_FLOAT_REENT -DSDCC_FLOAT_REENT -D__SDCC=3_4_0 -DSDCC=340 -D__SDCC_REVISION=8981 -DSDCC_REVISION=8981 -D__SDCC_mcs51 -DSDCC_mcs51 -D__mcs51 -D__STDC_NO_COMPLEX__ -D__STDC_NO_THREADS__ -D__STDC_NO_ATOMICS__ -D__STDC_NO_VLA__ -isystem /usr/bin/../share/sdcc/include/mcs51 -isystem /usr/share/sdcc/include/mcs51 -isystem /usr/bin/../share/sdcc/include -isystem /usr/share/sdcc/include test.c
sdcc: Generating code...
sdcc: Calling assembler...
+ /usr/bin/sdas8051 -plosgffwy test.rel test.asm
sdcc: Calling linker...
sdcc: Calling linker...
+ /usr/bin/sdld -nf test.lk
Compilation successful
-
选择模拟器|启动/关闭菜单项以激活模拟器。
-
选择模拟器|动画以慢速模式运行程序。
-
切换到 C 变量面板,并向下滚动,直到显示 Counter 变量。
-
观察它随时间的增长:
如您所见,Counter
变量的值字段现在是 74。
它是如何工作的...
对于我们的示例应用程序,我们将使用 8051 微控制器的仿真器。有几种可用;但是,我们将使用 MCU8051IDE,因为它在 Ubuntu 存储库中已经准备好了。
我们将其安装为常规的 Ubuntu 软件包,如下所示:
# apt install -y mcu8051ide
这是一个 GUI IDE,需要 X Window 系统才能运行。如果您使用 Linux 或 Windows 作为工作环境,请考虑直接从sourceforge.net/projects/mcu8051ide/files/
安装和运行它。
我们创建的简单程序定义了一个名为Counter
的全局变量,如下所示:
volatile int Counter = 0;
这被定义为volatile
,表示它可以在外部更改,并且编译器不应尝试优化代码以消除它。
接下来,我们定义了一个名为timer0_ISR
的简单函数:
void timer0_ISR (void) __interrupt(1)
它不接受任何参数,也不返回任何值。它唯一的作用是增加Counter
变量。它声明了一个重要的属性,称为__interrupt(1)
,以让编译器知道它是一个中断处理程序,并且它服务于中断号 1。编译器会自动生成代码,自动更新中断向量数组的相应条目。
在定义 ISR 本身之后,我们配置定时器的参数:
TMOD = 0x03;
TH0 = 0x0;
TL0 = 0x0;
然后,我们打开定时器 0,如下所示:
TR0 = 1;
以下命令启用定时器 0 的中断:
ET0 = 1;
以下代码启用所有中断:
EA = 1;
在这一点上,我们的 ISR 被定时器的中断周期性地激活。我们运行一个无限循环,因为所有的工作都是在 ISR 内完成的:
while (1); // do nothing
当我们在模拟器中运行上述代码时,我们会看到counter
变量的实际值随时间变化,表明我们的 ISR 被定时器激活。
使用 8 位自动重装模式生成 5 kHz 方波信号
在前面的示例中,我们学习了如何创建一个简单的 ISR,只进行计数器增量。让我们让中断例程做一些更有用的事情。在这个示例中,我们将学习如何编程 8051 微控制器,以便它生成具有给定频率的信号。
8051 微控制器有两个定时器 - 定时器 0 和定时器 1 - 都使用两个特殊功能寄存器:定时器模式(TMOD)和定时器控制(TCON)进行配置。定时器的值存储在 TH0 和 TL0 定时器寄存器中,用于定时器 0,以及 TH1 和 TL1 定时器寄存器用于定时器 1。
TMOD 和 TCON 位具有特殊含义。TMOD 寄存器的位定义如下:
位 | 定时器 | 名称 | 目的 |
---|---|---|---|
0 | 0 | M0 | 定时器模式选择器 - 低位。 |
1 | 0 | M1 | 定时器模式选择器 - 高位。 |
2 | 0 | CT | 计数器(1)或定时器(0)模式。 |
3 | 0 | GATE | 使能定时器 1,但仅当 INT0 的外部中断为高时。 |
4 | 1 | M0 | 定时器模式选择器 - 低位。 |
5 | 1 | M1 | 定时器模式选择器 - 高位。 |
6 | 1 | CT | 计数器(1)或定时器(0)模式。 |
7 | 1 | GATE | 使能定时器 1,但仅当 INT1 的外部中断为高时。 |
低 4 位分配给定时器 0,而高 4 位分配给定时器 1。
M0 和 M1 位允许我们以四种模式之一配置定时器:
模式 | M0 | M1 | 描述 |
---|---|---|---|
0 | 0 | 0 | 13 位模式。TL0 或 TL1 寄存器包含对应定时器值的低 5 位,TH0 或 TH1 寄存器包含对应定时器值的高 8 位。 |
1 | 0 | 1 | 16 位模式。TL0 或 TL1 寄存器包含对应定时器值的低 8 位,TH0 或 TH1 寄存器包含对应定时器值的高 8 位。 |
2 | 1 | 0 | 8 位模式自动重装。TL0 或 TL1 包含对应的定时器值,而 TH0 或 TL1 包含重装值。 |
3 | 1 | 1 | 定时器 0 的特殊 8 位模式 |
定时器控制(TCON)寄存器控制定时器中断。其位定义如下:
位 | 名称 | 目的 |
---|---|---|
0 | IT0 | 外部中断 0 控制位。 |
1 | IE0 | 外部中断 0 边沿标志。当 INT0 接收到高至低边沿信号时设置为 1。 |
2 | IT1 | 外部中断 1 控制位。 |
3 | IE1 | 外部中断 1 边沿标志。当 INT1 接收到高至低边沿信号时设置为 1。 |
4 | TR0 | 定时器 0 的运行控制。设置为 1 以启动,设置为 0 以停止定时器。 |
5 | TF0 | 定时器 0 溢出。当定时器达到其最大值时设置为 1。 |
6 | TR1 | 定时器 1 的运行控制。设置为 1 以启动,设置为 0 以停止定时器。 |
7 | TF1 | 定时器 1 溢出。当定时器达到其最大值时设置为 1。 |
我们将使用称为自动重载的 8051 定时器的特定模式。在这种模式下,TL0(定时器 1 的 TL1)寄存器包含计时器值,而 TH0(定时器 1 的 TH1)包含重载值。一旦 TL0 达到 255 的最大值,它就会生成溢出中断,并自动重置为重载值。
如何做...
按照以下步骤完成此操作:
-
启动mce8051ide并创建一个名为
Test
的新项目。 -
创建一个名为
generator.c
的新文件,并将以下代码片段放入其中。这将在 MCU 的P0_0
引脚上生成 5 kHz 信号:
#include<8051.h>
void timer0_ISR (void) __interrupt(1)
{
P0_0 = !P0_0;
}
void main(void)
{
TMOD = 0x02;
TH0 = 0xa3;
TL0 = 0x0;
TR0 = 1;
EA = 1;
while (1); // do nothing
}
-
选择工具|编译以构建代码。
-
选择模拟器|启动/关闭菜单项以激活模拟器。
-
选择模拟器|动画以以慢速模式运行程序。
它是如何工作的...
以下代码定义了定时器 0 的 ISR:
void timer0_ISR (void) __interrupt(1)
在每次定时器中断时,我们翻转 P0 的输入输出寄存器的 0 位。这将有效地在 P0 输出引脚上生成方波信号。
现在,我们需要弄清楚如何编程定时器以生成给定频率的中断。要生成 5 kHz 信号,我们需要以 10 kHz 频率翻转位,因为每个波包括一个高相位和一个低相位。
8051 MCU 使用外部振荡器作为时钟源。定时器单元将外部频率除以 12。对于常用作 8051 时间源的 11.0592 MHz 振荡器,定时器每 1/11059200*12 = 1.085 毫秒激活一次。
我们的定时器 ISR 应以 10 kHz 频率激活,或者每 100 毫秒激活一次,或者在每 100/1.085 = 92 个定时器滴答后激活一次。
我们将定时器 0 编程为以第二种模式运行,如下所示:
TMOD = 0x02;
在这种模式下,我们将定时器的复位值存储在 TH0 寄存器中。ISR 由定时器溢出激活,这发生在定时器计数器达到最大值之后。第二种模式是 8 位模式,意味着最大值是 255。要使 ISR 每 92 个时钟周期激活一次,自动重载值应为 255-92 = 163,或者用十六进制表示为0xa3
。
我们将自动重载值与初始定时器值一起存储在定时器寄存器中:
TH0 = 0xa3;
TL0 = 0x0;
定时器 0 被激活,如下所示:
TR0 = 1;
然后,我们启用定时器中断:
TR0 = 1;
最后,所有中断都被激活:
EA = 1;
从现在开始,我们的 ISR 每 100 微秒被调用一次,如下面的代码所示:
P0_0 = !P0_0;
这会翻转P0
寄存器的0
位,从而在相应的输出引脚上产生 5 kHz 方波信号。
使用定时器 1 作为事件计数器来计算 1 Hz 脉冲
8051 定时器具有双重功能。当它们被时钟振荡器激活时,它们充当定时器。然而,它们也可以被外部引脚上的信号脉冲激活,即 P3.4(定时器 0)和 P3.5(定时器 1),充当计数器。
在这个示例中,我们将学习如何编程定时器 1,以便它计算 8051 处理器的 P3.5 引脚的激活次数。
如何做...
按照以下步骤完成此操作:
-
打开 mcu8051ide。
-
创建一个名为
Counters
的新项目。 -
创建一个名为
generator.c
的新文件,并将以下代码片段放入其中。这将在每次定时器中断触发时递增一个计数器变量:
#include<8051.h>
volatile int counter = 0;
void timer1_ISR (void) __interrupt(3)
{
counter++;
}
void main(void)
{
TMOD = 0x60;
TH1 = 254;
TL1 = 254;
TR1 = 1;
ET1 = 1;
EA = 1;
while (1); // do nothing
}
-
选择工具|编译以构建代码。
-
打开 Virtual HW 菜单,并选择 Simple Key...条目。将打开一个新窗口。
-
在 Simple Keypad 窗口中,将端口 3 和位 5 分配给第一个键。然后,单击 ON 或 OFF 按钮以激活它:
-
选择模拟器|启动/关闭菜单项以激活模拟器。
-
选择模拟器|动画以以动画模式运行程序,该模式在调试器窗口中显示对特殊寄存器的所有更改。
-
切换到简单键盘窗口并单击第一个键。
工作原理...
在这个过程中,我们利用 8051 定时器的能力,使其作为计数器。我们以与普通定时器完全相同的方式定义中断服务例程。由于我们将定时器 1 用作计数器,我们使用中断线号3
,如下所示:
void timer1_ISR (void) __interrupt(3)
中断例程的主体很简单。我们只递增counter
变量。
现在,让我们确保 ISR 是由外部源而不是时钟振荡器激活的。为此,我们通过将TMOD
特殊功能寄存器的 C/T 位设置为 1 来配置定时器 1:
TMOD = 0x60;
同样的行配置定时器 1 以在 Mode 2 下运行- 8 位模式与自动重载。由于我们的目标是使中断例程在每次外部引脚激活时被调用,我们将自动重载和初始值设置为最大值254
:
TH1 = 254;
TL1 = 254;
接下来,我们启用定时器 1:
TR1 = 1;
然后,激活所有来自定时器 1 的中断,如下所示:
ET1 = 1;
EA = 1;
之后,我们可以进入一个什么也不做的无限循环,因为所有的工作都是在中断服务例程中完成的:
while (1); // do nothing
在这一点上,我们可以在模拟器中运行代码。但是,我们需要配置外部事件的来源。为此,我们利用 MCU8051IDE 支持的虚拟外部硬件组件之一-虚拟键盘。
我们配置其中一个键来激活 8051 的引脚 P3.5。当它在计数模式下使用时,该引脚被用作定时器 1 的源。
现在,我们运行代码。按下虚拟键会激活计数器。一旦计时器值溢出,我们的 ISR 就会被触发,递增counter
变量。
还有更多...
在这个过程中,我们使用定时器 1 作为计数器。同样的方法也可以应用于计数器 0。在这种情况下,引脚 P3.4 应该被用作外部源。
串行接收和发送数据
8051 微控制器配备了内置的通用异步收发器(UART)端口,用于串行数据交换。
串行端口由名为串行控制(SCON)的特殊功能寄存器(SFR)控制。其位定义如下:
位 | 名称 | 目的 |
---|---|---|
0 | RI(接收 中断的缩写) | 当一个字节完全接收时由 UART 设置 |
1 | TI(传输 中断的缩写) | 当一个字节完全传输时由 UART 设置 |
2 | RB8(接收 位 8的缩写) | 在 9 位模式下存储接收数据的第九位。 |
3 | TB8(传输位 8的缩写) | 在 9 位模式下存储要传输的数据的第九位(见下文) |
4 | REN(接收使能的缩写) | 启用(1)或禁用(0)接收操作 |
5 | SM2(启用多处理器) | 为 9 位模式启用(1)或禁用(0)多处理器通信 |
6 | SM1(串行模式,高位) | 定义串行通信模式 |
7 | SM0(串行模式,低位) | 定义串行通信模式 |
8051 UART 支持四种串行通信模式,所有这些模式都由 SM1 和 SM0 位定义:
模式 | SM0 | SM1 | 描述 |
---|---|---|---|
0 | 0 | 0 | 移位寄存器,固定波特率 |
1 | 0 | 1 | 8 位 UART,波特率由定时器 1 设置 |
2 | 1 | 0 | 9 位 UART,固定波特率 |
3 | 1 | 1 | 9 位 UART,波特率由定时器 1 设置 |
在这个过程中,我们将学习如何使用中断来实现使用可编程波特率的 8 位 UART 模式进行简单数据交换。
如何做...
按照以下步骤完成此过程:
-
打开 mcu8051ide 并创建一个新项目。
-
创建一个名为
serial.c
的新文件,并将以下代码片段复制到其中。这段代码将接收到的字节复制到P0
输出寄存器中。这与 MCU 上的通用输入/输出引脚相关联:
#include<8051.h>
void serial_isr() __interrupt(4) {
if(RI == 1) {
P0 = SBUF;
RI = 0;
}
}
void main() {
SCON = 0x50;
TMOD = 0x20;
TH1 = 0xFD;
TR1 = 1;
ES = 1;
EA = 1;
while(1);
}
-
选择工具 | 编译以构建代码。
-
选择模拟器 | 启动/关闭菜单项以激活模拟器。
工作原理...
我们为中断线4
定义了一个 ISR,用于串行端口事件触发:
void serial_isr() __interrupt(4)
一旦接收到一个完整的字节并存储在串行缓冲寄存器(SBUF)中,中断例程就会被调用。我们的中断服务程序的实现只是将接收到的字节复制到输入/输出端口,即P0
:
P0 = SBUF;
然后,它重置 RI 标志以启用即将到来的字节的中断。
为了使中断按预期工作,我们需要配置串行端口和定时器。首先,配置串行端口如下:
SCON = 0x50;
根据上表,这意味着串行控制寄存器(SCON)的 SM1 和 REN 位仅设置为 1,从而选择通信模式 1。这是一个由定时器 1 定义波特率的 8 位 UARS。然后,它启用接收器。
由于波特率由定时器 1 定义,下一步是配置定时器,如下所示:
TMOD = 0x20;
上述代码配置定时器 1 使用模式 2,即 8 位自动重载模式。
将 0xFD 加载到 TH1 寄存器中,将波特率设置为 9600 bps。然后,我们启用定时器 1、串行中断和所有中断。
还有更多...
数据传输可以以类似的方式实现。如果您向 SBUF 特殊寄存器写入数据,8051 UART 将开始传输。完成后,将调用串行中断并将 TI 标志设置为 1。
第五章:调试、日志记录和性能分析
调试和性能分析是任何类型应用程序开发工作流程中的重要部分。在嵌入式环境中,这些任务需要开发人员特别注意。嵌入式应用程序在可能与开发人员工作站非常不同的系统上运行,并且通常具有有限的资源和用户界面功能。
开发人员应该提前计划如何在开发阶段调试他们的应用程序,以及如何确定生产环境中问题的根本原因,并加以修复。
通常,解决方案是使用目标设备的仿真器以及嵌入式系统供应商提供的交互式调试器。然而,对于更复杂的系统,完整和准确的仿真几乎是不可行的,远程调试是最可行的解决方案。
在许多情况下,使用交互式调试器是不可能或根本不切实际的。程序在断点停止后几毫秒内硬件状态可能会发生变化,开发人员没有足够的时间来分析它。在这种情况下,开发人员必须使用广泛的日志记录进行根本原因分析。
在本章中,我们将重点介绍基于SoC(片上系统)和运行 Linux 操作系统的更强大系统的调试方法。我们将涵盖以下主题:
-
在GDB(GNU 项目调试器的缩写)中运行您的应用程序
-
使用断点
-
处理核心转储
-
使用 gdbserver 进行调试
-
添加调试日志
-
使用调试和发布版本
这些基本的调试技术将在本书中以及在您处理任何类型嵌入式应用程序的工作中有很大帮助。
技术要求
在本章中,我们将学习如何在ARM(Acorn RISC Machines的缩写)平台仿真器中调试嵌入式应用程序。此时,您应该已经在笔记本电脑或台式电脑上运行的虚拟化 Linux 环境中配置了两个系统:
-
Ubuntu Linux 作为构建系统在 Docker 容器中
-
Debian Linux 作为目标系统在QEMU(快速仿真器)ARM 仿真器中
要了解交叉编译的理论并设置开发环境,请参考第二章中的示例,设置环境。
在 GDB 中运行您的应用程序
在这个示例中,我们将学习如何在目标系统上使用调试器运行一个示例应用程序,以及尝试一些基本的调试技术。
GDB 是一个开源且广泛使用的交互式调试器。与大多数作为集成开发环境(IDE)产品的一部分提供的调试器不同,GDB 是一个独立的命令行调试器。这意味着它不依赖于任何特定的 IDE。正如您在示例中所看到的,您可以使用纯文本编辑器来处理应用程序的代码,同时仍然能够进行交互式调试,使用断点,查看变量和堆栈跟踪的内容,以及更多。
GDB 的用户界面是极简的。您可以像在 Linux 控制台上工作一样运行它——通过输入命令并分析它们的输出。这种简单性使其非常适合嵌入式项目。它可以在没有图形子系统的系统上运行。如果目标系统只能通过串行连接或 ssh shell 访问,它尤其方便。由于它没有花哨的用户界面,它可以在资源有限的系统上运行。
在这个示例中,我们将使用一个人工样本应用程序,它会因异常而崩溃。它不会记录任何有用的信息,异常消息太模糊,无法确定崩溃的根本原因。我们将使用 GDB 来确定问题的根本原因。
如何做...
我们现在将创建一个在特定条件下崩溃的简单应用程序:
-
在您的工作目录
~/test
中,创建一个名为loop
的子目录。 -
使用您喜欢的文本编辑器在
loop
子目录中创建一个名为loop.cpp
的文件。 -
让我们将一些代码放入
loop.cpp
文件中。我们从包含开始:
#include <iostream>
#include <chrono>
#include <thread>
#include <functional>
- 现在,我们定义程序将包含的三个函数。第一个是
runner
:
void runner(std::chrono::milliseconds limit,
std::function<void(int)> fn,
int value) {
auto start = std::chrono::system_clock::now();
fn(value);
auto end = std::chrono::system_clock::now();
std::chrono::milliseconds delta =
std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
if (delta > limit) {
throw std::runtime_error("Time limit exceeded");
}
}
- 第二个函数是
delay_ms
:
void delay_ms(int count) {
for (int i = 0; i < count; i++) {
std::this_thread::sleep_for(std::chrono::microseconds(1050));
}
}
- 最后,我们添加入口函数
main
:
int main() {
int max_delay = 10;
for (int i = 0; i < max_delay; i++) {
runner(std::chrono::milliseconds(max_delay), delay_ms, i);
}
return 0;
}
- 在
loop
子目录中创建一个名为CMakeLists.txt
的文件,并包含以下内容:
cmake_minimum_required(VERSION 3.5.1)
project(loop)
add_executable(loop loop.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "-g --std=c++11")
set(CMAKE_C_COMPILER /usr/bin/arm-linux-gnueabi-gcc)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
- 现在,切换到构建系统终端,并通过运行以下命令将当前目录更改为
/mnt/loop
。
$ cd /mnt/loop
- 按照以下方式构建应用程序:
$ cmake . && make
- 切换回您的本机环境,在
loop
子目录中找到loop
输出文件,并通过 ssh 将其复制到目标系统。使用用户帐户。切换到目标系统终端。根据需要使用用户凭据登录。现在,使用gdb
运行loop
可执行二进制文件:
$ gdb ./loop
- 调试器已启动,并显示命令行提示(
gdb
)。要运行应用程序,请键入run
命令:
(gdb) run
- 您可以看到应用程序由于运行时异常而异常终止。异常消息
Time limit exceeded
给了我们一个线索,但并没有指出发生异常的具体条件。让我们试着确定这一点。首先,让我们检查崩溃应用程序的堆栈跟踪:
(gdb) bt
- 这显示了从顶级函数
main
到库函数__GI_abort
的七个堆栈帧,后者实际上终止了应用程序。正如我们所看到的,只有帧7
和6
属于我们的应用程序,因为只有它们在loop.cpp
中定义。让我们仔细看一下frame 6
,因为这是抛出异常的函数:
(gdb) frame 6
- 运行
list
命令来查看附近的代码:
(gdb) list
- 正如我们所看到的,如果 delta 变量的值超过 limit 变量的值,就会抛出异常。但是这些值是什么?运行
info locals
命令来找出这一点:
(gdb) info locals
- 我们无法在这里看到限制变量的值。使用
info args
命令来查看它:
(gdb) info args
- 现在,我们可以看到限制是
10
,而 delta 是11
。当使用fn
参数设置为delay_ms
函数,并且value
参数的值设置为7
时,崩溃发生。
它是如何工作的...
该应用程序是故意创建的,在某些条件下会崩溃,并且没有提供足够的信息来确定这些条件。该应用程序由两个主要函数组成——runner
和delay_ms
。
runner
函数接受三个参数——时间限制、一个参数的函数和函数参数值。它运行作为参数提供的函数,传递值,并测量经过的时间。如果时间超过时间限制,它会抛出异常。
delay_ms
函数执行延迟。但是,它的实现是错误的,它将每毫秒视为由 1100 微秒而不是 1000 微秒组成。
main
函数在loop
目录中运行 runner,提供 10 毫秒作为时间限制的修复值和delay_ms
作为要运行的函数,但增加value
参数的值。在某个时候,delay_ms
函数超过了时间限制,应用程序崩溃了。
首先,我们为 ARM 平台构建应用程序,并将其传输到模拟器上运行:
重要的是要向编译器传递-g
参数。此参数指示编译器向生成的二进制文件添加调试符号。我们将其添加到CMakeLists.txt
文件中的CMAKE_CXX_FLAGS
参数中,如下所示:
SET(CMAKE_CXX_FLAGS "-g --std=c++11")
现在,我们运行调试器,并将应用程序可执行文件名作为其参数:
应用程序不会立即运行。我们使用run
GDB 命令启动它,并观察它在短时间内崩溃:
接下来,我们使用backtrace
命令来查看堆栈跟踪:
对堆栈跟踪的分析显示frame 6
应该给我们更多信息来揭示根本原因。通过接下来的步骤,我们切换到frame 6
并审查相关的代码片段:
接下来,我们分析本地变量和函数参数的值,以确定它们与时间限制的关系:
我们确定当传递给delay_ms
的值达到7
时发生崩溃,而不是预期的11
,这在正确实现延迟的情况下是预期的。
还有更多...
GDB 命令通常接受多个参数来微调它们的行为。使用help
命令来了解每个命令的更多信息。例如,这是help bt
命令的输出:
这显示了用于审查和分析堆栈跟踪的bt
命令的信息。类似地,您可以获取关于 GDB 支持的所有其他命令的信息。
使用断点
在这个教程中,我们将学习在使用 GDB 时更高级的调试技术。我们将使用相同的示例应用程序,并使用断点来找到实际延迟与delay_ms
参数值的依赖关系。
在 GDB 中使用断点与在集成 IDE 中使用断点类似,唯一的区别是开发人员不是使用内置编辑器来导航源代码,而是要学会显式使用行号、文件名或函数名。
这比点击运行调试器不太方便,但是灵活性使开发人员能够创建强大的调试场景。在这个教程中,我们将学习如何在 GDB 中使用断点。
如何做到...
在这个教程中,我们将使用与第一个教程相同的环境和相同的测试应用程序。参考第 1 到 9 步的在 GDB 中运行您的应用程序教程来构建应用程序并将其复制到目标系统上:
- 我们想要调试我们的
runner
函数。让我们看一下它的内容。在 gdb shell 中,运行以下程序:
(gdb) list runner,delay_ms
- 我们想要看到每次迭代中
delta
的变化。让我们在该行设置一个断点:
14 if (delta > limit) {
- 使用
break 14
命令在第 14 行设置一个断点:
(gdb) break 14
- 现在运行程序:
(gdb) run
- 检查
delta
的值:
(gdb) print delta
$1 = {__r = 0}
- 继续执行程序,输入
continue
或者c
:
(gdb) c
- 再次检查
delta
的值:
(gdb) print delta
-
正如我们预期的那样,
delta
的值在每次迭代中都会增加,因为delay_ms
需要越来越多的时间。 -
每次运行
print delta
都不方便。让我们使用名为command
的命令来自动化它:
(gdb) command
- 再次运行
c
。现在,每次停止后都会显示delta
的值:
(gdb) c
- 然而,输出太冗长了。让我们通过再次输入
command
并编写以下指令来使 GDB 输出静音。现在,运行c
或continue
命令几次以查看差异:
(gdb) command
Type commands for breakpoint(s) 1, one per line.
End with a line saying just "end".
>silent
>print delta
>end
(gdb) c
- 我们可以使用
printf
命令使输出更加简洁,如下所示:
(gdb) command
Type commands for breakpoint(s) 1, one per line.
End with a line saying just "end".
>silent
>printf "delta=%d, expected=%d\n", delta.__r, value
>end
(gdb) c
现在,我们可以看到两个值,计算出的延迟和预期的延迟,并且可以看到它们随时间的变化而发散。
它是如何工作的...
在这个教程中,我们想要设置一个断点来调试runner
函数。由于 GDB 没有内置编辑器,我们需要知道设置断点的行号。虽然我们可以直接从文本编辑器中获取它,但另一种方法是在 GDB 中查看相关代码片段。我们使用带有两个参数的gdb
命令列表 - 函数名称,以显示runner
函数的第一行和delay_ms
函数的第一行之间的代码行。这有效地显示了函数runner
的内容:
在步骤 4,使用break 14
命令在第 14 行设置断点,并运行程序。执行将在断点处停止:
我们使用print
命令检查delta
变量的值,并使用continue
命令继续执行程序,由于在循环中调用了runner
函数,它再次停在相同的断点处:
接下来,我们尝试更高级的技术。我们定义一组 GDB 命令,以在触发断点时执行。我们从一个简单的print
命令开始。现在,每次我们继续执行,我们都可以看到delta
变量的值:
接下来,我们使用silent
命令禁用辅助 GDB 输出,以使输出更加简洁:
最后,我们使用printf
命令格式化具有两个最有趣变量的消息:
正如你所看到的,GDB 为开发人员提供了很多灵活性,使得即使缺乏图形界面,调试也变得更加舒适。
还有更多...
重要的是要记住,优化选项-O2
和-O3
可能导致编译器完全消除一些代码行。如果将断点设置为这些行,这些断点将永远不会触发。为避免这种情况,关闭调试构建的编译器优化。
处理核心转储
在第一个教程中,我们学习了如何使用交互式命令行调试器确定崩溃应用程序的根本原因。但是,在生产环境中,应用程序崩溃时,有时无法或不切实际地在测试系统上重现相同的问题,从 GDB 中运行应用程序。
Linux 提供了一种机制,可帮助分析崩溃的应用程序,即使它们不是直接从 GDB 中运行的。当应用程序异常终止时,操作系统将其内存映像保存到名为core
的文件中。在本教程中,我们将学习如何配置 Linux 以生成崩溃应用程序的核心转储,以及如何使用 GDB 进行分析。
如何做...
我们将确定一个应用程序崩溃的根本原因,该应用程序未在 GDB 中运行:
-
在本教程中,我们将使用与第一个教程中相同的环境和相同的测试应用程序。请参阅第一个教程的步骤 1至7,构建应用程序并将其复制到目标系统。
-
首先,我们需要启用生成崩溃应用程序的核心转储。在大多数 Linux 发行版中,默认情况下关闭此功能。运行
ulimit -c
命令检查当前状态:
$ ulimit -c
- 前一个命令报告的值是要生成的核心转储的最大大小。零表示没有核心转储。要增加限制,我们需要首先获得超级用户权限。运行
su -
命令。提示输入Password
时,输入root
:
$ su -
Password:
- 运行
ulimit -c unlimited
命令允许任意大小的核心转储:
# ulimit -c unlimited
-
现在,通过按Ctrl + D或运行
logout
命令退出 root shell。 -
前面的命令仅为超级用户更改了核心转储限制。要将其应用于当前用户,请在用户 shell 中再次运行相同的命令:
$ ulimit -c unlimited
- 确保限制已更改:
$ ulimit -c
unlimited
- 现在,像往常一样运行应用程序:
$ ./loop
- 它将以异常崩溃。运行
ls
命令检查当前目录中是否创建了核心文件:
$ ls -l core
-rw------- 1 dev dev 536576 May 31 00:54 core
- 现在,运行
gdb
,传递可执行文件和core
文件作为参数:
$ gdb ./loop core
- 在 GDB shell 中,运行
bt
命令查看堆栈跟踪:
(gdb) bt
-
您可以看到与从
gdb
内部运行的应用程序相同的堆栈跟踪。但是,在这种情况下,我们看到了核心转储的堆栈跟踪。 -
在这一点上,我们可以使用与第一个教程中相同的调试技术来缩小崩溃原因。
它是如何工作的...
核心转储功能是 Linux 和其他类 Unix 操作系统的标准功能。然而,在每种情况下都创建核心文件并不实际。由于核心文件是进程内存的快照,它们可能在文件系统上占用几兆甚至几十几个 G 的空间。在许多情况下,这是不可接受的。
开发人员需要明确指定操作系统允许生成的核心文件的最大大小。这个限制,以及其他限制,可以使用ulimit
命令来设置。
我们运行ulimit
两次,首先为超级用户 root 移除限制,然后为普通用户/开发人员移除限制。需要两阶段的过程,因为普通用户的限制不能超过超级用户的限制。
在我们移除了核心文件大小的限制后,我们在没有 GDB 的情况下运行我们的测试应用程序。预期地,它崩溃了。崩溃后,我们可以看到当前目录中创建了一个名为core
的新文件。
当我们运行我们的应用程序时,它崩溃了。通常情况下,我们无法追踪崩溃的根本原因。然而,由于我们启用了核心转储,操作系统自动为我们创建了一个名为core
的文件:
核心文件是所有进程内存的二进制转储,但没有额外的工具很难分析它。幸运的是,GDB 提供了必要的支持。
我们运行 GDB 传递两个参数——可执行文件的路径和核心文件的路径。在这种模式下,我们不从 GDB 内部运行应用程序。我们已经在核心转储中冻结了应用程序在崩溃时的状态。GDB 使用可执行文件将core
文件中的内存地址绑定到函数和变量名:
因此,即使应用程序未从调试器中运行,您也可以在交互式调试器中分析崩溃的应用程序。当我们调用bt
命令时,GDB 会显示崩溃时的堆栈跟踪:
这样,即使最初没有在调试器中运行,我们也可以找出应用程序崩溃的根本原因。
还有更多...
使用 GDB 分析核心转储是嵌入式应用程序的广泛使用和有效实践。然而,要使用 GDB 的全部功能,应用程序应该构建时支持调试符号。
然而,在大多数情况下,嵌入式应用程序会在没有调试符号的情况下部署和运行,以减小二进制文件的大小。在这种情况下,对核心转储的分析变得更加困难,可能需要一些特定架构的汇编语言和数据结构实现的内部知识。
使用 gdbserver 进行调试
嵌入式开发的环境通常涉及两个系统——构建系统和目标系统,或者模拟器。尽管 GDB 的命令行界面使其成为低性能嵌入式系统的不错选择,但在许多情况下,由于远程通信的高延迟,目标系统上的交互式调试是不切实际的。
在这种情况下,开发人员可以使用 GDB 提供的远程调试支持。在这种设置中,嵌入式应用程序使用 gdbserver 在目标系统上启动。开发人员在构建系统上运行 GDB,并通过网络连接到 gdbserver。
在这个配方中,我们将学习如何使用 GDB 和 gdbserver 开始调试应用程序。
准备就绪...
按照第二章的连接到嵌入式系统配方,设置环境,在目标系统上有hello
应用程序可用。
如何做...
我们将使用前面的示例中使用的相同应用程序,但现在我们将在不同的环境中运行 GDB 和应用程序:
-
切换到目标系统窗口,然后输入Ctrl + D以注销当前用户会话。
-
以
user
身份登录,使用user
密码。 -
在
gdbserver
下运行hello
应用程序:
$ gdbserver 0.0.0.0:9090 ./hello
- 切换到构建系统终端,并将目录更改为
/mnt
:
# cd /mnt
- 运行
gdb
,将应用程序二进制文件作为参数传递:
# gdb -q hello
- 通过在 GDB 命令行中输入以下命令来配置远程连接:
target remote X.X.X.X:9090
- 最后,键入
continue
命令:
continue
现在程序正在运行,我们可以看到它的输出并像在本地运行一样对其进行调试。
工作原理...
首先,我们以 root 用户身份登录到目标系统并安装 gdbserver,除非它已经安装。安装完成后,我们再次使用用户凭据登录并运行 gdbserver,将要调试的应用程序的名称、IP 地址和要监听的端口作为其参数传递。
然后,我们切换到我们的构建系统并在那里运行 GDB。但是,我们不直接在 GDB 中运行应用程序,而是指示 GDB 使用提供的 IP 地址和端口建立与远程主机的连接。之后,您在 GDB 提示符处键入的所有命令都将传输到 gdbserver 并在那里执行。
添加调试日志
日志记录和诊断是任何嵌入式项目的重要方面。在许多情况下,使用交互式调试器是不可能或不切实际的。在程序停在断点后,硬件状态可能在几毫秒内发生变化,开发人员没有足够的时间来分析它。收集详细的日志数据并使用工具进行分析和可视化是高性能、多线程、时间敏感的嵌入式系统的更好方法。
日志记录本身会引入一定的延迟。首先,需要时间来格式化日志消息并将其放入日志流中。其次,日志流应可靠地存储在持久存储器中,例如闪存卡或磁盘驱动器,或者发送到远程系统。
在本教程中,我们将学习如何使用日志记录而不是交互式调试来查找问题的根本原因。我们将使用不同日志级别的系统来最小化日志记录引入的延迟。
如何做...
我们将修改我们的应用程序以输出对根本原因分析有用的信息:
-
转到您的工作目录
~/test
,并复制loop
项目目录。将副本命名为loop2
。切换到loop2
目录。 -
使用文本编辑器打开
loop.cpp
文件。 -
添加一个
include
:
#include <iostream>
#include <chrono>
#include <thread>
#include <functional>
#include <syslog.h>
- 通过在以下代码片段中突出显示的方式修改
runner
函数,添加对syslog
函数的调用:
void runner(std::chrono::milliseconds limit,
std::function<void(int)> fn,
int value) {
auto start = std::chrono::system_clock::now();
fn(value);
auto end = std::chrono::system_clock::now();
std::chrono::milliseconds delta =
std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
syslog(LOG_DEBUG, "Delta is %ld",
static_cast<long int>(delta.count()));
if (delta > limit) {
syslog(LOG_ERR,
"Execution time %ld ms exceeded %ld ms limit",
static_cast<long int>(delta.count()),
static_cast<long int>(limit.count()));
throw std::runtime_error("Time limit exceeded");
}
}
- 同样,更新
main
函数以初始化和完成syslog
:
int main() {
openlog("loop3", LOG_PERROR, LOG_USER);
int max_delay = 10;
for (int i = 0; i < max_delay; i++) {
runner(std::chrono::milliseconds(max_delay), delay_ms, i);
}
closelog();
return 0;
}
- 切换到构建系统终端。转到
/mnt/loop2
目录并运行程序:
# cmake && make
- 将生成的
binary
文件复制到目标系统并运行它:
$ ./loop
调试输出冗长,并提供更多上下文以找到问题的根本原因。
工作原理...
在本教程中,我们使用标准日志记录工具syslog
添加了日志记录。首先,我们通过调用openlog
来初始化我们的日志记录:
openlog("loop3", LOG_PERROR, LOG_USER);
接下来,我们将日志记录添加到runner
函数中。有不同的日志记录级别,可以方便地过滤日志消息,从最严重到最不严重。我们使用LOG_DEBUG
级别记录delta
值,该值表示runner
调用的函数实际运行的时间有多长:
syslog(LOG_DEBUG, "Delta is %d", delta);
此级别用于记录对应用程序调试有用的详细信息,但在生产环境中运行应用程序时可能会过于冗长。
但是,如果delta
超过限制,我们将使用LOG_ERR
级别记录此情况,以指示通常不应发生此情况并且这是一个错误:
syslog(LOG_ERR,
"Execution time %ld ms exceeded %ld ms limit",
static_cast<long int>(delta.count()),
static_cast<long int>(limit.count()));
在从应用程序返回之前,我们关闭日志记录以确保所有日志消息都得到适当保存:
closelog();
当我们在目标系统上运行应用程序时,我们可以在屏幕上看到我们的日志消息:
由于我们使用标准的 Linux 日志记录,我们也可以在系统日志中找到消息:
如您所见,记录并不难实现,但在调试和正常操作期间,它对于找出应用程序中各种问题的根本原因非常有帮助。
还有更多...
有许多日志记录库和框架,可能比标准记录器更适合特定任务;例如,Boost.Log,网址为theboostcpplibraries.com/boost.log
,以及spdlog,网址为github.com/gabime/spdlog
。它们提供了比syslog
的通用 C 接口更方便的 C++接口。在开始项目工作时,请检查现有的日志记录库,并选择最适合您要求的库。
使用调试和发布构建
正如我们在前面的食谱中所学到的,记录会带来相关成本。它会延迟格式化日志消息并将其写入持久存储或远程系统。
使用日志级别有助于通过跳过将一些消息写入日志文件来减少开销。但是,在将消息传递给log
函数之前,消息通常会被格式化。例如,在系统错误的情况下,开发人员希望将系统报告的错误代码添加到日志消息中。尽管字符串格式化通常比将数据写入文件要便宜,但对于负载高的系统或资源有限的系统来说,这可能仍然是一个问题。
编译器添加的调试符号不会增加运行时开销。但是,它们会增加生成二进制文件的大小。此外,编译器进行的性能优化可能会使交互式调试变得困难。
在本食谱中,我们将学习如何通过分离调试和发布构建并使用 C 预处理器宏来避免运行时开销。
如何做...
我们将修改我们在前面的食谱中使用的应用程序的构建规则,以拥有两个构建目标——调试和发布:
-
转到您的工作目录
~/test
,并复制loop2
项目目录。将副本命名为loop3
。切换到loop3
目录。 -
使用文本编辑器打开
CMakeLists.txt
文件。替换以下行:
SET(CMAKE_CXX_FLAGS "-g --std=c++11")
- 前面的行需要替换为以下行:
SET(CMAKE_CXX_FLAGS_RELEASE "--std=c++11")
SET(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_RELEASE} -g -DDEBUG")
- 使用文本编辑器打开
loop.cpp
文件。通过添加突出显示的行来修改文件:
#include <iostream>
#include <chrono>
#include <thread>
#include <functional>
#include <cstdarg>
#ifdef DEBUG
#define LOG_DEBUG(fmt, args...) fprintf(stderr, fmt, args)
#else
#define LOG_DEBUG(fmt, args...)
#endif
void runner(std::chrono::milliseconds limit,
std::function<void(int)> fn,
int value) {
auto start = std::chrono::system_clock::now();
fn(value);
auto end = std::chrono::system_clock::now();
std::chrono::milliseconds delta =
std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
LOG_DEBUG("Delay: %ld ms, max: %ld ms\n",
static_cast<long int>(delta.count()),
static_cast<long int>(limit.count()));
if (delta > limit) {
throw std::runtime_error("Time limit exceeded");
}
}
- 切换到构建系统终端。转到
/mnt/loop3
目录并运行以下代码:
# cmake -DCMAKE_BUILD_TYPE=Release . && make
- 将生成的
loop
二进制文件复制到目标系统并运行它:
$ ./loop
- 如您所见,该应用程序不会生成任何调试输出。现在让我们使用
ls -l
命令检查其大小:
$ ls -l loop
-rwxr-xr-x 1 dev dev 24880 Jun 1 00:50 loop
- 生成的二进制文件的大小为 24 KB。现在,让我们构建
Debug
构建并进行如下比较:
$ cmake -DCMAKE_BUILD_TYPE=Debug && make clean && make
- 检查可执行文件的大小:
$ ls -l ./loop
-rwxr-xr-x 1 dev dev 80008 Jun 1 00:51 ./loop
- 可执行文件的大小现在是 80 KB。它比发布构建大三倍以上。像以前一样运行它:
$ ./loop
如您所见,输出现在不同了。
它是如何工作的...
我们从用于添加调试日志食谱的项目副本开始,并创建两个不同的构建配置:
-
调试:具有交互式调试和调试日志支持的配置
-
发布:高度优化的配置,在编译时禁用了所有调试支持
为了实现它,我们利用了CMake
提供的功能。它支持开箱即用的不同构建类型。我们只需要分别为发布和调试构建定义编译选项。
我们为发布构建定义的唯一构建标志是要使用的 C++标准。我们明确要求代码符合 C++11 标准:
SET(CMAKE_CXX_FLAGS_RELEASE "--std=c++11")
对于调试构建,我们重用与发布构建相同的标志,将其引用为${CMAKE_CXX_FLAGS_RELEASE}
,并添加两个选项。-g
指示编译器向目标可执行二进制文件添加调试符号,而-DDEBUG
定义了一个预处理宏DEBUG
。
我们在loop.cpp
的代码中使用DEBUG
宏来选择LOG_DEBUG
宏的两种不同实现。
如果定义了DEBUG
,LOG_DEBUG
会扩展为调用fprintf
函数,该函数在标准错误通道中执行实际的日志记录。然而,如果未定义DEBUG
,LOG_DEBUG
会扩展为空字符串。这意味着在这种情况下,LOG_DEBUG
不会产生任何代码,因此不会增加任何运行时开销。
我们在运行函数的主体中使用LOG_DEBUG
来记录实际延迟和限制的值。请注意,LOG_DEBUG
周围没有if
- 格式化和记录数据或不执行任何操作的决定不是由我们的程序在运行时做出的,而是由代码预处理器在构建应用程序时做出的。
要选择构建类型,我们调用cmake
,将构建类型的名称作为命令行参数传递:
cmake -DCMAKE_BUILD_TYPE=Debug
CMake
只生成一个Make
文件来实际构建我们需要调用make
的应用程序。我们可以将这两个命令合并成一个单独的命令行:
cmake -DCMAKE_BUILD_TYPE=Release && make
第一次构建和运行应用程序时,我们选择发布版本。因此,我们看不到任何调试输出:
之后,我们使用调试构建类型重新构建我们的应用程序,并在运行时看到不同的结果:
通过调试和发布构建,您可以获得足够的信息进行舒适的调试,但请确保生产构建不会有任何不必要的开销。
还有更多...
在复杂项目中切换发布和调试构建时,请确保所有文件都已正确重建。最简单的方法是删除所有先前的构建文件。在使用make
时,可以通过调用make clean
命令来完成。
它可以作为命令行的一部分与cmake
和make
一起添加:
cmake -DCMAKE_BUILD_TYPE=Debug && make clean && make
将所有三个命令合并成一行对开发人员更加方便。
第六章:内存管理
内存效率是嵌入式应用的主要要求之一。由于目标嵌入式平台通常具有有限的性能和内存能力,开发人员需要知道如何以最有效的方式使用可用内存。
令人惊讶的是,最有效的方式并不一定意味着使用最少的内存。由于嵌入式系统是专用的,开发人员预先知道将在系统上执行哪些应用程序或组件。在一个应用程序中节省内存并不会带来任何收益,除非同一系统中运行的另一个应用程序可以使用额外的内存。这就是嵌入式系统中内存管理最重要的特征是确定性或可预测性的原因。知道一个应用程序在任何负载下可以使用两兆字节的内存比知道一个应用程序大部分时间可以使用一兆字节的内存,但偶尔可能需要三兆字节更重要得多。
同样,可预测性也适用于内存分配和释放时间。在许多情况下,嵌入式应用更倾向于花费更多内存以实现确定性定时。
在本章中,我们将学习嵌入式应用中广泛使用的几种内存管理技术。本章涵盖的技术如下:
-
使用动态内存分配
-
探索对象池
-
使用环形缓冲区
-
使用共享内存
-
使用专用内存
这些技术将帮助您了解内存管理的最佳实践,并可在处理应用程序中的内存分配时用作构建块。
使用动态内存分配
动态内存分配是 C++开发人员常见的做法,在 C++标准库中被广泛使用;然而,在嵌入式系统的环境中,它经常成为难以发现和难以避免的问题的根源。
最显著的问题是时间。内存分配的最坏情况时间是不受限制的;然而,嵌入式系统,特别是那些控制真实世界进程或设备的系统,通常需要在特定时间内做出响应。
另一个问题是碎片化。当分配和释放不同大小的内存块时,会出现技术上是空闲的内存区域,但由于太小而无法分配给应用程序请求。内存碎片随着时间的推移而增加,可能导致内存分配请求失败,尽管总的空闲内存量相当大。
避免这类问题的一个简单而强大的策略是在编译时或启动时预先分配应用程序可能需要的所有内存。然后应用程序根据需要使用这些内存。一旦分配了这些内存,直到应用程序终止,就不会释放这些内存。
这种方法的缺点是应用程序分配的内存比实际使用的内存多,而不是让其他应用程序使用它。在实践中,这对于嵌入式应用来说并不是问题,因为它们在受控环境中运行,所有应用程序及其内存需求都是预先知道的。
如何做到...
在本技术中,我们将学习如何预先分配内存并在应用程序中使用它:
-
在您的工作
〜/test
目录中,创建一个名为prealloc
的子目录。 -
使用您喜欢的文本编辑器在
prealloc
子目录中创建一个名为prealloc.cpp
的文件。将以下代码片段复制到prealloc.cpp
文件中以定义SerialDevice
类:
#include <cstdint>
#include <string.h>
constexpr size_t kMaxFileNameSize = 256;
constexpr size_t kBufferSize = 4096;
constexpr size_t kMaxDevices = 16;
class SerialDevice {
char device_file_name[256];
uint8_t input_buffer[kBufferSize];
uint8_t output_buffer[kBufferSize];
int file_descriptor;
size_t input_length;
size_t output_length;
public:
SerialDevice():
file_descriptor(-1), input_length(0), output_length(0) {}
bool Init(const char* name) {
strncpy(device_file_name, name, sizeof(device_file_name));
}
bool Write(const uint8_t* data, size_t size) {
if (size > sizeof(output_buffer)) {
throw "Data size exceeds the limit";
}
memcpy(output_buffer, data, size);
}
size_t Read(uint8_t* data, size_t size) {
if (size < input_length) {
throw "Read buffer is too small";
}
memcpy(data, input_buffer, input_length);
return input_length;
}
};
- 添加使用
SerialDevice
类的main
函数:
int main() {
SerialDevice devices[kMaxDevices];
size_t number_of_devices = 0;
uint8_t data[] = "Hello";
devices[0].Init("test");
devices[0].Write(data, sizeof(data));
number_of_devices = 1;
return 0;
}
- 在
loop
子目录中创建一个名为CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(prealloc)
add_executable(prealloc prealloc.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++17")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
现在可以构建和运行应用程序。它不会输出任何数据,因为它的目的是演示我们如何预先分配内存,而不知道设备的数量和我们与设备交换的消息的大小。
工作原理...
在这个配方中,我们定义了封装与串行设备进行数据交换的对象。设备由可变长度的设备文件名字符串标识。我们可以向设备发送和接收可变长度的消息。
由于我们只能在运行时发现连接到系统的设备数量,我们可能会在发现时创建设备对象。同样,由于我们不知道发送和接收的消息大小,因此自然而然地要动态分配消息的内存。
相反,我们预分配未初始化设备对象的数组:
SerialDevice devices[kMaxDevices];
反过来,每个对象都预分配了足够的内存来存储消息和设备文件名:
char device_file_name[kMaxFileNameSize];
uint8_t input_buffer[kBufferSize];
uint8_t output_buffer[kBufferSize];
我们使用局部变量来跟踪输入和输出缓冲区中数据的实际大小。无需跟踪文件名的大小,因为预期它是以零结尾的:
size_t input_length;
size_t output_length;
同样,我们跟踪实际发现的设备数量:
size_t number_of_devices = 0;
通过这种方式,我们避免了动态内存分配。尽管这样做有成本:我们人为地限制了支持的最大设备数量和消息的最大大小。其次,大量分配的内存从未被使用。例如,如果我们支持最多 16 个设备,而系统中只有 1 个设备,那么实际上我们只使用了分配内存的 1/16。如前所述,这对于嵌入式系统来说并不是问题,因为所有应用程序及其要求都是预定义的。没有应用程序可以从它可以分配的额外内存中受益。
探索对象池
正如我们在本章的第一个配方中讨论的那样,预分配应用程序使用的所有内存是一种有效的策略,有助于嵌入式应用程序避免与内存碎片化和分配时间相关的各种问题。
临时内存预分配的一个缺点是,应用程序现在负责跟踪预分配对象的使用情况。
对象池旨在通过提供类似于动态内存分配但使用预分配数组中的对象的泛化和便利接口来隐藏对象跟踪的负担。
如何做...
在这个配方中,我们将创建一个对象池的简单实现,并学习如何在应用程序中使用它:
-
在您的工作
~/test
目录中,创建一个名为objpool
的子目录。 -
使用您喜欢的文本编辑器在
objpool
子目录中创建一个objpool.cpp
文件。让我们定义一个模板化的ObjectPool
类。我们从私有数据成员和构造函数开始:
#include <iostream>
template<class T, size_t N>
class ObjectPool {
private:
T objects[N];
size_t available[N];
size_t top = 0;
public:
ObjectPool(): top(0) {
for (size_t i = 0; i < N; i++) {
available[i] = i;
}
}
- 现在让我们添加一个从池中获取元素的方法:
T& get() {
if (top < N) {
size_t idx = available[top++];
return objects[idx];
} else {
throw std::runtime_error("All objects are in use");
}
}
- 接下来,我们添加一个将元素返回到池中的方法:
void free(const T& obj) {
const T* ptr = &obj;
size_t idx = (ptr - objects) / sizeof(T);
if (idx < N) {
if (top) {
top--;
available[top] = idx;
} else {
throw std::runtime_error("Some object was freed more than once");
}
} else {
throw std::runtime_error("Freeing object that does not belong to
the pool");
}
}
- 然后,用一个小函数包装类定义,该函数返回从池中请求的元素数量:
size_t requested() const { return top; }
};
- 按照以下代码所示定义要存储在对象池中的数据类型:
struct Point {
int x, y;
};
- 然后添加与对象池一起工作的代码:
int main() {
ObjectPool<Point, 10> points;
Point& a = points.get();
a.x = 10; a.y=20;
std::cout << "Point a (" << a.x << ", " << a.y << ") initialized, requested " <<
points.requested() << std::endl;
Point& b = points.get();
std::cout << "Point b (" << b.x << ", " << b.y << ") not initialized, requested " <<
points.requested() << std::endl;
points.free(a);
std::cout << "Point a(" << a.x << ", " << a.y << ") returned, requested " <<
points.requested() << std::endl;
Point& c = points.get();
std::cout << "Point c(" << c.x << ", " << c.y << ") not intialized, requested " <<
points.requested() << std::endl;
Point local;
try {
points.free(local);
} catch (std::runtime_error e) {
std::cout << "Exception caught: " << e.what() << std::endl;
}
}
- 在
loop
子目录中创建一个名为CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(objpool)
add_executable(objpool objpool.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
-
构建应用程序并将生成的可执行二进制文件复制到目标系统。使用第二章中的配方,设置环境来完成。
-
切换到目标系统终端。如果需要,使用用户凭据登录。
-
运行二进制文件。
工作原理...
在这个应用程序中,我们使用了与第一个配方中相同的想法(预分配对象的静态数组),但是我们将其封装到一个模板化的ObjectPool
类中,以提供处理不同类型对象的通用接口。
我们的模板有两个参数——存储在ObjectPool
类实例中的对象的类或数据类型,以及池的大小。这些参数用于定义类的两个私有数据字段——对象数组和空闲索引数组:
T objects[N];
size_t available[N];
由于模板参数在编译时被解析,这些数组是静态分配的。此外,该类有一个名为top
的私有数据成员,它充当available
数组中的索引,并指向下一个可用对象。
可用数组包含当前可用于使用的objects
数组中所有对象的索引。在最开始,所有对象都是空闲的,并且可用数组中填充了对象数组中所有元素的索引:
for (size_t i = 0; i < N; i++) {
available[i] = i;
}
当应用程序需要从池中获取元素时,它调用get
方法。该方法使用顶部变量来获取池中下一个可用元素的索引:
size_t idx = available[top++];
return objects[idx];
当top
索引达到数组大小时,意味着不能再分配更多元素,因此该方法会抛出异常以指示错误条件:
throw std::runtime_error("All objects are in use");
可以使用free
将对象返回到池中。首先,它根据其地址检测元素的索引。索引被计算为对象地址与池起始地址的差异。由于池对象在内存中是连续存储的,我们可以轻松地过滤出相同类型的对象,但不能过滤出来自该池的对象:
const T* ptr = &obj;
size_t idx = (ptr - objects) / sizeof(T);
请注意,由于size_t
类型是无符号的,我们不需要检查结果索引是否小于零——这是不可能的。如果我们尝试将不属于池的对象返回到池中,并且其地址小于池的起始地址,它将被视为正索引。
如果我们返回的对象属于池,我们会更新顶部计数器,并将结果索引放入可用数组以供进一步使用:
top--;
available[top] = idx;
否则,我们会抛出异常,指示我们试图返回一个不属于该池的对象:
throw std::runtime_error("Freeing object that does not belong to the pool");
所请求的方法用于跟踪池对象的使用情况。它返回顶部变量,该变量有效地跟踪已经被索取但尚未返回到池中的对象数量。
size_t requested() const { return top; }
让我们定义一个数据类型并尝试使用来自池的对象。我们声明一个名为Point
的结构体,其中包含两个int
字段,如下面的代码所示:
struct Point {
int x, y;
};
现在我们创建一个大小为10
的Point
对象池:
ObjectPool<Point, 10> points;
我们从池中获取一个对象并填充其数据字段:
Point& a = points.get();
a.x = 10; a.y=20;
程序产生了以下输出:
输出的第一行报告了一个请求的对象。
我们请求了一个额外的对象并打印其数据字段,而不进行任何初始化。池报告说已经请求了两个对象,这是预期的。
现在我们将第一个对象返回到池中,并确保请求的对象数量减少。我们还可以注意到,即使将对象返回到池中,我们仍然可以从中读取数据。
让我们从池中再索取一个对象。请求的数量增加,但请求的对象与我们在上一步中返回的对象相同。
我们可以看到Point c
在从池中取出后没有被初始化,但其字段包含与Point a
相同的值。实际上,现在a
和c
是对池中相同对象的引用,因此对变量a
的修改将影响变量c
。这是我们对象池实现的一个限制。
最后,我们创建一个本地的Point
对象并尝试将其返回到池中:
Point local;
try {
points.free(local);
} catch (std::runtime_error e) {
std::cout << "Exception caught: " << e.what() << std::endl;
}
预计会出现异常,并且确实如此。在程序输出中,您可以看到一个Exception caught: Freeing object that does not belong to the pool
的消息。
还有更多...
尽管对象池的实现简化了与预分配对象的工作,但它有许多限制。
首先,所有对象都是在最开始创建的。因此,调用我们的池的get
方法不会触发对象构造函数,调用free
方法也不会调用析构函数。开发人员需要使用各种变通方法来初始化和去初始化对象。
一个可能的解决方法是定义目标对象的特殊方法,比如initialize
和deinitialize
,分别由ObjectPool
类的get
和free
方法调用。然而,这种方法将类的实现与ObjectPool
的实现耦合在一起。在本章的后面,我们将看到更高级的技术来克服这个限制。
我们的池的实现没有检测free
方法是否对一个对象调用了多次。这是一个错误,但是很常见,并导致难以调试的问题。虽然在技术上是可行的,但它给实现增加了不必要的额外复杂性。
使用环形缓冲区
环形缓冲区,或循环缓冲区,在嵌入式世界中是一个广泛使用的数据结构。它作为一个队列放置在固定大小的内存数组之上。缓冲区可以包含固定数量的元素。生成这些元素的函数将它们顺序放入缓冲区中。当达到缓冲区的末尾时,它会切换到缓冲区的开头,就好像它的第一个元素跟在最后一个元素后面。
当涉及到组织数据生产者和消费者之间的数据交换时,这种设计被证明是非常高效的,因为它们是独立的,不能等待对方,这在嵌入式开发中是常见的情况。例如,中断服务例程应该快速地将来自设备的数据排队等待进一步处理,而中断被禁用。如果处理数据的函数落后,它不能等待中断服务例程。同时,处理函数不需要完全与中断服务例程(ISR)同步;它可以一次处理多个元素,并在稍后赶上 ISR。
这个特性,以及它们可以在静态情况下预先分配,使得环形缓冲区在许多情况下成为最佳选择。
如何做...
在这个示例中,我们将学习如何在 C++数组之上创建和使用环形缓冲区:
-
在您的工作
~/test
目录中,创建一个名为ringbuf
的子目录。 -
使用您喜欢的文本编辑器在
ringbuf
子目录中创建一个ringbuf.cpp
文件。 -
从
private
数据字段开始定义RingBuffer
类。
#include <iostream>
template<class T, size_t N>
class RingBuffer {
private:
T objects[N];
size_t read;
size_t write;
size_t queued;
public:
RingBuffer(): read(0), write(0), queued(0) {}
- 现在我们添加一个将数据推送到缓冲区的方法:
T& push() {
T& current = objects[write];
write = (write + 1) % N;
queued++;
if (queued > N) {
queued = N;
read = write;
}
return current;
}
- 接下来,我们添加一个从缓冲区中拉取数据的方法:
const T& pull() {
if (!queued) {
throw std::runtime_error("No data in the ring buffer");
}
T& current = objects[read];
read = (read + 1) % N;
queued--;
return current;
}
- 让我们添加一个小方法来检查缓冲区是否包含任何数据,并完成类的定义:
bool has_data() {
return queued != 0;
}
};
- 有了
RingBuffer
的定义,我们现在可以添加使用它的代码了。首先,让我们定义我们将要使用的数据类型:
struct Frame {
uint32_t index;
uint8_t data[1024];
};
- 其次,添加
main
函数,并定义RingBuffer
的一个实例作为其变量,以及尝试使用空缓冲区的代码:
int main() {
RingBuffer<Frame, 10> frames;
std::cout << "Frames " << (frames.has_data() ? "" : "do not ")
<< "contain data" << std::endl;
try {
const Frame& frame = frames.pull();
} catch (std::runtime_error e) {
std::cout << "Exception caught: " << e.what() << std::endl;
}
- 接下来,添加使用缓冲区中五个元素的代码:
for (size_t i = 0; i < 5; i++) {
Frame& out = frames.push();
out.index = i;
out.data[0] = 'a' + i;
out.data[1] = '\0';
}
std::cout << "Frames " << (frames.has_data() ? "" : "do not ")
<< "contain data" << std::endl;
while (frames.has_data()) {
const Frame& in = frames.pull();
std::cout << "Frame " << in.index << ": " << in.data << std::endl;
}
- 之后,添加类似的代码,处理可以添加的更多元素的情况:
for (size_t i = 0; i < 26; i++) {
Frame& out = frames.push();
out.index = i;
out.data[0] = 'a' + i;
out.data[1] = '\0';
}
std::cout << "Frames " << (frames.has_data() ? "" : "do not ")
<< "contain data" << std::endl;
while (frames.has_data()) {
const Frame& in = frames.pull();
std::cout << "Frame " << in.index << ": " << in.data << std::endl;
}
}
- 在
loop
子目录中创建一个名为CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(ringbuf)
add_executable(ringbuf ringbuf.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
-
构建应用程序,并将生成的可执行二进制文件复制到目标系统。使用第二章中的示例,设置环境。
-
切换到目标系统终端。如果需要,使用用户凭据登录。
-
运行二进制文件。
它是如何工作的...
我们将我们的环形缓冲区实现为一个模板化的 C++类,它有三个私有数据字段:
-
objects
: 类型为T
的N
个元素的静态数组 -
read
: 一个用于读取元素的索引 -
write
: 用于写入元素的索引
RingBuffer
类公开了三个公共方法:
-
push()
: 将数据写入缓冲区 -
pull()
: 从缓冲区中读取数据 -
has_data()
: 检查缓冲区是否包含数据
让我们仔细看看它们是如何工作的。
push()
方法旨在被函数用于将数据存储在缓冲区中。与动态队列或动态栈的类似push()
方法不同,后者接受一个要存储的值作为参数,我们的实现不接受任何参数。由于所有元素在编译时都是预分配的,它返回对要更新的缓冲区中的值的引用。
push()
方法的实现很简单;它通过write
索引获取对元素的指针,然后推进write
索引并增加存储在缓冲区中的元素数量。请注意,取模运算符用于在write
索引达到大小限制时将其包装到数组的开头:
T& current = objects[write];
write = (write + 1) % N;
queued++;
如果我们尝试推送的元素数量超过objects
数组的容量处理能力会发生什么?这取决于我们计划存储在缓冲区中的数据的性质。在我们的实现中,我们假设接收方对最近的数据感兴趣,并且如果它无法赶上发送方,则可以容忍中间数据的丢失。如果接收方太慢,那么在接收方read
数据之前发送方运行了多少圈都无所谓:在这一点上超过N
步的所有数据都被覆盖。这就是为什么一旦存储的元素数量超过N
,我们开始推进read
索引以及write
索引,使它们确切地相隔N
步:
if (queued > N) {
queued = N;
read = write;
}
pull()
方法由从缓冲区读取数据的函数使用。与push()
方法类似,它不接受任何参数,并返回对缓冲区中元素的引用。不过,与push()
方法不同的是,它返回一个常量引用(如下面的代码所示),以表明它不应该修改缓冲区中的数据:
const T& pull() {
首先,它检查缓冲区中是否有数据,并且如果缓冲区不包含元素,则抛出异常:
if (!queued) {
throw std::runtime_error("No data in the ring buffer");
}
它通过读取索引获取对元素的引用,然后推进read
索引,应用与push()
方法为write
索引所做的相同的取模运算符:
read = (read + 1) % N;
queued--;
has_data()
方法的实现是微不足道的。如果对象计数为零,则返回false
,否则返回true
:
bool has_data() {
return queued != 0;
}
现在,让我们尝试实际操作。我们声明一个简单的数据结构Frame
,模拟设备生成的数据。它包含一个帧索引和一个不透明的数据缓冲区:
uint32_t index;
uint8_t data[1024];
};
我们定义了一个容量为10
个frame
类型元素的环形缓冲区:
RingBuffer<Frame, 10> frames;
让我们来看看程序的输出:
首先,我们尝试从空缓冲区中读取并得到一个异常,这是预期的。
然后,我们将五个元素写入缓冲区,使用拉丁字母表的字符作为数据载荷:
for (size_t i = 0; i < 5; i++) {
Frame& out = frames.push();
out.index = i;
out.data[0] = 'a' + i;
out.data[1] = '\0';
}
注意我们如何获取对元素的引用,然后在原地更新它,而不是将frame
的本地副本推入环形缓冲区。然后我们读取缓冲区中的所有数据并将其打印在屏幕上:
while (frames.has_data()) {
const Frame& in = frames.pull();
std::cout << "Frame " << in.index << ": " << in.data << std::endl;
}
程序输出表明我们可以成功读取所有五个元素。现在我们尝试将拉丁字母表的所有 26 个字母写入数组,远远超过其容量。
for (size_t i = 0; i < 26; i++) {
Frame& out = frames.push();
out.index = i;
out.data[0] = 'a' + i;
out.data[1] = '\0';
}
然后我们以与五个元素相同的方式读取数据。读取是成功的,但我们只收到了最后写入的 10 个元素;所有其他帧都已丢失并被覆盖。对于我们的示例应用程序来说这并不重要,但对于许多其他应用程序来说可能是不可接受的。确保数据不会丢失的最佳方法是保证接收方的激活频率高于发送方。有时,如果缓冲区中没有可用数据,接收方将被激活,但这是为了避免数据丢失而可以接受的代价。
使用共享内存
在运行在支持MMU(内存管理单元)的硬件上的现代操作系统中,每个应用程序作为一个进程运行,并且其内存与其他应用程序隔离。
这种隔离带来了重要的可靠性优势。一个应用程序不能意外地破坏另一个应用程序的内存。同样,一个意外破坏自己内存并崩溃的应用程序可以被操作系统关闭,而不会影响系统中的其他应用程序。将嵌入式系统的功能解耦为几个相互通信的隔离应用程序,通过一个明确定义的 API 显著减少了实现的复杂性,从而提高了稳定性。
然而,隔离会产生成本。由于每个进程都有自己独立的地址空间,两个应用程序之间的数据交换意味着数据复制、上下文切换和使用操作系统内核同步机制,这可能是相对昂贵的。
共享内存是许多操作系统提供的一种机制,用于声明某些内存区域为共享。这样,应用程序可以在不复制数据的情况下交换数据。这对于交换大型数据对象(如视频帧或音频样本)尤为重要。
如何做...
在这个示例中,我们将学习如何使用 Linux 共享内存 API 在两个或多个应用程序之间进行数据交换。
-
在您的工作
~/test
目录中,创建一个名为shmem
的子目录。 -
使用您喜欢的文本编辑器在
shmem
子目录中创建一个shmem.cpp
文件。从常见的头文件和常量开始定义SharedMem
类:
#include <algorithm>
#include <iostream>
#include <chrono>
#include <thread>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
const char* kSharedMemPath = "/sample_point";
const size_t kPayloadSize = 16;
using namespace std::literals;
template<class T>
class SharedMem {
int fd;
T* ptr;
const char* name;
public:
- 然后,定义一个大部分工作的构造函数:
SharedMem(const char* name, bool owner=false) {
fd = shm_open(name, O_RDWR | O_CREAT, 0600);
if (fd == -1) {
throw std::runtime_error("Failed to open a shared memory region");
}
if (ftruncate(fd, sizeof(T)) < 0) {
close(fd);
throw std::runtime_error("Failed to set size of a shared memory
region");
};
ptr = (T*)mmap(nullptr, sizeof(T), PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (!ptr) {
close(fd);
throw std::runtime_error("Failed to mmap a shared memory region");
}
this->name = owner ? name : nullptr;
std::cout << "Opened shared mem instance " << name << std::endl;
}
- 添加析构函数的定义:
~SharedMem() {
munmap(ptr, sizeof(T));
close(fd);
if (name) {
std::cout << "Remove shared mem instance " << name << std::endl;
shm_unlink(name);
}
}
- 用一个小方法来完成类定义,返回一个对共享对象的引用:
T& get() const {
return *ptr;
}
};
- 我们的
SharedMem
类可以处理不同的数据类型。让我们声明一个自定义数据结构,我们想要使用:
struct Payload {
uint32_t index;
uint8_t raw[kPayloadSize];
};
- 现在添加代码,将数据写入共享内存:
void producer() {
SharedMem<Payload> writer(kSharedMemPath);
Payload& pw = writer.get();
for (int i = 0; i < 5; i++) {
pw.index = i;
std::fill_n(pw.raw, sizeof(pw.raw) - 1, 'a' + i);
pw.raw[sizeof(pw.raw) - 1] = '\0';
std::this_thread::sleep_for(150ms);
}
}
- 还要添加从共享内存中读取数据的代码:
void consumer() {
SharedMem<Payload> point_reader(kSharedMemPath, true);
Payload& pr = point_reader.get();
for (int i = 0; i < 10; i++) {
std::cout << "Read data frame " << pr.index << ": " << pr.raw << std::endl;
std::this_thread::sleep_for(100ms);
}
}
- 添加
main
函数,将所有内容联系在一起,如下面的代码所示:
int main() {
if (fork()) {
consumer();
} else {
producer();
}
}
- 在
loop
子目录中创建一个名为CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(shmem)
add_executable(shmem shmem.cpp)
target_link_libraries(shmem rt)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++14")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
-
构建应用程序,并将生成的可执行二进制文件复制到目标系统。使用第二章中的设置环境的方法来完成。
-
切换到目标系统终端。如果需要,使用用户凭据登录。
-
运行二进制文件。
它是如何工作的...
在这个示例中,我们使用POSIX(可移植操作系统接口的缩写)API 来处理共享内存。这是一个灵活和细粒度的 C API,有很多可以调整或配置的参数。我们的目标是通过在其上实现一个更方便和类型安全的 C++包装器来隐藏这个低级 API 的复杂性。我们将使用RAII(资源获取即初始化的缩写)习惯,以确保所有分配的资源都得到适当的释放,我们的应用程序中没有内存或文件描述符泄漏。
我们定义了一个模板化的SharedMem
类。模板参数定义了存储在我们的共享内存实例中的数据类型。这样,我们使SharedMem
类的实例类型安全。我们不再需要在应用程序代码中使用 void 指针和类型转换,C++编译器会自动为我们完成:
template<class T>
class SharedMem {
所有共享内存分配和初始化都在SharedMem
构造函数中实现。它接受两个参数:
-
一个共享内存对象名称
-
一个所有权标志
POSIX 定义了一个shm_open
API,其中共享内存对象由名称标识,类似于文件名。这样,使用相同名称的两个独立进程可以引用相同的共享内存对象。共享对象的生命周期是什么?当为相同的对象名称调用shm_unlink
函数时,共享对象被销毁。如果对象被多个进程使用,第一个调用shm_open
的进程将创建它,其他进程将重用相同的对象。但是它们中的哪一个负责删除它?这就是所有权标志的用途。当设置为true
时,它表示SharedMem
实例在销毁时负责共享对象的清理。
构造函数依次调用三个 POSIX API 函数。首先,它使用shm_open
创建一个共享对象。虽然该函数接受访问标志和文件权限作为参数,但我们总是使用读写访问模式和当前用户的读写访问权限:
fd = shm_open(name, O_RDWR | O_CREAT, 0600);
接下来,我们使用ftruncate
调用定义共享区域的大小。我们使用模板数据类型的大小来实现这个目的:
if (ftruncate(fd, sizeof(T)) < 0) {
最后,我们使用mmap
函数将共享区域映射到我们的进程内存地址空间。它返回一个指针,我们可以用来引用我们的数据实例:
ptr = (T*)mmap(nullptr, sizeof(T), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
该对象将文件描述符和内存区域的指针保存为其私有成员。析构函数在对象被销毁时对它们进行释放。如果设置了所有者标志,我们还保留对象名称,以便我们可以删除它:
int fd;
T* ptr;
const char* name;
SharedMem
析构函数将共享内存对象从地址空间中取消映射:
munmap(ptr, sizeof(T));
如果对象是所有者,我们可以使用shm_unlink
调用来删除它。请注意,自从名称设置为nullptr
后,我们不再需要所有者标志,除非对象是所有者:
if (name) {
std::cout << "Remove shared mem instance " << name << std::endl;
shm_unlink(name);
}
为了访问共享数据,该类提供了一个简单的get
方法。它返回存储在共享内存中的对象的引用:
T& get() const {
return *ptr;
}
让我们创建两个使用我们创建的共享内存 API 的独立进程。我们使用 POSIX 的fork
函数来生成一个子进程。子进程将是数据生产者,父进程将是数据消费者:
if (fork()) {
consumer();
} else {
producer();
}
我们定义了一个Payload
数据类型,生产者和消费者都用于数据交换:
struct Payload {
uint32_t index;
uint8_t raw[kPayloadSize];
};
数据生产者创建一个SharedMem
实例:
SharedMem<Payload> writer(kSharedMemPath);
它使用get
方法接收的引用每 150 毫秒更新一次共享对象。每次,它增加有效载荷的索引字段,并用与索引匹配的拉丁字母填充其数据。
消费者和生产者一样简单。它创建一个与生产者同名的SharedMem
实例,但它声明了对该对象的所有权。这意味着它将负责删除它,如下面的代码所示:
SharedMem<Payload> point_reader(kSharedMemPath, true);
运行应用程序并观察以下输出:
每 100 毫秒,应用程序从共享对象中读取数据并将其打印到屏幕上。在消费者输出中,我们可以看到它接收到了生产者写入的数据。由于消费者和生产者周期的持续时间不匹配,我们可以看到有时相同的数据被读取两次
在这个例子中故意省略的逻辑的一个重要部分是生产者和消费者的同步。由于它们作为独立的项目运行,不能保证生产者在消费者尝试读取数据时已经更新了任何数据。以下是我们在结果输出中看到的内容:
Opened shared mem instance /sample_point
Read data frame 0:
Opened shared mem instance /sample_point
我们可以看到,在生产者打开相同的对象之前,消费者打开了共享内存对象并读取了一些数据。
同样,当消费者尝试读取数据时,无法保证生产者是否完全更新数据字段。我们将在下一章中更详细地讨论这个话题。
还有更多...
共享内存本身是一种快速高效的进程间通信机制,但当与环形缓冲区结合时,它真正发挥作用。通过将环形缓冲区放入共享内存中,开发人员可以允许独立的数据生产者和数据消费者异步交换数据,并且同步的开销很小。
使用专用内存
嵌入式系统通常通过特定的内存地址范围提供对其外围设备的访问。当程序访问这个区域中的地址时,它不会读取或写入内存中的值。相反,数据被发送到该地址映射的设备或从该设备读取。
这种技术通常被称为MMIO(内存映射输入/输出)。在这个教程中,我们将学习如何从用户空间的 Linux 应用程序中使用 MMIO 访问 Raspberry PI 的外围设备。
如何做...
Raspberry PI 有许多外围设备可以通过 MMIO 访问。为了演示 MMIO 的工作原理,我们的应用程序将访问系统定时器:
-
在您的工作
~/test
目录中,创建一个名为timer
的子目录。 -
使用您最喜欢的文本编辑器在
timer
子目录中创建名为timer.cpp
的文件。 -
将所需的头文件、常量和类型声明放入
timer.cpp
中:
#include <iostream>
#include <chrono>
#include <system_error>
#include <thread>
#include <fcntl.h>
#include <sys/mman.h>
constexpr uint32_t kTimerBase = 0x3F003000;
struct SystemTimer {
uint32_t CS;
uint32_t counter_lo;
uint32_t counter_hi;
};
- 添加
main
函数,其中包含程序的所有逻辑:
int main() {
int memfd = open("/dev/mem", O_RDWR | O_SYNC);
if (memfd < 0) {
throw std::system_error(errno, std::generic_category(),
"Failed to open /dev/mem. Make sure you run as root.");
}
SystemTimer *timer = (SystemTimer*)mmap(NULL, sizeof(SystemTimer),
PROT_READ|PROT_WRITE, MAP_SHARED,
memfd, kTimerBase);
if (timer == MAP_FAILED) {
throw std::system_error(errno, std::generic_category(),
"Memory mapping failed");
}
uint64_t prev = 0;
for (int i = 0; i < 10; i++) {
uint64_t time = ((uint64_t)timer->counter_hi << 32) + timer->counter_lo;
std::cout << "System timer: " << time;
if (i > 0) {
std::cout << ", diff " << time - prev;
}
prev = time;
std::cout << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
return 0;
}
- 在
timer
子目录中创建一个名为CMakeLists.txt
的文件,并包含以下内容:
cmake_minimum_required(VERSION 3.5.1)
project(timer)
add_executable(timer timer.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 现在可以构建和运行应用程序了。
请注意,它应该在真正的 Raspberry PI 3 设备上以root
身份运行。
它是如何工作的...
系统定时器是一个外围设备,通过 MMIO 接口连接到处理器。这意味着它有一系列专用的物理地址,每个地址都有特定的格式和用途。
我们的应用程序使用两个 32 位值表示的计时器计数器。组合在一起,它们形成一个 64 位的只读计数器,在系统运行时始终递增。
对于 Raspberry PI 3,为系统定时器分配的物理内存地址范围的偏移量为0x3F003000
(根据 Raspberry PI 硬件版本的不同可能会有所不同)。我们将其定义为一个常量。
constexpr uint32_t kTimerBase = 0x3F003000;
为了访问区域内的各个字段,我们定义了一个SystemTimer
结构:
struct SystemTimer {
uint32_t CS;
uint32_t counter_lo;
uint32_t counter_hi;
};
现在,我们需要获取指向定时器地址范围的指针,并将其转换为指向SystemTimer
的指针。这样,我们就可以通过读取SystemTimer
的数据字段来访问计数器的地址。
然而,我们需要解决一个问题。我们知道物理地址空间中的偏移量,但我们的 Linux 应用程序在虚拟地址空间中运行。我们需要找到一种将物理地址映射到虚拟地址的方法。
Linux 通过特殊的/proc/mem
文件提供对物理内存地址的访问。由于它包含所有物理内存的快照,因此只能由root
访问。
我们使用open
函数将其作为常规文件打开:
int memfd = open("/dev/mem", O_RDWR | O_SYNC);
一旦文件打开并且我们知道它的描述符,我们就可以将其映射到我们的虚拟地址空间中。我们不需要映射整个物理内存。与定时器相关的区域就足够了,这就是为什么我们将系统定时器范围的起始位置作为偏移参数传递,将SystemTimer
结构的大小作为大小参数传递:
SystemTimer *timer = (SystemTimer*)mmap(NULL, sizeof(SystemTimer),
PROT_READ|PROT_WRITE, MAP_SHARED, memfd, kTimerBase);
现在我们可以访问定时器字段了。我们在循环中读取定时器计数器,并显示其当前值及其与前一个值的差异。当我们以root
身份运行我们的应用程序时,我们会得到以下输出:
正如我们所看到的,从这个内存地址读取返回递增的值。差值的值大约为 10,000,而且非常恒定。由于我们在计数器读取循环中添加了 10 毫秒的延迟,我们可以推断这个内存地址与定时器相关,而不是常规内存,定时器计数器的粒度为 1 微秒。
还有更多...
树莓派有许多外围设备可以通过 MMIO 访问。您可以在BCM2835 ARM 外围设备手册中找到关于它们的地址范围和访问语义的详细信息,该手册可在www.raspberrypi.org/documentation/hardware/raspberrypi/bcm2835/BCM2835-ARM-Peripherals.pdf
上找到。
请注意,开发人员在处理可以同时被多个设备访问的内存时必须非常小心。当内存可以被多个处理器或同一处理器的多个核心访问时,您可能需要使用高级同步技术,如内存屏障,以避免同步问题。我们将在下一章讨论其中一些技术。如果您使用直接内存访问(DMA)或 MMIO,情况会变得更加复杂。由于 CPU 可能不知道内存被外部硬件更改,其缓存可能不同步,导致数据一致性问题。
第七章:多线程和同步
嵌入式平台涵盖了广阔的计算能力领域。有些微控制器只有几千字节的内存;有些功能强大的系统级芯片(SoCs)有几千兆字节的内存;还有一些多核 CPU 能够同时运行许多应用程序。
随着嵌入式开发人员可用的计算资源增加,以及他们可以构建的更复杂的应用程序,多线程支持变得非常重要。开发人员需要知道如何并行化他们的应用程序,以有效地利用所有 CPU 核心。我们将学习如何编写能够以高效和安全的方式利用所有可用 CPU 核心的应用程序。
在本章中,我们将涵盖以下主题:
-
探索 C++中的线程支持
-
探索数据同步
-
使用条件变量
-
使用原子变量
-
使用 C++内存模型
-
探索无锁同步
-
在共享内存中使用原子变量
-
探索异步函数和期货
这些示例可以用作构建自己的高效多线程和多进程同步代码的示例。
探索 C++中的线程支持
在 C++11 之前,线程完全超出了 C++作为一种语言的范围。开发人员可以使用特定于平台的库,如 pthread 或 Win32 应用程序编程接口(API)。由于每个库都有自己的行为,将应用程序移植到另一个平台需要大量的开发和测试工作。
C++11 引入了线程作为 C++标准的一部分,并在其标准库中定义了一组类来创建多线程应用程序。
在这个示例中,我们将学习如何使用 C++在单个应用程序中生成多个并发线程。
如何做...
在这个示例中,我们将学习如何创建两个并发运行的工作线程。
-
在您的
〜/test
工作目录中,创建一个名为threads
的子目录。 -
使用您喜欢的文本编辑器在
threads
子目录中创建一个名为threads.cpp
的文件。将代码片段复制到threads.cpp
文件中:
#include <chrono>
#include <iostream>
#include <thread>
void worker(int index) {
for (int i = 0; i < 10; i++) {
std::cout << "Worker " << index << " begins" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
std::cout << "Worker " << index << " ends" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
int main() {
std::thread worker1(worker, 1);
std::thread worker2(worker, 2);
worker1.join();
worker2.join();
std::cout << "Done" << std::endl;
}
- 在
loop
子目录中创建一个名为CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(threads)
add_executable(threads threads.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
target_link_libraries(threads pthread)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
您可以构建并运行应用程序。
它是如何工作的...
在这个应用程序中,我们定义了一个名为worker
的函数。为了保持代码简单,它并没有做太多有用的工作,只是打印Worker X
开始和Worker X
结束 10 次,消息之间有 50 毫秒的延迟。
在main
函数中,我们创建了两个工作线程,worker1
和worker2
:
std::thread worker1(worker, 1);
std::thread worker2(worker, 2);
我们向线程构造函数传递了两个参数:
-
在线程中运行的函数。
-
函数的参数。由于我们将先前定义的
worker
函数作为线程函数传递,参数应该与其类型匹配——在我们的例子中,它是int
。
这样,我们定义了两个工作线程,它们执行相同的工作,但具有不同的索引——1
和2
。
线程一旦创建就立即开始运行;不需要调用任何额外的方法来启动它们。它们完全并行执行,正如我们从程序输出中看到的那样:
我们的工作线程的输出是混合的,有时会混乱,比如Worker Worker 1 ends2 ends
。这是因为终端的输出也是并行工作的。
由于工作线程是独立执行的,主线程在创建工作线程后没有任何事情可做。但是,如果主线程的执行达到main
函数的末尾,程序将终止。为了避免这种情况,我们为每个工作线程添加了join
方法的调用。这种方法会阻塞,直到线程终止。这样,我们只有在两个工作线程完成工作后才退出主程序。
探索数据同步
数据同步是处理多个执行线程的任何应用程序的重要方面。不同的线程经常需要访问相同的变量或内存区域。两个或更多独立线程同时写入同一内存可能导致数据损坏。即使在另一个线程更新变量时同时读取该变量也是危险的,因为在读取时它可能只被部分更新。
为了避免这些问题,并发线程可以使用所谓的同步原语,这是使对共享内存的访问变得确定和可预测的 API。
与线程支持的情况类似,C++语言在 C++11 标准之前没有提供任何同步原语。从 C++11 开始,一些同步原语被添加到 C++标准库中作为标准的一部分。
在这个配方中,我们将学习如何使用互斥锁和锁保护来同步对变量的访问。
如何做...
在前面的配方中,我们学习了如何完全并发地运行两个工作线程,并注意到这可能导致终端输出混乱。我们将修改前面配方中的代码,添加同步,使用互斥锁和锁保护,并查看区别。
-
在您的
~/test
工作目录中,创建一个名为mutex
的子目录。 -
使用您喜欢的文本编辑器在
mutex
子目录中创建一个mutex.cpp
文件。将代码片段复制到mutex.cpp
文件中:
#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
std::mutex m;
void worker(int index) {
for (int i = 0; i < 10; i++) {
{
std::lock_guard<std::mutex> g(m);
std::cout << "Worker " << index << " begins" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
std::cout << "Worker " << index << " ends" << std::endl;
}
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
int main() {
std::thread worker1(worker, 1);
std::thread worker2(worker, 2);
worker1.join();
worker2.join();
std::cout << "Done" << std::endl;
}
- 在
loop
子目录中创建一个名为CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(mutex)
add_executable(mutex mutex.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
target_link_libraries(mutex pthread)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
您可以构建并运行应用程序。
工作原理...
构建并运行应用程序后,我们可以看到其输出与线程应用程序的输出类似。但也有明显的区别:
首先,输出不会混乱。其次,我们可以看到一个清晰的顺序——没有一个工作线程被另一个工作线程中断,每个开始都后跟相应的结束。区别在于源代码的突出部分。我们创建一个全局的mutex m
:
std::mutex m;
然后,我们使用lock_guard
来保护我们的关键代码部分,从打印Worker X begins
的行开始,到打印Worker X ends
的行结束。
lock_guard
是互斥锁的包装器,它使用RAII(资源获取即初始化的缩写)技术,在构造函数中自动锁定相应的互斥锁,当定义锁对象时,它在析构函数中解锁,在其作用域结束时。这就是为什么我们添加额外的花括号来定义我们关键部分的作用域:
{
std::lock_guard<std::mutex> g(m);
std::cout << "Worker " << index << " begins" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
std::cout << "Worker " << index << " ends" << std::endl;
}
虽然可以通过调用其 lock 和 unlock 方法显式锁定和解锁互斥锁,但不建议这样做。忘记解锁已锁定的互斥锁会导致难以检测和难以调试的多线程同步问题。RAII 方法会自动解锁互斥锁,使代码更安全、更易读和更易理解。
还有更多...
正确实现线程同步需要非常注意细节和彻底分析。多线程应用程序中一个非常常见的问题是死锁。这是一种情况,其中一个线程被阻塞,因为它正在等待另一个线程,而另一个线程又被阻塞,因为它正在等待第一个线程。因此,两个线程被无限期地阻塞。
如果需要两个或更多个互斥锁进行同步,则会发生死锁。C++17 引入了std::scoped_lock,可在en.cppreference.com/w/cpp/thread/scoped_lock
上找到,这是一个多个互斥锁的 RAII 包装器,有助于避免死锁。
使用条件变量
我们学会了如何同步两个或多个线程对同一变量的同时访问。线程访问变量的特定顺序并不重要;我们只是防止了对变量的同时读写。
一个线程等待另一个线程开始处理数据是一个常见的情况。在这种情况下,当数据可用时,第二个线程应该由第一个线程通知。这可以使用条件变量来完成,C++从 C++11 标准开始支持。
在这个配方中,我们将学习如何使用条件变量在数据可用时立即激活数据处理的单独线程。
如何做...
我们将实现一个具有两个工作线程的应用程序,类似于我们在探索数据同步配方中创建的应用程序。
-
在您的
~/test
工作目录中,创建一个名为condvar
的子目录。 -
使用您喜欢的文本编辑器在
condvar
子目录中创建一个名为condv.cpp
的文件。 -
现在,在
condvar.cpp
中放置所需的头文件并定义全局变量:
#include <condition_variable>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
std::mutex m;
std::condition_variable cv;
std::vector<int> result;
int next = 0;
- 在定义全局变量之后,我们添加了我们的
worker
函数,它与前面的配方中的worker
函数类似:
void worker(int index) {
for (int i = 0; i < 10; i++) {
std::unique_lock<std::mutex> l(m);
cv.wait(l, [=]{return next == index; });
std::cout << "worker " << index << "\n";
result.push_back(index);
next = next + 1;
if (next > 2) { next = 1; };
cv.notify_all();
}
}
- 最后,我们定义我们的入口点——
main
函数:
int main() {
std::thread worker1(worker, 1);
std::thread worker2(worker, 2);
{
std::lock_guard<std::mutex> l(m);
next = 1;
}
std::cout << "Start\n";
cv.notify_all();
worker1.join();
worker2.join();
for (int e : result) {
std::cout << e << ' ';
}
std::cout << std::endl;
}
- 在
loop
子目录中创建一个名为CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
cmake_minimum_required(VERSION 3.5.1)
project(condvar)
add_executable(condvar condvar.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
target_link_libraries(condvar pthread)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
您可以构建并运行应用程序。
工作原理...
与我们在探索数据同步配方中创建的应用程序类似,我们创建了两个工作线程worker1
和worker2
,它们使用相同的worker
函数线程,只是index
参数不同。
除了向控制台打印消息外,工作线程还更新了一个全局向量 result。每个工作线程只是在其循环中将其索引添加到result
变量中,如下命令所示:
std::vector<int> result;
我们希望每个工作线程只在轮到它时将其索引添加到结果中——worker 1
,然后worker 2
,然后再次worker 1
,依此类推。没有同步是不可能做到这一点的;然而,简单的互斥同步是不够的。它可以保证两个并发线程不会同时访问代码的同一关键部分,但不能保证顺序。可能是worker 1
在worker 2
锁定之前再次锁定互斥锁。
为了解决排序问题,我们定义了一个cv
条件变量和一个next
整数变量:
std::condition_variable cv;
int next = 0;
next
变量包含一个工作线程的索引。它初始化为0
,并在main
函数中设置为特定的工作线程索引。由于这个变量被多个线程访问,我们在锁保护下进行操作:
{
std::lock_guard<std::mutex> l(m);
next = 1;
}
尽管工作线程在创建后开始执行,但它们两者立即被条件变量阻塞,等待next
变量的值与它们的索引匹配。条件变量需要std::unique_lock
进行等待。我们在调用wait
方法之前创建它:
std::unique_lock<std::mutex> l(m);
cv.wait(l, [=]{return next == index; });
虽然条件变量cv
在main
函数中设置为1
,但这还不够。我们需要显式通知等待条件变量的线程。我们使用notify_all
方法来做到这一点:
cv.notify_all();
这将唤醒所有等待的线程,它们将自己的索引与next
变量进行比较。匹配的线程解除阻塞,而所有其他线程再次进入睡眠状态。
活动线程向控制台写入消息并更新result
变量。然后,它更新next
变量以选择下一个要激活的线程。我们递增索引直到达到最大值,然后将其重置为1
:
next = next + 1;
if (next > 2) { next = 1; };
与main
函数中的代码情况类似,在决定next
线程的索引后,我们需要调用notify_all
来唤醒所有线程,并让它们决定轮到谁工作:
cv.notify_all();
在工作线程工作时,main
函数等待它们的完成:
worker1.join();
worker2.join();
当所有工作线程完成时,将打印result
变量的值:
for (int e : result) {
std::cout << e << ' ';
}
构建并运行程序后,我们得到以下输出:
正如我们所看到的,所有线程都按预期顺序激活了。
还有更多...
在这个示例中,我们只使用了条件变量对象提供的一些方法。除了简单的wait
函数外,还有一些等待特定时间或等待直到达到指定时间点的函数。在en.cppreference.com/w/cpp/thread/condition_variable
上了解更多关于C++条件变量类的信息。
使用原子变量
原子变量之所以被命名为原子变量,是因为它们不能被部分读取或写入。例如,比较Point
和int
数据类型:
struct Point {
int x, y;
};
Point p{0, 0};
int b = 0;
p = {10, 10};
b = 10;
在这个例子中,修改p
变量相当于两次赋值:
p.x = 10;
p.y = 10;
这意味着任何并发线程读取p
变量时可能会得到部分修改的数据,比如x=10
,y=0
,这可能导致难以检测和难以重现的错误计算。这就是为什么对这种数据类型的访问应该是同步的。
那么b
变量呢?它能被部分修改吗?答案是:取决于平台。然而,C++提供了一组数据类型和模板,以确保变量作为一个整体原子地一次性改变。
在这个示例中,我们将学习如何使用原子变量来同步多个线程。由于原子变量不能被部分修改,因此不需要使用互斥锁或其他昂贵的同步原语。
如何做...
我们将创建一个应用程序,生成两个工作线程来并发更新一个数据数组。我们将使用原子变量而不是互斥锁,以确保并发更新是安全的。
-
在你的
~/test
工作目录中,创建一个名为atomic
的子目录。 -
使用你喜欢的文本编辑器在
atomic
子目录中创建一个名为atomic.cpp
的文件。 -
现在,我们放置所需的头文件,并在
atomic.cpp
中定义全局变量:
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
#include <vector>
std::atomic<size_t> shared_index{0};
std::vector<int> data;
- 在定义全局变量之后,我们添加我们的
worker
函数。它类似于之前示例中的worker
函数,但除了一个index
之外,它还有一个额外的参数timeout
:
void worker(int index, int timeout) {
while(true) {
size_t worker_index = shared_index.fetch_add(1);
if (worker_index >= data.size()) {
break;
}
std::cout << "Worker " << index << " handles "
<< worker_index << std::endl;
data[worker_index] = data[worker_index] * 2;
std::this_thread::sleep_for(std::chrono::milliseconds(timeout));
}
}
- 最后,我们定义我们的入口点——
main
函数:
int main() {
for (int i = 0; i < 10; i++) {
data.emplace_back(i);
}
std::thread worker1(worker, 1, 50);
std::thread worker2(worker, 2, 20);
worker1.join();
worker2.join();
std::cout << "Result: ";
for (auto& v : data) {
std::cout << v << ' ';
}
std::cout << std::endl;
}
- 在
loop
子目录中创建一个名为CMakeLists.txt
的文件,并包含以下内容:
cmake_minimum_required(VERSION 3.5.1)
project(atomic)
add_executable(atomic atomic.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
target_link_libraries(atomic pthread)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
你可以构建并运行应用程序。
工作原理...
我们正在创建一个应用程序,使用多个工作线程更新数组的所有元素。对于昂贵的更新操作,这种方法可以在多核平台上实现显著的性能提升。
困难在于在多个工作线程之间共享工作,因为它们每个可能需要不同的时间来处理数据元素。
我们使用一个shared_index
原子变量来存储尚未被任何工作线程声明的下一个元素的索引。这个变量,以及要处理的数组,被声明为全局变量:
std::atomic<size_t> shared_index{0};
std::vector<int> data;
我们的worker
函数类似于之前的示例中的worker
函数,但有重要的区别。首先,它有一个额外的参数timeout
。这用于模拟处理每个元素所需的时间差异。
其次,我们的工作线程不是在固定次数的迭代中运行,而是在一个循环中运行,直到shared_index
变量达到最大值。这表示所有元素都已被处理,工作线程可以终止。
在每次迭代中,一个工作线程读取shared_index
的值。如果有要处理的元素,它将shared_index
变量的值存储在一个本地的worker_index
变量中,并同时增加shared_index
变量。
虽然可以像使用常规变量一样使用原子变量——首先获取其当前值,然后增加变量——但这可能导致竞争条件。两个工作线程几乎同时读取变量。在这种情况下,它们都获得相同的值,然后开始处理相同的元素,相互干扰。这就是为什么我们使用特殊的fetch_add
方法,它增加变量并返回增加之前的值作为单个、不可中断的操作:
size_t worker_index = shared_index.fetch_add(1);
如果worker_index
变量达到数组的大小,这意味着所有元素都已经处理完毕,工作线程可以终止:
if (worker_index >= data.size()) {
break;
}
如果worker_index
变量有效,则工作线程将使用它来更新数组元素的值。在我们的情况下,我们只是将它乘以2
:
data[worker_index] = data[worker_index] * 2;
为了模拟昂贵的数据操作,我们使用自定义延迟。延迟的持续时间由timeout
参数确定:
std::this_thread::sleep_for(std::chrono::milliseconds(timeout));
在main
函数中,我们向数据向量中添加要处理的元素。我们使用循环将向量填充为从零到九的数字:
for (int i = 0; i < 10; i++) {
data.emplace_back(i);
}
初始数据集准备好后,我们创建两个工作线程,提供index
和timeout
参数。使用工作线程的不同超时来模拟不同的性能:
std::thread worker1(worker, 1, 50);
std::thread worker2(worker, 2, 20);
然后,我们等待两个工作线程完成它们的工作,并将结果打印到控制台。当我们构建和运行我们的应用程序时,我们会得到以下输出:
正如我们所看到的,Worker 2
处理的元素比Worker 1
多,因为它的超时是 20 毫秒,而Worker 1
是 50 毫秒。此外,所有元素都按预期进行处理,没有遗漏和重复。
还有更多...
我们学会了如何处理整数原子变量。虽然这种类型的原子变量是最常用的,但 C++也允许定义其他类型的原子变量,包括非整数类型,只要它们是平凡可复制的、可复制构造的和可复制赋值的。
除了我们在示例中使用的fetch_add
方法,原子变量还有其他类似的方法,可以帮助开发人员在单个操作中查询值和修改变量。考虑使用这些方法来避免竞争条件或使用互斥锁进行昂贵的同步。
在 C++20 中,原子变量获得了wait
、notify_all
和notify_one
方法,类似于条件变量的方法。它们允许使用更高效、轻量级的原子变量来实现以前需要条件变量的逻辑。
有关原子变量的更多信息,请访问en.cppreference.com/w/cpp/atomic/atomic
。
使用 C++内存模型
从 C++11 标准开始,C++定义了线程和同步的 API 和原语作为语言的一部分。在具有多个处理器核心的系统中进行内存同步是复杂的,因为现代处理器可以通过重新排序指令来优化代码执行。即使使用原子变量,也不能保证数据按预期顺序修改或访问,因为编译器可以改变顺序。
为了避免歧义,C++11 引入了内存模型,定义了对内存区域的并发访问行为。作为内存模型的一部分,C++定义了std::memory_order
枚举,它向编译器提供有关预期访问模型的提示。这有助于编译器以不干扰预期代码行为的方式优化代码。
在这个示例中,我们将学习如何使用最简单的std::memory_order
枚举来实现一个共享计数器变量。
如何做...
我们正在实现一个应用程序,其中有一个共享计数器,由两个并发的工作线程递增。
-
在您的
~/test
工作目录中,创建一个名为memorder
的子目录。 -
使用您喜欢的文本编辑器在
atomic
子目录中创建一个memorder.cpp
文件。 -
现在,我们在
memorder.cpp
中放置所需的头文件并定义全局变量:
#include <atomic>
#include <chrono>
#include <iostream>
#include <thread>
#include <vector>
std::atomic<bool> running{true};
std::atomic<int> counter{0};
- 全局变量定义后,我们添加我们的
worker
函数。该函数只是递增一个计数器,然后休眠一段特定的时间间隔:
void worker() {
while(running) {
counter.fetch_add(1, std::memory_order_relaxed);
}
}
- 然后,我们定义我们的
main
函数:
int main() {
std::thread worker1(worker);
std::thread worker2(worker);
std::this_thread::sleep_for(std::chrono::seconds(1));
running = false;
worker1.join();
worker2.join();
std::cout << "Counter: " << counter << std::endl;
}
- 在
loop
子目录中创建一个名为CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(memorder)
add_executable(memorder memorder.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
target_link_libraries(memorder pthread)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
您可以构建和运行应用程序。
工作原理...
在我们的应用程序中,我们将创建两个工作线程,它们将递增一个共享计数器,并让它们运行一段特定的时间。
首先,我们定义两个全局原子变量running
和counter
:
std::atomic<bool> running{true};
std::atomic<int> counter{0};
running
变量是一个二进制标志。当它设置为true
时,工作线程应该继续运行。在它变为false
后,工作线程应该终止。
counter
变量是我们的共享计数器。工作线程将同时递增它。我们使用了在使用原子变量示例中已经使用过的fetch_add
方法。它用于原子地递增一个变量。在这个示例中,我们将额外的参数std::memory_order_relaxed
传递给这个方法:
counter.fetch_add(1, std::memory_order_relaxed);
这个参数是一个提示。虽然原子性和修改的一致性对于计数器的实现很重要并且应该得到保证,但并发内存访问之间的顺序并不那么重要。std::memory_order_relaxed
为原子变量定义了这种内存访问。将其传递给fetch_add
方法允许我们为特定目标平台进行微调,以避免不必要的同步延迟,从而影响性能。
在main
函数中,我们创建两个工作线程:
std::thread worker1(worker);
std::thread worker2(worker);
然后,主线程暂停 1 秒。暂停后,主线程将running
变量的值设置为false
,表示工作线程应该终止。
running = false;
工作线程终止后,我们打印计数器的值:
生成的计数器值由传递给worker
函数的超时间隔确定。在我们的示例中,更改fetch_add
方法中的内存顺序类型不会导致结果值的明显变化。但是,它可以提高使用原子变量的高并发应用程序的性能,因为编译器可以重新排序并发线程中的操作而不会破坏应用程序逻辑。这种优化高度依赖于开发人员的意图,并且不能在没有开发人员提示的情况下自动推断。
还有更多...
C++内存模型和内存排序类型是复杂的主题,需要深入了解现代 CPU 如何访问内存并优化其代码执行。C++内存模型参考,en.cppreference.com/w/cpp/language/memory_model
提供了大量信息,是学习多线程应用程序优化的高级技术的良好起点。
探索无锁同步
在前面的示例中,我们学习了如何使用互斥锁和锁同步多个线程对共享数据的访问。如果多个线程尝试运行由锁保护的代码的关键部分,只有一个线程可以一次执行。所有其他线程都必须等待,直到该线程离开关键部分。
然而,在某些情况下,可以在没有互斥锁和显式锁的情况下同步对共享数据的访问。其思想是使用数据的本地副本进行修改,然后在单个、不可中断和不可分割的操作中更新共享副本。
这种类型的同步取决于硬件。目标处理器应该提供某种形式的比较和交换(CAS)指令。这检查内存位置中的值是否与给定值匹配,并且仅当它们匹配时才用新给定值替换它。由于它是单处理器指令,它不会被上下文切换中断。这使它成为更复杂的原子操作的基本构建块。
在本教程中,我们将学习如何检查原子变量是否是无锁的,或者是使用互斥体或其他锁定操作实现的。我们还将根据 C++11 中的原子比较交换函数的示例实现一个无锁推送操作,该示例可在en.cppreference.com/w/cpp/atomic/atomic_compare_exchange
上找到。
如何做...
我们正在实现一个简单的Stack
类,它提供了一个构造函数和一个名为Push
的函数。
-
在您的
~/test
工作目录中,创建一个名为lockfree
的子目录。 -
使用您喜欢的文本编辑器在
lockfree
子目录中创建一个名为lockfree.cpp
的文件。 -
现在,我们放入所需的头文件,并在
lockfree.cpp
文件中定义一个Node
辅助数据类型:
#include <atomic>
#include <iostream>
struct Node {
int data;
Node* next;
};
- 接下来,我们定义一个简单的
Stack
类。这使用Node
数据类型来组织数据存储:
class Stack {
std::atomic<Node*> head;
public:
Stack() {
std::cout << "Stack is " <<
(head.is_lock_free() ? "" : "not ")
<< "lock-free" << std::endl;
}
void Push(int data) {
Node* new_node = new Node{data, nullptr};
new_node->next = head.load();
while(!std::atomic_compare_exchange_weak(
&head,
&new_node->next,
new_node));
}
};
- 最后,我们定义一个简单的
main
函数,创建一个Stack
实例并将一个元素推入其中:
int main() {
Stack s;
s.Push(1);
}
- 在
loop
子目录中创建一个名为CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(lockfree)
add_executable(lockfree lockfree.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
target_link_libraries(lockfree pthread)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
您可以构建并运行应用程序。
工作原理...
我们创建了一个简单的应用程序,实现了一个整数值的简单堆栈。我们将堆栈的元素存储在动态内存中,对于每个元素,我们应该能够确定其后面的元素。
为此,我们定义了一个Node
辅助结构,它有两个数据字段。data
字段存储元素的实际值,而next
字段是堆栈中下一个元素的指针:
int data;
Node* next;
然后,我们定义Stack
类。通常,堆栈意味着两个操作:
-
Push
:将一个元素放在堆栈顶部 -
Pull
:从堆栈顶部获取一个元素
为了跟踪堆栈的顶部,我们创建一个top
变量,它保存指向Node
对象的指针。它将是我们堆栈的顶部:
std::atomic<Node*> head;
我们还定义了一个简单的构造函数,它初始化了我们的top
变量的值,并检查它是否是无锁的。在 C++中,原子变量可以使用原子一致性、可用性和分区容错性(CAP)操作或使用常规互斥体来实现。这取决于目标 CPU:
(head.is_lock_free() ? "" : "not ")
在我们的应用程序中,我们只实现了Push
方法,以演示如何以无锁的方式实现它。
Push
方法接受要放在堆栈顶部的值。为此,我们创建一个新的Node
对象的实例:
Node* new_node = new Node{data, nullptr};
由于我们将元素放在堆栈的顶部,新创建的实例的指针应该分配给top
变量,并且应该将top
变量的旧值分配给我们的新Node
对象的next
指针。
然而,直接这样做是不安全的。两个或更多线程可以同时修改top
变量,导致数据损坏。我们需要某种数据同步。我们可以使用锁和互斥体来做到这一点,但也可以以无锁的方式来实现。
这就是为什么我们最初只更新下一个指针。由于我们的新Node
对象还不是堆栈的一部分,所以我们可以在没有同步的情况下执行,因为其他线程无法访问它:
new_node->next = head.load();
现在,我们需要将其添加为堆栈的新top
变量。我们使用std::atomic_compare_exchange_weak
函数进行循环:
while(!std::atomic_compare_exchange_weak(
&head,
&new_node->next,
new_node));
此函数将top
变量的值与新元素的next
指针中存储的值进行比较。如果它们匹配,则将top
变量的值替换为新节点的指针并返回true
。否则,它将top
变量的值写入新元素的next
指针并返回false
。由于我们在下一步中更新了next
指针以匹配top
变量,这只能发生在另一个线程在调用std::atomic_compare_exchange_weak
函数之前修改了它。最终,该函数将返回true
,表示top
头部已更新为指向我们的元素的指针。
main
函数创建一个堆栈的实例,并将一个元素推入其中。在输出中,我们可以看到底层实现是否是无锁的:
对于我们的目标,实现是无锁的。
还有更多...
无锁同步是一个非常复杂的话题。开发无锁数据结构和算法需要大量的工作。即使是使用无锁操作实现简单的Push
逻辑也不容易理解。对于代码的适当分析和调试需要更大的努力。通常,这可能导致难以注意和难以实现的微妙问题。
尽管无锁算法的实现可以提高应用程序的性能,但考虑使用现有的无锁数据结构库之一,而不是编写自己的库。例如,Boost.Lockfree提供了一系列无锁数据类型供您使用。
在共享内存中使用原子变量
我们学会了如何使用原子变量来同步多线程应用程序中的两个或多个线程。但是,原子变量也可以用于同步作为独立进程运行的独立应用程序。
我们已经知道如何在两个应用程序之间交换数据使用共享内存。现在,我们可以结合这两种技术——共享内存和原子变量——来实现两个独立应用程序的数据交换和同步。
如何做...
在这个示例中,我们将修改我们在第六章中创建的应用程序,内存管理,用于在两个处理器之间使用共享内存区域交换数据。
-
在您的
~/test
工作目录中,创建一个名为shmatomic
的子目录。 -
使用您喜欢的文本编辑器在
shmatomic
子目录中创建一个名为shmatomic.cpp
的文件。 -
我们重用了我们在
shmem
应用程序中创建的共享内存数据结构。将公共头文件和常量放入shmatomic.cpp
文件中:
#include <atomic>
#include <iostream>
#include <chrono>
#include <thread>
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
const char* kSharedMemPath = "/sample_point";
- 接下来,开始定义模板化的
SharedMem
类:
template<class T>
class SharedMem {
int fd;
T* ptr;
const char* name;
public:
- 该类将有一个构造函数,一个析构函数和一个 getter 方法。让我们添加构造函数:
SharedMem(const char* name, bool owner=false) {
fd = shm_open(name, O_RDWR | O_CREAT, 0600);
if (fd == -1) {
throw std::runtime_error("Failed to open a shared
memory region");
}
if (ftruncate(fd, sizeof(T)) < 0) {
close(fd);
throw std::runtime_error("Failed to set size of a shared
memory region");
};
ptr = (T*)mmap(nullptr, sizeof(T), PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (!ptr) {
close(fd);
throw std::runtime_error("Failed to mmap a shared memory
region");
}
this->name = owner ? name : nullptr;
}
- 接下来是简单的析构函数和 getter:
~SharedMem() {
munmap(ptr, sizeof(T));
close(fd);
if (name) {
std::cout << "Remove shared mem instance " << name << std::endl;
shm_unlink(name);
}
}
T& get() const {
return *ptr;
}
};
- 现在,我们定义要用于数据交换和同步的数据类型:
struct Payload {
std::atomic_bool data_ready;
std::atomic_bool data_processed;
int index;
};
- 接下来,我们定义一个将生成数据的函数:
void producer() {
SharedMem<Payload> writer(kSharedMemPath);
Payload& pw = writer.get();
if (!pw.data_ready.is_lock_free()) {
throw std::runtime_error("Flag is not lock-free");
}
for (int i = 0; i < 10; i++) {
pw.data_processed.store(false);
pw.index = i;
pw.data_ready.store(true);
while(!pw.data_processed.load());
}
}
- 接下来是消耗数据的函数:
void consumer() {
SharedMem<Payload> point_reader(kSharedMemPath, true);
Payload& pr = point_reader.get();
if (!pr.data_ready.is_lock_free()) {
throw std::runtime_error("Flag is not lock-free");
}
for (int i = 0; i < 10; i++) {
while(!pr.data_ready.load());
pr.data_ready.store(false);
std::cout << "Processing data chunk " << pr.index << std::endl;
pr.data_processed.store(true);
}
}
- 最后,我们添加我们的
main
函数,将所有内容联系在一起:
int main() {
if (fork()) {
consumer();
} else {
producer();
}
}
- 在
loop
子目录中创建一个名为CMakeLists.txt
的文件,并包含以下内容:
cmake_minimum_required(VERSION 3.5.1)
project(shmatomic)
add_executable(shmatomic shmatomic.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
target_link_libraries(shmatomic pthread rt)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
您可以构建并运行应用程序。
工作原理...
在我们的应用程序中,我们重用了我们在第六章中介绍的模板化的SharedMem
类,内存管理。该类用于在共享内存区域中存储特定类型的元素。让我们快速回顾一下它的工作原理。
SharedMem
类是可移植操作系统接口(POSIX)共享内存 API 的包装器。它定义了三个私有数据字段来保存特定于系统的处理程序和指针,并公开由两个函数组成的公共接口:
-
一个接受共享区域名称和所有权标志的构造函数
-
一个
get
方法,返回存储在共享内存中的对象的引用
该类还定义了一个析构函数,执行所有必要的操作以正确关闭共享对象。因此,SharedMem
类可以用于使用 C++ RAII 习语进行安全资源管理。
SharedMem
类是一个模板类。它由我们想要存储在共享内存中的数据类型参数化。为此,我们定义了一个名为Payload
的结构:
struct Payload {
std::atomic_bool data_ready;
std::atomic_bool data_processed;
int index;
};
它有一个index
整数变量,我们将使用它作为数据交换字段,并且有两个原子布尔标志,data_ready
和data_processed
,用于数据同步。
我们还定义了两个函数,producer
和consumer
,它们将在单独的进程中工作,并使用共享内存区域相互交换数据。
producer
函数正在生成数据块。首先,它创建了SharedMem
类的一个实例,由Payload
数据类型参数化。它将共享内存区域的路径传递给SharedMem
构造函数:
SharedMem<Payload> writer(kSharedMemPath);
创建共享内存实例后,它获取对存储在其中的有效负载数据的引用,并检查我们在Payload
数据类型中定义的任何原子标志是否是无锁定的:
if (!pw.data_ready.is_lock_free()) {
throw std::runtime_error("Flag is not lock-free");
}
该函数在循环中生成 10 个数据块。数据块的索引被放入有效负载的index
字段中:
pw.index = i;
但是,除了将数据放入共享内存中,我们还需要同步对这些数据的访问。这就是我们使用原子标志的时候。
对于每次迭代,在更新index
字段之前,我们重置data_processed
标志。更新索引后,我们设置data ready
标志,这是向消费者指示新的数据块已准备就绪的指示器,并等待数据被消费者处理。我们循环直到data_processed
标志变为true
,然后进入下一个迭代:
pw.data_ready.store(true);
while(!pw.data_processed.load());
consumer
函数的工作方式类似。由于它在一个单独的进程中工作,它通过使用相同的路径创建SharedMem
类的实例来打开相同的共享内存区域。我们还使consumer
函数成为共享内存实例的所有者。这意味着它负责在SharedMem
实例被销毁后删除共享内存区域:
SharedMem<Payload> point_reader(kSharedMemPath, true);
与producer
函数类似,consumer
函数检查原子标志是否是无锁定的,并进入数据消耗的循环。
对于每次迭代,它在一个紧密的循环中等待直到数据准备就绪:
while(!pr.data_ready.load());
在producer
函数将data_ready
标志设置为true
后,consumer
函数可以安全地读取和处理数据。在我们的实现中,它只将index
字段打印到控制台。处理完数据后,consumer
函数通过将data_processed
标志设置为true
来指示这一点:
pr.data_processed.store(true);
这触发了producer
函数端的数据生产的下一个迭代:
结果,我们可以看到处理的数据块的确定性输出,没有遗漏或重复;这在数据访问不同步的情况下很常见。
探索异步函数和期货
在多线程应用程序中处理数据同步是困难的,容易出错,并且需要开发人员编写大量代码来正确对齐数据交换和数据通知。为了简化开发,C++11 引入了一种标准 API,以一种类似于常规同步函数调用的方式编写异步代码,并在底层隐藏了许多同步复杂性。
在这个示例中,我们将学习如何使用异步函数调用和期货在多个线程中运行我们的代码,几乎不需要额外的工作来进行数据同步。
如何做到这一点...
我们将实现一个简单的应用程序,调用一个长时间运行的函数,并等待其结果。在函数运行时,应用程序可以继续进行其他计算。
-
在您的
~/test
工作目录中,创建一个名为async
的子目录。 -
使用您喜欢的文本编辑器在
async
子目录中创建一个名为async.cpp
的文件。 -
将我们的应用程序代码放入
async.cpp
文件中,从公共头文件和我们的长时间运行的函数开始:
#include <chrono>
#include <future>
#include <iostream>
int calculate (int x) {
auto start = std::chrono::system_clock::now();
std::cout << "Start calculation\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
auto delta = std::chrono::system_clock::now() - start;
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(delta);
std::cout << "Done in " << ms.count() << " ms\n";
return x*x;
}
- 接下来,添加
test
函数,调用长时间运行的函数:
void test(int value, int worktime) {
std::cout << "Request result of calculations for " << value << std::endl;
std::future<int> fut = std::async (calculate, value);
std::cout << "Keep working for " << worktime << " ms" << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(worktime));
auto start = std::chrono::system_clock::now();
std::cout << "Waiting for result" << std::endl;
int result = fut.get();
auto delta = std::chrono::system_clock::now() - start;
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(delta);
std::cout << "Result is " << result
<< ", waited for " << ms.count() << " ms"
<< std::endl << std::endl;
}
- 最后,添加一个最简单的
main
函数:
int main ()
{
test(5, 400);
test(8, 1200);
return 0;
}
- 在
loop
子目录中创建一个名为CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(async)
add_executable(async async.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++14")
target_link_libraries(async pthread -static-libstdc++)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
您可以构建并运行应用程序。
工作原理...
在我们的应用程序中,我们定义了一个calculate
函数,应该需要很长时间才能运行。从技术上讲,我们的函数计算整数参数的平方,但我们添加了人为的延迟,使其运行 1 秒钟。我们使用sleep_for
标准库函数来为应用程序添加延迟:
std::this_thread::sleep_for(std::chrono::seconds(1));
除了计算,该函数还在控制台记录了开始工作时的时间,完成时的时间以及花费的时间。
接下来,我们定义了一个test
函数,调用calculate
函数,以演示异步调用的工作原理。
该函数有两个参数。第一个参数是传递给calculate
函数的值。第二个参数是在运行calculate
函数后并在请求结果之前,test
函数将花费的时间。这样,我们模拟了函数可以在并行计算中执行的有用工作。
test
函数通过异步模式运行calculate
函数,并传递第一个参数value
:
std::future<int> fut = std::async (calculate, value);
async
函数隐式地生成一个线程,并开始执行calculate
函数。
由于我们异步运行函数,结果还没有准备好。相反,async
函数返回一个std::future
的实例,一个在结果可用时将保存结果的对象。
接下来,我们模拟有用的工作。在我们的情况下,这是指定时间间隔的暂停。在可以并行完成的工作完成后,我们需要获取calculate
函数的结果才能继续。为了请求结果,我们使用std::future
对象的get
方法,如下所示:
int result = fut.get();
get
方法会阻塞,直到结果可用。然后,我们可以计算等待结果的时间,并将结果以及等待时间输出到控制台。
在main
函数中,我们运行test
函数来评估两种情况:
-
有用的工作所花费的时间比计算结果的时间更短。
-
有用的工作所花费的时间比计算结果的时间更长。
运行应用程序会产生以下输出。
在第一种情况下,我们可以看到我们开始计算,然后在计算完成之前开始等待结果。结果,get
方法阻塞了 600 毫秒,直到结果准备就绪:
在第二种情况下,有用的工作花费了1200
毫秒。正如我们所看到的,计算在结果被请求之前就已经完成了,因此get
方法没有阻塞,立即返回了结果。
还有更多...
期货和异步函数提供了一个强大的机制来编写并行和易懂的代码。异步函数是灵活的,支持不同的执行策略。Promise 是另一种机制,使开发人员能够克服异步编程的复杂性。更多信息可以在std::future
的参考页面找到[en.cppreference.com/w/cpp/thread/future
], std::promise
的参考页面[en.cppreference.com/w/cpp/thread/promise
], 以及std::async
的参考页面[en.cppreference.com/w/cpp/thread/async
]。
第八章:通信和序列化
复杂的嵌入式系统很少由单个应用程序组成。将所有逻辑放在同一个应用程序中是脆弱的、容易出错的,有时甚至难以实现,因为系统的不同功能可能由不同的团队甚至不同的供应商开发。这就是为什么将函数的逻辑隔离在独立的应用程序中,并使用明确定义的协议相互通信是一种常见的方法,用于扩展嵌入式软件。此外,这种隔离可以通过最小的修改与托管在远程系统上的应用程序通信,使其更具可扩展性。我们将学习如何通过将其逻辑分割为相互通信的独立组件来构建健壮和可扩展的应用程序。
在本章中,我们将涵盖以下主题:
-
在应用程序中使用进程间通信
-
探索进程间通信的机制
-
学习消息队列和发布-订阅模型
-
使用 C++ lambda 进行回调
-
探索数据序列化
-
使用 FlatBuffers 库
本章中的示例将帮助您了解可扩展和平台无关的数据交换的基本概念。它们可以用于实现从嵌入式系统到云端或远程后端的数据传输,或者使用微服务架构设计嵌入式系统。
在应用程序中使用进程间通信
大多数现代操作系统使用底层硬件平台提供的内存虚拟化支持,以将应用程序进程彼此隔离。
每个进程都有自己完全独立于其他应用程序的虚拟地址空间。这为开发人员带来了巨大的好处。由于应用程序的地址进程是独立的,一个应用程序不能意外地破坏另一个应用程序的内存。因此,一个应用程序的失败不会影响整个系统。由于所有其他应用程序都在继续工作,系统可以通过重新启动失败的应用程序来恢复。
内存隔离的好处是有代价的。由于一个进程无法访问另一个进程的内存,它需要使用专用的应用程序编程接口(API)进行数据交换,或者由操作系统提供的进程间通信(IPC)。
在这个示例中,我们将学习如何使用共享文件在两个进程之间交换信息。这可能不是最高效的机制,但它是无处不在的,易于使用,并且对于各种实际用例来说足够好。
如何做...
在这个示例中,我们将创建一个示例应用程序,创建两个进程。一个进程生成数据,而另一个读取数据并将其打印到控制台:
-
在您的工作目录(
~/test
)中,创建一个名为ipc1
的子目录。 -
使用您喜欢的文本编辑器在
ipc1
子目录中创建一个名为ipc1.cpp
的文件。 -
我们将定义两个模板类来组织我们的数据交换。第一个类
Writer
用于将数据写入文件。让我们将其定义放在ipc1.cpp
文件中:
#include <fstream>
#include <iostream>
#include <thread>
#include <vector>
#include <unistd.h>
std::string kSharedFile = "/tmp/test.bin";
template<class T>
class Writer {
private:
std::ofstream out;
public:
Writer(std::string& name):
out(name, std::ofstream::binary) {}
void Write(const T& data) {
out.write(reinterpret_cast<const char*>(&data), sizeof(T));
}
};
- 接下来是
Reader
类的定义,它负责从文件中读取数据:
template<class T>
class Reader {
private:
std::ifstream in;
public:
Reader(std::string& name) {
for(int count=10; count && !in.is_open(); count--) {
in.open(name, std::ifstream::binary);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
T Read() {
int count = 10;
for (;count && in.eof(); count--) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
T data;
in.read(reinterpret_cast<char*>(&data), sizeof(data));
if (!in) {
throw std::runtime_error("Failed to read a message");
}
return data;
}
};
- 接下来,我们定义将用于我们数据的数据类型:
struct Message {
int x, y;
};
std::ostream& operator<<(std::ostream& o, const Message& m) {
o << "(x=" << m.x << ", y=" << m.y << ")";
}
- 为了将所有内容整合在一起,我们定义了
DoWrites
和DoReads
函数,以及调用它们的main
函数:
void DoWrites() {
std::vector<Message> messages {{1, 0}, {0, 1}, {1, 1}, {0, 0}};
Writer<Message> writer(kSharedFile);
for (const auto& m : messages) {
std::cout << "Write " << m << std::endl;
writer.Write(m);
}
}
void DoReads() {
Reader<Message> reader(kSharedFile);
try {
while(true) {
std::cout << "Read " << reader.Read() << std::endl;
}
} catch (const std::runtime_error& e) {
std::cout << e.what() << std::endl;
}
}
int main(int argc, char** argv) {
if (fork()) {
DoWrites();
} else {
DoReads();
}
}
- 最后,创建一个包含程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(ipc1)
add_executable(ipc1 ipc1.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
现在可以构建和运行应用程序了。
工作原理...
在我们的应用程序中,我们探索了在文件系统中使用共享文件在两个独立进程之间进行数据交换。一个进程向文件写入数据,另一个从同一文件读取数据。
文件可以存储任何非结构化的字节序列。在我们的应用程序中,我们利用 C++模板的能力来处理严格类型化的 C++值,而不是原始字节流。这种方法有助于编写干净且无错误的代码。
我们从Write
类的定义开始。它是标准 C++ fstream
类的简单包装,用于文件输入/输出。该类的构造函数只打开一个文件流以进行以下写入:
Writer(std::string& name):
out(name, std::ofstream::binary) {}
除了构造函数,该类只包含一个名为Write
的方法,负责向文件写入数据。由于文件 API 操作的是字节流,我们首先需要将我们的模板数据类型转换为原始字符缓冲区。我们可以使用 C++的reinterpret_cast
来实现这一点:
out.write(reinterpret_cast<const char*>(&data), sizeof(T));
Reader
类的工作与Writer
类相反——它读取Writer
类写入的数据。它的构造函数稍微复杂一些。由于数据文件可能在创建Reader
类的实例时还没有准备好,构造函数会尝试在循环中打开它,直到成功打开为止。它会尝试 10 次,每次间隔 10 毫秒:
for(int count=10; count && !in.is_open(); count--) {
in.open(name, std::ifstream::binary);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
Read
方法从输入流中读取数据到临时值,并将其返回给调用者。与Write
方法类似,我们使用reinterpret_cast
来访问我们的数据对象的内存作为原始字符缓冲区:
in.read(reinterpret_cast<char*>(&data), sizeof(data));
我们还在Read
方法中添加了一个等待循环,等待Write
写入数据。如果我们到达文件的末尾,我们等待最多 1 秒钟获取新数据:
for (;count && in.eof(); count--) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
如果此时文件中没有可用的数据,或者出现 I/O 错误,我们会抛出异常来指示它:
if (!in) {
throw std::runtime_error("Failed to read a message");
}
请注意,我们不需要添加任何代码来处理文件在 1 秒内无法打开的情况,或者数据在一秒内不准备好的情况。这两种情况都由前面的代码处理。
现在Writer
和Reader
类已经实现,我们可以为我们的数据交换定义一个数据类型。在我们的应用程序中,我们将交换坐标,表示为x
和y
的整数值。我们的数据消息看起来像这样:
struct Message {
int x, y;
};
为了方便起见,我们重写了Message
结构的<<
运算符。每当Message
的实例被写入输出流时,它都会被格式化为(x, y)
:
std::ostream& operator<<(std::ostream& o, const Message& m) {
o << "(x=" << m.x << ", y=" << m.y << ")";
}
准备工作已经就绪,让我们编写数据交换的函数。DoWrites
函数定义了一个包含四个坐标的向量,并创建了一个Writer
对象:
std::vector<Message> messages {{1, 0}, {0, 1}, {1, 1}, {0, 0}};
Writer<Message> writer(kSharedFile);
然后,它在循环中写入所有的坐标:
for (const auto& m : messages) {
std::cout << "Write " << m << std::endl;
writer.Write(m);
}
DoReads
函数创建一个Reader
类的实例,使用与之前的Writer
实例相同的文件名。它进入一个无限循环,尝试读取文件中的所有消息:
while(true) {
std::cout << "Read " << reader.Read() << std::endl;
}
当没有更多的消息可用时,Read
方法会抛出一个异常来中断循环:
} catch (const std::runtime_error& e) {
std::cout << e.what() << std::endl;
}
main
函数创建了两个独立的进程,在其中一个进程中运行DoWrites
,在另一个进程中运行DoReads
。运行应用程序后,我们得到以下输出:
正如我们所看到的,写入者确实写入了四个坐标,读取者能够使用共享文件读取相同的四个坐标。
还有更多...
我们设计应用程序尽可能简单,专注于严格类型化的数据交换,并将数据同步和数据序列化排除在范围之外。我们将使用这个应用程序作为更高级技术的基础,这些技术将在接下来的示例中描述。
探索进程间通信的机制
现代操作系统提供了许多 IPC 机制,除了我们已经了解的共享文件之外,还有以下机制:
-
管道
-
命名管道
-
本地套接字
-
网络套接字
-
共享内存
有趣的是,其中许多提供的 API 与我们在使用常规文件时使用的 API 完全相同。因此,在这些类型的 IPC 之间切换是微不足道的,我们用来读写本地文件的相同代码可以用来与运行在远程网络主机上的应用程序进行通信。
在这个示例中,我们将学习如何使用名为POSIX的可移植操作系统接口(POSIX)命名管道来在同一台计算机上的两个应用程序之间进行通信。
准备工作
我们将使用作为在应用程序中使用进程间通信示例的一部分创建的应用程序的源代码作为本示例的起点。
如何做...
在这个示例中,我们将从使用常规文件进行 IPC 的源代码开始。我们将修改它以使用一种名为命名管道的 IPC 机制:
-
将
ipc1
目录的内容复制到一个名为ipc2
的新目录中。 -
打开
ipc1.cpp
文件,在#include <unistd.h>
后添加两个include
实例:
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
- 通过在
Writer
类的Write
方法中添加一行来修改Write
方法:
void Write(const T& data) {
out.write(reinterpret_cast<const char*>(&data), sizeof(T));
out.flush();
}
Reader
类中的修改更为重要。构造函数和Read
方法都受到影响:
template<class T>
class Reader {
private:
std::ifstream in;
public:
Reader(std::string& name):
in(name, std::ofstream::binary) {}
T Read() {
T data;
in.read(reinterpret_cast<char*>(&data), sizeof(data));
if (!in) {
throw std::runtime_error("Failed to read a message");
}
return data;
}
};
- 对
DoWrites
函数进行小的更改。唯一的区别是在发送每条消息后添加 10 毫秒的延迟:
void DoWrites() {
std::vector<Message> messages {{1, 0}, {0, 1}, {1, 1}, {0, 0}};
Writer<Message> writer(kSharedFile);
for (const auto& m : messages) {
std::cout << "Write " << m << std::endl;
writer.Write(m);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
- 最后,修改我们的
main
函数,创建一个命名管道而不是一个常规文件:
int main(int argc, char** argv) {
int ret = mkfifo(kSharedFile.c_str(), 0600);
if (!ret) {
throw std::runtime_error("Failed to create named pipe");
}
if (fork()) {
DoWrites();
} else {
DoReads();
}
}
现在可以构建和运行应用程序了。
工作原理...
正如你所看到的,我们对应用程序的代码进行了最少量的更改。所有读写数据的机制和 API 保持不变。关键的区别隐藏在一行代码后面:
int ret = mkfifo(kSharedFile.c_str(), 0600);
这一行创建了一种特殊类型的文件,称为命名管道
。它看起来像一个常规文件——它有一个名称、权限属性和修改时间。但是,它不存储任何真实的数据。写入到该文件的所有内容都会立即传递给从该文件读取的进程。
这种差异有一系列后果。由于文件中没有存储任何真实数据,所有的读取尝试都会被阻塞,直到有数据被写入。同样,写入也会被阻塞,直到读取者读取了先前的数据。
因此,不再需要外部数据同步。看一下Reader
类的实现。它在构造函数或Read
方法中都没有重试循环。
为了测试我们确实不需要使用任何额外的同步,我们在每条消息写入后添加了人为的延迟:
std::this_thread::sleep_for(std::chrono::milliseconds(10));
当我们构建和运行应用程序时,我们可以看到以下输出:
每个Write
方法后面都跟着适当的Read
方法,尽管我们在Reader
代码中没有添加任何延迟或检查。操作系统的 IPC 机制会透明地为我们处理数据同步,从而使代码更清晰和可读。
还有更多...
如你所见,使用命名管道与使用常规函数一样简单。套接字 API 是 IPC 的另一种广泛使用的机制。它稍微复杂一些,但提供了更多的灵活性。通过选择不同的传输层,开发人员可以使用相同的套接字 API 来进行本地数据交换和与远程主机的网络连接。
有关套接字 API 的更多信息,请访问man7.org/linux/man-pages/man7/socket.7.html
。
学习消息队列和发布-订阅模型
POSIX 操作系统提供的大多数 IPC 机制都非常基本。它们的 API 是使用文件描述符构建的,并且将输入和输出通道视为原始的字节序列。
然而,应用程序往往使用特定长度和目的的数据片段进行数据交换消息。尽管操作系统的 API 机制灵活且通用,但并不总是方便进行消息交换。这就是为什么在默认 IPC 机制之上构建了专用库和组件,以简化消息交换模式。
在这篇文章中,我们将学习如何使用发布者-订阅者(pub-sub)模型在两个应用程序之间实现异步数据交换。
这种模型易于理解,并且被广泛用于开发软件系统,这些系统被设计为相互独立、松散耦合的组件集合,它们之间进行通信。函数的隔离和异步数据交换使我们能够构建灵活、可扩展和健壮的解决方案。
在发布-订阅模型中,应用程序可以充当发布者、订阅者或两者兼而有之。应用程序不需要向特定应用程序发送请求并期望它们做出响应,而是可以向特定主题发布消息或订阅接收感兴趣的主题上的消息。在发布消息时,应用程序不关心有多少订阅者正在监听该主题。同样,订阅者不知道哪个应用程序将在特定主题上发送消息,或者何时期望收到消息。
操作方法...
我们在探索 IPC 机制配方中创建的应用程序已经包含了许多我们可以重用的构建模块,以实现发布/订阅通信。
Writer
类可以充当发布者,Reader
类可以充当订阅者。我们实现它们来处理严格定义的数据类型,这些数据类型将定义我们的消息。我们在前面的示例中使用的命名管道机制是在字节级别上工作的,并不能保证消息会自动传递。
为了克服这一限制,我们将使用 POSIX 消息队列 API,而不是命名管道。在它们的构造函数中,Reader
和Writer
都将接受用于标识消息队列的名称作为主题:
-
将我们在上一篇文章中创建的
ipc2
目录的内容复制到一个新目录:ipc3
。 -
让我们为 POSIX 消息队列 API 创建一个 C++包装器。在编辑器中打开
ipc1.cpp
并添加所需的头文件和常量定义:
#include <unistd.h>
#include <signal.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <mqueue.h>
std::string kQueueName = "/test";
- 然后,定义一个
MessageQueue
类。它将一个消息队列句柄作为其私有数据成员。我们可以使用构造函数和析构函数来管理句柄的安全打开和关闭,使用 C++ RAII 习惯用法。
class MessageQueue {
private:
mqd_t handle;
public:
MessageQueue(const std::string& name, int flags) {
handle = mq_open(name.c_str(), flags);
if (handle < 0) {
throw std::runtime_error("Failed to open a queue for
writing");
}
}
MessageQueue(const std::string& name, int flags, int max_count,
int max_size) {
struct mq_attr attrs = { 0, max_count, max_size, 0 };
handle = mq_open(name.c_str(), flags | O_CREAT, 0666,
&attrs);
if (handle < 0) {
throw std::runtime_error("Failed to create a queue");
}
}
~MessageQueue() {
mq_close(handle);
}
- 然后,我们定义两个简单的方法来将消息写入队列和从队列中读取消息:
void Send(const char* data, size_t len) {
if (mq_send(handle, data, len, 0) < 0) {
throw std::runtime_error("Failed to send a message");
}
}
void Receive(char* data, size_t len) {
if (mq_receive(handle, data, len, 0) < len) {
throw std::runtime_error("Failed to receive a message");
}
}
};
- 我们现在修改我们的
Writer
和Reader
类,以适应新的 API。我们的MessageQueue
包装器完成了大部分繁重的工作,代码更改很小。Writer
类现在看起来像这样:
template<class T>
class Writer {
private:
MessageQueue queue;
public:
Writer(std::string& name):
queue(name, O_WRONLY) {}
void Write(const T& data) {
queue.Send(reinterpret_cast<const char*>(&data), sizeof(data));
}
};
Reader
类中的修改更加实质性。我们让它充当订阅者,并将直接从队列中获取和处理消息的逻辑封装到类中:
template<class T>
class Reader {
private:
MessageQueue queue;
public:
Reader(std::string& name):
queue(name, O_RDONLY) {}
void Run() {
T data;
while(true) {
queue.Receive(reinterpret_cast<char*>(&data),
sizeof(data));
Callback(data);
}
}
protected:
virtual void Callback(const T& data) = 0;
};
- 由于我们仍然希望尽可能地保持
Reader
类的通用性,我们将定义一个新的类(CoordLogger
),它是从Reader
派生出来的,用于定义我们消息的特定处理方式:
class CoordLogger : public Reader<Message> {
using Reader<Message>::Reader;
protected:
void Callback(const Message& data) override {
std::cout << "Received coordinate " << data << std::endl;
}
};
DoWrites
代码基本保持不变;唯一的变化是我们使用不同的常量来标识我们的队列:
void DoWrites() {
std::vector<Message> messages {{1, 0}, {0, 1}, {1, 1}, {0, 0}};
Writer<Message> writer(kQueueName);
for (const auto& m : messages) {
std::cout << "Write " << m << std::endl;
writer.Write(m);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
- 由于消息处理逻辑已经移动到
Reader
和CoordLogger
类中,DoReads
现在就像这样简单:
void DoReads() {
CoordLogger logger(kQueueName);
logger.Run();
}
- 更新后的
main
函数如下:
int main(int argc, char** argv) {
MessageQueue q(kQueueName, O_WRONLY, 10, sizeof(Message));
pid_t pid = fork();
if (pid) {
DoWrites();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
kill(pid, SIGTERM);
} else {
DoReads();
}
}
- 最后,我们的应用程序需要链接
rt
库。我们通过在CMakeLists.txt
文件中添加一行来实现这一点:
target_link_libraries(ipc3 rt)
现在可以构建和运行应用程序了。
它是如何工作的...
在我们的应用程序中,我们重用了前面一篇文章中创建的应用程序的大部分代码,探索 IPC 机制。为了实现发布-订阅模型,我们需要进行两个重要的更改:
-
使我们的 IPC 基于消息。我们应该能够自动发送和接收消息。一个发布者发送的消息不应该破坏其他发布者发送的消息,订阅者应该能够整体读取消息。
-
让订阅者定义在新消息可用时调用的回调。
为了进行基于消息的通信,我们从命名管道切换到了 POSIX 消息队列 API。消息队列 API 与命名管道的常规基于文件的 API 不同,这就是为什么我们在 Linux 标准库提供的纯 C 接口之上实现了一个 C++包装器。
包装器的主要目标是使用资源获取即初始化(RAII)习语提供安全的资源管理。我们通过定义通过调用mq_open
获取队列处理程序的构造函数和使用mq_close
释放它的析构函数来实现这一点。这样,当MessageQueue
类的相应实例被销毁时,队列会自动关闭。
包装器类有两个构造函数。一个构造函数用于打开现有队列。它接受两个参数——队列名称和访问标志。第二个构造函数用于创建一个新队列。它接受两个额外的参数——消息长度和队列中消息的最大大小。
在我们的应用程序中,我们在main
函数中创建一个队列,将10
作为可以存储在队列中的消息数量。Message
结构的大小是我们队列中消息的最大大小:
MessageQueue q(kQueueName, O_WRONLY, 10, sizeof(Message));
然后,DoWrites
和DoReads
函数打开了已经使用相同名称创建的队列。
由于我们的MessageQueue
类的公共 API 类似于我们用于使用命名管道进行 IPC 的fstream
接口,因此只需要对写入器和读取器进行最小的更改,使它们能够与另一种 IPC 机制一起工作。我们使用MessageQueue
的实例而不是fstream
作为数据成员,保持其他逻辑不变。
为了让订阅者定义他们的回调方法,我们需要修改Reader
类。我们引入了Run
方法,而不是读取并返回单个方法的Read
方法。它循环遍历队列中所有可用的消息。对于每个被读取的方法,它调用一个回调方法:
while(true) {
queue.Receive(reinterpret_cast<char*>(&data), sizeof(data));
Callback(data);
}
我们的目标是使Reader
类通用且可重用于不同类型的消息。然而,并不存在通用的回调。每个回调都是特定的,应该由Reader
类的用户定义。
解决这个矛盾的一种方法是将Reader
定义为抽象类。我们将Callback
方法定义为virtual
函数:
protected:
virtual void Callback(const T& data) = 0;
现在,由于Reader
是抽象的,我们无法创建这个类的实例。我们必须继承它,并在一个名为CoordLogger
的派生类中提供Callback
方法的定义:
protected:
void Callback(const Message& data) override {
std::cout << "Received coordinate " << data << std::endl;
}
请注意,由于Reader
构造函数接受一个参数,我们需要在继承类中定义构造函数。我们将使用 C++11 标准中添加的继承构造函数:
using Reader<Message>::Reader;
现在,有了一个能够处理Message
类型消息的CoordLogger
类,我们可以在我们的DoReads
实现中使用它。我们只需要创建这个类的一个实例并调用它的Run
方法:
CoordLogger logger(kQueueName);
logger.Run();
当我们运行应用程序时,我们会得到以下输出:
这个输出与前面的输出并没有太大的不同,但现在实现的可扩展性更强了。DoReads
方法并没有针对消息做任何特定的操作。它的唯一任务是创建和运行订阅者。所有数据处理都封装在特定的类中。您可以在不改变应用程序架构的情况下添加、替换和组合发布者和订阅者。
还有更多...
POSIX 消息队列 API 提供了消息队列的基本功能,但它也有许多限制。使用一个消息队列无法向多个订阅者发送消息。您必须为每个订阅者创建一个单独的队列,否则只有一个订阅者从队列中读取消息。
有许多详细的消息队列和发布-订阅中间件可用作外部库。ZeroMQ 是一个功能强大、灵活且轻量级的传输库。这使它成为使用数据交换的发布-订阅模型构建的嵌入式应用程序的理想选择。
使用 C++ lambda 进行回调
在发布-订阅模型中,订阅者通常注册一个回调,当发布者的消息传递给订阅者时会被调用。
在前面的示例中,我们创建了一个使用继承和抽象类注册回调的机制。这不是 C++中唯一可用的机制。C++中提供的 lambda 函数,从 C++11 标准开始,可以作为替代解决方案。这消除了定义派生类所需的大量样板代码,并且在大多数情况下,允许开发人员以更清晰的方式表达他们的意图。
在这个示例中,我们将学习如何使用 C++ lambda 函数来定义回调。
如何做...
我们将使用前面示例中大部分代码,学习消息队列和发布-订阅模型。我们将修改Reader
类以接受回调作为参数。通过这种修改,我们可以直接使用Reader
,而不需要依赖继承来定义回调:
-
将我们在前面示例中创建的
ipc3
目录的内容复制到一个新目录ipc4
中。 -
保持所有代码不变,除了
Reader
类。让我们用以下代码片段替换它:
template<class T>
class Reader {
private:
MessageQueue queue;
void (*func)(const T&);
public:
Reader(std::string& name, void (*func)(const T&)):
queue(name, O_RDONLY), func(func) {}
void Run() {
T data;
while(true) {
queue.Receive(reinterpret_cast<char*>(&data),
sizeof(data));
func(data);
}
}
};
- 现在我们的
Reader
类已经改变,我们可以更新DoReads
方法。我们可以使用 lambda 函数来定义一个回调处理程序,并将其传递给Reader
的构造函数:
void DoReads() {
Reader<Message> logger(kQueueName, [](const Message& data) {
std::cout << "Received coordinate " << data << std::endl;
});
logger.Run();
}
-
CoordLogger
类不再需要,因此我们可以完全从我们的代码中安全地删除它。 -
您可以构建和运行应用程序。
它是如何工作的...
在这个示例中,我们修改了之前定义的Reader
类,以接受其构造函数中的额外参数。这个参数有一个特定的数据类型——一个指向函数的指针,它将被用作回调:
Reader(std::string& name, void (*func)(const T&)):
处理程序存储在数据字段中以供将来使用:
void (*func)(const T&);
现在,每当Run
方法读取消息时,它会调用存储在func
字段中的函数,而不是我们需要重写的Callback
方法:
queue.Receive(reinterpret_cast<char*>(&data), sizeof(data));
func(data);
将Callback
函数去掉使Reader
成为一个具体的类,我们可以直接创建它的实例。然而,现在我们需要在它的构造函数中提供一个处理程序作为参数。
使用纯 C,我们必须定义一个named
函数并将其名称作为参数传递。在 C++中,这种方法也是可能的,但 C++还提供了匿名函数或 lambda 函数的机制,可以直接在现场定义。
在DoReads
方法中,我们创建一个 lambda 函数,并直接将其传递给Reader
的构造函数:
Reader<Message> logger(kQueueName, [](const Message& data) {
std::cout << "Received coordinate " << data << std::endl;
});
构建和运行应用程序会产生以下输出:
正如我们所看到的,它与我们在前面的示例中创建的应用程序的输出相同。然而,我们用更少的代码以更可读的方式实现了它。
Lambda 函数应该明智地使用。如果保持最小,它们会使代码更易读。如果一个函数变得比五行更长,请考虑使用命名函数。
还有更多...
C++提供了灵活的机制来处理类似函数的对象,并将它们与参数绑定在一起。这些机制被广泛用于转发调用和构建函数适配器。en.cppreference.com/w/cpp/utility/functional
上的函数对象页面是深入了解这些主题的好起点。
探索数据序列化
我们已经在第三章 使用不同的架构中简要涉及了序列化的一些方面。在数据交换方面,序列化是至关重要的。序列化的任务是以一种可以被接收应用程序明确读取的方式表示发送应用程序发送的所有数据。鉴于发送方和接收方可能在不同的硬件平台上运行,并通过各种传输链路连接 - 传输控制协议/互联网协议(TCP/IP)网络,串行外围接口(SPI)总线或串行链路,这个任务并不那么简单。
根据要求实现序列化的方式有很多种,这就是为什么 C++标准库没有提供序列化的原因。
在这个示例中,我们将学习如何在 C++应用程序中实现简单的通用序列化和反序列化。
如何做...
序列化的目标是以一种可以在另一个系统或另一个应用程序中正确解码的方式对任何数据进行编码。开发人员通常面临的典型障碍如下:
-
平台特定的差异,如数据对齐和字节顺序。
-
内存中分散的数据;例如,链表的元素可能相距甚远。由指针连接的断开块的表示对于内存是自然的,但在传输到另一个进程时,无法自动转换为字节序列。
解决这个问题的通用方法是让一个类定义将其内容转换为序列化形式并从序列化形式中恢复类实例的函数。
在我们的应用程序中,我们将重载输出流的operator<<
和输入流的operator>>
,分别用于序列化和反序列化数据:
-
在您的
~/test
工作目录中,创建一个名为stream
的子目录。 -
使用您喜欢的文本编辑器在
stream
子目录中创建一个stream.cpp
文件。 -
从定义要序列化的数据结构开始:
#include <iostream>
#include <sstream>
#include <list>
struct Point {
int x, y;
};
struct Paths {
Point source;
std::list<Point> destinations;
};
- 接下来,我们重载
<<
和>>
运算符,负责将Point
对象分别写入和从流中读取。对于Point
数据类型,输入以下内容:
std::ostream& operator<<(std::ostream& o, const Point& p) {
o << p.x << " " << p.y << " ";
return o;
}
std::istream& operator>>(std::istream& is, Point& p) {
is >> p.x;
is >> p.y;
return is;
}
- 它们后面是
Paths
对象的<<
和>>
重载运算符:
std::ostream& operator<<(std::ostream& o, const Paths& paths) {
o << paths.source << paths.destinations.size() << " ";
for (const auto& x : paths.destinations) {
o << x;
}
return o;
}
std::istream& operator>>(std::istream& is, Paths& paths) {
size_t size;
is >> paths.source;
is >> size;
for (;size;size--) {
Point tmp;
is >> tmp;
paths.destinations.push_back(tmp);
}
return is;
}
- 现在,让我们在
main
函数中总结一切:
int main(int argc, char** argv) {
Paths paths = {{0, 0}, {{1, 1}, {0, 1}, {1, 0}}};
std::stringstream in;
in << paths;
std::string serialized = in.str();
std::cout << "Serialized paths into the string: ["
<< serialized << "]" << std::endl;
std::stringstream out(serialized);
Paths paths2;
out >> paths2;
std::cout << "Original: " << paths.destinations.size()
<< " destinations" << std::endl;
std::cout << "Restored: " << paths2.destinations.size()
<< " destinations" << std::endl;
return 0;
}
- 最后,创建一个包含程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(stream)
add_executable(stream stream.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
现在您可以构建和运行应用程序了。
它是如何工作的...
在我们的测试应用程序中,我们定义了一种数据类型,用于表示从源点到多个目标点的路径。我们故意使用了分散在内存中的分层结构,以演示如何以通用方式解决这个问题。
如果我们对性能没有特定要求,序列化的一种可能方法是以文本格式存储数据。除了它的简单性外,它还有两个主要优点:
-
文本编码自动解决了与字节顺序、对齐和整数数据类型大小相关的所有问题。
-
它可供人类阅读。开发人员可以使用序列化数据进行调试,而无需任何额外的工具。
为了使用文本表示,我们可以使用标准库提供的输入和输出流。它们已经定义了写入和读取格式化数字的函数。
Point
结构被定义为两个整数值:x
和y
。我们重写了这种数据类型的operator<<
,以便写入x
和y
值,后面跟着空格。这样,我们可以在重写的operator>>
操作中按顺序读取它们。
Path
数据类型有点棘手。它包含一个目的地的链表。由于列表的大小可能会变化,我们需要在序列化其内容之前写入列表的实际大小,以便在反序列化期间能够正确恢复它:
o << paths.source << paths.destinations.size() << " ";
由于我们已经重写了Point
方法的<<
和>>
操作符,我们可以在Paths
方法中使用它们。这样,我们可以将Point
对象写入流或从流中读取,而不知道它们的数据字段的内容。层次化数据结构被递归处理:
for (const auto& x : paths.destinations) {
o << x;
}
最后,我们测试我们的序列化和反序列化实现。我们创建一个Paths
对象的示例实例:
Paths paths = {{0, 0}, {{1, 1}, {0, 1}, {1, 0}}};
然后,我们使用std::stringstream
数据类型将其内容序列化为字符串:
std::stringstream in;
in << paths;
std::string serialized = in.str();
接下来,我们创建一个空的Path
对象,并将字符串的内容反序列化到其中:
Paths paths2;
out >> paths2;
最后,我们检查它们是否匹配。当我们运行应用程序时,我们可以使用以下输出来进行检查:
恢复对象的destinations
列表的大小与原始对象的destinations
列表的大小相匹配。我们还可以看到序列化数据的内容。
这个示例展示了如何为任何数据类型构建自定义序列化。可以在没有任何外部库的情况下完成。然而,在性能和内存效率要求的情况下,使用第三方序列化库将是更实用的方法。
还有更多...
从头开始实现序列化是困难的。cereal 库在uscilab.github.io/cereal/
和 boost 库在www.boost.org/doc/libs/1_71_0/libs/serialization/doc/index.html
提供了一个基础,可以帮助您更快速、更容易地向应用程序添加序列化。
使用 FlatBuffers 库
序列化和反序列化是一个复杂的主题。虽然临时序列化看起来简单直接,但要使其通用、易于使用和快速是困难的。幸运的是,有一些库处理了所有这些复杂性。
在这个示例中,我们将学习如何使用其中一个序列化库:FlatBuffers。它是专为嵌入式编程设计的,使序列化和反序列化内存高效且快速。
FlatBuffers 使用接口定义语言(IDL)来定义数据模式。该模式描述了我们需要序列化的数据结构的所有字段。当设计模式时,我们使用一个名为flatc的特殊工具来为特定的编程语言生成代码,这在我们的情况下是 C++。
生成的代码以序列化形式存储所有数据,并为开发人员提供所谓的getter和setter方法来访问数据字段。getter 在使用时执行反序列化。将数据存储在序列化形式中使得 FlatBuffers 真正的内存高效。不需要额外的内存来存储序列化数据,并且在大多数情况下,反序列化的开销很低。
在这个示例中,我们将学习如何在我们的应用程序中开始使用 FlatBuffers 进行数据序列化。
如何做...
FlatBuffers 是一组工具和库。在使用之前,我们需要下载并构建它:
-
下载最新的 FlatBuffers 存档,可在
codeload.github.com/google/flatbuffers/zip/master
下载,并将其提取到test
目录中。这将创建一个名为flatbuffers-master
的新目录。 -
切换到构建控制台,将目录更改为
flatbuffers-master
,并运行以下命令来构建和安装库和工具。确保以 root 用户身份运行。如果没有,请按Ctrl + C退出用户 shell:
# cmake .
# make
# make install
现在,我们准备在我们的应用程序中使用 FlatBuffers。让我们重用我们在以前的配方中创建的应用程序:
-
将
ipc4
目录的内容复制到新创建的名为flat
的目录中。 -
创建一个名为
message.fbs
的文件,打开它并输入以下代码:
struct Message {
x: int;
y: int;
}
- 从
message.fbs
生成 C++源代码,运行以下命令:
$ flatc --cpp message.fbs
这将创建一个名为message_generated.h
的新文件。
- 在编辑器中打开
ipc1.cpp
。在mqueue.h
包含之后,添加一个include
指令用于生成的message_generated.h
文件:
#include <mqueue.h>
#include "message_generated.h"
-
现在,摆脱我们代码中声明的
Message
结构。我们将使用 FlatBuffers 模式文件中生成的结构。 -
由于 FlatBuffers 使用 getter 方法而不是直接访问结构字段,我们需要修改我们重新定义的
operator<<
操作的主体,用于将点数据打印到控制台。更改很小——我们只是为每个数据字段添加括号:
std::ostream& operator<<(std::ostream& o, const Message& m) {
o << "(x=" << m.x() << ", y=" << m.y() << ")";
}
- 代码修改已完成。现在,我们需要更新构建规则以链接 FlatBuffers 库。打开
CMakeLists.txt
,并输入以下行:
cmake_minimum_required(VERSION 3.5.1)
project(flat)
add_executable(flat ipc1.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS_RELEASE "--std=c++11")
SET(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_RELEASE} -g -DDEBUG")
target_link_libraries(flat rt flatbuffers)
set(CMAKE_C_COMPILER /usr/bin/arm-linux-gnueabi-gcc)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
- 切换到构建控制台,然后切换到用户 shell:
# su - user
$
- 构建并运行应用程序。
工作原理...
FlatBuffers 是一个外部库,不在 Ubuntu 软件包存储库中,因此我们需要先下载、构建和安装它。安装完成后,我们可以在我们的应用程序中使用它。
我们使用了我们为使用 C++ lambda 进行回调配方创建的现有应用程序作为起点。在该应用程序中,我们定义了一个名为Message
的结构,用于表示我们用于 IPC 的数据类型。我们将用 FlatBuffers 提供的新数据类型替换它。这种新数据类型将为我们执行所有必要的序列化和反序列化。
我们完全从我们的代码中删除了Message
结构的定义。相反,我们生成了一个名为message_generated.h
的新头文件。这个文件是从message.fbs
的 FlatBuffers 模式文件生成的。这个模式文件定义了一个具有两个整数字段x
和y
的结构:
x: int;
y: int;
这个定义与我们之前的定义相同;唯一的区别是语法——FlatBuffers 的模式使用冒号将字段名与字段类型分隔开。
一旦message_generated.h
由flatc
命令调用创建,我们就可以在我们的代码中使用它。我们添加适当的include
如下:
#include "message_generated.h"
生成的消息与我们之前使用的消息结构相同,但正如我们之前讨论的,FlatBuffers 以序列化形式存储数据,并且需要在运行时对其进行反序列化。这就是为什么,我们不直接访问数据字段,而是使用x()
访问器方法而不是只是x
,以及y()
访问器方法而不只是y
。
我们唯一使用直接访问消息数据字段的地方是在重写的operator<<
操作中。我们添加括号将直接字段访问转换为调用 FlatBuffers 的 getter 方法:
o << "(x=" << m.x() << ", y=" << m.y() << ")";
让我们构建并运行应用程序。我们将看到以下输出:
输出与我们自定义消息数据类型的输出相同。在我们的代码中只进行了少量修改,我们就将我们的消息迁移到了 FlatBuffers。现在,我们可以在多台计算机上运行我们的发布者和订阅者——这些计算机可以具有不同的架构,并确保它们每个都正确解释消息。
还有更多...
除了 FlatBuffers 之外,还有许多其他序列化库和技术,每种都有其优缺点。请参考C++序列化 FAQ以更好地了解如何在您的应用程序中设计序列化。
第九章:外围设备
与外围设备的通信是任何嵌入式应用的重要部分。应用程序需要检查可用性和状态,并向各种设备发送数据和接收数据。
每个目标平台都不同,连接外围设备到计算单元的方式有很多种。然而,有几种硬件和软件接口已经成为与外围设备通信的行业标准。在本章中,我们将学习如何处理直接连接到处理器引脚或串行接口的外围设备。本章涵盖以下主题:
-
通过 GPIO 控制连接的设备
-
探索脉宽调制
-
使用 ioctl 访问 Linux 中的实时时钟
-
使用 libgpiod 控制 GPIO 引脚
-
控制 I2C 外围设备
本章的配方涉及与真实硬件的交互,并打算在真实的树莓派板上运行。
通过 GPIO 控制连接的设备
通用输入输出(GPIO)是将外围设备连接到 CPU 的最简单方式。每个处理器通常都有一些用于通用目的的引脚。这些引脚可以直接与外围设备的引脚电连接。嵌入式应用可以通过改变配置为输出的引脚的信号电平或读取输入引脚的信号电平来控制设备。
信号电平的解释不遵循任何协议,而是由外围设备确定。开发人员需要查阅设备数据表以便正确地编程通信。
这种类型的通信通常是在内核端使用专用设备驱动程序完成的。然而,这并不总是必需的。在这个配方中,我们将学习如何从用户空间应用程序中使用树莓派板上的 GPIO 接口。
如何做...
我们将创建一个简单的应用程序,控制连接到树莓派板上的通用引脚的发光二极管(LED):
-
在你的
~/test
工作目录中,创建一个名为gpio
的子目录。 -
使用你喜欢的文本编辑器在
gpio
子目录中创建一个gpio.cpp
文件。 -
将以下代码片段放入文件中:
#include <chrono>
#include <iostream>
#include <thread>
#include <wiringPi.h>
using namespace std::literals::chrono_literals;
const int kLedPin = 0;
int main (void)
{
if (wiringPiSetup () <0) {
throw std::runtime_error("Failed to initialize wiringPi");
}
pinMode (kLedPin, OUTPUT);
while (true) {
digitalWrite (kLedPin, HIGH);
std::cout << "LED on" << std::endl;
std::this_thread::sleep_for(500ms) ;
digitalWrite (kLedPin, LOW);
std::cout << "LED off" << std::endl;
std::this_thread::sleep_for(500ms) ;
}
return 0 ;
}
- 创建一个包含我们程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(gpio)
add_executable(gpio gpio.cpp)
target_link_libraries(gpio wiringPi)
-
使用WiringPI 示例部分的说明,将 LED 连接到树莓派板上。
-
建立一个 SSH 连接到你的树莓派板。按照Raspberry Pi 文档部分的说明进行操作。
-
通过 SSH 将
gpio
文件夹的内容复制到树莓派板上。 -
通过 SSH 登录到板上,然后构建和运行应用程序:
$ cd gpio && cmake . && make && sudo ./gpio
你的应用程序应该运行,你应该能够观察到 LED 在闪烁。
工作原理...
树莓派板有 40 个引脚(第一代有 26 个)可以使用内存映射输入输出(MMIO)机制进行编程。MMIO 允许开发人员通过读取或写入系统物理内存中的特定地址来查询或设置引脚的状态。
在第六章的使用专用内存配方中,内存管理,我们学习了如何访问 MMIO 寄存器。在这个配方中,我们将把 MMIO 地址的操作交给专门的库wiringPi
。它隐藏了内存映射和查找适当偏移量的所有复杂性,而是暴露了一个清晰的 API。
这个库已经预装在树莓派板上,所以为了简化构建过程,我们将直接在板上构建代码,而不是使用交叉编译。与其他教程不同,我们的构建规则没有提到交叉编译器 - 我们将使用板上的本机 ARM 编译器。我们只添加了对wiringPi
库的依赖:
target_link_libraries(gpio wiringPi)
这个示例的代码是对wiringPi
用于 LED 闪烁的示例的修改。首先,我们初始化wiringPi
库:
if (wiringPiSetup () < 0) {
throw std::runtime_error("Failed to initialize wiringPi");
}
接下来,我们进入无限循环。在每次迭代中,我们将引脚设置为HIGH
状态:
digitalWrite (kLedPin, HIGH);
在 500 毫秒的延迟之后,我们将相同的引脚设置为LOW
状态并添加另一个延迟:
digitalWrite (kLedPin, LOW);
std::cout << "LED off" << std::endl;
std::this_thread::sleep_for(500ms) ;
我们配置程序使用引脚0
,对应于树莓派的BCM2835
芯片的GPIO.0
或引脚17
:
const int kLedPin = 0;
如果 LED 连接到这个引脚,它将会闪烁,打开 0.5 秒,然后关闭 0.5 秒。通过调整循环中的延迟,您可以改变闪烁模式。
由于程序进入无限循环,我们可以通过在 SSH 控制台中按下Ctrl + C来随时终止它;否则,它将永远运行。
当我们运行应用程序时,我们只会看到以下输出:
我们记录 LED 打开或关闭的时间,但要检查程序是否真正工作,我们需要查看连接到引脚的 LED。如果我们按照接线说明,就可以看到它是如何工作的。当程序运行时,板上的 LED 会与程序输出同步闪烁:
我们能够控制直接连接到 CPU 引脚的简单设备,而无需编写复杂的设备驱动程序。
探索脉宽调制
数字引脚只能处于两种状态之一:HIGH
或LOW
。连接到数字引脚的 LED 也只能处于两种状态之一:on
或off
。但是有没有办法控制 LED 的亮度?是的,我们可以使用一种称为脉宽调制(PWM)的方法。
PWM 背后的想法很简单。我们通过周期性地打开或关闭电信号来限制电信号传递的功率。这使得信号以一定频率脉冲,并且功率与脉冲宽度成正比 - 即信号处于HIGH
状态的时间。
例如,如果我们将引脚设置为HIGH
10 微秒,然后在循环中再设置为LOW
90 微秒,连接到该引脚的设备将接收到原本的 10%的电源。
在这个教程中,我们将学习如何使用 PWM 来控制连接到树莓派板数字 GPIO 引脚的 LED 的亮度。
操作步骤如下...
我们将创建一个简单的应用程序,逐渐改变连接到树莓派板上的通用引脚的 LED 的亮度:
-
在您的
~/test
工作目录中,创建一个名为pwm
的子目录。 -
使用您喜欢的文本编辑器在
pwm
子目录中创建一个名为pwm.cpp
的文件。 -
让我们添加所需的
include
函数并定义一个名为Blink
的函数:
#include <chrono>
#include <thread>
#include <wiringPi.h>
using namespace std::literals::chrono_literals;
const int kLedPin = 0;
void Blink(std::chrono::microseconds duration, int percent_on) {
digitalWrite (kLedPin, HIGH);
std::this_thread::sleep_for(
duration * percent_on / 100) ;
digitalWrite (kLedPin, LOW);
std::this_thread::sleep_for(
duration * (100 - percent_on) / 100) ;
}
- 接下来是一个
main
函数:
int main (void)
{
if (wiringPiSetup () <0) {
throw std::runtime_error("Failed to initialize wiringPi");
}
pinMode (kLedPin, OUTPUT);
int count = 0;
int delta = 1;
while (true) {
Blink(10ms, count);
count = count + delta;
if (count == 101) {
delta = -1;
} else if (count == 0) {
delta = 1;
}
}
return 0 ;
}
- 创建一个包含我们程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(pwm)
add_executable(pwm pwm.cpp)
target_link_libraries(pwm wiringPi)
-
按照
wiringpi.com/examples/blink/
中的WiringPI 示例部分的说明,将 LED 连接到树莓派板上。 -
建立 SSH 连接到您的树莓派板。请按照
www.raspberrypi.org/documentation/remote-access/ssh/
中的Raspberry PI 文档部分的说明进行操作。 -
通过 SSH 将
pwm
文件夹的内容复制到树莓派板上。 -
通过 SSH 登录到板上,然后构建和运行应用程序:
$ cd pwm && cmake . && make && sudo ./pwm
您的应用程序现在应该运行,您可以观察 LED 的闪烁。
工作原理...
这个配方重用了从前一个配方中闪烁 LED 的代码和原理图。我们将这段代码从main
函数移动到一个新函数Blink
中。
Blink
函数接受两个参数——duration
和percent_on
:
void Blink(std::chrono::microseconds duration, int percent_on)
duration
确定脉冲的总宽度(以微秒为单位)。percent_on
定义了信号为HIGH
时的时间与脉冲总持续时间的比例。
实现很简单。当调用Blink
时,它将引脚设置为HIGH
并等待与percent_on
成比例的时间:
digitalWrite (kLedPin, HIGH);
std::this_thread::sleep_for(
duration * percent_on / 100);
之后,它将引脚设置为LOW
并等待剩余时间:
digitalWrite (kLedPin, LOW);
std::this_thread::sleep_for(
duration * (100 - percent_on) / 100);
Blink
是实现 PWM 的主要构建块。我们可以通过将percent_on
从0
变化到100
来控制亮度,如果我们选择足够短的duration
,我们将看不到任何闪烁。
电视或监视器的刷新率相等或短于持续时间是足够好的。对于 60 赫兹,持续时间为 16.6 毫秒。我们使用 10 毫秒以简化。
接下来,我们将所有内容包装在另一个无限循环中,但现在它有另一个参数count
:
int count = 0;
它在每次迭代中更新,并在0
和100
之间反弹。delta
变量定义了变化的方向——减少或增加——以及变化的量,在我们的情况下始终为1
:
int delta = 1;
当计数达到101
或0
时,方向会改变:
if (count == 101) {
delta = -1;
} else if (count == 0) {
delta = 1;
}
在每次迭代中,我们调用Blink
,传递10ms
作为脉冲和count
作为定义 LED 开启时间的比例,因此它的亮度(如下图所示):
Blink(10ms, count);
由于更新频率高,我们无法确定 LED 何时从开启到关闭。
当我们将所有东西连接起来并运行程序时,我们可以看到 LED 逐渐变亮或变暗。
还有更多...
PWM 广泛用于嵌入式系统,用于各种目的。这是伺服控制和电压调节的常见机制。使用脉宽调制维基百科页面,网址为en.wikipedia.org/wiki/Pulse-width_modulation
,作为了解更多关于这种技术的起点。
使用 ioctl 访问 Linux 中的实时时钟
在我们之前的配方中,我们使用 MMIO 从用户空间 Linux 应用程序访问外围设备。然而,这种接口不是用户空间应用程序和设备驱动程序之间通信的推荐方式。
在类 Unix 操作系统(如 Linux)中,大多数外围设备可以以与常规文件相同的方式访问,使用所谓的设备文件。当应用程序打开设备文件时,它可以从中读取,从相应设备获取数据,或者向其写入,向设备发送数据。
在许多情况下,设备驱动程序无法处理非结构化的数据流。它们期望以请求和响应的形式组织的数据交换,其中每个请求和响应都有特定和固定的格式。
这种通信由ioctl
系统调用来处理。它接受一个设备相关的请求代码作为参数。它还可能包含其他参数,用于编码请求数据或提供输出数据的存储。这些参数特定于特定设备和请求代码。
在这个配方中,我们将学习如何在用户空间应用程序中使用ioctl
与设备驱动程序进行数据交换。
如何做...
我们将创建一个应用程序,从连接到树莓派板的实时时钟(RTC)中读取当前时间:
-
在您的
~/test
工作目录中,创建一个名为rtc
的子目录。 -
使用您喜欢的文本编辑器在
rtc
子目录中创建一个名为rtc.cpp
的文件。 -
让我们把所需的
include
函数放到rtc.cpp
文件中:
#include <iostream>
#include <system_error>
#include <time.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <linux/rtc.h>
- 现在,我们定义一个名为
Rtc
的类,它封装了对真实时钟设备的通信:
class Rtc {
int fd;
public:
Rtc() {
fd = open("/dev/rtc", O_RDWR);
if (fd < 0) {
throw std::system_error(errno,
std::system_category(),
"Failed to open RTC device");
}
}
~Rtc() {
close(fd);
}
time_t GetTime(void) {
union {
struct rtc_time rtc;
struct tm tm;
} tm;
int ret = ioctl(fd, RTC_RD_TIME, &tm.rtc);
if (ret < 0) {
throw std::system_error(errno,
std::system_category(),
"ioctl failed");
}
return mktime(&tm.tm);
}
};
- 一旦类被定义,我们将一个简单的使用示例放入
main
函数中:
int main (void)
{
Rtc rtc;
time_t t = rtc.GetTime();
std::cout << "Current time is " << ctime(&t)
<< std::endl;
return 0 ;
}
- 创建一个包含我们程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(rtc)
add_executable(rtc rtc.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 构建您的应用程序并将生成的
rtc
二进制文件复制到我们的树莓派模拟器中。
工作原理...
我们正在实现一个直接与连接到系统的硬件 RTC 通信的应用程序。系统时钟和 RTC 之间存在差异。系统时钟仅在系统运行时处于活动状态并维护。当系统关闭电源或进入睡眠模式时,系统时钟变得无效。即使系统关闭,RTC 也处于活动状态。它维护用于在系统启动时配置系统时钟的实际时间。此外,它可以被编程为在睡眠模式下的特定时间唤醒系统。
我们将所有与 RTC 驱动程序的通信封装到一个名为Rtc
的类中。与驱动程序的所有数据交换都通过/dev/rtc
特殊设备文件进行。在Rtc
类构造函数中,我们打开设备文件并将结果文件描述符存储在fd
实例变量中:
fd = open("/dev/rtc", O_RDWR);
同样,析构函数用于关闭文件:
~Rtc() {
close(fd);
}
由于设备在析构函数中关闭,一旦Rtc
实例被销毁,我们可以使用资源获取即初始化(RAII)习惯用法在出现问题时抛出异常而不泄漏文件描述符:
if (fd < 0) {
throw std::system_error(errno,
std::system_category(),
"Failed to open RTC device");
}
我们的类只定义了一个成员函数—GetTime
。它是在RTC_RD_TIME
ioctl
调用之上的一个包装器。此调用期望返回一个rtc_time
结构以返回当前时间。它几乎与我们将要用来将 RTC 驱动程序返回的时间转换为 POSIX 时间戳格式的tm
结构相同,因此我们将它们都放入相同的内存位置作为union
数据类型:
union {
struct rtc_time rtc;
struct tm tm;
} tm;
通过这种方式,我们避免了从一个结构复制相同字段到另一个结构。
数据结构准备就绪后,我们调用ioctl
调用,将RTC_RD_TIME
常量作为请求 ID 传递,并将指向我们结构的指针作为存储数据的地址传递:
int ret = ioctl(fd, RTC_RD_TIME, &tm.rtc);
成功后,ioctl
返回0
。在这种情况下,我们使用mktime
函数将结果数据结构转换为time_t
POSIX 时间戳格式:
return mktime(&tm.tm);
在main
函数中,我们创建了Rtc
类的一个实例,然后调用GetTime
方法:
Rtc rtc;
time_t t = rtc.GetTime();
自从 POSIX 时间戳表示自 1970 年 1 月 1 日以来的秒数,我们使用ctime
函数将其转换为人类友好的表示,并将结果输出到控制台:
std::cout << "Current time is " << ctime(&t)
当我们运行我们的应用程序时,我们可以看到以下输出:
我们能够直接从硬件时钟使用ioctl
读取当前时间。ioctl
API 在 Linux 嵌入式应用中被广泛使用,用于与设备通信。
更多内容
在我们的简单示例中,我们学习了如何只使用一个ioctl
请求。RTC 设备支持许多其他请求,可用于设置闹钟,更新时间和控制 RTC 中断。更多细节可以在linux.die.net/man/4/rtc
的RTC ioctl 文档部分找到。
使用 libgpiod 控制 GPIO 引脚
在前面的教程中,我们学习了如何使用ioctl
API 访问 RTC。我们可以使用它来控制 GPIO 引脚吗?答案是肯定的。最近,Linux 添加了一个通用 GPIO 驱动程序,以及一个用户空间库libgpiod
,通过在通用ioctl
API 之上添加一个便利层来简化对连接到 GPIO 的设备的访问。此接口允许嵌入式开发人员在任何基于 Linux 的平台上管理其设备,而无需编写设备驱动程序。此外,它提供了 C++的绑定。
结果,尽管仍然被广泛使用,但wiringPi
库已被弃用,因为其易于使用的接口。
在本教程中,我们将学习如何使用libgpiod
C++绑定。我们将使用相同的 LED 闪烁示例来查看wiringPi
和libgpiod
方法的差异和相似之处。
如何做...
我们将创建一个应用程序,使用新的libgpiod
API 来闪烁连接到树莓派板的 LED。
-
在您的
~/test
工作目录中,创建一个名为gpiod
的子目录。 -
使用您喜欢的文本编辑器在
gpiod
子目录中创建一个gpiod.cpp
文件。 -
将应用程序的代码放入
rtc.cpp
文件中:
#include <chrono>
#include <iostream>
#include <thread>
#include <gpiod.h>
#include <gpiod.hpp>
using namespace std::literals::chrono_literals;
const int kLedPin = 17;
int main (void)
{
gpiod::chip chip("gpiochip0");
auto line = chip.get_line(kLedPin);
line.request({"test",
gpiod::line_request::DIRECTION_OUTPUT,
0}, 0);
while (true) {
line.set_value(1);
std::cout << "ON" << std::endl;
std::this_thread::sleep_for(500ms);
line.set_value(0);
std::cout << "OFF" << std::endl;
std::this_thread::sleep_for(500ms);
}
return 0 ;
}
- 创建一个包含我们程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(gpiod)
add_executable(gpiod gpiod.cpp)
target_link_libraries(gpiod gpiodcxx)
-
使用Raspberry PI documentation中的WiringPI 示例部分的说明,将 LED 连接到您的树莓派板。
-
建立一个 SSH 连接到您的树莓派板。请按照Raspberry PI documentation中的说明进行操作。
-
通过 SSH 将
gpio
文件夹的内容复制到树莓派板上。 -
安装
libgpiod-dev
软件包:
$ sudo apt-get install gpiod-dev
- 通过 SSH 登录到板上,然后构建和运行应用程序:
$ cd gpiod && cmake . && make && sudo ./gpiod
您的应用程序应该运行,您可以观察 LED 闪烁。
它是如何工作的...
我们的应用程序使用了 Linux 中访问 GPIO 设备的新的推荐方式。由于它是最近才添加的,因此需要安装最新版本的 Raspbian 发行版buster
。
gpiod
库本身提供了用于使用ioctl
API 与 GPIO 内核模块通信的高级包装。该接口设计用于 C 语言,其上还有一个用于 C++绑定的附加层。这一层位于libgpiocxx
库中,它是libgpiod2
软件包的一部分,与 C 的libgpiod
库一起提供。
该库使用异常来报告错误,因此代码简单且不会被返回代码检查所淹没。此外,我们不需要担心释放捕获的资源;它会通过 C++ RAII 机制自动完成。
应用程序启动时,它创建了一个 chip 类的实例,该类作为 GPIO 通信的入口点。它的构造函数接受要使用的设备的名称:
gpiod::chip chip("gpiochip0");
接下来,我们创建一个 line 的实例,它代表一个特定的 GPIO 引脚:
auto line = chip.get_line(kLedPin);
请注意,与wiringPi
实现不同,我们传递了17
引脚号,因为libgpiod
使用本机 Broadcom SOC 通道(BCM)引脚编号:
const int kLedPin = 17;
创建 line 实例后,我们需要配置所需的访问模式。我们构造一个line_request
结构的实例,传递一个消费者的名称("test"
)和一个指示引脚配置为输出的常量:
line.request({"test",
gpiod::line_request::DIRECTION_OUTPUT,
0}, 0);
之后,我们可以使用set_value
方法更改引脚状态。与wiringPi
示例一样,我们将引脚设置为1
或HIGH
,持续500ms
,然后再设置为0
或LOW
,再持续500ms
,循环进行:
line.set_value(1);
std::cout << "ON" << std::endl;
std::this_thread::sleep_for(500ms);
line.set_value(0);
std::cout << "OFF" << std::endl;
std::this_thread::sleep_for(500ms);
该程序的输出与通过 GPIO 连接的设备进行控制配方的输出相同。代码可能看起来更复杂,但新的 API 更通用,可以在任何 Linux 板上工作,而不仅仅是树莓派。
还有更多...
有关libgpiod
和 GPIO 接口的更多信息,可以在github.com/brgl/libgpiod
找到。
控制 I2C 外设设备
通过 GPIO 连接设备有一个缺点。处理器可用于 GPIO 的引脚数量有限且相对较小。当您需要处理大量设备或提供复杂功能的设备时,很容易用完引脚。
解决方案是使用标准串行总线之一连接外围设备。其中之一是Inter-Integrated Circuit(I2C)。由于其简单性和设备可以仅用两根导线连接到主控制器,因此这被广泛用于连接各种低速设备。
总线在硬件和软件层面都得到了很好的支持。通过使用 I2C 外设,开发人员可以在用户空间应用程序中控制它们,而无需编写复杂的设备驱动程序。
在这个教程中,我们将学习如何在树莓派板上使用 I2C 设备。我们将使用一款流行且便宜的 LCD 显示器。它有 16 个引脚,这使得它直接连接到树莓派板变得困难。然而,通过 I2C 背包,它只需要四根线来连接。
操作步骤...
我们将创建一个应用程序,该应用程序在连接到我们的树莓派板的 1602 LCD 显示器上显示文本:
-
在你的
~/test
工作目录中,创建一个名为i2c
的子目录。 -
使用你喜欢的文本编辑器在
i2c
子目录中创建一个i2c.cpp
文件。 -
将以下
include
指令和常量定义放入i2c.cpp
文件中:
#include <thread>
#include <system_error>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/ioctl.h>
#include <linux/i2c-dev.h>
using namespace std::literals::chrono_literals;
enum class Function : uint8_t {
clear = 0x01,
home = 0x02,
entry_mode_set = 0x04,
display_control = 0x08,
cursor_shift = 0x10,
fn_set = 0x20,
set_ddram_addr = 0x80
};
constexpr int En = 0b00000100;
constexpr int Rs = 0b00000001;
constexpr int kDisplayOn = 0x04;
constexpr int kEntryLeft = 0x02;
constexpr int kTwoLine = 0x08;
constexpr int kBacklightOn = 0x08;
- 现在,我们定义一个新的类
Lcd
,它封装了显示控制逻辑。我们从数据字段和public
方法开始:
class Lcd {
int fd;
public:
Lcd(const char* device, int address) {
fd = open(device, O_RDWR);
if (fd < 0) {
throw std::system_error(errno,
std::system_category(),
"Failed to open RTC device");
}
if (ioctl(fd, I2C_SLAVE, address) < 0) {
close(fd);
throw std::system_error(errno,
std::system_category(),
"Failed to aquire bus address");
}
Init();
}
~Lcd() {
close(fd);
}
void Clear() {
Call(Function::clear);
std::this_thread::sleep_for(2000us);
}
void Display(const std::string& text,
bool second=false) {
Call(Function::set_ddram_addr, second ? 0x40 : 0);
for(char c : text) {
Write(c, Rs);
}
}
- 接下来是
private
方法。低级辅助方法首先出现:
private:
void SendToI2C(uint8_t byte) {
if (write(fd, &byte, 1) != 1) {
throw std::system_error(errno,
std::system_category(),
"Write to i2c device failed");
}
}
void SendToLcd(uint8_t value) {
value |= kBacklightOn;
SendToI2C(value);
SendToI2C(value | En);
std::this_thread::sleep_for(1us);
SendToI2C(value & ~En);
std::this_thread::sleep_for(50us);
}
void Write(uint8_t value, uint8_t mode=0) {
SendToLcd((value & 0xF0) | mode);
SendToLcd((value << 4) | mode);
}
- 一旦辅助函数被定义,我们添加更高级的方法:
void Init() {
// Switch to 4-bit mode
for (int i = 0; i < 3; i++) {
SendToLcd(0x30);
std::this_thread::sleep_for(4500us);
}
SendToLcd(0x20);
// Set display to two-line, 4 bit, 5x8 character mode
Call(Function::fn_set, kTwoLine);
Call(Function::display_control, kDisplayOn);
Clear();
Call(Function::entry_mode_set, kEntryLeft);
Home();
}
void Call(Function function, uint8_t value=0) {
Write((uint8_t)function | value);
}
void Home() {
Call(Function::home);
std::this_thread::sleep_for(2000us);
}
};
- 添加使用
Lcd
类的main
函数:
int main (int argc, char* argv[])
{
Lcd lcd("/dev/i2c-1", 0x27);
if (argc > 1) {
lcd.Display(argv[1]);
if (argc > 2) {
lcd.Display(argv[2], true);
}
}
return 0 ;
}
- 创建一个包含我们程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(i2c)
add_executable(i2c i2c.cpp)
- 根据这个表格,将你的 1602LCD 显示器的
i2c
背包上的引脚连接到树莓派板上的引脚:
树莓派引脚名称 | 物理引脚号 | 1602 I2C 引脚 |
---|---|---|
GND | 6 | GND |
+5v | 2 | VSS |
SDA.1 | 3 | SDA |
SCL.1 | 5 | SCL |
-
建立 SSH 连接到你的树莓派板。按照Raspberry PI documentation部分的说明进行操作。
-
登录到树莓派板并运行
raspi-config
工具以启用i2c
:
sudo raspi-config
-
在菜单中,选择 Interfacing Options | I2C | Yes。
-
重新启动板以激活新设置。
-
通过 SSH 将
i2c
文件夹的内容复制到树莓派板上。 -
通过 SSH 登录到板上,然后构建和运行应用程序:
$ cd i2c && cmake . && make && ./i2c Hello, world!
你的应用程序应该运行,你可以观察到 LED 在闪烁。
工作原理...
在这个教程中,我们的外围设备——LCD 屏幕——通过 I2C 总线连接到板上。这是一种串行接口,所以连接只需要四根物理线。然而,LCD 屏幕可以做的远不止简单的 LED。这意味着用于控制它的通信协议也更复杂。
我们将只使用 1602 LCD 屏幕提供的功能的一小部分。通信逻辑松散地基于 Arduino 的LiquidCrystal_I2C
库,适用于树莓派。
我们定义了一个Lcd
类,它隐藏了 I2C 通信的所有复杂性和 1602 控制协议的私有方法。除了构造函数和析构函数之外,它只公开了两个公共方法:Clear
和Display
。
在 Linux 中,我们通过设备文件与 I2C 设备通信。要开始使用设备,我们需要使用常规的打开调用打开与 I2C 控制器对应的设备文件:
fd = open(device, O_RDWR);
可能有多个设备连接到同一总线。我们需要选择要通信的设备。我们使用ioctl
调用来实现这一点:
if (ioctl(fd, I2C_SLAVE, address) < 0) {
此时,I2C 通信已配置,我们可以通过向打开的文件描述符写入数据来发出 I2C 命令。然而,这些命令对于每个外围设备都是特定的。因此,在通用 I2C 初始化之后,我们需要继续进行 LCD 初始化。
我们将所有 LCD 特定的初始化放入Init
私有函数中。它配置操作模式、行数和显示字符的大小。为此,我们定义了辅助方法、数据类型和常量。
基本的辅助函数是SendToI2C
。它是一个简单的方法,将数据字节写入配置为 I2C 通信的文件描述符,并在出现错误时抛出异常。
if (write(fd, &byte, 1) != 1) {
throw std::system_error(errno,
std::system_category(),
"Write to i2c device failed");
}
除了SendToI2C
之外,我们还定义了另一个辅助方法SendToLcd
。它向 I2C 发送一系列字节,形成 LCD 控制器可以解释的命令。这涉及设置不同的标志并处理数据块之间需要的延迟:
SendToI2C(value);
SendToI2C(value | En);
std::this_thread::sleep_for(1us);
SendToI2C(value & ~En);
std::this_thread::sleep_for(50us);
LCD 以 4 位模式工作,这意味着发送到显示器的每个字节都需要两个命令。我们定义Write
方法来为我们执行这些操作:
SendToLcd((value & 0xF0) | mode);
SendToLcd((value << 4) | mode);
最后,我们定义设备支持的所有可能命令,并将它们放入Function
枚举类中。Call
辅助函数可以用于以类型安全的方式调用函数:
void Call(Function function, uint8_t value=0) {
Write((uint8_t)function | value);
}
最后,我们使用这些辅助函数来定义清除屏幕和显示字符串的公共方法。
由于通信协议的所有复杂性都封装在Lcd
类中,我们的main
函数相对简单。
它创建了一个类的实例,传入我们将要使用的设备文件名和设备地址。默认情况下,带有 I2C 背包的 1620 LCD 的地址是0x27
:
Lcd lcd("/dev/i2c-1", 0x27);
Lcd
类的构造函数执行所有初始化,一旦实例被创建,我们就可以调用Display
函数。我们不是硬编码要显示的字符串,而是使用用户通过命令行参数传递的数据。第一个参数显示在第一行。如果提供了第二个参数,它也会显示在显示器的第二行:
lcd.Display(argv[1]);
if (argc > 2) {
lcd.Display(argv[2], true);
}
我们的程序已经准备好了,我们可以将其复制到树莓派板上并在那里构建。但在运行之前,我们需要将显示器连接到板上并启用 I2C 支持。
我们使用raspi-config
工具来启用 I2C。我们只需要做一次,但除非之前未启用 I2C,否则需要重新启动:
最后,我们可以运行我们的应用程序。它将在 LCD 显示器上显示以下输出:
现在,我们知道如何从 Linux 用户空间程序控制通过 I2C 总线连接的设备。
还有更多...
有关使用 I2C 设备的更多信息,请访问elinux.org/Interfacing_with_I2C_Devices
上的与 I2C 设备接口页面。
第十章:降低功耗
嵌入式系统有许多应用需要它们以电池供电。从小型IoT(物联网的缩写)设备收集传感器数据,将其推送到云端进行处理,到自主车辆和机器人 - 这些系统应尽可能节能,以便它们可以在没有稳定外部电源供应的情况下长时间运行。
功率效率意味着智能控制系统的所有部分的功耗,从外围设备到内存和处理器。功率控制的效率在很大程度上取决于硬件组件的选择和系统设计。如果处理器不支持动态电压控制或外围设备在空闲时无法进入节能模式,那么在软件方面就无法做太多。然而,如果硬件组件实现了标准规范,例如高级配置和电源接口(ACPI),那么很多功耗管理的负担可以转移到操作系统内核。
在本章中,我们将探索现代硬件平台的不同节能模式以及如何利用它们。我们将学习如何管理外部设备的电源状态,并通过编写更高效的软件来减少处理器的功耗。
我们将涵盖以下主题:
-
在 Linux 中探索节能模式
-
使用RTC(实时时钟的缩写)唤醒
-
控制 USB 设备的自动挂起
-
配置 CPU 频率
-
使用事件等待
-
使用 PowerTOP 对功耗进行分析
本章的配方将帮助您有效利用现代操作系统的节能功能,并编写针对电池供电设备进行优化的代码。
技术要求
要在本章中运行代码示例,您需要具有树莓派 PI 盒子修订版 3 或更高版本。
在 Linux 中探索节能模式
当系统处于空闲状态且没有工作要做时,可以将其置于睡眠状态以节省电源。类似于人类的睡眠,它在外部事件唤醒之前无法做任何事情,例如闹钟。
Linux 支持多种睡眠模式。选择睡眠模式和它可以节省的功率取决于硬件支持以及进入该模式和从中唤醒所需的时间。
支持的模式如下:
-
挂起到空闲(S2I):这是一种轻度睡眠模式,可以纯粹通过软件实现,不需要硬件支持。设备进入低功耗模式,时间保持暂停,以便处理器在节能空闲状态下花费更多时间。系统通过来自任何外围设备的中断唤醒。
-
待机:这类似于 S2I,但通过将所有非引导 CPU 脱机来提供更多的节能。某些设备的中断可以唤醒系统。
-
挂起到 RAM(STR或S3):系统的所有组件(除了内存),包括 CPU,都进入低功耗模式。系统状态保持在内存中,直到被来自有限设备集的中断唤醒。此模式需要硬件支持。
-
休眠或挂起到磁盘:这提供了最大的节能,因为所有系统组件都可以关闭电源。进入此状态时,会拍摄内存快照并写入持久存储(磁盘或闪存)。之后,系统可以关闭。作为引导过程的一部分,在唤醒时,恢复保存的快照并系统恢复其工作。
在这个配方中,我们将学习如何查询特定系统支持的睡眠模式以及如何切换到其中之一。
如何做...
在这个配方中,我们将使用简单的 bash 命令来访问在QEMU(快速仿真器的缩写)中运行的 Linux 系统支持的睡眠模式。
-
按照第三章中描述的步骤运行树莓派 QEMU,使用不同的架构。
-
以用户
pi
登录,使用密码raspberry
。 -
运行
sudo
以获取 root 访问权限:
$ sudo bash
#
- 要获取支持的睡眠模式列表,请运行以下命令:
# cat /sys/power/state
- 现在切换到其中一个支持的模式:
# echo freeze > /sys/power/state
- 系统进入睡眠状态,但我们没有指示它如何唤醒。现在关闭 QEMU 窗口。
工作原理...
电源管理是 Linux 内核的一部分;这就是为什么我们不能使用 Docker 容器来处理它。Docker 虚拟化是轻量级的,并使用主机操作系统的内核。
我们也不能使用真正的树莓派板,因为由于硬件限制,它根本不提供任何睡眠模式。然而,QEMU 提供了完整的虚拟化,包括我们用来模拟树莓派的内核中的电源管理。
Linux 通过 sysfs 接口提供对其电源管理功能的访问。应用程序可以读取和写入/sys/power
目录中的文本文件。对于 root 用户,对电源管理功能的访问是受限的;这就是为什么我们需要在登录系统后获取 root shell:
$ sudo bash
现在我们可以获取支持的睡眠模式列表。为此,我们读取/sys/power/state
文件:
$ cat /sys/power/state
该文件由一行文本组成。每个单词代表一个支持的睡眠模式,模式之间用空格分隔。我们可以看到 QEMU 内核支持两种模式:freeze
和mem
:
Freeze 代表我们在前一节中讨论的 S2I 状态。mem
的含义由/sys/power/mem_sleep
文件的内容定义。在我们的系统中,它只包含[s2idle]
,代表与freeze
相同的 S2I 状态。
让我们将我们的模拟器切换到freeze
模式。我们将单词freeze
写入/sys/power/state
,立即 QEMU 窗口变黑并冻结:
我们能够让模拟的 Linux 系统进入睡眠状态,但无法唤醒它——没有它能理解的中断源。我们了解了不同的睡眠模式和内核 API 来处理它们。根据嵌入式系统的要求,您可以使用这些模式来降低功耗。
还有更多...
有关睡眠模式的更多信息可以在Linux 内核指南的相应部分中找到,网址为www.kernel.org/doc/html/v4.19/admin-guide/pm/sleep-states.html
。
使用 RTC 唤醒
在前面的示例中,我们能够让我们的 QEMU 系统进入睡眠状态,但无法唤醒它。我们需要一个设备,当其大部分内部组件关闭电源时,可以向系统发送中断。
RTC(实时时钟)就是这样的设备之一。它的功能之一是在系统关闭时保持内部时钟运行,并且为此,它有自己的电池。RTC 的功耗类似于电子手表;它使用相同的 3V 电池,并且可以在其自身的电源上工作多年。
RTC 可以作为闹钟工作,在给定时间向 CPU 发送中断。这使得它成为按计划唤醒系统的理想设备。
在这个示例中,我们将学习如何使用内置 RTC 在特定时间唤醒 Linux 系统。
如何做...
在这个示例中,我们将提前将系统的唤醒时间设置为 1 分钟,并将系统置于睡眠状态:
-
登录到任何具有 RTC 时钟的 Linux 系统——任何 Linux 笔记本都可以。不幸的是,树莓派没有内置 RTC,并且没有额外的硬件无法唤醒。
-
使用
sudo
获取 root 权限:
$ sudo bash
#
- 指示 RTC 在
1
分钟后唤醒系统:
# date '+%s' -d '+1 minute' > /sys/class/rtc/rtc0/wakealarm
- 将系统置于睡眠状态:
# echo freeze > /sys/power/state
- 等待一分钟。您的系统将会唤醒。
工作原理...
与 Linux 内核提供的许多其他功能一样,RTC 可以通过 sysfs 接口访问。为了设置一个将向系统发送唤醒中断的闹钟,我们需要向/sys/class/rtc/rtc0/wakealarm
文件写入一个POSIX(Portable Operating System Interface的缩写)时间戳。
我们在第十一章中更详细地讨论的 POSIX 时间戳,定义为自纪元以来经过的秒数,即 1970 年 1 月 1 日 00:00。
虽然我们可以编写一个程序,使用time
函数读取当前时间戳,再加上 60,并将结果写入wakealarm
文件,但我们可以使用 Unix shell 和date
命令在一行中完成这个操作,这在任何现代 Unix 系统上都可以实现。
date 实用程序不仅可以使用不同格式格式化当前时间,还可以解释不同格式的日期和时间。
我们指示date
解释时间字符串+1 minute
,并使用格式化模式%s
将其输出为 POSIX 时间戳。我们将其标准输出重定向到wakealarm
文件,有效地传递给 RTC 驱动程序:
date '+%s' -d '+1 minute' > /sys/class/rtc/rtc0/wakealarm
现在,知道 60 秒后闹钟会响,我们可以让系统进入睡眠状态。与前一个教程一样,我们将所需的睡眠模式写入/sys/power/state
文件:
# echo freeze > /sys/power/state
系统进入睡眠状态。您会注意到屏幕关闭了。如果您使用Secure Shell(SSH)连接到 Linux 框,命令行会冻结。然而,一分钟后它会醒来,屏幕会亮起,终端会再次响应。
这种技术非常适合定期、不经常地从传感器收集数据,比如每小时或每天。系统大部分时间都处于关闭状态,只有在收集数据并存储或发送到云端时才会唤醒,然后再次进入睡眠状态。
还有更多...
设置 RTC 闹钟的另一种方法是使用rtcwake
实用程序。
控制 USB 设备的 autosuspend
关闭外部设备是节省电力的最有效方法之一。然而,并不总是容易理解何时可以安全地关闭设备。外围设备,如网络卡或存储卡,可以执行内部数据处理;否则,在任意时间关闭设备的缓存和电源可能会导致数据丢失。
为了缓解这个问题,许多通过 USB 连接的外部设备在主机请求时可以将自己切换到低功耗模式。这样,它们可以在进入挂起状态之前执行处理内部数据的所有必要步骤。
由于 Linux 只能通过其 API 访问外围设备,它知道设备何时被应用程序和内核服务使用。如果设备在一定时间内没有被使用,Linux 内核中的电源管理系统可以自动指示设备进入省电模式——不需要来自用户空间应用程序的显式请求。这个功能被称为autosuspend。然而,内核允许应用程序控制设备的空闲时间,之后 autosuspend 会生效。
在这个教程中,我们将学习如何启用 autosuspend 并修改特定 USB 设备的 autosuspend 间隔。
如何做...
我们将启用 autosuspend 并修改连接到 Linux 框的 USB 设备的 autosuspend 时间:
-
登录到您的 Linux 框(树莓派、Ubuntu 和 Docker 容器不适用)。
-
切换到 root 账户:
$ sudo bash
#
- 获取所有连接的 USB 设备的当前
autosuspend
状态:
# for f in /sys/bus/usb/devices/*/power/control; do echo "$f"; cat $f; done
- 为一个设备启用
autosuspend
:
# echo auto > /sys/bus/usb/devices/1-1.2/power/control
- 读取设备的
autosuspend
间隔:
# cat /sys/bus/usb/devices/1-1.2/power/autosuspend_delay_ms
- 修改
autosuspend
间隔:
# echo 5000 > /sys/bus/usb/devices/1-1.2/power/autosuspend_delay_ms
- 检查设备的当前电源模式:
# cat /sys/bus/usb/devices/1-1.2/power/runtime_status
相同的操作可以使用标准文件 API 在 C++中编程。
它是如何工作的...
Linux 通过 sysfs 文件系统公开其电源管理 API,这使得可以通过标准文件读写操作读取当前状态并修改任何设备的设置成为可能。因此,我们可以使用支持基本文件操作的任何编程语言来控制 Linux 中的外围设备。
为了简化我们的示例,我们将使用 Unix shell,但在必要时完全相同的逻辑可以用 C++编程。
首先,我们检查所有连接的 USB 设备的autosuspend
设置。在 Linux 中,每个 USB 设备的参数都作为/sysfs/bus/usb/devices/
文件夹下的目录公开。每个设备目录又有一组代表设备参数的文件。所有与电源管理相关的参数都分组在power
子目录中。
要读取autosuspend
的状态,我们需要读取设备的power
目录中的control
文件。使用 Unix shell 通配符替换,我们可以为所有 USB 设备读取此文件:
# for f in /sys/bus/usb/devices/*/power/control; do echo "$f"; cat $f; done
对于与通配符匹配的每个目录,我们显示控制文件的完整路径及其内容。结果取决于连接的设备,可能如下所示:
报告的状态可能是 autosuspend 或on
。如果状态报告为 autosuspend,则自动电源管理已启用;否则,设备始终保持开启。
在我们的情况下,设备usb1
,1-1.1
和1-1.2
是开启的。让我们修改1-1.2
的配置以使用自动挂起。为此,我们只需向相应的_control_
文件中写入字符串_auto_
。
# echo auto > /sys/bus/usb/devices/1-1.2/power/control
再次运行循环读取所有设备的操作显示,1-1.2
设备现在处于autosuspend
模式:
它将在何时被挂起?我们可以从power
子目录中的autosuspend_delay_ms
文件中读取:
# cat /sys/bus/usb/devices/1-1.2/power/autosuspend_delay_ms
它显示设备在空闲2000
毫秒后将被挂起:
让我们将其更改为5
秒。我们在autosuspend_delay_ms
文件中写入5000
:
# echo 5000 > /sys/bus/usb/devices/1-1.2/power/autosuspend_delay_ms
再次读取它显示新值已被接受:
现在让我们检查设备的当前电源状态。我们可以从runtime_status
文件中读取它:
# cat /sys/bus/usb/devices/1-1.2/power/runtime_status
状态报告为active
:
请注意,内核不直接控制设备的电源状态;它只请求它们改变状态。即使请求设备切换到挂起模式,它也可能因为各种原因而拒绝这样做,例如,它可能根本不支持节能模式。
通过 sysfs 接口访问任何设备的电源管理设置是调整运行 Linux OS 的嵌入式系统的功耗的强大方式。
还有更多...
没有直接的方法立即关闭 USB 设备;但在许多情况下,可以通过向autosuspend_delay_ms
文件中写入0
来实现。内核将零的自动挂起间隔解释为对设备的立即挂起请求。
在 Linux 中,有关 USB 电源管理的更多细节可以在 Linux 内核文档的相应部分中找到,该文档可在www.kernel.org/doc/html/v4.13/driver-api/usb/power-management.html
上找到。
配置 CPU 频率
CPU 频率是系统的重要参数,它决定了系统的性能和功耗。频率越高,CPU 每秒可以执行的指令就越多。但这是有代价的。更高的频率意味着更高的功耗,反过来意味着需要散热更多的热量以避免处理器过热。
现代处理器能够根据负载使用不同的操作频率。对于计算密集型任务,它们使用最大频率以实现最大性能,但当系统大部分空闲时,它们会切换到较低的频率以减少功耗和热量影响。
适当的频率选择由操作系统管理。在这个示例中,我们将学习如何在 Linux 中设置 CPU 频率范围并选择频率管理器,以微调 CPU 频率以满足您的需求。
如何做...
我们将使用简单的 shell 命令来调整树莓派盒子上的 CPU 频率参数:
-
登录到树莓派或另一个非虚拟化的 Linux 系统。
-
切换到 root 帐户:
$ sudo bash
#
- 获取系统中所有 CPU 核心的当前频率:
# cat /sys/devices/system/cpu/*/cpufreq/scaling_cur_freq
- 获取 CPU 支持的所有频率:
# cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_available_frequencies
- 获取可用的 CPU 频率管理器:
# cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors
- 现在让我们检查当前使用的频率管理器是哪个:
# cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
- 将 CPU 的最小频率调整到最高支持的频率:
# echo 1200000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_min_freq
- 再次显示当前频率以了解效果:
# cat /sys/devices/system/cpu/*/cpufreq/scaling_cur_freq
- 将最小频率调整到最低支持的频率:
# echo 600000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_min_fre
- 现在让我们检查 CPU 频率如何取决于所使用的管理器。选择
performance
管理器并获取当前频率:
# echo performance > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
# cat /sys/devices/system/cpu/*/cpufreq/scaling_cur_freq
- 选择
powersave
管理器并观察结果:
# echo powersave > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
# cat /sys/devices/system/cpu/*/cpufreq/scaling_cur_freq
您可以使用常规文件 API 在 C++中实现相同的逻辑。
它是如何工作的...
与 USB 电源管理类似,CPU 频率管理系统 API 通过 sysfs 公开。我们可以像常规文本文件一样读取和修改其参数。
我们可以在/sys/devices/system/cpu/
目录下找到与 CPU 核心相关的所有设置。配置参数按 CPU 核心分组在名为每个代码索引的子目录中,如cpu1
,cpu2
等。
我们对与 CPU 频率管理相关的几个参数感兴趣,这些参数位于每个核心的cpufreq
子目录中。让我们读取所有可用核心的当前频率:
# cat /sys/devices/system/cpu/*/cpufreq/scaling_cur_freq
我们可以看到所有核心的频率都是相同的,为 600 MHz(cpufreq
子系统使用 KHz 作为频率的测量单位):
接下来,我们弄清楚 CPU 支持的所有频率:
# cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_available_frequencies
树莓派 3 的 ARM 处理器仅支持两种频率,600 MHz 和 1.2 GHz:
我们无法直接设置所需的频率。Linux 通过所谓的管理器内部管理 CPU 频率,并且只允许我们调整两个参数:
-
管理器的频率范围
-
管理器的类型
尽管这看起来像是一个限制,但这两个参数足够灵活,可以实现相当复杂的策略。让我们看看如何修改这两个参数如何影响 CPU 频率。
首先,让我们弄清楚支持哪些管理器以及当前使用的是哪个:
当前的管理器是ondemand
。*它根据系统负载调整频率。目前,树莓派板卡相当空闲,因此使用最低频率 600 MHz。但是如果我们将最低频率设置为最高频率呢?
# echo 1200000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_min_freq
在我们更新了一个核心的scaling_min_freq
参数后,所有核心的频率都被更改为最大值:
由于四个核心都属于同一个 CPU,我们无法独立地改变它们的频率;改变一个核心的频率会影响所有核心。但是,我们可以独立地控制不同 CPU 的频率。
现在我们将最小频率恢复到 600 MHz 并更改管理器。我们选择了performance
管理器,而不是调整频率的ondemand
管理器,旨在无条件地提供最大性能:
echo performance > /sys/devices/system/cpu/cpu0/cpufreq/scaling_g;overnor
毫不奇怪,它将频率提高到最大支持的频率:
另一方面,powersave
调度程序旨在尽可能节省电量,因为它始终坚持使用最低支持的频率,而不考虑负载:
正如您所看到的,调整频率范围和频率调度程序可以灵活地调整频率,以便根据系统的性质减少 CPU 消耗的电量。
还有更多...
除了ondemand
、performance
和powersave
之外,还有其他调度程序可以提供更灵活的 CPU 频率调整,供用户空间应用程序使用。您可以在 Linux CPUFreq 的相应部分中找到有关可用调度程序及其属性的更多详细信息www.kernel.org/doc/Documentation/cpu-freq/governors.txt
使用事件进行等待
等待是软件开发中极为常见的模式。应用程序必须等待用户输入或数据准备好进行处理。嵌入式程序与外围设备通信,需要知道何时可以从设备读取数据以及设备何时准备好接受数据。
通常,开发人员使用轮询技术的变体进行等待。他们在循环中检查设备特定的可用性标志,当设备将其设置为 true 时,他们继续读取或写入数据。
尽管这种方法易于实现,但从能耗的角度来看效率低下。当处理器不断忙于循环检查标志时,操作系统电源管理器无法将其置于更节能的模式中。根据负载,我们之前讨论的 Linux ondemand
频率调度程序甚至可以决定增加 CPU 频率,尽管这实际上是一种等待。此外,轮询请求可能会阻止目标设备或设备总线保持在节能模式,直到数据准备就绪。
这就是为什么对于关心能效的轮询程序,它应该依赖于操作系统生成的中断和事件。
在本教程中,我们将学习如何使用操作系统事件来等待特定的 USB 设备连接。
如何做...
我们将创建一个应用程序,可以监视 USB 设备并等待特定设备出现:
-
在您的工作
~/test
目录中创建一个名为udev
的子目录。 -
使用您喜欢的文本编辑器在
udev
子目录中创建一个名为udev.cpp
的文件。 -
将必要的包含和
namespace
定义放入udev.cpp
文件中:
#include <iostream>
#include <functional>
#include <libudev.h>
#include <poll.h>
namespace usb {
- 现在,让我们定义
Device
类:
class Device {
struct udev_device *dev{0};
public:
Device(struct udev_device* dev) : dev(dev) {
}
Device(const Device& other) : dev(other.dev) {
udev_device_ref(dev);
}
~Device() {
udev_device_unref(dev);
}
std::string action() const {
return udev_device_get_action(dev);
}
std::string attr(const char* name) const {
const char* val = udev_device_get_sysattr_value(dev,
name);
return val ? val : "";
}
};
- 之后,添加
Monitor
类的定义:
class Monitor {
struct udev_monitor *mon;
public:
Monitor() {
struct udev* udev = udev_new();
mon = udev_monitor_new_from_netlink(udev, "udev");
udev_monitor_filter_add_match_subsystem_devtype(
mon, "usb", NULL);
udev_monitor_enable_receiving(mon);
}
Monitor(const Monitor& other) = delete;
~Monitor() {
udev_monitor_unref(mon);
}
Device wait(std::function<bool(const Device&)> process) {
struct pollfd fds[1];
fds[0].events = POLLIN;
fds[0].fd = udev_monitor_get_fd(mon);
while (true) {
int ret = poll(fds, 1, -1);
if (ret < 0) {
throw std::system_error(errno,
std::system_category(),
"Poll failed");
}
if (ret) {
Device d(udev_monitor_receive_device(mon));
if (process(d)) {
return d;
};
}
}
}
};
};
- 在
usb
命名空间中定义了Device
和Monitor
之后,添加一个简单的main
函数,展示如何使用它们:
int main() {
usb::Monitor mon;
usb::Device d = mon.wait([](auto& d) {
auto id = d.attr("idVendor") + ":" +
d.attr("idProduct");
auto produce = d.attr("product");
std::cout << "Check [" << id << "] action: "
<< d.action() << std::endl;
return d.action() == "bind" &&
id == "8086:0808";
});
std::cout << d.attr("product")
<< " connected, uses up to "
<< d.attr("bMaxPower") << std::endl;
return 0;
}
- 创建一个包含我们程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(udev)
add_executable(usb udev.cpp)
target_link_libraries(usb udev)
-
使用
ssh
将udev
目录复制到您 Linux 系统上的家目录中。 -
登录到您的 Linux 系统,将目录切换到
udev
,并使用cmake
构建程序:
$cd ~/udev; cmake. && make
现在您可以构建并运行应用程序。
它是如何工作的...
为了获取有关 USB 设备事件的系统通知,我们使用了一个名为libudev
的库。它只提供了一个简单的 C 接口,因此我们创建了简单的 C++包装器来使编码更容易。
对于我们的包装器类,我们声明了一个名为usb
的namespace
:
namespace usb {
它包含两个类。第一个类是Device
,它为我们提供了一个 C++接口,用于低级libudev
对象udev_device
。
我们定义了一个构造函数,从udev_device
指针创建了一个Device
实例,并定义了一个析构函数来释放udev_device
。在内部,libudev
使用引用计数来管理其对象,因此我们的析构函数调用一个函数来减少udev_device
的引用计数:
~Device() {
udev_device_unref(dev);
}
Device(const Device& other) : dev(other.dev) {
udev_device_ref(dev);
}
这样,我们可以复制Device
实例而不会出现内存泄漏或文件描述符泄漏。
除了构造函数和析构函数之外,Device
类只有两个方法:action
和attr
。action
方法返回最近的 USB 设备动作:
std::string action() const {
return udev_device_get_action(dev);
}
attr
方法返回与设备关联的任何 sysfs 属性:
std::string attr(const char* name) const {
const char* val = udev_device_get_sysattr_value(dev,
name);
return val ? val : "";
}
Monitor
类也有构造函数和析构函数,但我们通过禁用复制构造函数使其不可复制:
Monitor(const Monitor& other) = delete;
构造函数使用静态变量初始化libudev
实例,以确保它只初始化一次:
struct udev* udev = udev_new();
它还设置了监视过滤器并启用了监视:
udev_monitor_filter_add_match_subsystem_devtype(
mon, "usb", NULL);
udev_monitor_enable_receiving(mon);
wait
方法包含最重要的监视逻辑。它接受类似函数的process
对象,每次检测到事件时都会调用它:
Device wait(std::function<bool(const Device&)> process) {
如果事件和它来自的设备是我们需要的,函数应返回true
;否则,它返回false
以指示wait
应继续工作。
在内部,wait
函数创建一个文件描述符,用于将设备事件传递给程序:
fds[0].fd = udev_monitor_get_fd(mon);
然后它设置监视循环。尽管它的名称是poll
函数,但它并不会不断检查设备的状态;它会等待指定文件描述符上的事件。我们传递-1
作为超时,表示我们打算永远等待事件:
int ret = poll(fds, 1, -1);
poll
函数仅在出现错误或新的 USB 事件时返回。我们通过抛出异常来处理错误情况:
if (ret < 0) {
throw std::system_error(errno,
std::system_category(),
"Poll failed");
}
对于每个事件,我们创建一个Device
的新实例,并将其传递给process
。如果process
返回true
,我们退出等待循环,将Device
的实例返回给调用者:
Device d(udev_monitor_receive_device(mon));
if (process(d)) {
return d;
};
让我们看看如何在我们的应用程序中使用这些类。在main
函数中,我们创建一个Monitor
实例并调用其wait
函数。我们使用 lambda 函数来处理每个动作:
usb::Device d = mon.wait([](auto& d) {
在 lambda 函数中,我们打印有关所有事件的信息:
std::cout << "Check [" << id << "] action: "
<< d.action() << std::endl;
我们还检查特定的动作和设备id
:
return d.action() == "bind" &&
id == "8086:0808";
一旦找到,我们会显示有关其功能和功率需求的信息:
std::cout << d.attr("product")
<< " connected, uses up to "
<< d.attr("bMaxPower") << std::endl;
最初运行此应用程序不会产生任何输出:
然而,一旦我们插入 USB 设备(在我这里是 USB 麦克风),我们可以看到以下输出:
应用程序可以等待特定的 USB 设备,并在连接后处理它。它可以在不忙碌循环的情况下完成,依靠操作系统提供的信息。因此,应用程序大部分时间都在睡眠,而poll
调用被操作系统阻塞。
还有更多...
有许多libudev
的 C++包装器。您可以使用其中之一,或者使用本示例中的代码作为起点创建自己的包装器。
使用 PowerTOP 进行功耗分析
在像 Linux 这样运行多个用户空间和内核空间服务并同时控制许多外围设备的复杂操作系统中,要找到可能导致过多功耗的组件并不总是容易的。即使找到了效率低下的问题,修复它可能也很困难。
其中一个解决方案是使用功耗分析工具,如 PowerTOP。它可以诊断 Linux 系统中的功耗问题,并允许用户调整可以节省功耗的系统参数。
在这个示例中,我们将学习如何在树莓派系统上安装和使用 PowerTOP。
如何做...
在这个示例中,我们将以交互模式运行 PowerTOP 并分析其输出:
-
以
pi
用户身份登录到您的树莓派系统,使用密码raspberry
。 -
运行
sudo
以获得 root 访问权限:
$ sudo bash
#
- 从存储库安装 PowerTOP:
# apt-get install powertop
- 保持在 root shell 中,运行 PowerTOP:
# powertop
PowerTOP UI 将显示在您的终端中。使用Tab键在其屏幕之间导航。
工作原理...
PowerTOP 是由英特尔创建的用于诊断 Linux 系统中功耗问题的工具。它是 Raspbian 发行版的一部分,可以使用apt-get
命令安装:
# apt-get install powertop
当我们在没有参数的情况下运行它时,它会以交互模式启动,并按其功耗和它们生成事件的频率对所有进程和内核任务进行排序。正如我们在使用事件进行等待一节中讨论的那样,程序需要频繁唤醒处理器,它的能效就越低:
使用Tab键,我们可以切换到其他报告模式。例如,设备统计显示设备消耗了多少能量或 CPU 时间:
另一个有趣的选项卡是 Tunab。PowerTOP 可以检查影响功耗的一些设置,并标记那些不够理想的设置:
如您所见,两个 USB 设备被标记为Bad
,因为它们没有使用自动挂起。通过按下Enter键,PowerTOP 启用了自动挂起,并显示了一个可以从脚本中使用以使其永久化的命令行。启用自动挂起后,可调状态变为Good
:
一些系统参数可以调整以节省电力。有时它们是显而易见的,比如在 USB 设备上使用自动挂起。有时它们不是,比如在用于将文件缓存刷新到磁盘的内核上使用超时。使用诊断和优化工具,如 PowerTOP,可以帮助您调整系统以实现最大功耗效率。
还有更多...
除了交互模式,PowerTOP 还有其他模式可帮助您优化功耗,如校准、工作负载和自动调整。有关 PowerTOP 功能、使用场景和结果解释的更多信息,请参阅01.org/sites/default/files/page/powertop_users_guide_201412.pdf
中的PowerTOP 用户指南。
第十一章:时间点和间隔
嵌入式应用程序处理发生在物理世界中的事件和控制过程——这就是为什么正确处理时间和延迟对它们至关重要。交通灯的切换;声音音调的生成;来自多个传感器的数据同步——所有这些任务都依赖于正确的时间测量。
纯 C 不提供任何标准函数来处理时间。预期应用程序开发人员将使用特定于目标操作系统的时间 API——Windows、Linux 或 macOS。对于裸机嵌入式系统,开发人员必须创建自定义函数来处理时间,这些函数基于特定于目标平台的低级定时器 API。结果,代码很难移植到其他平台。
为了克服可移植性问题,C++(从 C++11 开始)定义了用于处理时间和时间间隔的数据类型和函数。这个 API 被称为std::chrono
库,它帮助开发人员以统一的方式在任何环境和任何目标平台上处理时间。
在本章中,我们将学习如何在我们的应用程序中处理时间戳、时间间隔和延迟。我们将讨论与时间管理相关的一些常见陷阱,以及它们的适当解决方法。
我们将涵盖以下主题:
-
探索 C++ Chrono 库
-
测量时间间隔
-
处理延迟
-
使用单调时钟
-
使用可移植操作系统接口(POSIX)时间戳
使用这些示例,您将能够编写可在任何嵌入式平台上运行的时间处理的可移植代码。
探索 C++ Chrono 库
从 C++11 开始,C++ Chrono 库提供了标准化的数据类型和函数,用于处理时钟、时间点和时间间隔。在这个示例中,我们将探索 Chrono 库的基本功能,并学习如何处理时间点和间隔。
我们还将学习如何使用 C++字面量来更清晰地表示时间间隔。
如何做...
我们将创建一个简单的应用程序,创建三个时间点并将它们相互比较。
-
在您的
~/test
工作目录中,创建一个名为chrono
的子目录。 -
使用您喜欢的文本编辑器在
chrono
子目录中创建一个chrono.cpp
文件。 -
将以下代码片段放入文件中:
#include <iostream>
#include <chrono>
using namespace std::chrono_literals;
int main() {
auto a = std::chrono::system_clock::now();
auto b = a + 1s;
auto c = a + 200ms;
std::cout << "a < b ? " << (a < b ? "yes" : "no") << std::endl;
std::cout << "a < c ? " << (a < c ? "yes" : "no") << std::endl;
std::cout << "b < c ? " << (b < c ? "yes" : "no") << std::endl;
return 0;
}
- 创建一个包含程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(chrono)
add_executable(chrono chrono.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++14")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
现在您可以构建和运行应用程序。
它是如何工作的...
我们的应用程序创建了三个不同的时间点。第一个是使用系统时钟的now
函数创建的:
auto a = std::chrono::system_clock::now();
另外两个时间点是通过添加固定的时间间隔1
秒和200
毫秒从第一个时间点派生出来的:
auto b = a + 1s;
auto c = a + 200ms;
请注意我们是如何在数字值旁边指定时间单位的。我们使用了一个叫做 C++字面量的特性。Chrono 库为基本时间单位定义了这样的字面量。为了使用这些定义,我们添加了以下内容:
using namespace std::chrono_literals;
这是在我们的main
函数之前添加的。
接下来,我们将比较这些时间点:
std::cout << "a < b ? " << (a < b ? "yes" : "no") << std::endl;
std::cout << "a < c ? " << (a < c ? "yes" : "no") << std::endl;
std::cout << "b < c ? " << (b < c ? "yes" : "no") << std::endl;
当我们运行应用程序时,我们会看到以下输出:
如预期的那样,时间点a
比b
和c
都要早,其中时间点c
(即a
+200 毫秒)比b
(a
+1 秒)要早。字符串字面量有助于编写更易读的代码,C++ Chrono 提供了丰富的函数集来处理时间。我们将在下一个示例中学习如何使用它们。
还有更多...
Chrono 库中定义的所有数据类型、模板和函数的信息可以在 Chrono 参考中找到en.cppreference.com/w/cpp/chrono
测量时间间隔
与外围硬件交互或响应外部事件的每个嵌入式应用程序都必须处理超时和反应时间。为了正确地做到这一点,开发人员需要能够以足够的精度测量时间间隔。
C++ Chrono 库提供了一个用于处理任意跨度和精度的持续时间的std::chrono::duration
模板类。在这个示例中,我们将学习如何使用这个类来测量两个时间戳之间的时间间隔,并将其与参考持续时间进行比较。
如何做...
我们的应用程序将测量简单控制台输出的持续时间,并将其与循环中的先前值进行比较。
-
在您的
〜/test
工作目录中,创建一个名为intervals
的子目录。 -
使用您喜欢的文本编辑器在
intervals
子目录中创建一个名为intervals.cpp
的文件。 -
将以下代码片段复制到
intervals.cpp
文件中:
#include <iostream>
#include <chrono>
int main() {
std::chrono::duration<double, std::micro> prev;
for (int i = 0; i < 10; i++) {
auto start = std::chrono::steady_clock::now();
std::cout << i << ": ";
auto end = std::chrono::steady_clock::now();
std::chrono::duration<double, std::micro> delta = end - start;
std::cout << "output duration is " << delta.count() <<" us";
if (i) {
auto diff = (delta - prev).count();
if (diff >= 0) {
std::cout << ", " << diff << " us slower";
} else {
std::cout << ", " << -diff << " us faster";
}
}
std::cout << std::endl;
prev = delta;
}
return 0;
}
- 最后,创建一个
CMakeLists.txt
文件,其中包含我们程序的构建规则:
cmake_minimum_required(VERSION 3.5.1)
project(interval)
add_executable(interval interval.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
现在,您可以构建并运行应用程序。
它是如何工作的...
在应用程序循环的每次迭代中,我们测量一个输出操作的性能。为此,我们在操作之前捕获一个时间戳,操作完成后捕获另一个时间戳:
auto start = std::chrono::steady_clock::now();
std::cout << i << ": ";
auto end = std::chrono::steady_clock::now();
我们使用 C++11 的auto
让编译器推断时间戳的数据类型。现在,我们需要计算这些时间戳之间的时间间隔。从一个时间戳减去另一个时间戳就可以完成任务。我们明确将结果变量定义为std::chrono::duration
类,该类跟踪double
值中的微秒:
std::chrono::duration<double, std::micro> delta = end - start;
我们使用另一个相同类型的duration
变量来保存先前的值。除了第一次迭代之外的每次迭代,我们计算这两个持续时间之间的差异:
auto diff = (delta - prev).count();
在每次迭代中,持续时间和差异都会打印到终端上。当我们运行应用程序时,我们会得到这个输出:
正如我们所看到的,现代 C++提供了方便的方法来处理应用程序中的时间间隔。由于重载运算符,很容易获得两个时间点之间的持续时间,并且可以添加、减去或比较持续时间。
还有更多...
从 C++20 开始,Chrono 库支持直接将持续时间写入输出流并从输入流中解析持续时间。无需将持续时间显式序列化为整数或浮点值。这使得处理持续时间对于 C++开发人员更加方便。
处理延迟
周期性数据处理是许多嵌入式应用程序中的常见模式。代码不需要一直运行。如果我们预先知道何时需要处理,应用程序或工作线程可以大部分时间处于非活动状态,只有在需要时才唤醒并处理数据。这样可以节省电力消耗,或者在应用程序空闲时让设备上运行的其他应用程序使用 CPU 资源。
有几种组织周期性处理的技术。运行一个带有延迟的循环的工作线程是其中最简单和最常见的技术之一。
C++提供了标准函数来向当前执行线程添加延迟。在这个示例中,我们将学习两种向应用程序添加延迟的方法,并讨论它们的优缺点。
如何做...
我们将创建一个具有两个处理循环的应用程序。这些循环使用不同的函数来暂停当前线程的执行。
-
在您的
〜/test
工作目录中,创建一个名为delays
的子目录。 -
使用您喜欢的文本编辑器在
delays
子目录中创建一个名为delays.cpp
的文件。 -
让我们首先添加一个名为
sleep_for
的函数,以及必要的包含:
#include <iostream>
#include <chrono>
#include <thread>
using namespace std::chrono_literals;
void sleep_for(int count, auto delay) {
for (int i = 0; i < count; i++) {
auto start = std::chrono::system_clock::now();
std::this_thread::sleep_for(delay);
auto end = std::chrono::system_clock::now();
std::chrono::duration<double, std::milli> delta = end - start;
std::cout << "Sleep for: " << delta.count() << std::endl;
}
}
- 它后面是第二个函数
sleep_until
:
void sleep_until(int count,
std::chrono::milliseconds delay) {
auto wake_up = std::chrono::system_clock::now();
for (int i = 0; i < 10; i++) {
wake_up += delay;
auto start = std::chrono::system_clock::now();
std::this_thread::sleep_until(wake_up);
auto end = std::chrono::system_clock::now();
std::chrono::duration<double, std::milli> delta = end - start;
std::cout << "Sleep until: " << delta.count() << std::endl;
}
}
- 接下来,添加一个简单的
main
函数来调用它们:
int main() {
sleep_for(10, 100ms);
sleep_until(10, 100ms);
return 0;
}
- 最后,创建一个
CMakeLists.txt
文件,其中包含我们程序的构建规则:
cmake_minimum_required(VERSION 3.5.1)
project(delays)
add_executable(delays delays.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++14")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
现在,您可以构建并运行应用程序了。
它是如何工作的...
在我们的应用程序中,我们创建了两个函数,sleep_for
和sleep_until
。它们几乎相同,只是sleep_for
使用std::this_thread::sleep_for
来添加延迟,而sleep_until
使用std::this_thread::sleep_until
。
让我们更仔细地看看sleep_for
函数。它接受两个参数——count
和delay
。第一个参数定义了循环中的迭代次数,第二个参数指定了延迟。我们使用auto
作为delay
参数的数据类型,让 C++为我们推断实际的数据类型。
函数体由一个循环组成:
for (int i = 0; i < count; i++) {
在每次迭代中,我们运行delay
并通过在delay
之前和之后获取时间戳来测量其实际持续时间。std::this_thread::sleep_for
函数接受时间间隔作为参数:
auto start = std::chrono::system_clock::now();
std::this_thread::sleep_for(delay);
auto end = std::chrono::system_clock::now();
实际延迟以毫秒为单位测量,我们使用double
值作为毫秒计数器:
std::chrono::duration<double, std::milli> delta = end - start;
wait_until
函数只是稍有不同。它使用std::current_thred::wait_until
函数,该函数接受一个时间点来唤醒,而不是一个时间间隔。我们引入了一个额外的wake_up
变量来跟踪唤醒时间点:
auto wake_up = std::chrono::system_clock::now();
最初,它被设置为当前时间,并在每次迭代中,将作为函数参数传递的延迟添加到其值中:
wake_up += delay;
函数的其余部分与sleep_for
实现相同,除了delay
函数:
std::this_thread::sleep_until(wake_up);
我们运行两个函数,使用相同数量的迭代和相同的延迟。请注意我们如何使用 C++字符串字面量将毫秒传递给函数,以使代码更易读。为了使用字符串字面量,我们添加了以下内容:
sleep_for(10, 100ms);
sleep_until(10, 100ms);
这是在函数定义之上完成的,就像这样:
using namespace std::chrono_literals;
不同的延迟函数会有什么不同吗?毕竟,我们在两种实现中都使用了相同的延迟。让我们运行代码并比较结果:
有趣的是,我们可以看到sleep_for
的所有实际延迟都大于100
毫秒,而sleep_until
的一些结果低于这个值。我们的第一个函数delay_for
没有考虑打印数据到控制台所需的时间。当您确切地知道需要等待多长时间时,sleep_for
是一个不错的选择。然而,如果您的目标是以特定的周期性唤醒,sleep_until
可能是一个更好的选择。
还有更多...
sleep_for
和sleep_until
之间还有其他微妙的差异。系统定时器通常不太精确,并且可能会被时间同步服务(如网络时间协议 守护程序(ntpd))调整。这些时钟调整不会影响sleep_for
,但会影响sleep_until
。如果您的应用程序依赖于特定时间而不是时间间隔,请使用它;例如,如果您需要每秒重新绘制时钟显示上的数字。
使用单调时钟
C++ Chrono 库提供了三种类型的时钟:
-
系统时钟
-
稳定时钟
-
高分辨率时钟
高分辨率时钟通常被实现为系统时钟或稳定时钟的别名。然而,系统时钟和稳定时钟是非常不同的。
系统时钟反映系统时间,因此不是单调的。它可以随时通过时间同步服务(如网络时间协议(NTP))进行调整,因此甚至可以倒退。
这使得系统时钟成为处理精确持续时间的不良选择。稳定时钟是单调的;它永远不会被调整,也永远不会倒退。这个属性有它的代价——它与挂钟时间无关,通常表示自上次重启以来的时间。
稳定时钟不应该用于需要在重启后保持有效的持久时间戳,例如序列化到文件或保存到数据库。此外,稳定时钟不应该用于涉及来自不同来源的时间的任何时间计算,例如远程系统或外围设备。
在这个示例中,我们将学习如何使用稳定时钟来实现一个简单的软件看门狗。在运行后台工作线程时,重要的是要知道它是否正常工作或因编码错误或无响应的外围设备而挂起。线程定期更新时间戳,而监视例程则将时间戳与当前时间进行比较,如果超过阈值,则执行某种恢复操作。
如何做...
在我们的应用程序中,我们将创建一个在后台运行的简单迭代函数,以及在主线程中运行的监视循环。
-
在您的
~/test
工作目录中,创建一个名为monotonic
的子目录。 -
使用您喜欢的文本编辑器在
monotonic
子目录中创建一个monotonic.cpp
文件。 -
让我们添加头文件并定义我们例程中使用的全局变量:
#include <iostream>
#include <chrono>
#include <atomic>
#include <mutex>
#include <thread>
auto touched = std::chrono::steady_clock::now();
std::mutex m;
std::atomic_bool ready{ false };
- 它们后面是后台工作线程例程的代码:
void Worker() {
for (int i = 0; i < 10; i++) {
std::this_thread::sleep_for(
std::chrono::milliseconds(100 + (i % 4) * 10));
std::cout << "Step " << i << std::endl;
{
std::lock_guard<std::mutex> l(m);
touched = std::chrono::steady_clock::now();
}
}
ready = true;
}
- 添加包含监视例程的
main
函数:
int main() {
std::thread t(Worker);
std::chrono::milliseconds threshold(120);
while(!ready) {
auto now = std::chrono::steady_clock::now();
std::chrono::milliseconds delta;
{
std::lock_guard<std::mutex> l(m);
auto delta = now - touched;
if (delta > threshold) {
std::cout << "Execution threshold exceeded" << std::endl;
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
t.join();
return 0;
}
- 最后,创建一个包含程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(monotonic)
add_executable(monotonic monotonic.cpp)
target_link_libraries(monotonic pthread)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
现在可以构建和运行应用程序了。
它是如何工作的...
我们的应用程序是多线程的——它由运行监视的主线程和后台工作线程组成。我们使用三个全局变量进行同步。
touched
变量保存了由Worker
线程定期更新的时间戳。由于时间戳被两个线程访问,需要进行保护。我们使用一个m
互斥锁来实现。最后,为了指示工作线程已经完成了它的工作,使用了一个原子变量ready
。
工作线程是一个包含人为延迟的循环。延迟是基于步骤编号计算的,导致延迟从 100 毫秒到 130 毫秒不等:
std::this_thread::sleep_for(
std::chrono::milliseconds(100 + (i % 4) * 10));
在每次迭代中,Worker
线程更新时间戳。使用锁保护同步访问时间戳:
{
std::lock_guard<std::mutex> l(m);
touched = std::chrono::steady_clock::now();
}
监视例程在Worker
线程运行时循环运行。在每次迭代中,它计算当前时间和上次更新之间的时间间隔:
std::lock_guard<std::mutex> l(m);
auto delta = now - touched;
如果超过阈值,函数会打印警告消息,如下所示:
if (delta > threshold) {
std::cout << "Execution threshold exceeded" << std::endl;
}
在许多情况下,应用程序可能调用恢复函数来重置外围设备或重新启动线程。我们在监视循环中添加了10
毫秒的延迟:
std::this_thread::sleep_for(std::chrono::milliseconds(10));
这有助于减少资源消耗,同时实现可接受的反应时间。运行应用程序会产生以下输出:
我们可以在输出中看到几个警告,表明worker
线程中的一些迭代所花费的时间超过了120
毫秒的阈值。这是可以预料的,因为worker
函数是这样编写的。重要的是我们用一个单调的std::chrono::steady_clock
函数进行监视。使用系统时钟可能会导致在时钟调整期间对恢复函数的虚假调用。
还有更多...
C++20 定义了几种其他类型的时钟,比如gps_clock
,表示全球定位系统(GPS)时间,或者file_clock
,用于处理文件时间戳。这些时钟可能是稳定的,也可能不是。使用is_steady
成员函数来检查时钟是否是单调的。
使用 POSIX 时间戳
POSIX 时间戳是 Unix 操作系统中时间的传统内部表示。POSIX 时间戳被定义为自纪元以来的秒数,即协调世界时(UTC)1970 年 1 月 1 日的 00:00:00。
由于其简单性,这种表示在网络协议、文件元数据或序列化中被广泛使用。
在这个示例中,我们将学习如何将 C++时间点转换为 POSIX 时间戳,并从 POSIX 时间戳创建 C++时间点。
如何做...
我们将创建一个应用程序,将时间点转换为 POSIX 时间戳,然后从该时间戳中恢复时间点。
-
在你的
~/test
工作目录中,创建一个名为timestamps
的子目录。 -
使用你喜欢的文本编辑器在
timestamps
子目录中创建一个名为timestamps.cpp
的文件。 -
将以下代码片段放入文件中:
#include <iostream>
#include <chrono>
int main() {
auto now = std::chrono::system_clock::now();
std::time_t ts = std::chrono::system_clock::to_time_t(now);
std::cout << "POSIX timestamp: " << ts << std::endl;
auto restored = std::chrono::system_clock::from_time_t(ts);
std::chrono::duration<double, std::milli> delta = now - restored;
std::cout << "Recovered time delta " << delta.count() << std::endl;
return 0;
}
- 创建一个包含我们程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(timestamps)
add_executable(timestamps timestamps.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
现在,你可以构建并运行应用程序。
它是如何工作的...
首先,我们使用系统时钟为当前时间创建一个时间点对象:
auto now = std::chrono::system_clock::now();
由于 POSIX 时间戳表示自纪元以来的时间,我们不能使用稳定时钟。然而,系统时钟知道如何将其内部表示转换为 POSIX 格式。它提供了一个to_time_t
静态函数来实现这个目的:
std::time_t ts = std::chrono::system_clock::to_time_t(now);
结果被定义为具有类型std::time_t
,但这是一个整数类型,而不是对象。与时间点实例不同,我们可以直接将其写入输出流:
std::cout << "POSIX timestamp: " << ts << std::endl;
让我们尝试从这个整数时间戳中恢复一个时间点。我们使用一个from_time_t
静态函数:
auto restored = std::chrono::system_clock::from_time_t(ts);
现在,我们有两个时间戳。它们是相同的吗?让我们计算并显示差异:
std::chrono::duration<double, std::milli> delta = now - restored;
std::cout << "Recovered time delta " << delta.count() << std::endl;
当我们运行应用程序时,我们会得到以下输出:
时间戳是不同的,但差异始终小于 1,000。由于 POSIX 时间戳被定义为自纪元以来的秒数,我们丢失了毫秒和微秒等细粒度时间。
尽管存在这样的限制,POSIX 时间戳仍然是时间的重要和广泛使用的传输表示,我们学会了如何在需要时将它们转换为内部 C++表示。
还有更多...
在许多情况下,直接使用 POSIX 时间戳就足够了。由于它们被表示为数字,可以使用简单的数字比较来决定哪个时间戳更新或更旧。类似地,从一个时间戳中减去另一个时间戳会给出它们之间的秒数时间间隔。如果性能是一个瓶颈,这种方法可能比与本机 C++时间点进行比较更可取。
第十二章:错误处理和容错
难以高估嵌入式软件中错误处理的重要性。嵌入式系统应该在各种物理条件下无需监督地工作,例如控制可能故障或不总是提供可靠通信线路的外部外围设备。在许多情况下,系统的故障要么很昂贵,要么很不安全。
在本章中,我们将学习有助于编写可靠和容错的嵌入式应用程序的常见策略和最佳实践。
我们将在本章中介绍以下食谱:
-
处理错误代码
-
使用异常处理错误
-
在捕获异常时使用常量引用
-
解决静态对象
-
处理看门狗
-
探索高可用系统的心跳
-
实现软件去抖动逻辑
这些食谱将帮助您了解错误处理设计的重要性,学习最佳实践,并避免在此领域出现问题。
处理错误代码
在设计新函数时,开发人员经常需要一种机制来指示函数无法完成其工作,因为出现了某种错误。这可能是无效的,从外围设备接收到意外结果,或者是资源分配问题。
报告错误条件的最传统和广泛使用的方法之一是通过错误代码。这是一种高效且无处不在的机制,不依赖于编程语言或操作系统。由于其效率、多功能性和跨各种平台边界的能力,它在嵌入式软件开发中被广泛使用。
设计一个既返回值又返回错误代码的函数接口可能会很棘手,特别是如果值和错误代码具有不同的类型。在这个食谱中,我们将探讨设计这种类型的函数接口的几种方法。
操作步骤...
我们将创建一个简单的程序,其中包含一个名为Receive
的函数的三个实现。所有三个实现都具有相同的行为,但接口不同。按照以下步骤进行:
-
在您的工作目录
~/test
中,创建一个名为errcode
的子目录。 -
使用您喜欢的文本编辑器在
errcode
子目录中创建一个名为errcode.cpp
的文件。 -
将第一个函数的实现添加到
errcode.cpp
文件中:
#include <iostream>
int Receive(int input, std::string& output) {
if (input < 0) {
return -1;
}
output = "Hello";
return 0;
}
- 接下来,我们添加第二个实现:
std::string Receive(int input, int& error) {
if (input < 0) {
error = -1;
return "";
}
error = 0;
return "Hello";
}
Receive
函数的第三个实现如下:
std::pair<int, std::string> Receive(int input) {
std::pair<int, std::string> result;
if (input < 0) {
result.first = -1;
} else {
result.second = "Hello";
}
return result;
}
- 现在,我们定义一个名为
Display
的辅助函数来显示结果:
void Display(const char* prefix, int err, const std::string& result) {
if (err < 0) {
std::cout << prefix << " error: " << err << std::endl;
} else {
std::cout << prefix << " result: " << result << std::endl;
}
}
- 然后,我们添加一个名为
Test
的函数,调用所有三个实现:
void Test(int input) {
std::string outputResult;
int err = Receive(input, outputResult);
Display(" Receive 1", err, outputResult);
int outputErr = -1;
std::string result = Receive(input, outputErr);
Display(" Receive 2", outputErr, result);
std::pair<int, std::string> ret = Receive(input);
Display(" Receive 3", ret.first, ret.second);
}
main
函数将所有内容联系在一起:
int main() {
std::cout << "Input: -1" << std::endl;
Test(-1);
std::cout << "Input: 1" << std::endl;
Test(1);
return 0;
}
- 最后,我们创建一个包含程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(errcode)
add_executable(errcode errcode.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 您现在可以构建和运行应用程序了。
工作原理...
在我们的应用程序中,我们定义了一个从某个设备接收数据的函数的三种不同实现。它应该将接收到的数据作为字符串返回,但在出现错误时,应返回表示错误原因的整数错误代码。
由于结果和错误代码具有不同的类型,我们无法重用相同的值。要在 C++中返回多个值,我们需要使用输出参数或创建一个复合数据类型。
我们的实现同时探索了这两种策略。我们使用 C++函数重载来定义Receive
函数,它具有相同的名称,但不同类型的参数和返回值。
第一个实现返回一个错误代码,并将结果存储在输出参数中:
int Receive(int input, std::string& output)
输出参数是一个通过引用传递的字符串,让函数修改其内容。第二个实现颠倒了参数。它将接收到的字符串作为结果返回,并接受错误代码作为输出参数:
std::string Receive(int input, int& error)
由于我们希望错误代码由函数内部设置,因此我们也通过引用传递它。最后,第三种实现将结果和错误代码组合并返回一个 C++ pair
:
std::pair<int, std::string> Receive(int input)
该函数总是创建一个std::pair<int, std::string>
实例。由于我们没有向其构造函数传递任何值,因此对象是默认初始化的。整数元素设置为0
,字符串元素设置为空字符串。
这种方法不需要output
参数,更易读,但构造和销毁pair
对象的开销略高。
当所有三种实现都被定义后,我们在Test
函数中测试它们。我们将相同的参数传递给每个实现并显示结果。我们期望它们每个都生成相同的结果。
有两次调用Test
。首先,我们将-1
作为参数传递,这应该触发错误路径,然后我们传递1
,这将激活正常操作路径:
std::cout << "Input: -1" << std::endl;
Test(-1);
std::cout << "Input: 1" << std::endl;
Test(1);
当我们运行我们的程序时,我们会看到以下输出:
所有三种实现都根据输入参数正确返回结果或错误代码。您可以根据整体设计准则或个人偏好在应用程序中使用任何方法。
还有更多...
作为 C++17 标准的一部分,标准库中添加了一个名为std::optional
的模板。它可以表示可能丢失的可选值。它可以用作可能失败的函数的返回值。但是,它不能表示失败的原因,只能表示一个布尔值,指示该值是否有效。有关更多信息,请查看en.cppreference.com/w/cpp/utility/optional
上的std::optional
参考。
使用异常进行错误处理
虽然错误代码仍然是嵌入式编程中最常见的错误处理技术,但 C++提供了另一种称为异常的机制。
异常旨在简化错误处理并使其更可靠。当使用错误代码时,开发人员必须检查每个函数的结果是否有错误,并将结果传播到调用函数。这会使代码充斥着大量的 if-else 结构,使函数逻辑更加晦涩。
当使用异常时,开发人员无需在每个函数调用后检查错误。异常会自动通过调用堆栈传播,直到达到可以通过记录、重试或终止应用程序来正确处理它的代码。
虽然异常是 C++标准库的默认错误处理机制,但与外围设备或底层操作系统层通信仍涉及错误代码。在本教程中,我们将学习如何使用std::system_error
异常类将低级错误处理与 C++异常进行桥接。
如何做...
我们将创建一个简单的应用程序,通过串行链路与设备通信。请按照以下步骤操作:
-
在您的工作目录中,即
~/test
,创建一个名为except
的子目录。 -
使用您喜欢的文本编辑器在
except
子目录中创建一个名为except.cpp
的文件。 -
将所需的包含放入
except.cpp
文件中:
#include <iostream>
#include <system_error>
#include <fcntl.h>
#include <unistd.h>
- 接下来,我们定义一个抽象通信设备的
Device
类。我们从构造函数和析构函数开始:
class Device {
int fd;
public:
Device(const std::string& deviceName) {
fd = open(deviceName.c_str(), O_RDWR);
if (fd < 0) {
throw std::system_error(errno, std::system_category(),
"Failed to open device file");
}
}
~Device() {
close(fd);
}
- 然后,我们添加一个发送数据到设备的方法,如下所示:
void Send(const std::string& data) {
size_t offset = 0;
size_t len = data.size();
while (offset < data.size() - 1) {
int sent = write(fd, data.data() + offset,
data.size() - offset);
if (sent < 0) {
throw std::system_error(errno,
std::system_category(),
"Failed to send data");
}
offset += sent;
}
}
};
- 在我们的类被定义后,我们添加
main
函数来使用它:
int main() {
try {
Device serial("/dev/ttyUSB0");
serial.Send("Hello");
} catch (std::system_error& e) {
std::cout << "Error: " << e.what() << std::endl;
std::cout << "Code: " << e.code() << " means \""
<< e.code().message()
<< "\"" << std::endl;
}
return 0;
}
- 最后,我们创建一个
CMakeLists.txt
文件,其中包含程序的构建规则:
cmake_minimum_required(VERSION 3.5.1)
project(except)
add_executable(except except.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 您现在可以构建和运行应用程序。
工作原理...
我们的应用程序与通过串行连接的外部设备通信。在 POSIX 操作系统中,与设备通信类似于对常规文件的操作,并使用相同的 API;即open
、close
、read
和write
函数。
所有这些函数返回错误代码,指示各种错误条件。我们将通信包装在一个名为Device
的类中,而不是直接使用它们。
它的构造函数尝试打开由deviceName
构造函数参数引用的文件。构造函数检查错误代码,如果指示出现错误,则创建并抛出std::system_error
异常:
throw std::system_error(errno, std::system_category(),
"Failed to open device file");
我们使用三个参数构造std::system_error
实例。第一个是我们想要包装在异常中的错误代码。我们使用open
函数在返回错误时设置的errno
变量的值。第二个参数是错误类别。由于我们使用特定于操作系统的错误代码,我们使用std::system_category
的实例。第一个参数是我们想要与异常关联的消息。它可以是任何有助于我们在发生错误时识别错误的内容。
类似地,我们定义了Send
函数,它向设备发送数据。它是write
系统函数的包装器,如果write
返回错误,我们创建并抛出std::system_error
实例。唯一的区别是消息字符串,因为我们希望在日志中区分这两种情况:
throw std::system_error(errno, std::system_category(),
"Failed to send data");
}
在定义了Device
类之后,我们可以使用它。我们只需创建Device
类的一个实例并向其发送数据,而不是打开设备并检查错误,然后再次写入设备并再次检查错误:
Device serial("/dev/ttyUSB0");
serial.Send("Hello");
所有错误处理都在主逻辑之后的catch
块中。如果抛出系统错误,我们将其记录到标准输出。此外,我们打印嵌入在异常中的错误代码的信息。
} catch (std::system_error& e) {
std::cout << "Error: " << e.what() << std::endl;
std::cout << "Code: " << e.code() << " means \"" << e.code().message()
<< "\"" << std::endl;
}
当我们构建和运行应用程序时,如果没有设备连接为/dev/ttyUSB0
,它将显示以下输出:
如预期的那样,检测到了错误条件,我们可以看到所有必需的细节,包括底层操作系统错误代码及其描述。请注意,使用包装类与设备通信的代码是简洁易读的。
还有更多...
C++标准库提供了许多预定义的异常和错误类别。有关更多详细信息,请查看 C++错误处理参考en.cppreference.com/w/cpp/error
。
在捕获异常时使用常量引用
C++异常为异常处理设计提供了强大的基础。它们灵活多样,可以以多种不同的方式使用。您可以抛出任何类型的异常,包括指针和整数。您可以通过值或引用捕获异常。在选择数据类型时做出错误选择可能会导致性能损失或资源泄漏。
在这个配方中,我们将分析潜在的陷阱,并学习如何在 catch 块中使用常量引用来进行高效和安全的错误处理。
如何做...
我们将创建一个样本应用程序,抛出并捕获自定义异常,并分析数据类型选择如何影响效率。按照以下步骤进行:
-
在您的工作目录中,即
~/test
,创建一个名为catch
的子目录。 -
使用您喜欢的文本编辑器在
catch
子目录中创建一个名为catch.cpp
的文件。 -
将
Error
类的定义放在catch.cpp
文件中:
#include <iostream>
class Error {
int code;
public:
Error(int code): code(code) {
std::cout << " Error instance " << code << " was created"
<< std::endl;
}
Error(const Error& other): code(other.code) {
std::cout << " Error instance " << code << " was cloned"
<< std::endl;
}
~Error() {
std::cout << " Error instance " << code << " was destroyed"
<< std::endl;
}
};
- 接下来,我们添加辅助函数来测试三种不同的抛出和处理错误的方式。我们从通过值捕获异常的函数开始:
void CatchByValue() {
std::cout << "Catch by value" << std::endl;
try {
throw Error(1);
}
catch (Error e) {
std::cout << " Error caught" << std::endl;
}
}
- 然后,我们添加一个抛出指针并通过指针捕获异常的函数,如下所示:
void CatchByPointer() {
std::cout << "Catch by pointer" << std::endl;
try {
throw new Error(2);
}
catch (Error* e) {
std::cout << " Error caught" << std::endl;
}
}
- 接下来,我们添加一个使用
const
引用来捕获异常的函数:
void CatchByReference() {
std::cout << "Catch by reference" << std::endl;
try {
throw Error(3);
}
catch (const Error& e) {
std::cout << " Error caught" << std::endl;
}
}
- 在定义了所有辅助函数之后,我们添加
main
函数来将所有内容联系在一起:
int main() {
CatchByValue();
CatchByPointer();
CatchByReference();
return 0;
}
- 我们将应用程序的构建规则放入
CMakeLists.txt
文件中:
cmake_minimum_required(VERSION 3.5.1)
project(catch)
add_executable(catch catch.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 现在我们可以构建和运行应用程序了。
工作原理...
在我们的应用程序中,我们定义了一个名为Error
的自定义类,当抛出和捕获异常时将使用该类。该类提供了一个构造函数、一个复制构造函数和一个仅将信息记录到控制台的析构函数。我们需要它来评估不同异常捕获方法的效率。
Error
类只包含code
数据字段,用于区分类的实例:
class Error {
int code;
我们评估了三种异常处理方法。第一种CatchByValue
是最直接的。我们创建并抛出Error
类的一个实例:
throw Error(1);
然后,我们通过值捕获它:
catch (Error e) {
第二种实现CatchByPointer
,使用new
运算符动态创建Error
的实例:
throw new Error(2);
我们使用指针来捕获异常:
catch (Error* e) {
最后,CatchByReference
引发类似于CatchByValue
的异常,但在捕获时使用Error
的const
引用:
catch (const Error& e) {
有什么区别吗?当我们运行程序时,我们会得到以下输出:
如您所见,通过值捕获对象时,会创建异常对象的副本。虽然在示例应用程序中不是关键问题,但这种效率低下可能会导致高负载应用程序的性能问题。
通过指针捕获异常时不会出现效率低下,但我们可以看到对象的析构函数没有被调用,导致内存泄漏。可以通过在catch
块中调用delete
来避免这种情况,但这很容易出错,因为并不总是清楚谁负责销毁指针引用的对象。
引用方法是最安全和最有效的方法。没有内存泄漏和不必要的复制。同时,使引用为常量会给编译器一个提示,表明它不会被更改,因此可以在底层更好地进行优化。
还有更多...
错误处理是一个复杂的领域,有许多最佳实践、提示和建议。考虑阅读 C++异常和错误处理 FAQ isocpp.org/wiki/faq/exceptions
来掌握异常处理技能。
解决静态对象问题
在 C++中,如果对象无法正确实例化,对象构造函数会抛出异常。通常,这不会引起任何问题。在堆栈上构造的对象或使用new
关键字动态创建的对象引发的异常可以通过 try-catch 块处理,该块位于创建对象的代码周围。
对于静态对象来说,情况会变得更加复杂。这些对象在执行进入main
函数之前就被实例化,因此它们无法被程序的 try-catch 块包裹。C++编译器通过调用std::terminate
函数来处理这种情况,该函数打印错误消息并终止程序。即使异常是非致命的,也没有办法恢复。
有几种方法可以避免陷阱。作为一般规则,只应静态分配简单的整数数据类型。如果仍然需要具有复杂静态对象,请确保其构造函数不会引发异常。
在本教程中,我们将学习如何为静态对象实现构造函数。
如何做...
我们将创建一个自定义类,该类分配指定数量的内存并静态分配两个类的实例。按照以下步骤进行:
-
在您的工作目录中,即
〜/test
,创建一个名为static
的子目录。 -
使用您喜欢的文本编辑器在
static
子目录中创建一个名为static.cpp
的文件。 -
让我们定义一个名为
Complex
的类。将其私有字段和构造函数放在static.cpp
文件中:
#include <iostream>
#include <stdint.h>
class Complex {
char* ptr;
public:
Complex(size_t size) noexcept {
try {
ptr = new(std::nothrow) char[size];
if (ptr) {
std::cout << "Successfully allocated "
<< size << " bytes" << std::endl;
} else {
std::cout << "Failed to allocate "
<< size << " bytes" << std::endl;
}
} catch (...) {
// Do nothing
}
}
- 然后,定义一个析构函数和
IsValid
方法:
~Complex() {
try {
if (ptr) {
delete[] ptr;
std::cout << "Deallocated memory" << std::endl;
} else {
std::cout << "Memory was not allocated"
<< std::endl;
}
} catch (...) {
// Do nothing
}
}
bool IsValid() const { return nullptr != ptr; }
};
- 类定义后,我们定义了两个全局对象
small
和large
,以及使用它们的main
函数:
Complex small(100);
Complex large(SIZE_MAX);
int main() {
std::cout << "Small object is "
<< (small.IsValid()? "valid" : "invalid")
<< std::endl;
std::cout << "Large object is "
<< (large.IsValid()? "valid" : "invalid")
<< std::endl;
return 0;
}
- 最后,我们创建一个
CMakeLists.txt
文件,其中包含我们程序的构建规则:
cmake_minimum_required(VERSION 3.5.1)
project(static)
add_executable(static static.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 现在可以构建和运行应用程序。
工作原理...
在这里,我们定义了Complex
类,并且我们打算静态分配此类的实例。为了安全起见,我们需要确保此类的构造函数和析构函数都不会引发异常。
然而,构造函数和析构函数都调用可能引发异常的操作。构造函数执行内存分配,而析构函数将日志写入标准输出。
构造函数使用new
运算符分配内存,如果无法分配内存,则会引发std::bad_alloc
异常。我们使用std::nothrow
常量来选择new
的不抛出实现。new
将返回nullptr
而不是引发异常,如果它无法分配任何内存:
ptr = new(std::nothrow) char[size];
我们将构造函数的主体放在try
块中以捕获所有异常。catch
块为空-如果构造函数失败,我们无能为力:
} catch (...) {
// Do nothing
}
由于我们不允许任何异常传播到上一级,因此我们使用 C++关键字noexcept
将我们的构造函数标记为不抛出异常:
Complex(size_t size) noexcept {
然而,我们需要知道对象是否被正确创建。为此,我们定义了一个名为IsValid
的方法。如果内存已分配,则返回true
,否则返回false
:
bool IsValid() const { return nullptr != ptr; }
析构函数则相反。它释放内存并将释放状态记录到控制台。对于构造函数,我们不希望任何异常传播到上一级,因此我们将析构函数主体包装在 try-catch 块中:
try {
if (ptr) {
delete[] ptr;
std::cout << "Deallocated memory" << std::endl;
} else {
std::cout << "Memory was not allocated" << std::endl;
}
} catch (...) {
// Do nothing
}
现在,我们声明了两个全局对象small
和large
。全局对象是静态分配的。对象的大小是人为选择的,small
对象将被正确分配,但large
对象的分配应该失败:
Complex small(100);
Complex large(SIZE_MAX);
在我们的main
函数中,检查并打印对象是否有效:
std::cout << "Small object is " << (small.IsValid()? "valid" : "invalid")
<< std::endl;
std::cout << "Large object is " << (large.IsValid()? "valid" : "invalid")
<< std::endl;
当我们运行程序时,我们会看到以下输出:
正如我们所看到的,小对象被正确分配和释放。大对象的初始化失败,但由于它被设计为不引发任何异常,因此并未导致我们应用程序的异常终止。您可以使用类似的技术来为静态分配的对象编写健壮且安全的应用程序。
使用看门狗
嵌入式应用程序被设计为无需监督即可运行。这包括从错误中恢复的能力。如果应用程序崩溃,可以自动重新启动。但是,如果应用程序由于进入无限循环或由于死锁而挂起,我们该怎么办呢?
硬件或软件看门狗用于防止这种情况发生。应用程序应定期通知或喂养它们,以指示它们保持正常运行。如果在特定时间间隔内未喂养看门狗,则它将终止应用程序或重新启动系统。
存在许多不同的看门狗实现,但它们的接口本质上是相同的。它们提供一个函数,应用程序可以使用该函数重置看门狗定时器。
在本教程中,我们将学习如何在 POSIX 信号子系统之上创建一个简单的软件看门狗。相同的技术可以用于处理硬件看门狗定时器或更复杂的软件看门狗服务。
如何做...
我们将创建一个应用程序,定义Watchdog
类并提供其用法示例。按照以下步骤进行:
-
在您的工作目录中,即
~/test
,创建一个名为watchdog
的子目录。 -
使用您喜欢的文本编辑器在
watchdog
子目录中创建一个名为watchdog.cpp
的文件。 -
将所需的包含放在
watchdog.cpp
文件中:
#include <chrono>
#include <iostream>
#include <thread>
#include <unistd.h>
using namespace std::chrono_literals;
- 接下来,我们定义
Watchdog
类本身:
class Watchdog {
std::chrono::seconds seconds;
public:
Watchdog(std::chrono::seconds seconds):
seconds(seconds) {
feed();
}
~Watchdog() {
alarm(0);
}
void feed() {
alarm(seconds.count());
}
};
- 添加
main
函数,作为我们看门狗的用法示例:
int main() {
Watchdog watchdog(2s);
std::chrono::milliseconds delay = 700ms;
for (int i = 0; i < 10; i++) {
watchdog.feed();
std::cout << delay.count() << "ms delay" << std::endl;
std::this_thread::sleep_for(delay);
delay += 300ms;
}
}
- 添加一个包含程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(watchdog)
add_executable(watchdog watchdog.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++14")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 现在可以构建并运行应用程序。
工作原理...
我们需要一种机制来在应用程序挂起时终止它。虽然我们可以生成一个特殊的监控线程或进程,但还有另一种更简单的方法——POSIX 信号。
在 POSIX 操作系统中运行的任何进程都可以接收多个信号。为了向进程传递信号,操作系统会停止进程的正常执行并调用相应的信号处理程序。
可以传递给进程的信号之一称为alarm
,默认情况下,它的处理程序会终止应用程序。这正是我们需要实现看门狗的地方。
我们的Watchdog
类的构造函数接受一个参数seconds
:
Watchdog(std::chrono::seconds seconds):
这是我们的看门狗的时间间隔,它立即传递到feed
方法中以激活看门狗定时器:
feed();
feed
方法调用了一个 POSIX 函数alarm
来设置计时器。如果计时器已经设置,它会用新值更新它:
void feed() {
alarm(seconds.count());
}
最后,在析构函数中调用相同的alarm
函数来通过传递值0
来禁用计时器:
alarm(0);
现在,每次我们调用feed
函数时,都会改变进程接收alarm
信号的时间。然而,如果在计时器到期之前我们没有调用这个函数,它就会触发alarm
处理程序,终止我们的进程。
为了检查它,我们创建了一个简单的示例。这是一个有 10 次迭代的循环。在每次迭代中,我们显示一条消息并休眠一段特定的时间间隔。初始间隔为 700 毫秒,每次迭代增加 300 毫秒;例如,700 毫秒,1,000 毫秒,1,300 毫秒等等:
delay += 300ms;
我们的看门狗设置为 2 秒的间隔:
Watchdog watchdog(2s);
让我们运行应用程序并检查它的工作原理。它产生以下输出:
正如我们所看到的,应用程序在第六次迭代后被终止,因为延迟超过了看门狗的间隔。此外,由于它是异常终止的,它的返回代码是非零的。如果应用程序是由另一个应用程序或脚本生成的,这表明应用程序需要重新启动。
看门狗技术是构建健壮嵌入式应用程序的一种简单有效的方法。
探索高可用系统的心跳。
在前面的示例中,我们学习了如何使用看门狗定时器来防止软件挂起。类似的技术可以用来实现高可用系统,它由一个或多个软件或硬件组件组成,可以执行相同的功能。如果其中一个组件失败,另一个组件可以接管。
当前活动的组件应定期向其他被动组件广告其健康状态,使用称为心跳的消息。当它报告不健康状态或在特定时间内没有报告时,被动组件会检测到并激活自己。当失败的组件恢复时,它可以转换为被动模式,监视现在活动的组件是否失败,或者启动故障恢复过程来重新获得活动状态。
在这个示例中,我们将学习如何在我们的应用程序中实现一个简单的心跳监视器。
如何做...
我们将创建一个定义了Watchdog
类并提供其用法示例的应用程序。按照以下步骤进行:
-
在你的工作目录中,即
~/test
,创建一个名为heartbeat
的子目录。 -
使用你喜欢的文本编辑器在
heartbeat
子目录中创建一个名为heartbeat.cpp
的文件。 -
在
heatbeat.cpp
文件中放入所需的包含文件:
#include <chrono>
#include <iostream>
#include <system_error>
#include <thread>
#include <unistd.h>
#include <poll.h>
#include <signal.h>
using namespace std::chrono_literals;
- 接下来,我们定义一个
enum
来报告活动工作者的健康状态:
enum class Health : uint8_t {
Ok,
Unhealthy,
ShutDown
};
- 现在,让我们创建一个封装心跳报告和监控的类。我们从类定义、私有字段和构造函数开始:
class Heartbeat {
int channel[2];
std::chrono::milliseconds delay;
public:
Heartbeat(std::chrono::milliseconds delay):
delay(delay) {
int rv = pipe(channel);
if (rv < 0) {
throw std::system_error(errno,
std::system_category(),
"Failed to open pipe");
}
}
- 接下来,我们添加一个报告健康状态的方法:
void Report(Health status) {
int rv = write(channel[1], &status, sizeof(status));
if (rv < 0) {
throw std::system_error(errno,
std::system_category(),
"Failed to report health status");
}
}
- 接下来是健康监控方法:
bool Monitor() {
struct pollfd fds[1];
fds[0].fd = channel[0];
fds[0].events = POLLIN;
bool takeover = true;
bool polling = true;
while(polling) {
fds[0].revents = 0;
int rv = poll(fds, 1, delay.count());
if (rv) {
if (fds[0].revents & (POLLERR | POLLHUP)) {
std::cout << "Polling error occured"
<< std::endl;
takeover = false;
polling = false;
break;
}
Health status;
int count = read(fds[0].fd, &status,
sizeof(status));
if (count < sizeof(status)) {
std::cout << "Failed to read heartbeat data"
<< std::endl;
break;
}
switch(status) {
case Health::Ok:
std::cout << "Active process is healthy"
<< std::endl;
break;
case Health::ShutDown:
std::cout << "Shut down signalled"
<< std::endl;
takeover = false;
polling = false;
break;
default:
std::cout << "Unhealthy status reported"
<< std::endl;
polling = false;
break;
}
} else if (!rv) {
std::cout << "Timeout" << std::endl;
polling = false;
} else {
if (errno != EINTR) {
std::cout << "Error reading heartbeat data, retrying" << std::endl;
}
}
}
return takeover;
}
};
- 一旦心跳逻辑被定义,我们创建一些函数,以便在我们的测试应用程序中使用它:
void Worker(Heartbeat& hb) {
for (int i = 0; i < 5; i++) {
hb.Report(Health::Ok);
std::cout << "Processing" << std::endl;
std::this_thread::sleep_for(100ms);
}
hb.Report(Health::Unhealthy);
}
int main() {
Heartbeat hb(200ms);
if (fork()) {
if (hb.Monitor()) {
std::cout << "Taking over" << std::endl;
Worker(hb);
}
} else {
Worker(hb);
}
}
- 接下来,我们添加一个包含程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(heartbeat)
add_executable(heartbeat heartbeat.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++14")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 现在可以构建和运行应用程序了。
工作原理...
心跳机制需要某种通信渠道,让一个组件向其他组件报告其状态。在一个围绕多个处理单元构建的系统中,最好的选择是基于网络的套接字通信。我们的应用程序在单个节点上运行,因此我们可以使用本地 IPC 机制之一。
我们将使用 POSIX 管道机制进行心跳传输。创建管道时,它提供两个文件描述符进行通信——一个用于读取数据,另一个用于写入数据。
除了通信传输,我们还需要选择接管的时间间隔。如果监控过程在此间隔内未收到心跳消息,则应将另一个组件视为不健康或失败,并执行一些接管操作。
我们首先定义应用程序可能的健康状态。我们使用 C++的enum class
使状态严格类型化,如下所示:
enum class Health : uint8_t {
Ok,
Unhealthy,
ShutDown
};
我们的应用程序很简单,只有三种状态:Ok
、Unhealthy
和ShutDown
。ShutDown
状态表示活动进程将正常关闭,不需要接管操作。
然后,我们定义Heartbeat
类,它封装了所有消息交换、健康报告和监控功能。
它有两个数据字段,表示监控时间间隔和用于消息交换的 POSIX 管道:
int channel[2];
std::chrono::milliseconds delay;
构造函数创建管道,并在失败时抛出异常:
int rv = pipe(channel);
if (rv < 0) {
throw std::system_error(errno,
std::system_category(),
"Failed to open pipe");
健康报告方法是write
函数的简单包装。它将状态以无符号 8 位整数值的形式写入管道的write
文件描述符:
int rv = write(channel[1], &status, sizeof(status));
监控方法更复杂。它使用 POSIX 的poll
函数等待一个或多个文件描述符中的数据。在我们的情况下,我们只对一个文件描述符中的数据感兴趣——管道的读端。我们填充poll
使用的fds
结构,其中包括文件描述符和我们感兴趣的事件类型:
struct pollfd fds[1];
fds[0].fd = channel[0];
fds[0].events = POLLIN | POLLERR | POLLHUP;
两个布尔标志控制轮询循环。takeover
标志指示我们退出循环时是否应执行接管操作,而polling
标志指示循环是否应该存在:
bool takeover = true;
bool polling = true;
在循环的每次迭代中,我们使用poll
函数在套接字中轮询新数据。我们使用传入构造函数的监控间隔作为轮询超时:
int rv = poll(fds, 1, delay.count());
poll
函数的结果指示三种可能的结果之一:
-
如果大于零,我们可以从通信管道中读取新数据。我们从通信通道中读取状态并进行分析。
-
如果状态是
Ok
,我们记录下来并进入下一个轮询迭代。 -
如果状态是
ShutDown
,我们需要退出轮询循环,但也要阻止takeover
操作。为此,我们相应地设置我们的布尔标志:
case Health::ShutDown:
std::cout << "Shut down signalled"
<< std::endl;
takeover = false;
polling = false;
对于任何其他健康状态,我们会以takeover
标志设置为true
退出循环:
std::cout << "Unhealthy status reported"
<< std::endl;
polling = false;
在超时的情况下,poll
返回零。与Unhealthy
状态类似,我们需要从循环中退出并执行takeover
操作:
} else if (!rv) {
std::cout << "Timeout" << std::endl;
polling = false;
最后,如果poll
返回的值小于零,表示出现错误。系统调用失败有几种原因,其中一个非常常见的原因是被信号中断。这不是真正的错误;我们只需要再次调用poll
。对于所有其他情况,我们会写入日志消息并继续轮询。
监控方法在监控循环运行时会阻塞,并返回一个布尔值,让调用者知道是否应执行takeover
操作:
bool Monitor() {
现在,让我们尝试在一个玩具示例中使用这个类。我们将定义一个接受Heartbeat
实例引用并表示要完成的工作的Worker
函数:
void Worker(Heartbeat& hb) {
在内部循环的每次迭代中,Worker
报告其健康状态:
hb.Report(Health::Ok);
在某个时刻,它报告其状态为Unhealthy
:
hb.Report(Health::Unhealthy);
在main
函数中,我们使用 200 毫秒的轮询间隔创建了一个Heartbeat
类的实例:
Heartbeat hb(200ms);
然后,我们生成两个独立的进程。父进程开始监视,并且如果需要接管,运行Worker
方法:
if (hb.Monitor()) {
std::cout << "Taking over" << std::endl;
Worker(hb);
}
子类只是运行Worker
方法。让我们运行应用程序并检查它的工作原理。它产生以下输出:
正如我们所看到的,Worker
方法报告它正在处理数据,监视器检测到它的状态是健康的。然而,在Worker
方法报告其状态为Unhealthy
后,监视器立即检测到并重新运行工作程序,以继续处理。这种策略可以用于构建更复杂的健康监控和故障恢复逻辑,以实现您设计和开发的系统的高可用性。
还有更多...
在我们的示例中,我们使用了两个同时运行并相互监视的相同组件。但是,如果其中一个组件包含软件错误,在某些条件下导致组件发生故障,那么另一个相同的组件也很可能受到这个问题的影响。在安全关键系统中,您可能需要开发两个完全不同的实现。这种方法会增加成本和开发时间,但会提高系统的可靠性。
实现软件去抖动逻辑
嵌入式应用的常见任务之一是与外部物理控件(如按钮或开关)进行交互。尽管这些对象只有两种状态 - 开和关 - 但检测按钮或开关改变状态的时刻并不像看起来那么简单。
当物理按钮被按下时,需要一些时间才能建立联系。在此期间,可能会触发虚假中断,就好像按钮在开和关状态之间跳动。应用程序不应该对每个中断做出反应,而应该能够过滤掉虚假的转换。这就是去抖动。
尽管它可以在硬件级别实现,但最常见的方法是通过软件来实现。在本教程中,我们将学习如何实现一个简单通用的去抖动函数,可以用于任何类型的输入。
如何做...
我们将创建一个应用程序,定义一个通用的去抖动函数以及一个测试输入。通过用真实输入替换测试输入,可以将此函数用于任何实际目的。按照以下步骤进行:
-
在您的工作目录中,即
~/test
,创建一个名为debounce
的子目录。 -
使用您喜欢的文本编辑器在
debounce
子目录中创建一个名为debounce.cpp
的文件。 -
让我们在
debounce.cpp
文件中添加包含和一个名为debounce
的函数:
#include <iostream>
#include <chrono>
#include <thread>
using namespace std::chrono_literals;
bool debounce(std::chrono::milliseconds timeout, bool (*handler)(void)) {
bool prev = handler();
auto ts = std::chrono::steady_clock::now();
while (true) {
std::this_thread::sleep_for(1ms);
bool value = handler();
auto now = std::chrono::steady_clock::now();
if (value == prev) {
if (now - ts > timeout) {
break;
}
} else {
prev = value;
ts = now;
}
}
return prev;
}
- 然后,我们添加
main
函数,展示如何使用它:
int main() {
bool result = debounce(10ms, []() {
return true;
});
std::cout << "Result: " << result << std::endl;
}
- 添加一个包含程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(debounce)
add_executable(debounce debounce.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++14")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 现在可以构建和运行应用程序了。
工作原理...
我们的目标是检测按钮在开和关状态之间停止跳动的时刻。我们假设如果所有连续尝试读取按钮状态在特定时间间隔内返回相同的值(开或关),我们就可以知道按钮是真正开着还是关着。
我们使用这个逻辑来实现debounce
函数。由于我们希望去抖动逻辑尽可能通用,函数不应该知道如何读取按钮的状态。这就是为什么函数接受两个参数的原因:
bool debounce(std::chrono::milliseconds timeout, bool (*handler)(void)) {
第一个参数timeout
定义了我们需要等待报告状态变化的特定时间间隔。第二个参数handler
是一个函数或类似函数的对象,它知道如何读取按钮的状态。它被定义为指向没有参数的布尔函数的指针。
debounce
函数运行一个循环。在每次迭代中,它调用处理程序来读取按钮的状态并将其与先前的值进行比较。如果值相等,我们检查自最近状态变化以来的时间。如果超过超时时间,我们退出循环并返回:
auto now = std::chrono::steady_clock::now();
if (value == prev) {
if (now - ts > timeout) {
break;
}
如果值不相等,我们会重置最近状态变化的时间并继续等待:
} else {
prev = value;
ts = now;
}
为了最小化 CPU 负载并让其他进程做一些工作,我们在读取之间添加了 1 毫秒的延迟。如果函数打算用于不运行多任务操作系统的微控制器上,则不需要这个延迟:
std::this_thread::sleep_for(1ms);
我们的main
函数包含了对debounce
函数的使用示例。我们使用 C++ lambda 来定义一个简单的规则来读取按钮。它总是返回true
:
bool result = debounce(10ms, []() {
return true;
});
我们将10ms
作为debounce
超时传递。如果我们运行我们的程序,我们将看到以下输出:
debounce
函数工作了 10 毫秒并返回true
,因为测试输入中没有出现意外的状态变化。在实际输入的情况下,可能需要更多的时间才能使按钮状态稳定下来。这个简单而高效的去抖动函数可以应用于各种真实的输入。
第十三章:实时系统的指南
实时系统是时间反应至关重要的嵌入式系统的一类。未能及时反应的后果在不同的应用程序之间有所不同。根据严重程度,实时系统被分类如下:
-
硬实时:错过截止日期是不可接受的,被视为系统故障。这些通常是飞机、汽车和发电厂中的关键任务系统。
-
严格实时:在极少数情况下错过截止日期是可以接受的。截止日期后结果的有用性为零。想想一个直播流服务。交付太晚的视频帧只能被丢弃。只要这种情况不经常发生,这是可以容忍的。
-
软实时:错过截止日期是可以接受的。截止日期后结果的有用性会下降,导致整体质量的下降,应该避免。一个例子是从多个传感器捕获和同步数据。
实时系统不一定需要非常快。它们需要的是可预测的反应时间。如果一个系统通常可以在 10 毫秒内响应事件,但经常需要更长时间,那么它就不是一个实时系统。如果一个系统能够在 1 秒内保证响应,那就构成了硬实时。
确定性和可预测性是实时系统的主要特征。在本章中,我们将探讨不可预测行为的潜在来源以及减轻它们的方法。
本章涵盖以下主题:
-
在 Linux 中使用实时调度器
-
使用静态分配的内存
-
避免异常处理错误
-
探索实时操作系统
本章的食谱将帮助您更好地了解实时系统的具体情况,并学习一些针对这种嵌入式系统的软件开发的最佳实践。
在 Linux 中使用实时调度器
Linux 是一个通用操作系统,在各种嵌入式设备中通常被使用,因为它的多功能性。它可以根据特定的硬件进行定制,并且是免费的。
Linux 不是一个实时操作系统,也不是实现硬实时系统的最佳选择。然而,它可以有效地用于构建软实时系统,因为它为时间关键的应用程序提供了实时调度器。
在本章中,我们将学习如何在我们的应用程序中在 Linux 中使用实时调度器。
如何做...
我们将创建一个使用实时调度器的应用程序:
-
在您的工作目录
~/test
中,创建一个名为realtime
的子目录。 -
使用您喜欢的文本编辑器在
realtime
子目录中创建一个realtime.cpp
文件。 -
添加所有必要的包含和命名空间:
#include <iostream>
#include <system_error>
#include <thread>
#include <chrono>
#include <pthread.h>
using namespace std::chrono_literals;
- 接下来,添加一个配置线程使用实时调度器的函数:
void ConfigureRealtime(pthread_t thread_id, int priority) {
sched_param sch;
sch.sched_priority = 20;
if (pthread_setschedparam(thread_id,
SCHED_FIFO, &sch)) {
throw std::system_error(errno,
std::system_category(),
"Failed to set real-time priority");
}
}
- 接下来,我们定义一个希望以正常优先级运行的线程函数:
void Measure(const char* text) {
struct timespec prev;
timespec_get(&prev, TIME_UTC);
struct timespec delay{0, 10};
for (int i = 0; i < 100000; i++) {
nanosleep(&delay, nullptr);
}
struct timespec ts;
timespec_get(&ts, TIME_UTC);
double delta = (ts.tv_sec - prev.tv_sec) +
(double)(ts.tv_nsec - prev.tv_nsec) / 1000000000;
std::clog << text << " completed in "
<< delta << " sec" << std::endl;
}
- 接下来是一个实时线程函数和一个启动这两个线程的
main
函数:
void RealTimeThread(const char* txt) {
ConfigureRealtime(pthread_self(), 1);
Measure(txt);
}
int main() {
std::thread t1(RealTimeThread, "Real-time");
std::thread t2(Measure, "Normal");
t1.join();
t2.join();
}
- 最后,我们创建一个包含程序构建规则的
CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.5.1)
project(realtime)
add_executable(realtime realtime.cpp)
target_link_libraries(realtime pthread)
SET(CMAKE_CXX_FLAGS "--std=c++14")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabihf-g++)
- 现在您可以构建和运行应用程序了。
它是如何工作的...
Linux 有几种调度策略,它应用于应用程序进程和线程。SCHED_OTHER
是默认的 Linux 分时策略。它适用于所有线程,不提供实时机制。
在我们的应用程序中,我们使用另一个策略SCHED_FIFO
。这是一个简单的调度算法。使用这个调度器的所有线程只能被优先级更高的线程抢占。如果线程进入睡眠状态,它将被放置在具有相同优先级的线程队列的末尾。
SCHED_FIFO
策略的线程优先级始终高于SCHED_OTHER
策略的线程优先级,一旦SCHED_FIFO
线程变为可运行状态,它立即抢占正在运行的SCHED_OTHER
线程。从实际的角度来看,如果系统中只有一个SCHED_FIFO
线程在运行,它可以使用所需的 CPU 时间。SCHED_FIFO
调度程序的确定性行为和高优先级使其非常适合实时应用程序。
为了将实时优先级分配给一个线程,我们定义了一个ConfigureRealtime
函数。它接受两个参数——线程 ID 和期望的优先级:
void ConfigureRealtime(pthread_t thread_id, int priority) {
该函数为pthread_setschedparam
函数填充数据,该函数使用操作系统的低级 API 来更改线程的调度程序和优先级:
if (pthread_setschedparam(thread_id,
SCHED_FIFO, &sch)) {
我们定义一个Measure
函数,运行一个繁忙循环,调用nanosleep
函数,参数要求它休眠 10 纳秒,这对于将执行让给另一个线程来说太短了:
struct timespec delay{0, 10};
for (int i = 0; i < 100000; i++) {
nanosleep(&delay, nullptr);
}
此函数在循环之前和之后捕获时间戳,并计算经过的时间(以秒为单位):
struct timespec ts;
timespec_get(&ts, TIME_UTC);
double delta = (ts.tv_sec - prev.tv_sec) +
(double)(ts.tv_nsec - prev.tv_nsec) / 1000000000;
接下来,我们将RealTimeThread
函数定义为Measure
函数的包装。这将当前线程的优先级设置为实时,并立即调用Measure
:
ConfigureRealtime(pthread_self(), 1);
Measure(txt);
在main
函数中,我们启动两个线程,传递文本字面量作为参数以区分它们的输出。如果我们在树莓派设备上运行程序,可以看到以下输出:
实时线程所花费的时间少了四倍,因为它没有被普通线程抢占。这种技术可以有效地满足 Linux 环境中的软实时需求。
使用静态分配的内存
如第六章中已经讨论过的,应该避免在实时系统中使用动态内存分配,因为通用内存分配器没有时间限制。虽然在大多数情况下,内存分配不会花费太多时间,但不能保证。这对于实时系统是不可接受的。
避免动态内存分配的最直接方法是用静态分配替换它。C++开发人员经常使用std::vector
来存储元素序列。由于它与 C 数组相似,因此它高效且易于使用,并且其接口与标准库中的其他容器一致。由于向量具有可变数量的元素,因此它们广泛使用动态内存分配。然而,在许多情况下,可以使用std::array
类来代替std::vector
。它具有相同的接口,只是其元素的数量是固定的,因此其实例可以静态分配。这使得它成为在内存分配时间至关重要时替代std::vector
的良好选择。
在本示例中,我们将学习如何有效地使用std::array
来表示固定大小的元素序列。
操作步骤如下...
我们将创建一个应用程序,利用 C++标准库算法的功能来生成和处理固定数据帧,而不使用动态内存分配:
-
在您的工作目录
~/test
中,创建一个名为array
的子目录。 -
使用您喜欢的文本编辑器在
array
子目录中创建一个名为array.cpp
的文件。 -
在
array.cpp
文件中添加包含和新的类型定义:
#include <algorithm>
#include <array>
#include <iostream>
#include <random>
using DataFrame = std::array<uint32_t, 8>;
- 接下来,我们添加一个生成数据帧的函数:
void GenerateData(DataFrame& frame) {
std::random_device rd;
std::generate(frame.begin(), frame.end(),
[&rd]() { return rd() % 100; });
}
- 接下来是处理数据帧的函数:
void ProcessData(const DataFrame& frame) {
std::cout << "Processing array of "
<< frame.size() << " elements: [";
for (auto x : frame) {
std::cout << x << " ";
}
auto mm = std::minmax_element(frame.begin(),frame.end());
std::cout << "] min: " << *mm.first
<< ", max: " << *mm.second << std::endl;
}
- 添加一个将数据生成和处理联系在一起的
main
函数:
int main() {
DataFrame data;
for (int i = 0; i < 4; i++) {
GenerateData(data);
ProcessData(data);
}
return 0;
}
- 最后,我们创建一个
CMakeLists.txt
文件,其中包含程序的构建规则:
cmake_minimum_required(VERSION 3.5.1)
project(array)
add_executable(array array.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS_RELEASE "--std=c++17")
SET(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_RELEASE} -g -DDEBUG")
set(CMAKE_C_COMPILER /usr/bin/arm-linux-gnueabihf-gcc)
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabihf-g++)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
- 现在可以构建和运行应用程序了。
工作原理...
我们使用std::array
模板来声明自定义的DataFrame
数据类型。对于我们的示例应用程序,DataFrame
是一个包含八个 32 位整数的序列:
using DataFrame = std::array<uint32_t, 8>;
现在,我们可以在函数中使用新的数据类型来生成和处理数据框架。由于数据框架是一个数组,我们通过引用将其传递给GenerateData
函数,以避免额外的复制:
void GenerateData(DataFrame& frame) {
GenerateData
用随机数填充数据框架。由于std::array
具有与标准库中其他容器相同的接口,我们可以使用标准算法使代码更短更可读:
std::generate(frame.begin(), frame.end(),
[&rd]() { return rd() % 100; });
我们以类似的方式定义了ProcessData
函数。它也接受一个DataFrame
,但不应该修改它。我们使用常量引用明确说明数据不会被修改:
void ProcessData(const DataFrame& frame) {
ProcessData
打印数据框架中的所有值,然后找到框架中的最小值和最大值。与内置数组不同,当传递给函数时,std::arrays
不会衰减为原始指针,因此我们可以使用基于范围的循环语法。您可能会注意到,我们没有将数组的大小传递给函数,并且没有使用任何全局常量来查询它。这是std::array
接口的一部分。它不仅减少了函数的参数数量,还确保我们在调用它时不能传递错误的大小:
for (auto x : frame) {
std::cout << x << " ";
}
为了找到最小值和最大值,我们使用标准库的std::minmax_
元素函数,而不是编写自定义循环:
auto mm = std::minmax_element(frame.begin(),frame.end());
在main
函数中,我们创建了一个DataFrame
的实例:
DataFrame data;
然后,我们运行一个循环。在每次迭代中,都会生成和处理一个新的数据框架:
GenerateData(data);
ProcessData(data);
如果我们运行应用程序,我们会得到以下输出:
我们的应用程序生成了四个数据框架,并且只使用了几行代码和静态分配的数据来处理其数据。这使得std::array
成为实时系统开发人员的一个很好的选择。此外,与内置数组不同,我们的函数是类型安全的,我们可以在构建时检测和修复许多编码错误。
还有更多...
C++20 标准引入了一个新函数to_array
,允许开发人员从一维内置数组创建std::array
的实例。在to_array
参考页面上查看更多细节和示例(en.cppreference.com/w/cpp/container/array/to_array
)。
避免使用异常进行错误处理
异常机制是 C++标准的一个组成部分。这是设计 C++程序中的错误处理的推荐方式。然而,它确实有一些限制,不总是适用于实时系统,特别是安全关键系统。
C++异常处理严重依赖于堆栈展开。一旦抛出异常,它会通过调用堆栈传播到可以处理它的 catch 块。这意味着在其路径中调用堆栈帧中的所有本地对象的析构函数,并且很难确定并正式证明此过程的最坏情况时间。
这就是为什么安全关键系统的编码指南,如 MISRA 或 JSF,明确禁止使用异常进行错误处理。
这并不意味着 C++开发人员必须回到传统的纯 C 错误代码。在这个示例中,我们将学习如何使用 C++模板来定义可以保存函数调用的结果或错误代码的数据类型。
如何做...
我们将创建一个应用程序,利用 C++标准库算法的强大功能来生成和处理固定数据框架,而不使用动态内存分配:
-
在你的工作目录
~/test
中,创建一个名为expected
的子目录。 -
使用你喜欢的文本编辑器在
expected
子目录中创建一个expected.cpp
文件。 -
向
expected.cpp
文件添加包含和新的类型定义:
#include <iostream>
#include <system_error>
#include <variant>
#include <unistd.h>
#include <sys/fcntl.h>
template <typename T>
class Expected {
std::variant<T, std::error_code> v;
public:
Expected(T val) : v(val) {}
Expected(std::error_code e) : v(e) {}
bool valid() const {
return std::holds_alternative<T>(v);
}
const T& value() const {
return std::get<T>(v);
}
const std::error_code& error() const {
return std::get<std::error_code>(v);
}
};
- 接下来,我们为打开的 POSIX 函数添加一个包装器:
Expected<int> OpenForRead(const std::string& name) {
int fd = ::open(name.c_str(), O_RDONLY);
if (fd < 0) {
return Expected<int>(std::error_code(errno,
std::system_category()));
}
return Expected<int>(fd);
}
- 添加
main
函数,显示如何使用OpenForRead
包装器:
int main() {
auto result = OpenForRead("nonexistent.txt");
if (result.valid()) {
std::cout << "File descriptor"
<< result.value() << std::endl;
} else {
std::cout << "Open failed: "
<< result.error().message() << std::endl;
}
return 0;
}
- 最后,我们创建一个
CMakeLists.txt
文件,其中包含我们程序的构建规则:
cmake_minimum_required(VERSION 3.5.1)
project(expected)
add_executable(expected expected.cpp)
set(CMAKE_SYSTEM_NAME Linux)
#set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++17")
#set(CMAKE_C_COMPILER /usr/bin/arm-linux-gnueabihf-gcc)
#set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabihf-g++)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
- 现在可以构建和运行应用程序了。
它是如何工作的...
在我们的应用程序中,我们创建了一个数据类型,可以以类型安全的方式保存预期值或错误代码。C++17 提供了一个类型安全的联合类std::variant
,我们将使用它作为我们的模板类Expected
的基础数据类型。
Expected
类封装了一个std::variant
字段,可以容纳两种数据类型之一,即模板类型T
或std::error_code
,后者是错误代码的标准 C++泛化:
std::variant<T, std::error_code> v;
虽然可以直接使用std::variant
,但我们公开了一些使其更加方便的公共方法。valid
方法在结果持有模板类型时返回true
,否则返回false
:
bool valid() const {
return std::holds_alternative<T>(v);
}
value
和error
方法用于访问返回的值或错误代码:
const T& value() const {
return std::get<T>(v);
}
const std::error_code& error() const {
return std::get<std::error_code>(v);
}
一旦定义了Expected
类,我们就创建一个使用它的OpenForReading
函数。这会调用打开系统函数,并根据返回值创建一个持有文件描述符或错误代码的Expected
实例:
if (fd < 0) {
return Expected<int>(std::error_code(errno,
std::system_category()));
}
return Expected<int>(fd);
在main
函数中,当我们为不存在的文件调用OpenForReading
时,预计会失败。当我们运行应用程序时,可以看到以下输出:
我们的Expected
类允许我们以类型安全的方式编写可能返回错误代码的函数。编译时类型验证有助于开发人员避免许多传统错误代码常见的问题,使我们的应用程序更加健壮和安全。
还有更多...
我们的Expected
数据类型的实现是std::expected
类的一个变体(www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0323r7.html
),该类被提议用于标准化,但尚未获得批准。std::expected
的一个实现可以在 GitHub 上找到(github.com/TartanLlama/expected
)。
探索实时操作系统
正如本章已经讨论的那样,Linux 不是实时系统。它是软实时任务的一个很好选择,但尽管它提供了一个实时调度程序,但其内核过于复杂,无法保证硬实时应用程序所需的确定性水平。
时间关键的应用程序要么需要实时操作系统来运行,要么被设计和实现为在裸机上运行,根本没有操作系统。
实时操作系统通常比 Linux 等通用操作系统简单得多。此外,它们需要根据特定的硬件平台进行定制,通常是微控制器。
有许多实时操作系统,其中大多数是专有的,而且不是免费的。FreeRTOS 是探索实时操作系统功能的良好起点。与大多数替代方案不同,它是开源的,并且可以免费使用,因为它是根据 MIT 许可证分发的。它被移植到许多微控制器和小型微处理器,但即使您没有特定的硬件,Windows 和 POSIX 模拟器也是可用的。
在这个配方中,我们将学习如何下载和运行 FreeRTOS POSIX 模拟器。
如何做到...
我们将在我们的构建环境中下载和构建 FreeRTOS 模拟器:
- 切换到 Ubuntu 终端并将当前目录更改为
/mnt
:
$ cd /mnt
- 下载 FreeRTOS 模拟器的源代码:
$ wget -O simulator.zip http://interactive.freertos.org/attachments/token/r6d5gt3998niuc4/?name=Posix_GCC_Simulator_6.0.4.zip
- 提取下载的存档:
$ unzip simulator.zip
- 将当前目录更改为
Posix_GCC_Simulator/FreeRTOS_Posix/Debug
:
$ cd Posix_GCC_Simulator/FreeRTOS_Posix/Debug
- 通过运行以下命令修复
makefile
中的小错误:
$ sed -i -e 's/\(.*gcc.*\)-lrt\(.*\)/\1\2 -lrt/' makefile
- 从源代码构建模拟器:
$ make
- 启动它:
$ ./FreeRTOS_Posix
此时,模拟器正在运行。
它是如何工作的...
正如我们已经知道的那样,实时操作系统的内核通常比通用操作系统的内核简单得多。对于 FreeRTOS 也是如此。
由于这种简单性,内核可以在通用操作系统(如 Linux 或 Windows)中作为一个进程构建和运行。当从另一个操作系统中使用时,它就不再是真正的实时,但可以作为探索 FreeRTOS API 并开始开发后续可以在目标硬件平台的实时环境中运行的应用程序的起点。
在这个教程中,我们下载并为 POSIX 操作系统构建了 FreeRTOS 内核。
构建阶段很简单。一旦代码从存档中下载并提取出来,我们运行make
,这将构建一个单个可执行文件FreeRTOS-POSIX
。在运行make
命令之前,我们通过运行sed
在makefile
中修复了一个错误,将-lrt
选项放在 GCC 命令行的末尾。
$ sed -i -e 's/\(.*gcc.*\)-lrt\(.*\)/\1\2 -lrt/' makefile
运行应用程序会启动内核和预打包的应用程序:
我们能够在我们的构建环境中运行 FreeRTOS。您可以深入研究其代码库和文档,以更好地理解实时操作系统的内部和 API。
还有更多...
如果您在 Windows 环境中工作,有一个更好支持的 FreeRTOS 模拟器的 Windows 版本。它可以从www.freertos.org/FreeRTOS-Windows-Simulator-Emulator-for-Visual-Studio-and-Eclipse-MingW.html
下载,还有文档和教程。
第十四章:安全关键系统的指南
嵌入式系统的代码质量要求通常比其他软件领域更高。由于许多嵌入式系统在没有监督或控制的情况下工作,或者控制昂贵的工业设备,错误的成本很高。在安全关键系统中,软件或硬件故障可能导致受伤甚至死亡,错误的成本甚至更高。这种系统的软件必须遵循特定的指南,旨在最大程度地减少在调试和测试阶段未发现错误的机会。
在本章中,我们将通过以下示例探讨安全关键系统的一些要求和最佳实践:
-
使用所有函数的返回值
-
使用静态代码分析器
-
使用前置条件和后置条件
-
探索代码正确性的正式验证
这些示例将帮助您了解安全关键系统的要求和指南,以及用于认证和一致性测试的工具和方法。
使用所有函数的返回值
C 语言和 C++语言都不要求开发人员使用任何函数的返回值。完全可以定义一个返回整数的函数,然后在代码中调用它,忽略其返回值。
这种灵活性经常导致软件错误,可能难以诊断和修复。最常见的情况是函数返回错误代码。开发人员可能会忘记为经常使用且很少失败的函数添加错误条件检查,比如close
。
对于安全关键系统,最广泛使用的编码标准之一是 MISRA。它分别为 C 和 C++语言定义了要求——MISRA C 和 MISRA C++。最近引入的自适应 AUTOSAR 为汽车行业定义了编码指南。预计自适应 AUTOSAR 指南将作为更新后的 MISRA C++指南的基础。
MISRA 和 AUTOSAR 的 C++编码指南(www.autosar.org/fileadmin/user_upload/standards/adaptive/17-03/AUTOSAR_RS_CPP14Guidelines.pdf
)要求开发人员使用所有非 void 函数和方法的返回值。相应的规则定义如下:
"规则 A0-1-2(必需,实现,自动化):具有非 void 返回类型的函数返回值应该被使用。"
在这个示例中,我们将学习如何在我们的代码中使用这个规则。
如何做...
我们将创建两个类,它们在文件中保存两个时间戳。一个时间戳表示实例创建的时间,另一个表示实例销毁的时间。这对于代码性能分析很有用,可以测量我们在函数或其他感兴趣的代码块中花费了多少时间。按照以下步骤进行:
-
在您的工作目录中,即
~/test
,创建一个名为returns
的子目录。 -
使用您喜欢的文本编辑器在
returns
子目录中创建一个名为returns.cpp
的文件。 -
在
returns.cpp
文件中添加第一个类:
#include <system_error>
#include <unistd.h>
#include <sys/fcntl.h>
#include <time.h>
[[nodiscard]] ssize_t Write(int fd, const void* buffer,
ssize_t size) {
return ::write(fd, buffer, size);
}
class TimeSaver1 {
int fd;
public:
TimeSaver1(const char* name) {
int fd = open(name, O_RDWR|O_CREAT|O_TRUNC, 0600);
if (fd < 0) {
throw std::system_error(errno,
std::system_category(),
"Failed to open file");
}
Update();
}
~TimeSaver1() {
Update();
close(fd);
}
private:
void Update() {
time_t tm;
time(&tm);
Write(fd, &tm, sizeof(tm));
}
};
- 接下来,我们添加第二个类:
class TimeSaver2 {
int fd;
public:
TimeSaver2(const char* name) {
fd = open(name, O_RDWR|O_CREAT|O_TRUNC, 0600);
if (fd < 0) {
throw std::system_error(errno,
std::system_category(),
"Failed to open file");
}
Update();
}
~TimeSaver2() {
Update();
if (close(fd) < 0) {
throw std::system_error(errno,
std::system_category(),
"Failed to close file");
}
}
private:
void Update() {
time_t tm = time(&tm);
int rv = Write(fd, &tm, sizeof(tm));
if (rv < 0) {
throw std::system_error(errno,
std::system_category(),
"Failed to write to file");
}
}
};
main
函数创建了两个类的实例:
int main() {
TimeSaver1 ts1("timestamp1.bin");
TimeSaver2 ts2("timestamp2.bin");
return 0;
}
- 最后,我们创建一个
CMakeLists.txt
文件,其中包含程序的构建规则:
cmake_minimum_required(VERSION 3.5.1)
project(returns)
add_executable(returns returns.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++17")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 现在可以构建和运行应用程序了。
它是如何工作的...
我们现在创建了两个类,TimeSaver1
和TimeSaver2
,它们看起来几乎相同,并且执行相同的工作。这两个类都在它们的构造函数中打开一个文件,并调用Update
函数,该函数将时间戳写入打开的文件。
同样,它们的析构函数调用相同的Update
函数来添加第二个时间戳并关闭文件描述符。
然而,TimeSaver1
违反了A0-1-2规则,是不安全的。让我们仔细看看这一点。它的Update
函数调用了两个函数,time
和write
。这两个函数可能失败,返回适当的错误代码,但我们的实现忽略了它:
time(&tm);
Write(fd, &tm, sizeof(tm));
此外,TimeSaver1
的析构函数通过调用close
函数关闭打开的文件。这也可能失败,返回错误代码,我们忽略了它:
close(fd);
第二个类TimeSaver2
符合要求。我们将时间调用的结果分配给tm
变量:
time_t tm = time(&tm);
如果Write
返回错误,我们会抛出异常:
int rv = Write(fd, &tm, sizeof(tm));
if (rv < 0) {
throw std::system_error(errno,
std::system_category(),
"Failed to write to file");
}
同样,如果close
返回错误,我们会抛出异常:
if (close(fd) < 0) {
throw std::system_error(errno,
std::system_category(),
"Failed to close file");
}
为了减轻这种问题,C++17 标准引入了一个特殊的属性称为[[nodiscard]]
。如果一个函数声明了这个属性,或者它返回一个标记为nodiscard
的类或枚举,那么如果其返回值被丢弃,编译器应该显示警告。为了使用这个特性,我们创建了一个围绕write
函数的自定义包装器,并声明它为nodiscard
:
[[nodiscard]] ssize_t Write(int fd, const void* buffer,
ssize_t size) {
return ::write(fd, buffer, size);
}
当我们构建应用程序时,我们可以在编译器输出中看到这一点,这也意味着我们有机会修复它:
事实上,编译器能够识别并报告我们代码中的另一个问题,我们将在下一个示例中讨论。
如果我们构建并运行应用程序,我们不会看到任何输出,因为所有写入都会写入文件。我们可以运行ls
命令来检查程序是否产生结果,如下所示:
$ ls timestamp*
从中,我们得到以下输出:
如预期的那样,我们的程序创建了两个文件。它们应该是相同的,但实际上并不是。由TimeSaver1
创建的文件是空的,这意味着它的实现存在问题。
由TimeSaver2
生成的文件是有效的,但这是否意味着其实现是 100%正确的?未必,正如我们将在下一个示例中看到的那样。
还有更多...
有关[[nodiscard]]
属性的更多信息可以在其参考页面上找到(en.cppreference.com/w/cpp/language/attributes/nodiscard
)。从 C++20 开始,nodiscard
属性可以包括一个字符串文字,解释为什么不应丢弃该值;例如,[[nodiscard("检查写入错误")]]
。
重要的是要理解,遵守安全准则确实可以使您的代码更安全,但并不保证它。在我们的TimeSaver2
实现中,我们使用time
返回的值,但我们没有检查它是否有效。相反,我们无条件地写入输出文件。同样,如果write
返回非零数字,它仍然可以向文件写入比请求的数据少。即使您的代码形式上符合指南,它可能仍然存在相关问题。
使用静态代码分析器
所有安全准则都被定义为源代码或应用程序设计的具体要求的广泛集合。许多这些要求可以通过使用静态代码分析器自动检查。
静态代码分析器是一种可以分析源代码并在检测到违反代码质量要求的代码模式时警告开发人员的工具。在错误检测和预防方面,它们非常有效。由于它们可以在代码构建之前运行,因此很多错误都可以在开发的最早阶段修复,而不需要耗时的测试和调试过程。
除了错误检测和预防,静态代码分析器还用于证明代码在认证过程中符合目标要求和指南。
在这个示例中,我们将学习如何在我们的应用程序中使用静态代码分析器。
如何做...
我们将创建一个简单的程序,并运行其中一个许多可用的开源代码分析器,以检查潜在问题。按照以下步骤进行:
-
转到我们之前创建的
~/test/returns
目录。 -
从存储库安装
cppcheck
工具。确保您处于root
帐户下,而不是user
:
# apt-get install cppcheck
- 再次切换到
user
帐户:
# su - user
$
- 对
returns.cpp
文件运行cppcheck
:
$ cppcheck --std=posix --enable=warning returns.cpp
- 分析它的输出。
它是如何工作的...
代码分析器可以解析我们应用程序的源代码,并根据多种代表不良编码实践的模式进行测试。
存在许多代码分析器,从开源和免费到昂贵的企业级商业产品。
在使用所有函数的返回值示例中提到的MISRA编码标准是商业标准。这意味着您需要购买许可证才能使用它,并且需要购买一个经过认证的代码分析器,以便测试代码是否符合 MISRA 标准。
出于学习目的,我们将使用一个名为cppcheck
的开源代码分析器。它被广泛使用,并已经包含在 Ubuntu 存储库中。我们可以像安装其他 Ubuntu 软件包一样安装它:
# apt-get install cppcheck $ cppcheck --std=posix --enable=warning returns.cpp
现在,我们将源文件名作为参数传递。检查很快,生成以下报告:
正如我们所看到的,它在我们的代码中检测到了两个问题,甚至在我们尝试构建之前。第一个问题出现在我们更安全、增强的TimeSaver2
类中!为了使其符合 A0-1-2 要求,我们需要检查close
返回的状态代码,并在发生错误时抛出异常。然而,我们在析构函数中执行此操作,违反了 C++错误处理机制。
代码分析器检测到的第二个问题是资源泄漏。这解释了为什么TimeSaver1
会生成空文件。当打开文件时,我们意外地将文件描述符分配给局部变量,而不是实例变量,即fd
:
int fd = open(name, O_RDWR|O_CREAT|O_TRUNC, 0600);
现在,我们可以修复它们并重新运行cppcheck
,以确保问题已经消失,并且没有引入新问题。在开发工作流程中使用代码分析器可以使您的代码更安全,性能更快,因为您可以在开发周期的早期阶段检测和预防问题。
还有更多...
尽管cppcheck
是一个开源工具,但它支持多种 MISRA 检查。这并不意味着它是一个用于验证符合 MISRA 指南的认证工具,但它可以让您了解您的代码与 MISRA 要求的接近程度,以及可能需要多少努力使其符合要求。
MISRA 检查是作为一个附加组件实现的;您可以根据cppcheck
的 GitHub 存储库的附加组件部分中的说明来运行它(github.com/danmar/cppcheck/tree/master/addons
)。
使用前置条件和后置条件
在上一个示例中,我们学习了如何使用静态代码分析器来防止在开发的早期阶段出现编码错误。另一个防止错误的强大工具是按合同编程。
按合同编程是一种实践,开发人员在其中明确定义函数或模块的输入值、结果和中间状态的合同或期望。虽然中间状态取决于实现,但输入和输出值的合同可以作为公共接口的一部分进行定义。这些期望分别称为前置条件和后置条件,有助于避免由模糊定义的接口引起的编程错误。
在这个示例中,我们将学习如何在我们的 C++代码中定义前置条件和后置条件。
如何做...
为了测试前置条件和后置条件的工作原理,我们将部分重用我们在上一个示例中使用的TimeSaver1
类的代码。按照以下步骤进行:
-
在您的工作目录中,即
〜/test
,创建一个名为assert
的子目录。 -
使用您喜欢的文本编辑器在
assert
子目录中创建一个名为assert.cpp
的文件。 -
将
TimeSaver1
类的修改版本添加到assert.cpp
文件中:
#include <cassert>
#include <system_error>
#include <unistd.h>
#include <sys/fcntl.h>
#include <time.h>
class TimeSaver1 {
int fd = -1;
public:
TimeSaver1(const char* name) {
assert(name != nullptr);
assert(name[0] != '\0');
int fd = open(name, O_RDWR|O_CREAT|O_TRUNC, 0600);
if (fd < 0) {
throw std::system_error(errno,
std::system_category(),
"Failed to open file");
}
assert(this->fd >= 0);
}
~TimeSaver1() {
assert(this->fd >= 0);
close(fd);
}
};
- 接下来是一个简单的
main
函数:
int main() {
TimeSaver1 ts1("");
return 0;
}
- 将构建规则放入
CMakeLists.txt
文件中:
cmake_minimum_required(VERSION 3.5.1)
project(assert)
add_executable(assert assert.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 现在您可以构建和运行应用程序。
它是如何工作的...
在这里,我们重用了上一个示例中TimeSaver1
类的一些代码。为简单起见,我们删除了Update
方法,只留下了它的构造函数和析构函数。
我们故意保留了在上一个示例中由静态代码分析器发现的相同错误,以检查前置条件和后置条件检查是否可以防止这类问题。
我们的构造函数接受一个文件名作为参数。对于文件名,我们没有特定的限制,除了它应该是有效的。两个明显无效的文件名如下:
-
一个空指针作为名称
-
一个空的名称
我们将这些规则作为前置条件使用assert
宏:
assert(name != nullptr);
assert(name[0] != '\0');
要使用这个宏,我们需要包含一个头文件,即csassert
:
#include <cassert>
接下来,我们使用文件名打开文件并将其存储在fd
变量中。我们将其分配给局部变量fd
,而不是实例变量fd
。这是我们想要检测到的一个编码错误:
int fd = open(name, O_RDWR|O_CREAT|O_TRUNC, 0600);
最后,我们在构造函数中放置后置条件。在我们的情况下,唯一的后置条件是实例变量fd
应该是有效的:
assert(this->fd >= 0);
注意我们用 this 作为前缀以消除它与局部变量的歧义。同样,我们在析构函数中添加了一个前置条件:
assert(this->fd >= 0);
在这里我们不添加任何后置条件,因为在析构函数返回后,实例就不再有效了。
现在,让我们测试我们的代码。在main
函数中,我们创建了一个TimeSaver1
的实例,将一个空的文件名作为参数传递:
TimeSaver1 ts1("");
在构建和运行程序之后,我们将看到以下输出:
构造函数中的前置条件检查已经检测到了合同的违反并终止了应用程序。让我们将文件名更改为有效的文件名:
TimeSaver1 ts1("timestamp.bin");
我们再次构建和运行应用程序,得到了不同的输出:
现在,所有的前置条件都已经满足,但我们违反了后置条件,因为我们没有更新实例变量fd
。在第 16 行删除fd
前的类型定义,如下所示:
fd = open(name, O_RDWR|O_CREAT|O_TRUNC, 0600);
重新构建并再次运行程序会产生空输出:
这表明输入参数和结果的所有期望都已经满足。即使以基本形式,使用合同编程也帮助我们防止了两个编码问题。这就是为什么这种技术在软件开发的所有领域以及特别是在安全关键系统中被广泛使用的原因。
还有更多...
对于 C++20 标准,预计会添加更详细的合同编程支持。然而,它已经推迟到了以后的标准。提案的描述可以在论文A Contract Design (www.open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0380r1.pdf
)中找到,作者是 G. Dos Reis, J. D. Garcia, J. Lakos, A. Meredith, N. Myers, B. Stroustrup。
探索代码正确性的形式验证
静态代码分析器和合同编程方法有助于开发人员显著减少其代码中的编码错误数量。然而,在安全关键软件开发中,这还不够。重要的是正式证明软件组件的设计是正确的。
有一些相当复杂的方法来做到这一点,还有一些工具可以自动化这个过程。在这个示例中,我们将探索一种名为 CPAchecker 的正式软件验证工具之一 (cpachecker.sosy-lab.org/index.php
)。
如何做...
我们将下载并安装CPAcheck
到我们的构建环境中,然后对一个示例程序运行它。按照以下步骤进行:
-
用包括您的构建环境在内的终端打开。
-
确保您有 root 权限。如果没有,按Ctrl + D退出user会话返回到root会话。
-
安装 Java 运行时:
# apt-get install openjdk-11-jre
- 切换到用户会话并切换到
/mnt
目录:
# su - user
$ cd /mnt
- 下载并解压
CPACheck
存档,如下所示:
$ wget -O - https://cpachecker.sosy-lab.org/CPAchecker-1.9-unix.tar.bz2 | tar xjf -
- 切换到
CPAchecker-1.9-unix
目录:
$ cd CPAchecker-1.9-unix
- 对示例文件运行
CPAcheck
:
./scripts/cpa.sh -default doc/examples/example.c
- 下载故意包含错误的示例文件:
$ wget https://raw.githubusercontent.com/sosy-lab/cpachecker/trunk/doc/examples/example_bug.c
- 对新示例运行检查器:
./scripts/cpa.sh -default example_bug.c
- 切换到您的网络浏览器并打开由工具生成的
~/test/CPAchecker-1.9-unix/output/Report.html
报告文件。
它是如何工作的...
要运行CPAcheck
,我们需要安装 Java 运行时。这在 Ubuntu 存储库中可用,我们使用apt-get
来安装它。
下一步是下载CPAcheck
本身。我们使用wget
工具下载存档文件,并立即将其提供给tar
实用程序进行提取。完成后,可以在CPAchecker-1.9-unix
目录中找到该工具。
我们使用预打包的示例文件之一来检查工具的工作方式:
./scripts/cpa.sh -default doc/examples/example.c
它生成了以下输出:
我们可以看到,该工具没有发现这个文件中的任何问题。在CPAcheck
存档中没有包含错误的类似文件,但我们可以从其网站上下载:
$ wget https://raw.githubusercontent.com/sosy-lab/cpachecker/trunk/doc/examples/example_bug.c
我们再次运行该工具并获得以下输出:
现在,结果不同了:检测到了一个错误。我们可以打开工具生成的 HTML 报告进行进一步分析。除了日志和统计信息外,它还显示了流自动化图:
正式验证方法和工具是复杂的,可以处理相对简单的应用程序,但它们保证了所有情况下应用程序逻辑的正确性。
还有更多...
您可以在其网站上找到有关 CPAchecker 的更多信息(cpachecker.sosy-lab.org/index.php
)。
第十五章:微控制器编程
在之前的章节中,我们大多涵盖了适用于具有兆字节内存并运行 Linux 操作系统的相对强大的嵌入式系统的主题。现在,我们将探索嵌入式系统光谱的另一面——微控制器。
正如我们在介绍中讨论的那样,微控制器通常用于执行简单的、通常是实时的任务,比如收集数据或为特定设备提供高级 API。微控制器价格低廉,能耗低,可以在各种环境条件下工作,因此是物联网应用的理想选择。
他们低成本的另一面是他们的能力。通常,它们具有以千字节为单位的内置存储器,没有硬件内存映射。它们根本不运行任何操作系统,或者运行像 FreeRTOS 这样的简单实时操作系统。
有许多型号的微控制器,专为特定应用而设计。在本章中,我们将学习如何使用 Arduino 开发环境。这些配方是为基于 ATmega328 微控制器的 Arduino UNO 板创建的,该板广泛用于教育和原型开发,但它们也适用于其他 Arduino 板。
我们将涵盖以下主题:
-
搭建开发环境
-
编译和上传程序
-
调试微控制器代码
这些配方将帮助您设置环境并开始为微控制器开发。
搭建开发环境
Arduino UNO 板配备了一个名为 Arduino IDE 的集成开发环境,可以从www.arduino.cc/
网站免费下载。
在这个配方中,我们将学习如何设置并连接您的 Arduino 板。
如何做...
我们将安装 Arduino IDE,将 Arduino UNO 板连接到计算机,然后在 IDE 和板之间建立通信:
-
在浏览器中打开下载页面(
www.arduino.cc/en/Main/Software
),选择与您的操作系统匹配的安装选项。 -
下载完成后,按照入门(
www.arduino.cc/en/Guide/HomePage
)页面上的安装说明进行操作。 -
使用 USB 电缆将您的 Arduino 板连接到计算机上,它将自动上电。
-
运行 Arduino IDE。
-
现在,我们需要在 IDE 和板之间建立通信。切换到 Arduino IDE 窗口。在应用程序菜单中,选择“工具”->“端口”。这将打开一个子菜单,显示可用的串行端口选项。选择带有 Arduino 名称的端口。
-
在“工具”菜单中,点击“板”项目,然后选择您的 Arduino 板型号。
-
选择“工具”->“板信息”菜单项。
工作原理...
Arduino 板配备了一个免费的 IDE,可以从制造商的网站下载。IDE 的安装很简单,与为您的平台安装任何其他软件没有区别。
所有代码都是在 IDE 中编写、编译和调试的,但生成的编译图像应该被刷到目标板上并在那里执行。为此,IDE 应该能够与板进行通信。
该板通过 USB 连接到运行 IDE 的计算机。USB 电缆不仅提供通信,还为板提供电源。一旦板连接到计算机,它就会打开并开始工作。
IDE 使用串行接口与板进行通信。由于您的计算机上可能已经配置了多个串行端口,设置通信的步骤之一是选择其中一个可用的端口。通常,它是带有 Arduino 名称的端口:
最后,一旦选择了端口,我们让 IDE 知道我们使用的 Arduino 板的类型。完成后,我们可以检查板和 IDE 之间的通信是否实际有效。当我们调用 Board Info 菜单项时,IDE 会显示一个包含有关连接板的信息的对话框窗口:
如果对话框没有显示,这表示存在问题。板可能已断开连接或损坏,或者可能选择了错误的端口。否则,我们准备构建和运行我们的第一个程序。
还有更多...
如果出现问题,请考虑阅读 Arduino 网站上的故障排除部分(www.arduino.cc/en/Guide/Troubleshooting
)。
编译和上传程序
在上一个步骤中,我们学习了如何设置开发环境。现在,让我们编译和运行我们的第一个程序。
Arduino UNO 板本身没有屏幕,但我们需要一种方式来知道我们的程序正在做些什么。但是,它确实有一个内置 LED,我们可以在不连接任何外围设备到板上的情况下从我们的程序中控制。
在这个步骤中,我们将学习如何编译和运行一个在 Arduino UNO 板上闪烁内置 LED 的程序。
如何做...
我们将编译并上传到板上一个已经存在的 IDE 自带的示例应用程序:
-
将 Arduino 板连接到计算机并打开 Arduino IDE。
-
在 Arduino IDE 中,打开文件菜单,选择示例-> 01.基础-> 闪烁。
-
将打开一个新窗口。在此窗口中,单击上传按钮。
-
观察板上的内置 LED 开始闪烁。
它是如何工作的...
Arduino 是一个广泛用于教育目的的平台。它设计成易于使用,并附带一堆示例。对于我们的第一个程序,我们选择了一个不需要将板与外部设备连接的应用程序。一旦启动 IDE,我们从可用的示例中选择了 Blink 应用程序,如下所示:
这将打开一个带有程序代码的窗口:
除了程序的源代码外,我们还可以看到一个黑色控制台窗口和状态栏,指示 Arduino UNO 板通过/dev/cu.usbmodem14101
串行端口连接。设备名称取决于板型号,端口名称在 Windows 或 Linux 中可能看起来不同。
在源代码上方,我们可以看到几个按钮。第二个按钮是上传按钮。按下后,IDE 开始构建应用程序,然后将生成的二进制文件上传到板上。我们可以在控制台窗口中看到构建状态:
上传后立即启动应用程序。如果我们看看板,我们可以看到内置的黄色 LED 已经开始闪烁。我们成功构建并运行了我们的第一个 Arduino 应用程序。
还有更多...
上传后,程序将存储在板上的闪存内存中。如果关闭板电源然后再次打开,即使没有运行 IDE,程序也会开始运行。
调试微控制器代码
与树莓派等更强大的嵌入式平台相比,Arduino 的调试能力有限。Arduino IDE 不提供集成调试器,Arduino 板本身也没有内置屏幕。但是,它确实具有 UART,并提供可用于调试目的的串行接口。
在这个步骤中,我们将学习如何使用 Arduino 串行接口进行调试和读取用户输入。
如何做...
我们将为 Arduino 控制器实现一个简单的程序,该程序在串行端口上等待用户输入,并根据数据打开或关闭内置 LED:
-
打开 Arduino IDE 并在其文件菜单中选择新建。将显示一个新的 Sketch 窗口。
-
将以下代码片段粘贴到 Sketch 窗口中:
void setup() {
pinMode(LED_BUILTIN, OUTPUT);
Serial.begin(9600);
while (!Serial);
}
void loop() {
if (Serial.available() > 0) {
int inByte = Serial.read();
if (inByte == '1') {
Serial.print("Turn LED on\n");
digitalWrite(LED_BUILTIN, HIGH);
} else if (inByte == '0') {
Serial.print("Turn LED off\n");
digitalWrite(LED_BUILTIN, LOW);
} else {
Serial.print("Ignore byte ");
Serial.print(inByte);
Serial.print("\n");
}
delay(500);
}
}
-
单击“上传”按钮以构建和运行代码。
-
在 Arduino IDE 的工具菜单中选择串行监视器。串行监视器窗口将出现。
-
在串行监视器窗口中,输入
1010110
。
工作原理...
我们创建一个由两个函数组成的新的 Arduino 草图。第一个函数 setup
在程序启动时被调用,并用于提供应用程序的初始配置。
在我们的情况下,我们需要初始化串行接口。串行通信的最重要参数是每秒位数的速度。微控制器和 IDE 都应同意使用相同的速度,否则通信将无法工作。串行监视器默认使用每秒 9,600 位,我们在程序中使用这个值:
Serial.begin(9600);
虽然可以使用更高的通信速度。串行监视器在屏幕右下角有一个下拉菜单,允许选择其他速度。如果决定使用其他速度,则应相应修改代码。
我们还为输出配置引脚 13,对应于内置 LED:
pinMode(LED_BUILTIN, OUTPUT);
我们使用常量 LED_BUILTIN
,而不是 13
,以使代码更易理解。第二个函数 loop
定义了 Arduino 程序的无限循环。对于每次迭代,我们从串行端口读取一个字节:
if (Serial.available() > 0) {
int inByte = Serial.read();
如果字节是 1
,我们打开 LED 并向串行端口写入一条消息:
Serial.print("Turn LED on\n");
digitalWrite(LED_BUILTIN, HIGH);
同样地,对于 0
,我们关闭 LED:
Serial.print("Turn LED off\n");
digitalWrite(LED_BUILTIN, LOW);
所有其他值都被忽略。在从端口读取每个字节后,我们添加 500 微秒的延迟。这样,我们可以定义不同的闪烁模式。例如,如果我们发送 1001001
,LED 将在 0.5 秒内打开,然后在 1 秒内关闭,再在 0.5 秒内打开,再在 1 秒内关闭,最后再次打开。
如果我们运行代码并在串行监视器中输入 1001001
,我们可以看到以下输出:
LED 正如预期地闪烁,并且除此之外,我们可以在串行监视器中看到调试消息。通过这种方式,我们可以调试真实的、更复杂的应用程序。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
2022-05-04 Impatient JavaScript 中文版校对活动期待大家的参与
2022-05-04 ApacheCN 翻译/校对活动进度公告 2022.5.4
2022-05-04 非安全系列教程 NPM、PYPI、DockerHub 备份
2022-05-04 UIUC CS241 系统编程中文讲义校对活动 | ApacheCN