让我在写这篇文章时一次又一次感到惊讶的一件事是计算机是多么简单。我依然很难不让自己产生心理负担,总是期待更多的复杂性或抽象性,而实际上这些并不存在!如果在继续之前有一件事你应该牢记于心,那就是所有看起来简单的东西实际上就是那么简单。这种简单性非常美丽,有时也非常,非常诅咒。

让我们从你计算机在其核心的工作原理开始。

计算机架构

计算机的中央处理单元(CPU)负责所有计算。它是大人物,是魔术师阿拉卡巴姆。你一开机,它就开始嗡嗡作响,执行一条又一条指令。

第一款量产的CPU是英特尔4004,由意大利物理学家和工程师Federico Faggin在60年代末设计。它是一个4位架构,而不是我们今天使用的64位系统,它远不如现代处理器复杂,但其许多简单性仍然保留了下来。

CPU执行的“指令”只是二进制数据:一个或两个字节代表正在运行的指令(操作码),然后是运行该指令所需的任何数据。我们称之为机器代码的东西只不过是一系列这些二进制指令的连续。 汇编语言是一种有助于阅读和编写机器代码的语法,它比原始位更容易被人类阅读和编写;它总是被编译为CPU知道如何读取的二进制。

第 1 章:基础 - 图1

插一句:指令并不总是像上述例子那样在机器代码中一一对应。例如,add eax, 512 会被翻译为 05 00 02 00 00

第一个字节(05)是一个操作码,专门代表将EAX寄存器加到一个32位数。剩余的字节是512(0x200)以小端序字节顺序表示。

Defuse Security 创建了一个有用的工具 用于在汇编和机器代码之间进行转换。

RAM 是你计算机的主要内存库,一个大型的多用途空间,用于存储在你计算机上运行的程序所使用的所有数据。这包括程序代码本身以及操作系统核心的代码。CPU 始终直接从 RAM 读取机器代码,代码如果没有加载到 RAM 中是不能运行的。

CPU 存储了一个指令指针,它指向 CPU 将要从 RAM 中获取下一条指令的位置。每执行一条指令后,CPU 移动指针并重复。这就是取指执行周期

第 1 章:基础 - 图2

执行一条指令后,指针会向前移动到 RAM 中指令后面的地方,这样它现在就指向下一条指令。这就是代码运行的原因!指令指针不断向前移动,以存储在内存中的顺序执行机器代码。有些指令可以告诉指令指针跳到其他地方,或者根据某个条件跳到不同的地方;这使得可重用代码和条件逻辑成为可能。

这个指令指针存储在一个寄存器中。寄存器是一些小型存储单元,CPU 读写它们的速度极快。每种 CPU 架构都有一组固定的寄存器,用于从存储计算过程中临时值到配置处理器的所有事情。

一些寄存器可以直接从机器代码中访问,比如前面图中提到的 ebx

其他寄存器仅供 CPU 内部使用,但通常可以使用专用指令进行更新或读取。一个例子是指令指针,它不能直接读取,但可以通过跳转指令等进行更新。

处理器是天真的

让我们回到最初的问题:当你在计算机上运行可执行程序时会发生什么?首先,一堆魔法操作准备好运行它——我们稍后会详细讨论这一切——但在这个过程的最后,某个文件中有机器代码。操作系统将其加载到 RAM 中,并指示 CPU 将指令指针跳到 RAM 中的那个位置。CPU 像往常一样继续运行它的取指执行周期,于是程序就开始执行了!

(对我来说,这是一个让我自己惊讶的时刻——真的,这就是你用来阅读这篇文章的程序的运行方式!你的 CPU 正在按顺序从 RAM 中获取浏览器的指令并直接执行它们,然后它们渲染出这篇文章。)

第 1 章:基础 - 图3

事实证明,CPU 的世界观非常基础;它们只能看到当前的指令指针和一些内部状态。进程完全是操作系统的抽象概念,而不是 CPU 本身理解或跟踪的东西。

*挥挥手* 进程是由~操作系统开发者~大字节发明的抽象,用来卖更多的计算机

对我来说,这引发了更多的问题:

  1. 如果 CPU 不知道多处理,并且只是顺序执行指令,为什么它不会卡在它正在运行的程序里?多个程序如何同时运行?
  2. 如果程序直接在 CPU 上运行,并且 CPU 可以直接访问 RAM,为什么代码不能访问其他进程的内存,或者更糟糕的是,内核的内存?
  3. 说到这个,是什么机制防止每个进程运行任何指令并对你的计算机做任何事情?系统调用到底是什么?

关于内存的问题值得单独讨论,在第五章中有详细介绍——简而言之,大多数内存访问实际上通过一个重映整个地址空间的间接层。现在,我们假设程序可以直接访问所有 RAM,并且计算机一次只能运行一个进程。我们将适时解释这两个假设。

是时候通过第一个兔子洞,进入一个充满系统调用和安全环的世界了。

顺便说一下:内核是什么?

你计算机的操作系统,如 macOS、Windows 或 Linux,是在你计算机上运行并使所有基本功能正常工作的软件集合。“基本功能”是一个非常笼统的术语,“操作系统”也是——根据你问的人不同,它可以包括默认情况下随计算机附带的应用程序、字体和图标等内容。

然而,内核是操作系统的核心。当你启动计算机时,指令指针从某个程序开始。那个程序就是内核。内核几乎可以完全访问你的计算机内存、外围设备和其他资源,并负责运行安装在你计算机上的软件(称为用户态程序)。在本文中,我们将了解内核如何拥有这种访问权限——以及用户态程序为什么没有。

Linux 只是一个内核,需要很多用户态软件,如 shell 和显示服务器,才能使用。macOS 的内核称为 XNU,类似于 Unix,而现代 Windows 的内核称为 NT Kernel

两个环统治一切

处理器的模式(有时称为特权级别或环)控制它允许做什么。现代架构至少有两个选项:内核/监督模式和用户模式。虽然架构可能支持多于两种模式,但目前只常用内核模式和用户模式。

在内核模式中,一切都可以:CPU 被允许执行任何支持的指令并访问任何内存。在用户模式中,只允许执行一部分指令,I/O 和内存访问受到限制,许多 CPU 设置被锁定。通常情况下,内核和驱动程序在内核模式下运行,而应用程序在用户模式下运行。

处理器以内核模式启动。在执行程序之前,内核会启动切换到用户模式的过程。

第 1 章:基础 - 图4 内核态和用户态

一个处理器模式在实际架构中表现的例子:在 x86-64 架构中,可以从名为 cs(代码段)的寄存器中读取当前特权级(CPL)。具体来说,CPL 包含在 cs 寄存器的两个最低有效位中。这两个位可以存储 x86-64 的四个可能的环:环 0 是内核模式,环 3 是用户模式。环 1 和环 2 设计用于运行驱动程序,但仅由少数旧的特殊操作系统使用。如果 CPL 位是 11,例如,CPU 正在运行在环 3:用户模式。

什么是系统调用?

程序在用户模式下运行,因为它们不能被信任完全访问计算机。用户模式发挥其作用,防止访问计算机的大部分内容——但程序需要能够访问 I/O、分配内存并以某种方式与操作系统交互!为此,在用户模式下运行的软件必须向操作系统内核请求帮助。操作系统可以实施自己的安全保护措施,以防止程序执行任何恶意操作。

如果你曾经编写过与操作系统交互的代码,你可能会认识到诸如 openreadforkexit 这样的函数。在几层抽象之下,这些函数都使用系统调用来请求操作系统的帮助。系统调用是一种特殊的程序,让程序开始从用户空间到内核空间的转换,从程序代码跳到操作系统代码。

用户空间到内核空间的控制转移是使用名为软件中断的处理器功能完成的:

  1. 在启动过程中,操作系统在 RAM 中存储一个称为中断向量表(IVT;x86-64 称其为中断描述符表)的表,并将其注册到 CPU。IVT 将中断号映射到处理程序代码指针。

第 1 章:基础 - 图5

  1. 然后,用户态程序可以使用类似 INT 的指令,告诉处理器在 IVT 中查找给定的中断号,切换到内核模式,然后将指令指针跳转到 IVT 中存储的内存地址。

当这个内核代码完成时,它使用类似 IRET 的指令告诉 CPU 切换回用户模式,并将指令指针返回到触发中断时的位置。

(如果你感兴趣,Linux 中用于系统调用的中断 ID 是 0x80。你可以在 Michael Kerrisk 的在线手册页目录 上阅读 Linux 系统调用列表。)

封装 API:抽象中断

以下是我们目前关于系统调用所了解的内容:

  • 用户模式程序不能直接访问 I/O 或内存。它们必须请求操作系统帮助与外界交互。
  • 程序可以使用类似 INT 和 IRET 的特殊机器代码指令将控制权委托给操作系统。
  • 程序不能直接切换特权级别;软件中断是安全的,因为处理器已经被操作系统预先配置了要跳转到的操作系统代码位置。中断向量表只能从内核模式配置。

程序在触发系统调用时需要将数据传递给操作系统;操作系统需要知道要执行哪个特定的系统调用以及系统调用本身需要的任何数据,例如,要打开的文件名。传递这些数据的机制因操作系统和架构而异,但通常是在触发中断之前将数据放在某些寄存器或堆栈上。

由于设备之间系统调用的调用方式不同,程序员为每个程序自己实现系统调用是不切实际的。这也意味着操作系统不能更改它们的中断处理方式,因为担心会破坏每个使用旧系统编写的程序。最后,我们通常不再用纯汇编语言编写程序——程序员不能在每次想读取文件或分配内存时都需要使用汇编。

第 1 章:基础 - 图6

操作系统在这些中断之上提供了一个抽象层。在类Unix系统上,libc提供了包装必要汇编指令的可重用高级库函数,而在Windows系统中则是一部分名为ntdll.dll的库。调用这些库函数本身并不会导致切换到内核模式,它们只是标准的函数调用。在这些库内部,汇编代码实际上会将控制权转移给内核,并且比包装库子程序更加依赖于平台。

当你在类Unix系统上的C程序中调用 exit(1) 函数时,该函数内部会运行机器码来触发一个中断,将系统调用的操作码和参数放入正确的寄存器/栈/其他位置。

提升速度的必要性 / 让我们进入 CISC 世界

许多像x86-64这样的 CISC架构包含为系统调用设计的指令,这是由于系统调用范式的普遍存在。

Intel和AMD在x86-64上实际上有两套优化后的系统调用指令。SYSCALLSYSENTER 是像 INT 0x80 这样的指令的优化替代品。它们对应的返回指令,SYSRETSYSEXIT,旨在快速转换回用户空间并恢复程序代码的执行。

(AMD和Intel处理器对这些指令的兼容性略有不同。SYSCALL 通常是64位程序的最佳选择,而 SYSENTER 在32位程序中具有更好的支持。)

RISC架构则不太可能有这种特殊指令。基于RISC架构的苹果Silicon使用的AArch64架构,仅使用一个中断指令来处理系统调用和软件中断。我认为Mac用户使用起来都很顺畅 :)


哇,内容太多了!让我们简要回顾一下:

  • 处理器在无限的取指-执行循环中执行指令,并不了解操作系统或程序的概念。处理器的模式,通常存储在一个寄存器中,决定了可以执行哪些指令。操作系统代码在内核模式下运行,并在运行程序时切换到用户模式。
  • 要运行一个二进制程序,操作系统会切换到用户模式,并指向RAM中代码的入口点。因为它们只有用户模式的权限,希望与外界交互的程序需要跳转到操作系统代码寻求帮助。系统调用是程序从用户模式切换到内核模式并进入操作系统代码的标准化方式。
  • 程序通常通过调用共享库函数来使用这些系统调用。这些函数包装了机器码,用于处理软件中断或特定于体系结构的系统调用指令,从而将控制权转移到操作系统内核并进行环形切换。内核执行其任务后再次切换到用户模式,并返回到程序代码中。

让我们弄清楚如何回答我之前的第一个问题:

如果CPU不跟踪多个进程,只是一条一条地执行指令,为什么它不会被困在运行的程序内部?多个程序如何同时运行?

这个问题的答案,亲爱的朋友,也是为什么 Coldplay 如此受欢迎的答案… 时钟!(嗯,技术上是定时器。我只是想要插入那个笑话。)