为什么 Arduino 中的数字 I/O 很慢以及如何解决?






4.96/5 (21投票s)
本文解释了为什么 Arduino 数字 I/O 函数很慢,并将其与 Wiring 框架中使用的更快实现进行了比较。
引言
我喜欢Arduino,并且经常用它进行原型设计。使用互联网上随处可见的广泛代码库,您可以很快地让事物运行起来,这真是太棒了。但是当我查看 Arduino 库的源代码时,我有点失望。有些事情可以处理得更好。有些人甚至以一种不那么委婉的方式来描述它,请看这里。
我为什么要探究底层?这一切始于我发现用于写入和读取数字引脚的函数相当慢。我想知道原因以及如何加快它们的速度。如果您对此类事情感兴趣,请继续阅读。
在本文中,我将描述 Arduino 中读写数字 I/O 的函数是如何实现的,并将其实现与Wiring 框架进行比较,Wiring 是 Arduino 的原始灵感来源,并且似乎编写得更好。它似乎不广为人知,但它仍在开发中,除了自己的硬件外,它还支持 Arduino 硬件,因此可以用作编程 Arduino 的替代软件框架。
最后,我还将介绍一些测量这两种实现速度的实验结果。本文假设读者具备一些微控制器编程背景知识,否则某些部分可能难以理解。不过,我希望主要内容对任何人来说都是可理解的。
免责声明
我确实喜欢 Arduino,并且认为它是一个很棒的项目。我写这篇文章并不是说它不好,或者您应该停止使用它。对于大多数用户来说,Arduino 库开箱即用就能很好地工作。一些浪费的 CPU 周期和内存无关紧要。如果您正在将其用于一个重要的项目,其中每个周期/字节/毫瓦的能耗都很重要,那么不要想当然地认为所有东西在底层都是完美的。
新增内容(2014 年 5 月更新)
大约一年前我写这篇文章时,我只考虑了下面详细描述的两种选择。我还提到了第三种选择——“以某种方式将寄存器地址编码到引脚号中”,但我将其驳回,认为对于 Arduino 中识别引脚的普通引脚号来说,这是不可能做到的。但我仍然想为 Arduino 数字 I/O 函数创建一个更快的替代方案,并且在尝试各种选择时,正是这个最初被驳回的选项被证明是最好的!事实证明,即使将普通引脚号转换为带有编码寄存器地址的“引脚代码”,这种方法也会比原始 I/O 更快。
因此,我创建了数字 I/O 函数的替代版本,它具有 Wiring 方法的优点(参见下面的选项 1)——也就是说,它们在编译时已知引脚的情况下编译成单条指令,但对于存储在变量中的引脚号,它们也工作得更快。详细信息和可供下载的代码可以在这里找到:https://codeproject.org.cn/Articles/732646/Fast-digital-I-O-for-Arduino。
Arduino 如何处理数字 I/O
Arduino 库定义了用于读取和写入 I/O 引脚的函数 digitalRead
和 digitalWrite
。这些函数将引脚号(一个整数)作为输入参数。例如,要打开连接到数字引脚 7 的 LED,您可以使用以下代码
digitalWrite(7, HIGH);
要读取数字引脚 8 的状态,您可以写入
state = digitalRead(8);
换句话说,Arduino 编程模型使用单个整数来标识 I/O 引脚。在 Arduino 开发板上,这些引脚被命名为数字引脚 0、数字引脚 1 等。这是对硬件的有用抽象。MCU 本身(以 Arduino Uno 为例,是 Atmel 的 ATmega328)将引脚组织成命名为 A、B、C 等的端口。每个端口有 8 个引脚。因此,例如,端口 A 上有引脚 0 到 7(称为 PA0 到 PA7),端口 B 上有引脚 0 到 7(PB0 到 PB7)等。下图(来自 Arduino.cc)显示了 Arduino 引脚号和 MCU 端口的关系。
每个端口由多个寄存器控制。例如,要将端口 D 中的引脚 7 设置为逻辑 1,您需要将端口 D 的数据寄存器 (PORTD) 中的位 7 设置为 1。C 语言代码可能如下所示
PORTD |= 0x80;
因此,当我们写入 digitalWrite(8,HIGH) 时,Arduino 软件需要将其转换为设置某个寄存器中的某个位。这是每个想要抽象硬件引脚的软件库都必须处理的问题。如何将数字引脚号转换为 MCU 寄存器?
我想到了两种选择
- 使用条件表达式 (C 条件运算符)
- 使用一个将引脚号映射到外围寄存器的数组
实际上还有第三种选择——以某种方式将寄存器地址编码到引脚号中,但这在 Arduino 库的情况下是不可能的,它使用“原始”引脚号来标识程序中的引脚。这可能是一个不幸的设计决策,导致了本文所讨论的性能问题。另一方面,它对用户来说确实非常直观。
选项 1 可以说是更直观的。在 digitalWrite
函数中,会有一个条件语句,说明如果引脚号在 0 到 7 之间,则端口寄存器是 PORTD(参见上面的引脚映射图)。如果大于 7,则寄存器是 PORTB,依此类推。这种方法有一个很大的优点——如果引脚号是编译时常量(在编译器编译程序时已知),那么生成的代码可以像直接操作“原生”MCU 寄存器一样高效。
如果引脚号不是编译时常量,则编译器在编译期间不会处理条件,而是成为带有条件表达式的“普通”程序代码。代码的大小(和速度) then 取决于有多少引脚/寄存器,即必须评估多少条件。
如果使用选项 2,我们使用数组将引脚号映射到相应的寄存器。这是 Arduino 中使用的方法。有一个数组将引脚号映射到“端口号”
const uint8_t PROGMEM digital_pin_to_port_PGM[] = {
PD, /* 0 */
PD,
PD,
PD,
PD,
PD,
PD,
PD,
PB, /* 8 */
PB,
…
第二个数组将端口号映射到控制此端口的实际寄存器。有几个这样的数组用于涉及不同类型的寄存器,但现在这对我们来说并不重要。在下面的列表中,是 Arduino Uno 的输出寄存器数组。
const uint16_t PROGMEM port_to_output_PGM[] = { NOT_A_PORT, NOT_A_PORT, (uint16_t) &PORTB, (uint16_t) &PORTC, (uint16_t) &PORTD, };
从代码片段中可以看出,根据给定的数字引脚号获取寄存器的过程涉及两个阶段。首先,使用 digital_pin_to_port_PGM
数组将引脚号转换为端口号(由常量 PD、PB 等表示)。然后,此端口号用作 port_to_output_PGM
数组的索引以获取寄存器本身。
这种两阶段过程可能看起来很复杂,但是如果直接使用引脚号作为包含寄存器的某个数组的索引,那么这个数组就需要包含与给定 Arduino 板上的引脚一样多的成员(在 Arduino Mega 的情况下大约 50 个)。由于需要多个这样的数组用于各种寄存器,这将占用大量内存。在两阶段版本中,只有一个数组需要这么大的尺寸。然后,寄存器数组的大小只与涉及的寄存器数量一样大。
当使用选项 2 时,需要决定将数组存储在数据内存 (RAM) 还是程序内存 (Flash) 中。考虑到 MCU 中 RAM 内存的有限大小,将表存储在 Flash 内存中是有意义的。Arduino 就是这种情况。然而,访问内存速度较慢的缺点,尽管不如看起来那么糟糕。8 位 AVR CPU 需要 3 个时钟周期从 Flash 内存读取一个字节 (LPM 指令),需要 2 个时钟周期从 RAM 内存读取 1 个字节 (LDS 指令)。
比较两种选择
在本节中,我尝试比较这两种选择。提醒一下,选项 2 在 Arduino 中使用,选项 1 在 Wiring 框架中。
速度
选项 1 如果引脚是编译时常量,则可以编译成对相应寄存器的直接操作。在这种情况下,它执行速度非常快。如果它不是编译时常量,则生成的代码是一系列条件分支。执行此代码当然需要更长的时间。
选项 2 无论引脚号是否是编译时常量,都会生成相同的代码。正如我们稍后将看到的,在这种情况下,执行时间与非常量引脚号的选项 1 非常相似。
总而言之,选项 1 可以非常快(2 个 CPU 周期),也可以很慢(大约 50 个周期甚至更多,取决于引脚数量)。选项 2 总是很慢,但可能比选项 1 的慢速版本稍快,而且速度不取决于引脚数量。
如果我们能假设在大多数情况下引脚号在编译时是已知的,那么选项 1 会更好。当然,这样的假设并不容易证明。我稍后会更详细地讨论这个问题。
速度一致性
有些人可能会认为,如果代码在所有情况下以相同的速度执行,而不是有快速和慢速版本,即使这个单速版本比快速版本慢得多,这也是一个优点。此外,对于可能依赖标准函数慢速实现的现有代码,也有一个打破现有代码的有效担忧。在这里,很难选择其中一个选项。这个问题也与我考虑的最后一个标准密切相关——在多少程序中可以使用快速版本。
代码的实现和可理解性
可以说,选项 2 比选项 1 更容易理解。嵌套的 C 条件语句可能令人困惑且难以“解码”。然而,如果我们同意核心库应该由经验丰富的程序员维护(并最终移植到其他平台),那么这不是使用选项 2 的重要论据。
有多少情况下引脚号是(可以是)编译时常量
在比较这两种选项时,这个问题似乎最为重要,因为,显然,如果选项 1 的快速版本很少使用,那么拥有它就没什么意义。这似乎也是选项 1 未在官方 Arduino 发行版中使用的主要论据——即快速版本很少使用,因为不可能将引脚号作为编译时常量传递给“库”。
将引脚号传递给库
首先我们应该澄清“库”这个词,它既可以指预编译的二进制文件,也可以指源代码形式的一组函数。对于预编译的二进制文件,很明显无法将编译时常量传递给代码,因为库代码是预先编译的。Arduino IDE 中的构建过程配置为首先将所有“库”构建成静态库,然后用户程序链接到此库。这样就不可能利用更快的函数,反对选项 1 的论点是有效的。但是,可以跳过创建静态库,而是将所有源文件一起构建,而不会有任何性能损失。实际上,这样构建甚至可能更快,因为构建工具能够只构建已更改的文件。
如果我们假设“库”可以以源代码形式使用并与用户程序一起构建,那么在哪些情况下我们可以在编译时知道引脚号?
用户程序中的常量引脚号
- 一个操作引脚的函数(我们希望它灵活,将引脚号作为参数传递给函数)。
- 一个操作引脚的 C++ 对象
将引脚号传递给函数
假设您想编写自己的函数,它将引脚号作为输入参数。传递给此类函数的参数将不是编译时常量,除非编译器足够智能,可以“追溯”参数的来源以找出它是常量。这对于内联函数是可能的,并且在 Wiring 对数字 I/O 函数的实现中使用了(因为我们实际上在调用例如 digitalWrite
时也将引脚号传递给函数),但似乎很难(如果可能的话)在一般情况下实现。
关于函数参数使用 const
关键字可能会有一些困惑——这可能暗示编译器该参数是编译时常量。然而,正如我使用 Arduino 中使用的 AVR-GCC 编译器验证的那样,情况并非如此。毕竟,这很有道理。C 语言中的函数参数是按值传递的,因此会创建参数的副本,并且编译器需要为该参数保留内存(或寄存器)。即使您使用简单的常量调用函数,例如 5,该数字也会复制到变量中,并且在函数内部无法判断此变量是否包含曾经是编译时常量的值。此外,const
关键字仅表示参数在函数内部不会被修改。它不强制我们向函数传递一个 const 变量。
引脚号和 C++ 对象
第二种情况的例子是 Arduino 中的 Servo
类。您可以创建此类的多个实例来控制多个伺服电机。对于每个实例,您调用 attach
函数并为其提供引脚号。在这种情况下,很容易想象引脚号必须作为变量存储在伺服对象中,因此不会是编译时常量。我曾想过,如果将引脚号定义为 const
变量(并在构造函数中初始化),情况可能会好转,但即使对于内联成员函数,编译器仍然拒绝使用快速版本。在这里,Arduino 使用“原始”引脚号来指代程序中的引脚的设计决策似乎真的阻碍了我们。
哪个选项更好?
我仍然相信,在大多数程序中,引脚号可以以编译时常量的方式使用。在某些情况下,这确实不可能,尤其是在我们想使用面向对象的方法时,但这在我看来并不能证明在所有情况下都使用 Arduino 库中那样慢的版本。
一般来说,最好设计不同的数字 I/O 接口,以便即使是非常量引脚号也能产生高效的代码。这样的实现是可能的,例如可以看看 mbed 或 Atmel 软件框架。
鉴于现在标准的 Arduino I/O 函数所继承的限制,我仍然认为最好有选择。如果我需要快速 I/O 并以可快速运行的方式编写程序,使用常量引脚号,我可以在原生的 Arduino 软件中自动获得快速结果,而无需使用直接端口操作或一些第三方库。对我来说,这就是我更喜欢使用 Wiring 框架来编程我的 Arduino 硬件的原因——因为它给了我这个选择。
顺便提一下,我使用 Eclipse IDE 而不是 Arduino 附带的默认 IDE,因此切换到不同的软件框架相当容易。我没有尝试在 Arduino IDE 中使用 Wiring 框架,也没有直接使用 Wiring IDE 编程 Arduino 硬件,尽管我认为后一种选择应该可以原生工作。在 Wiring IDE 中可以选择 Arduino 作为目标硬件。
实践实验
首先,我编译了以下代码,并查看了汇编结果。
void loop()
{
digitalWrite(13, HIGH);
delay(500);
digitalWrite(13, LOW);
delay(500);
}
使用 Wiring 框架,以下是编译器的汇编输出。
00000c5c <_Z4loopv>:
c5c: 2d 9a sbi 0x05, 5 ; 5
c5e: 64 ef ldi r22, 0xF4 ; 244
c60: 71 e0 ldi r23, 0x01 ; 1
c62: 80 e0 ldi r24, 0x00 ; 0
c64: 90 e0 ldi r25, 0x00 ; 0
c66: 0e 94 f6 00 call 0x1ec ; 0x1ec <delay>
c6a: 2d 98 cbi 0x05, 5 ; 5
c6c: 64 ef ldi r22, 0xF4 ; 244
c6e: 71 e0 ldi r23, 0x01 ; 1
c70: 80 e0 ldi r24, 0x00 ; 0
c72: 90 e0 ldi r25, 0x00 ; 0
c74: 0e 94 f6 00 call 0x1ec ; 0x1ec <delay>
c78: 08 95 ret
对 digitalWrite
的调用被直接在循环中替换为单条指令(SBI
- 设置 I/O 寄存器中的位,或 CBI
- 清除 I/O 寄存器中的位)。当然,如果我们不直接写入引脚号 (13),而是使用 #define
或定义一个 const int
变量来保存引脚号(例如 const int pin = 13;
),我们会得到相同的结果。
如果引脚号是非常量 int,例如定义为 int pin = 13;
然后在调用 digitalWrite(pin, HIGH);
中使用,则会生成以下代码(不完整列表)
00000d70 <_Z4loopv>:
d70: 80 91 42 01 lds r24, 0x0142
d74: 61 e0 ldi r22, 0x01 ; 1
d76: 0e 94 66 01 call 0x2cc ; 0x2cc <_pinWrite>
对 digitalWrite
的调用被替换为对 _pinWrite
的调用,并且引脚号和 HIGH
值 (1) 在寄存器 R24
和 R22
中传递给函数。在第一行中,引脚号从变量加载到 R24 中。
使用 Arduino 框架,相同的 C 代码会产生以下结果(部分列表)。
00000b52 <loop>:
b52: 8d e0 ldi r24, 0x0D ; 13
b54: 61 e0 ldi r22, 0x01 ; 1
b56: 0e 94 55 05 call 0xaaa ; 0xaaa <digitalWrite>
b5a: 64 ef ldi r22, 0xF4 ; 244
b5c: 71 e0 ldi r23, 0x01 ; 1
b5e: 80 e0 ldi r24, 0x00 ; 0
b60: 90 e0 ldi r25, 0x00 ; 0
b62: 0e 94 82 04 call 0x904 ; 0x904 <delay>
结果与之前非常相似。在这种情况下,引脚号是常量 (13),并在第一行加载到 R24 中。无论引脚号是直接写入还是定义为 const int
或 int
变量,我们都会得到相同的代码。_pinWrite
和 digitalWrite
函数的汇编列表,因为这会占用太多空间,而且没有汇编编程经验也难以理解。简单来说,可以说 Arduino 版本包含大约 80 条指令,而 Wiring 版本 (_pinWrite
) 包含大约 70 条。因此,在大小上,这两个版本几乎相同。至于速度,需要详细分析,因为两个版本都包含分支,并非所有指令都会执行。我猜 Arduino 版本的速度不会有太大变化,因为无论索引是什么,从表中加载值所需的时间都是相同的。另一方面,包含一系列比较指令的 Wiring 版本对于较高的引脚号应该需要更长的时间。速度比较
micros
函数测量。i = 255;
start = micros();
while ( i-- > 0)
{
digitalWrite(LED_PIN, HIGH);
digitalWrite(LED_PIN, LOW);
}
end = micros();
一个版本的程序使用设置为 255 的 8 位变量,另一个版本使用设置为 1000 的 16 位变量。下表显示了执行一次 digitalWrite
函数所需的时间。“us”表示微秒。
每次 digitalWrite 的时间 | Arduino | Wiring,const 引脚* | Wiring,非 const 引脚 |
8位循环变量 | 4.09 微秒 | 0.23 微秒 | 4.53 微秒 |
16位循环变量 | 4.20 微秒 | 0.25 微秒 | 4.63 微秒 |
* 在这种情况下,我们实际上测量的是循环控制代码而不是 I/O 操作本身,见下文。
请注意,表中显示的时间不是函数执行的真实时间。它们还包括执行循环本身以及获取开始和停止时间戳所需的时间。这种支持代码带来的误差对于慢速版本的 digitalWrite
来说相对可以忽略不计,但在 Wiring 中使用快速版本且引脚号在编译时已知的情况下,误差可能超过被测量操作本身的持续时间。在 Arduino 中使用的 16 MHz 时钟下,CBI 和 SBI 指令(每个需要 2 个周期)的执行时间应该是 0.125 微秒,而我们得到的是 0.23 微秒。
结论
众所周知,Arduino 中用于操作数字 I/O 的函数很慢。原因也已为人所知并在社区中讨论,并提出了解决方案(请参阅此处)。不那么广为人知的是,Wiring 框架包含了这些函数的实现,它正是 Arduino 解决方案所提议的——如果引脚号是编译时常量,它会非常快,只有在引脚号未知时才会变慢。因此,Wiring 框架提供了一个工具,可以轻松比较抽象数字引脚的两种方法。它还提供了一个替代软件框架来编程 Arduino 硬件,而无需使用直接端口操作(这不适用于不同的 Arduino 硬件版本),或使用第三方库来实现快速 I/O。
我试图在上面比较 Arduino 和 Wiring 中使用的实现,所以在这个结论中,我将只总结这两个版本的优缺点并提出我的看法。
Arduino 实现使用位于程序内存中的表(数组)将数字引脚号映射到 MCU 寄存器。Wiring 版本使用条件运算符执行此映射,如果引脚号在编译时已知且条件由编译器解析,则会产生非常高效的代码。如果引脚号不是常量,则生成的程序将包含一系列分支(条件评估)。
Arduino 的优点
- 易于理解和编写(易于移植)
- 一致的速度(尽管慢);不取决于引脚号是否是编译时常量和/或引脚号本身的值。
Arduino 缺点
- 慢;在 MCU 的“原生”C 代码中可能只有一条指令,变成了 50 条或更多指令。
- 不安全的代码 - 未处理数组边界溢出。可以通过稍微降低速度来修复。
Wiring 优点
- 如果我们在编译时知道引脚号,速度会非常快。
Wiring 缺点
- 更难理解和编写(嵌套条件运算符)
- 执行速度不一致;函数在不同程序中可能以不同速度运行——取决于是否将引脚用作编译时常量。此外,对于在评估序列中稍后评估的较高引脚号,函数可能比低引脚号稍慢。
这份清单可以提供一些概览。我当然遗漏了一些优缺点。无论如何,在我看来,选择一个版本而非另一个版本的关键问题是,引脚号在多少情况下是(可以是)编译时常量。换句话说,在多少情况下我们可以从快速版本中受益。我没有对这个问题进行任何认真的研究,所以以下几行仅代表我的个人观点。另请注意,我将 Arduino/Wiring API 的设计视为既定事实。也就是说,我们需要有函数,它们接受引脚号作为等于其名称中引脚号的简单数字。如果使用不同的接口,那肯定会改变情况。
我相信在大多数情况下,引脚号可以定义为常量。我假设目标项目可以使用 Arduino/Wiring 框架的源代码形式。以预构建库的形式使用可能更舒适,但这使得无法从编译时常量优化中受益。即使是当前版本 (1.0.3) 的 Arduino IDE,每次构建用户程序时都会重新构建库,因此将库与用户程序一起以源代码形式构建,而不是先构建库再将其与用户程序链接,似乎没有任何困难。
在某些情况下,在不牺牲程序效率的情况下,不可能将引脚号设为编译时常量(但在这种情况下,也应考虑 digitalRead/Write API 的效率)。在某些情况下,使用常量引脚号似乎与面向对象编程的原则相冲突——使用一个类的多个实例,每个实例在不同的引脚或引脚集上操作。Arduino 本身就是以面向对象的方式设计的,尽管这对于大多数用例来说似乎有些大材小用。例如,广泛使用的 Serial 和 Wire 对象实际上是它们各自类的单个“内置”实例。虽然可以想象有项目会使用不止一个串行或 I2C 接口,但这种情况并不常见,而且我们是否需要能够使用类的多个实例的通用代码,这还有待商榷。
同样,在 LCD 类的情况下,允许用户指定任何一组引脚来接口显示器是很好的,但这值得性能问题吗?在我看来,对用户硬件设计施加一些限制以允许软件库中更高效的代码是合理的。
引脚号不是常量的另一种情况是糟糕的用户程序设计,不幸的是,Arduino IDE 中包含的示例仍然如此。使用 int pin = 13;
当然会导致效率低下的代码,而添加一个单词 const
可以让编译器生成优化代码,而几乎不会增加程序员的脑力消耗。
结论(的结论)
基于以上原因,我相信 Wiring 方法更好,应该在 Arduino 中采用。在大多数程序中,引脚号似乎是(或可以很容易地是)常数。不采用这种方法的主要论点似乎是,只有用户程序和少数带有硬编码引脚号的库才能使用快速版本。这是事实,但正如上面提到的,大多数库可能可以使用硬编码引脚号,而用户程序在我看来是最重要的用例。
另一方面,我承认对于大多数用户来说,digitalWrite
函数执行需要 0.15 微秒还是 4.5 微秒并不重要。从这个角度来看,可以理解的是,将 Arduino 中的实现从基于内存的表切换到条件宏所需的努力可能不值得。对于大多数用户来说,不会有任何区别,而那些需要更快核心或只是无法接受切换引脚需要 50 个 CPU 周期想法的少数用户,可以要么使用现有的快速 I/O 解决方案,编写自己的代码,或者使用 Wiring 框架来编程他们的 Arduino 硬件。
最后,本文的目的并非要说服 Arduino 团队改变实现。相反,我希望提供问题的概述,并总结解决方案的优缺点。无论数字 I/O 函数是快是慢,Arduino 都将是一个有用的工具,但与任何其他工具一样,人们应该了解其局限性。对于那些考虑为嵌入式系统编写自己的软件框架的人来说,这个 Arduino 问题表明在设计 API 时考虑所有用例是多么重要。
解决方案
我大约一年前写的上述文本实际上并没有给出任何令人满意的解决方案。它基本上说,如果你需要快速数字 I/O 并且你的引脚是编译时常量,你可以使用 Wiring 库。但这并不容易,对于存储在变量中的引脚号,它可能导致比原始 Arduino 库更慢的数字 I/O。一年后,我回到了这个问题,并尝试找到更好的解决方案。我想我找到了。请参阅我的新文章了解详细信息:https://codeproject.org.cn/Articles/732646/Fast-digital-I-O-for-Arduino。
简单来说,这个解决方案很容易添加到普通的 Arduino IDE 中(只需复制几个文件)。对于编译时已知的引脚,它的工作速度与 Wiring(宏)版本一样快,对于存储在变量中的引脚,它的执行时间大约是标准 Arduino 数字 I/O 函数的一半。
历史
2013-05-10 第一个版本。
2014-05-16 添加了关于 Arduino 快速数字 I/O 版本的信息。