Nuttx - Memory Management

从用户 malloc() 到物理页分配再到 L2 页表写入,完整解析 CONFIG_BUILD_KERNEL 模式下 NuttX 四层内存分配器的协作机制。

本文回答以下问题:用户进程调用 malloc() 时,内存从哪里来?堆空间不够时如何动态扩展?物理页是怎样被分配并映射到用户虚拟地址的?内核堆和用户堆有什么区别?读完后,你将能够从源码级别追踪一次 malloc() 从用户空间穿透到 MMU 页表的完整路径,并理解 NuttX 四层分配器各自的职责与边界。


1. 开篇:KERNEL 模式下内存管理要解决什么问题?

在 FLAT 模式下,所有任务共享一个全局堆——malloc() 直接从这个堆中分配,简单直接。但在 KERNEL 模式下,每个用户进程拥有独立的虚拟地址空间,这引入了三个 FLAT 模式不存在的问题:

  1. 隔离:进程 A 的堆内存不能被进程 B 访问,需要 MMU 页表保证
  2. 映射:用户虚拟地址必须映射到物理页才能使用,但物理页的分配和映射是内核特权操作
  3. 扩展:用户堆初始只有很小的空间,malloc() 超出时需要动态申请新物理页并更新页表

这三个问题导致 KERNEL 模式需要一个多层分配器架构——用户态的 malloc() 最终要穿透多层才能拿到可用内存。

NuttX 官方文档 Documentation/implementation/memory_configurations.rst(第 508-537 行)对此有明确描述:物理页分配器(page allocator)是 KERNEL 模式下 malloc() 的底层支撑,用户堆通过 sbrk() 接口按需扩展。本文将沿着这条路径逐层深入。

接下来先看 qemu-armv7a 上的物理内存布局,这是所有分配器的”原材料”。


2. 物理内存布局(qemu-armv7a)

所有虚拟地址最终映射到物理内存。理解物理内存的分区方式,才能理解各层分配器的”地盘”在哪里。

2.1 qemu-armv7a 内存映射

1
2
3
4
5
6
7
8
9
10
11
12
13
Physical Address         Usage                          Size
================ ===== ====
0x00000000-0x000FFFFF FLASH (kernel .text/.rodata) 1MB
0x08000000-0x0DFFFFFF IO/Peripherals (UART,GIC...) 96MB
0x40000000-0x4FFFFFFF DDR 256MB
+-- 0x40000000 Kernel .data/.bss + stacks
| ...
+-- g_idle_topstack Kernel Heap start
| ... (kmm_malloc allocates here)
+-- 0x40300000 Page Pool start ~13MB
| ... (mm_pgalloc allocates here)
+-- 0x40FFC000 Kernel L1 Page Table 16KB
+-- 0x41000000 Page Pool end / DDR continues

三个关键区域:

区域 作用 分配器
Kernel Heap 内核数据结构(TCB、task_group、内核栈等) kmm_malloc()
Page Pool 用户进程的物理页(.text、.data、heap、stack、L2 页表) mm_pgalloc()
Kernel L1 Page Table 所有进程 L1 页表的模板 固定,不分配

Page Pool 是用户进程物理内存的唯一来源——所有用户 .text.data、堆页、栈页,以及 L2 页表本身,都从这里分配。

有了物理布局的全貌,下面看 NuttX 如何用四层分配器将物理页面转化为用户可用的虚拟内存。


3. 四层分配器架构总览

单层分配器无法同时满足:字节级精细分配(用户 malloc 16 字节)、页级物理内存管理(4KB 粒度)、MMU 映射操作(写 L2 页表)。NuttX 将这些职责分层,每层只关注一个粒度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Layer 4 (top)     malloc(size) / free(ptr)         User API
| (bytes)
v
Layer 3 mm_malloc(USR_HEAP, size) Core heap manager
| (chunks, free-list)
[heap space exhausted?]
| yes
v
Layer 2 sbrk(incr) -> pgalloc() Heap expansion
| (pages, 4KB)
v
Layer 1 (bottom) mm_pgalloc(npages) Physical page allocator
| (bitmap/granule)
v
L2 PTE write + cache flush MMU mapping
组件 粒度 关键文件
4 malloc()/free() 字节 mm/umm_heap/umm_malloc.c
3 mm_malloc()/mm_free() 块(chunk) mm/mm_heap/mm_malloc.c
2 sbrk()pgalloc() 页(4KB) mm/umm_heap/umm_sbrk.c, arch/arm/src/armv7-a/arm_pgalloc.c
1 mm_pgalloc()gran_alloc() 页(4KB) mm/mm_gran/mm_pgalloc.c, mm/mm_gran/mm_granalloc.c

四层分配模型

实例:malloc(100) 穿透四层的具体地址

1
2
3
4
5
Layer 4: malloc(100) 由用户进程在 VA 0x80201008 处获得 100 字节
Layer 3: mm_malloc() 从堆空闲链表中切出 112 字节 chunk (at VA 0x80201000)
Layer 2: sbrk()/pgalloc() 此前已将 VA 0x80201000 映射到 PA 0x40322000
Layer 1: mm_pgalloc(1) 从 Page Pool bitmap 中分配了 PA 0x40322000
Hardware: L2 PTE l2table[1] = 0x40322000 | 0xFFE (user RW, small page, WB cache)

接下来从底层往上逐层剖析。先从最底层的物理页分配器开始——因为上层最终都依赖它。


4. Layer 1:物理页分配器(mm_pgalloc + 粒度分配器)

用户进程的每个虚拟页(4KB)背后必须有一个物理页支撑。当用户堆扩展、新进程加载 ELF 段、或分配用户栈时,都需要从 Page Pool 中分配物理页。物理页分配器的职责就是管理 Page Pool 中哪些页是空闲的、哪些已被使用。

4.1 初始化:mm_pginitialize()

文件:mm/mm_gran/mm_pgalloc.c:99-103

1
2
3
4
void mm_pginitialize(FAR void *heap_start, size_t heap_size)
{
g_pgalloc = gran_initialize(heap_start, heap_size, MM_PGSHIFT, MM_PGSHIFT);
}

nx_start() 启动时调用:up_allocate_pgheap(&start, &size) 获取 Page Pool 的物理地址和大小,然后 mm_pginitialize() 初始化粒度分配器。MM_PGSHIFT = 12(即 4KB = 2^12),粒度和对齐都是一个页。

4.2 分配:mm_pgalloc()

文件:mm/mm_gran/mm_pgalloc.c:148-151

1
2
3
4
uintptr_t mm_pgalloc(unsigned int npages)
{
return (uintptr_t)gran_alloc(g_pgalloc, (size_t)npages << MM_PGSHIFT);
}

返回值是物理地址——这块内存还没有被映射到任何虚拟地址,不能直接使用。调用者(pgalloc())负责将其映射到用户虚拟地址空间。

4.3 粒度分配器的 bitmap 算法

文件:mm/mm_gran/mm_gran.h:68-81

1
2
3
4
5
6
7
struct gran_s
{
uint8_t log2gran; /* log2(granule size) = 12 */
uint16_t ngranules; /* Page Pool 中的总页数 */
uintptr_t heapstart; /* Page Pool 起始物理地址 */
uint32_t gat[1]; /* Granule Allocation Table (bitmap) */
};

GAT(Granule Allocation Table) 是一个位图,每个 bit 对应一个 4KB 页:

1
2
3
4
5
6
Page Pool: 13MB ÷ 4KB = 3328 pages → 3328 bits → 104 个 uint32_t

gat[0] = 0b11111111_11111111_11111111_11110000 (前28页已用,后4页空闲)
gat[1] = 0b00000000_00000000_00000000_00000000 (全部空闲)
...
gat[103]= 0b00000000_00000000_00000000_00000011 (最后2页已用:L1页表)

实例:分配 1 页的过程

1
2
3
4
5
6
7
8
gran_alloc(g_pgalloc, 4096):
ngran = 4096 >> 12 = 1 (需要 1 个连续粒度)
gran_search(g_pgalloc, 1):
扫描 gat[] 位图,找到第一个值为 0 的 bit
假设找到 gat[1] 的 bit 0 → 对应物理页 index = 32
gran_set(g_pgalloc, 32, 1):
gat[1] |= (1 << 0) → 标记为已分配
返回物理地址 = heapstart + 32 * 4096 = 0x40300000 + 0x20000 = 0x40320000

对比 Linux buddy system:Linux 使用 buddy allocator(O(1) 分配、支持多种 order),而 NuttX 用线性扫描位图(O(n) 最坏情况)。为什么?因为嵌入式系统的 Page Pool 通常很小(几千页),线性扫描足够快,且位图实现极简、内存开销最低(每页仅 1 bit)。

物理页分配器解决了”从哪里拿物理页”的问题。下一步是把这些物理页映射到用户虚拟地址——这是 Layer 2 的职责。


5. Layer 2:堆扩展(sbrk → pgalloc → L2 页表)

用户堆初始只有很小的空间(甚至可能是 0)。当 mm_malloc() 发现空闲链表中没有足够大的块时,它需要向操作系统请求更多内存。在 Unix/Linux 中这是 sbrk()mmap() 的职责。NuttX KERNEL 模式实现了 sbrk()

5.1 sbrk() 实现

文件:mm/umm_heap/umm_sbrk.c:76-126

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
FAR void *sbrk(intptr_t incr)
{
uintptr_t brkaddr;
unsigned int pgincr;
size_t bytesize;

umm_try_initialize();

/* 获取当前堆的 break 地址(堆末尾)*/
brkaddr = (uintptr_t)mm_brkaddr(USR_HEAP, 0);

if (incr > 0)
{
/* 将增量转换为页数(向上取整)*/
pgincr = MM_NPAGES(incr);

/* 分配物理页并映射到 break 地址 */
allocbase = pgalloc(brkaddr, pgincr);
if (allocbase == 0)
{
return (FAR void *)-1;
}

/* 扩展堆管理器的可用区域 */
bytesize = pgincr << MM_PGSHIFT;
mm_extend(USR_HEAP, (FAR void *)allocbase, bytesize, 0);
}

return (FAR void *)brkaddr;
}

sbrk() 做三件事:

  1. 计算需要多少页MM_NPAGES(incr) 将字节数向上取整到页数
  2. **调用 pgalloc()**:分配物理页 + 映射到虚拟地址
  3. **调用 mm_extend()**:告诉堆管理器”你有更多空间可用了”

5.2 pgalloc():物理页分配 + MMU 映射的桥梁

文件:arch/arm/src/armv7-a/arm_pgalloc.c:178-254(以下为关键路径摘录并添加 Step 注释标注,原函数含调试日志和断言共 76 行)

这是 KERNEL 模式内存管理的核心函数——它将 Layer 1(物理页分配)和 MMU 映射连接起来:

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
uintptr_t pgalloc(uintptr_t brkaddr, unsigned int npages)
{
struct tcb_s *tcb = this_task();
struct arch_addrenv_s *addrenv = &tcb->addrenv_own->addrenv;

/* brkaddr = 0 表示堆还没有分配过 */
if (brkaddr == 0)
{
brkaddr = CONFIG_ARCH_HEAP_VBASE; /* 0x80200000 */
}

for (; npages > 0; npages--)
{
/* Step 1: 确保该虚拟地址所在的 1MB section 有 L2 页表 */
paddr = get_pgtable(addrenv, brkaddr);

/* Step 2: 获取 L2 页表的虚拟地址(内核可以直接访问)*/
l2table = (uint32_t *)arm_pgvaddr(paddr);

/* Step 3: 从 Page Pool 分配一个数据物理页 */
paddr = mm_pgalloc(1);

/* Step 4: 计算该虚拟地址在 L2 页表中的索引 */
index = (brkaddr & 0x000ff000) >> 12;

/* Step 5: 写入 L2 页表条目(建立 VA → PA 映射)*/
l2table[index] = paddr | MMU_L2_UDATAFLAGS;

/* Step 6: 刷新 D-Cache(确保 PTE 对 MMU 硬件可见)*/
up_flush_dcache((uintptr_t)&l2table[index],
(uintptr_t)&l2table[index] + sizeof(uint32_t));

brkaddr += MM_PGSIZE; /* 前进到下一页 */
}

return brkaddr;
}

实例:用户堆从 0x80200000 扩展一页到 0x80201000

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
pgalloc(0x80200000, 1):
Step 1: get_pgtable(addrenv, 0x80200000)
L1 index = 0x80200000 >> 20 = 0x802
查 addrenv->l1table[0x802]
如果非空 → 提取 L2 页表物理地址
如果为空 → alloc_pgtable():
mm_pgalloc(1) = 0x40320000 (L2 页表用的物理页)
memset(arm_pgvaddr(0x40320000), 0, 1024)
l1table[0x802] = 0x40320000 | MMU_L1_PGTABFLAGS
返回 0x40320000

Step 2: l2table = arm_pgvaddr(0x40320000) → 得到内核虚拟地址

Step 3: mm_pgalloc(1) = 0x40321000 (数据页物理地址)

Step 4: index = (0x80200000 & 0x000ff000) >> 12 = 0

Step 5: l2table[0] = 0x40321000 | MMU_L2_UDATAFLAGS
MMU_L2_UDATAFLAGS = PTE_TYPE_SMALL | PTE_WRITE_BACK | PTE_AP_RW01
最终 PTE 值 = 0x40321FFE (小页、回写缓存、用户可读写)

Step 6: flush dcache

返回 0x80201000 (下一个可用的虚拟地址)

此后,用户进程访问 VA 0x80200000-0x80200FFF 时,MMU 会通过 L1→L2 查表,找到物理页 0x40321000 上的数据。

pgalloc() 是 Layer 2 的核心。它上面是堆管理器(Layer 3),下面是物理页分配器(Layer 1)。下一节看堆管理器如何管理已映射的虚拟内存。


6. Layer 3:核心堆管理器(mm_heap)

pgalloc() 以页(4KB)为粒度。但用户可能只需要 16 字节。堆管理器的职责是在已映射的虚拟页内做字节级细分——将 4KB 页切割为多个小块,按需分配给用户,并在释放时合并回大块。

6.1 核心算法原理(选读)

mm_heap 的算法可以用三个关键词概括:块(chunk)、桶(bucket)、合并/分裂(coalesce/split)

原理一:整块内存被切成”块”,每块前面贴个标签

堆就是一段连续内存,被切成大小不等的块。每块开头有一个 8 字节的 header,记录本块大小和分配状态。已分配块的用户数据紧跟在 header 后面;空闲块的 header 之后是链表指针(flink/blink),用于串入空闲链表。

1
2
3
4
5
6
7
8
Heap memory (one contiguous region):

+--------+------------+--------+-------------------+--------+
| header | user data | header | free space | header |
| 8B | (100B) | 8B | (flink/blink + | 8B |
| ALLOC | | FREE | unused bytes) | ALLOC |
+--------+------------+--------+-------------------+--------+
Chunk A (112B total) Chunk B (256B total) Guard

原理二:空闲块按大小分桶索引

空闲块按大小分散到多个”桶”中,但所有桶串在同一条双向链表上——mm_nodelist[] 的每个元素是链表中的哨兵节点(size=0),真正的空闲块穿插在哨兵之间,整体按大小升序排列:

1
2
nodelist[0] <-> [16B] <-> [24B] <-> nodelist[1] <-> [32B] <-> [48B] <-> nodelist[2] <-> [80B] <-> ...
(size=0) (size=0) (size=0)

malloc(100) 时,计算 alignsize = 112,定位到桶 3(>= 128B),从 nodelist[3].flink 开始遍历。如果桶 3 为空,循环自动穿过下一个哨兵进入更大的桶——无需额外跳桶逻辑。找到第一个 >= 112 的块就是 best-fit。

原理三:分裂与合并

  • 分裂(split):找到的空闲块太大时(比如要 112B 但找到 256B),切成两半——前半给用户,后半作为新空闲块放回对应桶。
  • 合并(coalesce):释放时检查物理相邻的前后块是否也是空闲的,如果是就合并成一个更大的空闲块,放入更大的桶。这减少了碎片。

关键问题:空闲链表按大小排序,怎么找到物理相邻的块?

答案是:不通过空闲链表找邻居。每个块的 header 中有 size(本块大小)和 preceding(前一块大小)两个字段,构成”边界标签(boundary tag)”。通过指针算术 O(1) 直接跳到物理邻居:

1
2
3
4
5
6
7
8
9
preceding   size        preceding   size        preceding   size
+--------+--------+ +--------+--------+ +--------+--------+
| Prev chunk | | Current chunk | | Next chunk |
| | | | | |
+-----------------+ +-----------------+ +-----------------+
^ ^ ^
| | |
| prev = node - node->preceding next = node + node->size
| (向前跳 preceding 字节) (向后跳 size 字节)

所以合并时:

  • 后邻居:next = (char *)node + node->size — 直接算出地址
  • 前邻居:prev = (char *)node - node->preceding — 直接算出地址
  • 检查邻居是否空闲:看 next->sizeMM_ALLOC_BIT,或本块 sizeMM_PREVFREE_BIT

空闲链表和物理邻居各管各的事:

机制 用途 寻址方式
flink/blink 空闲链表 malloc 时找可用块 按大小遍历
size/preceding 边界标签 free 时找物理邻居做合并 指针算术(O(1))

与 FreeRTOS heap_4 的关系:底层的块管理(header、边界标签、合并、分裂)思路一致,NuttX 在此基础上加了分桶索引和 best-fit 策略,提升了搜索效率和碎片控制。

理解了这三个原理,下面看具体的数据结构如何支撑它们。

6.2 数据结构

文件:mm/mm_heap/mm.h:175-205

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 已分配块的头部(每个分配的内存块前面都有这个) */
struct mm_allocnode_s
{
mmsize_t preceding; /* 物理前一个块的大小 */
mmsize_t size; /* 本块大小(低2位复用为标志位)*/
};

/* 空闲块的头部(在 allocnode 基础上增加链表指针)*/
struct mm_freenode_s
{
mmsize_t preceding;
mmsize_t size;
FAR struct mm_freenode_s *flink; /* 空闲链表前向指针 */
FAR struct mm_freenode_s *blink; /* 空闲链表后向指针 */
};

size 字段的低 2 位被复用为标志(文件:mm/mm_heap/mm.h:128-130):

1
2
#define MM_ALLOC_BIT     0x1   /* bit 0: 本块已分配 */
#define MM_PREVFREE_BIT 0x2 /* bit 1: 物理前一个块是空闲的 */

堆本身的结构(文件:mm/mm_heap/mm.h:221-277):

1
2
3
4
5
6
7
8
9
10
11
12
struct mm_heap_s
{
mutex_t mm_lock; /* 堆互斥锁 */
size_t mm_heapsize; /* 堆总大小 */
size_t mm_curused; /* 当前使用量 */
size_t mm_maxused; /* 峰值使用量 */

FAR struct mm_allocnode_s *mm_heapstart[CONFIG_MM_REGIONS]; /* 每区域起始哨兵 */
FAR struct mm_allocnode_s *mm_heapend[CONFIG_MM_REGIONS]; /* 每区域结束哨兵 */

struct mm_freenode_s mm_nodelist[MM_NNODES]; /* 分桶空闲链表 */
};

6.3 分桶空闲链表

mm_nodelist[] 看起来是一个数组,但实际上所有桶串在同一条双向链表上mm_nodelist[0] ~ mm_nodelist[N] 本身是链表中的哨兵节点(size=0),真正的空闲块按大小升序穿插其间:

1
2
3
mm_nodelist[0] <-> [16B] <-> [24B] <-> mm_nodelist[1] <-> [32B] <-> [48B] <-> mm_nodelist[2] <-> [80B] <-> ... -> NULL
(size=0) (size=0) (size=0)
sentinel sentinel sentinel

桶的划分基于 2 的幂次:

1
2
3
4
5
mm_nodelist[0]:  16~31B 的空闲块在这个哨兵之后
mm_nodelist[1]: 32~63B 的空闲块在这个哨兵之后
mm_nodelist[2]: 64~127B 的空闲块在这个哨兵之后
...
mm_nodelist[MM_NNODES-1]: >= 4MB 的空闲块

这个设计的巧妙之处:malloc()mm_nodelist[ndx].flink 开始遍历,如果当前桶没有够大的块,循环会穿过下一个哨兵(size=0,不满足条件,跳过)自动进入更大的桶继续搜索——无需额外的”找下一个桶”逻辑。

下图展示了堆的物理内存布局(左)和空闲链表组织(右)的对应关系,以及边界标签导航(底部):

mm_heap

6.4 mm_malloc() 算法

文件:mm/mm_heap/mm_malloc.c:170-413(以下为核心逻辑摘录,原函数含锁处理、mempool 快路径等共 243 行)

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
FAR void *mm_malloc(FAR struct mm_heap_s *heap, size_t size)
{
size_t alignsize;
int ndx;
FAR struct mm_freenode_s *node;

/* 对齐并加上头部开销 */
alignsize = MM_ALIGN_UP(size + MM_ALLOCNODE_OVERHEAD);

/* 计算起始搜索桶 */
ndx = mm_size2ndx(alignsize);

/* 从桶 ndx 开始搜索——所有桶串在同一条链表上,
* mm_nodelist[] 哨兵节点 size=0 会被自动跳过,
* 因此这个循环会自动穿越到更大的桶继续搜索 */
for (node = heap->mm_nodelist[ndx].flink; node; node = node->flink)
{
nodesize = MM_SIZEOF_NODE(node);
if (nodesize >= alignsize)
{
break; /* best-fit: 第一个满足的就是最小够用的 */
}
}

if (node)
{
/* 从空闲链表摘除 */
node->blink->flink = node->flink;
if (node->flink) node->flink->blink = node->blink;

/* 如果剩余空间 >= MM_MIN_CHUNK,分裂出一个新的空闲块 */
remaining = nodesize - alignsize;
if (remaining >= MM_MIN_CHUNK)
{
remainder = (FAR struct mm_freenode_s *)((char *)node + alignsize);
remainder->size = remaining | MM_PREVFREE_BIT;
mm_addfreechunk(heap, remainder);
node->size = alignsize | MM_ALLOC_BIT;
}
else
{
node->size |= MM_ALLOC_BIT;
}

return (char *)node + MM_SIZEOF_ALLOCNODE;
}

return NULL; /* 堆空间不足,调用者会触发 sbrk() */
}

实例:malloc(100) 的内部过程

1
2
3
4
5
6
7
8
9
10
11
12
13
(1) alignsize = ALIGN_UP(100 + 8) = ALIGN_UP(108) = 112 bytes (8字节对齐)
(2) ndx = mm_size2ndx(112) = 3 (对应 >= 64 bytes 的桶)
(3) 搜索 mm_nodelist[3].flink → 找到一个 256 字节的空闲块
(4) 分裂:前 112 字节标记为 ALLOC,后 144 字节作为新空闲块放回链表
(5) 返回指针 = 块地址 + 8 (跳过 allocnode 头部)

Memory layout:
+------------------+------------------+-------------------+
| allocnode header | user data (100B) | padding (4B) |
| (8 bytes) | | (to align 112) |
| size=112|ALLOC | | |
+------------------+------------------+-------------------+
|<--- 112 bytes total (including header) --->|

6.5 mm_free() 算法:合并相邻空闲块

文件:mm/mm_heap/mm_free.c:84-221

释放时尝试与物理相邻的前后空闲块合并,减少碎片:

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
void mm_delayfree(FAR struct mm_heap_s *heap, FAR void *mem, bool delay)
{
FAR struct mm_freenode_s *node;
FAR struct mm_freenode_s *next;
FAR struct mm_freenode_s *prev;

node = (FAR struct mm_freenode_s *)((char *)mem - MM_SIZEOF_ALLOCNODE);
node->size &= ~MM_ALLOC_BIT; /* 清除 ALLOC 标志 */

/* 尝试与后面的块合并 */
next = (FAR struct mm_freenode_s *)((char *)node + MM_SIZEOF_NODE(node));
if ((next->size & MM_ALLOC_BIT) == 0)
{
/* next 是空闲的,从链表摘除后合并 */
mm_unlinkfree(heap, next);
node->size += MM_SIZEOF_NODE(next);
}

/* 尝试与前面的块合并 */
if ((node->size & MM_PREVFREE_BIT) != 0)
{
prev = (FAR struct mm_freenode_s *)((char *)node - node->preceding);
mm_unlinkfree(heap, prev);
prev->size += MM_SIZEOF_NODE(node);
node = prev; /* 合并后 node 指向更大的块 */
}

/* 将合并后的块插入空闲链表 */
mm_addfreechunk(heap, node);
}

合并使得释放的内存可以被后续更大的分配重用,避免出现大量无法使用的小碎片。

堆管理器负责虚拟内存内的字节级分配。当它空间不足时,会调用上面的 sbrk() 扩展。接下来看上层的用户堆和内核堆如何使用这个核心管理器。


7. Layer 4:用户堆与内核堆

  • 内核堆(kmm):内核代码分配 TCB、task_group、内核栈、驱动缓冲区等。位于内核地址空间(恒等映射的 DDR),所有进程可见。
  • 用户堆(umm):每个用户进程自己的 malloc()。位于用户虚拟地址空间(0x80200000+),进程间隔离。

7.1 内核堆初始化

文件:mm/kmm_heap/kmm_initialize.c:61-70

1
2
3
4
5
6
7
8
void kmm_initialize(FAR void *heap_start, size_t heap_size)
{
struct mm_heap_config_s config;
config.name = "Kmem";
config.start = heap_start;
config.size = heap_size;
g_kmmheap = mm_initialize_pool(&config, NULL);
}

nx_start() 中被调用:up_allocate_kheap() 确定内核堆的起止范围(从 g_idle_topstack 到 Page Pool 起始之前),然后 kmm_initialize() 创建堆。

kmm_malloc(size) 就是 mm_malloc(g_kmmheap, size) 的简单包装——不涉及页分配或 MMU 操作,因为内核堆的虚拟地址就是物理地址(恒等映射)。

7.2 用户堆初始化

文件:mm/umm_heap/umm_initialize.c:87-100

1
2
3
4
5
6
7
void umm_initialize(FAR void *heap_start, size_t heap_size)
{
struct mm_heap_config_s config;
config.start = heap_start;
config.size = heap_size;
USR_HEAP = mm_initialize_pool(&config, NULL);
}

USR_HEAP 的解析(文件:include/nuttx/mm/mm.h:119):

1
#define USR_HEAP (ARCH_DATA_RESERVE->ar_usrheap)

在 KERNEL 模式下,USR_HEAP 指向进程 .data 段开头保留区域中的堆指针。每个进程有自己的 .data 段(映射到不同物理页),所以每个进程有自己的 USR_HEAP

7.3 惰性初始化:umm_try_initialize()

文件:mm/umm_heap/umm_initialize.c:117-146

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void umm_try_initialize(void)
{
uintptr_t allocbase;

if (USR_HEAP != NULL)
return; /* 已初始化 */

/* 分配第一个堆页 */
allocbase = pgalloc(0, 1); /* brkaddr=0 → 使用 CONFIG_ARCH_HEAP_VBASE */
DEBUGASSERT(allocbase != 0);

/* 在该页上初始化堆管理结构 */
umm_initialize((FAR void *)allocbase, CONFIG_MM_PGSIZE);
}

用户进程首次 malloc() 时触发:分配第一个物理页映射到 CONFIG_ARCH_HEAP_VBASE(0x80200000),在上面初始化 mm_heap_s 结构。之后的 malloc() 就在这个堆上分配,空间不够时 sbrk() 继续扩展。

7.4 malloc() 到 sbrk() 的触发路径

文件:mm/umm_heap/umm_memalign.c:54(KERNEL 模式下 malloc() 实际调用 memalign()

mm_malloc() 返回 NULL 时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
FAR void *memalign(size_t alignment, size_t size)
{
FAR void *ret;

umm_try_initialize();
ret = mm_memalign(USR_HEAP, alignment, size);
if (ret == NULL)
{
/* 堆空间不够,扩展 */
if (sbrk(size < 1 ? 1 : size) != (FAR void *)-1)
{
ret = mm_memalign(USR_HEAP, alignment, size); /* 重试 */
}
}

return ret;
}

整个链条:malloc(100)memalign(8, 100)mm_memalign() 返回 NULL → sbrk(100)pgalloc() 分配物理页 + 映射 → mm_extend() 扩展堆 → 重试 mm_memalign() 成功。

下图展示了堆空间不足时 malloc() 触发扩展的完整时序:

malloc完整路径


8. 内存释放与进程退出

分配只是内存管理的一半。如果释放逻辑有缺陷(碎片化、无法合并、泄漏),长时间运行的嵌入式系统会逐渐耗尽内存。此外,KERNEL 模式下进程退出时的”批量回收”机制是其相比 FLAT 模式的重要优势——理解它才能明白为什么 KERNEL 模式不怕用户进程的内存泄漏。

8.1 free() 路径

1
2
3
4
5
6
free(ptr)
→ mm_free(USR_HEAP, ptr)
→ mm_delayfree(heap, ptr, delay)
→ 清除 ALLOC 位
→ 合并前后空闲块
→ mm_addfreechunk(heap, node)

释放的内存回到空闲链表,可被后续 malloc() 重用。注意:释放不会归还物理页给 Page Pool——已映射的页永远属于该进程的堆,直到进程退出。

8.2 延迟释放(mm_delaylist)

文件:mm/mm_heap/mm_free.c:84-100

如果 free() 时无法获取堆的 mutex(例如在中断上下文中被调用),内存块被放入 per-CPU 的 mm_delaylist[],等下次 malloc() 时批量处理:

1
2
3
4
5
6
7
if (mm_lock(heap) < 0 || delay)
{
/* 暂时放入延迟列表 */
node->flink = heap->mm_delaylist[this_cpu()];
heap->mm_delaylist[this_cpu()] = node;
return;
}

8.3 进程退出时的整体回收

当进程的最后一个线程退出时,整个地址环境被销毁:

1
2
3
4
5
6
7
addrenv_destroy()
→ up_addrenv_destroy(addrenv)
→ arm_addrenv_destroy_region(l1table, ..., vaddr) [对每个区域]
→ 遍历 L2 页表的每个非空条目
→ mm_pgfree(paddr, 1) [归还物理页给 Page Pool]
→ mm_pgfree(l2table_paddr, 1) [归还 L2 页表页]
→ mm_pgfree(l1table_paddr, 4) [归还 L1 页表]

进程退出时不需要逐个 free() 堆上的对象——整个地址空间的物理页被批量归还给 Page Pool。这比 FLAT 模式(需要手动 free 每个分配)高效得多。


9. 对比分析

特性 NuttX KERNEL NuttX FLAT Linux
堆的数量 每进程一个用户堆 + 一个内核堆 全局一个堆 每进程用户堆 + SLAB/SLUB 内核堆
物理页分配器 粒度位图(线性扫描) Buddy system(O(1))
堆扩展机制 sbrk()pgalloc() 无(堆大小固定) brk()/mmap() → page fault → alloc
用户 malloc 失败时 同步扩展(sbrk 立即分配页) 直接返回 NULL lazy(先映射虚拟,访问时 page fault)
进程退出时 批量归还物理页 需手动 free(否则泄漏) 销毁地址空间,批量归还
空闲块管理 分桶空闲链表(best-fit) SLAB(small)+ Buddy(large)
碎片处理 前后合并 SLAB + compaction
内存开销/块 8 字节 header SLAB 无 header(基于 page offset)

NuttX 的设计取舍

  • 同步分配而非 Linux 的 demand paging——简化了实现(不需要 page fault handler),代价是 malloc() 可能阻塞较长时间
  • 位图而非 buddy——节省内存(Page Pool 几千页只需几百字节位图),代价是分配速度 O(n)
  • 分桶链表而非 SLAB——通用性好(适合各种大小),代价是小对象有 8 字节 header 开销

10. 关键要点

  1. NuttX KERNEL 模式使用四层分配器:用户 API(malloc)→ 核心堆管理器(mm_malloc)→ 堆扩展(sbrk/pgalloc)→ 物理页分配器(granule bitmap)。

  2. 内核堆和用户堆完全独立:内核堆在恒等映射的 DDR 上,无需 MMU 操作;用户堆在进程独有的虚拟地址空间,每次扩展需要分配物理页 + 写 L2 PTE。

  3. pgalloc() 是 KERNEL 模式的核心桥梁:它将 Layer 1(物理页)和 MMU 映射连接起来,每次调用做 3 件事:确保 L2 页表存在、分配数据页、写 PTE + flush cache。

  4. 物理页分配使用 bitmap 算法:Page Pool 中每页用 1 bit 跟踪,gran_alloc() 线性扫描找连续空闲位。简单但对嵌入式场景足够。

  5. 用户堆惰性初始化:首次 malloc() 时才分配第一个页并创建堆结构,避免为从不使用堆的进程浪费物理页。

  6. free() 不归还物理页:释放的内存回到堆空闲链表供重用,但物理页不会还给 Page Pool。只有进程退出时才批量回收。

  7. 进程退出 = 批量回收addrenv_destroy() 遍历所有 L2 页表条目,将所有物理页和页表页归还 Page Pool。不需要逐个 free。


11. 参考文件索引

文件路径 关键内容 引用行号
mm/mm_heap/mm.h mm_allocnode_s, mm_freenode_s, mm_heap_s 68-277
mm/mm_heap/mm_malloc.c mm_malloc() 核心算法 170-413
mm/mm_heap/mm_free.c mm_delayfree() 释放与合并 84-221
mm/mm_heap/mm_initialize.c mm_addregion() 堆初始化 234+
mm/mm_heap/mm_extend.c mm_extend() 堆扩展 54+
mm/kmm_heap/kmm_initialize.c kmm_initialize() 内核堆 61-70
mm/umm_heap/umm_initialize.c umm_initialize(), umm_try_initialize() 87-146
mm/umm_heap/umm_sbrk.c sbrk() 实现 76-126
mm/umm_heap/umm_memalign.c memalign() 触发 sbrk 54+
mm/mm_gran/mm_gran.h struct gran_s 粒度分配器 68-81
mm/mm_gran/mm_pgalloc.c mm_pgalloc(), mm_pginitialize() 99-197
mm/mm_gran/mm_granalloc.c gran_alloc() 位图搜索 43+
mm/mm_gran/mm_grantable.c gran_search(), gran_set() 260-299
arch/arm/src/armv7-a/arm_pgalloc.c pgalloc() MMU 映射 178-254
arch/arm/src/armv7-a/arm_addrenv.c up_addrenv_create/destroy 167-314
arch/arm/src/armv7-a/arm_addrenv_utils.c arm_addrenv_create/destroy_region 60-214
include/nuttx/mm/mm.h USR_HEAP 宏定义 119
include/nuttx/addrenv.h addrenv_reserve_s (ar_usrheap) 301-305
sched/init/nx_start.c 启动时初始化 kmm + pgalloc 540-575
sched/addrenv/addrenv.c addrenv_destroy() 进程退出回收 196+
arch/arm/src/armv7-a/arm_allocateheap.c up_allocate_kheap(), up_allocate_pgheap() 241+
Documentation/implementation/memory_configurations.rst 官方内存配置文档 508-537