到目前为止,每次我谈到阅读和写作记忆时都有点空洞。例如,ELF 文件指定要加载数据的特定内存地址,那么为什么不同进程尝试使用冲突内存时不会出现问题呢?为什么每个进程似乎都有不同的内存环境? 另外,我们到底是怎么到这里的?我们知道这execve是一个用新程序替换当前进程的系统调用,但这并不能解释如何启动多个进程。它绝对没有解释第一个程序是如何运行的——哪只鸡(进程)产下(产卵)所有其他鸡蛋(其他进程)? 我们的旅程即将结束。回答完这两个问题后,我们将对您的计算机如何从启动到运行您现在正在使用的软件有一个大致完整的了解。 内存是假的 那么……关于记忆。事实证明,当 CPU 读取或写入内存地址时,它实际上并不是指物理内存 (RAM) 中的该位置。相反,它指向虚拟内存空间中的一个位置。 CPU 与称为内存管理单元(MMU) 的芯片进行通信。MMU 的工作方式就像一个带有字典的翻译器,将虚拟内存中的位置翻译为 RAM 中的位置。当 CPU 收到一条从内存地址读取的指令时0xfffaf54834067fe2,它会要求 MMU 转换该地址。MMU在字典中查找,发现匹配的物理地址是0x53a4b64a90179fe2,并将该数字发送回CPU。然后 CPU 可以从 RAM 中的该地址读取数据。一张微笑的 CPU 和 MMU 进行对话的图。 MMU 是一个很高的芯片,戴着图书馆眼镜,拿着一本标有“字典:指针 0x0000 到 0xffff”的大书。 CPU 要求 MMU 转换长内存地址。 MMU 想了一会儿,然后用不同的指针进行响应。] 当计算机首次启动时,内存访问直接进入物理 RAM。启动后,操作系统立即创建翻译字典并告诉CPU开始使用MMU。 这个字典实际上称为页表,这种翻译每次内存访问的系统称为分页。页表中的条目称为页,每个页代表虚拟内存的某一块如何映射到 RAM。这些块始终具有固定大小,并且每个处理器架构具有不同的页面大小。x86-64 的默认页面大小为 4 KiB,这意味着每个页面指定 4,096 字节长的内存块的映射。 换句话说,对于 4 KiB 页面,地址的底部 12 位在 MMU 转换前后始终相同 – 12,因为这是索引转换后获得的 4,096 字节页面所需的位数。 x86-64 还允许操作系统启用更大的 2 MiB 或 4 GiB 页面,这可以提高地址转换速度,但会增加内存碎片和浪费。页大小越大,MMU 转换的地址部分就越小。4 KiB 分页内存地址的细分。 最低 12 位索引页面,其余位由 MMU 转换并成为页面的起始地址。 页表本身仅驻留在 RAM 中。虽然它可以包含数百万个条目,但每个条目的大小仅为几个字节的数量级,因此页表不会占用太多空间。 为了在启动时启用分页,内核首先在 RAM 中构建页表。然后,它将页表起始的物理地址存储在称为页表基址寄存器(PTBR)的寄存器中。最后,内核启用分页以通过 MMU 转换所有内存访问。在 x86-64 上,控制寄存器 3 (CR3) 的前 20 位用作 PTBR。CR0 的位 31(指定用于分页的 PG)设置为 1 以启用分页。 分页系统的神奇之处在于可以在计算机运行时编辑页表。这就是每个进程可以拥有自己独立的内存空间的方式——当操作系统将上下文从一个进程切换到另一个进程时,一项重要的任务是将虚拟内存空间重新映射到物理内存中的不同区域。假设您有两个进程:进程 A 可以在 …
我们现在已经非常了解了execve。在大多数路径的末尾,内核将到达包含要启动的机器代码的最终程序。通常,在实际跳转到代码之前需要一个设置过程 – 例如,程序的不同部分必须加载到内存中的正确位置。每个程序需要不同数量的内存来处理不同的事情,因此我们有标准文件格式来指定如何设置要执行的程序。虽然 Linux 支持许多此类格式,但迄今为止最常见的格式是ELF(可执行和可链接格式)。记号笔在纸上画画。 画面中,一个巫师精灵正在冥想,一手拿着 gnu 的头,另一只手拿着 Linux 企鹅。 小精灵的声音逐渐减弱,说道:“好吧,实际上,Linux 只是内核,操作系统是……”该图画上用红色标记标注了标题:“你听说过架子上的小精灵!现在,做好准备吧。” .. GNU/Linux 上的 elf。” 这幅画的署名是“尼基”。 (感谢Nicky Case绘制的可爱图画。) 旁白:精灵无处不在吗? 当您在 Linux 上运行应用程序或命令行程序时,它很可能是 ELF 二进制文件。然而,在 macOS 上,事实上的格式是Mach-O。Mach-O 与 ELF 执行所有相同的操作,但结构不同。在 Windows 上,.exe 文件使用可移植可执行文件格式,这也是具有相同概念的不同格式。 在Linux内核中,ELF二进制文件由处理程序处理binfmt_elf,该处理程序比许多其他处理程序更复杂并且包含数千行代码。它负责从 ELF 文件中解析出某些详细信息,并使用它们将进程加载到内存中并执行。 我运行了一些命令行功夫来按行数对 binfmt 处理程序进行排序: 外壳会话 $ wc -l binfmt_* | sort -nr | sed 1d 2181 binfmt_elf.c 1658 binfmt_elf_fdpic.c 944 binfmt_flat.c 836 binfmt_misc.c 158 binfmt_script.c 64 binfmt_elf_test.c 文件结构 在更深入地了解如何binfmt_elf执行 ELF 文件之前,让我们先看一下文件格式本身。ELF 文件通常由四部分组成: 该图显示了 ELF 文件结构的概述,包含四个连续部分。 第 1 部分,ELF 标头:有关二进制文件的基本信息以及 PHT 和 SHT 的位置。 第 2 部分,程序头表 (PHT):描述如何以及在何处将 ELF 文件的数据加载到内存中。 第 3 节,节头表 (SHT):可选的数据“映射”,以协助调试。 第 4 部分,数据:所有二进制数据。 PHT 和 SHT 指向本节。 ELF 头 每个 ELF 文件都有一个ELF …
到目前为止,我们已经介绍了 CPU 如何执行从可执行文件加载的机器代码、什么是基于环的安全性以及系统调用如何工作。在本节中,我们将深入了解 Linux 内核,首先了解程序是如何加载和运行的。 我们将专门研究 x86-64 上的 Linux。为什么? Linux 是一款功能齐全的生产操作系统,适用于桌面、移动和服务器用例。Linux 是开源的,因此只需阅读其源代码就可以非常容易地进行研究。我将在本文中直接引用一些内核代码! x86-64 是大多数现代台式计算机使用的体系结构,也是许多代码的目标体系结构。我提到的特定于 x86-64 的行为子集可以很好地概括。 我们学到的大部分内容都可以很好地推广到其他操作系统和体系结构,即使它们在各种特定方面有所不同。 Exec 系统调用的基本行为 演示 exec 系统调用的流程图。 左边是一组标记为“用户空间”的流程图项,右边是一组标记为“内核空间”的流程图项。 从用户空间组开始:用户在终端中运行 ./file.bin,然后运行系统调用 execve(“./file.bin”, …)。 这流向正在执行的 SYSCALL 指令,该指令随后指向内核空间组中的第一项:“加载并设置二进制文件”,该指令指向“尝试 binfmt”。 如果支持 binfmt,它将启动新进程(替换当前进程)。 如果没有,它会再次尝试 binfmt。 让我们从一个非常重要的系统调用开始:execve。它加载一个程序,如果成功,则用该程序替换当前进程。还存在其他几个系统调用(execlp、execvpe等),但它们都以各种方式分层execve。 在旁边:execveat execve实际上是建立在 之上的execveat,这是一个更通用的系统调用,它运行带有一些配置选项的程序。为了简单起见,我们主要讨论execve;唯一的区别是它提供了一些默认值execveat。 好奇ve代表什么?表示v一个参数是参数 ( ) 的向量(列表)argv,表示e另一个参数是环境变量 ( ) 的向量envp。各种其他 exec 系统调用具有不同的后缀来指定不同的调用签名。in只是“at”,因为它指定了运行的at位置。execveat`execve` 的调用签名execve是: int execve(const char *filename, char *const argv[], char *const envp[]); 参数filename指定要运行的程序的路径。 argv是程序的一个以 null 结尾的(意味着最后一项是空指针)参数列表。您通常会看到传递给 C 主函数的参数argc实际上是稍后由系统调用计算的,因此是空终止。 该envp参数包含另一个以 null 结尾的环境变量列表,用作应用程序的上下文。他们……按照惯例KEY=VALUE是成对的。按照惯例。我喜欢电脑。 有趣的事实!您知道程序的第一个参数是程序名称的约定吗?这纯粹是一个约定,实际上并不是由execve系统调用本身设置的!execve第一个参数将是作为参数中第一项传递的任何内容argv,即使它与程序名称无关。 有趣的是,execve确实有一些代码假设argv[0]是程序名称。稍后我们讨论解释型脚本语言时会详细介绍这一点。 第0步:定义 我们已经知道系统调用是如何工作的,但我们从未见过现实世界的代码示例!让我们看一下 Linux 内核的源代码,看看execve其底层是如何定义的: 文件系统/exec.c 2105 2106 2107 2108 2109 2110 2111 SYSCALL_DEFINE3(execve, const char __user *, filename, const char __user *const __user *, argv, const char __user *const __user …
假设您正在构建一个操作系统,并且希望用户能够同时运行多个程序。不过,您没有精美的多核处理器,因此您的 CPU 一次只能运行一条指令! 幸运的是,您是一位非常聪明的操作系统开发人员。您发现可以通过让进程轮流使用 CPU 来伪造并行性。如果您循环遍历这些进程并运行每个进程中的几条指令,那么它们都可以响应,而不会出现任何单个进程占用 CPU 的情况。 但是如何从程序代码中夺回控制权来切换进程呢?经过一番研究后,您发现大多数计算机都带有定时器芯片。您可以对定时器芯片进行编程,以在经过一定时间后触发切换到操作系统中断处理程序。 硬件中断 之前,我们讨论了如何使用软件中断将控制权从用户层程序交给操作系统。这些被称为“软件”中断,因为它们是由程序自愿触发的——处理器在正常的获取-执行周期中执行的机器代码告诉它将控制权切换到内核。该图说明了硬件中断如何破坏正常执行。 顶部:一张键盘图,其中突出显示了一个按键,右侧的 CPU 上画有一个闪电。 底部:一些标记为“程序代码”的二进制文件,一个类似的闪电,还有一些标记为“内核代码”的二进制文件。 闪电标记为“中断触发上下文切换”。 操作系统调度程序使用PIT等定时器芯片来触发多任务处理的硬件中断:[](https://en.wikipedia.org/wiki/Programmable_interval_timer) 在跳转到程序代码之前,操作系统设置定时器芯片在一段时间后触发中断。 操作系统切换到用户模式并跳转到程序的下一条指令。 当定时器到时,它会触发硬件中断以切换到内核模式并跳转到操作系统代码。 操作系统现在可以保存程序停止的位置,加载不同的程序,然后重复该过程。 这称为抢占式多任务处理;进程的中断称为抢占)。比如说,如果您在浏览器上阅读本文并在同一台计算机上听音乐,那么您自己的计算机可能每秒会重复这个精确的循环数千次。 时间片计算 时间片是操作系统调度程序允许进程在抢占之前运行的持续时间。选择时间片的最简单方法是为每个进程提供相同的时间片(可能在 10 毫秒范围内),并按顺序循环执行任务。这称为固定时间片循环调度。 旁白:有趣的行话事实! 您知道时间片通常被称为“量子”吗?现在您做到了,您可以给所有技术朋友留下深刻的印象。我认为我应该受到很多赞扬,因为我在本文的其他句子中都没有提到量子。 说到时间片术语,Linux 内核开发人员使用jiffy时间单位来计算固定频率计时器的滴答数。除此之外,jiffies 用于测量时间片的长度。Linux 的 jiffy 频率通常为 1000 Hz,但可以在编译内核时进行配置。 固定时间片调度的一个微小改进是选择目标延迟——进程响应的理想最长时间。目标延迟是进程被抢占后恢复执行所需的时间(假设进程数量合理)。这很难想象!别担心,图表很快就会出现。 时间片的计算方法是目标延迟除以任务总数;这比固定时间片调度更好,因为它消除了用更少的进程进行浪费的任务切换。如果目标延迟为 15 毫秒并有 10 个进程,则每个进程将获得 15/10 或 1.5 毫秒的运行时间。由于只有 3 个进程,每个进程获得更长的 5 毫秒时间片,同时仍达到目标延迟。 进程切换的计算成本很高,因为它需要保存当前程序的整个状态并恢复不同的状态。过了某个点,时间片太小可能会导致进程切换太快而出现性能问题。通常给时间片持续时间一个下限(最小粒度)。这确实意味着当有足够的进程使最小粒度生效时,就会超出目标延迟。 在撰写本文时,Linux 的调度程序使用 6 毫秒的目标延迟和 0.75 毫秒的最小粒度。标题为“朴素动态时间片循环调度”的图表。 它描述了 3 个不同进程的时间序列,它们有时间在重复循环中执行。 在每个进程的执行块之间有一个短得多的块,标记为“内核调度程序”。 每个程序执行块的长度被标记为“时间片(2ms)”。 从进程 1 开始执行到进程 1 下一次开始执行的距离(包含进程 2 和 3 的执行时间)被标记为“目标延迟 (6ms)”。 使用这种基本时间片计算的循环调度接近于当今大多数计算机的做法。这还是有点天真;大多数操作系统往往有更复杂的调度程序,其中考虑了进程优先级和截止日期。从 2007 年开始,Linux 使用了一种名为Completely Fair Scheduler 的调度器。CFS 做了很多非常奇特的计算机科学事情来确定任务的优先级并分配 CPU 时间。 每次操作系统抢占一个进程时,它都需要加载新程序保存的执行上下文,包括其内存环境。这是通过告诉 CPU 使用不同的页表(从“虚拟”地址到物理地址的映射)来实现的。这也是防止程序互相访问内存的系统;我们将在本文的第 5章和第 6章中深入探讨这个兔子洞。 注意#1:内核可抢占性 到目前为止,我们只讨论了用户态进程的抢占和调度。如果内核代码处理系统调用或执行驱动程序代码花费的时间过长,则可能会使程序感觉滞后。 现代内核,包括 Linux,都是抢占式内核。这意味着它们的编程方式允许内核代码本身像用户态进程一样被中断和调度。 除非您正在编写内核或其他东西,否则了解这一点并不是很重要,但基本上我读过的每一篇文章都提到了它,所以我想我也会!额外的知识很少是坏事。 注#2:历史课 古代操作系统,包括经典的 Mac OS 和早于 NT 的 Windows …
从头开始…… 我用计算机做了很多事情,但我的知识始终有一个空白:当你在计算机上运行程序时到底会发生什么?我思考了这个差距——我拥有大部分必需的低级知识,但我正在努力将所有内容拼凑在一起。程序真的是直接在 CPU 上执行吗,还是另有原因?我使用过系统调用,但它们是如何工作的?它们到底是什么?多个程序如何同时运行? 我崩溃了,开始尽可能多地思考。如果您不上大学,那么综合的系统资源并不多,因此我必须筛选大量不同质量的不同来源,有时甚至是相互冲突的信息。经过几周的研究和近 40 页的笔记之后,我想我对计算机从启动到程序执行的工作原理有了更好的了解。我很想写一篇可靠的文章来解释我所学到的东西,所以我正在写一篇我希望拥有的文章。 你知道他们说什么……只有当你能向别人解释某件事时,你才能真正理解它。 第一章:基础知识 第二章:多任务处理 第三章:程序运行 第四章:ELF 第五章:paging 第六章:异步执行 第七章:总结