优化 Arduino 内存使用






4.88/5 (15投票s)
在嵌入式设备中,RAM(随机存取存储器)是系统中最宝贵和有限的资源之一。本文的重点是优化 Arduino MCU 的 RAM 使用,但相同的原理也适用于许多其他嵌入式设备。
您的 Arduino 是否在没有明显原因的情况下“发疯”,并自行重启或复位?您的设备是否开始出现异常,但您百分百确定您的代码是正确的?在这种情况下,可能的原因之一就是缺乏空闲 RAM(随机存取存储器)。换句话说,您的 MCU 没有足够的空闲 RAM 来执行所需的任务。
随机存取存储器:类型和区别
嵌入式设备中有两种主要类型的 RAM:SRAM(静态随机存取存储器)和 DRAM(动态随机存取存储器)。虽然 SRAM 的读/写/访问操作速度更快,但它也更昂贵,并且通常占用更多的物理空间。另一方面,DRAM 的读/写/访问操作通常较慢(每代都在改进),但生产成本更低,并且物理尺寸通常更小。
无论嵌入式设备使用哪种类型的 RAM(SRAM 或 DRAM),以下讨论均适用。Arduino 板使用的许多 MCU(例如 Arduino UNO v3 中的 ATmega328p 和 Arduino MEGA2560 中的 ATmega2560)使用 SRAM 存储器,但不幸的是数量很少(例如 ATmega328P 为 2KB,ATmega2560 为 8KB),因此在编写代码时需要特别注意。在接下来的讨论中,我们将 SRAM 和 DRAM 都简称为 RAM。
RAM 诊断:当 堆 遇到 栈
首先,我们需要检查问题是否由可用 RAM 不足引起,而不是由各种其他可能的原因引起,例如有缺陷的 MCU、外设问题,甚至是隐藏的代码错误。调试 Arduino 并不容易,因为它不会在错误时“蜂鸣”,不会显示蓝屏,也不会触发弹出窗口告诉您可能的问题。Arduino MCU 中可用的 RAM 组织如下图所示(图片链接来自:avr-libc)。
- .data 变量 是第一个 RAM 部分,用于存储程序
static
数据,例如string
s、已初始化的结构和全局变量。 - .bss 变量 是为未初始化的
global
和static
变量分配的内存。 - 堆 是动态内存区域,这是
malloc
(及类似函数)的“游乐场”。堆可以根据需求增长(当进行新的分配时)或“可能”减小(当内存被释放时,例如使用free
时)。 - 栈 是位于 RAM 末尾的内存区域,它向堆区域增长。栈区域用于函数调用,存储局部变量的值。当函数调用结束时,局部变量占用的内存会被回收。
- 外部 RAM 仅适用于某些 MCU,这意味着可以以类似于 PC 的方式添加 RAM。通常这很昂贵(几 KB 的外部 RAM 通常比 MCU 本身更贵),并且还需要高级硬件和软件技能。
- 可用空闲内存 是堆和栈之间的区域,我们需要测量这个区域以检测由 RAM 资源不足引起的问题。当这个区域对于所需任务来说太小,或者完全缺失(堆 遇到 栈)时,我们的 MCU 就会开始出现异常或自行重启。
以下 C/C++ 方法定义允许计算 Arduino MCU 的空闲内存(以字节为单位)。它适用于 Arduino IDE 和其他工具(如 AvrStudio)。
extern unsigned int __bss_end;
extern unsigned int __heap_start;
extern void *__brkval;
uint16_t getFreeSram() {
uint8_t newVariable;
// heap is empty, use bss as start memory address
if ((uint16_t)__brkval == 0)
return (((uint16_t)&newVariable) - ((uint16_t)&__bss_end));
// use heap end as the start of the memory address
else
return (((uint16_t)&newVariable) - ((uint16_t)__brkval));
};
getFreeRam
函数定义了一个新变量(名为 newVariable
),该变量作为函数的局部变量将存储在栈中。由于 栈 内存区域向 堆 增长,因此该新变量的内存地址是调用此方法时栈使用的最后一个内存地址。__brkval
是指向堆使用的最后一个内存地址(朝向栈)的指针。我们不必担心 __brkval
的管理,因为它是在内部完成的。我们还必须确保堆不为空,因为如果堆为空,则 __brkval
无法使用(它是一个 NULL
指针)。如果堆为空,则我们使用 __bss_end
,它是一个内部定义的变量,存储在 .bss 变量 RAM 区域的最后一部分。
空闲 RAM 的数量表示我们的 newVariable
变量使用的地址与 __brkval
引用的地址(如果堆为空,则为 __bss_end
的地址)之间的差异。这为我们提供了 8 位 MCU 上未使用的字节数,例如 Arduino 使用的 MCU(除了 Arduino DUE,它使用 ARM 32 位 MCU)。
上述代码适用于大多数 Arduino MCU(高达 64KB RAM),如果您发现有不适用的,请报告。
注意:上述讨论是对 RAM 划分及其管理的简化说明。我们的目的是为所有人(初学者和高级程序员)提供解释,而不会深入“黑洞”细节。
RAM 使用优化:栈 还是 堆?
既然知道问题是由于缺乏 RAM 资源造成的,我们能做些什么来解决它呢?至少有两种方法:使用具有更多 RAM 资源的 MCU,或者优化代码以更好地管理现有 RAM 资源。虽然在某些情况下第一种方法是可接受的(MCU 的实际价格相当低),但在许多其他情况下,这不是一个真正的解决方案,例如,如果硬件已经存在并且需要向其添加新功能。我们将进一步讨论如何优化 RAM 使用,这在许多情况下是您的 Arduino 的必经之路。
避免使用动态内存分配
虽然在编程具有数百兆字节、千兆字节甚至兆兆字节 RAM 的普通 PC 时使用动态内存分配是一个很好的解决方案,但对于嵌入式设备(如 Arduino 系列)来说,通常是一个坏主意。动态内存分配的问题是它很容易产生内存(堆区域)碎片。内存碎片可以看作是 RAM 中的小“孔洞”,在许多情况下无法重用。
我们举个例子。假设通过 malloc
调用分配了 8 字节内存,然后通过另一个 malloc
调用分配了 16 字节内存。结果,我们有了 24 字节的连续已分配堆内存。之后,由于前 8 字节内存不再使用,我们决定通过 free
调用将其回收,希望将来可以使用这些内存。确实,内存被释放了,但同时,我们也在堆中创建了一个“孔洞”。为什么这不好?好吧,如果现在我们需要分配 10 字节(或任何大于 8 的数字)内存,堆就会增加,因为 8 字节空闲内存(堆孔洞)不足。使用 malloc
调用(也包括使用 calloc
或 realloc
时)的内存分配适用于连续的内存区域。如果稍后需要分配 6 字节内存,这些内存可以使用“孔洞”的一部分,但剩余的 2 字节(原来是 8 字节区域)现在被隔离,并且很可能永远不会被使用。重复我们上面描述的操作将导致堆大小变得很大,并带有小的不可用(在大多数情况下)内存孔洞。因此,迟早,堆和栈的冲突将变得难以避免(请记住,栈向堆增长,堆向栈增长)。当这两个区域相遇(或冲突)时,就会开始发生奇怪的事情,例如自动重置。
一些简单的规则可能有助于避免 RAM 碎片
- 尽可能使用 栈 而不是 堆 - 栈内存是首选,因为当函数返回时内存会完全释放,而且栈内存不会产生碎片。一般来说,这意味着使用局部变量并避免使用动态内存分配(即
malloc
、calloc
和realloc
调用)。 - 尽可能避免使用全局和静态数据 - 这些变量占用的内存区域(.data 变量 和 .bss 变量)在程序生命周期内永远不会被释放。
- 当必须使用字符串时,请务必保持其尽可能短 - 请记住,每个字符占用一个字节的 RAM(ATmega328p 的整个 2KB RAM 内存可以被一个长度为 2048 个字符的字符串占用)。
- 使用数组时,尽量使其长度最小化 - 如果以后您确实需要不同的长度,只需增加/减少它并重新编程您的 MCU。
为变量/字段使用适当的类型
一般来说,程序员倾向于使用比实际所需范围更大的数据类型,在许多情况下,原因是为了“谁知道呢,也许以后我需要一个更大的值”。例如,有人可能会定义一个整数(使用 int
或 short
类型)变量,而实际上该变量的值只是小于 100 的正数。无论我们是编程低资源设备(如 MCU)还是普通 PC 应用程序,这都是一个坏主意。请记住,如果该变量确实需要更大的范围,我们以后可以更改变量类型。
下表提供了在编程低资源设备(但不仅仅是)时最常用的 C/C++ 类型
数据类型 | 字节大小 | 值 |
---|---|---|
布尔值, bool | 1 | 真(1) 或 假(0) |
char | 1 | ASCII 字符或范围 [-128, 127] 内的带符号值 |
无符号字符, byte, uint8_t | 1 | ASCII 字符或范围 [0, 255] 内的无符号值 |
int, short | 2 | 范围 [-32768, 32767] 内的带符号值 |
无符号 int, word, uint16_t | 2 | 范围 [0, 65535] 内的无符号值 |
long | 4 | 范围 [-2147483648, 2147483647] 内的带符号值 |
无符号 long, uint32_t | 4 | 范围 [0, 4294967295] 内的无符号值 |
float, double | 4 |
范围 [-3.4028235e+38, 3.4028235e+38] 内的浮点值 注意:在此 (Arduino) 平台上,float 和 double 是相同的 |
请负责任地尝试使用既符合数据要求,又占用内存存储字节数最少的类型。再举一个例子来说服您:一个包含 128 个 uint16_t
类型元素而不是 uint8_t
类型的数组会多占用 128 字节的 RAM。这占用了 Arduino UNO v3 总内存的 6.25%,仅仅因为我们为数组变量使用了错误的类型!
对“常量”数据使用 PROGMEM
在许多情况下,大量 RAM 被 static
内存(.data 变量
RAM 区域)占用,这是使用全局变量(例如字符串或数字)的结果。当这些数据不太可能改变时,可以很容易地将其存储在所谓的 PROGMEM
(程序内存)中。这代表了一部分闪存,而且要知道,一般来说,闪存比 RAM 大很多倍(例如,ATmega2560 有 8KB RAM 和 256KB 闪存)。使用 PROGMEM
的缺点是读取速度较慢,与从 RAM 读取相同数据相比。
定义 PROGMEM
变量的通用方法是
#include <avr/pgmspace.h>
const PROGMEM datatype varName[] = {v0, v1, v2...};
例如,我们定义一个 string
和前七个质数集,并要求将它们存储在 PROGMEM
区域中,如下所示
#include <avr/pgmspace.h>
const PROGMEM char errorMsg[] = {"Invalid code!"};
const PROGMEM uint8_t primes[] = { 2, 3, 5, 7, 11, 13, 17};
我们需要包含 pgmspace.h 才能使用 PROGMEM
。之后,读取前七个质数集可以按如下方式完成
for ( uint8_t k = 0; k < 7; k++) {
uint8_t prime = pgm_read_byte_near( primes + k);
// now, do something with the prime number stored in the "prime" variable
}
在使用字符串时也必须特别小心。例如,当使用以下代码时
Serial.println( "Invalid code!");
字符串 "Invalid code!"
同时存储在闪存和 RAM 中。虽然我们无法避免将其存储在闪存中,但我们希望避免在程序启动时将字符串加载到 RAM 中。为此,我们使用 Arduino 社区提供的 F
宏。上述代码可以更改,以便仅在需要时才从闪存加载字符串
Serial.println( F("Invalid code!"));
优点是显而易见的:我们为每个字符节省了一个字节的 RAM。这样,只有当上述代码执行时(即,当包含上述代码的方法被调用时),字符串才会被加载到 RAM 中。使用 F
宏时也有一些注意事项
- 它只适用于字符串,因此对于其他类型,仍然需要明确使用 PROGMEM。
- 字符串是常量,因此写入闪存后无法更改(这意味着:如果需要更改,我们需要用新值重新刷新/重新编程 MCU)。
- 如果您多次使用同一个字符串,那么它会在闪存中存储多次(每次出现一次),因此会占用更多的闪存(每个字符一个字节)。