05-内存管理.md 60 KB


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)。

 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]=0b10bit[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_MEMORYmem_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 最昂贵。

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"]

三、页分配器与伙伴系统

3.1 伙伴系统的组织结构

伙伴系统把空闲内存按 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_RECLAIMABLEMIGRATE_CMAMIGRATE_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);
}

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_mapkswapd 进程指针。

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_pagesmanaged_pages/present_pages/spanned_pageslru_locklruvecvm_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 失败的典型现象。

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_cachefreelist),这也是 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 pageslab_cachefreelist 页内简单链表
空闲对象组织 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 更易读

4.3 kmem_cache 完整生命周期

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);

4.4 对象着色(object coloring)原理

同一 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 效果最明显。

4.5 kmalloc 与 size class 内部实现

kmalloc 内部维护一组 kmalloc_caches,按用途分为 KMALLOC_NORMALKMALLOC_RECLAIM(带 __GFP_RECLAIMABLE)、KMALLOC_DMAKMALLOC_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;

96192 这两个非 2 的幂的 class 是内核统计真实分配分布后加进去的,这两个尺寸请求量极大,专用 cache 能避免明显的内部碎片浪费。所以 kmalloc(300, GFP_KERNEL) 实际落在 kmalloc-512,占用 512 字节;kmalloc(100, GFP_KERNEL) 落在 kmalloc-128KMALLOC_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_kzallocdevm_kcallocdevm_kstrdupdevm_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() 逐页归还并删除映射

    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 才能访问:

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__iomemsparse 的地址空间注解,直接解引用会被静态检查器报错,也是驱动里最常见的低级错误之一。这些访问器还隐含内存屏障语义,用 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() 不会;多个驱动映射同一段寄存器属于设计错误。

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、硬件描述符环)用 kmallocdma_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() 立即还给内核
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_createmmap 分配独立内存作为线程栈(MAP_STACK),大小由 pthread_attr_setstacksize 指定,默认 8MB;嵌入式设备上线程多时应显式设小。

mm_struct 中与内存相关的关键字段:mmap(VMA 链表头)、mm_rb(VMA 红黑树,按地址查找 O(log n))、mmap_basetask_sizestart_code/end_code/start_data/end_datastart_brk/brkstart_stackarg_start/arg_end/env_start/env_endpgdmm_users/mm_countmmap_lock

6.3 写时复制(COW)

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/vmstatcow_fault 会持续升高,此时应考虑改用共享内存或 vfork
  • 统计/proc/vmstatpgfaultpgmajfaultcow_fault 可区分缺页类型;/proc/PID/smapsShared_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 清除/失效操作。

/* 一致性分配 */
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,且方向要与实际传输方向一致。

7.2 CMA:连续内存分配器

伙伴系统在运行一段时间后往往给不出大块连续内存,而 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@0x80000000CONFIG_CMA=yCONFIG_CMA_SIZE_MBYTES=64CONFIG_DMA_CMA=y。运行时 grep -i cma /proc/meminfoCmaTotal/CmaFreeCmaFree 持续接近 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_DEVICEDMA_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 进程地址空间

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 命令分配、网络缓冲区)不会因内存压力停摆。

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 维护独立副本,修改时只操作本地副本,无需加锁或跨核同步。

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

# 编译时开启 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/slabinfoslabtop 监控各类 slab 缓存使用量增长趋势;slub_debug(红区、poison);CONFIG_PAGE_OWNERpage_owner=on 记录每页分配者,配合 /sys/kernel/debug/page_owner 排查页级泄漏)。嵌入式现场的实用做法是跑长稳脚本,定期 dump /proc/meminfo/proc/slabinfo 和 CMA 计数,看 SlabCmaFree 是否单调下降。


十、跨平台对比

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_DMAkmalloc(..., GFP_KERNEL | GFP_DMA) 从低于 16MB 的区域分配,地址限制过严,现代平台少用。
  3. DMA pool:频繁分配/释放小块 DMA 内存时用 dma_pool_create/dma_pool_alloc 减少碎片。
  4. 设备树预留:用不带 reusablereserved-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_pageTLBIMVA)远优于 flush_tlb_mmTLBIASID),local_flush_tlb_allTLBIALL)最昂贵。进程切换时用 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/vmstatcow_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/meminfoCmaFree 很小而 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,cmasize、为特定外设建立专用 shared-dma-pool 隔离、启动参数加 cma=64M、在启动早期一次性分配好缓冲区、把大缓冲区分配从运行时前置到 probe。若平台有 IOMMU(如 RK3568)优先改用 IOMMU 映射,从根上摆脱物理连续约束。另外 dma_alloc_coherent 失败不总是返回 NULL,也可能返回 ERR_PTR,必须用 IS_ERR_OR_NULL 检查。