☰
Linux 0.11 内存空间深度解析:从分页机制到进程隔离
2026/9/28 23:39:37 网站建设 项目流程

聊Linux 0.11的内存空间,可能是学习操作系统最划算的一笔投入。这个版本代码量不到两万行,却把80386的分段、分页、进程隔离、物理页分配这些概念全部串在了一起。很多人一上来啃《深入理解Linux内核》这类大部头,结果被各种抽象机制直接淹没;我自己的经验反而是从0.11入手,把"内存空间"这四个字拆开,一条线走到黑,才真正建立起对内存管理的直觉。这篇文章打算把Linux 0.11的内存空间完整梳理一遍,从物理内存布局、线性地址分配、页目录页表组织,到fork时的页表复制、写时保护触发,一路讲到常见误区和调试手段。无论你是做嵌入式、搞驱动、准备Linux相关面试,还是单纯想搞懂内核,这条梳理路径都值得跟着走一遍。

1. 为什么是Linux 0.11,为什么要梳理内存空间

1.1 0.11在Linux历史中的坐标

Linux 0.11是1991年的版本,距今已经三十多年。它比0.01成熟得多,能跑多进程、有文件系统、支持终端,还能自己编译内核;但比后来的0.12、0.95又简单太多,没有复杂的swap、没有完整的虚拟内存系统、没有细粒度的内存对象缓存。它正好卡在"分段分页全都有、但还没被各种优化细节淹没"的位置上。

这个版本最难得的地方在于,你可以在一个周末把关键代码从头到尾读完。现代内核像一座城市,你只能看到局部;0.11像一座小镇,你站在制高点能看清每条街道怎么走。我读它的时候最大的感受是:内存管理不是一堆抽象名词,而是几段随时可以追踪到具体实现的代码。0.11就是这样一个天然的解剖样本。

1.2 内存模型的基本盘:分段与分页并存,眼花但不乱

0.11运行在80386保护模式下,CPU的段机制和页机制是同时开启的。这让不少人头大,因为平时习惯了"虚拟地址→物理地址"的线性思维,突然告诉你中间还有一层"线性地址",确实容易懵。

0.11好在把两种机制用得很直白:

  • 段机制负责进程隔离。每个进程通过LDT(局部描述符表)里的代码段、数据段描述符,把自己的线性地址空间定位到"进程号乘以64MB"的位置。
  • 页机制负责物理内存管理。线性地址经过页目录、页表两级转换,映射到真正的物理页框。

一个用户程序访问一个内存地址,先做段转换,得到线性地址,再做页转换,得到物理地址。这就是"逻辑地址→线性地址→物理地址"的完整链路。这条链路一旦走通,后面看任何内存相关代码都是在这个框架里补细节。

1.3 梳理内存空间这条路,谁需要走一遍

如果你在做嵌入式Linux或者驱动开发,内存空间的划分、页表的组织、进程隔离的实现,都是绕不开的基本功。如果你在准备面试,0.11里的写时复制、缺页处理、进程地址空间切换,正好是那些高频题目的原型。如果你只是好奇操作系统到底怎么管理内存,0.11是最合适的起点。

下面我会从物理布局讲到线性地址,从数据结构讲到关键代码路径,最后给出我在Bochs里调试的真实教训。这篇文章不会让你瞬间成为内核专家,但能让你以后再看到"内存踩踏""0地址访问""写时复制"这些词时,脑子里浮现出一个清晰的图景,而不是一团模糊的概念。

2. 从物理内存到虚拟内存的全局观

2.1 物理内存布局:1MB以下的禁区、1MB以上的地盘

先看物理内存。0.11启动时,setup.s通过BIOS中断拿到机器装配的内存大小,传给main.c。main.c里对物理内存做分区,划分逻辑大致如下:

地址范围用途
0x000000 - 0x0FFFFF低端1MB:实模式中断向量、BIOS数据、显存等,内核不碰
1MB - buffer_memory_end缓冲区,给硬盘等块设备做cache
buffer_memory_end - memory_end主内存区,交给mem_map管理,进程要页从这里领

megabytes这个"buffer_memory_end"不是写死的。main.c里的判断是:内存大于12MB时缓冲区上限设为4MB;大于6MB时设为2MB;否则设为1MB。说白了就是在内存紧张的时候,宁可少给缓冲区一点空间,也要保证主内存区够进程用。这个取舍放在今天看依然合理,甚至可以说是早期Linux里少有的"不为设计而设计"的实用主义。

低端1MB在0.11里其实还有一个细节:内核代码和核心数据结构(页目录、GDT、IDT、每个进程的TSS/LDT描述符)都挤在这1MB以内。所以内核开发时你顺手改一个数组定义,可能就让整个内核镜像膨胀到超出了低端内存的容纳范围,启动直接失败。这也是我最早踩过的坑之一。

2.2 内核空间与用户空间的边界:不是3G/1G模型

现代Linux把4GB虚拟地址空间划成用户态3GB、内核态1GB,所有进程共享内核部分的高地址映射。这个模型在0.11里完全不存在。0.11的线性地址空间里,每个进程各占一块64MB的区域,内核代码在物理内存低端,通过内核段的base=0来访问。

换句话说,0.11的"用户空间"不是一个固定的区间,而是由进程的LDT段描述符动态指定的。进程0在0-64MB,进程1在64-128MB,进程2在128-192MB,以此类推。这种隔离方式非常原始,但非常直观:每个进程的线性地址区间天然错开,互不重叠。

这个差异如果不搞清楚,后面看任何源码都会迷惑。比如你打开0.11的memory.c,看到一堆关于64MB、进程号的计算,第一反应可能是"这写的是什么",但只要你脑子里有这个"每进程一块线性区"的模型,一切都顺理成章。

2.3 每进程64MB线性空间:当时的进程隔离方案

为什么偏偏是64MB?因为在0.11的设计里,进程号nr作为索引,线性地址基址base = nr * 0x4000000(0x4000000就是64MB)。0x4000000这个数很讲究,它对齐到了页目录项的边界上:一个页目录项映射4MB,64MB正好是16个页目录项。这样任意两个进程的线性地址区间都能整齐地按页目录项切分,复制页表时非常方便。

当时单进程的地址空间上限就是64MB,在现代看来小得可怜,但放到1991年完全够用——那时主流机器的内存普遍只有4MB、8MB、16MB。0.11的设计哲学就是"看起来够用就行",没有现代内核那么多防御性设计。理解这种"时代局限性"反而能帮你更好地理解内核演进的逻辑:很多东西不是天生如此,是慢慢被现实逼出来的。

同时要注意,0.11最多支持64个进程(NR_TASKS=64),这个限制也和线性地址空间划分直接相关。GDT里为每个进程预留TSS和LDT两个槽位,64个进程就需要128个槽,加上系统占用的几个,也就把GDT的256项空间用掉了一半以上。

2.4 一次地址访问要闯过哪几道关

举个具体的例子。假设进程5的代码里访问一个变量,编译后产生一个逻辑地址0x1234。CPU执行访问指令时,实际的流程是:

  1. 根据当前LDT(进程5的LDT)取数据段描述符,得到段基址:5 * 64MB = 320MB。
  2. 逻辑地址0x1234加上段基址,得到线性地址0x140001234。
  3. 线性地址按位拆分:页目录索引为线性地址的高10位(320MB / 4MB = 第80项),页表索引为中间10位,页内偏移为低12位。
  4. 查页目录第80项拿到页表地址,再查页表对应项拿到物理页框号,加上页内偏移,最终得到物理地址。

整个过程由CPU自动完成,但每一道关卡都在访问内存管理数据结构。哪一环断了,CPU就会触发缺页异常,把控制权交给内核的缺页处理函数。这也是为什么page fault处理在整个内核中都是重头戏——它承担着"补齐页表"的职责。

3. 内存管理的核心数据结构

3.1 task_struct里的内存字段

0.11的task_struct里没有独立的mm_struct,而是把内存相关字段直接摊在进程控制块里。看include/linux/sched.h,你会发现这样一段:

struct task_struct { long state; long counter; long priority; long signal; struct sigaction sigaction[32]; long blocked; int exit_code; unsigned long start_code, end_code, end_data, brk, start_stack; long pid, father, pgrp, session, leader; ... struct desc_struct ldt[2]; struct tss_struct tss; };

start_code、end_code、end_data、brk、start_stack是exec后由do_execve填充的,分别表示代码段起点、代码段终点、数据段终点、堆终点、栈起点。这四个字段决定了进程的代码、数据、堆、栈在逻辑地址空间里的边界。

ldt[2]放的是进程自己的两个段描述符。tss里保存了esp0、cr3等寄存器现场。注意0.11的TSS是直接嵌在task_struct里的,而不是现代内核那样单独分配。这种"全部摊在一个结构体里"的做法虽然不够优雅,但胜在简单:切换进程时,整个进程控制块都是连续的物理内存,访问起来毫无压力。

3.2 页目录与页表的组织

0.11的全局页目录是swapper_pg_dir,定义在head.s里。关键点是:所有进程复用同一个页目录,不同进程只是在这个页目录的不同索引段上建立映射。每个进程占用16个页目录项(64MB / 4MB),这些目录项指向进程自己的页表。

页表是运行时动态分配的物理页。fork时copy_mem会复制父进程的页表到子进程的线性地址区间;缺页时do_no_page会按需建立新的页表项。每个页表正好占一页物理内存,对应1024项,映射4MB的线性地址空间。

页表项的结构和现代x86基本一致,几个关键位的含义要记牢:

位含义
bit0存在位P,为0表示页不在内存
bit1读写位R/W,为0表示只读
bit2用户/管理位U/S,为0表示仅内核态可访问
bit3页级写穿PWT
bit4页级缓存PCD
bit5访问位A
bit6脏位D
bit12-31物理页框地址

0.11的写时复制正好利用了bit1:fork后父子进程的页表项都被清掉R/W位,一旦有人写,就触发page fault进入do_wp_page,复制页面后再把对应页表项置为可写。

3.3 mem_map:物理页面的记账本

主内存区的物理页用mem_map数组管理。这个数组的内存管理方式原始到不能再原始:没有buddy系统、没有slab缓存、没有LRU链表,只有一个0/1标记数组。

mem_map的下标通过MAP_NR(addr)计算,对应物理地址从LOW_MEM(0x640000,即640KB)开始的每一页。元素为0表示空闲,为1表示已分配。get_free_page从数组末尾向前找第一个0,把它置1,算出物理地址,清零页面后返回。

这个设计也不记录页的所有者、不记录引用计数。一页内存要么没有主人,要么只有一个主人。这带来一个明显隐患:如果某页被错误释放两次,第一次置0,第二次又置0,这页就可能被两个进程同时拿到,造成内存数据重叠。0.11里没出大事故,纯粹是因为运行场景小、并发低。到了0.12之后,内核才逐渐引入更完善的页表项管理,这就是后话了。

3.4 GDT/IDT/LDT:段机制的三件套

0.11的GDT在head.s里定义,sched_init中动态填充。布局大致如下:

GDT索引内容
0空描述符
1内核代码段(base=0)
2内核数据段(base=0)
3系统调用段(base=0)
4任务0的TSS
5任务0的LDT
6任务1的TSS
7任务1的LDT
...后续任务的TSS/LDT

每个进程在GDT里占两个槽:一个TSS,一个LDT。进程切换通过ljmp到目标进程的TSS选择子,CPU自动加载新的TSS和LDT,从而切换到目标进程的线性地址空间。选择子的规律也比较规整:TSS选择子 = 0x20 + nr16,LDT选择子 = 0x28 + nr16。

LDT里放两个描述符:代码段和数据段。它们的base都被设为nr*64MB,limit设为64MB。这意味着用户态代码编译出来的逻辑地址天然在0-64MB范围内,经过段基址平移后落到自己的线性区间。这套"段基址平移"的方案在今天看来笨重,但在没有硬件ASID(地址空间标识)的年代,它是一种干净利落的进程隔离手段。

4. 关键路径源码拆解

4.1 copy_mem与fork时内存复制

fork时最核心的内存操作在copy_mem。流程可以概括为四步:

  1. 根据子进程的进程号nr计算线性地址基址new_data_base = nr * 0x4000000。
  2. 记录父进程当前的基址old_data_base。
  3. 设置子进程LDT的代码段、数据段描述符,把段基址改为new_data_base。
  4. 调用copy_page_tables,把父进程线性地址区域的页目录项和页表项整体复制到new_data_base处。

copy_page_tables内部会为每个被复制的页表重新分配一页物理内存,把源页表内容整体拷贝过去,确保子进程有自己的页表副本。这里有一个关键操作:它会把源页表项和目的页表项的R/W位都清掉,让所有映射变成只读。这就是写时复制(COW)的触发条件。

复制结束后还有一段TLB刷新逻辑。0.11的做法比较粗暴:重新加载CR3,让整个TLB全部失效。这当然会带来一点性能开销,但对当时的进程数量来说完全可接受。你需要记住的是:修改页表后必须让CPU丢掉旧的缓存,否则新映射可能不生效,这个问题的排查往往非常隐蔽。

4.2 get_free_page与物理页分配

get_free_page是0.11物理内存分配的核心函数,逻辑非常简单:

unsigned long get_free_page(void) { for (i = mem_map + PAGES - 1; i >= mem_map; i--) if (!*i) { *i = 1; page = LOW_MEM + (i - mem_map) * PAGE_SIZE; clear_page(page); return page; } return 0; }

注意它从高地址向低地址找空闲页。这个方向和很多现代内核相反,但目的很明确:让靠近缓冲区、低端内核区的页面尽量少被分配,从而降低碎片化风险。

clear_page(page)把物理页清零。这个清零不是可有可无的,如果不做,新进程很可能读到上一个使用者的残留数据,造成严重的信息泄漏。0.11虽然古老,但这一点做得很到位。分配失败返回0,调用方需要自行处理错误。比如copy_page_tables在分配失败时会释放已建立的页表并返回负值,导致fork失败。错误路径虽然简单,但逻辑闭环是完整的。

4.3 free_page与页释放

free_page的对立面实现同样简单:

void free_page(unsigned long addr) { if (addr < LOW_MEM) return; if (addr >= high_memory) return; mem_map[MAP_NR(addr)]--; }

这个实现里有几个值得注意的点。

第一,它对低端内存和超出high_memory的地址做了保护,避免误释放内核区或不存在的内存。

第二,它用mem_map--而不是直接置0。分配时明明只置1,为什么释放要递减?看起来像是为"引用计数"预留的口子,但实际上0.11并没有完整的引用计数机制。如果不小心对同一个地址调用两次free_page,mem_map会变成负数,但这个负数不会被检查,页面随后还可能被再次分配出去。这个"计数衰减"设计埋了个雷。

第三,free_page不会清理对应的页表项。这意味着释放物理页后,页表项仍然标记该页存在且可读写,后续访问会读到别的进程的数据。0.11在exit和exec路径里专门提供了free_page_tables来拆除整个进程的页表,那才是真正干净的释放操作。直接调free_page释放物理页,再把进程转出去,很容易产生内存错乱。

4.4 缺页异常与写保护处理

缺页入口在page.s里的汇编封装,根据错误码跳到do_no_page(页不存在)或do_wp_page(写保护页)。

do_wp_page处理的是写保护页。0.11里的处理逻辑简单直接:只要写保护异常发生在用户空间,就分配一个新页,把旧页内容拷贝过来,再把对应的页表项改为可写。它不区分"本来就是只读的代码页"和"本来可写但因COW变成只读的数据页",一律复制。代码段正常不会有人写,所以影响不大,但这确实属于吃性能的糙办法。

do_no_page处理的是页不存在的情况。它先判断地址是否落在进程的文件映射区域内,如果是,就从文件读取对应内容填充到新分配的页面。0.11没有swap机制,所以对于超出文件范围的地址,直接分配一页清零返回。这就是"懒加载"的雏形:exec之后,进程的代码段和数据段并不是一次性全部读入内存,而是等访问时缺页,再从文件系统按需读取。这个思想一直延续到今天,只是现代内核的缺页路径要复杂得多。

4.5 缓冲区与外设IO的关系

缓冲区也是0.11内存空间的重要组成。buffer_init在1MB到buffer_memory_end之间建立空闲链表,每个buffer head对应一块数据区域。硬盘读写时,块设备驱动通过缓冲区把数据搬进内存。

0.11的缓冲区是静态划定的,和现代内核里动态伸缩的page cache完全不同。好处是实现简单,所有缓冲区操作都不涉及内存压力;坏处是内存利用率低,缓冲区空闲时不能被进程借用,紧张时也不能自动缩水。理解这个背景后,再看后来内核引入动态页缓存的设计思路,就会明白这是被实际使用场景逼出来的优化。

5. 常见问题、误区与调试方法

5.1 fork后父子进程为何能隔离

常见误区:以为0.11的fork是"直接复制页表,所以父子进程共享物理页"。实际上页表虽然复制了,但物理页引用是共享的,且被标记为只读。子进程或者父进程一旦写内存,就触发do_wp_page,复制物理页并把这个页对应的页表项改成可写。从这个意义上说,0.11已经实现了写时复制,只是它的COW不区分"文件映射页"和"匿名页",一律复制。

这也带出一个经典面试题:为什么0.11 fork之后父子进程的代码段可以共享?答案就是代码段通常只读,写保护异常几乎不会触发,所以两进程实际共享同一份物理代码页。但一旦有人往代码段写,页面数据就会被错误复制一份——虽然成本高,但安全上反而没问题。

5.2 内存踩踏的经典案例

我在Bochs里试过一个场景:写一个用户程序,通过系统调用访问一个内核地址,然后往里写数据。0.11没有现代内核那种强隔离,但页表项的U/S位是有区分作用的,用户态访问内核页会触发保护异常。0.11对这种异常的处理比较直接:die。所以它也不是完全没有保护,只是保护边界不如现代版本细腻。

我实际遇到过的踩踏案例是缓冲区管理代码越界写了一个字节,把主内存区某个mem_map标记写坏,导致某页被分配了两次。表面现象是进程A的数据突然变成进程B的数据,非常难查。最后是在get_free_page里临时加打印,输出每次分配的物理页地址和调用栈,才定位到是哪个模块越界。这种"页面被重复分配"的问题在0.11里排查起来尤其麻烦,因为它没有引用计数来检测。

5.3 用模拟器观察内存现场

我强烈推荐Bochs,它自带调试器,可以单步执行、读写物理内存、设置内存断点。调试0.11时我的常用操作:

bochs -q # 在Bochs命令行: # info memory 查看内存布局 # x /64bx 0x100000 查看物理内存内容 # u /10 反汇编当前EIP处指令

QEMU配合GDB也可以,gdb里target remote :1234,然后用monitor info mem看客户机的页表情况。但我个人的体感是QEMU对古老Linux镜像的兼容性不如Bochs稳定,遇到一些奇怪的指令,Bochs能跑而QEMU不动的时候,以Bochs为准。

另一个实用技巧是在page.s的入口处打印CR2寄存器的值。CR2保存了触发缺页的线性地址,配合错误码能快速判断是页不存在还是写保护。我靠这一招省下的时间,够我再完整读一遍0.11的内存管理代码。

5.4 面试高频问题速查

把0.11相关的常见问题整理成一张速查表,方便复习:

问题0.11的答案
进程地址空间如何隔离段基址隔离,每个进程占64MB线性地址区
页表何时建立fork时复制父进程页表,缺页时动态建立
写时复制如何触发fork后页表项清R/W位,写访问触发do_wp_page
物理页如何分配mem_map找空闲页,从高地址向前遍历
内核与用户空间边界段描述符区分,用户走LDT,内核走GDT
缺页后如何懒加载do_no_page按文件偏移读取内容填页
进程切换如何换地址空间ljmp加载TSS和LDT,切换段基址

这些问题放到今天依然是Linux内存面试题的核心骨架,只是现代内核的答案要复杂得多,0.11反而是最容易讲清楚原型的版本。

6. 我的实操体会与后续扩展方向

我第一次完整读完0.11的进程切换和内存管理代码,是在一个周末。说实话,读的时候总觉得"内存"还是一个模模糊糊的平面。后来我在Bochs里把fork前后的CR3、LDT、页表项全部打印出来,对着物理地址一个个核对,才真正把"逻辑地址→线性地址→物理地址"这三个概念焊死在一起。

建议你也这样干一次:不要停留在读代码,想尽办法在模拟器里看到内存的实际变化。比如fork之后,打开子进程的线性地址区间,对比页表项和父进程的差异;比如故意触发do_wp_page,观察物理页什么时候被复制、引用地址什么时候变化。这种"亲眼所见"的经验,比任何原理讲解都更深刻。

再往后学的话,可以顺着三个方向延伸:一是从0.11跳到0.95之后的版本,看3G/1G模型、完整的页缓存、swap机制是怎么演进出来的;二是对照现代x86的PML4四级页表,看64位下地址转换如何扩展;三是把0.11的buffer机制和后来的radix tree、page cache对比,理解内存管理从"手写链表"到"通用框架"的演化动机。

对了,最后分享一个小技巧:如果你在调试时发现进程内存数据莫名其妙被改,先别急着怀疑业务代码。在0.11里,优先怀疑mem_map的越界写和缓冲区越界,这两个地方一个会导致页面重复分配,一个会导致内核和用户数据互相污染。把这两个点查完,大部分诡异问题都会水落石出。这个排查思路,到现在做嵌入式开发依然适用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询