[PICO][Adv]内存碎片

Raspberry Pi Pico 2 内存碎片

在嵌入式编程中,内存管理是一个重要的主题,尤其是当你开始编写更复杂的程序时。内存碎片是一个常见的问题,它可能导致程序运行不稳定甚至崩溃。Raspberry Pi Pico 2 虽然拥有 520KB 的 SRAM,比传统 Arduino 宽裕很多,但若频繁进行动态内存分配,仍然可能遭遇碎片问题。本文将详细介绍什么是内存碎片、它是如何产生的、以及如何避免它,并提供适用于 Pico 2 的 Arduino 风格和 C/C++ 风格代码示例。


1. 什么是内存碎片?

内存碎片是指堆内存空间被分割成许多不连续的小块,导致无法分配较大的连续内存块。在 Pico 2 中,内存碎片通常发生在使用 mallocfree(或 new/delete)进行动态内存分配时。当频繁地分配和释放不同大小的内存块时,空闲内存会变得“支离破碎”,即使总空闲内存足够,也可能无法满足一个大的分配请求。

1.1 碎片的类型

类型 描述
外部碎片 空闲内存块被已分配的内存块隔开,分散在堆中各处。
内部碎片 分配的内存块大于实际请求的大小,造成空间浪费(例如对齐要求)。

对于嵌入式系统,外部碎片是更常见且更棘手的问题。


2. 内存碎片的原因

内存碎片的主要原因是动态内存分配的不规则性。以下是一些常见的原因:

  • 频繁分配和释放内存:每次 mallocfree 都会在堆中寻找合适的空闲块。如果分配和释放的顺序与大小不匹配,堆就会被逐渐“切碎”。
  • 不同大小的内存块:如果程序分配的内存块大小不一(例如 8 字节、32 字节、128 字节交替),内存管理器可能无法有效合并相邻的空闲块,导致碎片累积。
  • 长生命周期的分配:一个长期存在的小内存块可能会阻碍其两侧的空闲块合并,从而形成“空洞”。
  • 随机分配模式:在事件驱动或状态机程序中,分配模式难以预测,更容易产生碎片。

3. 内存碎片的影响

影响 描述
内存分配失败 即使总空闲内存足够,也可能无法分配一块连续的大内存。malloc 返回 NULL,程序需要处理该错误。
程序崩溃 如果未检查返回值并盲目使用空指针,会导致硬件异常或系统复位。
性能下降 内存管理器需要遍历更复杂的空闲链表来寻找合适块,增加分配时间。
长期运行不稳定 碎片会随着时间累积,最终使程序在运行数小时或数天后突然失败。

提示:Pico 2 的 520KB SRAM 相对较大,碎片问题可能不会像在 2KB 设备上那样快速显现,但在长时间运行、频繁动态分配的项目中仍然需要警惕。


4. 如何避免内存碎片

避免内存碎片的最佳方法是尽量减少动态内存分配。以下是一些具体建议:

策略 说明
使用静态内存分配 尽可能使用全局数组或 static 局部变量,而不是动态分配。
预分配内存 在程序启动时一次性分配所需的最大内存,然后自己管理这部分内存(如线性分配器)。
使用内存池 预先分配一大块内存,并将其划分为固定大小的“块”,每次分配返回一个块。这样可以避免外部碎片。
分配固定大小 如果必须动态分配,尽量使所有分配的大小相同(或呈整数倍关系),有助于空闲块合并。
避免随机生命周期 尽量将动态分配集中在初始化阶段,运行时只释放,不再分配新块。
使用栈分配 对于临时需要的较大缓冲区,可以使用局部数组(在栈上分配),函数返回时自动释放。

5. 代码示例

5.1 Arduino 风格:静态预分配

以下示例使用静态数组代替动态分配,完全避免碎片风险。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#define BUFFER_SIZE 256

// 预分配一个静态缓冲区(位于 .bss 段,不占用栈)
uint8_t buffer[BUFFER_SIZE];

void setup() {
Serial.begin(115200);

// 直接使用预分配的内存,无需 malloc
for (int i = 0; i < BUFFER_SIZE; i++) {
buffer[i] = i % 256;
}

// 打印部分内容
for (int i = 0; i < 32; i++) {
Serial.print(buffer[i]);
Serial.print(" ");
}
Serial.println();
}

void loop() {
// 主循环中也可以继续使用 buffer,不会产生碎片
delay(1000);
}

5.2 C/C++ 风格:初始化时一次性分配

main() 中预先分配所有需要的内存,并在程序结束时释放(通常不需要)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include "pico/stdlib.h"
#include <stdlib.h>
#include <stdio.h>

#define MAX_SAMPLES 1000

int main() {
stdio_init_all();

// 预分配一个大的数组(在堆上,但只分配一次)
uint16_t* sensorData = (uint16_t*)malloc(MAX_SAMPLES * sizeof(uint16_t));
if (sensorData == NULL) {
printf("Initial allocation failed!\n");
return -1;
}

// 程序运行期间不再进行其他 malloc/free
for (int i = 0; i < MAX_SAMPLES; i++) {
sensorData[i] = i * 2;
}

printf("Data ready. First value: %u\n", sensorData[0]);

// 如果程序不退出,通常不需要 free;但为了规范,可以在退出前释放
// free(sensorData);

while (true) {
tight_loop_contents();
}
return 0;
}

5.3 内存池实现(固定大小块)

对于需要动态分配固定大小对象的场景(如链表节点),可以实现一个简单的内存池,从根本上避免外部碎片。

Arduino 风格内存池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 固定大小内存池(例如 32 个 MyNode 对象)
template<typename T, size_t PoolSize>
class MemoryPool {
private:
T pool[PoolSize];
bool used[PoolSize];

public:
MemoryPool() {
for (size_t i = 0; i < PoolSize; i++) used[i] = false;
}

T* allocate() {
for (size_t i = 0; i < PoolSize; i++) {
if (!used[i]) {
used[i] = true;
return &pool[i];
}
}
return NULL; // 池已满
}

void deallocate(T* ptr) {
if (ptr >= pool && ptr < pool + PoolSize) {
size_t index = ptr - pool;
used[index] = false;
}
}
};

// 使用示例
struct SensorNode {
uint32_t timestamp;
uint16_t value;
SensorNode* next;
};

MemoryPool<SensorNode, 50> nodePool; // 最多 50 个节点

void setup() {
Serial.begin(115200);
SensorNode* head = NULL;

// 创建节点
SensorNode* newNode = nodePool.allocate();
if (newNode) {
newNode->timestamp = millis();
newNode->value = analogRead(26);
newNode->next = head;
head = newNode;
}

// 释放节点
nodePool.deallocate(head);
}

C/C++ 风格内存池

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include "pico/stdlib.h"
#include <stdlib.h>
#include <stdio.h>

#define POOL_BLOCK_SIZE 32 // 每个块 32 字节
#define POOL_BLOCK_COUNT 20 // 共 20 个块

typedef struct {
uint8_t data[POOL_BLOCK_SIZE];
uint8_t used;
} PoolBlock;

static PoolBlock memoryPool[POOL_BLOCK_COUNT];

void pool_init() {
for (int i = 0; i < POOL_BLOCK_COUNT; i++) {
memoryPool[i].used = 0;
}
}

void* pool_alloc() {
for (int i = 0; i < POOL_BLOCK_COUNT; i++) {
if (!memoryPool[i].used) {
memoryPool[i].used = 1;
return (void*)memoryPool[i].data;
}
}
return NULL;
}

void pool_free(void* ptr) {
if (ptr == NULL) return;
// 计算属于哪个块
uintptr_t offset = (uintptr_t)ptr - (uintptr_t)memoryPool;
if (offset < sizeof(memoryPool)) {
int index = offset / sizeof(PoolBlock);
memoryPool[index].used = 0;
}
}

int main() {
stdio_init_all();
pool_init();

void* block1 = pool_alloc();
void* block2 = pool_alloc();
printf("Allocated blocks: %p, %p\n", block1, block2);

pool_free(block1);
pool_free(block2);

while (true) tight_loop_contents();
return 0;
}

提示:内存池技术非常适合需要频繁创建/销毁相同大小对象的场景(如消息队列、任务控制块)。它完全避免了外部碎片,并且分配/释放时间是确定的(O(n)或更优)。


6. 实际案例:传感器数据队列

假设你需要缓存最近 100 个传感器读数,每个读数包含一个时间戳和一个数值。使用静态数组(环形缓冲区)是最简单且无碎片的方法。

Arduino 风格实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#define QUEUE_SIZE 100

struct Reading {
unsigned long timestamp;
int value;
};

Reading queue[QUEUE_SIZE];
int head = 0;
int count = 0;

void addReading(int value) {
queue[head].timestamp = millis();
queue[head].value = value;
head = (head + 1) % QUEUE_SIZE;
if (count < QUEUE_SIZE) count++;
}

void setup() {
Serial.begin(115200);
}

void loop() {
int sensorVal = analogRead(26);
addReading(sensorVal);

// 每 10 秒打印一次队列中的最新数据
static unsigned long lastPrint = 0;
if (millis() - lastPrint >= 10000) {
lastPrint = millis();
for (int i = 0; i < count; i++) {
int idx = (head - 1 - i + QUEUE_SIZE) % QUEUE_SIZE;
Serial.print(queue[idx].timestamp);
Serial.print(": ");
Serial.println(queue[idx].value);
}
}
delay(100);
}

这种设计完全不使用动态内存,因此不存在碎片问题。


7. 检测内存碎片的方法

虽然 Pico 2 没有内置的堆分析工具,但你可以通过一些技巧估算碎片程度。

7.1 尝试分配大块内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void checkFragmentation() {
size_t largest = 0;
// 尝试分配逐渐变小的块,直到成功
for (size_t size = 4096; size > 0; size -= 256) {
void* p = malloc(size);
if (p != NULL) {
largest = size;
free(p);
break;
}
}
Serial.print("Largest allocatable block: ");
Serial.print(largest);
Serial.println(" bytes");
}

7.2 使用 SDK 的 malloc 钩子(C/C++ 风格)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <malloc.h>

void my_malloc_hook(void *ptr, size_t size, void *caller) {
// 记录分配信息
}

void my_free_hook(void *ptr, void *caller) {
// 记录释放信息
}

int main() {
__malloc_hook = my_malloc_hook;
__free_hook = my_free_hook;
// ...
}

通过统计分配和释放的块大小,可以间接分析碎片趋势。


8. 总结

内存碎片是嵌入式动态内存管理中的隐形杀手。Raspberry Pi Pico 2 虽然 SRAM 较大,但若程序设计不当,碎片仍可能破坏长期运行的稳定性。通过以下原则,你可以有效避免碎片:

原则 描述
静态优先 优先使用全局数组或 static 变量。
预分配 在初始化阶段分配所有需要的内存,运行时不再分配。
内存池 对于固定大小的对象,使用内存池代替通用 malloc
固定大小分配 如果必须动态分配,尽量使所有分配块大小相同。
避免随机生命周期 尽量使分配和释放的顺序呈 LIFO(后进先出),有助于合并空闲块。

9. 练习与附加资源

  • 练习 1:修改上面的内存池示例,使其支持不同大小的块(如创建多个池,分别管理 32 字节、128 字节等)。
  • 练习 2:编写一个程序,模拟随机分配和释放不同大小的内存块,每隔一段时间打印最大可分配块大小,观察碎片随时间的增长。
  • 练习 3:将链表节点分配改为内存池方式,并比较与原生 malloc/free 在长时间运行下的稳定性。
  • 资源

通过理解内存碎片的成因并采取预防措施,你可以编写出在 Pico 2 上长时间稳定运行的程序,充分利用其 520KB SRAM 的优势。


[PICO][Adv]内存碎片
https://ka5fxt.cn/2026/03/30/PICO-Adv-内存碎片/
发布于
2026年3月30日
许可协议