Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

Einführung

首先来看一下计算机存储的层次结构:

image-20250403010809824

从上往下依次是寄存器、缓存、内存、硬盘以及磁带驱动器。存储空间大小(从上往下)依次递增,但读取速度依次递减。

我们这章主要关注内存(Hauptspeicher)。

物理内存管理

假如所有程序可以完全访问整个物理内存,那么每个程序都有可能占用整个主内存,甚至可能覆盖掉系统内存区域,导致系统崩溃。包括程序直接也可能会相互干扰。所以我们希望给每个进程分配独立的存储空间。

寻址(Adressierung)

而为了更好地管理物理地址空间(Physischer Adressraum),我们会从中抽象出逻辑地址空间(Logischer Adressraum),然后通过内存映射(Speicherabbildung,Memory Address Translation)将逻辑地址转换成实际的物理地址。这种内存映射也叫做寻址(Adressierung)

寻址方式一般有3种:

直接寻址(Direkte Adressierung)

这种寻址的映射相当于是 $f = id$ 。图示:

image-20250403103732392

但一旦有多个程序运行的话,我们会碰到以下问题:

  1. 大量交换(Extensives Swapping

    在某一时刻一定是只有一个程序在运行并且使用内存空间,所以当操作系统想要切换运行的程序时,需要把当前运行程序的内存内容保存到硬盘,然后从硬盘加载下一个程序到内存中。

    这样会导致效率极其低下(或者说非常慢)。

  2. 重定位(Relokation

    由于程序可以被加载到内存的不同位置,所以每次重新分配地址时需要改写(重定位)所有的地址,会非常的麻烦。(可以看下面举的例子)

基址寻址(Basis-Adressierung)

每一个进程会得到一个基址(Basisadresse)$b_x$ ,这个进程的所有地址都是基于这个基址的一个相对地址。

图示:

image-20250403103914097

举个例子区分一下基址寻址(Basis-Adressierung)和直接寻址(Direkte Adressierung):假设我们现在的内存空间有15个Block,并且有2个程序在使用内存,我们将1-5分给第一个程序,把6-10分给第二个程序。如果是直接寻址的话,第一个程序的地址需要记录成(1,2,3,4,5),第二个需要记录(6,7,8,9,10)。但如果是基址寻址的话,第二个只需要记录(5;1,2,3,4,5),主要会记录相对位置。这样一来在重新分配内存空间时只需要修改basis(也就是5)即可(比如说(10;1,2,3,4,5)),其他的不需要改变。

当然基址寻址(Basis-Adressierung)也有个缺点,那就是每次计算地址时都需要进行一次加法操作。(Aufwendige Additionsoperation

段式寻址(Segmentadressierung)

将Adressraums分成不同长度的逻辑段(logische Segmente),比如说分成Stack-, Daten-, Code-Segment。这样做的好处是可以给每一段内容不同的权限(Zugriffsberechtigungen),以提高安全性,针对pwn的攻击特别有用。

每个段需要两个基本信息:段起始地址(Segmentanfangsadresse)段的长度(Länge des Segments)

CPU通常提供一个段寄存器(Segmentregister)来保存段信息。

只不过主要是32 位操作系统会使用段机制,而在64 位系统中基本不用段机制,最多用于线程管理(TLS)和兼容性支持。

我们来看一下x86里的段式寻址(Segmentadressierung)的例子:

x86 架构会使用一个叫做全局描述符表(Global Descriptor Table, GDT)的数据结构来管理段,每一项会包含以下信息:

  • 基地址(Basisadresse)
  • 段的长度(Länge des Segments)
  • 标志位(Flags):例如Zugriff以及Segment的类型(Code,Daten还是Stack)

而段寄存器(Segmentregister)中保存的不是地址,而是GDT表中的一个索引。

image-20250403161731590

1. 想要访问 cs:0x800 的话,需要先在段寄存器(Segmentregister)找到cs对应的值,也就是 0x08,然后找到这个值在DGT对应的那一行信息。随后计算 0x10000 + 0x800 = 0x10800。确认它在范围内,所以这个就是我们要的地址。

2. 想要访问 es:0x800 的话,需要先在段寄存器(Segmentregister)找到es对应的值,也就是 0x10,然后找到这个值在DGT对应的那一行信息。随后计算 0x20000 + 0x800,会发现超出范围了,因为它的长度只有 0x00800,所以会返回 “Segmentation Fault”。

空闲内存管理(Freispeicherverwaltung)

存储结构

位图(Bitmap)

将存储空间分成相同大小的块(Blöcke),每个块用一个bit标记,1表示已占用,0表示空闲。这一串内容就是位图(Bitmap)

例子:

image-20250403214805646

但是实现过程中会遇到一个问题:Blcok的大小该怎么选择呢?选小了就会需要更大位图,但选大了又容易导致浪费。

优势:

  • 可以简单快速地访问固定大小的内存块。

缺点:

  • 假如有个进程需要k个Block,那么就需要在位图里找到7个连续的0,这个的开销就太大了。
链表(Verkettete Liste)

还是将存储空间分成相同大小的块(Blöcke),然后链表记录块的占用信息:

链表的每一项存储:

  • 开始地址
  • 长度
  • 指向下一项的指针(einen Zeiger auf den nächsten Eintrag (oder Terminator T == NULL))

例子:

image-20250403215806991

P表示已被进程占用,F表空闲。

优点:

  • 灵活(Flexible Speicheraufteilung)

缺点:

  • 需要线性搜索(Lineare Suche)

    需要挨个找有没有合适的。

  • 没有固定的管理结构

优化的办法:

  • 分别维护两个链表,一个存储已分配的内存块,一个存储空闲的内存块。
  • 按照内存块大小排序
  • 使用平衡树结构管理链表((balancierter) Baumstruktur)
  • 同时使用多个链表

内存分配策略(Belegungstrategien)

由于需要一直分配再释放进程的存储空间,所以存储空间容易碎片化。比如说在上面那张图里有着好几个空闲的连续的存储空间,我们该如何给每个进程选择一块合适的位置呢?

一般会用到以下几个策略:

(以这个为例:)

image-20250403225311550

First-Fit

从前往后,找到大小够的直接给。

image-20250403225454516

Next-Fit

从上次停止的地方开始,找到够的直接给。

image-20250403225512322

Best-Fit

分配“最小剩余空间”的空闲块(Freibereich mit dem geringsten Verschnitt

image-20250403225523616

Worst-Fit

分配“最大剩余空间”的空闲块(Freibereich mit dem größten Verschnitt

image-20250403225534503

Buddy-Algorithmus

将存储空间分成 $2^k$ 大小的Block,where $l \leq k \leq u$,$2^l$是最小的可占用的空间大小,$2^u$是最大的可占用的空间大小。当一个进程需要的存储空间为 $x$ 时,会给它分配一块大小为 $2^{\lfloor log_2(x) \rfloor}$ 的Block。

这样一来每次最多会浪费半个Block大小的存储空间。

分配的具体算法看这个例子就好:(需要额外注意它合并空闲块的算法)

image-20250403230727156

image-20250403230740892

image-20250403230752724

碎片化(Fragmentierung)

一般会分成内部碎片与外部碎片。

内部碎片(Interne Fragmentierung):在按块分配内存时,通常会分配多于实际所需的内存。这部分多余的内存区域既不能被当前进程使用(内部),也不能被其他进程使用(外部)。也就是会产生很多碎片(Verschnitt)。这些多余的部分就被叫做内部碎片。

例如Buddy-Algorithmus主要带来的便是内部碎片。它给每个进程分配了一个Block之后,这个Block里多余的部分是不会分配给其他进程的。

外部碎片(Externe Fragmentierung):由于创建和释放内存块的动态变化,会在主存中产生空洞(Löcher)。即使总的空闲内存量是足够的,也可能没有一个足够大的连续内存区域来满足一个请求。这些空闲内存就被叫做外部碎片。

比如说使用Best-Fit策略,就会剩下来很多小的不连续的空闲块。

虚拟内存管理(Virtuelle Speicherverwaltung)

一般情况下,进程的虚拟地址空间是大于实际可用的物理内存的。所以我们同样需要管理虚拟内存,以决定将哪些部分加载到(物理的实际)内存中。

最常用的实现方式便是分页(Paging)

将虚拟地址空间被划分为页面(Seiten,Pages),内存(Hauptspeicher)被划分为物理页框(Kacheln,Frames)。通常每一页和每个页框的大小相同,或者至少页的大小是页框大小的倍数。

操作系统需要做的便是将页面映射到页框(Abbilden von Seiten auf Kacheln)。

image-20250404102118727

这个映射具体由内存管理单元(Memory Management Unit,MMU)完成。

Paging中三个重要概念:(下面会详细讲)

  • 页表(Page Table)

    负责管理虚拟页与物理页框之间的映射关系,操作系统为每个进程维护一张页表。

  • 缺页异常(Seitenfehler,Page Fault)

    当访问一个尚未被加载到内存的虚拟页时,就会产生一个页错误(Page Fault),硬件会发出一个中断信号。操作系统响应这个中断,把缺失的页从磁盘加载到内存

  • 页面换入(Seiteneinlagerung)

    如果内存已满,则需要将已有的页移出(换出)来腾出空间,被换出的页会被保存到硬盘中。

流程

程序访问虚拟页 → 检查页表 → 若页不在内存 → 触发页错误(Page Fault) → OS 调入页面 → 若内存满 → 页面置换 → 更新页表 → 继续执行

硬件组件

如之前提到的,内存管理单元(Memory Management Unit,MMU)负责完成地址的映射。

它的工作流程如图:

image-20250404104342208

  1. CPU 发送虚拟地址(VA)给 MMU

  2. MMU 根据虚拟地址确定 Page Table Entry 的地址(PTEA)

  3. MMU 从缓存或内存中读取页表项(PTE)
  4. MMU 根据页表项计算出物理地址(PA)
  5. 缓存/内存将物理地址对应的数据(Daten)发送回 CPU

但这样会带来一个问题:由于页表通常存放在主存中,所以地址转换(Adressabbildung)的开销会非常大。

因此可以利用缓存(Cache)来改善这个问题:地址转换后备缓冲区(Translation Lookaside Buffer,TLB),它负责存储近期访问过的页表项(Page Table Entry)。

流程:

image-20250404105231210

  1. CPU 发送虚拟地址 VA
  2. MMU 提取出虚拟页号 VPN,查询 TLB
  3. 如果 TLB 命中(TLB Hit),直接获得页表项(PTE)
  4. MMU 根据 PTE 生成物理地址(PA)
  5. 数据从物理内存中返回到 CPU

没有 Hit 的情况叫做 TLB Miss,会需要一次额外的内存访问来加载页表项(PTE):

image-20250404105349835

页表(Page Table)

一个虚拟地址 v 会被解释为v=(s,w),其中s表示页号(Seitenummer),w表示偏移量(Offset)。

image-20250404163614044

每一页都对应一个页表项(1 Page-Table-Eintrag pro Seite),而这个页表项里会存储页框号(Frame-Nummer)。在转换地址时,s会被直接转换成页框号(Frame-Nummer),然后和w拼起来组成真正的物理地址。如图:

image-20250404164126567

具体的转换过程可以看文章末尾的相关例题。

而页表中除了地址,还会存储附加信息,通过单个位(Bit)来表示:

  • P(present)位:是否存在

    表示对应的物理地址是否存在/有效

  • U/S(user/supervisor)位:用户/内核模式访问控制

    指示是否只有操作系统内核(Betriebssystemkern)可以访问该页面

  • R(referenced)/ A(accessed)位:是否被访问过

    只要这个页面被访问(zugegriffen),CPU就会自动设置这一位(为1)。

  • M(modify)/ Dirty Bit:是否被修改过

    只要这页里有至少一个字节被写入(geschrieben),CPU就会自动设置这一位(为1)。

  • XD(execute-disable)位:禁止执行位

    表示该页是否允许执行指令。属于保护措施。

多级页表(Mehrstufige Seitentabellen)

如果虚拟地址空间很大,那么相应的页表(Page Table)也非常大,意味着将整个页表放入内存会占用大量内存资源。

所以需要引入多级页表结构,每一级页表只负责一部分地址空间,按需加载。

image-20250404165428484

页错误(Seitenfehler,Page Fault)

当程序访问一个虚拟地址时,如果该地址对应的页面不在内存中(即页表中的 P-Bit 未设置),就会发生页错误(Seitenfehler,Page Fault)。硬件会触发中断(Interrupt),交由操作系统处理,执行页错误处理器(Page-Fault-Handler)。

处理时分2种情况:

  1. 页面被换出(ausgelagert):

    查找是否有空闲页框(Kachel):

    • 有空闲:直接加载页面
    • 无空闲:使用页面置换算法(Seitenersetzungsstrategien)释放空间
  2. 页面不存在也未被换出(nicht ausgelagert

    操作系统会触发内存保护错误(Speicherschutz-Fehler)

之后便会更新页表项(Page-Table-Eintrag):设置P-Bit,清除R-Bit和M-Bit。

这些操作都完成之后便会恢复程序的状态。

目前这些转换地址的流程总结一下就是:

image-20250404165524088

页面置换算法(Seitenersetzungsstrategien)

替换页面的时候,我们有3个问题需要考虑:当内存满了,我们应该

  1. 什么时候加载页面?

    针对这个问题我们有2种方法:

    1. 按需分页(On-Demand Paging):页面只有在确实被访问到时,才会被加载到主存。
    2. 预取(Prefetching):会提前加载页面以备不时之需。但这样一来又会回到一开始的问题,我们需要在提前多久加载哪些内容呢?
  2. 将页面加载到哪里

    所有页框(Kacheln)地位相同,一般不需要策略决定。但在实际中操作系统一般会对内核代码和内核数据做特殊处理,比如说恒等映射(Identity Mapping),即虚拟地址 = 物理地址。这样效率会高很多。

  3. 如果内存已经满了,我们该把现有的哪一页给换下去

    这些正是我们下面会仔细讲的内容。

理论上最完美的算法当然是替换掉那个 下一次将被访问时间最晚 的页面。操作系统肯定是无法做到预知未来的,不过我们可以尝试往这个方向靠。

FIFO

跟普通的First-in-First-Out一样,先替换掉最早来的。

例子:

image-20250404214804028

缺点:在实际情况里大部分最早来的都是最重要的,所以之后大概率会多次访问,如果被替换下去了会一直触发Page Fault,开销会很大。

所以相比之下较常用的是它的改版:Second-Chance-Algorithmus。

Second-Chance-Algorithmus

在FIFO的基础上,会给每页第二次机会。

页面被访问时会设置R-bit = 1。如果在队伍首则判断R-Bit的值,等于0就会被替换掉(如果M=1需要先执行写入操作然后再替换),等于1的话就给R-Bit的值更新成0然后挪到队伍尾。

图示:

image-20250404214820967

缺点:每次都移动整个队列非常麻烦。

所以使用更高效的数据结构替代队列:Clock。

Clock-Algorithmus

将所有页面按顺序组成一个环状列表(就像Clock一样),有一个“时钟指针”指向其中某个页面,表示当前检查的位置。这个指针用于寻找要替换的页面。

具体流程:

  1. 如果当前指向的页面的 R 位为 0:

    • 替换这个页面为新页面

    • 指针移动一格(顺时针)

  2. 如果 R 位为 1:

    • 将 R 位清除(设为 0)

    • 指针继续移动一格,检查下一个页面

  3. 重复 1-2 步骤:

    • 直到找到一个 R 位为 0 的页面

    • 这个页面会被替换

  4. 如果该页面的 M 位(Modified Bit)为 1:

    • 将该页面内容写回磁盘
  5. 如果 M 位为 0:

    • 直接丢弃该页面(因为它未被修改)

具体例子见文章结尾的例题。

Clock 例子

假设现在需要进行这些操作:

image-20250404221319540

那么:(每一步里被修改了的内容都用橙色标出来了的)

image-20250404221345638

image-20250404221414899

image-20250404221429321

Least Recently Used (LRU)

因为程序的局部性原理(Lokalitätseigenschaft),我们可以假设过去经常使用的页面,未来也可能被再次使用。

当发生Page Fault时,替换掉那个“最长时间没被使用”的页面。

缺点:这个方法的实现需要一个双向链表,记录所有被访问页面,每次访问页面都要把它移到表头(意味着频繁更新),开销太大了,效率不高。

不过我们可以考虑使用软件近似模拟LRU(用“近似但效率更高”的方法来模拟 LRU 的行为趋势):Not Frequently Used (NFU) 和 Aging。

LRU例子

假设现在有6页(Page)和4个页框(Kacheln),我们想要按照这个顺序访问:1 3 5 4 2 4 3 2 1 0 5 3 ,那么具体的过程便是:
image-20250404223527711

Not Frequently Used (NFU)

  • 给每个页面 P 引入一个软件计数器 $A_p$
    • 固定大小,例如 b 位(bit),初始为 0
  • 每次定时器中断(Timer-Interrupt)时:
    • 操作系统检查所有页面的 R 位(被访问位)
    • 如果某个页面 P 的 R 位被设置:
      • 对应的计数器 $A_p$ 加 1

缺点:NFU并不知道“访问是否是最近的”,只知道总访问次数。比如说一个页面过去访问很多,但最近一直没被访问,NFU仍然会认为它很重要。

为了解决这个问题便需要Aging-Verfahren。

NFU 例子1

页面编号:0, 1, 2, 3, 4

访问顺序(参考链):0111221123324434

得到的计数器值:$A_0=1, A_1=5, A_2=4, A_3=3, A_4=3,$

NFU 例子2

假设现在有6页(Page)和4个页框(Kacheln),我们想要按照这个顺序访问:1 3 5 4 2 4 3 2 1 0 5 3 ,那么具体的过程便是:

image-20250404224410764

Aging-Verfahren

Aging主要的思路是记录访问的“老化”程度(Erfassung der Alterung von Zugriffen)。

首先设置一个固定时间间隔 t(比如 20 毫秒)并记录哪些页面在这个间隔内被访问了。

同样会给每个页面 P 引入一个软件计数器 $A_p$。

每隔 t 的时间都会更新$A_p$:

  • 将计数器向右移一位(shift)
  • 把当前 R 位的值放到最高位

Aging 例子

假设:Aging 计数器为 4 位(二进制),当前计数器 $A_p = 1100_2 = 12_{10}$,当前的引用位 R = 0。

那么新的 $A_p$ 会变成 $0110_2 = 6_{10}$ 。

image-20250404232557694

当然,Aging也是有一定局限性的,但是我没看懂。(乐)

Working-Set Ersetzungs-Verfahren

前面这些策略全都是基于按需分页(Demand Paging)的,我们现在来看点不一样的。

首先,一个进程当前真正需要的那一批页面(Die Menge der Seiten, die ein Prozess aktuell benötigt)称为:工作集(Working Set)。而抖动(Setitenflattern,Thrashing)指的是当进程频繁地触发Page Fault。

Working-Set-Modell中:

  • 在任意时刻 t,工作集 w(k, t) 表示进程在最近的 k 次内存访问中访问到的页面集合。(从t往前数k个)

比如说给定一个访问序列:26157777516234123444344,并且将k设置为4,那么w(k, t1) = {3, 4},w(8, t1) = {2, 3, 4}。

最简单的办法肯定是只替换不在当前工作集内的页面,但是每次都往前追踪k次访问带来的开销太大了。所以我们使用进程的执行时间(Rechenzeit)来近似。

需要先定义几个值:

  • 执行时间区间 $\tau$

  • 时间间隔 I(Clock Tick 间隔)

  • 给每个进程设置一个单调递增的计数器 $Z$,用于近似进程的执行时间(Ausführungszeit /Rechenzeit),实际使用CPU的时间)
  • 给每一页 P 设置一个计数器 $Z_p$,用于近似上次访问的时间。

Working-Set Ersetzungs-Verfahren:

  • 当发生页面错误时,操作系统会检查所有页的 R 位(R-Bits):
    • 如果R=1 für Seite P:页面属于 Working-Set,不会被替换,但是会将当前的 $Z_p$ 更新成 $Z$ 的值;
    • 如果R=0 für Seite P:
      • 如果$Z - Z_P > \tau$:P不属于Working-Set,会被替换掉;
      • 如果$Z - Z_P \leq \tau$:虽然 P 在 I 这段时间里没有被使用过,但还是属于 Working-Set。
  • 如果找到了多个符合条件的候选页:系统会选择 $Z_p$ 最小的那一页,称为 $P_{\text{old}}$。
  • 如果找不到任何页面可以换出(所有页面都还在 Working Set 中):替换最老的页面(即 $P_{\text{old}}$)。

缺点:实现起来非常复杂。

所以实际中会用 Working-Set Ansatz mit Clock-artiger Verwaltung der Seiten。

Working Set with Clock

malloc()

Linux中的内存管理

使用4级页表(4-stufige Pagetabelle)。

内存分配(Speicher-Allokation):

  • Buddy 分配器(Buddy-Allocator,基于之前讲的Buddy-Algorithm):最小分配单位为页(page)

页面替换策略(Seitenersetzungsstrategie):

  • 使用 Swap 区:页不会立即被换出,而是暂存在 Swap 区
  • 操作系统会保留一部分空闲帧(Frames),以便快速加载需要的页面
  • 使用 Clock 替换算法的一个变种 作为页面替换策略

例题