C++17 嵌入式编程实用指南(一)

原文:zh.annas-archive.org/md5/B28E444E77634E28D12AD6F4C3A426AD

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

C++不会增加任何膨胀,扩展可维护性,并且相对于其他编程语言具有许多优势,因此它是嵌入式开发的不错选择。您想要构建独立的或联网的嵌入式系统,并使其具有安全性和内存安全性吗?在本书中,您将学会如何做到这一点。您将学习 C++的工作原理,并与其他用于嵌入式开发的语言进行比较,以及如何为嵌入式设备创建高级 GUI,以设计具有吸引力和功能性的 UI,并将成熟的策略集成到您的设计中,以实现最佳的硬件性能。

本书将带您了解各种嵌入式系统硬件板,以便您为项目选择最佳的硬件。您将学习如何通过充分采用本书中提出的成熟编程模式来解决复杂的架构问题。

本书适合对象

如果您想要开始在 C++中开发有效的嵌入式程序,那么这本书适合您。需要对 C++语言构造有良好的了解,以理解本书涵盖的主题。不假设对嵌入式系统有任何了解。

本书涵盖的内容

第一章《嵌入式系统是什么?》使您熟悉嵌入式系统的含义。通过查看各种类别和每个类别中的嵌入式系统的示例,应该形成对术语“嵌入式”的含义以及该术语内的广泛多样性的良好概述。它探讨了历史上和当前可用的各种微控制器和系统级芯片解决方案,您可以在现有系统以及新设计中找到。

第二章《C++作为嵌入式语言》解释了为什么 C++实际上与 C 和类似语言一样灵活。C++不仅通常至少与 C 一样快,而且没有额外的膨胀,并且在代码范例和可维护性方面提供了许多优势。

第三章《为嵌入式 Linux 和类似系统开发》解释了如何为基于 Linux 的嵌入式系统开发,并在 SBC 上进行管理,并处理基于 Linux 和基于 PC 的开发之间的差异。

第四章《资源受限嵌入式系统》涉及规划和有效利用有限资源。我们将看看如何为新项目选择合适的 MCU,并在项目中添加外围设备以及处理以太网和串行接口需求。我们还将看一个 AVR 项目的例子,如何为其他 MCU 架构开发,以及是否使用 RTOS。

第五章《示例-带 Wi-Fi 的土壤湿度监测器》解释了如何创建一个带有泵或类似装置的 Wi-Fi 启用的土壤湿度监测器。使用内置的 Web 服务器,您可以使用其基于浏览器的 UI 进行监控和控制,或者使用其 REST API 将其集成到更大的系统中。

第六章《测试基于操作系统的应用程序》介绍了如何开发和测试基于嵌入式操作系统的应用程序。您将学习如何安装和使用交叉编译工具链,使用 GDB 进行远程调试,并编写构建系统。

第七章《测试资源受限平台》展示了如何有效地为基于 MCU 的目标开发。您还将看到如何实现一个集成环境,使我们能够从桌面操作系统和提供的工具舒适地调试基于 MCU 的应用程序。

第八章《示例-基于 Linux 的信息娱乐系统》解释了如何相对容易地构建基于 SBC 的信息娱乐系统,使用语音转文本来构建语音驱动的用户界面。我们还将看看如何扩展它以添加更多功能。

第九章,示例-建筑监控与控制,展示了如何开发建筑全面的监控和管理系统,系统的组成以及在开发过程中学到的经验。

第十章,使用 Qt 开发嵌入式系统,探讨了 Qt 框架在开发嵌入式系统时的多种用法。我们将比较它与其他框架的优劣,并了解 Qt 如何针对这些嵌入式平台进行优化,然后通过一个基于 QML 的 GUI 示例来完善先前创建的信息娱乐系统。

第十一章,开发混合 SoC/FPGA 系统,教会您如何与混合 FPGA/SoC 系统的 FPGA 部分进行通信,并帮助您了解 FPGA 中实现各种算法并在 SoC 端使用的方法。您还将学习如何在混合 FPGA/SoC 系统上实现基本示波器。

附录,最佳实践,介绍了在嵌入式软件设计中可能遇到的一些常见问题和陷阱。

为了充分利用本书

需要具备对树莓派的工作知识。您将需要 C++编译器、GCC ARM Linux(交叉)工具链、AVR 工具链、Sming 框架、Valgrind、Qt 框架和 Lattice Diamond IDE。

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support并注册,文件将直接发送到您的邮箱。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packtpub.com

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,并按照屏幕上的说明进行操作。

下载文件后,请确保使用最新版本的解压软件解压文件夹:

  • Windows 系统使用 WinRAR/7-Zip

  • Mac 系统使用 Zipeg/iZip/UnRarX。

  • Linux 系统使用 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Embedded-Programming-with-CPP-17。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还提供来自丰富图书和视频目录的其他代码包,网址为**github.com/PacktPublishing/**。请查看!

使用的约定

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

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名。例如:“C++类本身是以 C 语言实现的,包含了类变量的struct。”

代码块设置如下:

class B : public A { 
   // Private members. 

public: 
   // Additional public members. 
}; 

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

class B : public A { 
   // Private members. 

public: 
   // Additional public members. 
}; 

任何命令行输入或输出都以以下形式书写:

sudo usermod -a -G gpio user
sudo usermod -a -G i2c user

粗体:表示新术语、重要词汇或屏幕上看到的词语。例如,菜单或对话框中的词语会以这种形式出现在文本中。例如:“与 MCU 相比,SoC 的资源限制没有那么严格,通常运行完整的操作系统OS),如 Linux 衍生的 OS、VxWorks 或 QNX。”

警告或重要提示以这种形式出现。

技巧和窍门以这种形式出现。

第一部分:基础知识-嵌入式编程和 C++的作用

在本节中,读者应该熟悉目前存在的许多嵌入式平台,以及一个基本的实际示例项目。

接下来的章节将在本节中介绍。

  • 第一章,《嵌入式系统是什么?》

  • 第二章,《C++作为嵌入式语言》

  • 第三章,《为嵌入式 Linux 和类似系统开发》

  • 第四章,《资源受限的嵌入式系统》

  • 第五章,《示例-带 Wi-Fi 的土壤湿度监测器》

第一章:什么是嵌入式系统?

基本上,嵌入式系统中的“嵌入式”部分指的是被嵌入到更大系统中的状态。被嵌入的系统是某种类型的计算机系统,它在整个系统中具有一个或多个非常特定的功能,而不是一个通用组件。这个更大的系统可以是数字的、机械的或模拟的,而额外的集成数字电路与接口、传感器和存储器的数据紧密交互,以实现实际的系统功能。

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

  • 嵌入式平台的不同类别

  • 每个类别的例子

  • 每个类别的发展挑战

嵌入式系统的多种面貌

今天设备中的每个计算机化功能都是使用一个或多个微处理器实现的,这意味着一个计算机处理器(中央处理单元或 CPU)通常包含在一个单一的集成电路(IC)中。微处理器至少包括算术逻辑单元(ALU)和控制电路,但逻辑上也包括寄存器和输入/输出(I/O)银行,以及通常针对特定产品类别(可穿戴设备、低功耗传感器、混合信号等)或市场(消费品、医疗、汽车等)定制的更高级功能。

在历史上的这一点上,几乎所有的微处理器都可以在嵌入式系统中找到。即使人们可能拥有计算机、笔记本电脑和智能手机,甚至可能还有平板电脑,但一个家庭中嵌入式微处理器的数量远远超过通用微处理器的数量。

即使在笔记本电脑或个人电脑中,除了通用 CPU 之外,还有许多嵌入式微处理器。这些微处理器的任务包括处理键盘或鼠标输入,处理触摸屏输入,将数据流转换为以太网数据包,或创建视频或音频输出。

在旧系统中,比如 Commodore 64,也可以看到同样的模式,有 CPU IC、声音 IC、视频 IC 等。虽然 CPU 运行应用程序开发人员编写的任何代码,但系统中的其他芯片具有非常具体的目的,甚至包括软盘或硬盘驱动器的控制器 IC。

除了通用计算机之外,我们在各处都可以找到嵌入式微处理器,通常以更进一步集成的 MCU 的形式存在。它们控制厨房设备、洗衣机和汽车发动机,除了更高级的功能和传感器信息的处理。

虽然最初的微波炉是模拟设备,使用机械定时器和可变电阻器来设置功率水平和持续时间,但今天的微波炉至少包含一个微控制器,负责处理用户输入,驱动某种类型的显示器,并配置微波炉的系统。显示器本身可以根据所选择的配置的复杂性具有自己的微控制器。

也许更令人兴奋的是,嵌入式系统还提供监控、自动化和故障安全功能,保持飞机飞行,确保制导导弹和太空火箭按预期执行,并在医学和机器人技术等领域实现不断增加的可能性。飞机的航空电子设备不断监测来自众多传感器的无数参数,运行相同代码的三重冗余配置以检测任何可能的故障。

微小而强大的微处理器使得对化学物质和 DNA 或 RNA 链的快速分析成为可能,而以前需要大量设备。随着技术的进步,嵌入式系统已经变得足够小,可以被送入人体监测其健康状况。

在地球之外,月球、火星和小行星上的空间探测器和探测车每天都在执行各种任务,这都得益于经过充分测试的嵌入式系统。月球任务本身得益于第一个嵌入式系统的主要示例,即阿波罗导航计算机。这种 1966 年的嵌入式系统由装满三输入 NOR 逻辑门的线缠绕板组成,专门用于处理土星五号火箭发射的指挥舱和登月舱的导航、引导和控制。

嵌入式系统的无处不在和多功能性使其成为现代生活中不可分割的一部分。

嵌入式系统通常可以区分为以下几类:

  • 微控制器MCUs

  • 片上系统SoC),通常作为单板计算机SBC

微控制器

嵌入式系统领域创新的推动因素之一是成本,因为它们通常是高产量、廉价的消费品。为此,将整个微处理器、存储器、存储器和输入/输出外围设备集成到单个芯片上有助于简化实施工作,减少 PCB 实际面积,同时具有更快、更简单的设计和生产,以及更高的产量。这导致在 20 世纪 70 年代开发了微控制器MCUs):可以以最小成本添加到新设计中的单芯片计算机系统。

随着在 20 世纪 90 年代初将可擦可编程只读存储器EEPROM)引入 MCUs,首次有可能重复地重新编写 MCU 的程序存储器,而无需通过 MCU 封装中的特殊石英窗口使用紫外线擦除存储器内容。这使得原型设计变得更加容易,并进一步降低了成本,就开发和低产量生产而言,实现了在线编程。

因此,许多以前由复杂的机械和模拟机制控制的系统(如电梯和温度控制器)现在包含一个或多个 MCU,这些 MCU 处理相同的功能,同时降低成本并提高可靠性。通过在软件中处理功能,开发人员还可以自由添加高级功能,例如复杂的预设程序(用于洗衣机、微波炉等)和简单到复杂的显示以向用户提供反馈。

TMS 1000

第一个商用 MCU 是德州仪器的 TMS 1000,是一种通用的 4 位单芯片系统。它于 1974 年首次上市销售。原始型号具有 1 KB 的 ROM,64 x 4 位的 RAM 和 23 个 I/O 引脚。它们的时钟速度可以从 100 到 400 KHz,每条指令执行需要六个时钟周期。

后来的型号将增加 ROM 和 RAM 的大小,尽管基本设计在 1981 年停产之前基本保持不变:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/3b757a88-54d0-4f2c-a0d1-367c2523d7c2.png

MCU 芯片的尺寸大约为 5 x 5 毫米,足够小以适应 DIP 封装。这种类型的 MCU 使用掩模可编程 ROM,这意味着您不能获得空白的 TMS 1000 芯片并对其进行编程。相反,您必须将经过调试的程序发送给德州仪器,以便使用光刻掩模进行物理生产,从而为每个位产生金属桥。

作为一个相对较原始的设计(相对于后来的 MCUs),它缺乏堆栈和中断,有一组 43 条指令和两个通用寄存器,使其与英特尔 4004 CPU 非常相似。一些型号具有特殊的外围设备,用于驱动真空荧光显示器VFD),并且可以持续读取输入以处理用户通过键盘输入而不中断主程序。其基本引脚布局如下:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/d3dc0886-0ef2-4ab9-906d-8c68a6eeff37.png

显然,引脚功能早于我们今天所知的通用输入/输出(GPIO)引脚 - K 引脚只能用于输入,而输出引脚标记为 O,控制引脚标记为 R。OSC 引脚需要连接到外部振荡器电路。与离散逻辑 IC 类似,Init 引脚用于在上电时初始化芯片,并且必须保持高电平至少六个周期,而最近的 MCU 则集成了上电复位(POR)和最多需要一个离散电阻和电容的复位引脚。

根据 1974 年德州仪器的原始新闻稿,这些微控制器可以以低至 3 美元的价格购得,如果你大量购买的话甚至更便宜。它们将被用于流行的玩具,如 Speak and Spell,但也会出现在几乎所有其他地方,包括家用电器、汽车和科学设备。到了 1980 年代初停产时,已经销售了数百万台。

有趣的是,尽管一次性可编程的低成本微控制器的价格大大降低,但这类产品仍然存在 - 例如,Padauk PMS150C 现在可以以 0.03 美元的价格购得,虽然它采用 8 位架构,但其 1K 字的 ROM 和 64 字节的 RAM 听起来似曾相识。

英特尔 MCS-48

英特尔对德州仪器成功的 TMS 1000 MCU 的回应是 MCS-48 系列,其中 8048、8035 和 8748 是 1976 年发布的第一批型号。8048 具有 1KB 的 ROM 和 64 字节的 RAM。它是一个 8 位设计,采用哈佛结构(分离代码/数据存储器),引入了 8 位本地字长和中断支持(两个单级),并兼容 8080/8085 外围设备,使其成为一款非常多功能的 MCU。更宽的 ALU 和寄存器字长的优势在今天仍然可以感知到,例如,32 位加法在 8 位 MCU 上是作为一系列带进位的 8 位加法依次执行的。

MCS-48 具有超过 96 条指令,其中大多数指令长度为一个字节,并允许在内部存储器之外添加外部存储器。在社区的努力下,MCS-48 系列的相关信息已经被整理并发布在devsaurus.github.io/mcs-48/mcs-48.pdf上。

在这里,我们考虑了 MCS-48 功能块图的简单性,并将其与后续产品进行了比较,如下所示:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/b5d1ac0d-9dea-45f2-a35b-77b9c74a8ade.png

即使是在 TMS 1000 之后的几年内推出的设计,MCU 设计的快速演变也是显而易见的。由于 MCU 设计与当时流行的 CPU 设计一起发展,包括 6502 及其 16 位版本,以及最终成为 M68K 处理器系列的设计,因此可以找到许多相似之处。

由于其灵活的设计,MCS-48 一直保持着流行,并一直生产到 1990 年代,直到 MCS-51(8051)系列逐渐取代它。有关 8051 的更多详细信息,请参见下一节。

MCS-48 被用于原始 IBM PC 的键盘控制器。它还与 80286 和 80386 一起用于执行 A20 线门控和复位功能。后来的 PC 将这些功能集成到超级 I/O 设备中。

MCS-48 的其他显著用途包括 Magnavox Odyssey 视频游戏机和一系列 Korg 和 Roland 模拟合成器。虽然 MCS-48 系列可以选择使用掩模 ROM(最多 2KB),但 87P50 使用外部 ROM 模块进行编程,而 8748 和 8749 则配备了高达 2KB 的 EPROM,这使得 MCU 的内部编程可以重复更改。

与独立的 EPROM 模块一样,这需要包含一个熔合石英窗口的封装,这样紫外线就可以到达 MCU 芯片,正如下面这张 Konstantin Lanzet 拍摄的 8749 MCU 与 EPROM 的照片所示(CC BY-SA 3.0):

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/4ad70b0c-1c97-4323-ae7e-4c79e53867d6.png

定义写入的 EPROM 单元中存储的电荷在强紫外线照射 20-30 分钟后会消散。在几周的阳光直射下也可以实现相同效果。擦除周期通常意味着取出封装并将其放入密封的擦除设备中。之后,EPROM 可以重新编程。EPROM 的指定数据保留在 85°C 时约为 10-20 年,由于随温度呈指数增长,因此在室温下 100 年或更长时间的声明并不罕见(27C512A:200 年)。

由于制作石英窗口并将其集成到封装中的费用昂贵,一次性可编程 EPROM 曾一度被使用,这样可以轻松编程 EPROM,但将编程后的芯片安装在不透明封装中,因此无法再次重新编程。最终,EEPROM 在 20 世纪 80 年代初开始出现,几乎完全取代了 EPROM。 EEPROM 在开始出现存储数据之前可以重写大约一百万次。它们的数据保留性能与 EPROM 类似。

英特尔 MCS-51

从 Cypress CY7C68013A(USB 外围控制器)到 Ti CC2541(蓝牙 SoC)的最新芯片都采用了通用的 8051 核心,这表明英特尔 MCS-51 系列设计至今仍然受欢迎。其他制造商也推出了大量衍生的 MCU,尽管英特尔于 2007 年 3 月停止生产这个系列的 MCU。它是在 20 世纪 80 年代首次推出的,是一种 8 位 MCU,类似于 8048,但在其功能集上有很大的扩展。

如英特尔 80xxAH 数据表中所示的功能模块图如下:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/893fbdf4-9c16-48cd-8446-bc058a32f4f0.png

它与 Atmel(现在是微芯片)AT89S51 非常相似,而且至今仍在生产中。

数据表通常在“特性”列表中解释尺寸和性能指标,如下所引用的 AT89S51:

  • 4K 字节的系统内可编程(ISP)闪存存储器
  • 耐久性:10,000 次写入/擦除周期(EEPROM 为 1,000,000 次)
  • 4.0V 至 5.5V 的工作范围

  • 完全静态操作:0 赫兹至 33 兆赫(曾为 12 兆赫)

  • 三级程序存储器锁

  • 128 x 8 位内部 RAM

  • 32 个可编程 I/O 线路

但随后的列表中还包括现代核心、外围、低功耗和可用性功能:

  • 两个 16 位定时器/计数器

  • 六个中断源

  • 全双工 UART 串行通道

  • 低功耗空闲和关机模式

  • 中断从掉电模式恢复

  • 看门狗定时器

  • 双数据指针

  • 关机标志

  • 快速编程时间

  • 灵活的 ISP 编程,字节和页面模式

在过去几十年里,8051 架构的唯一重大变化涉及从原始的n 型金属氧化物半导体(NMOS)晶体管技术迁移到互补 MOS(CMOS)-通常表示为 80C51-以及最近添加了 USB、I2C 和 SPI 接口,以及自本世纪初以来变得普遍的先进电源管理和调试接口。Atmel 应用说明 3487A 没有对字母 S 给出简明的解释,然而当时新的现场串行编程(ISP)可能因此受到强调。

AT89S51 的引脚图表记录了 SPI 引脚(MOSI,MISO,SCK):

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/a764f469-f62f-4b51-99ee-5cc20c141900.png

除了独立 MCU 外,8051 核心还集成到更大的系统中,其中低功耗、基本 MCU 专用于各种低速、实时或高 I/O 计数任务。从 Ti CC2541(蓝牙低功耗 SoC)到 Cypress CY7C68013A(FX2LP™ USB 外围控制器)等各种芯片都突显了 8051 架构的实用性和相关性。

现场可编程门阵列FPGA)或应用特定集成电路ASIC)开发中,8051 型处理器也常常被部署为软核心,它们被改编并添加到 VHDL 和 Verilog HDL 项目中,以处理更适合顺序执行的任务,而无需紧密的时序或大带宽。软核心的魅力在于能够使用功能齐全的开发和调试工具,同时与其余硬件设计紧密集成。由软核心运行的几百字节程序代码的等效物可能是一个大型状态机,存储器,计数器和 ALU 类似的逻辑,这引发了一个问题,即哪种实现更容易验证和维护。

PIC

PIC MCU 系列于 1976 年由 General Instrument 首次推出,使用他们的新 CP1600 16 位 CPU。这个 CPU 几乎与 PDP-11 系列处理器兼容,具有其指令集。

1987 年,General Instrument 将其微电子部门剥离出来,创建了 Microchip Technology,该公司于 1989 年成为独立公司。Microchip Technology 至今仍在生产新的 PIC 设计。随着 PIC 核心和外设的发展,芯片内存技术的发展产生了封装紧密的 EPROM,用于及时可编程,后来是 EEPROM,用于电路中的重新编程能力。像大多数 MCU 一样,PIC MCU 具有哈佛结构。如今,PIC 设计从 8 位到 32 位不等,具有各种功能。这是本书撰写时的 PIC 系列:

系列引脚内存详情
PIC106-8384-896 字节 ROM,64-512 字节 RAM8 位,8-16 MHz,修改的哈佛结构
PIC1282-16 KB ROM,256 字节 RAM8 位,16 MHz,修改的哈佛结构
PIC168-643.5-56 KB ROM,1-4 KB RAM8 位修改的哈佛结构
PIC1740-684-16 KB ROM,232-454 字节 RAM8 位,33 MHz,被 PIC18 取代,尽管存在第三方克隆产品。
PIC1828-10016-128 KB ROM,3,728-4,096 字节 RAM8 位修改的哈佛结构
PIC24(dsPIC)14-14464-1,024KB ROM,8-16 KB RAM16 位,DsPIC(dsPIC33)MCU 内置数字信号处理(DSP)外设。
PIC32MX64-10032-512 KB ROM,8-32 KB RAM32 位,200 MHz MIPS M4K 与 MIPS16e 模式,2007 年发布。
PIC32MZ ECPIC32MZ EFPIC32MZ DA64-288512-2,048 KB ROM,256-640 KB 静态 RAM(32 MB DDR2 DRAM)32 位,MIPS ISA(2013),PIC32MZ DA 版本(2017)具有图形核心。核心速度为 200 MHz(EC,DA)和 252 MHz(EF)。
PIC32MM20-6416-256 KB RAM,4-32 KB RAM32 位 microMIPS,25 MHz,针对低成本和低功耗进行了优化的变体。
PIC32MK64-100512-1,024 KB ROM,128-256 KB RAM32 位,120 MHz,MIPS ISA,2017 年推出的变体。针对工业控制和其他形式的深度集成应用。

PIC32 系列的有趣之处在于它们基于 MIPS 处理器核心,并使用这个指令集架构ISA),而不是所有其他 PIC MCU 使用的 PIC ISA。它们共享的处理器核心设计是 M4K,这是来自 MIPS Technology 的 32 位 MIPS32 核心。在这些系列之间,通过查看各自数据表中的块图,这些差异很容易看出来。

在 PIC 系列微控制器的几十年的发展中,最好以功能块图的形式来体现,因此我们首先看看 PIC10:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/90869401-7d48-4657-b198-ad7556ad1ca8.png

这些都是非常小的 MCU,几乎没有任何外围设备围绕着一个在这里没有更详细定义的处理器核心 - 而参考表中只提到了内存布局。I/O 端口非常简单,我们今天所知道的 I2C 和 UART 接口并没有作为外围逻辑实现。举个例子,接下来的一个控制器,PIC16F84 的数据表非常详细地描述了处理器架构,并显示增加了更多的上电和复位电路,同时扩展了 GPIO 并添加了 EEPROM 以便轻松集成非易失性存储。自包含的串行外围设备仍然不存在。

接下来,我们将看一下 PIC18:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/e6ce1ab5-1c0f-4c29-b0c3-7e061f2310c5.png

PIC18 系列是最新的 8 位 PIC 架构,MCU 覆盖了各种应用。它比 PIC10、PIC12 和 PIC16 系列有更多的 I/O 选项,同时在 ROM 和 RAM 方面也提供了更多的选项,并且现在提供了 USART 以及用于 4 线 SPI 的同步串行端口。还要注意的是,端口现在具有备用引脚功能,并且从外围设备到引脚的路由以及相应的配置寄存器出于简单起见未显示。

接下来,让我们观察一下在 PIC24 功能块图中,焦点从核心转移到端口和外围设备的能力:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/7c2d613d-7ecb-41b3-b888-92bc4c9eb493.png

该图与 PIC10 的图类似,CPU 被抽象为相对于 MCU 的单个块。每个PORT块都是一组 I/O 引脚,我们的空间已经不足以显示所有可能的引脚功能。

每个 I/O 引脚可以具有固定功能(与外围模块链接),或具有可分配功能(硬件级别重路由,或在软件中完成)。一般来说,MCU 越复杂,I/O 引脚越可能是通用的,而不是固定功能。

最后我们看看 PIC32:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/473399ca-4ada-4083-8265-cae721ce7a90.png

这个块图是 PIC32MX 系列中 PIC32MX1XX/2XX 设备的。它通常以 50 MHz 的频率运行。

PIC32 架构的一个有趣特性是,它通过使程序指令和数据都通过系统总线矩阵传输,有效地将哈佛架构的 M4K MIPS CPU 转变为更类似于冯·诺伊曼架构。请注意,PIC10 图表中专用于单个处理器寄存器的空间现在随意地描绘了一个复杂的数字或混合信号外围设备,或者功能强大的 JTAG 在线编程和调试接口。

AVR

AVR 架构是由挪威科技学院的两名学生开发的,最初的 AVR MCU 是在北欧 VLSI(现在的北欧半导体)开发的。最初它被称为μRISC,并且可以通过许可获得,直到该技术被出售给 Atmel。第一款 Atmel AVR MCU 于 1997 年发布。

今天,我们可以回顾一系列 8 位 AVR 系列:

系列引脚内存详情
ATtiny6-320.5-16KB ROM 0-2 KB RAM1.6-20 MHz。紧凑、节能的 MCU,具有有限的外围设备。
ATmega32-1004-256 KB ROM 0.5-32 KB RAM
ATxmega44-10016-384 KB ROM, 1-32 KB RAM32 MHz, 最大的 AVR MCU,具有广泛的外围设备和 DMA 等性能增强功能。

Atmel 曾经也有一个 32 位的 AVR32 架构,但随着转向 ARM 32 位架构(SAM),它被 Atmel 废弃了。有关 SAM 的更多详细信息,请参阅基于 ARM 的 MCU部分。在相应的产品选择指南中可以找到更详细的信息。

此外,Atmel 曾经有所谓的可编程系统级集成电路FPSLIC)MCU:混合 AVR/FPGA 系统。这些基本上允许您向 AVR MCU 的硬件添加自己的外围设备和功能。

让我们来看看 ATtiny 系列。这是 ATtiny212/412 系列 MCU 的块图:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/05383bb6-99ec-434c-b603-09548ed2532e.png

这系列的 ATtiny MCU 可以运行高达 20 MHz,具有高达 4 KB 的 Flash ROM 和 256 字节的 SRAM,以及高达 128 字节的 EEPROM,全部都在一个 8 引脚的封装中。尽管尺寸小,但它有大量的外围设备,可以路由到任何支持的引脚:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/ad999a59-ffc7-4ab8-9089-7bf6be05bb22.png

与流行的 ATmega2560 和相关的 MCU 相比,ATtiny 系列 MCU 具有以下特性:

设备Flash(KB)EEPROM(KB)RAM(KB)通用 I/O 引脚16 位 PWM 通道UARTADC 通道
ATmega64064488612416
ATmega1280128488612416
ATmega12811284854628
ATmega2560256488612416
ATmega25612564854628

GPIO 引脚数量众多,因此块图相应地更加复杂,有更多的端口块用于 I/O 引脚:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/ce7750cb-a33d-49af-842d-a414db0e2b60.png

这里,所有的输入和输出箭头都表示一个引脚或引脚块,其中大部分是通用的。由于引脚数量众多,对于物理芯片来说,使用行内封装格式(DIP、SOIC 等)已不再实用。

对于 ATmega640、1280 和 2560,使用了 100 引脚 TQFP 封装,这里显示了每个引脚的功能,如其数据表中所示:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/7d0a8e35-d4cb-4e8b-825f-247338630835.png

ATxmega 系列与 ATmega 非常相似,具有相似的引脚布局,主要通过架构变化和优化、更多的 ROM 和 RAM 以及外围选项来区分自己。

选择 ATtiny、ATmega 或 ATxmega MCU 首先取决于您对项目的要求,特别是所需的输入和输出、外围设备的类型(串行、SPI、I2C、CAN 等)以及运行此代码所需的代码和 RAM 的大小。

M68k 和 Z80 基于

Zilog Z80 8 位处理器是与 Intel 8080 兼容的处理器,与其他微处理器在 1980 年代竞争,为家用计算机和游戏系统提供动力,包括任天堂 Game Boy、世嘉 Master System、Sinclair ZX80/ZX81/Spectrum、MSX 和 Tandy TRS-80。

Zilog 于 1994 年推出了基于 Z80 微处理器的 MCU(Z380),并在多年后进行了各种更新,包括 Z8、eZ80 等。Z80 克隆机也很常见。

另一个流行的 1980 年代微处理器是 Motorola 68k(或 68000)。它的 ALU 和外部数据总线为 16 位,但寄存器和内部数据总线为 32 位。在 1979 年推出后,其架构至今仍在使用,Freescale Semiconductor(现在是 NXP)生产了许多 68k 微处理器。

Motorola 推出了许多基于 68k 架构的 MCU,包括 1989 年的 MC68320 通信控制器。当前基于 68k 的 MCU 设计包括 ColdFire,这是一个完全的 32 位设计。

ARM Cortex-M

一种非常常见的 32 位 MCU 是 ARM Cortex-M 系列。它包括 M0、M0+、M1、M3、M4、M7、M23 和 M33,其中一些具有浮点单元FPU)选项,以提高浮点性能。

它们不仅用作独立的 MCU,而且通常集成到片上系统SoC)设备中,以提供特定功能,例如触摸屏、传感器或电源管理功能。由于 Arm Holdings 自己不制造任何 MCU,许多第三方制造商已经获得了许可,有时会对设计进行自己的修改和改进。

以下是这些 MCU 的简要概述:

核心宣布架构指令集
M02009Armv6-MThumb-1,部分 Thumb-2。
M0+2012Armv6-MThumb-1,部分 Thumb-2。
M12007Armv6-MThumb-1,部分 Thumb-2。
M32004Armv7-MThumb-1,Thumb-2。
M42010Armv7-MThumb1,Thumb-2,可选 FPU。
M72014Armv7E-MThumb-1,Thumb-2,可选 FPU。
M232016Armv8-MThumb-1,部分 Thumb-2。
M332016Armv8-MThumb 1,Thumb-2,可选 FPU。

Thumb 指令集是紧凑的 16 位长度指令,非常适合嵌入式、资源受限的系统。其他 ARM 微处理器系列也可以支持这个 Thumb 指令集,除了 32 位指令集。

H8(SuperH)

H8 系列 MCU 通常用于 8 位、16 位和 32 位变体。最初由日立在 1990 年代初创建,直到几年前,瑞萨科技仍在开发新设计,尽管后者建议新设计使用 RX(32 位)或 RL78(16 位)系列。 H8 MCU 的一个显着用途是在使用 H8/300 MCU 的乐高 Mindstorms RCX 控制器中。

ESP8266/ESP32

ESP 系列是由 Espressif Systems 生产的 32 位 MCU,具有集成的 Wi-Fi(两者)和蓝牙(ESP32)功能。

ESP8266 首次出现在 2014 年,当时由第三方制造商 Ai-Thinker 以模块(ESP-01)的形式销售,可以由另一个 MCU 或基于微处理器的系统使用以提供 Wi-Fi 功能。 ESP-01 模块包含了用于此目的的固件,允许使用 Hayes 风格的调制解调器命令来寻址模块。

其系统规格如下:

  • Tensilica Xtensa Diamond Standard L106 微处理器(32 位)

  • 80-160 MHz 的 CPU 速度

  • 少于 50 KB 的 RAM 可用于用户应用程序(加载了 Wi-Fi 堆栈)

  • 外部 SPI ROM(512 KB 至 16 MB)

  • Wi-Fi 支持 802.11 b/g/n

由于发现 ESP-01 模块上的 32 位 MCU 能够完成比分配给它的简单调制解调器任务更多的任务,因此很快就开始用于更通用的任务,包括一系列升级的 ESP8266 模块(带有集成的 EEPROM 芯片)以及分线板。其中,NodeMCU 风格的板变得非常受欢迎,尽管许多其他第三方制造商也制造了自己的分线板,提供不同的外形和功能。

ESP8266EX 的基本框图如下:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/f0bed65c-4e5c-4765-9d54-34ed7cdd3ee6.png

在 ESP8266 取得巨大成功之后,Espressif Systems 开发了 ESP32,其中使用了升级的双核 CPU 等其他更改。其框图如下:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/56fe4fde-18fb-4f1c-b82f-4f07034f5fc3.png

其规格如下:

  • Xtensa 32 位 LX6(双核)微处理器

  • 160-240 MHz 的 CPU 速度

  • 520 KB 的 SRAM

  • Wi-Fi 支持 802.11 b/g/n

  • 蓝牙 v4.2 和 BLE(低功耗)

ESP8266 和 ESP32 通常作为完整的模块出售,其中包括 MCU、外部 ROM 模块和 Wi-Fi 天线,可以集成到板上或者提供外部天线选项:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/e66a9c6a-5202-4624-ac38-6a6d2b8273ca.png

金属屏蔽罩可以保护板子免受电磁干扰的影响,有利于其 Wi-Fi(以及 ESP32 的蓝牙)收发器,但整个设计与固定天线和几何形状对于 FCC 认证和后续作为认可模块的使用是必需的。连接具有更高增益的外部天线可能会违反当地法规。它附带的 FCC ID 对于获得包含这种模块的产品的商业化认可至关重要。

其他

除了之前列出的 MCU 之外,还有许多制造商提供不同架构的广泛范围的 MCU。一些,例如 Parallax 的 Propeller MCU 具有多核架构,相当独特,而大多数只是实现通常的单核 CPU 架构,具有许多外围设备、RAM 和内部或外部 ROM。

除了物理芯片,Altera(现在是英特尔)、Lattice Semiconductor 和 Xilinx 提供所谓的软核,这些 MCU 旨在在 FPGA 芯片上运行,可以作为独立组件或作为 FPGA 上更大设计的一部分。这些也可以被 C/C++编译器所针对。

挑战

MCU 的主要开发挑战在于相对有限的资源。特别是对于小型、低引脚数的 MCU,你必须清楚一个特定代码需要多少资源(CPU 周期、RAM 和 ROM),以及是否实际上可以添加特定功能。

这也意味着为特定项目选择合适的 MCU 既需要技术知识,也需要经验。前者是为了选择适合任务的 MCU;后者对于最佳 MCU 非常有帮助,并有助于缩短选择所需的时间。

片上系统/单板计算机

片上系统SoCs)与 MCUs 类似,但它们通过一定程度的集成来区别于那些类型的嵌入式系统,同时仍需要一些外部组件来运行。它们通常作为单板计算机(SBC)的一部分,包括 PC/104 标准,以及最近的形式因素,如树莓派和衍生板:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/4dae667e-c4de-480b-9abb-3fda933f6019.png

此图表来自xdevs.com/article/rpi3_oc/。它清楚地显示了单板计算机(在本例中是树莓派 3)的布局。BCM2837 是基于 ARM 的 SoC,提供 CPU 核心和基本外围设备(大部分都分布在标题部分)。所有的 RAM 都在外部模块中,以及以太网和 Wi-Fi 外围设备。ROM 以 SD(Flash)卡的形式提供,同时也提供存储。

大多数 SoC 都是基于 ARM(Cortex-A 系列),尽管 MIPS 也很常见。单板计算机在工业环境中常被使用。

其他实例是大量生产的板,比如智能手机的板,它们没有预定义的形式因素,但仍然遵循相同的模式,具有 SoC 和外部 RAM、ROM 和存储,以及各种外围设备。这与上一节的 MCUs 形成对比,后者除了少数需要外部 ROM 外,通常都能够独立运行。

挑战

与 MCUs 相比,SoCs 的开发挑战往往要少得多。其中一些是在同一级别,并且具有一个接口,甚至可以直接在设备上进行开发,甚至在设备上进行编译循环,而无需在 PC 上进行交叉编译并复制二进制文件。这也得益于运行完整的操作系统,而不是为裸机开发。

显而易见的缺点是,随着功能的增加,复杂性也增加,导致的问题也增多,比如处理用户帐户、设置权限、管理设备驱动等等。

摘要

在本章中,我们深入了解了嵌入式系统的构成。我们学会了如何区分各种类型的嵌入式系统,以及如何确定为项目选择合适的 MCU 或 SoC 的基础知识。

在本章之后,读者应该能够轻松阅读 MCU 和 SoC 的数据表,解释两者之间的区别,并确定对于特定项目需要什么。

下一章将探讨为什么 C++是嵌入式系统编程的高度适合选择。

第二章:C++作为嵌入式语言

在资源受限的嵌入式系统上进行开发时,通常仅考虑 C 和 ASM 作为可行选择,并伴随着这样的想法:C++的占用空间比 C 大,或者增加了相当多的复杂性。在本章中,我们将详细讨论所有这些问题,并考虑 C++作为嵌入式编程语言的优点:

  • C++相对于 C

  • C++作为多范式语言的优势

  • 与现有 C 和 ASM 的兼容性

  • C++11、C++14 和 C++17 的变化

C++相对于 C

C 和 C++的谱系都可以追溯到 ALGOL 编程语言,该语言于 1958 年推出第一个版本(ALGOL 58),随后在 1960 年和 1968 年进行了更新。ALGOL 引入了命令式编程的概念——一种编程风格,其中语句明确告诉计算机如何对数据进行更改以输出和控制流。

从命令式编程中自然而然地出现的一种范式是使用过程。我们将从一个示例开始,介绍这个术语。过程与子例程和函数是同义词。它们标识了一组语句,并使它们自包含,这样就限制了这些语句的范围,使其仅限于它们所包含的部分,从而创建了层次结构,并因此将这些过程引入为新的、更抽象的语句。这种过程式编程风格的大量使用与所谓的结构化编程并存,结构化编程还包括循环和分支控制结构。

随着时间的推移,结构化和模块化编程风格被引入为改进应用程序代码的开发、质量和可维护性的技术。C 语言是一种命令式、结构化的编程语言,因为它使用了语句、控制结构和函数。

例如,C 中的标准 Hello World 示例:

#include <stdio.h> 
int main(void) 
{ 
    printf("hello, world"); 
    return 0; 
} 

任何 C(和 C++)应用程序的入口点是main()函数(过程)。在这个函数的第一条语句行中,我们调用另一个过程(printf()),它包含自己的语句,并可能调用其他语句块,以额外的函数形式。

通过实现一个main()逻辑块(main()函数),我们已经使用了过程式编程,根据需要调用它。虽然main()函数只会被调用一次,但过程式风格在printf()语句中再次出现,它在应用程序的其他地方调用语句,而无需显式复制它们。应用过程式编程使得维护生成的代码变得更加容易,并创建可以在多个应用程序中使用的代码库,同时只维护一个代码库。

1979 年,Bjarne Stroustrup 开始了C with Classes的工作,他在其中采用了 C 的现有编程范式,并从其他语言中添加了元素,特别是 Simula(面向对象编程:命令式和结构化)和 ML(模板形式的泛型编程)。它还提供了Basic Combined Programming LanguageBCPL)的速度,而不限制开发人员的低级关注。

这种结果是多范式语言在 1983 年更名为C++,同时增加了 C 中没有的其他特性,包括运算符和函数重载、虚函数、引用,并开始为这种 C++语言开发独立的编译器。

C++的基本目标一直是为现实世界的问题提供实际解决方案。此外,C++一直意图成为更好的 C,因此得名。 Stroustrup 本人在《Evolving C++ 1991-2006》中定义了一些规则,包括以下规则,这些规则至今仍驱动着 C++的发展:

  • C++的发展必须受到真实问题的驱动

  • 每个特性必须有一个相当明显的实现

  • C++是一种语言,而不是一个完整的系统

  • 不要试图强迫人们使用特定的编程风格

  • 不会有静态类型系统的隐式违规。

  • 为用户定义的类型提供与内置类型一样好的支持

  • 不留下 C++以下的低级语言(除了汇编语言)

  • 不使用的东西就不需要付费(零开销规则)

  • 如果有疑问,提供手动控制的手段

相对于 C 语言的差异显然不仅仅是面向对象编程。尽管人们仍然认为 C++只是 C 的一组扩展,但它长期以来一直是自己的语言,增加了严格的类型系统(与当时的 C 的弱类型系统相比),更强大的编程范式和 C 中找不到的特性。因此,它与 C 的兼容性更多地可以被看作是巧合,C 恰好是在正确的时间用作基础语言。

当时 Simula 的问题在于它对于一般用途来说太慢了,而 BCPL 则太低级。C 语言在当时是一个相对较新的语言,它在功能和性能之间提供了合适的平衡。

C++作为嵌入式语言

大约在 1983 年,当 C++刚刚被构想出来并得到了名字时,面向一般用户以及企业的流行个人计算机系统的规格如下表所列:

系统CPU时钟速度(MHz)RAM(KB)ROM(KB)存储(KB)
BBC Micro6502(B+ 6512A)216-12832-128最大 1,280(ADFS 软盘)最大 20 MB(硬盘)
MSXZilog Z803.588-12832720(软盘)
Commodore 646510~164201,000(磁带)170(软盘)
Sinclair ZX81Zilog Z803.581815(插卡)
IBM PCIntel 80804.7716-2568360(软盘)

现在将这些计算机系统与最近的 8 位微控制器MCU)AVR ATMega 2560 的规格进行比较:

  • 16 MHz 时钟速度

  • 8 KB RAM

  • 256 KB ROM(程序)

  • 4 KB ROM(数据)

ATMega 2560 于 2005 年推出,是当今可用的更强大的 8 位 MCU 之一。它的功能与 1980 年代的计算机系统相比有了很大的提升,而且 MCU 不依赖于任何外部存储器组件。

如今,由于改进的硅 IC 制造工艺,MCU 的核心时钟速度显著更快,这也提供了更小的芯片尺寸、更高的吞吐量,因此成本更低,而且 1980 年代的架构通常需要 2 到 5 个时钟周期来检索、解码、执行指令并存储结果,而 AVR 的单周期执行性能则不同。

当前 MCU(静态)RAM 的限制主要是由成本和功耗约束造成的,但对于大多数 MCU 来说,可以很容易地通过使用外部 RAM 芯片以及添加低成本的基于闪存的或其他大容量存储设备来规避这些限制。

Commodore 64(C64)这样的系统通常是用 C 语言编程的,除了内置的 BASIC 解释器(内置 ROM 中)。Commodore 64 的一个著名的 C 开发环境是 Spinnaker 发布的 Power C:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/68f27296-883d-413d-9ab1-22a01b44e154.png

Power C 是面向 C 开发人员的一种生产力软件品牌。它放在一张单面、双面软盘上,允许您在编辑器中编写 C 代码,然后使用包含的编译器、链接器、头文件和库来编译生成系统的可执行文件。

当时存在许多这样的编译器集合,针对各种系统,显示出了丰富的软件开发生态系统。其中,C++当然是一个新手。Stroustrup 的《C++程序设计语言》第一版是在 1985 年出版的,但最初并没有一个稳固的语言实现与之配套。

然而,对于 C++ 的商业支持开始迅速出现,主要的开发环境,如 Borland C++ 1.0 在 1987 年发布,并在 1991 年更新到 2.0。这些开发环境特别在 IBM PC 及其众多克隆机上得到使用,那里没有像 BASIC 这样的首选开发语言。

虽然 C++ 在 1985 年开始作为非官方标准,但直到 1989 年第二版 The C++ Programming Language 的发布作为权威作品,C++ 才达到了大约与 ISO/IEC 14882:1998(通常称为 C++98)首次标准化的功能水平相等。可以说,C++ 在 1990 年摩托罗拉 68040 和 1992 年英特尔 486DX 出现之前就已经有了显著的发展和采用,这将处理能力提升到了 20 MIPS 以上。

现在我们已经考虑了早期硬件规格和 C++ 与 C 以及当时旨在在相对有限的系统上使用的其他语言的发展,似乎可以认为 C++ 完全有能力在这样的硬件上运行,从而在现代微控制器上运行。然而,似乎有必要问问自从那时以来增加到 C++ 中的复杂性在多大程度上影响了内存或计算性能要求。

C++ 语言特性

我们之前看过数据和系统状态的显式变化性质,这定义了命令式编程与声明式编程的区别,声明式编程不是在循环中操作数据,而是将功能声明为将运算符映射到某些数据,从而阐明功能,而不是具体操作的顺序。但为什么编程语言必须必然是命令式和声明式范式之间的选择呢?

事实上,C++ 的主要区别特征之一是其多范式性质,同时使用命令式和声明式范式。通过将面向对象、泛型和函数式编程纳入 C++,除了 C 的过程式编程之外,似乎自然而然地会认为这一切都必须付出代价,无论是在 CPU 使用率方面还是在内存和/或 ROM 消耗方面。

然而,正如我们在本章前面学到的,C++ 语言特性最终是建立在 C 语言之上的,因此应该没有或几乎没有相对于在纯 C 中实现类似构造的开销。为了解决这个难题,并调查低开销假设的有效性,我们现在将详细研究一些 C++ 语言特性,以及它们最终是如何实现的,以及它们在二进制和内存大小方面的相应成本。

一些专门关注 C++ 作为低级嵌入式语言的例子是在得到 Rud Merriam 的 Code Craft 系列的许可后使用的,该系列已在 Hackaday 上发布:hackaday.io/project/8238-embedding-c

命名空间

命名空间是引入应用程序中的额外作用域级别的一种方式。正如我们在早期关于类的部分中看到的那样,这些是编译器级别的概念。

主要用途在于模块化代码,将其分成逻辑段,以便在类不是最明显的解决方案的情况下,或者在您想要明确将类排序到特定类别中使用命名空间的情况下。这样,您还可以避免类似命名的类、类型和枚举之间的名称和类型冲突。

强类型

类型信息对于测试对数据的正确访问和解释是必要的。C++ 中一个与 C 相关的重要特性是强类型系统的包含。这意味着编译器执行的许多类型检查比 C 允许的要严格得多,C 是一种弱类型语言。

当看这段合法的 C 代码时,这一点显而易见,当编译为 C++ 时会生成错误:

void* pointer; 
int* number = pointer; 

或者,它们也可以以以下方式编写:

int* number = malloc(sizeof(int) * 5); 

C++禁止隐式转换,要求将这些示例写成如下形式:

void* pointer; 
int* number = (int*) pointer; 

它们也可以以以下方式编写:

int* number = (int*) malloc(sizeof(int) * 5); 

由于我们明确指定了要转换的类型,我们可以放心,在编译时任何类型转换都会按我们的期望进行。

同样,如果我们试图从一个没有这个限定符的引用中赋值给一个带有const限定符的变量,编译器也会抱怨并抛出错误:

const int constNumber = 42; 
int number = &constNumber; // Error: invalid initialization of reference. 

为了解决这个问题,您需要显式地进行以下转换:

const int constNumber = 42; 
int number = const_cast<int&>(constNumber); 

像这样进行显式转换是完全可能和有效的。但是,当使用这个引用来修改被假定为常量值的内容时,可能会在以后引起巨大的问题和头痛。然而,当你发现自己编写类似上面的代码时,可以合理地假定你已经意识到了这些影响。

这种强制使用显式类型的做法有一个重要的好处,就是使得静态分析比在弱类型语言中更有用和有效。这反过来又有利于运行时安全性,因为任何转换和赋值很可能是安全的,没有意外的副作用。

由于类型系统主要是编译器的特性,而不是任何一种运行时代码,(可选的)运行时类型信息是一个例外。在 C++中,具有强类型的类型系统的开销只在编译时才会被注意到,因为对每个变量赋值、操作和转换都必须执行更严格的检查。

类型转换

每当将一个值赋给一个兼容的变量时,就会发生类型转换,这个变量的类型并不完全相同。每当存在转换规则时,这种转换可以隐式进行,否则可以向编译器提供一个显式提示(转换)来调用特定的规则,以解决模糊性。

C 只有隐式和显式类型转换,而 C++通过一些基于模板的函数进行了扩展,允许以各种方式转换常规类型和对象(类):

  • dynamic_cast <new_type>(表达式)

  • reinterpret_cast <new_type>(表达式)

  • static_cast <new_type>(表达式)

  • const_cast <new_type>(表达式)

在这里,dynamic_cast保证了结果对象是有效的,依赖于运行时类型信息RTTI)(请参见后面关于它的部分)。static_cast类似,但不验证结果对象。

接下来,reinterpret_cast可以将任何东西转换为任何东西,甚至是不相关的类。这种转换是否有意义留给开发人员决定,就像常规的显式转换一样。

最后,const_cast很有趣,因为它可以设置或移除一个值的const状态,当你只需要一个函数的非const版本时,这可能很有用。然而,这也绕过了类型安全系统,应该非常谨慎地使用。

面向对象编程OOP)自 Simula 以来就存在,Simula 以其缓慢的语言而闻名。这导致 Bjarne Stroustrup 基于快速高效的 C 编程语言来实现他的 OOP。

C++使用 C 风格的语言构造来实现对象。当我们看 C++代码及其对应的 C 代码时,这一点变得很明显。

当查看 C++类时,我们看到它的典型结构:

namespace had { 
using uint8_t = unsigned char; 
const uint8_t bufferSize = 16;  
    class RingBuffer { 
        uint8_t data[bufferSize]; 
        uint8_t newest_index; 
        uint8_t oldest_index;  
        public: 
        enum BufferStatus { 
            OK, EMPTY, FULL 
        };  
        RingBuffer();  
        BufferStatus bufferWrite(const uint8_t byte); 
        enum BufferStatus bufferRead(uint8_t& byte); 
    }; 
} 

这个类也在一个命名空间内(我们将在后面的部分更详细地看一下),一个unsigned char类型的重新定义,一个命名空间全局变量定义,最后是类定义本身,包括私有和公共部分。

这段 C++代码定义了许多不同的作用域,从命名空间开始,到类结束。类本身在其公共、受保护和私有访问级别方面增加了作用域。

同样的代码也可以在常规的 C 中实现:

typedef unsigned char uint8_t; 
enum BufferStatus {BUFFER_OK, BUFFER_EMPTY, BUFFER_FULL}; 
#define BUFFER_SIZE 16 
struct RingBuffer { 
   uint8_t data[BUFFER_SIZE]; 
   uint8_t newest_index; 
   uint8_t oldest_index; 
};  
void initBuffer(struct RingBuffer* buffer); 
enum BufferStatus bufferWrite(struct RingBuffer* buffer, uint8_t byte); 
enum BufferStatus bufferRead(struct RingBuffer* buffer, uint8_t *byte); 

using关键字类似于typedef,因此在这里有一个直接的映射。我们使用const代替#defineenum在 C 和 C++之间本质上是相同的,只是 C++的编译器在作为类型使用时不需要显式标记enum。当涉及到简化 C++代码时,对于结构体也是如此。

C++类本身在 C 中实现为包含类变量的struct。当创建类实例时,这实质上意味着初始化了这个struct的一个实例。然后,这个struct实例的指针在调用 C++类的函数时被传递。

这些基本示例向我们展示了,与基于 C 的代码相比,我们使用的任何 C++特性都没有运行时开销。命名空间、类访问级别(public、private 和 protected)等仅由编译器用于验证正在编译的代码。

C++代码的一个很好的特点是,尽管性能相同,但它需要更少的代码,同时还允许您定义严格的接口访问级别,并且在类被销毁时调用析构函数类方法,从而允许您自动清理分配的资源。

使用 C++类遵循以下模式:

had::RingBuffer r_buffer;  
int main() { 
    uint8_t tempCharStorage;     
    // Fill the buffer. 
    for (int i = 0; r_buffer.bufferWrite('A' + i) == 
    had::RingBuffer::OK; i++)    { 
        // 
    } 
    // Read the buffer. 
    while (r_buffer.bufferRead(tempCharStorage) == had::RingBuffer::OK) 
    { 
         // 
    } 
} 

这与 C 版本的比较如下:

struct RingBuffer buffer;  
int main() { 
    initBuffer(&buffer); 
    uint8_t tempCharStorage;  
    // Fill the buffer. 
    uint8_t i = 0; 
    for (; bufferWrite(&buffer, 'A' + i) == BUFFER_OK; i++) {          
        // 
    }  
    // Read the buffer. 
    while (bufferRead(&buffer, &tempCharStorage) == BUFFER_OK) { // 
    } 
} 

使用 C++类与使用 C 风格的方法并没有太大的不同。不需要为每个功能调用手动传递分配的struct实例,而是调用类方法,这可能是最大的区别。这个实例仍然以this指针的形式可用,指向类实例。

虽然 C++示例在RingBuffer类中使用了命名空间和嵌入枚举,但这些只是可选功能。人们仍然可以使用全局枚举,或者在命名空间的范围内,或者有许多层的命名空间。这在很大程度上取决于应用程序的要求。

至于使用类的成本,本节示例的版本已针对 Arduino UNO(ATMega328 MCU)和 Arduino Due(AT91SAM3X8E MCU)开发板进行了编译,给出了编译代码的以下文件大小:

UnoDue
CC++CC++
全局范围数据61465211,18411,196
主范围数据66466411,20011,200
四个实例63867611,22411,228

这些代码文件大小的优化设置为-O2

在这里,我们可以看到一旦编译,C++代码与 C 代码是相同的,除了在全局类实例的初始化上,由于增加的代码来执行这个初始化,Uno 的代码量为 38 字节。

由于这段代码只需要存在一个实例,这是一个我们只需要支付一次的固定成本:在第一行和最后一行,我们有一个和四个类实例或它们的等价物,然而 Uno 固件中只有额外的 38 字节。对于 Due 固件,我们可以看到类似的情况,尽管没有那么明显。这种差异可能受到一些其他设置或优化的影响。

这告诉我们有时我们不希望编译器为我们初始化一个类,但如果我们需要最后几个字节的 ROM 或 RAM,我们应该自己做。然而,大多数情况下这不会成为问题。

继承

除了允许您将代码组织成对象之外,类还允许通过多态性将类作为其他类的模板。在 C++中,我们可以将任意数量的类的属性合并到一个新的类中,赋予它自定义的属性和方法。

这是一种非常有效的创建用户定义类型UDTs)的方法,特别是当与运算符重载结合使用来使用常见运算符为 UDT 定义加法、减法等操作时。

C++中的继承遵循以下模式:

class B : public A { // Private members. public: // Additional public members. }; 

在这里,我们声明一个类B,它派生自类A。这使我们可以在类 B 的实例上使用类 A 中定义的任何公共方法,就好像它们一开始就是在后者中定义的一样。

所有这些似乎都很容易理解,即使在我们开始从多个基类派生的那一刻,事情可能会变得有点混乱。然而,通过适当的规划和设计,多态性可以成为一个非常强大的工具。

不幸的是,这些都没有回答使用多态性会给我们的代码增加多少额外开销的问题。我们之前看到,C++类本身在运行时不会增加任何开销,但通过从一个或多个基类派生,预期生成的代码将会变得复杂得多。

幸运的是,情况并非如此。与简单类一样,由此产生的派生类是基础结构的简单融合,这些基础结构构成了类的实现。继承过程本身以及随之而来的验证,主要是一个编译时问题,为开发人员带来了各种好处。

虚基类

有时,对于基类来说,为一个类方法提供实现并不太合理,但与此同时,我们希望强制任何派生类实现该方法。解决这个问题的答案是虚拟方法。

考虑以下类定义:

class A { 
public: 
   virtual bool methodA() = 0; 
   virtual bool methodB() = 0; 
}; 

如果我们尝试从这个类派生,我们必须实现这两个类方法,否则会得到编译器错误。由于基类中的两个方法都是虚拟的,整个基类被称为虚基类。这对于希望定义一个可以由一系列不同类实现的接口,同时保留只有一个用户定义类型来引用的便利性非常有用。

在内部,这些虚拟方法是使用vtables实现的,它是虚拟表的缩写。这是一个数据结构,对于每个虚拟方法,都包含一个指向该方法实现的内存地址(指针):

VirtualClass* → vtable_ptr → vtable[0]methodA() 

我们可以将这种间接级别对性能的影响与 C 风格代码和具有直接方法调用的类进行比较。 Code Craft 关于虚拟函数定时的文章(hackaday.com/2015/11/13/code-craft-embedding-c-timing-virtual-functions/)描述了这样一种方法,并得出了有趣的发现:

UnoDue
OsO2OsO2
C 函数调用10.410.23.73.6
C++直接调用10.410.33.83.8
C++虚拟调用11.110.93.93.8
多个 C 调用110.4106.339.435.5
C 函数指针调用105.7102.938.634.9
C++虚拟调用103.2100.439.535.2

这里列出的所有时间都以微秒为单位。

这个测试使用了与比较 C 代码和 C++类之间的编译输出大小相同的两个 Arduino 开发板。使用了两种不同的优化级别来比较这些编译器设置的影响:-Os 优化生成的二进制文件的大小(以字节为单位),而-O2设置优化速度,比-O1优化级别更为激进。

从这些定时中,我们可以确定虚拟方法引入的间接级别是可以测量的,尽管不是很显著,在 Arduino Uno 开发板的 ATMega328 上增加了整整 0.7 微秒,在更快的基于 ARM 的开发板上增加了约 0.1 微秒。

即使从绝对角度来看,虚拟类方法的使用也不会带来足够的性能损失,除非性能至关重要,这主要是在较慢的 MCU 上。 MCU 的 CPU 速度越快,使用它的影响就越不严重。

函数内联

在 C++中,内联关键字是对编译器的提示,让它知道我们希望每次调用以此关键字为前缀的函数时,都会得到该函数的实现,而不是将其复制到调用位置,从而跳过函数调用的开销。

这是一种编译时优化,每次对内联函数的不同调用只会将函数实现的大小添加到编译器输出中一次。

运行时类型信息

RTTI 的主要目的是允许使用安全的类型转换,就像使用dynamic_cast<>操作符一样。由于 RTTI 涉及为每个多态类存储额外信息,因此会有一定的开销。

这是一个运行时特性,正如名称所示,因此如果您不需要它提供的功能,可以禁用它。在一些嵌入式平台上禁用 RTTI 是常见做法,特别是在低资源平台上很少使用,比如 8 位 MCU。

异常处理

异常通常在桌面平台上使用,提供了一种为错误条件生成异常并在 try/catch 块中捕获和处理的方法。

虽然异常支持本身并不昂贵,但生成异常相对昂贵,需要大量的 CPU 时间和 RAM 来准备和处理异常。您还必须确保捕获每个异常,否则可能导致应用程序在没有明确原因的情况下终止。

异常与检查方法返回代码之间的区别是需要根据具体情况来决定的,也可能是个人偏好的问题。这需要一种完全不同的编程风格,可能并不适合每个人。

模板

人们经常认为 C++中的模板非常沉重,并且使用它们会带来严重的惩罚。这完全忽略了模板的本质,即模板只是用作从单个模板自动生成几乎相同代码的一种简便方法 - 因此得名。

这实际上意味着对于我们定义的任何函数或类模板,每次引用模板时,编译器都会生成模板的内联实现。

这是我们在 C++标准模板库(STL)中经常看到的一种模式,正如其名称所示,它大量使用模板。例如,像一个简单的 map 这样的数据结构:

std::map<std::string, int> myMap; 

这里发生的是编译器会获取std::map的单一模板,以及我们在尖括号内提供的模板参数,填充模板并在其位置写入内联实现。

实际上,我们得到的是与手动编写整个数据结构实现相同的实现,只是针对这两种类型。由于替代方案将是为每种可想象的内置类型和额外的用户定义类型手动编写每个实现,使用通用模板可以节省大量时间,而不会牺牲性能。

标准模板库

C++的标准库(STL)包含了一个全面且不断增长的函数、类等集合,允许执行常见任务而无需依赖外部库。STL 的 string 类非常受欢迎,可以安全地处理字符串,而无需处理空终止符或类似的内容。

大多数嵌入式平台支持 STL 的全部或至少是重要部分,除了可用 RAM 等方面的限制,阻止了完整哈希表和其他复杂数据结构的实现。许多嵌入式 STL 实现都包含针对目标平台的优化,最小化 RAM 和 CPU 的使用。

可维护性

在前面的章节中,我们看到了 C++提供的许多特性,以及在资源有限的平台上使用它们的可行性。使用 C++的一个重要优势是通过使用模板来减小代码大小,以及使用类、命名空间等来组织和模块化代码库。

通过在代码中努力实现更模块化的方法,并在模块之间建立清晰的接口,使得在项目之间重用代码变得更加可行。这也通过使特定代码部分的功能更清晰,并为单元测试和集成测试提供明确的目标,简化了代码的维护。

总结

在本章中,我们解决了为什么要在嵌入式开发中使用 C++的重要问题。我们看到,由于 C++的开发方式,它非常适用于资源受限的平台,同时提供了许多对项目管理和组织至关重要的特性。

读者现在应该能够描述 C++的主要特性,并提供每个特性的具体示例。在编写 C++代码时,读者将清楚地了解特定语言特性的成本,能够理由为什么一个代码部分的实现优于另一个实现,基于空间和 RAM 约束。

在下一章中,我们将介绍基于单板计算机(SBCs)等系统的嵌入式 Linux 开发过程。

第三章:开发嵌入式 Linux 和类似系统

现在,基于 SoC 的小型系统随处可见,从智能手机、视频游戏机、智能电视机,到汽车和飞机上的信息娱乐系统。依赖这些系统的消费类设备非常普遍。

除了消费类设备,它们也作为工业和建筑级控制系统的一部分,用于监控设备、响应输入,并执行整个传感器和执行器网络的定时任务。与 MCU 相比,SoC 的资源限制没有那么严格,通常运行完整的操作系统(OS),如基于 Linux 的操作系统、VxWorks 或 QNX。

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

  • 如何为基于操作系统的嵌入式系统开发驱动程序

  • 集成外围设备的方法

  • 如何处理和实现实时性能要求

  • 识别和处理资源限制

嵌入式操作系统

在为嵌入式系统编写应用程序时,通常会使用操作系统,这是一个不切实际的建议。操作系统为应用程序提供了许多抽象硬件的 API,以及使用这些硬件实现的功能,如网络通信或视频输出。

这里的权衡在于便利性和代码大小以及复杂性。

而裸机实现理想上只实现它需要的功能,操作系统则带有任务调度器,以及应用程序可能永远不需要的功能。因此,重要的是要知道何时使用操作系统而不是直接为硬件开发,了解随之而来的复杂性。

使用操作系统的好处在于,如果必须能够同时运行不同的任务(多任务或多线程)。从头开始实现自己的调度器通常不值得。通过使用操作系统,可以更轻松地运行非固定数量的应用程序,并且可以随意删除和添加它们。

最后,当您可以访问操作系统和易于访问的驱动程序以及与其相关的 API 时,高级图形输出、图形加速(如 OpenGL)、触摸屏和高级网络功能(例如 SSH 和加密)的实现会变得更加容易。

常用的嵌入式操作系统包括以下内容:

名称供应商许可证平台详情
Raspbian社区为基础主要 GPL,类似ARM(树莓派)基于 Debian Linux 的操作系统
Armbian社区为基础GPLv2ARM(各种开发板)基于 Debian Linux 的操作系统
AndroidGoogleGPLv2,ApacheARM,x86,x86_64基于 Linux
VxWorksWind River(英特尔)专有ARM,x86,MIPS,PowerPC,SH-4RTOS,单片内核
QNXBlackBerry专有ARMv7,ARMv8,x86RTOS,微内核
Windows IoT微软专有ARM,x86以前称为 Windows 嵌入式
NetBSDNetBSD 基金会2 条款 BSDARM,68k,MIPS,PowerPC,SPARC,RISC-V,x86 等最具可移植性的基于 BSD 的操作系统

所有这些操作系统的共同之处在于它们处理基本功能,如内存和任务管理,同时使用编程接口(API)提供对硬件和操作系统功能的访问。

在本章中,我们将专门关注基于 SoC 和 SBC 的系统,这反映在前述操作系统列表中。这些操作系统中的每一个都旨在用于至少具有几兆字节 RAM 和几兆字节到几千兆字节存储的系统。

如果目标 SoC 或 SBC 尚未被现有的 Linux 发行版所针对,或者希望大量定制系统,可以使用 Yocto Project 的工具(www.yoctoproject.org/)。

基于 Linux 的嵌入式操作系统非常普遍,Android 就是一个著名的例子。它主要用于智能手机、平板电脑和类似设备,这些设备严重依赖图形用户交互,同时依赖于 Android 应用程序基础设施和相关 API。由于这种专业化水平,它不适合其他用例。

Raspbian 基于非常常见的 Debian Linux 发行版,主要针对树莓派系列的 SBC。Armbian 类似,但覆盖了更广泛的 SBC 范围。这两者都是社区努力的成果。这类似于 Debian 项目,也可以直接用于嵌入式系统。Raspbian、Armbian 和其他类似项目的主要优势在于它们提供了与目标 SBC 一起使用的现成镜像。

与基于 Linux 的操作系统一样,NetBSD 的优势在于它是开源的,这意味着您可以完全访问源代码,并且可以对操作系统的任何方面进行大量定制,包括对自定义硬件的支持。NetBSD 和类似的基于 BSD 的操作系统的一个重大优势是,操作系统是从单一代码库构建的,并由一组开发人员管理。这通常简化了嵌入式项目的开发和维护。

BSD 许可证(三或两条款)对商业项目有重大好处,因为该许可证只要求提供归属,而不要求制造商在请求时提供操作系统的全部源代码。如果对源代码进行某些修改,添加希望保持闭源的代码模块,这可能非常相关。

例如,最近的 PlayStation 游戏机使用了 FreeBSD 的修改版本,使得索尼能够对硬件和游戏机的使用进行大幅优化,而无需与操作系统的其余部分一起发布此代码。

还存在专有选项,例如来自黑莓(QNX)和微软(Windows IoT,以前是 Windows 嵌入式,以前是 Windows CE)的产品。这些产品通常需要按设备收取许可费,并要求制造商提供任何定制的帮助。

实时操作系统

实时操作系统(RTOS)的基本要求是能够保证任务在一定时间范围内被执行和完成。这使得可以将它们用于实时应用,其中同一任务批次的执行时间变化(抖动)是不可接受的。

由此,我们可以得出硬实时和软实时操作系统之间的基本区别:低抖动的操作系统是硬实时的,因为它可以保证给定任务总是以几乎相同的延迟执行。有更高抖动的操作系统通常但并非总是能以相同的延迟执行任务。

在这两个类别中,我们可以再次区分事件驱动和时间共享调度器。前者根据优先级切换任务(优先级调度),而后者使用定时器定期切换任务。哪种设计更好取决于系统的使用目的。

时间共享比事件驱动的调度器更重要的一点是,它不仅给予了低优先级任务更多的 CPU 时间,还使多任务系统看起来更加流畅。

一般来说,只有在项目要求必须能够保证输入在严格定义的时间窗口内处理时,才会使用实时操作系统。对于机器人技术和工业应用等应用,确保每次都在完全相同的时间范围内执行动作可能至关重要,否则可能导致生产线中断或产品质量下降。

在本章稍后将要讨论的示例项目中,我们不使用实时操作系统,而是使用常规基于 Linux 的操作系统,因为没有硬实时要求。使用实时操作系统将增加不必要的负担,可能增加复杂性和成本。

将 RTOS 视为尽可能接近直接为硬件(裸机)编程的实时性质,而无需放弃使用完整 OS 的所有便利之一。

自定义外围设备和驱动程序

外围设备被定义为向计算机系统添加 I/O 或其他功能的辅助设备。这可以是从 I2C、SPI 或 SD 卡控制器到音频或图形设备的任何东西。其中大多数是 SoC 的一部分,其他通过 SoC 向外部世界暴露的接口添加。外部外围设备的例子包括 RAM(通过 RAM 控制器)和实时时钟RTC)。

在使用廉价的 SBC 时,例如树莓派、橙子派和无数类似系统时,可能会遇到的一个问题是它们通常缺乏 RTC,这意味着当它们关闭电源时,它们不再跟踪时间。通常的想法是这些板子无论如何都会连接到互联网,因此 OS 可以使用在线时间服务(网络时间协议,或NTP)来同步系统时间,从而节省板子空间。

在没有互联网连接的情况下,或者在线时间同步之前的延迟是不可接受的情况下,或者其他无数原因之一,可能会使用 SBC。在这种情况下,可能需要向板上添加 RTC 外围设备并配置 OS 以利用它。

添加 RTC

人们可以以低廉的价格获得 RTC 模块,通常基于 DS1307 芯片。这是一个 5V 模块,通过 I2C 总线连接到 SBC(或 MCU):

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/5881b3bf-15ed-4189-ab38-619ca08aa8c8.png

这张图片是一个基于 DS1307 的小型 RTC 模块。正如人们可以看到的,它有 RTC 芯片、晶体和 MCU。最后一个用于与主机系统通信,无论它是 SoC 还是 MCU-based board。所有人需要的是能够提供 RTC 模块操作所需的所需电压(和电流)的能力,以及一个 I2C 总线。

将 RTC 模块连接到 SBC 板后,下一个目标是让 OS 也使用它。为此,我们必须确保加载 I2C 内核模块,以便我们可以使用 I2C 设备。

针对 SBC 的 Linux 发行版,如 Raspbian 和 Armbian,通常带有多个 RTC 模块的驱动程序。这使我们可以相对快速地设置 RTC 模块并将其与 OS 集成。对于我们之前看过的模块,我们需要 I2C 和 DS1307 内核模块。对于第一代树莓派 SBC 上的 Raspbian OS,这些模块将被称为i2c-dev2cbcm2708rtc-ds1307

首先,您必须启用这些模块,以便它们在系统启动时加载。对于 Raspbian Linux,可以编辑/etc/modules文件来实现这一点,以及其他为该平台提供的配置工具。重新启动后,我们应该能够使用 I2C 扫描工具在 I2C 总线上检测 RTC 设备。

有了 RTC 设备工作,我们可以在 Raspbian 上删除 fake-hwclock 软件包。这是一个简单的模块,用于伪造 RTC,但仅在系统关闭之前将当前时间存储在文件中,以便在下次启动时,由于从存储的日期和时间恢复,文件系统的日期和类似内容将保持一致,而不会创建任何新文件突然变得更旧

相反,我们将使用 hwclock 实用程序,它将使用任何真实的 RTC 来同步系统时间。这需要修改 OS 启动的方式,将 RTC 模块的位置作为引导参数传递,格式如下:

rtc.i2c=ds1307,1,0x68

这将在 I2C 总线上初始化一个 RTC(/dev/rtc0)设备,地址为 0x68。

自定义驱动程序

驱动程序(内核模块)的确切格式和集成与 OS 内核的方式因每个 OS 而异,因此在这里不可能完全涵盖。然而,我们将看一下我们之前使用的 RTC 模块的 Linux 驱动程序是如何实现的。

此外,我们将在本章后面看看如何从用户空间使用 I2C 外设,在俱乐部房间监控示例中。使用基于用户空间的驱动程序(库)通常是将其实现为内核模块的良好替代方案。

RTC 功能已集成到 Linux 内核中,其代码位于/drivers/rtc文件夹中(在 GitHub 上可以找到,网址为github.com/torvalds/linux/tree/master/drivers/rtc)。

rtc-ds1307.c文件包含我们需要读取和设置 RTC 的两个函数:ds1307_get_time()ds1307_set_time()。这些函数的基本功能与我们将在本章后面的俱乐部房间监控示例中使用的功能非常相似,我们只是将 I2C 设备支持集成到我们的应用程序中。

从用户空间与 I2C、SPI 和其他外设通信的一个主要优势是,我们不受 OS 内核支持的编译环境的限制。以 Linux 内核为例,它主要用 C 语言编写,还有一些汇编语言。其 API 是 C 风格的 API,因此我们必须使用明显的 C 风格编码方法来编写我们的内核模块。

显然,这将抵消大部分优势,更不用说尝试一开始就用 C++编写这些模块的意义了。当将我们的模块代码移至用户空间并将其用作应用程序的一部分或共享库时,我们就没有这样的限制,可以自由使用任何和所有 C++概念和功能。

为了完整起见,Linux 内核模块的基本模板如下:

#include <linux/module.h>       // Needed by all modules 
#include <linux/kernel.h>       // Needed for KERN_INFO 

int init_module() { 
        printk(KERN_INFO "Hello world.n"); 

        return 0; 
} 

void cleanup_module() { 
        printk(KERN_INFO "Goodbye world.n"); 
} 

这是一个必需的 Hello World 示例,以 C++风格编写。

在考虑基于内核和用户空间的驱动程序模块时的最后一个考虑因素是上下文切换。从效率的角度来看,内核模块更快,延迟更低,因为 CPU 不必反复从用户空间切换到内核空间上下文,然后再次与设备通信,并将消息从设备传递回与其通信的代码。

对于高带宽设备(如存储和捕获),这可能会导致系统顺畅运行与严重滞后和难以执行其任务之间的差异。

然而,在考虑本章中的俱乐部房间监控示例及其偶尔使用 I2C 设备时,很明显,内核模块将是严重过度的,没有任何实质性的好处。

资源限制

尽管 SBC 和 SoC 往往非常强大,但它们仍无法与现代台式机系统或服务器进行直接比较。它们在 RAM、存储大小和缺乏扩展选项方面有明显的限制。

对于(永久安装的)RAM 容量差异很大的情况,您必须在考虑相对缓慢的 CPU 性能之前,考虑系统上希望运行的应用程序的内存需求。

由于 SBC 通常没有或只有少量具有高耐久率的存储空间(意味着可以经常写入而不受限制的写入周期),它们通常不具有交换空间,并将所有内容保存在可用的 RAM 中。没有交换的支持,任何内存泄漏和过度内存使用将迅速导致系统无法正常工作或不断重启。

尽管多年来 SBC 的 CPU 性能已经显著提高,但通常仍建议使用交叉编译器在快速的台式机系统或服务器上为 SBC 生成代码。

更多关于开发问题和解决方案的内容将在第六章 测试基于 OS 的应用程序和附录 最佳实践中进行讨论。

示例 - 俱乐部房间监控

在这一部分,我们将看到一个基于 SBC 的实际实现,为俱乐部房间执行以下功能:

  • 监控俱乐部门锁的状态

  • 监控俱乐部状态开关

  • 通过 MQTT 发送状态更改通知

  • 为当前俱乐部状态提供 REST API

  • 控制状态灯

  • 控制俱乐部房间的电源

这里的基本用例是,我们有一个俱乐部房间,我们希望能够监控其门锁的状态,并在俱乐部内部有一个开关来调节俱乐部非永久电源插座的通电状态。将俱乐部状态开关调至on将为这些插座供电。我们还希望通过 MQTT 发送通知,以便俱乐部房间或其他地方的其他设备可以更新它们的状态。

MQTT 是基于 TCP/IP 的简单的二进制发布/订阅协议。它提供了一种轻量级的通信协议,适用于资源受限的应用程序,如传感器网络。每个 MQTT 客户端与中央服务器通信:MQTT 代理。

硬件

clubstatus系统的框图如下所示:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/bfb888d8-bf9f-4dab-9366-d473d1c7dd7f.png

对于 SBC 平台,我们使用树莓派,要么是树莓派 B+型号,要么是 B 系列的新成员,比如树莓派 3 型 B:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/97073e5a-4311-4381-bf99-025dc76193c3.png

我们在 SBC 系统中寻找的主要功能是以太网连接,当然还有与树莓派兼容的通用输入/输出GPIO)引脚。

使用这块板子时,我们将在μSD 卡上安装标准的 Raspbian 操作系统。除此之外不需要任何特殊配置。选择 B+型号或类似型号的主要原因是它们具有标准的安装孔图案。

继电器

为了控制房间中的状态灯和非永久电源插座,我们使用了一些继电器,这种情况下是四个继电器:

继电器功能
0非永久插座的电源状态
1绿色状态灯
2黄色状态灯
3红色状态灯

这里的想法是,电源状态继电器连接到一个开关,控制着俱乐部状态关闭时未供电的插座的主电源。状态灯指示当前的俱乐部状态。接下来的部分将提供这个概念的实现细节。

为了简化设计,我们将使用一个包含四个继电器的现成继电器板,由 NXP PCAL9535A I/O 端口芯片(GPIO 扩展器)驱动,连接到树莓派 SBC 的 I2C 总线上:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/5853c932-146d-46f6-abc9-8537243bc361.png

这块特定的板子是 Seeed Studio Raspberry Pi 继电器板 v1.0:wiki.seeedstudio.com/Raspberry_Pi_Relay_Board_v1.0/。它提供了我们需要的四个继电器,允许我们切换高达 30V 直流或 250V 交流的灯和开关。这使得我们可以连接几乎任何类型的照明和进一步的继电器和开关。

与 SBC 的连接是通过将继电器板叠放在 SBC 的 GPIO 引脚上实现的,这使我们可以在继电器板的顶部添加更多的板子。这使我们可以向系统添加去抖动功能,如接线计划图所示。

去抖动

去抖动板需要去抖动开关信号,并为树莓派提供电源。去抖动机械开关的理论和原因是,这些开关提供的信号不干净,意味着它们不会立即从开到闭。它们会在短暂地闭合(接触)之后,弹簧金属触点的弹性会导致它们再次打开,并在这两种状态之间快速移动,最终定格在最终位置,正如我们可以从连接到简单开关的示波器的下图中看到的:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/279f998d-afcf-427d-aeae-3b6f2fcc04e6.png

这种特性的结果是,到达 SBC 的 GPIO 引脚的信号会在几毫秒内迅速变化(或更糟)。基于这些开关输入变化进行任何操作都会导致巨大问题,因为人们很难区分所需的开关变化和在这种变化过程中开关触点的快速跳动。

消除抖动可以通过硬件或软件来实现。后者的解决方案涉及在开关状态首次改变时启动计时器。这种方法的假设是,在一定时间(以毫秒为单位)过去后,开关处于稳定状态,可以安全地读取。这种方法的缺点在于它给系统增加了额外的负担,占用了一个或多个计时器,或者暂停了程序的执行。此外,在开关输入上使用中断需要在计时器运行时禁用中断,这会给代码增加进一步的复杂性。

在硬件中进行消抖可以使用离散元件,或者使用 SR 触发器(由两个与非门组成)。对于这种应用,我们将使用以下电路,它与最常用的 SPST(单极单刀)类型的开关配合良好:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/78c9ed38-dcb3-4eb3-85a1-733bf19c5bd6.png

这个电路的概念是,当开关打开时,电容通过 R1(和 D1)充电,导致反相施密特触发器电路(U1)上的输入变高,导致连接到 U1 输出的 SBC 的 GPIO 引脚读取低电平。当开关关闭时,电容通过 R2 放电到地面。

充电和放电都需要一定的时间,在 U1 输入上发生变化之前会增加延迟。充电和放电速率由 R1 和 R2 的值决定,其公式如下:

在这里,V(t)是时间t(以秒为单位)时的电压。*V[S]*是源电压,t是源电压施加后的时间(以秒为单位)。R 是电路电阻(欧姆),C 是电容(法拉)。最后,e是一个数学常数,其值为 2.71828(约),也称为欧拉数。

对于电容器的充电和放电,使用了 RC 时间常数τ(tau),其定义如下:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/4c70f6d7-152d-44d3-a793-6c3786e82007.png

这定义了电容器充电到 63.2%(1τ)所需的时间,然后是 86%(2τ)。电容器放电 1τ后从完全充电状态下降到 37%,2τ后为 13.5%。这里注意到的一件事是,电容器永远不会完全充电或放电;充电或放电的过程只是减慢到几乎不可察觉的程度。

使用我们的消抖电路的数值,我们得到了以下充电的时间常数:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/32b180c1-b53b-4bb7-82ed-e324fb5f3094.png

放电时间如下:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/5f7819ea-e9e2-461d-a593-c4fdd7ba7a39.png

分别对应 51 和 22 微秒。

与任何施密特触发器一样,它具有所谓的滞后特性,这意味着它具有双阈值。这有效地在输出响应上方和下方添加了一个死区,输出不会改变:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/bc437d21-ffee-4425-9d2f-3184fc28df6d.png

施密特触发器的滞后通常用于通过设置明确的触发电平来消除传入信号的噪音。尽管我们已经在使用的 RC 电路应该能够滤除几乎所有的噪音,但添加施密特触发器可以增加一点额外的保险,而不会产生任何负面影响。

当可用时,也可以使用 SBC 的 GPIO 引脚的滞后功能。对于这个项目和选择的去抖电路,我们还希望芯片具有反转属性,这样我们就可以得到连接开关的预期高/低响应,而不必在软件中反转含义。

去抖 HAT

使用上一节的信息和去抖电路,组装了一个原型板:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/3c655ae5-9b38-479e-8178-6676cf9e0cd9.png

这个原型实现了两个去抖通道,这是项目所需的两个开关。它还添加了一个螺钉端子,用于连接 SBC 电源连接。这样可以通过 5V 引脚为 SBC 供电,而不必使用树莓派的微型 USB 连接器。为了集成的目的,通常更容易直接从电源供应器运行导线到螺钉端子或类似的地方,而不是在微型 USB 插头上进行调整。

当然,这个原型不是树莓派基金会规定的合适的 HAT。这些要求以下功能:

  • 它具有包含供应商信息、GPIO 映射和设备信息的有效 EEPROM,连接到树莓派 SBC 上的ID_SCID_SD I2C 总线引脚

  • 它具有现代的 40 针(女)GPIO 连接器,还将 HAT 与 SBC 的间距至少 8 毫米

  • 它遵循机械规格

  • 如果通过 5V 引脚为 SBC 提供电源,HAT 必须能够持续提供至少 1.3 安培

通过添加所需的 I2C EEPROM(CAT24C32)和其他功能,我们可以看到使用倒置六通道提供的倒置六通道施密特触发器 IC(40106)的完整版本是什么样子的:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/f94f1673-7d99-4eb5-b684-35e094620374.png

此 KiCad 项目的文件可以在作者的 GitHub 帐户github.com/MayaPosch/DebounceHat中找到。通过扩展的通道数量,相对容易地集成更多的开关、继电器和其他元素到系统中,可能使用各种传感器来监视诸如窗户之类的东西,输出高/低信号。

电源

对于我们的项目,我们需要的所需电压是树莓派板的 5V 和通过继电器开关的灯的第二电压。我们选择的电源必须能够为 SBC 和灯提供足够的电力。对于前者,1-2 A 应该足够,后者取决于所使用的灯和它们的功率要求。

实施

监控服务将作为基本的systemd服务实现,这意味着它将在系统启动时由操作系统启动,并且可以使用所有常规的 systemd 工具来监视和重新启动服务。

我们将有以下依赖项:

  • POCO

  • WiringPi

  • libmosquittopp(和 libmosquitto)

使用 libmosquitto 依赖项(mosquitto.org/man/libmosquitto-3.html)用于添加 MQTT 支持。 libmosquittopp 依赖项是围绕基于 C 的 API 的包装器,提供了基于类的接口,这使得集成到 C++项目中更容易。

POCO 框架(pocoproject.org/)是一组高度可移植的 C++ API,提供从网络相关功能(包括 HTTP)到所有常见的低级功能。在这个项目中,它的 HTTP 服务器将被使用,以及它对处理配置文件的支持。

最后,WiringPi(wiringpi.com/)是访问和使用树莓派和兼容系统上的 GPIO 头部特性的事实标准头文件。它实现了与 I2C 设备和 UART 的通信 API,并使用 PWM 和数字引脚。在这个项目中,它允许我们与继电器板和去抖板进行通信。

此代码的当前版本也可以在作者的 GitHub 帐户上找到:github.com/MayaPosch/ClubStatusService

我们将从主文件开始:

#include "listener.h"

 #include <iostream>
 #include <string>

 using namespace std;

 #include <Poco/Util/IniFileConfiguration.h>
 #include <Poco/AutoPtr.h>
 #include <Poco/Net/HTTPServer.h>

 using namespace Poco::Util;
 using namespace Poco;
 using namespace Poco::Net;

 #include "httprequestfactory.h"
 #include "club.h"

在这里,我们包括一些基本的 STL 功能,以及来自 POCO 的 HTTP 服务器和ini文件支持。监听器头文件是为我们的 MQTT 类,httprequestfactory和 club 头文件是为 HTTP 服务器和主要的监控逻辑,分别是:

int main(int argc, char* argv[]) {
          Club::log(LOG_INFO, "Starting ClubStatus server...");
          int rc;
          mosqpp::lib_init();

          Club::log(LOG_INFO, "Initialised C++ Mosquitto library.");

          string configFile;
          if (argc > 1) { configFile = argv[1]; }
          else { configFile = "config.ini"; }

          AutoPtr<IniFileConfiguration> config;
          try {
                config = new IniFileConfiguration(configFile);
          }
          catch (Poco::IOException &e) {
                Club::log(LOG_FATAL, "Main: I/O exception when opening configuration file: " + configFile + ". Aborting...");
                return 1;
          }

          string mqtt_host = config->getString("MQTT.host", "localhost");
          int mqtt_port = config->getInt("MQTT.port", 1883);
          string mqtt_user = config->getString("MQTT.user", "");
          string mqtt_pass = config->getString("MQTT.pass", "");
          string mqtt_topic = config->getString("MQTT.clubStatusTopic",    "/public/clubstatus");
          bool relayactive = config->getBool("Relay.active", true);
          uint8_t relayaddress = config->getInt("Relay.address", 0x20);

在这一部分中,我们初始化 MQTT 库(libmosquittopp)并尝试打开配置文件,如果在命令行参数中没有指定任何内容,则使用默认路径和名称。

POCO 的IniFileConfiguration类用于打开和读取配置文件,如果找不到或无法打开配置文件,则会抛出异常。POCO 的AutoPtr相当于 C++11 的unique_ptr,允许我们创建一个新的基于堆的实例,而不必担心以后处理它。

接下来,我们读取我们对 MQTT 和继电器板功能感兴趣的值,指定默认值是有意义的地方:

Listener listener("ClubStatus", mqtt_host, mqtt_port, mqtt_user, mqtt_pass);

    Club::log(LOG_INFO, "Created listener, entering loop...");

    UInt16 port = config->getInt("HTTP.port", 80);
    HTTPServerParams* params = new HTTPServerParams;
    params->setMaxQueued(100);
    params->setMaxThreads(10);
    HTTPServer httpd(new RequestHandlerFactory, port, params);
    try {
          httpd.start();
    }
    catch (Poco::IOException &e) {
          Club::log(LOG_FATAL, "I/O Exception on HTTP server: port already in use?");
          return 1;
    }
    catch (...) {
          Club::log(LOG_FATAL, "Exception thrown for HTTP server start. Aborting.");
          return 1;
    }

在这一部分中,我们启动 MQTT 类,并为其提供连接到 MQTT 代理所需的参数。接下来,读取 HTTP 服务器的配置详细信息,并创建一个新的HTTPServer实例。

服务器实例使用提供的端口和一些限制进行配置,用于 HTTP 服务器允许使用的最大线程数,以及它可以保持的最大排队连接数。这些参数对于优化系统性能并将这样的代码适应到资源更少的系统中是有用的。

新的客户端连接由自定义的RequestHandlerFactory类处理,我们稍后会看到:


             Club::mqtt = &listener;
             Club::start(relayactive, relayaddress, mqtt_topic);

             while(1) {
                   rc = listener.loop();
                   if (rc){
                         Club::log(LOG_ERROR, "Disconnected. Trying to 
                         reconnect...");
                         listener.reconnect();
                   }
             }

             mosqpp::lib_cleanup();
             httpd.stop();
             Club::stop();

             return 0;
 }

最后,我们将创建的Listener实例的引用分配给静态的Club类的mqtt成员。这将使Listener对象更容易在以后使用,我们将看到。

通过在Club上调用start(),将处理连接硬件的监视和配置,并且在主函数中完成了这个方面。

最后,我们进入了一个 MQTT 类的循环,确保它保持与 MQTT 代理的连接。离开循环时,我们将清理资源并停止 HTTP 服务器等。然而,由于我们在这里是一个无限循环,这个代码不会被执行到。

由于这个实现将作为一个 24/7 运行的服务,以一种干净的方式终止服务并不是绝对必要的。一个相对简单的方法是添加一个信号处理程序,一旦触发就会中断循环。为了简单起见,这在这个项目中被省略了。

监听器

Listener类的类声明如下:

class Listener : public mosqpp::mosquittopp {
          //

 public:
          Listener(string clientId, string host, int port, string user, string pass);
          ~Listener();

          void on_connect(int rc);
          void on_message(const struct mosquitto_message* message);
          void on_subscribe(int mid, int qos_count, const int* granted_qos);

          void sendMessage(string topic, string& message);
          void sendMessage(string& topic, char* message, int msgLength);
 };

这个类提供了一个简单的 API 来连接到 MQTT 代理并向该代理发送消息。我们从mosquittopp类继承,重新实现了一些回调方法来处理连接新接收的消息和完成对 MQTT 主题的订阅的事件。

接下来,让我们看一下实现:

#include "listener.h"

 #include <iostream>

 using namespace std;
 Listener::Listener(string clientId, string host, int port, string user, string pass) : mosquittopp(clientId.c_str()) {
          int keepalive = 60;
          username_pw_set(user.c_str(), pass.c_str());
          connect(host.c_str(), port, keepalive);
 }

 Listener::~Listener() {
          //
 }

在构造函数中,我们使用 mosquittopp 类的构造函数分配唯一的 MQTT 客户端标识字符串。我们使用默认值为 60 秒的保持活动设置,这意味着我们将保持与 MQTT 代理的连接开放的时间,而不会发送任何控制或其他消息。

设置用户名和密码后,我们连接到 MQTT 代理:

void Listener::on_connect(int rc) {
    cout << "Connected. Subscribing to topics...n";

          if (rc == 0) {
                // Subscribe to desired topics.
                string topic = "/club/status";
                subscribe(0, topic.c_str(), 1);
          }
          else {
                cerr << "Connection failed. Aborting subscribing.n";
          }
 }

每当尝试与 MQTT 代理建立连接时,都会调用此回调函数。我们检查rc的值,如果值为零,表示成功,我们开始订阅任何所需的主题。在这里,我们只订阅一个主题:/club/status。如果任何其他 MQTT 客户端向此主题发送消息,我们将在下一个回调函数中收到它:


 void Listener::on_message(const struct mosquitto_message* message) {
          string topic = message->topic;
          string payload = string((const char*) message->payload, message->payloadlen);

          if (topic == "/club/status") {
                string topic = "/club/status/response";
                char payload[] = { 0x01 }; 
                publish(0, topic.c_str(), 1, payload, 1); // QoS 1\.   
          }     
 }

在这个回调函数中,我们接收一个带有 MQTT 主题和负载的结构体。然后我们将主题与我们订阅的主题字符串进行比较,这种情况下只是/club/status 主题。收到此主题的消息后,我们将发布一个新的 MQTT 消息,其中包含主题和负载。最后一个参数是服务质量QoS)值,在这种情况下设置为至少一次传递标志。这保证至少有另一个 MQTT 客户端会接收到我们的消息。

MQTT 负载始终是二进制的,例如在这里是1。要使其反映俱乐部房间的状态(打开或关闭),我们需要集成来自静态Club类的响应,我们将在下一节中讨论这个。

首先,我们来看一下Listener类的其余函数:

 void Listener::on_subscribe(int mid, int qos_count, const int* granted_qos) {
          // 
 }

 void Listener::sendMessage(string topic, string &message) {
          publish(0, topic.c_str(), message.length(), message.c_str(), true);
 }

 void Listener::sendMessage(string &topic, char* message, int msgLength) {
          publish(0, topic.c_str(), msgLength, message, true);
 }

新订阅的回调函数在这里为空,但可以用于添加日志记录或类似功能。此外,我们还有一个重载的sendMessage()函数,允许应用程序的其他部分也发布 MQTT 消息。

有这两个不同函数的主要原因是,有时使用char*数组发送更容易,例如,作为二进制协议的一部分发送 8 位整数数组,而其他时候 STL 字符串更方便。这样,我们可以同时获得两种方式的最佳效果,而不必在代码中的任何位置发送 MQTT 消息时转换其中一种。

publish()的第一个参数是消息 ID,这是一个我们可以自己分配的自定义整数。在这里,我们将其保留为零。我们还使用了retain标志(最后一个参数),将其设置为 true。这意味着每当一个新的 MQTT 客户端订阅我们发布保留消息的主题时,该客户端将始终接收到在该特定主题上发布的最后一条消息。

由于我们将在 MQTT 主题上发布俱乐部房间的状态,因此希望 MQTT 代理保留最后的状态消息,以便使用此信息的任何客户端在连接到代理时立即接收到当前状态,而不必等待下一个状态更新。

俱乐部

俱乐部头文件声明了构成项目核心的类,并负责处理开关输入、控制继电器和更新俱乐部房间的状态:

#include <wiringPi.h>
 #include <wiringPiI2C.h>

在这个头文件中值得注意的第一件事是包含的内容。它们为我们的代码添加了基本的 WiringPi GPIO 功能,以及用于 I2C 使用的功能。进一步的 WiringPi 可以包括其他需要这种功能的项目,比如 SPI、UART(串行)、软件 PWM、树莓派(Broadcom SoC)特定功能等等:

enum Log_level {
    LOG_FATAL = 1,
    LOG_ERROR = 2,
    LOG_WARNING = 3,
    LOG_INFO = 4,
    LOG_DEBUG = 5
 };

我们将使用enum定义我们将使用的不同日志级别:

 class Listener;

我们提前声明Listener类,因为我们将在这些类的实现中使用它,但暂时不想包含整个头文件:

class ClubUpdater : public Runnable {
          TimerCallback<ClubUpdater>* cb;
          uint8_t regDir0;
          uint8_t regOut0;
          int i2cHandle;
          Timer* timer;
          Mutex mutex;
          Mutex timerMutex;
          Condition timerCnd;
          bool powerTimerActive;
          bool powerTimerStarted;

 public:
          void run();
          void updateStatus();
          void writeRelayOutputs();
          void setPowerState(Timer &t);
 };

ClubUpdater类负责配置基于 I2C 的 GPIO 扩展器,控制继电器,并处理俱乐部状态的任何更新。POCO 框架中的Timer实例用于向电源状态继电器添加延迟,我们将在实现中看到。

这个类继承自 POCO Runnable类,这是 POCO Thread类所期望的基类,它是围绕本地线程的包装器。

这两个uint8_t成员变量镜像了 I2C GPIO 扩展器设备上的两个寄存器,允许我们设置设备上输出引脚的方向和值,从而有效地控制附加的继电器:

class Club {
          static Thread updateThread;
          static ClubUpdater updater;

          static void lockISRCallback();
          static void statusISRCallback();

 public:
          static bool clubOff;
          static bool clubLocked;
          static bool powerOn;
          static Listener* mqtt;
          static bool relayActive;
          static uint8_t relayAddress;
          static string mqttTopic;      // Topic we publish status updates on.

          static Condition clubCnd;
          static Mutex clubCndMutex;
          static Mutex logMutex;
          static bool clubChanged ;
          static bool running;
          static bool clubIsClosed;
          static bool firstRun;
          static bool lockChanged;
          static bool statusChanged;
          static bool previousLockValue;
          static bool previousStatusValue;

          static bool start(bool relayactive, uint8_t relayaddress, string topic);
          static void stop();
          static void setRelay();
          static void log(Log_level level, string msg);
 };

Club类可以被视为系统的输入端,设置和处理 ISR(中断处理程序),并作为所有与俱乐部状态相关的变量(如锁定开关状态、状态开关状态和电源系统状态(俱乐部开放或关闭))的中央(静态)类。

这个类被完全静态化,以便它可以被程序的不同部分自由使用来查询房间状态。

接下来,这是实现:

#include "club.h"

 #include <iostream>

 using namespace std;

 #include <Poco/NumberFormatter.h>

 using namespace Poco;

 #include "listener.h"

在这里,我们包含了Listener头文件,以便我们可以使用它。我们还包括了 POCO NumberFormatter类,以便我们可以格式化整数值以进行日志记录。

 #define REG_INPUT_PORT0              0x00
 #define REG_INPUT_PORT1              0x01
 #define REG_OUTPUT_PORT0             0x02
 #define REG_OUTPUT_PORT1             0x03
 #define REG_POL_INV_PORT0            0x04
 #define REG_POL_INV_PORT1            0x05
 #define REG_CONF_PORT0               0x06
 #define REG_CONG_PORT1               0x07
 #define REG_OUT_DRV_STRENGTH_PORT0_L 0x40
 #define REG_OUT_DRV_STRENGTH_PORT0_H 0x41
 #define REG_OUT_DRV_STRENGTH_PORT1_L 0x42
 #define REG_OUT_DRV_STRENGTH_PORT1_H 0x43
 #define REG_INPUT_LATCH_PORT0        0x44
 #define REG_INPUT_LATCH_PORT1        0x45
 #define REG_PUD_EN_PORT0             0x46
 #define REG_PUD_EN_PORT1             0x47
 #define REG_PUD_SEL_PORT0            0x48
 #define REG_PUD_SEL_PORT1            0x49
 #define REG_INT_MASK_PORT0           0x4A
 #define REG_INT_MASK_PORT1           0x4B
 #define REG_INT_STATUS_PORT0         0x4C
 #define REG_INT_STATUS_PORT1         0x4D
 #define REG_OUTPUT_PORT_CONF         0x4F

接下来,我们定义了目标 GPIO 扩展器设备 NXP PCAL9535A 的所有寄存器。即使我们只使用其中的两个寄存器,将完整列表添加是一个很好的做法,以简化以后代码的扩展。也可以使用单独的头文件,以便轻松使用不同的 GPIO 扩展器,而不需要对代码进行重大更改,甚至根本不需要。

 #define RELAY_POWER 0
 #define RELAY_GREEN 1
 #define RELAY_YELLOW 2
 #define RELAY_RED 3

在这里,我们定义了哪些功能连接到哪个继电器,对应于 GPIO 扩展芯片的特定输出引脚。由于我们有四个继电器,因此使用了四个引脚。这些连接到芯片上的第一个(总共两个)八个引脚的银行。

当然,重要的是这些定义与实际连接到这些继电器的内容相匹配。根据使用情况,这也可以是可配置的。

bool Club::clubOff;
 bool Club::clubLocked;
 bool Club::powerOn;
 Thread Club::updateThread;
 ClubUpdater Club::updater;
 bool Club::relayActive;
 uint8_t Club::relayAddress;
 string Club::mqttTopic;
 Listener* Club::mqtt = 0;

 Condition Club::clubCnd;
 Mutex Club::clubCndMutex;
 Mutex Club::logMutex;
 bool Club::clubChanged = false;
 bool Club::running = false;
 bool Club::clubIsClosed = true;
 bool Club::firstRun = true;
 bool Club::lockChanged = false;
 bool Club::statusChanged = false;
 bool Club::previousLockValue = false;
 bool Club::previousStatusValue = false;

由于Club是一个完全静态的类,我们在进入ClubUpdater类的实现之前初始化了它的所有成员变量。

void ClubUpdater::run() {
    regDir0 = 0x00;
    regOut0 = 0x00;
    Club::powerOn = false;
    powerTimerActive = false;
    powerTimerStarted = false;
    cb = new TimerCallback<ClubUpdater>(*this, &ClubUpdater::setPowerState);
    timer = new Timer(10 * 1000, 0);

当我们启动这个类的一个实例时,它的run()函数被调用。在这里,我们设置了一些默认值。方向和输出寄存器变量最初设置为零。俱乐部房间电源状态设置为 false,与电源计时器相关的布尔变量设置为 false,因为电源计时器尚未激活。这个计时器用于在打开或关闭电源之前设置延迟,我们稍后将会详细介绍。

默认情况下,这个计时器的延迟是十秒。当然,这也可以是可配置的。

if (Club::relayActive) {
    Club::log(LOG_INFO, "ClubUpdater: Starting i2c relay device.");
    i2cHandle = wiringPiI2CSetup(Club::relayAddress);
    if (i2cHandle == -1) {
        Club::log(LOG_FATAL, string("ClubUpdater: error starting          
        i2c relay device."));
        return;
    }

    wiringPiI2CWriteReg8(i2cHandle, REG_CONF_PORT0, 0x00);
    wiringPiI2CWriteReg8(i2cHandle, REG_OUTPUT_PORT0, 0x00);

    Club::log(LOG_INFO, "ClubUpdater: Finished configuring the i2c 
    relay device's registers.");
}

接下来,我们设置 I2C GPIO 扩展器。这需要 I2C 设备地址,我们之前传递给了Club类。这个设置函数的作用是确保在 I2C 总线上的这个地址上有一个活动的 I2C 设备。之后,它应该准备好进行通信。也可以通过将 relayActive 变量设置为 false 来跳过这一步。这是通过在配置文件中设置适当的值来完成的,当在没有 I2C 总线或连接设备的系统上运行集成测试时非常有用。

设置完成后,我们写入了第一个银行的方向和输出寄存器的初始值。两者都写入了空字节,以便它们控制的所有八个引脚都设置为输出模式和二进制零(低)状态。这样,连接到前四个引脚的所有继电器最初都是关闭的。

          updateStatus();

          Club::log(LOG_INFO, "ClubUpdater: Initial status update complete.");
          Club::log(LOG_INFO, "ClubUpdater: Entering waiting condition.");

          while (Club::running) {
                Club::clubCndMutex.lock();
                if (!Club::clubCnd.tryWait(Club::clubCndMutex, 60 * 1000)) {.
                      Club::clubCndMutex.unlock();
                      if (!Club::clubChanged) { continue; }
                }
                else {
                      Club::clubCndMutex.unlock();
                }

                updateStatus();
          }
 }

完成这些配置步骤后,我们运行了俱乐部房间状态的第一次更新,使用相同的函数,以后当输入发生变化时也会调用。这导致所有输入被检查,并且输出被设置为相应的状态。

最后,我们进入一个等待循环。这个循环由Club::running布尔变量控制,允许我们通过信号处理程序或类似方法中断它。实际的等待是使用条件变量进行的,在这里我们等待,直到一分钟等待超时发生(之后,我们经过快速检查后返回等待),或者我们被设置为输入的其中一个中断信号。

接下来,我们看一下用于更新输出状态的函数:

void ClubUpdater::updateStatus() {
    Club::clubChanged = false;

    if (Club::lockChanged) {
          string state = (Club::clubLocked) ? "locked" : "unlocked";
          Club::log(LOG_INFO, string("ClubUpdater: lock status changed to ") + state);
          Club::lockChanged = false;

          if (Club::clubLocked == Club::previousLockValue) {
                Club::log(LOG_WARNING, string("ClubUpdater: lock interrupt triggered, but value hasn't changed. Aborting."));
                return;
          }

          Club::previousLockValue = Club::clubLocked;
    }
    else if (Club::statusChanged) {           
          string state = (Club::clubOff) ? "off" : "on";
          Club::log(LOG_INFO, string("ClubUpdater: status switch status changed to ") + state);
          Club::statusChanged = false;

          if (Club::clubOff == Club::previousStatusValue) {
                Club::log(LOG_WARNING, string("ClubUpdater: status interrupt triggered, but value hasn't changed. Aborting."));
                return;
          }

          Club::previousStatusValue = Club::clubOff;
    }
    else if (Club::firstRun) {
          Club::log(LOG_INFO, string("ClubUpdater: starting initial update run."));
          Club::firstRun = false;
    }
    else {
          Club::log(LOG_ERROR, string("ClubUpdater: update triggered, but no change detected. Aborting."));
          return;
    }

当我们进入此更新函数时,我们首先确保Club::clubChanged布尔值设置为 false,以便可以由其中一个中断处理程序再次设置。

之后,我们检查输入发生了什么变化。如果锁定开关被触发,它的布尔变量将被设置为 true,或者状态开关的变量可能已被触发。如果是这种情况,我们将重置变量,并将新读取的值与该输入的上次已知值进行比较。

作为一种合理检查,如果值没有发生变化,我们会忽略触发。如果中断由于噪音而被触发,例如开关的信号线靠近电源线,这种情况可能会发生。后者的任何波动都会引起前者的激增,这可能会触发 GPIO 引脚的中断。这是处理非理想物理世界的现实的一个明显例子,也展示了硬件和软件对系统可靠性的影响的重要性。

除了这个检查之外,我们还使用我们的中央记录器记录事件,并更新缓冲输入值,以便在下一次运行中使用。

if/else 语句中的最后两种情况处理了初始运行,以及默认处理程序。当我们最初运行此函数时,就像我们之前看到的那样,没有中断会被触发,因此显然我们必须为状态和锁定开关添加第三种情况:

    if (Club::clubIsClosed && !Club::clubOff) {
          Club::clubIsClosed = false;

          Club::log(LOG_INFO, string("ClubUpdater: Opening club."));

          Club::powerOn = true;
          try {
                if (!powerTimerStarted) {
                      timer->start(*cb);
                      powerTimerStarted = true;
                }
                else { 
                      timer->stop();
                      timer->start(*cb);
                }
          }
          catch (Poco::IllegalStateException &e) {
                Club::log(LOG_ERROR, "ClubUpdater: IllegalStateException on timer start: " + e.message());
                return;
          }
          catch (...) {
                Club::log(LOG_ERROR, "ClubUpdater: Unknown exception on timer start.");
                return;
          }

          powerTimerActive = true;

          Club::log(LOG_INFO, "ClubUpdater: Started power timer...");

          char msg = { '1' };
          Club::mqtt->sendMessage(Club::mqttTopic, &msg, 1);

          Club::log(LOG_DEBUG, "ClubUpdater: Sent MQTT message.");
    }
    else if (!Club::clubIsClosed && Club::clubOff) {
          Club::clubIsClosed = true;

          Club::log(LOG_INFO, string("ClubUpdater: Closing club."));

          Club::powerOn = false;

          try {
                if (!powerTimerStarted) {
                      timer->start(*cb);
                      powerTimerStarted = true;
                }
                else { 
                      timer->stop();
                      timer->start(*cb);
                }
          }
          catch (Poco::IllegalStateException &e) {
                Club::log(LOG_ERROR, "ClubUpdater: IllegalStateException on timer start: " + e.message());
                return;
          }
          catch (...) {
                Club::log(LOG_ERROR, "ClubUpdater: Unknown exception on timer start.");
                return;
          }

          powerTimerActive = true;

          Club::log(LOG_INFO, "ClubUpdater: Started power timer...");

          char msg = { '0' };
          Club::mqtt->sendMessage(Club::mqttTopic, &msg, 1);

          Club::log(LOG_DEBUG, "ClubUpdater: Sent MQTT message.");
    }

接下来,我们检查是否必须将俱乐部房间的状态从关闭更改为打开,或者反之亦然。这是通过检查俱乐部状态(Club::clubOff)布尔值相对于存储的上次已知状态的Club::clubIsClosed布尔值来确定的。

基本上,如果状态开关从打开到关闭或反之亦然,这将被检测到,并且将开始更改为新状态。这意味着将启动电源定时器,该定时器将在预设延迟后打开或关闭俱乐部房间中的非永久电源。

POCO Timer类要求我们在启动之前先停止定时器,如果之前已经启动过。这要求我们添加一个额外的检查。

此外,我们还使用对 MQTT 客户端类的引用,向 MQTT 代理发送消息,其中包括更新后的俱乐部房间状态,这里可以是 ASCII 1 或 0。此消息可用于触发其他系统,这些系统可以更新俱乐部房间的在线状态,或者可以用于更多创造性的用途。

当然,消息的确切有效载荷可以进行可配置。

在下一节中,我们将根据房间内电源的状态更新状态灯的颜色。为此,我们使用以下表格:

颜色状态开关锁定开关电源状态
绿色打开解锁打开
黄色关闭解锁关闭
红色关闭锁定关闭
黄色和红色打开锁定打开

实现如下:


    if (Club::clubOff) {
          Club::log(LOG_INFO, string("ClubUpdater: New lights, clubstatus off."));

          mutex.lock();
          string state = (Club::powerOn) ? "on" : "off";
          if (powerTimerActive) {
                Club::log(LOG_DEBUG, string("ClubUpdater: Power timer active, inverting power state from: ") + state);
                regOut0 = !Club::powerOn;
          }
          else {
                Club::log(LOG_DEBUG, string("ClubUpdater: Power timer not active, using current power state: ") + state);
                regOut0 = Club::powerOn; 
          }

          if (Club::clubLocked) {
                Club::log(LOG_INFO, string("ClubUpdater: Red on."));
                regOut0 |= (1UL << RELAY_RED); 
          } 
          else {
                Club::log(LOG_INFO, string("ClubUpdater: Yellow on."));
                regOut0 |= (1UL << RELAY_YELLOW);
          } 

          Club::log(LOG_DEBUG, "ClubUpdater: Changing output register to: 0x" + NumberFormatter::formatHex(regOut0));

          writeRelayOutputs();
          mutex.unlock();
    }

我们首先检查俱乐部房间电源的状态,这告诉我们要使用输出寄存器的第一个位的值。如果电源定时器处于活动状态,我们必须反转电源状态,因为我们要写入当前的电源状态,而不是存储在电源状态布尔变量中的未来状态。

如果俱乐部房间的状态开关处于关闭位置,则锁定开关的状态决定最终的颜色。当俱乐部房间被锁定时,我们触发红色继电器,否则我们触发黄色继电器。后者表示中间状态,即俱乐部房间关闭但尚未锁定。

在这里使用互斥锁是为了确保 I2C 设备输出寄存器的写入以及更新本地寄存器变量是以同步的方式进行的:

    else { 
                Club::log(LOG_INFO, string("ClubUpdater: New lights, clubstatus on."));

                mutex.lock();
                string state = (Club::powerOn) ? "on" : "off";
                if (powerTimerActive) {
                      Club::log(LOG_DEBUG, string("ClubUpdater: Power timer active,    inverting power state from: ") + state);
                      regOut0 = !Club::powerOn; // Take the inverse of what the timer    callback will set.
                }
                else {
                      Club::log(LOG_DEBUG, string("ClubUpdater: Power timer not active,    using current power state: ") + state);
                      regOut0 = Club::powerOn; // Use the current power state value.
                }

                if (Club::clubLocked) {
                      Club::log(LOG_INFO, string("ClubUpdater: Yellow & Red on."));
                      regOut0 |= (1UL << RELAY_YELLOW);
                      regOut0 |= (1UL << RELAY_RED);
                }
                else {
                      Club::log(LOG_INFO, string("ClubUpdater: Green on."));
                      regOut0 |= (1UL << RELAY_GREEN);
                }

                Club::log(LOG_DEBUG, "ClubUpdater: Changing output register to: 0x" +    NumberFormatter::formatHex(regOut0));

                writeRelayOutputs();
                mutex.unlock();
          }
 }

如果俱乐部房间的状态开关设置为开,我们会得到另外两个颜色选项,绿色是通常的选项,表示俱乐部房间解锁并且状态开关启用。然而,如果后者打开但房间被锁上,我们会得到黄色和红色。

完成输出寄存器的新内容后,我们总是使用writeRelayOutputs()函数将我们的本地版本写入远程设备,从而触发新的继电器状态:

void ClubUpdater::writeRelayOutputs() {
    wiringPiI2CWriteReg8(i2cHandle, REG_OUTPUT_PORT0, regOut0);

    Club::log(LOG_DEBUG, "ClubUpdater: Finished writing relay outputs with: 0x" 
                + NumberFormatter::formatHex(regOut0));
 }

这个功能非常简单,使用 WiringPi 的 I2C API 向连接的设备输出寄存器写入一个 8 位值。我们也在这里记录写入的值:

   void ClubUpdater::setPowerState(Timer &t) {
          Club::log(LOG_INFO, string("ClubUpdater: setPowerState called."));

          mutex.lock();
          if (Club::powerOn) { regOut0 |= (1UL << RELAY_POWER); }
          else { regOut0 &= ~(1UL << RELAY_POWER); }

          Club::log(LOG_DEBUG, "ClubUpdater: Writing relay with: 0x" +    NumberFormatter::formatHex(regOut0));

          writeRelayOutputs();

          powerTimerActive = false;
          mutex.unlock();
 }

在这个函数中,我们将俱乐部房间的电源状态设置为其布尔变量包含的任何值。我们使用与更新俱乐部房间状态颜色时相同的互斥体。然而,在这里我们不是从头开始创建输出寄存器的内容,而是选择切换其变量中的第一个位。

切换完这个位后,我们像往常一样向远程设备写入,这将导致俱乐部房间的电源切换状态。

接下来,我们看一下静态的Club类,从我们调用的第一个函数开始初始化它:

bool Club::start(bool relayactive, uint8_t relayaddress, string topic) {
          Club::log(LOG_INFO, "Club: starting up...");

          relayActive = relayactive;
          relayAddress = relayaddress;
          mqttTopic = topic;

          wiringPiSetup();

          Club::log(LOG_INFO,  "Club: Finished wiringPi setup.");

          pinMode(0, INPUT);
          pinMode(7, INPUT);
          pullUpDnControl(0, PUD_DOWN);
          pullUpDnControl(7, PUD_DOWN);
          clubLocked = digitalRead(0);
          clubOff = !digitalRead(7);

          previousLockValue = clubLocked;
          previousStatusValue = clubOff;

          Club::log(LOG_INFO, "Club: Finished configuring pins.");

          wiringPiISR(0, INT_EDGE_BOTH, &lockISRCallback);
          wiringPiISR(7, INT_EDGE_BOTH, &statusISRCallback);

          Club::log(LOG_INFO, "Club: Configured interrupts.");

          running = true;
          updateThread.start(updater);

          Club::log(LOG_INFO, "Club: Started update thread.");

          return true;
 }

通过这个功能,我们启动整个俱乐部监控系统,就像我们在应用程序入口点中看到的那样。它接受一些参数,允许我们打开或关闭继电器功能,设置继电器的 I2C 地址(如果使用继电器),以及要发布俱乐部房间状态更改的 MQTT 主题。

在使用这些参数设置成员变量的值后,我们初始化 WiringPi 框架。WiringPi 提供了许多不同的初始化函数,基本上是在如何访问 GPIO 引脚上有所不同。

我们在这里使用的wiringPiSetup()函数通常是最方便的函数,因为它将使用虚拟引脚号,这些虚拟引脚号映射到底层的 Broadcom SoC 引脚。WiringPi 编号的主要优势在于它在不同版本的树莓派 SBC 之间保持不变。

通过使用 Broadcom(BCM)编号或 SBC 电路板上引脚排列的物理位置,我们冒着在板子版本之间发生变化的风险,但 WiringPi 编号方案可以弥补这一点。

对于我们的目的,我们在 SBC 上使用以下引脚:

锁定开关状态开关
BCM17
物理位置11
WiringPi0

在初始化 WiringPi 库之后,我们设置所需的引脚模式,将我们的两个引脚都设置为输入。然后我们在每个引脚上启用下拉。这将启用 SoC 中的内置下拉电阻,它将始终尝试将输入信号拉低(相对于地面)。是否需要为输入(或输出)引脚启用下拉电阻或上拉电阻取决于情况,特别是连接的电路。

重要的是要观察连接电路的行为;如果连接电路有使线路上的值“浮动”的倾向,这将导致输入引脚上的不良行为,值会随机变化。通过将线路拉低或拉高,我们可以确保我们在引脚上读取的不仅仅是噪音。

在我们的每个引脚上设置模式后,我们首次读取它们的值,这使我们能够在稍后使用ClubUpdater类中的当前值运行更新函数。然而,在这之前,我们首先为两个引脚注册我们的中断方法。

中断处理程序只不过是一个回调函数,每当指定的事件发生在指定的引脚上时就会被调用。WiringPi 的 ISR 函数接受引脚编号、事件类型和我们希望使用的处理程序函数的引用。对于我们选择的事件类型,在输入引脚上的值从高变低,或者从低变高时,我们的中断处理程序将被触发。这意味着当连接的开关从开到关,或者从关到开时,它将被触发。

最后,我们通过使用ClubUpdater类实例并将其推送到自己的线程中来启动更新线程:

void Club::stop() {
          running = false;
 }

调用此函数将允许ClubUpdaterrun()函数中的循环结束,这将终止它运行的线程,也允许应用程序的其余部分安全关闭:

void Club::lockISRCallback() {
          clubLocked = digitalRead(0);
          lockChanged = true;

          clubChanged = true;
          clubCnd.signal();
 }

 void Club::statusISRCallback() {
          clubOff = !digitalRead(7);
          statusChanged = true;

          clubChanged = true;
          clubCnd.signal();
 }

我们的中断处理程序都非常简单。当操作系统接收到中断时,它会触发相应的处理程序,这导致它们读取输入引脚的当前值,并根据需要反转该值。在中断触发时,statusChangedlockChanged变量被设置为 true,以指示更新函数中的哪个中断被触发。

在向ClubUpdaterun循环等待的条件变量上发出信号之前,我们也对clubChanged布尔变量执行相同的操作。

这个类的最后一部分是日志函数:

void Club::log(Log_level level, string msg) {
    logMutex.lock();
    switch (level) {
          case LOG_FATAL: {
                cerr << "FATAL:t" << msg << endl;
                string message = string("ClubStatus FATAL: ") + msg;
                if (mqtt) {
                      mqtt->sendMessage("/log/fatal", message);
                }

                break;
          }
          case LOG_ERROR: {
                cerr << "ERROR:t" << msg << endl;
                string message = string("ClubStatus ERROR: ") + msg;
                if (mqtt) {
                      mqtt->sendMessage("/log/error", message);
                }

                break;
          }
          case LOG_WARNING: {
                cerr << "WARNING:t" << msg << endl;
                string message = string("ClubStatus WARNING: ") + msg;
                if (mqtt) {
                      mqtt->sendMessage("/log/warning", message);
                }

                break;
          }
          case LOG_INFO: {
                cout << "INFO: t" << msg << endl;
                string message = string("ClubStatus INFO: ") + msg;
                if (mqtt) {
                      mqtt->sendMessage("/log/info", message);
                }

                break;
          }
          case LOG_DEBUG: {
                cout << "DEBUG:t" << msg << endl;
                string message = string("ClubStatus DEBUG: ") + msg;
                if (mqtt) {
                      mqtt->sendMessage("/log/debug", message);
                }

                break;
          }
          default:
                break;
    }

    logMutex.unlock();
 }

我们在这里使用另一个互斥体来同步系统日志(或控制台)中的日志输出,并防止应用程序的不同部分同时调用此函数时发生并发访问 MQTT 类。正如我们将在一会儿看到的,这个日志函数也被用在其他类中。

有了这个日志函数,我们可以在本地(系统日志)和远程使用 MQTT 进行日志记录。

HTTP 请求处理程序

每当 POCO 的 HTTP 服务器接收到一个新的客户端连接时,它都会使用我们的RequestHandlerFactory类的一个新实例来获取特定请求的处理程序。因为它是一个如此简单的类,它完全在头文件中实现:

#include <Poco/Net/HTTPRequestHandlerFactory.h>
 #include <Poco/Net/HTTPServerRequest.h>

 using namespace Poco::Net;

 #include "statushandler.h"
 #include "datahandler.h"

 class RequestHandlerFactory: public HTTPRequestHandlerFactory { 
 public:
          RequestHandlerFactory() {}
          HTTPRequestHandler* createRequestHandler(const HTTPServerRequest& request) {
                if (request.getURI().compare(0, 12, "/clubstatus/") == 0) { 
                     return new StatusHandler(); 
               }
                else { return new DataHandler(); }
          }
 };

我们的类并不比较 HTTP 服务器提供的 URL,以确定要实例化和返回哪种类型的处理程序。在这里,我们可以看到,如果 URL 字符串以/clubstatus开头,我们将返回状态处理程序,该处理程序实现了 REST API。

默认处理程序是一个简单的文件服务器,它尝试将请求解释为文件名,我们将在一会儿看到。

状态处理程序

此处理程序实现了一个简单的 REST API,返回一个包含当前俱乐部状态的 JSON 结构。这可以被外部应用程序用来显示系统的实时信息,这对于仪表板或网站非常有用。

由于它的简单性,这个类也完全在它的头文件中实现:

#include <Poco/Net/HTTPRequestHandler.h>
 #include <Poco/Net/HTTPServerResponse.h>
 #include <Poco/Net/HTTPServerRequest.h>
 #include <Poco/URI.h>

 using namespace Poco;
 using namespace Poco::Net;

 #include "club.h"

 class StatusHandler: public HTTPRequestHandler { 
 public: 
          void handleRequest(HTTPServerRequest& request, HTTPServerResponse& response)  {         
                Club::log(LOG_INFO, "StatusHandler: Request from " +                                                     request.clientAddress().toString());

                URI uri(request.getURI());
                vector<string> parts;
                uri.getPathSegments(parts);

                response.setContentType("application/json");
                response.setChunkedTransferEncoding(true); 

                if (parts.size() == 1) {
                      ostream& ostr = response.send();
                      ostr << "{ "clubstatus": " << !Club::clubOff << ",";
                      ostr << ""lock": " << Club::clubLocked << ",";
                      ostr << ""power": " << Club::powerOn << "";
                      ostr << "}";
                }
                else {
                      response.setStatus(HTTPResponse::HTTP_BAD_REQUEST);
                      ostream& ostr = response.send();
                      ostr << "{ "error": "Invalid request." }";
                }
          }
 };

我们在这里使用Club类的中央日志函数来注册有关传入请求的详细信息。在这里,我们只记录客户端的 IP 地址,但可以使用 POCO HTTPServerRequest类的 API 来请求更详细的信息。

接下来,从请求中获取 URI,并将 URL 的路径部分拆分为一个向量实例。在为响应对象设置内容类型和传输编码设置之后,我们检查我们确实得到了预期的 REST API 调用,此时我们组成 JSON 字符串,从Club类获取俱乐部房间状态信息,并返回。

在 JSON 对象中,我们包括有关俱乐部房间状态的一般信息,反转其布尔变量,以及锁的状态和电源状态,其中 1 表示锁已关闭或电源已打开。

如果 URL 路径有更多的段,它将是一个无法识别的 API 调用,这将导致我们返回一个 HTTP 400(错误请求)错误。

数据处理程序

当请求处理程序工厂无法识别 REST API 调用时,数据处理程序被调用。它尝试找到指定的文件,从磁盘中读取它,并返回它,以及适当的 HTTP 标头。这个类也在它的头文件中实现:

#include <Poco/Net/HTTPRequestHandler.h>
 #include <Poco/Net/HTTPServerResponse.h>
 #include <Poco/Net/HTTPServerRequest.h>
 #include <Poco/URI.h>
 #include <Poco/File.h>

 using namespace Poco::Net;
 using namespace Poco;

 class DataHandler: public HTTPRequestHandler { 
 public: 
    void handleRequest(HTTPServerRequest& request, HTTPServerResponse& response) {
          Club::log(LOG_INFO, "DataHandler: Request from " + request.clientAddress().toString());

          // Get the path and check for any endpoints to filter on.
          URI uri(request.getURI());
          string path = uri.getPath();

          string fileroot = "htdocs";
          if (path.empty() || path == "/") { path = "/index.html"; }

          File file(fileroot + path);

          Club::log(LOG_INFO, "DataHandler: Request for " + file.path());

我们在这里假设要提供的任何文件都可以在运行此服务的文件夹的子文件夹中找到。文件名(和路径)从请求 URL 中获取。如果路径为空,我们将分配一个默认的索引文件来代替提供:

          if (!file.exists() || file.isDirectory()) {
                response.setStatus(HTTPResponse::HTTP_NOT_FOUND);
                ostream& ostr = response.send();
                ostr << "File Not Found.";
                return;
          }

          string::size_type idx = path.rfind('.');
          string ext = "";
          if (idx != std::string::npos) {
                ext = path.substr(idx + 1);
          }

          string mime = "text/plain";
          if (ext == "html") { mime = "text/html"; }
          if (ext == "css") { mime = "text/css"; }
          else if (ext == "js") { mime = "application/javascript"; }
          else if (ext == "zip") { mime = "application/zip"; }
          else if (ext == "json") { mime = "application/json"; }
          else if (ext == "png") { mime = "image/png"; }
          else if (ext == "jpeg" || ext == "jpg") { mime = "image/jpeg"; }
          else if (ext == "gif") { mime = "image/gif"; }
          else if (ext == "svg") { mime = "image/svg"; }

我们首先检查生成的文件路径是否有效,并且它是一个常规文件,而不是一个目录。如果此检查失败,我们将返回 HTTP 404 文件未找到错误。

通过这个检查后,我们尝试从文件路径中获取文件扩展名,以确定文件的特定 MIME 类型。如果失败,我们将使用纯文本的默认 MIME 类型:

                try {
                      response.sendFile(file.path(), mime);
                }
                catch (FileNotFoundException &e) {
                      Club::log(LOG_ERROR, "DataHandler: File not found exception    triggered...");
                      cerr << e.displayText() << endl;

                      response.setStatus(HTTPResponse::HTTP_NOT_FOUND);
                      ostream& ostr = response.send();
                      ostr << "File Not Found.";
                      return;
                }
                catch (OpenFileException &e) {
                      Club::log(LOG_ERROR, "DataHandler: Open file exception triggered: " +    e.displayText());

                      response.setStatus(HTTPResponse::HTTP_INTERNAL_SERVER_ERROR);
                      ostream& ostr = response.send();
                      ostr << "Internal Server Error. Couldn't open file.";
                      return;
                }
          }
 };

作为最后一步,我们使用响应对象的sendFile()方法将文件发送给客户端,以及我们之前确定的 MIME 类型。

我们还处理了此方法可能抛出的两个异常。第一个异常发生在由于某种原因找不到文件时。这会导致我们返回另一个 HTTP 404 错误。

如果由于某种原因无法打开文件,我们将返回 HTTP 500 内部服务器错误,以及异常中的文本。

服务配置

对于树莓派 SBC 的 Raspbian Linux 发行版,系统服务通常使用systemd进行管理。这使用一个简单的配置文件,我们的俱乐部监控服务使用类似以下内容的配置文件:

[Unit] 
Description=ClubStatus monitoring & control 

[Service] 
ExecStart=/home/user/clubstatus/clubstatus /home/user/clubstatus/config.ini 
User=user 
WorkingDirectory=/home/user/clubstatus 
Restart=always 
RestartSec=5 

[Install] 
WantedBy=multi-user.target 

此服务配置指定了服务的名称,服务是从“user”用户帐户的文件夹启动的,并且服务的配置文件也在同一个文件夹中找到。我们设置了服务的工作目录,还启用了服务在失败后自动重新启动的功能,间隔为五秒。

最后,服务将在系统启动到用户可以登录系统的地步后启动。这样,我们可以确保网络和其他功能已经启动。如果一个系统服务启动得太早,可能会因为尚未初始化的功能缺失而失败。

接下来是 INI 文件配置文件:

[MQTT]
 ; URL and port of the MQTT server.
 host = localhost
 port = 1883

 ; Authentication
 user = user
 pass = password

 ; The topic status on which changes will be published.
 clubStatusTopic = /my/topic

 [HTTP]
 port = 8080

 [Relay]
 ; Whether an i2c relay board is connected. 0 (false) or 1 (true).
 active = 0
 ; i2c address, in decimal or hexadecimal.
 address = 0x20

配置文件分为三个部分,MQTT、HTTP 和 Relay,每个部分包含相关变量。

对于 MQTT,我们有连接到 MQTT 代理的预期选项,包括基于密码的身份验证。我们还指定了俱乐部状态更新将在此发布的主题。

HTTP 部分只包含我们将监听的端口,默认情况下服务器在所有接口上监听。如果需要,可以通过在启动 HTTP 服务器之前使此属性可配置来使网络接口可配置。

最后,继电器部分允许我们打开或关闭继电器板功能,并配置 I2C 设备地址(如果我们正在使用此功能)。

权限

由于 GPIO 和 I2C 都被视为常见的 Linux 设备,它们都有自己的权限集。假设希望避免以 root 身份运行服务,我们需要将运行服务的帐户添加到gpioi2c用户组中:

    sudo usermod -a -G gpio user
    sudo usermod -a -G i2c user

之后,我们需要重新启动系统(或注销并再次登录)以使更改生效。现在我们应该能够无问题地运行服务了。

最终结果

通过在目标 SBC 上配置和安装应用程序和systemd服务,它将自动启动和配置自身。为了完成系统,您可以将其与合适的电源供应一起安装到一个外壳中,从开关运行信号线、网络电缆等。

这个系统的一个实现安装在德国卡尔斯鲁厄的 Entropia 黑客空间。这个设置在俱乐部门外使用了一个真实的交通灯(合法获得)来指示状态,使用 12 伏 LED 灯。SBC、继电器板、去抖板和电源(5V 和 12V MeanWell 工业电源)都集成在一个单一的激光切割木制外壳中:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/fdcc5ed3-6f1c-4c43-a51e-d14a36225368.png

但是,您可以自由地以任何您希望的方式集成组件。这里需要考虑的主要事项是,电子设备都受到安全保护,以免受到损害和意外接触,因为继电器板可能会切换主电压,以及可能是电源供应的主电压线。

示例 - 基本媒体播放器

基于 SBC 的嵌入式系统的另一个基本示例是媒体播放器。这可以涉及音频和音频-视觉(AV)媒体格式。使用 SBC 的系统用于播放媒体与常规键盘和鼠标输入的区别,以及嵌入式 SBC 媒体播放器的区别在于,后者的系统只能用于该目的,软件和用户界面(物理和软件方面)都经过优化,用于媒体播放器使用。

为此,必须开发一个基于软件的前端,以及一个物理接口外设,用于控制媒体播放器。这可以是一系列连接到 GPIO 引脚的开关,输出到常规 HDMI 显示器。或者,也可以使用触摸屏,尽管这将需要更复杂的驱动程序设置。

由于我们的媒体播放器系统在本地存储媒体文件,我们希望使用支持 SD 卡以外的外部存储的 SBC。一些 SBC 配备了 SATA 连接,允许我们连接容量远远超过 SD 卡的硬盘驱动器(HDD)。即使我们坚持使用紧凑的 2.5 英寸 HDD,这些 HDD 的尺寸与许多流行的 SBC 大致相同,我们可以轻松而相对便宜地获得数 TB 的存储空间。

除了存储要求,我们还需要具有数字视频输出,并且我们希望使用 GPIO 或 USB 端口进行用户界面按钮的操作。

这个目的非常适合的板子是 LeMaker Banana Pro,它配备了 H3 ARM SoC、硬件 SATA 和千兆以太网支持,以及支持 4k 视频解码的全尺寸 HDMI 输出:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/74a1aea9-04a3-4e25-9ac3-f4bc3020306d.png

在安装 Armbian 或类似操作系统到 SBC 的基础知识之后,我们可以在系统上设置一个媒体播放器应用程序,使其与操作系统一起启动,并配置它加载播放列表,并监听一些 GPIO 引脚上的事件。这些 GPIO 引脚将连接到一些控制开关,允许我们浏览播放列表,并启动、暂停和停止播放列表项。

其他交互方法也是可能的,例如红外线或基于无线电的遥控器,每种方法都有其优缺点。

我们将在接下来的章节中通过创建这个媒体播放器系统并将其转变为信息娱乐系统来进行工作:

  • 第六章,测试基于操作系统的应用

  • 第八章,示例-基于 Linux 的信息娱乐系统

  • 第十一章,使用 Qt 开发嵌入式系统

总结

在本章中,我们研究了基于操作系统的嵌入式系统,探索了我们可以使用的许多操作系统,尤其是实时操作系统的显着差异。我们还看到了如何将 RTC 外设集成到基于 SBC 的 Linux 系统中,并探索了基于用户空间和内核空间的驱动程序模块,以及它们的优缺点。

除了本章的示例项目,读者现在应该对如何将一组需求转化为一个功能正常的基于操作系统的嵌入式系统有了一个很好的想法。读者将知道如何添加外部外设并从操作系统中使用它们。

在下一章中,我们将研究为资源受限的嵌入式系统开发,包括 8 位 MCU 及其更大的兄弟。

第四章:资源受限的嵌入式系统

使用较小的嵌入式系统,如微控制器(MCU),意味着具有较少的 RAM、CPU 功率和存储空间。本章涉及规划和有效利用有限资源,考虑到当前可用的各种 MCU 和片上系统SoC)解决方案。我们将考虑以下方面:

  • 为项目选择合适的 MCU

  • 并发和内存管理

  • 添加传感器、执行器和网络访问

  • 裸机开发与实时操作系统

小系统的大局观

当首次面对需要使用至少一种 MCU 的新项目时,可能会感到任务艰巨。正如我们在第一章中看到的,嵌入式系统是什么,即使我们仅限于最近发布的 MCU,也有大量 MCU 可供选择。

开始时询问需要多少位可能似乎是显而易见的,比如在选择 8 位、16 位和 32 位 MCU 之间,或者像时钟速度这样易于量化的东西,但这些指标有时会误导,并且通常不利于缩小产品选择范围。事实证明,父类别的可用性是足够的 I/O 和集成外围设备,以便以精简和可靠的方式实现硬件,以及针对设计时面临的要求和预计在产品寿命期间出现的处理能力。

因此,更详细地说,我们需要回答这些问题:

  • 外围设备:需要哪些外围设备与系统的其余部分进行交互?

  • CPU:运行应用程序代码需要多少 CPU 功率?

  • 浮点数:我们是否需要硬件浮点支持?

  • ROM:我们需要多少 ROM 来存储代码?

  • RAM:运行代码需要多少 RAM?

  • 电源和热量:电气功率和热量限制是多少?

每个 MCU 系列都有其自身的优势和劣势,尽管选择一个 MCU 系列而不是另一个最重要的因素之一是其开发工具的质量。对于业余和其他非商业项目,人们主要会考虑社区的实力和可用的免费开发工具,而在商业项目的背景下,人们还会考虑 MCU 制造商和可能的第三方支持。

嵌入式开发的一个关键方面是系统内编程和调试。由于编程和调试是相互交织的,我们将在稍后查看相应的接口选项,以便确定满足我们的需求和约束的内容。

一个受欢迎且强大的调试接口已经成为底层联合测试动作组(JTAG)IEEE 标准 1149.1 的代名词,并且很容易通过经常标记为 TDI、TDO、TCK、TMS 和 TRST 的信号来识别,定义了名副其实的测试动作端口(TAP)。该标准已经扩展到 1149.8,并且并非所有版本都适用于数字逻辑,因此我们将限制我们的范围到 1149.1 和在 1149.7 下描述的降低的引脚计数版本。目前,我们只需要至少支持全功能 JTAG、SWD 和 UPDI 接口中的一个。

在第七章中,我们将深入研究使用片上调试和命令行工具以及集成开发环境来调试基于 MCU 的系统的内容,测试资源有限的平台

最后,如果我们将在未来几年的活跃生产阶段中制造包含所选 MCU 的产品,那么至关重要的是我们确保至少在那段时间内 MCU 的可用性(或兼容替代品的可用性)。值得信赖的制造商将产品生命周期信息作为其供应链管理的一部分提供,提前 1 至 2 年发送停产通知,并建议进行寿命周期购买。

对于许多应用来说,很难忽视廉价、强大且易于使用的 Arduino 兼容板的广泛可用性,特别是围绕 AVR 系列 MCU 设计的流行板。在这些板中,ATmega MCU——mega168/328,特别是 mega1280/2560 变种——为高级功能和输入、控制和遥测数据处理提供了大量的处理能力、ROM 和 RAM,以及不同但丰富的外围设备和 GPIO。

所有这些方面使得在承诺更具体的低规格和(希望)更好的 BOM 成本之前,原型设计变得非常简单。例如,ATmega2560“MEGA”板如下所示,我们将在本章后面的一些示例中更详细地研究其他板,以了解如何为 AVR 平台开发。

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/bf763d40-f2e9-4f8c-88ca-e25e963aa6c8.png

通常,人们会选择一些可能适用于项目的 MCU,获取开发板,将它们连接到预期系统组件的其余部分(通常是在它们自己的开发板或分离板上),并开始为 MCU 开发软件,使一切协同工作。

随着系统的更多部分变得最终确定,开发板和面包板组件的数量将减少,直到开始进行最终印刷电路板PCB)布局。这也将经历多次迭代,因为问题得到解决,最后一刻添加功能,并且整个系统经过测试和优化。

在这种系统中,MCU 在物理层面与硬件一起工作,因此通常需要同时指定硬件和软件,因为软件对硬件功能非常依赖。在行业中经常遇到的一个共同主题是硬件模块化,可以作为小型附加 PCB,最小化增加复杂性,为温度控制器和变频驱动器等设备添加传感器或通信接口,或作为全功能的 DIN 轨道模块连接到公共串行总线。

示例-激光切割机的机器控制器

使用高功率激光切割各种材料是最快速和最准确的方法之一。随着二氧化碳(CO[2])的价格多年来急剧下降,这导致了廉价激光切割机的广泛使用,如下图所示:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/1db0afad-701b-4921-aa11-cebb4a9fb4e3.png

虽然完全可以只使用基本的外壳和用于移动头部横跨机床的步进运动控制板来操作激光切割机,但从可用性和安全性的角度来看,这并不理想。然而,许多可以在线购买的廉价激光切割机完全没有任何安全或可用性功能。

功能规格

为了完成产品,我们需要添加一个控制系统,使用传感器和执行器来监视和控制机器的状态,确保它始终处于安全状态,并在必要时关闭激光束。这意味着保护以下三个部分的访问:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/897f1ff6-32a8-4358-b81d-d2bdd82b8d69.png

切割光束通常由 CO[2]激光器产生,这是一种 1964 年发明的气体激光器。高电压的应用导致电流流动,从而激发孔内的气体分子,最终形成一束长波红外LWIR)或 IR-C 的相干光束,波长为 9.4 或 10.6 微米。

LWIR 的一个特点是它被大量材料强烈吸收,因此可以用于雕刻、切割,甚至是组织的手术,因为生物组织中的水能够高效吸收激光束。这也解释了为什么即使皮肤短暂暴露于 CO[2]激光束也是极其危险的。

为了实现安全操作,必须通过在正常操作期间锁定封闭式空间、关闭激光电源,并在任何互锁打开或任何其他安全条件不再满足时关闭光束快门或最好是这些措施的组合来抑制激光光束的暴露。

例如,必须遵守温度限制:大多数 CO[2]激光器由水冷气体放电管组成,在冷却故障的情况下可能会迅速破裂或弯曲。此外,切割过程会产生刺激性或有毒的烟雾,需要持续从封闭空间中排出,以免在打开盖子时污染光学器件并排出到环境中。

这些要求需要我们监测冷却水流量和温度,排气口的空气流动,以及排气过滤器的空气流动阻力(质量流量的压降)。

最后,我们还希望使用激光切割机变得更加方便,避免需要以机器特定的方式处理设计,然后将其转换并通过 USB 上传到步进电机控制板。相反,我们希望从 SD 卡或 USB 存储设备加载设计项目,并使用简单的 LCD 和按钮来设置选项。

设计要求

考虑到之前的要求,我们可以列出控制系统所需的功能列表:

  • 操作员安全:

  • 访问面板上的互锁开关(关闭时)

  • 锁定机制(机械锁定访问面板;冗余)

  • 紧急停止

  • 激光冷却:

  • 泵继电器

  • 水箱中的温度传感器(冷却能力,进水温度)

  • 排气冷却口的温度传感器(外壳温度)

  • 流量传感器(水流速;冗余)

  • 排气口:

  • 风扇继电器

  • 空气过滤器状态(差压传感器)

  • 风扇速度(RPM)

  • 激光模块:

  • 激光功率继电器

  • 光束快门(冗余)

  • 用户界面

  • 警报指示灯:

  • 面板互锁

  • 空气过滤器状态

  • 风扇状态

  • 泵状态

  • 水温

  • 指示灯:

  • 待机

  • 启动

  • 操作

  • 紧急停止

  • 冷却

  • 通讯:

  • 与步进电机板的 USB 通信(UART)

  • 运动控制:生成步进电机指令

  • 从 SD 卡/USB 存储设备读取文件

  • 通过以太网/ Wi-Fi 接受文件

  • NFC 读卡器用于识别用户

实施相关选择

正如本章开头所指出的,中档 MCU 目前能够提供资源来满足大多数,如果不是所有的设计要求。因此,我们将花钱在硬件组件上还是软件开发上是一个棘手的问题。除了无法预料的因素,我们现在将更仔细地研究三种候选解决方案:

  • 单个中档 AVR MCU 板(ATmega2560)

  • 更高端的 Cortex-M3 MCU 板(SAM3X8E)

  • 中档 MCU 板和带 OS 的 SBC 的组合

我们只需一个 Arduino Mega(ATmega2560)就可以满足设计要求,因为前五个部分在 CPU 速度方面要求不高,只需要一些数字输入和输出引脚,以及根据我们将使用的传感器的确切类型可能需要一些模拟引脚,或者最多需要一个外围接口来使用(例如,用于 MEMS 压力传感器)。

挑战始于前一个功能清单中的通信中的运动控制功能,我们突然需要将矢量图形文件.svg)转换为一系列步进命令。这是一个数据传输、文件解析、路径生成和在机器人世界中所知的逆运动学的复合问题。USB 通信对我们的 8 位 MCU 也可能存在问题,主要是因为处理器负载的峰值与 USB 端点通信或 UART RX 缓冲寄存器处理的超时时间重合。

关键在于知道何时改变策略。运动控制是时间关键的,因为它与物理世界的惯性有关。此外,我们受到控制器的处理和带宽资源的限制,使得控制和数据传输、缓冲以及最终的处理和输出生成本身成为可能。作为一个一般模式,更有能力的内部或外部外设可以通过处理事件和内存事务自己来放松时间要求,减少上下文切换和处理开销。以下是这些考虑的一个不完整列表:

  • 简单的 UART 需要在 RX 完成(RXC)时收集每个字节。如果未能这样做,将导致数据丢失,如 DOR 标志所示。一些控制器,如 ATmega8u2 到 ATmega32u4,通过 RTS/CTS 线提供原生硬件流控制,可以防止 USB-UART 转换器(如 PL2303 和 FT232)发送数据,迫使它们进行缓冲,直到 UDR 再次方便地清空。

  • 专用的 USB 主机外设,如 MAX3421,通过 SPI 连接,有效地消除了大容量存储集成的 USB 定时要求。

  • 除了 UART 之外,网络通信外设由于层堆栈的复杂性,在软件中具有固有的缓冲。对于以太网,W5500 是一个有吸引力的解决方案。

  • 有时候,添加另一个较小的 MCU 是有意义的,它可以独立处理 I/O 和模式生成,并实现我们选择的接口 - 例如串行或并行。这已经是一些 Arduino 板的情况,其中包含一个 ATmega16u2 用于 USB 串行转换。

NFC 读卡器功能要求近场通信NFC,RFID 的一个子集)以防止激光切割机的未经授权使用,这将增加最大的负担。不是因为与 NFC 读卡器本身的通信,而是由于代码大小的增加和处理密码学与证书的 CPU 需求增加,取决于所选择的安全级别。我们还需要一个安全的地方来存储证书,这通常会提高 MCU 的规格。

现在我们到了考虑更高级选项的时候。更简单的 ATmega2560 仍然是一个很好的选择,因为它有大量的 GPIO,并且可以通过 SPI 读取 SD 卡,同时与外部集成的以太网芯片通信。然而,在运动控制和 NFC 读卡器功能清单中的计算或内存密集型任务可能会使 MCU 负担过重,或者导致复杂的“优化”解决方案,可维护性较差。

将 MCU 升级为 Arduino Due 开发板上找到的 ARM Cortex-M3,可能会解决所有这些瓶颈。它将保留我们在 ATmega2560 上习惯的大量 GPIO,同时显著提高 CPU 性能。步进驱动模式可以在 MCU 上生成,它还具有原生 USB 支持,以及其他高级外设(USART、SPI 和 I2C 和 HSMCI,它们也具有 DMA)。

基本的 NFC 标签读卡器可以通过 UART、SPI 或 I2C 连接,这种设计选择会导致一个如图所示的系统:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/a97fac06-4f02-4255-b48c-c2ba8fb95b60.png

涉及 SBC 的第三种方案将再次使用 ATmega2560,并添加一个运行 OS 的低功耗 SBC。这个 SBC 将处理任何 CPU 密集型任务,以太网和 Wi-Fi 连接,USB(主机)任务等。它将通过 UART 与 ATmega 端通信,可能在两个板之间添加数字隔离器或电平转换器,以适应 3.3V(SBC)和 5V TTL(Atmega)逻辑电平。

选择 SBC + MCU 解决方案将大大改变软件挑战,但在硬件方面只会略微重新组织我们的系统。这将如下所示:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/07063017-8a6b-4a0e-98ce-25e7f373a6fa.png

与大多数开发过程一样,只有少数绝对的答案,许多解决方案在功耗、复杂性和维护要求之间进行权衡后,就能满足功能要求,被视为足够好的解决方案。

在这个特定的例子中,可以选择高端单板或双板解决方案,而且很可能需要同样多的努力来满足要求。主要的区别之一是基于 OS 的解决方案需要进行频繁的 OS 更新,因为它是一个运行完整 OS 的网络连接系统,而嵌入式以太网控制器具有卸载的硬件 TCP/IP 堆栈和内存,往往更加稳健和可靠。

基于 Cortex-M3 的选项(或者更快的 Cortex-M4)将只包含我们自己的代码,因此不太可能存在可以轻易被攻击的常见安全问题。我们仍然需要进行维护,但我们的代码足够小,可以完全验证和阅读,唯一的遗憾是 Arduino Due 设计未能为 RMII 引出引脚以连接外部以太网 PHY,这会阻碍其内部以太网 MAC 的使用。

按照我们在本章开头整理的清单,但这次考虑到 ATmega2560 + SBC 和应用程序,我们得到了以下的职责分配:

  • 外围设备:MCU 端主要需要 GPIO,一些模拟(ADC)输入,以太网,USB,以及 SPI 和/或 I2C。

  • CPU:所需的 MCU 性能对时间至关重要,但较小,除非我们需要将矢量路径元素处理为步进指令。只要能够为 MCU 端执行足够的命令并避免时间关键的交互,SBC 端可以很复杂。

  • 浮点:如果我们有硬件浮点支持,MCU 上的步进指令转换算法将执行得更快。所涉及的长度和时间尺度可能使固定点算术成为可能,从而放宽了这一要求。

  • ROM:整个 MCU 代码可能只需要几千字节,因为它并不是非常复杂。SBC 代码将通过调用高级库来提供所需的功能而大幅增加,但这将被类似规模的大容量存储和处理能力所抵消。

  • RAM:MCU 上几 KB 的 SRAM 应该足够。步进指令转换算法可能需要修改以适应 SRAM 的限制,包括其缓冲和处理数据的要求。在最坏的情况下,缓冲区可以缩小。

  • 电源和热量:考虑到激光切割系统的功率需求和冷却系统,我们没有重大的功率或热量限制。包含控制系统的部分已经配备了适当尺寸的冷却风扇,并且已经安装了主电源供应。

在这一点上需要注意的是,尽管我们已经充分意识到了手头任务的复杂性和要求,从而得出了对硬件组件的选择,但如何详细实现这些要求的方面仍然留给软件开发人员。

例如,我们可以定义自己的数据结构和格式,并自行实现特定于机器的路径生成和运动控制,或者采用(RS-274)G 代码中间格式,该格式在数控应用中已经有数十年的历史,并且非常适合生成运动控制命令。G 代码在 diy 硬件社区中也得到了广泛的接受,特别是用于 FDM 3D 打印。

G-code 基于运动控制的一个值得注意的成熟开源实现是 GRBL,引入为:

Grbl 是一个免费的、开源的、高性能的软件,用于控制移动的机器,制造东西,或者使东西移动,并且可以在直接的 Arduino 上运行。如果 maker 运动是一个行业,Grbl 将成为行业标准。

–https://github.com/gnea/grbl

很可能我们将不得不为不同的安全检查违规添加停止和紧急停止功能。虽然温度偏差或堵塞的过滤器最好只是停止激光切割机,并允许在解决问题后恢复工作,但是由于打开机箱而触发的联锁必须立即关闭激光,即使没有完成路径段和运动的最后命令。

模块化运动控制任务并为其生成 G 代码的选择除了具有经过验证的实现可用之外,还有其他好处,使我们可以轻松添加可用性功能,例如手动控制进行设置和校准,以及使用先前在机器端生成的可读代码进行可测试性,就像我们的文件解释和路径生成算法的输出检查一样。

有了需求列表,完成了初始设计,并对我们如何实现目标有了更深入的了解,下一步将是获取一个带有选择的 MCU 和/或 SoC 的开发板(或多个开发板),以及任何外围设备,以便可以开始开发固件并集成系统。

虽然本书所述的机器控制系统的完整实现超出了本书的范围,但我们将在本章的其余部分和第六章中努力实现对微控制器和 SBC 目标品种的开发的深入理解,测试基于 OS 的应用程序,第八章,示例-基于 Linux 的信息娱乐系统,以及第十一章,为混合 SoC/FPGA 系统开发

嵌入式 IDE 和框架

虽然 SoC 的应用开发往往与桌面和服务器环境非常相似,正如我们在上一章中看到的,MCU 的开发需要对正在开发的硬件有更加深入的了解,有时甚至需要了解要在特定寄存器中设置的确切位。

存在一些旨在为特定 MCU 系列抽象这些细节的框架,以便可以开发一个通用 API,而不必担心它在特定 MCU 上的实现方式。其中,Arduino 框架是工业应用之外最为人所知的,尽管也有许多商业框架经过认证可用于生产。

诸如 AVR 和 SAM MCU 的高级软件框架ASF)等框架可以与各种 IDE 一起使用,包括 Atmel Studio、Keil µVision 和 IAR 嵌入式工作室。

以下是一些流行的嵌入式 IDE 的非尽事宜列表:

名称公司许可证平台备注
Atmel StudioMicrochip专有AVR, SAM (ARM Cortex-M).最初由 Atmel 开发,后被 Microchip 收购。
µVisionKeil (ARM)专有ARM Cortex-M, 166, 8051, 251.微控制器开发套件MDK)工具链的一部分。
嵌入式工作台IAR专有ARM Cortex-M, 8051, MSP430, AVR, Coldfire, STM8, H8, SuperH 等。每个 MCU 架构都有单独的 IDE。
MPLAB XMicrochip专有PIC, AVR.使用基于 Java 的 NetBeans IDE 作为基础。
ArduinoArduinoGPLv2一些 AVR 和 SAM MCU(可扩展)。基于 Java 的 IDE。仅支持自己的 C 方言语言。

IDE 的主要目标是将整个工作流程集成到一个应用程序中,从编写初始代码到使用编译后的代码对 MCU 内存进行编程和调试应用程序运行时。

是否使用完整的 IDE 是一个偏好问题。当使用基本编辑器和命令行工具时,所有基本功能仍然存在,尽管像 ASF 这样的框架是为了与 IDE 深度集成而编写的。

流行的 Arduino 框架的主要优势之一是,它已经在越来越多的 MCU 架构上支持了各种 MCU 外设和其他功能的 API 标准化。再加上框架的开源性质,使其成为一个新项目的吸引人的目标。当涉及到原型设计时,这一点尤为吸引人,因为有大量为这个 API 编写的库和驱动程序。

不幸的是,Arduino IDE 只专注于 C 编程语言的简化方言,尽管其核心库广泛使用 C++。尽管如此,这使我们能够将库集成到我们自己的嵌入式 C++项目中,正如我们将在本章后面看到的那样。

编程 MCU

在为目标 MCU 编译代码之后,二进制图像需要在执行和调试之前写入控制器内存。在本节中,我们将看一下可以实现这一目标的各种方法。如今,只有在晶圆级别之前,已知良好的晶圆片被粘合到引线框架并封装之前,才会使用测试插座进行工厂端编程。表面贴装零件已经排除了轻松移除 MCU 进行(重复)编程的可能性。

存在许多(通常是特定供应商的)选项用于电路内编程,这些选项由它们使用的外设和它们影响的存储器区域来区分。

因此,一个原始的 MCU 通常需要使用外部编程适配器进行编程。这些通常通过设置 MCU 的引脚,使其进入编程模式,之后 MCU 接受包含新 ROM 图像的数据流。

另一个常用的选项是在 ROM 的第一部分添加引导加载程序,允许 MCU 自行编程。这是通过引导加载程序在启动时检查是否应切换到编程模式或继续加载实际程序(放置在引导加载程序部分之后)来实现的。

内存编程和设备调试

外部编程适配器通常利用专用接口和相关协议,允许对目标设备进行编程和调试。可以用来编程 MCU 的协议包括以下内容:

名称引脚特点描述
SPI(ISP)4程序串行外围接口SPI),用于与旧 AVR MCU 一起访问其串行编程模式(电路中串行编程ISP))。

| JTAG | 5 | 程序调试

边界 | 专用的,行业标准的芯片内接口,用于编程和调试支持。在 AVR ATxmega 设备上受支持。 |

UPDI1程序调试用于较新的 AVR MCU,包括 ATtiny 设备的统一编程和调试接口UDPI)。这是 ATxmega 设备上发现的双线 PDI 的继任者的单线接口。
**HVPP/**HVSP**17/**5程序高电压并行编程/高电压串行编程。AVR 编程模式使用复位引脚上的 12V 和对 8+引脚的直接访问。忽略任何内部保险丝设置或其他配置选项。主要用于工厂编程和恢复。
TPI3程序用于一些 ATtiny AVR 设备的微型编程接口。这些设备还缺少 HVPP 或 HVSP 的引脚数量。

| SWD | 3 | 程序调试

边界 | 串行线调试。类似于具有两条线的减少引脚计数 JTAG,但使用 ARM 调试接口功能,允许连接的调试器成为总线主机,访问 MCU 的存储器和外围设备。 |

ARM MCU 通常提供 JTAG 作为其主要的编程和调试手段。在 8 位 MCU 上,JTAG 并不常见,这主要是由于其要求的复杂性。

AVR MCU 倾向于提供通过 SPI 的系统编程(ISP),除了高电压编程模式。进入编程模式要求在编程和验证期间保持复位引脚低,并在编程周期结束时释放和触发。

ISP 的一个要求是 MCU 中相关的(SPIEN 保险丝位)被设置为启用系统编程接口。如果未设置此位,设备将不会在 SPI 线上响应。如果没有 JTAG 可用并通过 JTAGEN 保险丝位启用,则只能使用 HVPP 或 HVSP 来恢复和重新编程芯片。在后一种情况下,不寻常的引脚组合和 12V 供电电压不一定与板电路很好地集成。

大多数串行编程接口所需的物理连接都相当简单,即使 MCU 已经集成到电路中,如下图所示:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/b03d2974-c53c-4473-aec3-c599459b49e2.png

在这里,如果存在内部振荡器,则外部振荡器是可选的。 PDIPDOSCK线对应于它们各自的 SPI 线。在编程期间,复位线保持活动(低电平)。以这种方式连接到 MCU 后,我们可以自由地写入其闪存存储器,EEPROM 和配置保险丝。

在较新的 AVR 设备上,我们发现了统一编程和调试接口UPDI),它只使用一根线(除了电源和地线)连接到目标 MCU,以提供编程和调试支持。

此接口简化了先前的连接图如下:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/b0bfa79c-4d7e-4d8e-bec0-5e232d4c017f.png

这与 ATxmega 上的 JTAG(IEEE 1149.1)(启用时)有利地比较如下:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/bd797ef1-480e-4ff2-91b1-9b42f5bd02d6.png

在 ATxmega 上实现的减少引脚计数 JTAG 标准(IEEE 1149)仅需要一个时钟 TCKC,一个数据线 TMSC,因此被称为紧凑 JTAG。在这些接口中,UPDI 仍然需要与目标设备的最少连接。除此之外,它们都支持 AVR MCU 的类似功能。

对于使用 JTAG 进行编程和调试的其他系统,没有标准连接。每个制造商都使用自己首选的连接器,从 2 x 5 引脚(Altera,AVR)到 2 x 10 引脚(ARM),或单个 8 引脚连接器(Lattice)。

由于 JTAG 更多是一种协议标准而不是物理规范,因此应就特定细节咨询目标平台的文档。

引导加载程序

引导加载程序已被引入为一个小的额外应用程序,它使用现有接口(例如 UART 或以太网)提供自我编程能力。在 AVR 上,可以在其闪存中保留 256 字节到 4 KB 的引导加载程序部分。此代码可以执行任意数量的用户定义任务,从与远程系统建立串行链接,到使用 PXE 通过以太网从远程镜像引导。

在本质上,AVR 引导加载程序与任何其他 AVR 应用程序没有什么不同,只是在编译时添加了一个额外的链接器标志来设置引导加载程序的起始字节地址:

--section-start=.text=0x1800 

用特定 MCU 的类似地址替换这个地址(对于 AVR,根据设置的 BOOTSZ 标志和使用的控制器,查看关于引导大小配置的数据表:引导复位地址,例如,引导复位地址为 0xC00 是以字为单位的,部分起始位置以字节定义)。这确保引导加载程序代码将被写入 MCU 的 ROM 的正确位置。将引导加载程序代码写入 ROM 通常通过 ISP 完成。

AVR MCU 将 flash ROM 分为两个部分:不可读写时写(对于大多数,如果不是所有的应用内存空间)和可读写时写RWW)部分。简而言之,这意味着 RWW 部分可以安全地擦除和重写,而不会影响 CPU 的操作。这就是为什么引导加载程序驻留在 NRWW 部分的原因,也是为什么引导加载程序不容易更新自身的原因。

另一个重要的细节是引导加载程序也不能更新设置 MCU 中各种标志的保险丝。要更改这些标志,必须通过外部编程设备进行。

在使用引导加载程序对 MCU 进行编程后,通常会设置 MCU 中的标志,以让处理器知道已安装引导加载程序。在 AVR 的情况下,这些标志是 BOOTSZ 和 BOOTRST。

内存管理

微控制器的存储和内存系统由多个组件组成。有一个只读存储器ROM)部分,它只在芯片编程时写入一次,但通常不能被 MCU 本身改变,正如我们在前一节中看到的。

MCU 可能还有一些持久存储,以 EEPROM 或等效形式存在。最后,还有 CPU 寄存器和随机存取存储器RAM)。这导致以下示例性的内存布局:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/eefb6e49-e5ff-4360-b08c-a862fb6e0530.png

使用修改后的哈佛架构(在某个架构级别上分割程序和数据存储器,通常使用数据总线)在 MCU 中很常见。例如,AVR 架构中,程序存储器位于 ROM 中,对于 ATmega2560,它使用自己的总线与 CPU 核心连接,正如我们在第一章中所看到的那样,这是这个 MCU 的框图,嵌入式系统是什么?

将这些内存空间分开为不同的总线的一个主要优势是可以分别访问它们,这样更好地利用了 8 位处理器可用的有限寻址空间(1 和 2 字节宽地址)。这还允许在 CPU 忙于其他内存空间时进行并发访问,进一步优化了可用资源。

对于 SRAM 中的数据存储器,我们可以自由使用它。在这里,我们至少需要一个堆栈才能运行程序。根据 MCU 中剩余的 SRAM 量,我们还可以添加堆。然而,只涉及静态分配内存的中等复杂度的应用程序,不涉及产生带有堆分配代码的高级语言特性,可以实现。

堆栈和堆

是否需要在编程的 MCU 上初始化堆栈取决于一个人希望走多低级。当使用 C 运行时(在 AVR 上:avr-libc),运行时将通过让链接器将裸代码放入 init 部分(例如由以下指定)来处理初始化堆栈和其他细节:

__attribute__ ((naked, used, section (".init3")))

在执行任何我们自己的应用代码之前。

AVR 上的标准 RAM 布局是从 RAM 的开始处开始.data变量,然后是.bss。堆栈从 RAM 的相反位置开始,向开始位置增长。在.bss部分的结束和堆栈的结束之间将留下空间,如下所示:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/c84909aa-7715-46f6-9538-5760568e9748.png

由于堆栈的增长取决于正在运行的应用程序中函数调用的深度,很难说有多少空间可用。一些 MCU 还允许使用外部 RAM,这可能是堆的可能位置如下:

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/e568f5de-0419-4838-bbe9-3b238d5f9280.png

AVR Libc 库实现了一个针对 AVR 架构进行了优化的malloc()内存分配器例程。使用它,可以实现自己的newdelete功能,如果有需要的话,因为 AVR 工具链没有实现这两个功能。

为了在 AVR MCU 上使用外部内存作为堆存储,必须确保已初始化外部内存,之后地址空间才可供malloc()使用。堆空间的起始和结束由以下全局变量定义:

char * __malloc_heap_start 
char * __malloc_heap_end 

AVR 文档对调整堆的建议如下:

如果堆将移动到外部 RAM,__malloc_heap_end必须相应调整。这可以在运行时直接写入该变量,也可以在链接时通过调整符号__heap_end的值来自动完成。

中断,ESP8266 IRAM_ATTR

在台式 PC 或服务器上,整个应用程序二进制文件将加载到 RAM 中。但是在 MCU 上,通常会尽可能多地将程序指令保留在 ROM 中,直到需要它们。这意味着我们应用程序的大部分指令不能立即执行,而必须先从 ROM 中获取,然后 MCU 的 CPU 才能通过指令总线获取它们以执行。

在 AVR 上,每个可能的中断都在向量表中定义,该表存储在 ROM 中。这为每种中断类型提供了默认处理程序或用户定义的版本。要标记中断例程,可以使用__attribute__((signal))属性,或者使用ISR()宏:

#include <avr/interrupt.h> 

ISR(ADC_vect) { 
         // user code 
} 

这个宏处理注册中断的细节。只需指定名称并为中断处理程序定义一个函数。然后通过中断向量表调用它。

使用 ESP8266(及其后续产品 ESP32),我们可以使用特殊属性IRAM_ATTR标记中断处理程序函数。与 AVR 不同,ESP8266 MCU 没有内置 ROM,而必须使用其 SPI 外设将任何指令加载到 RAM 中,这显然相当慢。

使用此属性与中断处理程序的示例如下:

void IRAM_ATTR MotionModule::interruptHandler() {
          int val = digitalRead(pin);
          if (val == HIGH) { motion = true; }
          else { motion = false; }
 }

在这里,我们有一个与运动检测器信号连接的中断处理程序,连接到一个输入引脚。与任何良好编写的中断处理程序一样,它非常简单,旨在在返回到应用程序的正常流程之前快速执行。

如果将此处理程序放在 ROM 中,这意味着例程不会立即响应运动传感器输出的变化。更糟糕的是,这将导致处理程序需要更长的时间才能完成,从而延迟应用程序其余代码的执行。

通过使用IRAM_ATTR标记,我们可以避免这个问题,因为整个处理程序在需要时已经在 RAM 中,而不是整个系统在等待 SPI 总线返回请求的数据之前就会停顿。

请注意,尽管这种属性可能看起来很诱人,但应该谨慎使用,因为大多数 MCU 的 ROM 比 RAM 多得多。在 ESP8266 的情况下,有 64kB RAM 用于代码执行,可能还有数兆字节的外部 Flash ROM。

在编译我们的代码时,编译器会将带有此属性标记的指令放入一个特殊的部分,以便 MCU 知道将其加载到 RAM 中。

并发

除了少数例外,MCU 是单核系统。多任务处理通常不会进行;相反,有一个单一的执行线程,计时器和中断添加了异步操作的方法。

原子操作通常由编译器支持,AVR 也不例外。在以下情况下可以看到需要原子指令块。请记住,虽然存在一些例外情况(MOVW 用于复制寄存器对和通过 X、Y、Z 指针进行间接寻址),但在 8 位架构上的指令通常只影响 8 位值。

  • 在主函数中以字节方式读取一个 16 位变量,并在 ISR 中更新它。

  • 一个 32 位变量在主函数或 ISR 中被读取、修改,然后存储回去,而另一个例程可能会尝试访问它。

  • 代码块的执行时间至关重要(比如位操作 I/O,禁用 JTAG)。

AVR libc 文档中给出了第一种情况的基本示例:

#include <cinttypes> 
#include <avr/interrupt.h> 
#include <avr/io.h> 
#include <util/atomic.h> 

volatile uint16_t ctr; 

ISR(TIMER1_OVF_vect) { 
   ctr--; 
} 

int main() { 
         ctr = 0x200; 
         start_timer(); 
         sei(); 
         uint16_t ctr_copy; 
         do { 
               ATOMIC_BLOCK(ATOMIC_FORCEON) 
               { 
                     ctr_copy = ctr; 
               } 
         } 
         while (ctr_copy != 0); 

         return 0; 
} 

在这段代码中,一个 16 位整数在中断处理程序中被改变,而主程序正在将其值复制到一个本地变量中。我们调用sei()(设置全局中断标志)来确保中断寄存器处于已知状态。volatile关键字提示编译器,这个变量及其访问方式不应以任何方式进行优化。

因为我们包含了 AVR 原子头文件,我们可以使用ATOMIC_BLOCK宏,以及ATOMIC_FORCEON宏。这样做会创建一个代码段,保证以原子方式执行,没有任何干扰来自中断处理程序等。我们传递给ATOMIC_BLOCK的参数将全局中断状态标志强制为启用状态。

由于我们在开始原子块之前将此标志设置为相同状态,我们不需要保存此标志的先前值,这节省了资源。

正如前面所述,MCU 往往是单核系统,具有有限的多任务处理和多线程能力。要进行适当的多线程和多任务处理,需要进行上下文切换,不仅要保存运行任务的堆栈指针,还要保存所有寄存器和相关状态。

这意味着虽然在单个 MCU 上可能运行多个线程和任务是可能的,在 8 位 MCU(如 AVR 和 PIC(8 位范围))的情况下,这样做的努力很可能不值得,而且需要大量的劳动。

在更强大的 MCU 上(如 ESP8255 和 ARM Cortex-M),可以运行实时操作系统(RTOSes),这些系统实现了这种上下文切换,而不需要做所有的繁重工作。我们将在本章后面讨论 RTOSes。

AVR 开发与 Nodate

Microchip 为 AVR 开发提供了 GCC 工具链的二进制版本。在撰写本文时,最新版本的 AVR-GCC 是 3.6.1,包含 GCC 版本 5.4.0。这意味着对 C++14 的全面支持和对 C++17 的有限支持。

使用这个工具链非常容易。可以从 Microchip 网站上简单地下载它,将其解压到一个合适的文件夹,并将包含 GCC 可执行文件的文件夹添加到系统路径中。之后,它可以用来编译 AVR 应用程序。一些平台也会通过包管理器提供 AVR 工具链,这样的话过程会更加简单。

安装了这个 GCC 工具链后,一个可能注意到的事情是没有 C++ STL 可用。因此,只能使用 GCC 支持的 C++语言特性。正如 Microchip AVR FAQ 所指出的:

  • 显然,C++相关的标准函数、类和模板类都不可用。

  • 操作符 new 和 delete 没有被实现;尝试使用它们会导致链接器抱怨未定义的外部引用。(这可能可以修复。)

  • 一些提供的包含文件不是 C++安全的,也就是说,它们需要被包装成extern"C" { . . . }。(这当然也可以修复。)

  • 不支持异常。由于 C++前端默认启用异常,需要在编译器选项中使用-fno-exceptions显式关闭异常。如果没有这样做,链接器将抱怨对__gxx_personality_sj0的未定义外部引用。

由于缺乏包含 STL 功能的 Libstdc++实现,我们只能通过使用第三方实现来添加这样的功能。这些包括基本上提供完整 STL 的版本,以及不遵循标准 STL API 的轻量级重新实现。后者的一个例子是 Arduino AVR 核心,它提供了类似于 STL 等效的 String 和 Vector 类,尽管存在一些限制和差异。

作为 Microchip AVR GCC 工具链的一种替代方案是 LLVM,这是一个编译器框架,最近为 AVR 添加了实验性支持,并且在未来的某个时候应该允许为 AVR MCU 生成二进制文件,同时通过其 Clang 前端(C/C++支持)提供完整的 STL 功能。

https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/hsn-emb-prog-cpp17/img/5b4b8498-6d84-46e3-9887-ab7249b81b3d.png

将这视为 LLVM 开发的一个抽象快照,同时说明 LLVM 的一般概念及其对中间表示的强调。

不幸的是,尽管 PIC MCU 系列在许多方面也属于 Microchip 并且类似于 AVR,但在这一点上,Microchip 并没有为其提供 C++编译器,直到将其升级到 PIC32(基于 MIPS)MCU 系列。

进入 Nodate

在这一点上,您可以选择使用我们在本章中之前讨论过的 IDE 之一,但这对于 AVR 开发本身来说并不那么有教育意义。因此,我们将看一个为使用修改后的 Arduino AVR 核心开发的 ATmega2560 板的简单应用程序,称为 Nodate(github.com/MayaPosch/Nodate)。这个框架重构了原始核心,使其可以作为常规 C++库来使用,而不仅仅是与 Arduino C 方言解析器和前端一起使用。

安装 Nodate 非常简单:只需将其下载到系统的适当位置,并将NODATE_HOME系统变量指向 Nodate 安装的根文件夹。之后,我们可以以一个示例应用程序作为新项目的基础。

示例 - CMOS IC 测试仪

在这里,我们将看一个更全面的示例项目,实现一个用于 5V 逻辑芯片的集成电路(IC)测试仪。除了使用其 GPIO 引脚探测芯片外,该项目还通过 SPI 从 SD 卡读取芯片描述和测试程序(以逻辑表的形式)。用户控制以串行命令行界面的形式添加。

首先,我们看一下该 Nodate 项目的Makefile,它位于项目的根目录中:

ARCH ?= avr

 # Board preset.
 BOARD ?= arduino_mega_2560

 # Set the name of the output (ELF & Hex) file.
 OUTPUT := sdinfo

 # Add files to include for compilation to these variables.
 APP_CPP_FILES = $(wildcard src/*.cpp)
 APP_C_FILES = $(wildcard src/*.c)

 #
 # --- End of user-editable variables --- #
 #

 # Nodate includes. Requires that the NODATE_HOME environment variable has been set.
 APPFOLDER=$(CURDIR)
 export

 all:
    $(MAKE) -C $(NODATE_HOME)

 flash:
    $(MAKE) -C $(NODATE_HOME) flash

 clean:
    $(MAKE) -C $(NODATE_HOME) clean

我们指定的第一项是我们要定位的架构,因为 Nodate 也可以用于定位其他 MCU 类型。在这里,我们将 AVR 指定为架构。

接下来,我们使用 Arduino Mega 2560 开发板的预设。在 Nodate 中,我们有许多类似这样的预设,它们定义了有关开发板的许多细节。对于 Arduino Mega 2560,我们得到以下预设:

MCU := atmega2560 
PROGRAMMER := wiring 
VARIANT := mega # "Arduino Mega" board type

如果没有定义板预设,就必须在项目的 Makefile 中定义这些变量,并为每个变量选择一个现有值,每个变量都在 Nodate AVR 子文件夹的自己的 Makefile 中定义。或者,可以将自己的 MCU、编程器和(引脚)变体文件添加到 Nodate 中,并添加一个新的板预设,然后使用它。

完成 makefile 后,是时候实现主函数了:

#include <wiring.h>
 #include <SPI.h>
 #include <SD.h>

 #include "serialcomm.h"

接线头文件提供了对所有与 GPIO 相关的功能的访问。此外,我们还包括了 SPI 总线、SD 卡读卡器设备的头文件,以及一个包装串行接口的自定义类的头文件,稍后我们将会更详细地看到:

int main () {
    init();
    initVariant();

    Serial.begin(9600);

    SPI.begin();

进入主函数后,我们通过调用init()来初始化 GPIO 功能。接下来的调用加载了我们正在针对的特定板的引脚配置(在顶部的VARIANT变量或板预设的 Makefile 中)。

在此之后,我们以 9600 波特率启动第一个串行端口,然后是 SPI 总线,最后是欢迎消息的输出,如下所示:

   Serial.println("Initializing SD card...");

    if (!SD.begin(53)) {
          Serial.println("Initialization failed!");
          while (1);
    }

    Serial.println("initialization done.");

    Serial.println("Commands: index, chip");
    Serial.print("> ");

此时,我们期望 Mega 板上连接了一个 SD 卡,其中包含我们可以测试的可用芯片的列表。在这里,引脚 53 是硬件 SPI 片选引脚,方便地位于板上其他 SPI 引脚旁边。

假设板子已经正确连接并且可以无问题地读取卡片,我们会在控制台屏幕上看到一个命令行提示符:

          while (1) {
                String cmd;
                while (!SerialComm::readLine(cmd)) { }

                if (cmd == "index") { readIndex(); }
                else if (cmd == "chip") { readChipConfig(); }
                else { Serial.println("Unknown command.");      }

                Serial.print("> ");
          }

          return 0;
 }

这个循环只是等待串行输入上的输入,之后它将尝试执行接收到的命令。我们调用用于从串行输入读取的函数是阻塞的,只有在收到换行符(用户按下Enter)或其内部缓冲区大小超过而没有收到换行符时才会返回。在后一种情况下,我们只是忽略输入,并尝试再次从串行输入读取。这结束了main()的实现。

现在让我们来看一下SerialComm类的头文件:

#include <HardwareSerial.h>      // UART.

 static const int CHARBUFFERSIZE 64

 class SerialComm {
          static char charbuff[CHARBUFFERSIZE];

 public:
          static bool readLine(String &str);
 };

我们包括了硬件串行连接支持的头文件。这使我们可以访问底层的 UART 外设。这个类本身是纯静态的,定义了字符缓冲区的最大大小,以及从串行输入读取一行的函数。

接下来是它的实现:

#include "serialcomm.h"

 char SerialComm::charbuff[CHARBUFFERSIZE];

 bool SerialComm::readLine(String &str) {
          int index = 0;

          while (1) {
                while (Serial.available() == 0) { }

                char rc = Serial.read();
                Serial.print(rc);

                if (rc == '\n') {
                      charbuff[index] = 0;
                      str = charbuff;
                      return true;
                }

                if (rc >= 0x20 || rc == ' ') {
                      charbuff[index++] = rc;
                      if (index > CHARBUFFERSIZE) {
                            return false;
                      }
                }
          }

          return false;
 }

while循环中,我们首先进入一个循环,该循环在串行输入缓冲区中没有字符可读时运行。这使得它成为一个阻塞读取。

由于我们希望能够看到我们输入的内容,所以在下一部分中,我们会回显我们已经读取的任何字符。之后,我们检查是否收到了换行符。如果是,我们会向本地缓冲区添加一个终止空字节,并将其读入我们提供引用的 String 实例中,之后返回 true。

这里可以实现的一个可能的改进是增加一个退格功能,用户可以使用退格键删除读取缓冲区中的字符。为此,我们需要为退格控制字符(ASCII 0x8)添加一个情况,它将从缓冲区中删除最后一个字符,并且还可以让远程终端删除其最后一个可见字符。

在尚未找到换行符的情况下,我们继续到下一部分。在这里,我们检查是否收到了被视为 ASCII 0x20 的有效字符,或者空格。如果是,我们继续将新字符添加到缓冲区,最后检查是否已经到达读取缓冲区的末尾。如果没有,我们返回 false 以指示缓冲区已满但尚未找到换行符。

接下来是indexchip命令的处理函数readIndex()readChipConfig()

void readIndex() {
          File sdFile = SD.open("chips.idx");
          if (!sdFile) {
                Serial.println("Failed to open IC index file.");
                Serial.println("Please check SD card and try again.");
                while(1);
          }

          Serial.println("Available chips:");
          while (sdFile.available()) {
                Serial.write(sdFile.read());
          }

          sdFile.close();
 }

这个函数大量使用了 Arduino SD 卡库中的SD和相关的File类。基本上,我们在 SD 卡上打开芯片索引文件,确保我们得到了一个有效的文件句柄,然后继续读取并打印文件中的每一行。这个文件是一个简单的基于行的文本文件,每行一个芯片名称。

在处理程序代码的末尾,我们已经从 SD 卡中读取完毕,文件句柄可以使用sdFile.close()关闭。稍后稍长一些的readChipHandler()实现也适用相同的方法。

用法

举例来说,当我们使用一个简单的 HEF4001 IC(4000 CMOS 系列四输入或门)进行测试时,我们必须向 SD 卡添加一个文件,其中包含了这个 IC 的测试描述和控制数据。4001.ic测试文件如下所示,因为它适合跟踪解析它并执行相应测试的代码。

HEF4001B
Quad 2-input NOR gate.
A1-A2: 22-27, Vss: GND, 3A-4B: 28-33, Vdd: 5V
22:0,23:0=24:1
22:0,23:1=24:0
22:1,23:0=24:0
22:1,23:1=24:0
26:0,27:0=25:1
26:0,27:1=25:0
26:1,27:0=25:0
26:1,27:1=25:0
28:0,29:0=30:1
28:0,29:1=30:0
28:1,29:0=30:0
28:1,29:1=30:0
33:0,32:0=31:1
33:0,32:1=31:0
33:1,32:0=31:0
33:1,32:1=31:0

前三行按原样打印,剩下的行指定了各个测试场景。这些测试是行,并使用以下格式:

<pin>:<value>,[..,]<pin>:<value>=<pin>:<value>

我们将这个文件命名为4001.ic,并将更新后的index.idx文件(包含新行上的’4001’条目)写入 SD 卡。为了支持更多的 IC,我们只需重复这个模式,使用它们各自的测试序列,并在索引文件中列出它们。最后是芯片配置的处理程序,它也启动了测试过程:

 void readChipConfig() {
          Serial.println("Chip name?");
          Serial.print("> ");
          String chip;
          while (!SerialComm::readLine(chip)) { }

我们首先询问用户 IC 的名称,如之前由index命令打印出来的:

          File sdFile = SD.open(chip + ".ic");      
          if (!sdFile) {
                Serial.println("Failed to open IC file.");
                Serial.println("Please check SD card and try again.");
                return;
          }

          String name = sdFile.readStringUntil('\n');
          String desc = sdFile.readStringUntil('\n');

我们尝试打开 IC 详细信息的文件,继续读取文件内容,从正在测试的 IC 的名称和描述开始:

          Serial.println("Found IC:");
          Serial.println("Name: " + name);
          Serial.println("Description: " + desc);   

          String pins = sdFile.readStringUntil('\n');
          Serial.println(pins);

显示了这个 IC 的名称和描述后,我们读取包含如何将 IC 连接到 Mega 板标头的指令的行:


          Serial.println("Type 'start' and press <enter> to start test.");
          Serial.print("> ");
          String conf;
          while (!SerialComm::readLine(conf)) { }
          if (conf != "start") {
                Serial.println("Aborting test.");
                return;
          }

在这里,我们询问用户是否确认开始测试 IC。除了start命令之外的任何命令都将中止测试并返回到中央命令循环。

收到start命令后,测试开始:

          int result_pin, result_val;
          while (sdFile.available()) {
                // Read line, format:
                // <pin>:<value>, [..,]<pin>:<value>=<pin>:<value>
                pins = sdFile.readStringUntil('=');
                result_pin = sdFile.readStringUntil(':').toInt();
                result_val = sdFile.readStringUntil('\n').toInt();
                Serial.print("Result pin: ");
                Serial.print(result_pin);
                Serial.print(", expecting: ");
                Serial.println(result_val);
                Serial.print("\n");

                pinMode(result_pin, INPUT);

作为第一步,我们读取 IC 文件中的下一行,该行应包含第一个测试。第一部分包含输入引脚设置,等号后的部分包含 IC 的输出引脚及其在此测试中的预期值。

我们打印出了连接到结果引脚的板头编号和预期值。接下来,我们将结果引脚设置为输入引脚,以便在测试完成后读取它:

                int pin;
                bool val;
                int idx = 0;
                unsigned int pos = 0;
                while ((idx = pins.indexOf(':', pos)) > 0) {
                      int pin = pins.substring(pos, idx).toInt();
                      pos = idx + 1; // Move to character beyond the double colon.

                      bool val = false
                      if ((idx = pins.indexOf(",", pos)) > 0) {
                            val = pins.substring(pos, idx).toInt();
                            pos = idx + 1;
                      }
                      else {
                            val = pins.substring(pos).toInt();
                      }

                      Serial.print("Setting pin ");
                      Serial.print(pin);
                      Serial.print(" to ");
                      Serial.println(val);
                      Serial.print("\n");
                      pinMode(pin, OUTPUT);
                      digitalWrite(pin, val);
                }

对于实际测试,我们使用从文件中读取的第一个字符串进行测试,解析它以获取输入引脚的值。对于每个引脚,我们首先获取它的编号,然后获取值(01)。

在将这些引脚编号和值回显到串行输出之前,我们将这些引脚的模式设置为输出模式,然后将测试值写入到每个引脚,如下所示:


                delay(10);

                int res_val = digitalRead(result_pin);
                if (res_val != result_val) {
                      Serial.print("Error: got value ");
                      Serial.print(res_val);
                      Serial.println(" on the output.");
                      Serial.print("\n");
                }
                else {
                      Serial.println("Pass.");
                }
          }     

          sdFile.close();
 }

离开内部循环后,所有输入值都将被设置。我们只需稍等片刻,确保 IC 有足够的时间来稳定其新的输出值,然后我们尝试读取其输出引脚上的结果值。

IC 验证是对结果引脚的简单读取,然后将接收到的值与预期值进行比较。然后将此比较的结果打印到串行输出。

测试完成后,我们关闭 IC 文件并返回到中央命令循环,等待下一步指令。

将程序烧录到 Mega 板上并通过串口连接后,我们得到了以下结果:

    Initializing SD card...
    initialization done.
    Commands: index, chip
    > index  

启动后,我们收到了 SD 卡被找到并成功初始化的消息。我们现在可以从 SD 卡中读取。我们还看到了可用的命令。

接下来,我们指定index命令以获取我们可以测试的可用 IC 的概述:

    Available chips:
    4001
    > chip
    Chip name?
    > 4001
    Found IC:
    Name: HEF4001B
    Description: Quad 2-input NOR gate.
    A1-A2: 22-27, Vss: GND, 3A-4B: 28-33, Vdd: 5V
    Type 'start' and press <enter> to start test.
    > start  

只有一个 IC 可用于测试,我们指定chip命令进入 IC 条目菜单,然后输入 IC 的规范。

这将加载我们放在 SD 卡上的文件并打印前三行。然后等待我们连接芯片,按照 Mega 板上的标头编号和 IC 的引脚指示来进行。

确认我们没有搞错任何接线后,我们输入start并确认。这启动了测试:

    Result pin: 24, expecting: 1
    Setting pin 22 to 0
    Setting pin 23 to 0
    Pass.
    Result pin: 24, expecting: 0
    Setting pin 22 to 0
    Setting pin 23 to 1
    Pass.
    Result pin: 24, expecting: 0
    Setting pin 22 to 1
    Setting pin 23 to 0
    [...]
    Result pin: 31, expecting: 0
    Setting pin 33 to 1
    Setting pin 32 to 0
    Pass.
    Result pin: 31, expecting: 0
    Setting pin 33 to 1
    Setting pin 32 to 1
    Pass.
    >  

对于芯片中的四个相同的或门,我们通过相同的真值表运行,测试每个输入组合。这个特定的 IC 通过了测试,并可以安全地用于项目中。

这种测试设备对于测试任何类型的 5V 电平 IC 都是有用的,包括 74 和 4000 逻辑芯片。还可以适应设计,使用 PWM、ADC 和其他引脚来测试输入输出不严格为数字的 IC。

使用 Sming 进行 ESP8266 开发

对于基于 ESP8266 的开发,其创建者(Espressif)没有提供官方的开发工具,除了一个裸机和基于 RTOS 的 SDK。包括 Arduino 在内的开源项目提供了一个更加开发者友好的框架来开发应用程序。在 ESP8266 上,C++的替代品是 Sming(github.com/SmingHub/Sming),它是一个与 Arduino 兼容的框架,类似于我们在前一节中看到的 AVR 的 Nodate。

在下一章(第五章,示例-带 Wi-Fi 的土壤湿度监测器)中,我们将深入研究在 ESP8266 上使用这个框架进行开发。

ARM MCU 开发

与为 AVR MCU 开发并没有太大的不同,除了 C++得到了更好的支持,还有各种工具链可供选择,就像我们在本章开头看到的那样,有许多流行的 IDE。对于 Cortex-M 的 RTOS,可用的列表比 AVR 或 ESP8266 要大得多。

使用包括 GCC 和 LLVM 在内的免费开源编译器来针对广泛的 ARM MCU 架构(基于 Cortex-M 和类似的架构)进行开发,这就是为 ARM MCU 开发提供了很大自由度的地方,同时可以轻松访问完整的 C++ STL(尽管可能需要暂时放弃异常)。

在为 Cortex-M MCU 进行裸机开发时,可能需要添加这个链接器标志来提供一些通常由操作系统提供的基本存根功能:

-specs=nosys.specs 

使得 ARM MCU 不那么吸引人的一点是,标准的板和 MCU 要少得多,就像 AVR 的 Arduino 板一样。尽管 Arduino 基金会曾经推出了基于 SAM3X8E Cortex-M3 MCU 的 Arduino Due 板,但这个板使用了与基于 ATmega2560 的 Arduino Mega 板相同的形式因子和大致相同的引脚布局(只是基于 3.3V I/O 而不是 5V)。

因为这种设计选择,MCU 的许多功能没有被拆分出来,除非一个人非常擅长用焊接铁和细线,否则是无法访问的。这些功能包括以太网连接、数十个 GPIO(数字)引脚等等。同样,Arduino Mega(ATmega2560)板也存在同样的问题,但在这个 Cortex-M MCU 上更加明显。

结果是作为开发和原型板,没有明显的通用选择。人们可能会倾向于只使用相对便宜且丰富的原型板,比如 STMicroelectronics 为其一系列基于 Cortex-M 的 MCU 提供的原型板。

RTOS 的使用

在平均 MCU 上可用的资源有限,而在运行在它们上的应用程序中,通常都是相当简单的处理循环,很难说服人在这些 MCU 上使用 RTOS。直到一个人不得不进行复杂的资源和任务管理时,才会有吸引人使用 RTOS 以节省开发时间的情况。

因此使用 RTOS 的好处主要在于避免重复造轮子。然而,这是一个需要根据具体情况决定的事情。对于大多数项目来说,需要将 RTOS 集成到开发工具链中的可能性更大,而不是一个不切实际的想法,它会增加工作量而不会减轻工作量。

然而,对于一些项目,例如试图在不同的通信和存储接口以及用户界面之间平衡 CPU 时间和系统资源的项目,使用 RTOS 可能是有意义的。

正如我们在本章中看到的,许多嵌入式开发使用简单循环(超级循环)以及许多中断来处理实时任务。在中断函数和超级循环之间共享数据时,开发人员有责任确保安全地进行。

在这里,RTOS 将提供调度程序,甚至可以运行相互隔离的任务(进程)(特别是在具有内存管理单元(MMU)的 MCU 上)。在多核 MCU 上,RTOS 可以轻松地允许用户有效地利用所有核心,而无需自行进行调度。

与所有事物一样,使用 RTOS 并不仅仅是一系列优势的集合。即使忽略了将 RTOS 添加到项目中可能导致的 ROM 和 RAM 空间需求的增加,它也将从根本上改变一些系统交互,并可能导致中断延迟的增加。

这就是为什么,尽管名称中有“实时”,但很难比使用简单的执行循环和一些中断更实时。因此,RTOS 的好处绝对不是可以做出一概而论的事情,特别是当支持裸机编程的库或框架(例如本章中提到的与 Arduino 兼容的库)已经可用于将原型制作和生产开发变得简单,就像将一些现有库绑在一起一样。

总结

在本章中,我们看了如何为新项目选择合适的 MCU,以及如何添加外围设备并处理项目中的以太网和串行接口要求。我们考虑了各种 MCU 中内存的布局以及如何处理堆栈和堆。最后,我们看了一个 AVR 项目的示例,如何为其他 MCU 架构开发,并是否使用 RTOS。

在这一点上,读者应该能够根据一组项目要求来论证为什么选择一个 MCU 而不是另一个。他们应该能够使用 UART 和其他外围设备来实现简单的项目,并了解适当的内存管理以及中断的使用。

在下一章中,我们将深入研究如何为 ESP8266 开发嵌入式项目,该项目将跟踪土壤湿度水平并在需要时控制灌溉泵。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值