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的块。
2.5 实际分配路径:从API到页帧
理解了这三层架构,我们再来看一个内存分配请求是怎么走完的。假设驱动里调用:
void *buf = kmalloc(8192, GFP_KERNEL); // 申请8KB连续内存
内核内部大致走这么几步:
- 确定Node:根据当前CPU所在Node,或者根据cpuset/mempolicy选择最优Node。
- 确定Zone:根据GFP标志(GFP_KERNEL默认从ZONE_NORMAL开始),查
node_zonelists找到合适的Zone。 - 检查水位:看该Zone的空闲页是否高于WMARK_LOW。如果低于,唤醒kswapd异步回收。
- Buddy分配:在
free_area[order]里找空闲块。order=1(因为8KB=2个页)。如果order 1链表为空,从order 2分裂,以此类推。 - 返回页帧:拿到
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就是为此设计的——它确保页面可以被迁移,从而在拔出内存前清空该区域。