【操作系统精讲】分页与分段:一维与二维地址、内外两种碎片、段页式

写在前面

分页和分段是操作系统里最容易被"背过"也最容易被"背错"的一对概念。

背得出"一个定长、一个变长"的人很多,但往下问一层就散了:为什么分页的地址叫一维、分段叫二维?分页到底有没有外部碎片?段页式里那张段表存的还是"基址 + 长"吗?内部碎片和外部碎片的区别,很多笔记写成"一个在块内、一个在块外",这句话对,但据此判断不了具体场景。

这篇只解决这几件事,不往请求调页、置换算法、TLB 替换策略上扩散。线索是一条:

共同点在哪 → 差异从哪一句长出来 → 一维和二维的本质是什么 → 两种碎片怎么区分 → 段页式为什么是多数教材的终点


一、共同点:都是"离散分配 + 地址映射"

先把连续分配的痛点摆出来,才能看出这俩为什么要存在。

连续分配要求一个进程占一整块连续物理内存。这带来两个后果:

  1. 进程必须整体装入,内存不够就得等;
  2. 反复申请释放之后,内存被切成一堆大小不一的空洞,单个都装不下新进程,但总量够 —— 这就是外部碎片。

分页和分段给出的解法是同一个:把进程切块,块与块不必相邻,散在物理内存各处的块用一张表记下来。于是:

项目分页分段
切分对象进程的逻辑地址空间进程的逻辑地址空间
物理布局离散离散
映射手段页表段表
转换动作逻辑地址 → 物理地址逻辑地址 → 物理地址
归属虚拟内存的实现基础虚拟内存的实现基础

三点值得单独指出:

  • 转换是硬件做的,不是 OS 做的。MMU 每次访存都查表,OS 只负责建表、填表、换表。说"操作系统做地址转换"是不准确的。
  • 表项里存的是块号,不是地址。物理地址 = 块号 × 块大小 + 块内偏移,这个乘法由硬件完成,省下一位就省下一半表项位宽。
  • 两者都让"逻辑地址空间"和"物理内存"解耦。解耦之后才有按需调页、写时复制、进程隔离这一整套东西。这是它们能算虚拟内存方案的原因。

差异则全部来自一句话:

分页按物理等长切,分段按程序逻辑变长切。

下面每一条区别,都是这句话的推论。


二、核心区别总表

维度分页分段
划分依据物理单位,等长切块逻辑单位,按模块变长
谁决定大小OS / 硬件,固定(常见 4 KB)编译器、程序员,随模块变化
面向对象面向系统面向用户
地址结构一维:页号 + 页内偏移二维:段号 + 段内偏移
对程序员透明,看不见可见,需要指定段
地址空间形态一个连续的线性编号空间多个独立编号的空间
碎片类型内部碎片外部碎片
共享粒度页,与逻辑边界不对齐段,天然对应模块
保护粒度页级权限,一页内不能有两种权限段级权限,与模块语义对齐
动态增长可支持,逻辑连续、物理离散天然支持,改段表长度即可
动态链接需要处理重定位以段为单位,改段表基址即可
表项内容物理块号 + 权限位基址 + 段长 + 权限位
越界检查页号超出页表范围即无效段内偏移与段长比较,超出即陷入
表规模与地址空间大小成正比,需要多级与段数成正比,通常很小

表里"面向系统 / 面向用户"是教材原话,不是修辞。它指的是这块内存切分的依据来自谁:页的边界由硬件页框决定,与程序写了什么无关;段的边界由程序结构决定,编译器知道哪几段是代码、哪几段是数据。


三、一维与二维:地址到底是谁切的

这是最容易背串的一处。先说结论:

叫一维还是二维,不看地址里能拆出几个字段,而看这些字段来自同一个编号空间,还是来自两个独立编号空间。

分页:一维

分页的逻辑地址就是一个整数。页号和页内偏移是硬件按位切出来的,切法固定:页大小 2^k,低 k 位是偏移,高位全是页号。

32 位机器、4 KB 页(2^12):低 12 位偏移,高 20 位页号,逻辑地址空间共 2^20 = 1048576 页。

举例,逻辑地址 0x00003A58:

0x00003A58
  ├─ 页号   = 0x3         (高 20 位)
  └─ 页内偏移 = 0xA58      (低 12 位)

查页表第 3 项,得到物理块号 0x2F,则

物理地址 = 0x2F × 0x1000 + 0xA58 = 0x2FA58

程序员全程只看到一个数字:0x3A58。写 int *p = malloc(...) 拿到的指针就是一个连续递增的地址,代码里没有任何地方需要说"这段在几号页"。所以叫一维:只有一个编号空间,编号连续,从 0 排到 2^32-1。

分段:二维

分段的逻辑地址必须写成二元组 (段号, 段内偏移),两个分量都得给。

因为程序本身就是分块的:代码一段、全局数据一段、栈一段、堆一段。这些段各自从 0 开始编号内部的偏移,段与段之间在逻辑上并不连续 —— 代码段的末尾接不上数据段的开头。

举例,(段号 = 1, 段内偏移 = 0x2A00):

段表第 1 项:基址 = 0x8000,段长 = 0x4000
段内偏移 0x2A00 < 段长 0x4000  →  合法
物理地址 = 0x8000 + 0x2A00 = 0xAA00

是

否

逻辑地址:段号 1 与段内偏移 0x2A00

查段表第 1 项

基址 0x8000 段长 0x4000

偏移 0x2A00 是否小于段长 0x4000

物理地址 0x8000 加 0x2A00 等于 0xAA00

越界中断

注意这里多了一步越界检查:偏移必须小于段长。分页没有这一步,因为页号一旦超出页表范围,页表项本身就是无效的,硬件直接陷入。这是"段长可变"带来的额外代价 —— 得有个长度字段兜住。

所以叫二维:两个独立编号空间,段号空间和段内偏移空间各排各的,段号 1 的偏移 0x2A00 和段号 2 的偏移 0x2A00 是两个完全不同的位置。

判据

一句话记住:

页号怎么来的空间形态
分页硬件从一个整数里算出来一维连续
分段程序 / 编译器直接给出来二维分块
段页式里地址写成 `段号段内页号页内偏移` 三个字段,但仍然算二维 —— 因为段号依旧是程序给的逻辑编号,页号是从段内偏移里切出来的。字段数变了,性质没变。

四、内部碎片与外部碎片

先把两个定义钉死,这一节的判断全靠它们。

内部碎片:这块已经分给进程了,进程用不完,剩下的空间别人拿不到。分配了,但用不满。

外部碎片:这块是空闲的、没人占用,但是太小,没有进程愿意要它。空闲,但太小没法分配。

两个定义里各有一个关键词:内部碎片是"已分配",外部碎片是"空闲"。抓住这两个词,判断就不会反。

位于谁占着别人能不能用成因
内部碎片已分配块的内部被分配者不能分配粒度大于实际需求
外部碎片已分配块之间没人不能,太小变长分配 + 反复申请释放

分页为什么只有内部碎片

物理内存被划成等大的页框,进程也按页框大小切。任何一个空闲页框都能装任何一个页 —— 不存在"太小装不进"这回事,所以分页没有外部碎片。

代价落在最后一页。举例:页 4 KB,进程实际需要 10 KB。

需要 3 页 = 12 KB
最后一页只用了 2 KB,剩 2 KB 空着
这 2 KB 在最后一页框内部,进程自己用不到,别人也拿不走

平均下来,每个进程浪费约半页。页越大,浪费越多;页越小,浪费越少,但页表越大、TLB 命中率越低、磁盘 IO 效率越差。4 KB 是这几项折中出来的结果,不是随便定的。

分段为什么只有外部碎片

段是变长的,每个段按实际长度分配一整块。段内不切块,所以纯分段没有内部碎片 —— 申请 60 KB 就得到 60 KB,不多不少。

问题出在回收。连续做几次不同大小的分配与释放:

物理内存(按地址从低到高)

太小

太小

段A
已用 60KB

空洞
8KB

段B
已用 100KB

空洞
12KB

段C
已用 40KB

新段需要 30KB

两个空洞加起来 20 KB,单个最大 12 KB,新段要 30 KB,装不进去。这 8 KB 和 12 KB 就是外部碎片:它们确实空着,但没人要得动。

分段自己治不了这个病。可选的路只有三条:内存紧凑(挪动已分配的段把空洞拼起来,代价是大量拷贝且要改所有段表)、预留更大的段(浪费更多)、干脆放弃变长 —— 最后一条就是分页。

一句话收敛

内部碎片是粒度的代价,外部碎片是变长的代价。

分页用固定粒度换掉了外部碎片,代价是内部碎片;分段保留了变长,因此保留了外部碎片。

段页式两种都沾一点:外部碎片消失(内存按固定页框分配),内部碎片还在(每段的最后一页填不满)。


五、透明、共享、保护、增长

这几项常被当成并列的四条原则背,其实根子只有两个:粒度和边界。

对程序员透明

分页完全透明。程序里写 &x,得到的是一个普通地址,编译、链接、运行时都不需要知道它落在哪一页。页表由 OS 建,硬件查。

分段不透明。链接器必须知道"这个符号在第几段第几偏移",程序里也可能显式操作段(x86 的 DS、CS 寄存器)。可见性的差别直接来自"段号是不是程序给的"。

共享

共享的本质是:两个进程的地址映射到同一块物理内存。

分页的困难在于粒度。共享的最小单位是一页,而页边界是硬件按 4 KB 硬切的,与程序的逻辑边界无关。于是会出现:某一页里既有该共享的只读代码,又有不该共享的、带进程私有数据的部分 —— 这一页只能二选一。

分段没有这个问题。段就是逻辑模块,共享一个库 = 两个进程的段表项指向同一段,权限位照样各自独立。教材说"分段便于共享",讲的是这层意思。

一个现实补丁值得知道:现代 ELF 会把代码段、数据段各自按页边界对齐(p_align = 0x1000),于是共享库的只读代码页天然可以按页共享 —— 这等于让编译器去补分页的粒度缺陷。分页共享难,是"默认不帮你对齐",不是"做不到"。

保护

两种都能做保护,但能表达的语义不一样:

权限挂在能表达
分页页表项(R/W、U/S、NX)“这一页可读可写”
分段段表项(类型、DPL)“这个代码段只读可执行,这个数据段可读写”

分段胜在语义对齐:一页里如果混着代码和可写数据,页级权限没法分别设置,只能整页放开或整页禁止;段级权限天然对应"这个模块该有什么权限"。

动态增长与动态链接

  • 增长:段长可变,栈段要涨,改段表长度字段就行 —— 前提是段末尾还有空位,被相邻段挡住就得搬家,这正是外部碎片的来源。分页下栈要涨就再给几页,逻辑地址连续、物理地址随意,反而没有"邻居"约束。
  • 链接:动态链接以模块(段)为单位。加载时只需把段表项指向新基址,模块内部的段内偏移一个字节都不用改。分页下模块被切成页,页之间没有逻辑身份,加载时得处理重定位。

六、段页式:先分段,段内再分页

动机很直接:要分段的逻辑边界,也要分页的物理分配方式。做法是两级切分:

  1. 用户程序按逻辑分成段(代码、数据、栈……),每段有自己的段号;
  2. 每个段内部再按页大小切成等长的页;
  3. 物理内存仍按页框分配,页框可以散落。

于是得到两张表:

  • 每进程一张段表,每项记录:段号、段长(该段共几页)、该段页表始址、权限;
  • 每段一张页表,每项记录:段内页号 → 物理块号。

地址结构

段号 S | 段内页号 P | 页内偏移 d

一种常见的 32 位划分(示意,非标准):

字段位数容量
段号 S8最多 256 段
段内页号 P12每段最多 4096 页 = 16 MB
页内偏移 d12页大小 4 KB

8 + 12 + 12 = 32,进程最大 256 × 16 MB = 4 GB,正好铺满 32 位地址空间。

转换流程

否

是

否

是

逻辑地址:段号 S 与段内偏移 W

段表寄存器:段表始址、段表长度

S 是否小于段表长度

越界中断

段表第 S 项:页表长度、页表始址、权限

把 W 拆成页号 P 与页内偏移 d

P 是否小于页表长度

页表第 P 项:物理块号 b

物理地址:b 乘页大小 加 d

两次越界检查,一次都不少:先看段号在不在段表里,再看段内页号有没有越过该段的页表长度。这也是"段页式把分段的长度约束保留下来了"的证据。

一个完整换算

逻辑地址拆为 S = 2, P = 5, d = 0x1A0:

查段表第 2 项  →  页表始址 = 0x4000,页表长度 = 8(该段共 8 页)
查该页表第 5 项  →  物理块号 b = 0x30
物理地址 = 0x30 × 0x1000 + 0x1A0 = 0x301A0

代价:访存次数

  • 纯分页:1 次访存(查页表)+ 1 次访存(取数据)= 2 次;
  • 纯分段:查段表 1 次 + 取数据 1 次 = 2 次;
  • 段页式:查段表 + 查页表 + 取数据 = 3 次。

三次访存是段页式最实在的缺点。解法是 TLB:把"段号 + 页号 → 物理块号"这条映射整个缓存进快表,命中后只需 1 次访存。所以工程上的段页式,性能取决于 TLB 命中率,而不是这几张表本身有多快。

段页式的碎片账

碎片类型段页式
外部碎片无 —— 物理内存按固定页框分配,任何空闲页框都能被任何页占用
内部碎片有 —— 每个段的最后一页填不满,浪费 ≤ 页大小

这也解释了段页式为什么能同时拿到两边的好处:物理分配用分页(除掉外部碎片),逻辑划分用分段(保住共享、保护、动态链接)。


七、背诵版

一屏能背完的版本:

共同点

都是离散内存分配,都靠地址映射表(页表 / 段表)完成逻辑地址 → 物理地址转换,都属于虚拟内存方案。地址转换由 MMU 硬件完成,OS 只负责建表。

核心区别

  1. 分页:面向系统,物理单位。 页大小固定(OS 定);地址一维:页号 + 页内偏移;消除外部碎片,存在内部碎片;对程序员透明,共享麻烦。
  2. 分段:面向用户,逻辑单位。 段大小不固定(按程序逻辑:代码段、数据段、栈段);地址二维:段号 + 段内偏移;容易产生外部碎片;方便共享、保护、动态链接、动态扩容。

二者结合:段页式

先分段,每个段内部再分页。地址:`段号段内页号页内偏移`。同时拿到分段易共享保护、分页无外部碎片的优点,代价是多一层表、访存三次、仍有内部碎片。

极简句

  • 分页:地址硬件拆分,一维;块固定,产生内部碎片。
  • 分段:(段号, 偏移) 两个分量,二维;段变长,产生外部碎片。
  • 内部碎片:分配了,但用不满。
  • 外部碎片:空闲着,但太小没人要。

八、常见追问

分页到底有没有外部碎片

没有。外部碎片的定义是"空闲但太小、装不进需求",而页框等大,任何空闲页框都能装任何一页,不存在"太小"这一说。分页用内部碎片把外部碎片换掉了。

纯分段会不会有内部碎片

不会有实质性的内部碎片。段按实际长度整块分配,段内不再切块。但有的实现会对段做对齐(比如按 4 KB 对齐),这时段末尾的补齐严格说也算浪费,量级远小于分页的最后一页。

为什么分页共享麻烦,现实里 .so 却又是按页共享的

因为共享单位是页,而页边界与逻辑边界不对齐 —— 页里一旦混进私有数据,这一页就不能共享了。解决靠链接器:ELF 把只读段、可写段各自按页对齐,于是"该共享的页"和"不该共享的页"恰好分开。分页不是不能共享,是需要外部把边界对齐好;分段内置了这个边界。

段页式的越界检查有几次

两次。段号要小于段表长度;段内页号要小于该段的页表长度。第二次检查替代了纯分段里"偏移 < 段长"那一步,本质约束一样。

现代系统还用分段吗

基本不用。Linux 在 32 位保护模式下把段设成平坦模型(基址 0、段长 4 GB),x86-64 更是把段机制关掉,只留 FS / GS 的基址做线程局部存储。原因不是分段不好用,而是:段表要随进程数量线性增长、变长段难以支持按需调页与写时复制、跨架构(RISC 普遍只有分页)移植成本高。分页的页表可以做成多级稀疏结构,一个只用几 MB 地址空间的进程不必为整个 4 GB 建表 —— 这一点分段做不到。

页大小为什么是 4 KB

内部碎片希望页小,页表规模、TLB 覆盖范围、磁盘 IO 希望页大。4 KB 是这两组矛盾的折中:页再小,32 位单级页表就要 4 MB 以上,多级页表的层数和 TLB 缺失率都上去;页再大,小进程的最后一页浪费就不可接受。

内部碎片能彻底消除吗

不能,只能转移。减小页大小会把浪费压小,但把成本推到页表和 TLB 上;真正的解法是在用户态另建一层分配粒度 —— malloc 的 slab / size class 就是这么干的,把小对象从页里再切一刀。内核视角的碎片,用户态视角还能再分。


一句收尾:分页和分段的分歧,从来不是"哪种更好",而是"你按物理的等长去切,还是按逻辑的变长去切"。选了等长,拿到无外部碎片,交出逻辑边界;选了变长,拿到逻辑边界,交出碎片控制权。段页式不接受这个二选一,于是付了两次查表的钱。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐