Category Archives: Arduino 魔法书

魔法书2:测试Arduino 执行速度极限

我是潘,曾经是个工程师。这是为 Ardui.Co 制作的 “Arduino 魔法书” 系列的专栏。上次我们介绍了如何利用底层代码高效节约Arduino存储空间。本节课将测量一下,这些底层代码究竟让 Arduino 提高了多少效能。

要测量底层代码的效率,得先研究一下如何测量 Arduino 的执行速度。最简单的方法自然是示波器,但我们先从 Arduino 入手,让它测量自身的运行速度(除了自己,Arduino 也可以其他设备速度,原理一样)。

值得注意的是,测量自己必定会消耗一些自身的资源影响结果,所以只能做相对测量,而不能做绝对测量。 先看看Arduino UNO 在一秒钟内能执行多少次循环:

打开串口监视器,按下 Arduino UNO 的 Reset 按钮,显示 837173,这个数字说明Arduino 在一秒钟内执行了83万多次的循环,相当厉害!

不过,这个数字只告诉了我们 Arduino 在什么都不做时的速度,这个值不是绝对的。再强调一点,测量自身是要消耗资源的。

Arduino UNO使用了16MHz的晶振,每秒钟可以执行16,000,000条指令,或者说,CPU 每秒可以运行 16,000,000 个周期。

上面的程序中,16,000,000 / 837,173 = 19.1 周期/循环,每个while()循环大约需要19个周期的CPU资源。

时间判断使用了 timer0_millis 来计算系统时间,这个变量在上一节介绍过,属于定时器 timer0,系统每过1ms就会增加1。只要不重置它,millis() 这个Arduino 包装过的时间函数依然有效。不过,一旦重置,millis() 就失效了。

直接调用 timer0_millis,可以节省不少系统资源,如果换成millis() 效率会低很多(可尝试自行替换)。

 digitalWrite() 的性能

之前提到,digitalWrite() 很占空间而且效率很低,我们拿它跟 bitSet() 对比测试一下。

Arduino UNO 在这个循环上一秒钟内执行了 112,811 次,平均每个循环用了 16,000,000 / 112811 = 141.83 个CPU 周期。两个digitalWrite() ,平均每个 71 个周期。

如果用置位 bitSet() 宏,能提升多少性能呢?将 bitSet() 和 bitClear() 置换 digitalWrite():

这么简单的替换,每秒就增加到 691,578 次循环,足足快了6倍!

23个CPU周期即完成一个循环。但是还不够快,我们把While()循环的代码修改一下:

现在每秒执行757,443个循环,21个CPU周期即执行一个循环。LED两个循环就被反转一次,相当快了。

虽然LED疯狂地闪烁没啥用处(实际上是个高速 PWM 信号驱动),但如果在其他应用中,能这么快地收发数据肯定是一件很棒的事。

还要再快点

这就是Arduino的极限吗?肯定不是,因为测量自身就需要消耗不少资源,现在把自身测量的代码去掉,用外部的设备,示波器来测量它的速度。

这个程序很简单,设置 D13 为输出,利用 loop() 循环来执行翻转。此时,Arduino 没空帮你测量自己了,就要靠我们的大家伙示波器了: 示波器探针与 D13 连接,测出的频率是1.33MHz,16MHz / 1.33MHz = 12.03MHz,相当于12个周期执行一次翻转循环。

除了循环,Arduino 什么都不做,为什么还需要12个周期。原因是基于C构建的 Arduino IDE 隐藏了 main() 函数,每次执行loop()时,其实都是被 main() 调用。这12个周期就是loop()的开销。

能否将 loop() 的资源节省下来?可以,只要使循环在 loop() 完成即可,一个while() 就可以解决问题:

现在速度提高到2MHz左右,只需要8个周期就可以执行一个循环! 但还有一个问题,示波器时不时出现跳跃和毛刺。这是因为Arduino的定时器是一直开着的,每秒钟产生1000次左右的中断,中断函数执行期间,消耗CPU资源,I/O 是不会翻转的。在setup() 里面增加 noInterrputs(); 告诉程序禁止中断,即可解决问题了。

通过简单的优化,不仅可以节省Arduino 的空间,而且更有效地执行程序。

魔法书1:利用底层代码释放Arduino空间

我是潘,曾经是个工程师。这是为 Ardui.Co 制作的 “Arduino 魔法书” 系列的专栏。从现在开始,我将尝试通过各种方式,去挖掘Arduino的潜能极限。本篇将探讨如何利用底层代码,让程序变得更小、让 Arduino 变得更高效,进而让产品更可靠。“魔法书”不是教程,而是实验性的栏目,仅供参考。

任何机器只能认1和0,二进制是最基础的机器语言,但从人类的角度来看,几乎不可能用二进制代码来编程,效率太低。所以需要更通俗易懂的“高级”语言,C语言就是其中一种,而Arduino的编程语言就是通过C语言的简化、演变过来。

当编写好程序后,编译器(内置在Arduino IDE中)就可以把高级语言,翻译成机器能懂的二进制代码。但这个过程存在一个弊端,编译器总是无法翻译出最优的代码,要么体积臃肿,要么各种资源浪费。如果我们希望程序变得更高效,就必须往底层深入。

但别误会,我们不会直接写二进制代码。其实,C语言已非常接近底层,只要通过 Arduino 兼容C语言的特性,即可让程序得到不错的优化,让Arduino 的资源有效利用,大幅提升执行效率。

Arduino UNO 的 ROM 空间只有 32K 极为有限,如果你编写程序时,发现空间不够,别急着买更高级的板子,而是先看看你的代码,是否还有压缩和改进的空间。现在我们尝试一下,将最简单的示例程序 Blink (LED闪烁)优化,看看能压缩多少空间出来。

编译后,Arduino IDE 会给出二进制代码的大小,因此我们可以清晰看到改进的空间:

示例Blink 程序编译后,二进制代码的大小是928字节,占用了Arduino UNO 总空间的2%。

压缩这个程序前,先看看它是怎样运作的:

1、配置数字引脚 D13 为输出;
2、改变这个输出引脚的状态,LED亮或灭;
3、暂停一下让人感知亮/灭时间,然后回到第2步。

这个程序为了初学者明白易懂,所以没有做任何优化,每一步都清晰简单:点亮LED,停留一段时间,关闭LED,停留一段时间,再重复。不过,点亮和关闭也可以用一个抽象概念来代替——翻转LED的状态。明白这点后,这程序起码可以压缩一半。

 必须 pinMode() 吗?

我们先拿pinMode()函数来开刀,如果把它注释掉,程序从928字节,减少到864字节,也就是说pinMode() 占用了64字节的空间。

但没有pinMode() 引脚就会一直处于输入状态。此时,引脚抗阻极高,即使其电平可以被控制,但也无法驱动LED。用什么底层代码来实现 pinMode() 的效果呢?首先,我们先得了解pinMode() 的工作原理。

Arduino 基于 Atmel AVR 单片机简化而来。不同的 AVR 单片机具有不同数量的端口,每个端口有一定数量的引脚。同时,每个端口也关联着一个数据方向寄存器(data direction register,即DDRx)。

DDRx 决定了端口上不同引脚的输入输出状态。例如,端口 A 有 6 个引脚,给寄存器赋值 000001 的话,前面4个引脚,都是输入状态,最后一个引脚则为输出状态。pinMode() 简化将指定端口、寄存器赋值两个过程完全省略了。我们只要告诉它,某个引脚的状态,它就会设定设定好。

现在用 bitSet(DDRB, 5) 来代替它。LED的引脚D13 对应 AVR 的是端口B,第5个引脚,因此,我们设置端口B的寄存器 DDRB的第5位为1,即可以设置D13为输出状态。

此时D13为输出状态了,但程序空间只增加了2字节。不过,bitSet() 为何那么神奇呢?因为他不是一个函数,而只是一个宏定义。他被包含在编译器的 wiring.h 头文件中,它的定义和Arduino 的代码一起被打包。

这个函数需要两个参数:值(value)和位(bit)。bitSet(DDRB, 5),在Arduino 编译时,会直接替换成C语言: DDRB |= 1<<5,其含义是,将DDRB的第5位置位(赋值1)。“I=” 是一个复合赋值运算符,表示按“位”运算,“<<”是左移运算符,这里用了5表示要操作DDRB寄存器的第5位,即对应I/O端口B的第5个引脚:PB5(关于端口对应的的列表,可以参考官方MAP)。另外,也可以用二进制0b00100000、八进制040、十进制32、16进制0x20数值来赋值,不过可读性就差很多。

简单的语句替换,就把笨重的pinMode()占用的空间节省出来了。 不过,前提是你很熟悉 Arduino 的端口、引脚和C语言的表达。

digitalWrite() 太费力

digitalWrite()虽然很方便,但是代价却相当大,如果把它注释掉,足足释放了226个字节的空间:

Blink 程序的原理是点亮LED,停留一段时间,再熄灭,停留一段时间,再点亮。这个亮-灭交替的过程可以看作是输出引脚在翻转。

对于 ATmage168 或者以上型号的芯片,AVR的翻转方式很简单,只要向输入寄存器的地址写入一个1即可,但对于老的型号ATmega8/16/32则无效。

如果将digitalWrite()函数都换成bitSet(PINB,5);那么空间可以大幅缩少至:644个字节,只用了4个字节就完成 digitalWrite() 的功能。

程序足够精简了吗?不,loop() 本身就是一个循环,每次循环 LED 都可以翻转,即亮灭替换,因此loop() 中可以进一步精简,去掉一个 bitSet(PINB, 5)和delay(1000),效果完全是一样的,但节约了44字节。

delay() 纯粹为了浪费时间,但是非常浪费系统空间,注释掉之后系统减少了152个字节,堪比digitalWrite()。

最有有效浪费时间的方法,就是利用内部定时器。这里引入time0(Arduino 三个定时器之一),定时会不断递增一个变量timer0_millis ,即“毫秒递增变量”,我们要引用这个变量就需要在setup() 和 loop()之外,加入 extern volatile unsigned long timer0_millis; 来声明这个变量。现在引入一个while()循环来替代delay():

while(timer0_millis < 1000); // 如果 timer0_millis每一毫秒加1,如果不到1000,就一直执行循环;
timer0_millis = 0; // 将 timer0_millis 复位置0,下一个loop() 时重新计数;

这个计数方法使得millis()、delay() 函数完全失效了,因为这些函数都是基于timer0来实现的。

经这一番压缩,最后Blink 从 928字节压缩到490字节,差不多只有原来一半大小,现在你还急着购买空间更大、价格更贵的芯片吗?