大家好,欢迎来到IT知识分享网。
原文:annas-archive.org/md5/5ff617ac4c3bf4b93da8
译者:飞龙
协议:CC BY-NC-SA 4.0
前言
长期以来,嵌入式系统的开发要求要么使用纯 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/43_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 架构上相对便宜,但在其他架构上可能更昂贵。
字节序
字节序定义了表示大数值的字节在内存中存储的顺序。
有两种字节序:
- 大端:最重要的字节被首先存储。
0x0
32 位值存储在ptr
地址如下:
内存中的偏移 | 值 |
---|---|
ptr |
0x01 |
ptr + 1 |
0x02 |
ptr + 2 |
0x03 |
ptr + 3 |
0x04 |
大端架构的例子包括 AVR32 和 Motorola 68000。
- 小端:最不重要的字节被首先存储。
0x0
32 位值存储在ptr
地址如下:
内存中的偏移 | 值 |
---|---|
ptr |
0x04 |
ptr + 1 |
0x03 |
ptr + 2 |
0x02 |
ptr + 3 |
0x01 |
x86 架构是小端的。
- 双端:硬件支持可切换的字节序。一些例子是 PowerPC、ARMv3 和前面的例子。
字节序在与其他系统交换数据时特别重要。如果开发人员按原样发送0x0
32 位整数,如果接收者的字节序与发送者的字节序不匹配,它可能被读取为0x0
。这就是为什么数据应该进行序列化。
这段 C++代码可以用来确定系统的字节序:
#include <iostream> int main() {
union {
uint32_t i; uint8_t c[4]; } data; data.i = 0x0; 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/
- 对于 macOS:docs.docker.com/docker-for-mac/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 位的值,0x0,被存储在
ptr
地址上,如下所示:
内存偏移(字节) | 值 |
---|---|
ptr | 0x01 |
ptr + 1 | 0x02 |
ptr + 2 | ox03 |
ptr + 3 | 0x04 |
大端架构的例子包括 AVR32 和 Motorola 68000。
- 小端:最不重要的字节被先存储。一个 32 位的值,0x0,被存储在
ptr
地址上,如下所示:
内存偏移(字节) | 值 |
---|---|
ptr | 0x04 |
ptr + 1 | 0x03 |
ptr + 2 | 0x02 |
ptr + 3 | 0x01 |
x86 架构是小端的。
在与其他系统交换数据时,处理字节顺序尤为重要。如果开发人员将一个 32 位整数,比如 0x0,原样发送,如果接收者的字节顺序与发送者的字节顺序不匹配,它可能被读取为 0x0。这就是为什么数据应该被序列化的原因。
在这个配方中,我们将学习如何确定目标系统的字节顺序。
如何做…
我们将创建一个简单的程序,可以检测目标平台的字节顺序。按照以下步骤来做:
- 在你的工作目录,即
~/test
,创建一个名为endianness
的子目录。 - 使用你喜欢的文本编辑器在循环子目录中创建一个名为
loop.cpp
的文件。将以下代码片段复制到endianness.cpp
文件中:
#include <iostream> int main() {
union {
uint32_t i; uint8_t c[4]; } data; data.i = 0x0; 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
可能需要比请求的内存多两倍。对于较大的对齐块,这种内存开销增长可能是显著的。
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://haidsoft.com/125842.html