优化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,中间增加一个延时函数,为了让我们能看到亮灭的状态变化,如果不加延时的话程序会全速运行,大家估计只能看到亮的状态了。

1.gif

下面仔细查看该源码的编译日志:

Screenshot from 2018-06-20 11:42:45.png

可以看出该源码编译后占用空间1462字节,对于ArduinoMega2560最大可用的Flash空间为253952字节,253952/1024 = 248 KB,下面来列出arduinoMega2560开发板的统计信息方便大家理解:

Screenshot from 2018-06-19 11:40:41.png

对于FlashMemory和EEPROM的区别是,闪存(Flash Memory)是一种长寿命的非易失性(在断电情况下仍能保持所存储的数据信息)的存储器,数据删除不是以单个的字节为单位而是以固定的区块为单位(注意:NOR Flash 为字节存储),区块大小一般为256KB到20MB。闪存是电子可擦除只读存储器(EEPROM)的变种,闪存与EEPROM不同的是,EEPROM能在字节水平上进行删除和重写而不是整个芯片擦写,而闪存的大部分芯片需要块擦除。


0x02 优化pinMode()函数

根据编译日志可知,原始的blink程序编译后的二进制文件大小是1462字节,仅仅就写了一个控制led灯闪烁的程序就花费这么多存储空间,那功能更复杂的话估计编写的程序会超过flash的限制,因此我们需要尽可能的减小该大小,这样我们就能编写更大的程序,完成更复杂的功能。

我们首先查看pinMode()函数占用的空间大小,将其注释掉重新来编译就会发现二进制文件只有1384个字节,相对于原始的1462字节,减少了80字节。

Screenshot from 2018-06-19 13:14:59.png

可以来看看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图:

1.jpg

在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中,

Screenshot from 2018-06-19 16:00:59.png

将pinMode改为bitSet后,可以发现二进制程序占用空间减少了78字节,bitSet仅仅占用2个字节。可以想象一下,如果程序中有10个pinMode的话,那么使用bitSet可以瞬间节约780字节的空间,将该程序下载到ArduinoMega2560板子中发现效果是跟pinMode是一样的,但是占用flash空间却减少了78字节:

Screenshot from 2018-06-19 16:23:06.png

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的存储空间呢:

Screenshot from 2018-06-19 16:55:20.png

0x04 优化delay延时函数

(1)如果不考虑严格的延时1秒的话,只是简单的增加一个延时的话最常用的就是使用for循环,增加一个空循环就行,具体代码如下:

Screenshot from 2018-06-20 10:05:29.png

可以发现程序的存储空间现在为660字节,相比较使用delay()延时函数又节省了148字节的空间,当将该代码上传到ArduinoMega2560板子上时发现led灯并没有出现一亮一灭的现象,那说明这个延时函数没有起作用。当尝试把循环次数从30000变为300000时,仍然没有发现延时的效果,那到底是什么原因呢?

因为编译器在编译这份arduino源码时发现for循环里面竟然是空的,没有执行任何语句,于是判定for循环是个多余的代码,没必要包含在最终程序中进行编译,因为你的循环什么事情也没做,竟然只是空转还浪费cpu宝贵的资源,编译器认为自己做了一件非常正确的事。因为编译器的作用不仅包含编译你的源码,还要负责优化你的代码的执行速度,使代码执行速度尽可能的快,编译器认为你弄个这么没用的空循环一定是脑子进水了,所以就帮你把没用代码在编译时去掉了。

如果你真的想使用这样的空循环来增加延时的话,就需要通过一个标志来明确的提示编译器,不要优化我这个空循环。这个标志就是关键字volatile,将这个关键字加在int前就可以了,volatile关键字就是告诉编译器不要对这个特别的变量做任何假设,不要假设它没用而进行删除来优化运行速度。

Screenshot from 2018-06-20 10:23:37.png

(2)使用定时器来准确的延时1秒钟,那就可以使用Timer0这个定时器,它在一加电后就开始运行,与该定时器有绑定的中断处理函数TIMER0_OVF_vect,在中断处理函数中会递增一个无符号的长整型数timer0_millis。该定时器会在1ms产生一个中断这样就将timer0_millis加1,我们就可以根据timer0_millis来知道时间过去了多少毫秒。如果你不将timer0_millis这个无符号长整型数来自己重置的话,那这个数也会最终溢出的,下面来查看下arduino下基本数据类型的取值范围:

Screenshot from 2018-06-20 10:51:34.png

这个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中定义了,所以我们在本地又要对该数进行操作的话需要加上外部变量声明,具体程序如下:

Screenshot from 2018-06-20 11:06:15.png

需要注意的是修改了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小课堂的微信群与更多志同道合的小伙伴一起学习和交流!

评论:

1 条评论

50%好评

  • 1 星级:(0%)
  • 2 星级:(0%)
  • 3 星级:(0%)
  • 4 星级:(0%)
  • 5 星级:(100%)
  1. 甘草酸不酸
    甘草酸不酸 发表于: 

    讲的非常好,正好解决了我的内存爆满的问题

发表评论