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 年停产之前基本保持不变:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

显然,引脚功能早于我们今天所知的通用输入/输出(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 功能块图的简单性,并将其与后续产品进行了比较,如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

即使是在 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):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

定义写入的 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 数据表中所示的功能模块图如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

它与 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):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

除了独立 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:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

最后我们看看 PIC32:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这个块图是 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 的块图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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 的基本框图如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

其规格如下:

  • 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 天线,可以集成到板上或者提供外部天线选项:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

金属屏蔽罩可以保护板子免受电磁干扰的影响,有利于其 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 标准,以及最近的形式因素,如树莓派和衍生板:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

此图表来自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:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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):

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这张图片是一个基于 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系统的框图如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

继电器

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

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

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

去抖动

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

  • 充电:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • 放电:外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

放电时间如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

分别对应 51 和 22 微秒。

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

去抖 HAT

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

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这个原型实现了两个去抖通道,这是项目所需的两个开关。它还添加了一个螺钉端子,用于连接 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)的完整版本是什么样子的:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

此 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 工业电源)都集成在一个单一的激光切割木制外壳中:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

示例 - 基本媒体播放器

基于 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 输出:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

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

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

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

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

总结

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

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值