城西物流园里,仓库管理员小张最近愁得睡不着。仓库明明还有不少空位,可新来的大件货物怎么也塞不进去——之前堆放的货东一块西一块,留下的缝隙都是“鸡肋”空间。操作系统的内存管理,碰到的正是跟小张一模一样的难题:程序启动时申请内存,运行中释放内存,来来回回之后,物理内存里布满了用不上的小空洞。 小张想了两套对策。第一套是“标准干货箱”:不管什么货,全部分切成统一尺寸的箱子,一片一片塞进仓库的固定格里,缝隙再也不是问题——这就是分页。第二套是“分类储物区”:把货物按照“类型”整块划分专区,代码归代码、数据归数据,看着顺眼、取用方便——这就是分段。两套方案各有代价:标准箱总让最后一箱装不满,分类区之间又会留下挤不进货物的缝。聪明的操作系统干脆把两招合体:专区内再放标准箱,把两类碎片的麻烦都压到最小。 这篇文章,就带你看懂这两个“整理方案”是怎么运作的,以及现代的电脑仓库,到底选了哪张图纸。
掌握分页与分段是理解操作系统内存管理的基石;对 AI 从业者而言,大模型推理的显存优化(如 vLLM)、训练框架的显存复用,本质上都是这两个概念的工程变体。
可视化演示
分步展示分页、分段及段页式内存管理的地址转换机制,对比碎片类型与共享粒度差异,交互切换理解三种方案的本质。
本节配有交互动效(进入视口后自动加载,约 3KB)。
分页机制:固定大小页框映射 虚拟地址分为页号和页内偏移,页表将虚拟页号映射到物理页框号 物理地址 = 页框号 × 页大小 + 偏移 分页消除外部碎片,但存在页内内部碎片。
内存分配困境
小张的仓库里,货物都是“整块整车”地卸下来堆放在一起。今天的货从墙角开始摆,明天的货紧挨着摆。程序加载到内存也是这个道理:早期的操作系统要求一个进程必须占据一块连续的存储区,从某个地址一直到另一个地址,中间不允许“跳空”。这种方案叫连续分配,思路直白,但会把可用空间越切越碎。
老客户的货越堆越多,中途又有几批货临时运走,仓库里出现了一个个又窄又短的缝隙。新来的大货柜需要一整片空地,可怎么比划都塞不下——明明总空间是够的,可就是凑不出一块大的。这种现象,正是操作系统的“内存碎片化”:作业之间留下的零碎洞叫外部碎片,分配出去却发现用不完的部分叫内部碎片。连续分配下,外部碎片是主要矛盾。由此计算机科学家想出一个破局思路:既然程序不必连续,为什么不把内存切成更小的单位,让程序像拼图一样“离散”地放进去?同时,虚拟内存为每个进程画了一张独立的“仓库图纸”(虚拟地址空间),物理内存只是一排排真实的货架。思路一变,后面的分页、分段才有了舞台。
小张明白了,问题不是仓库太小,而是摆放方式不对。要么把货切碎(分页),要么按类划区(分段),出路就在这两套方案里。
- 分子 E程序中真正运行需要的有用字节数,相当于货物本身占的体积。
- 分母 M物理内存总容量,相当于仓库里全部货架的总容积。
- 比值 U内存被有效利用的程度,余下的部分就是碎片与空闲。
分页机制
小张的第一套方案:买回一批统一尺寸的标准干货箱,不管什么货,通通拆开按箱装。仓库的货架也预先划成一个个同样大小的格子。任何一箱货放到任何格子都行,货箱之间没有宽度差异,自然也就没有塞不进的缝。
对应到操作系统:把虚拟内存空间切成固定大小的块,每一块叫一页;把物理内存切成同样大小的块,每一块叫页框。页大小常见 4KB。进程的页可以放进任意空闲页框,不需要连续。由此虚拟地址与物理地址之间多了一张映射表——页表,它记录了每一页放到哪个页框里。每个进程一张页表,页表基址寄存器用来定位这张表;页表项里除了物理页框号,还有存在位、读写权限位等控制信息。
技术细节上,虚拟地址被拆成页号和页内偏移两部分。地址转换时,CPU 先取页号查页表,得到物理页框号,再与偏移拼接出物理地址。
标准箱方案让小张惊喜:无论货怎么来,只要格子还有空的,就一定能放下。但小张也注意到,最后一箱常常只装了半箱货——那空着的半箱,就是“内部碎片”。
- 提取页号 P虚拟地址按页大小整除即可得到所在页号,相当于看箱子上印的“第几箱”。
- 提取偏移 d虚拟地址取余得到页内偏移,相当于这箱货在箱内的第几格。
- 查页表得页框号 F用 P 查页表,得到货箱实际所在货架格子的编号 F。
- 合成物理地址用 F 乘页大小再加上偏移 d,得到真实的内存地址。
分段机制
小张的第二套方案:不开箱,按“类别”划区。把仓库分成几个大区——办公货区、建材货区、生鲜冷库,每个区一整块连着的空间。货物完整地码进去,想取哪箱不用翻半天,想给某个区加装门禁也容易。
操作系统中的对应物:一个程序并不是铁板一块,它天然包含代码段、数据段、栈段、堆段。分段机制尊重这种结构,把虚拟内存按逻辑单位切成若干段,每段大小不等,段号从 0 开始编号。物理内存中,为每段分配一块连续区域,各段之间不必相邻。段表记录每段的基址和段长;栈段这类会随运行伸缩的段,还可以在段长字段上做动态增长的支持,只要物理内存中还有空闲空间,段长就能扩展。
技术细节上,虚拟地址被拆成段号 s 与段内偏移 d。查段表得到该段在物理内存中的基址 B,于是物理地址 = 基址 + 偏移。段表还记录了段长 L,访问前要做越界检查:如果 d 超出了 L,就拒绝这次访问并抛出异常。
分类区的优势是整齐、好管理、好保护,但小张皱着眉头发现:区与区之间总有一些不规则的过道缝隙——这些就是外部碎片,时间一长,还是会浪费不少空间。
- 取段号 s虚拟地址的高位是段号,用来查段表,相当于报出“我要去建材区”。
- 查段表得基址 B 与段长 L段表给出该段在物理内存中的起点和长度。
- 越界检查如果偏移 d 大于等于段长 L,说明越界,触发异常。
- 合成物理地址基址加偏移得到最终地址。
碎片对比
标准箱方案里,箱子里总有装不满的空位;分类区方案里,区与区之间总有挤不进东西的过道。一个在“盒子内部”,一个在“区域外部”,操作系统把这分别命名为内部碎片与外部碎片。分页消除了外部碎片,但页是固定大小的,进程最后一页用不完,这部分就是内部碎片,平均约等于半页。分段消除了内部碎片(每段按需分配),但段与段之间的空隙可能积累成无法使用的外部碎片。
对付外部碎片的经典招数是“紧凑”:把正在运行的进程整体搬到相邻位置,把散落的小空闲区合并成大空间。但紧凑代价不低——它需要动态重定位,搬运期间进程不能执行。另一个思路是“最佳适配/最先适配”这类放置策略,只能缓解,无法根除。于是有一个直观的量化指标衡量浪费:碎片率。
小张把两套方案的账本摊开对比:分页牺牲的是“箱内的零头”(内部碎片),分段牺牲的是“区间的过道”(外部碎片)。没有绝对免费的选择,只有适合场景的取舍。
- 碎片空间内部碎片或外部碎片的字节数,相当于空箱容积加过道面积。
- 总空间 M当前物理内存或某分区的容量。
- 比例碎片占总体空间的比重,越低越好。
地址转换
分页和分段都离不开账本。分页的账本按页记录“第几箱→第几格”(页表);分段的账本按区记录“几号区→仓库哪个位置+区多大”(段表)。CPU 的 MMU(内存管理单元)就像一个拿着账本的调度员,每次访问内存都要先翻账本。页表基址寄存器指向当前进程的页表,段表基址寄存器指向当前进程的段表。
查一次页表就是一次内存访问,再访问目标数据又是一次内存访问,所以无快表时一次取数需 2 次访存,段页式则需 3 次。为了提速,CPU 加入 TLB(快表,即转译后备缓冲器),缓存最近用过的页表项——命中时只需 1 次访存。此外,单级页表可能非常庞大,于是引入多级页表:只在需要时才分配下一级页表,用空间换时间中的“空间”被大幅压缩。
账本翻得勤不勤、记得牢不牢,决定了仓库出货速度。TLB 就是小张脑子里那本“热货记忆册”,总在用的箱子位置不用每次翻账本。
- h 为命中率TLB 中能找到目标映射的概率,相当于调度员凭记忆直接报出位置。
- t 为一次访存时间访问内存中一个字的耗时。
- 命中时TLB 直接给出页框号,只访问 1 次内存。
- 未命中时需要查内存中的页表再取数据,共 2 次访存。
- 加权期望两部分相加得到平均访存时间,h 接近 1 时 EAT 接近 t。
共享与保护
仓库里有一台咖啡机是几个团队共用的茶歇区。分类区方案里,这个茶歇区是一个整“区”,想共享就指给多个团队,想上锁就在区门口设个门禁卡。而标准箱方案里,每个箱子只是货物的一小片,要把连续几个箱子共享出来,得一格一格地分,麻烦得多。
共享与保护是内存管理的两大重点。分段以逻辑单位为共享粒度,很适合共享代码段、动态链接库;段表项中也能设置读、写、执行权限位,越界和越权都会触发异常。分页虽然也能共享,但按页共享粒度太细,容易破坏整体性;页表项同样有保护位,只是粒度以页为单位。另外,被多个进程共享的代码段要求“可重入”:代码本身只读,各进程把私有数据放在自己的数据段中,才能安全地同时运行。
茶歇区加门禁卡,这套思路让共享代码库既能被所有进程“共用一套”,又不会被人乱改。分段在这件事上天生比页级更顺手。
- 越界检查偏移 d 须小于段长 L,否则该访问非法。
- 权限检查段表项中的权限位决定本段可读、可写或可执行。
- 两者同时满足访问才被允许,否则触发保护异常。
段页式结合
小张终于想通了:先按类别划出几个大区(分段),每个大区里再全部用标准箱(分页)。这样既保留了区好管理、好设门禁的优点,又消灭了区与区之间塞不下的过道。分类和标准化的好处全占上。
段页式存储管理以段为外层、页为内层:进程先按逻辑分段,段内再按固定大小分页。地址转换分三步:段号查段表,得到该段的页表起始地址;段内页号查页表,得到页框号;页框号加偏移得到物理地址。无 TLB 时一次访问需 3 次访存,这是它付出的时间代价;空间上也同时维护段表和页表两张表,表项开销高于纯分页。
小张的“专区内标准箱”方案让仓库重获秩序。代价是搬运时要翻两本账(大区账+箱格账),但总比货堆满地来得强。
- 查段表用段号 s 找到该段对应的页表起始地址。
- 查页表用段内页号 p 找到物理页框号 F。
- 访问数据合成物理地址后取目标数据。
- 合计三次访存相加;开启 TLB 后可接近一次。
权衡与选型
图纸要落地,就得看现实条件:仓库的硬件、物流的习惯、管理员的能力。操作系统的选择也一样,性能和成本要一起算。实际系统里,x86 CPU 从硬件上同时提供分段与分页,主流操作系统的做法是“绕过分段、专注分页”:Linux 把段基址设为 0、段长设为最大,让分段失去隔离作用,只依赖分页。这样共享保护全部交给页表权限位完成,模型简单统一。
页大小的选择也讲究:4KB 小页灵活但页表大,2MB/1GB 大页能减少页表项、提高 TLB 命中率,但内部碎片更多。现代数据库和大模型推理框架主动申请“大页”,就是希望在页表开销与碎片浪费之间找自己的平衡点。理解这些取舍,是阅读系统源码和做性能优化时的重要背景。
小张最后在图纸上写下结论:分段适合做“逻辑世界的框架”,分页适合做“物理世界的摆放”,而现代系统大多让分页承担主角,把分段优化到近乎透明。理解这两套方案的权衡,就能看懂系统设计和 AI 框架的许多底层决策。
---
- 页表项数虚拟空间按页切分的数量,等于 V 除以 S。
- 每项大小 e一个页表项通常为 4 字节或 8 字节。
- 总占用页表项数乘每项大小,就是页表占用的内存。
AI 应用场景
vLLM 的 PagedAttention:给 KV Cache 装标准箱
阶段:大模型推理引擎的显存管理
解决的问题:传统推理框架为 KV Cache 申请连续显存,请求长短不一、动态生成,显存很快碎片化且无法按需增长,导致显存利用率低、吞吐量上不去。vLLM 借鉴分页思想,把 KV Cache 切成固定大小的逻辑块,映射到物理块上,按需分配,几乎消除了碎片化浪费。
具体案例:部署 Llama-70B 时,vLLM 的 PagedAttention 把显存利用率从传统方案的 40%-60% 提升到 90% 以上,吞吐量提高数倍。每个请求的 KV Cache 像一箱箱标准货,散落在显存各页框中,用完即还。
PyTorch Caching Allocator:显存里的分类储物区
阶段:深度学习训练的张量内存分配与复用
解决的问题:训练中反复创建、销毁张量,如果每次都向 CUDA 申请和释放显存,会产生大量碎片和性能抖动。PyTorch 的缓存分配器预申请大块显存,再按大小分桶复用,类似分段“按逻辑分类”的思路,显著降低显存碎片和 OOM 概率。
具体案例:训练 GPT-3 规模模型时,Caching Allocator 让显存峰值降低约 30%,训练速度提升约 20%。框架在后台扮演小张:货(张量)按大小分类入库,释放后留在桶里供下次复用。
CUDA Unified Memory:CPU 与 GPU 的共享账本
阶段:异构计算的统一内存管理
解决的问题:传统 CPU 与 GPU 显存分离,程序员要手动拷贝数据,容易出错且难以优化。CUDA 的 UVM(统一虚拟内存)让 CPU 与 GPU 共享一个虚拟地址空间,以页为粒度自动迁移数据,类似分页的换入换出。
具体案例:在科学计算或大模型微调中,程序员像访问普通内存一样读写 GPU 数据,硬件驱动按页搬运;只有真正用到的页才被迁移,省去大量手动拷贝。仓库比喻:小张让主仓库和移动仓库共用一套账本,哪边要货就从哪边调箱。
Python 演示
"""分页与分段: 碎片化内存下谁更能装得下进程""" import math PAGE_SIZE = 4096 # 页大小 4KB, 一页对应一个页框 def paging_load(process_size, free_frame_count): """ 分页装载: 进程被切成等长页, 可散落在任意空闲页框。 返回 (是否成功, 内部碎片字节数)。 """ need_frames = math.ceil(process_size / PAGE_SIZE) if need_frames > free_frame_count: return False, 0 internal_frag = need_frames * PAGE_SIZE - process_size return True, internal_frag def segment_load(seg_size, free_blocks): """ 分段装载: 进程按逻辑段申请一整块连续空间。 free_blocks 是可用空闲块列表 [(起始地址, 大小)]。 返回 (是否成功, 该块剩余碎片)。 """ for start, size in free_blocks: if size >= seg_size: return True, size - seg_size return False, 0 def main(): process_size = 16 * 1024 + 500 # 16.5KB 的"AI 推理小进程" free_frames = 6 # 内存里还剩 6 个空闲页框(24KB) ok, internal = paging_load(process_size, free_frames) need = math.ceil(process_size / PAGE_SIZE) print(f"[分页] 进程 {process_size}B 需要 {need} 个页框, " f"空闲 {free_frames} 个, 装载{'成功' if ok else '失败'}") print(f"[分页] 内部碎片 = {internal}B") # 系统运行一段时间后, 256KB 内存被拆成若干大小不一的空闲区 free_blocks = [ (0x0000, 8 * 1024), (0x3000, 4 * 1024), (0x8000, 6 * 1024), (0xB000, 2 * 1024), (0xE000, 3 * 1024), ] total_free = sum(sz for _, sz in free_blocks) max_free = max(sz for _, sz in free_blocks) print(f"[分段] 空闲总量 {total_free}B, 最大连续 {max_free}B") ok, external = segment_load(process_size, free_blocks) print(f"[分段] 进程需要 {process_size}B 连续空间, 装载" f"{'成功' if ok else '失败 - 外部碎片太多, 拼不出一整块!'}") if __name__ == "__main__": main()
need_frames = math.ceil(process_size / PAGE_SIZE)向上取整算出需要几个页框,像数“标准箱”要几箱if need_frames > free_frame_count页框不够就失败,但页框可以零散分布internal_frag = need_frames * PAGE_SIZE - process_size最后一页用不满的字节数就是内部碎片for start, size in free_blocks:遍历空闲块,寻找能塞下整个段的空间if size >= seg_size只看单块连续空间够不够大,这是分段的关键约束total_free = sum(sz for _, sz in free_blocks)空闲总量看似够,却救不了分段
常见误区
误区分页把内存切成固定大小的页框,每页都能填进去,所以分页没有任何内存浪费。
正解分页消除的是“外部碎片”,但“内部碎片”依然存在——进程最后一页往往装不满,这部分按页框整体分配却用不掉的字节就是内部碎片。仓库比喻:标准箱解决了货架间塞不进裂缝的问题,但每箱总有一截空位。页越小内部碎片越少,但页表会变大,二者需要权衡。
为何常见:初学者常把“消除外部碎片”理解成“消除全部碎片”,忽略了固定大小划分天然带来的尾部浪费。
误区现代系统都用分页,分段是过时的古董,已经不存在了。
正解x86 从硬件上仍同时支持分段与分页,Linux 这类主流系统只是把段基址设为 0、段长设为整个地址空间,让分段“透明化”,从而只依赖分页。在实模式或某些嵌入式场景,分段依然重要。仓库比喻:分类区并没有拆掉,只是被标记成“所有区都从仓库入口开始”,等于没分区。
为何常见:普通开发者只在书上看过分段,日常写代码接触不到,就误以为它消失了。
误区只要进程建立,页表就记录了该进程所有虚拟页的映射关系,因此页表总是占很大空间。
正解页表项有“存在位”,未分配的虚拟页在页表中是无效项,不必占用实际资源;多级页表更是按需建立,只有实际用到的页目录和页表才被分配。仓库比喻:账本不会把所有可能的箱子都提前画好,而是货真的来了才记账。
为何常见:把“地址空间大小”与“实际分配空间”混为一谈,忽略了按需分配机制。
误区把页设得大一点,页表就小、TLB 命中率也高,所以页越大越划算。
正解大页确实减少了页表项和 TLB 缺失,但代价是最后一页的内部碎片更多。比如 2MB 大页装一个"2MB+1B"的进程,会浪费约 2MB 空间。所以系统提供多种页大小(4KB、2MB、1GB),按场景选择。仓库比喻:大箱子装大货省纸板,但装小货时空得更多。
为何常见:只看到页表省空间的收益,忽略了内部碎片增长的成本。
面试题
Q1. 分页存储管理主要消除了连续分配中的哪一类内存问题?
- A.内部碎片
- B.外部碎片← 正确答案
- C.内存泄漏
- D.磁盘坏道
答案:B。分页把内存切成等大的页框,任何页都能放进任何空闲页框,不需要连续空间,因此连续分配中“碎片化空洞”(外部碎片)被消除。仓库比喻:标准箱能塞进任意空格,过道问题消失。但注意它并不消除内部碎片,因此 A 不正确;内存泄漏与磁盘坏道与分页无关。
Q2. 分段机制划分内存空间的依据是什么?
- A.固定大小
- B.程序的逻辑单位← 正确答案
- C.用户随机指定
- D.物理页框大小
答案:B。分段尊重程序结构,按代码段、数据段、栈段、堆段等逻辑单位划分,每段大小不等。仓库比喻:按货物类别分区。A 是分页思路,D 完全无关。
Q3. 页表的核心作用是?
- A.记录页的大小
- B.将虚拟页号映射为物理页框号← 正确答案
- C.存储程序的源代码
- D.管理磁盘空闲块
答案:B。页表是虚拟页到物理页框的映射表,MMU 通过它完成地址转换。仓库比喻:账本记录第几箱货放到第几号格子。页大小是系统定死的,不靠页表记录,A 错误。
Q4. 不启用 TLB 快表时,段页式存储管理访问一次数据需要几次内存访问?
- A.1 次
- B.2 次
- C.3 次← 正确答案
- D.4 次
答案:C。段页式需要先查段表得到页表起始地址(1 次),再查页表得到页框号(1 次),最后访问目标数据(1 次),共 3 次。仓库比喻:先翻大区账本,再翻箱格账本,最后取货。TLB 可把开销压到接近 1 次,但那是另一回事。
Q5. 与分段相比,分页最明显的劣势是?
- A.存在外部碎片
- B.共享与保护粒度太细← 正确答案
- C.无法离散分配
- D.不需要地址转换
答案:B。分页以固定页为共享/保护单位,很难按“代码段”“数据段”这样的逻辑整体来共享或加权限。仓库比喻:想共享整条商品线,却只能一格一格授权,极为繁琐。分页没有外部碎片且天然离散分配,A、C 都反了。
Q6. 下列关于分段优点的表述,正确的有?
- A.按逻辑单位共享更方便← 正确答案
- B.段内没有内部碎片← 正确答案
- C.完全消除了所有碎片
- D.便于针对段设置读写保护← 正确答案
答案:ABD。段的长度由逻辑内容决定,按需分配,段内不浪费,故 B 对;共享和按段保护都很顺手,A、D 对;但段与段之间仍可能出现空隙(外部碎片),C 错。仓库比喻:分类区内部不浪费,但区与区之间有过道。
Q7. 关于 TLB(快表)的说法,正确的有?
- A.缓存最近用过的页表项← 正确答案
- B.TLB 命中时无需再访问内存中的页表← 正确答案
- C.TLB 未命中时需要查内存页表← 正确答案
- D.TLB 容量越大访问一定越快
答案:ABC。TLB 本质是页表项的硬件高速缓存,命中即可跳过内存页表访问,未命中还需查内存中的页表并更新 TLB,A、B、C 对。但 TLB 容量受成本和功耗约束,不是越大越快,D 错。仓库比喻:记忆中的热货位置有限,记太多反而顾不过来。
Q8. 某系统页大小为 2KB,某进程占用 6000B,其内部碎片为多少?
- A.144B← 正确答案
- B.1024B
- C.2048B
- D.0
答案:A。6000B 需要 ceil(6000/2048)=3 页,3×2048=6144B,内部碎片 = 6144-6000=144B。仓库比喻:6000B 货用 3 个标准箱装,最后一箱空着 144B。前两箱是满的,只有最后一箱产生浪费。
Q9. 分段地址转换中,若段内偏移 d 大于等于段长 L,系统应当如何处理?
- A.忽略偏移继续访问
- B.触发越界异常← 正确答案
- C.自动扩展该段
- D.返回 0 值
答案:B。偏移越过段长说明访问了不属于该段的地址,是严重的地址越界,MMU 会触发保护异常,防止程序破坏其他区域。仓库比喻:你报的货位超出了本区范围,保安会拦住你,而不是放你乱闯。
Q10. 某系统虚拟地址空间 1GB,页大小 4KB,每个页表项 4B,则该进程单级页表占用多少内存?
- A.256KB
- B.1MB← 正确答案
- C.4MB
- D.16MB
答案:B。1GB/4KB = 262144 个页表项(2^18),每项 4B,共 262144×4 = 1048576B = 1MB。仓库比喻:账本里要有 26 万条“第几箱→第几格”记录,每条占 4 字节。这正是单级页表过大的原因,也是多级页表存在的意义。