操作系统学习手记02

学完这一章,我最大的感受就是,我们平时写代码时随手 new 一个对象,或者定义一个变量,背后有很复杂的机制。

所以,这篇学习笔记,我想好好地把这条链给捋清楚。

L20 内存使用与分段

在咱们开始聊具体的内存管理技术之前,得先建立一个最基本的认知:程序看到的地址和内存条上真正的物理地址,不是一回事。

  • 逻辑地址(Logical Address):也叫虚拟地址,是程序代码中使用的地址,由 CPU 生成。比如C语言里的指针地址。
  • 物理地址(Physical Address):是内存硬件上的真实地址,内存单元的唯一标识。

内存管理单元(MMU)是 CPU 内部的一个硬件,它的核心工作就是把程序产生的逻辑地址“翻译”成物理地址。而操作系统要做的,就是管理好这个翻译过程的“规则”。

早期的系统很简单,直接把整个程序加载到一块连续的内存里,逻辑地址加一个基准地址(基址寄存器)就得到物理地址了。但这种方式太死板,而且容易产生碎片。

于是,分段(Segmentation) 机制登场了。

分段的思想非常符合我们程序员的直觉。我们写程序时,天然就会把代码分成几个逻辑部分:主函数(代码段)、全局变量(数据段)、函数调用栈(栈段)等等。分段机制就是把这种逻辑上的划分,直接映射到内存管理中。

  • 核心思想:不再把整个程序看成一个整体,而是看作若干个逻辑**段(Segment)**的集合。每个段的大小可以不一样。
  • 地址表示:逻辑地址不再是一个单一的值,而是一个二维的结构:(段号, 段内偏移量)
  • 实现机制:操作系统为每个进程维护一张段表(Segment Table)。段表里的每一项记录了一个段的信息,最重要的两个是:
    • 段基址(Base):这个段在物理内存中的起始地址。
    • 段限长(Limit):这个段的大小。 CPU 中有两个特殊寄存器:段表基址寄存器(STBR)指向当前进程段表的起始位置,段表长度寄存器(STLR)记录段表的大小。
  • 地址翻译过程
    1. CPU 拿到一个逻辑地址 (段号 s, 偏移量 d)
    2. 检查段号 s 是否合法(s < STLR)。
    3. 从段表中 STBR + s 的位置,取出该段的基址 base 和限长 limit
    4. 检查偏移量 d 是否合法(d < limit)。如果越界,则触发异常(比如“段错误”)。
    5. 计算物理地址:Physical Address = base + d

分段的优缺点很鲜明:

  • 优点
    • 逻辑清晰:用户可以按逻辑模块来组织程序,方便。
    • 易于共享和保护:可以很容易地让多个进程共享同一个代码段(比如共享库),同时对不同段设置不同的读/写/执行权限,安全性好。
  • 缺点
    • 外部碎片(External Fragmentation):这是分段最大的痛点。由于段的大小是可变的,内存经过多次分配和回收后,会产生大量不连续的小空闲块。这些小块单独来看可能无法使用,但它们的总和可能很大。这就好比停车场里有很多零散的空位,但就是停不进一辆大巴车。为了解决这个问题,需要进行内存紧缩(Compaction),但这个操作开销巨大。

L21 内存分区与分页

为了解决分段产生的外部碎片问题,工程师们提出了另一种天才的设计——分页(Paging)

分页的思想和分段完全不同。它不再从程序的逻辑结构出发,而是从物理内存管理的角度出发,采取“化整为零,规格统一”的策略。

  • 核心思想
    • 物理内存划分为大小相等的固定块,每一块称为帧(Frame)
    • 逻辑地址空间也划分为同样大小的固定块,每一块称为页(Page)
  • 地址表示:逻辑地址被看作一个一维的整数,但硬件会把它拆分成两部分:(页号, 页内偏移量)。高位是页号,低位是偏移量。
  • 实现机制:操作系统为每个进程维护一张页表(Page Table)。页表的作用就是记录逻辑页到物理帧的映射关系。
    • 页表的索引就是页号
    • 页表项的内容就是该页对应的帧号。 CPU 中的页表基址寄存器(PTBR)指向当前进程页表的起始位置。
  • 地址翻译过程
    1. CPU 拿到一个逻辑地址,并将其拆分为 (页号 p, 偏移量 d)
    2. 访问当前进程的页表(地址在 PTBR 中),以页号 p 为索引,查找到对应的帧号 f
    3. 计算物理地址:Physical Address = f * 帧大小 + d

为了加速这个查找过程,现代CPU都引入了转译后备缓冲区(TLB - Translation Lookaside Buffer)。它是一个高速的硬件缓存,专门用来存放最近使用过的页表项。地址翻译时,会先查 TLB,如果命中(TLB Hit),就直接拿到帧号,速度极快。如果未命中(TLB Miss),才需要去内存中查询页表,并把查到的结果存入 TLB 以备后用。

分页的优缺点也一目了然:

  • 优点
    • 没有外部碎片:因为内存是以帧为单位进行分配的,任何一个空闲的帧都可以分配给任何一个页。分配和回收都非常简单。
  • 缺点
    • 内部碎片(Internal Fragmentation):一个进程的最后一页通常不会占满整个页框,那么多出来的空间就被浪费了。虽然单个内部碎片不大,但数量多了也很可观。
    • 页表开销:页表本身是存放在内存中的,如果逻辑地址空间很大(比如32位系统有4GB),页表本身就会变得非常巨大,占用大量内存。为了解决这个问题,又发展出了多级页表、反向页表等技术。

L22 段页结合的实际内存管理

分段和分页各有优劣,那么能不能把它们结合起来,取长补短呢?答案是肯定的。这就是段页式管理(Segmented Paging)

这种机制融合了两者的优点:既保留了分段的逻辑划分、易于共享和保护的特性,又通过分页解决了分段的外部碎片问题。

  • 核心思想:先将程序按逻辑分段,然后把每个段再进行分页。
  • 地址表示:逻辑地址变成了三维结构:(段号, 段内页号, 页内偏移量)
  • 实现机制
    • 系统为每个进程维护一个段表
    • 段表的每个表项不再是段的物理基址,而是指向该段对应的页表的基址。
  • 地址翻译过程:这是一个两级查找的过程。
    1. CPU 拿到逻辑地址 (s, p, d)
    2. 用段号 s 查段表,找到该段的页表的基址。
    3. 用页号 p 查该段的页表,找到对应的帧号 f
    4. 计算最终物理地址:Physical Address = f * 帧大小 + d

段页式管理是现代操作系统(如Intel x86架构的保护模式)中实际采用的内存管理方式。它虽然增加了地址翻译的复杂度(需要两次内存访问,当然TLB可以缓解这个问题),但提供了非常强大和灵活的内存视图。

L23 请求调页内存换入

上面讨论的所有机制,都有一个共同的前提:程序在运行前,必须把需要的部分(段或页)全部加载到内存中。如果程序太大,内存不够怎么办?

虚拟内存(Virtual Memory) 技术就是为了解决这个问题而生的。它彻底打破了“程序必须完全在内存中才能运行”的限制,允许程序使用的逻辑地址空间远大于实际的物理内存空间。

请求调页(Demand Paging) 是实现虚拟内存最常用的方法。

  • 核心思想:不要在程序开始时就加载所有页,而是在真正需要访问某个页时,才把它从磁盘加载到内存。这种懒加载(Lazy Loading)策略,也叫请求调页
  • 实现机制
    • 需要在页表项中增加一个有效-无效位(Valid-Invalid Bit)
      • Valid(或1):表示该页在内存中,页表项有效。
      • Invalid(或0):表示该页不在内存中(可能在磁盘上),或者该地址根本不合法。
  • 页错误(Page Fault)处理流程:这是请求调页的核心。
    1. 当CPU访问一个逻辑地址时,MMU进行地址翻译,发现对应页的页表项是 Invalid
    2. MMU 捕捉到这个错误,并触发一个硬件中断(Trap),这个中断就是“页错误”。
    3. CPU控制权转交给操作系统的页错误处理程序
    4. 操作系统检查这个地址是否合法,以及该页在磁盘(通常是一个称为“交换空间”或“交换文件”的区域)上的位置。
    5. 在物理内存中找到一个空闲的帧。
    6. 发起一次磁盘I/O操作,将所需的页从磁盘**换入(Swap-in)**到这个空闲帧中。
    7. I/O完成后,更新该进程的页表,将对应的页表项设置为 Valid,并填入正确的帧号。
    8. 重新执行导致页错误的那个指令。这次,地址翻译就能成功了。

请求调页带来了巨大的好处:程序启动更快、内存利用率更高、系统可以支持更大的并发度,并且能够运行比物理内存还大的程序。

L24 内存换出

请求调页的流程中有一个关键步骤:找到一个“空闲的帧”。但如果物理内存已经满了,一个空闲帧都没有了怎么办?

这时候,操作系统就必须做出选择:挑选一个当前在内存中的页,把它写回磁盘(如果它被修改过的话),以腾出空间。这个过程就是页面置换(Page Replacement),被换出去的页称为“牺牲页(Victim Page)”。

页面置換算法的好坏,直接决定了虚拟内存系统的性能。一个好的算法,应该选择一个“未来最不可能被访问到”的页进行置换,从而最小化页错误的次数。

以下是几种经典的页面置换算法:

  1. 先进先出(FIFO)算法
    • 思路:置换最先进入内存的页面。实现简单,用一个队列即可。
    • 问题:性能较差。一个很早就进入内存但一直被频繁访问的“热点”页面,可能会被无情地换出。它还会遭受“Belady异常”——即分配的帧数越多,缺页率反而可能上升的诡异现象。
  2. 最优(OPT/MIN)算法
    • 思路:置换未来最长时间内不会被访问的页面。
    • 评价:这是一种理想化的算法,缺页率最低,可以作为衡量其他算法性能的基准。但它需要预知未来,因此在实际中无法实现。
  3. 最近最少使用(LRU - Least Recently Used)算法
    • 思路:置换最长时间没有被访问过的页面。这是基于“局部性原理”——如果一个页面最近被访问了,那么它在不久的将来也很可能被再次访问。
    • 评价:性能接近最优算法,非常好。但实现起来很困难,需要硬件支持来记录每个页面的访问时间,开销很大。
  4. LRU 的近似算法:由于真LRU太难实现,实际系统采用的都是它的近似算法。
    • 二次机会(Second-Chance)/ 时钟(Clock)算法:这是对FIFO的改进。页面被组织成一个环形队列(像一个钟面)。每个页有一个引用位(Reference Bit),访问时由硬件置为1。置换时,指针从当前位置开始扫描。如果遇到引用位为1的页,就给它“第二次机会”,将其引用位清零,然后继续扫描。如果遇到引用位为0的页,就选择它作为牺牲页。这种算法在性能和实现开销之间取得了很好的平衡。

如果一个进程分配到的帧数太少,导致它需要用到的页面集合(工作集)无法全部驻留在内存中,那么它会频繁地发生页错误,CPU大部分时间都在等待磁盘I/O,而不是执行代码。这种系统性能急剧下降的状态,称为颠簸(Thrashing)

总结

这一切的核心,都是抽象。通过在逻辑和物理之间增加一层又一层的映射,操作系统为我们程序员隐藏了底层硬件的复杂性,提供了一个简洁、巨大、安全的内存模型。当然,这一切都是有代价的,那就是地址翻译的性能开销和系统实现的复杂性。但相比于它带来的巨大好处,这些代价完全值得。

理解了内存管理的这些底层原理,以后再遇到内存相关的 Bug,或者思考程序性能优化时,或许就能有更深入的视角了。


操作系统学习手记02
http://st3r665.github.io/2025/08/31/操作系统学习手记02/
作者
St3r
发布于
2025年8月31日
许可协议