65.9K
CodeProject 正在变化。 阅读更多。
Home

Sketch 框架和类库 - 第一部分

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2016年7月27日

CPOL

12分钟阅读

viewsIcon

18028

downloadIcon

213

为不同的 Arduino 板提供标准接口

引言

本文共三部分,探讨了针对 Arduino 板上运行的 Sketch 开发标准框架,以及用于封装板接口的 .NET 类库,以供 Windows 控制程序使用。

Sketch 框架提供了一种标准化的方式来接收控制应用程序的命令,并报告状态信息(或多或少是静态的)和动态值(例如从板上的输入引脚读取的值)。

类库使控制应用程序的开发者可以不必过多关注驱动串行通信的底层细节,而是能够编写一个能够与具有不同功能的设备协同工作的应用程序。

背景

在我(在大学心理学系)的工作中,我们经常需要在研究实验中测量各种环境和生物参数,以及使用一系列“特殊”的输入和输出设备向参与者呈现刺激并检测其反应。

这以前涉及定制电子设计和与控制实验的计算机进行接口,以及/或破解标准设备(如键盘和操纵杆)以使用特殊按钮或模拟输入。

我们通常还关注反应时间,因此最小化影响时序精度的延迟或抖动至关重要。

当 Arduino 发布时,我们立刻意识到它可以为各种传感器提供标准化的接口,并能够控制各种不同的输出设备。

各种生物和环境传感器可以直接连接到 Arduino,它们的值可以通过 USB 串行端口传达给实验,或者通过配置 Arduino 模拟标准的键盘或游戏控制器来传达。

一些问题很快显现出来——最基本的问题是如何一致地识别给定板上运行的是哪个 Sketch 和哪个版本。此外,能够询问固件具有哪些功能,并将答案以标准格式返回也很有用。

某些命令在各种应用程序中是通用的——例如设置 Sketch 主程序循环的长度,这促使我们开发了一个标准命令格式,以便所有实验应用程序都能够以通用方式与板进行通信。

这进一步促进了类库的开发,以封装与板的通信,并消除了开发主要实验软件的程序员/研究人员必须处理串行通信底层细节的复杂性。

本文介绍了一种解决这些问题的特定方案,该方案应适用于我们心理学研究项目以外的许多不同情况。如果您正在使用具有不同传感器和控制不同硬件的 Arduino,那么拥有一个标准框架可以大大简化工作。

这分为三个部分。第一部分,我们将定义一套通用的命令集和协议,并实现一个标准的 Sketch 框架。在这个阶段,我们不关心控制计算机方面的事情,只需要一个简单的终端程序——尽管 Arduino IDE 自带的终端无法向板发送控制字符,所以您需要像 TeraTerm 这样的工具。

第二部分开发了一个基本的类库,用于封装使用标准框架的任何板的接口。第三部分将开发一个演示控制程序。

使用代码

最早的决定之一是标准化 PC 和 Arduino 之间串行通信的通用波特率。在 9600 波特率下,线上的位时间约为 104 微秒(us),因此一个 10 位(包括起始位+停止位)的字节(或字符)传输需要 1.04 毫秒(ms)。由于我们关注的是毫秒范围内的时序精度和几十毫秒的采样间隔,这有点慢。我们决定标准化为 115200 波特率,并尽可能使用单个字符作为命令和响应。在此速率下,一个字符在传输时大约需要 87us ——然后两端都有驱动串行端口并向应用程序呈现接收到的值的低级处理开销。

从引脚返回字符串值通常会使用 5 个字符,这需要近 0.5 ms 的传输时间。实际上,这意味着我们无法比大约 2ms 的循环延迟更快地轮询此协议,但这对我们几乎所有应用程序来说都足够了。

在确定了通用波特率后,下一个问题是如何简单地识别给定板上运行的是哪个固件。

为了最小化发出命令时的响应时间,我们决定一个命令应由单个字符组成,后面可以选择性地跟随提供所需参数的字符,最后是一个换行符。我们决定保留 ASCII 控制字符(值小于 32)作为标准命令,并允许应用程序使用所有其他字符来实现其特定需求。

在标准框架中,我们将存储应用程序名称、版本号、编译日期和开发者姓名。我们还决定在编译时为每个板自动分配一个唯一的名称。

终于到了 Sketch 代码部分

#include <avr/pgmspace.h> 	//enable use of flash memory

static String unitID = "Demo-" + String(__TIME__); 
// __TIME__ has format hh:mm:ss – if we happen to compile at exactly the same time another day we will get the same ID, but pretty unlikely.
						
/* Sketch info strings */
const char sketchName[] PROGMEM = "SoP Demo";	// any valid string
const char sketchVer[] PROGMEM = "1.0.0";		// A.B[.C[.D]] 
const char sketchDate[] PROGMEM = __TIMESTAMP__;		
// __TIMESTAMP__ has format ddd dd MMM hh:mm:ss YYYY - a nightmare to parse!!!
const char sketchAuthor[] PROGMEM = "RogerCO";	// any valid string
const char* const sketchArr[] PROGMEM = { sketchName, sketchVer, sketchDate, sketchAuthor };

char strbuffer[50];  // variable to copy strings from PROGMEN when required

我们在这里所做的是将一些标准信息定义为字符串常量,并将它们保存在程序(闪存)存储器 PROGMEM 中,以节省 SRAM 空间。

我们将 UnitID 存储在 SRAM 中作为一个变量,以便我们可以在需要时稍后更改它。

我们将使用 Ctrl S (ASCII 19) 作为单个字符命令,单位将通过返回 Sketch 信息来响应。

主程序循环通常会执行所有必需的输入引脚轮询,然后检查串行端口是否有任何传入命令并作出相应响应。

static long loopTime = 20;	// default delay in mS used in main loop

void loop()
{
	//do any read/write ports stuff 

	// deal with any serial commands
	char serialChar;			
	while (Serial.available() > 0) {  
		serialChar = Serial.read();
		if (serialChar = 19) {	// standard commands are Ctrl chars
			reportSketch();
		} else { 	// deal with any sketch specific commands	

	} // endwhile serial port has bytes available

	// do any final tidying up required at end of loop and wait until we go again
	delay(loopTime);

} // end main loop

当我们从 Sketch 返回状态值时,我们将使用一种标准格式:单个字符标识符后跟一个等号,然后是值,最后是行终止符。这将允许我们以标准方式解析响应。

void reportSketch(void) {
/* reports sketch details */
	strcpy_P(strbuffer, (char*)pgm_read_word(&(sketchArr[0]))); //sketch name
	Serial.print("S="); Serial.println(strbuffer);
	strcpy_P(strbuffer, (char*)pgm_read_word(&(sketchArr[1]))); //sketch version
	Serial.print("V="); Serial.println(strbuffer);
	strcpy_P(strbuffer, (char*)pgm_read_word(&(sketchArr[2]))); //sketch comiled date
	Serial.print("D="); Serial.println(strbuffer);
	strcpy_P(strbuffer, (char*)pgm_read_word(&(sketchArr[3]))); //sketch author
	Serial.print("A="); Serial.println(strbuffer);
	Serial.print("N="); Serial.println(unitID);
}

这里我们正在从 PROGMEM 读取信息,并将标识符和字符串写入串行端口。

与其硬编码标识符,不如首先将它们全部定义为常量。通常,Sketch 会返回两种类型的数据——很少更改的状态信息,以及在主循环的每一次迭代中返回的动态值,或者响应 Arduino 上的中断输入。

对于状态信息,我们将通过一个单一字符后跟一个等号,然后是数据,最后是行终止符来标识返回的内容。除了上面显示的 Sketch 名称、版本、日期、作者和单元 ID 之外,我们可能还需要各种其他标准命令和响应。我们将保留一批大写标识符字符用于标准响应。我们不会使用控制字符作为响应标签,以便它们保持人类可读。

如果我们每秒轮询超过十次(循环时间小于 100ms),并且每次循环都通过串行端口发送值,那么计算机可能会收到大量数据。从控制端打开或关闭此功能可能会很方便。此外,如果 Arduino 上的引脚配置为中断,或者我们正在使用生成串行端口输出的定时器中断,我们也可能希望远程启用或禁用它们。

一个常见的需求是调整主程序循环的长度,并报告循环时间中的任何错误或抖动。

/* variables associated with standard status info */
static unsigned long loopTime = 20;  // default delay in mS used in main loop
static boolean sendSerialValues = false; // default not send values to serial port
static boolean sendSerialInts = false; // default not send interrupts to serial port

我们还发现,我们希望 Sketch 能够告诉我们它的输入和输出引脚是如何使用的,它响应哪些命令(除了标准集之外),以及它返回哪些状态或值。

这是一组我们正在使用的标准控制字符命令的标识符

/* standard ctrl commands */
static const byte cmdLoopOn = 1;	//ctrlA enables loop values out to serial
static const byte cmdIntOn = 2;		//ctrlB enables interrupt values to serial
static const byte qCmdDefn = 3;		//ctrlC report sketch command definitions
static const byte qStatusDefn = 4;	//ctrlD report sketch status definitions
static const byte qValDefn = 5;		//ctrlE report sketch value definitions
static const byte qIns = 6;			//ctrlF report input pins used
static const byte qOuts = 7;		//ctrlG report output pins used
static const byte qSketch = 19;		//ctrlS report sketch name, ver, date, author
static const byte cmdLoopTime = 20;	//ctrlT set or get main loop time in ms
static const byte cmdUnitID = 21;	//ctrlU set or get UnitID
static const byte cmdLoopOff = 24;	//ctrlX disable loop values out to serial
static const byte cmdIntOff = 25;	//ctrlY disable interrupt values to serial
static const byte qErrors = 26;		//ctrlZ report timing errors

这是一组对应的状态标签

/* standard status flags */
static const String lblAuthor = "A=";	// Sketch author
static const String lblCmd = "C=";		// Command definition
static const String lblDate = "D=";	    // Sketch Date
static const String lblStatus = "E=";	// Status definition
static const String lblValue = "F=";	// Value definition"
static const String lblIn = "G=";		// Input pin definition"
static const String lblOut = "H=";		// Output pin definition"
static const String lblIntOn = "I=";	// Interrupt output enabled 0|1
static const String lblUnitName = "N=";	// Unit ID
static const String lblLoopOn = "O=";	// Loop output enabled 0|1
static const String lblSketch = "S=";	// Sketch name
static const String lblLoopTime = "T=";	// Main Loop time ms 
static const String lblVersion = "V=";	// Sketch version (as version string format)
static const String lblErrors = "Z=";	// Timing errors report

此外,还需要定义一些特定于 Sketch 的命令和响应。例如,假设我们的板正在读取一个模拟输入,并在每次循环时将值报告给控制程序。它还可能闪烁一个 LED 来指示已达到阈值。我们可能需要命令来启用或禁用 LED 输出,并设置 LED 脉冲的最小持续时间。

/* example sketch specific commands and descriptors */
static const byte cmdDoLED = 'L';
const char Lcstr[] PROGMEM = "L=Enable LED";
static const byte cmdNoLED = 'l';
const char lcstr[] PROGMEM = "l=Disable LED";
static const byte cmdPulseWidth = 'p';
const char pcstr[] PROGMEM = "p=Set/Get LED pulse duration (ms)";
const char* const cmdArr[] PROGMEM = { Lcstr, lcstr, pcstr };
static int cmdSize = 3;

这里我们定义了此 Sketch 将响应的三个命令及其描述。当我们在打开或关闭功能时,我们将使用大写命令打开它,使用小写命令关闭它。如果命令需要一些参数,例如脉冲宽度的值,那么它将紧跟在命令字符之后,并以换行符终止。

我们还需要定义对应于 Sketch 特定的值和状态信息的标签和描述。同样,我们将把它们存储在 PROGMEM 中。

定义响应都有一个标准格式。每个定义占一行,以适当的标签开头,然后是定义,然后是换行符。因此,当 Sketch 收到命令(例如 qCmdDefn)时,它会遍历此 Sketch 的有效命令列表并返回每个命令,每个命令都单独一行,前面加上 lblCmd。

响应命令 CtrlS,如前面所见,我们返回有关 Sketch 的信息。响应 CrtlC 是返回 Sketch 将识别的其他命令及其描述的列表。每个命令和描述将列在单独一行上,以“C=”开头。

int x;

void reportCommands(void) {
/* returns a list of all of the command characters recognised by this sketch */
	for (x = 0; x < cmdSize; x++) {
		strcpy_P(strbuffer, (char*)pgm_read_word(&(cmdArr[x])));
		Serial.print(lblCmd);
		Serial.println(strbuffer);
	}
}

此函数将遍历 PROGMEM 中的命令定义数组,并将它们输出到串行端口。控制程序将接收到这个

C=L=启用 LED

C=l=禁用 LED

C=p=设置/获取输出脉冲宽度(毫秒)

现在,控制程序可以知道,如果我们向 Arduino 发送一行开头的“L”,它将启用 LED 输出,如果我们发送“p500”,它将把脉冲宽度设置为 500ms。

为其他标准定义命令 CtrlE、CtrlF、CtrlG 和 CtrlH 定义了类似的函数:reportStatusCodes()、reportValueCodes()、reportInputPins()、reportOutputPins()

我们将返回任何在循环中报告的动态值,同样使用单个字符标识符,然后是冒号“:”,然后是值作为字符字符串,以换行符终止。如果需要,我们可以通过仅在值更改时返回它们,或者将它们保存在本地存储中并在响应特定命令时读取它们来减少输出。

/* Sketch value variables, identifier labels and descriptors */
static int anaVal;
static const String lblAnaVal = "A:";
const char avstr[] PROGMEM = "A:,Analogue input value (arbitary units)";
const char* const valArr[] PROGMEM = { avstr };
const int valSize = 1;

如果 `sendSerialValues` 为 true,那么我们将从板上获得一个值流

A:126
A:132
A:129
...

现在我们可以编写一个通用函数来处理控制命令,并为每个命令编写特定函数。

在主程序循环中

// ...
while (Serial.available() > 0) {  
		serialChar = Serial.read();
		String cmdstr;
		if (serialByte < 28) {	// standard commands are Ctrl chars
			doStdCmd(serialChar);
		} else { 	// deal with any sketch specific commands	

	} // endwhile serial port has bytes available
// ...

 

doStdCmd() 函数用于处理标准命令(ascii 值小于 32),以及一个示例函数,该函数读取命令后的参数值以设置循环时间

void doStdCmd(char cmd) {
/* handles a standard control command */
	switch (cmd) {
    case qSketch:	
        reportSketch();
		break;
	case cmdLoopOn:
		sendSerialValues = true;
		Serial.print(lblLoopOn); Serial.println(sendSerialValues);
		break;
// other command function calls as required - see full example
	case cmdLoopTime:	// in this case we are expecting a parameter value
		setLoopTime();
// we always report back the current loop time, so sending just CtrlT will give us the current value without changing it
		Serial.print(lblLoopTime); Serial.println(loopTime);
		break;
	}
}

void setLoopTime (void) {
/* example of handling a command that has a numeric parameter */
	boolean endOfDigits = false;
	String cmdStr = "";
int inChar;
	while (!endOfDigits && Serial.available()) { 
//read digits from the port while available until a non-digit char found
		inChar = Serial.read();
		if (isDigit(inChar)) {
			cmdStr += (char)inChar;
		} else {
			endOfDigits = true;
		}
	}
	if (cmdStr.length() >= 1) { // if we have a number > 0 set new loopTime
		long tmp = cmdStr.toInt();
		if (tmp > 0) loopTime = tmp; 
	}
}

好的,假设我们已经在 PROGMEM 中定义了所需的状态、值、输入和输出定义,并编写了相应的 reportStatusCodes() 等函数,那么我们应该有了一个基本的框架,并对如何处理特定于 Sketch 的命令有了一些想法。这些都显示在可供下载的完整示例框架代码中。

最后一点,我们碰巧非常关注确保主程序循环的执行时间始终相同,无论在每次循环中可能涉及多么复杂的命令处理、中断处理或值转换。

简单来说,我们将记录循环开始和结束时的内部毫秒定时器 millis() 的时间,并相应地调整我们使用的延迟。

/* variables to adjust length of timing loop */
unsigned long loopStart;
unsigned long loopEnd;
static unsigned long loopTime = 20;	// default delay in mS used in main loop
int exeTime;

void loop()
{
	loopStart = millis();

	// do other stuff as required

	// correct timing errors
	loopEnd = millis();
	exeTime = loopEnd - loopStart;
//trap error if millis() wraps around during this loop, this will happen every few days, you can calculate how often if you read the documentation ...
	if (exeTime > 0) { exeTime = 0; }  
	if (exeTime > loopTime) 
		delay(loopTime - exeTime);
	} else {			
// if the actual loop execution time is greater than the desired delay then we have overrun so no delay and optionally write some code here to track the errors and report them in response to a qErrors command ... 
	}

} // end main loop

就是这样。现在我们有了一个框架,可以用于任何 Sketch,它可以响应一些标准命令,使我们能够识别正在运行的内容及其功能。

关于工具的最后一点说明。显然,您可以在任何喜欢的开发环境中编写 Sketch 代码,包括 Arduino 原生 IDE,您也需要它来编译和上传代码到板上。

由于我们的大部分其他工作都在 Windows 上进行,我们广泛使用 Visual Studio,我推荐 Visual Micro 插件。它将 Arduino 开发工具封装在 Visual Studio 中,并让您可以使用 Intellisense 和您习惯的所有其他工具。您必须在 Visual Studio 中安装 C++。

在第二部分,我们将利用此框架提供的功能,构建一个类库,该类库封装了用于 Windows 窗体应用程序的 Arduino 接口,包括识别 Arduino 连接到的虚拟串行端口,并以一致的方式呈现其状态信息和值。

关注点

我们定义了一个标准的通信协议,用于向 Arduino 板发送命令,并返回状态信息和动态值。

我们使用 PROGMEM 将字符串常量存储在合适的位置。在未来的文章中,我们将探讨如何使用 EEPROM 来保存状态变量,以便在板断电时保留它们。

我们可以查询 Sketch,询问它正在使用哪些引脚、它响应哪些命令、它返回哪些状态信息和值。

我们力求确保主程序循环的执行时间一致,无论每次循环中发生什么处理。因此,如果我们每 20ms 轮询一个传感器,我们每 20ms 就会从 Arduino 收到一个结果——当然,控制计算机操作系统中的开销可能是可变的,这是一个完全不同的主题。Windows 和 Linux/MacOS 对应用程序开发者来说都不是实时操作系统。

如果您正在制作多个具有不同功能的 Arduino 项目,那么为所有 Sketch 使用一个标准框架从长远来看可以大大简化工作。当然,每次都需要包含标准代码会带来一些开销,但如果您是熟练的 C 程序员,您会找到将所有标准函数放入一个库的方法,您只需在每个项目的顶部 #include 即可。

历史

首次提交版本 2016 年 7 月 26 日,更正了拼写错误并添加了一些小内容 7 月 27 日

2016 年 8 月 1 日更新代码以清理一些错误并添加注释

© . All rights reserved.