2. 嵌入式内存管理基础

做驱动开发,尤其是涉及到DMA、大块连续内存分配时,你迟早得跟Linux内存管理架构打交道。很多新手一上来就啃mm/page_alloc.c,结果被各种链表和位图搞晕。其实,理解这套架构的关键,是搞清楚三个层次:Node(节点)、Zone(区域)、Page Frame(页帧)。

说白了,Linux把物理内存当成一个“树状仓库”来管。最顶层是Node,对应物理上的NUMA节点;中间层是Zone,按内存的用途和属性分区;最底层是Page Frame,也就是4KB(或更大)的物理页框。我当年第一次接触NUMA机器时,发现内存分配竟然有“远近亲疏”,着实花了不少时间才适应。

2.1 内存节点(Node)—— NUMA的基石

先说说Node。在单核或小型SMP系统里,所有CPU访问内存的速度是一样的,这种架构叫UMA(Uniform Memory Access)。但在服务器和高端嵌入式多核芯片里,CPU和内存之间不是对等的——每个CPU离自己“最近”的那块内存访问最快,离得远的就慢。这就是NUMA(Non-Uniform Memory Access)。

Linux用struct pglist_data(简称pg_data_t)来描述一个内存节点。每个节点管理自己那一亩三分地。你可以在/sys/devices/system/node/下看到系统有哪些节点。

关键数据结构:

// 每个内存节点对应一个 pg_data_t
typedef struct pglist_data {
    struct zone node_zones[MAX_NR_ZONES];  // 该节点包含的Zone数组
    struct zonelist node_zonelists[MAX_ZONELISTS]; // 备用Zone列表(分配时的fallback顺序)
    int nr_zones;                           // 该节点实际Zone数量
    unsigned long node_start_pfn;           // 该节点起始页帧号
    unsigned long node_present_pages;       // 物理页总数
    unsigned long node_spanned_pages;       // 总跨度(含空洞)
    int node_id;                            // 节点ID
    // ... 还有页面回收、kswapd相关字段
} pg_data_t;

我在调试一款ARM64多核SoC时遇到过一个问题:4个CPU cluster,每个cluster有自己的内存控制器,但Linux只识别成一个Node。后来发现是UEFI的SRAT表没配好,导致NUMA信息丢失。嗯,这里要注意:嵌入式设备不一定都跑NUMA,很多单核或同构多核SoC其实只有一个Node,但代码里依然用Node的概念来组织,只是nr_node_ids为1而已。

2.2 内存区域(Zone)—— 按用途分区

每个Node内部,内存又被划分成若干个Zone。为什么要分区?因为不同硬件模块对内存地址有不同要求。比如DMA控制器只能访问低16MB或低4GB,内核映射需要直接访问的线性区,等等。

Linux定义了以下几种Zone(以x86-64和ARM64常见配置为例):

Zone名称 宏定义 典型范围 用途说明
DMA ZONE_DMA 0 ~ 16MB 老式ISA DMA设备专用,地址受限
DMA32 ZONE_DMA32 0 ~ 4GB 32位DMA设备可用,x86_64常见
Normal ZONE_NORMAL 16MB ~ 896MB(32位) / 全部(64位) 内核直接映射区,最常用
HighMem ZONE_HIGHMEM 896MB以上(32位) 用户态高频内存,内核不能直接访问
Movable ZONE_MOVABLE 可配置 支持内存热插拔和防碎片

个人经验:在64位嵌入式系统(比如ARM64)上,你基本看不到ZONE_HIGHMEM,因为虚拟地址空间足够大,所有物理内存都能被内核直接映射。但ZONE_DMA和ZONE_DMA32依然存在——很多外设的DMA地址宽度只有32位甚至更少。我曾经被一个USB控制器坑过:它只能做30位地址的DMA,结果从ZONE_NORMAL分配的内存地址超过了1GB,DMA传输直接挂掉。后来我强制用dma_alloc_coherent的GFP标志指定从ZONE_DMA分配才解决。

每个Zone由struct zone描述,里面最核心的是free_area数组——这是Buddy System(伙伴系统)的根基。

struct zone {
    unsigned long watermark[NR_WMARK];  // 水位线:min/low/high
    long lowmem_reserve[MAX_NR_ZONES];  // 各Zone的保留页
    struct pglist_data *zone_pgdat;     // 所属Node
    struct per_cpu_pageset __percpu *pageset; // 每CPU页缓存
    // 伙伴系统的核心:每个order一个链表
    struct free_area free_area[MAX_ORDER];
    unsigned long _watermark[NR_WMARK];
    // ... 还有大量统计和回收字段
};

水位线(watermark)是Zone里一个非常重要的概念。它决定了内存分配是走“快速通道”还是触发回收。我习惯把这三个水位线记成:

  • WMARK_MIN:底线,低于这个值只能让kswapd内核线程去抢内存
  • WMARK_LOW:低水位,kswapd开始异步回收
  • WMARK_HIGH:高水位,kswapd可以歇着了

为什么会这样设计?你想想看,如果每次分配内存都同步回收,那系统性能会急剧下降。所以Linux用水位线做“提前预警”——水位降到Low时,kswapd就开始干活,尽量让水位回到High以上。

2.3 页帧(Page Frame)—— 最小的管理单元

页帧,就是物理内存的最小管理单位。在Linux里,每个物理页帧对应一个struct page。这个结构体非常精巧,它用union把各种用途的字段塞进40字节(64位系统)里。

struct page {
    unsigned long flags;          // 页状态标志(PG_locked, PG_dirty等)
    atomic_t _refcount;           // 引用计数
    atomic_t _mapcount;           // 映射计数(被多少个页表映射)
    unsigned long private;        // 私有数据(不同用途含义不同)
    struct address_space *mapping; // 所属地址空间(文件页/匿名页)
    pgoff_t index;                // 在文件或交换区中的偏移
    struct list_head lru;         // LRU链表节点(用于页面回收)
    void *virtual;                // 内核虚拟地址(若已映射)
    // ... 更多union成员
};

每个物理页帧都有一个唯一的页帧号(PFN,Page Frame Number)。PFN从0开始,按物理地址顺序递增。PFN和物理地址的换算很简单:

// 物理地址 = PFN × PAGE_SIZE
// PFN = 物理地址 >> PAGE_SHIFT
#define page_to_pfn(page)   ((unsigned long)((page) - mem_map) + ARCH_PFN_OFFSET)
#define pfn_to_page(pfn)    (mem_map + ((pfn) - ARCH_PFN_OFFSET))

避坑指南:我曾经在移植驱动时,直接用__pa(page_address(page))来获取物理地址,结果在HIGHMEM系统上崩溃了。因为page_address()只对ZONE_NORMAL的页有效,HIGHMEM的页没有固定映射。正确的做法是用page_to_pfn()再左移PAGE_SHIFT,或者直接用virt_to_phys()——前提是你有虚拟地址。

2.4 三者的关系:一张图说清楚

下面这张SVG图展示了Node、Zone、Page Frame之间的层级关系,以及Buddy System如何把连续页帧组织成不同order的块。

Linux内存管理三层架构:Node → Zone → Page Frame Linux内存管理三层架构 Node 0 (pg_data_t) node_id=0, node_start_pfn=0, node_present_pages=2GB ZONE_DMA 0 ~ 16MB, watermark: min/low/high ZONE_DMA32 16MB ~ 4GB, watermark: min/low/high ZONE_NORMAL 4GB以上, watermark: min/low/high Zone 内部:Buddy System (free_area[0..MAX_ORDER-1]) order 0 4KB块 链表头 order 1 8KB块 链表头 order 2 16KB块 链表头 ...... order 10 4MB块 链表头 Page Frame 层 (struct page 数组) PFN
0 PFN
1
PFN
2
...... PFN
N
PFN
N+1
...... PFN
M
...... 每个struct page ≈ 40字节 flags | _refcount | mapping | lru ...

2.5 实际分配路径:从API到页帧

理解了这三层架构,我们再来看一个内存分配请求是怎么走完的。假设驱动里调用:

void *buf = kmalloc(8192, GFP_KERNEL);  // 申请8KB连续内存

内核内部大致走这么几步:

  1. 确定Node:根据当前CPU所在Node,或者根据cpuset/mempolicy选择最优Node。
  2. 确定Zone:根据GFP标志(GFP_KERNEL默认从ZONE_NORMAL开始),查node_zonelists找到合适的Zone。
  3. 检查水位:看该Zone的空闲页是否高于WMARK_LOW。如果低于,唤醒kswapd异步回收。
  4. Buddy分配:在free_area[order]里找空闲块。order=1(因为8KB=2个页)。如果order 1链表为空,从order 2分裂,以此类推。
  5. 返回页帧:拿到struct page指针,转换成虚拟地址返回给调用者。

我建议:在调试内存分配问题时,可以打开/proc/zoneinfo和/proc/buddyinfo。前者能看到每个Zone的水位和统计,后者能看到每个order的空闲块数量。我曾经靠/proc/buddyinfo发现某个Zone的order 10块全没了,导致DMA分配大块内存失败——原来是某个驱动没释放DMA缓冲池。

2.6 嵌入式场景下的特殊考量

嵌入式设备和服务器不一样,内存通常只有256MB~2GB,而且往往是UMA(单Node)。但有几个点要特别注意:

  • CMA(Contiguous Memory Allocator):很多嵌入式SoC需要大块连续内存给GPU、VPU、Camera。CMA在ZONE_MOVABLE或ZONE_NORMAL里预留区域,平时给用户态用,需要时“迁移”走页面腾出连续空间。我调试过一个问题:CMA区域碎片化严重,导致4K视频解码时分配连续内存失败。后来调整了CMA大小和迁移策略才解决。
  • ZONE_DMA的地址限制:有些MCU或DSP通过共享内存通信,它们只能访问低32位地址。如果你的SoC有这种协处理器,分配共享内存时一定要用GFP_DMA或dma_alloc_coherent指定设备掩码。
  • 内存热插拔:部分高端嵌入式系统支持内存热插拔(比如某些网络设备)。ZONE_MOVABLE就是为此设计的——它确保页面可以被迁移,从而在拔出内存前清空该区域。