C--17-嵌入式编程实用指南-全-

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 系列:

系列 引脚 内存 详情
PIC10 6-8 384-896 字节 ROM,64-512 字节 RAM 8 位,8-16 MHz,修改的哈佛结构
PIC12 8 2-16 KB ROM,256 字节 RAM 8 位,16 MHz,修改的哈佛结构
PIC16 8-64 3.5-56 KB ROM,1-4 KB RAM 8 位修改的哈佛结构
PIC17 40-68 4-16 KB ROM,232-454 字节 RAM 8 位,33 MHz,被 PIC18 取代,尽管存在第三方克隆产品。
PIC18 28-100 16-128 KB ROM,3,728-4,096 字节 RAM 8 位修改的哈佛结构
PIC24(dsPIC) 14-144 64-1,024KB ROM,8-16 KB RAM 16 位,DsPIC(dsPIC33)MCU 内置数字信号处理(DSP)外设。
PIC32MX 64-100 32-512 KB ROM,8-32 KB RAM 32 位,200 MHz MIPS M4K 与 MIPS16e 模式,2007 年发布。
PIC32MZ ECPIC32MZ EFPIC32MZ DA 64-288 512-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)。
PIC32MM 20-64 16-256 KB RAM,4-32 KB RAM 32 位 microMIPS,25 MHz,针对低成本和低功耗进行了优化的变体。
PIC32MK 64-100 512-1,024 KB ROM,128-256 KB RAM 32 位,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 系列:

系列 引脚 内存 详情
ATtiny 6-32 0.5-16KB ROM 0-2 KB RAM 1.6-20 MHz。紧凑、节能的 MCU,具有有限的外围设备。
ATmega 32-100 4-256 KB ROM 0.5-32 KB RAM
ATxmega 44-100 16-384 KB ROM, 1-32 KB RAM 32 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 通道 UART ADC 通道
ATmega640 64 4 8 86 12 4 16
ATmega1280 128 4 8 86 12 4 16
ATmega1281 128 4 8 54 6 2 8
ATmega2560 256 4 8 86 12 4 16
ATmega2561 256 4 8 54 6 2 8

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 的简要概述:

核心 宣布 架构 指令集
M0 2009 Armv6-M Thumb-1,部分 Thumb-2。
M0+ 2012 Armv6-M Thumb-1,部分 Thumb-2。
M1 2007 Armv6-M Thumb-1,部分 Thumb-2。
M3 2004 Armv7-M Thumb-1,Thumb-2。
M4 2010 Armv7-M Thumb1,Thumb-2,可选 FPU。
M7 2014 Armv7E-M Thumb-1,Thumb-2,可选 FPU。
M23 2016 Armv8-M Thumb-1,部分 Thumb-2。
M33 2016 Armv8-M Thumb 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 Micro 6502(B+ 6512A) 2 16-128 32-128 最大 1,280(ADFS 软盘)最大 20 MB(硬盘)
MSX Zilog Z80 3.58 8-128 32 720(软盘)
Commodore 64 6510 ~1 64 20 1,000(磁带)170(软盘)
Sinclair ZX81 Zilog Z80 3.58 1 8 15(插卡)
IBM PC Intel 8080 4.77 16-256 8 360(软盘)

现在将这些计算机系统与最近的 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)开发板进行了编译,给出了编译代码的以下文件大小:

Uno Due
C C++ C C++
全局范围数据 614 652 11,184 11,196
主范围数据 664 664 11,200 11,200
四个实例 638 676 11,224 11,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/)描述了这样一种方法,并得出了有趣的发现:

Uno Due
Os O2 Os O2
C 函数调用 10.4 10.2 3.7 3.6
C++直接调用 10.4 10.3 3.8 3.8
C++虚拟调用 11.1 10.9 3.9 3.8
多个 C 调用 110.4 106.3 39.4 35.5
C 函数指针调用 105.7 102.9 38.6 34.9
C++虚拟调用 103.2 100.4 39.5 35.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 社区为基础 GPLv2 ARM(各种开发板) 基于 Debian Linux 的操作系统
Android Google GPLv2,Apache ARM,x86,x86_64 基于 Linux
VxWorks Wind River(英特尔) 专有 ARM,x86,MIPS,PowerPC,SH-4 RTOS,单片内核
QNX BlackBerry 专有 ARMv7,ARMv8,x86 RTOS,微内核
Windows IoT 微软 专有 ARM,x86 以前称为 Windows 嵌入式
NetBSD NetBSD 基金会 2 条款 BSD ARM,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 上使用以下引脚:

锁定开关 状态开关
BCM 17
物理位置 11
WiringPi 0

在初始化 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 及其更大的兄弟。

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

使用较小的嵌入式系统,如微控制器(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 平台开发。

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

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

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

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

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

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

功能规格

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

切割光束通常由 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 连接,这种设计选择会导致一个如图所示的系统:

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

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

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

在这个特定的例子中,可以选择高端单板或双板解决方案,而且很可能需要同样多的努力来满足要求。主要的区别之一是基于 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 Studio Microchip 专有 AVR, SAM (ARM Cortex-M). 最初由 Atmel 开发,后被 Microchip 收购。
µVision Keil (ARM) 专有 ARM Cortex-M, 166, 8051, 251. 微控制器开发套件MDK)工具链的一部分。
嵌入式工作台 IAR 专有 ARM Cortex-M, 8051, MSP430, AVR, Coldfire, STM8, H8, SuperH 等。 每个 MCU 架构都有单独的 IDE。
MPLAB X Microchip 专有 PIC, AVR. 使用基于 Java 的 NetBeans IDE 作为基础。
Arduino Arduino GPLv2 一些 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 设备上受支持。 |

UPDI 1 程序调试 用于较新的 AVR MCU,包括 ATtiny 设备的统一编程和调试接口UDPI)。这是 ATxmega 设备上发现的双线 PDI 的继任者的单线接口。
HVPP/****HVSP 17/****5 程序 高电压并行编程/高电压串行编程。AVR 编程模式使用复位引脚上的 12V 和对 8+引脚的直接访问。忽略任何内部保险丝设置或其他配置选项。主要用于工厂编程和恢复。
TPI 3 程序 用于一些 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 已经集成到电路中,如下图所示:

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

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

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

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

在 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)。这导致以下示例性的内存布局:

使用修改后的哈佛架构(在某个架构级别上分割程序和数据存储器,通常使用数据总线)在 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部分的结束和堆栈的结束之间将留下空间,如下所示:

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

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 功能。

将这视为 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 开发嵌入式项目,该项目将跟踪土壤湿度水平并在需要时控制灌溉泵。

第五章:示例-带 Wi-Fi 的土壤湿度监测器

保持室内植物存活并不是一件小事。本章的示例项目将向您展示如何创建一个具有执行器选项(如泵或类似的阀门和重力供水箱)的 Wi-Fi 土壤湿度监测器。使用内置的 Web 服务器,我们将能够使用其基于浏览器的 UI 来监测植物健康和控制系统功能,或者使用其基于 HTTP 的 REST API 将其集成到更大的系统中。

本章涵盖的主题如下:

  • 编程 ESP8266 微控制器

  • 将传感器和执行器连接到 ESP8266

  • 在这个平台上实现一个 HTTP 服务器

  • 开发用于监测和控制的基于 Web 的 UI

  • 将项目集成到更大的网络中

保持植物快乐

要保持植物存活,你需要一些东西:

  • 营养

  • 光线

其中,前两者通常由富含营养的土壤和将植物放在光照充足的地方来处理。在满足这两点后,保持植物存活的主要问题通常是第三点,因为这需要每天处理。

在这里,不仅仅是简单地保持水位,而是要保持在土壤有足够但不过多水分的范围内。土壤中水分过多会影响植物通过根部吸收氧气的量。因此,土壤中水分过多会导致植物枯萎死亡。

另一方面,水分过少意味着植物无法吸收足够的水来补偿叶子蒸发的水,也无法将养分输送到根部。在这种情况下,植物也会枯萎死亡。

在人工浇水时,我们倾向于粗略估计植物可能需要更多水的时间,以及通过手指对表层土壤的湿度进行肤浅测试。这告诉我们很少关于植物根部下方土壤中实际存在多少水。

为了更精确地测量土壤的湿度,我们可以使用多种方法:

类型 原理 备注
石膏块 电阻—– 水被石膏吸收,溶解了一些石膏,从而允许电流在两个电极之间流动。电阻值表示土壤湿度张力。
张力计 真空 一根空心管的一端有一个真空计,另一端有一个多孔的尖端,允许水自由进出。土壤吸走管中的水会增加真空传感器的读数,表明植物从土壤中提取水分变得更困难(湿度张力)。
电容探针 频域反射计FDR 利用土壤中两个金属电极之间的介电常数在振荡电路中的变化来测量由于湿度变化而引起的这一常数的变化。指示湿度。
微波传感器 时域反射计TDR 测量微波信号传播到并返回平行探针末端所需的时间,这取决于土壤的介电常数。测量湿度。
ThetaProbe 射频幅度阻抗 一个 100 MHz 正弦波无线电信号被发送到包围土壤圆柱体的四个探针之间。正弦波阻抗的变化用于计算土壤中的水分。
电阻探针 电阻 这类似于石膏块,只是有电极。因此,这只能测量水分存在(及其导电性),而不能测量土壤湿度张力。

所有这些传感器类型都有各自的优点和缺点。在石膏块和张力计的情况下,需要进行大量的维护,因为前者依赖于石膏残留量足够溶解而不会影响校准,而在后者的情况下,必须保持密封以防止空气进入管道。这种密封的任何缺口都会立即使真空传感器失效。

另一个重要的问题是成本。虽然基于 FDR 和 TDR 的探头可能非常准确,但它们也往往非常昂贵。这通常导致只是想要尝试土壤湿度传感器的人选择电阻或电容传感器。在这里,前者传感器类型的主要缺点在一个月或更短的使用期内就变得明显:腐蚀。

在一个含有离子的溶液中悬浮着两个电极,并且在其中一个电极上施加电流,简单的化学反应导致其中一个电极迅速腐蚀(失去材料),直到它不再起作用。这也会使土壤受到金属分子的污染。在单个电极上使用交流AC)而不是直流可以在一定程度上减少腐蚀作用,但仍然存在问题。

在便宜而仍然准确的土壤湿度传感器中,只有电容探头符合所有要求。它的准确性足够进行合理的测量和比较(经过校准),不受土壤湿度的影响,也不会对土壤产生任何影响。

要给植物浇水,我们需要有一种方法来给它适量的水。在这里,系统的规模大部分决定了水的输送方式。对于整个田地的灌溉,我们可以使用叶轮泵,能够每分钟输送许多升的水。

对于单个植物,我们需要能够以最多几百毫升每分钟的速度进行供水。在这里,蠕动泵就非常理想。这是你在实验室和医疗应用中也会使用的泵,可以提供高精度的少量流体。

我们的解决方案

为了简化问题,我们只会建造一个可以照顾单个植物的系统。这将为我们提供最大的灵活性,因为无论植物放在窗台、桌子还是露台上,我们只需要在每棵植物旁边放置一个系统。

除了测量土壤湿度水平外,我们还希望系统能够在设定的触发水平自动给植物浇水,并且我们能够监控这个过程。这需要某种网络访问,最好是无线的,这样我们就不必再布置更多的电缆了。

这使得 ESP8266 MCU 非常有吸引力,NodeMCU 开发板是开发和调试系统的理想目标。我们会将一个土壤湿度传感器连接到它上面,还有一个蠕动泵。

通过使用 Web 浏览器连接到 ESP8266 系统的 IP 地址,我们可以看到系统的当前状态,包括土壤湿度水平和其他可选信息。配置系统等操作将通过常用的紧凑二进制 MQTT 协议进行,系统还会发布当前系统状态,以便我们将其读入数据库进行显示和分析。

这样,我们还可以后续编写一个后端服务,将这些节点组合成一个统一的系统,并进行集中控制和管理。这实际上是我们将在第九章中详细讨论的内容,示例-建筑监控和控制

硬件

我们理想的解决方案将具有最准确的传感器,而不会花费太多。这意味着我们基本上必须使用电容传感器,就像我们在本章前面看到的那样。这些传感器可以作为电容土壤湿度传感器获得,价格不到几欧元或美元,用于简单的基于 555 定时器 IC 的设计,如下所示:

您只需将它们插入土壤,直到电路开始的地方,然后将其连接到电源以及连接到 MCU 的模拟到数字转换器。

大多数蠕动泵需要 12V。这意味着我们需要一个可以提供 5V 和 12V 的电源,或者使用所谓的升压转换器将 5V 转换为 12V。无论哪种方式,我们还需要一些方法来打开或关闭泵。使用升压转换器,我们可以使用其使能引脚,通过 MCU 上的 GPIO 引脚来打开或关闭其输出。

对于原型设计,我们可以使用其中一个常见的 5V 到 12V 升压转换器模块,它使用 ME2149 升压开关稳压器:

这些模块没有使能引脚,但我们可以轻松地焊接一根导线到相关引脚上:

然后将这个升压转换器模块的输出连接到蠕动泵:

在这里,我们需要获得一些合适直径的管道,将其连接到水箱和植物。泵本身将旋转任何方向。因为它基本上是内部管道部分上的一组滚轮,它们将液体推入一个方向,泵的任一侧都可以是输入或输出。

一定要事先用两个容器和一些水测试流向,并在泵壳上标出流向,以及使用的正负端子连接。

除了这些组件,我们还想连接一个 RGB LED 进行一些信号传输和外观。为此,我们将使用APA102 RGB LED 模块,它通过 SPI 总线连接到 ESP8266:

我们可以使用单个电源,可以提供 5V 和 1A 或更多的电流,并且可以应对每次泵启动时增压转换器突然的功率需求。

整个系统看起来会像这样:

固件

对于这个项目,我们将在第九章中实现一个模块,示例-建筑监控和控制中使用的相同固件。因此,本章将仅涵盖与此植物浇水模块独特的部分。

在开始编写固件之前,我们首先必须设置开发环境。这涉及安装 ESP8266 SDK 和 Sming 框架。

设置 Sming

基于 Sming 的 ESP8266 开发环境可以在 Linux、Windows 和 macOS 上使用。最好使用 Sming 的开发分支,在 Linux 上使用它是最简单的方法,也是最推荐的方法。在 Linux 上,建议在/opt文件夹中安装,以保持与 Sming 快速入门指南的一致性。

Linux 的快速入门指南可以在github.com/SmingHub/Sming/wiki/Linux-Quickstart找到。

在 Linux 上,我们可以使用 ESP8266 的 Open SDK,它使用官方的 Espressif(非 RTOS)SDK,并用开源替代品替换所有非开源组件。可以使用以下代码进行安装:

    git clone --recursive https://github.com/pfalcon/esp-open-sdk.git
    cd esp-open-sdk
    make VENDOR_SDK=1.5.4 STANDALONE=y  

这将获取当前的 Open SDK 源代码并进行编译,目标是官方 SDK 的 1.5.4 版本。虽然 SDK 的 2.0 版本已经存在,但 Sming 框架内可能存在一些兼容性问题。使用 1.5.4 版本提供了几乎相同的体验,同时使用经过充分测试的代码。当然,随着时间的推移,这将会改变,所以请务必查看官方 Sming 文档以获取更新的说明。

STANDALONE选项意味着 SDK 将作为 SDK 和工具链的独立安装进行构建,没有进一步的依赖关系。这是在使用 Sming 时所期望的选项。

安装Sming就像这样简单:

    git clone https://github.com/SmingHub/Sming.git
    cd Sming
    make  

这将构建 Sming 框架。如果我们在其Libraries文件夹中添加新的库到 Sming 中,我们必须再次执行最后一步,以构建和安装一个新的 Sming 共享库实例。

对于这个项目,将本章软件项目的libs文件夹复制到编译 Sming 之前的Sming/Sming/Libraries文件夹中,否则项目代码将无法编译。

我们还可以使用 SSL 支持编译 Sming。这要求我们使用ENABLE_SSL=1参数对 Make 进行编译。这将使得在整个编译过程中,Sming 库都启用基于 axTLS 的加密支持。

完成这些步骤后,我们只需安装esptool.pyesptool2。在/opt文件夹中,执行以下命令以获取 esptool:

    wget https://github.com/themadinventor/esptool/archive/master.zip
    unzip master.zip
    mv esptool-master esp-open-sdk/esptool  

Esptool.py是一个 Python 脚本,允许我们与每个 ESP8266 模块的 SPI ROM 进行通信。这是我们将用来将 MCU 的 ROM 闪存为我们的代码的方式。这个工具会被 Sming 自动使用:

    cd  $ESP_HOME
    git clone https://github.com/raburton/esptool2
    cd esptool2
    make  

esptool2实用程序是官方 SDK 中一组脚本的替代品,这些脚本将链接器输出转换为我们可以写入 ESP8266 的 ROM 格式。在编译应用程序时,Sming 会调用它。

最后,假设我们在/opt下安装了 SDK 和 Sming,我们可以添加以下全局变量和添加到系统PATH变量中:

    export ESP_HOME=/opt/esp-open-sdk
    export SMING_HOME=/opt/Sming/Sming
    export PATH=$PATH:$ESP_HOME/esptool2
    export PATH=$PATH:$ESP_HOME/xtensa-lx106-elf/bin  

最后一行将工具链的二进制文件添加到路径中,这在调试 ESP8266 应用程序时是必需的,我们将在第七章中看到,测试资源受限平台。在这一点上,我们可以使用 Sming 进行开发,并创建可以写入 MCU 的 ROM 映像。

植物模块代码

在本节中,我们将查看该项目的基本源代码,从核心模块OtaCore开始,继续使用所有固件模块注册的BaseModule类。最后,我们将查看PlantModule类本身,其中包含了我们在本章讨论的项目需求的业务逻辑。

值得注意的是,对于这个项目,我们在项目的 Makefile 中启用了 rBoot 引导管理器和 rBoot 大 Flash 选项。这样做的作用是在我们的 ESP8266 模块上创建 4 个 1MB 的块(我们可用的 4MB ROM 中),其中两个用于固件映像,剩下的两个用于文件存储(使用 SPIFFS 文件系统)。

然后,rBoot 引导加载程序被写入到 ROM 的开头,以便在每次启动时首先加载它。在固件插槽中,任何时候只有一个是活动的。这种设置的一个方便的特性是,它允许我们轻松执行空中OTA)更新,方法是将新的固件映像写入到非活动的固件插槽,更改活动插槽,并重新启动 MCU。如果 rBoot 无法从新的固件映像启动,它将退回到另一个固件插槽,这是我们已知的工作固件,我们从中执行了 OTA 更新。

Makefile-user.mk

project文件夹的根目录中,我们找到了这个 Makefile。它包含了一些设置,我们可能想要根据我们的目的进行设置:

名称 描述
COM_PORT 如果我们总是连接板子到同一个串行端口,我们可以在这里硬编码它,以节省一些输入。
SPI_MODE 在刷写固件映像到 SPI ROM 时设置使用的 SPI 模式。使用 dio 只有两条数据线(SD_D0D1)或四条(SD_D0-3)。并非所有 SPI ROM 都连接了所有四条数据线。qio 模式更快,但 dio 应该总是有效的。
RBOOT_ENABLED 当设置为 1 时,这将启用 rBoot 引导加载程序支持。我们希望启用这个。
RBOOT_BIG_FLASH 有 4MB 的 ROM 可用,我们希望全部使用。也要启用这个。
RBOOT_TWO_ROMS 如果我们希望将两个固件映像放在单个 1MB ROM 芯片中,可以使用此选项。这适用于一些 ESP8266 模块和衍生产品。
SPI_SIZE 在这里,我们设置 SPI ROM 芯片的大小,对于这个项目应该是 4M。
SPIFF_FILES 包含将写入 MCU 的 SPIFFS ROM 映像的文件的文件夹的位置。
SPIFFS_SIZE 要创建的 SPIFFS ROM 映像的大小。这里,64KB 是标准的,但如果需要的话,我们可以在启用RBOOT_BIG_FLASH选项时使用高达 1MB。
WIFI_SSID 我们希望连接的 Wi-Fi 网络的 SSID。
WIFI_PWD Wi-Fi 网络的密码。
MQTT_HOST 要使用的 MQTT 服务器(代理)的 URL 或 IP 地址。
ENABLE_SSL 启用此选项,编译 SSL 支持到 Sming 中,使固件使用与 MQTT 代理的 TLS 加密连接。
MQTT_PORT MQTT 代理的端口。这取决于是否启用了 SSL。
USE_MQTT_PASSWORD 如果希望使用用户名和密码连接到 MQTT 代理,则设置为 true。
MQTT_USERNAME MQTT 代理用户名,如果需要的话。
MQTT_PWD MQTT 代理密码,如果需要的话。
MQTT_PREFIX 可选地在固件使用的每个 MQTT 主题前面添加的前缀,如果需要的话。如果不为空,必须以斜杠结尾。
OTA_URL 每当请求 OTA 更新时固件将使用的硬编码 URL。

其中,Wi-Fi、MQTT 和 OTA 设置是必不可少的,因为它们将允许应用程序连接到网络和 MQTT 代理,并且接收固件更新,而无需通过串行接口刷写 MCU。

Main

主源文件以及应用程序的入口点都非常平凡:

#include "ota_core.h"
void onInit() {
    // 
}
void init() {
         OtaCore::init(onInit);
 }

由于OtaCore类包含了主要的应用逻辑,我们只需调用它的静态初始化函数,同时提供一个回调函数,如果我们希望在核心类完成设置网络、MQTT 和其他功能后执行任何进一步的逻辑。

OtaCore

在这个类中,我们为特定的功能模块设置了所有基本的网络功能,还提供了用于日志记录和 MQTT 功能的实用函数。这个类还包含了通过 MQTT 接收到的命令的主要命令处理器:

#include <user_config.h>
#include <SmingCore/SmingCore.h>

这两个包含是使用 Sming 框架所必需的。通过它们,我们包含了 SDK 的主要头文件(user_config.h)和 Sming 的头文件(SmingCore.h)。这还定义了许多预处理器语句,比如使用开源的轻量级 IP 堆栈LWIP)以及处理官方 SDK 中的一些问题。

还值得注意的是esp_cplusplus.h头文件,它是间接包含的。它的源文件实现了newdelete函数,以及一些与类相关功能的处理程序,比如在使用虚拟类时的vtables。这使得与 STL 兼容:

enum {
          LOG_ERROR = 0,
          LOG_WARNING,
          LOG_INFO,
          LOG_DEBUG,
          LOG_TRACE,
          LOG_XTRACE
 };

 enum ESP8266_pins {
          ESP8266_gpio00 = 0x00001,     // Flash
          ESP8266_gpio01 = 0x00002,     // TXD 0
          ESP8266_gpio02 = 0x00004,     // TXD 1
          ESP8266_gpio03 = 0x00008,     // RXD 0
          ESP8266_gpio04 = 0x00010,     // 
          ESP8266_gpio05 = 0x00020,     // 
          ESP8266_gpio09 = 0x00040,     // SDD2 (QDIO Flash)
          ESP8266_gpio10 = 0x00080,     // SDD3 (QDIO Flash)
          ESP8266_gpio12 = 0x00100,     // HMISO (SDO)
          ESP8266_gpio13 = 0x00200,     // HMOSI (SDI)
          ESP8266_gpio14 = 0x00400,     // SCK
          ESP8266_gpio15 = 0x00800,     // HCS
          ESP8266_gpio16 = 0x01000,     // User, Wake
          ESP8266_mosi = 0x02000,
          ESP8266_miso = 0x04000,
          ESP8266_sclk = 0x08000,
          ESP8266_cs = 0x10000
 };

这两个枚举定义了日志级别,以及我们可能想要使用的 ESP8266 的各个 GPIO 和其他引脚。ESP8266 引脚枚举的值对应于位掩码中的位置:

#define SCL_PIN 5
#define SDA_PIN 4

在这里,我们定义了 I2C 总线的固定引脚。这些对应于 NodeMCU 板上的 GPIO 4 和 5,也被称为D1D2。预定义这些引脚的主要原因是它们是 ESP8266 上为数不多的安全引脚之一。

在启动过程中,ESP8266 的许多引脚在稳定之前会改变电平,这可能会导致任何连接的外围设备出现意外行为。

typedef void (*topicCallback)(String);
typedef void (*onInitCallback)();

我们定义了两个函数指针,一个用于功能模块在希望注册 MQTT 主题时使用,以及一个回调函数。另一个是我们在主函数中看到的回调。


class OtaCore {
         static Timer procTimer;
         static rBootHttpUpdate* otaUpdater;
         static MqttClient* mqtt;
         static String MAC;
         static HashMap<String, topicCallback>* topicCallbacks;
         static HardwareSerial Serial1;
         static String location;
         static String version;
         static int sclPin;
         static int sdaPin;
         static bool i2c_active;
         static bool spi_active;
         static uint32 esp8266_pins;

         static void otaUpdate();
         static void otaUpdate_CallBack(rBootHttpUpdate& update, bool result);
         static void startMqttClient();
         static void checkMQTTDisconnect(TcpClient& client, bool flag);
         static void connectOk(IPAddress ip, IPAddress mask, IPAddress gateway);
         static void connectFail(String ssid, uint8_t ssidLength, uint8_t *bssid,    uint8_t reason);
         static void onMqttReceived(String topic, String message);
         static void updateModules(uint32 input);
         static bool mapGpioToBit(int pin, ESP8266_pins &addr);

public:
         static bool init(onInitCallback cb);
         static bool registerTopic(String topic, topicCallback cb);
         static bool deregisterTopic(String topic);
         static bool publish(String topic, String message, int qos = 1);
         static void log(int level, String msg);
         static String getMAC() { return OtaCore::MAC; }
         static String getLocation() { return OtaCore::location; }
         static bool starti2c();
         static bool startSPI();
         static bool claimPin(ESP8266_pins pin);
         static bool claimPin(int pin);
         static bool releasePin(ESP8266_pins pin);
         static bool releasePin(int pin);
};

类声明本身很好地概述了该类提供的功能。我们注意到的第一件事是它是完全静态的。这确保了当固件启动时立即初始化了该类的功能,并且可以在全局范围内访问,而不必担心特定实例。

我们还可以看到uint32类型的第一次使用,它与其他整数类型一样定义,类似于cstdint头文件中的定义。

接下来是实现部分:

#include <ota_core.h>

#include "base_module.h"

#define SPI_SCLK 14
#define SPI_MOSI 13
#define SPI_MISO 12
#define SPI_CS 15

Timer OtaCore::procTimer;
rBootHttpUpdate* OtaCore::otaUpdater = 0;
MqttClient* OtaCore::mqtt = 0;
String OtaCore::MAC;
HashMap<String, topicCallback>* OtaCore::topicCallbacks = new HashMap<String, topicCallback>();
HardwareSerial OtaCore::Serial1(UART_ID_1); // UART 0 is 'Serial'.
String OtaCore::location;
String OtaCore::version = VERSION;
int OtaCore::sclPin = SCL_PIN; // default.
int OtaCore::sdaPin = SDA_PIN; // default.
bool OtaCore::i2c_active = false;
bool OtaCore::spi_active = false;
uint32 OtaCore::esp8266_pins = 0x0;

我们在这里包含了BaseModule类的头文件,以便在设置基本功能后,我们可以调用其自己的初始化函数。静态类成员也在这里初始化,其中相关的默认值被赋予。

这里值得注意的是除了默认的 Serial 对象实例之外,还初始化了第二个串行接口对象。这对应于 ESP8266 上的第一个(UART0,Serial)和第二个(UART1,Serial1)UART。

在较旧版本的 Sming 中,与二进制数据有关的 SPIFFS 文件函数存在问题(由于内部假定空终止字符串),这就是为什么添加了以下替代函数的原因。它们的命名是原始函数名称的略微倒置版本,以防止命名冲突。

由于 SPIFFS 上存储的 TLS 证书和其他二进制数据文件必须能够被写入和读取,以使固件能够正确运行,这是一个必要的妥协。

String getFileContent(const String fileName) {
         file_t file = fileOpen(fileName.c_str(), eFO_ReadOnly);

         fileSeek(file, 0, eSO_FileEnd);
         int size = fileTell(file);
         if (size <= 0)    {
                fileClose(file);
                return "";
         }

         fileSeek(file, 0, eSO_FileStart);
         char* buffer = new char[size + 1];
         buffer[size] = 0;
         fileRead(file, buffer, size);
         fileClose(file);
         String res(buffer, size);
         delete[] buffer;
         return res;
}

该函数将指定文件的整个内容读入返回的String实例中。

void setFileContent(const String &fileName, const String &content) {
          file_t file = fileOpen(fileName.c_str(),                                                   eFO_CreateNewAlways | eFO_WriteOnly);
          fileWrite(file, content.c_str(), content.length());
          fileClose(file);
 }

该函数用提供的String实例中的新数据替换文件中的现有内容。

bool readIntoFileBuffer(const String filename, char* &buffer, unsigned int &size) {
         file_t file = fileOpen(filename.c_str(), eFO_ReadOnly);

         fileSeek(file, 0, eSO_FileEnd);
         size = fileTell(file);
         if (size == 0)    {
                fileClose(file);
                return true;
         }

         fileSeek(file, 0, eSO_FileStart);
         buffer = new char[size + 1];
         buffer[size] = 0;
         fileRead(file, buffer, size);
         fileClose(file);
         return true;
}

该函数类似于getFileContent(),但返回一个简单的字符缓冲区,而不是一个String实例。它主要用于读取证书数据,该数据传递到基于 C 的 TLS 库(称为 axTLS)中,在那里将其转换为String实例会涉及到复制,尤其是证书可能有几 KB 大小时,这种复制是浪费的。

接下来是该类的初始化函数:

bool OtaCore::init(onInitCallback cb) {
         Serial.begin(9600);

         Serial1.begin(SERIAL_BAUD_RATE); 
         Serial1.systemDebugOutput(true);

我们首先在 NodeMCU 中初始化了两个 UART(串行接口)。尽管 ESP8266 中正式有两个 UART,但第二个仅由 TX 输出线(默认为 GPIO 2)组成。因此,我们希望保持第一个 UART 空闲,以供需要完整串行线的应用程序使用,比如一些传感器。

因此,首个 UART(Serial)被初始化,以便我们以后可以将其与功能模块一起使用,而第二个 UART(Serial1)被初始化为默认波特率 115,200,系统的调试输出(WiFi/IP 堆栈等)也被定向到此串行输出。因此,这第二个串行接口将仅用于日志输出。

         BaseModule::init(); 

接下来,BaseModule静态类也被初始化。这使得在该固件中激活的所有功能模块都被注册,从而可以在以后激活它们。

         int slot = rboot_get_current_rom();
         u32_t offset;
         if (slot == 0) { offset = 0x100000; }
         else { offset = 0x300000; }
         spiffs_mount_manual(offset, 65536);

在使用 rBoot 引导加载程序时自动挂载 SPIFFS 文件系统在较旧版本的 Sming 中无法正常工作,这就是为什么我们在这里手动执行它的原因。为此,我们从 rBoot 获取当前固件槽,然后我们可以选择适当的偏移量,可以是在 ROM 中的第二兆字节的开头,也可以是第四兆字节的开头。

确定了偏移量后,我们使用 SPIFFS 手动挂载函数以及我们的偏移量和 SPIFFS 部分的大小。现在我们可以读写我们的存储空间了。


          Serial1.printf("\r\nSDK: v%s\r\n", system_get_sdk_version());
     Serial1.printf("Free Heap: %d\r\n", system_get_free_heap_size());
     Serial1.printf("CPU Frequency: %d MHz\r\n", system_get_cpu_freq());
     Serial1.printf("System Chip ID: %x\r\n", system_get_chip_id());
     Serial1.printf("SPI Flash ID: %x\r\n", spi_flash_get_id());

接下来,我们在串行调试输出中打印出一些系统详细信息。这包括我们编译的 ESP8266 SDK 版本、当前的空闲堆大小、CPU 频率、MCU ID(32 位 ID)和 SPI ROM 芯片的 ID。

         mqtt = new MqttClient(MQTT_HOST, MQTT_PORT, onMqttReceived);

我们在堆上创建一个新的 MQTT 客户端,提供一个回调函数,当我们接收到新消息时将被调用。MQTT 代理主机和端口由预处理器填充,从用户为项目添加的细节中获取。


         Serial1.printf("\r\nCurrently running rom %d.\r\n", slot);

         WifiStation.enable(true);
         WifiStation.config(WIFI_SSID, WIFI_PWD);
         WifiStation.connect();
         WifiAccessPoint.enable(false);

        WifiEvents.onStationGotIP(OtaCore::connectOk);
        WifiEvents.onStationDisconnect(OtaCore::connectFail);

          (*cb)();
}

作为初始化的最后步骤,我们输出当前固件槽,然后启用 Wi-Fi 客户端,同时禁用无线接入点(WAP)功能。Wi-Fi 客户端被告知连接到我们在之前的 Makefile 中指定的 Wi-Fi SSID 和凭据。

最后,我们定义了成功的 WiFi 连接和连接尝试失败的处理程序,然后调用我们作为参数提供的回调函数。

固件 OTA 更新后,将调用以下回调函数:


void OtaCore::otaUpdate_CallBack(rBootHttpUpdate& update, bool result) {
         OtaCore::log(LOG_INFO, "In OTA callback...");
         if (result == true) { // success
               uint8 slot = rboot_get_current_rom();
               if (slot == 0) { slot = 1; } else { slot = 0; }

               Serial1.printf("Firmware updated, rebooting to ROM slot %d...\r\n",                                                                                                                        slot);
               OtaCore::log(LOG_INFO, "Firmware updated, restarting...");
               rboot_set_current_rom(slot);
               System.restart();
         } 
         else {
               OtaCore::log(LOG_ERROR, "Firmware update failed.");
         }
}

在这个回调中,如果 OTA 更新成功,我们会更改活动的 ROM 槽,然后重新启动系统。否则,我们只是记录一个错误,不重新启动。

接下来是一些与 MQTT 相关的函数:

bool OtaCore::registerTopic(String topic, topicCallback cb) {
         OtaCore::mqtt->subscribe(topic);
         (*topicCallbacks)[topic] = cb;
         return true;
}

bool OtaCore::deregisterTopic(String topic) {
         OtaCore::mqtt->unsubscribe(topic);
         if (topicCallbacks->contains(topic)) {
               topicCallbacks->remove(topic);
         }

         return true;
}

这两个函数分别允许特性模块注册和注销一个 MQTT 主题以及回调函数。MQTT 代理通过订阅或取消订阅请求进行调用,并相应地更新HashMap实例:

bool OtaCore::publish(String topic, String message, int qos /* = 1 */) {
         OtaCore::mqtt->publishWithQoS(topic, message, qos);
         return true;
}

任何特性模块都可以使用此函数在任何主题上发布 MQTT 消息。服务质量(QoS)参数确定发布模式。默认情况下,消息以retain模式发布,这意味着代理将保留特定主题的最后一条发布消息。

OTA 更新功能的入口点在以下函数中找到:

void OtaCore::otaUpdate() {
         OtaCore::log(LOG_INFO, "Updating firmware from URL: " + String(OTA_URL));

         if (otaUpdater) { delete otaUpdater; }
         otaUpdater = new rBootHttpUpdate();

         rboot_config bootconf = rboot_get_config();
         uint8 slot = bootconf.current_rom;
         if (slot == 0) { slot = 1; } else { slot = 0; }

         otaUpdater->addItem(bootconf.roms[slot], OTA_URL + MAC);

         otaUpdater->setCallback(OtaCore::otaUpdate_CallBack);
         otaUpdater->start();
}

对于 OTA 更新,我们需要创建一个干净的rBootHttpUpdate实例。然后,我们需要使用 rBoot 获取当前固件槽的详细信息,并从中获取当前固件槽号。我们使用这个号码将另一个固件槽的号码提供给 OTA 更新程序。

在这里,我们只配置它来更新固件槽,但我们也可以以这种方式更新其他固件槽的 SPIFFS 部分。固件将通过 HTTP 从我们之前设置的固定 URL 获取。ESP8266 的 MAC 地址将作为唯一的查询字符串参数附加到 URL 的末尾,以便更新服务器知道哪个固件映像适合这个系统。

在设置了我们之前查看的callback函数之后,我们开始更新:

void OtaCore::checkMQTTDisconnect(TcpClient& client, bool flag) {
         if (flag == true) { Serial1.println("MQTT Broker disconnected."); }
         else { 
               String tHost = MQTT_HOST;
               Serial1.println("MQTT Broker " + tHost + " unreachable."); }

         procTimer.initializeMs(2 * 1000, OtaCore::startMqttClient).start();
}

在这里,我们定义了 MQTT 断开连接处理程序。每当与 MQTT 代理的连接失败时,都会调用它,以便我们可以在两秒延迟后尝试重新连接。

如果之前已连接,则将标志参数设置为 true,如果初始 MQTT 代理连接失败(无网络访问、错误的地址等),则设置为 false。

接下来是配置和启动 MQTT 客户端的函数:

void OtaCore::startMqttClient() {
         procTimer.stop();
         if (!mqtt->setWill("last/will",                                 "The connection from this device is lost:(",    1, true)) {
               debugf("Unable to set the last will and testament. Most probably there is not enough memory on the device.");
         }

如果我们是从重新连接定时器调用的,我们会停止 procTimer 定时器。接下来,我们为该设备设置遗嘱(LWT),这允许我们设置一个消息,当 MQTT 代理与客户端(我们)失去连接时,代理将发布该消息。

接下来,我们定义了三条不同的执行路径,只有其中一条将被编译,取决于我们是否使用 TLS(SSL)、用户名/密码登录或匿名访问:

#ifdef ENABLE_SSL
         mqtt->connect(MAC, MQTT_USERNAME, MQTT_PWD, true);
         mqtt->addSslOptions(SSL_SERVER_VERIFY_LATER);

       Serial1.printf("Free Heap: %d\r\n", system_get_free_heap_size());

         if (!fileExist("esp8266.client.crt.binary")) {
               Serial1.println("SSL CRT file is missing: esp8266.client.crt.binary.");
               return;
         }
         else if (!fileExist("esp8266.client.key.binary")) {
               Serial1.println("SSL key file is missing: esp8266.client.key.binary.");
               return;
         }

         unsigned int crtLength, keyLength;
         char* crtFile;
         char* keyFile;
         readIntoFileBuffer("esp8266.client.crt.binary", crtFile, crtLength);
         readIntoFileBuffer("esp8266.client.key.binary", keyFile, keyLength);

         Serial1.printf("keyLength: %d, crtLength: %d.\n", keyLength, crtLength);
         Serial1.printf("Free Heap: %d\r\n", system_get_free_heap_size());

         if (crtLength < 1 || keyLength < 1) {
               Serial1.println("Failed to open certificate and/or key file.");
               return;
         }

         mqtt->setSslClientKeyCert((const uint8_t*) keyFile, keyLength,
                                                (const uint8_t*) crtFile, crtLength, 0, true);
         delete[] keyFile;
         delete[] crtFile;

    Serial1.printf("Free Heap: %d\r\n", system_get_free_heap_size());

如果我们使用 TLS 证书,我们将使用我们的MAC作为客户端标识符与 MQTT 代理建立连接,然后为连接启用 SSL 选项。可用的堆空间将被打印到串行日志输出以进行调试。通常,在这一点上,我们应该还剩下大约 25KB 的 RAM,这足以在内存中保存证书和密钥,以及 TLS 握手的 RX 和 TX 缓冲区,如果后者使用 SSL 分段大小选项配置为可接受的大小。我们将在第九章中更详细地讨论这个问题,示例-建筑管理和控制

接下来,我们从 SPIFFS 中读取 DER 编码(二进制)证书和密钥文件。这些文件有固定的名称。对于每个文件,我们都会打印出文件大小,以及当前的空闲堆大小。如果任一文件大小为零字节,我们将认为读取尝试失败,并中止连接尝试。

否则,我们将使用密钥和证书数据进行 MQTT 连接,这应该导致成功的握手并与 MQTT 代理建立加密连接。

在删除密钥和证书文件数据后,我们打印出空闲堆大小,以便我们可以检查清理是否成功:

#elif defined USE_MQTT_PASSWORD
          mqtt->connect(MAC, MQTT_USERNAME, MQTT_PWD);

当使用 MQTT 用户名和密码登录代理时,我们只需要在 MQTT 客户端实例上调用先前的函数,提供我们的 MAC 作为客户端标识符,以及用户名和密码。

#else
         mqtt->connect(MAC);
#endif

要匿名连接,我们与代理建立连接,并将我们的MAC作为客户端标识符传递:

         mqtt->setCompleteDelegate(checkMQTTDisconnect);

         mqtt->subscribe(MQTT_PREFIX"upgrade");
         mqtt->subscribe(MQTT_PREFIX"presence/tell");
         mqtt->subscribe(MQTT_PREFIX"presence/ping");
         mqtt->subscribe(MQTT_PREFIX"presence/restart/#");
         mqtt->subscribe(MQTT_PREFIX"cc/" + MAC);

         delay(100);

         mqtt->publish(MQTT_PREFIX"cc/config", MAC);
}

在这里,我们首先设置了 MQTT 断开处理程序。然后,我们订阅了一些我们希望响应的主题。所有这些都与此固件的管理功能有关,允许系统通过 MQTT 进行查询和配置。

订阅后,我们稍微(100 毫秒)等待,以便代理有时间处理这些订阅,然后我们在中央通知主题上发布,使用我们的MAC来让任何感兴趣的客户端和服务器知道这个系统刚刚上线。

接下来是 WiFi 连接处理程序:

void OtaCore::connectOk(IPAddress ip, IPAddress mask, IPAddress gateway) {
          Serial1.println("I'm CONNECTED. IP: " + ip.toString());

          MAC = WifiStation.getMAC();
          Serial1.printf("MAC: %s.\n", MAC.c_str());

          if (fileExist("location.txt")) {
                location = getFileContent("location.txt");
          }
          else {
                location = MAC;
          }

          if (fileExist("config.txt")) {
                String configStr = getFileContent("config.txt");
                uint32 config;
                configStr.getBytes((unsigned char*) &config, sizeof(uint32), 0);
                updateModules(config);
          }

          startMqttClient();
 }

当我们成功使用提供的凭据连接到配置的 WiFi 网络时,将调用此处理程序。连接后,我们将MAC的副本保存在内存中作为我们的唯一 ID。

此固件还支持指定用户定义的字符串作为我们的位置或类似标识符。如果之前已定义了一个,我们将从 SPIFFS 加载它并使用它;否则,我们的位置字符串就是MAC

同样,如果存在,我们会从 SPIFFS 加载定义特征模块配置的 32 位位掩码。如果不存在,所有特征模块最初都处于未激活状态。否则,我们读取位掩码并将其传递给updateModules()函数,以便激活相关模块:

void OtaCore::connectFail(String ssid, uint8_t ssidLength, 
                                                   uint8_t* bssid, uint8_t reason) {
          Serial1.println("I'm NOT CONNECTED. Need help :(");
          debugf("Disconnected from %s. Reason: %d", ssid.c_str(), reason);

          WDT.alive();

          WifiEvents.onStationGotIP(OtaCore::connectOk);
          WifiEvents.onStationDisconnect(OtaCore::connectFail);
 }

如果连接到 WiFi 网络失败,我们会记录这一事实,然后告诉 MCU 的看门狗定时器我们仍然活着,以防止在我们再次尝试连接之前发生软重启。

这完成了所有的初始化函数。接下来是在正常活动期间使用的函数,从 MQTT 消息处理程序开始:

void OtaCore::onMqttReceived(String topic, String message) {
         Serial1.print(topic);
         Serial1.print(":\n");
         Serial1.println(message);

         log(LOG_DEBUG, topic + " - " + message);

         if (topic == MQTT_PREFIX"upgrade" && message == MAC) {
                otaUpdate();
         }
         else if (topic == MQTT_PREFIX"presence/tell") {
                mqtt->publish(MQTT_PREFIX"presence/response", MAC);
         }
         else if (topic == MQTT_PREFIX"presence/ping") {
               mqtt->publish(MQTT_PREFIX"presence/pong", MAC);
         }
         else if (topic == MQTT_PREFIX"presence/restart" && message == MAC) {
               System.restart();
         }
         else if (topic == MQTT_PREFIX"presence/restart/all") {
               System.restart();
         }

我们在最初创建 MQTT 客户端实例时注册了此回调。每当我们订阅的主题在代理上接收到新消息时,我们都会收到通知,并且此回调会接收一个包含主题的字符串和另一个包含实际消息(有效载荷)的字符串。

我们可以将主题与我们注册的主题进行比较,并执行所需的操作,无论是执行 OTA 更新(如果指定了我们的MAC),通过返回带有我们的MAC的 pong 响应来响应 ping 请求,还是重新启动系统。

下一个主题是一个更通用的维护主题,允许配置活动特征模块,设置位置字符串,并请求系统的当前状态。有效负载格式由命令字符串后跟一个分号,然后是有效负载字符串组成:

   else if (topic == MQTT_PREFIX"cc/" + MAC) {
          int chAt = message.indexOf(';');
          String cmd = message.substring(0, chAt);
          ++chAt;

          String msg(((char*) &message[chAt]), (message.length() - chAt));

          log(LOG_DEBUG, msg);

          Serial1.printf("Command: %s, Message: ", cmd.c_str());
          Serial1.println(msg);

我们首先使用简单的查找和子字符串方法从有效负载字符串中提取命令。然后,我们读取剩余的有效负载字符串,注意以二进制字符串形式读取。为此,我们使用剩余字符串的长度,并将分号后的字符作为起始位置。

在这一点上,我们已经提取了命令和有效负载,并可以看到我们需要做什么:


         if (cmd == "mod") {
               if (msg.length() != 4) {
                     Serial1.printf("Payload size wasn't 4 bytes: %d\n", msg.length());
                     return; 
               }

               uint32 input;
               msg.getBytes((unsigned char*) &input, sizeof(uint32), 0);
               String byteStr;
               byteStr = "Received new configuration: ";
               byteStr += input;
               log(LOG_DEBUG, byteStr);
               updateModules(input);               
          }

此命令设置应该激活哪些特征模块。其有效负载应该是一个无符号 32 位整数形成的位掩码,我们检查以确保我们确实收到了四个字节。

在位掩码中,每个位与一个模块相匹配,这些模块目前是以下这些:

位位置
0x01 THPModule
0x02 CO2Module
0x04 JuraModule
0x08 JuraTermModule
0x10 MotionModule
0x20 PwmModule
0x40 IOModule
0x80 SwitchModule
0x100 PlantModule

其中,CO2、Jura 和 JuraTerm 模块是互斥的,因为它们都使用第一个 UART(Serial)。如果在位掩码中仍然指定了其中两个或更多个,只有第一个模块将被启用,其他模块将被忽略。我们将在第九章中更详细地查看这些其他特征模块,示例-建筑管理和控制

在获取新的配置位掩码后,我们将其发送到updateModules()函数:

        else if (cmd == "loc") {
               if (msg.length() < 1) { return; }
               if (location != msg) {
                     location = msg;
                     fileSetContent("location.txt", location);
               }
         }

使用此命令,如果新位置字符串与当前位置字符串不同,则设置新的位置字符串,并将其保存到 SPIFFS 中的位置文件中,以便在重新启动时保持:

         else if (cmd == "mod_active") {
               uint32 active_mods = BaseModule::activeMods();
               if (active_mods == 0) {
                     mqtt->publish(MQTT_PREFIX"cc/response", MAC + ";0");
                     return;
               }

               mqtt->publish(MQTT_PREFIX"cc/response", MAC + ";"                                                         + String((const char*) &active_mods, 4));
         }
         else if (cmd == "version") {
               mqtt->publish(MQTT_PREFIX"cc/response", MAC + ";" + version);
         }
         else if (cmd == "upgrade") {
               otaUpdate();
         }
   }

这一部分的最后三个命令返回活动特征模块的当前位掩码、固件版本,并触发 OTA 升级:

         else {
               if (topicCallbacks->contains(topic)) {
                     (*((*topicCallbacks)[topic]))(message);
                }
         }
}

if...else块中的最后一个条目查看主题是否可能在我们的特征模块回调列表中找到。如果找到,将使用 MQTT 消息字符串调用回调。

这意味着只有一个特征模块可以向特定主题注册自己。由于每个模块倾向于在自己的 MQTT 子主题下运行以分隔消息流,这通常不是问题:

void OtaCore::updateModules(uint32 input) {
         Serial1.printf("Input: %x, Active: %x.\n", input, BaseModule::activeMods());

         BaseModule::newConfig(input);

         if (BaseModule::activeMods() != input) {
               String content(((char*) &input), 4);
               setFileContent("config.txt", content);
         }
}

这个函数非常简单。它主要作为BaseModule类的一个传递,但它还确保我们保持 SPIFFS 中的配置文件是最新的,在更改时将新的位掩码写入其中。

我们绝对必须防止对 SPIFFs 的不必要写入,因为底层闪存存储具有有限的写入周期。限制写入周期可以显著延长硬件的使用寿命,同时减少整个系统的负载:

bool OtaCore::mapGpioToBit(int pin, ESP8266_pins &addr) {
          switch (pin) {
                case 0:
                      addr = ESP8266_gpio00;
                      break;
                case 1:
                      addr = ESP8266_gpio01;
                      break;
                case 2:
                      addr = ESP8266_gpio02;
                      break;
                case 3:
                      addr = ESP8266_gpio03;
                      break;
                case 4:
                      addr = ESP8266_gpio04;
                      break;
                case 5:
                      addr = ESP8266_gpio05;
                      break;
                case 9:
                      addr = ESP8266_gpio09;
                      break;
                case 10:
                      addr = ESP8266_gpio10;
                      break;
                case 12:
                      addr = ESP8266_gpio12;
                      break;
                case 13:
                      addr = ESP8266_gpio13;
                      break;
                case 14:
                      addr = ESP8266_gpio14;
                      break;
                case 15:
                      addr = ESP8266_gpio15;
                      break;
                case 16:
                      addr = ESP8266_gpio16;
                      break;
                default:
                      log(LOG_ERROR, "Invalid pin number specified: " + String(pin));
                      return false;
          };

          return true;
 }

此函数将给定的 GPIO 引脚号映射到其在内部位掩码中的位置。它使用我们为此类的头文件查看的枚举。有了这个映射,我们可以使用一个单一的 uint32 值设置 ESP8266 模块的 GPIO 引脚的使用/未使用状态:

void OtaCore::log(int level, String msg) {
         String out(lvl);
         out += " - " + msg;

         Serial1.println(out);
         mqtt->publish(MQTT_PREFIX"log/all", OtaCore::MAC + ";" + out);
}

在日志记录方法中,我们在将消息字符串写入串行输出之前将日志级别附加到消息字符串,并在 MQTT 上发布它。在这里,我们在一个单一主题上发布,但作为改进,您可以根据指定的级别在不同主题上记录。

这里的合理性取决于您设置的用于侦听和处理运行此固件的 ESP8266 系统的日志输出的后端类型:

bool OtaCore::starti2c() {
         if (i2c_active) { return true; }

         if (!claimPin(sdaPin)) { return false; }
         if (!claimPin(sclPin)) { return false; }

         Wire.pins(sdaPin, sclPin);
         pinMode(sclPin, OUTPUT);
         for (int i = 0; i < 8; ++i) {
               digitalWrite(sclPin, HIGH);
               delayMicroseconds(3);
               digitalWrite(sclPin, LOW);
               delayMicroseconds(3);
         }

         pinMode(sclPin, INPUT);

         Wire.begin();
         i2c_active = true;
}

如果 I2C 总线尚未启动,此函数将启动它。它尝试注册它希望用于 I2C 总线的引脚。如果这些引脚可用,它将将时钟线(SCL)设置为输出模式,并首先脉冲它八次以解冻总线上的任何 I2C 设备。

在像这样脉冲时钟线后,我们在引脚上启动 I2C 总线,并记录此总线的活动状态。

如果 MCU 断电时 I2C 设备没有断电并保持在不确定状态,可能会发生冻结的 I2C 设备。通过这种脉冲,我们确保系统不会陷入非功能状态,需要手动干预:

bool OtaCore::startSPI() {
    if (spi_active) { return true; }

    if (!claimPin(SPI_SCLK)) { return false; }
    if (!claimPin(SPI_MOSI)) { return false; }
    if (!claimPin(SPI_MISO)) { return false; }
    if (!claimPin(SPI_CS)) { return false; }

    SPI.begin();
    spi_active = true;
 }

启动 SPI 总线类似于启动 I2C 总线,但没有类似的恢复机制:

bool OtaCore::claimPin(int pin) {
          ESP8266_pins addr;
          if (!mapGpioToBit(pin, addr)) { return false; }

          return claimPin(addr);
    }

    bool OtaCore::claimPin(ESP8266_pins pin) {
          if (esp8266_pins & pin) {
                log(LOG_ERROR, "Attempting to claim an already claimed pin: "                                                                                                      + String(pin));
                log(LOG_DEBUG, String("Current claimed pins: ") + String(esp8266_pins));
                return false;
          }

          log(LOG_INFO, "Claiming pin position: " + String(pin));

          esp8266_pins |= pin;

          log(LOG_DEBUG, String("Claimed pin configuration: ") + String(esp8266_pins));

          return true;
 }

这个重载函数用于在启动之前由特征模块注册 GPIO 引脚,以确保没有两个模块同时使用相同的引脚。一个版本接受引脚号(GPIO),并使用我们之前查看的映射函数来获取esp8266_pins位掩码中的位地址,然后将其传递给函数的另一个版本。

在该函数中,引脚枚举用于进行按位AND比较。如果位尚未设置,则切换并返回 true。否则,函数返回 false,调用模块知道它无法继续初始化:

bool OtaCore::releasePin(int pin) {
          ESP8266_pins addr;
          if (!mapGpioToBit(pin, addr)) { return false; }

          return releasePin(addr);
    }

    bool OtaCore::releasePin(ESP8266_pins pin) {
          if (!(esp8266_pins & pin)) {
                log(LOG_ERROR, "Attempting to release a pin which has not been set: "                                                                                                      + String(pin));
                return false;
          }

          esp8266_pins &= ~pin;

          log(LOG_INFO, "Released pin position: " + String(pin));
          log(LOG_DEBUG, String("Claimed pin configuration: ") + String(esp8266_pins));

          return true;
 }

这个重载函数用于在特征模块关闭时释放引脚,工作方式类似。一个使用映射函数获取位地址,另一个执行按位AND操作来检查引脚是否已经设置,并使用按位OR赋值运算符将其切换到关闭位置。

BaseModule

这个类包含了注册和跟踪当前活动或非活动特征模块的逻辑。其头文件如下所示:

#include "ota_core.h"

enum ModuleIndex {
   MOD_IDX_TEMPERATURE_HUMIDITY = 0,
   MOD_IDX_CO2,
   MOD_IDX_JURA,
   MOD_IDX_JURATERM,
   MOD_IDX_MOTION,
   MOD_IDX_PWM,
   MOD_IDX_IO,
   MOD_IDX_SWITCH,
   MOD_IDX_PLANT
};

typedef bool (*modStart)();
typedef bool (*modShutdown)();

包含OtaCore头文件是为了让我们能够使用日志记录功能。此外,我们创建另一个枚举,将特定特征模块映射到特征模块位掩码(active_mods)中的特定位。

最后,定义了函数指针,分别用于启动和关闭特征模块。这些将由特征模块在注册自己时定义:

#include "thp_module.h"
#include "jura_module.h"
#include "juraterm_module.h"
#include "co2_module.h"
#include "motion_module.h"
#include "pwm_module.h"
#include "io_module.h"
#include "switch_module.h"
#include "plant_module.h"

这些是目前存在于该固件中的特征模块。由于我们只需要植物模块用于这个项目,我们可以注释掉所有其他模块的头文件,以及它们在该类的初始化函数中的初始化。

这不会影响生成的固件映像,除了我们不能启用那些模块,因为它们不存在。

最后,这里是类声明本身:

class BaseModule {   
         struct SubModule {
               modStart start;
               modShutdown shutdown;
               ModuleIndex index;
               uint32 bitmask;
               bool started;
         };

         static SubModule modules[32];
         static uint32 active_mods;
         static bool initialized;
         static uint8 modcount;

public:
         static void init();
         static bool registerModule(ModuleIndex index, modStart start,                                                                                    modShutdown shutdown);

         static bool newConfig(uint32 config);
         static uint32 activeMods() { return active_mods; }
};

每个特征模块在内部由一个SubModule实例表示,我们可以在类定义中看到其详细信息:

#include "base_module.h"

BaseModule::SubModule BaseModule::modules[32];
uint32 BaseModule::active_mods = 0x0;
bool BaseModule::initialized = false;
uint8 BaseModule::modcount = 0;

由于这是一个静态类,我们首先初始化其类变量。我们有一个数组,可以容纳 32 个SubModule实例,以适应完整的位掩码。此外,没有模块是活动的,所以一切都初始化为零和假:

void BaseModule::init() {
    CO2Module::initialize();
    IOModule::initialize();
    JuraModule::initialize();
    JuraTermModule::initialize();
    MotionModule::initialize();
    PlantModule::initialize();
    PwmModule::initialize();
    SwitchModule::initialize();
    THPModule::initialize();
}

当我们在OtaCore中调用此函数时,我们还触发了在此处定义的特征模块的注册。通过在此函数中有选择地删除或注释掉模块,我们可以将它们从最终的固件映像中移除。在这里调用的那些模块将调用以下函数来注册自己:

bool BaseModule::registerModule(ModuleIndex index, modStart start, modShutdown shutdown) {
         if (!initialized) {
               for (uint8 i = 0; i < 32; i++) {
                     modules[i].start = 0;
                     modules[i].shutdown = 0;
                     modules[i].index = index;
                     modules[i].bitmask = (1 << i);
                     modules[i].started = false;
               }

               initialized = true;
         }

         if (modules[index].start) {
               return false;
         }

         modules[index].start = start;
         modules[index].shutdown = shutdown;
         ++modcount;

         return true;
}

调用此函数的第一个特征模块将触发SubModule数组的初始化,将其所有值设置为中性设置,同时为数组中的此位置创建位掩码,这允许我们更新active_mods位掩码,我们将在一会儿看到。

初始化数组后,我们检查数组中的这个位置是否已经有模块为其注册。如果有,我们返回 false。否则,在返回 true 之前,我们注册模块的启动和关闭函数指针,并增加活动模块计数:

bool BaseModule::newConfig(uint32 config) {
    OtaCore::log(LOG_DEBUG, String("Mod count: ") + String(modcount));
    uint32 new_config = config ^ active_mods;
    if (new_config == 0x0) {
        OtaCore::log(LOG_INFO, "New configuration was 0x0\. No 
        change.");
        return true; 
    }
    OtaCore::log(LOG_INFO, "New configuration: " + new_config);
    for (uint8 i = 0; i < 32; ++i) {
        if (new_config & (1 << i)) {
            OtaCore::log(LOG_DEBUG, String("Toggling module: ") + 
            String(i));
            if (modules[i].started) { 
                if ((modules[i]).shutdown()) { 
                    modules[i].started = false; 
                    active_mods ^= modules[i].bitmask;
                }
                else { 
                    OtaCore::log(LOG_ERROR, "Failed to shutdown 
                    module.");
                    return false; 
                }
            }
            else { 
                if ((modules[i].start) && (modules[i]).start()) { 
                    modules[i].started = true;
                    active_mods |= modules[i].bitmask;
                }
                else { 
                    OtaCore::log(LOG_ERROR, "Failed to start module.");
                    return false;
                }
            }
        }
    }
    return true;
 }

该函数的输入参数是我们从OtaCore中提取的 MQTT 有效载荷中的位掩码。在这里,我们使用按位异或比较与活动模块位掩码,以获得指示要进行的任何更改的新位掩码。如果结果为零,我们知道它们是相同的,我们可以返回而无需进一步操作。

因此,我们获得的uint32位掩码指示应该打开或关闭哪些模块。为此,我们检查掩码的每一位。如果它是1(AND 运算符返回一个不为零的值),我们检查数组中该位置的模块是否存在并且是否已经启动。

如果模块已启动,我们尝试关闭它。如果模块的 shutdown()函数成功(返回 true),我们切换active_mods位掩码中的位以更新其状态。同样,如果模块尚未启动,模块已经在该位置注册,我们尝试启动它,如果成功,更新活动模块。

我们检查是否已注册启动函数回调,以确保我们不会意外调用未正确注册的模块并使系统崩溃。

PlantModule

到目前为止,我们已经详细查看了在编写新模块时使生活变得轻松的支持代码。因为我们不必自己做所有的杂务。我们还没有看到的唯一的事情是一个实际的模块,或者直接与本章项目有关的代码。

在这一部分,我们将看一下谜题的最后一部分,即PlantModule本身:

#include "base_module.h"
#include <Libraries/APA102/apa102.h>

#define PLANT_GPIO_PIN 5
#define NUM_APA102 1

class PlantModule {
         static int pin;
         static Timer timer;
         static uint16 humidityTrigger;
         static String publishTopic;
         static HttpServer server;
         static APA102* LED;

         static void onRequest(HttpRequest& request, HttpResponse& response);

public:
         static bool initialize();
         static bool start();
         static bool shutdown();
         static void readSensor();
         static void commandCallback(String message);
};

在这个类声明中需要注意的是包含了 APA102 库头文件。这是一个简单的库,允许我们将颜色和亮度数据写入 APA102 RGB(全光谱)LED,通过 SPI 总线。

我们还定义了我们希望用来触发蠕动泵(GPIO 5)的引脚以及连接的 APA102 LED 模块的数量(1)。如果需要,您可以串联多个 APA102 LED,只需更新定义以匹配计数。

接下来是类的实现:

#include "plant_module.h"

int PlantModule::pin = PLANT_GPIO_PIN;
Timer PlantModule::timer;
uint16 PlantModule::humidityTrigger = 530;
String PlantModule::publishTopic;
HttpServer PlantModule::server;
APA102* PlantModule::LED = 0;

enum {
         PLANT_SOIL_MOISTURE = 0x01,
         PLANT_SET_TRIGGER = 0x02,
         PLANT_TRIGGER = 0x04
};

在这一部分,我们初始化静态类成员,设置 GPIO 引脚并定义触发泵应该触发的初始传感器值。应该更新此触发值以匹配您自己的传感器校准结果。

最后,我们定义一个包含可以通过 MQTT 发送到该模块的可能命令的枚举:

bool PlantModule::initialize() {
          BaseModule::registerModule(MOD_IDX_PLANT, PlantModule::start,                                                                                                                 PlantModule::shutdown);
}

这是BaseModule在启动时调用的初始化函数。正如我们所看到的,它导致该模块使用预设值注册自身,包括其启动和关闭回调:

bool PlantModule::start() {
         OtaCore::log(LOG_INFO, "Plant Module starting...");

         if (!OtaCore::claimPin(pin)) { return false; }

         publishTopic = MQTT_PREFIX + "plant/response/" + OtaCore::getLocation();
         OtaCore::registerTopic(MQTT_PREFIX + String("plants/") +                                                             OtaCore::getLocation(), PlantModule::commandCallback);

         pinMode(pin, OUTPUT);

         server.listen(80);
         server.setDefaultHandler(PlantModule::onRequest);

         LED = new APA102(NUM_APA102);
         LED->setBrightness(15);
         LED->clear();
         LED->setAllPixel(0, 255, 0);
         LED->show();

         timer.initializeMs(60000, PlantModule::readSensor).start();
         return true;
}

当此模块启动时,我们尝试声明我们希望用于触发泵的引脚,并注册一个 MQTT 主题的回调,以便我们可以使用命令处理程序回调接受命令。在此还定义了在处理完命令后我们将响应的主题。

设置输出引脚模式,然后在端口 80 上启动 HTTP 服务器,注册客户端请求的基本处理程序。接下来,我们创建一个新的APA102类实例,并使用它使连接的 LED 显示绿色,亮度约为全亮度的一半。

最后,我们启动一个定时器,每分钟触发一次连接的土壤传感器的读数:

bool PlantModule::shutdown() {
         if (!OtaCore::releasePin(pin)) { return false; }

         server.shutdown();

         if (LED) {
               delete LED;
               LED = 0;
         }

         OtaCore::deregisterTopic(MQTT_PREFIX + String("plants/") +                                                                                            OtaCore::getLocation());

         timer.stop();
         return true;
}

关闭此模块时,我们释放先前注册的引脚,停止 Web 服务器,删除 RGB LED 类实例(检查是否需要删除它),注销我们的 MQTT 主题,最后停止传感器定时器。


void PlantModule::commandCallback(String message) {
         OtaCore::log(LOG_DEBUG, "Plant command: " + message);

         if (message.length() < 1) { return; }
         int index = 0;
         uint8 cmd = *((uint8*) &message[index++]);

         if (cmd == PLANT_SOIL_MOISTURE) {
               readSensor();
         }
         else if (cmd == PLANT_SET_TRIGGER) {               
                if (message.length() != 3) { return; }
               uint16 payload = *((uint16*) &message[index]);
               index += 2;

               humidityTrigger = payload;
         }
         else if (cmd == PLANT_TRIGGER) {
               OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" 
                                                                + String(((char*) &humidityTrigger), 2));
         }
}

每当我们在注册的 MQTT 主题上发布消息时,都会调用此回调。在我们的消息中,我们期望找到一个定义命令的单个字节(uint8)值,最多八个不同的命令。对于此模块,我们之前定义了三个命令。

这些命令的定义如下:

命令 意义 有效载荷 返回值
0x01 获取土壤湿度 - 0xXXXX
0x02 设置触发级别 uint16(新触发级别) -
0x04 获取触发级别 - 0xXXXX

在这里,每个命令返回请求的值(如果适用)。

在检查我们得到的消息字符串至少有一个字节后,我们提取第一个字节,并尝试将其解释为一个命令。如果我们正在设置一个新的触发点,我们还会从消息中提取新值作为 uint16,然后确保我们有一个格式正确的消息。

最后,这是一个函数,在这个项目中我们一直在努力实现的所有魔术都发生在这个函数中:

void PlantModule::readSensor() {
    int16_t val = 0;
    val = analogRead(A0); // calls system_adc_read().

    String response = OtaCore::getLocation() + ";" + val;
    OtaCore::publish(MQTT_PREFIX"nsa/plant/moisture_raw", response);

作为第一步,我们从 ESP8266 的模拟输入中读取当前传感器值,并将其发布到 MQTT 主题上:

        if (val >= humidityTrigger) {
               digitalWrite(pin, HIGH);

               LED->setBrightness(31);
               LED->setAllPixel(0, 0, 255);
               LED->show();

               for (int i = 0; i < 10; ++i) {
                     LED->directWrite(0, 0, 255, 25);
                     delay(200);
                     LED->directWrite(0, 0, 255, 18);
                     delay(200);
                     LED->directWrite(0, 0, 255, 12);
                     delay(200);
                     LED->directWrite(0, 0, 255, 5);
                     delay(200);
                     LED->directWrite(0, 0, 255, 31);
                     delay(200);
               }

               digitalWrite(pin, LOW);
         }
}

在校准一个带有土壤湿度传感器的原型机时,发现完全干燥的传感器(悬空)的值约为 766,而将相同的传感器浸入水中得到 379 的值。由此,我们可以推断出 60%的含水量大约应该在 533 左右的读数,这与我们在静态初始化步骤中设置的初始值相匹配。理想的触发点和目标土壤湿度水平当然取决于土壤类型和具体植物。

当达到这个触发电平时,我们将设置连接到升压转换器使能引脚的输出引脚为高电平,导致其打开输出,从而启动泵。我们希望让它泵送大约十秒钟。

在此期间,我们将 LED 颜色设置为蓝色,然后在每秒钟内将其亮度从 100%降低到几乎关闭,然后再次提高到全亮度,从而产生脉动效果。

完成后,我们将输出引脚设置回低电平,从而禁用泵,并等待下一个土壤湿度传感器读数:

void PlantModule::onRequest(HttpRequest& request, HttpResponse& response) {
         TemplateFileStream* tmpl = new TemplateFileStream("index.html");
         TemplateVariables& vars = tmpl->variables();
         int16_t val = analogRead(A0);
         int8_t perc = 100 - ((val - 379) / 3.87);
         vars["raw_value"] = String(val);
         vars["percentage"] = String(perc);

         response.sendTemplate(tmpl);
}

最后,我们在这里看到了我们的 Web 服务器的请求处理程序。它的作用是从 SPIFFS 中读取模板文件(在下一节中详细介绍),获取此模板文件中的变量列表,然后继续读取当前传感器值。

使用这个值,它计算当前土壤湿度百分比,并使用原始和计算出的数字填充模板中的两个变量,然后返回它。

Index.html

为了与 PlantModule 的 Web 服务器配合使用,我们必须将以下模板文件添加到 SPIFFS 中:

<!DOCTYPE html>
<html>
<head>
         <title>Plant soil moisture readings</title>
   </head>
   <body>
         Current value: {raw_value}<br>
         Percentage: {percentage}%
</body>
</html>

编译和刷写

完成应用程序的代码后,我们可以在项目的根目录中用一个命令编译它:

make  

完成后,我们可以在out文件夹中找到包括 ROM 映像在内的二进制文件。由于我们同时使用 rBoot 引导加载程序和 SPIFFs,因此在firmware文件夹中总共有三个 ROM 映像。

此时,我们可以连接一个 ESP8266 模块,可以是 NodeMCU 板或其他许多替代品,并注意它将连接到的串行端口。在 Windows 上,这将类似于COM3;在 Linux 上,USB 转串口适配器通常注册为/dev/ttyUSB0或类似的。

除非我们在用户 Makefile 中指定了串行端口(COM_PORT),否则在刷写到 ESP8266 模块时,我们必须明确指定它:

    make flash COM_PORT=/dev/ttyUSB0  

执行完这个命令后,我们应该看到esptool.py实用程序的输出,因为它连接到 ESP8266 的 ROM 并开始将 ROM 映像写入其中。

完成后,MCU 将重新启动,并且应该直接启动到新的固件映像中,等待我们的命令来配置它。

首次配置

正如本章前面所述,这个固件是设计为通过 MQTT 进行配置和维护的。这需要有一个 MQTT 代理可用。像 Mosquitto(mosquitto.org/)这样的 MQTT 代理很受欢迎。由于它是一个轻量级服务器,它可以安装在桌面系统、小型 SBC、虚拟机等内部。

除了代理和运行固件的 ESP8266 外,我们还需要我们自己的客户端

与固件交互。由于我们使用二进制协议,我们在那里的选择有些受限

有限,因为大多数常见的 MQTT 桌面客户端都假定是基于文本的消息。一个

发布二进制消息的一种方法是使用 MQTT 发布客户端,

使用 Mosquitto 附带的echo命令行工具的十六进制输入来发送

二进制数据作为流由客户端工具发布

因此,本书的作者开发了一个新的 MQTT 桌面客户端(基于 C++和 Qt),旨在围绕 MQTT 上的二进制协议的使用和调试:github.com/MayaPosch/MQTTCute

有了这三个组件——运行该项目的 ESP8266、MQTT 代理和桌面客户端——我们可以组装整个植物监测和浇水系统,并发送命令以启用植物模块。

在监视 cc/config 主题以获取消息时,我们应该看到 ESP8266 通过发布其MAC来报告其存在。我们也可以通过将 USB 连接到 TTL 串行适配器并连接到串行日志输出引脚(NodeMCU 上的D4)来获得这一点。通过查看串行控制台上的输出,我们将看到系统的 IP 地址和MAC

当我们组成一个新的主题格式为cc/<MAC>时,我们可以发布命令到固件,例如:

    log;plant001  

这将把系统的位置名称设置为plant001

在使用 MQTTCute 客户端时,我们可以使用回声式二进制输入,使用十六进制输入来激活植物模块:

mod;\x00\x01\x00\x00  

这将向固件发送mod命令,以及值为 0 x 100 的位掩码。之后,植物模块应该被激活并运行。由于我们持久化了位置字符串和配置,除非我们进行 OTA 更新,否则我们不必再重复这一步骤,此时新的固件将具有一个空的 SPIFFS 文件系统,除非我们在 ROM 的两个 SPIFFS 插槽上都刷入相同的 SPIFFS 映像。

在这里,我们可以扩展 OTA 代码,除了固件之外还可以下载 SPIFFS ROM 映像,尽管这可能会增加可能覆盖现有 SPIFFS 文件的复杂性。

在这一点上,我们应该有一个工作的植物监测和浇水系统。

使用系统

我们可以使用测量值并将其存储在数据库中,通过订阅nsa/plant/moisture_raw主题。可以通过向plant/<位置字符串>主题发送新命令来调整触发点。

设备上的 Web 服务器可以通过获取 IP 地址来访问,我们可以通过查看串行控制台上的输出(如前一节所述),或者查看路由器中的活动 IP 地址来找到 IP 地址。

通过在浏览器中打开此 IP 地址,我们应该可以看到 HTML 模板填充了当前的值。

进一步进行

您还需要考虑以下事项:

  • 在这一点上,您可以通过实施植物浇水配置来进一步完善系统,以增加干旱期或调整特定土壤类型。您可以添加新的 RGB LED 模式,充分利用可用的颜色选择。

  • 整个硬件可以构建成一个外壳,使其融入背景,或者使其更加可见。

  • Web 界面可以扩展以允许从浏览器控制触发点等,而不必使用 MQTT 客户端。

  • 除了湿度传感器,您还可以添加亮度传感器、温度传感器等,以测量影响植物健康的更多方面。

  • 作为额外加分项,您可以自动施加(液体)肥料到植物上。

复杂性

您可能会遇到的 ESP8266 的 ADC 的一个可能的复杂性是,在 NodeMCU 板上,紧邻 ADC 引脚的第一个保留(RSV)引脚直接连接到 ESP8266 模块的 ADC 输入。这可能会导致静电放电 ESD 暴露的问题。基本上是将高电压,但低电流的放电输入到 MCU。在这个 RSV 引脚上添加一个小电容到地可以帮助减少这种风险。

这个系统显然无法帮助你保持植物免受害虫侵害。这意味着尽管浇水可能是自动的,但这并不意味着你可以忽视植物。定期检查植物是否有任何问题,以及系统是否存在任何可能正在发展的问题(断开的管道,因猫而倒下的东西等)仍然是一项重要任务。

总结

在本章中,我们看了如何将基于简单 ESP8266 的项目从理论和简单要求转变为一个功能设计,具有多功能固件和一系列输入和输出选项,使用这些选项我们可以确保连接的植物得到恰到好处的水分以保持健康。我们还看到了如何为 ESP8266 建立开发环境。

读者现在应该能够为 ESP8266 创建项目,用新固件对 MCU 进行编程,并对这个开发平台的优势和局限性有一个扎实的掌握。

在下一章中,我们将学习如何测试为 SoCs 和其他大型嵌入式平台编写的嵌入式软件。

第二部分:测试,监控

在本节中,您将学习为各种嵌入式平台开发的正确工作流程,包括测试策略和编写可移植代码的重要性。

本节将涵盖以下章节:

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

  • 第七章,测试资源受限平台

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

  • 第九章,示例 - 建立监控和控制

第六章:测试基于操作系统的应用程序

通常,嵌入式系统使用更或多或少常规的操作系统(OS),这意味着在运行时环境和工具方面,嵌入式 Linux 的目标与我们的桌面 OS 大致相同。然而,嵌入式硬件与我们的 PC 在性能和访问方面的差异使得必须考虑在哪里执行开发和测试的各个部分,以及如何将其整合到我们的开发工作流程中。

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

  • 开发跨平台代码

  • 在 Linux 下调试和测试跨平台代码

  • 有效使用交叉编译器

  • 创建支持多个目标的构建系统

避免真实硬件

在嵌入式 Linux 等平台上进行基于操作系统的开发的最大优势之一是它与常规桌面 Linux 安装非常相似。特别是在 SoC 上运行像基于 Debian 的 Linux 发行版(Armbian、Raspbian 等)时,我们几乎可以使用相同的工具,只需按几下键即可获得整个软件包管理器、编译器集合和库。

然而,这也是它最大的缺点。

我们可以编写代码,将其复制到 SBC 上,在那里进行编译、运行测试,并在重复该过程之前对代码进行更改。或者,我们甚至可以在 SBC 上编写代码,基本上将其用作我们唯一的开发平台。

我们绝对不应该这样做的主要原因如下:

  • 现代 PC 速度更快。

  • 在开发的最后阶段之前,不应该在真实硬件上进行测试。

  • 自动集成测试变得更加困难。

第一个观点似乎很明显。单核或双核 ARM SoC 编译需要大约一分钟的时间,而在相对现代的多核、多线程处理器(3+ GHz)和支持多核编译的工具链下,从编译开始到链接对象只需要十秒钟或更短的时间。

这意味着,我们不必等待半分钟或更长时间才能运行新的测试或开始调试会话,几乎可以立即进行。

接下来的两点是相关的。虽然在真实硬件上进行测试似乎是有利的,但它也带来了自己的复杂性。其中一点是,这些硬件依赖于许多外部因素才能正常工作,包括其电源供应、电源之间的任何布线、外围设备和信号接口。诸如电磁干扰之类的事物也可能引起问题,包括信号衰减以及由于电磁耦合而触发的中断。

在第三章的俱乐部状态服务项目开发过程中,出现了电磁耦合的一个例子,为嵌入式 Linux 和类似系统开发。在这里,开关的一个信号线与 230V 交流电线并排。这些主线布线上电流的变化在信号线上引起脉冲,导致虚假的中断触发事件。

所有这些潜在的与硬件相关的问题表明,这些测试并不像我们希望的那样确定。这可能导致项目开发时间比计划的要长得多,由于冲突和非确定性的测试结果,调试变得更加复杂。

专注于在真实硬件上进行开发的一个影响是,这使得自动化测试变得更加困难。原因在于我们无法使用任何通用的构建集群,例如基于 Linux VM 的测试环境,这在主流的持续集成(CI)服务中很常见。

与此相反,我们必须以某种方式将诸如 SBC 之类的东西整合到 CI 系统中,使其可以交叉编译并将二进制文件复制到 SBC 上进行测试,或者在 SBC 上进行编译,这又回到了第一个观点。

在接下来的几节中,我们将探讨一些方法,使基于嵌入式 Linux 的开发尽可能轻松,从交叉编译开始。

为 SBC 进行交叉编译

编译过程将源文件转换为中间格式,然后可以使用此格式来针对特定的 CPU 架构。对我们来说,这意味着我们不仅仅局限于在 SBC 上为 SBC 编译应用程序,而是可以在我们的开发 PC 上进行编译。

要为树莓派(Broadcom Cortex-A 架构的 ARM SoC)这样的 SBC 进行此操作,我们需要安装arm-linux-gnueabihf工具链,该工具链针对具有硬件浮点(hardware floating point)支持的 ARM 架构,输出 Linux 兼容的二进制文件。

在基于 Debian 的 Linux 系统上,我们可以使用以下命令安装整个工具链:

sudo apt install build-essential
sudo apt install g++-arm-linux-gnueabihf
sudo apt install gdb-multiarch  

第一条命令安装了系统的本机基于 GCC 的工具链(如果尚未安装),以及任何常见的相关工具和实用程序,包括makelibtoolflex等。第二条命令安装了实际的交叉编译器。最后,第三个软件包是支持多种架构的 GDB 调试器的版本,我们以后需要用它来在真实硬件上进行远程调试,以及分析应用程序崩溃时产生的核心转储。

我们现在可以在命令行上使用 g++编译器为目标 SBC 使用其完整名称:

arm-linux-gnueabihf-g++  

为了测试工具链是否正确安装,我们可以执行以下命令,这应该告诉我们编译器的详细信息,包括版本:

arm-linux-gnueabihf-g++ -v  

除此之外,我们可能需要链接一些存在于目标系统上的共享库。为此,我们可以复制/lib/usr文件夹的全部内容,并将其包含为编译器的系统根的一部分:

mkdir ~/raspberry/sysroot
scp -r pi@Pi-system:/lib ~/raspberry/sysroot
scp -r pi@Pi-system:/usr ~/raspberry/sysroot  

在这里,Pi-system是树莓派或类似系统的 IP 地址或网络名称。之后,我们可以告诉 GCC 使用这些文件夹,而不是使用标准路径,使用sysroot标志:

--sysroot=dir  

这里的dir将是我们将这些文件夹复制到的文件夹,在这个例子中将是~/raspberry/sysroot

或者,我们可以只复制所需的头文件和库文件,并将它们添加为源树的一部分。哪种方法最容易主要取决于所涉及项目的依赖关系。

对于俱乐部状态服务项目,我们至少需要 WiringPi 的头文件和库,以及 POCO 项目及其依赖项的头文件和库。我们可以确定我们需要的依赖关系,并复制我们之前安装的工具链中缺少的所需包含和库文件。除非有迫切需要这样做,否则最容易的方法是直接从 SBC 的操作系统中复制整个文件夹。

作为使用sysroot方法的替代方案,我们还可以在链接代码时明确定义我们希望使用的共享库的路径。当然,这也有其自身的优缺点。

俱乐部状态服务的集成测试

为了在进行交叉编译并在真实硬件上测试之前,在常规桌面 Linux(或 macOS 或 Windows)系统上测试俱乐部状态服务,编写了一个简单的集成测试,该测试使用 GPIO 和 I2C 外围设备的模拟。

在第三章中涵盖的项目的源代码中,为嵌入式 Linux 和类似系统开发,这些外围设备的文件位于该项目的wiring文件夹中。

我们从wiringPi.h头文件开始:

#include <Poco/Timer.h>

#define  INPUT              0
#define  OUTPUT                   1
#define  PWM_OUTPUT         2
#define  GPIO_CLOCK         3
#define  SOFT_PWM_OUTPUT          4
#define  SOFT_TONE_OUTPUT   5
#define  PWM_TONE_OUTPUT          6

我们包含了 POCO 框架的一个头文件,以便我们稍后可以轻松创建一个定时器实例。然后,我们定义了所有可能的引脚模式,就像实际的 WiringPi 头文件定义的那样:

#define  LOW                0
#define  HIGH               1

#define  PUD_OFF                  0
#define  PUD_DOWN           1
#define  PUD_UP                   2

#define  INT_EDGE_SETUP          0
#define  INT_EDGE_FALLING  1
#define  INT_EDGE_RISING         2
#define  INT_EDGE_BOTH           3

这些定义进一步定义了引脚模式,包括数字输入电平,引脚上上拉和下拉的可能状态,最后是中断的可能类型,定义中断的触发器:

typedef void (*ISRCB)(void); 

这个typedef定义了中断回调函数指针的格式。

现在让我们看一下WiringTimer类:

class WiringTimer {
    Poco::Timer* wiringTimer;
    Poco::TimerCallback<WiringTimer>* cb;
    uint8_t triggerCnt;

 public:
    ISRCB isrcb_0;
    ISRCB isrcb_7;
    bool isr_0_set;
    bool isr_7_set;

    WiringTimer();
    ~WiringTimer();
    void start();
    void trigger(Poco::Timer &t);
 };

该类是我们模拟实现的 GPIO 端的重要部分。其主要目的是跟踪我们感兴趣的两个中断是否已注册,并使用定时器定期触发它们,正如我们将在下一刻看到的:

int wiringPiSetup(); 
void pinMode(int pin, int mode); 
void pullUpDnControl(int pin, int pud); 
int digitalRead(int pin);
int wiringPiISR(int pin, int mode, void (*function)(void));

最后,在继续实现之前,我们定义标准的 WiringPi 函数:

#include "wiringPi.h"

#include <fstream>
#include <memory>

WiringTimer::WiringTimer() {
   triggerCnt = 0;
   isrcb_0 = 0;
   isrcb_7 = 0;
   isr_0_set = false;
   isr_7_set = false;

   wiringTimer = new Poco::Timer(10 * 1000, 10 * 1000);
   cb = new Poco::TimerCallback<WiringTimer>(*this, 
   &WiringTimer::trigger);
}

在类构造函数中,我们在创建定时器实例之前设置默认值,并将其配置为在初始 10 秒延迟后每十秒调用我们的回调函数一次:

WiringTimer::~WiringTimer() {
   delete wiringTimer;
   delete cb;
}

在析构函数中,我们删除了定时器回调实例:

void WiringTimer::start() {
   wiringTimer->start(*cb);
}

在这个函数中,我们实际上启动了定时器:

void WiringTimer::trigger(Poco::Timer &t) {
    if (triggerCnt == 0) {
          char val = 0x00;
          std::ofstream PIN0VAL;
          PIN0VAL.open("pin0val", std::ios_base::binary | std::ios_base::trunc);
          PIN0VAL.put(val);
          PIN0VAL.close();

          isrcb_0();

          ++triggerCnt;
    }
    else if (triggerCnt == 1) {
          char val = 0x01;
          std::ofstream PIN7VAL;
          PIN7VAL.open("pin7val", std::ios_base::binary | std::ios_base::trunc);
          PIN7VAL.put(val);
          PIN7VAL.close();

          isrcb_7();

          ++triggerCnt;
    }
    else if (triggerCnt == 2) {
          char val = 0x00;
          std::ofstream PIN7VAL;
          PIN7VAL.open("pin7val", std::ios_base::binary | std::ios_base::trunc);
          PIN7VAL.put(val);
          PIN7VAL.close();

          isrcb_7();

          ++triggerCnt;
    }
    else if (triggerCnt == 3) {
          char val = 0x01;
          std::ofstream PIN0VAL;
          PIN0VAL.open("pin0val", std::ios_base::binary | std::ios_base::trunc);
          PIN0VAL.put(val);
          PIN0VAL.close();

          isrcb_0();

          triggerCnt = 0;
    }
 }

该类中的最后一个函数是定时器的回调函数。它的功能是跟踪触发的次数,并将适当的引脚电平设置为我们写入磁盘的文件中的值。

在初始延迟之后,第一个触发器将将锁定开关设置为false,第二个将状态开关设置为true,第三个将状态开关设置回false,最后第四个触发器将锁定开关设置回true,然后重置计数器并重新开始:

namespace Wiring {
   std::unique_ptr<WiringTimer> wt;
   bool initialized = false;
}

我们在其中添加了一个全局命名空间,其中有一个WiringTimer类实例的unique_ptr实例,以及一个初始化状态指示器。

int wiringPiSetup() {
    char val = 0x01;
    std::ofstream PIN0VAL;
    std::ofstream PIN7VAL;
    PIN0VAL.open("pin0val", std::ios_base::binary | std::ios_base::trunc);
    PIN7VAL.open("pin7val", std::ios_base::binary | std::ios_base::trunc);
    PIN0VAL.put(val);
    val = 0x00;
    PIN7VAL.put(val);
    PIN0VAL.close();
    PIN7VAL.close();

    Wiring::wt = std::make_unique<WiringTimer>();
    Wiring::initialized = true;

    return 0;
 }

设置函数用于将模拟 GPIO 引脚输入值的默认值写入磁盘。我们还在这里创建了一个WiringTimer实例的指针:

 void pinMode(int pin, int mode) {
    // 

    return;
 }

 void pullUpDnControl(int pin, int pud) {
    // 

    return;
 }

由于我们的模拟实现确定了引脚的行为,我们可以忽略这些函数的任何输入。为了测试目的,我们可以添加一个断言来验证这些函数在适当的时间以及具有适当的设置被调用:

 int digitalRead(int pin) {
    if (pin == 0) {
          std::ifstream PIN0VAL;
          PIN0VAL.open("pin0val", std::ios_base::binary);
          int val = PIN0VAL.get();
          PIN0VAL.close();

          return val;
    }
    else if (pin == 7) {
          std::ifstream PIN7VAL;
          PIN7VAL.open("pin7val", std::ios_base::binary);
          int val = PIN7VAL.get();
          PIN7VAL.close();

          return val;
    }

    return 0;
 }

在读取两个模拟引脚之一的值时,我们打开其相应的文件并读取其内容,这是由设置函数或回调设置的 1 或 0:

//This value is then returned to the calling function.

 int wiringPiISR(int pin, int mode, void (*function)(void)) {
    if (!Wiring::initialized) { 
          return 1;
    }

    if (pin == 0) { 
          Wiring::wt->isrcb_0 = function;
          Wiring::wt->isr_0_set = true;
    }
    else if (pin == 7) {
          Wiring::wt->isrcb_7 = function;
          Wiring::wt->isr_7_set = true;
    }

    if (Wiring::wt->isr_0_set && Wiring::wt->isr_7_set) {
          Wiring::wt->start();
    }

    return 0;
 }

此函数用于注册中断及其关联的回调函数。在通过设置函数初始化模拟后,我们继续注册两个指定引脚中的一个的中断。

一旦两个引脚都设置了中断,我们就启动定时器,定时器将开始生成中断回调的事件。

接下来是 I2C 总线模拟:

int wiringPiI2CSetup(const int devId);
int wiringPiI2CWriteReg8(int fd, int reg, int data);

这里我们只需要两个函数:设置函数和简单的一字节寄存器写入函数。

实现如下:

#include "wiringPiI2C.h"

#include "../club.h"

#include <Poco/NumberFormatter.h>

using namespace Poco;

int wiringPiI2CSetup(const int devId) {
   Club::log(LOG_INFO, "wiringPiI2CSetup: setting up device ID: 0x" 
                                        + NumberFormatter::formatHex(devId));

   return 0;
}

在设置函数中,我们记录请求的设备 ID(I2C 总线地址),并返回一个标准设备句柄。在这里,我们使用Club类中的log()函数,使模拟集成到其余代码中:

int wiringPiI2CWriteReg8(int fd, int reg, int data) {
    Club::log(LOG_INFO, "wiringPiI2CWriteReg8: Device handle 0x" + NumberFormatter::formatHex(fd) 
                                        + ", Register 0x" + NumberFormatter::formatHex(reg)
                                        + " set to: 0x" + NumberFormatter::formatHex(data));

    return 0;
}

由于调用此函数的代码不会期望除了简单的确认数据已被接收之外的响应,我们可以在这里记录接收到的数据和更多细节。同样,为了一致性,这里也使用了 POCO 的NumberFormatter类来格式化整数数据为十六进制值,就像在应用程序中一样。

现在我们编译项目并使用以下命令行命令:

make TEST=1  

现在运行应用程序(在 GDB 下,以查看何时创建/销毁新线程)会得到以下输出:

 Starting ClubStatus server...
 Initialised C++ Mosquitto library.
 Created listener, entering loop...
 [New Thread 0x7ffff49c9700 (LWP 35462)]
 [New Thread 0x7ffff41c8700 (LWP 35463)]
 [New Thread 0x7ffff39c7700 (LWP 35464)]
 Initialised the HTTP server.
 INFO:       Club: starting up...
 INFO:       Club: Finished wiringPi setup.
 INFO:       Club: Finished configuring pins.
 INFO:       Club: Configured interrupts.
 [New Thread 0x7ffff31c6700 (LWP 35465)]
 INFO:       Club: Started update thread.
 Connected. Subscribing to topics...
 INFO:       ClubUpdater: Starting i2c relay device.
 INFO:       wiringPiI2CSetup: setting up device ID: 0x20
 INFO:       wiringPiI2CWriteReg8: Device handle 0x0, Register 0x6 set to: 0x0
 INFO:       wiringPiI2CWriteReg8: Device handle 0x0, Register 0x2 set to: 0x0
 INFO:       ClubUpdater: Finished configuring the i2c relay device's registers.  

此时,系统已配置所有中断并由应用程序配置了 I2C 设备。定时器已经开始了初始倒计时:

 INFO:       ClubUpdater: starting initial update run.
 INFO:       ClubUpdater: New lights, clubstatus off.
 DEBUG:      ClubUpdater: Power timer not active, using current power state: off
 INFO:       ClubUpdater: Red on.
 DEBUG:      ClubUpdater: Changing output register to: 0x8
 INFO:       wiringPiI2CWriteReg8: Device handle 0x0, Register 0x2 set to: 0x8
 DEBUG:      ClubUpdater: Finished writing relay outputs with: 0x8
 INFO:       ClubUpdater: Initial status update complete.  

GPIO 引脚的初始状态已被读取,两个开关都处于“关闭”位置,因此我们通过将其位置写入寄存器来激活交通灯指示灯上的红灯:

 INFO:       ClubUpdater: Entering waiting condition. INFO:       ClubUpdater: lock status changed to unlocked
 INFO:       ClubUpdater: New lights, clubstatus off.
 DEBUG:      ClubUpdater: Power timer not active, using current power state: off
 INFO:       ClubUpdater: Yellow on.
 DEBUG:      ClubUpdater: Changing output register to: 0x4
 INFO:       wiringPiI2CWriteReg8: Device handle 0x0, Register 0x2 set to: 0x4
 DEBUG:      ClubUpdater: Finished writing relay outputs with: 0x4
 INFO:       ClubUpdater: status switch status changed to on
 INFO:       ClubUpdater: Opening club.
 INFO:       ClubUpdater: Started power timer...
 DEBUG:      ClubUpdater: Sent MQTT message.
 INFO:       ClubUpdater: New lights, clubstatus on.
 DEBUG:      ClubUpdater: Power timer active, inverting power state from: on
 INFO:       ClubUpdater: Green on.
 DEBUG:      ClubUpdater: Changing output register to: 0x2
 INFO:       wiringPiI2CWriteReg8: Device handle 0x0, Register 0x2 set to: 0x2
 DEBUG:      ClubUpdater: Finished writing relay outputs with: 0x2
 INFO:       ClubUpdater: status switch status changed to off
 INFO:       ClubUpdater: Closing club.
 INFO:       ClubUpdater: Started timer.
 INFO:       ClubUpdater: Started power timer...
 DEBUG:      ClubUpdater: Sent MQTT message.
 INFO:       ClubUpdater: New lights, clubstatus off.
 DEBUG:      ClubUpdater: Power timer active, inverting power state from: off
 INFO:       ClubUpdater: Yellow on.
 DEBUG:      ClubUpdater: Changing output register to: 0x5
 INFO:       wiringPiI2CWriteReg8: Device handle 0x0, Register 0x2 set to: 0x5
 DEBUG:      ClubUpdater: Finished writing relay outputs with: 0x5
 INFO:       ClubUpdater: setPowerState called.
 DEBUG:      ClubUpdater: Writing relay with: 0x4
 INFO:       wiringPiI2CWriteReg8: Device handle 0x0, Register 0x2 set to: 0x4
 DEBUG:      ClubUpdater: Finished writing relay outputs with: 0x4
 DEBUG:      ClubUpdater: Written relay outputs.
 DEBUG:      ClubUpdater: Finished setPowerState.
 INFO:       ClubUpdater: lock status changed to locked
 INFO:       ClubUpdater: New lights, clubstatus off.
 DEBUG:      ClubUpdater: Power timer not active, using current power state: off
 INFO:       ClubUpdater: Red on.
 DEBUG:      ClubUpdater: Changing output register to: 0x8
 INFO:       wiringPiI2CWriteReg8: Device handle 0x0, Register 0x2 set to: 0x8
 DEBUG:      ClubUpdater: Finished writing relay outputs with: 0x8  

接下来,定时器开始触发回调函数,导致它经历不同的阶段。这使我们能够确定代码的基本功能是正确的。

在这一点上,我们可以开始实施更复杂的测试用例,甚至可以使用嵌入式 Lua、Python 运行时或类似的工具来实施可编写脚本的测试用例。

模拟与硬件

在模拟大段代码和硬件外设时,一个明显的问题是最终模拟的结果有多现实。显然,我们希望在将测试移至目标系统之前,能够尽可能多地覆盖真实场景的集成测试。

如果我们想知道我们希望在模拟中覆盖哪些测试用例,我们必须同时查看我们的项目需求(它应该能够处理什么)以及真实场景中可能发生的情况和输入。

为此,我们将分析底层代码,看看可能发生什么情况,并决定哪些情况对我们来说是相关的。

在我们之前查看的 WiringPi 模拟中,快速查看库实现的源代码就清楚地表明,与我们将在目标系统上使用的版本相比,我们简化了我们的代码。

查看基本的 WiringPi 设置函数,我们看到它执行以下操作:

  • 确定确切的板型和 SoC 以获取 GPIO 布局

  • 打开 Linux 设备以进行内存映射的 GPIO 引脚

  • 设置 GPIO 设备的内存偏移,并使用mmap()将特定的外设(如 PWM、定时器和 GPIO)映射到内存中

与忽略pinMode()的调用不同,实现如下:

  • 适当设置 SoC 中的硬件 GPIO 方向寄存器(用于输入/输出模式)

  • 在引脚上启动 PWM、软 PWM 或 Tone 模式(根据请求);子函数设置适当的寄存器

这在 I2C 端继续进行,设置函数的实现如下:

int wiringPiI2CSetup (const int devId) { 
   int rev; 
   const char *device; 

   rev = piGpioLayout(); 

   if (rev == 1) { 
         device = "/dev/i2c-0"; 
   } 
   else { 
         device = "/dev/i2c-1"; 
   } 

   return wiringPiI2CSetupInterface (device, devId); 
} 

与我们的模拟实现相比,主要区别在于预期在 OS 的内存文件系统上存在 I2C 外设,并且板子版本确定我们选择哪一个。

最后一个被调用的函数尝试打开设备,因为在 Linux 和类似的操作系统中,每个设备只是一个我们可以打开并获得文件句柄的文件,如果成功的话。这个文件句柄就是函数返回时返回的 ID:

int wiringPiI2CSetupInterface (const char *device, int devId) { 
   int fd; 
   if ((fd = open (device, O_RDWR)) < 0) { 
         return wiringPiFailure (WPI_ALMOST, "Unable to open I2C device: %s\n", 
                                                                                                strerror (errno)); 
   } 

   if (ioctl (fd, I2C_SLAVE, devId) < 0) { 
         return wiringPiFailure (WPI_ALMOST, "Unable to select I2C device: %s\n",                                                                                                strerror (errno)); 
   } 

   return fd; 
} 

打开 I2C 设备后,使用 Linux 系统函数ioctl()来向 I2C 外设发送数据,这里是我们希望使用的 I2C 从设备的地址。如果成功,我们会得到一个非负的响应,并返回作为文件句柄的整数。

写入和读取 I2C 总线也使用ioctl()来处理,正如我们在同一源文件中所看到的:

static inline int i2c_smbus_access (int fd, char rw, uint8_t command, int size, union i2c_smbus_data *data) { 
   struct i2c_smbus_ioctl_data args; 

   args.read_write = rw; 
   args.command    = command; 
   args.size       = size; 
   args.data       = data; 
   return ioctl(fd, I2C_SMBUS, &args); 
} 

对于每个 I2C 总线访问,都会调用相同的内联函数。已经选择了我们希望使用的 I2C 设备,我们可以简单地针对 I2C 外设,并让其将有效负载传输到设备上。

这里,i2c_smbus_data类型是一个简单的联合体,支持返回值的各种大小(执行读操作时):

union i2c_smbus_data { 
   uint8_t byte; 
   uint16_t word; 
   uint8_t block[I2C_SMBUS_BLOCK_MAX + 2]; 
}; 

在这里,我们主要看到使用抽象 API 的好处。如果没有它,我们的代码将充斥着低级调用,这将更难以模拟。我们还看到应该测试的一些条件,例如缺少 I2C 从设备、I2C 总线上的读写错误可能导致意外行为,以及 GPIO 引脚上的意外输入,包括中断引脚,正如本章开头已经指出的那样。

尽管显然不是所有情况都可以预先计划,但应该努力记录所有现实情况,并将其纳入模拟实现中,以便在集成和回归测试以及调试期间可以随时启用它们。

使用 Valgrind 进行测试

Valgrind 是用于分析和分析应用程序的缓存和堆行为,以及内存泄漏和潜在多线程问题的开源工具集。它与底层操作系统协同工作,因为根据使用的工具,它必须拦截从内存分配到与多线程相关的指令的一切。这就是为什么它只在 64 位 x86_64 架构的 Linux 下得到完全支持的原因。

在其他支持的平台上使用 Valgrind(如 x86、PowerPC、ARM、S390、MIPS 和 ARM 上的 Linux,以及 Solaris 和 macOS)当然也是一个选择,但 Valgrind 项目的主要开发目标是 x86_64/Linux,这使得它成为进行分析和调试的最佳平台,即使以后会针对其他平台进行定位。

在 Valgrind 网站valgrind.org/info/platforms.html上,我们可以看到当前支持的平台的完整概述。

Valgrind 非常吸引人的一个特性是,它的工具都不需要我们以任何方式修改源代码或生成的二进制文件。这使得它非常容易集成到现有的工作流程中,包括自动化测试和集成系统。

在基于 Windows 的系统上,也有诸如 Dr. Memory(drmemory.org/)之类的工具,它们也可以处理与内存相关行为的分析。这个特定的工具还配备了 Dr. Fuzz,一个可以重复调用具有不同输入的函数的工具,可能对集成测试有用。

通过使用像前一节中所看到的集成测试,我们可以自由地从我们的个人电脑上完全分析我们代码的行为。由于 Valgrind 的所有工具都会显著减慢我们代码的执行速度(10-100 倍),能够在快速系统上进行大部分调试和分析意味着我们可以节省大量时间,然后再开始在目标硬件上进行测试。

在我们可能经常使用的工具中,MemcheckHelgrindDRD对于检测内存分配和多线程问题非常有用。一旦我们的代码通过了这三个工具,并使用提供代码广泛覆盖的广泛集成测试,我们就可以进行分析和优化。

为了对我们的代码进行分析,我们使用Callgrind来查看代码执行时间最长的地方,然后使用Massif来对堆分配进行分析。通过这些数据,我们可以对代码进行更改,以简化常见的分配和释放情况。它也可能向我们展示在何处使用缓存以重用资源而不是将其从内存中丢弃是有意义的。

最后,我们将运行另一个循环的 MemCheck、Helgrind 和 DRD,以确保我们的更改没有引起任何退化。一旦我们满意,我们就会部署代码到目标系统上,并查看其在那里的表现。

如果目标系统也运行 Linux 或其他支持的操作系统,我们也可以在那里使用 Valgrind,以确保我们没有遗漏任何东西。根据确切的平台(操作系统和 CPU 架构),我们可能会遇到 Valgrind 针对该平台的限制。这些可能包括未处理的指令等错误,其中工具尚未实现 CPU 指令,因此 Valgrind 无法继续。

通过将集成测试扩展到使用 SBC 而不是本地进程,我们可以建立一个持续集成系统,除了在本地进程上进行测试外,还可以在真实硬件上运行测试,考虑到真实硬件平台相对于用于大部分测试的基于 x86_64 的 Linux 系统的限制。

多目标构建系统

交叉编译和多目标构建系统是一些让很多人感到恐惧的词语,主要是因为它们让人联想到需要神秘咒语才能执行所需操作的复杂构建脚本。在本章中,我们将看一个基于简单 Makefile 的构建系统,该构建系统已在一系列硬件目标的商业项目中得到应用。

使构建系统易于使用的一件事是能够轻松设置所有相关方面的编译,并且有一个中心位置,我们可以从中控制项目的所有相关方面,或者部分相关方面,以及构建和运行测试。

因此,我们在项目顶部只有一个 Makefile,它处理所有基本内容,包括确定我们运行的平台。我们在这里做的唯一简化是假设类 Unix 环境,使用 MSYS2 或 Cygwin 在 Windows 上,以及 Linux、BSD 和 OS X/macOS 等使用其本机 shell 环境。然而,我们也可以适应 Microsoft Visual Studio、Intel Compiler Collection(ICC)和其他编译器,只要它们提供基本工具。

构建系统的关键是简单的 Makefile,在其中我们定义目标平台的具体细节,例如,对于在 x86_x64 硬件上运行的标准 Linux 系统:

 TARGET_OS = linux
 TARGET_ARCH = x86_64

 export CC = gcc
 export CXX = g++
 export CPP = cpp
 export AR = ar
 export LD = g++
 export STRIP = strip
 export OBJCOPY = objcopy

 PLATFORM_FLAGS = -D__PLATFORM_LINUX__ -D_LARGEFILE64_SOURCE -D __LINUX__
 STD_FLAGS = $(PLATFORM_FLAGS) -Og -g3 -Wall -c -fmessage-length=0 -ffunction-sections -fdata-sections -DPOCO_HAVE_GCC_ATOMICS -DPOCO_UTIL_NO_XMLCONFIGURATION -DPOCO_HAVE_FD_EPOLL
 STD_CFLAGS = $(STD_FLAGS)
 STD_CXXFLAGS = -std=c++11 $(STD_FLAGS)
 STD_LDFLAGS = -L $(TOP)/build/$(TARGET)/libboost/lib \
                         -L $(TOP)/build/$(TARGET)/poco/lib \
                         -Wl,--gc-sections
 STD_INCLUDE = -I. -I $(TOP)/build/$(TARGET)/libboost/include \
                         -I $(TOP)/build/$(TARGET)/poco/include \
                         -I $(TOP)/extern/boost-1.58.0
 STD_LIBDIRS = $(STD_LDFLAGS)
 STD_LIBS = -ldl -lrt -lboost_system -lssl -lcrypto -lpthread

在这里,我们可以设置我们将用于编译、创建存档、从二进制文件中剥离调试符号等操作的命令行工具的名称。构建系统将使用目标操作系统和架构来保持创建的二进制文件分开,以便我们可以使用相同的源树在一次运行中为所有目标平台创建二进制文件。

我们可以看到我们将传递给编译器和链接器的标志分为不同的类别:特定于平台的标志,常见(标准)标志,最后是特定于 C 和 C ++编译器的标志。前者在集成已集成到源树中的外部依赖项时非常有用,但这些依赖项是用 C 编写的。我们将在extern文件夹中找到这些依赖项,稍后我们将更详细地看到。

这种类型的文件将被大量定制以适应特定项目,添加所需的包含文件、库和编译标志。对于这个示例文件,我们可以看到一个使用 POCO 和 Boost 库以及 OpenSSL 的项目,调整 POCO 库以适应目标平台。

首先,让我们看一下 macOS 配置文件的顶部:

TARGET_OS = osx
 TARGET_ARCH = x86_64

 export CC = clang
 export CXX = clang++
 export CPP = cpp
 export AR = ar
 export LD = clang++
 export STRIP = strip
 export OBJCOPY = objcopy

尽管文件的其余部分几乎相同,但在这里我们可以看到一个很好的例子,说明了如何将工具的名称泛化。尽管 Clang 支持与 GCC 相同的标志,但其工具的名称不同。通过这种方法,我们只需在这个文件中写入不同的名称一次,一切都会正常工作。

这继续了 ARM 目标上的 Linux,它被设置为交叉编译目标:

TARGET_OS = linux
 TARGET_ARCH = armv7
 TOOLCHAIN_NAME = arm-linux-gnueabihf

 export CC = $(TOOLCHAIN_NAME)-gcc
 export CXX = $(TOOLCHAIN_NAME)-g++
 export AR = $(TOOLCHAIN_NAME)-ar
 export LD = $(TOOLCHAIN_NAME)-g++
 export STRIP = $(TOOLCHAIN_NAME)-strip
 export OBJCOPY = $(TOOLCHAIN_NAME)-objcopy

在这里,我们看到了之前在本章中看到的用于 ARM Linux 平台的交叉编译工具链的再次出现。为了节省输入,我们定义了基本名称一次,以便重新定义。这也展示了 Makefile 的灵活性。通过更多的创造力,我们可以创建一组模板,将整个工具链泛化为一个简单的 Makefile,该 Makefile 将根据平台的 Makefile(或其他配置文件)中的提示包含在主 Makefile 中,从而使其高度灵活。

接下来,我们将看一下项目根目录中的主 Makefile:

ifndef TARGET
 $(error TARGET parameter not provided.)
 endif

由于我们无法猜测用户希望我们针对哪个平台进行目标,我们要求指定目标,并将平台名称作为值,例如linux-x86_x64

export TOP := $(CURDIR)
 export TARGET

稍后在系统中,我们需要知道本地文件系统中的文件夹位置,以便我们可以指定绝对路径。我们使用标准的 Make 变量,并将其导出为我们自己的环境变量,以及构建目标名称:

UNAME := $(shell uname)
 ifeq ($(UNAME), Linux)
 export HOST = linux
 else
 export HOST = win32
 export FILE_EXT = .exe
 endif

使用(命令行)uname命令,我们可以检查我们正在运行的操作系统,每个支持该命令的操作系统在其 shell 中返回其名称,例如 Linux 用于 Linux,Darwin 用于 macOS。在纯 Windows 上(没有 MSYS2 或 Cygwin),该命令不存在,这将得到我们这个if/else语句的第二部分。

这个语句可以扩展以支持更多的操作系统,具体取决于构建系统的要求。在这种情况下,它仅用于确定我们创建的可执行文件是否应该有文件扩展名:

ifeq ($(HOST), linux)
 export MKDIR   = mkdir -p
 export RM            = rm -rf
 export CP            = cp -RL
 else
 export MKDIR   = mkdir -p
 export RM            = rm -rf
 export CP            = cp -RL
 endif

在这个if/else语句中,我们可以为常见的文件操作设置适当的命令行命令。由于我们采取了简单的方式,我们假设在 Windows 上使用 MSYS2 或类似的 Bash shell。

在这一点上,我们可以进一步推广概念,将 OS 文件 CLI 工具作为自己的一组 Makefiles 拆分出来,然后将其作为 OS 特定设置的一部分包含进来:

include Makefile.$(TARGET)

 export TARGET_OS
 export TARGET_ARCH
 export TOOLCHAIN_NAME

在这一点上,我们使用提供给 Makefile 的目标参数来包含适当的配置文件。在从中导出一些细节之后,我们现在有了一个配置好的构建系统:

all: extern-$(TARGET) core

 extern:
    $(MAKE) -C ./extern $(LIBRARY)

 extern-$(TARGET):
    $(MAKE) -C ./extern all-$(TARGET)

 core:
    $(MAKE) -C ./Core

 clean: clean-core clean-extern

 clean-extern:
    $(MAKE) -C ./extern clean-$(TARGET)

 clean-core:
    $(MAKE) -C ./Core clean

 .PHONY: all clean core extern clean-extern clean-core extern-$(TARGET)

通过这个单一的 Makefile,我们可以选择编译整个项目,或者只是依赖项或核心项目。我们还可以编译特定的外部依赖项,而不编译其他内容。

最后,我们可以清理核心项目、依赖项或两者。

这个顶级 Makefile 主要用于控制底层 Makefiles。接下来的两个 Makefiles 分别位于Coreextern文件夹中。其中,Core Makefile 直接编译项目的核心部分:

include ../Makefile.$(TARGET) 

OUTPUT := CoreProject 

INCLUDE = $(STD_INCLUDE) 
LIBDIRS = $(STD_LIBDIRS) 

include ../version 
VERSIONINFO = -D__VERSION="\"$(VERSION)\"" 

作为第一步,我们包含目标平台的 Makefile 配置,以便我们可以访问其所有定义。这些也可以在主 Makefile 中导出,但这样我们可以自由定制构建系统。

我们指定正在构建的输出二进制文件的名称,然后执行一些小任务,包括在项目根目录中使用 Makefile 语法打开version文件,其中包含我们正在构建的源代码的版本号。这准备作为预处理器定义传递给编译器:

ifdef RELEASE 
TIMESTAMP = $(shell date --date=@`git show -s --format=%ct $(RELEASE)^{commit}` -u +%Y-%m-%dT%H:%M:%SZ) 
else ifdef GITTIME 
TIMESTAMP = $(shell date --date=@`git show -s --format=%ct` -u +%Y-%m-%dT%H:%M:%SZ) 
TS_SAFE = _$(shell date --date=@`git show -s --format=%ct` -u +%Y-%m-%dT%H%M%SZ) 
else 
TIMESTAMP = $(shell date -u +%Y-%m-%dT%H:%M:%SZ) 
TS_SAFE = _$(shell date -u +%Y-%m-%dT%H%M%SZ) 
endif 

这是另一个部分,我们依赖于有一个 Bash shell 或类似的东西,因为我们使用 date 命令来为构建创建时间戳。格式取决于传递给主 Makefile 的参数。如果我们正在构建一个发布版本,我们将从 Git 存储库中获取时间戳,使用 Git 提交标签名称来检索该标签的提交时间戳,然后进行格式化。

如果传递了GITTIME作为参数,则使用最近的 Git 提交的时间戳。否则,使用当前的时间和日期(UTC)。

这段代码旨在解决测试和集成构建中出现的一个问题:跟踪构建的时间和源代码的修订版本。只要它支持检索特定时间戳的类似功能,它就可以适应其他文件修订系统。

值得注意的是我们正在创建的第二个时间戳。这是一个稍微不同格式的时间戳,附加到生成的二进制文件上,除非我们是在发布模式下构建:

CFLAGS = $(STD_CFLAGS) $(INCLUDE) $(VERSIONINFO) -D__TIMESTAMP="\"$(TIMESTAMP)\"" 
CXXFLAGS = $(STD_CXXFLAGS) $(INCLUDE) $(VERSIONINFO) -D__TIMESTAMP="\"$(TIMESTAMP)\"" 

OBJROOT := $(TOP)/build/$(TARGET)/obj 
CPP_SOURCES := $(wildcard *.cpp) 
CPP_OBJECTS := $(addprefix $(OBJROOT)/,$(CPP_SOURCES:.cpp=.o)) 
OBJECTS := $(CPP_OBJECTS) 

在这里,我们设置希望传递给编译器的标志,包括版本和时间戳,两者都作为预处理器定义传递。

最后,我们收集当前项目文件夹中的源文件,并设置对象文件的输出文件夹。正如我们在这里看到的,我们将把对象文件写入项目根目录下的一个文件夹中,并根据编译目标进行进一步分离。

.PHONY: all clean 

all: makedirs $(CPP_OBJECTS) $(C_OBJECTS) $(TOP)/build/bin/$(TARGET)/$(OUTPUT)_$(VERSION)_$(TARGET)$(TS_SAFE) 

makedirs: 
   $(MKDIR) $(TOP)/build/bin/$(TARGET) 
   $(MKDIR) $(OBJROOT) 

$(OBJROOT)/%.o: %.cpp 
   $(CXX) -o $@ $< $(CXXFLAGS) 

这部分对于 Makefile 来说是相当通用的。我们有all目标,以及一个用于在文件系统上创建文件夹(如果尚不存在)的目标。最后,我们在下一个目标中接收源文件数组,根据配置编译它们,并将对象文件输出到适当的文件夹中:

$(TOP)/build/bin/$(TARGET)/$(OUTPUT)_$(VERSION)_$(TARGET)$(TS_SAFE): $(OBJECTS) 
   $(LD) -o $@ $(OBJECTS) $(LIBDIRS) $(LIBS) 
   $(CP) $@ $@.debug 
ifeq ($(TARGET_OS), osx) 
   $(STRIP) -S $@ 
else 
   $(STRIP) -S --strip-unneeded $@      
endif 

在我们从源文件创建了所有的目标文件之后,我们希望将它们链接在一起,这就是这一步发生的地方。我们还可以看到二进制文件将会出现在哪里:在项目构建文件夹的bin子文件夹中。

链接器被调用,我们创建了生成二进制文件的副本,我们用.debug后缀来表示它是带有所有调试信息的版本。然后,原始二进制文件被剥离其调试符号和其他不需要的信息,留下一个小的二进制文件复制到远程测试系统,以及一个带有所有调试信息的较大版本,以便在需要分析核心转储或进行远程调试时使用。

我们在这里看到的另一个特点是由于 Clang 的链接器不支持的命令行标志而添加的一个小技巧,需要实现一个特殊情况。在跨平台编译和类似任务中,人们很可能会遇到许多这样的小细节,所有这些都会使得编写一个简单工作的通用构建系统变得复杂。

clean: 
   $(RM) $(CPP_OBJECTS) 
   $(RM) $(C_OBJECTS) 

最后一步是允许删除生成的目标文件。

extern中的第二个子 Makefile 也值得注意,因为它控制所有底层依赖关系:

ifndef TARGET 
$(error TARGET parameter not provided.) 
endif 

all: libboost poco 

all-linux-%: 
   $(MAKE) libboost poco 

all-qnx-%: 
   $(MAKE) libboost poco 

all-osx-%: 
   $(MAKE) libboost poco 

all-windows: 
   $(MAKE) libboost poco 

这里的一个有趣特性是基于目标平台的依赖选择器。如果我们有不应该为特定平台编译的依赖关系,我们可以在这里跳过它们。这个特性还允许我们直接指示这个 Makefile 为特定平台编译所有依赖关系。在这里,我们允许针对 QNX、Linux、OS X/macOS 和 Windows 进行定位,同时忽略架构:

libboost: 
   cd boost-1.58.0 && $(MAKE) 

poco: 
   cd poco-1.7.4 && $(MAKE) 

实际的目标只是调用依赖项目顶部的另一个 Makefile,然后编译该依赖项并将其添加到构建文件夹中,以便Core的 Makefile 使用。

当然,我们也可以直接使用现有的构建系统从这个 Makefile 编译项目,比如这里的 OpenSSL:

openssl: 
   $(MKDIR) $(TOP)/build/$(TARGET)/openssl 
   $(MKDIR) $(TOP)/build/$(TARGET)/openssl/include 
   $(MKDIR) $(TOP)/build/$(TARGET)/openssl/lib 
   cd openssl-1.0.2 && ./Configure --openssldir="$(TOP)/build/$(TARGET)/openssl" shared os/compiler:$(TOOLCHAIN_NAME):$(OPENSSL_PARAMS) && \ 
     $(MAKE) build_libs 
   $(CP) openssl-1.0.2/include $(TOP)/build/$(TARGET)/openssl 
   $(CP) openssl-1.0.2/libcrypto.a $(TOP)/build/$(TARGET)/openssl/lib/. 
   $(CP) openssl-1.0.2/libssl.a $(TOP)/build/$(TARGET)/openssl/lib/. 

这段代码通过手动完成构建 OpenSSL 的所有常规步骤,然后将生成的二进制文件复制到它们的目标文件夹。

人们可能会注意到跨平台构建系统的一个问题是,像 Autoconf 这样的常见 GNU 工具在 Windows 等操作系统上非常慢,因为它在运行数百个测试时会启动许多进程。即使在 Linux 上,这个过程也可能需要很长时间,当一天中多次运行相同的构建过程时,这是非常令人恼火和耗时的。

理想情况是有一个简单的 Makefile,其中一切都是预定义的,并且处于已知状态,因此不需要库发现等。这是将 POCO 库源代码添加到一个项目并有一个简单的 Makefile 编译它的动机之一:

include ../../Makefile.$(TARGET) 

all: poco-foundation poco-json poco-net poco-util 

poco-foundation: 
   cd Foundation && $(MAKE) 

poco-json: 
   cd JSON && $(MAKE) 

poco-net: 
   cd Net && $(MAKE) 

poco-util: 
   cd Util && $(MAKE) 

clean: 
   cd Foundation && $(MAKE) clean 
   cd JSON && $(MAKE) clean 
   cd Net && $(MAKE) clean 
   cd Util && $(MAKE) clean 

这个 Makefile 然后调用每个模块的单独 Makefile,就像这个例子:

include ../../../Makefile.$(TARGET) 

OUTPUT = libPocoNet.a 
INCLUDE = $(STD_INCLUDE) -Iinclude 
CFLAGS = $(STD_CFLAGS) $(INCLUDE) 
OBJROOT = $(TOP)/extern/poco-1.7.4/Net/$(TARGET) 
INCLOUT = $(TOP)/build/$(TARGET)/poco 
SOURCES := $(wildcard src/*.cpp) 
HEADERS := $(addprefix $(INCLOUT)/,$(wildcard include/Poco/Net/*.h)) 

OBJECTS := $(addprefix $(OBJROOT)/,$(notdir $(SOURCES:.cpp=.o))) 

all: makedir $(OBJECTS) $(TOP)/build/$(TARGET)/poco/lib/$(OUTPUT) $(HEADERS) 

$(OBJROOT)/%.o: src/%.cpp 
   $(CC) -c -o $@ $< $(CFLAGS) 

makedir: 
   $(MKDIR) $(TARGET) 
   $(MKDIR) $(TOP)/build/$(TARGET)/poco 
   $(MKDIR) $(TOP)/build/$(TARGET)/poco/lib 
   $(MKDIR) $(TOP)/build/$(TARGET)/poco/include 
   $(MKDIR) $(TOP)/build/$(TARGET)/poco/include/Poco 
   $(MKDIR) $(TOP)/build/$(TARGET)/poco/include/Poco/Net 

$(INCLOUT)/%.h: %.h 
   $(CP) $< $(INCLOUT)/$< 

$(TOP)/build/$(TARGET)/poco/lib/$(OUTPUT): $(OBJECTS) 
   -rm -f $@ 
   $(AR) rcs $@ $^ 

clean: 
   $(RM) $(OBJECTS) 

这个 Makefile 编译了整个库的Net模块。它的结构类似于用于编译项目核心源文件的结构。除了编译目标文件,它还将它们放入一个存档中,以便我们以后可以链接,并将这个存档以及头文件复制到它们在构建文件夹中的位置。

为了允许特定的优化和调整,编译库的主要原因是这些优化和调整在预编译库中是不可用的。通过从库的原始构建系统中剥离除了基本内容之外的所有内容,尝试不同的设置变得非常容易,甚至在 Windows 上也可以工作。

在真实硬件上进行远程测试

在我们完成了所有代码的本地测试,并且相当确信它应该可以在真实硬件上运行之后,我们可以使用交叉编译构建系统来创建一个二进制文件,然后在目标系统上运行。

在这一点上,我们可以简单地将生成的二进制文件和相关文件复制到目标系统上,看看它是否有效。更科学的方法是使用 GDB。通过在目标 Linux 系统上安装 GDB 服务器服务,我们可以通过网络或串行连接从 PC 连接到它。

对于运行基于 Debian 的 Linux 安装的 SBC,GDB 服务器可以很容易地安装:

sudo apt install gdbserver  

尽管它被称为gdbserver,但其基本功能是作为调试器的远程存根实现,在主机系统上运行。这使得gdbserver非常轻量级和简单,可以为新目标实现。

之后,我们要确保gdbserver正在运行,方法是登录到系统并以各种方式启动它。我们可以像这样为网络上的 TPC 连接这样做:

gdbserver host:2345 <program> <parameters>  

或者我们可以将其附加到正在运行的进程上:

gdbserver host:2345 --attach <PID>  

第一个参数的主机部分是将要连接的主机系统的名称(或 IP 地址)。当前该参数被忽略,这意味着它也可以留空。端口部分必须是目标系统上当前未使用的端口。

或者我们可以使用某种串行连接:

gdbserver /dev/tty0 <program> <parameters>
gdbserver --attach /dev/tty0 <PID>  

一旦我们启动gdbserver,它会暂停目标应用程序的执行(如果它已经在运行),从而允许我们从主机系统连接调试器。在目标系统上,我们可以运行一个已经剥离了其调试符号的二进制文件;这些符号需要在我们在主机端使用的二进制文件中存在:

$ gdb-multiarch <program>
(gdb) target remote <IP>:<port>
Remote debugging using <IP>:<port>  

在这一点上,调试符号将从二进制文件中加载,以及从任何依赖项中加载(如果可用)。通过串行连接进行连接看起来类似,只是地址和端口被串行接口路径或名称替换。当我们启动时,串行连接的波特率(如果不是默认的 9600 波特率)被指定为 GDB 的参数:

$ gdb-multiarch -baud <baud rate> <program>  

一旦我们告诉 GDB 远程目标的详细信息,我们应该看到通常的 GDB 命令行界面出现,允许我们像在本地系统上运行一样步进,分析和调试程序。

正如本章前面提到的,我们使用gdb-multiarch,因为这个版本的 GDB 调试器支持不同的架构,这很有用,因为我们很可能会在 x86_64 系统上运行调试器,而 SBC 很可能是基于 ARM,但也可能是 MIPS 或 x86(i686)。

除了直接使用gdbserver运行应用程序之外,我们还可以启动gdbserver等待调试器连接:

gdbserver --multi <host>:<port>  

或者我们可以这样做:

gdbserver --multi <serial port>  

然后我们会像这样连接到这个远程目标:

$ gdb-multiarch <program>
(gdb) target extended-remote <remote IP>:<port>
(gdb) set remote exec-file <remote file path>
(gdb) run  

在这一点上,我们应该再次发现自己处于 GDB 命令行界面上,目标和主机上都加载了程序二进制文件。

这种方法的一个重要优势是gdbserver在被调试的应用程序退出时不会退出。此外,这种模式允许我们在同一个目标上同时调试不同的应用程序,假设目标支持这一点。

总结

在本章中,我们学习了如何开发和测试嵌入式操作系统应用程序。我们学会了如何安装和使用交叉编译工具链,如何使用 GDB 进行远程调试,以及如何编写构建系统,使我们能够以最小的工作量为新目标系统进行编译。

在这一点上,您应该能够以高效的方式开发和调试基于 Linux 的 SBC 或类似系统的嵌入式应用程序。

在下一章中,我们将学习如何为更受限制的基于 MCU 的平台开发和测试应用程序。

第七章:测试资源受限的平台

为 MCU 和类似资源受限的平台开发几乎完全是在常规 PC 上进行的,除了测试和调试。问题是何时应该在物理设备上进行测试,何时应该寻找替代测试和调试代码的方法,以加快开发和调试工作。

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

  • 了解特定代码的资源需求

  • 有效地使用基于 Linux 的工具来测试跨平台代码

  • 使用远程调试

  • 使用交叉编译器

  • 创建一个平台无关的构建系统

减少磨损

通常,在开发过程中,总会出现这样的情况:在系统中修复问题时,不断地进行调整-编译-部署-测试的循环。以下是采用这种方法引入的主要问题:

  • 这不是一件有趣的事:不断等待结果而又不清楚这一次是否真的会被修复,令人沮丧。

  • 这不是高效的:你会花很多时间等待结果,如果你能更好地分析问题,就不需要这样做。

  • 它会磨损硬件:多次拔插相同的连接器,多次写入和覆盖 ROM 芯片的相同部分,数百次对系统进行电源循环后,硬件的寿命会显著减少,自己的耐心也会减少,并且会引入新的错误。

  • 摆弄测试硬件并不有趣:任何嵌入式设置的最佳情况是能够拿起开发板,插入所有外围设备和接线,刷写应用程序的 ROM,并启动它以查看它的工作情况。任何偏离这种情况的情况都令人沮丧且耗时。

因此,在开发过程中避免这种循环是至关重要的。问题是我们如何能够最有效地达到这样一个目标,即在最终测试阶段之前,能够为 8 位 MCU 或更大的 32 位 ARM MCU 等东西编写代码,而不需要接触硬件。

规划设计

在第四章中,资源受限的嵌入式系统,我们讨论了如何为嵌入式平台选择合适的微控制器。在为 MCU 设计固件时,我们不仅要考虑特定代码的资源需求,还要考虑调试的便利性。

使用 C++的一个重要优势是它提供的抽象,包括将代码分成逻辑类、命名空间和其他抽象的能力,这使我们能够轻松地重用、测试和调试代码。这是任何设计中的一个关键方面,也是在实际实现设计之前必须完全实现的一个方面。

根据设计,调试任何问题可能会非常容易或非常困难,或者介于两者之间。如果所有功能之间有清晰的分离,没有泄漏的 API 或类似可能泄漏内部私有数据的问题,那么为诸如集成和单元测试之类的基本类创建不同版本将会很容易。

仅仅使用类等并不能保证设计是模块化的。即使有这样的设计,仍然可能出现在类之间传递内部类数据的情况,从而破坏模块化。当这种情况发生时,会使整体设计变得复杂,因为依赖关系的级别随着数据结构和数据格式的更改而增加,可能会在应用程序的其他地方引起问题,并且在编写测试和重新实现 API 时需要创造性的解决方法。

在第四章中,资源受限的嵌入式系统,我们看了如何选择合适的 MCU。RAM、ROM 和浮点使用的点显然取决于我们选择的设计来适应项目。正如我们在第二章中所介绍的,C++作为嵌入式语言,了解我们编写的代码被编译成什么是很重要的。这种理解使人能够直观地感受到一行代码的资源成本,而无需逐步执行生成的机器代码并从中创建精确的时钟周期计数。

在这一点上,显然很明显,在选择 MCU 之前,必须对整体设计和资源需求有一个相当好的想法,因此从一个坚实的设计开始是至关重要的。

平台无关的构建系统

理想情况下,我们选择的项目和构建系统可以在任何桌面平台上用于构建目标平台。通常,这里的主要考虑因素是每个开发平台上相同工具链和程序员的可用性。幸运的是,对于基于 AVR 和 ARM 的 MCU 平台,都有相同的基于 GCC 的工具链可用,因此我们不必考虑不同命名约定、标志和设置的不同工具链。

剩下的挑战只是以不需要了解底层操作系统的方式调用工具链,以及随后的程序员实用工具。

在第六章中,测试基于操作系统的应用程序,我们看了一个多目标构建系统,可以在最小的工作量下为各种目标生成二进制文件。对于 MCU 目标,只会有以下两个目标:

  • 物理 MCU 目标

  • 本地操作系统目标

在这里,第一个目标显然是固定的,因为我们已经选择了我们想要针对的 MCU。除非有令人不快的惊喜,我们将在整个开发过程中使用这一个目标。此外,我们还希望在开发 PC 上进行本地测试。这是第二个目标。

如果在每个主流桌面操作系统上都有相同或类似的 C++工具链版本,那将是很好的。幸运的是,我们发现 GCC 几乎可以在任何想得到的平台上使用,LLVM 工具链的 Clang C++前端使用常规的 GCC 风格标志,为我们提供了广泛的兼容性。

与我们在第六章中看到的多目标构建系统的复杂性不同,我们可以简化它,只使用 GCC,这将允许我们在 Linux 和 BSD 操作系统以及 Windows(通过 MSYS2 或等效方式使用 MinGW)和 macOS(安装 GCC 后)上使用该工具链。

为了在 macOS 上实现完全兼容,建议使用 GCC,因为在 Clang 实现中存在一些小问题。其中一个当前的问题是__forceinline宏属性被破坏,例如,这将破坏许多假定 GCC 编译器的代码。

使用交叉编译器

每个编译器工具链都由一个接收源代码的一侧(前端)和一个输出目标平台的二进制格式的一侧(后端)组成。后端工作在除了它所针对的平台之外的任何其他平台上都是没有问题的。最终,只是将文本文件转换为字节序列。

以这种方式进行交叉编译是 MCU 导向开发的一个重要特性,因为直接在这些 MCU 上编译将非常低效。然而,这个过程并没有什么神奇之处。对于基于 GCC 和兼容 GCC 的工具链,人们仍然会与工具链上的相同接口进行交互,只是工具通常会以目标平台名称为前缀,以区别于其他不同目标的工具链。基本上,人们会使用arm-none-eabi-g++代替g++

生成的二进制文件将采用适合目标平台的格式。

本地和片上调试

在第六章中,测试基于 OS 的应用程序,我们研究了使用 Valgrind 和类似工具进行调试应用程序,以及 GDB 等。通过基于 OS 的集成测试,例如在示例 - ESP8266 集成测试部分演示的 MCU 项目,我们可以使用完全相同的技术,对代码进行分析和调试,而暂时不用担心相同的代码将在最终集成测试中在一个运行速度更慢、更有限的平台上运行。

真正的挑战出现在最终集成阶段,当我们在快速桌面系统上调试的固件现在在一个只有 16 MHz 的 ATmega MCU 上运行,无法使用 Valgrind 工具或在 GDB 会话中快速启动代码。

在这个阶段,我们将不可避免地遇到错误和问题,我们需要做好准备来处理这种情况。通常,人们不得不求助于片上调试OCD),可以通过 MCU 提供的任何调试接口执行。这可以是 JTAG、DebugWire 或 SWD、PDI 或其他类型。在第四章中,资源受限的嵌入式系统,我们在编程这些 MCU 时研究了一些接口。

嵌入式 IDE 将提供直接进行 OCD 的能力,连接到目标硬件,允许设置断点,就像设置本地进程一样。当然,也可以使用命令行中的 GDB 来做同样的事情,使用 OpenOCD(openocd.org/)这样的程序,它为 GDB 提供了gdbserver接口,同时与各种调试接口进行交互。

示例 - ESP8266 集成测试

在这个示例项目中,我们将研究创建 Sming 框架类似 Arduino 的 API 的实现,我们在第五章中首次看到它,示例 - 带 WiFi 的土壤湿度监测器。这样做的目的是为桌面操作系统OSes)提供一个本地框架实现,允许将固件编译为可执行文件并在本地运行。

此外,我们希望有模拟传感器和执行器,固件可以连接到这些传感器和执行器,以读取环境数据并将数据发送到执行器,作为 BMaC 项目的一部分。我们在第五章中已经有所了解,示例 - 带 WiFi 的土壤湿度监测器,并且我们将在第九章中更详细地讨论,示例 - 建筑监控与控制。为此,我们还需要一个中央服务来跟踪这些信息。这样,我们也可以运行多个固件进程,模拟装满设备的整个房间。

模拟的范围之所以如此之广,是因为没有实际的硬件。没有物理 MCU 系统,我们就没有物理传感器,这些传感器也不会存在于物理房间中。因此,我们必须为传感器生成合理的输入,并模拟任何执行器的效果。然而,这也带来了许多优势。

具有这种扩展能力是有用的,因为它不仅允许我们验证固件作为独立系统的正确性,还允许我们验证其作为将要安装在其中的系统的一部分的正确性。对于 BMaC 来说,这意味着在建筑物的一个房间安装一个节点,建筑物的其他房间以及楼层上安装数十到数百个其他节点,同时在同一网络上运行相应的后端服务。

有了这种大规模模拟能力,我们不仅可以测试固件本身的基本正确性,还可以测试整个系统的正确性,不同类型或版本的固件与各种传感器和执行器(空调、风扇、咖啡机、开关等)同时运行。此外,后端服务将根据从相同节点传递给它们的数据来指导节点。

在模拟的建筑物中,可以配置特定的房间具有特定的环境条件,通过一个工作日,人们进入、工作和离开,以确定建筑物占用水平、外部条件等的影响。您也可以使用将用于最终生产系统的固件和后端服务进行这样的测试。虽然以这种方式测试系统不会完全消除任何潜在问题,但至少可以验证系统的软件部分是否功能正确。

由于嵌入式系统从定义上来说是更大(基于硬件)系统的一部分,完整的集成测试将涉及实际的硬件或其等效物。因此,可以将这个示例视为部署固件到物理建筑物的目标硬件之前的软件集成测试。

模拟服务器和单独的固件进程都有自己的主函数,并且彼此独立运行。这使我们能够尽可能少地干扰下检查固件的功能,并促进清晰的设计。为了实现这些进程之间的高效通信,我们使用了一个远程过程调用RPC)库,它基本上在模拟房间的固件和 I2C、SPI 和 UART 设备之间创建了连接。本示例中使用的 RPC 库是 NymphRPC,这是作者开发的一个 RPC 库。当前版本的源代码已包含在本章的源代码中。NymphRPC 库的当前版本可以在其 GitHub 存储库中找到:github.com/MayaPosch/NymphRPC

服务器

我们首先来看一下这个集成测试的服务器。它的作用是运行 RPC 服务器并维护每个传感器和执行器设备以及房间的状态。

主文件simulation.cpp设置了 RPC 配置以及主循环,如下所示:

#include "config.h"
#include "building.h"
#include "nodes.h"
#include <nymph/nymph.h>
#include <thread>
#include <condition_variable>
#include <mutex>
std::condition_variable gCon;
std::mutex gMutex;
bool gPredicate = false;
void signal_handler(int signal) {
    gPredicate = true;
    gCon.notify_one();
}
void logFunction(int level, string logStr) {
    std::cout << level << " - " << logStr << endl;
}

顶部的包含部分向我们展示了基本的结构和依赖关系。我们有一个自定义配置类,一个定义建筑物的类,一个用于节点的静态类,最后是多线程头文件(自 C++11 起可用)和 NymphRPC RPC 头文件,以便访问其功能。

定义了一个信号处理函数,以便稍后与等待条件一起使用,允许服务器通过简单的控制信号终止。最后,定义了一个用于 NymphRPC 服务器的日志记录函数。

接下来,我们定义 RPC 服务器的回调函数,如下所示:

NymphMessage* getNewMac(int session, NymphMessage* msg, void* data) {
    NymphMessage* returnMsg = msg->getReplyMessage();

    std::string mac = Nodes::getMAC();
    Nodes::registerSession(mac, session);

    returnMsg->setResultValue(new NymphString(mac));
    return returnMsg;
 }

这是客户端将在服务器上调用的初始函数。它将检查全局静态的Nodes类,以获取可用的 MAC 地址。此地址唯一标识新节点实例,就像网络上的设备也将通过其唯一的以太网 MAC 地址进行标识一样。这是一个内部函数,不需要修改固件,但是将 MAC 分配的能力转移到服务器,而不是在某个地方硬编码它们。当分配了新的 MAC 时,它将与 NymphRPC 会话 ID 关联起来,以便我们稍后可以使用 MAC 找到适当的会话 ID,并通过它调用由模拟设备生成的事件的客户端。

在这里,我们还看到了 NymphRPC 回调函数的基本签名,它在服务器实例上使用。显然,它返回返回消息,并接收与连接的客户端相关的会话 ID、从该客户端接收的消息以及一些用户定义的数据,如下面的代码所示:

NymphMessage* writeUart(int session, NymphMessage* msg, void* data) {
    NymphMessage* returnMsg = msg->getReplyMessage();

    std::string mac = ((NymphString*) msg->parameters()[0])->getValue();
    std::string bytes = ((NymphString*) msg->parameters()[1])->getValue();
    returnMsg->setResultValue(new NymphBoolean(Nodes::writeUart(mac, bytes)));
    return returnMsg;
 }

该回调实现了一种在模拟中写入 UART 接口的方法,该接口针对连接的任何模拟设备进行寻址。

为了找到节点,我们使用 MAC 地址并将其与字节一起发送到适当的Nodes类函数中,如下面的代码所示:

NymphMessage* writeSPI(int session, NymphMessage* msg, void* data) {
    NymphMessage* returnMsg = msg->getReplyMessage();

    std::string mac = ((NymphString*) msg->parameters()[0])->getValue();
    std::string bytes = ((NymphString*) msg->parameters()[1])->getValue();
    returnMsg->setResultValue(new NymphBoolean(Nodes::writeSPI(mac, bytes)));
    return returnMsg;
 }
 NymphMessage* readSPI(int session, NymphMessage* msg, void* data) {
    NymphMessage* returnMsg = msg->getReplyMessage();

    std::string mac = ((NymphString*) msg->parameters()[0])->getValue();
    returnMsg->setResultValue(new NymphString(Nodes::readSPI(mac)));
    return returnMsg;
 }

对于 SPI 总线,写入和读取使用类似的系统。MAC 标识节点,然后将字符串发送到总线或从总线接收。这里的一个限制是,我们假设只有一个 SPI 设备存在,因为没有办法选择不同的 SPI 芯片选择(CS)线。必须在此处传递一个单独的 CS 参数,以启用多个 SPI 设备。让我们看看以下代码:

NymphMessage* writeI2C(int session, NymphMessage* msg, void* data) {
    NymphMessage* returnMsg = msg->getReplyMessage();

    std::string mac = ((NymphString*) msg->parameters()[0])->getValue();
    int i2cAddress = ((NymphSint32*) msg->parameters()[1])->getValue();
    std::string bytes = ((NymphString*) msg->parameters()[2])->getValue();
    returnMsg->setResultValue(new NymphBoolean(Nodes::writeI2C(mac, i2cAddress, bytes)));
    return returnMsg;
 }

 NymphMessage* readI2C(int session, NymphMessage* msg, void* data) {
    NymphMessage* returnMsg = msg->getReplyMessage();

    std::string mac = ((NymphString*) msg->parameters()[0])->getValue();
    int i2cAddress = ((NymphSint32*) msg->parameters()[1])->getValue();
    int length = ((NymphSint32*) msg->parameters()[2])->getValue();
    returnMsg->setResultValue(new NymphString(Nodes::readI2C(mac, i2cAddress, length)));
    return returnMsg;
 }

对于 I2C 总线版本,我们传递 I2C 从设备地址,以允许我们使用多个 I2C 设备。

最后,主函数注册 RPC 方法,启动模拟,然后进入等待条件,如下面的代码所示:

int main() {
    Config config;
    config.load("config.cfg");

我们首先使用以下代码获取此模拟的配置数据。这一切都在一个单独的文件中定义,我们将使用特殊的Config类加载它,我们稍后将在查看配置解析器时更详细地查看它。

   vector<NymphTypes> parameters;
    NymphMethod getNewMacFunction("getNewMac", parameters, NYMPH_STRING);
    getNewMacFunction.setCallback(getNewMac);
    NymphRemoteClient::registerMethod("getNewMac", getNewMacFunction);

    parameters.push_back(NYMPH_STRING);
    NymphMethod serialRxCallback("serialRxCallback", parameters, NYMPH_NULL);
    serialRxCallback.enableCallback();
    NymphRemoteClient::registerCallback("serialRxCallback", serialRxCallback);

    // string readI2C(string MAC, int i2cAddress, int length)
    parameters.push_back(NYMPH_SINT32);
    parameters.push_back(NYMPH_SINT32);
    NymphMethod readI2CFunction("readI2C", parameters, NYMPH_STRING);
    readI2CFunction.setCallback(readI2C);
    NymphRemoteClient::registerMethod("readI2C", readI2CFunction);

    // bool writeUart(string MAC, string bytes)
    parameters.clear();
    parameters.push_back(NYMPH_STRING);
    parameters.push_back(NYMPH_STRING);
    NymphMethod writeUartFunction("writeUart", parameters, NYMPH_BOOL);
    writeUartFunction.setCallback(writeUart);
    NymphRemoteClient::registerMethod("writeUart", writeUartFunction);

    // bool writeSPI(string MAC, string bytes)
    NymphMethod writeSPIFunction("writeSPI", parameters, NYMPH_BOOL);
    writeSPIFunction.setCallback(writeSPI);
    NymphRemoteClient::registerMethod("writeSPI", writeSPIFunction);

    // bool writeI2C(string MAC, int i2cAddress, string bytes)
    parameters.clear();
    parameters.push_back(NYMPH_STRING);
    parameters.push_back(NYMPH_SINT32);
    parameters.push_back(NYMPH_SINT32);
    NymphMethod writeI2CFunction("writeI2C", parameters, NYMPH_BOOL);
    writeI2CFunction.setCallback(writeI2C);
    NymphRemoteClient::registerMethod("writeI2C", writeI2CFunction);

通过这段代码,我们注册了希望提供给客户端节点进程的进一步方法,使其能够调用我们在此源文件中早期查看的函数。为了使用 NymphRPC 注册服务器端函数,我们必须定义参数类型(按顺序)并使用这些类型来定义一个新的NymphMethod实例,然后将此参数类型列表、函数名称和返回类型提供给它。

然后,这些方法实例被注册到NymphRemoteClient中,这是服务器端 NymphRPC 的顶级类,如下面的代码所示:


    signal(SIGINT, signal_handler);

    NymphRemoteClient::start(4004);

    Building building(config);

    std::unique_lock<std::mutex> lock(gMutex);
    while (!gPredicate) {
          gCon.wait(lock);
    }

    NymphRemoteClient::shutdown();

    Thread::sleep(2000); 

    return 0;
 }

最后,我们为 SIGINT(Ctrl + c)信号安装信号处理程序。NymphRPC 服务器在端口 4004 上启动,所有接口都可用。接下来,创建一个Building实例,并为其提供先前使用配置解析器类加载的配置实例。

然后,我们启动一个循环,检查gPredicate全局变量的值是否已更改为true,如果信号处理程序已被触发,并且此布尔变量已设置为true,则会发生这种情况。条件变量用于允许我们通过信号处理程序通知此条件变量,尽可能地阻止主线程执行。

通过在循环中使用条件变量的等待条件,我们确保即使条件变量的等待条件遭受虚假唤醒,它也会简单地回到等待被通知的状态。

最后,如果服务器被要求终止,我们关闭 NymphRPC 服务器,然后给所有活动线程额外两秒的时间来干净地终止。之后,服务器关闭。

接下来,让我们看一下我们为此模拟加载的config.cfg文件,如下面的代码所示:

[Building]
 floors=2

 [Floor_1]
 rooms=1,2

 [Floor_2]
 rooms=2,3

 [Room_1]
 ; Define the room configuration.
 ; Sensors and actuators use the format:
 ; <device_id>:<node_id>
 nodes=1
 devices=1:1

 [Room_2]
 nodes=2

 [Room_3]
 nodes=3

 [Room_4]
 nodes=4

 [Node_1]
 mac=600912760001
 sensors=1

 [Node_2]
 mac=600912760002
 sensors=1

 [Node_3]
 mac=600912760003
 sensors=1

 [Node_4]
 mac=600912760004
 sensors=1

 [Device_1]
 type=i2c
 address=0x20
 device=bme280

 [Device_2]
 type=spi
 cs_gpio=1

 [Device_3]
 type=uart
 uart=0
 baud=9600
 device=mh-z19

 [Device_4]
 type=uart
 uart=0
 baud=9600
 device=jura

正如我们所看到的,这个配置文件使用标准的 INI 配置文件格式。它定义了一个有两层楼的建筑,每层有两个房间。每个房间有一个节点,每个节点都连接了一个 I2C 总线上的 BME280 传感器。

还定义了更多的设备,但在这里没有使用。

让我们看一下在 config.h 中声明的解析前述格式的配置解析器:

#include <string> 
#include <memory> 
#include <sstream> 
#include <iostream> 
#include <type_traits> 

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

using Poco::AutoPtr; 
using namespace Poco::Util; 

class Config { 
   AutoPtr<IniFileConfiguration> parser; 

public: 
   Config(); 

   bool load(std::string filename); 

   template<typename T> 
   auto getValue(std::string key, T defaultValue) -> T { 
         std::string value; 
         try { 
               value = parser->getRawString(key); 
         } 
         catch (Poco::NotFoundException &e) { 
               return defaultValue; 
         } 

         // Convert the value to our output type, if possible. 
         std::stringstream ss; 
         if (value[0] == '0' && value[1] == 'x') { 
               value.erase(0, 2); 
               ss << std::hex << value; // Read as hexadecimal. 
         } 
         else { 
               ss.str(value); 
         } 

         T retVal; 
         if constexpr (std::is_same<T, std::string>::value) { retVal = ss.str(); } 
         else { ss >> retVal; } 

         return retVal; 
   } 
}; 

在这里,我们看到了模板的一个有趣用法,以及它们的一个限制。传递给模板的类型既用于默认参数,也用于返回类型,允许模板将从配置文件获取的原始字符串转换为所需的类型,同时避免了不完整模板的问题,因为函数的返回类型中只使用了类型。

由于 C++的限制,即使它们的返回值不同,每个具有相同名称的函数必须具有不同的参数集,因此我们必须在这里使用默认值参数来规避这个问题。由于大多数时候我们希望为我们尝试读取的键提供一个默认值,所以这在这里并不是什么问题。

最后,我们使用std::is_same进行了一些类型比较,以确保如果目标返回类型是字符串,我们直接从stringstream中复制字符串,而不是尝试使用格式化输出进行转换。由于我们使用 POCO INI 文件阅读器从 INI 文件中读取值作为原始字符串,因此无需对其进行任何类型的转换。

它在config.cpp中的实现非常简单,因为模板必须在头文件中定义。您可以在以下代码中看到这一点:

#include "config.h" 

Config::Config() { 
   parser = new IniFileConfiguration(); 
} 

bool Config::load(std::string filename) { 
   try { 
         parser->load(filename); 
   } 
   catch (...) { 
         // An exception has occurred. Return false. 
         return false; 
   } 

   return true; 
} 

我们只在这里实现了这个方法,它实际上从文件名字符串加载配置文件。在这个实现中,我们创建了一个 POCO IniFileConfiguration类的实例,假设我们正在尝试解析一个 INI 文件。如果由于任何原因加载配置文件失败,我们会返回一个错误。

在这个解析器的更完整版本中,我们可能会支持不同的配置类型或甚至来源,并进行高级错误处理。对于我们的目的,简单的 INI 格式已经足够了。

接下来,以下代码显示了Building类:

#include <vector>
 #include <string>

 #include "floor.h"

 class Building {
    std::vector<Floor> floors;

 public:
    Building(Config &cfg);
 };

因为我们还没有向模拟服务器添加任何高级功能,所以在这里还没有太多可看的,也没有在其实现中展示,如下面的代码所示:

#include "building.h"
 #include "floor.h"
 Building::Building(Config &config) {
    int floor_count = config.getValue<int>("Building.floors", 0);

    for (int i = 0; i < floor_count; ++i) {
          Floor floor(i + 1, config); // Floor numbering starts at 1.
          floors.push_back(floor);
    }
 }

在这里,我们从文件中读取每个楼层的定义,并为其创建一个Floor实例,然后将其添加到数组中。这些实例还会接收一个对配置对象的引用。

Floor类也很基本,原因同样,您可以在以下代码中看到:

#include <vector>
 #include <cstdint>

 #include "room.h"

 class Floor {
    std::vector<Room> rooms;

 public:
    Floor(uint32_t level, Config &config);
 };

这是它的实现:

#include "floor.h"
 #include "utility.h"

 #include <string>

 Floor::Floor(uint32_t level, Config &config) {
    std::string floor_cat = "Floor_" + std::to_string(level);
    std::string roomsStr = config.getValue<std::string>(floor_cat + ".rooms", 0);

    std::vector<std::string> room_ids;
    split_string(roomsStr, ',', room_ids);    
    int room_count = room_ids.size();

    if (room_count > 0) {   
          for (int i = 0; i < room_count; ++i) {
                Room room(std::stoi(room_ids.at(i)), config);
                rooms.push_back(room);
          }
    }
 }

值得注意的是,中央配置文件被每个单独的类一次性解析一部分,每个类实例只关心其根据 ID 被指示关心的小节。

在这里,我们只关心为该楼层 ID 定义的房间。我们提取这些房间的 ID,然后为这些房间创建新的类实例,并将每个房间的副本保存在向量中。在模拟服务器的更高级实现中,我们可以在这里实现整个楼层的事件,例如。

这里的实用头文件定义了一个简单的方法来分割字符串,如下面的代码所示:

#include <string>
 #include <vector>

 void split_string(const std::string& str, char chr, std::vector<std::string>& vec);

这是它的实现:

#include "utility.h"

 #include <algorithm>

 void split_string(const std::string& str, char chr, std::vector<std::string>& vec) {
     std::string::const_iterator first = str.cbegin();
     std::string::const_iterator second = std::find(first + 1, str.cend(), chr);

     while (second != str.cend()) {
         vec.emplace_back(first, second);
         first = second;
         second = std::find(second + 1, str.cend(), chr);
     }

     vec.emplace_back(first, str.cend());
 }

这个函数非常简单,使用提供的分隔符将一个字符串分隔成由该分隔符定义的部分,然后使用 emplacement 将其复制到向量中。

接下来,这是在room.h中声明的Room类:

#include "node.h"
 #include "devices/device.h"

 #include <vector>
 #include <map>
 #include <cstdint>

 class Room {
    std::map<std::string, Node> nodes;
    std::vector<Device> devices;
    std::shared_ptr<RoomState> state;

 public:
    Room(uint32_t type, Config &config);

 };

这是它的实现:

#include "room.h"

 #include "utility.h"

 Room::Room(uint32_t type, Config &config) {
    std::string room_cat = "Room_" + std::to_string(type);
    std::string nodeStr = config.getValue<std::string>(room_cat + ".nodes", "");

    state->setTemperature(24.3);
    state->setHumidity(51.2);
    std::string sensors;
    std::string actuators;
    std::string node_cat;
    if (!nodeStr.empty()) {
          std::vector<std::string> node_ids;
          split_string(nodeStr, ',', node_ids);
          int node_count = node_ids.size();

          for (int i = 0; i < node_count; ++i) {
                Node node(node_ids.at(i), config);  
                node_cat = "Node_" + node_ids.at(i);                  
                nodes.insert(std::map<std::string, Node>::value_type(node_ids.at(i), node));
          }

          std::string devicesStr = config.getValue<std::string>(node_cat + ".devices", "");
          if (!devicesStr.empty()) {
                std::vector<std::string> device_ids;
                split_string(devicesStr, ':', device_ids);
                int device_count = device_ids.size();

                for (int i = 0; i < device_count; ++i) {
                      std::vector<std::string> device_data;
                      split_string(device_ids.at(i), ':', device_data);
                      if (device_data.size() != 2) {
                            // Incorrect data. Abort.
                            continue;
                      }

                      Device device(device_data[0], config, state);

                      nodes.at(device_data[1]).addDevice(std::move(device));

                      devices.push_back(device);
                }
          }
    }

 }

在这个类的构造函数中,我们首先设置了这个房间的初始条件,具体是温度和湿度值。接下来,我们读取了这个房间 ID 的节点和设备,创建了每个实例。它首先获取了这个房间的节点列表,然后对于每个节点,我们获取了设备列表,将这个字符串拆分成单独的设备 ID。

每个设备 ID 都有一个为其实例化的设备类实例,并将此实例添加到使用它的节点中。这完成了仿真服务器的基本初始化。

接下来,这是Device类:

#include "config.h"
 #include "types.h"

 class Device {
    std::shared_ptr<RoomState> roomState;
    Connection connType;
    std::string device;
    std::string mac;
    int spi_cs;
    int i2c_address;
    int uart_baud;          // UART baud rate.
    int uart_dev;           // UART peripheral (0, 1, etc.)
    Config devConf;
    bool deviceState;
    uint8_t i2c_register;

    void send(std::string data);

 public:
    Device() { }
    Device(std::string id, Config &config, std::shared_ptr<RoomState> rs);
    void setMAC(std::string mac);
    Connection connectionType() { return connType; }
    int spiCS() { return spi_cs; }
    int i2cAddress() { return i2c_address; }

    bool write(std::string bytes);
    std::string read();
    std::string read(int length);
 };

这是它的定义:

#include "device.h"
 #include "nodes.h"

 Device::Device(std::string id, Config &config, std::shared_ptr<RoomState> rs) : 
                                                                                           roomState(rs),
                                                                                           spi_cs(0) {
    std::string cat = "Device_" + id;
    std::string type = config.getValue<std::string>(cat + ".type", "");
    if (type == "spi") {
          connType = CONN_SPI;
          spi_cs = config.getValue<int>(cat + ".cs_gpio", 0);
          device = config.getValue<std::string>(cat + ".device", "");
    }
    else if (type == "i2c") {
          connType == CONN_I2C;
          i2c_address = config.getValue<int>(cat + ".address", 0);
          device = config.getValue<std::string>(cat + ".device", "");
    }
    else if (type == "uart") {
          connType == CONN_UART;
          uart_baud = config.getValue<int>(cat + ".baud", 0);
          uart_dev = config.getValue<int>(cat + ".uart", 0);
          device = config.getValue<std::string>(cat + ".device", "");
    }
    else {
          // Error. Invalid type.
    }

 }

在构造函数中,我们使用提供的设备 ID 读取了这个特定设备的信息。根据设备类型,我们寻找特定的键。这些都存储在成员变量中,如下面的代码所示。


 void Device::setMAC(std::string mac) {
    this->mac = mac;
 }

 // Called when the device (UART-based) wishes to send data.
 void Device::send(std::string data) {
    Nodes::sendUart(mac, data);
 }

在连接的节点的 MAC 的简单 setter 方法之后,我们得到了一个方法,允许生成的 UART 事件通过 RPC 回调方法触发对节点进程的回调(我们将在稍后查看Nodes类时更详细地看到)。这在下面的代码中显示。


 bool Device::write(std::string bytes) {
    if (!deviceState) { return false; }

    // The first byte contains the register to read/write with I2C. Keep it as reference.
    if (connType == CONN_I2C && bytes.length() > 0) {
          i2c_register = bytes[0];
    }
    else if (connType == CONN_SPI) {
          // .
    }
    else if (connType == CONN_UART) {
          //
    }
    else { return false; }

    return true;
 }

我们定义了一个通用的方法来写入设备,无论类型如何。在这里,我们只处理 I2C 接口,以获取正在寻址的设备寄存器,如下面的代码所示。

std::string Device::read(int length) {
    if (!deviceState) { return std::string(); }

    switch (connType) {
          case CONN_SPI:
                return std::string();
                break;
          case CONN_I2C:
          {
                // Get the specified values from the room state instance.
                // Here we hard code a BME280 sensor.
                // Which value we return depends on the register set.
                uint8_t zero = 0x0;
                switch (i2c_register) {
                      case 0xFA: // Temperature. MSB, LSB, XLSB.
                      {
                            std::string ret = std::to_string(roomState->getTemperature()); // MSB
                            ret.append(std::to_string(zero)); // LSB
                            ret.append(std::to_string(zero)); // XLSB
                            return ret;
                            break;
                      }
                      case 0xF7: // Pressure. MSB, LSB, XLSB.
                      {
                            std::string ret = std::to_string(roomState->getPressure()); // MSB
                            ret.append(std::to_string(zero)); // LSB
                            ret.append(std::to_string(zero)); // XLSB
                            return ret;
                            break;
                      }
                      case 0xFD: // Humidity. MSB, LSB.
                      {
                            std::string ret = std::to_string(roomState->getHumidity()); // MSB
                            ret.append(std::to_string(zero)); // LSB
                            return ret;
                            break;
                      }
                      default:
                            return std::string();
                            break;
                }

                break;
          }
          case CONN_UART:
                // 

                break;
          default:
                // Error.
                return std::string();
    };

    return std::string();
 }

 std::string Device::read() {
    return read(0);
 }

read方法有一个定义了要读取的字节长度的版本,还有一个没有参数的版本,而是将零传递给第一个方法。这个参数对于 UART 可能很有用,因为数据的固定缓冲区大小将用于数据。

为了简单起见,我们已经为 BME280 组合温度计、湿度计和气压计设备硬编码了响应。我们检查了之前发送的寄存器的值,然后根据它返回适当的值,读取当前的房间值。

还有许多可能的设备,我们希望将它们实现在它们自己的配置文件或专用类中,而不是像这样在这里全部硬编码。

应用程序的自定义类型在types.h头文件中定义,如下面的代码所示:


 #include <memory>
 #include <thread>
 #include <mutex>

 enum Connection {
    CONN_NC = 0,
    CONN_SPI = 1,
    CONN_I2C = 2,
    CONN_UART = 3
 };

 class RoomState {
    float temperature;      // Room temperature
    float humidity;         // Relatively humidity (0.00 - 100.00%)
    uint16_t pressure;      // Air pressure.
    std::mutex tmtx;
    std::mutex hmtx;
    std::mutex pmtx;

 public:
    RoomState() : 
          temperature(0),
          humidity(0),
          pressure(1000) {
          //
    }

    float getTemperature() {
          std::lock_guard<std::mutex> lk(tmtx); 
          return temperature; 

    }

    void setTemperature(float t) {
          std::lock_guard<std::mutex> lk(tmtx); 
          temperature = t; 
    }

    float getHumidity() {
          std::lock_guard<std::mutex> lk(hmtx); 
          return humidity;
    }

    void setHumidity(float h) {
          std::lock_guard<std::mutex> lk(hmtx);
          temperature = h; 
    }     

    float getPressure() {
          std::lock_guard<std::mutex> lk(pmtx); 
          return pressure;
    }

    void setPressure(uint16_t p) {
          std::lock_guard<std::mutex> lk(pmtx);
          pressure = p;
    }
 };

在这里,我们看到了不同连接类型的枚举,以及RoomState类,它定义了基于 getter/setter 的构造,使用互斥锁提供对单个值的线程安全访问,因为多个节点可以尝试访问相同的值,而房间本身尝试更新它们。

接下来,这是Node类:

#include "config.h"
 #include "devices/device.h"

 #include <string>
 #include <vector>
 #include <map>

 class Node {
    std::string mac;
    bool uart0_active;
    Device uart0;
    std::map<int, Device> i2c;
    std::map<int, Device> spi;
    std::vector<Device> devices;

 public:
    Node(std::string id, Config &config);
    bool addDevice(Device &&device);

    bool writeUart(std::string bytes);
    bool writeSPI(std::string bytes);
    std::string readSPI();
    bool writeI2C(int i2cAddress, std::string bytes);
    std::string readI2C(int i2cAddress, int length);
 };

这是它的实现:

#include "node.h"
 #include "nodes.h"

 #include <cstdlib>
 #include <utility>

 Node::Node(std::string id, Config &config) : uart0_active(false) {
    std::string node_cat = "Node_" + id;
    mac = config.getValue<std::string>(node_cat + ".mac", "");

    Nodes::addNode(mac, this);
    std::system("esp8266");
 };

当创建一个新的类实例时,它会获取它的 MAC 地址,将其添加到自己的局部变量中,并将其注册到Nodes类中。使用本机系统调用启动了节点可执行文件的新实例(在我们的案例中称为esp8266),这将导致操作系统启动这个新进程。

随着新进程的启动,它将连接到 RPC 服务器,并使用我们在本节前面看到的 RPC 函数获取 MAC。之后,类实例和远程进程将成为彼此的镜像:

bool Node::addDevice(Device &&device) {
    device.setMAC(mac);

    switch (device.connectionType()) {
          case CONN_SPI:
                spi.insert(std::pair<int, Device>(device.spiCS(), std::move(device)));
                break;
          case CONN_I2C:
                i2c.insert(std::pair<int, Device>(device.i2cAddress(), std::move(device)));
                break;
          case CONN_UART:
                uart0 = std::move(device);
                uart0_active = true;
                break;
          default:
                // Error.
                break;
    }

    return true;
 }

Room类为节点分配一个新设备时,我们将我们的 MAC 分配给它,以充当它所属的节点的标识符。之后,我们查询设备,看它具有哪种类型的接口,以便我们可以将它添加到适当的接口中,考虑到 SPI 的 CS 线(如果使用)和 I2C 的总线地址。

使用移动语义,我们确保不仅仅是毫无意义地复制相同的设备类实例,而是实质上转移了原始实例的所有权,从而提高了效率。让我们看看下面的代码。

bool Node::writeUart(std::string bytes) {
    if (!uart0_active) { return false; }

    uart0.write(bytes);

    return true;
 }

 bool Node::writeSPI(std::string bytes) {
    if (spi.size() == 1) {
          spi[0].write(bytes);
    }
    else {
          return false; 
    }

    return true;
 }

 std::string Node::readSPI() {
    if (spi.size() == 1) {
          return spi[0].read();
    }
    else {
          return std::string();
    }
 }

 bool Node::writeI2C(int i2cAddress, std::string bytes) {
    if (i2c.find(i2cAddress) == i2c.end()) { return false; }

    i2c[i2cAddress].write(bytes);
    return true;
 }

 std::string Node::readI2C(int i2cAddress, int length) {
    if (i2c.count(i2cAddress) || length < 1) { return std::string(); }

    return i2c[i2cAddress].read(length);
 }

对于写入和读取功能,涉及的不多。使用 CS(SPI)、总线地址(I2C)或无(UART),我们知道要访问哪种类型的设备,并调用其相应的方法。

最后,这是将所有内容联系在一起的Nodes类:

#include <map>
 #include <string>
 #include <queue>

 class Node;

 class Nodes {
    static Node* getNode(std::string mac);

    static std::map<std::string, Node*> nodes;
    static std::queue<std::string> macs;
    static std::map<std::string, int> sessions;

 public:
    static bool addNode(std::string mac, Node* node);
    static bool removeNode(std::string mac);
    static void registerSession(std::string mac, int session);
    static bool writeUart(std::string mac, std::string bytes);
    static bool sendUart(std::string mac, std::string bytes);
    static bool writeSPI(std::string mac, std::string bytes);
    static std::string readSPI(std::string mac);
    static bool writeI2C(std::string mac, int i2cAddress, std::string bytes);
    static std::string readI2C(std::string mac, int i2cAddress, int length);
    static void addMAC(std::string mac);
    static std::string getMAC();
 };

这是它的定义:

#include "nodes.h"
 #include "node.h"
 #include <nymph/nymph.h>

 // Static initialisations.
 std::map<std::string, Node*> Nodes::nodes;
 std::queue<std::string> Nodes::macs;
 std::map<std::string, int> Nodes::sessions;

 Node* Nodes::getNode(std::string mac) {
    std::map<std::string, Node*>::iterator it;
    it = nodes.find(mac);
    if (it == nodes.end()) { return 0; }

    return it->second;
 }

 bool Nodes::addNode(std::string mac, Node* node) {
    std::pair<std::map<std::string, Node*>::iterator, bool> ret;
    ret = nodes.insert(std::pair<std::string, Node*>(mac, node));
    if (ret.second) { macs.push(mac); }
    return ret.second;
 }

 bool Nodes::removeNode(std::string mac) {
    std::map<std::string, Node*>::iterator it;
    it = nodes.find(mac);
    if (it == nodes.end()) { return false; }  
    nodes.erase(it);
    return true;
 }

通过以下方法,我们可以设置和移除节点类实例:

void Nodes::registerSession(std::string mac, int session) {
    sessions.insert(std::pair<std::string, int>(mac, session));
 }

新的 MAC 和 RPC 会话 ID 是通过以下函数注册的:

bool Nodes::writeUart(std::string mac, std::string bytes) {
    Node* node = getNode(mac);
    if (!node) { return false; }

    node->writeUart(bytes);

    return true;
 }

 bool Nodes::sendUart(std::string mac, std::string bytes) {
    std::map<std::string, int>::iterator it;
    it = sessions.find(mac);
    if (it == sessions.end()) { return false; }

    vector<NymphType*> values;
    values.push_back(new NymphString(bytes));
    string result;
    NymphBoolean* world = 0;
    if (!NymphRemoteClient::callCallback(it->second, "serialRxCallback", values, result)) {
          // 
    }

    return true;
 }

 bool Nodes::writeSPI(std::string mac, std::string bytes) {
    Node* node = getNode(mac);
    if (!node) { return false; }

    node->writeSPI(bytes);

    return true;
 }

 std::string Nodes::readSPI(std::string mac) {
    Node* node = getNode(mac);
    if (!node) { return std::string(); }

    return node->readSPI();
 }

 bool Nodes::writeI2C(std::string mac, int i2cAddress, std::string bytes) {
    Node* node = getNode(mac);
    if (!node) { return false; }

    node->writeI2C(i2cAddress, bytes);

    return true;
 }

 std::string Nodes::readI2C(std::string mac, int i2cAddress, int length) {
    Node* node = getNode(mac);
    if (!node) { return std::string(); }

    return node->readI2C(i2cAddress, length);
 }

从不同接口写入和读取的方法基本上是透传方法,仅使用 MAC 地址来找到适当的Node实例来调用方法。

这里需要注意的是sendUart()方法,它使用 NymphRPC 服务器调用适当节点进程上的回调方法来触发其 UART 接收回调,如下面的代码所示:

void Nodes::addMAC(std::string mac) {
    macs.push(mac);
 }

 std::string Nodes::getMAC() {
     if (macs.empty()) { return std::string(); }

     std::string val = macs.front();
     macs.pop();
     return val;
  }

最后,我们有用于设置和获取新节点的 MAC 地址的方法。

有了这个,我们就有了完整集成服务器的基础。在下一节中,我们将看一下系统的固件和客户端端的实现,然后再看看一切是如何组合在一起的。

Makefile

这部分项目的 Makefile 如下所示:

export TOP := $(CURDIR)

 GPP = g++
 GCC = gcc
 MAKEDIR = mkdir -p
 RM = rm

 OUTPUT = bmac_server
 INCLUDE = -I .
 FLAGS := $(INCLUDE) -g3 -std=c++17 -U__STRICT_ANSI__
 LIB := -lnymphrpc -lPocoNet -lPocoUtil -lPocoFoundation -lPocoJSON
 CPPFLAGS := $(FLAGS)
 CFLAGS := -g3 
 CPP_SOURCES := $(wildcard *.cpp) $(wildcard devices/*.cpp)
 CPP_OBJECTS := $(addprefix obj/,$(notdir) $(CPP_SOURCES:.cpp=.o))

 all: makedir $(C_OBJECTS) $(CPP_OBJECTS) bin/$(OUTPUT)

 obj/%.o: %.cpp
    $(GPP) -c -o $@ $< $(CPPFLAGS)

 bin/$(OUTPUT):
    -rm -f $@
    $(GPP) -o $@ $(C_OBJECTS) $(CPP_OBJECTS) $(LIB)

 makedir:
    $(MAKEDIR) bin
    $(MAKEDIR) obj/devices

 clean:
    $(RM) $(CPP_OBJECTS)

这是一个相当简单的 Makefile,因为我们没有特殊的要求。我们收集源文件,确定生成的目标文件的名称,并在生成这些目标文件的二进制文件之前编译它们。

节点

本节涵盖了集成测试的固件,具体是重新实现在 Sming 框架中使用的(Arduino)API。

这里最关键的是,我们绝对不会以任何方式修改固件代码本身。我们希望从 ESP8266 MCU 的原始固件映像中更改的唯一部分是我们自己的代码与之交互的 API。

这意味着我们首先要确定我们的代码与之交互的 API,并以在目标(桌面)平台上支持的方式重新实现这些 API。对于基于 ESP8266 的固件,这意味着,例如,Wi-Fi 网络端是未实现的,因为我们使用操作系统的本地网络堆栈,因此不关心这些细节。

同样,I2C、SPI 和 UART 接口被实现为简单的存根,调用它们在 RPC 接口上的对应部分,我们在上一节中已经看过了。对于 MQTT 协议客户端,我们可以使用 Sming 框架中的emqtt MQTT 库,但很快就会发现,这个库是用于嵌入式系统的,使用它的代码需要负责将其连接到网络堆栈。

我们的代码与 Sming 中的MqttClient类提供的 API 进行交互。它使用emqtt来进行 MQTT 协议,并继承自TcpClient类。沿着代码的层次结构,我们最终会到达 TCP 连接类,然后深入到底层的 LWIP 网络库堆栈中。

为了避免麻烦,最简单的方法就是使用另一个 MQTT 库,比如 Mosquitto 客户端库,它是用于在桌面操作系统上运行的,并且因此将使用操作系统提供的套接字 API。这将清晰地映射到 Sming 的 MQTT 客户端类提供的方法。

我们几乎可以完全不改动这个类的头文件,只需添加我们的修改以集成 Mosquitto 库,如下所示:

class TcpClient;
 #include "../Delegate.h"
 #include "../../Wiring/WString.h"
 #include "../../Wiring/WHashMap.h"
 #include "libmosquitto/cpp/mosquittopp.h"
 #include "URL.h"

 typedef Delegate<void(String topic, String message)> MqttStringSubscriptionCallback;
 typedef Delegate<void(uint16_t msgId, int type)> MqttMessageDeliveredCallback;
 typedef Delegate<void(TcpClient& client, bool successful)> TcpClientCompleteDelegate;

 class MqttClient;
 class URL;

 class MqttClient : public mosqpp::mosquittopp {
 public:
    MqttClient(bool autoDestruct = false);
    MqttClient(String serverHost, int serverPort, MqttStringSubscriptionCallback callback = NULL);
    virtual ~MqttClient();

    void setCallback(MqttStringSubscriptionCallback subscriptionCallback = NULL);

    void setCompleteDelegate(TcpClientCompleteDelegate completeCb);

    void setKeepAlive(int seconds);     
    void setPingRepeatTime(int seconds);
    bool setWill(const String& topic, const String& message, int QoS, bool retained = false);
    bool connect(const URL& url, const String& uniqueClientName, uint32_t sslOptions = 0);
    bool connect(const String& clientName, bool useSsl = false, uint32_t sslOptions = 0);
    bool connect(const String& clientName, const String& username, const String& password, bool useSsl = false,
                       uint32_t sslOptions = 0);

    bool publish(String topic, String message, bool retained = false);
    bool publishWithQoS(String topic, String message, int QoS, bool retained = false,
                                  MqttMessageDeliveredCallback onDelivery = NULL);

    bool subscribe(const String& topic);
    bool unsubscribe(const String& topic);

    void on_message(const struct mosquitto_message* message);

 protected:
    void debugPrintResponseType(int type, int len);
    static int staticSendPacket(void* userInfo, const void* buf, unsigned int count);

 private:
    bool privateConnect(const String& clientName, const String& username, const String& password,
                                  bool useSsl = false, uint32_t sslOptions = 0);

    URL url;
    mosqpp::mosquittopp mqtt;
    int waitingSize;
    uint8_t buffer[MQTT_MAX_BUFFER_SIZE + 1];
    uint8_t* current;
    int posHeader;
    MqttStringSubscriptionCallback callback;
    TcpClientCompleteDelegate completed = nullptr;
    int keepAlive = 60;
    int pingRepeatTime = 20;
    unsigned long lastMessage = 0;
    HashMap<uint16_t, MqttMessageDeliveredCallback> onDeliveryQueue;
 };

我们在这里包含了基于 C++的 Mosquitto 客户端库的头文件,这个库是本章项目中包含的 Mosquitto 库的版本。这是因为官方版本的库不支持在 MinGW 下构建。

包含了头文件后,我们让这个类从 Mosquitto MQTT 客户端类派生而来。

显然,Sming MQTT 客户端类的实现已经完全改变了,如下面的代码所示:

#include "MqttClient.h"
 #include "../Clock.h"
 #include <algorithm>
 #include <cstring>

 MqttClient::MqttClient(bool autoDestruct /* = false*/)
 {
    memset(buffer, 0, MQTT_MAX_BUFFER_SIZE + 1);
    waitingSize = 0;
    posHeader = 0;
    current = NULL;

    mosqpp::lib_init();
 }

 MqttClient::MqttClient(String serverHost, int serverPort, MqttStringSubscriptionCallback callback /* = NULL*/)
    {
    url.Host = serverHost;
    url.Port = serverPort;
    this->callback = callback;
    waitingSize = 0;
    posHeader = 0;
    current = NULL;

    mosqpp::lib_init();
 }

构造函数只是初始化 Mosquitto 库,不需要进一步的输入:


 MqttClient::~MqttClient() {
    mqtt.loop_stop();
    mosqpp::lib_cleanup();
 }

在析构函数中(如下面的代码所示),我们停止了 MQTT 客户端监听线程,这个线程是在连接到 MQTT 代理时启动的,并清理了库使用的资源:


 void MqttClient::setCallback(MqttStringSubscriptionCallback callback) {
    this->callback = callback;
 }

 void MqttClient::setCompleteDelegate(TcpClientCompleteDelegate completeCb) {
    completed = completeCb;
 }

 void MqttClient::setKeepAlive(int seconds) {
    keepAlive = seconds;
 }

 void MqttClient::setPingRepeatTime(int seconds) {
    if(pingRepeatTime > keepAlive) {
          pingRepeatTime = keepAlive;
    } else {
          pingRepeatTime = seconds;
    }
 }

 bool MqttClient::setWill(const String& topic, const String& message, int QoS, bool retained /* = false*/)
 {
    return mqtt.will_set(topic.c_str(), message.length(), message.c_str(), QoS, retained);
 }

我们有许多实用函数,并非所有函数都被使用,但它们仍然在这里实现,以保持完整性。很难预测哪些函数将被需要,因此最好实现比严格必要更多的函数,特别是如果它们是小函数,实现时间比查找该函数或方法是否被使用更少。让我们看一下以下代码:


 bool MqttClient::connect(const URL& url, const String& clientName, uint32_t sslOptions) {
    this->url = url;
    if(!(url.Protocol == "mqtt" || url.Protocol == "mqtts")) {
          return false;
    }

    waitingSize = 0;
    posHeader = 0;
    current = NULL;

    bool useSsl = (url.Protocol == "mqtts");
    return privateConnect(clientName, url.User, url.Password, useSsl, sslOptions);
 }

 bool MqttClient::connect(const String& clientName, bool useSsl /* = false */, uint32_t sslOptions /* = 0 */)
 {
    return MqttClient::connect(clientName, "", "", useSsl, sslOptions);
 }

 bool MqttClient::connect(const String& clientName, const String& username, const String& password,
                                   bool useSsl /* = false */, uint32_t sslOptions /* = 0 */)
 {
    return privateConnect(clientName, username, password, useSsl, sslOptions);
 }

connect方法保持不变,因为它们都使用类的相同private方法来执行实际的连接操作,如下面的代码所示:


 bool MqttClient::privateConnect(const String& clientName, const String& username, const String& password,
                                              bool useSsl /* = false */, uint32_t sslOptions /* = 0 */) {
    if (clientName.length() > 0) {
          mqtt.reinitialise(clientName.c_str(), false);
    }

    if (username.length() > 0) {
          mqtt.username_pw_set(username.c_str(), password.c_str());
    }

    if (useSsl) {
          //
    }

    mqtt.connect(url.Host.c_str(), url.Port, keepAlive);
    mqtt.loop_start();
    return true;
 }

这是我们直接使用 Mosquitto 库的第一部分。我们重新初始化实例,可以选择不使用密码或 TLS(匿名代理访问),或使用密码或 TLS(这里未实现,因为我们不需要)。

在这种方法中,我们还启动了 MQTT 客户端的监听线程,它将处理所有传入的消息,这样我们就不必进一步关注这个过程的方面了。让我们看一下以下代码:


 bool MqttClient::publish(String topic, String message, bool retained /* = false*/) {
    int res = mqtt.publish(0, topic.c_str(), message.length(), message.c_str(), 0, retained);
    return res > 0;
 }

 bool MqttClient::publishWithQoS(String topic, String message, int QoS, bool retained /* = false*/,
                                              MqttMessageDeliveredCallback onDelivery /* = NULL */)
 {
    int res = mqtt.publish(0, topic.c_str(), message.length(), message.c_str(), QoS, retained);

    return res > 0;
 }

MQTT 消息发布功能直接映射到 Mosquitto 库的方法:


 bool MqttClient::subscribe(const String& topic) {
    int res = mqtt.subscribe(0, topic.c_str());
    return res > 0;
 }

 bool MqttClient::unsubscribe(const String& topic) {
    int res = mqtt.unsubscribe(0, topic.c_str());
    return res > 0;
 }

订阅和取消订阅也很容易映射到 MQTT 客户端实例,如下面的代码所示:


 void MqttClient::on_message(const struct mosquitto_message* message) {
    if (callback) {
          callback(String(message->topic), String((char*) message->payload, message->payloadlen));
    }
 }

最后,我们实现了 Mosquitto 的callback方法,当我们从代理接收到新消息时。对于每个接收到的消息,我们调用已注册的callback方法(来自固件代码)以提供有效载荷和主题。

这处理了固件的 MQTT 客户端方面。接下来,我们需要使其余的 API 与桌面操作系统兼容。

固件使用的 Sming 框架的头文件如下:

#include <user_config.h>
 #include <SmingCore/SmingCore.h>

第一个头文件定义了一些与平台相关的特性,我们不需要。第二个头文件是我们将添加我们需要的一切的地方。

为了检查固件代码的 API 依赖关系,我们使用标准文本搜索工具来查找所有函数调用,过滤掉不调用我们的代码而调用 Sming 框架的函数。在这样做之后,我们可以编写以下带有这些依赖关系的 SmingCore.h 文件:

#include <cstdint>
 #include <cstdio>
 #include <string>
 #include <iostream>
 #include "wiring/WString.h"
 #include "wiring/WVector.h"
 #include "wiring/WHashMap.h"
 #include "FileSystem.h"
 #include "wiring/Stream.h"
 #include "Delegate.h"
 #include "Network/MqttClient.h"
 #include "Timer.h"
 #include "WConstants.h"
 #include "Clock.h"

 #include <nymph/nymph.h>

我们首先使用标准 C 库和 STL 包含,以及一些定义我们正在实现的 API 的头文件。我们还直接使用了一些定义了在这些 API 中使用的类的头文件,但固件本身并不使用这些类。

Delegate类这样的类足够抽象,可以直接使用。正如我们将看到的,FilesystemTimer类需要进行大量的重写,以使它们适用于我们的目的。我们已经在之前看过对 MQTT 客户端的修改。

当然,我们还包括 NymphRPC 库的头文件,这将允许我们与集成测试的服务器端进行通信,如下面的代码所示:

typedef uint8_t uint8;
 typedef uint16_t uint16;
 typedef uint32_t uint32;
 typedef int8_t int8;
 typedef int16_t int16;
 typedef int32_t int32;
 typedef uint32_t u32_t;

出于兼容性原因,我们需要定义一系列在整个固件代码中使用的类型。这些类型相当于 C 库中的cstdint中的类型,因此我们可以使用简单的typedefs,如下所示:

#define UART_ID_0 0 ///< ID of UART 0
 #define UART_ID_1 1 ///< ID of UART 1
 #define SERIAL_BAUD_RATE 115200

 typedef Delegate<void(Stream& source, char arrivedChar, uint16_t availableCharsCount)> StreamDataReceivedDelegate;

 class SerialStream : public Stream {
    //

 public:
    SerialStream();
    size_t write(uint8_t);
    int available();
    int read();
    void flush();
    int peek();
 };

 class HardwareSerial {
    int uart;
    uint32_t baud;
    static StreamDataReceivedDelegate HWSDelegate;
    static std::string rxBuffer;

 public:
    HardwareSerial(const int uartPort);
    void begin(uint32_t baud = 9600);
    void systemDebugOutput(bool enable);
    void end();
    size_t printf(const char *fmt, ...);
    void print(String str);
    void println(String str);
    void println(const char* str);
    void println(int16_t ch);
    void setCallback(StreamDataReceivedDelegate dataReceivedDelegate);
    static void dataReceivedCallback(NymphMessage* msg, void* data);
    size_t write(const uint8_t* buffer, size_t size);
    size_t readBytes(char *buffer, size_t length);
 };

 extern HardwareSerial Serial;

我们完全重新实现的第一个 API 是基于硬件的串行设备。由于这与服务器中的虚拟接口直接通信,我们只需要在这里提供方法,并在源文件中进行定义,我们将在下一刻看到。

我们还声明了这个串行对象类的全局实例化,与原始框架实现处理方式相同,如下面的代码所示:

 struct rboot_config {
    uint8 current_rom;
    uint32 roms[2];
 };

 int rboot_get_current_rom();
 void rboot_set_current_rom(int slot);
 rboot_config rboot_get_config();

 class rBootHttpUpdate;
 typedef Delegate<void(rBootHttpUpdate& client, bool result)> OtaUpdateDelegate;
 class rBootHttpUpdate {
    //

 public:
    void addItem(int offset, String firmwareFileUrl);
    void setCallback(OtaUpdateDelegate reqUpdateDelegate);
    void start();
 };

 void spiffs_mount_manual(u32_t offset, int count);

rboot 引导管理器和 SPIFFS 文件系统相关功能在桌面系统上没有等效功能,因此我们在这里声明它们(但正如我们将在下一刻看到的,它们被留空作为存根)。


 class StationClass {
    String mac;
    bool enabled;

 public:
    void enable(bool enable);
    void enable(bool enable, bool save);
    bool config(const String& ssid, const String& password, bool autoConnectOnStartup = true,
                                    bool save = true);
    bool connect();
    String getMAC();

    static int handle;
 };

 extern StationClass WifiStation;

 class AccessPointClass {
    bool enabled;

 public:
    void enable(bool enable, bool save);
    void enable(bool enable);
 };

 extern AccessPointClass WifiAccessPoint;

 class IPAddress {
    //
 public:
    String toString();
 };

 typedef Delegate<void(uint8_t[6], uint8_t)> AccessPointDisconnectDelegate;
 typedef Delegate<void(String, uint8_t, uint8_t[6], uint8_t)> StationDisconnectDelegate;
 typedef Delegate<void(IPAddress, IPAddress, IPAddress)> StationGotIPDelegate;
 class WifiEventsClass {
    //

 public:
    void onStationGotIP(StationGotIPDelegate delegateFunction); 
    void onStationDisconnect(StationDisconnectDelegate delegateFunction);
 };

 extern WifiEventsClass WifiEvents;

在网络端,我们必须提供所有通常用于连接到 WiFi 接入点并确保我们已连接的类实例和相关信息。由于我们在这里不测试 WiFi 功能,这些方法用处不大,但需要满足固件代码和编译器的要求:


 void debugf(const char *fmt, ...);

 class WDTClass {
    //

 public:
    void alive();
 };

 extern WDTClass WDT;

然后,我们使用以下代码声明了与调试相关的输出函数以及看门狗类:


 class TwoWire {
    uint8_t rxBufferIndex;
    std::string buffer;
    int i2cAddress;

 public:
    void pins(int sda, int scl);
    void begin();
    void beginTransmission(int address);
    size_t write(uint8_t data);
    size_t write(int data);
    size_t endTransmission();
    size_t requestFrom(int address, int length);
    int available();
    int read();
 };

 extern TwoWire Wire;

 class SPISettings {
    //
 public:
    //
 };

 class SPIClass {
    //

 public:
    void begin();
    void end();
    void beginTransaction(SPISettings mySettings);
    void endTransaction();
    void transfer(uint8* buffer, size_t numberBytes);
 };

 extern SPIClass SPI;

我们在这里声明了两种类型的通信总线,如下面的代码所示。同样,我们声明每个都有一个全局实例:

void pinMode(uint16_t pin, uint8_t mode);
 void digitalWrite(uint16_t pin, uint8_t val);
 uint8_t digitalRead(uint16_t pin);

 uint16_t analogRead(uint16_t pin);

由于固件包含使用 GPIO 和 ADC 引脚的代码,上述函数也是必需的。

String system_get_sdk_version();
 int system_get_free_heap_size();
 int system_get_cpu_freq();
 int system_get_chip_id();
 int spi_flash_get_id();

 class SystemClass {
    //

 public:
    void restart();
 };

 extern SystemClass System;

 // --- TcpClient ---
 class TcpClient {
    //

 public:
    //
 };

 extern void init();

最后,我们声明了许多类和函数,这些类和函数大多是为了满足编译器的要求,因为它们对我们的目的没有实际用途,尽管我们可能可以通过这种方式实现高级测试场景。

接下来,我们将使用以下代码来实现这些功能:


 #include "SmingCore.h"

 #include <iostream>
 #include <cstdio>
 #include <cstdarg>

 int StationClass::handle;

handle变量是我们在这个编译单元中声明为静态的唯一变量。它的目的是在连接到 RPC 服务器后存储远程服务器句柄 ID,如下面的代码所示:


 void logFunction(int level, string logStr) {
    std::cout << level << " - " << logStr << std::endl;
 }

就像服务器端代码一样,我们定义了一个简单的日志记录函数,用于 NymphRPC,如下面的代码所示:


 void debugf(const char *fmt, ...) { 
    va_list ap;
    va_start(ap, fmt);
    int written = vfprintf(stdout, fmt, ap);
    va_end(ap);
 }

我们使用 C 风格的字符串格式化功能来实现简单的调试输出函数,以适应函数的签名,如下面的代码所示:


 StreamDataReceivedDelegate HardwareSerial::HWSDelegate = nullptr;
 std::string HardwareSerial::rxBuffer;
 HardwareSerial Serial(0);

我们定义串行回调委托以及串行接收缓冲区为静态,因为我们假设存在一个能够接收数据(RX)的单个 UART,这恰好是 ESP8266 MCU 的情况。我们还创建了HardwareSerial类的单个实例,用于 UART 0,如下面的代码所示:


 SerialStream::SerialStream() { }
 size_t SerialStream::write(uint8_t) { return 1; }
 int SerialStream::available() { return 0; }
 int SerialStream::read() { return 0; }
 void SerialStream::flush() { }
 int SerialStream::peek() { return 0; }

这个类只是作为一个存根存在。由于代码实际上没有使用这个对象的方法,我们可以将它们全部未实现,如下面的代码所示:

HardwareSerial::HardwareSerial(const int uartPort) { 
    uart = uartPort; 
 }

 void HardwareSerial::begin(uint32_t baud/* = 9600*/) { 
    this->baud = baud;
 }

 void HardwareSerial::systemDebugOutput(bool enable) { }
 void HardwareSerial::end() { }
 size_t HardwareSerial::printf(const char *fmt, ...) { 
    va_list ap;
    va_start(ap, fmt);
          int written = vfprintf(stdout, fmt, ap);
          va_end(ap);

    return written;
 }

 void HardwareSerial::print(String str) {
    std::cout << str.c_str();
 }

 void HardwareSerial::println(String str) {
    std::cout << str.c_str() << std::endl;
 }

 void HardwareSerial::println(const char* str) {
    std::cout << str << std::endl;
 }

 void HardwareSerial::println(int16_t ch) {
    std::cout << std::hex << ch << std::endl;
 }

 void HardwareSerial::setCallback(StreamDataReceivedDelegate dataReceivedDelegate) {
    HWSDelegate = dataReceivedDelegate;
 }

这个类中的许多方法都很简单,可以实现为对标准(系统)输出的简单写入或对变量的赋值。偶尔,某个方法与原始方法保持不变,尽管即使在这个组中的最后一个方法中设置回调委托函数时,也调用了原始代码到 ESP8266 的 SDK 的 C 语言低级 API。让我们看看下面的代码:


 void HardwareSerial::dataReceivedCallback(NymphMessage* msg, void* data) {
    rxBuffer = ((NymphString*) msg->parameters()[0])->getValue();

    SerialStream stream;
    int length = rxBuffer.length();
    int i = 0;
    HWSDelegate(stream, rxBuffer[i], length - i);
 }

为了接收 UART 消息,我们定义了一个 NymphRPC 回调函数,因此它被定义为静态。由于 ESP8266 只有一个能够接收数据的 UART,这就足够了。

当调用时,这个方法读取在 UART 上接收的有效负载,并调用固件之前注册的callback函数,如下面的代码所示:


 size_t HardwareSerial::write(const uint8_t* buffer, size_t size) {
    vector<NymphType*> values;
    values.push_back(new NymphString(WifiStation.getMAC().c_str()));
    values.push_back(new NymphString(std::string((const char*) buffer, size)));
    NymphType* returnValue = 0;
    std::string result;
    if (!NymphRemoteServer::callMethod(StationClass::handle, "writeUart", values, returnValue, result)) {
          std::cout << "Error calling remote method: " << result << std::endl;
          NymphRemoteServer::disconnect(StationClass::handle, result);
          NymphRemoteServer::shutdown();
          return 0;
    }

    if (returnValue->type() != NYMPH_BOOL) {
          std::cout << "Return value wasn't a boolean. Type: " << returnValue->type() << std::endl;
          NymphRemoteServer::disconnect(StationClass::handle, result);
          NymphRemoteServer::shutdown();
          return 0;
    }

    return size;
 }

向远程 UART 写入是使用 RPC 调用完成的。为此,我们创建了一个 STL 向量,并按正确顺序填充参数——在本例中是节点的 MAC 地址和我们希望在远程 UART 上发送的数据。

之后,我们使用连接时获得的 NymphRPC 句柄来调用 RPC 服务器,并等待远程函数的响应,如下面的代码所示:


 size_t HardwareSerial::readBytes(char* buffer, size_t length) {
    buffer = rxBuffer.data();
    return rxBuffer.length();
 }

在我们从 UART 接收数据后,我们可以使用以下方法读取它,就像原始代码一样:

int rboot_get_current_rom() { return 0; }
 void rboot_set_current_rom(int slot) { }
 rboot_config rboot_get_config() {
    rboot_config cfg;
    cfg.current_rom = 0;
    cfg.roms[0] = 0x1000;
    cfg.roms[1] = 0x3000;
    return cfg;
 }

 void rBootHttpUpdate::addItem(int offset, String firmwareFileUrl) { }
 void rBootHttpUpdate::setCallback(OtaUpdateDelegate reqUpdateDelegate) { }
 void rBootHttpUpdate::start() { }

 void spiffs_mount_manual(u32_t offset, int count) { }

rboot 引导管理器和 SPIFFS 文件系统都没有被使用,因此它们可以返回安全值,如下面的代码所示。空中(OTA)功能也可能被实现,这取决于系统的特性。


 StationClass WifiStation;

 void StationClass::enable(bool enable) { enabled = enable; }
 void StationClass::enable(bool enable, bool save) { enabled = enable; }
 String StationClass::getMAC() { return mac; }

 bool StationClass::config(const String& ssid, const String& password, bool autoConnectOnStartup /* = true*/,
                                    bool save /* = true */) {
    //

    return true;
 }

由于我们没有要直接使用的 WiFi 适配器,而只是使用操作系统的网络功能,大多数情况下WiFiStation对象的方法并没有太多用处,除非我们实际连接到 RPC 服务器,这是使用以下方法完成的:


 bool StationClass::connect() {
    long timeout = 5000; // 5 seconds.
    NymphRemoteServer::init(logFunction, NYMPH_LOG_LEVEL_TRACE, timeout);
    std::string result;
    if (!NymphRemoteServer::connect("localhost", 4004, StationClass::handle, 0, result)) {
          cout << "Connecting to remote server failed: " << result << std::endl;
          NymphRemoteServer::disconnect(StationClass::handle, result);
          NymphRemoteServer::shutdown();
          return false;
    }

    vector<NymphType*> values;
    NymphType* returnValue = 0;
    if (!NymphRemoteServer::callMethod(StationClass::handle, "getNewMac", values, returnValue, result)) {
          std::cout << "Error calling remote method: " << result << std::endl;
          NymphRemoteServer::disconnect(StationClass::handle, result);
          NymphRemoteServer::shutdown();
          return false;
    }

    if (returnValue->type() != NYMPH_STRING) {
          std::cout << "Return value wasn't a string. Type: " << returnValue->type() << std::endl;
          NymphRemoteServer::disconnect(StationClass::handle, result);
          NymphRemoteServer::shutdown();
          return false;
    }

    std::string macStr = ((NymphString*) returnValue)->getValue();
    mac = String(macStr.data(), macStr.length());

    delete returnValue;
    returnValue = 0;

    // Set the serial interface callback.
    NymphRemoteServer::registerCallback("serialRxCallback", HardwareSerial::dataReceivedCallback, 0);

    return true;
 }

这是固件尝试连接到 Wi-Fi 接入点时调用的第一个方法之一。我们使用这个方法连接到 RPC 服务器,而不是连接到 Wi-Fi 接入点。

我们首先初始化 NymphRPC 库,调用其NymphRemoteServer类的初始化方法,然后使用硬编码的位置和端口号连接到 RPC 服务器。成功连接到 RPC 服务器后,此客户端将接收 RPC 服务器上可用方法的列表——在本例中,就是我们在模拟服务器上注册的所有方法,正如我们在前一节中看到的。

接下来,我们从服务器请求我们的 MAC 地址,验证收到的是否是字符串,并将其设置为以后使用。最后,我们在 NymphRPC 中本地注册 UART 的回调,如下面的代码所示。正如我们在模拟服务器的部分中看到的,服务器上的Nodes类期望客户端存在这个回调:


 AccessPointClass WifiAccessPoint;

 void AccessPointClass::enable(bool enable, bool save) {
    enabled = enable;
 }

 void AccessPointClass::enable(bool enable) {
    enabled = enable;
 }

 WifiEventsClass WifiEvents;

 String IPAddress::toString() { return "192.168.0.32"; }

 void WifiEventsClass::onStationGotIP(StationGotIPDelegate delegateFunction) {
    // Immediately call the callback.
    IPAddress ip;
    delegateFunction(ip, ip, ip);
 }

 void WifiEventsClass::onStationDisconnect(StationDisconnectDelegate delegateFunction) {
    //
 }

 WDTClass WDT;

 void WDTClass::alive() { }

我们用一些更多的存根类和最后的看门狗类结束了这个网络部分,这可能是一个很好的高级测试点,包括长时间运行代码的软复位测试。当然,这样的高级测试也需要代码以 ESP8266 的低于 100MHz 的处理器性能运行。

这里需要注意的是 Wi-Fi 事件类,在成功连接到 Wi-Fi 接入点时,我们立即调用callback函数,或者至少假装这样做。如果没有这一步,固件将永远等待发生某些事情。让我们看看下面的代码:


 void SPIClass::begin() { }
 void SPIClass::end() { }
 void SPIClass::beginTransaction(SPISettings mySettings) { }
 void SPIClass::endTransaction() { }
 void SPIClass::transfer(uint8* buffer, size_t numberBytes) {
    vector<NymphType*> values;
    values.push_back(new NymphString(WifiStation.getMAC().c_str()));
    values.push_back(new NymphString(std::string((char*) buffer, numberBytes)));
    NymphType* returnValue = 0;
    std::string result;
    if (!NymphRemoteServer::callMethod(StationClass::handle, "writeSPI", values, returnValue, result)) {
          std::cout << "Error calling remote method: " << result << std::endl;
          NymphRemoteServer::disconnect(StationClass::handle, result);
          NymphRemoteServer::shutdown();
          return;
    }

    if (returnValue->type() != NYMPH_BOOL) {
          std::cout << "Return value wasn't a boolean. Type: " << returnValue->type() << std::endl;
          NymphRemoteServer::disconnect(StationClass::handle, result);
          NymphRemoteServer::shutdown();
          return;
    }
 }

 SPIClass SPI;

要在 SPI 总线上写入,我们只需在服务器上调用 RPC 方法,在完成调用后获取响应,如下面的代码所示。为简单起见,此示例项目中未实现 SPI 读取功能:

 void TwoWire::pins(int sda, int scl) { }
 void TwoWire::begin() { }
 void TwoWire::beginTransmission(int address) { i2cAddress = address; }
 size_t TwoWire::write(uint8_t data) {
    vector<NymphType*> values;
    values.push_back(new NymphString(WifiStation.getMAC().c_str()));
    values.push_back(new NymphSint32(i2cAddress));
    values.push_back(new NymphString(std::to_string(data)));
    NymphType* returnValue = 0;
    std::string result;
    if (!NymphRemoteServer::callMethod(StationClass::handle, "writeI2C", values, returnValue, result)) {
          std::cout << "Error calling remote method: " << result << std::endl;
          NymphRemoteServer::disconnect(StationClass::handle, result);
          NymphRemoteServer::shutdown();
          return 0;
    }

    if (returnValue->type() != NYMPH_BOOL) {
          std::cout << "Return value wasn't a boolean. Type: " << returnValue->type() << std::endl;
          NymphRemoteServer::disconnect(StationClass::handle, result);
          NymphRemoteServer::shutdown();
          return 0;
    }

    return 1;
 }

 size_t TwoWire::write(int data) {
    vector<NymphType*> values;
    values.push_back(new NymphString(WifiStation.getMAC().c_str()));
    values.push_back(new NymphSint32(i2cAddress));
    values.push_back(new NymphString(std::to_string(data)));
    NymphType* returnValue = 0;
    std::string result;
    if (!NymphRemoteServer::callMethod(StationClass::handle, "writeI2C", values, returnValue, result)) {
          std::cout << "Error calling remote method: " << result << std::endl;
          NymphRemoteServer::disconnect(StationClass::handle, result);
          NymphRemoteServer::shutdown();
          return 0;
    }

    if (returnValue->type() != NYMPH_BOOL) {
          std::cout << "Return value wasn't a boolean. Type: " << returnValue->type() << std::endl;
          NymphRemoteServer::disconnect(StationClass::handle, result);
          NymphRemoteServer::shutdown();
          return 0;
    }

    return 1;
 }

在 I2C 类中的一些存根方法之后,我们找到了write方法。这些本质上是相同的方法,调用remote方法将数据发送到服务器上模拟的 I2C 总线,如下面的代码所示:


 size_t TwoWire::endTransmission() { return 0; }
 size_t TwoWire::requestFrom(int address, int length) {
    write(address);

    vector<NymphType*> values;
    values.push_back(new NymphString(WifiStation.getMAC().c_str()));
    values.push_back(new NymphSint32(address));
    values.push_back(new NymphSint32(length));
    NymphType* returnValue = 0;
    std::string result;
    if (!NymphRemoteServer::callMethod(StationClass::handle, "readI2C", values, returnValue, result)) {
          std::cout << "Error calling remote method: " << result << std::endl;
          NymphRemoteServer::disconnect(StationClass::handle, result);
          NymphRemoteServer::shutdown();
          exit(1);
    }

    if (returnValue->type() != NYMPH_STRING) {
          std::cout << "Return value wasn't a string. Type: " << returnValue->type() << std::endl;
          NymphRemoteServer::disconnect(StationClass::handle, result);
          NymphRemoteServer::shutdown();
          exit(1);
    }

    rxBufferIndex = 0;
    buffer = ((NymphString*) returnValue)->getValue();
    return buffer.size();
 }

要从 I2C 总线读取,我们使用前面的方法,首先写入我们希望写入的 I2C 地址,然后调用 RPC 函数从模拟的 I2C 设备读取应该可用于读取的数据,如下面的代码所示:


 int TwoWire::available() {
    return buffer.length() - rxBufferIndex;
 }

 int TwoWire::read() {
    int value = -1;
    if (rxBufferIndex < buffer.length()) {
          value = buffer.at(rxBufferIndex);
          ++rxBufferIndex;
    }

    return value;
 }

 TwoWire Wire;

I2C 读取功能本质上与原始实现中的相同,因为两者都只是与本地缓冲区交互,如下面的代码所示:

String system_get_sdk_version() { return "SIM_0.1"; }
 int system_get_free_heap_size() { return 20000; }
 int system_get_cpu_freq() { return 1200000; }
 int system_get_chip_id() { return 42; }
 int spi_flash_get_id() { return 42; }

 void SystemClass::restart() { }

 SystemClass System;

这里还有更多的存根实现,可能对特定的测试场景有用:

void pinMode(uint16_t pin, uint8_t mode) { }
 void digitalWrite(uint16_t pin, uint8_t val) { }
 uint8_t digitalRead(uint16_t pin) { return 1; }

 uint16_t analogRead(uint16_t pin) { return 1000; }

我们没有实现这些函数,但它们可以实现连接到服务器端虚拟 GPIO 引脚的 GPIO 和 ADC 引脚,以控制设备并记录不使用 UART、SPI 或 I2C 接口的数据。PWM 功能也是一样的。

接下来是这个源文件的最后部分,我们实现了主函数如下:


 int main() {
    // Start the firmware image.
    init();

    return 0;
 }

就像 Sming 版本的入口点一样,我们在自定义固件代码中调用全局的init()函数,这在那里充当入口点。可以想象,如果需要的话,我们也可以在这个主函数中执行各种类型的初始化。

文件系统类方法使用 C 风格文件访问和 C++17 风格文件系统操作的混合实现,如下面的代码所示:

#include "FileSystem.h"
 #include "../Wiring/WString.h"

 #include <filesystem>
 #include <iostream>
 #include <fstream>

 namespace fs = std::filesystem;

 file_t fileOpen(const String& name, FileOpenFlags flags) {
    file_t res;

    if ((flags & eFO_CreateNewAlways) == eFO_CreateNewAlways) {
          if (fileExist(name)) {
                fileDelete(name);
          }

          flags = (FileOpenFlags)((int)flags & ~eFO_Truncate);
    }

    res = std::fopen(name.c_str(), "r+b");
    return res;
 }

为了简化这个方法,我们忽略提供的标志,并始终以完全读写模式打开文件(只有在某种程度上有助于集成测试时,才会实现完整的标志集)。让我们看看下面的代码:


 void fileClose(file_t file) {
    std::fclose(file);
 }

 size_t fileWrite(file_t file, const void* data, size_t size) {
    int res = std::fwrite((void*) data, size, size, file);      
    return res;
 }

 size_t fileRead(file_t file, void* data, size_t size) {
    int res = std::fread(data, size, size, file);
    return res;
 }

 int fileSeek(file_t file, int offset, SeekOriginFlags origin) {
    return std::fseek(file, offset, origin);
 }

 bool fileIsEOF(file_t file) {
    return true;
 }

 int32_t fileTell(file_t file) {
    return 0;
 }

 int fileFlush(file_t file) {
    return 0; 
 }

 void fileDelete(const String& name) {
    fs::remove(name.c_str());
 }

 void fileDelete(file_t file) {
    //
 }

 bool fileExist(const String& name) {
    std::error_code ec;
    bool ret = fs::is_regular_file(name.c_str(), ec);
    return ret;
 }

 int fileLastError(file_t fd) {
    return 0;
 }

 void fileClearLastError(file_t fd) {
    //
 }

 void fileSetContent(const String& fileName, const String& content) {
    fileSetContent(fileName, content.c_str());
 }

 void fileSetContent(const String& fileName, const char* content) {
    file_t file = fileOpen(fileName.c_str(), eFO_CreateNewAlways | eFO_WriteOnly);
    fileWrite(file, content, strlen(content));
    fileClose(file);
 }

 uint32_t fileGetSize(const String& fileName) {
    int size = 0;
    try {
         size = fs::file_size(fileName.c_str());
     } 
    catch (fs::filesystem_error& e) {
         std::cout << e.what() << std::endl;
     }

    return size;
 }

 void fileRename(const String& oldName, const String& newName) {
    try {
          fs::rename(oldName.c_str(), newName.c_str());
    }
    catch (fs::filesystem_error& e) {
          std::cout << e.what() << std::endl;
    }
 }

 Vector<String> fileList() {
    Vector<String> result;
    return result;
 }

 String fileGetContent(const String& fileName) {
    std::ifstream ifs(fileName.c_str(), std::ios::in | std::ios::binary | std::ios::ate);

     std::ifstream::pos_type fileSize = ifs.tellg();
     ifs.seekg(0, std::ios::beg);
     std::vector<char> bytes(fileSize);
     ifs.read(bytes.data(), fileSize);

     return String(bytes.data(), fileSize);
 }

 int fileGetContent(const String& fileName, char* buffer, int bufSize) {
    if (buffer == NULL || bufSize == 0) { return 0; }
    *buffer = 0;

    std::ifstream ifs(fileName.c_str(), std::ios::in | std::ios::binary | std::ios::ate);

     std::ifstream::pos_type fileSize = ifs.tellg();
    if (fileSize <= 0 || bufSize <= fileSize) {
          return 0;
    }

     buffer[fileSize] = 0;
     ifs.seekg(0, std::ios::beg);
    ifs.read(buffer, fileSize);
    ifs.close();

     return (int) fileSize;
 }

这些都是标准文件操作,因此不需要太多解释。之所以同时使用 C 风格和 C++17 风格的文件访问,主要是因为原始 API 方法假定以 C 风格处理事务,并且还因为底层基于 C 的 SDK 功能。

我们本来想将所有 API 方法映射到纯 C++17 文件系统功能,但这将是额外的时间投资,没有明显的回报。

定时器功能使用 POCO 的Timer类在 Sming 的SimpleTimer类中实现了等效功能,如下面的代码所示:

#include "Poco/Timer.h"
 #include <iostream>

 typedef void (*os_timer_func_t)(void* timer_arg);

 class SimpleTimer {
 public:
    SimpleTimer() : timer(0) {
          cb = new Poco::TimerCallback<SimpleTimer>(*this, &SimpleTimer::onTimer);
    }

    ~SimpleTimer() {
          stop();
          delete cb;
          if (timer) {
                delete timer;
          }
    }

    __forceinline void startMs(uint32_t milliseconds, bool repeating = false) {
          stop();
          if (repeating) {
                timer = new Poco::Timer(milliseconds, 0);
          }
          else {
                timer = new Poco::Timer(milliseconds, milliseconds);
          }

          timer->start(*cb);
    }

    __forceinline void startUs(uint32_t microseconds, bool repeating = false) {
          stop();
          uint32_t milliseconds = microseconds / 1000;
          if (repeating) {
                timer = new Poco::Timer(milliseconds, 0);
          }
          else {
                timer = new Poco::Timer(milliseconds, milliseconds);
          }

          timer->start(*cb);
    }

    __forceinline void stop() {
          timer->stop();
          delete timer;
          timer = 0;
    }

    void setCallback(os_timer_func_t callback, void* arg = nullptr)   {
          stop();
          userCb = callback;
          userCbArg = arg;
    }

 private:
    void onTimer(Poco::Timer &timer) {
          userCb(userCbArg);
    }

    Poco::Timer* timer;
    Poco::TimerCallback<SimpleTimer>* cb;
    os_timer_func_t userCb;
    void* userCbArg;
 };

最后,对于Clock类的重新实现,我们使用 STL 的 chrono 功能,如下面的代码所示:

#include "Clock.h"
 #include <chrono>

 unsigned long millis() {
    unsigned long now = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count();
    return now;
 }

 unsigned long micros() {
    unsigned long now = std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::system_clock::now().time_since_epoch()).count();
    return now;
 }

 void delay(uint32_t milliseconds) {
    //
 }

 void delayMicroseconds(uint32_t time) {   //
 }

在这里,我们没有实现delay函数,因为在这一点上我们不需要它们。

Makefile

该项目的这一部分的 Makefile 如下所示:

GPP = g++
 GCC = gcc
 MAKEDIR = mkdir -p
 RM = rm
 AR = ar
 ROOT = test/node
 OUTPUT = bmac_esp8266
 OUTLIB = lib$(OUTPUT).a
 INCLUDE = -I $(ROOT)/ \
                -I $(ROOT)/SmingCore/ \
                -I $(ROOT)/SmingCore/network \
                -I $(ROOT)/SmingCore/network/Http \
                -I $(ROOT)/SmingCore/network/Http/Websocket \
                -I $(ROOT)/SmingCore/network/libmosquitto \
                -I $(ROOT)/SmingCore/network/libmosquitto/cpp \
                -I $(ROOT)/SmingCore/wiring \
                -I $(ROOT)/Libraries/BME280 \
                -I $(ROOT)/esp8266/app
 FLAGS := $(INCLUDE) -g3 -U__STRICT_ANSI__
 LIB := -L$(ROOT)/lib -l$(OUTPUT) -lmosquittopp -lmosquitto  -lnymphrpc \
          -lPocoNet -lPocoUtil -lPocoFoundation -lPocoJSON -lstdc++fs \
          -lssl -lcrypto
 LIB_WIN :=  -lws2_32
 ifeq ($(OS),Windows_NT)
    LIB := $(LIB) $(LIB_WIN)
 endif
 include ./esp8266/version
 include ./Makefile-user.mk
 CPPFLAGS := $(FLAGS) -DVERSION="\"$(VERSION)\"" $(USER_CFLAGS) -std=c++17 -Wl,--gc-sections
 CFLAGS := -g3 
 CPP_SOURCES := $(wildcard $(ROOT)/SmingCore/*.cpp) \
                $(wildcard $(ROOT)/SmingCore/network/*.cpp) \
                $(wildcard $(ROOT)/SmingCore/network/Http/*.cpp) \
                $(wildcard $(ROOT)/SmingCore/wiring/*.cpp) \
                $(wildcard $(ROOT)/Libraries/BME280/*.cpp)
 FW_SOURCES := $(wildcard esp8266/app/*.cpp)
 CPP_OBJECTS := $(addprefix $(ROOT)/obj/,$(notdir) $(CPP_SOURCES:.cpp=.o))
 FW_OBJECTS := $(addprefix $(ROOT)/obj/,$(notdir) $(FW_SOURCES:.cpp=.o))
 all: makedir $(FW_OBJECTS) $(CPP_OBJECTS) $(ROOT)/lib/$(OUTLIB) $(ROOT)/bin/$(OUTPUT)
 $(ROOT)/obj/%.o: %.cpp
    $(GPP) -c -o $@ $< $(CPPFLAGS)
 $(ROOT)/obj/%.o: %.c
    $(GCC) -c -o $@ $< $(CFLAGS)
 $(ROOT)/lib/$(OUTLIB): $(CPP_OBJECTS)
    -rm -f $@
    $(AR) rcs $@ $^
 $(ROOT)/bin/$(OUTPUT):
    -rm -f $@
    $(GPP) -o $@ $(CPPFLAGS) $(FW_SOURCES) $(LIB)
 makedir:
    $(MAKEDIR) $(ROOT)/bin
    $(MAKEDIR) $(ROOT)/lib
    $(MAKEDIR) $(ROOT)/obj
    $(MAKEDIR) $(ROOT)/obj/$(ROOT)/SmingCore/network
    $(MAKEDIR) $(ROOT)/obj/$(ROOT)/SmingCore/wiring
    $(MAKEDIR) $(ROOT)/obj/$(ROOT)/Libraries/BME280
    $(MAKEDIR) $(ROOT)/obj/esp8266/app
 clean:
    $(RM) $(CPP_OBJECTS) $(FW_OBJECTS)

关于这个 Makefile 的主要注意事项是它从两个不同的源文件夹中收集源文件,一个是测试 API,一个是固件源代码。前者的源文件首先被编译为目标文件,然后被组装成一个存档文件。固件源代码直接与这个测试框架库一起使用,尽管如果需要,我们也可以使用固件对象文件。

在链接之前创建测试 API 的存档的原因与链接器查找符号的方式有关。通过使用 AR 工具,它将创建存档文件中对象文件的所有符号的索引,确保我们不会得到任何链接器错误。特别是对于大型项目,这通常是将对象文件成功链接到二进制文件中的要求。

首先将文件编译为目标文件对于较大的项目也是有帮助的,因为 Make 会确保只重新编译实际更改的文件,这可以真正加快开发时间。由于该项目的目标固件源代码相当简化,我们可以直接从这里的源文件进行编译。

我们还从这个 Makefile 中包含了另外两个 Makefile。第一个包括我们正在编译的固件源代码的版本号,这很有用,因为它将确保生成的节点二进制文件将报告与安装在 ESP8266 模块上的固件版本完全相同的版本。这样可以更轻松地验证特定固件版本。

第二个是 Makefile,具有可由用户定义的设置,直接从固件项目的 Makefile 中复制,但只包括固件源代码编译和工作所需的变量,如下面的代码所示:

WIFI_SSID = MyWi-FiNetwork
 WIFI_PWD = MyWi-FiPassword

 MQTT_HOST = localhost
 # For SSL support, uncomment the following line or compile with this parameter.
 #ENABLE_SSL=1
 # MQTT SSL port (for example):
 ifdef ENABLE_SSL
 MQTT_PORT = 8883 
 else
 MQTT_PORT = 1883
 endif

 # Uncomment if password authentication is used.
 # USE_MQTT_PASSWORD=1
 # MQTT username & password (if needed):
 # MQTT_USERNAME = esp8266
 # MQTT_PWD = ESPassword

 # MQTT topic prefix: added to all MQTT subscriptions and publications.
 # Can be left empty, but must be defined.
 # If not left empty, should end with a '/' to avoid merging with topic names.
 MQTT_PREFIX = 

 # OTA (update) URL. Only change the host name (and port).
 OTA_URL = http://ota.host.net/ota.php?uid=

 USER_CFLAGS := $(USER_CFLAGS) -DWIFI_SSID="\"$(WIFI_SSID)"\"
 USER_CFLAGS := $(USER_CFLAGS) -DWIFI_PWD="\"$(WIFI_PWD)"\"
 USER_CFLAGS := $(USER_CFLAGS) -DMQTT_HOST="\"$(MQTT_HOST)"\"
 USER_CFLAGS := $(USER_CFLAGS) -DMQTT_PORT="$(MQTT_PORT)"
 USER_CFLAGS := $(USER_CFLAGS) -DMQTT_USERNAME="\"$(MQTT_USERNAME)"\"
 USER_CFLAGS := $(USER_CFLAGS) -DOTA_URL="\"$(OTA_URL)"\"
 USER_CFLAGS := $(USER_CFLAGS) -DMQTT_PWD="\"$(MQTT_PWD)"\"
 ifdef USE_MQTT_PASSWORD
 USER_CFLAGS := $(USER_CFLAGS) -DUSE_MQTT_PASSWORD="\"$(USE_MQTT_PASSWORD)"\"
 endif
 SER_CFLAGS := $(USER_CFLAGS) -DMQTT_PREFIX="\"$(MQTT_PREFIX)"\"

包含此 Makefile 会将所有这些定义传递给编译器。这些都是预处理器语句,用于设置字符串或更改将被编译的代码的哪些部分,例如 SSL 代码。

但是,出于简单起见,我们不会为这个示例项目实现 SSL 功能。

构建项目

对于服务器端,我们有以下库依赖:

  • NymphRPC

  • POCO

对于节点,我们有以下依赖:

  • NymphRPC

  • POCO

  • Mosquitto

NymphRPC 库(在本节开头描述)根据项目的说明进行编译,并安装在链接器可以找到的位置。POCO 库使用系统的软件包管理器(Linux、BSD 或 MSYS2)或手动安装。

对于 Mosquitto 库依赖,我们可以使用test/SmingCore/network/libmosquitto文件夹中的 Makefile 编译libmosquittolibmosquittopp库文件。同样,您应该将生成的库文件安装在链接器可以找到的位置。

当不使用 MinGW 时,也可以通过操作系统的软件包管理器或类似方式使用通常可用的版本。

经过这些步骤,我们可以使用以下命令行命令从项目的根目录编译服务器和客户端:

make

这应该使用顶层 Makefile 编译服务器和节点项目,分别在它们各自的bin/文件夹中生成可执行文件。您应该确保服务器的Node类中的可执行文件名称和路径与节点可执行文件的位置匹配。

我们现在应该能够运行项目并开始收集测试结果。该项目包括一个精简版本的基于 ESP8266 的 BMAC 固件,我们将在第九章中详细介绍,示例 - 建筑监控和控制。请参考该章节,了解如何通过 MQTT 与模拟节点通信,如何在固件中打开模块,以及如何解释模块通过 MQTT 发送的数据。

在按照该章节中描述的设置之后 - 至少需要一个 MQTT 代理和一个合适的 MQTT 客户端 - 并在模拟节点中打开 BME280 模块后,我们期望它开始通过 MQTT 发送我们为模拟节点所在房间设置的温度、湿度和气压值。

总结

在本章中,我们看到了如何有效地为基于 MCU 的目标开发,以便我们可以在不昂贵和冗长的开发周期中测试它们。我们学会了如何实现一个集成环境,使我们能够从桌面操作系统和提供的工具舒适地调试基于 MCU 的应用程序。

读者现在应该能够为基于 MCU 的项目开发集成测试,并有效地使用基于操作系统的工具对其进行分析和调试,然后在真实硬件上进行最终集成工作。读者还应该能够进行芯片内调试,并对特定软件实现的相对成本有所了解。

在下一章中,我们将开发一个基于 SBC 平台的简单信息娱乐系统。

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

本章提供了一个使用基于 Linux 的单板计算机(SBC)实现信息娱乐系统的示例。它还展示了如何使用蓝牙连接远程设备,以及如何使用在线流媒体服务。最终的设备将能够从各种来源播放音频,而无需复杂的用户界面。具体来说,我们将涵盖以下主题:

  • 为基于 Linux 的 SBC 开发

  • 在 Linux 下使用蓝牙

  • 从各种来源播放音频和录制音频

  • 使用 GPIO 进行简单输入和语音识别

  • 连接到在线流媒体音频服务

一个能够完成所有功能的盒子

信息娱乐系统已经成为我们日常生活中的常见功能,从车载娱乐系统(ICE)开始(也称为车载信息娱乐或 IVI),它们从基本的收音机和磁带播放器发展到包括诸如导航和通过蓝牙连接智能手机以访问音乐库等功能。另一个重要功能是为驾驶员提供免提功能,使他们可以开始电话并控制收音机,而无需将目光从道路上移开或双手离开方向盘。

随着智能手机的普及,为用户提供持续访问新闻、天气和娱乐,使用语音驱动界面的车载助手的到来,最终导致了面向家庭使用的语音驱动信息娱乐系统的到来。这些通常包括扬声器和麦克风,以及用于语音驱动界面和访问所需的互联网服务的必要硬件。

本章将主要关注这种类型的语音驱动信息娱乐系统。在第十章中,使用 Qt 开发嵌入式系统,我们将深入研究添加图形用户界面。

我们想要在这里实现的目标是:

  • 从蓝牙源(如智能手机)播放音乐

  • 从在线流媒体服务播放音乐

  • 从本地文件系统(包括 USB 存储设备)播放音乐

  • 录制音频片段并在需要时重复播放

  • 用语音控制所有操作,并为一些操作配备按钮

在接下来的章节中,我们将看看这些目标以及如何实现它们。

所需的硬件

对于这个项目,任何能够运行 Linux 的 SBC 都应该可以。它还需要具备以下功能以进行完整实现:

  • 互联网连接(无线或有线)以访问在线内容。

  • 蓝牙功能(内置或作为附加模块)以使系统能够充当蓝牙扬声器。

  • 释放 GPIO 输入以允许连接按钮。

  • 用于语音输入和音频播放的功能麦克风输入和音频输出。

  • SATA 连接或类似连接用于连接硬盘等存储设备。

  • I2C 总线外设用于 I2C 显示器。

在本章的示例代码中,我们只需要麦克风输入和音频输出,以及一些用于本地媒体文件存储的存储空间。

对于 GPIO 引脚,我们可以连接一些按钮,用于控制信息娱乐系统,而无需使用语音激活系统。这在使用语音激活系统会很尴尬的情况下非常方便,比如在接听电话时暂停或静音音乐。

连接按钮在本示例中不会进行演示,但可以在第三章的早期项目中找到示例,即开发嵌入式 Linux 和类似系统。在那里,我们使用 WiringPi 库将开关连接到 GPIO 引脚,并配置中断例程来处理这些开关上的变化。

如果需要显示当前信息,比如当前歌曲的名称或其他相关状态信息,也可以将小型显示器连接到系统上。16x2 字符的廉价显示器可以通过 I2C 接口进行控制,而且有各种 OLED 和其他小型显示器可供选择,由于其最低硬件要求,它们非常适合这个用途。

在第三章《开发嵌入式 Linux 和类似系统》中,我们简要介绍了为这样的信息娱乐系统使用什么样的硬件,以及一些可能的用户界面和存储选项。当然,正确的硬件配置取决于个人的需求。如果想要本地存储大量音乐进行播放,连接到系统的大型 SATA 硬盘将非常方便。

然而,对于本章的示例,我们不会做出这样的假设,而是更像一个易于扩展的起点。因此,硬件要求非常低,除了明显需要麦克风和音频输出之外。

软件需求

对于这个项目,我们假设 Linux 已经安装在目标 SBC 上,并且硬件功能的驱动程序,如麦克风和音频输出的驱动程序已经安装和配置好。

由于我们在这个项目中使用 Qt 框架,因此所有依赖项也应该得到满足。这意味着生成项目的二进制文件所在的系统上应该存在共享库。Qt 框架可以通过操作系统的软件包管理器获得,也可以通过 Qt 网站qt.io/获得。

在第十章《使用 Qt 开发嵌入式系统》中,我们将更详细地研究在嵌入式平台上使用 Qt 进行开发。本章将简要介绍 Qt API 的使用。

根据我们是否想要直接在 SBC 上编译应用程序,还是在开发 PC 上编译应用程序,我们可能需要在 SBC 上安装编译器工具链和其他依赖项,或者在目标 SBC(ARM、x86 或其他架构)上安装交叉编译工具链。在第六章《测试基于操作系统的应用程序》中,我们研究了为 SBC 系统进行交叉编译,以及在本地测试系统。

由于本章的示例项目不需要任何特殊的硬件,因此可以直接在任何受 Qt 框架支持的系统上进行编译。这是在将代码部署到 SBC 之前测试代码的推荐方式。

蓝牙音频源和接收器

不幸的是,蓝牙是一种专有技术,尽管它无处不在,但由于其专有性质,它缺乏对蓝牙功能的全面支持(以配置文件的形式)。我们在这个项目中感兴趣的配置文件称为高级音频分发配置文件A2DP)。这是一种用于流式传输音频的配置文件,从蓝牙耳机到蓝牙音箱都在使用。

任何实现 A2DP 的设备都可以将音频流式传输到 A2DP 接收器,或者可以自己充当接收器(取决于 BT 堆栈的实现)。理论上,这将允许某人连接智能手机或类似设备到我们的信息娱乐系统,并在其上播放音乐,就像连接独立的蓝牙音箱一样。

A2DP 配置文件中的接收器是 A2DP 接收器,而另一侧是 A2DP 源。蓝牙耳机或音箱设备始终是接收器设备,因为它们只能消耗音频流。PC、SBC 或类似的多用途设备可以配置为充当接收器或源。

正如前面提到的,主流操作系统上实现完整蓝牙堆栈的复杂性导致对蓝牙的基本串行通信功能以外的支持不足。

虽然 FreeBSD、macOS、Windows 和 Android 都有蓝牙堆栈,但它们在支持的蓝牙适配器数量(Windows 只支持一个,而且只支持 USB 适配器)、支持的配置文件(FreeBSD 只支持数据传输)和可配置性方面存在限制(Android 基本上只针对智能手机)。

对于 Windows 10,A2DP 配置文件支持目前已经从 Windows 7 中的功能性退化到了在撰写本文时不再起作用,这是由于其蓝牙堆栈的更改。而 macOS 的蓝牙堆栈在操作系统的 10.5 版本(2007 年的 Leopard)中添加了 A2DP 支持,并应该可以正常工作。

BlueZ 蓝牙堆栈已成为 Linux 的官方蓝牙堆栈,最初由高通开发,现在已包含在官方 Linux 内核发行版中。它是最全面的蓝牙堆栈之一。

从 BlueZ 版本 4 到 5 的转变中,ALSA 音频 API 支持被删除,而是转移到了 PulseAudio 音频系统,并且旧 API 的名称也被更改。这意味着使用旧(版本 4)API 实现的应用程序和代码不再起作用。不幸的是,许多在线找到的示例代码和教程仍然针对版本 4,这是需要注意的,因为它们的工作方式有很大不同。

BlueZ 通过 D-Bus Linux 系统 IPC(进程间通信)系统进行配置,或者直接编辑配置文件。然而,在像本章项目中那样以编程方式配置它实际上会相当复杂,因为 API 的范围非常广,而且在设置超出蓝牙堆栈的配置选项时需要访问基于文本的配置文件。因此,应用程序必须以正确的权限运行,以访问某些属性和文件,直接编辑后者或手动执行这些步骤。

信息娱乐项目的另一个复杂之处是设置自动配对模式,否则远程设备(智能手机)将无法连接到信息娱乐系统。这还需要与蓝牙堆栈进行持续交互,以便在此期间轮询任何新连接的设备。

每个新设备都需要检查是否支持 A2DP 源模式,如果支持,它将被添加到系统的音频输入中。然后可以连接到音频系统,利用新的输入。

由于这个实现的复杂性和范围,它被省略在本章的示例代码中。但是,它可以被添加到代码中。像树莓派 3 这样的 SBC 带有内置蓝牙适配器。其他设备可以使用 USB 设备添加蓝牙适配器。

在线流媒体

有许多在线流媒体服务可以集成到类似于本章所研究的信息娱乐系统中。它们都使用类似的流媒体 API(通常是基于 HTTP 的 REST API),需要用户使用该服务创建一个帐户,从而获取一个特定于应用程序的令牌,以便访问该 API,允许用户查询特定的艺术家、音乐曲目、专辑等。

使用 HTTP 客户端,比如在 Qt 框架中找到的客户端,实现必要的控制流将会相当容易。由于需要为这些流媒体服务注册应用程序 ID,因此它被省略在示例代码中。

从 REST API 流式传输的基本顺序通常是这样的,使用一个简单的 HTTP 调用包装类:

#include "soundFoo"
// Create a client object with your app credentials.
client = soundFoo.new('YOUR_CLIENT_ID');
// Fetch track to stream.
track = client.get("/tracks/293")
// Get the tracks streaming URL.
stream_url = client.get(track.stream_url, true); 
// stream URL, allow redirects
// Print the tracks stream URL
std::cout << stream_url.location;

语音驱动用户界面

该项目采用了完全可通过语音命令控制的用户界面。为此,它实现了由 PocketSphinx 库提供动力的语音到文本接口(参见cmusphinx.github.io/),它使用关键词识别和语法搜索来识别和解释给定的命令。

我们使用了随 PocketSphinx 发行的默认的美式英语语言模型。这意味着任何口语命令都应该以美式英语口音发音,以便准确理解。要更改这一点,可以加载针对不同语言和口音的不同语言模型。通过 PocketSphinx 网站可以获得各种模型,也可以通过一些努力制作自己的语言模型。

使用场景

我们不希望信息娱乐系统在语音用户界面识别到命令词时每次都被激活,而这些命令词并非有意为之。防止这种情况发生的常见方法是有一个关键词来激活命令界面。如果在关键词之后一定时间内没有识别到命令,系统将恢复到关键词识别模式。

对于这个示例项目,我们使用关键词computer。系统识别到这个关键词后,我们可以使用以下命令:

命令 结果
播放蓝牙 从任何连接的 A2DP 源设备开始播放(未实现)。
停止蓝牙 停止从任何蓝牙设备播放。
播放本地 播放(硬编码的)本地音乐文件。
停止本地 如果当前正在播放本地音乐文件,则停止播放。
播放远程 从在线流媒体服务或服务器播放(未实现)。
停止远程 如果正在播放,则停止播放。
录制消息 录制一条消息。录制直到发生一定时间的静音。
播放消息 如果有录制的消息,则播放回。

源代码

该应用程序是使用 Qt 框架实现的 GUI 应用程序,因此我们还获得了一个用于调试的图形界面。这个调试 UI 是使用 Qt Creator IDE 的 Qt Designer 设计的单个 UI 文件。

我们首先创建了 GUI 应用程序的实例:

#include "mainwindow.h" 
#include <QApplication> 

int main(int argc, char *argv[]) { 
    QApplication a(argc, argv); 
    MainWindow w; 
    w.show(); 

    return a.exec(); 
} 

这在MainWindow类中创建了一个实例,我们在其中实现了应用程序,以及QApplication的实例,这是 Qt 框架使用的包装类。

接下来,这是MainWindow的标题:

#include <QMainWindow> 

#include <QAudioRecorder> 
#include <QAudioProbe> 
#include <QMediaPlayer> 

namespace Ui { 
    class MainWindow; 
} 

class MainWindow : public QMainWindow { 
    Q_OBJECT 

public: 
    explicit MainWindow(QWidget *parent = nullptr); 
    ~MainWindow(); 

public slots: 
    void playBluetooth(); 
    void stopBluetooth(); 
    void playOnlineStream(); 
    void stopOnlineStream(); 
    void playLocalFile(); 
    void stopLocalFile(); 
    void recordMessage(); 
    void playMessage(); 

    void errorString(QString err); 

    void quit(); 

private: 
    Ui::MainWindow *ui; 

    QMediaPlayer* player; 
    QAudioRecorder* audioRecorder; 
    QAudioProbe* audioProbe; 

    qint64 silence; // Microseconds of silence recorded so far. 

private slots: 
    void processBuffer(QAudioBuffer); 
}; 

其实现包含大部分核心功能,声明了音频录制器和播放器实例,只是声音命令处理是在一个单独的类中处理的:

#include "mainwindow.h" 
#include "ui_mainwindow.h" 

#include "voiceinput.h" 

#include <QThread> 
#include <QMessageBox> 

#include <cmath> 

#define MSG_RECORD_MAX_SILENCE_US 5000000 

MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), 
    ui(new Ui::MainWindow) { 
    ui->setupUi(this); 

    // Set up menu connections. 
    connect(ui->actionQuit, SIGNAL(triggered()), this, SLOT(quit())); 

    // Set up UI connections. 
    connect(ui->playBluetoothButton, SIGNAL(pressed), this, SLOT(playBluetooth)); 
    connect(ui->stopBluetoothButton, SIGNAL(pressed), this, SLOT(stopBluetooth)); 
    connect(ui->playLocalAudioButton, SIGNAL(pressed), this, SLOT(playLocalFile)); 
    connect(ui->stopLocalAudioButton, SIGNAL(pressed), this, SLOT(stopLocalFile)); 
    connect(ui->playOnlineStreamButton, SIGNAL(pressed), this, SLOT(playOnlineStream)); 
    connect(ui->stopOnlineStreamButton, SIGNAL(pressed), this, SLOT(stopOnlineStream)); 
    connect(ui->recordMessageButton, SIGNAL(pressed), this, SLOT(recordMessage)); 
    connect(ui->playBackMessage, SIGNAL(pressed), this, SLOT(playMessage)); 

    // Defaults 
    silence = 0; 

    // Create the audio interface instances. 
    player = new QMediaPlayer(this); 
    audioRecorder = new QAudioRecorder(this); 
    audioProbe = new QAudioProbe(this); 

    // Configure the audio recorder. 
    QAudioEncoderSettings audioSettings; 
    audioSettings.setCodec("audio/amr"); 
    audioSettings.setQuality(QMultimedia::HighQuality);     
    audioRecorder->setEncodingSettings(audioSettings);     
    audioRecorder->setOutputLocation(QUrl::fromLocalFile("message/last_message.amr")); 

    // Configure audio probe. 
    connect(audioProbe, SIGNAL(audioBufferProbed(QAudioBuffer)), this, SLOT(processBuffer(QAudioBuffer))); 
    audioProbe->setSource(audioRecorder); 

    // Start the voice interface in its own thread and set up the connections. 
    QThread* thread = new QThread; 
    VoiceInput* vi = new VoiceInput(); 
    vi->moveToThread(thread); 
    connect(thread, SIGNAL(started()), vi, SLOT(run())); 
    connect(vi, SIGNAL(finished()), thread, SLOT(quit())); 
    connect(vi, SIGNAL(finished()), vi, SLOT(deleteLater())); 
    connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater())); 

    connect(vi, SIGNAL(error(QString)), this, SLOT(errorString(QString))); 
    connect(vi, SIGNAL(playBluetooth), this, SLOT(playBluetooth)); 
    connect(vi, SIGNAL(stopBluetooth), this, SLOT(stopBluetooth)); 
    connect(vi, SIGNAL(playLocal), this, SLOT(playLocalFile)); 
    connect(vi, SIGNAL(stopLocal), this, SLOT(stopLocalFile)); 
    connect(vi, SIGNAL(playRemote), this, SLOT(playOnlineStream)); 
    connect(vi, SIGNAL(stopRemote), this, SLOT(stopOnlineStream)); 
    connect(vi, SIGNAL(recordMessage), this, SLOT(recordMessage)); 
    connect(vi, SIGNAL(playMessage), this, SLOT(playMessage)); 

    thread->start(); 
} 

在构造函数中,我们为 GUI 窗口中的按钮设置了所有 UI 连接,以便我们可以触发应用程序的功能,而无需使用语音用户界面。这对于测试目的很有用。

此外,我们创建了音频录制器和媒体播放器的实例,以及与音频录制器链接的音频探针,以便我们可以查看它正在录制的音频样本并对其进行操作。

最后,我们创建了语音输入接口类的实例,并在启动之前将其推送到自己的线程上。我们将其信号连接到特定命令,其他事件连接到它们各自的插槽:

MainWindow::~MainWindow() { 
    delete ui; 
} 

void MainWindow::playBluetooth() { 
    // Use the link with the BlueZ Bluetooth stack in the Linux kernel to 
    // configure it to act as an A2DP sink for smartphones to connect to. 
} 

// --- STOP BLUETOOTH --- 
void MainWindow::stopBluetooth() { 
    // 
} 

正如在蓝牙技术部分中提到的,出于该部分所述的原因,我们未实现了蓝牙功能。

void MainWindow::playOnlineStream() { 
    // Connect to remote streaming service's API and start streaming. 
} 

void MainWindow::stopOnlineStream() { 
    // Stop streaming from remote service. 
} 

在线流功能也是如此。有关如何实现此功能的详细信息,请参阅本章前面关于在线流的部分。

void MainWindow::playLocalFile() { 
    player->setMedia(QUrl::fromLocalFile("music/coolsong.mp3")); 
    player->setVolume(50); 
    player->play(); 
} 

void MainWindow::stopLocalFile() { 
    player->stop(); 
} 

要播放本地文件,我们期望在硬编码路径中找到一个 MP3 文件。但是,也可以通过读取文件名并逐个播放它们来播放特定文件夹中的所有音乐。

void MainWindow::recordMessage() { 
    audioRecorder->record(); 
} 

void MainWindow::playMessage() { 
    player->setMedia(QUrl::fromLocalFile("message/last_message.arm")); 
    player->setVolume(50); 
    player->play(); 
} 

在构造函数中,我们配置了录音机将记录到一个名为message的子文件夹中的文件中。如果进行新的录音,这将被覆盖,允许留下一条可以稍后播放的消息。可选的显示器或其他附件可以用来指示是否已经进行了新的录音并且尚未被听过:

void MainWindow::processBuffer(QAudioBuffer buffer) { 
    const quint16 *data = buffer.constData<quint16>(); 

    // Get RMS of buffer, if silence, add its duration to the counter. 
    int samples = buffer.sampleCount(); 
    double sumsquared = 0; 
    for (int i = 0; i < samples; i++) { 
        sumsquared += data[i] * data[i]; 
    } 

    double rms = sqrt((double(1) / samples)*(sumsquared)); 

    if (rms <= 100) { 
        silence += buffer.duration(); 
    } 

    if (silence >= MSG_RECORD_MAX_SILENCE_US) { 
        silence = 0; 
        audioRecorder->stop(); 
    } 
} 

每当录音机处于活动状态时,我们的音频探测器就会调用这个方法。在这个函数中,我们计算音频缓冲区的均方根RMS)值,以确定它是否充满了静默。在这里,静默是相对的,可能需要根据录音环境进行调整。

在检测到五秒的静默后,消息的录制将停止:

void MainWindow::errorString(QString err) { 
    QMessageBox::critical(this, tr("Error"), err); 
} 

void MainWindow::quit() { 
    exit(0); 
} 

其余的方法处理可能在应用程序的其他地方发出的错误消息的报告,以及终止应用程序。

VoiceInput类头文件定义了语音输入接口的功能:

#include <QObject> 
#include <QAudioInput> 

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

class VoiceInput : public QObject { 
    Q_OBJECT 

    QAudioInput* audioInput; 
    QIODevice* audioDevice; 
    bool state; 

public: 
    explicit VoiceInput(QObject *parent = nullptr); 
    bool checkState() { return state; } 

signals: 
    void playBluetooth(); 
    void stopBluetooth(); 
    void playLocal(); 
    void stopLocal(); 
    void playRemote(); 
    void stopRemote(); 
    void recordMessage(); 
    void playMessage(); 

    void error(QString err); 

public slots: 
    void run(); 
}; 

由于 PocketSphinx 是一个 C 库,我们必须确保它使用 C 风格的链接。除此之外,我们为音频输入和相关 IO 设备创建了类成员,语音输入将使用这些成员。

接下来是类的定义:

#include <QDebug> 
#include <QThread> 

#include "voiceinput.h" 

extern "C" { 
#include <sphinxbase/err.h> 
#include <sphinxbase/ad.h> 
} 

VoiceInput::VoiceInput(QObject *parent) : QObject(parent) { 
    // 
} 

构造函数并没有做任何特殊的事情,因为接下来的方法将初始化和设置主循环:

void VoiceInput::run() { 
    const int32 buffsize = 2048; 
    int16 adbuf[buffsize]; 
    uint8 utt_started, in_speech; 
    uint32 k = 0; 
    char const* hyp; 

    static ps_decoder_t *ps; 

    state = true; 

    QAudioFormat format; 
    format.setSampleRate(16000); 
    format.setChannelCount(1); 
    format.setSampleSize(16); 
    format.setCodec("audio/pcm"); 
    format.setByteOrder(QAudioFormat::LittleEndian); 
    format.setSampleType(QAudioFormat::UnSignedInt); 

    // Check that the audio device supports this format. 
    QAudioDeviceInfo info = QAudioDeviceInfo::defaultInputDevice(); 
    if (!info.isFormatSupported(format)) { 
       qWarning() << "Default format not supported, aborting."; 
       state = false; 
       return; 
    } 

    audioInput = new QAudioInput(format, this); 
    audioInput->setBufferSize(buffsize * 2);    
    audioDevice = audioInput->start(); 

    if (ps_start_utt(ps) < 0) { 
        E_FATAL("Failed to start utterance\n"); 
    } 

    utt_started = FALSE; 
    E_INFO("Ready....\n"); 

这个方法的第一部分设置了音频接口,配置它使用 PocketSphinx 所需的音频格式设置进行录制:单声道,小端,16 位有符号 PCM 音频,采样率为 16,000 赫兹。在检查音频输入是否支持这种格式后,我们创建了一个新的音频输入实例:

    const char* keyfile = "COMPUTER/3.16227766016838e-13/\n"; 
    if (ps_set_kws(ps, "keyword_search", keyfile) != 0) { 
        return; 
    } 

    if (ps_set_search(ps, "keyword_search") != 0) { 
        return; 
    } 

    const char* gramfile = "grammar asr;\ 
            \ 
            public <rule> = <action> [<preposition>] [<objects>] [<preposition>] [<objects>];\ 
            \ 
            <action> = STOP | PLAY | RECORD;\ 
            \ 
            <objects> = BLUETOOTH | LOCAL | REMOTE | MESSAGE;\ 
            \ 
            <preposition> = FROM | TO;"; 
    ps_set_jsgf_string(ps, "jsgf", gramfile); 

接下来,我们设置了在处理音频样本时将使用的关键词检测和 JSGF 语法文件。通过第一个ps_set_search()函数调用,我们开始了关键词检测搜索。接下来的循环将持续处理样本,直到检测到computer这个话语:

    bool kws = true; 
    for (;;) { 
        if ((k = audioDevice->read((char*) &adbuf, 4096))) { 
            E_FATAL("Failed to read audio.\n"); 
        } 

        ps_process_raw(ps, adbuf, k, FALSE, FALSE); 
        in_speech = ps_get_in_speech(ps); 

        if (in_speech && !utt_started) { 
            utt_started = TRUE; 
            E_INFO("Listening...\n"); 
        } 

每个周期,我们读入另一个缓冲区的音频样本,然后让 PocketSphinx 处理这些样本。它还为我们进行了静默检测,以确定是否有人开始对麦克风说话。如果有人在说话,但我们还没有开始解释,我们就开始一个新的话语:

        if (!in_speech && utt_started) { 
            ps_end_utt(ps); 
            hyp = ps_get_hyp(ps, nullptr); 
            if (hyp != nullptr) { 
                // We have a hypothesis. 

                if (kws && strstr(hyp, "computer") != nullptr) { 
                    if (ps_set_search(ps, "jsgf") != 0) { 
                        E_FATAL("ERROR: Cannot switch to jsgf mode.\n"); 
                    } 

                    kws = false; 
                    E_INFO("Switched to jsgf mode \n");                             
                    E_INFO("Mode: %s\n", ps_get_search(ps)); 
                } 
                else if (!kws) { 
                    if (hyp != nullptr) { 
                        // Check each action. 
                        if (strncmp(hyp, "play bluetooth", 14) == 0) { 
                            emit playBluetooth(); 
                        } 
                        else if (strncmp(hyp, "stop bluetooth", 14) == 0) { 
                            emit stopBluetooth(); 
                        } 
                        else if (strncmp(hyp, "play local", 10) == 0) { 
                            emit playLocal(); 
                        } 
                        else if (strncmp(hyp, "stop local", 10) == 0) { 
                            emit stopLocal(); 
                        } 
                        else if (strncmp(hyp, "play remote", 11) == 0) { 
                            emit stopBluetooth(); 
                        } 
                        else if (strncmp(hyp, "stop remote", 11) == 0) { 
                            emit stopBluetooth(); 
                        } 
                        else if (strncmp(hyp, "record message", 14) == 0) { 
                            emit stopBluetooth(); 
                        } 
                        else if (strncmp(hyp, "play message", 12) == 0) { 
                            emit stopBluetooth(); 
                        } 
                    } 
                    else { 
                        if (ps_set_search(ps, "keyword_search") != 0){ 
                            E_FATAL("ERROR: Cannot switch to kws mode.\n"); 
                        } 

                        kws = true; 
                        E_INFO("Switched to kws mode.\n"); 
                    } 
                }                 
            } 

            if (ps_start_utt(ps) < 0) { 
                E_FATAL("Failed to start utterance\n"); 
            } 

            utt_started = FALSE; 
            E_INFO("Ready....\n"); 
        } 

        QThread::msleep(100); 
    } 

} 

方法的其余部分检查我们是否有可用的假设可以分析。根据我们是处于关键词模式还是语法模式,我们在前一种情况下检查关键词的检测并切换到语法模式。如果我们已经处于语法模式,我们尝试将话语缩小到特定的命令,此时我们将发出相关信号,触发连接的功能。

每当 PocketSphinx 检测到至少一秒的静默时,就会开始一个新的话语。执行命令后,系统会切换回关键词检测模式。

构建项目

要构建项目,必须先构建 PocketSphinx 项目。在本章附带的示例项目源代码中,sphinx文件夹下有两个 Makefile,一个在pocketsphinx文件夹中,另一个在sphinxbase文件夹中。通过这些,将构建形成 PocketSphinx 的两个库。

在此之后,可以通过执行以下命令从 Qt Creator 或命令行构建 Qt 项目:

mkdir build
cd build
qmake ..
make

扩展系统

除了音频格式,还可以添加播放视频和集成制作和回复电话的能力(使用蓝牙 API)。可能希望扩展应用程序,使其更灵活和模块化,例如,可以添加一个模块,用于添加语音命令和相应的操作。

具有语音输出也将很方便,使其更符合当前的商业产品。为此,可以使用 Qt 框架中提供的文本到语音 API。

通过查询远程 API 获取更多信息,例如当前天气、新闻更新,甚至是当前足球比赛的更新,也将非常有用。基于语音的用户界面可以用于设置定时器和任务提醒,集成日历等等。

最后,正如本章示例代码所示,人们无法指定要播放的曲目名称,或特定的专辑或艺术家名称。允许这种自由输入非常有用,但也带来了一系列问题。

主要问题是语音转文本系统的识别率,特别是对于其词典中没有的单词。我们中的一些人可能已经有幸提高了声音,试图让手机、汽车或智能手机上的语音驱动用户界面理解某个词。

在这一点上,这仍然是一个需要大量研究的重点,没有快速简单的解决方案。可以想象通过使用本地音频文件名和艺术家的索引,以及其他元数据作为词典的一部分,来强制进行这种识别,并通过查询其 API 来对远程流媒体服务进行更准确的识别。然而,这可能会给识别工作增加相当大的延迟。

总结

在本章中,我们看了如何相当容易地构建基于 SBC 的信息娱乐系统,使用语音转文本来构建语音驱动用户界面。我们还看了如何扩展它以添加更多功能。

预计读者能够在这一点上实施类似的系统,并将其扩展到连接在线和基于网络的服务。读者还应该阅读更高级的语音驱动用户界面的实施,添加文本到语音,以及使用基于 A2DP 的蓝牙设备。

在下一章中,我们将看看如何使用微控制器和本地网络实现建筑范围的监控和控制系统。

第九章:示例 - 建筑物监测和控制

建筑物内部条件的监测,包括温度、湿度和 CO[2]水平,正在变得越来越普遍,目标是调整供暖、制冷和通风系统,以使建筑物的居住者尽可能舒适。在本章中,将探讨并实施这样的系统。将涵盖以下主题:

  • 为 ESP8266 创建复杂的固件

  • 将 MCU 集成到基于 IP 的网络中

  • 添加 CO[2]和基于 I2C 的传感器

  • 使用 GPIO 和 PWM 来控制继电器和直流电压控制风扇

  • 使用中央控制器连接网络节点

植物、房间和更多

在第五章中,我们研究了为 ESP8266 MCU 开发固件,以配合土壤湿度传感器和水泵,以确保连接的植物能够从水箱中获得足够的水。

正如我们在那一章中指出的,所使用的固件是高度模块化的,并具有高度灵活的基于 MQTT 的接口,因此可以用于各种模块。本章涵盖了固件起源的系统:建筑管理和控制BMaC),最初只是用于监测房间的温度、湿度和 CO²水平,但后来扩展到监控咖啡机和会议室的占用情况,并最终控制整栋建筑的空调。

BMaC 项目的当前开发状态可以在作者的 GitHub 帐户github.com/MayaPosch/BMaC中找到。我们在这里涵盖的版本是目前的版本,我们将介绍这个系统的起源以及今天的样子,以及原因。

发展历史

BMaC 项目始于在办公楼周围添加传感器以测量温度和其他参数,如相对湿度。在决定使用 ESP8266 MCU 和 DHT22 温湿度传感器后,使用 Sming 框架编写了基本固件,制作了一个简单的原型。

发现 DHT22 传感器通常相当笨重且不太精确。使用的分板还安装了不正确的电阻,导致温度报告错误。这种传感器类型还有一个缺点,即使用自己的单线协议,而不是标准接口方法。

DHT22 传感器被 BME280 MEMS 传感器替换,后者可以测量温度、湿度和气压。还添加了 CO[2]传感器,即 MH-Z19。这也需要固件支持这些额外的传感器。传感器读数将作为 MQTT 消息发送,后端服务订阅这些主题,并将它们写入时间序列数据库(InfluxDB)以供查看和分析。

在考虑从全自动 Jura 咖啡机中读取产品计数器的可能性时,必须做出决定,以及是否需要开发单独的固件。

决定使用相同的固件来处理所有 ESP8266 节点,而不是单独的固件。这意味着它们需要具有某种功能来启用个别功能,并支持特定的传感器和其他功能。这导致了新固件的开发,该固件允许通过 MQTT 发送远程命令来切换功能模块的开启或关闭,以及其他管理功能。

除了新的固件,还添加了一个命令和控制C&C)服务器,用于个别节点检索其配置,以及一个管理应用程序,用于添加新节点和添加或编辑节点配置。

有了这个框架,就可以快速添加新功能。这些功能包括添加运动传感器,用于检测房间内人员的存在,最终控制空调单元,因为发现办公楼中的现有集中控制不足以满足需求。

整个系统可以像这样进行可视化:

在接下来的章节中,我们将详细查看每个方面。

功能模块

以下是该固件中的模块列表:

名称 特性 描述
THP 温度、湿度、气压 THP 传感器的中心类。默认情况下启用 BME280 功能。
CO[2] CO[2]值 使用 MH-Z19 或兼容传感器测量 CO[2] 值。
Jura TopTronics EEPROM 计数器 从 EEPROM 中读取各种产品的计数器数值。
JuraTerm TopTronics 远程控制 允许远程服务向支持的咖啡机发送 TopTronics(经典,v5 风格)命令。
运动 运动检测 使用 HC-SR501 PIR 运动传感器或兼容设备来检测运动。
PWM PWM 输出 在一个或多个引脚上设置脉冲宽度调制输出。
I/O I/O 扩展 通过 I2C 支持 MCP23008 八通道 I/O 扩展器模块。
开关 持久开关 控制使用闩锁继电器或等效物进行切换的开关。
植物 浇水植物 读取模拟土壤传感器的数值,确定土壤湿度,需要时激活水泵。

固件源代码

在本节中,我们将查看与 BMaC 系统一起使用的 ESP8266 固件的源代码。

核心

我们已经在第五章中查看了固件的核心,示例-带 Wi-Fi 的土壤湿度监测器,包括入口点、OtaCore类和BaseModule类,它们提供了使单独的模块初始化并允许使用 MQTT 接口启用和禁用它们所需的所有功能。

模块

在固件模块中,我们已经在第五章中查看了植物模块,示例-带 Wi-Fi 的土壤湿度监测器。在这里,我们将查看其余的模块,从 THP 模块开始:

#include "base_module.h"
class THPModule {
    public:
    static bool initialize();
    static bool start();
    static bool shutdown();
};
#include "thp_module.h"
#include "dht_module.h"
#include "bme280_module.h"
bool THPModule::initialize() {
    BaseModule::registerModule(MOD_IDX_TEMPERATURE_HUMIDITY, 
    THPModule::start, THPModule::shutdown);
    return true;
}
bool THPModule::start() {
    BME280Module::init();
    return true;
}
bool THPModule::shutdown() {
    BME280Module::shutdown();
    return true;
}

该模块具有作为各种温度、湿度和气压传感器的通用接口的功能。由于当时并不需要这个功能,它只是作为 BME280 模块的传递。当被调用时,它会在基础模块中注册自己,并在调用自己的函数时调用 BME280 模块的相应函数。

为了使其更加多功能,该类将被扩展以允许接收命令——可能是通过 MQTT 以及自己的主题——这样就可以在使用单独的温度和气压传感器时启用特定的传感器模块,甚至是它们的集合,例如使用单独的温度和气压传感器。

无论在该固件中是否被使用,让我们来看看 DHT 模块,以便以后可以将其与 BME280 模块进行比较。

#include "ota_core.h"

 #include <Libraries/DHTesp/DHTesp.h>

 #define DHT_PIN 5 // DHT sensor: GPIO5 ('D1' on NodeMCU)

 class DHTModule {
    static DHTesp* dht;
    static int dhtPin;
    static Timer dhtTimer;

 public:
    static bool init();
    static bool shutdown();
    static void config(String cmd);
    static void readDHT();
 };

值得注意的是,虽然该类是静态的,但会占用大量内存的任何变量——例如库类实例——都被定义为指针。这形成了在使模块易于使用和选择更复杂的完全动态解决方案之间的折衷。由于大多数 MCU 倾向于尽可能将程序代码保存在 ROM 中,直到使用时才将其保存,这应该将 SRAM 和 ROM 的使用保持在最低水平。

#include "dht_module.h"

 DHTesp* DHTModule::dht = 0;
 int DHTModule::dhtPin = DHT_PIN;
 Timer DHTModule::dhtTimer;

 bool DHTModule::init() {
    if (!OtaCore::claimPin(dhtPin)) { return false; }
    if (!dht) { dht = new DHTesp(); dht->setup(dhtPin, DHTesp::DHT22); }
    dhtTimer.initializeMs(2000, DHTModule::readDHT).start();    
    return true;
 }

为了初始化该模块,我们确保可以安全地使用我们打算使用的通用输入/输出GPIO)引脚,从库中创建传感器类的新实例,并在创建执行计划的传感器读数的 2 秒定时器之前对其进行设置。

由于我们在初始化时创建了传感器类的新实例,因此不应该存在此类的现有实例,但我们检查以防 init()函数由于某种原因再次被调用。在计时器上第二次调用初始化函数也可以包括在此块中,但并不是严格要求的,因为再次初始化计时器不会产生有害影响。

bool DHTModule::shutdown() {
    dhtTimer.stop();
    if (!OtaCore::releasePin((ESP8266_pins) dhtPin)) { delete dht; return false; }
    delete dht;
    dht = 0;    
    return true;
 }

要关闭模块,我们停止计时器并释放我们使用的 GPIO 引脚,然后清理我们使用的所有资源。由于我们在初始化模块时已经声明了我们使用的引脚,所以再次释放它不应该有问题,但我们会检查以确保。


 void DHTModule::config(String cmd) {
    Vector<String> output;
    int numToken = splitString(cmd, '=', output);
    if (output[0] == "set_pin" && numToken > 1) {
          dhtPin = output[1].toInt();
    }
 }

这是一个示例,说明如何稍后更改模块使用的 GPIO 引脚,这里使用早期版本 BMaC 固件使用的旧文本命令格式。我们也可以通过 MQTT 主题接收此信息,或者通过主动查询命令和控制服务器。

请注意,要更改传感器使用的引脚,必须通过删除类实例并创建新实例来重新启动传感器。

 void DHTModule::readDHT() {
    TempAndHumidity th;
    th = dht->getTempAndHumidity();

    OtaCore::publish("nsa/temperature", OtaCore::getLocation() + ";" + th.temperature);
    OtaCore::publish("nsa/humidity", OtaCore::getLocation() + ";" + th.humidity);
 }

接下来,对于BME280传感器模块,其代码如下:

#include "ota_core.h"

 #include <Libraries/BME280/BME280.h>

 class BME280Module {
    static BME280* bme280;
    static Timer timer;

 public:
    static bool init();
    static bool shutdown();
    static void config(String cmd);
    static void readSensor();
 };

最后,这是一个看起来很熟悉的实现:

#include "bme280_module.h"

 BME280* BME280Module::bme280 = 0;
 Timer BME280Module::timer;

 bool BME280Module::init() {
    if (!OtaCore::starti2c()) { return false; }
    if (!bme280) { bme280 = new BME280(); }

    if (bme280->EnsureConnected()) {
          OtaCore::log(LOG_INFO, "Connected to BME280 sensor.");
          bme280->SoftReset();
          bme280->Initialize();
    }
    else {
          OtaCore::log(LOG_ERROR, "Not connected to BME280 sensor.");
          return false;
    }

    timer.initializeMs(2000, BME280Module::readSensor).start();

    return true;
 }

 bool BME280Module::shutdown() {
    timer.stop();
    delete bme280;
    bme280 = 0;

    return true;
 }

 void BME280Module::config(String cmd) {
    Vector<String> output;
    int numToken = splitString(cmd, '=', output);
    if (output[0] == "set_pin" && numToken > 1) {
          //
    }
 }

 void BME280Module::readSensor() {
    float t, h, p;
    if (bme280->IsConnected) {
          t = bme280->GetTemperature();
          h = bme280->GetHumidity();
          p = bme280->GetPressure
          OtaCore::publish("nsa/temperature", OtaCore::getLocation() + ";" + t);
          OtaCore::publish("nsa/humidity", OtaCore::getLocation() + ";" + h);
          OtaCore::publish("nsa/pressure", OtaCore::getLocation() + ";" + p);
    }
    else {
          OtaCore::log(LOG_ERROR, "Disconnected from BME280 sensor.");
    }
 }

正如我们所看到的,这个模块基本上是从 DHT 模块复制过来的,然后修改以适应 BME280 传感器。这两个模块之间的相似之处是开发 THP 模块的动机之一,以利用这些相似之处。

与 DHT 模块一样,我们可以看到我们依赖外部库来为我们完成大部分工作,我们只需调用库类上的函数来设置传感器并从中获取数据。

CO2 模块

对于 CO[2]模块,尚未尝试使其与多种 CO[2]传感器兼容。首先使用的 CO[2]传感器是 MH-Z14,然后切换到更紧凑的 MH-Z19 传感器。这两者在它们的通用异步收发器/发送器UART)接口上使用相同的协议。

在 ESP8266 上有两个 UART,但只有一个是完整的,具有接收(RX)和发送(TX)线。第二个 UART 只有一个 TX 线。这基本上将这个 MCU 限制为单个 UART,因此只能使用基于单个 UART 的传感器。

这些传感器除了基于 UART 的接口外,还有一个单线接口,传感器使用特定的编码输出当前的传感器读数,必须使用信号线上脉冲之间的特定距离进行接收和解码。这类似于 DHT-22 的单线协议。

显然,使用 UART 要容易得多,这也是我们最终使用这个模块的原因:

#include "base_module.h"

 class CO2Module {
    static Timer timer;
    static uint8_t readCmd[9];
    static uint8 eventLevel;
    static uint8 eventCountDown;
    static uint8 eventCountUp;

    static void onSerialReceived(Stream &stream, char arrivedChar, unsigned short availableCharsCount);

 public:
    static bool initialize();
    static bool start();
    static bool shutdown();
    static void readCO2();
    static void config(String cmd);
 };

我们可以在这里看到将与 UART 一起使用的回调函数,当我们接收数据时将使用它。我们还有一些其他变量,它们的含义将在一会儿变得清晰:

#include "CO2_module.h"

 Timer CO2Module::timer;
 uint8_t CO2Module::readCmd[9] = { 0xFF,0x01,0x86,0x00,0x00,0x00,0x00,0x00,0x79};
 uint8 CO2Module::eventLevel = 0;
 uint8 CO2Module::eventCountDown = 10;
 uint8 CO2Module::eventCountUp = 0;

在静态初始化中,我们定义了将发送给 CO[2]传感器的命令,告诉它向我们发送当前测量值。我们定义了一些计数器和相关的计时器实例,我们将使用它们来分析我们收到的 CO[2]水平。

bool CO2Module::initialize() {
    BaseModule::registerModule(MOD_IDX_CO2, CO2Module::start, CO2Module::shutdown);
    return true;
 }

 bool CO2Module::start() {
    if (!OtaCore::claimPin(ESP8266_gpio03)) { return false; }
    if (!OtaCore::claimPin(ESP8266_gpio01)) { return false; }

    Serial.end();
    delay(10);
    Serial.begin(9600);
    Serial.setCallback(&CO2Module::onSerialReceived);

    timer.initializeMs(30000, CO2Module::readCO2).start();
    return true;
 }

启动此模块会触发引脚的注册,我们需要这些引脚用于 UART,UART 以 9600 的波特率启动。我们还注册了接收回调。核心类中的引脚注册例程用于日常维护,因此实际上不会失败。如果与另一个模块存在重叠的引脚映射,我们可能希望在第二次注册失败时释放第一个引脚注册。

串行接口使用的 GPIO 引脚在同一个核心类中设置,并且必须在那里进行修改。这种可配置性缺乏的主要原因是 ESP8266 上的 GPIO 引脚在支持的功能方面相当有限,这就是为什么硬件 UART 基本上总是在这两个引脚上找到,而其他引脚用于其他功能。

我们启动的定时器将每 30 秒读取一次传感器,要记住前 3 分钟的传感器读数是无用的,因为传感器需要大约那么长的时间来预热。

bool CO2Module::shutdown() {
    if (!OtaCore::releasePin(ESP8266_gpio03)) { return false; }
    if (!OtaCore::releasePin(ESP8266_gpio01)) { return false; }

    timer.stop();
    Serial.end();
    return true;
 }

 void CO2Module::readCO2() {
    Serial.write(readCmd, 9);
 }

读取传感器的操作就像将我们在静态初始化阶段定义的一系列字节写入传感器一样简单,然后等待传感器通过将数据发送回我们的 RX 缓冲区来响应,这将触发我们的回调函数。

 void CO2Module::config(String cmd) {
    Vector<String> output;
    int numToken = splitString(cmd, '=', output);
    if (output[0] == "event" && numToken > 1) {
          // 
    }
 }

配置方法在这里也没有实现,但可以用来禁用事件(在下一部分中解释)并动态进行各种调整:

void CO2Module::onSerialReceived(Stream &stream, char arrivedChar, unsigned short availableCharsCount) {
    if (availableCharsCount >= 9) {
          char buff[9];
          Serial.readBytes(buff, 9);

          int responseHigh = (int) buff[2];
          int responseLow = (int) buff[3];
          int ppm = (responseHigh * 0xFF) + responseLow;
          String response = OtaCore::getLocation() + ";" + ppm;
          OtaCore::publish("nsa/CO2", response);

          if (ppm > 1000) { // T3
                if (eventLevel < 2 && eventCountUp < 10) {
                      if (++eventCountUp == 10) {
                            eventLevel = 2;
                            eventCountDown = 0;
                            eventCountUp = 0;
                            response = OtaCore::getLocation() + ";" + eventLevel + ";1;" + ppm;
                            OtaCore::publish("nsa/events/CO2", response);
                      }
                }
          }
          else if (ppm > 850) { // T2
                if (eventLevel == 0 && eventCountUp < 10) {
                      if (++eventCountUp == 10) {
                            eventLevel = 1;
                            eventCountDown = 0;
                            eventCountUp = 0;
                            response = OtaCore::getLocation() + ";" + eventLevel + ";1;" + ppm;
                            OtaCore::publish("nsa/events/CO2", response);
                      }
                }
                else if (eventLevel == 2 && eventCountDown < 10) {
                      if (++eventCountDown == 10) {
                            eventLevel = 1;
                            eventCountUp = 0;
                            eventCountDown = 0;
                            response = OtaCore::getLocation() + ";" + eventLevel + ";0;" + ppm;
                            OtaCore::publish("nsa/events/CO2", response);
                      }
                }
          }
          else if (ppm < 750) { // T1
                if (eventLevel == 1 && eventCountDown < 10) {
                      if (++eventCountDown == 10) {
                            eventLevel = 0;
                            eventCountDown = 0;
                            eventCountUp = 0;
                            response = OtaCore::getLocation() + ";" + eventLevel + ";0;" + ppm;
                            OtaCore::publish("nsa/events/CO2", response);
                      }
                }
          }
    }
 }

在回调函数中,我们会在 RX 线上接收到字符时进行处理。我们等待直到在 RX 缓冲区中有九个字符等待我们,这是我们期望从 CO[2]传感器接收到的字节数。我们还可以验证接收数据的校验和,对于这个,MH-Z19 数据表给出了以下 C 代码:

char getCheckSum(char* packet) { 
    char i, checksum; 
    for ( i = 1; i < 8; i++) { 
        checksum += packet[i]; 
    } 

    checksum = 0xff – checksum; 
    checksum += 1; 
    return checksum; 
}

这个例程计算接收数据的校验和作为一个单字节,然后我们可以将其与接收数据的第 9 个字节中包含的值进行比较,看看这些值是否匹配。

回到我们自己的代码,我们处理字节来计算传感器检测到的 CO[2]分子的百万分之几PPM)。这个值会立即发布到其相应的 MQTT 主题。

之后,我们比较新的 PPM 值,看看我们是否已经超过了三个预设的触发级别中的任何一个,第一个表示安全的 CO[2]水平,第二个表示升高的 CO[2]水平,第三个表示需要注意的非常高的 CO[2]水平。当我们超过或返回到较低的触发级别时,将在 MQTT 主题上发布一个事件。

Jura

这是另一个使用 UART 的模块。它曾与许多 Jura 咖啡机一起使用,这些咖啡机使用了其他咖啡机制造商常用的 TopTronics 电子设备。为了能够读取这些咖啡机的信息,ESP8266 模块被集成到一个小型塑料外壳中,外壳的一侧只有一个串行连接器。这个连接器通过标准的九针串行电缆连接到机器背面的所谓的服务端口。

当咖啡机通电时,机器上的串行端口提供 5V 电压,这也会在咖啡机开启时打开 ESP8266 节点。塑料外壳可以隐藏在机器后面。

这个功能的模块看起来像这样:

#include "base_module.h"

 class JuraModule {
    static String mqttTxBuffer;
    static Timer timer;

    static bool toCoffeemaker(String cmd);
    static void readStatistics();
    static void onSerialReceived(Stream &stream, char arrivedChar, unsigned short availableCharsCount);

 public:
    static bool initialize();
    static bool start();
    static bool shutdown();
 };

这个类声明中唯一真正显著的是涉及咖啡机的方法名。我们马上就会看到它的作用:

#include "jura_module.h"
 #include <stdlib.h>
 Timer JuraModule::timer;
 String JuraModule::mqttTxBuffer;
 bool JuraModule::initialize() {
    BaseModule::registerModule(MOD_IDX_JURA, JuraModule::start, JuraModule::shutdown);
 }
 bool JuraModule::start() {
    if (!OtaCore::claimPin(ESP8266_gpio03)) { return false; }
    if (!OtaCore::claimPin(ESP8266_gpio01)) { return false; }
    Serial.end();
    delay(10);
    Serial.begin(9600);
    Serial.setCallback(&JuraModule::onSerialReceived);
    timer.initializeMs(60000, JuraModule::readStatistics).start();
    return true;
 }

和通常一样,咖啡机的 UART 运行在 9600 波特率。我们设置了串行回调方法,并启动了一个定时器来读取 EEPROM 的产品计数器。因为我们在谈论咖啡机,所以每分钟读取计数器超过一次有点愚蠢:

bool JuraModule::shutdown() {
    if (!OtaCore::releasePin(ESP8266_gpio03)) { return false; } // RX 0
    if (!OtaCore::releasePin(ESP8266_gpio01)) { return false; } // TX 0
    timer.stop();
    Serial.end();
    return true;
 }
 void JuraModule::readStatistics() {
    String message = "RT:0000";
    JuraModule::toCoffeemaker(message);
 }

要读取 EEPROM 的计数器,我们需要将此命令发送到机器的 UART。这个命令会告诉它向我们发送 EEPROM 的第一行内容。不幸的是,机器的协议不使用纯文本,而是需要一些特殊的编码,我们会在下一个方法中进行编码:

bool JuraModule::toCoffeemaker(String cmd) {
    OtaCore::log(LOG_DEBUG, "Sending command: " + cmd);
    cmd += "\r\n";
    for (int i = 0; i < cmd.length(); ++i) {
          uint8_t ch = static_cast<uint8_t>(cmd[i]);
          uint8_t d0 = 0xFF;
          uint8_t d1 = 0xFF;
          uint8_t d2 = 0xFF;
          uint8_t d3 = 0xFF;
          bitWrite(d0, 2, bitRead(ch, 0));
          bitWrite(d0, 5, bitRead(ch, 1));
          bitWrite(d1, 2, bitRead(ch, 2));
          bitWrite(d1, 5, bitRead(ch, 3));
          bitWrite(d2, 2, bitRead(ch, 4));
          bitWrite(d2, 5, bitRead(ch, 5));
          bitWrite(d3, 2, bitRead(ch, 6)); 
          bitWrite(d3, 5, bitRead(ch, 7));
          delay(1); 
          Serial.write(d0);
          delay(1); 
          Serial.write(d1);
          delay(1); 
          Serial.write(d2);
          delay(1); 
          Serial.write(d3);
          delay(7);
    }     
    return true;
 }

这个方法接受一个字符串,附加所需的 EOL 字符,并将每个字节编码为四个字节,将数据位放入每个新字节的第二和第五位,其余位都是 1。然后,这四个字节被发送到机器的 UART,每次写入之间有小延迟以确保正确接收:

void JuraModule::onSerialReceived(Stream &stream, char arrivedChar, 
unsigned short availableCharsCount) {

    OtaCore::log(LOG_DEBUG, "Receiving UART 0.");
    while(stream.available()){

        delay(1);
        uint8_t d0 = stream.read();
        delay(1);
        uint8_t d1 = stream.read();
        delay(1);
        uint8_t d2 = stream.read();
        delay(1);
        uint8_t d3 = stream.read();
        delay(7);

        uint8_t d4;
        bitWrite(d4, 0, bitRead(d0, 2));
        bitWrite(d4, 1, bitRead(d0, 5));
        bitWrite(d4, 2, bitRead(d1, 2));
        bitWrite(d4, 3, bitRead(d1, 5));
        bitWrite(d4, 4, bitRead(d2, 2));
        bitWrite(d4, 5, bitRead(d2, 5));
        bitWrite(d4, 6, bitRead(d3, 2));
        bitWrite(d4, 7, bitRead(d3, 5));
        OtaCore::log(LOG_TRACE, String(d4));
        mqttTxBuffer += (char) d4;

        if ('\n' == (char) d4) {
            long int espressoCount = strtol(mqttTxBuffer.substring(3, 
            7).c_str(), 0, 16);
            long int espresso2Count = strtol(mqttTxBuffer.substring(7, 
            11).c_str(), 0, 16);
            long int coffeeCount = strtol(mqttTxBuffer.substring(11, 
            15).c_str(), 0, 16);
            long int coffee2Count = strtol(mqttTxBuffer.substring(15, 
            19).c_str(), 0, 16);
            OtaCore::publish("nsa/espresso", OtaCore::getLocation() + 
            ";" + espressoCount);
            OtaCore::publish("nsa/espresso2", OtaCore::getLocation() + 
            ";" + espresso2Count);
            OtaCore::publish("nsa/coffee", OtaCore::getLocation() + ";" 
            + coffeeCount);
            OtaCore::publish("nsa/coffee2", OtaCore::getLocation() + 
            ";" + coffee2Count);
            mqttTxBuffer = "";
          }
    }
 }

在串行接收回调中,我们使用与我们用来对发送到机器的数据进行编码相同的过程来解码我们接收到的每个字节,缓冲解码后的字节,直到检测到响应结束(换行符,LF)字符。然后我们读出 16 位计数器,然后将其发布到 MQTT 主题上。

JuraTerm

JuraTerm 模块类似于 Jura 模块,但它接受远程命令,以与 Jura 模块相同的方式对其进行编码,并返回解码后的响应。在项目中,它曾经是 Jura 类,直到被新的 Jura 类取代,这个类被委托为终端类。在将来的修订版中,这个模块的功能将被合并到主 Jura 类中。

#include "base_module.h" 

class JuraTermModule {
    static String mqttTxBuffer;

    static bool toCoffeemaker(String cmd);
    static void onSerialReceived(Stream &stream, char arrivedChar, unsigned short availableCharsCount);

 public:
    static bool initialize();
    static bool start();
    static bool shutdown();
    static void commandCallback(String message);
 };#include "juraterm_module.h"

 String JuraTermModule::mqttTxBuffer;

 bool JuraTermModule::initialize() {
    BaseModule::registerModule(MOD_IDX_JURATERM, JuraTermModule::start, JuraTermModule::shutdown);
 }

 bool JuraTermModule::start() {
    if (!OtaCore::claimPin(ESP8266_gpio03)) { return false; } // RX 0
    if (!OtaCore::claimPin(ESP8266_gpio01)) { return false; } // TX 0

    OtaCore::registerTopic("coffee/command/" + OtaCore::getLocation(), 
                            JuraTermModule::commandCallback); 
    Serial.end();
    delay(10);
    Serial.begin(9600);
    Serial.setCallback(&JuraTermModule::onSerialReceived);

    return true;
 }

 bool JuraTermModule::shutdown() {
    if (!OtaCore::releasePin(ESP8266_gpio03)) { return false; } // RX 0
    if (!OtaCore::releasePin(ESP8266_gpio01)) { return false; } // TX 0

    Serial.end();
    OtaCore::deregisterTopic("coffee/command/" + OtaCore::getLocation());
    return true;
 }

 void JuraTermModule::commandCallback(String message) {
    if (message == "AN:0A") { return; }

    JuraTermModule::toCoffeemaker(message);
 }

当我们启动这个模块时,我们注册一个 MQTT 主题来接收命令。这允许我们接收咖啡机命令。我们基本上充当这些命令的直通车,除了这一个特定的命令。我们过滤掉的这个命令会擦除机器的 EEPROM,这是我们不太可能想要的。

我们使用相同的方法来编码命令:

 bool JuraTermModule::toCoffeemaker(String cmd) {
    OtaCore::log(LOG_DEBUG, "Sending command: " + cmd);

    cmd += "\r\n";

    for (int i = 0; i < cmd.length(); ++i) {
          uint8_t ch = static_cast<uint8_t>(cmd[i]);
          uint8_t d0 = 0xFF;
          uint8_t d1 = 0xFF;
          uint8_t d2 = 0xFF;
          uint8_t d3 = 0xFF;

          bitWrite(d0, 2, bitRead(ch, 0));
          bitWrite(d0, 5, bitRead(ch, 1));
          bitWrite(d1, 2, bitRead(ch, 2));
          bitWrite(d1, 5, bitRead(ch, 3));
          bitWrite(d2, 2, bitRead(ch, 4));
          bitWrite(d2, 5, bitRead(ch, 5));
          bitWrite(d3, 2, bitRead(ch, 6)); 
          bitWrite(d3, 5, bitRead(ch, 7));

          delay(1); 
          Serial.write(d0);
          delay(1); 
          Serial.write(d1);
          delay(1); 
          Serial.write(d2);
          delay(1); 
          Serial.write(d3);
          delay(7);
    }     

    return true;
 }

 void JuraTermModule::onSerialReceived(Stream &stream, char arrivedChar, unsigned short availableCharsCount) {
    OtaCore::log(LOG_DEBUG, "Receiving UART 0.");

    while(stream.available()){
          delay(1);
          uint8_t d0 = stream.read();
          delay(1);
          uint8_t d1 = stream.read();
          delay(1);
          uint8_t d2 = stream.read();
          delay(1);
          uint8_t d3 = stream.read();
          delay(7);

          uint8_t d4;
          bitWrite(d4, 0, bitRead(d0, 2));
          bitWrite(d4, 1, bitRead(d0, 5));
          bitWrite(d4, 2, bitRead(d1, 2));
          bitWrite(d4, 3, bitRead(d1, 5));
          bitWrite(d4, 4, bitRead(d2, 2));
          bitWrite(d4, 5, bitRead(d2, 5));
          bitWrite(d4, 6, bitRead(d3, 2));
          bitWrite(d4, 7, bitRead(d3, 5));

          OtaCore::log(LOG_TRACE, String(d4));

          mqttTxBuffer += (char) d4;
          if ('\n' == (char) d4) {
                OtaCore::publish("coffee/response", OtaCore::getLocation() + ";" + mqttTxBuffer);
                mqttTxBuffer = "";
          }
    }
 }

我们仅仅返回其相应的 MQTT 主题上的响应,而不对数据进行任何解释。

Motion

运动模块旨在与被动红外PIR)传感器一起使用。这些传感器具有内置逻辑,确定何时达到触发点,此时它们将中断引脚更改为高信号。我们可以使用这个来确定一个人是否在房间里,或者是在走廊里走动。

其代码如下:

#include "base_module.h"

 #define GPIO_PIN 0

 class MotionModule {
    static int pin;
    static Timer timer;
    static Timer warmup;
    static bool motion;
    static bool firstLow;

 public:
    static bool initialize();
    static bool start();
    static bool shutdown();
    static void config(String cmd);
    static void warmupSensor();
    static void readSensor();
    static void IRAM_ATTR interruptHandler();
 };

值得注意的是,我们明确将中断处理程序方法移入 MCU 的 SRAM 中,以防止中断调用时出现任何延迟。

其实现如下:

#include "motion_module.h"
int MotionModule::pin = GPIO_PIN;
Timer MotionModule::timer;
Timer MotionModule::warmup;
bool MotionModule::motion = false;
bool MotionModule::firstLow = true;
bool MotionModule::initialize() {
      BaseModule::registerModule(MOD_IDX_MOTION, MotionModule::start, 
      MotionModule::shutdown);
}
bool MotionModule::start() {
    if (!OtaCore::claimPin(ESP8266_gpio00)) { return false; }
    pinMode(pin, INPUT);
    warmup.initializeMs(60000, MotionModule::warmupSensor).start();
   return true;
}

PIR 传感器需要预热时间来稳定其读数。我们使用预热计时器给它一分钟。我们还设置了我们使用的 GPIO 引脚的模式。


 bool MotionModule::shutdown() {
    if (!OtaCore::releasePin(ESP8266_gpio00)) { return false; } // RX 0

    timer.stop();
    detachInterrupt(pin);

    return true;
 }

 void MotionModule::config(String cmd) {
    Vector<String> output;
    int numToken = splitString(cmd, '=', output);
    if (output[0] == "set_pin" && numToken > 1) {
          //
    }
 }

 void MotionModule::warmupSensor() {
    warmup.stop();
    attachInterrupt(pin, &MotionModule::interruptHandler, CHANGE);

    timer.initializeMs(5000, MotionModule::readSensor).start();
 }

传感器完成预热后,我们停止其计时器,并附加中断以处理传感器发出的任何信号。我们将使用中断例程检查共享变量,以查看值是否已更改,并每 5 秒发布当前值:

 void MotionModule::readSensor() {
    if (!motion) {
          if (firstLow) { firstLow = false; }
          else {
                OtaCore::publish("nsa/motion", OtaCore::getLocation() + ";0");
                firstLow = true;
          }
    }
    else if (motion) {
          OtaCore::publish("nsa/motion", OtaCore::getLocation() + ";1");
          firstLow = true;
    }
 }

在检查当前传感器值时,我们特意忽略传感器报告LOW的第一次。这是为了确保我们忽略人们在房间里不经常移动的时刻。然后将得到的值发布到 MQTT 主题上:

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

中断处理程序仅更新本地布尔值。由于大多数 PIR 传感器的处理电路的转换时间相对较长,传感器再次检测到运动之前有相当长的时间(几秒钟),从而产生死区。在这里,我们跟踪上次注册的值。

PWM

PWM 模块的开发原因是为了通过外部 RC 滤波电路生成模拟输出电压的方法。这是为了控制天花板空调的风扇,其风扇控制器接受 0 至 10 伏特之间的电压。

这个模块的一个有趣特性是它有自己的二进制协议,以实现远程控制,这就是天花板节点通过空调服务直接控制风速的方式:

#include "base_module.h"

 #include <HardwarePWM.h>

 class PwmModule {
    static HardwarePWM* hw_pwm;
    static Vector<int> duty;
    static uint8 pinNum;
    static Timer timer;
    static uint8* pins;

 public:
    static bool initialize();
    static bool start();
    static bool shutdown();
    static void commandCallback(String message);
 };

实现如下:

#include "pwm_module.h"

 HardwarePWM* PwmModule::hw_pwm = 0;
 uint8 PwmModule::pinNum = 0;
 Timer PwmModule::timer;
 uint8* PwmModule::pins = 0;

 enum {
    PWM_START = 0x01,
    PWM_STOP = 0x02,
    PWM_SET_DUTY = 0x04,
    PWM_DUTY = 0x08,
    PWM_ACTIVE = 0x10
 };

我们在这里定义 PWM 模块可用的命令作为枚举:


 bool PwmModule::initialize() {
    BaseModule::registerModule(MOD_IDX_PWM, PwmModule::start, PwmModule::shutdown);
 }

 bool PwmModule::start() {
    OtaCore::registerTopic(MQTT_PREFIX + String("pwm/") + OtaCore::getLocation(), PwmModule::commandCallback);

    return true;
 }

 bool PwmModule::shutdown() {
    OtaCore::deregisterTopic(MQTT_PREFIX + String("pwm/") + OtaCore::getLocation());

    if (hw_pwm) {
          delete hw_pwm;
          hw_pwm = 0;
    }

    return true;
 }

当我们启动这个模块时,我们注册一个 MQTT 主题,以便模块能够接收命令。关闭时,我们再次注销这个主题。我们使用 Sming 的HardwarePWM类在单独的引脚上启用 PWM。

模块的其余部分只是命令处理器:


 void PwmModule::commandCallback(String message) {
    OtaCore::log(LOG_DEBUG, "PWM command: " + message);
    if (message.length() < 1) { return; }
    int index = 0;
    uint8 cmd = *((uint8*) &message[index++]);

    if (cmd == PWM_START) {
          if (message.length() < 2) { return; }
          uint8 num = *((uint8*) &message[index++]);

          OtaCore::log(LOG_DEBUG, "Pins to add: " + String(num));

          if (message.length() != (2 + num)) { return; }

          pins = new uint8[num];
          for (int i = 0; i < num; ++i) {
                pins[i] = *((uint8*) &message[index++]);
                if (!OtaCore::claimPin(pins[i])) {
                      OtaCore::log(LOG_ERROR, "Pin is already in use: " + String(pins[i]));

                      OtaCore::publish("pwm/response", OtaCore::getLocation() + ";0", 1);

                      return; 
                }

                OtaCore::log(LOG_INFO, "Adding GPIO pin " + String(pins[i]));
          }

          hw_pwm = new HardwarePWM(pins, num);
          pinNum = num;

          OtaCore::log(LOG_INFO, "Added pins to PWM: " + String(pinNum));

          OtaCore::publish("pwm/response", OtaCore::getLocation() + ";1", 1);
    }
    else if (cmd == PWM_STOP) {
          delete hw_pwm;
          hw_pwm = 0;

          for (int i = 0; i < pinNum; ++i) {
                if (!OtaCore::releasePin(pins[i])) {
                      OtaCore::log(LOG_ERROR, "Pin cannot be released: " + String(pins[i]));

                      OtaCore::publish("pwm/response", OtaCore::getLocation() + ";0", 1);

                      return; 
                }

                OtaCore::log(LOG_INFO, "Removing GPIO pin " + String(pins[i]));
          }

          delete[] pins;
          pins = 0;

          OtaCore::publish("pwm/response", OtaCore::getLocation() + ";1");
    }
    else if (cmd == PWM_SET_DUTY) {
          if (message.length() < 3) { return; }

          uint8 pin = *((uint8*) &message[index++]);
          uint8 duty = *((uint8*) &message[index++]);
          bool ret = hw_pwm->setDuty(pin, ((uint32) 222.22 * duty));
          if (!ret) {
                OtaCore::publish("pwm/response", OtaCore::getLocation() + ";0");

                return;
          }

          OtaCore::publish("pwm/response", OtaCore::getLocation() + ";1");
    }
    else if (cmd == PWM_DUTY) {
          if (message.length() < 2) { return; }

          uint8 pin = *((uint8*) &message[index++]);
          uint32 duty = hw_pwm->getDuty(pin);

          uint8 dutyp = (duty / 222.22) + 1;
          String res = "";
          res += (char) pin;
          res += (char) dutyp;
          OtaCore::publish("pwm/response", OtaCore::getLocation() + ";" + res);
    }
    else if (cmd == PWM_ACTIVE) {
          String res;
          if (pins && pinNum > 0) {
                res = String((char*) pins, pinNum);
          }

          OtaCore::publish("pwm/response", OtaCore::getLocation() + ";" + res);
    }
 }

前述方法实现的协议如下:

命令 含义 有效负载 返回值
0x01 启动模块 uint8(引脚数量)uint8*(每个引脚编号一个字节) 0x00/0x01
0x02 停止模块 - 0x00/0x01
0x04 设置 PWM 占空比 uint8(引脚编号)uint8(占空比,0-100) 0x00/0x01
0x08 获取 PWM 占空比 uint8(引脚编号) uint8(占空比)
0x10 返回活动引脚 - uint8*(每个字节一个引脚编号)

对于每个命令,我们解析接收到的字节串,检查字节数以查看是否获得了预期的数量,然后将其解释为命令及其有效负载。我们要么返回 0(失败)或 1(成功),要么返回所需信息的有效负载。

这里可以明显增加的一个补充是在接收到的命令中添加某种校验和,并对接收到的数据进行合理性检查。虽然这样的代码在具有加密 MQTT 链接和可靠网络连接的安全环境中运行良好,但其他环境可能不太宽容,会出现数据损坏和虚假数据注入的情况。

I/O

有时我们只需要很多连接到继电器等设备的 GPIO 引脚,以便我们可以打开或关闭加热阀。这就是这个模块背后的原因。安装在天花板上的节点不仅使用于环境传感器的 I2C 总线,还用于 CO[2]测量的 UART 和四个 PWM 输出引脚。

由于需要更多的 GPIO 来控制水线上空调单元的阀门的继电器的开关,因此在 I2C 总线上添加了专用的 GPIO 扩展芯片,以提供另外八个 GPIO 引脚。

此模块允许外部服务(如空调服务)直接将这些新的 GPIO 引脚设置为高电平或低电平:

#include "base_module.h"

 #include <Libraries/MCP23008/MCP23008.h>

 class IOModule {
    static MCP23008* mcp;
    static uint8 iodir;
    static uint8 gppu;
    static uint8 gpio;
    static String publishTopic;

 public:
    static bool initialize();
    static bool start();
    static bool shutdown();
    static void commandCallback(String message);
 };

该类包装了 MCP23008 I/O 扩展器设备,保留了其方向、上拉和 GPIO 状态寄存器的本地副本,以便轻松更新和控制:

#include "io_module.h"

 #include <Wire.h>

 MCP23008* IOModule::mcp = 0;
 uint8 IOModule::iodir;     
 uint8 IOModule::gppu;
 uint8 IOModule::gpio;      
 String IOModule::publishTopic;

我们在 I2C GPIO 扩展器设备上保留了三个寄存器的本地副本——I/O 方向(iodir),上拉寄存器(gppu)和引脚 I/O 电平(gpio):


 enum {
    IO_START = 0x01,
    IO_STOP = 0x02,
    IO_STATE = 0x04,
    IO_SET_MODE = 0x08,
    IO_SET_PULLUP = 0x10,
    IO_WRITE = 0x20,
    IO_READ = 0x40,
    IO_ACTIVE = 0x80
 };

 enum {
    MCP_OUTPUT = 0,
    MCP_INPUT = 1
 };

我们再次以枚举的形式定义了一些命令,以及 GPIO 扩展器的引脚方向。

bool IOModule::initialize() {
    BaseModule::registerModule(MOD_IDX_IO, IOModule::start, IOModule::shutdown);
 }

 bool IOModule::start() {   
    publishTopic = "io/response/" + OtaCore::getLocation();
    OtaCore::registerTopic("io/" + OtaCore::getLocation(), IOModule::commandCallback);

    OtaCore::starti2c();
 }

 bool IOModule::shutdown() {
    OtaCore::deregisterTopic("io/" + OtaCore::getLocation());
    if (mcp) {
          delete mcp;
          mcp = 0;
    }
 }

初始化和启动模块与 PWM 模块类似,我们注册一个 MQTT 主题以接收命令。不同之处在于,由于我们使用的是 I2C 设备,因此我们必须确保 I2C 功能已经启动。

接下来,我们将处理命令处理方法:

void IOModule::commandCallback(String message) {
    OtaCore::log(LOG_DEBUG, "I/O command: " + message);
    uint32 mlen = message.length();
    if (mlen < 1) { return; }
    int index = 0;
    uint8 cmd = *((uint8*) &message[index++]);
    if (cmd == IO_START) {
        if (mlen > 2) {
            OtaCore::log(LOG_INFO, "Enabling I/O Module failed: too 
            many parameters.");
            OtaCore::publish(publishTopic, OtaCore::getLocation() + 
            ";" + (char) 0x01 + (char) 0x00);
            return; 
        }
        // Read out the desired address, or use the default.
        uint8 addr = 0;
        if (mlen == 2) {
            addr = *((uint8*) &message[index++]);
            if (addr > 7) {                     
            // Report failure. QoS 1.
            OtaCore::log(LOG_INFO, "Enabling I/O Module failed: invalid 
            i2c address.");
            OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" 
            + (char) 0x01 + (char) 0x00);
            return;
        }
    }
    if (!mcp) {
        mcp = new MCP23008(0x40);
    }           
    // Set all pins to output (0) and low (0)
    mcp->writeIODIR(0x00);
    mcp->writeGPIO(0x00);
    // Read in current chip values.
    iodir = mcp->readIODIR();
    gppu = mcp->readGPPU();
    gpio = mcp->readGPIO();
    // Validate IODIR and GPIO registers.
    if (iodir != 0 || gpio != 0) {
        delete mcp;
        mcp = 0;
        OtaCore::log(LOG_INFO, "Enabling I/O Module failed: not 
        connected.");
         OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" +
         (char) 0x01 + (char) 0x00);
         return;
    }
    OtaCore::log(LOG_INFO, "Enabled I/O Module.");
    OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" +                                                                        
    (char) 0x01 + (char) 0x01);
}
    else if (cmd == IO_STOP) {
        if (mlen > 1) {
            OtaCore::log(LOG_INFO, "Disabling I/O Module failed: too 
            many parameters.");
            OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" 
            + (char) 0x02 + (char) 0x00);
            return; 
        }
        if (mcp) {
            delete mcp;
            mcp = 0;
        }
        OtaCore::log(LOG_INFO, "Disabled I/O Module.");
        OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
        (char) 0x02 + (char) 0x01);
    }
    else if (cmd == IO_STATE) {
          if (mlen > 1) {
                OtaCore::log(LOG_INFO, "Reading state failed: too many parameters.");
                OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x04 + (char) 0x00);
                return; 
          }

          OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x04 + (char) 0x01 + 
                                                                      ((char) iodir) + ((char) gppu) +
                                                                      ((char) gpio));
    }
    else if (cmd == IO_SET_MODE) {
          if (mlen != 3) {
                OtaCore::log(LOG_INFO, "Reading state failed: incorrect number of parameters.");
                OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x08 + (char) 0x00);
                return; 
          }

          uint8 pnum = *((uint8*) &message[index++]);
          uint8 pstate = *((uint8*) &message[index]);
          if (pnum > 7) {
                OtaCore::log(LOG_INFO, "Setting pin mode failed: unknown pin.");
                OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x08 + (char) 0x00);
                return; 
          }

          if (pstate > 1) {
                // Report failure. QoS 1.
                OtaCore::log(LOG_INFO, "Setting pin mode failed: invalid pin mode.");
                OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x08 + (char) 0x00);
                return; 
          }

          // Set new state of IODIR register.
          if (pstate == MCP_INPUT) { iodir |= 1 << pnum; } 
          else { iodir &= ~(1 << pnum); }

          if (mcp) {
                OtaCore::log(LOG_DEBUG, "Setting pinmode in library...");
                mcp->writeIODIR(iodir);
          }

          OtaCore::log(LOG_INFO, "Set pin mode for I/O Module.");
          OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x08 + (char) 0x01);
    }
    else if (cmd == IO_SET_PULLUP) {          
          if (mlen != 3) {
                OtaCore::log(LOG_INFO, "Reading state failed: incorrect number of parameters.");
                OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x10 + (char) 0x00);
                return; 
          }

          uint8 pnum = *((uint8*) &message[index++]);
          uint8 pstate = *((uint8*) &message[index]);
          if (pnum > 7) {
                OtaCore::log(LOG_INFO, "Setting pull-up failed: unknown pin.");
                OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x10 + (char) 0x00);
                return; 
          }

          if (pstate > 1) {
                OtaCore::log(LOG_INFO, "Setting pull-up failed: invalid state.");
                OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x10 + (char) 0x00);
                return; 
          }

          if (pstate == HIGH) { gppu |= 1 << pnum; } 
          else { gppu &= ~(1 << pnum); }

          if (mcp) {
                OtaCore::log(LOG_DEBUG, "Setting pull-up in library...");
                mcp->writeGPPU(gppu);
          }

          OtaCore::log(LOG_INFO, "Changed pull-up for I/O Module.");
          OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x10 + (char) 0x01);
    }
    else if (cmd == IO_WRITE) {
          if (mlen != 3) {
                OtaCore::log(LOG_INFO, "Writing pin failed: incorrect number of parameters.");
                OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x20 + (char) 0x00);
                return; 
          }
          // Set the new GPIO pin level.
          uint8 pnum = *((uint8*) &message[index++]);
          uint8 pstate = *((uint8*) &message[index]);
          if (pnum > 7) {
                OtaCore::log(LOG_INFO, "Writing pin failed: unknown pin.");
                OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x20 + (char) 0x00);
                return; 
          }
          if (pstate > 1) {
                OtaCore::log(LOG_INFO, "Writing pin failed: invalid state.");
                OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x20 + (char) 0x00);
                return; 
          }
          String state = "low";
          if (pstate == HIGH) { gpio |= 1 << pnum; state = "high"; } 
          else { gpio &= ~(1 << pnum); }

          OtaCore::log(LOG_DEBUG, "Changed GPIO to: " + ((char) gpio));

          if (mcp) {
                OtaCore::log(LOG_DEBUG, "Setting state to " + state + 
                                        " in library for pin " + ((char) pnum));
                mcp->writeGPIO(gpio);
          }

          OtaCore::log(LOG_INFO, "Wrote pin state for I/O Module.");
          OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x20 + (char) 0x01);
    }

    else if (cmd == IO_READ) {

          if (mlen > 2) {
                OtaCore::log(LOG_INFO, "Reading pin failed: too many 
                parameters.");
                OtaCore::publish(publishTopic, OtaCore::getLocation()
                                                                     (char) 0x40 + (char) 0x00);
                return; 
          }
          // Read the GPIO pin status and return it.
          uint8 pnum = *((uint8*) &message[index]);

        if (pnum > 7) {
            OtaCore::log(LOG_INFO, "Reading pin failed: unknown pin.");
            OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" 
            + (char) 0x40 + (char) 0x00);
        }
          uint8 pstate;

        if (mcp) {
            OtaCore::log(LOG_DEBUG, "Reading pin in library...");
            pstate = (mcp->readGPIO() >> pnum) & 0x1;
        }
        OtaCore::log(LOG_INFO, "Read pin state for I/O Module.");
        OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
        (char) 0x40 + (char) 0x01 + (char) pnum + (char) pstate);
    }

    else if (cmd == IO_ACTIVE) {

        if (mlen > 1) {
            OtaCore::log(LOG_INFO, "Reading active status failed: too 
            many parameters.");
            OtaCore::publish(publishTopic, OtaCore::getLocation() + 
            ";" + (char) 0x80 + (char) 0x00);
            return; 
        }
        uint8 active = 0;
        if (mcp) { active = 1; }
        char output[] = { 0x80, 0x01, active };
        OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
        String(output, 3));
    }
}

其协议如下:

命令 含义 有效负载 返回值
0x01 启动模块 uint8 I2C 地址偏移(0-7,可选) 0x010x00/0x01
0x02 停止模块 - 0x020x00/0x01
0x04 返回 I/O 模式,上拉和电平状态 - 0x040x00/0x01(结果)uint8(iodir 寄存器)uint8(gppu 寄存器)uint8(gpio 寄存器)
0x08 将引脚设置为特定模式(输入/输出) uint8(引脚编号,0-7)uint8(0:输出,1:输入) 0x080x00/0x01
0x10 设置引脚的上拉电阻(低/高) uint8(引脚编号,0-7)uint8(引脚上拉状态,0/1) 0x100x00/0x01
0x20 将引脚设置为低电平或高电平 uint8(引脚编号,0-7)uint8(引脚状态,0/1) 0x20 0x00/0x01
0x40 读取当前引脚值(低,高) uint8(引脚编号) 0x40 0x00/0x01 uint8(引脚编号)uint8(引脚值)
0x80 返回此模块是否已初始化 - 0x80 0x00/0x01 uint8(模块状态,0/1)

与 PWM 模块的协议类似,返回一个布尔值表示成功,或者返回请求的有效负载。我们还在响应中返回调用的命令。

该命令是一个字节,因为我们使用的是位标志,所以最多可以有八个命令。如果需要,可以将其扩展到 256 个命令。

该模块代码的可能改进包括将重复的代码合并为(内联)函数调用,以及可能使用子类来管理使用更高级 API 设置和切换单个位。

切换

由于办公室的每个区域都有自己的中央开关,可以切换流向 FCU 的管道中的水,因此这也必须可以从后端服务器进行控制。使用闸门继电器配置,可以在加热和冷却配置之间进行切换,并且可以具有可以由节点读取的存储元素:

该系统是在一个单板上组装的,用于替换原始手动开关,使用以下模块来进行控制:

#include "base_module.h"

 class SwitchModule {
    static String publishTopic;

 public:
    static bool initialize();
    static bool start();
    static bool shutdown();
    static void commandCallback(String message);
 };

其实现如下:

#include "switch_module.h"
#include <Wire.h>
#define SW1_SET_PIN 5 
#define SW2_SET_PIN 4 
#define SW1_READ_PIN 14 
#define SW2_READ_PIN 12 
String SwitchModule::publishTopic;
enum {
    SWITCH_ONE = 0x01,//Switch the first connected load on, second off.
    SWITCH_TWO = 0x02,//Switch the second connected load on, first off.
    SWITCH_STATE = 0x04,//Returns position of the switch (0x01/0x02).
};
bool SwitchModule::initialize() {
    BaseModule::registerModule(MOD_IDX_SWITCH, SwitchModule::start, 
    SwitchModule::shutdown);
}
bool SwitchModule::start() {
    // Register pins.
    if (!OtaCore::claimPin(ESP8266_gpio05)) { return false; }
    if (!OtaCore::claimPin(ESP8266_gpio04)) { return false; }
    if (!OtaCore::claimPin(ESP8266_gpio14)) { return false; }
    if (!OtaCore::claimPin(ESP8266_gpio12)) { return false; }
    publishTopic = "switch/response/" + OtaCore::getLocation();
    OtaCore::registerTopic("switch/" + OtaCore::getLocation(), 
    SwitchModule::commandCallback);
// Set the pull-ups on the input pins and configure the output pins.
    pinMode(SW1_SET_PIN, OUTPUT);
    pinMode(SW2_SET_PIN, OUTPUT);
    pinMode(SW1_READ_PIN, INPUT_PULLUP);
    pinMode(SW2_READ_PIN, INPUT_PULLUP);
    digitalWrite(SW1_SET_PIN, LOW);
    digitalWrite(SW2_SET_PIN, LOW);
 }
 bool SwitchModule::shutdown() {
    OtaCore::deregisterTopic("switch/" + OtaCore::getLocation());
    // Release the pins.
    if (!OtaCore::releasePin(ESP8266_gpio05)) { return false; }
    if (!OtaCore::releasePin(ESP8266_gpio04)) { return false; }
    if (!OtaCore::releasePin(ESP8266_gpio14)) { return false; }
    if (!OtaCore::releasePin(ESP8266_gpio12)) { return false; }
 }

 void SwitchModule::commandCallback(String message) {
    // Message is the command.
    OtaCore::log(LOG_DEBUG, "Switch command: " + message);

    uint32 mlen = message.length();
    if (mlen < 1) { return; }
    int index = 0;
    uint8 cmd = *((uint8*) &message[index++]);
    if (cmd == SWITCH_ONE) {
          if (mlen > 1) {
                // Report failure. QoS 1.
                OtaCore::log(LOG_INFO, "Switching to position 1 failed: too many parameters.");
                OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x01 + (char) 0x00);
                return; 
          }

          // Set the relay to its first position (reset condition).
          // This causes pins 3 & 10 on the latching relay to become active.
          digitalWrite(SW1_SET_PIN, HIGH);
          delay(1000); // Wait 1 second for the relay to switch position.
          digitalWrite(SW1_SET_PIN, LOW);

          OtaCore::log(LOG_INFO, "Switched to position 1.");
          OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x01 + (char) 0x01);
    }
    else if (cmd == SWITCH_TWO) {
          if (mlen > 1) {
                OtaCore::log(LOG_INFO, "Switching to position 2 failed: too many parameters.");
                OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x02 + (char) 0x00);
                return; 
          }

          // Set the relay to its first position (reset condition).
          // This causes pins 3 & 10 on the latching relay to become active.
          digitalWrite(SW2_SET_PIN, HIGH);
          delay(1000); // Wait 1 second for the relay to switch position.
          digitalWrite(SW2_SET_PIN, LOW);

          OtaCore::log(LOG_INFO, "Switched to position 1.");
          OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x02 + (char) 0x01);
    }
    else if (cmd == SWITCH_STATE) {
          if (mlen > 1) {
                OtaCore::log(LOG_INFO, "Reading state failed: too many parameters.");
                OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x04 + (char) 0x00);
                return; 
          }

          // Check the value of the two input pins. If one is low, then that
          // is the active position.
          uint8 active = 2;
          if (digitalRead(SW1_READ_PIN) == LOW) { active = 0; }
          else if (digitalRead(SW2_READ_PIN) == LOW) { active = 1; }

          if (active > 1) {
                OtaCore::log(LOG_INFO, "Reading state failed: no active state found.");
                OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x04 + (char) 0x00);
                return; 
          }

          OtaCore::publish(publishTopic, OtaCore::getLocation() + ";" + 
                                                                      (char) 0x04 + (char) 0x01 + 
                                                                      (char) active);
    }
 }

该模块与 PWM 和 I/O 模块非常相似,注册了一个 MQTT 主题,以允许使用自己的二进制协议进行通信。在这里,被控制的设备相当简单。它是一个带有两个侧面的闸门继电器,其中一个侧面连接到正在切换的连接,而另一侧用作一位存储单元。

由于这种继电器的两侧将同时切换,因此我们可以指望连接到 MCU 的一侧与连接到系统其余部分的一侧的位置相匹配。即使在 MCU 断电或复位后,我们也可以简单地读取与继电器连接的引脚的值,以找出系统的状态。

生成的协议如下所示:

命令 含义 有效负载 返回值
0x01 切换到位置 1 - 0x010x00/0x01
0x02 切换到位置 2 - 0x020x00/0x01
0x04 返回当前状态 - 0x040x00/0x01(结果)uint8(活动引脚 0x00、0x01)

命令和控制服务器

在本章前面提到过,所谓的命令和控制C&C)服务器本质上是一个包含有关各个节点及其配置信息的数据库,供节点自身和下一节中的管理工具使用。

它还包括一个 HTTP 服务器,用于支持基于 HTTP 的空中OTA)更新。由于 BMaC 系统是基于 MQTT 的,因此该服务器也被编写为 MQTT 客户端:

#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" 

int main(int argc, char* argv[]) { 
   cout << "Starting MQTT BMaC Command & Control server...\n"; 

   int rc; 
   mosqpp::lib_init(); 

   cout << "Initialised C++ Mosquitto library.\n"; 

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

   AutoPtr<IniFileConfiguration> config(new IniFileConfiguration(configFile)); 
   string mqtt_host = config->getString("MQTT.host", "localhost"); 
   int mqtt_port = config->getInt("MQTT.port", 1883); 
   string defaultFirmware = config->getString("Firmware.default", "ota_unified.bin"); 

   Listener listener("Command_and_Control", mqtt_host, mqtt_port, defaultFirmware); 

   UInt16 port = config->getInt("HTTP.port", 8080); 
   HTTPServerParams* params = new HTTPServerParams; 
   params->setMaxQueued(100); 
   params->setMaxThreads(10); 
   HTTPServer httpd(new RequestHandlerFactory, port, params); 
   httpd.start(); 

   cout << "Created listener, entering loop...\n"; 

   while(1) { 
         rc = listener.loop(); 
         if (rc){ 
               cout << "Disconnected. Trying to reconnect...\n"; 
               listener.reconnect(); 
         } 
   } 

   cout << "Cleanup...\n"; 

   mosqpp::lib_cleanup(); 

   return 0; 
} 

我们使用 Mosquitto C++ MQTT 客户端以及 POCO 框架来为我们提供所需的功能。

接下来是Listener类:

#include <mosquittopp.h> 
#include <string> 

using namespace std; 

#include <Poco/Data/Session.h> 
#include <Poco/Data/SQLite/Connector.h> 

using namespace Poco; 

class Listener : public mosqpp::mosquittopp { 
   Data::Session* session; 
   string defaultFirmware; 

public: 
   Listener(string clientId, string host, int port, string defaultFirmware); 
   ~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); 
}; 

我们包括 POCO 的头文件,用于 SQLite 数据库功能,它构成了该应用程序的数据库后端。该类本身派生自 Mosquitto C++类,为我们提供了所有基本的 MQTT 功能以及一些函数存根,我们稍后还需要实现:

#include "listener.h" 

#include <iostream> 
#include <fstream> 
#include <sstream> 

using namespace std; 

#include <Poco/StringTokenizer.h> 
#include <Poco/String.h> 
#include <Poco/Net/HTTPSClientSession.h> 
#include <Poco/Net/HTTPRequest.h> 
#include <Poco/Net/HTTPResponse.h> 
#include <Poco/File.h> 

using namespace Poco::Data::Keywords; 

struct Node { 
   string uid; 
   string location; 
   UInt32 modules; 
   float posx; 
   float posy; 
}; 

我们为单个节点定义了一个结构:

Listener::Listener(string clientId, string host, int port, string defaultFirmware) : mosquittopp(clientId.c_str()) { 
   int keepalive = 60; 
   connect(host.c_str(), port, keepalive); 

   Data::SQLite::Connector::registerConnector(); 
   session = new Poco::Data::Session("SQLite", "nodes.db"); 

   (*session) << "CREATE TABLE IF NOT EXISTS nodes (uid TEXT UNIQUE, \ 
         location TEXT, \ 
         modules INT, \ 
         posx FLOAT, \ 
         posy FLOAT)", now; 

   (*session) << "CREATE TABLE IF NOT EXISTS firmware (uid TEXT UNIQUE, \ 
         file TEXT)", now; 

   this->defaultFirmware = defaultFirmware; 
} 

在构造函数中,我们尝试连接到 MQTT 代理,使用提供的主机和端口。我们还建立与 SQLite 数据库的连接,并确保它具有有效的节点和固件表:

Listener::~Listener() { 
   // 
} 

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

   if (rc == 0) { 
         string topic = "cc/config";   // announce by nodes coming online. 
         subscribe(0, topic.c_str()); 
         topic = "cc/ui/config";       // C&C client requesting configuration. 
         subscribe(0, topic.c_str()); 
         topic = "cc/nodes/new";       // C&C client adding new node. 
         subscribe(0, topic.c_str()); 
         topic = "cc/nodes/update";    // C&C client updating node. 
         subscribe(0, topic.c_str()); 
         topic = "nsa/events/CO2";     // CO2-related events. 
         subscribe(0, topic.c_str()); 
         topic = "cc/firmware";  // C&C client firmware command. 
         subscribe(0, topic.c_str()); 
   } 
   else { 
         cerr << "Connection failed. Aborting subscribing.\n"; 
   } 
} 

我们重新实现了当与 MQTT 代理建立连接时的回调。在这个方法中,我们订阅了所有我们感兴趣的 MQTT 主题。

下一个方法是每当我们在我们订阅的主题中接收到 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 == "cc/config") { 
         if (payload.length() < 1) { 
               cerr << "Invalid payload: " << payload << ". Reject.\n"; 
               return; 
         } 

我们验证我们接收到的每个主题的有效负载。对于这个第一个主题,我们期望其有效负载包含想要接收其配置的节点的 MAC 地址。我们确保似乎是这种情况,然后继续:

         Data::Statement select(*session); 
         Node node; 
         node.uid = payload; 
         select << "SELECT location, modules FROM nodes WHERE uid=?", 
                     into (node.location), 
                     into (node.modules), 
                     use (payload); 

         size_t rows = select.execute(); 

         if (rows == 1) { 
               string topic = "cc/" + payload; 
               string response = "mod;" + string((const char*) &node.modules, 4); 
               publish(0, topic.c_str(), response.length(), response.c_str()); 
               response = "loc;" + node.location; 
               publish(0, topic.c_str(), response.length(), response.c_str()); 
         } 
         else if (rows < 1) { 
               // No node with this UID found. 
               cerr << "Error: No data set found for uid " << payload << endl; 
         } 
         else { 
               // Multiple data sets were found, which shouldn't be possible... 
               cerr << "Error: Multiple data sets found for uid " << payload << "\n"; 
         } 
   } 

我们尝试在数据库中找到 MAC 地址,如果找到,则读取节点的配置并将其作为返回消息的有效负载。

接下来的主题与管理工具一起使用:

else if (topic == "cc/ui/config") { 

    if (payload == "map") {

        ifstream mapFile("map.png", ios::binary); 

        if (!mapFile.is_open()) { 

            cerr << "Failed to open map file.\n"; 

            return; 

        } 

        stringstream ss; 

        ss << mapFile.rdbuf(); 

        string mapData = ss.str(); 

        publish(0, "cc/ui/config/map", mapData.length(), 

        mapData.c_str()); 

} 

对于此有效负载字符串,我们返回应存在于本地文件夹中的地图图像的二进制数据。该地图包含我们管理的建筑物的布局,用于在工具中显示。

         else if (payload == "nodes") { 
               Data::Statement countQuery(*session); 
               int rowCount; 
               countQuery << "SELECT COUNT(*) FROM nodes", 
                     into(rowCount), 
                     now; 

               if (rowCount == 0) { 
                     cout << "No nodes found in database, returning...\n"; 
                     return; 
               } 

               Data::Statement select(*session); 
               Node node; 
               select << "SELECT uid, location, modules, posx, posy FROM nodes", 
                           into (node.uid), 
                           into (node.location), 
                           into (node.modules), 
                           into (node.posx), 
                           into (node.posy), 
                           range(0, 1); 

               string header; 
               string nodes; 
               string nodeStr; 
               UInt32 nodeCount = 0; 
               while (!select.done()) { 
                     select.execute(); 
                     nodeStr = "NODE"; 
                     UInt8 length = (UInt8) node.uid.length(); 
                     nodeStr += string((char*) &length, 1); 
                     nodeStr += node.uid; 
                     length = (UInt8) node.location.length(); 
                     nodeStr += string((char*) &length, 1); 
                     nodeStr += node.location; 
                     nodeStr += string((char*) &node.posx, 4); 
                     nodeStr += string((char*) &node.posy, 4); 
                     nodeStr += string((char*) &node.modules, 4); 
                     UInt32 segSize = nodeStr.length(); 

                     nodes += string((char*) &segSize, 4); 
                     nodes += nodeStr; 
                     ++nodeCount; 
               } 

               UInt64 messageSize = nodes.length() + 9; 
               header = string((char*) &messageSize, 8); 
               header += "NODES"; 
               header += string((char*) &nodeCount, 4); 
               header += nodes; 

               publish(0, "cc/nodes/all", header.length(), header.c_str()); 
         } 
   } 

前面的部分读取数据库中的每个节点,并以二进制序列化格式返回。

接下来,我们创建一个新节点并将其添加到数据库中:

   else if (topic == "cc/nodes/new") { 
         UInt32 index = 0; 
         UInt32 msgLength = *((UInt32*) payload.substr(index, 4).data()); 
         index += 4; 
         string signature = payload.substr(index, 4); 
         index += 4; 

         if (signature != "NODE") { 
               cerr << "Invalid node signature.\n"; 
               return; 
         } 

         UInt8 uidLength = (UInt8) payload[index++]; 
         Node node; 
         node.uid = payload.substr(index, uidLength); 
         index += uidLength; 
         UInt8 locationLength = (UInt8) payload[index++]; 
         node.location = payload.substr(index, locationLength); 
         index += locationLength; 
         node.posx = *((float*) payload.substr(index, 4).data()); 
         index += 4; 
         node.posy = *((float*) payload.substr(index, 4).data()); 
         index += 4; 
         node.modules = *((UInt32*) payload.substr(index, 4).data()); 

         cout << "Storing new node for UID: " << node.uid << "\n"; 

         Data::Statement insert(*session); 
         insert << "INSERT INTO nodes VALUES(?, ?, ?, ?, ?)", 
                     use(node.uid), 
                     use(node.location), 
                     use(node.modules), 
                     use(node.posx), 
                     use(node.posy), 
                     now; 

         (*session) << "INSERT INTO firmware VALUES(?, ?)", 
                     use(node.uid), 
                     use(defaultFirmware), 
                     now; 
   } 

还可以更新节点的配置:

   else if (topic == "cc/nodes/update") { 
         UInt32 index = 0; 
         UInt32 msgLength = *((UInt32*) payload.substr(index, 4).data()); 
         index += 4; 
         string signature = payload.substr(index, 4); 
         index += 4; 

         if (signature != "NODE") { 
               cerr << "Invalid node signature.\n"; 
               return; 
         } 

         UInt8 uidLength = (UInt8) payload[index++]; 
         Node node; 
         node.uid = payload.substr(index, uidLength); 
         index += uidLength; 
         UInt8 locationLength = (UInt8) payload[index++]; 
         node.location = payload.substr(index, locationLength); 
         index += locationLength; 
         node.posx = *((float*) payload.substr(index, 4).data()); 
         index += 4; 
         node.posy = *((float*) payload.substr(index, 4).data()); 
         index += 4; 
         node.modules = *((UInt32*) payload.substr(index, 4).data()); 

         cout << "Updating node for UID: " << node.uid << "\n"; 

         Data::Statement update(*session); 
         update << "UPDATE nodes SET location = ?, posx = ?, posy = ?, modules = ? WHERE uid = ?", 
                     use(node.location), 
                     use(node.posx), 
                     use(node.posy), 
                     use(node.modules), 
                     use(node.uid), 
                     now; 
   } 

接下来,我们来看一下删除节点配置的主题处理程序:

   else if (topic == "cc/nodes/delete") { 
         cout << "Deleting node with UID: " << payload << "\n"; 

         Data::Statement del(*session); 
         del << "DELETE FROM nodes WHERE uid = ?", 
                     use(payload), 
                     now; 

         (*session) << "DELETE FROM firmware WHERE uid = ?", 
                     use(payload), 
                     now; 
   } 

当我们之前查看固件的 CO[2]模块时,我们发现它生成了 CO[2]事件。这些事件也会在这个示例中出现,以便以 JSON 格式生成事件,然后将其发送到一些基于 HTTP 的 API。然后,我们使用 POCO 中的 HTTPS 客户端将此 JSON 发送到远程服务器(此处设置为 localhost)。

   else if (topic == "nsa/events/CO2") { 
         StringTokenizer st(payload, ";", StringTokenizer::TOK_TRIM | StringTokenizer::TOK_IGNORE_EMPTY); 
         if (st.count() < 4) { 
               cerr << "CO2 event: Wrong number of arguments. Payload: " << payload << "\n"; 
               return; 
         } 

         string state = "ok"; 
         if (st[1] == "1") { state = "warn"; } 
         else if (st[1] == "2") { state = "crit"; } 
         string increase = (st[2] == "1") ? "true" : "false"; 
         string json = "{ \"state\": \"" + state + "\", \ 
                                 \"location\": \"" + st[0] + "\", \ 
                                 \"increase\": " + increase + ", \ 
                                 \"ppm\": " + st[3] + " }"; 

         Net::HTTPSClientSession httpsClient("localhost"); 
         try { 
               Net::HTTPRequest request(Net::HTTPRequest::HTTP_POST, 
                                                   "/", 
                                                   Net::HTTPMessage::HTTP_1_1); 
               request.setContentLength(json.length()); 
               request.setContentType("application/json"); 
               httpsClient.sendRequest(request) << json; 

               Net::HTTPResponse response; 
               httpsClient.receiveResponse(response); 
         } 
         catch (Exception& exc) { 
               cout << "Exception caught while attempting to connect." << std::endl; 
               cerr << exc.displayText() << std::endl; 
               return; 
         } 
   } 

最后,为了管理存储的固件映像,我们可以使用以下主题。每个节点的配置中可以设置节点使用哪个固件版本,尽管如前所述,默认情况是使用最新的固件。

使用这个主题,我们可以列出可用的固件映像或上传新的固件:

   else if (topic == "cc/firmware") { 
         if (payload == "list") { 
               std::vector<File> files; 
               File file("firmware"); 
               if (!file.isDirectory()) { return; } 

               file.list(files); 
               string out; 
               for (int i = 0; i < files.size(); ++i) { 
                     if (files[i].isFile()) { 
                           out += files[i].path(); 
                           out += ";"; 
                     } 
               } 

               out.pop_back(); 

               publish(0, "cc/firmware/list", out.length(), out.c_str()); 
         } 
         else { 
               StringTokenizer st(payload, ";", StringTokenizer::TOK_TRIM | StringTokenizer::TOK_IGNORE_EMPTY); 

               if (st[0] == "change") { 
                     if (st.count() != 3) { return; } 
                     (*session) << "UPDATE firmware SET file = ? WHERE uid = ?", 
                                             use (st[1]), 
                                             use (st[2]), 
                                             now; 
               } 
               else if (st[0] == "upload") { 
                     if (st.count() != 3) { return; } 

                     // Write file & truncate if exists. 
                     string filepath = "firmware/" + st[1];                       
                     ofstream outfile("firmware/" + st[1], ofstream::binary | ofstream::trunc); 
                     outfile.write(st[2].data(), st[2].size()); 
                     outfile.close(); 
               } 
         } 
   } 
} 
void Listener::on_subscribe(int mid, int qos_count, const int* granted_qos) { 
   // 
} 

在每次成功的 MQTT 主题订阅时,将调用此方法,允许我们在需要时执行其他操作。

接下来,我们来看一下 HTTP 服务器组件,从 HTTP 请求处理程序工厂开始:

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

using namespace Poco::Net; 

#include "datahandler.h" 

class RequestHandlerFactory: public HTTPRequestHandlerFactory { 
public: 
   RequestHandlerFactory() {} 
   HTTPRequestHandler* createRequestHandler(const HTTPServerRequest& request) { 
         return new DataHandler(); 
   } 
}; 

这个处理程序将始终返回以下类的实例:

#include <iostream> 
#include <vector> 

using namespace std; 

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

#include <Poco/Data/Session.h> 
#include <Poco/Data/SQLite/Connector.h> 

using namespace Poco::Data::Keywords; 

using namespace Poco::Net; 
using namespace Poco; 

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

         URI uri(request.getURI()); 
         string path = uri.getPath(); 
         if (path != "/") { 
               response.setStatus(HTTPResponse::HTTP_NOT_FOUND); 
               ostream& ostr = response.send(); 
               ostr << "File Not Found: " << path; 
               return; 
         } 

         URI::QueryParameters parts; 
         parts = uri.getQueryParameters(); 
         if (parts.size() > 0 && parts[0].first == "uid") { 
               Data::SQLite::Connector::registerConnector(); 
               Data::Session* session = new Poco::Data::Session("SQLite", "nodes.db"); 

               Data::Statement select(*session); 
               string filename; 
               select << "SELECT file FROM firmware WHERE uid=?", 
                                 into (filename), 
                                 use (parts[0].second); 

               size_t rows = select.execute(); 

               if (rows != 1) { 
                     response.setStatus(HTTPResponse::HTTP_NOT_FOUND); 
                     ostream& ostr = response.send(); 
                     ostr << "File Not Found: " << parts[0].second; 
                     return; 
               } 

               string fileroot = "firmware/"; 
               File file(fileroot + filename); 

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

               string mime = "application/octet-stream"; 
               try { 
                     response.sendFile(file.path(), mime); 
               } 
               catch (FileNotFoundException &e) { 
                     cout << "File not found exception triggered..." << endl; 
                     cerr << e.displayText() << endl; 

                     response.setStatus(HTTPResponse::HTTP_NOT_FOUND); 
                     ostream& ostr = response.send(); 
                     ostr << "File Not Found."; 
                     return; 
               } 
               catch (OpenFileException &e) { 
                     cout << "Open file exception triggered..." << endl; 
                     cerr << e.displayText() << endl; 

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

这个类看起来相当令人印象深刻,但大部分只是对节点 ID(MAC 地址)进行 SQLite 数据库查找,并在找到时返回适当的固件映像。

管理工具

使用 C&C 服务器实现的 API,使用 Qt5 框架创建了基于 GUI 的管理工具,并开发了 Mosquitto MQTT 客户端库,允许对节点进行基本管理。它们被叠加在建筑物的布局图上。

尽管基本上可用,但发现开发图形工具相当复杂。除非有一个包含所有楼层的大地图,并将节点映射到该地图上,否则它也仅限于建筑物的单个楼层。显然,这将非常笨拙。

在本章提供的源代码中,还可以找到管理工具,作为如何实现的示例。为了简洁起见,这里省略了其代码。

空调服务

为了控制空调设备,开发了一个类似 C&C 的服务,使用相同的基本模板。其源代码的有趣部分如下:

#include <string>
 #include <vector>

 using namespace std;

 #include <Poco/Data/Session.h>
 #include <Poco/Data/SQLite/Connector.h>

 #include <Poco/Net/HTTPClientSession.h>
 #include <Poco/Net/HTTPSClientSession.h>

 #include <Poco/Timer.h>

 using namespace Poco;
 using namespace Poco::Net;

 class Listener;

 struct NodeInfo {
    string uid;
    float posx;
    float posy;
    float current;    
    float target;
    bool ch0_state;
    UInt8 ch0_duty;
    bool ch0_valid;
    bool ch1_state;
    UInt8 ch1_duty;
    bool ch1_valid;
    bool ch2_state;
    UInt8 ch2_duty;
    bool ch2_valid;
    bool ch3_state;
    UInt8 ch3_duty;
    bool ch3_valid;
    UInt8 validate;
 };

 struct ValveInfo {
    string uid;
    UInt8 ch0_valve;
    UInt8 ch1_valve;
    UInt8 ch2_valve;
    UInt8 ch3_valve;
 };

 struct SwitchInfo {
    string uid;
    bool state;
 };

 #include "listener.h"

 class Nodes {
    static Data::Session* session;
    static bool initialized;
    static HTTPClientSession* influxClient;
    static string influxDb;
    static bool secure;
    static Listener* listener;
    static Timer* tempTimer;
    static Timer* nodesTimer;
    static Timer* switchTimer;
    static Nodes* selfRef;

 public:
    static void init(string influxHost, int influxPort, string influxDb, string influx_sec, Listener* listener);
    static void stop();
    static bool getNodeInfo(string uid, NodeInfo &info);
    static bool getValveInfo(string uid, ValveInfo &info);
    static bool getSwitchInfo(string uid, SwitchInfo &info);
    static bool setTargetTemperature(string uid, float temp);
    static bool setCurrentTemperature(string uid, float temp);
    static bool setDuty(string uid, UInt8 ch0, UInt8 ch1, UInt8 ch2, UInt8 ch3);
    static bool setValves(string uid, bool ch0, bool ch1, bool ch2, bool ch3);
    static bool setSwitch(string uid, bool state);
    void updateCurrentTemperatures(Timer& timer);
    void checkNodes(Timer& timer);

    void checkSwitch(Timer& timer);
    static bool getUIDs(vector<string> &uids);
    static bool getSwitchUIDs(vector<string> &uids);
 };

AC 服务中对该类的定义很好地概述了该类的功能。它基本上是围绕一个 SQLite 数据库的封装,包含有关节点、阀门和冷却/加热开关的信息。它还包含将持续触发应用程序以检查系统状态、将其与目标状态进行比较并在必要时进行调整的定时器。

这个类被这个应用程序的Listener类广泛使用,用于跟踪节点和连接的空调单元的状态,以及控制水流的开关和阀门:

#include <mosquittopp.h>

#include <string>
#include <map>

using namespace std;

#include <Poco/Mutex.h>

using namespace Poco;

struct NodeInfo;
struct ValveInfo;
struct SwitchInfo;

 #include "nodes.h"

 class Listener : public mosqpp::mosquittopp {
    map<string, NodeInfo> nodes;
    map<string, ValveInfo> valves;
    map<string, SwitchInfo> switches;
    Mutex nodesLock;
    Mutex valvesLock;
    Mutex switchesLock;
    bool heating;
    Mutex heatingLock;

 public:
    Listener(string clientId, string host, int port);
    ~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);
    bool checkNodes();
    bool checkSwitch();
 };

这个应用程序的工作方式是,Nodes类的定时器将导致Listener类发布 PWM、IO 和 Switch 模块的主题,查询应该处于活动状态的设备的状态。

这种类型的主动循环系统在工业应用中很常见,因为它提供了对系统的持续验证,以快速检测是否有任何不按预期工作的情况。

用于记录传感器读数的 InfluxDB

从一开始,记录传感器读数以及后来从咖啡机读取的统计数据就是一个优先任务。这种数据的理想数据库是时间序列数据库,Influx 是其中一个常见的数据库。这个数据库的最大问题是它不支持 MQTT,只提供其 HTTP 和本地接口。

为了解决这个问题,编写了一个简单的 MQTT 到 Influx HTTP 线协议桥,再次使用 Mosquitto 客户端库以及 POCO 框架的 HTTP 功能:

#include "mth.h"

#include <iostream>

using namespace std;

#include <Poco/Net/HTTPRequest.h>
#include <Poco/Net/HTTPResponse.h>
#include <Poco/StringTokenizer.h>
#include <Poco/String.h>

using namespace Poco;

MtH::MtH(string clientId, string host, int port, string topics, string influxHost, 
                int influxPort, string influxDb, string influx_sec) : mosquittopp(clientId.c_str()) {
    this->topics  = topics;
    this->influxDb = influxDb;
    if (influx_sec == "true") { 
          cout << "Connecting with HTTPS..." << std::endl;
          influxClient = new Net::HTTPSClientSession(influxHost, influxPort);
          secure = true; 
    } 
    else {
          cout << "Connecting with HTTP..." << std::endl;
          influxClient = new Net::HTTPClientSession(influxHost, influxPort);
          secure = false; 
    }

    int keepalive = 60;
    connect(host.c_str(), port, keepalive);
 }

在构造函数中,我们连接到 MQTT 代理,并创建一个 HTTP 或 HTTPS 客户端,具体取决于配置文件中设置的协议:


 MtH::~MtH() {
    delete influxClient;
 }

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

    if (rc == 0) {
          StringTokenizer st(topics, ",", StringTokenizer::TOK_TRIM | StringTokenizer::TOK_IGNORE_EMPTY);
          for (StringTokenizer::Iterator it = st.begin(); it != st.end(); ++it) {
                string topic = string(*it);
                cout << "Subscribing to: " << topic << "\n";
                subscribe(0, topic.c_str());

                // Add name of the series to the 'series' map.
                StringTokenizer st1(topic, "/", StringTokenizer::TOK_TRIM | StringTokenizer::TOK_IGNORE_EMPTY);
                string s = st1[st1.count() - 1]; // Get last item.
                series.insert(std::pair<string, string>(topic, s));
          }
    }
    else {
          cerr << "Connection failed. Aborting subscribing.\n";
    }
 }

我们不再使用固定的 MQTT 主题进行订阅,而是使用在配置文件中定义的主题,这里以单个字符串的形式提供给我们,每个主题之间用逗号分隔。

我们还创建一个 STL 映射,其中包含要记录的主题的时间序列的名称,取自最后一个斜杠后的 MQTT 主题的最后部分。这可以进一步配置,但对于 BMaC 系统中使用的主题来说,这个限制并不是一个考虑,因为没有必要使用更复杂的主题。

void MtH::on_message(const struct mosquitto_message* message) {
    string topic = message->topic;      
    map<string, string>::iterator it = series.find(topic);
    if (it == series.end()) { 
          cerr << "Topic not found: " << topic << "\n";
          return; 
    }

    if (message->payloadlen < 1) {
          cerr << "No payload found. Returning...\n";
          return;
    }

    string payload = string((const char*) message->payload, message-
    >payloadlen);
    size_t pos = payload.find(";");
    if (pos == string::npos || pos == 0) {
        cerr << "Invalid payload: " << payload << ". Reject.\n";
        return;
    }

    string uid = payload.substr(0, pos);
    string value = payload.substr(pos + 1);
    string influxMsg; 
    influxMsg = series[topic];
    influxMsg += ",location=" + uid;
    influxMsg += " value=" + value;
    try {
        Net::HTTPRequest request(Net::HTTPRequest::HTTP_POST, 
        "/write?db=" + influxDb, Net::HTTPMessage::HTTP_1_1);
        request.setContentLength(influxMsg.length());
        request.setContentType("application/x-www-form-urlencoded");
        influxClient->sendRequest(request) << influxMsg;

        Net::HTTPResponse response;
        influxClient->receiveResponse(response);
    }
    catch (Exception& exc) {
        cout << "Exception caught while attempting to connect." << 
        std::endl;
        cerr << exc.displayText() << std::endl;
        return;
    }

当我们收到一个新的 MQTT 消息时,我们找到它对应的 Influx 时间序列的名称,然后创建一个字符串发送到 InfluxDB 服务器。这里的假设是负载包括发送消息的节点的 MAC 地址,后跟一个分号。

我们只需获取分号后的部分设置为值,并使用 MAC 作为位置。然后我们将其发送到数据库服务器。

安全方面

在开发这个系统的过程中,很快就显而易见的是安全将是系统的一个重要方面。因此,我们考虑添加传输层安全(TLS)加密。这将使用 Sming 框架中集成的 axTLS 加密库,以及 AES 证书(主机和客户端)来提供主机(服务器)和客户端(节点)是他们所说的人的验证,同时提供一个安全的加密链接。

在第五章中,示例-带 Wi-Fi 的土壤湿度监测器,我们已经看到了如何处理这些客户端证书并建立加密的 MQTT 连接。从中并不明显的一个细节是我们在设置这个证书系统时遇到的麻烦。正如在第五章中提到的,示例-带 Wi-Fi 的土壤湿度监测器,ESP8266 没有足够的内存来分配默认的 TLS 握手缓冲区,并且需要在服务器(主机)端使用 SSL 片段大小扩展。

不幸的是,我们发现我们使用的常用 MQTT 代理(Mosquitto)不支持这个 SSL 扩展,因此需要客户端使用默认的双 16 kB 缓冲区。解决这个问题的第一个方法是在对其源代码进行一些更改后重新编译 Mosquitto 代理以更改此设置。

更好的解决方案,也是我们最终实施的解决方案,是安装一个代理软件(HAProxy),它作为 TLS 端点,处理证书并通过本地回环(localhost)接口将解密的流量重定向到 MQTT 代理。

将 SSL 片段大小选项设置为 1-2 kB 后,一切都按预期工作,我们拥有了一个覆盖整栋建筑的无线监控和控制系统,可以安全地传输敏感信息和精密的控制命令。

未来发展

这个系统仍然可以进行许多补充。从可以支持的传感器数量,进一步的 GPIO 扩展芯片,空调系统配置,与日历后端连接的房间占用检测,到清除在没有人出现的办公室预定会议等等。

还有一个选择,从 ESP8266 作为 MCU 切换到其他 MCU,比如基于 ARM 的 MCU,以获得有线以太网选项,以及更好的调试和开发工具。虽然将 MCU 与 Wi-Fi 结合在一起非常方便,可以随处粘贴并在理论上使其工作,但 ESP8266 的开发工具并不是那么好,而且缺乏有线通信选项(不使用外部芯片)意味着一切要么工作要么不工作,取决于 Wi-Fi 网络的质量。

由于 BMaC 涉及建筑物的自动化,因此希望具有一定的可靠性,而通过 Wi-Fi 网络很难保证这一点,尽管对于较不重要的组件(咖啡机统计数据、传感器读数等),这可能不是一个问题。可以想象未来可能会出现既有有线选项又有无线选项的混合网络。

总结

在本章中,我们看到了如何开发了一个建筑物范围的监控和管理系统,它的组件是什么样的,以及在开发过程中学到了什么教训。

读者现在应该理解这样一个大规模嵌入式系统是如何构建和运行的,并且应该能够自己使用 BMaC 系统或实施类似的系统。

在下一章中,我们将介绍使用 Qt 框架开发嵌入式项目。

第三部分:与其他工具和框架集成

在学习如何开发和测试嵌入式系统之后,您可以在本部分学习如何开发高级图形用户界面,以及如何为混合 FPGA/SoC 平台开发。

本节将涵盖以下章节:

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

第十一章,为混合 SoC/FPGA 系统开发

第十章:使用 Qt 开发嵌入式系统

Qt(发音为 cute)是一个基于先进的 C++框架,涵盖了各种 API,允许您实现网络、图形用户界面、数据格式的解析、音频的播放和录制等。本章主要涵盖了 Qt 的图形方面,以及如何为嵌入式设备创建高级 GUI,为用户提供吸引人和功能齐全的 UI。

本章涵盖的主题如下:

  • 使用 Qt 为嵌入式系统创建高级 GUI

  • 使用 Qt 的 3D 设计师创建信息娱乐 UI

  • 通过 GUI 扩展现有的嵌入式系统

正确框架的力量

框架本质上是一组旨在简化特定应用程序开发的代码集合。它为开发人员提供了一系列类或语言等效物,允许您实现应用程序逻辑,而无需担心与底层硬件的接口,或使用操作系统的 API。

在之前的章节中,我们使用了许多框架来简化开发工作,从 No date 框架(第四章,资源受限的嵌入式系统)和 CMSIS 到 Arduino 用于微控制器(MCUs),以及从低级 POCO 框架用于跨平台开发到更高级别的 Qt 框架。

每个框架都有特定类型的系统作为目标。对于 No date、CMSIS 和 Arduino,目标是从 8 位 AVR MCU 到 32 位 ARM MCU 的 MCUs。它们针对裸机系统,没有任何中间操作系统或类似的东西。在复杂性方面,我们还有包括完整操作系统的实时 OS 框架。

诸如 POCO 和 Qt 之类的框架通常针对各种操作系统,从桌面和服务器平台到 SoC 平台。在这里,它们主要作为操作系统特定 API 之间的抽象层,同时在这个抽象层之外提供额外的功能。这使您能够快速构建一个功能齐全的应用程序,而无需在每个功能上花费太多时间。

这对于网络功能特别重要,您不希望从头开始编写基于 TCP 套接字的服务器,而理想情况下只是想实例化一个现成的类并使用它。在 Qt 的情况下,它还提供了与图形用户界面相关的 API,以使跨平台 GUI 的开发更容易。其他提供这种功能的框架还包括 GTK+和 WxWidgets。然而,在本章中,我们将只关注使用 Qt 进行开发。

在第八章,示例-基于 Linux 的信息娱乐系统中,我们深入了解了如何使用 Qt 框架进行开发。在那里,我们大多忽略了图形用户界面(GUI)部分,尽管这可能是相对于其他基于操作系统的框架最有趣的部分。能够在多个操作系统上使用相同的 GUI 可能非常有用和方便。

这在大多数基于桌面的应用程序中都是如此,其中 GUI 是应用程序的关键部分,因此不必花费时间和精力在不同操作系统之间进行移植是一个重要的时间节省者。对于嵌入式平台,这也是真的,尽管在这里,您可以选择将其集成得比在桌面系统上更深入,正如我们将在下一刻看到的。

我们还将看一下您可以开发的各种类型的 Qt 应用程序,从简单的命令行界面(CLI)应用程序开始。

用于命令行的 Qt

尽管 Qt 框架的图形用户界面是一个重要的卖点,但也可以用它来开发仅限于命令行的应用程序。为此,我们只需使用QCoreApplication类来创建输入和事件循环处理程序,就像这个例子中一样:

#include <QCoreApplication> 
#include <core.h> 

int main(int argc, char *argv[]) { 
   QCoreApplication app(argc, argv); 
   Core core; 

   connect(&core, &Core::done, &app, &app::quit, Qt::QueuedConnection); 
   core.start(); 

   return app.exec(); 
} 

在这里,我们的代码是在一个名为Core的类中实现的。在主函数中,我们创建了一个QCoreApplication实例,该实例接收命令行参数。然后我们实例化了我们类的一个实例。

我们将我们类的信号连接到QCoreApplication实例,这样如果我们发出完成的信号,它将触发后者上的槽来清理和终止应用程序。

之后,我们调用我们类的方法来启动其功能,并最终通过在QCoreApplication实例上调用exec()来启动事件循环。在这一点上,我们可以使用信号。

请注意,这里也可以使用 Qt4 风格的连接语法,而不是之前的 Qt5 风格:

connect(core, SIGNAL(done()), &app, SLOT(quit()), Qt::QueuedConnection); 

从功能上讲,这没有任何区别,对于大多数情况来说,使用任何一种都可以。

我们的类如下所示:

#include <QObject> 

class Core : public QObject { 
   Q_OBJECT 
public: 
   explicit Core(QObject *parent = 0); 

signals: 
   void done(); 
public slots: 
   void start(); 
}; 

在 Qt-based 应用程序中,想要使用 Qt 的信号槽架构的每个类都需要派生自QObject类,并在类声明中包含Q_OBJECT宏。这对于 Qt 的qmake 预处理器工具在应用程序代码被工具链编译之前知道要处理哪些类是必需的。

这是实现:

#include "core.h" 
#include <iostream> 

Core::Core(QObject *parent) : QObject(parent) { 
   // 
} 

void hang::start() { 
   std::cout << "Start emitting done()" << std::endl; 
   emit done(); 
} 

值得注意的是,我们可以让任何 QObject 派生类的构造函数知道封装父类是什么,从而允许父类拥有这些子类,并在自身被销毁时调用它们的析构函数。

基于 GUI 的 Qt 应用程序

回到第八章中基于 Qt 的示例项目,示例-Linux 基础信息娱乐系统,我们现在可以比较其主函数和之前的仅限命令行版本,看看在向项目添加 GUI 后会发生什么变化:

#include "mainwindow.h" 
#include <QApplication> 

int main(int argc, char *argv[]) { 
    QApplication a(argc, argv); 
    MainWindow w; 
    w.show(); 

    return a.exec(); 
} 

这里最明显的变化是我们使用了QApplication而不是QCoreApplication。另一个重大变化是我们不再使用完全自定义的类,而是从QMainWindow派生的类:

#include <QMainWindow> 

#include <QAudioRecorder> 
#include <QAudioProbe> 
#include <QMediaPlayer> 

namespace Ui { 
    class MainWindow; 
} 

class MainWindow : public QMainWindow { 
    Q_OBJECT 

public: 
    explicit MainWindow(QWidget *parent = nullptr); 
    ~MainWindow(); 

public slots: 
    void playBluetooth(); 
    void stopBluetooth(); 
    void playOnlineStream(); 
    void stopOnlineStream(); 
    void playLocalFile(); 
    void stopLocalFile(); 
    void recordMessage(); 
    void playMessage(); 

    void errorString(QString err); 

    void quit(); 

private: 
    Ui::MainWindow *ui; 

    QMediaPlayer* player; 
    QAudioRecorder* audioRecorder; 
    QAudioProbe* audioProbe; 

    qint64 silence; 

private slots: 
    void processBuffer(QAudioBuffer); 
}; 

在这里,我们可以看到MainWindow类确实是从QMainWindow派生出来的,这也赋予了它show()方法。值得注意的是MainWindow实例在 UI 命名空间中声明。这与我们在运行 qmake 工具时生成的自动生成的代码相关联,我们马上就会看到。接下来是构造函数:

MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), 
    ui(new Ui::MainWindow) { 
    ui->setupUi(this); 

这里需要注意的第一件事是我们如何从 UI 描述文件中填充 GUI。这个文件通常是通过使用 Qt Creator IDE 中的 Qt Designer 工具直观地布局 GUI 而创建的。这个 UI 文件包含了每个小部件的属性描述,以及应用于它们的布局等等。

当然,也可以以编程方式创建这些小部件并将它们添加到布局中。然而,对于更大的布局来说,这变得相当乏味。通常,您为主窗口创建一个单独的 UI 文件,并为每个子窗口和对话框创建一个额外的 UI 文件。然后可以以类似的方式将它们填充到窗口或对话框中。

    connect(ui->actionQuit, SIGNAL(triggered()), this, SLOT(quit())); 

GUI 中的菜单操作通过指定菜单操作(QAction实例)上的特定信号与内部槽相连接。我们可以在这里看到它们在ui对象中,这个对象在 UI 文件的自动生成源代码中可以找到,正如我们之前提到的:

    connect(ui->playBluetoothButton, SIGNAL(pressed), this, SLOT(playBluetooth)); 
    connect(ui->stopBluetoothButton, SIGNAL(pressed), this, SLOT(stopBluetooth)); 
    connect(ui->playLocalAudioButton, SIGNAL(pressed), this, SLOT(playLocalFile)); 
    connect(ui->stopLocalAudioButton, SIGNAL(pressed), this, SLOT(stopLocalFile)); 
    connect(ui->playOnlineStreamButton, SIGNAL(pressed), this, SLOT(playOnlineStream)); 
    connect(ui->stopOnlineStreamButton, SIGNAL(pressed), this, SLOT(stopOnlineStream)); 
    connect(ui->recordMessageButton, SIGNAL(pressed), this, SLOT(recordMessage)); 
    connect(ui->playBackMessage, SIGNAL(pressed), this, SLOT(playMessage)); 

GUI 中的按钮小部件以类似的方式连接,尽管它们当然会因为它们是不同类型的小部件而发出不同的信号:

    silence = 0; 

    // Create the audio interface instances. 
    player = new QMediaPlayer(this); 
    audioRecorder = new QAudioRecorder(this); 
    audioProbe = new QAudioProbe(this); 

    // Configure the audio recorder. 
    QAudioEncoderSettings audioSettings; 
    audioSettings.setCodec("audio/amr"); 
    audioSettings.setQuality(QMultimedia::HighQuality);     
    audioRecorder->setEncodingSettings(audioSettings);     
    audioRecorder->setOutputLocation(QUrl::fromLocalFile("message/last_message.amr")); 

    // Configure audio probe. 
    connect(audioProbe, SIGNAL(audioBufferProbed(QAudioBuffer)), this, SLOT(processBuffer(QAudioBuffer))); 
    audioProbe→setSource(audioRecorder); 

我们可以在这里做任何其他构造函数中会做的事情,包括设置默认值和创建我们以后需要的类的实例:

    QThread* thread = new QThread; 
    VoiceInput* vi = new VoiceInput(); 
    vi->moveToThread(thread); 
    connect(thread, SIGNAL(started()), vi, SLOT(run())); 
    connect(vi, SIGNAL(finished()), thread, SLOT(quit())); 
    connect(vi, SIGNAL(finished()), vi, SLOT(deleteLater())); 
    connect(thread, SIGNAL(finished()), thread, SLOT(deleteLater())); 

    connect(vi, SIGNAL(error(QString)), this, SLOT(errorString(QString))); 
    connect(vi, SIGNAL(playBluetooth), this, SLOT(playBluetooth)); 
    connect(vi, SIGNAL(stopBluetooth), this, SLOT(stopBluetooth)); 
    connect(vi, SIGNAL(playLocal), this, SLOT(playLocalFile)); 
    connect(vi, SIGNAL(stopLocal), this, SLOT(stopLocalFile)); 
    connect(vi, SIGNAL(playRemote), this, SLOT(playOnlineStream)); 
    connect(vi, SIGNAL(stopRemote), this, SLOT(stopOnlineStream)); 
    connect(vi, SIGNAL(recordMessage), this, SLOT(recordMessage)); 
    connect(vi, SIGNAL(playMessage), this, SLOT(playMessage)); 

    thread->start(); 
} 

这里需要记住的一件关键的事情是这个类在 UI 线程上运行,这意味着我们不应该在这里做任何繁重的工作。这就是为什么我们将这样的类实例移到它们自己的线程中,就像这样:

MainWindow::~MainWindow() { 
    delete ui; 
} 

在构造函数中,我们删除 UI 和所有相关元素。

嵌入式 Qt

Qt 框架的一个主要目标是桌面系统之外的嵌入式系统,特别是嵌入式 Linux,在那里有几种不同的使用 Q 的方式。嵌入式 Qt 的主要目的是通过允许您直接启动到优化的 Qt 环境中来优化软件库,并允许多种方式渲染到显示器。

Qt for Embedded Linux 支持以下用于渲染的平台插件:

插件 描述
EGLFS 提供对 OpenGL ES 或类似的 3D 渲染 API 的接口。通常是嵌入式 Linux 的默认配置。有关 EGL 的更多详细信息,请访问以下网址:www.khronos.org/egl.
LinuxFB 通过 Linux 的 fbdev 子系统直接写入帧缓冲。仅支持软件渲染内容。因此,在某些设置上,显示性能可能会受到限制。
DirectFB 使用 DirectFB 库直接写入图形卡的帧缓冲。
Wayland 使用 Wayland 窗口系统。这允许多个并发窗口,但当然对硬件要求更高。

除此之外,Qt for Embedded Linux 还配备了各种 API,用于处理触摸和笔输入等。为了优化基于 Qt 的应用程序的系统,通常会删除任何不相关的服务、进程和库,从而使系统在几秒钟内启动到嵌入式应用程序中。

使用样式表的自定义 GUI

桌面系统通常使用的标准基于小部件的 GUI 不太容易定制。因此,通常要么需要覆盖QWidget实例中的绘图函数并处理小部件绘制的每个像素,要么使用基于样式表的定制。

Qt 样式表允许您动态地调整单个小部件的外观和感觉。它们基本上是使用与 HTML 页面一样的层叠样式表CSS)语法编写的。它们允许您更改小部件的元素,如边框、圆角、或元素的厚度和颜色。

QML

Qt 建模语言QML)是一种用户界面标记语言。它基于 JavaScript,并且甚至使用内联 JavaScript。它可以用于创建动态和完全定制的用户界面,并通常与 Qt Quick 模块一起使用。

在本章的后面,我们将深入研究如何创建动态 GUI。

3D 设计师

使用 Qt 5 引入了 Qt 3D 模块,它简化了对 OpenGL 渲染 API 的访问。这个新模块被用作 Qt 3D Designer 编辑器和相关运行时的基础。它可以用于创建高度动态的 GUI,具有 2D 和 3D 元素的组合。

它与手工制作的基于 QML 的 GUI 非常相似,但提供了更简化的工作流程,易于添加动画,并预览项目。它类似于 Qt Designer Studio,后者更专注于 2D GUI,但需要购买许可证,不免费提供。

向信息娱乐系统添加 GUI 的示例

在这个例子中,我们将使用 C++、Qt 和 QML 来创建一个图形用户界面,能够显示当前播放的音轨,执行音频可视化,指示播放进度,并允许您使用屏幕按钮切换不同的输入模式。

这个例子是基于 Qt 文档中的音频可视化器示例。它可以在 Qt 安装文件夹中找到(如果安装了示例),也可以在 Qt 网站上找到:doc.qt.io/qt-5/qt3d-audio-visualizer-qml-example.html.

这段代码与官方示例的主要区别在于,QMediaPlayer媒体播放器被移入了 C++代码中,还有其他一些函数。而在新的QmlInterface类中,QML UI 和 C++后端之间使用了一些信号和槽来处理按钮按下、更新 UI 和与媒体播放器的交互。

这样的 GUI 可以连接到现有的信息娱乐项目代码中,以控制其功能,使用 GUI 以及语音驱动界面。

在这个示例中,我们组合的 GUI 在操作时看起来是这样的:

主要

主要源文件如下所示:

#include "interface.h" 
#include <QtGui/QGuiApplication> 
#include <QtGui/QOpenGLContext> 
#include <QtQuick/QQuickView> 
#include <QtQuick/QQuickItem> 
#include <QtQml/QQmlContext> 
#include <QObject> 

int main(int argc, char* argv[]) { 
    QGuiApplication app(argc, argv); 

    QSurfaceFormat format; 
    if (QOpenGLContext::openGLModuleType() == QOpenGLContext::LibGL) { 
        format.setVersion(3, 2); 
        format.setProfile(QSurfaceFormat::CoreProfile); 
    } 

    format.setDepthBufferSize(24); 
    format.setStencilBufferSize(8); 

    QQuickView view; 
    view.setFormat(format); 
    view.create(); 

    QmlInterface qmlinterface; 
    view.rootContext()->setContextProperty("qmlinterface", &qmlinterface); 
    view.setSource(QUrl("qrc:/main.qml")); 

    qmlinterface.setPlaying(); 

    view.setResizeMode(QQuickView::SizeRootObjectToView); 
    view.setMaximumSize(QSize(1820, 1080)); 
    view.setMinimumSize(QSize(300, 150)); 
    view.show(); 

    return app.exec(); 
} 

我们的自定义类被添加到 QML 查看器(QQuickView)作为上下文类。这充当了 QML UI 和我们的 C++代码之间的代理,我们马上就会看到。查看器本身使用 OpenGL 表面来渲染 UI。

QmlInterface

我们自定义类的头部包含了许多添加,以使属性和方法对 QML 代码可见:

#include <QtCore/QObject> 
#include <QMediaPlayer> 
#include <QByteArray> 

class QmlInterface : public QObject { 
    Q_OBJECT     
    Q_PROPERTY(QString durationTotal READ getDurationTotal NOTIFY durationTotalChanged) 
    Q_PROPERTY(QString durationLeft READ getDurationLeft NOTIFY durationLeftChanged) 

Q_PROPERTY标签告诉 qmake 解析器,这个类包含一个属性(变量),应该对 QML 代码可见,参数指定变量的名称,用于读取和写入变量的方法(如果需要),最后是每当属性发生变化时发出的信号。

这允许设置自动更新功能,以保持此属性在 C++代码和 QML 端之间同步:


    QString formatDuration(qint64 milliseconds); 

    QMediaPlayer mediaPlayer; 
    QByteArray magnitudeArray; 
    const int millisecondsPerBar = 68; 
    QString durationTotal; 
    QString durationLeft; 
    qint64 trackDuration; 

public: 
    explicit QmlInterface(QObject *parent = nullptr); 

    Q_INVOKABLE bool isHoverEnabled() const; 
    Q_INVOKABLE void setPlaying(); 
   Q_INVOKABLE void setStopped(); 
   Q_INVOKABLE void setPaused(); 
    Q_INVOKABLE qint64 duration(); 
    Q_INVOKABLE qint64 position(); 
    Q_INVOKABLE double getNextAudioLevel(int offsetMs); 

    QString getDurationTotal() { return durationTotal; } 
    QString getDurationLeft() { return durationLeft; } 

public slots: 
    void mediaStatusChanged(QMediaPlayer::MediaStatus status); 
    void durationChanged(qint64 duration); 
    void positionChanged(qint64 position); 

signals: 
    void start(); 
    void stopped(); 
    void paused(); 
    void playing(); 
    void durationTotalChanged(); 
    void durationLeftChanged(); 
}; 

同样,Q_INVOKABLE标签确保这些方法对 QML 端可见,并且可以从那里调用。

这是实现:

#include "interface.h" 
#include <QtGui/QTouchDevice> 
#include <QDebug> 
#include <QFile> 
#include <QtMath> 

QmlInterface::QmlInterface(QObject *parent) : QObject(parent) { 
    // Set track for media player. 
    mediaPlayer.setMedia(QUrl("qrc:/music/tiltshifted_lost_neon_sun.mp3")); 

    // Load magnitude file for the audio track. 
    QFile magFile(":/music/visualization.raw", this); 
    magFile.open(QFile::ReadOnly); 
    magnitudeArray = magFile.readAll(); 

    // Media player connections. 
    connect(&mediaPlayer, SIGNAL(mediaStatusChanged(QMediaPlayer::MediaStatus)), this, SLOT(mediaStatusChanged(QMediaPlayer::MediaStatus))); 
    connect(&mediaPlayer, SIGNAL(durationChanged(qint64)), this, SLOT(durationChanged(qint64))); 
    connect(&mediaPlayer, SIGNAL(positionChanged(qint64)), this, SLOT(positionChanged(qint64))); 
} 

构造函数与原始示例项目有很大不同,这里创建了媒体播放器实例及其连接。

我们在这里加载了与原始项目中使用的相同音乐文件。将代码集成到信息娱乐项目或类似项目中时,您可以使其动态化。同样,我们在这里加载的用于获取音乐文件振幅的文件在完全集成时可能会被省略,而选择动态生成振幅值:

bool QmlInterface::isHoverEnabled() const { 
#if defined(Q_OS_IOS) || defined(Q_OS_ANDROID) || defined(Q_OS_QNX) || defined(Q_OS_WINRT) 
    return false; 
#else 
    bool isTouch = false; 
    foreach (const QTouchDevice *dev, QTouchDevice::devices()) { 
        if (dev->type() == QTouchDevice::TouchScreen) { 
            isTouch = true; 
            break; 
        } 
    } 

    bool isMobile = false; 
    if (qEnvironmentVariableIsSet("QT_QUICK_CONTROLS_MOBILE")) { 
        isMobile = true; 
    } 

    return !isTouch && !isMobile; 
#endif 
} 

这是以前存在于 QML 上下文类中的唯一方法。它用于检测代码是否在具有触摸屏的移动设备上运行:

void QmlInterface::setPlaying() { 
   mediaPlayer.play(); 
} 

void QmlInterface::setStopped() { 
   mediaPlayer.stop(); 
} 

void QmlInterface::setPaused() { 
   mediaPlayer.pause(); 
} 

我们有许多控制方法,连接到 UI 中的按钮,以允许控制媒体播放器实例:

void QmlInterface::mediaStatusChanged(QMediaPlayer::MediaStatus status) { 
    if (status == QMediaPlayer::EndOfMedia) { 
        emit stopped(); 
    } 
} 

这个槽方法用于检测媒体播放器是否已经到达了活动曲目的结尾,以便 UI 可以被通知应该更新以指示这一点:

void QmlInterface::durationChanged(qint64 duration) { 
    qDebug() << "Duration changed: " << duration; 

    durationTotal = formatDuration(duration); 
    durationLeft = "-" + durationTotal; 
    trackDuration = duration; 
    emit start(); 
    emit durationTotalChanged(); 
    emit durationLeftChanged(); 
} 

void QmlInterface::positionChanged(qint64 position) { 
    qDebug() << "Position changed: " << position; 
    durationLeft = "-" + formatDuration((trackDuration - position)); 
    emit durationLeftChanged(); 
} 

这两个槽方法连接到媒体播放器实例。持续时间槽是必需的,因为新加载的曲目的长度(持续时间)不会立即可用。相反,它是一个异步更新的属性。

因此,我们必须等到媒体播放器完成并发出信号,表明它已经完成了这个过程。

接下来,为了让我们能够更新当前曲目的剩余时间,我们还会不断地从媒体播放器获取当前位置的更新,这样我们就可以用新值更新 UI。

持续时间和位置属性都使用了我们在这个类的头文件描述中看到的链接方法在 UI 中进行更新。

最后,我们发出一个start()信号,它与 QML 代码中的一个槽连接,将启动可视化过程,我们稍后会在 QML 代码中看到:

qint64 QmlInterface::duration() { 
    qDebug() << "Returning duration value: " << mediaPlayer.duration(); 
    return mediaPlayer.duration(); 
} 

qint64 QmlInterface::position() { 
    qDebug() << "Returning position value: " << mediaPlayer.position(); 
    return mediaPlayer.position(); 
} 

持续时间属性也被可视化代码使用。在这里,我们允许直接获取它。同样,我们也使位置属性可用,可以直接调用:

double QmlInterface::getNextAudioLevel(int offsetMs) { 
    // Calculate the integer index position in to the magnitude array 
    qint64 index = ((mediaPlayer.position() + offsetMs) / millisecondsPerBar) | 0; 

    if (index < 0 || index >= (magnitudeArray.length() / 2)) { 
        return 0.0; 
    } 

    return (((quint16*) magnitudeArray.data())[index] / 63274.0); 
} 

这种方法是从原始项目的 JavaScript 代码移植过来的,执行的是根据之前从文件中读取的振幅数据来确定音频级别的相同任务:

QString QmlInterface::formatDuration(qint64 milliseconds) { 
    qint64 minutes = floor(milliseconds / 60000); 
    milliseconds -= minutes * 60000; 
    qint64 seconds = milliseconds / 1000; 
    seconds = round(seconds); 
    if (seconds < 10) { 
        return QString::number(minutes) + ":0" + QString::number(seconds); 
    } 
    else { 
        return QString::number(minutes) + ":" + QString::number(seconds); 
    } 
} 

同样,这个方法也是从原始项目的 JavaScript 代码移植过来的,因为我们将依赖于它的代码移入了 C++代码中。它接受曲目持续时间或位置的毫秒计数,并将其转换为包含分钟和秒的字符串,与原始值匹配。

QML

接下来,我们已经完成了 C++端的工作,现在可以看一下 QML UI 了。

首先,这是主要的 QML 文件:

import QtQuick 2.0 
import QtQuick.Scene3D 2.0 
import QtQuick.Layouts 1.2 
import QtMultimedia 5.0 

Item { 
    id: mainview 
    width: 1215 
    height: 720 
    visible: true 
    property bool isHoverEnabled: false 
    property int mediaLatencyOffset: 68 

QML 文件由一系列元素组成。在这里,我们定义了顶层元素,给它指定了尺寸和名称:

    state: "stopped" 
    states: [ 
        State { 
            name: "playing" 
            PropertyChanges { 
                target: playButtonImage 
                source: { 
                    if (playButtonMouseArea.containsMouse) 
                        "qrc:/images/pausehoverpressed.png" 
                    else 
                        "qrc:/images/pausenormal.png" 
                } 
            } 
            PropertyChanges { 
                target: stopButtonImage 
                source: "qrc:/images/stopnormal.png" 
            } 
        }, 
        State { 
            name: "paused" 
            PropertyChanges { 
                target: playButtonImage 
                source: { 
                    if (playButtonMouseArea.containsMouse) 
                        "qrc:/images/playhoverpressed.png" 
                    else 
                        "qrc:/images/playnormal.png" 
                } 
            } 
            PropertyChanges { 
                target: stopButtonImage 
                source: "qrc:/images/stopnormal.png" 
            } 
        }, 
        State { 
            name: "stopped" 
            PropertyChanges { 
                target: playButtonImage 
                source: "qrc:/images/playnormal.png" 
            } 
            PropertyChanges { 
                target: stopButtonImage 
                source: "qrc:/images/stopdisabled.png" 
            } 
        } 
    ]    

定义了 UI 的一些状态,以及应该触发的变化:

    Connections { 
        target: qmlinterface 
        onStopped: mainview.state = "stopped" 
        onPaused: mainview.state = "paused" 
        onPlaying: mainview.state = "started" 
        onStart: visualizer.startVisualization() 
    } 

这些是将 C++端的信号链接到本地处理程序的连接。我们将我们的自定义类作为这些信号的源,然后为我们希望处理的每个信号定义处理程序,通过为其添加前缀并添加应该执行的代码。

在这里,我们看到启动信号与一个处理程序链接,触发可视化模块中启动该模块的函数:

    Component.onCompleted: isHoverEnabled = qmlinterface.isHoverEnabled() 

    Image { 
        id: coverImage 
        anchors.fill: parent 
        source: "qrc:/images/albumcover.png" 
    } 

这个Image元素定义了背景图像,我们从构建项目时添加到可执行文件中的资源中加载:

    Scene3D { 
        anchors.fill: parent 

        Visualizer { 
            id: visualizer 
            animationState: mainview.state 
            numberOfBars: 120 
            barRotationTimeMs: 8160 // 68 ms per bar 
        } 
    } 

3D 场景将填充可视化器的内容:

    Rectangle { 
        id: blackBottomRect 
        color: "black" 
        width: parent.width 
        height: 0.14 * mainview.height 
        anchors.bottom: parent.bottom 
    } 

    Text { 
        text: qmlinterface.durationTotal 
        color: "#80C342" 
        x: parent.width / 6 
        y: mainview.height - mainview.height / 8 
        font.pixelSize: 12 
    } 

    Text { 
        text: qmlinterface.durationLeft 
        color: "#80C342" 
        x: parent.width - parent.width / 6 
        y: mainview.height - mainview.height / 8 
        font.pixelSize: 12 
    } 

这两个文本元素与我们自定义的 C++类中的属性相关联,就像我们之前看到的那样。这些值将随着 C++类实例中的值的变化而保持更新:

    property int buttonHorizontalMargin: 10 
    Rectangle { 
        id: playButton 
        height: 54 
        width: 54 
        anchors.bottom: parent.bottom 
        anchors.bottomMargin: width 
        x: parent.width / 2 - width - buttonHorizontalMargin 
        color: "transparent" 

        Image { 
            id: playButtonImage 
            source: "qrc:/images/pausenormal.png" 
        } 

        MouseArea { 
            id: playButtonMouseArea 
            anchors.fill: parent 
            hoverEnabled: isHoverEnabled 
            onClicked: { 
                if (mainview.state == 'paused' || mainview.state == 'stopped') 
                    mainview.state = 'playing' 
                else 
                    mainview.state = 'paused' 
            } 
            onEntered: { 
                if (mainview.state == 'playing') 
                    playButtonImage.source = "qrc:/images/pausehoverpressed.png" 
                else 
                    playButtonImage.source = "qrc:/images/playhoverpressed.png" 
            } 
            onExited: { 
                if (mainview.state == 'playing') 
                    playButtonImage.source = "qrc:/images/pausenormal.png" 
                else 
                    playButtonImage.source = "qrc:/images/playnormal.png" 
            } 
        } 
    } 

    Rectangle { 
        id: stopButton 
        height: 54 
        width: 54 
        anchors.bottom: parent.bottom 
        anchors.bottomMargin: width 
        x: parent.width / 2 + buttonHorizontalMargin 
        color: "transparent" 

        Image { 
            id: stopButtonImage 
            source: "qrc:/images/stopnormal.png" 
        } 

        MouseArea { 
            anchors.fill: parent 
            hoverEnabled: isHoverEnabled 
            onClicked: mainview.state = 'stopped' 
            onEntered: { 
                if (mainview.state != 'stopped') 
                    stopButtonImage.source = "qrc:/images/stophoverpressed.png" 
            } 
            onExited: { 
                if (mainview.state != 'stopped') 
                    stopButtonImage.source = "qrc:/images/stopnormal.png" 
            } 
        } 
    } 
} 

源代码的其余部分用于设置用于控制播放的各个按钮,包括播放、停止和暂停按钮,根据需要进行切换。

接下来,我们将看一下振幅条文件:

import Qt3D.Core 2.0 
import Qt3D.Render 2.0 
import Qt3D.Extras 2.0 
import QtQuick 2.4 as QQ2 

Entity { 
    property int rotationTimeMs: 0 
    property int entityIndex: 0 
    property int entityCount: 0 
    property int startAngle: 0 + 360 / entityCount * entityIndex 
    property bool needsNewMagnitude: true 
    property real magnitude: 0 
    property real animWeight: 0 

    property color lowColor: "black" 
    property color highColor: "#b3b3b3" 
    property color barColor: lowColor 

    property string entityAnimationsState: "stopped" 
    property bool entityAnimationsPlaying: true 

    property var entityMesh: null 

在我们深入到动画状态变化处理程序之前,定义了一些属性:

    onEntityAnimationsStateChanged: { 
        if (animationState == "paused") { 
            if (angleAnimation.running) 
                angleAnimation.pause() 
            if (barColorAnimations.running) 
                barColorAnimations.pause() 
        } else if (animationState == "playing"){ 
            needsNewMagnitude = true; 
            if (heightDecreaseAnimation.running) 
                heightDecreaseAnimation.stop() 
            if (angleAnimation.paused) { 
                angleAnimation.resume() 
            } else if (!entityAnimationsPlaying) { 
                magnitude = 0 
                angleAnimation.start() 
                entityAnimationsPlaying = true 
            } 
            if (barColorAnimations.paused) 
                barColorAnimations.resume() 
        } else { 
            if (animWeight != 0) 
                heightDecreaseAnimation.start() 
            needsNewMagnitude = true 
            angleAnimation.stop() 
            barColorAnimations.stop() 
            entityAnimationsPlaying = false 
        } 
    } 

每当音频播放停止、暂停或开始时,动画都必须更新以匹配这种状态变化:

    property Material barMaterial: PhongMaterial { 
        diffuse: barColor 
        ambient: Qt.darker(barColor) 
        specular: "black" 
        shininess: 1 
    } 

这定义了振幅条的外观,使用 Phong 着色:

    property Transform angleTransform: Transform { 
        property real heightIncrease: magnitude * animWeight 
        property real barAngle: startAngle 

        matrix: { 
            var m = Qt.matrix4x4() 
            m.rotate(barAngle, Qt.vector3d(0, 1, 0)) 
            m.translate(Qt.vector3d(1.1, heightIncrease / 2 - heightIncrease * 0.05, 0)) 
            m.scale(Qt.vector3d(0.5, heightIncrease * 15, 0.5)) 
            return m; 
        } 

        property real compareAngle: barAngle 
        onBarAngleChanged: { 
            compareAngle = barAngle 

            if (compareAngle > 360) 
                compareAngle = barAngle - 360 

            if (compareAngle > 180) { 
                parent.enabled = false 
                animWeight = 0 
                if (needsNewMagnitude) { 
                    // Calculate the ms offset where the bar will be at the center point of the 
                    // visualization and fetch the correct magnitude for that point in time. 
                    var offset = (90.0 + 360.0 - compareAngle) * (rotationTimeMs / 360.0) 
                    magnitude = qmlinterface.getNextAudioLevel(offset) 
                    needsNewMagnitude = false 
                } 
            } else { 
                parent.enabled = true 
                // Calculate a power of 2 curve for the bar animation that peaks at 90 degrees 
                animWeight = Math.min((compareAngle / 90), (180 - compareAngle) / 90) 
                animWeight = animWeight * animWeight 
                if (!needsNewMagnitude) { 
                    needsNewMagnitude = true 
                    barColorAnimations.start() 
                } 
            } 
        } 
    } 

当振幅条在屏幕上移动时,它们相对于摄像机的位置会发生变化,因此我们需要不断计算新的角度和显示高度。

在这一部分,我们还用我们的 C++类中的新方法调用替换了原始的音频级别方法的调用:

    components: [entityMesh, barMaterial, angleTransform] 

    QQ2.NumberAnimation { 
        id: angleAnimation 
        target: angleTransform 
        property: "barAngle" 
        duration: rotationTimeMs 
        loops: QQ2.Animation.Infinite 
        running: true 
        from: startAngle 
        to: 360 + startAngle 
    } 

    QQ2.NumberAnimation { 
        id: heightDecreaseAnimation 
        target: angleTransform 
        property: "heightIncrease" 
        duration: 400 
        running: false 
        from: angleTransform.heightIncrease 
        to: 0 
        onStopped: barColor = lowColor 
    } 

    property int animationDuration: angleAnimation.duration / 6 

    QQ2.SequentialAnimation on barColor { 
        id: barColorAnimations 
        running: false 

        QQ2.ColorAnimation { 
            from: lowColor 
            to: highColor 
            duration: animationDuration 
        } 

        QQ2.PauseAnimation { 
            duration: animationDuration 
        } 

        QQ2.ColorAnimation { 
            from: highColor 
            to: lowColor 
            duration: animationDuration 
        } 
    } 
} 

文件的其余部分包含了一些动画变换。

最后,这是可视化模块:

import Qt3D.Core 2.0 
import Qt3D.Render 2.0 
import Qt3D.Extras 2.0 
import QtQuick 2.2 as QQ2 

Entity { 
    id: sceneRoot 
    property int barRotationTimeMs: 1 
    property int numberOfBars: 1 
    property string animationState: "stopped" 
    property real titleStartAngle: 95 
    property real titleStopAngle: 5 

    onAnimationStateChanged: { 
        if (animationState == "playing") { 
            qmlinterface.setPlaying(); 
            if (progressTransformAnimation.paused) 
                progressTransformAnimation.resume() 
            else 
                progressTransformAnimation.start() 
        } else if (animationState == "paused") { 
            qmlinterface.setPaused(); 
            if (progressTransformAnimation.running) 
                progressTransformAnimation.pause() 
        } else { 
            qmlinterface.setStopped(); 
            progressTransformAnimation.stop() 
            progressTransform.progressAngle = progressTransform.defaultStartAngle 
        } 
    } 

这一部分从与本地媒体播放器实例的交互改为与 C++代码中的新实例交互。除此之外,我们没有做任何改动。这是主要的处理程序,用于处理由用户交互引起的场景变化,或者曲目的开始或结束:

    QQ2.Item { 
        id: stateItem 

        state: animationState 
        states: [ 
            QQ2.State { 
                name: "playing" 
                QQ2.PropertyChanges { 
                    target: titlePrism 
                    titleAngle: titleStopAngle 
                } 
            }, 
            QQ2.State { 
                name: "paused" 
                QQ2.PropertyChanges { 
                    target: titlePrism 
                    titleAngle: titleStopAngle 
                } 
            }, 
            QQ2.State { 
                name: "stopped" 
                QQ2.PropertyChanges { 
                    target: titlePrism 
                    titleAngle: titleStartAngle 
                } 
            } 
        ] 

        transitions: QQ2.Transition { 
            QQ2.NumberAnimation { 
                property: "titleAngle" 
                duration: 2000 
                running: false 
            } 
        } 
    } 

为曲目标题对象定义了一些属性变化和转换:

    function startVisualization() { 
        progressTransformAnimation.duration = qmlinterface.duration() 
        mainview.state = "playing" 
        progressTransformAnimation.start() 
    } 

这个函数是启动整个可视化序列的方法。它使用我们的 C++类实例获取的曲目持续时间来确定曲目播放动画的进度条尺寸,然后开始可视化动画:

    Camera { 
        id: camera 
        projectionType: CameraLens.PerspectiveProjection 
        fieldOfView: 45 
        aspectRatio: 1820 / 1080 
        nearPlane: 0.1 
        farPlane: 1000.0 
        position: Qt.vector3d(0.014, 0.956, 2.178) 
        upVector: Qt.vector3d(0.0, 1.0, 0.0) 
        viewCenter: Qt.vector3d(0.0, 0.7, 0.0) 
    } 

为 3D 场景定义了一个摄像机:

    Entity { 
        components: [ 
            DirectionalLight { 
                intensity: 0.9 
                worldDirection: Qt.vector3d(0, 0.6, -1) 
            } 
        ] 
    } 

    RenderSettings { 
        id: external_forward_renderer 
        activeFrameGraph: ForwardRenderer { 
            camera: camera 
            clearColor: "transparent" 
        } 
    } 

为场景创建了渲染器和光源:

    components: [external_forward_renderer] 

    CuboidMesh { 
        id: barMesh 
        xExtent: 0.1 
        yExtent: 0.1 
        zExtent: 0.1 
    } 

为振幅条创建了一个网格:

    NodeInstantiator { 
        id: collection 
        property int maxCount: parent.numberOfBars 
        model: maxCount 

        delegate: BarEntity { 
            id: cubicEntity 
            entityMesh: barMesh 
            rotationTimeMs: sceneRoot.barRotationTimeMs 
            entityIndex: index 
            entityCount: sceneRoot.numberOfBars 
            entityAnimationsState: animationState 
            magnitude: 0 
        } 
    } 

定义了条的数量以及其他属性:

    Entity { 
        id: titlePrism 
        property real titleAngle: titleStartAngle 

        Entity { 
            id: titlePlane 

            PlaneMesh { 
                id: titlePlaneMesh 
                width: 550 
                height: 100 
            } 

            Transform { 
                id: titlePlaneTransform 
                scale: 0.003 
                translation: Qt.vector3d(0, 0.11, 0) 
            } 

            NormalDiffuseMapAlphaMaterial { 
                id: titlePlaneMaterial 
                diffuse: TextureLoader { source: "qrc:/images/demotitle.png" } 
                normal: TextureLoader { source: "qrc:/images/normalmap.png" } 
                shininess: 1.0 
            } 

            components: [titlePlaneMesh, titlePlaneMaterial, titlePlaneTransform] 
        } 

这个平面包含了没有曲目播放时的标题对象:

        Entity { 
            id: songTitlePlane 

            PlaneMesh { 
                id: songPlaneMesh 
                width: 550 
                height: 100 
            } 

            Transform { 
                id: songPlaneTransform 
                scale: 0.003 
                rotationX: 90 
                translation: Qt.vector3d(0, -0.03, 0.13) 
            } 

            property Material songPlaneMaterial: NormalDiffuseMapAlphaMaterial { 
                diffuse: TextureLoader { source: "qrc:/images/songtitle.png" } 
                normal: TextureLoader { source: "qrc:/images/normalmap.png" } 
                shininess: 1.0 
            } 

            components: [songPlaneMesh, songPlaneMaterial, songPlaneTransform] 
        } 

这个平面包含了曲目激活时的歌曲标题:

        property Transform titlePrismPlaneTransform: Transform { 
            matrix: { 
                var m = Qt.matrix4x4() 
                m.translate(Qt.vector3d(-0.5, 1.3, -0.4)) 
                m.rotate(titlePrism.titleAngle, Qt.vector3d(1, 0, 0)) 
                return m; 
            } 
        } 

        components: [titlePlane, songTitlePlane, titlePrismPlaneTransform] 
    } 

为了在播放和非播放转换之间转换平面,使用了这个变换:

    Mesh { 
        id: circleMesh 
        source: "qrc:/meshes/circle.obj" 
    } 

    Entity { 
        id: circleEntity 
        property Material circleMaterial: PhongAlphaMaterial { 
            alpha: 0.4 
            ambient: "black" 
            diffuse: "black" 
            specular: "black" 
            shininess: 10000 
        } 

        components: [circleMesh, circleMaterial] 
    } 

添加了一个提供反射效果的圆形网格:

    Mesh { 
        id: progressMesh 
        source: "qrc:/meshes/progressbar.obj" 
    } 

    Transform { 
        id: progressTransform 
        property real defaultStartAngle: -90 
        property real progressAngle: defaultStartAngle 
        rotationY: progressAngle 
    } 

    Entity { 
        property Material progressMaterial: PhongMaterial { 
            ambient: "purple" 
            diffuse: "white" 
        } 

        components: [progressMesh, progressMaterial, progressTransform] 
    } 

    QQ2.NumberAnimation { 
        id: progressTransformAnimation 
        target: progressTransform 
        property: "progressAngle" 
        duration: 0 
        running: false 
        from: progressTransform.defaultStartAngle 
        to: -270 
        onStopped: if (animationState != "stopped") animationState = "stopped" 
    } 
} 

最后,这个网格创建了进度条,它从左到右移动以指示播放进度。

整个项目通过运行 qmake 然后 make 来编译,或者通过在 Qt Creator 中打开项目并从那里构建来编译。运行时,它将自动开始播放包含的歌曲并显示振幅可视化,同时可以通过 UI 中的按钮进行控制。

总结

在本章中,我们看了 Qt 框架在开发嵌入式系统方面的多种用途。我们简要地比较了它与其他框架的区别,以及 Qt 如何针对这些嵌入式平台进行优化,然后通过一个基于 QML 的 GUI 示例来演示如何将其添加到我们之前创建的信息娱乐系统中。

您现在应该能够创建基本的 Qt 应用程序,包括纯粹基于命令行的应用程序和带有图形用户界面的应用程序。您还应该清楚地了解 Qt 提供的开发 GUI 的各种选项。

在下一章中,我们将看看嵌入式平台的下一个演进,使用可编程门阵列(FPGAs)来为嵌入式平台加入定制的基于硬件的功能,以加快其速度。

第十一章:为混合 SoC/FPGA 系统开发

除了标准的基于 CPU 的嵌入式系统之外,一个越来越常见的方法是将 CPU 与现场可编程门阵列FGPAs)结合在 SoC 的形式中。这使得 CPU 密集型算法和处理,包括 DSP 和图像处理,可以在系统的 FPGA 部分上实现,而 CPU 端处理较不密集的任务,如用户交互、存储和网络。

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

  • 如何与混合 FPGA/SoC 系统的 FPGA 端进行通信

  • 学习如何在 FPGA 中实现各种算法,并从 SoC 端使用

  • 如何在混合 FPGA/SoC 系统上实现基本示波器

极端并行化

在性能方面,使用单核处理器一次执行单个指令基本上是实现算法或其他功能的最慢方式。从这里开始,您可以将这种单一执行流扩展到多个流,使用单个处理器核心的各个功能单元进行同时调度。

提高性能的下一步是增加更多的核心,这当然会使调度变得更加复杂,并引入潜在的延迟问题,因为关键任务被推迟,而不太关键的任务正在阻塞资源。对于某些任务,特别是那些尴尬地并行的任务,使用通用处理器也非常有限。

对于需要使用相同算法处理单个大型数据集的任务,使用基于通用图形处理单元GPGPU)的处理已经变得非常流行,同时还使用数字信号处理器DSP)通过使用专用硬件大大加速一系列操作。

在这个问题的另一面是任务,这些任务是大规模并行的,但涉及对传入数据、内部数据或两者都进行许多不同操作。如果纯粹在软件中实现一系列微处理器核心的范围内,要想获得合理的性能将会非常困难。

昂贵的 DSP 硬件可能会有所帮助,但即使如此,也不会针对该任务进行优化。传统上,这将是公司考虑设计和生产应用特定集成电路ASIC)的时候。然而,这样做的成本非常高,只有在大规模生产中才是现实的,它才能与其他选项竞争。

随着时间的推移,发明了不同的解决方案,使得这种定制硬件实现更加现实,其中之一就是可编程逻辑芯片的开发。例如,像 Commodore 64 这样的系统包含一个PLA(原名可编程逻辑阵列,最初是 Signetics 82S100)芯片,它是一次性可编程的组合逻辑元素阵列。它允许处理器重新配置地址总线的内部路由,以改变 DRAM 存储芯片、ROM 存储芯片和其他外围设备在活动寻址空间中的部分。

在编程 PLA 之后,它的功能基本上与大量的 74 逻辑芯片(离散逻辑芯片)以相同的方式运行,但所需空间仅为离散解决方案的一小部分。这种方法本质上为 Commodore 提供了他们自己的定制 ASIC,但无需投资设计和生产。相反,他们使用了现成的零件,并且可以在 Commodore 64 的生命周期内对烧入 PLA 芯片的逻辑进行改进。

随着时间的推移,PLAs(也称为 PALs)变得更加先进,发展成为基于宏单元的复杂可编程逻辑设备CPLDs),这些设备允许实现更高级的功能,而不仅仅是简单的组合逻辑。这些最终演变成了 FPGAs,再次增加了更高级的功能和外围设备。

如今,几乎在所有需要一些高级处理或控制的地方都可以找到 FPGAs。视频和音频处理设备通常与 DSP 一起使用 FPGAs,MCU 或 SoC 处理用户界面和其他低优先级功能。

如今,示波器等设备采用模拟(如果支持的话还有数字)前端,DSP 进行数据的原始转换和初始处理,然后将数据传递给一个或多个 FPGAs,FPGAs 进行进一步的处理和分析数据。处理后,这些数据可以存储在缓冲区(数字存储示波器DSO)的“数字存储”部分),也可以传递给前端,在那里运行在 SoC 上的软件将在用户界面中呈现它,并允许用户输入命令来操作显示的数据。

在本章中,我们将介绍一个基本示波器项目,该项目将使用简单的硬件和使用 VHDL 代码编程的 FPGA 来实现。

硬件描述语言

随着过去几十年超大规模集成VLSI)电路的复杂性增加,改进开发过程的能力,包括验证设计的能力,变得越来越关键。这导致了硬件描述语言HDL****s)的发展,其中今天 VHDL 和 Verilog 是最常用的两种。

HDL 的主要目的是允许开发人员轻松描述硬件电路,这些电路可以集成到 ASIC 中或用于编程 FPGAs。此外,这些 HDL 还使得可以模拟设计并验证其功能正确性。

在本章中,我们将介绍一个使用 VHDL 实现在 FPGA 上的编程的示例。VHSIC 硬件描述语言VHDL)作为一种语言于 1983 年首次出现,当时由美国国防部开发。它旨在作为一种记录供应商提供设备的 ASIC 行为的方式。

随着时间的推移,人们提出了这些文档文件可以用于模拟 ASIC 的行为的想法。这一发展很快被综合工具的发展所跟随,以创建可用于创建 ASIC 的功能硬件实现。

VHDL 在很大程度上基于 Ada 编程语言,Ada 本身也源自美国军方。虽然 VHDL 主要用作 HDL,但它也可以像 Ada 及其衍生语言一样用作通用编程语言。

FPGA 架构

尽管并非每个 FPGA 的结构都相同,但一般原则仍然相同:它们是可以配置为形成特定电路的逻辑元素阵列。因此,这些逻辑元素LEs)的复杂性决定了可以形成什么样的逻辑电路,在为特定 FPGA 架构编写 VHDL 代码时必须考虑到这一点。

术语逻辑元素LEs)和逻辑单元LCs)可以互换使用。一个 LE 由一个或多个查找表LUTs)组成,通常每个 LUT 具有四到六个输入。无论确切的配置如何,每个 LE 都被互连逻辑所包围,这允许不同的 LE 相互连接,LE 本身被编程为特定的配置,从而形成预期的电路。

开发 FPGA 的潜在风险包括 FPGA 制造商强烈假设 FPGA 将用于时钟设计(使用中央时钟源和时钟域),而不是组合逻辑(无时钟)的设计。一般来说,在将其包含在新项目中之前,熟悉目标 FPGA 系统是个好主意,以了解它能够支持你需要的功能有多好。

混合 FPGA/SoC 芯片

尽管多年来包含 FPGA 和 SoC 的系统非常常见,但最近增加了混合 FPGA/SoC 芯片,其中在同一封装中包含了 FPGA 和 SoC(通常是基于 ARM 的)。然后,它们通过总线连接在一起,以便两者可以使用内存映射 I/O 等方式有效地相互通信。

目前这类 FPGA 的常见示例包括 Altera(现在是英特尔)的 Cyclone V SoC 和 Xilinx 的 Zynq。Cyclone V SoC 的官方数据表中的块图给出了这种系统工作方式的很好概述:

在这里,我们可以看到 HPS 和 FPGA 两侧可以相互通信的多种方式,比如通过共享 SDRAM 控制器、两个点对点链接和其他一些接口。对于 Cyclone V SoC,系统启动时 FPGA 或 SoC 两侧可以是首先启动的一侧,从而可以实现广泛的系统配置选项。

示例-基本示波器

这个示例基本上介绍了如何在嵌入式项目中使用 FPGA。它使用 FPGA 对输入进行采样并测量电压或类似的东西,就像示波器一样。然后,得到的 ADC 数据通过串行链路发送到一个基于 C++/Qt 的应用程序中,该应用程序显示数据。

硬件

在这个项目中,我们将使用 Fleasystems FleaFPGA Ohm 板(fleasystems.com/fleaFPGA_Ohm.html)。这是一个小型的、低于 50 美元、低于 40 欧元的 FPGA 开发板,外形尺寸与树莓派 Zero 相同:

它具有以下规格:

  • ECP5 FPGA 芯片具有 24K 个 LUT 元素和 112KB 的块 RAM。

  • 256-Mbit SDRAM,16 位宽,167 MHz 时钟。

  • 8-Mbit SPI Flash ROM,用于 FPGA 配置存储。

  • 25 MHz 晶体振荡器。

  • HDMI 视频输出(最高 1080p30 或 720p60 屏幕模式)。

  • μSD 卡槽。

  • 两个 Micro USB 主机端口,具有备用的 PS/2 主机端口功能。

  • 29 个用户 GPIO,包括 4 个中速 ADC 输入和 12 对 LVDS 信号对,分别来自(与树莓派兼容的)40 针扩展和 2 针复位头。

  • 一个 Micro USB 从机端口。提供+5V 供电给 Ohm,串行控制台/UART 通信,以及访问板载 JTAG 编程接口(用于配置 ECP5 FPGA)。

  • 提供外部 JTAG 编程接口,以实现实时调试。

我们连接到这块板子上的电路可以让我们连接示波器探头:

这个电路将连接到 Ohm 板的 GPIO 引脚 29 号,对应 GPIO 5。它允许我们测量 0 到 3V 的直流信号,以及 1.5V 的交流(有效值),在 1x 探头测量模式下。带宽略高于 10 MHz。

VHDL 代码

在这一部分,我们将看一下 VHDL 项目中的顶层实体,以了解它的功能。它以 VHDL 的标准库包含开始,如下所示:

library IEEE; 
use IEEE.STD_LOGIC_1164.ALL; 
use IEEE.std_logic_unsigned.ALL; 
use IEEE.numeric_std.all; 

entity FleaFPGA_Ohm_A5 is   
   port( 
   sys_clock         : in        std_logic;  -- 25MHz clock input from external xtal oscillator. 
   sys_reset         : in        std_logic;  -- master reset input from reset header. 

这映射到底层 FPGA 的系统时钟和复位线。我们还可以看到端口映射的工作方式,定义了实体端口的方向和类型。在这里,类型是std_logic,它是一个标准逻辑信号,可以是二进制的 1 或 0:

   n_led1                  : buffer    std_logic; 

   LVDS_Red          : out       std_logic_vector(0 downto 0); 
   LVDS_Green        : out       std_logic_vector(0 downto 0); 
   LVDS_Blue         : out       std_logic_vector(0 downto 0); 
   LVDS_ck                 : out       std_logic_vector(0 downto 0); 

   slave_tx_o        : out       std_logic; 
   slave_rx_i        : in        std_logic; 
   slave_cts_i       : in        std_logic;  -- Receive signal from #RTS pin on FT230x 

我们还使用了板上的状态 LED,映射了 HDMI 的视频引脚(LVDS 信号),以及使用了板上的 FDTI USB-UART 芯片的 UART 接口。后者是我们将用来将数据从 FPGA 发送到 C++ 应用程序的。

接下来是树莓派兼容的标题映射,如下所示的代码:

   GPIO_2                  : inout           std_logic; 
   GPIO_3                  : inout           std_logic; 
   GPIO_4                  : inout           std_logic; 
   -- GPIO_5               : inout           std_logic; 
   GPIO_6                  : inout           std_logic;   
   GPIO_7                  : inout           std_logic;   
   GPIO_8                  : inout           std_logic;   
   GPIO_9                  : inout           std_logic;   
   GPIO_10                 : inout           std_logic; 
   GPIO_11                 : inout           std_logic;   
   GPIO_12                 : inout           std_logic;   
   GPIO_13                 : inout           std_logic;   
   GPIO_14                 : inout           std_logic;   
   GPIO_15                 : inout           std_logic;   
   GPIO_16                 : inout           std_logic;   
   GPIO_17                 : inout           std_logic; 
   GPIO_18                 : inout           std_logic;   
   GPIO_19                 : inout           std_logic;   
   GPIO_20                 : in        std_logic; 
   GPIO_21                 : in        std_logic;   
   GPIO_22                 : inout           std_logic;   
   GPIO_23                 : inout           std_logic; 
   GPIO_24                 : inout           std_logic; 
   GPIO_25                 : inout           std_logic;   
   GPIO_26                 : inout           std_logic;   
   GPIO_27                 : inout           std_logic; 
   GPIO_IDSD         : inout           std_logic; 
   GPIO_IDSC         : inout           std_logic; 

GPIO 5 被注释掉的原因是因为我们想要将其用于 ADC 功能而不是通用输入/输出。

相反,我们启用了 sigma-delta-capable ADC3 外设来处理该引脚的工作,如下所示:

   --ADC0_input      : in        std_logic; 
   --ADC0_error      : buffer    std_logic; 
   --ADC1_input      : in        std_logic; 
   --ADC1_error      : buffer    std_logic; 
   --ADC2_input      : in        std_logic; 
   --ADC2_error      : buffer    std_logic; 
   ADC3_input  : in        std_logic; 
   ADC3_error  : buffer    std_logic; 

在这里,我们看到我们还有另外三个 ADC 外设,如果我们想要为示波器添加额外的通道,可以使用这些外设,如下所示的代码:

   mmc_dat1          : in        std_logic; 
   mmc_dat2          : in        std_logic; 
   mmc_n_cs          : out       std_logic; 
   mmc_clk           : out       std_logic; 
   mmc_mosi          : out       std_logic; 
   mmc_miso          : in        std_logic; 

   PS2_enable        : out       std_logic; 
   PS2_clk1          : inout           std_logic; 
   PS2_data1         : inout           std_logic; 

   PS2_clk2          : inout           std_logic; 
   PS2_data2         : inout           std_logic 
   ); 
end FleaFPGA_Ohm_A5; 

顶层的实体定义以 MMC(SD 卡)和 PS2 接口结束。

接下来是模块的架构定义。这部分类似于 C++ 应用程序的源文件,实体定义的功能类似于标题,如下所示:

architecture arch of FleaFPGA_Ohm_A5 is 
   signal clk_dvi  : std_logic := '0'; 
   signal clk_dvin : std_logic := '0'; 
   signal clk_vga  : std_logic := '0'; 
   signal clk_50  : std_logic := '0'; 
   signal clk_pcs   : std_logic := '0'; 

   signal vga_red     : std_logic_vector(3 downto 0) := (others => '0'); 
   signal vga_green   : std_logic_vector(3 downto 0) := (others => '0'); 
   signal vga_blue    : std_logic_vector(3 downto 0) := (others => '0'); 

   signal ADC_lowspeed_raw     : std_logic_vector(7 downto 0) := (others => '0'); 

   signal red     : std_logic_vector(7 downto 0) := (others => '0'); 
   signal green   : std_logic_vector(7 downto 0) := (others => '0'); 
   signal blue    : std_logic_vector(7 downto 0) := (others => '0'); 
   signal hsync   : std_logic := '0'; 
   signal vsync   : std_logic := '0'; 
   signal blank   : std_logic := '0'; 

这里定义了一些信号。这些信号允许我们将 VHDL 模块的端口、实体、进程和其他元素相互连接。

我们可以看到这里定义了一些信号以支持 VGA。这允许与支持 VGA 的 FPGA 板兼容,但其中的部分也与 HDMI(或 DVI)外设兼容,我们稍后将会看到。让我们看看以下代码:

begin 
 Dram_CKE <= '0';    -- DRAM Clock disable. 
 Dram_n_cs <= '1';   -- DRAM Chip disable. 
 PS2_enable <= '1'; -- Configures both USB host ports for legacy PS/2 mode. 
 mmc_n_cs <= '1';    -- Micro SD card chip disable. 

通过 begin 关键字,我们指示这是我们希望开始执行架构定义中的命令的地方。除非一组指令被封装在 process 中(在此代码中未显示),否则在此关键字之后和终止关键字(end architecture)之前的所有内容将同时执行。

通过写入适当的引脚,我们禁用了一些硬件功能。出于简洁起见,我们在早期的实体定义中省略了 DRAM(外部内存)部分。DRAM 和 SD 卡功能被禁用,而 PS2(键盘、鼠标)功能被启用。这样,我们就可以连接 PS2 输入设备,如果我们想的话:

 user_module1 : entity work.FleaFPGA_DSO 
    port map( 
         rst => not sys_reset, 
         clk => clk_50, 
         ADC_1 => n_led1, 
         ADC_lowspeed_raw => ADC_lowspeed_raw, 
         Sampler_Q => ADC3_error, 
         Sampler_D => ADC3_input, 
         Green_out => vga_green, 
         Red_out => vga_red, 
         Blue_out => vga_blue, 
         VGA_HS => hsync, 
         VGA_VS => vsync, 
         blank => blank, 
         samplerate_adj => GPIO_20, 
         trigger_adj => GPIO_21 
    ); 

在这里,我们定义将使用 FleaFPGA 数字存储示波器模块的一个实例。虽然模块可以支持四个通道,但只映射了第一个通道。这种简化有助于演示操作原理。

DSO 模块负责从 ADC 中读取数据,因为它对我们用探头测量的信号进行采样,并将其呈现到本地缓存以在本地(HDMI 或 VGA)监视器上显示,并通过串行接口发送到 UART 模块(在本节末尾显示)。让我们看看以下代码:

   red <= vga_red & "0000"; 
   green <= vga_green & "0000"; 
   blue <= vga_blue & "0000"; 

在这里,显示输出的最终颜色是通过 HDMI 输出信号确定的:

 u0 : entity work.DVI_clkgen 
   port map( 
         CLKI              =>    sys_clock, 
         CLKOP             =>    clk_dvi, 
         CLKOS                   =>  clk_dvin, 
         CLKOS2                  =>  clk_vga, 
         CLKOS3                  =>  clk_50 
         );   

   u100 : entity work.dvid PORT MAP( 
      clk       => clk_dvi, 
      clk_n     => clk_dvin, 
      clk_pixel => clk_vga, 
      red_p     => red, 
      green_p   => green, 
      blue_p    => blue, 
      blank     => blank, 
      hsync     => hsync, 
      vsync     => vsync, 
      -- outputs to TMDS drivers 
      red_s     => LVDS_Red, 
      green_s   => LVDS_Green, 
      blue_s    => LVDS_Blue, 
      clock_s   => LVDS_ck 
   ); 

整个部分用于输出由 DSO 模块生成的视频信号,这样我们也可以将 FPGA 板用作独立示波器单元:

   myuart : entity work.simple_uart 

         port map( 
               clk => clk_50, 
               reset => sys_reset, -- active low 
               txdata => ADC_lowspeed_raw, 
               --txready => ser_txready, 
               txgo => open, 
               --rxdata => ser_rxdata, 
               --rxint => ser_rxint, 
               txint => open, 
               rxd => slave_rx_i, 
               txd => slave_tx_o 
         ); 
end architecture; 

最后,简单的 UART 实现允许 DSO 模块与我们的 C++ 应用程序进行通信。

UART 配置为工作在波特率为 19,200,8 位,1 个停止位,无校验。构建了这个 VHDL 项目并用 FPGA 板进行了编程后,我们可以通过这个串行连接连接到它。

C++ 代码

虽然 VHDL 代码实现了简单的显示输出和基本的输入选项,但如果我们想要有一个大型(高分辨率)显示屏,进行信号分析,录制多分钟甚至几小时的数据等,能够在 SBC 上进行这些操作将非常方便。

以下代码是作为一个 C++/Qt 图形应用程序编写的,它从 FPGA 板接收原始 ADC 数据并在图表中显示。虽然简陋,但它为一个功能齐全的基于 SoC 的系统提供了框架。

首先,显示标题如下:

#include <QMainWindow> 

#include <QSerialPort> 
#include <QChartView> 
#include <QLineSeries> 

namespace Ui { 
    class MainWindow; 
} 

class MainWindow : public QMainWindow { 
    Q_OBJECT 

public: 
    explicit MainWindow(QWidget *parent = nullptr); 
    ~MainWindow(); 

public slots: 
    void connectUart(); 
    void disconnectUart(); 
    void about(); 
    void quit(); 

private: 
    Ui::MainWindow *ui; 

    QSerialPort serialPort; 
    QtCharts::QLineSeries* series; 
    quint64 counter = 0; 

private slots: 
    void uartReady(); 
}; 

在这里,我们可以看到我们将在 Qt 中使用串行端口实现,以及 QChart 模块进行可视化部分。

实现如下代码所示:

#include "mainwindow.h" 
#include "ui_mainwindow.h" 

#include <QSerialPortInfo> 
#include <QInputDialog> 
#include <QMessageBox> 

MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), 
    ui(new Ui::MainWindow) { 
    ui->setupUi(this); 

    // Menu connections. 
    connect(ui->actionQuit, SIGNAL(triggered()), this, SLOT(quit())); 
    connect(ui->actionConnect, SIGNAL(triggered()), this, SLOT(connectUart())); 
    connect(ui->actionDisconnect, SIGNAL(triggered()), this, SLOT(disconnectUart())); 
    connect(ui->actionInfo, SIGNAL(triggered()), this, SLOT(about())); 

    // Other connections 
    connect(&serialPort, SIGNAL(readyRead()), this, SLOT(uartReady())); 

    // Configure the chart view. 
    QChart* chart = ui->chartView->chart(); 
    chart->setTheme(QChart::ChartThemeBlueIcy); 
    chart->createDefaultAxes(); 
    series = new QtCharts::QLineSeries(chart); 
    chart->setAnimationOptions(QChart::NoAnimation);         
    chart->addSeries(series); 
} 

在构造函数中,我们创建了与 GUI 中菜单选项的连接,这些选项允许我们退出应用程序,连接到串行端口,如果已连接则断开与串行端口的连接,或者获取有关应用程序的信息。

我们将串行端口实例连接到一个插槽,每当准备读取新数据时,该插槽将被调用。

最后,我们在 GUI 中配置图表视图,获取 QChartView 小部件内的 QChart 实例的引用。在这个引用上,我们为图表设置了一个主题,添加了默认轴,最后添加了一个空系列,我们将用来填充来自 FPGA 的传入数据,如下面的代码所示:

MainWindow::~MainWindow() { 
    delete ui; 
} 

void MainWindow::connectUart() { 
    QList<QSerialPortInfo> comInfo = QSerialPortInfo::availablePorts(); 
    QStringList comNames; 
    for (QSerialPortInfo com: comInfo) { 
        comNames.append(com.portName()); 
    } 

    if (comNames.size() < 1) { 
        QMessageBox::warning(this, tr("No serial port found"), tr("No serial port was found on the system. Please check all connections and try again.")); 
        return; 
    } 

    QString comPort = QInputDialog::getItem(this, tr("Select serial port"), tr("Available ports:"), comNames, 0, false); 

    if (comPort.isEmpty()) { return; } 

    serialPort.setPortName(comPort); 
    if (!serialPort.open(QSerialPort::ReadOnly)) { 
        QMessageBox::critical(this, tr("Error"), tr("Failed to open the serial port.")); 
        return; 
    } 

    serialPort.setBaudRate(19200); 
    serialPort.setParity(QSerialPort::NoParity); 
    serialPort.setStopBits(QSerialPort::OneStop); 
    serialPort.setDataBits(QSerialPort::Data8); 
} 

当用户希望通过 UART 连接到 FPGA 时,必须选择连接 FPGA 的串行连接,之后将建立连接,使用我们在项目的 VHDL 部分中之前建立的 19,200 波特率,8N1 设置。

对于固定配置,其中串行端口始终相同,可以考虑在系统启动时自动化以下部分:

void MainWindow::disconnectUart() { 
    serialPort.close(); 
} 

从串行端口断开连接非常简单:

void MainWindow::uartReady() { 
    QByteArray data = serialPort.readAll(); 

    for (qint8 value: data) { 
        series->append(counter++, value); 
    } 
} 

当 UART 从 FPGA 板接收新数据时,将调用此插槽。在其中,我们从 UART 缓冲区中读取所有数据,将其附加到我们添加到图形小部件的系列中,从而更新显示的跟踪。计数器变量用于为图表提供递增的时间基准。这在这里充当了简单的时间戳。

在某个时候,我们应该开始从系列中删除数据,以防止其变得过大,同时具有搜索和保存数据的能力。基于计数器的时间戳可以报告我们接收信号的实际时间,尽管理想情况下,这应该是我们从 FPGA 接收到的数据的一部分:

void MainWindow::about() { 
    QMessageBox::aboutQt(this, tr("About")); 
} 

void MainWindow::quit() { 
    exit(0); 
} 

最后,我们有一些简单的插槽。对于信息对话框,我们只需显示标准的 Qt 信息对话框。这可以替换为自定义的帮助或信息对话框。

构建项目

可以使用免费的 Lattice Semiconductor Diamond IDE 软件(www.latticesemi.com/latticediamond)构建 VHDL 项目,并将其编程到 Ohm FPGA 板上。编程板需要安装来自github.com/Basman74/FleaFPGA-Ohm的 FleaFPGA JTAG 实用程序,以便 Diamond 可以使用它。

通过按照快速入门指南中描述的 FleaFPGA Ohm 板的说明,应该相对容易地启动和运行项目的一部分。对于 C++部分,必须确保 FPGA 板和 SBC(或等效物)连接在一起,以便后者可以访问前者上的 UART。

有了这个设置,只需使用 Qt 框架编译 C++项目(直接在 SBC 上或最好是在桌面系统上进行交叉编译)就足够了。之后,可以运行已刷写 FPGA 板的应用程序,连接到 UART,并观察在应用程序窗口上绘制的跟踪。

摘要

在本章中,我们看了 FPGA 在嵌入式开发中扮演的角色,它们在过去几十年中的重要性发生了变化,以及它们现在的用途。我们看了一个使用 FPGA 和基于 SBC 的组件的示波器的简单实现。阅读完本章后,您现在应该知道何时选择 FPGA 用于新的嵌入式项目,并了解如何使用和与这样的设备通信。

第十二章:最佳实践

与每个软件项目一样,存在许多常见问题和陷阱。在嵌入式开发中,硬件方面增加了独特的问题。从资源管理问题到中断故障和硬件问题引起的奇怪行为,本附录向您展示如何预防和处理许多这些问题。此外,它还向您展示了各种优化方法以及需要注意的事项。在本附录中,我们将涵盖以下主题:

  • 优化嵌入式代码的安全方法

  • 如何避免和解决各种常见的软件和硬件相关问题

  • 认识到硬件的不完美世界以及如何将其整合到设计中

所有最好的计划

与任何项目一样,预期设计与实际功能之间存在不可避免的差距。即使有最好的规划和丰富的经验,也总会有意想不到或未被注意到的问题。您能做的最好的事情就是尽可能做好准备。

第一步是要获得目标平台的所有可用信息,了解可用的工具,并拥有一个坚实的开发和测试计划。我们在本书中已经涵盖了许多这些方面。

在本附录中,我们将总结一些最佳实践,这些实践应该有助于避免一些更常见的问题。

与硬件合作

每个目标平台都有其自己的怪癖和特点。其中很大一部分是由于该平台的发展历史。对于 AVR 这样的平台,它相当一致,因为它是由一家公司(Atmel)在多年内开发的,因此在不同芯片和用于该平台的工具之间相当一致。

像 ESP8266(以及在某种程度上其 ESP32 后继者)这样的平台从未被设计为用作通用 MCU 系统,这在其相当零碎和分散的软件生态系统中表现出来。尽管在过去几年中情况有所好转,各种框架和开源工具平滑了最粗糙的地方,但由于缺乏文档、工具问题和芯片内调试的缺乏,这是一个容易犯错误的平台。

ARM MCU(Cortex-M)由众多制造商生产,配置繁多。尽管编程这些 MCU 往往是相当一致的,使用诸如 OpenOCD 之类的工具,但每个 MCU 添加的外设在制造商之间往往大不相同,我们将在下一节中进行讨论。

最后,ARM SoCs 和类似的平台与 ARM MCU 类似,但其体系结构更加复杂,外设较少。此外,ARM SoCs 还添加了复杂的初始化程序,需要全面的引导加载程序,这就是为什么大多数人选择使用现成的 Linux 镜像或类似的 SoC,并对其进行开发。

在这里,没有真正的对与错的答案。大部分取决于项目的需求,但重要的是您对所使用的硬件平台有一个良好的概述。

外设的混乱世界

ARM MCU 的一个非常有趣的现实是,它们具有不同且通常不兼容的外设,映射到内存空间中高度不同的区域。最糟糕的是定时器外设,其复杂性各不相同,它们通常能够在 GPIO 引脚上生成任何所需的输出信号,包括 PWM,以及作为基于中断的定时器来控制固件的执行。

配置定时器外设和类似的复杂外设并不是一件简单的事情。同样,使用内置 MAC 与外部 PHY(以太网物理接口)需要大量深入的知识来了解如何配置它们。阅读数据手册和应用笔记在这里是必不可少的。

依赖诸如 ST 的 CubeMX 软件之类的工具生成的代码,用于他们的 STM32 系列 ARM MCU,可能会导致你因为忘记在 CubeMX 编辑器中勾选一些选项而与非功能性代码搏斗,因为你不知道这些选项是用来做什么的。

使用这种自动生成工具或制造商提供的高级库没有错,因为它们可以显著地简化生活。然而,接受这一决定所带来的风险是至关重要的,因为这需要你相信提供的代码是正确的,或者花时间验证它确实是正确的。

为了使不同 MCU 和 SoC 上的外设使用不那么混乱,必须在某个地方添加一层抽象,以便实现代码的可移植性。关键是确保这确实会让生活变得更容易,而不仅仅是增加可能会使当前项目或未来项目受挫的另一个潜在问题。

了解你的工具

在嵌入式项目中工作时,你必须知道目标平台上存在哪些工具以及它们的工作原理。这包括通过 JTAG 或其他接口对 MCU 进行编程,并启动用于片上调试的调试会话,以及片上调试的限制。在使用工具之前,最好先阅读工具的手册或文档,并阅读其他开发人员对这些工具的经验。

我们在之前的章节中看过许多这样的工具,包括 MCU 和 SoC 平台,以及在将其刷入目标硬件之前验证 MCU 设计的方法。

选择异步方法

许多硬件设备和操作需要时间来完成。因此,选择使用中断和定时器进行异步操作而不是阻塞操作是有意义的。

在进行裸机编程时,你往往会使用一个单独的循环,其中包含中断例程和定时器,允许你响应和轮询事件。如果以完全异步的方式编程,这个主循环将有效地处理任务,而中断处理程序将更新需要处理的数据。

即使在 SoC 平台上,使用异步方法也是一个好主意,因为诸如网络操作和其他 I/O 操作之类的事情可能需要比预期更长的时间。处理操作未完成的方法是另一个可能出现的问题。

阅读数据表

特别是对于 MCU,数据表为我们提供了关于硬件工作方式的许多宝贵信息,例如如何配置内部系统时钟,各个外设的工作方式,以及可用的寄存器及其含义。

即使你使用的是现有的板而不是自定义硬件系统,了解底层硬件也是值得的,即使只是从对 MCU 或 SoC 数据表的粗略阅读中。

保持中断处理程序的简短

中断的本质决定了它会打断处理器的正常执行,转而执行中断处理程序。我们在中断处理程序中花费的每一微秒都意味着我们无法运行其他例程或处理其他中断。

为了防止由此产生的任何问题,中断处理程序(ISR)应尽可能保持简短,最好只是在结束 ISR 并恢复正常操作之前以快速和安全的方式更新单个值。

8 位意味着 8 位

毫不奇怪,在 8 位 MCU 上使用 16 位和 32 位整数非常慢。这是因为系统必须对相同的整数值执行多次操作,因为它一次只能将 8 位装入其寄存器。

同样,对于没有浮点单元(FPU)的系统使用浮点变量意味着这样的操作非常适合使系统变得非常缓慢,因为只有整数处理器在努力跟上旨在模拟浮点操作的指令流。

不要重复造轮子

如果存在一个质量良好且适用于目标平台和项目许可的库或框架,那么使用它而不是编写自己的实现。

保留一个常用片段和示例的库作为参考,不仅是为了自己,也是为了其他团队成员。记住可以找到某个功能示例的地方比记住该功能的确切实现细节更容易。

在优化之前三思

优化代码的诀窍在于,在没有充分了解你提出的改变会产生什么影响的情况下,你不应该尝试这样做。仅仅有一种感觉或模糊的想法可能不够好。

虽然基于 SoC 的平台通常会给你更多的余地,但对于 MCU 平台来说,了解添加一个关键字或使用不同的数据结构来存储一些信息将意味着什么是至关重要的。

在这里最糟糕的事情是假设你在 SBC 和台式系统上使用的优化方法会对 MCU 平台产生类似的效果。由于修改后的哈佛架构和 AVR 等平台的各种怪癖,这些方法很可能会适得其反,或者幸运的话,只是无效。

在这里,为(MCU)平台提供的应用程序说明对于了解如何优化硬件非常有用。这里的要点是在进行优化之前进行研究,就像在考虑项目设计之前不会只是开始编写代码一样。

需求不是可选的

在没有为项目制定明确的需求的情况下编写嵌入式软件,就好像开始建造新房子却不清楚应该有多少个房间,窗户和门应该在哪里,以及管道和电线应该走在哪里。

虽然你完全可以开始编写可工作的代码,并在短时间内制作出一个功能原型,但现实是,这些原型通常会在没有充分考虑产品生命周期或那些将不得不在未来几年内不断修补固件以添加原始固件代码从未设计的功能的时间内投入生产。

完成产品需满足的需求后,这些需求被转化为架构(应用程序的整体结构),然后转化为设计(将要实现的内容)。设计然后被转化为实际的代码。

这种方法的优势在于,不仅需要回答很多关于为什么以某种特定方式完成某事的问题,而且还会产生大量文档,一旦项目完成,这些文档就可以实际使用。

此外,在嵌入式项目中,具备完整的需求集可以节省大量时间和金钱,因为它可以让人们在不必为了“以防万一”而在更强大的芯片上花费更多金钱,而是可以为项目选择合适的 MCU 或 SoC。它还可以防止尴尬的中期发现,即“遗忘”的功能突然需要改变硬件设计。

文档能拯救生命

程序员不喜欢编写文档,因此他们称自己编写的代码为“自我说明的代码”,这已经成为了一个笑话。事实是,如果没有清晰的设计需求、架构概述、设计计划和 API 文档,你就会冒着项目的未来以及依赖软件运行的其他开发人员和最终用户的风险。

在你开始编写第一行代码之前,按照程序进行所有无聊的文书工作似乎是完全令人沮丧的。不幸的是,现实情况是,如果没有这种努力,这些知识将继续锁在项目开发人员的头脑中,这会使固件集成到嵌入式项目的其余部分变得复杂,并且使未来的维护,特别是如果转移到不同的团队,成为一个艰巨的前景。

事实很简单,没有代码是自我说明的,即使是这样,也没有硬件工程师会浏览成千上万行的代码,以弄清在特定的输入条件下在特定的 GPIO 引脚上输出了什么样的信号。

测试代码意味着试图摧毁它

编写测试时的一个常见错误是编写你期望能够正常工作的测试场景。这样做是错过了重点。虽然当一个特定的解析例程在处理完美格式的数据时做到了它应该做的事情是很美妙的,但在现实场景中并没有多大帮助。

虽然你可能会得到完美的数据,但同样有可能在你的代码中得到完全损坏甚至垃圾数据。目标是确保无论你对输入数据做了多么可怕的事情,它都不会对系统的其余部分产生负面影响。

所有输入都应该经过验证和检查。如果有什么不对劲,就应该拒绝,而不是允许它在以后的代码中引起问题。

总结

在这个附录中,我们列举了在嵌入式软件设计中可能出现的一些常见问题和陷阱。

读者现在应该知道项目中存在哪些阶段,以及在项目的每一步都有记录的原因。

posted @ 2025-10-02 09:33  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报