---
title: 内存管理机制
tags: [Linux内核, 内存管理, 虚拟内存, 嵌入式, 伙伴系统, slab]
created: 2026-09-16
updated: 2026-09-17
---
# 内存管理机制
> 💡 **关联知识**: [[FreeRTOS学习笔记/16-内存管理]] | [[计算机操作系统/内存管理]] | [[02-嵌入式Linux内核基础/06-设备模型与驱动框架]]
---
## 一、内存管理概述
### 1.1 物理内存与虚拟内存
物理内存是 DDR 控制器直接管理的线性地址空间,ARM 平台通常从 `0x80000000` 开始编排,启动时从中划分出内核镜像、设备树、页表和可用内存池。虚拟内存是每个进程独立看到的地址空间:32 位系统下每个进程拥有 4GB 虚拟地址范围,用户空间占低 3GB(`0x00000000 - 0xBFFFFFFF`),内核空间占高 1GB(`0xC0000000 - 0xFFFFFFFF`)。进程切换时内核把新进程的页表基址写入 TTBR0,MMU 随即切换到新的映射。
两套地址之间的映射关系保存在页表中。内核在 `fork()` 时为子进程建立用户空间页表,系统调用陷入内核态时切换到内核空间映射。这种设计的直接好处:进程之间互不干扰,内核代码和数据被所有进程共享但受权限位保护,Swap 机制可以把暂时不用的页面换出到磁盘,等价于扩大了物理内存容量。
### 1.2 内存层次
嵌入式系统的存储层次从快到慢:寄存器(<1ns)→ L1 Cache(~1ns)→ L2 Cache(~3ns)→ DDR(~50ns)→ eMMC/SD(~100us)。这个层次决定了内核内存管理的核心策略:页分配器(伙伴系统)按 2^n 页的幂次分配,天然对齐有利于 DMA 和 Cache 行填充;slab 分配器在页之上建立对象池,减少频繁的页分配/释放开销;per-cpu 缓存则把热点数据锁在 Cache 中,避免多核竞争。
---
## 二、页表与地址翻译
### 2.1 ARM32 两级页表结构
ARMv7-A 的 MMU 使用「短描述符」(short-descriptor)格式,地址翻译由两个基址寄存器参与:`TTBR0` 指向用户空间页表,`TTBR1` 指向内核空间页表,`TTBCR.N` 决定两者各覆盖多少地址。Linux 常用 `TTBCR.N = 2`,即 TTBR0 覆盖低 3GB(用户),TTBR1 覆盖高 1GB(内核)。
| 层级 | 名称 | 条目数 | 表大小 | 对齐要求 | 索引位 |
| ---- | ------------------- | ------ | ------ | -------- | ----------- |
| L1 | 页全局目录(PGD) | 4096 | 16KB | 16KB | `VA[31:20]` |
| L2 | 页表(PTE,粗粒度) | 256 | 1KB | 1KB | `VA[19:12]` |
一级描述符由低两位 `[1:0]` 区分类型:`0b00` Fault(无效映射,触发缺页)、`0b01` 二级页表指针(指向 L2)、`0b10` Section(直接映射 1MB,省一次内存访问)、`0b11` 细粒度页表(已废弃)。二级描述符:`0b00` Fault、`0b01` 大页(64KB)、`0b10` 小页(4KB)。
```text
31 20 19 12 11 0
+------------+----------+--------------+
| L1 index | L2 index | page offset |
| 12 bit | 8 bit | 12 bit |
+------------+----------+--------------+
```
L2 描述符的 `bit[4]` 是 **XN(eXecute Never)**,置 1 表示该页不可执行——这是内核对栈和堆做 NX 保护、`mprotect(PROT_EXEC)` 生效的硬件基础,也是阻止栈溢出攻击跳转到 shellcode 的关键。一级描述符中 `bit[1:0]=0b10` 且 `bit[18]=1` 表示 **Supersection(16MB)**,要求 16MB 对齐,用一个大 TLB 条目覆盖大片内存,适合 CMA 这类大块连续区域。
### 2.2 页表控制位:AP、Domain、C/B
**AP(Access Permission)**:Section 中为 `bit[15:10]`,小页中为 `bit[9:4]`。
| AP[1:0] | 特权态 | 用户态 | 典型用途 |
| ------- | ------ | ------ | ---------------------- |
| `00` | 读写 | 无访问 | 内核私有数据 |
| `01` | 读写 | 读写 | 用户可写数据段 |
| `10` | 只读 | 无访问 | 内核只读数据(rodata) |
| `11` | 只读 | 只读 | 用户代码段 |
AP[2] 是只读位:0 表示特权态读写,1 表示特权态只读。Linux 用这组位实现 `PROT_READ`/`PROT_WRITE` 和内核 rodata 保护。
**Domain(域,`bit[8:5]`)** 是 4 位编号,配合协处理器寄存器 `DACR` 使用,DACR 中每个域占 2 bit:`00` No access(任何访问都异常)、`01` Client(正常执行 AP 检查)、`11` Manager(跳过 AP 检查且不更新访问位),`10` 保留。Linux 把所有页放在 Domain 0 并设为 Client,其余域设为 Manager。
**C/B 与 TEX(缓存属性)**:`bit[3]=C`(Cacheable)、`bit[2]=B`(Bufferable)连同 `TEX[2:0]`(`bit[14:12]`)决定内存类型:
| TEX | C | B | 内存类型 | 用途 |
| ----- | --- | --- | ---------------------------------- | ---------------------------------- |
| `000` | 0 | 0 | Strongly-ordered | 外设寄存器,严格顺序,不缓存不重排 |
| `000` | 0 | 1 | Device / Shareable | 外设寄存器,写缓冲 |
| `000` | 1 | 0 | Normal, Non-cacheable | 需要显式 cache 维护的 DMA 缓冲 |
| `001` | 1 | 1 | Normal, Write-Back Write-Allocate | 普通 DDR 内存 |
| `010` | 0 | 0 | Normal, Non-cacheable(shareable) | 多核共享数据 |
驱动无需手改这些位,内核通过 `ioremap()` 的 `MT_DEVICE`、内存管理的 `MT_MEMORY` 等 `mem_type` 封装映射属性。
### 2.3 TLB 工作原理与刷新
TLB 是硬件地址翻译缓存,ARMv7 分为指令 TLB(I-TLB)、数据 TLB(D-TLB)和统一的 L2 TLB,条目标签通常由虚拟页号加 **ASID** 组成。ASID 是 8 位进程标识,存放在 `TTBR0` 低 8 位:进程切换时若两个进程 ASID 不同,TLB 条目可以共存而不必整体刷新。内核在 `switch_mm()` 中分配 ASID(`mm->context.id`),ASID 用尽时触发 rollover,重新分配并全量刷新 TLB。
| 接口 | 粒度 | 底层 ARM 操作 |
| ---------------------------------- | ------------ | ---------------------- |
| `local_flush_tlb_all()` | 全部 | `TLBIALL` |
| `flush_tlb_mm(mm)` | 整个地址空间 | `TLBIASID` |
| `flush_tlb_range(vma, start, end)` | 一段地址范围 | `TLBIMVA` 循环 |
| `flush_tlb_page(vma, addr)` | 单个页面 | `TLBIMVA` |
| `flush_icache_range(start, end)` | 指令 cache | `ICIMVAU` + `BPIALLIS` |
需要刷新的典型场景:`set_pte_at` 修改 PTE 之后、`mprotect` 改权限、`munmap` 解除映射、`vmalloc`/`vfree` 增删映射、缺页建立映射之后。粒度选择直接影响性能:单页 `TLBIMVA` 远优于整 mm 的 `TLBIASID`,而 `TLBIALL` 最昂贵。
```mermaid
flowchart TD
A["CPU 发出虚拟地址 VA"] --> B{"TLB 命中?
标签 = VPN + ASID"}
B -->|命中| C["直接得到物理地址 PA"]
B -->|未命中| D{"VA 在用户区还是内核区?"}
D -->|用户区| E["TTBR0 + L1 表项 (VA 31:20)"]
D -->|内核区| F["TTBR1 + L1 表项 (VA 31:20)"]
E --> G{"L1 描述符类型"}
F --> G
G -->|Fault| H["缺页异常 Page Fault"]
G -->|Section 1MB| I["PA = base + 偏移"]
G -->|页表指针| J["L2 表项 (VA 19:12)"]
J --> K{"L2 描述符类型"}
K -->|Fault| H
K -->|Small / Large Page| L["PA = page_base + 低位偏移"]
I --> M["权限检查 AP + Domain + XN"]
L --> M
M -->|通过| N["填充 TLB 条目"]
N --> C
M -->|失败| O["Data Abort 权限异常"]
C --> P["访问 Cache / DDR"]
```
---
## 三、页分配器与伙伴系统
### 3.1 伙伴系统的组织结构
伙伴系统把空闲内存按 2 的幂次组织成 11 个链表(order 0 到 order 10)。每个 order 内部不是单个链表,而是按**迁移类型**分组的数组:
```c
struct free_area {
struct list_head free_list[MIGRATE_TYPES];
unsigned long nr_free; /* 该 order 的空闲块总数 */
};
struct zone {
struct free_area free_area[MAX_ORDER]; /* MAX_ORDER = 11 */
...
};
```
```text
free_area[0] order 0 -> 1 页 (4KB)
free_area[1] order 1 -> 2 页 (8KB)
free_area[2] order 2 -> 4 页 (16KB)
...
free_area[10] order 10 -> 1024 页 (4MB)
```
迁移类型包括 `MIGRATE_UNMOVABLE`(内核数据结构)、`MIGRATE_MOVABLE`(用户页、页缓存)、`MIGRATE_RECLAIMABLE`、`MIGRATE_CMA`、`MIGRATE_ISOLATE`。按类型分链是内存压缩能工作的前提——只有把「可移动页」集中在一起,才谈得上搬迁它们去拼出连续块。
分配过程(以请求 order-2 即 4 页为例):先从 order-2 链表查找对应迁移类型的空闲块;若为空则向上查 order-3,依此类推直到 order 10;找到后将大块逐级拆半,得到所需块,把拆分产生的伙伴挂回对应 order 的链表;全部 order 都无合适块时唤醒 `kswapd` 或触发 direct reclaim,仍失败则返回 NULL。
```mermaid
flowchart TD
A["alloc_pages(GFP_KERNEL, 2)"] --> B{"order-2 有合适
迁移类型的块?"}
B -->|有| C["摘除该块"]
B -->|无| D["向上查找 order 3..10"]
D --> E{"找到更大块?"}
E -->|否| F["唤醒 kswapd / direct reclaim"]
F --> G{"回收后仍失败?"}
G -->|是| H["返回 NULL"]
G -->|否| B
E -->|是| K["大块逐级一分为二, 一半挂回低 order 链表"]
K --> C
C --> M["标记页已用, 返回第一个 struct page"]
```
释放是分配的逆操作。由于块本身是 2^order 页对齐的,伙伴的页帧号可以直接用异或算出,空闲且同 zone、同迁移类型就合并成 order+1,再继续向上判断:
```c
static inline unsigned long
__find_buddy_pfn(unsigned long page_pfn, unsigned int order)
{
return page_pfn ^ (1 << order);
}
```
### 3.2 struct zone 与 struct pglist_data
物理内存组织是三层:节点(Node)→ 区域(Zone)→ 页框(Page)。**`struct pglist_data`**(别名 `pg_data_t`)描述一个 NUMA 节点,全局数组 `node_data[MAX_NUMNODES]` 保存所有节点;嵌入式 ARM 平台通常只有一个节点(`contig_page_data`)。它包含 zone 数组、zonelist(分配失败时的区域回退顺序)、页框管理区 `node_mem_map`、`kswapd` 进程指针。
| Zone | 物理范围 | 说明 |
| -------------- | ---------------------------------- | ---------------------------------------- |
| `ZONE_DMA` | 0 - 16MB | 传统 DMA 可寻址范围 |
| `ZONE_DMA32` | 0 - 4GB | 32 位 DMA 设备可寻址 |
| `ZONE_NORMAL` | 内核直接映射区 | 常规内存,ARM32 上为主体 |
| `ZONE_HIGHMEM` | 高于内核线性映射区 | 仅 32 位系统存在 |
| `ZONE_MOVABLE` | 由 `kernelcore`/`movablecore` 划分 | 只放可移动页,为热插拔和 compaction 服务 |
`struct zone` 关键字段:`free_area[MAX_ORDER]`(伙伴系统链表)、`watermark[NR_WMARK]`(水位线)、`nr_free_pages`、`managed_pages`/`present_pages`/`spanned_pages`、`lru_lock` 与 `lruvec`、`vm_stat[]`。
**水位线**是内存回收的触发条件:空闲页降到 `WMARK_LOW` 唤醒 `kswapd`;降到 `WMARK_MIN` 进入 direct reclaim,由分配者自己回收;连 `WMARK_MIN` 都保不住且请求带 `ALLOC_NO_WATERMARKS`(如 `PF_MEMALLOC` 的回收线程)则可能触发 OOM Killer;`WMARK_HIGH` 是 kswapd 的停止线。
```bash
cat /proc/buddyinfo # 各 zone 各 order 的空闲块数量
cat /proc/pagetypeinfo # 各迁移类型分布(排查 CMA 区混入不可移动页)
cat /proc/zoneinfo # 水位线、统计
```
`/proc/buddyinfo` 一行形如 `Node 0, zone Normal 21 12 9 5 3 1 1 0 0 0 0`,从左到右是 order 0..10。若 order 5 及以上全是 0,说明系统已无法分配 128KB 以上的连续物理内存——这正是嵌入式设备长时间运行后 `dma_alloc_coherent` 失败的典型现象。
### 3.3 struct page
内核为每个页框维护一个 `struct page`(占 64 字节),所有 `struct page` 组成全局数组 `mem_map`,以页帧号(PFN)为下标访问。`flags` 常用标志:PG_locked(正在 I/O)、PG_dirty(需回写)、PG_lru(在 LRU 链上)、PG_slab(由 slab 管理)、PG_buddy(伙伴系统空闲页)。
`_count` 是引用计数,降到 0 才可回收;`_mapcount` 记录被多少个页表项映射,为 -1 时表示无进程映射。两者区分对理解回收和 COW 至关重要:一个页可能被 `mmap` 到多个进程(`_mapcount > 0`)同时被文件缓存持有(`_count > 0`),只有两者都归零才能释放。SLUB 复用了 `struct page` 的 union 字段存放 slab 元数据(`slab_cache`、`freelist`),这也是 SLUB 比 SLAB 省内存的原因。
### 3.4 分配 API 与 GFP 标志
- `alloc_pages(gfp_mask, order)`:分配 2^order 个连续物理页,返回第一个页的 `struct page`
- `__free_pages(page, order)`:释放 2^order 个连续物理页
- `__get_free_pages(gfp_mask, order)` / `free_pages(addr, order)`:以虚拟地址形式分配/释放
- `page_address(page)`:把 `struct page` 转成虚拟地址
`order` 取值范围 0 到 `MAX_ORDER - 1`(通常为 10),即最大 4MB 连续块。
| 标志 | 适用场景 | 行为 |
| ------------ | ---------------------- | ---------------------------------- |
| `GFP_KERNEL` | 进程上下文,可睡眠 | 可触发直接回收、换出页面、等待 I/O |
| `GFP_ATOMIC` | 中断上下文、持有自旋锁 | 不可睡眠,失败立即返回 NULL |
| `GFP_NOIO` | 已在 I/O 路径中 | 不触发新的 I/O 来回收内存 |
| `GFP_NOFS` | 已在文件系统路径中 | 不触发文件系统操作来回收内存 |
| `GFP_DMA` | 需要 DMA 可用内存 | 从 DMA 区域分配,地址低于 16MB |
| `GFP_DMA32` | 需要 32 位地址的内存 | 地址低于 4GB |
常用修饰符:`__GFP_ZERO`(填零)、`__GFP_RETRY_MAYFAIL`(尽力尝试,允许失败)、`__GFP_NOFAIL`(绝不失败,慎用)、`__GFP_COMP`(复合页)、`__GFP_MOVABLE`(可迁移)。选择原则:中断或持锁路径必须用 `GFP_ATOMIC`;进程上下文用 `GFP_KERNEL`;若代码本身就在回收路径中(如 `shrink_slab`)必须用 `GFP_NOFS`/`GFP_NOIO` 避免递归。
### 3.5 内存压缩(Compaction)
内存压缩解决「有空间但没有连续空间」的问题:把散落在低地址的**可移动页**搬到其他空闲页上,腾出连续区域。`compact_zone()` 分四步:隔离(从 zone 尾部扫描,把可移动页从 LRU 摘下放入 `migrate_pages` 链表)→ 迁移(分配新位置、拷贝内容、修改所有指向它的 PTE、释放原页)→ 同步(更新映射并刷新 TLB)→ 释放空闲块并触发伙伴合并。
相关参数与观测:`kcompactd` 每 NUMA 节点一个线程,在 kswapd 之后被动触发;`/proc/sys/vm/compaction_proactiveness`(5.16+)控制主动压缩程度;`/proc/sys/vm/extfrag_threshold` 是碎片指数阈值;`/sys/kernel/debug/extfrag/unusable_index` 给出各 order 的不可用指数(越接近 1000 表示按该 order 分配越必然失败)。
压缩的代价是迁移成本,且**不可移动页是硬约束**:`MIGRATE_UNMOVABLE` 的页无法搬运,散落在要合并的区域中就会导致压缩失败,因此内核尽量把不可移动页聚合。只有请求 order `>= PAGE_ALLOC_COSTLY_ORDER`(通常为 3)时才值得压缩,低 order 请求直接回退到其他迁移类型更划算。
---
## 四、slab 分配器
### 4.1 为什么需要 slab
伙伴系统以页为最小单位分配,但内核中大量数据结构只需几十到几百字节。如果每个 `struct task_struct`(约 2KB)或 `struct inode`(约 600 字节)都占一整页,内存浪费严重,且频繁小块分配/释放会造成外部碎片。
slab 分配器在伙伴系统之上建立对象缓存:从伙伴系统获取整页,切割成固定大小的对象按需分配;释放时对象不立即归还,而是保留在缓存中供下次使用。好处有三:同类型对象大小相同不产生外部碎片;空闲对象链表在 Cache 中,分配只需一次链表操作;对象着色减少多核伪共享。
### 4.2 slab / slub / slob 三种实现
内核历史上存在三套实现,由 `CONFIG_SLAB` / `CONFIG_SLUB` / `CONFIG_SLOB` 三选一:
| 维度 | SLAB | SLUB | SLOB |
| ------------ | ---------------------------------- | ---------------------------------------------- | ------------------ |
| 引入时间 | 2.2 | 2.6.22 | 2.x(早期嵌入式) |
| 默认状态 | 长期默认 | 2.6.23 起默认,现代唯一推荐 | 已废弃 |
| 元数据位置 | 独立 `struct slab` + 每 cache 队列 | 塞进 `struct page`(`slab_cache`、`freelist`) | 页内简单链表 |
| 空闲对象组织 | per-cpu array cache + shared | per-cpu freelist,无对象队列 | slab 页内单向链表 |
| 内存开销 | 较大 | 小,适合小内存系统 | 最小 |
| 适用场景 | 历史系统 | 通用,包括嵌入式 | 极小的 `!SMP` 系统 |
`SLOB` 已在 Linux 6.6 中被移除。SLUB 胜出的关键:把 slab 元数据放进 `struct page` 的 union,省掉独立的 `struct slab` 和每 cache 的对象队列;per-cpu freelist 让快路径完全无锁。
```bash
ls /sys/kernel/slab/ # 每个 kmem_cache 一个目录
cat /sys/kernel/slab/kmalloc-64/objects
slabinfo -X # 比 /proc/slabinfo 更易读
```
### 4.3 kmem_cache 完整生命周期
```c
struct kmem_cache *kmem_cache_create(const char *name, size_t size,
size_t align, unsigned int flags,
void (*ctor)(void *));
/* 4.16 之后推荐用于含用户可读写数据的结构体, 配合 HARDENED_USERCOPY */
struct kmem_cache *kmem_cache_create_usercopy(const char *name, size_t size,
size_t align, unsigned int flags,
size_t useroffset, size_t usersize,
void (*ctor)(void *));
void *kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags);
void *kmem_cache_alloc_node(struct kmem_cache *cachep, gfp_t flags, int node);
void *kmem_cache_zalloc(struct kmem_cache *cachep, gfp_t flags); /* 清零 */
void kmem_cache_free(struct kmem_cache *cachep, void *objp);
int kmem_cache_shrink(struct kmem_cache *cachep); /* 归还空闲 slab */
void kmem_cache_destroy(struct kmem_cache *cachep); /* 必须先释放所有对象 */
```
| flag | 作用 |
| ---------------------- | ----------------------------------- |
| `SLAB_HWCACHE_ALIGN` | 对象按 Cache 行对齐,牺牲内存换性能 |
| `SLAB_POISON` | 填充 0x6b,检测 use-after-free |
| `SLAB_RED_ZONE` | 对象前后加哨兵,检测越界写 |
| `SLAB_TYPESAFE_BY_RCU` | 对象可延迟释放,配合 RCU 使用 |
| `SLAB_ACCOUNT` | 计入 memory cgroup |
| `SLAB_CACHE_DMA` | 从 DMA 区域分配 |
| `SLAB_PANIC` | 分配失败直接 panic(关键缓存) |
创建函数只能睡眠,不可在中断上下文调用。SLUB 的快路径只有几步:读 per-cpu 的 `c->freelist` 取下第一个对象,`freelist` 指向下一个;链表为空才走慢路径向伙伴系统要新 slab。`kmem_cache_destroy` 不检查是否还有对象未释放,遗留对象会造成泄漏和悬空指针,卸载模块前务必确认 cache 已清空;创建后忘记销毁则模块再也无法重新加载。
```c
static struct kmem_cache *my_cache;
static void my_ctor(void *p) /* 仅在对象首次分配时调用 */
{
struct my_obj *o = p;
INIT_LIST_HEAD(&o->node);
spin_lock_init(&o->lock);
o->magic = 0x5A5A;
}
static int __init my_init(void)
{
my_cache = kmem_cache_create("my_obj", sizeof(struct my_obj), 0,
SLAB_HWCACHE_ALIGN | SLAB_PANIC, my_ctor);
return 0;
}
static void my_exit(void)
{
kmem_cache_destroy(my_cache);
}
module_init(my_init);
module_exit(my_exit);
```
### 4.4 对象着色(object coloring)原理
同一 slab 缓存中的对象大小相同。如果每个 slab 的起始偏移完全一致,那么「每个 slab 的第 1 个对象」都落在同一个 Cache 行偏移上,多核同时分配各自 CPU 上的第 1 个对象就会反复争抢同一条 Cache 行,产生**伪共享**(false sharing)。
着色的做法是给不同 slab 加上 0 ~ max_colour 个 Cache 行的起始偏移。SLUB 中对应实现:
```c
s->colour_off = cache_line_size(); /* 一种颜色 = 一条缓存行 */
s->colour = min(left, max_colour); /* 本 slab 使用的颜色编号 */
```
slab 内第 `i` 个对象的地址为 `slab_start + s->colour * s->colour_off + i * s->size`。因为一部分空间被 padding 占掉,一个 slab 能容纳的对象数会随颜色变化,代价是牺牲一点空间换取多核扩展性。注意着色不是缓存旁路,它只改变起始偏移的对齐;对 `SLAB_HWCACHE_ALIGN` 的 cache 效果最明显。
### 4.5 kmalloc 与 size class 内部实现
`kmalloc` 内部维护一组 `kmalloc_caches`,按用途分为 `KMALLOC_NORMAL`、`KMALLOC_RECLAIM`(带 `__GFP_RECLAIMABLE`)、`KMALLOC_DMA`、`KMALLOC_CGROUP`,每种类型下再按 size class 组织。分配时把请求大小向上取整到最近的 class(`kmalloc_index()`,节选):
```c
if (size <= 8) return 3; /* kmalloc-8 */
if (size <= 16) return 4; /* kmalloc-16 */
if (size <= 32) return 5;
if (size <= 64) return 6;
if (size <= 96) return 1; /* kmalloc-96, 非 2 的幂的专用 class */
if (size <= 128) return 7;
if (size <= 192) return 2; /* kmalloc-192, 非 2 的幂的专用 class */
if (size <= 256) return 8;
if (size <= 512) return 9;
if (size <= 1024) return 10;
if (size <= 2048) return 11;
if (size <= 4096) return 12;
```
`96` 和 `192` 这两个非 2 的幂的 class 是内核统计真实分配分布后加进去的,这两个尺寸请求量极大,专用 cache 能避免明显的内部碎片浪费。所以 `kmalloc(300, GFP_KERNEL)` 实际落在 `kmalloc-512`,占用 512 字节;`kmalloc(100, GFP_KERNEL)` 落在 `kmalloc-128`。`KMALLOC_MAX_CACHE_SIZE` 通常是 8KB(也可是 4KB),超过后走页分配器;`KMALLOC_MAX_SIZE` 一般对应 `MAX_ORDER` 能提供的最大连续块(4MB 量级)。`kmalloc` 保证物理连续,这是它适合 DMA 的原因。
| API | 说明 |
| ------------------------ | ------------------------------------------ |
| `kmalloc(size, gfp)` | 分配但不清零 |
| `kzalloc(size, gfp)` | 分配并清零 |
| `kcalloc(n, size, gfp)` | 分配数组并检查乘法溢出 |
| `krealloc(p, size, gfp)` | 调整大小,可能搬迁 |
| `ksize(p)` | 返回实际可用的分配大小(排查内部碎片浪费) |
| `kfree(p)` | 释放,接受 NULL |
`kfree` 在 SLUB 中通过 `virt_to_head_page()` 拿到 `struct page`,再用 `page->slab_cache` 找回所属 `kmem_cache`,因此释放时不需要传 cache 指针。这也意味着 **`kfree` 只能释放 `kmalloc`/`kmem_cache_alloc` 系分配的内存**,绝不能用于 `vmalloc` 的地址或静态/栈变量。
### 4.6 devm_kmalloc:设备管理的内存分配
Linux 2.6 引入设备资源管理(devres)机制,`devm_kmalloc` 分配的内存在设备移除或驱动卸载时自动释放,避免驱动代码中大量的错误路径释放逻辑。同类接口还有 `devm_kzalloc`、`devm_kcalloc`、`devm_kstrdup`、`devm_get_free_pages`,嵌入式驱动开发中推荐优先使用。
---
## 五、vmalloc 与 ioremap
### 5.1 vmalloc 工作原理
`vmalloc` 分配的内存虚拟地址连续,但物理上不要求连续。内核虚拟地址空间自高到低划分为:向量页/固定映射 → VMALLOC 区(vmalloc/ioremap)→ 内核模块区 → 直接映射区(lowmem,`__va`)→ 内核镜像与页表。工作流程:
1. `__get_vm_area_node()` 在 VMALLOC 区(`VMALLOC_START` ~ `VMALLOC_END`)找到满足大小和对齐的虚拟地址,`vmap_area` 挂在红黑树和链表上
2. 通过 `alloc_page()` 逐页(或按 order)分配物理内存
3. `map_vm_area()` 用 `mk_pte()` 建立 PTE,把物理页贴到虚拟地址上
4. 需要时刷新 TLB;`vfree()` 逐页归还并删除映射
```c
void *vmalloc(unsigned long size);
void *vzalloc(unsigned long size); /* 清零 */
void *vmalloc_user(unsigned long size); /* 供用户 mmap */
void *vmalloc_32(unsigned long size); /* 保证地址在 32 位内 */
void *vmap(struct page **pages, unsigned int count,
unsigned long flags, pgprot_t prot); /* 只建映射, 不分配 */
void vfree(const void *addr);
void vunmap(const void *addr);
```
`vmalloc` 慢有三个原因:每次都要走伙伴系统分配物理页(无法复用 slab 缓存);要建立新 PTE 并刷新 TLB,造成 TLB 抖动;VMALLOC 区元数据操作需要加锁。为此内核做了 `vmap_area` 的 per-cpu 缓存和延迟释放——`vfree` 通过 `llist` 挂到队列,由 worker 批量 `vunmap`,避免在原子上下文里同步拆页表。
### 5.2 ioremap 映射外设寄存器
外设寄存器(如 IMX6ULL 的 IOMUXC、eMMC 控制器)的物理地址在 DDR 之外,内核直接映射区通常不覆盖,必须先 `ioremap` 才能访问:
```c
void __iomem *ioremap(phys_addr_t phys_addr, size_t size);
void __iomem *ioremap_cache(phys_addr_t phys_addr, size_t size);
void __iomem *ioremap_wc(phys_addr_t phys_addr, size_t size);
void __iomem *ioremap_np(phys_addr_t phys_addr, size_t size); /* non-posted */
void iounmap(void __iomem *addr);
```
`ioremap` 建立的是 `MT_DEVICE` 属性映射(Strongly-ordered 或 Device),即不缓存、保持访问顺序。**返回的 `__iomem` 指针不能直接解引用**,必须用 `readl`/`writel`/`readb`/`writeb`;`__iomem` 是 `sparse` 的地址空间注解,直接解引用会被静态检查器报错,也是驱动里最常见的低级错误之一。这些访问器还隐含内存屏障语义,用 `memcpy` 或指针赋值都会破坏访问顺序。
```c
static int my_probe(struct platform_device *pdev)
{
void __iomem *base;
/* 一行搞定 resource 提取 + request_mem_region + ioremap */
base = devm_platform_ioremap_resource(pdev, 0);
if (IS_ERR(base))
return PTR_ERR(base);
writel(0x1, base + REG_CTRL);
return 0;
}
```
`devm_ioremap_resource()` 会检查资源冲突并调用 `request_mem_region()`,而裸 `devm_ioremap()` 不会;多个驱动映射同一段寄存器属于设计错误。
### 5.3 kmalloc / vmalloc / ioremap / DMA 对比
| 特性 | `kmalloc` | `vmalloc` | `ioremap` | `dma_alloc_coherent` |
| -------- | ---------------------------------- | ---------------------- | ---------------------- | ---------------------- |
| 物理连续 | 是 | 否 | 不适用(物理地址已知) | 是 |
| 来源 | slab 缓存 | VMALLOC 区 + 伙伴系统 | 已有物理地址(外设) | DMA 区 / CMA |
| 访问方式 | 直接解引用 | 直接解引用 | `readl`/`writel` | 直接解引用 |
| 缓存属性 | 普通可缓存 | 普通可缓存 | `MT_DEVICE`,不缓存 | 不缓存(coherent) |
| 分配速度 | 快 | 慢 | 中(改页表) | 慢(可能需要迁移页面) |
| 大小上限 | ~4MB(`KMALLOC_MAX_SIZE`) | 虚拟地址空间限制 | 任意 | CMA 区大小限制 |
| 可睡眠 | `GFP_KERNEL` 可,`GFP_ATOMIC` 不可 | 可(不可在原子上下文) | 可 | 可 |
| 释放 | `kfree` | `vfree` | `iounmap` | `dma_free_coherent` |
| 典型用途 | 结构体、小缓冲区 | 大块内存、模块加载 | 外设寄存器 | DMA 描述符与缓冲区 |
选择原则:需要物理连续时(DMA、硬件描述符环)用 `kmalloc` 或 `dma_alloc_coherent`;不需要物理连续的大块内存用 `vmalloc`;访问寄存器用 `ioremap`。**四者的释放函数不能混用**——用 `kfree` 去释放 `vmalloc` 的地址会立刻破坏内核堆。
---
## 六、用户空间内存
### 6.1 malloc / free 的实现原理
`malloc` 是 glibc 的实现,底层只依赖两个系统调用:默认阈值 `M_MMAP_THRESHOLD` 为 128KB(动态调整)。
| 请求大小 | 底层机制 | free 时的行为 |
| -------- | ------------------------------------ | --------------------------------------- |
| < 128KB | `brk()` 扩展堆顶 | 归还给 glibc 的空闲链表,不一定还给内核 |
| ≥ 128KB | `mmap(MAP_PRIVATE \| MAP_ANONYMOUS)` | `munmap()` 立即还给内核 |
```c
SYSCALL_DEFINE1(brk, unsigned long, brk)
{
if (brk < mm->end_code) return -ENOMEM;
newbrk = PAGE_ALIGN(brk);
if (newbrk < mm->brk) /* 收缩: 直接 munmap */
do_munmap(mm, newbrk, oldbrk - newbrk, NULL);
else if (newbrk > mm->brk) /* 扩展: 建立新 VMA */
mm->brk = do_brk_flags(&mm->mmap, oldbrk,
newbrk - oldbrk, 0, NULL);
return mm->brk;
}
```
glibc 不轻易把内存还给内核,因为 `brk` 收缩必须在堆顶进行——堆中间有空洞时无法回退。`free` 只在「释放的是 top chunk 且超过 `M_TRIM_THRESHOLD`」时才调用 `brk`。这解释了为什么进程 RSS 常常远小于 `malloc` 累加的总量(glibc 保留),也常常远大于实际使用量(`brk` 保留)。调优接口:`mallopt(M_MMAP_THRESHOLD, ...)`、`mallopt(M_TRIM_THRESHOLD, ...)`、环境变量 `MALLOC_ARENA_MAX`(嵌入式常用 `MALLOC_ARENA_MAX=2` 抑制多线程 arena 膨胀)。
### 6.2 栈与堆
堆向上增长(`brk` 抬高),栈向下增长,两者相向而行,中间的空白由缺页机制按需分配。**栈**由标记了 `VM_GROWSDOWN` 的 VMA 描述,大小受 `RLIMIT_STACK` 限制(默认 8MB);栈底留有一页**保护页**(guard page,不映射),越界访问触发 `SIGSEGV`,这就是栈溢出被立即捕获的机制,内核在 `expand_stack()` / `acct_stack_growth()` 中判断是否允许继续向下扩展。**堆**范围是 `mm->start_brk` ~ `mm->brk`,而 `mmap` 区域各自独立成 VMA,没有统一的「堆顶」概念。
线程栈与主线程栈不同:`pthread_create` 用 `mmap` 分配独立内存作为线程栈(`MAP_STACK`),大小由 `pthread_attr_setstacksize` 指定,默认 8MB;嵌入式设备上线程多时应显式设小。
`mm_struct` 中与内存相关的关键字段:`mmap`(VMA 链表头)、`mm_rb`(VMA 红黑树,按地址查找 O(log n))、`mmap_base`、`task_size`、`start_code/end_code/start_data/end_data`、`start_brk/brk`、`start_stack`、`arg_start/arg_end/env_start/env_end`、`pgd`、`mm_users/mm_count`、`mmap_lock`。
### 6.3 写时复制(COW)
`fork()` 不复制物理内存,而是让父子进程共享同一批物理页,并把双方的 PTE 都改成只读(`copy_page_range()` 中 `ptep_set_wrprotect()`),同时 `page->_mapcount` 加一。任何一方尝试写入时触发写保护缺页,内核才真正复制一页。
```mermaid
flowchart TD
A["fork()"] --> B["copy_page_range() 复制页表项"]
B --> C["父子 PTE 均置只读
_mapcount++ _count++"]
C --> D{"任一进程写入共享页?"}
D -->|写保护 fault| E["do_wp_page()"]
E --> F{"PTE 已可写?"}
F -->|是, 竞态| G["直接返回, 重试指令"]
F -->|否| H{"_mapcount == 1
且 _count == 1?"}
H -->|独占匿名页| I["原地改回可写
不复制, 零开销"]
H -->|共享| J["alloc_page 分配新页 + 拷贝内容"]
J --> K["替换 PTE 指向新页并设为可写"]
K --> L["旧页引用计数递减, 归零则释放"]
L --> M["刷新 TLB, 重试指令"]
```
- **零页共享**:读 `mmap` 匿名内存时,所有进程的只读访问都映射到全局 `empty_zero_page`,只有写时才分配真实页。所以 `malloc` 一大块内存后只读不写,RSS 几乎不涨。
- **为什么 `fork` + `exec` 不浪费内存**:子进程从未写过任何共享页,`exec` 直接换掉整个 `mm_struct`,旧页由父进程继续持有,一次拷贝都没发生。`vfork`/`posix_spawn` 进一步避免 COW,代价是父进程被阻塞。
- **性能陷阱**:若页长期被多个映射共享(如 `fork` 出的 worker 只读),`_mapcount` 始终大于 1,每次写入都会产生 COW 缺页,`/proc/vmstat` 的 `cow_fault` 会持续升高,此时应考虑改用共享内存或 `vfork`。
- **统计**:`/proc/vmstat` 的 `pgfault`、`pgmajfault`、`cow_fault` 可区分缺页类型;`/proc/PID/smaps` 的 `Shared_Clean`/`Private_Dirty` 能看出实际共享程度。
---
## 七、DMA 内存
### 7.1 一致性内存 vs 流式映射
| 类型 | API | 一致性保障 | 典型用途 |
| ------------------ | ------------------------------- | --------------------------------------------- | ---------------------------- |
| 一致性(coherent) | `dma_alloc_coherent` | CPU 与外设看到同一份数据,无需显式 cache 维护 | 描述符环、长期共享的控制结构 |
| 流式(streaming) | `dma_map_single` / `dma_map_sg` | 每次传输前后需要 cache 同步 | 单次传输的数据缓冲区 |
一致性的实现方式是把该块内存映射为**非缓存**(uncached),DDR 读写直接落到物理内存,代价是 CPU 访问变慢;流式映射保留缓存属性,由 `dma_map_*`/`dma_unmap_*` 在传输边界插入 cache 清除/失效操作。
```c
/* 一致性分配 */
void *dma_alloc_coherent(struct device *dev, size_t size,
dma_addr_t *dma_handle, gfp_t flag);
void dma_free_coherent(struct device *dev, size_t size,
void *cpu_addr, dma_addr_t dma_handle);
/* 流式映射 */
dma_addr_t dma_map_single(struct device *dev, void *ptr,
size_t size, enum dma_data_direction dir);
void dma_unmap_single(struct device *dev, dma_addr_t addr,
size_t size, enum dma_data_direction dir);
enum dma_data_direction {
DMA_BIDIRECTIONAL = 0,
DMA_TO_DEVICE, /* 内存 -> 设备, 需要 clean(写回) */
DMA_FROM_DEVICE, /* 设备 -> 内存, 需要 invalidate(失效) */
DMA_NONE,
};
/* 同步接口: 用于"设备写了一半, CPU 想提前看数据" */
void dma_sync_single_for_cpu(struct device *dev, dma_addr_t addr,
size_t size, enum dma_data_direction dir);
void dma_sync_single_for_device(struct device *dev, dma_addr_t addr,
size_t size, enum dma_data_direction dir);
```
典型用法(`dma_handle` 是给硬件的地址,`buf` 是给 CPU 的虚拟地址):
```c
static int my_dma_init(struct my_dma *d, struct device *dev, size_t size)
{
int ret = dma_set_mask_and_coherent(dev, DMA_BIT_MASK(32));
if (ret)
return ret;
d->buf = dma_alloc_coherent(dev, size, &d->buf_dma, GFP_KERNEL);
if (!d->buf)
return -ENOMEM;
writel(d->buf_dma, dev->base + REG_DMA_ADDR); /* 写的是 dma_addr_t */
return 0;
}
```
流式映射的错误路径必须 `dma_unmap_single`,且方向要与实际传输方向一致。
### 7.2 CMA:连续内存分配器
伙伴系统在运行一段时间后往往给不出大块连续内存,而 DMA 控制器(尤其是摄像头、VPU、NPU)经常需要数 MB 到数十 MB 的连续缓冲。CMA(Contiguous Memory Allocator)的思路是「预留但不浪费」:启动时预留一块连续区域并注册为 `MIGRATE_CMA`;空闲时该区域仍可被伙伴系统分配**可移动页**;DMA 请求到来时把区域内的可移动页迁移出去腾出连续空间;`cma_release` 归还后重新变成「可借给可移动页」的状态。
```dts
reserved-memory {
#address-cells = <1>;
#size-cells = <1>;
ranges;
linux,cma {
compatible = "shared-dma-pool";
reusable; /* 可被伙伴系统借用, DMA 时再迁移走 */
size = <0x4000000>; /* 64MB */
alignment = <0x2000>; /* 8KB */
linux,cma-default; /* 作为默认 CMA 池 */
};
vpu_pool: vpu_pool { /* 专用池: 只有显式指定的设备能用 */
compatible = "shared-dma-pool";
reusable;
size = <0x2000000>; /* 32MB */
alignment = <0x100000>;
};
};
```
驱动通过 `memory-region` 属性关联专用池,之后该设备的 `dma_alloc_coherent` 就从该池分配:
```c
static int vpu_probe(struct platform_device *pdev)
{
return of_reserved_mem_device_init_by_idx(&pdev->dev,
pdev->dev.of_node, 0);
}
```
命令行与内核配置:`cma=64M@0x80000000`、`CONFIG_CMA=y`、`CONFIG_CMA_SIZE_MBYTES=64`、`CONFIG_DMA_CMA=y`。运行时 `grep -i cma /proc/meminfo` 看 `CmaTotal`/`CmaFree`:`CmaFree` 持续接近 0 说明区域被反复借给可移动页无法归还,或驱动泄漏了 `dma_alloc_coherent`。CMA 的局限是区域大小固定——预留太少则大块 DMA 分配仍失败,预留太多则浪费内存;经验做法是按平台上最大外设的需求预留,NPU/VPU 用专用池隔离。
### 7.3 DMA 一致性陷阱
1. **用了 `kmalloc` 却忘了 cache 维护**:非一致性架构(老 ARM)上,CPU 写完的数据可能还在 Cache 里,DMA 读到旧内容;必须用 `dma_map_single` 或在 `__dma_flush_area` 之后启动 DMA,用 `dma_alloc_coherent` 则天然规避。
2. **cache line 不对齐导致的「踩踏」**:缓冲区首尾不按 cache line 对齐时,invalidate 会把相邻的、CPU 刚写过还留在 cache 里的数据一起丢弃。因此 `dma_alloc_coherent` 的地址一定按 `dma_get_cache_alignment()` 对齐,手动分配的缓冲区也必须保证。
3. **方向标错**:`DMA_FROM_DEVICE` 与 `DMA_TO_DEVICE` 弄反会清除本该保留的 cache 或保留本该清除的 cache,表现为偶发数据错误。
4. **在 IOMMU 平台上把物理地址直接给硬件**:有 IOMMU(如 RK3568)时必须用 `dma_map_*` 返回的 IOVA,直接写物理地址会导致 IOMMU 缺页。
5. **错误路径漏 `dma_free_coherent`**:这类泄漏 kmemleak 看不到(不在 slab 里),只能靠 `/proc/meminfo` 和 CMA 计数排查。
---
## 八、内存映射
### 8.1 mmap 原理
`mmap` 把文件或设备的内存映射到进程虚拟地址空间,用户态通过指针直接读写,内核在缺页中断时按需加载数据,避免了 `read/write` 的拷贝开销。内核处理流程:
1. 分配 `vm_area_struct`(VMA),描述这段虚拟地址范围的属性
2. 将 VMA 插入进程 `mm_struct` 的红黑树
3. 注册文件操作的 `fault` 方法(不立即分配物理内存)
4. 进程访问映射地址时触发缺页中断
5. 缺页处理程序调用 `fault` 方法,分配物理页并建立页表映射
这种「按需分配」意味着 `mmap` 一个 1GB 的文件并不会立即占用 1GB 物理内存,只有实际访问到的页面才会被分配。
### 8.2 进程地址空间
```text
0xFFFFFFFF +---------------------------+
| 内核空间 1GB | 所有进程共享
0xC0000000 +---------------------------+
| 栈 (向下增长, ~8MB) | [stack]
| ↓ 空闲区域 ↑ |
| mmap 区 (自顶向下) | [mmap] 库 / 共享内存
| 库映射 (.so) |
| 堆 (向上增长, brk) | [heap]
+---------------------------+
| .bss / .data / .rodata |
| .text 代码段 |
0x00000000 +---------------------------+
```
每个进程的虚拟地址空间由 `mm_struct` 描述,其中包含若干 VMA,每个 VMA 代表一段具有相同属性(权限、映射源)的虚拟地址区域。VMA 的主要操作:open/mmap(创建新 VMA 并插入红黑树)、mprotect(修改访问权限)、munmap(删除 VMA,解除映射)、brk/mmap(扩展或收缩堆)、缺页处理(按需建立物理页映射)。
### 8.3 页表映射的建立
缺页中断发生时,内核的 `handle_mm_fault()` 执行以下步骤:
1. 确定触发缺页的虚拟地址(`regs` 中的 FAR)
2. 在进程的 VMA 红黑树中查找该地址所属的 VMA(`find_vma()`)
3. 检查访问权限(读/写/执行)与 VMA 权限是否匹配,不匹配返回 `SIGSEGV`
4. 根据 VMA 类型分发处理:匿名页、文件映射页、交换页、设备映射页
5. 分配物理页,从文件读入数据(文件映射)或填零(匿名页)
6. 修改页表建立映射,刷新 TLB,返回用户态重试触发缺页的指令
缺页分两种:**minor fault**(页在内存中,只是 PTE 没建立)和 **major fault**(需要从磁盘/交换区读入)。`/usr/bin/time -v` 输出里的 major/minor page fault 数量是评估内存行为的直接指标。
### 8.4 大页(Huge Page)
标准 4KB 页在大内存负载下会产生大量页表项和 TLB 缺失。大页机制支持 2MB(PMD 级别)和 1GB(PUD 级别)的页,减少页表层级和 TLB 压力。嵌入式系统中大页使用场景较少,但某些高端平台(如 RK3568)若运行 64 位内核且内存超过 2GB,可考虑启用大页优化大块 DMA 缓冲区的映射效率。THP(Transparent Huge Pages)可在 `/sys/kernel/mm/transparent_hugepage/enabled` 中配置,对标记了 `MADV_HUGEPAGE` 的区域自动生效。
---
## 九、内核内存管理
### 9.1 内存池(mempool)
内核在某些关键路径中使用内存池保证分配不会失败:预先从伙伴系统分配一批对象放入池中,普通分配失败时从池中取对象,确保关键路径(如 SCSI 命令分配、网络缓冲区)不会因内存压力停摆。
```c
mempool_t *mempool_create(int min_nr, mempool_alloc_t *alloc_fn,
mempool_free_t *free_fn, void *pool_data);
void *mempool_alloc(mempool_t *pool, gfp_t gfp_mask);
void mempool_free(void *element, mempool_t *pool);
void mempool_destroy(mempool_t *pool);
```
内存池本质是安全网:正常情况下内核从 slab 分配,池只是保持一定数量的预留对象,只有内存压力极大、slab 分配失败时才真正动用预留。注意预留元素是**独占**的,池子越大,平时真正可用的内存就越少。
### 9.2 per-cpu 变量
多核系统中频繁修改的全局变量会导致 Cache 行在核心间反复弹跳(cache line bouncing)。per-cpu 变量为每个 CPU 维护独立副本,修改时只操作本地副本,无需加锁或跨核同步。
```c
DEFINE_PER_CPU(unsigned long, my_counter); /* 编译期静态分配 */
this_cpu_inc(my_counter); /* 无锁 */
unsigned long v = this_cpu_read(my_counter);
void __percpu *alloc_percpu(type); /* 运行期动态分配 */
void free_percpu(void __percpu *p);
int cpu;
unsigned long total = 0;
for_each_possible_cpu(cpu) /* 汇总所有 CPU */
total += per_cpu(my_counter, cpu);
```
内核中大量使用 per-cpu 变量:调度器运行队列、网络协议栈统计计数器、内存分配器本地缓存等。SLUB 的 `cpu_slab`(per-cpu freelist)、伙伴系统的 per-cpu page list(PCP)都是同一思想的应用。`alloc_percpu()` 分配的内存物理不连续,每个副本按 `SMP_CACHE_BYTES` 对齐以独占 Cache 行。
使用要点:`this_cpu_*` 隐含抢占禁用,不能跨 CPU 长期持有;需要跨多个操作保持一致时用 `get_cpu_ptr()` / `put_cpu_ptr()` 显式配对。与锁的本质区别是语义——锁解决「多个执行流必须看到同一份数据」,per-cpu 解决「各自维护自己的数据,需要时再汇总」,因此适合计数器与本地缓存,不适合需要全局一致性的临界区。
### 9.3 内存泄漏检测
**kmemleak**:注册 kmalloc 的 tracepoint,每次分配时记录对象地址、大小和调用栈,释放时删除记录;后台扫描线程定期遍历所有已分配但未被任何全局/栈指针引用的对象,把疑似泄漏项输出到 `/sys/kernel/debug/kmemleak`。
```bash
# 编译时开启 CONFIG_DEBUG_KMEMLEAK
echo scan > /sys/kernel/debug/kmemleak
cat /sys/kernel/debug/kmemleak
```
输出包含泄漏对象的地址、大小、分配时的 PID 和完整调用栈,可直接定位到代码行。缺点是性能开销大(每次内存操作都要记录调用栈),生产环境不建议开启。注意判定依据是「有没有被扫描到指针」,因此故意打散的指针、编码后的句柄、交给硬件使用的 DMA 缓冲都会产生误报,结论要人工复核。
**KASAN(Kernel Address Sanitizer)**:编译时插桩,检测内存越界访问和 use-after-free,增加约 2-3 倍运行开销和约 1/4 内存消耗,主要用于开发调试;支持 `generic`(软件影子内存)、`sw_tags`(KASAN_SW_TAGS,ARM64 可用)、`hw_tags`(基于 ARMv8.5 MTE 的硬件标签)三种模式。
**其他工具**:`/proc/slabinfo` 与 `slabtop` 监控各类 slab 缓存使用量增长趋势;`slub_debug`(红区、poison);`CONFIG_PAGE_OWNER`(`page_owner=on` 记录每页分配者,配合 `/sys/kernel/debug/page_owner` 排查页级泄漏)。嵌入式现场的实用做法是跑长稳脚本,定期 dump `/proc/meminfo`、`/proc/slabinfo` 和 CMA 计数,看 `Slab`、`CmaFree` 是否单调下降。
---
## 十、跨平台对比
### 10.1 IMX6ULL vs STM32 vs RK3568 内存差异
| 特性 | IMX6ULL | STM32MP1 | RK3568 |
| ------------- | ------------- | ------------- | ---------------- |
| 处理器架构 | ARM Cortex-A7 | ARM Cortex-A7 | ARM Cortex-A55 |
| DDR 类型 | DDR3L | DDR3/LPDDR2 | DDR4/LPDDR4 |
| 典型 DDR 容量 | 256MB-512MB | 256MB-512MB | 1GB-8GB |
| 物理地址起始 | 0x80000000 | 0xC0000000 | 0x00000000 |
| MMU 页大小 | 4KB | 4KB | 4KB(64KB 可选) |
| DMA 地址宽度 | 32 位 | 32 位 | 40 位 |
| IOMMU | 无 | 无 | 有 |
| 大页支持 | 2MB | 2MB | 2MB / 1GB |
**IMX6ULL**:内存较小的典型嵌入式平台,256MB DDR 下伙伴系统可用页数有限,外部碎片更容易导致大块分配失败。DMA 需要物理连续内存,通常使用 CMA 预留一块连续区域专门给 DMA 使用。运行一段时间后 `/proc/buddyinfo` 的高 order 列归零是常见现象。
**STM32MP1**:与 IMX6ULL 类似的内存规模,但 STM32MP15 系列同时包含 Cortex-A7 和 Cortex-M4 双核。A7 侧运行 Linux,M4 侧运行裸机或 RTOS,两个核心通过共享内存通信。共享内存区域需要在设备树中标记为 `reserved-memory`,避免 Linux 将其纳入伙伴系统管理。
**RK3568**:高端嵌入式平台,DDR 容量可达 8GB,支持 64 位寻址。内存管理压力小得多,但仍需注意 DMA 一致性映射和 IOMMU 的配置。VPU(视频处理单元)和 NPU(神经网络处理单元)需要大块连续内存作为工作缓冲区,通常通过 CMA 或设备树预留内存来保证。
### 10.2 无 MMU 平台的内存管理
部分低端嵌入式 MCU(如 STM32F4、RT1052 的 M4 内核)没有 MMU,无法使用虚拟内存。内核(如 FreeRTOS、RT-Thread)直接操作物理地址,没有进程隔离,所有代码共享同一个地址空间,内存管理主要依赖静态分配(全局数组、栈)和简单的堆分配器。
对比 [[FreeRTOS学习笔记/16-内存管理]] 中的方案:FreeRTOS 提供 5 种堆实现,从最简单的 heap_1(只分配不释放)到 heap_5(支持非连续堆区域),复杂度和灵活性依次递增。Linux 的伙伴系统 + slab 组合远比 FreeRTOS 的堆管理复杂,但核心目标相同——在有限资源下高效分配和回收内存。
区别的根源在「有没有 MMU」:有 MMU 才能谈虚拟地址、按需分页、COW、内存保护;没有 MMU 时 `malloc` 返回的就是物理地址,碎片无法靠迁移解决,只能靠静态规划和内存池提前预留。移植 Linux 驱动思路到 RTOS 时最容易踩的坑,就是误以为可以用「先随意分配、以后再整理」的策略。
---
## 十一、面试精选
### 题目 1:伙伴系统如何解决外部碎片?还有什么局限性?
**考察点**:伙伴系统原理、碎片化
**参考答案**:
伙伴系统通过「合并伙伴」机制缓解外部碎片:每次释放一个块时检查其地址相邻、大小相同的伙伴块是否也空闲,空闲就合并为更大的块并继续向上合并,伙伴的页帧号可直接计算:`buddy_pfn = pfn ^ (1 << order)`。局限是运行较长时间后不同 order 的空闲块分散在各处,即使总空闲内存很多也可能凑不出连续的 2^n 页;嵌入式系统内存小(256MB-1GB)时更突出。内核通过 kswapd 定期回收、compaction 搬迁页面来缓解,但都有性能开销。最有效的办法是在启动阶段预留足够的 CMA 区域,把大块连续内存需求前置到内存充足时解决。
### 题目 2:kmalloc 和 vmalloc 有什么区别?各适用于什么场景?
**考察点**:内存分配 API、物理连续性
**参考答案**:
`kmalloc` 分配物理连续内存,底层来自 slab 缓存,最大通常 4MB,适合 DMA 缓冲区、硬件描述符环、中断上下文中的小块分配。`vmalloc` 分配虚拟连续但物理不连续的内存,通过修改页表实现映射,适合大块内存(模块代码加载、大缓冲区),但不适合 DMA,也不能在原子上下文使用。性能上 `kmalloc` 更快(slab 命中只需一次链表操作),`vmalloc` 需要逐页分配 + 改页表 + 刷 TLB,明显更慢。补充:32 位系统上 VMALLOC 区是有限资源,长期占用会导致后续 `ioremap` 失败,驱动中 `kmalloc` 的使用频率应远高于 `vmalloc`。
### 题目 3:slab 分配器的对象着色(object coloring)是什么?为什么需要它?
**考察点**:slab 缓存优化、Cache 行伪共享
**参考答案**:
同类型对象大小相同,若所有 slab 的起始偏移一致,则「每个 slab 的第 1 个对象」都落在同一条 Cache 行的同一偏移上,多核同时分配各自 CPU 的第 1 个对象时会反复争抢同一条 Cache 行,产生伪共享。着色给不同 slab 分配不同起始偏移(通常 3-4 种颜色,每种等于一条 Cache 行),让不同 slab 中的对象落在不同缓存行。SLUB 中对应实现是 `s->colour_off = cache_line_size()` 和分配给每个 slab 的 `s->colour`;代价是产生 padding,每个 slab 能容纳的对象数略有减少。
### 题目 4:解释 kmemleak 的工作原理和使用方法。
**考察点**:内存泄漏检测、调试工具
**参考答案**:
kmemleak 记录所有 `kmalloc`/`kfree` 的调用栈(对象地址、大小、调用栈),释放时删除记录;后台扫描线程定期遍历所有已分配但未被任何全局/栈指针引用的对象,把疑似泄漏项输出到 `/sys/kernel/debug/kmemleak`。用法:内核开启 `CONFIG_DEBUG_KMEMLEAK`,运行 `echo scan > /sys/kernel/debug/kmemleak` 触发扫描,`cat` 该文件查看结果,输出含地址、大小、分配 PID 和完整调用栈,可直接定位到代码行。缺点是每次内存操作都要记录调用栈,开销较大,生产环境不建议开启;由于判定依据是「能否在内存中扫描到指向它的指针」,打散的指针、编码句柄、DMA 缓冲都会误报,结论需人工复核。
### 题目 5:在嵌入式 Linux 系统中,如何保证 DMA 分配到物理连续的内存?
**考察点**:DMA 内存管理、CMA、GFP 标志
**参考答案**:
1. **CMA**(最常用):启动时预留连续区域并注册为 `MIGRATE_CMA`,平时可借给可移动页,DMA 时迁移走以保证连续性;设备树用 `reserved-memory` + `compatible = "shared-dma-pool"` + `reusable` 配置。
2. **GFP_DMA**:`kmalloc(..., GFP_KERNEL | GFP_DMA)` 从低于 16MB 的区域分配,地址限制过严,现代平台少用。
3. **DMA pool**:频繁分配/释放小块 DMA 内存时用 `dma_pool_create`/`dma_pool_alloc` 减少碎片。
4. **设备树预留**:用不带 `reusable` 的 `reserved-memory` 预留固定地址内存,排除在伙伴系统之外,驱动用 `of_reserved_mem_device_init_by_idx()` 绑定。
5. **IOMMU**:RK3568 这类平台可让 DMA 使用 IOVA,从根上解除物理连续约束。
同时要保证缓冲区按 cache line 对齐,避免 invalidate 时误伤相邻数据。
### 题目 6:ARM32 的页表有几级?一次地址翻译最多访问几次内存?TLB 起什么作用?
**考察点**:页表结构、MMU、TLB
**参考答案**:
ARMv7-A 短描述符格式使用两级页表:L1(PGD,4096 项,16KB,16KB 对齐)由 `VA[31:20]` 索引,条目可以是 1MB 的 Section、指向 L2 的页表指针或 Fault;L2(PTE,粗粒度 256 项,1KB)由 `VA[19:12]` 索引,条目映射 4KB 小页(`0b10`)或 64KB 大页(`0b01`),最后 12 位是页内偏移。因此最坏情况翻译一个 4KB 页需要 3 次内存访问(L1 描述符、L2 描述符、最终数据),命中 Section 只需 2 次,Supersection(`bit[18]=1`,16MB)可进一步减少层级。
TLB 是硬件翻译缓存,缓存「虚拟页号 + ASID → 物理页号 + 权限」,命中时完全跳过页表遍历,只需 1 次内存访问。刷新粒度决定性能:`flush_tlb_page`(`TLBIMVA`)远优于 `flush_tlb_mm`(`TLBIASID`),`local_flush_tlb_all`(`TLBIALL`)最昂贵。进程切换时用 8 位 ASID 打标签避免全量刷新,ASID 用尽触发 rollover 时才需要全量刷新。
### 题目 7:kmalloc 请求 300 字节,实际占用了多少?kfree 时内核如何知道该还给哪个缓存?
**考察点**:slab size class、kfree 实现
**参考答案**:
`kmalloc(300)` 会向上取整到最近的 size class,257~512 字节落在 index 9,即 `kmalloc-512`,实际占用 512 字节对象(内部碎片 212 字节),可用 `ksize(ptr)` 运行时确认。内核还有 96 和 192 两个非 2 的幂的特化 class,因为这两个尺寸请求量特别大。
`kfree` 不需要知道大小,它走「从地址反查页」的路径:`virt_to_head_page()` 把虚拟地址转成所属 `struct page`(复合页取首页),SLUB 中 `page->slab_cache` 直接存着所属 `kmem_cache` 指针,于是 `slab_free()` 把对象还给了正确的 per-cpu freelist。这也说明两个约束:`kmalloc` 的内存必须用 `kfree` 释放,不能对 `vmalloc` 的地址调用(那头页不在直接映射区,`virt_to_head_page` 得到的 page 无效);也不能对静态数组或栈变量调用 `kfree`。
### 题目 8:malloc 一块 1MB 的内存,从用户态调用到物理页真正分配,中间经历了什么?
**考察点**:用户空间内存、缺页、按需分配
**参考答案**:
分三段:1)**glibc 层**:1MB 超过 `M_MMAP_THRESHOLD`(默认 128KB),glibc 不扩展堆,而是调用 `mmap(NULL, 1MB, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)`,此时**没有任何物理页被分配**。2)**内核 mmap 层**:`do_mmap()` 分配一个 `vm_area_struct` 描述这 1MB 虚拟范围并插入红黑树,标记为匿名 VMA 后返回,VMA 数量增加而 RSS 不变。3)**首次写入触发缺页**:MMU 找不到有效 PTE → Data Abort → `handle_mm_fault()` → `find_vma()` 确认地址合法 → 判为匿名页 → `do_anonymous_page()`:只读访问映射到全局 `empty_zero_page`,不分配内存;写访问则 `alloc_pages(GFP_HIGHUSER_MOVABLE)` 分配物理页、清零、`set_pte_at()` 建立映射、刷新 TLB、重试指令。
所以 1MB 全写完后 RSS 才涨 1MB(256 个 4KB 页,开启 THP 时按 2MB 大页分配),读而不写时 RSS 几乎不涨。`free()` 时因为是 mmap 来的,glibc 直接 `munmap()` 立即归还。这也解释了嵌入式设备上「malloc 成功但写入时 OOM」的现象——虚拟地址足够,物理页和 overcommit 策略(`/proc/sys/vm/overcommit_memory`)才决定成败。
### 题目 9:写时复制(COW)是如何实现的?为什么 fork 后立即 exec 不会浪费内存?
**考察点**:COW、fork 优化
**参考答案**:
`fork()` 走 `copy_mm()` → `dup_mmap()` → `copy_page_range()` 复制页表项但**不复制物理页**,父进程所有可写私有 PTE 被改成只读(`ptep_set_wrprotect()`),父子共享同一物理页,`page->_mapcount` 加一;任何一方写入时触发写保护缺页,`do_wp_page()` 分配新页、拷贝内容、替换自己的 PTE 为可写,旧页引用计数递减。
关键快路径:写 fault 时若发现该页只被自己映射(`page_mapcount() == 1`)且没有其他引用(`page_count() == 1`),说明它已是「独占匿名页」,内核直接把 PTE 改回可写,完全不拷贝。这就是 `fork()` 后立即 `exec()` 不浪费内存的原因——子进程从未写过共享页,`exec` 直接换掉整个 `mm_struct`,旧页由父进程继续持有,一次拷贝都没发生。
判定细节:`_mapcount` 是被多少个 PTE 映射,`_count` 是总引用计数(还包括 `get_user_pages`、页缓存、swap cache)。两者必须都看,因为一个页可能只被一个 PTE 映射,但被驱动通过 `get_user_pages` 额外持有引用,此时原地改可写会破坏驱动看到的数据一致性。常见性能陷阱:若页长期被多个映射共享(如 `fork` 出的 worker 只读),`_mapcount` 一直大于 1,每次写入都产生 COW 缺页,`/proc/vmstat` 的 `cow_fault` 持续升高,此时应改用共享内存或 `vfork`。
### 题目 10:系统空闲内存还有 300MB,但 `dma_alloc_coherent` 申请 8MB 连续内存失败,怎么排查?
**考察点**:碎片化、CMA、调试方法
**参考答案**:
这是典型的外部碎片问题——总量够但没有连续的 2^n 页。排查顺序:
1. **看伙伴系统分布**:`cat /proc/buddyinfo`。若 order 11(8MB)及以上为 0,说明确实没有 8MB 连续块;注意 order 上限由 `MAX_ORDER` 决定,若 `MAX_ORDER=10` 则最大只有 4MB,8MB 必然失败。
2. **看 CMA 状态**:`grep -i cma /proc/meminfo`。`CmaFree` 很小而 `CmaTotal` 足够,说明 CMA 区被可移动页占满且收缩失败(常见原因是有 `MIGRATE_UNMOVABLE` 页嵌在 CMA 区,或持有者用了 `get_user_pages` 导致页面不可迁移)。
3. **看迁移类型分布**:`cat /proc/pagetypeinfo` 观察 CMA 区的 `Unmovable` 块数量。CMA 区内只要存在不可移动页,对应 order 就永远无法被 CMA 借用。
4. **看碎片指数**:`/sys/kernel/debug/extfrag/unusable_index`,接近 1000 表示按该 order 分配几乎必然失败。
5. **看泄漏**:设备长时间运行后 `CmaFree` 单调下降,多半是驱动错误路径漏了 `dma_free_coherent`;注意 CMA 不在 slab 里,别只盯 `/proc/slabinfo`。
修复手段:放大设备树中 `linux,cma` 的 `size`、为特定外设建立专用 `shared-dma-pool` 隔离、启动参数加 `cma=64M`、在启动早期一次性分配好缓冲区、把大缓冲区分配从运行时前置到 `probe`。若平台有 IOMMU(如 RK3568)优先改用 IOMMU 映射,从根上摆脱物理连续约束。另外 `dma_alloc_coherent` 失败不总是返回 NULL,也可能返回 `ERR_PTR`,必须用 `IS_ERR_OR_NULL` 检查。