title: 内存管理机制 tags: [Linux内核, 内存管理, 虚拟内存, 嵌入式, 伙伴系统, slab] created: 2026-09-16
💡 关联知识: [[FreeRTOS学习笔记/16-内存管理]] | [[计算机操作系统/内存管理]] | [[02-嵌入式Linux内核基础/06-设备模型与驱动框架]]
物理内存是 DDR 控制器直接管理的线性地址空间,ARM 平台通常从 0x80000000 开始编排,启动时从中划分出内核镜像、设备树、页表和可用内存池。虚拟内存是每个进程独立看到的地址空间:32 位系统下每个进程拥有 4GB 虚拟地址范围,用户空间占低 3GB(0x00000000 - 0xBFFFFFFF),内核空间占高 1GB(0xC0000000 - 0xFFFFFFFF)。进程切换时内核把新进程的页表基址写入 TTBR0,MMU 随即切换到新的映射。
两套地址之间的映射关系保存在页表中。内核在 fork() 时为子进程建立用户空间页表,系统调用陷入内核态时切换到内核空间映射。这种设计的直接好处:进程之间互不干扰,内核代码和数据被所有进程共享但受权限位保护,Swap 机制可以把暂时不用的页面换出到磁盘,等价于扩大了物理内存容量。
嵌入式系统的存储层次从快到慢:寄存器(<1ns)→ L1 Cache(~1ns)→ L2 Cache(~3ns)→ DDR(~50ns)→ eMMC/SD(~100us)。这个层次决定了内核内存管理的核心策略:页分配器(伙伴系统)按 2^n 页的幂次分配,天然对齐有利于 DMA 和 Cache 行填充;slab 分配器在页之上建立对象池,减少频繁的页分配/释放开销;per-cpu 缓存则把热点数据锁在 Cache 中,避免多核竞争。
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)。
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 这类大块连续区域。
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 封装映射属性。
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 最昂贵。
flowchart TD
A["CPU 发出虚拟地址 VA"] --> B{"TLB 命中?<br/>标签 = 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"]
伙伴系统把空闲内存按 2 的幂次组织成 11 个链表(order 0 到 order 10)。每个 order 内部不是单个链表,而是按迁移类型分组的数组:
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 */
...
};
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。
flowchart TD
A["alloc_pages(GFP_KERNEL, 2)"] --> B{"order-2 有合适<br/>迁移类型的块?"}
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,再继续向上判断:
static inline unsigned long
__find_buddy_pfn(unsigned long page_pfn, unsigned int order)
{
return page_pfn ^ (1 << order);
}
物理内存组织是三层:节点(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 的停止线。
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 失败的典型现象。
内核为每个页框维护一个 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 省内存的原因。
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 避免递归。
内存压缩解决「有空间但没有连续空间」的问题:把散落在低地址的可移动页搬到其他空闲页上,腾出连续区域。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 请求直接回退到其他迁移类型更划算。
伙伴系统以页为最小单位分配,但内核中大量数据结构只需几十到几百字节。如果每个 struct task_struct(约 2KB)或 struct inode(约 600 字节)都占一整页,内存浪费严重,且频繁小块分配/释放会造成外部碎片。
slab 分配器在伙伴系统之上建立对象缓存:从伙伴系统获取整页,切割成固定大小的对象按需分配;释放时对象不立即归还,而是保留在缓存中供下次使用。好处有三:同类型对象大小相同不产生外部碎片;空闲对象链表在 Cache 中,分配只需一次链表操作;对象着色减少多核伪共享。
内核历史上存在三套实现,由 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 让快路径完全无锁。
ls /sys/kernel/slab/ # 每个 kmem_cache 一个目录
cat /sys/kernel/slab/kmalloc-64/objects
slabinfo -X # 比 /proc/slabinfo 更易读
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 已清空;创建后忘记销毁则模块再也无法重新加载。
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);
同一 slab 缓存中的对象大小相同。如果每个 slab 的起始偏移完全一致,那么「每个 slab 的第 1 个对象」都落在同一个 Cache 行偏移上,多核同时分配各自 CPU 上的第 1 个对象就会反复争抢同一条 Cache 行,产生伪共享(false sharing)。
着色的做法是给不同 slab 加上 0 ~ max_colour 个 Cache 行的起始偏移。SLUB 中对应实现:
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 效果最明显。
kmalloc 内部维护一组 kmalloc_caches,按用途分为 KMALLOC_NORMAL、KMALLOC_RECLAIM(带 __GFP_RECLAIMABLE)、KMALLOC_DMA、KMALLOC_CGROUP,每种类型下再按 size class 组织。分配时把请求大小向上取整到最近的 class(kmalloc_index(),节选):
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 的地址或静态/栈变量。
Linux 2.6 引入设备资源管理(devres)机制,devm_kmalloc 分配的内存在设备移除或驱动卸载时自动释放,避免驱动代码中大量的错误路径释放逻辑。同类接口还有 devm_kzalloc、devm_kcalloc、devm_kstrdup、devm_get_free_pages,嵌入式驱动开发中推荐优先使用。
vmalloc 分配的内存虚拟地址连续,但物理上不要求连续。内核虚拟地址空间自高到低划分为:向量页/固定映射 → VMALLOC 区(vmalloc/ioremap)→ 内核模块区 → 直接映射区(lowmem,__va)→ 内核镜像与页表。工作流程:
__get_vm_area_node() 在 VMALLOC 区(VMALLOC_START ~ VMALLOC_END)找到满足大小和对齐的虚拟地址,vmap_area 挂在红黑树和链表上alloc_page() 逐页(或按 order)分配物理内存map_vm_area() 用 mk_pte() 建立 PTE,把物理页贴到虚拟地址上需要时刷新 TLB;vfree() 逐页归还并删除映射
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,避免在原子上下文里同步拆页表。
外设寄存器(如 IMX6ULL 的 IOMUXC、eMMC 控制器)的物理地址在 DDR 之外,内核直接映射区通常不覆盖,必须先 ioremap 才能访问:
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 或指针赋值都会破坏访问顺序。
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() 不会;多个驱动映射同一段寄存器属于设计错误。
| 特性 | 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 的地址会立刻破坏内核堆。
malloc 是 glibc 的实现,底层只依赖两个系统调用:默认阈值 M_MMAP_THRESHOLD 为 128KB(动态调整)。
| 请求大小 | 底层机制 | free 时的行为 |
|---|---|---|
| < 128KB | brk() 扩展堆顶 |
归还给 glibc 的空闲链表,不一定还给内核 |
| ≥ 128KB | mmap(MAP_PRIVATE \| MAP_ANONYMOUS) |
munmap() 立即还给内核 |
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 膨胀)。
堆向上增长(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。
fork() 不复制物理内存,而是让父子进程共享同一批物理页,并把双方的 PTE 都改成只读(copy_page_range() 中 ptep_set_wrprotect()),同时 page->_mapcount 加一。任何一方尝试写入时触发写保护缺页,内核才真正复制一页。
flowchart TD
A["fork()"] --> B["copy_page_range() 复制页表项"]
B --> C["父子 PTE 均置只读<br/>_mapcount++ _count++"]
C --> D{"任一进程写入共享页?"}
D -->|写保护 fault| E["do_wp_page()"]
E --> F{"PTE 已可写?"}
F -->|是, 竞态| G["直接返回, 重试指令"]
F -->|否| H{"_mapcount == 1<br/>且 _count == 1?"}
H -->|独占匿名页| I["原地改回可写<br/>不复制, 零开销"]
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 能看出实际共享程度。| 类型 | API | 一致性保障 | 典型用途 |
|---|---|---|---|
| 一致性(coherent) | dma_alloc_coherent |
CPU 与外设看到同一份数据,无需显式 cache 维护 | 描述符环、长期共享的控制结构 |
| 流式(streaming) | dma_map_single / dma_map_sg |
每次传输前后需要 cache 同步 | 单次传输的数据缓冲区 |
一致性的实现方式是把该块内存映射为非缓存(uncached),DDR 读写直接落到物理内存,代价是 CPU 访问变慢;流式映射保留缓存属性,由 dma_map_*/dma_unmap_* 在传输边界插入 cache 清除/失效操作。
/* 一致性分配 */
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 的虚拟地址):
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,且方向要与实际传输方向一致。
伙伴系统在运行一段时间后往往给不出大块连续内存,而 DMA 控制器(尤其是摄像头、VPU、NPU)经常需要数 MB 到数十 MB 的连续缓冲。CMA(Contiguous Memory Allocator)的思路是「预留但不浪费」:启动时预留一块连续区域并注册为 MIGRATE_CMA;空闲时该区域仍可被伙伴系统分配可移动页;DMA 请求到来时把区域内的可移动页迁移出去腾出连续空间;cma_release 归还后重新变成「可借给可移动页」的状态。
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 就从该池分配:
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 用专用池隔离。
kmalloc 却忘了 cache 维护:非一致性架构(老 ARM)上,CPU 写完的数据可能还在 Cache 里,DMA 读到旧内容;必须用 dma_map_single 或在 __dma_flush_area 之后启动 DMA,用 dma_alloc_coherent 则天然规避。dma_alloc_coherent 的地址一定按 dma_get_cache_alignment() 对齐,手动分配的缓冲区也必须保证。DMA_FROM_DEVICE 与 DMA_TO_DEVICE 弄反会清除本该保留的 cache 或保留本该清除的 cache,表现为偶发数据错误。dma_map_* 返回的 IOVA,直接写物理地址会导致 IOMMU 缺页。dma_free_coherent:这类泄漏 kmemleak 看不到(不在 slab 里),只能靠 /proc/meminfo 和 CMA 计数排查。mmap 把文件或设备的内存映射到进程虚拟地址空间,用户态通过指针直接读写,内核在缺页中断时按需加载数据,避免了 read/write 的拷贝开销。内核处理流程:
vm_area_struct(VMA),描述这段虚拟地址范围的属性mm_struct 的红黑树fault 方法(不立即分配物理内存)fault 方法,分配物理页并建立页表映射这种「按需分配」意味着 mmap 一个 1GB 的文件并不会立即占用 1GB 物理内存,只有实际访问到的页面才会被分配。
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(扩展或收缩堆)、缺页处理(按需建立物理页映射)。
缺页中断发生时,内核的 handle_mm_fault() 执行以下步骤:
regs 中的 FAR)find_vma())SIGSEGV缺页分两种:minor fault(页在内存中,只是 PTE 没建立)和 major fault(需要从磁盘/交换区读入)。/usr/bin/time -v 输出里的 major/minor page fault 数量是评估内存行为的直接指标。
标准 4KB 页在大内存负载下会产生大量页表项和 TLB 缺失。大页机制支持 2MB(PMD 级别)和 1GB(PUD 级别)的页,减少页表层级和 TLB 压力。嵌入式系统中大页使用场景较少,但某些高端平台(如 RK3568)若运行 64 位内核且内存超过 2GB,可考虑启用大页优化大块 DMA 缓冲区的映射效率。THP(Transparent Huge Pages)可在 /sys/kernel/mm/transparent_hugepage/enabled 中配置,对标记了 MADV_HUGEPAGE 的区域自动生效。
内核在某些关键路径中使用内存池保证分配不会失败:预先从伙伴系统分配一批对象放入池中,普通分配失败时从池中取对象,确保关键路径(如 SCSI 命令分配、网络缓冲区)不会因内存压力停摆。
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 分配失败时才真正动用预留。注意预留元素是独占的,池子越大,平时真正可用的内存就越少。
多核系统中频繁修改的全局变量会导致 Cache 行在核心间反复弹跳(cache line bouncing)。per-cpu 变量为每个 CPU 维护独立副本,修改时只操作本地副本,无需加锁或跨核同步。
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 解决「各自维护自己的数据,需要时再汇总」,因此适合计数器与本地缓存,不适合需要全局一致性的临界区。
kmemleak:注册 kmalloc 的 tracepoint,每次分配时记录对象地址、大小和调用栈,释放时删除记录;后台扫描线程定期遍历所有已分配但未被任何全局/栈指针引用的对象,把疑似泄漏项输出到 /sys/kernel/debug/kmemleak。
# 编译时开启 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 是否单调下降。
| 特性 | 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 或设备树预留内存来保证。
部分低端嵌入式 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 时最容易踩的坑,就是误以为可以用「先随意分配、以后再整理」的策略。
考察点:伙伴系统原理、碎片化
参考答案:
伙伴系统通过「合并伙伴」机制缓解外部碎片:每次释放一个块时检查其地址相邻、大小相同的伙伴块是否也空闲,空闲就合并为更大的块并继续向上合并,伙伴的页帧号可直接计算:buddy_pfn = pfn ^ (1 << order)。局限是运行较长时间后不同 order 的空闲块分散在各处,即使总空闲内存很多也可能凑不出连续的 2^n 页;嵌入式系统内存小(256MB-1GB)时更突出。内核通过 kswapd 定期回收、compaction 搬迁页面来缓解,但都有性能开销。最有效的办法是在启动阶段预留足够的 CMA 区域,把大块连续内存需求前置到内存充足时解决。
考察点:内存分配 API、物理连续性
参考答案:
kmalloc 分配物理连续内存,底层来自 slab 缓存,最大通常 4MB,适合 DMA 缓冲区、硬件描述符环、中断上下文中的小块分配。vmalloc 分配虚拟连续但物理不连续的内存,通过修改页表实现映射,适合大块内存(模块代码加载、大缓冲区),但不适合 DMA,也不能在原子上下文使用。性能上 kmalloc 更快(slab 命中只需一次链表操作),vmalloc 需要逐页分配 + 改页表 + 刷 TLB,明显更慢。补充:32 位系统上 VMALLOC 区是有限资源,长期占用会导致后续 ioremap 失败,驱动中 kmalloc 的使用频率应远高于 vmalloc。
考察点: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 能容纳的对象数略有减少。
考察点:内存泄漏检测、调试工具
参考答案:
kmemleak 记录所有 kmalloc/kfree 的调用栈(对象地址、大小、调用栈),释放时删除记录;后台扫描线程定期遍历所有已分配但未被任何全局/栈指针引用的对象,把疑似泄漏项输出到 /sys/kernel/debug/kmemleak。用法:内核开启 CONFIG_DEBUG_KMEMLEAK,运行 echo scan > /sys/kernel/debug/kmemleak 触发扫描,cat 该文件查看结果,输出含地址、大小、分配 PID 和完整调用栈,可直接定位到代码行。缺点是每次内存操作都要记录调用栈,开销较大,生产环境不建议开启;由于判定依据是「能否在内存中扫描到指向它的指针」,打散的指针、编码句柄、DMA 缓冲都会误报,结论需人工复核。
考察点:DMA 内存管理、CMA、GFP 标志
参考答案:
MIGRATE_CMA,平时可借给可移动页,DMA 时迁移走以保证连续性;设备树用 reserved-memory + compatible = "shared-dma-pool" + reusable 配置。kmalloc(..., GFP_KERNEL | GFP_DMA) 从低于 16MB 的区域分配,地址限制过严,现代平台少用。dma_pool_create/dma_pool_alloc 减少碎片。reusable 的 reserved-memory 预留固定地址内存,排除在伙伴系统之外,驱动用 of_reserved_mem_device_init_by_idx() 绑定。同时要保证缓冲区按 cache line 对齐,避免 invalidate 时误伤相邻数据。
考察点:页表结构、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 时才需要全量刷新。
考察点: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。
考察点:用户空间内存、缺页、按需分配
参考答案:
分三段: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)才决定成败。
考察点: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。
dma_alloc_coherent 申请 8MB 连续内存失败,怎么排查?考察点:碎片化、CMA、调试方法
参考答案:
这是典型的外部碎片问题——总量够但没有连续的 2^n 页。排查顺序:
cat /proc/buddyinfo。若 order 11(8MB)及以上为 0,说明确实没有 8MB 连续块;注意 order 上限由 MAX_ORDER 决定,若 MAX_ORDER=10 则最大只有 4MB,8MB 必然失败。grep -i cma /proc/meminfo。CmaFree 很小而 CmaTotal 足够,说明 CMA 区被可移动页占满且收缩失败(常见原因是有 MIGRATE_UNMOVABLE 页嵌在 CMA 区,或持有者用了 get_user_pages 导致页面不可迁移)。cat /proc/pagetypeinfo 观察 CMA 区的 Unmovable 块数量。CMA 区内只要存在不可移动页,对应 order 就永远无法被 CMA 借用。/sys/kernel/debug/extfrag/unusable_index,接近 1000 表示按该 order 分配几乎必然失败。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 检查。