【操作系统精讲】分页与分段:一维与二维地址、内外两种碎片、段页式
【操作系统精讲】分页与分段:一维与二维地址、内外两种碎片、段页式
写在前面
分页和分段是操作系统里最容易被"背过"也最容易被"背错"的一对概念。
背得出"一个定长、一个变长"的人很多,但往下问一层就散了:为什么分页的地址叫一维、分段叫二维?分页到底有没有外部碎片?段页式里那张段表存的还是"基址 + 长"吗?内部碎片和外部碎片的区别,很多笔记写成"一个在块内、一个在块外",这句话对,但据此判断不了具体场景。
这篇只解决这几件事,不往请求调页、置换算法、TLB 替换策略上扩散。线索是一条:
共同点在哪 → 差异从哪一句长出来 → 一维和二维的本质是什么 → 两种碎片怎么区分 → 段页式为什么是多数教材的终点
一、共同点:都是"离散分配 + 地址映射"
先把连续分配的痛点摆出来,才能看出这俩为什么要存在。
连续分配要求一个进程占一整块连续物理内存。这带来两个后果:
- 进程必须整体装入,内存不够就得等;
- 反复申请释放之后,内存被切成一堆大小不一的空洞,单个都装不下新进程,但总量够 —— 这就是外部碎片。
分页和分段给出的解法是同一个:把进程切块,块与块不必相邻,散在物理内存各处的块用一张表记下来。于是:
| 项目 | 分页 | 分段 |
|---|---|---|
| 切分对象 | 进程的逻辑地址空间 | 进程的逻辑地址空间 |
| 物理布局 | 离散 | 离散 |
| 映射手段 | 页表 | 段表 |
| 转换动作 | 逻辑地址 → 物理地址 | 逻辑地址 → 物理地址 |
| 归属 | 虚拟内存的实现基础 | 虚拟内存的实现基础 |
三点值得单独指出:
- 转换是硬件做的,不是 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 和段号 2 的偏移 0x2A00 是两个完全不同的位置。
判据
一句话记住:
| 页号怎么来的 | 空间形态 | |
|---|---|---|
| 分页 | 硬件从一个整数里算出来 | 一维连续 |
| 分段 | 程序 / 编译器直接给出来 | 二维分块 |
| 段页式里地址写成 `段号 | 段内页号 | 页内偏移` 三个字段,但仍然算二维 —— 因为段号依旧是程序给的逻辑编号,页号是从段内偏移里切出来的。字段数变了,性质没变。 |
四、内部碎片与外部碎片
先把两个定义钉死,这一节的判断全靠它们。
内部碎片:这块已经分给进程了,进程用不完,剩下的空间别人拿不到。分配了,但用不满。
外部碎片:这块是空闲的、没人占用,但是太小,没有进程愿意要它。空闲,但太小没法分配。
两个定义里各有一个关键词:内部碎片是"已分配",外部碎片是"空闲"。抓住这两个词,判断就不会反。
| 位于 | 谁占着 | 别人能不能用 | 成因 | |
|---|---|---|---|---|
| 内部碎片 | 已分配块的内部 | 被分配者 | 不能 | 分配粒度大于实际需求 |
| 外部碎片 | 已分配块之间 | 没人 | 不能,太小 | 变长分配 + 反复申请释放 |
分页为什么只有内部碎片
物理内存被划成等大的页框,进程也按页框大小切。任何一个空闲页框都能装任何一个页 —— 不存在"太小装不进"这回事,所以分页没有外部碎片。
代价落在最后一页。举例:页 4 KB,进程实际需要 10 KB。
需要 3 页 = 12 KB
最后一页只用了 2 KB,剩 2 KB 空着
这 2 KB 在最后一页框内部,进程自己用不到,别人也拿不走
平均下来,每个进程浪费约半页。页越大,浪费越多;页越小,浪费越少,但页表越大、TLB 命中率越低、磁盘 IO 效率越差。4 KB 是这几项折中出来的结果,不是随便定的。
分段为什么只有外部碎片
段是变长的,每个段按实际长度分配一整块。段内不切块,所以纯分段没有内部碎片 —— 申请 60 KB 就得到 60 KB,不多不少。
问题出在回收。连续做几次不同大小的分配与释放:
两个空洞加起来 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) | “这个代码段只读可执行,这个数据段可读写” |
分段胜在语义对齐:一页里如果混着代码和可写数据,页级权限没法分别设置,只能整页放开或整页禁止;段级权限天然对应"这个模块该有什么权限"。
动态增长与动态链接
- 增长:段长可变,栈段要涨,改段表长度字段就行 —— 前提是段末尾还有空位,被相邻段挡住就得搬家,这正是外部碎片的来源。分页下栈要涨就再给几页,逻辑地址连续、物理地址随意,反而没有"邻居"约束。
- 链接:动态链接以模块(段)为单位。加载时只需把段表项指向新基址,模块内部的段内偏移一个字节都不用改。分页下模块被切成页,页之间没有逻辑身份,加载时得处理重定位。
六、段页式:先分段,段内再分页
动机很直接:要分段的逻辑边界,也要分页的物理分配方式。做法是两级切分:
- 用户程序按逻辑分成段(代码、数据、栈……),每段有自己的段号;
- 每个段内部再按页大小切成等长的页;
- 物理内存仍按页框分配,页框可以散落。
于是得到两张表:
- 每进程一张段表,每项记录:段号、段长(该段共几页)、该段页表始址、权限;
- 每段一张页表,每项记录:段内页号 → 物理块号。
地址结构
段号 S | 段内页号 P | 页内偏移 d
一种常见的 32 位划分(示意,非标准):
| 字段 | 位数 | 容量 |
|---|---|---|
| 段号 S | 8 | 最多 256 段 |
| 段内页号 P | 12 | 每段最多 4096 页 = 16 MB |
| 页内偏移 d | 12 | 页大小 4 KB |
8 + 12 + 12 = 32,进程最大 256 × 16 MB = 4 GB,正好铺满 32 位地址空间。
转换流程
两次越界检查,一次都不少:先看段号在不在段表里,再看段内页号有没有越过该段的页表长度。这也是"段页式把分段的长度约束保留下来了"的证据。
一个完整换算
逻辑地址拆为 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 只负责建表。
核心区别
- 分页:面向系统,物理单位。 页大小固定(OS 定);地址一维:页号 + 页内偏移;消除外部碎片,存在内部碎片;对程序员透明,共享麻烦。
- 分段:面向用户,逻辑单位。 段大小不固定(按程序逻辑:代码段、数据段、栈段);地址二维:段号 + 段内偏移;容易产生外部碎片;方便共享、保护、动态链接、动态扩容。
二者结合:段页式
| 先分段,每个段内部再分页。地址:`段号 | 段内页号 | 页内偏移`。同时拿到分段易共享保护、分页无外部碎片的优点,代价是多一层表、访存三次、仍有内部碎片。 |
极简句
- 分页:地址硬件拆分,一维;块固定,产生内部碎片。
- 分段:(段号, 偏移) 两个分量,二维;段变长,产生外部碎片。
- 内部碎片:分配了,但用不满。
- 外部碎片:空闲着,但太小没人要。
八、常见追问
分页到底有没有外部碎片
没有。外部碎片的定义是"空闲但太小、装不进需求",而页框等大,任何空闲页框都能装任何一页,不存在"太小"这一说。分页用内部碎片把外部碎片换掉了。
纯分段会不会有内部碎片
不会有实质性的内部碎片。段按实际长度整块分配,段内不再切块。但有的实现会对段做对齐(比如按 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 就是这么干的,把小对象从页里再切一刀。内核视角的碎片,用户态视角还能再分。
一句收尾:分页和分段的分歧,从来不是"哪种更好",而是"你按物理的等长去切,还是按逻辑的变长去切"。选了等长,拿到无外部碎片,交出逻辑边界;选了变长,拿到逻辑边界,交出碎片控制权。段页式不接受这个二选一,于是付了两次查表的钱。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)