适用于Arduino的快速数字I/O






4.88/5 (26投票s)
本文介绍了一种更快但仍易于使用的Arduino数字I/O版本
引言
本文介绍了我的Arduino数字输入/输出函数版本,它比“内置”函数运行更快,同时保持与原始函数一样易用和可移植。如果您只想尝试新功能,请直接跳到“使用代码”部分,否则,请继续阅读以了解一些问题介绍。
众所周知,Arduino中的数字I/O函数相当慢。使用Arduino的digitalWrite
函数改变输出引脚的逻辑电平(例如,打开LED)大约需要4微秒,而如果您使用Atmel AVR微控制器(Arduino板的大脑)的I/O寄存器“原生”编写代码,则需要不到0.1微秒。当然,对于大多数用户来说,是0.1微秒还是4微秒并不重要,它仍然足够快,但在某些情况下,速度或功耗至关重要。
不久前我写了一篇关于这个主题的文章——“为什么Arduino中的数字I/O很慢,以及如何解决?”。这篇文章主要是关于为什么这些函数很慢;它没有提供任何易于使用的解决方案。在这篇新文章中,我终于提出了我的解决方案。
当然,最终的解决方案是直接使用I/O寄存器。换句话说,更接近硬件。但这样你会失去程序的可移植性。虽然digitalWrite
适用于任何Arduino板,但这种方法只适用于一块板,并且您必须修改代码才能在不同的板上使用它(例如,如果您从Arduino Uno切换到Arduino Mega)。此外,您需要了解I/O寄存器才能使用它们。
我曾想过数字I/O能否更快,同时也能保持可移植性和易用性。我尝试了各种选项(本文末尾有描述),并提出了这里介绍的解决方案。
新数字I/O实现的主要特点
- 已为标准Arduino (Uno) 和 Arduino Mega 实现并测试。
- 易于移植到其他开发板。
- 如果引脚号是常量,则速度非常快;即使引脚号是变量,也比标准Arduino I/O快得多。
- 可以像Arduino函数一样使用,只需在函数名称后添加“2”,例如
digitalWrite2(13, HIGH);
。 - 如果您不介意使用特殊引脚代码而不是简单的引脚号,您可以使用更快的函数,例如
digitalWrite2f(DP1, HIGH);
。 - 通过复制3个文件即可轻松添加到Arduino中。
- 可轻松决定是优先选择速度还是最小化程序大小。
- 正确处理超出范围的引脚号(与标准Arduino I/O不同)。
我知道这听起来像是在打广告,所以这里是数据。下表比较了Arduino标准I/O和新版本(我简称为I/O 2,因为缺乏想象力)的速度。
注意:' us' 用作微秒的符号。
Arduino 标准 (Uno)
引脚是常量 | 引脚是变量 | 测试程序大小 | |
Arduino I/O “原样” | 4.1 微秒 | 4.1 微秒 | 3098 B |
不带定时器检查的 Arduino I/O(见下注1) | 3.4 微秒 | 3.4 微秒 | 2998 B |
带整数参数的 I/O 2(见下注2) | 0.8 微秒 | 2.0 微秒 (2.8 微秒) | 2876 B (2944 B) |
带原生参数(引脚代码)的 I/O 2 | 0.6 微秒 | 1.1 微秒 (1.9 微秒) | 2856 B (2924 B) |
Arduino Mega
引脚是常量 | 引脚是变量 | |
Arduino I/O “原样” | 6.8 微秒 | 6.8 微秒 |
不带定时器检查的 Arduino I/O(见下注1) | 3.4 微秒 | 3.4 微秒 |
带整数参数的 I/O 2(见下注2) | 1.0 微秒 | 2.2 微秒 (3.2 微秒) |
带原生参数(引脚代码)的 I/O 2 | 0.8 微秒 | 1.4 微秒 (2.3 微秒) |
结果是使用Arduino软件版本1.0.5-r2获得的;测试程序是在Arduino IDE中以默认设置构建的。
I/O 2 函数括号中的数字是用户选项设置为优先选择程序小尺寸而不是速度时获得的时间(这实际上意味着I/O函数不是“内联”到代码中,而是被调用)。
注1:在Arduino的digitalWrite
和digitalRead
实现中,有一个检查受影响的引脚是否被定时器使用。每次写入或读取引脚时都进行此检查似乎是“愚蠢的”——正如Arduino开发者自己在源注释中所写——因此我没有在我的数字I/O函数中包含此检查。为了提供公平的比较,我测量了标准Arduino函数在禁用此检查和标准“开箱即用”版本下的速度。请注意,对于Arduino Mega,这个“愚蠢的”定时器检查占执行digitalWrite
所需时间的大约一半!
注2:对于我创建的数字I/O函数,“原生”的引脚识别是“引脚代码”,而不是Arduino中使用的普通引脚号。但是,我意识到对于许多人来说,在程序中使用普通数字更容易,所以我创建了包装函数,它们将引脚号作为整数,将其转换为引脚代码,然后调用原生函数。令我惊讶(和满意)的是,即使这些包装函数仍然比原始Arduino I/O快得多。
Arduino IDE 1.6.0的新速度结果
2015年3月,我使用新的Arduino 1.6.0 IDE,并采用了一种不同且更精确的方法再次测量了速度:以全速切换输出引脚为高电平和低电平,并用示波器测量输出。以下是Arduino Uno的结果
函数 | 速度(微秒) |
Arduino I/O “原样” | 5.18 |
不带定时器检查的 Arduino I/O | 3.52 |
DIO2 带整数参数,非常量 | 1.79 |
DIO2 带整数参数,常量 | 1.16 |
DIO2 带原生参数,非常量 | 0.97 |
DIO2 带原生参数,常量 | 0.19 |
与旧结果相比,存在两个显著差异
- Arduino "As is" 速度比以前低 (5.2 vs. 4.1 us)。我猜这是因为检查引脚是否在计时器上的一些代码更改,因为不检查的速度大致相同。
- DIO2 使用原生参数(引脚代码)作为常量的速度要高得多。这里我猜我之前测量速度时犯了一个错误。0.19微秒非常接近预期结果,即0.125微秒(单个SBI指令,在16兆赫下需要2个CPU时钟周期)。如果您运行库中包含的测试程序,该程序测量100次digitalWrite,您将获得大约0.35微秒,因为有循环开销。
使用代码
在您的程序中使用 I/O 2 函数有两种方法
1) 作为 Arduino 库安装 (名为 DIO2)
或者
2) 将 3 个文件复制到您的 Arduino 安装目录
选项2从一开始就得到支持。我于2015年3月添加了库选项(1),因为这似乎是扩展Arduino环境的正确方法。不幸的是,您仍然需要将一个文件复制到Arduino位置的正确文件夹中,因为无法从库代码中确定选择了哪个板,并且需要针对特定板定义引脚。库选项的优点是您在编辑器中可以为新函数进行语法着色,可以轻松访问示例程序,并且如果需要为Uno或Mega以外的(当前DIO2支持的)其他板构建程序,您将不会遇到错误。
选项 1 - 用作 Arduino 库
步骤 1: 像安装其他 Arduino 库一样安装 DIO2 库,即将下载的文件解压到您的 Arduino 库文件夹中,或者使用 Arduino 下拉菜单“草图”>“导入库”中的自动库安装功能。
请注意,自动库安装只会将库安装到您的用户配置文件中;即Documents\Arduino\libraries文件夹(至少这是我在Win7电脑上找到它的位置)。
步骤 2: 将您计划使用的板的 pin2_arduino.h 文件复制到您的 Arduino 位置的相应文件夹中。
您会在附件的zip文件(或解压后的库文件夹)中的[zip file]\board\[board]找到此文件,其中[board]对于Arduino Uno是standard,对于Arduino Mega是mega。
此目标文件夹为
适用于 Arduino 1.6.0 IDE
[你的_arduino_位置]\hardware\arduino\avr\variants\[板名]
示例:c:\arduino-1.6.0\hardware\arduino\avr\variants\standard 或
c:\arduino-1.6.0\hardware\arduino\avr\variants\mega
适用于旧版 Arduino 1.0.x IDE
[你的_arduino_位置]\hardware\arduino\variants\[板]
例如:c:\arduino-1.0.5-r2\hardware\arduino\variants\standard 或
c:\arduino-1.0.5-r2\hardware\arduino\variants\mega。
请注意,pin2_arduino.h 文件对于 Arduino 标准版和 Arduino Mega 版是不同的。请为您的 Arduino 变体使用适当的文件。
另请注意,您没有覆盖 Arduino 安装中的任何内容,并且您仍然可以使用原始的数字 I/O 功能。
选项2 - 将所需文件复制到您的 Arduino 目录
如果您选择使用此选项而不是库,您需要将3个文件复制到您的Arduino位置的相应文件夹中。您将在附件的zip文件中找到这些文件;源文件和目标位置如下:
Arduino 1.6.0 IDE
将 arduino2.h 和 digital2.c 从 [zip file]\src\ 复制到 [your_arduino_location]\hardware\arduino\avr\cores\arduino\。
将 pins2_arduino.h 从 [zip file]\board\standard 或 mega 复制到 [your_arduino_location]\hardware\arduino\avr\variants\standard 或 mega。
Arduino 1.0.x IDE
将 arduino2.h 和 digital2.c 从 [zip file]\src\ 复制到 [your_arduino_location]\hardware\arduino\cores\arduino\
将 pins2_arduino.h 从 [zip file]\board\standard 或 mega 复制到 [your_arduino_location]\hardware\arduino\variants\standard 或 mega。
请注意,使用此选项2有一个缺点:如果您需要为Uno或Mega以外的其他Arduino变体构建程序,由于该变体缺少pins2_arduino.h文件,您将遇到构建错误。为了解决这个问题,您可以将[zip file]\board\dummy中提供的“虚拟”pins2_arduino.h文件复制到Arduino变体文件夹中的相应文件夹。
测试程序
完成上述操作后,启动Arduino IDE并像往常一样创建一个新程序(草图)。您现在可以使用下面描述的I/O 2函数,而不是标准的Arduino函数。
这是一个闪烁引脚13上的LED的示例程序。它应该在Arduino Uno和Mega上无需更改即可运行。
/*
Blink2 - Blink example modified for using digital I/O 2 instead of standard Arduino digital I/O.
*/
//include the fast I/O 2 functions
#include "arduino2.h"
// Pin 13 has an LED connected on most Arduino boards.
// give it a name:
const int led = 13;
// the setup routine runs once when you press reset:
void setup() {
// initialize the digital pin as an output.
pinMode2(led, OUTPUT);
}
// the loop routine runs over and over again forever:
void loop() {
digitalWrite2(led, HIGH); // turn the LED on (HIGH is the voltage level)
delay(1000); // wait for a second
digitalWrite2(led, LOW); // turn the LED off by making the voltage LOW
delay(1000); // wait for a second
}
与正常的Arduino Blink示例相比,只有两个小改动
- 文件开头有
#include "arduino2.h"
- 使用函数名后追加“2”的标准Arduino函数,例如
pinMode2()
而不是pinMode()
。
您还可以进行第三个可选更改——在#include "arduino2.h"行上方添加一个#define,它决定您是偏爱快速程序执行还是更小的程序大小
#define GPIO2_PREFER_SPEED 1
#include "arduino2.h"
如果GPIO2_PREFER_SPEED
设置为1,I/O 2函数将被声明为inline
,这将导致更快的执行速度,但如果您多次使用这些函数,程序可能会变得很大,因为每次在程序中调用该函数时,该调用都会被函数的完整代码替换。
如果GPIO2_PREFER_SPEED
为0,则函数将正常调用,这意味着程序中只存在每个函数的一个副本。这可以节省一些程序内存,但程序运行速度较慢(函数调用会占用一些CPU时间)。
如果您未定义此常量,将使用默认值1。在大多数情况下,我建议使用值1,即优先选择速度。仅当您的程序内存耗尽时,才尝试将值更改为0。然而,程序不太可能仅仅通过数字I/O就耗尽所有程序内存,因此您实际上不需要使用值0。
如果您希望您的数字I/O运行更快,并且可以接受将引脚称为DP1
而不是1,您可以使用名称中带有“2f”的函数。如上所述,这些是I/O 2库的原生函数,它们没有将整数引脚号转换为特殊引脚代码的开销。以下是使用这些原生函数的Blink示例代码
#include "arduino2.h" // include the fast I/O 2 functions
// The I/O 2 functions use special data type for pin
// Pin codes, such as DP13 are defined in pins2_arduino.h
const GPIO_pin_t led_pin = DP13;
void setup() {
pinMode2f(led_pin, OUTPUT);
}
void loop() {
digitalWrite2f(led_pin, HIGH);
delay(1000);
digitalWrite2f(led_pin, LOW);
delay(1000);
}
请注意,我们不是将引脚定义为int
,而是将其定义为GPIO_pin_t
。此数据类型将在下面的参考部分中描述。
示例程序
在附件中,还提供了适用于Arduino标准版(Uno)和Mega的示例程序(草图)。除了上面提到的简单闪烁示例,每块板都提供了三个程序:
- 用于测量 digitalWrite 速度的程序
- 用于测试所有输出的程序
- 用于测试所有输入的程序
您将在源代码的注释中直接找到这些示例的使用说明。
参考
GPIO_pin_t
- 用于数字引脚的数据类型。引脚的名称是DP + 引脚号。例如,连接LED的数字引脚13被称为DP13
。使用这种特殊数据类型而不是简单的数字(int)可以使函数运行更快。如果您想知道原因,请参阅“工作原理”部分。
接受简单引脚号的函数
这些函数与原始的Arduino I/O函数完全兼容。只需在原始函数名称后添加“2”,您就使用了新的更快版本。
void pinMode2(uint8_t pin, uint8_t mode);
设置引脚方向。可选模式为 INPUT, INPUT_PULLUP 和 OUTPUT。
uint8_t digitalRead2(uint8_t pin);
读取给定引脚的值。该引脚应事先使用 pinMode2 配置为输入。返回值是 HIGH 或 LOW。
void digitalWrite2(uint8_t pin, uint8_t value);
将给定引脚设置为HIGH或LOW电平。该引脚应使用pinMode2配置为输出。
使用引脚代码的函数
这些函数比之前的函数更快。它们与原始的Arduino函数不同之处在于引脚参数的类型——它们使用GPIO_pin_t
而不是uint8_t
(或int
)。因此在您的程序中,您将引脚定义为
GPIO_pin_t pin = DP1;
而不是
int pin = 1;
.
其他参数与标准Arduino函数相同。
void pinMode2f(GPIO_pin_t pin, uint8_t mode);
uint8_t digitalRead2f(GPIO_pin_t pin);
void digitalWrite2f(GPIO_pin_t pin, uint8_t value);
工作原理
在本节中,我将描述新的I/O函数内部的工作原理。此版本的函数是许多不同方法实验的结果。对于感兴趣的读者,我将在本文末尾描述这些各种方法。
首先,有必要稍微了解一下微控制器中数字I/O的工作原理。
简单解释
这个解释(希望)更容易理解,但也被简化和不准确。想象一下,对于Arduino中使用的微控制器,Arduino板上名为13的引脚将被识别为引脚号8229。对我们来说,在程序中写8229来指代这个引脚是不切实际的。我们希望能够简单地写13。另一方面,如果我们每次对引脚进行读写操作时都强制微控制器将数字13“翻译”成8229,这将耗费一些CPU时间。如果我们通过为引脚定义符号名称,像这样在代码中“离线”进行这种翻译
#define PIN_13 8229
它会运行得更快。
这样,在我们的程序中,我们可以使用符号PIN_13
,它对人类仍然有意义,并且实际上给微控制器它需要的数字。编译器将完成将PIN_13翻译为8229的工作。
更精确的解释
Arduino 的大脑,一个微控制器(简称 MCU),将 I/O 引脚组织成端口。端口命名为 A、B、C 等。每个端口有 8 个引脚。因此,例如,端口 A 有 0 到 7 号引脚(称为 PA0 到 PA7),端口 B 有 0 到 7 号引脚(PB0 到 PB7),等等。
每个端口都由一组寄存器控制。例如,有一个用于设置方向(输入或输出)的寄存器,还有一个用于设置引脚电压电平(高或低)的寄存器。现在,我们只考虑设置输出引脚电压电平的寄存器。我们称这个寄存器为数据寄存器。您可以将这个寄存器想象成程序中一个8位长的普通变量。这个变量中的每个位控制给定端口的一个引脚。例如,要将端口D中的引脚0设置为逻辑1(高电压),您需要将端口D的数据寄存器(PORTD)中的位0设置为1。C语言代码可以像这样
PORTD = PORTD | 1;
或简称
PORTD |= 1;
你也可以这样写
PORTD = 1;
但这样你不仅会将位0设置为逻辑1,还会将所有其他引脚设置为逻辑0。你将设置整个字节(所有8位/引脚)的值,并且由于你写入的值是1(二进制0000 0001),你实际上是在说“将第一位设置为逻辑1,并将所有其他位设置为逻辑0”。通常,你只想改变一个位而保持其他位不变,这就是为什么有OR操作。
我们正在解决的问题
在Arduino程序中,我们不想处理寄存器、OR和AND操作,我们只想编写digitalWrite(pin, value);
,并让软件库将其转换为适当的PORTx |= bit_mask;
。问题是如何尽可能快地,和/或用尽可能少的代码进行这种转换。
解决方案
在尝试了其他选项后,我得出结论,如果I/O函数能将其所需信息作为输入参数,则可以实现最快的操作。这些信息是控制端口的寄存器地址和该端口内引脚的位掩码。这两个信息被编码成一个16位数字,我称之为引脚代码。这个16位数字的低字节包含数据寄存器的地址,高字节是引脚的位掩码。让我们看一个例子来明确说明:
对于Arduino Uno,LED连接到数字引脚13。在MCU中,这实际上是端口B上的引脚5 (PB5)。端口B的数据寄存器地址是0x25(此信息来自MCU数据手册)。引脚5的位掩码是一个字节,其中位5的值为1,所有其他位为0。所以二进制是00100000;十六进制是0x20。您也可以在C程序中通过写入 (1 << 5) 来创建这个数字——将1左移5次。请注意,位5实际上是指从右侧开始的第6个位,因为位号从0开始。
综上所述,Arduino 引脚 13 的引脚代码将是:0x2025。
虽然可以这样处理引脚代码,但C语言提供了一些工具使其使用更舒适和安全。首先,我们不会将引脚代码定义为简单的整数,而是为其创建一个新的数据类型。这种类型称为GPIO_pin_t
,表示“这是一个引脚”,而不仅仅是“任何整数”。如果这样做,编译器可以帮助我们检测I/O函数的错误使用。如果有人不小心用普通引脚号调用了期望引脚代码的函数,例如digitalWrite2f(13, HIGH);
(错误!)而不是digitalWrite2f(DP13, HIGH);
,编译器会抱怨“invalid conversion from 'int' to 'GPIO_pin_t'”。很酷,不是吗?为了实现这一点,引脚代码在enum
中定义,而不是使用#define
。
第二个技巧对“最终用户”来说是不可见的,但在为新开发板定义引脚时帮助很大。它是一个宏GPIO_MAKE_PINCODE(port, pin)
。多亏了这个宏,给定arduino开发板的引脚定义可以像这样:
enum GPIO_pin_enum
{
DP_INVALID = 0x0025,
DP0 = GPIO_MAKE_PINCODE(MYPORTD,0),
DP1 = GPIO_MAKE_PINCODE(MYPORTD,1),
DP2 = GPIO_MAKE_PINCODE(MYPORTD,2),
...
这比这个更具可读性
enum GPIO_pin_enum
{
DP_INVALID = 0x0025,
DP0 = 0x012B,
DP1 = 0x022B,
DP2 = 0x042B,
DP3 = 0x082B,
...
您可以在附件源代码中的pins2_arduino.h文件中查看Arduino Uno和Mega的实际引脚定义。
与标准Arduino I/O的兼容性
就我个人而言,我认为当您进行一段时间的编程后,使用符号名称来表示引脚之类的东西是很自然的事情。很可惜Arduino中没有使用它,因为现在数百万的Arduino用户会习惯于编写digitalWrite(PIN_13, HIGH);
而不是digitalWrite(13, HIGH);
,而且一旦您将实际的引脚“隐藏”在某个符号名称后面,您就可以对其进行一些操作——例如将有用的信息编码到其中。
但是由于人们习惯了Arduino中引脚的普通数字,I/O 2库提供了接受此类数字的函数。如果您使用这些函数,您无需更改程序中的任何内容,只需调用digitalWrite2
而不是digitalWrite
即可。
它是如何工作的?在pins2_arduino.h文件中定义了一个数组,其中包含给定板上可用引脚的引脚代码。当您调用例如digitalWrite2
时,它将使用此数组将引脚号转换为其引脚代码,然后使用此引脚代码调用原生的digitalWrite2f
。当然,这种转换需要一些时间,但这些函数仍然比原始的Arduino函数快。
集成到Arduino包中
我决定以与原始 Arduino 组织文件相同的方式组织新 I/O 的文件。因此,在此文件夹中有一个pins2_arduino.h文件
[你的_arduino_位置]\hardware\arduino\variants\[变体名]\,其中包含特定于给定 Arduino 变体(例如 Uno 或 Mega)的定义。
还有一些通用代码,放置在位于以下位置的arduino2.h和digital2.c文件中:
[你的_arduino_位置]\hardware\arduino\cores\arduino\.
将函数移植到其他板
如果您决定在Uno或Mega以外的其他Arduino板上使用I/O 2函数,则必须创建您自己的pin2_arduino.h文件版本,其中包含引脚代码。这是一项相对容易的任务。我会说它比创建标准Arduino数字I/O所需的各种数组更容易。您只需使用适合您的板的引脚代码定义枚举GPIO_pin_enum
和gpio_pins_progmem
数组,该数组将这些引脚代码映射到引脚号。代码的其他部分应该不需要更改。有关详细说明,请参阅提供的pins2_arduino.h文件中的注释。
关注点
地址大于一个字节的寄存器
对于Arduino Mega中使用的MCU,一些I/O寄存器的地址高于0xFF,这意味着它们的地址无法放入引脚代码中为此目的保留的单个字节中。幸运的是,它们的地址仅介于0x0100和0x01F0之间,所以我们只需要一个额外的位来存储这样的地址。更幸运的是,地址适合一个字节的寄存器其地址不大于0x40左右。因此,地址字节中的最高位(位7)可以自由地用于此目的。这在处理地址时需要一些额外的位操作,但它不会使函数的速度降低太多。
引脚不是整数
请注意,此库中使用GPIO_pin_t
而不是int
并不会限制您将引脚存储在变量中的能力。您只需编写
GPIO_pin_t pin = DP1;
而不是
int pin = 1;
然而,它确实影响了您在循环中轻松操作引脚的能力。假设您需要按顺序打开连接到引脚1到4的LED。使用标准的Arduino digitalWrite
,您可以这样写:
int pin;
for ( pin = 1; pin <= 4; pin++) {
digitalWrite(pin, HIGH);
}
您不能使用 GPIO_pin_t 这样做,因为引脚代码的数值之间没有关联。如果您确实需要/想为此使用循环,并且想使用 I/O 2 的更快原生函数,您可以使用这个技巧:
GPIO_pin_t pins[] = {
DP10,
DP11,
DP12,
DP13,
DP_INVALID,
};
...
int i;
for ( i = 0; pins[i] != DP_INVALID; i++) {
digitalWrite2f(pins[i], HIGH);
}
或者,简单的方法:使用与Arduino兼容的函数,它们接受整数引脚号(例如digitalWrite2)。
关闭计时器
如前所述,在我的数字I/O实现中,我省略了检查给定引脚是否在计时器上以及最终禁用计时器,这在标准Arduino实现中是存在的。我假设此检查是为健忘的用户设计的,他们想读取或写入他们在同一个程序中先前用作PWM输出(analogWrite)的引脚。但我并没有真正深入了解所有代码细节,因此可能需要某处添加检查和/或关闭计时器,可能是在pinMode
中。如果您遇到与此相关的问题,请告诉我。
使用 I/O 2 函数可能出现的错误
不可能错误地使用整数而不是引脚代码调用digitalWrite2f
;编译器会报告错误:“invalid conversion from 'int' to 'GPIO_pin_t'”。但是,可能会错误地使用引脚代码而不是整数引脚号调用digitalWrite2
。这只会导致编译器警告,不幸的是,Arduino IDE在默认配置下不显示构建警告。您可以在文件 > 首选项中启用编译的详细输出,并在Arduino IDE的输出窗口中检查警告,这些警告以红色字体而非正常输出的白色字体显示。请注意,Arduino库本身中存在一些警告;这与I/O 2无关。
其他抽象数字引脚的方法
在本节中,我将简要介绍我尝试过的其他使Arduino I/O更快的方法,其中上述方法是赢家。
所有方法的基本前提是,必须为用户提供简单的接口,例如digitalWrite(pin, value)
。这导致了如何将“pin”参数转换为给定引脚的适当端口寄存器和位掩码,然后使用位掩码对寄存器进行读/写操作的任务。
如果您想进行类似的实验,我建议使用Atmel Studio。它包含一个模拟器,您可以在其中测量代码执行所需的时间并在调试器中逐步执行,这使得实验更容易和更快。之后,对于硬件实验,您可能需要使用一些真正的IDE。我使用了带有AVR插件的Eclipse,直到最后阶段,为了最终验证,才回到Arduino IDE。
选项 1:内存中的数组
此方法用于Arduino中的数字I/O。有关其工作原理的详细描述,请参阅我的旧文章。我没有亲自尝试这种方法,但从测量标准Arduino digitalWrite的速度来看,我们可以说写入一个引脚(不检查计时器)大约需要3.4 微秒。
选项 2:条件链
这是我在旧文章中描述的第二种方法。它用于Wiring,即Arduino的祖先。原则上,您将引脚号与每个端口所属的值范围进行比较。例如,在Arduino Uno中,引脚0到7属于端口D,引脚8到13属于端口B,等等。代码可以像这样:
if ( pin < 8)
return &PORTD;
else if (pin < 14)
return &PORTB;
else if ( pin < 20 )
return &PORTC;
else
return NOT_A_REG;
位掩码可以如下从引脚号获取
/* the lower 3 bits are always the pin number if each port has 8 pins (0-7; 8-15;...)
But Arduino standard has only 6 pins (0-5) for port B and C, so we have to compensate for the 2 missing pins. */
#define GPIO_PIN_MASK(pin) ( (pin < 14) ? (1 << (pin & 0x07)) : (1 << ((pin+2) & 0x07) ) )
在实际实现中,代码将使用C三元条件运算符(a ? b : c
)而不是if-else放入宏中,但这对于我们的解释来说并不重要。
与选项1相比,这种方法有一个优点:如果引脚号是常量,编译器可以在编译期间评估条件,结果效率与直接使用寄存器并在程序中写入PORTB |= 0x20;一样。但如果引脚存储在变量中,速度和大小与选项1相似。更糟糕的是,大小和速度取决于板上有多少个引脚以及这些引脚到实际MCU端口的映射有多“混乱”。因此,虽然在Arduino Uno的情况下,这种方法由于常数引脚的速度提升而优于选项1,但对于Arduino Mega,它可能会比数组版本(非常数引脚;常数引脚仍然会非常快)表现差得多。
我尝试了这种方法的各种组合,例如以这种方式获取端口和位掩码,或者使用switch语句或数组获取端口和/或掩码,但结果不尽人意,对于Arduino Uno,写入数字引脚的速度超过2 微秒。考虑到引脚数量增多(Arduino Mega)时预期的(且可观的)速度下降,这不是一个好的选择。
选项 3:大型开关
这种方法对我来说是最惊讶的。我们可以编写一个开关,它将直接包含对每个可能引脚号的相应寄存器和相应掩码的操作:
#define PORT_WRITE(port, pin, value) ((value == 0)?(port &= ~(1<<pin)):(port |= (1<<pin)) )
void switch_digitalWrite(uint8_t pin, uint8_t val)
{
switch(pin)
{
case 0:
PORT_WRITE(PORTD, 0, val);
break;
case 1:
PORT_WRITE(PORTD, 1, val);
break;
..
您可能会认为这不是一个好方法。对于有20个引脚的Arduino Uno可能没问题,但对于有70个引脚的Arduino Mega呢?如果您想使用引脚60,评估所有可能的值将花费太长时间...并非如此。编译器很聪明,会将switch语句“优化”成一种带有跳转到每个case代码的表格。或者,如果您只是在每个case中为变量分配一些值,它甚至可以在内存中创建一个数组,其中包含每个case的可能值,然后简单地读取和存储该值。结果,switch会产生相当快的代码,并且速度不取决于引脚号。在我的测试中,使用switch进行引脚写入可以获得大约2.2微秒,如果我用if语句以某种二分搜索的方式编写代码,甚至可以低于2.0微秒。
我之所以放弃这种方法,是因为你需要为每个寄存器,或者说为每个操作都需要这样一个大型的开关——一个用于写入引脚,一个用于读取,一个用于设置方向。这样的代码将难以移植到其他开发板并难以维护——如果你在一个开关中修复了一个错误,你必须记住在所有其他开关中也修复它。
选项 4:根据引脚号计算端口和掩码
这种方法意味着我们以某种方式从引脚号计算寄存器地址和引脚掩码。引脚号仍然是简单的整数,就像在Arduino中一样。我将用我尝试过的一个例子来解释:假设我们有带有Atmega328 MCU的Arduino Uno板,但稍微重新排列了引脚,使得Arduino的数字引脚0是端口B的位0;数字引脚1是端口B的位1;依此类推直到引脚7是端口B的位7。引脚8是端口C的位0;引脚9是端口C的位1;……引脚16是端口D的位0,等等。所以引脚号与端口和位之间存在关系。然后我们可以计算端口号:
port_number = pin / 8;
和位数
bit_number = pin % 8;
如果MCU寄存器设计得比较有利(Atmega328就是这种情况),我们只需要在port_number
上加上一些偏移量即可获得该端口寄存器的地址。在我们的例子中,偏移量将是0x25,因为端口号0表示端口B,其地址为0x25。位掩码通过位移位:(1 << bit_number) 直接从位号计算出来。
这种方法看起来很有前景,但也有缺点。
首先,它需要重新编号Arduino板上的引脚——这显然是不可行的。
或者创建一个转换函数,将Arduino的引脚号转换为我们的引脚号——这很容易,但会耗费CPU时间。
或者在计算中加入一些条件——这也会耗费CPU时间。
其次,计算本身并不像人们想象的那么快。例如,我惊讶于当N是一个变量时,一个简单的位移位(1 << N)需要多少时间,编译器实际上必须生成一个循环来位移N次。这种方法在理想情况下,即重新排序引脚后,我的结果约为2.0微秒。再加上需要将Arduino引脚号映射到这些重新排序的引脚号,它就不像您希望的那么快了。
选项 5:将端口和掩码编码到引脚定义中
这是在我的测试中获胜的选项;它在速度和代码的可维护性(和可移植性)之间提供了最佳的权衡。我使用的具体实现已在上面详细描述。总的来说,我们可以说其原理是将端口地址和位掩码包含在引脚的定义中。因此,数字I/O函数不直接接收引脚号,而是接收一种包含操作引脚所需所有信息的代码(或数据结构)。与其他方法相比,其优点是它不需要“昂贵”的计算,例如按变量数位移位,或比较和分支。
历史
2014-02-25 第一版。
2014-05-30 文字 minor 更新;上传了适用于 Linux 的修复版代码。
2015-02-27 代码更新以在 Arduino IDE 1.6.0 中构建,文件中文件位置在文本中更新
2015-03-09 更改以允许作为Arduino库使用(目录重新组织),并将zip文件重命名为dio2.zip。还添加了使用示波器进行的新速度测试(没有循环控制代码的错误)。