学习操作系统时,虚拟内存、缺页中断、分页和地址转换经常被拆成几个独立问题。

其实可以先记住一条主线:程序使用的是虚拟地址,CPU 中的 MMU 负责把它转换成物理地址;如果需要访问的页面还没有装入物理内存,就会触发缺页中断,由操作系统把页面调入内存,然后继续执行程序。

把这条线理顺以后,很多概念就不再需要死记硬背了。

一、什么是虚拟内存

每个进程运行时,看到的并不是计算机中真实的物理内存,而是一套相对独立的虚拟地址空间。例如,一个程序可能认为自己正在访问地址 0x1000,但这段地址最终对应哪一块物理内存,需要由操作系统和硬件共同决定。

虚拟内存主要解决了几个问题。

第一,它让每个进程拥有相对独立的地址空间。进程 A 和进程 B 即使使用相同的虚拟地址,最终也可以映射到不同的物理内存,彼此不容易干扰。

第二,它提高了内存使用效率。程序运行时并不需要把全部代码和数据一次性装入内存,只加载当前真正需要的页面即可。

第三,它提供了内存保护。页表中可以记录某个页面是否可读、可写、可执行。如果程序越权访问,操作系统就可以终止它,避免影响其他进程。

不少人会把虚拟内存简单理解为“物理内存不够时,用硬盘顶上”。这种说法只讲到了一部分。磁盘交换空间确实可以作为后备存储,但虚拟内存更重要的价值是地址隔离、按需加载和访问保护。

二、虚拟地址是如何转换成物理地址的

程序执行一条访存指令时,CPU 产生的是虚拟地址。这个地址通常可以拆成两部分:虚拟页号和页内偏移量。

假设页面大小为 4 KB,一个虚拟地址可以理解为:

虚拟地址 = 虚拟页号 + 页内偏移量

MMU(Memory Management Unit,内存管理单元)会根据虚拟页号查询页表,找到对应的物理页框号,再和原来的页内偏移量组合,形成最终的物理地址。

物理地址 = 物理页框号 + 页内偏移量

页表存放在内存中,如果每次访问数据都先去内存查一次页表,速度会受到明显影响。因此 CPU 通常还会使用 TLB,可以把它理解为页表项的高速缓存。

一次地址转换大致可以分为以下几步:

  1. CPU 生成虚拟地址;

  2. MMU 先查询 TLB;

  3. TLB 命中时,直接得到物理页框号;

  4. TLB 未命中时,再查询内存中的页表;

  5. 页面已经在内存中,则完成地址转换并访问数据;

  6. 页面不在内存中,则触发缺页中断。

三、什么是缺页中断

缺页中断是指程序访问某个虚拟页面时,页表发现这个页面当前不在物理内存中,于是 CPU 暂停当前程序,转交给操作系统处理。

它虽然叫“中断”,但更准确地说属于由当前指令引发的异常。它不是键盘、网卡等外部设备随机发来的硬件中断。

操作系统处理缺页中断时,一般会经历下面的过程:

  1. 保存当前进程的运行现场;

  2. 检查这个虚拟地址是否合法;

  3. 如果访问非法,例如访问了没有权限的地址,则向进程发送异常信号,严重时直接结束进程;

  4. 如果访问合法,只是页面尚未装入,则寻找一个可用物理页框;

  5. 没有空闲页框时,根据页面置换算法选择一个页面淘汰;

  6. 如果被淘汰页面被修改过,需要先把它写回磁盘;

  7. 从磁盘把目标页面加载到物理内存;

  8. 更新页表和相关缓存;

  9. 恢复进程现场,重新执行刚才失败的指令。

四、分页和分段有什么区别

分页和分段都是管理内存的方式,但划分依据不同。

分页是把虚拟地址空间和物理内存都划分为固定大小的块。虚拟空间中的块叫页,物理内存中的块叫页框。例如,常见页面大小是 4 KB。程序员通常感知不到具体页面,分页主要服务于操作系统的内存管理。

分段则是按照程序的逻辑结构划分,例如代码段、数据段和栈段。每个段表达一个相对完整的逻辑区域,因此不同段的长度可以不同。

对比项分页分段
划分方式按固定大小划分按逻辑功能划分
大小页面大小固定段的大小不固定
地址组成页号与页内偏移段号与段内偏移
对程序员是否明显通常透明具有明显逻辑含义
主要碎片可能产生内部碎片容易产生外部碎片
主要目的提高内存管理效率按逻辑单元组织和保护程序

所谓内部碎片,是已经分配出去的页面内部存在未使用空间;外部碎片则是空闲空间分散在不同位置,虽然总量足够,却找不到一块连续的大空间。

现代通用操作系统通常以分页作为虚拟内存管理的核心。一些体系结构仍保留分段机制,但实际使用可能被弱化,或者与分页结合使用。

五、死锁产生的四个必要条件

死锁是指多个进程或线程互相等待对方持有的资源,导致所有参与者都无法继续执行。

死锁产生需要同时满足四个条件:

1. 互斥条件

某个资源在同一时刻只能被一个线程占用。例如,一把互斥锁被线程 A 获得后,线程 B 只能等待。

2. 请求并保持条件

线程已经持有部分资源,同时还在等待其他资源,而且等待期间不释放手中已有的资源。

3. 不可剥夺条件

已经分配给线程的资源,不能被系统强制抢走,只能由持有者主动释放。

4. 循环等待条件

多个线程形成环形等待关系。例如,线程 A 等待线程 B 的资源,线程 B 又在等待线程 A 的资源。

下面是一个常见的 Java 死锁结构:

Object lockA = new Object();
Object lockB = new Object();

// 线程1:先获得A,再等待B
synchronized (lockA) {
    synchronized (lockB) {
        // 处理业务
    }
}

// 线程2:先获得B,再等待A
synchronized (lockB) {
    synchronized (lockA) {
        // 处理业务
    }
}

如果两个线程分别拿到第一把锁,就可能互相等待。预防死锁的基本思路,是破坏四个必要条件中的任意一个。实际开发中最常见的办法,是规定统一的加锁顺序,例如所有线程都先获取 lockA,再获取 lockB,从而破坏循环等待条件。

此外,还可以使用带超时的锁、减少锁的持有时间,或者避免在持有锁时调用不确定耗时的外部服务。

六、什么是守护进程

守护进程是运行在后台、通常长期存在,并为系统或其他程序提供服务的进程。Linux 中常见的 sshd 负责远程登录服务,cron 负责定时任务,很多服务名称也习惯以字母 d 结尾,其中的 d 就来自 daemon。

守护进程一般有几个特点:

  • 长期在后台运行,不直接与用户终端交互;

  • 通常在系统启动时启动,关闭时停止;

  • 持续监听请求、处理定时任务或维护系统状态;

  • 日志一般写入日志文件或系统日志,而不是输出到终端。

Logo

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

更多推荐