字数 6241,阅读大约需 32 分钟
第五章:内存管理
内存是计算机系统中最宝贵的资源之一。Linux 0.12的内存管理系统负责将有限的物理内存分配给多个进程使用,同时通过分页机制和虚拟内存技术,让每个进程都感觉自己独占了整个地址空间。这套系统包括物理内存管理、虚拟内存映射、页面分配回收、写时复制机制以及页面交换等关键技术,是操作系统实现进程隔离和内存保护的基础。
5.1 内存布局与地址空间
Linux 0.12运行在x86保护模式下,支持4GB线性地址空间和分页机制。系统将16MB物理内存划分为几个区域:低1MB内存用于内核代码、数据和缓冲区(包括0-640KB的内核程序和640KB-1MB的显存和BIOS区域),1MB以上的主内存区用于用户进程的页面分配。
每个进程都有自己的4GB线性地址空间。0-64MB的区域用于任务号0-63的64个进程,每个进程占据64MB虚拟空间(即使实际可用物理内存远小于这个值)。进程的地址空间通过页目录和页表映射到物理内存。进程0使用0-64MB线性地址,进程1使用64-128MB线性地址,以此类推。这种线性地址的分配方式简单直接,但限制了最大进程数为64个。
地址转换采用二级页表机制。页目录表位于物理地址0处,包含1024个页目录项,每项4字节。页目录项中的高20位指向一个页表的物理地址,低12位是标志位(P存在位、R/W读写位、U/S用户/内核位等)。每个页表包含1024个页表项,每项4字节,结构与页目录项类似。通过将32位线性地址的高10位作为页目录索引,中间10位作为页表索引,低12位作为页内偏移,即可完成线性地址到物理地址的转换。
线性地址到物理地址举个例子,线性地址 0x12345678 的地址转换过程如下:其高 10 位(0x048)作为页目录索引,与 CR3 寄存器中的页目录表基址结合定位到具体的页目录项,从中取出页表的物理地址;中间 10 位(0x135)作为页表索引,与页表物理地址结合定位到页表项,从中取出物理页面的起始地址;最后加上低 12 位偏移量(0x678),即可得到最终的物理地址。
在实际的内存布局中,内核的前160个页面(640KB)被映射到物理内存的低端,这部分内存由内核和所有进程共享,不执行写时复制。每个用户进程的代码段和数据段从其64MB空间的起始处开始,通过分页机制映射到主内存区中的物理页面。进程的页表保存在主内存区动态分配的页面中,fork时为子进程新建页表并复制父进程的页表项(设置为只读以实现写时复制)。
物理与线性地址的宏定义
在mm/memory.c中,内核通过一组宏来描述主内存区的范围以及线性地址 → mem_map索引的转换关系:
// mm/memory.c - 内存布局相关宏#define LOW_MEM 0x100000 /* 低端1MB以内留给内核和硬件 */#define PAGING_MEMORY (15*1024*1024)/* 可分页内存大小=15MB */#define PAGING_PAGES (PAGING_MEMORY >> 12) /* 可管理的物理页面数=15MB/4KB=3840 *//* * 将物理地址addr转换为mem_map[]数组的索引 * 只对1MB以上的主内存区有效 */#define MAP_NR(addr) (((addr) - LOW_MEM) >> 12)#define USED 100 /* mem_map中USED=100表示“已占用” */
- • LOW_MEM: 表示主内存区的起始物理地址(1MB),其下方由内核和硬件占用。
- • PAGING_MEMORY / PAGING_PAGES: 描述了最多支持的分页内存大小以及对应页面数。
- • MAP_NR(addr): 用于将一个物理地址映射到
mem_map[]中的下标,方便记录该物理页面的引用计数。
页目录与页表的初始化:paging_init()
内核在启动完成基本初始化后,会调用paging_init()来建立内核和所有任务共享的页目录和页表。下面是经简化并加上详细注释的核心代码:
// mm/memory.c - 建立内核页表unsigned long pg_dir[1024]; /* 页目录表,占4KB,对齐在0地址附近 */void paging_init(unsigned long start_mem, unsigned long end_mem){ /* * start_mem: 可用内存起始地址(通常是1MB) * end_mem : 可用内存结束地址(不超过16MB) * 本函数的任务: * 1. 建立从 0 到 end_mem 的线性地址到物理地址的页表映射 * 2. 将页目录基址写入cr3 * 3. 打开cr0中的分页使能位PG */ unsigned long * pg_table; unsigned long tmp; int i, j; /* * 首先清零整个页目录,避免残留无效项 */ for (i = 0; i < 1024; i++) pg_dir[i] = 0; /* P=0 表示该页目录项无效 */ /* * 从start_mem开始,每4KB分配一个页表,用于映射线性地址空间 * Linux 0.12中,内核建立了恒等映射:线性地址 = 物理地址 */ tmp = 0; /* tmp用于记录当前映射的物理地址 */ for (i = 0; tmp < end_mem; i++) { /* 为每个页目录项分配一个页表页面 */ pg_table = (unsigned long *) start_mem; /* 页表本身放在主内存区 */ start_mem += PAGE_SIZE; /* 下一个页表的存放位置 */ /* * 设置页目录项: * - 高20位指向页表的物理地址 * - 低12位标志: P=1,R/W=1,U/S=0 */ pg_dir[i] = (unsigned long)pg_table | 7; /* 0b111 = P+R/W+U/S内核 */ /* 填充当前页表: 建立4KB*1024=4MB的线性→物理恒等映射 */ for (j = 0; j < 1024 && tmp < end_mem; j++) { *pg_table = tmp | 7; /* 物理地址tmp,同样P=1,R/W=1,U/S=0 */ pg_table++; tmp += PAGE_SIZE; /* 映射下一个4KB页面 */ } } /* * 将页目录基址写入cr3,告诉CPU页目录表的位置 */ asm volatile ("movl %0,%%cr3"::"r"(pg_dir)); /* * 打开cr0的PG位 (bit 31),正式启用分页机制 */ asm volatile ( "movl %%cr0, %%eax\n\t" /* eax = cr0 */ "orl $0x80000000, %%eax\n\t" /* 置PG位 */ "movl %%eax, %%cr0\n\t" /* 写回cr0 */ : : : "eax");}
注意: 上述代码略去了与缓冲区、内核镜像重定位等细节相关的部分,目的是突出“如何从0地址开始建立线性地址到物理地址的恒等映射”。
每个进程的64MB线性地址空间如何实现
Linux 0.12中,每个任务(task)拥有一个独立的LDT,其中代码段和数据段的基址不同,从而实现“每个进程有自己的64MB线性空间”的效果:
// include/linux/sched.h 中的LDT布局/* * 每个进程的LDT中有3个描述符: * 0: 空描述符 * 1: 代码段描述符 (base = 进程线性空间起始) * 2: 数据段描述符 (base = 进程线性空间起始) */struct desc_struct ldt[3];// 设置进程p的代码/数据段基址,使其获得自己的64MB线性空间void set_base(struct desc_struct * desc, unsigned long base);void copy_mem(int nr, struct task_struct * p){ /* * 省略部分代码... * 针对任务号nr,将LDT中的代码段和数据段基址分别设置为: * base = nr * 64MB */ unsigned long code_limit = 0x4000000; /* 64MB = 0x4000000 */ unsigned long base = nr * code_limit; /* 每个任务的线性空间起点 */ set_base(p->ldt + 1, base); /* 代码段描述符 */ set_base(p->ldt + 2, base); /* 数据段描述符 */ /* * 这样, 当进程使用逻辑地址0时, 实际线性地址是 base+0 * - 任务0: base=0, 线性0..64MB * - 任务1: base=64MB,线性64..128MB * - ... */}
结合前面的页表恒等映射,可以这样理解:
- • 物理内存0 ~ end_mem被映射到线性地址0 ~ end_mem;
- • 每个进程通过不同的段基址将“自己的0地址”平移到不同的64MB窗口;
- • 通过分页机制,用户空间的访问最终仍然落到同一份页表和物理内存上。
任务号 (Task ID)(例如: 0, 1, 2, 3 ...) │ ▼┌───────────────────────────────────────────┐│ 64MB 线性窗口 ││ ││ 每个任务拥有独立的 64MB 线性地址空间 ││ 窗口基址 = 任务号 × 64MB ││ ││ 示例: ││ 任务0: 线性窗口 0x00000000 - 0x03FFFFFF (0-64MB) ││ 任务1: 线性窗口 0x04000000 - 0x07FFFFFF (64-128MB) ││ 任务2: 线性窗口 0x08000000 - 0x0BFFFFFF (128-192MB) ││ 任务3: 线性窗口 0x0C000000 - 0x0FFFFFFF (192-256MB) │└───────────────────────────────────────────┘ │ ▼┌───────────────────────────────────────────┐│ 页表/分段映射 ││ ││ 线性地址 → 分页机制 → 物理地址 ││ (或通过段基址直接映射) │└───────────────────────────────────────────┘ │ ▼┌───────────────────────────────────────────┐│ 物理地址 ││ ││ 最终访问的实际内存地址 │└───────────────────────────────────────────┘
5.2 物理内存管理
物理内存管理的核心数据结构是mem_map数组,定义在mm/memory.c中。这是一个unsigned char数组,每个元素对应一个4KB物理页面,元素的值表示该页面的引用计数(被多少个进程或内核结构引用)。mem_map最多可以管理15MB的主内存区(PAGING_PAGES=3840个页面)。
mem_map数组与mem_init()初始化
// mm/memory.c - 物理内存管理核心数据结构unsigned char mem_map[PAGING_PAGES] = {0,};/* * mem_map[i]描述了物理页面(LOW_MEM + i*4096)的使用情况: * =0 : 空闲页面,可供分配 * =1 : 被1个进程/内核结构使用 * =n : 被n个进程共享(典型场景是写时复制COW) * =USED(100): 表示“不可分配”,用于保留内核所占用的页面 */void mem_init(long start_mem, long end_mem){ int i; HIGH_MEMORY = end_mem; /* 记录系统可用物理内存的上限 */ /* * 1. 初始状态下,将所有可分页内存标记为USED,避免误分配 * 这样做的好处是:接下来我们只需要把真正“空闲”的区间清零即可。 */ for (i = 0; i < PAGING_PAGES; i++) mem_map[i] = USED; /* 100 表示“已占用/保留” */ /* * 2. 根据start_mem和end_mem,将主内存区对应的mem_map元素清零,表示可分配 * - LOW_MEM以下(1MB)是内核和硬件保留区,不参与分配 * - (LOW_MEM, start_mem)之间通常用于内核镜像、缓冲区等,也不算进主内存区 */ i = MAP_NR(start_mem); /* 计算start_mem对应的页面号 */ int end = MAP_NR(end_mem); /* 计算end_mem对应的页面号 */ while (i < end) mem_map[i++] = 0; /* 置0表示空闲页面 */}
- • mem_map中值为 USED 的页面永远不会被
get_free_page()分配,通常对应内核占用的页面; - • 值为 0 表示空闲;值大于0表示此页面被若干个进程共享。
mem_map数组 │ ├── 索引0 → 内核页面 = USED ├── 索引1~255 → 内核与缓冲 = USED └── 索引256~3839 → 主内存区 │ ├── 值=0 → 空闲页面 ├── 值=1 → 被1个进程占用 ├── 值=2 → 被2个进程共享 └── 值=3 → 被3个进程共享
get_free_page(): 分配一个物理页面
// mm/memory.c - 从mem_map中分配一个空闲页面unsigned long get_free_page(void){ int i; /* * 从高地址向低地址扫描mem_map,优先使用高端内存, * 可以在一定程度上减少与内核低端内存的冲突。 */ for (i = PAGING_PAGES - 1; i >= 0; i--) { if (mem_map[i] == 0) { /* 找到一个空闲页面 */ mem_map[i] = 1; /* 引用计数=1,表示被占用 */ /* 计算该页面的物理地址: LOW_MEM + i*PAGE_SIZE */ unsigned long addr = LOW_MEM + (i << 12); /* 将新页面内容清零,避免泄漏旧数据 */ memset((void *)addr, 0, PAGE_SIZE); return addr; /* 返回物理地址 */ } } return 0; /* 没有空闲页面,后续代码会尝试swap换出或直接报错 */}
注意: 在完整源代码中,当get_free_page()返回0时,上层有时会调用oom()或尝试swap_out,视具体调用场景而定。这里为了突出核心逻辑,省略了换页部分的细节。
get_free_page 流程: │ 扫描 mem_map 找 0 │ 找到后设为 1 并返回物理地址
free_page(): 释放一个物理页面
// mm/memory.c - 释放一个物理页面void free_page(unsigned long addr){ /* * 只允许释放1MB以上的主内存区页面。 * 低端内存(内核/硬件)是保留区域,不能被free_page释放。 */ if (addr < LOW_MEM) return; /* 直接返回,忽略释放请求 */ if (addr >= HIGH_MEMORY) /* 超出物理内存上限 */ panic("trying to free nonexistent page"); int nr = MAP_NR(addr); /* 物理地址 -> mem_map索引 */ if (mem_map[nr] == 0) /* 引用计数本就为0,说明逻辑错误 */ panic("trying to free free page"); mem_map[nr]--; /* 引用计数减1 */}
- • 引用计数减到0之前:表示仍有其他进程在使用该页面,不能回收;
- • 减到0之后:下一次调用
get_free_page()时,这个页面就会被重新分配。
结合get_free_page()和free_page(),我们就有了一套最基本的物理内存分配/释放接口,其他内存管理函数(如页面异常处理、写时复制、页面交换)都建立在这套接口之上。
free_page 流程: │ mem_map 对应项减 1 │ ┌─── 减到0? ───┐ │ │ 是 否 │ │ 页面变为空闲 仍被其他进程引用
5.3 页面异常处理
页面异常(Page Fault)是虚拟内存机制的核心。当CPU访问一个线性地址时,如果对应的页表项不存在(P位=0)或者违反了保护规则(例如写只读页面),CPU会触发INT 14页面异常,跳转到page_fault异常处理程序(定义在mm/page.s中)。
page_fault首先保存寄存器现场,然后从CR2寄存器读取引发异常的线性地址,从栈中取出错误码。错误码的第0位表示是缺页异常(0)还是页保护异常(1),第1位表示是读访问(0)还是写访问(1),第2位表示是内核态(0)还是用户态(1)。根据第0位,page_fault调用do_no_page()处理缺页,或者调用do_wp_page()处理写保护异常。
do_no_page()处理缺页异常,它的任务是为访问的线性地址分配物理页面并建立映射。如果该地址位于进程的可执行文件映射区域,do_no_page()会从可执行文件中读取对应内容到新分配的页面(这就是需求加载,demand loading);如果是数据段或栈的访问,则分配一个零页面。建立页表项后,该异常被修复,进程可以继续执行,就像什么都没发生过。
do_wp_page()处理写保护异常,它是实现写时复制(Copy-On-Write,COW)的关键。当进程试图写一个只读页面时,如果该页面的引用计数为1(只有当前进程使用),do_wp_page()将页表项改为可写,进程可以直接写入;如果引用计数大于1(多个进程共享,例如fork后的父子进程),do_wp_page()会分配一个新的物理页面,将原页面内容复制到新页面,然后将当前进程的页表项指向新页面并设为可写,同时将原页面的引用计数减1。这样就实现了写时复制:多个进程共享只读页面,只有在写入时才真正复制页面。
5.4 写时复制机制
写时复制是Linux内存管理的一项重要优化技术,它让fork()系统调用几乎无需复制内存就能创建子进程。copy_page_tables()函数实现了这个机制,它在fork时被copy_mem()调用,负责为子进程复制页表。
copy_page_tables()并不复制物理页面的内容,而是让子进程的页表项指向和父进程相同的物理页面,同时将这些页表项的R/W位清零,设为只读。这样父子进程共享相同的物理页面,但都不能写入。当父进程或子进程任一方试图写入共享页面时,CPU会触发页保护异常,do_wp_page()检测到引用计数大于1,就分配新页面并复制内容,然后修改触发异常进程的页表项指向新页面并设为可写。
这种机制的优势是明显的。fork后如果子进程立即调用exec加载新程序,那么从父进程继承的页面根本不会被写入,直接被释放,如果事先复制了所有页面就完全是浪费。即使父子进程都会修改内存,写时复制也能将复制操作延迟到真正需要时才执行,并且只复制被修改的页面。这就像图书馆的复印政策:不要求每个读者都复印整本文,只有当读者需要在书上做笔记时才复印那一页。
copy_page_tables 复制页表 │ ├── 子进程页表项指向父进程的物理页面 │ ├── 父子进程页表项都设为只读 (R/W=0) │ └── 物理页面引用计数 +1 │ ▼ 父子进程正常读取页面 │ ▼ ┌─── 进程写入页面? ────────┐ │ │ 否 是 │ │ │ ▼ │ 触发页保护异常 │ │ │ ▼ │ do_wp_page 检查引用计数 >1 │ │ │ ▼ │ 分配新物理页面 │ │ │ ▼ │ 复制原页面内容到新页面 │ │ │ ▼ │ 修改写进程页表指向新页面 │ │ │ ▼ │ 设置新页表项为可写 (R/W=1) │ │ │ ▼ │ 原页面引用计数 -1 │ │ │ ▼ │ 进程继续执行,写入成功 │ └────── 回到正常读取 ◀─────┘
5.5 页面交换机制
Linux 0.12引入了基本的页面交换(Swap)功能,允许将不常用的内存页面交换到磁盘上的交换设备,从而支持超过物理内存大小的虚拟内存。swap.c文件实现了交换页面管理,swap_bitmap数组记录交换设备中哪些页槽被占用。
当物理内存不足时,get_free_page()会调用try_to_swap_out()尝试换出页面。try_to_swap_out()扫描所有进程的页表,寻找合适的页面换出候选者(通常选择最近最少使用的页面)。找到后,它将该页面内容写入交换设备的一个空闲页槽,然后修改页表项:将P位清零表示页面不在内存,将页槽号保存在页表项的高位,释放物理页面。
当进程访问已被换出的页面时,会触发缺页异常。do_no_page()检测到页表项P位为0但高位不为0(保存了页槽号),就知道这是一个已换出的页面。它分配新的物理页面,从交换设备的对应页槽读取内容,建立页表映射,释放交换设备页槽。这样页面就从磁盘换回到内存,进程可以继续访问。
页面交换让系统能够运行内存需求超过物理内存的程序,但代价是性能下降。磁盘访问比内存访问慢几个数量级,频繁的换入换出会导致系统卡顿(这称为thrashing,颠簸)。因此页面交换是一种以时间换空间的权衡策略。
物理内存 (mem_map) 交换设备 (swap_bitmap)┌─────────────┐ ┌────────────────┐│ 空闲页面(0) │ ◄────► │ bit=1 → 页槽空闲 │├─────────────┤ │ bit=0 → 页槽已占用 ││ 已占用页面(>0) │ └────────────────┘└─────────────┘
5.6 共享页面与内核空间
Linux 0.12实现了简单的共享页面机制。内核代码和数据占据物理内存的低640KB,这部分内存被映射到所有进程的线性地址空间低端(通过所有进程共享的内核页表实现)。由于这部分页表项的R/W位为0(只读),用户进程无法修改内核内存,保证了系统安全性。
此外,多个进程执行同一个可执行文件时(例如多个用户同时运行/bin/sh),它们的代码段可以共享物理页面。当do_no_page()从可执行文件加载页面时,它会检查该页面是否已被其他进程加载,如果是就增加引用计数并共享,而不是重新加载。这样可以节省内存,提高系统性能。
内核自身也使用虚拟内存,但内核的页表是固定的,在head.s中初始化时就建立好了。内核使用的物理页面(如task_struct、缓冲区等)通过get_free_page()分配,但不会被换出到交换设备,因为内核代码不能被缺页异常中断(会导致递归异常)。
5.7 实际示例:一个完整的fork-exec过程中的内存变化
让我们通过一个实际场景理解内存管理的完整流程。进程A(PID=5)执行fork()创建子进程B(PID=6)。copy_process()为进程B分配task_struct页面,然后调用copy_mem()复制内存空间。
copy_mem()首先为进程B分配页表页面(每个页目录项对应的页表需要一个页面),然后调用copy_page_tables(0, 64MB*5, 进程A的内存大小)。假设进程A使用了10个页面,copy_page_tables()为进程B建立页表项,让这10个页表项都指向进程A的物理页面,并将进程A和进程B的页表项R/W位都设为0(只读)。对应的10个物理页面的mem_map引用计数从1增加到2。此时进程A和进程B共享这10个页面。
fork返回后,进程B执行execve("/bin/ls")加载新程序。do_execve()首先调用free_page_tables()释放进程B从进程A继承的页表和页面。free_page_tables()将10个共享页面的引用计数减1(从2变为1),页面回归进程A独占但不释放物理页面。然后do_execve()打开/bin/ls可执行文件,为进程B分配新的页表,但不分配物理页面(采用需求加载)。
进程B开始执行/bin/ls的第一条指令时,由于页面不存在,触发缺页异常。do_no_page()被调用,它调用get_free_page()分配一个空闲页面(假设是第100号页面),然后从/bin/ls文件中读取对应的代码段内容到该页面,建立页表映射。进程B继续执行,每次访问新的代码或数据页面都会触发缺页异常,逐步将/bin/ls加载到内存。
进程B在执行过程中,假设修改了栈上的一个变量,该变量所在的页面是进程B独占的,可以直接写入。如果进程A也修改了之前共享的页面(现在已不再共享,因为进程B执行了exec),由于引用计数已经是1,do_wp_page()直接将页表项改为可写,无需复制页面。
通过本章的学习,我们深入理解了Linux 0.12的内存管理系统:从物理内存管理到虚拟内存映射,从页面分配回收到页面异常处理,从写时复制机制到页面交换,以及共享页面的实现。这套系统虽然相对简单,但已经包含了现代操作系统内存管理的核心思想,为进程隔离、资源共享和虚拟内存提供了坚实的基础。
5.8 参考资料
本章内容基于以下资料编写:
Intel官方文档
- • Intel 80386 Programmer's Reference Manual (1986)
- • Chapter 5: Memory Management - 详细描述了分段和分页机制
- • Chapter 9.6: Page-Fault Exception - 介绍了页面异常的错误码和处理
- • 在线阅读:https://css.csail.mit.edu/6.858/2014/readings/i386.pdf
Linux 0.12源代码文件
- • mm/memory.c - mem_init、get_free_page、free_page、copy_page_tables、do_no_page、do_wp_page
- • mm/page.s - page_fault页面异常汇编入口
- • mm/swap.c - try_to_swap_out、swap_in页面交换功能
- • include/linux/mm.h - 内存管理相关宏定义和常量
"不积跬步,无以至千里;不积小流,无以成江海。" —— 荀子