优化arduino程序存储空间
0x00 Abstract
我们一般在开发Arduino的程序时都是根据功能需求来编写代码,当经过测试后程序满足功能需求后就停止开发,剩下的就是只有在功能需求变更或代码中存在bug时才会再次动手修改代码。其实在真正的项目开发时,我们除了完成功能保证代码不存在bug的同时还要求程序的执行效率不断提高,具体的要求就是以下三点:
(1)程序和数据占用的设备存储空间要尽可能的小,无论是ROM和RAM都要尽可能的小;
(2)程序的执行速度要尽可能的快,当然快的前提是要保证程序执行的准确及稳定性;
(3)减小系统的整体功耗,即消耗尽可能少的电,保证节能;
简单一句话来说的话就是,我们希望可以在占用最少的设备存储空间时,用消耗最少的电量来使程序执行的最快,这样我们的产品才能更简单、廉价、可靠。在本次教程中通过使用atmega2560底层代码来实现以前相同的功能,这样就可以保证程序占用空间及运行速度更快,因为我们常用的arduino函数都是底层函数的一个封装,使我们使用起来更为方便。
经过代码优化,可以保证程序的占用空间可以减小大约50%,通过本次教程的学习还可以使我们更为了解arduino程序底层的实现机制。
0x01 查看Blink程序
大家第一次在arduino上开发程序时,第一个程序应该就是blink控制D13上的led灯闪烁的程序了,程序源码如下:
/*
Blink
Turns an LED on for one second, then off for one second, repeatedly.
Most Arduinos have an on-board LED you can control. On the UNO, MEGA and ZERO
it is attached to digital pin 13, on MKR1000 on pin 6. LED_BUILTIN is set to
the correct LED pin independent of which board is used.
If you want to know what pin the on-board LED is connected to on your Arduino
model, check the Technical Specs of your board at:
https://www.arduino.cc/en/Main/Products
modified 8 May 2014
by Scott Fitzgerald
modified 2 Sep 2016
by Arturo Guadalupi
modified 8 Sep 2016
by Colby Newman
This example code is in the public domain.
http://www.arduino.cc/en/Tutorial/Blink
*/
// the setup function runs once when you press reset or power the board
void setup() {
// initialize digital pin LED_BUILTIN as an output.
pinMode(LED_BUILTIN, OUTPUT);
}
// the loop function runs over and over again forever
void loop() {
digitalWrite(LED_BUILTIN, HIGH); // turn the LED on (HIGH is the voltage level)
delay(1000); // wait for a second
digitalWrite(LED_BUILTIN, LOW); // turn the LED off by making the voltage LOW
delay(1000); // wait for a second
}
通过该源码我们就了解了整个实现闪烁的流程,首先在setup()函数中进行一些初始化配置,将数字脚13配置为输出。然后在loop()循环函数中不断的修改输出脚的电压,当置为高时就可以点亮该led,置为低就是关闭该led,中间增加一个延时函数,为了让我们能看到亮灭的状态变化,如果不加延时的话程序会全速运行,大家估计只能看到亮的状态了。
下面仔细查看该源码的编译日志:
可以看出该源码编译后占用空间1462字节,对于ArduinoMega2560最大可用的Flash空间为253952字节,253952/1024 = 248 KB,下面来列出arduinoMega2560开发板的统计信息方便大家理解:
对于FlashMemory和EEPROM的区别是,闪存(Flash Memory)是一种长寿命的非易失性(在断电情况下仍能保持所存储的数据信息)的存储器,数据删除不是以单个的字节为单位而是以固定的区块为单位(注意:NOR Flash 为字节存储),区块大小一般为256KB到20MB。闪存是电子可擦除只读存储器(EEPROM)的变种,闪存与EEPROM不同的是,EEPROM能在字节水平上进行删除和重写而不是整个芯片擦写,而闪存的大部分芯片需要块擦除。
0x02 优化pinMode()函数
根据编译日志可知,原始的blink程序编译后的二进制文件大小是1462字节,仅仅就写了一个控制led灯闪烁的程序就花费这么多存储空间,那功能更复杂的话估计编写的程序会超过flash的限制,因此我们需要尽可能的减小该大小,这样我们就能编写更大的程序,完成更复杂的功能。
我们首先查看pinMode()函数占用的空间大小,将其注释掉重新来编译就会发现二进制文件只有1384个字节,相对于原始的1462字节,减少了80字节。
可以来看看pinMode()函数是如何实现的,该实现源码是在Arduino IDE软件目录下/home/corvin/Software/arduino-1.8.4/hardware/arduino/avr/cores/arduino/wiring_digital.c:
void pinMode(uint8_t pin, uint8_t mode)
{
uint8_t bit = digitalPinToBitMask(pin);
uint8_t port = digitalPinToPort(pin);
volatile uint8_t *reg, *out;
if (port == NOT_A_PIN) return;
// JWS: can I let the optimizer do this?
reg = portModeRegister(port);
out = portOutputRegister(port);
if (mode == INPUT) {
uint8_t oldSREG = SREG;
cli();
*reg &= ~bit;
*out &= ~bit;
SREG = oldSREG;
} else if (mode == INPUT_PULLUP) {
uint8_t oldSREG = SREG;
cli();
*reg &= ~bit;
*out |= bit;
SREG = oldSREG;
} else {
uint8_t oldSREG = SREG;
cli();
*reg |= bit;
SREG = oldSREG;
}
}
由该函数可知,pinMode就是将某port下的某bit置为1而已,下面就需要弄清楚连接有led灯的D13是在哪个port的bit上就可以了,可以来继续跟踪代码,最终在/home/corvin/Software/arduino-1.8.4/hardware/arduino/avr/variants/mega/pins_arduino.h中可以看到pwm13连接在哪个port上:
const uint8_t PROGMEM digital_pin_to_port_PGM[] = {
// PORTLIST
// -------------------------------------------
PE , // PE 0 ** 0 ** USART0_RX
PE , // PE 1 ** 1 ** USART0_TX
PE , // PE 4 ** 2 ** PWM2
PE , // PE 5 ** 3 ** PWM3
PG , // PG 5 ** 4 ** PWM4
PE , // PE 3 ** 5 ** PWM5
PH , // PH 3 ** 6 ** PWM6
PH , // PH 4 ** 7 ** PWM7
PH , // PH 5 ** 8 ** PWM8
PH , // PH 6 ** 9 ** PWM9
PB , // PB 4 ** 10 ** PWM10
PB , // PB 5 ** 11 ** PWM11
PB , // PB 6 ** 12 ** PWM12
PB , // PB 7 ** 13 ** PWM13
PJ , // PJ 1 ** 14 ** USART3_TX
PJ , // PJ 0 ** 15 ** USART3_RX
PH , // PH 1 ** 16 ** USART2_TX
PH , // PH 0 ** 17 ** USART2_RX
PD , // PD 3 ** 18 ** USART1_TX
PD , // PD 2 ** 19 ** USART1_RX
PD , // PD 1 ** 20 ** I2C_SDA
PD , // PD 0 ** 21 ** I2C_SCL
PA , // PA 0 ** 22 ** D22
PA , // PA 1 ** 23 ** D23
PA , // PA 2 ** 24 ** D24
PA , // PA 3 ** 25 ** D25
PA , // PA 4 ** 26 ** D26
PA , // PA 5 ** 27 ** D27
PA , // PA 6 ** 28 ** D28
PA , // PA 7 ** 29 ** D29
PC , // PC 7 ** 30 ** D30
PC , // PC 6 ** 31 ** D31
PC , // PC 5 ** 32 ** D32
PC , // PC 4 ** 33 ** D33
PC , // PC 3 ** 34 ** D34
PC , // PC 2 ** 35 ** D35
PC , // PC 1 ** 36 ** D36
PC , // PC 0 ** 37 ** D37
PD , // PD 7 ** 38 ** D38
PG , // PG 2 ** 39 ** D39
PG , // PG 1 ** 40 ** D40
PG , // PG 0 ** 41 ** D41
PL , // PL 7 ** 42 ** D42
PL , // PL 6 ** 43 ** D43
PL , // PL 5 ** 44 ** D44
PL , // PL 4 ** 45 ** D45
PL , // PL 3 ** 46 ** D46
PL , // PL 2 ** 47 ** D47
PL , // PL 1 ** 48 ** D48
PL , // PL 0 ** 49 ** D49
PB , // PB 3 ** 50 ** SPI_MISO
PB , // PB 2 ** 51 ** SPI_MOSI
PB , // PB 1 ** 52 ** SPI_SCK
PB , // PB 0 ** 53 ** SPI_SS
PF , // PF 0 ** 54 ** A0
PF , // PF 1 ** 55 ** A1
PF , // PF 2 ** 56 ** A2
PF , // PF 3 ** 57 ** A3
PF , // PF 4 ** 58 ** A4
PF , // PF 5 ** 59 ** A5
PF , // PF 6 ** 60 ** A6
PF , // PF 7 ** 61 ** A7
PK , // PK 0 ** 62 ** A8
PK , // PK 1 ** 63 ** A9
PK , // PK 2 ** 64 ** A10
PK , // PK 3 ** 65 ** A11
PK , // PK 4 ** 66 ** A12
PK , // PK 5 ** 67 ** A13
PK , // PK 6 ** 68 ** A14
PK , // PK 7 ** 69 ** A15
};
通过代码来跟踪会比较麻烦点,最简单方法就是根据arduinoMega2560的pinMap可以得知D13(也可以叫PWM13)是连接在PortB的bit7上,下图是一个完整的pinMap图:
在Atmel AVR上设置一个I/O脚的方向是非常简单的,每个引脚都属于一个端口,一个指定I/O端口里的每个位可以是输入或输出,每个独立的引脚的方向是由和那个I/O端口关联的数据方向寄存器(data direction register, 即DDRx)里的位来决定的。因此我们可以直接置位该寄存器的位来达到设置D13为输出模式,这里使用一个宏定义bitSet(value, bit)来实现的寄存器操作,该宏定义实现在:/home/corvin/Software/arduino-1.8.4/hardware/arduino/avr/cores/arduino/Arduino.h中,
将pinMode改为bitSet后,可以发现二进制程序占用空间减少了78字节,bitSet仅仅占用2个字节。可以想象一下,如果程序中有10个pinMode的话,那么使用bitSet可以瞬间节约780字节的空间,将该程序下载到ArduinoMega2560板子中发现效果是跟pinMode是一样的,但是占用flash空间却减少了78字节:
0x03 优化翻转输出脚代码
在blink程序的loop()中通过将D13置高电平来点亮,延时1秒好让人眼观察到点亮,然后D13置低电平来关闭led灯,再延时1秒好让人眼观察到熄灭的状态。以上的状态不断的在loop()中循环执行就出现了led灯不断闪烁的现象了,我们可以发现这部分代码编写起来思路非常清晰,可读性很好,但是实现却有点笨,因为AVR的芯片在设计时就考虑到会有不断翻转引脚的需求了,输入引脚地址寄存器(Input Pins Address, PINx),只要向该寄存器中对应位写入1就可以将对应端口的输出引脚状态来翻转。意思就是说假如当前DDRB的bit7当前是高电平状态的话,向PINB bit7写如1的话就会将DDRB的bit7置低电平,如果再往PINB的bit7写入1的话就又会将DDRB的bit7置高电平,只要写入1就能不断的翻转对应DDRB的bit的电平状态。
我们将digitalWrite(LED_BUILTIN, HIGH/LOW)来优化为bitSet(PINB, 7)直接操作寄存器,这样就能再次减小二进制程序的存储空间,现在存储空间为808字节,再次减少了576字节,这可是相当的厉害了,这就说明其实digitalWrite()函数的实现非常占用存储空间,设想一下如果程序中有10个digitalWrite的话,使用bitSet()的话可以减少2.8K的存储空间呢:
0x04 优化delay延时函数
(1)如果不考虑严格的延时1秒的话,只是简单的增加一个延时的话最常用的就是使用for循环,增加一个空循环就行,具体代码如下:
可以发现程序的存储空间现在为660字节,相比较使用delay()延时函数又节省了148字节的空间,当将该代码上传到ArduinoMega2560板子上时发现led灯并没有出现一亮一灭的现象,那说明这个延时函数没有起作用。当尝试把循环次数从30000变为300000时,仍然没有发现延时的效果,那到底是什么原因呢?
因为编译器在编译这份arduino源码时发现for循环里面竟然是空的,没有执行任何语句,于是判定for循环是个多余的代码,没必要包含在最终程序中进行编译,因为你的循环什么事情也没做,竟然只是空转还浪费cpu宝贵的资源,编译器认为自己做了一件非常正确的事。因为编译器的作用不仅包含编译你的源码,还要负责优化你的代码的执行速度,使代码执行速度尽可能的快,编译器认为你弄个这么没用的空循环一定是脑子进水了,所以就帮你把没用代码在编译时去掉了。
如果你真的想使用这样的空循环来增加延时的话,就需要通过一个标志来明确的提示编译器,不要优化我这个空循环。这个标志就是关键字volatile,将这个关键字加在int前就可以了,volatile关键字就是告诉编译器不要对这个特别的变量做任何假设,不要假设它没用而进行删除来优化运行速度。
(2)使用定时器来准确的延时1秒钟,那就可以使用Timer0这个定时器,它在一加电后就开始运行,与该定时器有绑定的中断处理函数TIMER0_OVF_vect,在中断处理函数中会递增一个无符号的长整型数timer0_millis。该定时器会在1ms产生一个中断这样就将timer0_millis加1,我们就可以根据timer0_millis来知道时间过去了多少毫秒。如果你不将timer0_millis这个无符号长整型数来自己重置的话,那这个数也会最终溢出的,下面来查看下arduino下基本数据类型的取值范围:
这个timer0_millis最大可以是4294967295,如果1ms增加1的话,要想达到这个最大值,那就需要经过:
总秒数 = 4294967295/1000 = 4294967.295
总小时数 = 4294967.295/3600 = 1193.046470833
总天数 = 1193.046470833/24 = 49.710269618
也就是说如果现在将arduinoMega2560开机的话,timer0_millis开始1ms增加1,那要经过大概50天的时间,这个timer0_millis计数值才会溢出从4294967295重新变为0,然后再继续不断的开始重新计数。因此大家需要了解你的程序如果使用了这个timer0_millis计数值的话,需要注意你的程序可能会在经过大概50天就会出错一次,例如使用了delay()、millis()这些函数,因为这些函数背后的实现都是使用了这个timer0_millis计数。millis()表示开机后程序运行的总时间,这个其实就是返回的timer0_millis这个值,但是要知道这个值大概50天的时候就会出错了,所以你的程序需要对这里特别注意才行。
下面来修改blink程序中的延时函数,我们使用timer0_millis来作为时间计数值,由于timer0_millis已经在/home/corvin/Software/arduino-1.8.4/hardware/arduino/avr/cores/arduino/wiring.c中定义了,所以我们在本地又要对该数进行操作的话需要加上外部变量声明,具体程序如下:
需要注意的是修改了timer0_millis的值后会导致millis()和delay()函数运行不正常,不过在本次实验中没有影响。现在总结一下看看,从最开始的1462字节到最终优化后存储空间只有702字节:
存储空间相对于原始的减少 = (1462-702)/1462 = 52%
0x05 Reference
[1]. ArduinoMega2560[OL]. https://www.arduino.cn/thread-17938-1-1.html
[2].Dale Wheat 著, 翁恺 译. Arduino技术内幕[M]. 北京:人民邮电出版社. 2015. 83-112.
[3].闪存概念[OL].https://baike.baidu.com/item/%E9%97%AA%E5%AD%98/108500?fromtitle=flash%20memory&fromid=3740729&fr=aladdin
[4].arduinoMega2560 PinMapping[OL]. https://www.arduino.cc/en/Hacking/PinMapping2560
[5].arduino基本数据类型[OL]. https://www.cnblogs.com/lulipro/p/7672954.html
0x06 Feedback
大家在按照教程操作过程中有任何问题,可以关注ROS小课堂的官方微信公众号,在公众号中给我发消息反馈问题即可,我基本上每天都会处理公众号中的留言!当然如果你要是顺便给ROS小课堂打个赏,我也会感激不尽的,打赏30块还会邀请进ROS小课堂的微信群与更多志同道合的小伙伴一起学习和交流!