C++17 嵌入式编程实用指南(二)
在本章中,我们看了如何为新项目选择合适的 MCU,以及如何添加外围设备并处理项目中的以太网和串行接口要求。我们考虑了各种 MCU 中内存的布局以及如何处理堆栈和堆。最后,我们看了一个 AVR 项目的示例,如何为其他 MCU 架构开发,并是否使用 RTOS。在这一点上,读者应该能够根据一组项目要求来论证为什么选择一个 MCU 而不是另一个。他们应该能够使用 UART 和其他外围设备来实现简单的项目,
原文:
zh.annas-archive.org/md5/B28E444E77634E28D12AD6F4C3A426AD译者:飞龙
第四章:资源受限的嵌入式系统
使用较小的嵌入式系统,如微控制器(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 已经集成到电路中,如下图所示:

在这里,如果存在内部振荡器,则外部振荡器是可选的。 PDI,PDO和SCK线对应于它们各自的 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()内存分配器例程。使用它,可以实现自己的new和delete功能,如果有需要的话,因为 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 以指示缓冲区已满但尚未找到换行符。
接下来是index和chip命令的处理函数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);
}
对于实际测试,我们使用从文件中读取的第一个字符串进行测试,解析它以获取输入引脚的值。对于每个引脚,我们首先获取它的编号,然后获取值(0或1)。
在将这些引脚编号和值回显到串行输出之前,我们将这些引脚的模式设置为输出模式,然后将测试值写入到每个引脚,如下所示:
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.py和esptool2。在/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_D0,D1)或四条(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头文件,它是间接包含的。它的源文件实现了new和delete函数,以及一些与类相关功能的处理程序,比如在使用虚拟类时的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,也被称为D1和D2。预定义这些引脚的主要原因是它们是 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 和其他大型嵌入式平台编写的嵌入式软件。
openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。
更多推荐



所有评论(0)