第一章:基础知识

@高效码农  August 31, 2023

在撰写本文时,让我一次又一次感到惊讶的一件事是计算机是多么简单。我仍然很难不让自己感到兴奋,期待比实际存在的更复杂或抽象!如果在继续之前你应该记住一件事,那就是一切看似简单的事情实际上就是那么简单。这种简单性非常美丽,但有时又非常非常令人讨厌。

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

计算机是如何设计的

计算机的中央处理单元(CPU)负责所有计算。这是大奶酪。沙赞阿拉卡布拉姆。当你启动计算机时,它就会开始嘎嘎作响,执行一条又一条指令。

第一个量产的 CPU 是Intel 4004,由意大利物理学家和工程师 Federico Faggin 在 60 年代末设计。它是 4 位架构,而不是我们今天使用的64 位系统,而且它远没有现代处理器那么复杂,但它的许多简单性仍然存在。

CPU 执行的“指令”只是二进制数据:一两个字节表示正在运行的指令(操作码),后面跟着运行该指令所需的任何数据。我们所说的机器代码只不过是一系列连续的二进制指令。汇编是一种用于读取和编写机器代码的有用语法,它比原始位更容易被人类读取和写入;它始终被编译为 CPU 知道如何读取的二进制文件。

该图演示了机器代码如何转换为汇编代码并返回。 双向箭头连接三个示例:机器代码(二进制)后跟 3 个字节的二进制数,机器代码(十六进制)后跟转换为十六进制的 3 个字节(0x83、0xC3、0x0A),以及汇编后跟“add ebx, 10” ”。 汇编代码和机器代码采用颜色编码,因此可以清楚地看出机器代码的每个字节都转换为汇编中的一个字。

顺便说一句:指令并不总是以机器代码 1:1 的形式表示,如上面的示例所示。例如,add eax, 512翻译为05 00 02 00 00.

第一个字节 ( 05) 是一个操作码,具体表示将 EAX 寄存器与 32 位数字相加。其余字节为小端0x200字节顺序的 512 ( ) 。[](https://en.wikipedia.org/wiki/Endianness)

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

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

CPU 存储一个指令指针,该指针指向 RAM 中要获取下一条指令的位置。执行完每条指令后,CPU 会移动指针并重复执行。这是获取-执行周期
2023-08-31T06:39:02.png
演示获取-执行周期的图表。 有两个文本气泡。 第一个标记为“Fetch”,并包含文本“从当前指令指针处的内存读取指令”。 第二个标题为“执行”,并包含文本“运行指令,然后移动指令指针”。 获取气泡有一个指向执行气泡的箭头,而执行气泡有一个指向回获取气泡的箭头,意味着重复的过程。]

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

该指令指针存储在寄存器中。寄存器是小型存储桶,CPU 的读写速度非常快。每个 CPU 架构都有一组固定的寄存器,用于从计算过程中存储临时值到配置处理器等各种用途。

有些寄存器可以直接从机器代码访问,如ebx前面的图中所示。

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

处理器很幼稚

让我们回到最初的问题:当你在计算机上运行可执行程序时会发生什么?首先,一堆魔法恰好准备好运行它 - 我们稍后将完成所有这些 - 但在该过程结束时,某个文件中的机器代码。操作系统将其加载到 RAM 中,并指示 CPU 将指令指针跳转到 RAM 中的该位置。CPU 像往常一样继续运行其获取-执行周期,因此程序开始执行!

(这对我来说是那些让我兴奋不已的时刻之一 - 说真的,这就是您用来阅读本文的程序的运行方式!您的 CPU 正在按顺序从 RAM 中获取浏览器的指令并直接执行它们,它们正在渲染这篇文章。)
2023-08-31T06:42:32.png
描述 RAM 中一系列机器代码字节的图表。 标有“指令指针”的箭头指向突出显示的字节,并且有箭头表示指令指针如何在 RAM 中向前移动。

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

*挥手*过程是由以下组成的抽象操作系统开发人员大字节出售更多电脑

对我来说,这提出的问题多于答案:

  1. 如果CPU不知道多处理并且只是顺序执行指令,那么为什么它不会卡在它正在运行的任何程序中?如何同时运行多个程序?
  2. 如果程序直接在CPU上运行,并且CPU可以直接访问RAM,为什么代码不能从其他进程访问内存,或者,上帝保佑,不能从内核访问内存?
  3. 说到这里,阻止每个进程运行任何指令并对您的计算机执行任何操作的机制是什么?什么是该死的系统调用?

关于内存的问题值得单独讨论,并在第 5 章中介绍——TL;DR 是大多数内存访问实际上都会经历一层重新映射整个地址空间的误导。现在,我们假设程序可以直接访问所有 RAM,而计算机一次只能运行一个进程。我们将及时解释这两个假设。

是时候跳过我们的第一个兔子洞,进入充满系统调用和安全环的土地了。

旁白:顺便说一句,什么是内核?

您计算机的操作系统(例如 macOS、Windows 或 Linux)是在您的计算机上运行并支持所有基本功能运行的软件集合。“基本内容”是一个非常通用的术语,“操作系统”也是一个非常通用的术语 - 取决于您问的是谁,它可以包括计算机默认附带的应用程序、字体和图标等内容。

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

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

两枚戒指统治一切

处理器所处的模式(有时称为特权级别或环)控制其允许执行的操作。现代架构至少有两种选择:内核/管理程序模式和用户模式。虽然一个架构可能支持两种以上的模式,但目前通常只使用内核模式和用户模式。

在内核模式下,一切皆有可能:CPU 可以执行任何支持的指令并访问任何内存。在用户模式下,仅允许指令的子集,I/O 和内存访问受到限制,并且许多 CPU 设置被锁定。通常,内核和驱动程序在内核模式下运行,而应用程序在用户模式下运行。

处理器以内核模式启动。在执行程序之前,内核会启动到用户模式的切换。
2023-08-31T06:52:08.png
两张伪造的 iMessage 屏幕截图展示了用户模式和内核模式保护之间的差异。 第一个,标记为内核模式:右侧显示“读取此受保护的内存!”,左侧回复“给你,亲爱的:)”。 第二个,标记为用户模式:右侧显示“读取此受保护的内存!”,左侧回复“不!分段错误!”

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

什么是系统调用?

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

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

用户空间到内核空间的控制传输是使用称为软件中断的处理器功能来完成的:

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

2023-08-31T06:53:13.png
标题为“中断向量表”的表格图像。 第一列标有数字符号,具有一系列从 01 开始到 04 的数字。表中相应的第二列标有“处理程序地址”,每个条目包含一个随机的 8 字节长的十六进制数字。 桌子底部有文字“等等……”

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

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

(如果您好奇,Linux 上用于系统调用的中断 ID 是。您可以在Michael Kerrisk 的在线联机帮助页0x80目录中阅读 Linux 系统调用列表。)[](https://man7.org/linux/man-pages/man2/syscalls.2.html)

包装 API:抽象中断

以下是迄今为止我们对系统调用的了解:

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

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

跨设备调用系统调用方式的差异意味着程序员为每个程序自己实现系统调用是非常不切实际的。这也意味着操作系统无法更改其中断处理,因为担心会破坏为使用旧系统而编写的每个程序。最后,我们通常不再用原始汇编语言编写程序——不能指望程序员在想要读取文件或分配内存时随时使用汇编语言。
2023-08-31T06:53:57.png
标题为“跨架构系统调用的实现方式不同”的绘图。 左边是一个微笑的 CPU,它接收一些二进制文件并吐出一个文件名 file.txt。 右侧分开的是一个不同的 CPU,它接收相同的二进制数据,但面部表情混乱且令人作呕。

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

当您exit(1)从在类 Unix 系统上运行的 C 进行调用时,该函数在将系统调用的操作码和参数放入正确的寄存器/堆栈/其他内容之后,会在内部运行机器代码来触发中断。电脑真是太酷了!

对速度的极品 / 让我们学习 CISC-y

许多CISC架构(例如 x86-64)包含为系统调用而设计的指令,这些指令是由于系统调用范例的流行而创建的。

Intel 和 AMD 在 x86-64 上协调得不太好;它实际上有两套优化的系统调用指令。SYSCALLSYSENTERINT 0x80. 它们相应的返回指令SYSRETSYSEXIT旨在快速转换回用户空间并恢复程序代码。

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

作为风格的代表,RISC架构往往没有这样的特殊指令。Apple Silicon 所基于的 RISC 架构 AArch64 仅使用一条中断指令来执行系统调用和软件中断。我认为 Mac 用户做得很好:)

哇,太多了!让我们简单回顾一下:

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

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

如果CPU不跟踪多个进程而只是执行一条又一条指令,那么为什么它不会卡在它正在运行的任何程序中?如何同时运行多个程序?

我亲爱的朋友,这个问题的答案也是为什么酷玩乐队如此受欢迎的答案……时钟!(嗯,从技术上讲是计时器。我只是想把这个笑话塞进去。)



评论已关闭