第3章:内存管理(Memory Management)
一、导读
3.1 本章学习目标
第3章深入讲解Linux内核的内存管理机制。在理解了第2章的硬件寻址基础之后,本章将聚焦于内核如何管理和分配内存资源。Linux的内存管理是内核中最复杂的子系统之一,它需要同时满足多种需求:高效分配物理页框、管理虚拟地址空间、支持内存映射、处理内存不足等。
本章的核心学习目标包括:
- 理解伙伴系统(Buddy System)的工作原理:如何管理物理页框的分配和释放,减少外部碎片
- 掌握slab分配器的设计思想:如何高效分配小对象,避免内部碎片
- 理解kmalloc和vmalloc的区别:连续物理内存 vs 连续虚拟内存
- 掌握内存映射(mmap)的实现:如何将文件或设备映射到进程地址空间
- 理解内存区域(Zone)的划分:ZONE_DMA、ZONE_NORMAL、ZONE_HIGHMEM各自的用途
- 了解写时复制(Copy-on-Write)机制:fork如何高效创建子进程
3.2 为什么本章如此重要
内存管理是操作系统的核心职能之一。程序的性能、系统的稳定性、甚至安全性,都与内存管理密切相关。内存泄漏会导致系统资源耗尽,内存碎片会降低系统性能,错误的内存访问会导致安全漏洞。
对于内核开发者而言,理解内存管理机制是编写驱动程序和内核模块的基础。不同的内存分配接口(kmalloc、vmalloc、kmem_cache_alloc)适用于不同的场景,选择不当会导致性能问题甚至系统崩溃。对于系统程序员而言,理解mmap、COW等机制有助于编写更高效的程序。
3.3 核心问题引导
在阅读本章时,请带着以下问题思考:
为什么内核需要多种内存分配接口?一种通用的分配器为什么不够?
伙伴系统如何解决外部碎片问题?它又引入了什么新问题?
slab分配器为什么比通用的分配器更适合分配内核数据结构?
kmalloc和vmalloc返回的内存有什么本质区别?为什么不能互换使用?
fork()创建子进程时,为什么不需要复制整个地址空间?
当系统物理内存不足时,内核如何决定回收哪些页面?
二、核心概念详解
2.1 内存区域(Zone)
Linux将物理内存划分为多个区域(Zone),每个区域具有不同的特性和用途。这种划分源于硬件的限制和需求。
/* 内存区域定义(include/linux/mmzone.h) */
enum zone_type {
ZONE_DMA, /* 0-16MB,ISA设备DMA */
ZONE_DMA32, /* 0-4GB,32位设备DMA */
ZONE_NORMAL, /* 4GB以上,正常内核映射 */
ZONE_HIGHMEM, /* 32位系统>896MB(64位不需要) */
ZONE_MOVABLE, /* 可迁移的页面 */
MAX_NR_ZONES
};
/*
* ZONE_DMA (0-16MB):
* 早期的ISA设备只能寻址低16MB物理内存。
* 现代系统中这个区域通常很小或不存在。
*
* ZONE_DMA32 (0-4GB):
* 32位DMA设备只能寻址4GB以内的物理内存。
* 在64位系统上,这个区域确保有足够内存供32位设备使用。
*
* ZONE_NORMAL (4GB+):
* 内核正常使用的内存区域。在64位系统上,
* 几乎所有内存都在这个区域。内核的直接映射区
* 覆盖这个区域的所有页面。
*
* ZONE_HIGHMEM:
* 仅存在于32位系统。超过896MB的物理内存
* 无法被内核直接映射(因为内核虚拟地址空间只有1GB,
* 其中128MB用于vmalloc等),需要临时映射才能访问。
* 64位系统不需要HIGHMEM。
*/每个区域由一个pglist_data(也称为pg_data_t)结构描述,系统中可能有多个pglist_data(NUMA系统中每个节点一个)。
/* pglist_data 结构(简化) */
typedef struct pglist_data {
struct zone node_zones[MAX_NR_ZONES]; /* 各内存区域 */
struct zonelist node_zonelists[MAX_ZONELISTS];
int nr_zones; /* 区域数量 */
struct page *node_mem_map; /* 页描述符数组 */
unsigned long node_start_paddr; /* 起始物理地址 */
unsigned long node_spanned_pages; /* 总页数(含空洞) */
unsigned long node_present_pages; /* 实际存在页数 */
int node_id; /* NUMA节点ID */
struct pglist_data *pgdat_next; /* 链表指针 */
spinlock_t node_size_lock; /* 保护锁 */
} pg_data_t;2.2 伙伴系统(Buddy System)
伙伴系统是Linux内核管理物理页框分配的核心算法。它的设计目标是:快速分配和释放连续的物理页框,同时尽量减少外部碎片。
2.2.1 基本原理
伙伴系统将空闲页框按大小分组,每组包含大小为 2^i 个连续页框的块(称为块(block)),其中 i 的范围是 0 到 MAX_ORDER-1(通常为11,即最大块为 1024 页 = 4MB)。
伙伴系统的空闲链表:
Order 0 (1页=4KB): [free] → [free] → [free] → NULL
Order 1 (2页=8KB): [free] → [free] → NULL
Order 2 (4页=16KB): [free] → NULL
Order 3 (8页=32KB): [free] → [free] → NULL
...
Order 10 (1024页=4MB): [free] → NULL分配过程:当请求分配 2^i 个页框时:
查找 order i 的空闲链表
如果有空闲块,直接分配
如果没有,查找 order i+1 的空闲链表
将找到的块一分为二(split),一半分配,一半放入 order i 的空闲链表
重复直到找到可用块或返回失败
释放过程:当释放 2^i 个页框时:
找到该块的"伙伴"(buddy)——地址相邻、大小相同的块
如果伙伴也在空闲链表中,合并两者为 2^(i+1) 的块
重复合并直到伙伴不在空闲链表中或达到最大order
/* 伙伴系统的核心数据结构 */
struct free_area {
struct list_head free_list[MIGRATE_TYPES]; /* 空闲块链表 */
unsigned long nr_free; /* 空闲块数量 */
};
/* zone 中的伙伴系统 */
struct zone {
/* ... */
struct free_area free_area[MAX_ORDER]; /* 各order的空闲链表 */
/* ... */
};
/* 分配页框的核心函数(简化) */
struct page *__alloc_pages_nodemask(gfp_t gfp_mask, unsigned int order,
struct zonelist *zonelist,
nodemask_t *nodemask) {
struct page *page;
struct zone *zone;
int migratetype;
/* 1. 确定迁移类型 */
migratetype = gfpflags_to_migratetype(gfp_mask);
/* 2. 从合适的zone中分配 */
/* 先尝试在请求的zone中分配 */
/* 如果失败,尝试其他zone */
/* 如果还是失败,触发内存回收 */
/* 3. 从伙伴系统获取页框 */
page = __rmqueue(zone, order, migratetype);
if (!page) {
/* 内存回收后重试 */
__alloc_pages_slowpath(gfp_mask, order, zonelist);
}
return page;
}
/* 伙伴系统的拆分操作 */
static struct page *__rmqueue(struct zone *zone, unsigned int order,
int migratetype) {
struct free_area *area;
struct page *page;
unsigned int current_order;
/* 从请求的order开始,向上查找 */
for (current_order = order;
current_order < MAX_ORDER;
++current_order) {
area = &(zone->free_area[current_order]);
if (list_empty(&area->free_list[migratetype]))
continue;
/* 找到一个空闲块 */
page = list_entry(area->free_list[migratetype].next,
struct page, lru);
list_del(&page->lru);
area->nr_free--;
/* 拆分到请求的order */
while (current_order > order) {
area--;
current_order--;
/* 将块一分为二,高半部分放入低order链表 */
buddy = page + (1 << current_order);
list_add(&buddy->lru, &area->free_list[migratetype]);
area->nr_free++;
}
return page;
}
return NULL; /* 分配失败 */
}2.2.2 伙伴系统的优缺点
优点:
- 分配和释放速度快(O(1)链表操作)
- 有效减少外部碎片(通过合并伙伴块)
- 实现相对简单
缺点:
- 内部碎片:请求3个页框时,必须分配4个(2^2),浪费1个
- 只能分配2的幂次个页框,不适合分配小对象
2.3 slab分配器
伙伴系统的最小分配单位是1个页框(4KB),但内核中大量使用的数据结构(如task_struct、inode、dentry)通常小于4KB。如果直接用伙伴系统分配这些小对象,会造成严重的内部碎片。
slab分配器就是为了解决这个问题而设计的。它为每种类型的内核对象创建一个专用缓存(slab cache),每个缓存中预分配了大量该类型的对象。
/* slab 缓存的结构 */
/*
* kmem_cache 结构(简化)
*
* ┌────────────────────────────────────────┐
* │ kmem_cache │
* │ name: "task_struct" │
* │ obj_size: 5888 bytes │
* │ align: 64 │
* │ obj_per_slab: 4 (每slab 4个对象) │
* ├────────────────────────────────────────┤
* │ slab 1 (16KB = 4页) │
* │ ┌──────┬──────┬──────┬──────┐ │
* │ │ obj │ obj │ obj │ obj │ │
* │ │ used │ used │ free │ free │ │
* │ └──────┴──────┴──────┴──────┘ │
* ├────────────────────────────────────────┤
* │ slab 2 (16KB) │
* │ ┌──────┬──────┬──────┬──────┐ │
* │ │ obj │ obj │ obj │ obj │ │
* │ │ used │ free │ free │ free │ │
* │ └──────┴──────┴──────┴──────┘ │
* └────────────────────────────────────────┘
*/
/* 创建 slab 缓存 */
struct kmem_cache *kmem_cache_create(
const char *name, /* 缓存名称 */
size_t size, /* 对象大小 */
size_t align, /* 对齐要求 */
unsigned long flags, /* 标志 */
void (*ctor)(void *) /* 构造函数(可选) */
);
/* 从 slab 缓存分配对象 */
void *kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags);
/* 释放对象回 slab 缓存 */
void kmem_cache_free(struct kmem_cache *cachep, void *objp);
/* 销毁 slab 缓存 */
void kmem_cache_destroy(struct kmem_cache *cachep);
/* 使用示例:内核中 task_struct 的分配 */
/* kernel/fork.c */
static struct kmem_cache *task_struct_cachep;
void __init fork_init(void) {
task_struct_cachep = kmem_cache_create("task_struct",
sizeof(struct task_struct),
ARCH_MIN_TASKALIGN,
SLAB_PANIC | SLAB_NOTRACK,
NULL);
}
/* 分配新的 task_struct */
struct task_struct *dup_task_struct(struct task_struct *orig) {
struct task_struct *tsk;
tsk = kmem_cache_alloc(task_struct_cachep, GFP_KERNEL);
/* ... 初始化 tsk ... */
return tsk;
}# 查看系统的 slab 缓存使用情况
cat /proc/slabinfo | head -20
# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab>
dentry 45678 46080 192 21 1
inode_cache 12345 12480 608 6 1
task_struct 234 256 5888 5 82.4 kmalloc 与 vmalloc
Linux内核提供了两种主要的内存分配接口:kmalloc和vmalloc。它们的区别在于分配的内存是否在物理上连续。
/* kmalloc - 分配连续的物理内存 */
/* 底层使用 slab 分配器 */
void *kmalloc(size_t size, gfp_t flags);
void kfree(const void *objp);
/* vmalloc - 分配连续的虚拟内存(物理上不连续) */
/* 通过修改页表使虚拟地址连续,但物理页框可以分散 */
void *vmalloc(unsigned long size);
void vfree(const void *addr);
/*
* 对比:
*
* kmalloc:
* 虚拟地址: [连续]
* 物理地址: [连续]
* 适用场景: DMA操作、硬件驱动、需要物理连续的场景
* 限制: 最大分配约 4MB(取决于伙伴系统MAX_ORDER)
* 速度: 快(直接映射,无需修改页表)
*
* vmalloc:
* 虚拟地址: [连续]
* 物理地址: [不连续]
* 适用场景: 大块内存、模块加载、不需要物理连续的场景
* 限制: 可以分配很大的内存(受vmalloc空间限制)
* 速度: 较慢(需要修改页表,可能产生TLB刷新)
*//* GFP 标志(Get Free Pages flags) */
/* 控制内存分配的行为 */
/* 常用标志 */
#define GFP_KERNEL 0x00CC /* 内核正常分配,可睡眠等待 */
#define GFP_ATOMIC 0x00D0 /* 原子上下文,不可睡眠 */
#define GFP_USER 0x00C8 /* 用户进程分配 */
#define GFP_HIGHUSER 0x00D0 /* 用户进程高端内存分配 */
#define GFP_DMA 0x0001 /* 要求 ZONE_DMA 区域 */
/* 行为标志 */
#define __GFP_WAIT 0x0010 /* 可以睡眠等待 */
#define __GFP_HIGH 0x0020 /* 可以使用保留的内存 */
#define __GFP_IO 0x0040 /* 可以执行I/O操作来回收内存 */
#define __GFP_FS 0x0080 /* 可以执行文件系统操作来回收内存 */
#define __GFP_ZERO 0x8000 /* 清零返回的内存 */2.5 内存映射(mmap)
mmap系统调用将文件或设备映射到进程的虚拟地址空间。映射后,对映射区域的读写操作会被转换为对底层文件的读写,由内核负责数据的换入换出。
/* mmap 系统调用 */
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
int munmap(void *addr, size_t length);
/* prot 参数: */
/* PROT_EXEC - 可执行 */
/* PROT_READ - 可读 */
/* PROT_WRITE - 可写 */
/* PROT_NONE - 不可访问 */
/* flags 参数: */
/* MAP_SHARED - 共享映射,修改对其他进程可见 */
/* MAP_PRIVATE - 私有映射,修改使用COW,不影响其他进程 */
/* MAP_ANONYMOUS - 匿名映射,不关联文件 */
/* MAP_FIXED - 要求映射到指定地址 *//* 使用 mmap 映射文件 */
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>
int main() {
int fd = open("data.txt", O_RDWR);
if (fd < 0) {
perror("open");
return 1;
}
struct stat st;
fstat(fd, &st);
size_t file_size = st.st_size;
/* 将文件映射到内存 */
char *mapped = mmap(NULL, file_size,
PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (mapped == MAP_FAILED) {
perror("mmap");
close(fd);
return 1;
}
/* 直接通过内存指针读写文件 */
mapped[0] = 'H';
mapped[1] = 'e';
mapped[2] = 'l';
mapped[3] = 'l';
mapped[4] = 'o';
printf("File content: %.*s\n", (int)file_size, mapped);
/* 同步到磁盘(可选,close时也会同步) */
msync(mapped, file_size, MS_SYNC);
munmap(mapped, file_size);
close(fd);
return 0;
}2.6 写时复制(Copy-on-Write)
fork()系统调用创建子进程时,如果完整复制父进程的整个地址空间,开销巨大。Linux使用写时复制(COW)技术优化这一过程:父子进程最初共享相同的物理页面,只有当某一方尝试写入时,内核才复制该页面。
/* fork 的 COW 实现(简化) */
/* kernel/fork.c */
static unsigned long copy_page_range(struct mm_struct *dst_mm,
struct mm_struct *src_mm,
pgd_t *dst_pgd, pgd_t *src_pgd) {
/* 遍历源进程的所有页表项 */
/* 对每个页表项: */
/* 1. 复制页表项到子进程 */
/* 2. 将父子进程的页表项都设为只读 */
/* 3. 增加物理页的引用计数 */
/* 当任何一方写入时,触发缺页异常 */
/* 缺页处理函数复制页面,并恢复写权限 */
}
/* 缺页异常中的 COW 处理(简化) */
/* mm/memory.c */
static int do_wp_page(struct vm_area_struct *vma, unsigned long address,
pte_t *page_table) {
struct page *old_page = vm_normal_page(vma, address, *page_table);
struct page *new_page;
/* 如果只有一个引用,直接恢复写权限 */
if (page_count(old_page) == 1) {
/* 不需要复制,直接提升权限 */
pte_mkdirty(*page_table);
return VM_FAULT_WRITE;
}
/* 否则,分配新页面并复制内容 */
new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, address);
copy_user_highpage(new_page, old_page, address, vma);
/* 更新页表项指向新页面 */
/* 减少旧页面的引用计数 */
/* 新页面设为可写 */
return VM_FAULT_WRITE;
}三、实战案例
3.1 观察伙伴系统状态
# 查看伙伴系统的空闲块分布
cat /proc/buddyinfo
# Node 0, zone DMA 1 0 1 0 2 1 1 0 1 1 3
# Node 0, zone Normal 56 32 18 10 5 3 2 1 0 0 128
# 解读:
# Normal zone, order 0: 56个空闲的1页块(56×4KB=224KB)
# Normal zone, order 1: 32个空闲的2页块(32×8KB=256KB)
# Normal zone, order 9: 0个空闲的512页块
# Normal zone, order 10: 128个空闲的1024页块(128×4MB=512MB)
# 触发内存分配后再次查看
# 观察各order的空闲块数量变化3.2 比较 kmalloc 和 vmalloc 的性能
/* mem_bench.c - 内存分配性能对比 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sys/mman.h>
#include <fcntl.h>
#define SIZE (64 * 1024 * 1024) /* 64MB */
int main() {
struct timespec start, end;
double elapsed;
/* 测试 malloc(底层使用 brk/mmap) */
clock_gettime(CLOCK_MONOTONIC, &start);
void *p1 = malloc(SIZE);
memset(p1, 0, SIZE); /* 触发实际分配 */
clock_gettime(CLOCK_MONOTONIC, &end);
elapsed = (end.tv_sec - start.tv_sec) +
(end.tv_nsec - start.tv_nsec) / 1e9;
printf("malloc + memset: %.6f seconds\n", elapsed);
free(p1);
/* 测试 mmap 匿名映射 */
clock_gettime(CLOCK_MONOTONIC, &start);
void *p2 = mmap(NULL, SIZE, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
memset(p2, 0, SIZE); /* 触发缺页异常 */
clock_gettime(CLOCK_MONOTONIC, &end);
elapsed = (end.tv_sec - start.tv_sec) +
(end.tv_nsec - start.tv_nsec) / 1e9;
printf("mmap + memset: %.6f seconds\n", elapsed);
munmap(p2, SIZE);
return 0;
}3.3 使用 mmap 实现进程间共享内存
/* shm_producer.c - 共享内存生产者 */
#include <stdio.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <string.h>
#define SHM_NAME "/my_shm"
#define SHM_SIZE 4096
int main() {
int fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666);
ftruncate(fd, SHM_SIZE);
char *shared = mmap(NULL, SHM_SIZE, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
for (int i = 0; i < 10; i++) {
sprintf(shared, "Message %d", i);
printf("Produced: %s\n", shared);
sleep(1);
}
munmap(shared, SHM_SIZE);
close(fd);
return 0;
}
/* shm_consumer.c - 共享内存消费者 */
#include <stdio.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#define SHM_NAME "/my_shm"
#define SHM_SIZE 4096
int main() {
int fd = shm_open(SHM_NAME, O_RDONLY, 0666);
char *shared = mmap(NULL, SHM_SIZE, PROT_READ,
MAP_SHARED, fd, 0);
for (int i = 0; i < 10; i++) {
printf("Consumed: %s\n", shared);
sleep(1);
}
munmap(shared, SHM_SIZE);
close(fd);
shm_unlink(SHM_NAME);
return 0;
}四、本章小结
本章深入讲解了Linux内核的内存管理机制。伙伴系统通过按2的幂次组织空闲页框,实现了快速的分配和释放,同时通过合并伙伴块减少外部碎片。slab分配器则为小对象提供了高效的分配方案,通过预分配和对象复用,避免了频繁创建和销毁的开销。
kmalloc和vmalloc分别适用于不同的场景:前者分配物理连续的内存,适合DMA等硬件操作;后者分配虚拟连续的内存,适合大块内存分配。mmap系统调用通过将文件映射到地址空间,提供了高效的文件I/O和进程间通信机制。写时复制(COW)技术使得fork()操作变得轻量级,只有在真正需要写入时才复制页面。
这些机制共同构成了Linux内存管理的完整体系,它们的设计体现了在性能、灵活性和复杂度之间的精妙平衡。
五、思考与练习
思考题
伙伴系统在长时间运行后,即使总空闲内存充足,也可能无法分配大块连续内存。这是什么问题?Linux如何缓解这个问题?
slab分配器中的"着色(coloring)"技术是什么?它解决了什么问题?
为什么在原子上下文(如中断处理函数)中不能使用GFP_KERNEL标志?
vfork和fork的区别是什么?在什么场景下应该使用vfork?
编程练习
编写一个程序,使用mmap映射一个大文件,然后使用mincore()系统调用检查哪些页面已经在物理内存中。
编写一个程序,对比顺序写入和随机写入对内存分配性能的影响,分析原因。
编写一个简单的内存分配器(实现malloc和free的基本功能),使用mmap作为底层内存来源。