原文:
zh.annas-archive.org/md5/B28E444E77634E28D12AD6F4C3A426AD
译者:飞龙
第二部分:测试,监控
在本节中,您将学习为各种嵌入式平台开发的正确工作流程,包括测试策略和编写可移植代码的重要性。
本节将涵盖以下章节:
-
第六章,测试基于操作系统的应用程序
-
第七章,测试资源受限平台
-
第八章,示例 - 基于 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 的工具链(如果尚未安装),以及任何常见的相关工具和实用程序,包括make
,libtool
,flex
等。第二条命令安装了实际的交叉编译器。最后,第三个软件包是支持多种架构的 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 倍),能够在快速系统上进行大部分调试和分析意味着我们可以节省大量时间,然后再开始在目标硬件上进行测试。
在我们可能经常使用的工具中,Memcheck、Helgrind和DRD对于检测内存分配和多线程问题非常有用。一旦我们的代码通过了这三个工具,并使用提供代码广泛覆盖的广泛集成测试,我们就可以进行分析和优化。
为了对我们的代码进行分析,我们使用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 分别位于Core
和extern
文件夹中。其中,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
类这样的类足够抽象,可以直接使用。正如我们将看到的,Filesystem
和Timer
类需要进行大量的重写,以使它们适用于我们的目的。我们已经在之前看过对 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 编译libmosquitto
和libmosquittopp
库文件。同样,您应该将生成的库文件安装在链接器可以找到的位置。
当不使用 MinGW 时,也可以通过操作系统的软件包管理器或类似方式使用通常可用的版本。
经过这些步骤,我们可以使用以下命令行命令从项目的根目录编译服务器和客户端:
make
这应该使用顶层 Makefile 编译服务器和节点项目,分别在它们各自的bin/
文件夹中生成可执行文件。您应该确保服务器的Node
类中的可执行文件名称和路径与节点可执行文件的位置匹配。
我们现在应该能够运行项目并开始收集测试结果。该项目包括一个精简版本的基于 ESP8266 的 BMAC 固件,我们将在第九章中详细介绍,示例 - 建筑监控和控制。请参考该章节,了解如何通过 MQTT 与模拟节点通信,如何在固件中打开模块,以及如何解释模块通过 MQTT 发送的数据。
在按照该章节中描述的设置之后 - 至少需要一个 MQTT 代理和一个合适的 MQTT 客户端 - 并在模拟节点中打开 BME280 模块后,我们期望它开始通过 MQTT 发送我们为模拟节点所在房间设置的温度、湿度和气压值。
总结
在本章中,我们看到了如何有效地为基于 MCU 的目标开发,以便我们可以在不昂贵和冗长的开发周期中测试它们。我们学会了如何实现一个集成环境,使我们能够从桌面操作系统和提供的工具舒适地调试基于 MCU 的应用程序。
读者现在应该能够为基于 MCU 的项目开发集成测试,并有效地使用基于操作系统的工具对其进行分析和调试,然后在真实硬件上进行最终集成工作。读者还应该能够进行芯片内调试,并对特定软件实现的相对成本有所了解。
在下一章中,我们将开发一个基于 SBC 平台的简单信息娱乐系统。