操作系统第二讲:Boot, Process, Kernel

复习目标:理解计算机从按下电源键到操作系统启动的过程;理解“程序”和“进程”的区别;理解进程的内存布局;理解为什么操作系统必须依赖用户态/内核态、特权指令、内存保护和时钟中断来实现隔离与控制。
参考课件:Operating Systems Lecture 2 — Boot, Process, Kernel, Prof

目录

0. 这一讲到底在讲什么?

第一讲我们讲了操作系统的三个角色:

  • Referee(裁判):分配资源,保证公平,防止程序互相破坏。
  • Illusionist(魔术师):制造“每个程序好像独占 CPU、独占内存”的幻觉。
  • Glue(胶水):屏蔽硬件差异,给应用提供统一接口。

第二讲就是把这些概念落地。它回答三个核心问题:

  1. 电脑是怎么启动起来的?
    从 BIOS/UEFI 到 bootloader,再到 kernel。

  2. 程序是怎么变成进程的?
    程序是磁盘上的静态文件,进程是正在运行的程序实例。

  3. 为什么普通程序不能随便控制硬件和内核?
    因为 CPU 和 OS 共同提供了用户态/内核态、特权指令、内存保护、时钟中断等机制。

一句话概括本讲:

计算机启动时,BIOS/UEFI 先运行,bootloader 把 kernel 加载进内存;kernel 启动后管理进程;为了让进程既能高效运行又不能乱搞系统,CPU 和 OS 共同提供用户态/内核态、特权指令、内存保护和时钟中断。


1. 开机之后发生了什么?

1.1 BIOS:开机后第一个跑的软件

BIOS,全称 Basic Input Output System,可以理解为主板自带的固件。它不是你安装在硬盘里的普通软件,而是更靠近硬件的启动程序,通常存储在主板 ROM / Flash 里。

BIOS 主要做这些事:

  1. POST 自检
    检查 CPU、内存、显示设备、键盘等硬件是否大致正常。

  2. 初始化硬件状态
    比如 VGA 显示、键盘、基础 I/O 设备。

  3. 构建硬件描述信息
    比如 ACPI,用来告诉操作系统硬件长什么样、如何管理电源和设备。

  4. 从磁盘加载 bootloader
    传统 BIOS 通常从启动盘的第一个扇区加载 bootloader。一个扇区通常是 512 字节。

  5. 把控制权交给 bootloader
    也就是设置代码段和指令指针,让 CPU 跳到 bootloader 继续执行。

可以把 BIOS 想象成:

电脑刚醒来时的“第一位引导员”。它不负责真正管理整个系统,只负责把机器带到可以继续启动操作系统的状态。

启动流程大概是:

Power On
  ↓
CPU 从固定地址开始执行 BIOS
  ↓
BIOS 做硬件自检和初始化
  ↓
BIOS 找到启动设备
  ↓
BIOS 加载 bootloader 到内存
  ↓
CPU 跳转到 bootloader

1.2 Bootloader:真正把内核搬进内存的人

Bootloader 是启动加载器。它通常属于操作系统的一部分,或者和操作系统一起存放在磁盘、U 盘等介质上。

Bootloader 主要做四件事:

  1. 从 real mode 切换到 protected mode
  2. 检查 kernel image 是否正确
  3. 把 kernel 从磁盘加载到内存
  4. 把控制权转交给真正的 OS kernel

用一句话说:

BIOS 负责找到 bootloader;bootloader 负责找到并加载 kernel;kernel 才是真正开始管理系统的操作系统核心。

也可以这样理解:

BIOS:我知道怎么从硬件启动,并找到启动器。
Bootloader:我知道这个 OS 的内核文件在哪里、格式是什么、怎么加载。
Kernel:我负责管理 CPU、内存、磁盘、进程、文件系统等真正的操作系统功能。

1.3 为什么 BIOS 不直接加载 kernel?

这是一个很自然的问题:既然 BIOS 可以读磁盘,为什么不直接把内核加载起来?

原因是:不同操作系统的内核格式、启动方式、文件系统、启动参数都不一样。如果 BIOS 直接支持所有 OS,会变得极其复杂,也很难维护。

所以中间加一层 bootloader 更合理。

它带来的好处包括:

  • 灵活性:不同 OS 可以使用不同 bootloader。
  • 兼容性:BIOS 不需要理解所有内核格式。
  • 启动设备选择:可以从硬盘、U 盘、网络等位置启动。
  • 多系统管理:例如 GRUB 可以让你选择 Linux 或 Windows。
  • 安全验证:可以检查内核镜像是否被篡改。
  • 错误处理:启动失败时可以显示提示或进入恢复模式。
  • 便于更新:bootloader 可以随 OS 更新,而不需要改主板固件。

所以启动链路不是:

BIOS → Kernel

而是:

BIOS → Bootloader → Kernel

2. Real Mode 和 Protected Mode

2.1 Real Mode 是什么?

Real mode 是 x86 早期为了兼容 8086 而保留的模式。它非常原始,能力有限。

典型特点:

  • 地址空间很小,早期约 1MB。
  • 没有现代意义上的内存保护。
  • 没有完善的权限隔离。
  • 更像“裸机直接跑代码”的状态。

电脑刚启动时,为了兼容历史,CPU 会先处在 real mode 或类似的早期启动状态。这个阶段只能做最基础的初始化。


2.2 Protected Mode 是什么?

Protected mode 是现代操作系统真正需要的模式。它支持:

  • 更大的地址空间。
  • 内存保护。
  • 用户态/内核态权限隔离。
  • 多任务。
  • 分段、分页等地址管理机制。

为什么 bootloader 要从 real mode 切换到 protected mode?

因为 kernel 想实现:

  • 进程隔离。
  • 虚拟内存。
  • 系统调用。
  • 中断和异常处理。
  • 用户态/内核态切换。

这些都需要 protected mode 这样的硬件支持。

可以把它理解为:

real mode:机器刚启动时的原始状态
protected mode:现代操作系统真正开始工作的状态

3. JOS 启动案例:代码到底在干什么?

课件里用了 JOS 作为启动案例。JOS 是 MIT 6.828 / 6.S081 里常用的教学操作系统,它的启动代码非常适合帮助我们理解“从硬件到 OS”的过程。


3.1 CPU 开机后的第一条指令

课件中给出了这条指令:

[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b

这表示:

  • CPU 刚启动时,CS = 0xf000
  • IP = 0xfff0
  • 所以物理地址是 0xffff0
  • 第一条指令是一个 long jump / far jump

在 real mode 下,物理地址计算方式是:

physical address = CS × 16 + IP

代入:

CS = 0xf000
IP = 0xfff0

physical address
= 0xf000 × 16 + 0xfff0
= 0xf0000 + 0xfff0
= 0xffff0

所以 CPU 开机后不是随便执行,而是硬件规定它从固定地址开始执行。这个地址位于 BIOS ROM 区域的顶部附近。

ljmp $0xf000,$0xe05b 的意思是:

把 CS 设置为 0xf000
把 IP 设置为 0xe05b
跳到 BIOS 主体初始化代码继续执行

这条指令的本质是:

CPU 先落在一个固定入口点,然后跳到 BIOS 真正的初始化逻辑。


3.2 JOS bootloader 汇编代码主线

课件里的汇编代码看起来比较多,但主线其实很清晰:

.code16
cli
cld

这表示当前还是 16-bit real mode。

  • cli:关闭中断,避免启动过程被打断。
  • cld:清除方向标志,保证字符串操作按正常方向进行。

接下来会初始化段寄存器:

xorw %ax, %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %ss

这一步是为了建立一个干净的段环境。

然后代码会启用 A20。A20 是 x86 的历史包袱。早期为了兼容 8086,超过 1MB 的地址会发生回绕。启用 A20 之后,CPU 才能正常访问 1MB 以上的内存。

可以简单记:

开 A20 是为了突破早期 1MB 地址限制。

接下来 bootloader 会加载 GDT,并设置 CR0 的 PE bit:

lgdt gdtdesc

movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0

这里:

  • lgdt gdtdesc:加载全局描述符表 GDT。
  • cr0:CPU 控制寄存器。
  • CR0_PE_ON:Protection Enable,打开后进入 protected mode。

然后还需要一次 far jump:

ljmp $PROT_MODE_CSEG, $protcseg

为什么要 jump?

因为 CPU 需要刷新 CS,开始使用 protected mode 下的代码段选择子。之后代码进入 .code32,也就是 32-bit protected mode。

后面会设置各个段寄存器和栈:

movw $PROT_MODE_DSEG, %ax
movw %ax, %ds
movw %ax, %es
movw %ax, %fs
movw %ax, %gs
movw %ax, %ss

movl $start, %esp
call bootmain

这一段的意思是:

  1. 设置数据段、栈段等段寄存器。
  2. 设置栈指针 esp
  3. 调用 C 函数 bootmain()

所以 bootloader 汇编部分的完整主线是:

16-bit real mode
  ↓
关中断,初始化段寄存器
  ↓
启用 A20
  ↓
设置 GDT
  ↓
打开 CR0 的 PE 位
  ↓
far jump 切换到 protected mode
  ↓
进入 32-bit 代码
  ↓
设置栈
  ↓
调用 bootmain()

这段代码最重要的意义是:

它把机器从“刚开机的原始状态”带到“可以运行 C 代码加载内核”的状态。


3.3 bootmain:加载 ELF 内核

课件里的 C 代码大意是读取磁盘上的 ELF 内核文件。

ELF 是 Linux / Unix 系统常见的可执行文件格式。一个 ELF 文件里会有:

  • ELF Header:描述整个文件。
  • Program Headers:告诉 loader 哪些段要加载到内存。
  • Segments:真正的代码和数据。

核心流程可以简化为:

void bootmain(void) {
    // 1. 读取磁盘第一页,拿到 ELF header
    readseg((uint32_t) ELFHDR, SECTSIZE * 8, 0);

    // 2. 检查 ELF magic number,确认它确实是 ELF 文件
    if (ELFHDR->e_magic != ELF_MAGIC)
        goto bad;

    // 3. 遍历 program header,把每个 segment 加载到指定物理地址
    for (...) {
        readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
    }

    // 4. 跳转到内核入口地址
    ((void (*)(void)) (ELFHDR->e_entry))();
}

这段代码的本质是:

bootloader 不需要理解整个操作系统,它只需要看懂 kernel image 的格式,把代码段和数据段搬进内存,然后跳到 kernel 的入口点。

其中 readseg() 做的是按扇区读磁盘。磁盘通常不是按字节读,而是按 sector 这样的块来读。

所以启动加载的流程是:

读取 ELF header
  ↓
检查 ELF magic
  ↓
读取 program headers
  ↓
把每个 segment 加载到目标物理地址
  ↓
跳转到 e_entry,也就是内核入口

4. UEFI:更现代的 BIOS

UEFI,全称 Unified Extensible Firmware Interface,可以理解为 BIOS 的继任者。

它相比传统 BIOS 有很多优势:

  • 启动更快。
  • 支持文件系统。
  • 可以放在主板 flash、硬盘甚至网络位置。
  • 支持鼠标等更丰富输入。
  • 支持 Secure Boot。
  • UI 更友好。
  • 某种意义上更像一个 mini OS。

简单对比:

项目 BIOS UEFI
历史 更传统 更现代
文件系统支持 较弱 更强
UI 文本/简陋界面 可图形化
安全启动 传统 BIOS 不强调 支持 Secure Boot
能力 更像启动固件 更像 mini OS

复习时不用深挖 UEFI 的内部实现,重点知道:

BIOS/UEFI 是固件层,bootloader 是 OS 启动链的一部分,kernel 是真正的 OS 核心。


5. 进程:运行中的程序

5.1 程序和进程的区别

课件定义:

Process is the execution of an application program with restricted rights.

也就是:

进程是一个正在执行的程序实例,并且它拥有受限制的权限。

程序和进程的区别可以类比面向对象:

程序 program:类 class
进程 process:对象 object / instance

例如:

/bin/ls

这是一个程序文件。

如果你在两个终端里同时运行:

ls
ls

就会产生两个进程。它们执行的是同一个程序,但它们是两个不同的运行实例。

所以:

程序:静态的,放在磁盘上
进程:动态的,正在运行,有自己的执行状态和资源上下文

一个程序可以有:

  • 0 个进程:程序存在,但现在没人运行它。
  • 1 个进程:正在运行一次。
  • 多个进程:同一个程序被运行多次。

5.2 进程拥有哪些东西?

一个进程通常拥有:

  • 自己的地址空间。
  • 文件描述符上下文。
  • 文件系统上下文。
  • 一个或多个线程。
  • 当前执行状态。
  • 寄存器和程序计数器。
  • I/O 状态。

这里“restricted rights” 很关键。进程不是想干什么就干什么,它不能随便访问其他进程的内存,也不能随便执行特权指令。

这体现了 OS 的裁判角色:

每个进程都能运行,但必须在规则内运行。


6. PCB:操作系统给进程建立的档案袋

PCB,全称 Process Control Block,中文叫进程控制块。

它是 OS 用来记录进程状态的数据结构。可以理解成 OS 给每个进程建立的“档案袋”。

PCB 里通常包括:

  • Process ID,PID:进程编号。
  • Process state:运行、就绪、等待等状态。
  • Process priority:调度优先级。
  • Program counter:下一条要执行的指令地址。
  • Memory-related information:地址空间、页表等内存信息。
  • Register information:寄存器值。
  • I/O status information:打开的文件、I/O 设备等。
  • Accounting information:运行时间、资源使用情况等统计信息。

PCB 最典型的使用场景是:上下文切换

假设 CPU 正在运行进程 A,现在 OS 想切到进程 B:

进程 A 正在运行
  ↓
OS 保存 A 的寄存器、PC、栈指针等状态到 A 的 PCB
  ↓
OS 从 B 的 PCB 恢复 B 的寄存器、PC、栈指针
  ↓
CPU 开始运行 B

如果没有 PCB,进程被暂停后就无法恢复。因为 OS 不知道它执行到哪里、寄存器里是什么、栈在哪里。

所以可以记:

PCB 是“进程暂停后还能继续运行”的关键。


7. 进程的内存布局

7.1 经典内存区域

一个进程的虚拟地址空间通常可以分成这些区域:

高地址
  ↓
Stack
Heap
Uninitialized data (.bss)
Code (.text)
Initialized data (.data)
  ↓
低地址

每个区域的含义是:

区域 作用
.text 代码区,存放机器指令
.data 已初始化的全局变量/静态变量
.bss 未初始化或零初始化的全局变量/静态变量
heap 动态分配内存,例如 malloc / new
stack 函数调用栈,存放局部变量、返回地址、调用上下文

重点是:这个地址布局是虚拟地址布局,不是物理地址布局。

也就是说,进程以为自己有一片连续的内存,但实际物理内存可能完全不连续。后面地址翻译和分页会详细讲这个。


7.2 代码例子:a、b、c、d、e 分别在哪里?

课件给了这个例子:

int a = 0;
int b;

void hello() {
    static int c;
    int d;
    int *e = malloc(...);
}

对应关系是:

int a = 0;        // .data 或某些实现中可能放 .bss
int b;            // .bss

void hello() {
    static int c; // .bss
    int d;        // stack
    int *e = malloc(...); // e 本身在 stack,malloc 出来的对象在 heap
}

这里要特别注意 e

int *e = malloc(...);

这行代码里有两个东西:

  1. e 这个指针变量本身
    它是局部变量,所以在 stack

  2. malloc(...) 分配出来的那块内存
    它在 heap

所以更精确的说法是:

e 本身在 stack
e 指向的对象在 heap

这类题考试很容易考,因为它能检查你是否真的理解不同内存区域的生命周期。


7.3 生命周期角度理解内存区域

还可以从生命周期角度理解:

变量类型 生命周期 通常位置
函数代码 程序整个运行期间 .text
全局初始化变量 程序整个运行期间 .data
全局未初始化变量 程序整个运行期间 .bss
static 局部变量 程序整个运行期间 .data / .bss
普通局部变量 函数调用期间 stack
malloc/new 分配对象 手动释放前一直存在 heap

7.4 readelf:观察可执行文件结构

课件提到可以使用 readelf 查看可执行文件里面有什么。

例如:

gcc -g test.c -o test
readelf -S test

-S 表示查看 section headers。你可能会看到:

.text
.data
.bss
.rodata
.symtab
.strtab

这说明我们在图里看到的 .text.data.bss 不是凭空想象,而是真实存在于可执行文件和运行时内存布局中的概念。

不过课件也提醒:具体输出依赖编译器、优化等级、是否开启 debug 信息等。


8. CPU 虚拟化:如何让多个程序“同时”使用 CPU?

8.1 基本模型

CPU 虚拟化的基本模型是:

运行进程 A 一小会儿
  ↓
运行进程 B 一小会儿
  ↓
运行进程 C 一小会儿
  ↓
再切回进程 A

这样用户感觉多个程序在同时运行。实际上,如果只有一个 CPU core,同一时刻只能运行一个执行流,只是切换得很快。

这就是 OS 的魔术师角色:

每个进程都好像拥有自己的 CPU,但实际上 OS 在不断切换它们。


8.2 CPU 虚拟化的两个挑战

课件提出两个挑战:

挑战一:Performance

虚拟化不能太慢。

如果每条指令都交给 OS 检查,安全是安全了,但性能会非常差。

挑战二:Control / Isolation

OS 必须能在需要时拿回 CPU。

否则程序可以这样:

while (1) {
    // do nothing
}

如果 OS 没有办法抢回 CPU,这个程序就会永远霸占 CPU,整个系统就卡住了。

所以 OS 要同时满足:

大多数时候让程序直接跑,保证性能
关键时候必须接管控制,保证隔离和安全

8.3 三种虚拟化方案

方案一:Direct Run
App instructions → CPU

优点:

  • 性能好。

缺点:

  • 没有隔离。
  • OS 不参与。
  • 程序可能乱访问硬件和内存。
方案二:Always Go Through OS
App instructions → OS → CPU

优点:

  • 隔离好。
  • OS 可以检查每一步。

缺点:

  • 性能极差。
  • 每条指令都经过 OS,开销太大。
方案三:Limited Direct Execution
普通指令 → 直接在 CPU 上运行
敏感指令 → 进入 OS,由 OS 决定是否允许

这是现代 OS 的核心思路。

它兼顾了:

  • 性能:大多数普通指令直接运行。
  • 隔离:敏感操作必须经过 OS。
  • 控制:OS 可以通过中断和异常重新获得 CPU。

8.4 Limited Direct Execution 的两个特征

课件总结了两个特征。

Feature 1:Restricted Operations

也就是特权指令。

敏感操作不能让用户程序直接执行,必须交给 OS 判断是否合法。

例如:

  • I/O 读写。
  • 修改权限级别。
  • 上下文切换。
  • 设置系统时间。
  • 修改关键硬件状态。
Feature 2:Inter-process Switching

进程之间要能切换。

切换分两类:

  1. Voluntary switching,自愿切换
    例如进程调用系统调用、等待 I/O、调用 wait()

  2. Involuntary switching,非自愿切换
    例如 timer interrupt 到来,OS 强制打断当前进程,切换到另一个进程。

这就是抢占式多任务的基础。


9. Dual Mode:用户态和内核态

9.1 为什么需要双模式?

如果所有程序都拥有最高权限,系统会非常危险。

一个普通程序可能:

  • 修改别的进程内存。
  • 直接操作磁盘。
  • 修改系统时间。
  • 关闭中断。
  • 永远霸占 CPU。
  • 修改页表。
  • 破坏内核数据结构。

所以 CPU 提供了不同权限级别。操作系统利用这些权限级别,把程序分成:

User mode:用户态,普通应用程序运行
Kernel mode:内核态,操作系统内核运行

可以用课堂里的比喻:

学生:用户态
老师/TA:内核态

学生不能随便改成绩系统,老师/TA 才有管理权限。普通程序也不能随便改系统状态,内核才有最高权限。


9.2 硬件需要提供什么?

为了支持双模式,硬件需要提供:

  1. Privileged instructions,特权指令
  2. Memory protection,内存保护
  3. Timer interrupts,时钟中断
  4. Safe mode transfer,安全模式切换

为什么必须靠硬件?

因为恶意程序不会自觉遵守规则。如果只靠软件约定,程序完全可以绕开。必须由 CPU 在执行指令时检查当前权限级别。


10. 特权指令:普通程序不能直接执行的指令

课件把指令分为两类:

特权指令 非特权指令
I/O read/write 算术运算
Context switch 调用函数
Changing privilege level 读取 CPU 状态
Set system time 读取系统时间

一个简单判断原则是:

任何可能影响其他进程或整个系统的指令,大概率都是特权指令。

例如,设置系统时间看起来很简单,但如果普通程序可以随便改时间,会影响:

  • 日志顺序。
  • 文件时间戳。
  • 定时任务。
  • 证书有效期。
  • 分布式系统一致性。

所以它必须是受控操作。


10.1 如果应用需要特权操作怎么办?

答案是:通过 系统调用

用户程序不能直接操作磁盘,但可以调用:

open(path, flags);
read(fd, buf, size);
write(fd, buf, size);
close(fd);

这些函数最终会进入内核。内核检查权限,确认合法后,替用户程序完成操作。

所以系统调用的本质是:

用户程序向内核提出请求,请内核代替自己执行特权操作。

它不是普通函数调用,而是用户态到内核态的一次受控切换。


10.2 用户态强行执行特权指令会怎样?

课件中展示了一个用 assembly 执行特权指令的例子。大意是:用户程序尝试执行只有内核态才能执行的指令,结果 CPU 检测到权限不足,于是抛出异常,OS 接管,程序被终止。

流程是:

用户程序执行特权指令
  ↓
CPU 检查当前权限级别
  ↓
发现当前是 user mode
  ↓
该指令需要 kernel mode
  ↓
CPU 抛出异常
  ↓
OS 接管
  ↓
进程被终止或收到信号

这个例子说明:

权限不是靠编译器保证的,而是 CPU 在运行时硬件检查的。

编译器可能能把这段代码编译出来,但 CPU 执行时会拦住。


11. 内存保护

11.1 为什么需要内存保护?

如果没有内存保护,一个进程可以随便访问另一个进程的内存。

比如恶意程序可以:

读取浏览器密码
修改别的进程变量
破坏内核代码
覆盖系统数据结构

所以 OS 必须保证:

进程 A 只能访问进程 A 自己允许访问的地址
进程 B 只能访问进程 B 自己允许访问的地址
用户程序不能随便访问 kernel code/data

内存保护是进程隔离的基础。


11.2 Segmentation:base + bounds

课件先讲了分段方式。

基本思路是:

每个进程有 base 和 bounds
每次内存访问都检查地址是否在合法范围内

合法范围是:

[base, base + bounds)

如果访问地址超出范围,就抛出异常。

示意:

virtual address
  ↓
检查是否小于 bounds
  ↓
加上 base 得到 physical address

优点:

  • 思路简单。
  • 可以实现基本隔离。

缺点:

  • heap 和 stack 不容易灵活扩展。
  • 内存共享不方便。
  • 容易产生内存碎片。
  • 对复杂地址空间支持不好。

所以现代系统主要依赖分页。


11.3 Paging:现代 OS 的核心机制之一

Paging,分页,是 OS 中非常重要的概念。

它的核心思想是:

进程看到的每个虚拟地址,会被映射到某个物理地址;这个映射可以是不连续的。

也就是说,进程看到:

虚拟地址 0x1000
虚拟地址 0x2000
虚拟地址 0x3000

物理内存中可能对应:

物理地址 0x9a000
物理地址 0x31000
物理地址 0xf2000

它们不需要连续。

这里有两方合作:

  1. OS 决定映射关系
    OS 在内核态设置页表,决定虚拟页映射到哪个物理页,以及权限是什么。

  2. CPU/MMU 执行地址翻译和权限检查
    程序每次访问内存时,硬件负责查页表或 TLB,检查是否允许访问。

这就是硬件和操作系统合作的典型例子。


11.4 kernel 为什么放在高地址?

很多系统会把用户进程放在低地址,把 kernel code/data 映射到高地址。

示意:

高地址
  ↓
Kernel code/data
Stack
Heap
.bss
.text
.data
  ↓
低地址

为什么要这样设计?

原因一:方便内核处理系统调用和中断

当用户进程通过系统调用进入内核时,内核需要马上访问自己的代码和数据。如果每个进程地址空间里都在固定高地址映射内核,切换会更方便。

原因二:保持用户空间和内核空间分离

低地址主要给用户程序,高地址留给内核,结构清晰。

原因三:用户看得到地址范围,但不能访问

这点最关键。

虽然 kernel 映射在进程地址空间的高地址区域,但页表权限会标记为:

user mode:不可访问
kernel mode:可以访问

所以用户程序随机访问内核区域时:

用户态访问 kernel address
  ↓
CPU/MMU 检查权限
  ↓
发现 user mode 不允许访问
  ↓
抛出异常

这就是虚拟内存和权限位共同实现隔离。


12. Timer Interrupt:OS 如何抢回 CPU?

12.1 为什么需要 timer interrupt?

假设一个用户程序写了:

while (1) {
}

如果 OS 没有办法打断它,这个程序就会永远霸占 CPU。

所以 OS 需要一种机制:

即使进程不主动让出 CPU,OS 也能定期拿回控制权。

这就是 timer interrupt。


12.2 timer interrupt 的执行流程

大致流程是:

用户进程正在运行
  ↓
硬件计时器到点
  ↓
触发 timer interrupt
  ↓
CPU 进入内核态
  ↓
OS 保存当前进程状态
  ↓
调度器选择下一个进程
  ↓
恢复某个进程运行

这个“下一个进程”可能是别的进程,也可能还是原来的进程。

Timer interrupt 的重要意义是:

它让抢占式调度成为可能。

没有 timer interrupt,OS 只能依赖进程自愿让出 CPU,比如主动调用 yield() 或阻塞在 I/O 上。但恶意程序或死循环程序不会主动让出 CPU。


13. CPL:CPU 怎么知道现在是用户态还是内核态?

在 x86 中,当前特权级叫 Current Privilege Level,CPL

x86 使用 CS 段寄存器的低 2 位表示 CPL。

常用的是:

CPL = 0:kernel mode
CPL = 3:user mode

虽然 x86 有 4 个 ring:

Ring 0
Ring 1
Ring 2
Ring 3

但大多数 OS 主要只用:

Ring 0:内核态
Ring 3:用户态

课件中问了一个有迷惑性的问题:如何切换用户态和内核态?

比如:

CPL &= 0x0
CPL |= 0x3
...

这里要注意:

CPL 不是普通 C 变量,用户程序不能通过 &=|= 随便修改它。

CPL 由 CPU 的受控机制修改,比如:

  • 系统调用。
  • 中断。
  • 异常。
  • 从内核返回用户态的特殊指令。

所以正确理解是:

用户态 → 内核态:必须通过 syscall / interrupt / exception 等受控入口
内核态 → 用户态:必须通过受控返回机制

这就是下一讲 context switch 和 mode transfer 要详细展开的内容。


14. 第二讲整体知识地图

可以把本讲串成下面这条主线:

按下电源
  ↓
BIOS / UEFI 运行
  ↓
硬件自检和初始化
  ↓
加载 bootloader
  ↓
bootloader 切换 CPU 模式
  ↓
bootloader 加载 kernel
  ↓
kernel 初始化并开始管理系统
  ↓
程序被加载并运行成进程
  ↓
进程拥有自己的地址空间和 PCB
  ↓
OS 用 limited direct execution 让程序高效运行
  ↓
CPU/OS 用 user mode 和 kernel mode 保证隔离
  ↓
特权指令、内存保护、时钟中断共同防止程序失控

本讲最重要的几个关键词:

BIOS
Bootloader
Kernel
Real Mode
Protected Mode
Process
PCB
Address Space
.text / .data / .bss / heap / stack
Limited Direct Execution
User Mode
Kernel Mode
Privileged Instruction
Memory Protection
Paging
Timer Interrupt
CPL

15. 小测题与答案

题 1:BIOS 和 bootloader 的区别是什么?

答案:

BIOS 是主板固件,开机后最先运行,负责硬件自检、初始化设备、寻找启动设备,并加载 bootloader。

Bootloader 是启动加载器,通常随 OS 存放在磁盘或启动介质中,负责切换 CPU 模式、检查并加载 kernel image,最后跳转到 kernel。


题 2:为什么 BIOS 不直接加载 kernel?

答案:

因为不同操作系统的内核格式、文件系统、启动方式、启动参数、安全验证方式都不同。如果 BIOS 直接支持所有 OS,会非常复杂。Bootloader 提供了灵活性、兼容性、启动设备管理、安全验证、错误处理和更新能力。


题 3:real mode 和 protected mode 的核心区别是什么?

答案:

real mode 是早期兼容模式,能力有限,没有现代意义上的权限隔离和内存保护。protected mode 支持更大的地址空间、内存保护、权限级别和多任务,是现代 OS 真正运行所需的模式。


题 4:程序和进程有什么区别?

答案:

程序是静态的可执行文件,通常存放在磁盘上。进程是程序的一次运行实例,拥有执行状态、地址空间、寄存器上下文、文件描述符等资源。一个程序可以对应多个进程。


题 5:PCB 里保存哪些信息?什么时候用?

答案:

PCB 保存 PID、进程状态、优先级、程序计数器、寄存器、内存信息、I/O 状态、记账信息等。上下文切换时,OS 会把当前进程状态保存到 PCB,并从另一个进程的 PCB 恢复状态。


题 6:下面变量分别在哪里?

int a = 0;
int b;

void hello() {
    static int c;
    int d;
    int *e = malloc(...);
}

答案:

  • a:已初始化全局变量,通常在 .data,某些实现中零初始化也可能放 .bss
  • b:未初始化全局变量,在 .bss
  • c:static 局部变量,生命周期贯穿整个程序,未初始化时在 .bss
  • d:普通局部变量,在 stack。
  • e:指针变量本身在 stack,malloc 分配出的对象在 heap。

题 7:为什么不能让普通程序直接执行 I/O 指令?

答案:

因为 I/O 操作可能影响整个系统和其他进程。如果普通程序可以随便操作设备,就会破坏隔离和安全。因此 I/O 指令通常是特权指令,只能在内核态执行。用户程序需要通过系统调用请求内核代为执行。


题 8:limited direct execution 是什么?

答案:

limited direct execution 是现代 OS 虚拟化 CPU 的基本思路:大多数普通指令让用户程序直接在 CPU 上运行,以保证性能;敏感操作必须进入 OS,由内核检查和执行,以保证隔离和控制。


题 9:timer interrupt 的作用是什么?

答案:

timer interrupt 让 OS 能定期抢回 CPU 控制权,防止某个进程无限占用 CPU,并支持抢占式调度。时钟中断发生后,OS 可以保存当前进程状态并调度另一个进程运行。


题 10:CPL 是普通程序可以直接修改的吗?

答案:

不是。CPL 是 CPU 当前特权级,x86 中由 CS 段寄存器低 2 位表示。用户程序不能通过普通赋值或位运算修改 CPL。用户态进入内核态必须通过系统调用、中断、异常等受控机制。


16. 常见 Q&A

Q1:BIOS、bootloader、kernel 三者谁更“底层”?

从启动顺序看:

BIOS/UEFI → bootloader → kernel

BIOS/UEFI 最先运行,最靠近硬件;bootloader 是 OS 启动链的一部分;kernel 是真正长期运行并管理系统资源的核心。


Q2:Bootloader 是操作系统的一部分吗?

通常可以认为是 OS 启动体系的一部分。它不负责进程管理、内存管理、文件系统运行时管理等完整 OS 功能,但它负责把 kernel 加载起来,是 OS 能运行的前置步骤。


Q3:Kernel 和完整操作系统有什么区别?

Kernel 是操作系统的核心,负责 CPU 调度、内存管理、进程管理、文件系统核心逻辑、设备驱动、中断处理等。

完整操作系统除了 kernel,还包括:

  • shell
  • 图形界面
  • 系统服务
  • 标准库
  • 工具程序
  • 包管理器
  • 用户应用环境

所以:

Kernel ⊂ Operating System

Q4:为什么说进程的地址不是物理地址?

因为进程看到的是虚拟地址。虚拟地址需要经过页表和 MMU 翻译,才能变成物理地址。这样 OS 才能实现隔离、共享、灵活分配和按需加载。


Q5:为什么 heap 往上长,stack 往下长?

经典布局里,heap 从低地址向高地址增长,stack 从高地址向低地址增长。这样两者可以共享中间的空闲空间,程序运行时动态扩展更方便。

不过这只是经典模型,具体系统和架构实现可能有所不同。


Q6:为什么普通函数调用不能进入内核态?

普通函数调用只是修改用户态程序自己的栈和 PC。它不会改变 CPU 特权级。

进入内核态需要硬件支持的受控入口,例如 syscall、trap、interrupt、exception。这样 CPU 和 OS 才能检查入口是否合法,避免用户程序跳到内核任意位置执行。


Q7:用户程序调用 read() 是不是直接读磁盘?

不是。用户程序调用的 read() 通常是 libc 提供的接口。它最终通过系统调用进入内核,内核检查权限和参数,再通过文件系统和设备驱动完成真正的读操作。

调用链大概是:

用户程序 read()
  ↓
libc wrapper
  ↓
syscall / trap
  ↓
kernel syscall handler
  ↓
文件系统 / 设备驱动
  ↓
返回用户态

Q8:内核映射在每个进程地址空间里,会不会不安全?

不会,只要权限设置正确。

内核区域可以映射在进程地址空间中,但页表项会标记为 user mode 不可访问。用户程序访问这部分地址会触发异常。只有 CPU 已经进入 kernel mode 后,内核才能访问这些地址。


Q9:为什么异常、系统调用、中断都能进入内核?

因为它们都是硬件和 OS 预先设计好的受控入口。

它们不会让用户程序随便跳到内核任意位置,而是通过固定的入口表和权限检查进入内核 handler。这保证了安全性。


Q10:第二讲和第三讲有什么关系?

第二讲提出问题:

用户态和内核态为什么存在?
用户程序如何进入内核?
OS 如何抢回 CPU?

第三讲会进一步讲:

  • exception
  • interrupt
  • system call
  • interrupt vector table / IDT
  • interrupt stack
  • x86 mode transfer
  • context switch

也就是具体解释“用户态和内核态到底怎么切换”。


17. 拓展理解

17.1 OS 为什么需要硬件支持?

操作系统不是纯软件就能实现隔离的。

如果 CPU 不提供用户态/内核态,普通程序就可以执行任意指令。

如果 MMU 不提供地址翻译和权限检查,普通程序就可以访问任意内存。

如果没有 timer interrupt,OS 就无法强制抢回 CPU。

所以现代 OS 的很多功能,本质上都是:

OS 设计策略,硬件负责强制执行。

这也是系统课很重要的一条主线:hardware-software cooperation


17.2 “受限制的直接执行”为什么优雅?

因为它抓住了一个平衡:

所有指令都模拟:太慢
所有指令都直接跑:不安全
大多数直接跑,少数敏感操作受控进入 OS:兼顾性能和安全

这就是 limited direct execution 的精髓。

它也是很多系统设计的共同思想:

快路径尽量直接,慢路径/危险路径交给内核处理。


17.3 进程隔离和虚拟内存的关系

进程隔离依赖虚拟内存。

每个进程都有自己的虚拟地址空间。即使两个进程都访问 0x400000,它们最终映射到的物理页也可以完全不同。

这让每个程序都产生一种幻觉:

我拥有一整片属于自己的内存。

但实际上:

OS 和 MMU 在背后管理虚拟地址到物理地址的映射。


17.4 为什么本讲是后续课程的地基?

本讲后面很多知识都会继续展开:

本讲概念 后续对应内容
用户态/内核态 系统调用、中断、异常
PCB 上下文切换、调度
虚拟地址 地址翻译、分页、TLB
heap / stack malloc、虚拟内存、缺页
timer interrupt 抢占式调度
特权指令 syscall、设备 I/O
kernel high address 内核地址空间、页表权限

所以这一讲不是孤立知识点,而是后面一半课程的基础。


18. 复习清单

复习第二讲时,可以按下面的问题自测:

  • 我能说清楚 BIOS 做了哪些事。
  • 我能说清楚 bootloader 做了哪些事。
  • 我知道为什么 BIOS 不直接加载 kernel。
  • 我能解释 real mode 和 protected mode 的区别。
  • 我能看懂 0xf000:0xfff0 → 0xffff0 的地址计算。
  • 我知道 bootloader 为什么要设置 GDT、打开 CR0 的 PE 位。
  • 我知道程序和进程的区别。
  • 我能解释 PCB 是什么,以及上下文切换为什么需要 PCB。
  • 我能判断 .text.data.bss、heap、stack 分别放什么。
  • 我知道 int *e = malloc(...)e*e 的位置不同。
  • 我能解释 limited direct execution。
  • 我知道为什么需要 user mode 和 kernel mode。
  • 我能举例说明什么是特权指令。
  • 我知道用户程序执行特权指令会触发异常。
  • 我能解释 segmentation 的 base + bounds 思想。
  • 我能解释 paging 为什么能实现内存保护。
  • 我知道为什么 kernel 常放在高地址。
  • 我知道 timer interrupt 如何帮助 OS 抢回 CPU。
  • 我知道 CPL 是什么,以及为什么用户不能直接修改它。

19. 最后总结

第二讲的核心不是背启动流程,而是理解操作系统为什么必须和硬件合作。

从启动角度看:

BIOS/UEFI → bootloader → kernel

从运行角度看:

program → process → PCB → address space

从保护角度看:

user mode / kernel mode
privileged instructions
memory protection
timer interrupt

从设计思想看:

让普通操作直接执行,保证性能;
让危险操作受控进入内核,保证安全;
让 OS 能随时抢回 CPU,保证控制权。

这就是第二讲最重要的主线。

Logo

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

更多推荐