FreeBSD 中的 Linux® 模拟

商标

FreeBSD 是 FreeBSD 基金会的注册商标。

IBM、AIX、OS/2、PowerPC、PS/2、S/390 和 ThinkPad 是国际商业机器公司在美国、其他国家/地区或两者的商标。

Adobe、Acrobat、Acrobat Reader、Flash 和 PostScript 都是 Adobe Systems Incorporated 在美国和/或其他国家/地区的注册商标或商标。

Linux 是 Linus Torvalds 的注册商标。

Sun、Sun Microsystems、Java、Java 虚拟机、JDK、JRE、JSP、JVM、Netra、OpenJDK、Solaris、StarOffice、SunOS 和 VirtualBox 是 Sun Microsystems, Inc. 在美国和其他国家/地区的商标或注册商标。

NetBSD 是 NetBSD 基金会的注册商标。

RealNetworks、RealPlayer 和 RealAudio 是 RealNetworks, Inc. 的注册商标。

Oracle 是 Oracle Corporation 的注册商标。

制造商和销售商用来区分其产品的许多名称都被宣称为商标。在本文档中出现这些名称的地方,并且 FreeBSD 项目知道商标声明,则这些名称后面已加上“™”或“®”符号。

摘要

这篇硕士论文涉及更新 Linux® 模拟层(所谓的 Linuxulator)。任务是更新该层以匹配 Linux® 2.6 的功能。作为参考实现,选择了 Linux® 2.6.16 内核。该概念松散地基于 NetBSD 实现。大部分工作是在 2006 年夏季作为 Google Summer of Code 学生计划的一部分完成的。重点是将 NPTL(新的 POSIX® 线程库)支持引入模拟层,包括 TLS(线程局部存储)、futexes(快速用户空间互斥体)、PID 转换和其他一些小事。在此过程中,发现了许多小问题并进行了修复。我的工作已集成到 FreeBSD 主源代码存储库中,并将包含在即将发布的 7.0R 版本中。我们模拟开发团队正在努力使 Linux® 2.6 模拟成为 FreeBSD 中的默认模拟层。


1. 简介

在过去几年中,基于开源 UNIX® 的操作系统开始在服务器和客户端机器上广泛部署。在这些操作系统中,我想指出两个:FreeBSD,因为它具有 BSD 血统、久经考验的代码库和许多有趣的特性,以及 Linux®,因为它拥有广泛的用户群、热情的开源开发者社区以及大型公司的支持。FreeBSD 倾向于用于服务器级机器,为繁重的网络任务提供服务,而在桌面级机器上用于普通用户的频率较低。虽然 Linux® 在服务器上具有相同的用途,但它在家用用户中使用得更多。这导致出现了一种情况,即有许多仅适用于 Linux® 的二进制程序,而这些程序缺乏对 FreeBSD 的支持。

自然而然,就需要能够在 FreeBSD 系统上运行 Linux® 二进制文件,而这正是本论文所要探讨的:在 FreeBSD 操作系统中模拟 Linux® 内核。

在 2006 年夏季,Google Inc. 赞助了一个项目,该项目专注于扩展 FreeBSD 中的 Linux® 模拟层(所谓的 Linuxulator),以包含 Linux® 2.6 的功能。本论文是作为该项目的一部分编写的。

2. 内部一瞥…​

在本节中,我们将描述每个相关的操作系统。它们如何处理系统调用、陷阱帧等所有底层内容。我们还描述了它们理解常见 UNIX® 原语的方式,例如什么是 PID、什么是线程等。在第三小节中,我们将讨论如何在一般情况下实现 UNIX® 上的 UNIX® 模拟。

2.1. 什么是 UNIX®

UNIX® 是一种历史悠久的操作系统,它影响了目前使用的几乎所有其他操作系统。从 20 世纪 60 年代开始,它的发展一直持续到今天(尽管在不同的项目中)。UNIX® 的开发很快分叉成两个主要方向:BSD 和 System III/V 系列。它们相互影响,发展出共同的 UNIX® 标准。在 BSD 中产生的贡献中,我们可以命名虚拟内存、TCP/IP 网络、FFS 等等。System V 分支为 SysV 进程间通信原语、写时复制等做出了贡献。UNIX® 本身已不复存在,但它的理念已被世界各地的许多其他操作系统采用,从而形成了所谓的 UNIX® 类操作系统。如今,最具影响力的是 Linux®、Solaris,以及可能(在某种程度上)FreeBSD。还有公司内部的 UNIX® 派生产品(AIX、HP-UX 等),但这些产品越来越多地迁移到上述系统。让我们总结一下典型的 UNIX® 特征。

2.2. 技术细节

每个正在运行的程序都构成一个进程,表示计算的一种状态。运行的进程分为内核空间和用户空间。某些操作只能从内核空间执行(处理硬件等),但进程应将其大部分生命周期花费在用户空间中。内核是管理进程、硬件和底层细节的地方。内核为用户空间提供标准的统一 UNIX® API。下面介绍其中最重要的内容。

2.2.1. 内核和用户空间进程之间的通信

常见的 UNIX® API 将系统调用定义为从用户空间进程向内核发出命令的一种方式。最常见的实现是使用中断或专门指令(考虑 ia32 的 SYSENTER/SYSCALL 指令)。系统调用由一个数字定义。例如,在 FreeBSD 中,系统调用号 85 是 swapon(2) 系统调用,系统调用号 132 是 mkfifo(2)。某些系统调用需要参数,这些参数以各种方式(实现相关)从用户空间传递到内核空间。系统调用是同步的。

另一种可能的通信方式是使用 陷阱。陷阱在某些事件发生后异步发生(除以零、页面错误等)。陷阱对于进程来说可能是透明的(页面错误),或者可能导致反应,例如发送 信号(除以零)。

2.2.2. 进程之间的通信

还有其他 API(System V IPC、共享内存等),但最重要的 API 是信号。信号由进程或内核发送,并由进程接收。某些信号可以被忽略或由用户提供的例程处理,某些信号会导致无法更改或忽略的预定义操作。

2.2.3. 进程管理

内核实例首先在系统中被处理(所谓的 init)。每个正在运行的进程都可以使用 fork(2) 系统调用创建其自身的副本。引入了此系统调用的某些略微修改的版本,但基本语义相同。每个正在运行的进程都可以使用 exec(3) 系统调用转换为其他进程。引入了此系统调用的某些修改,但所有这些都服务于相同的基本目的。进程通过调用 exit(2) 系统调用结束其生命周期。每个进程都由一个唯一的数字标识,称为 PID。每个进程都有一个定义的父进程(由其 PID 标识)。

2.2.4. 线程管理

传统的 UNIX® 没有定义任何用于线程的 API 或实现,而 POSIX® 定义了其线程 API,但实现未定义。传统上,有两种实现线程的方式。将它们作为单独的进程处理(1:1 线程)或将整个线程组封装在一个进程中并在用户空间中管理线程(1:N 线程)。比较每种方法的主要特性

1:1 线程

  • - 重量级线程

  • - 用户无法更改调度(通过 POSIX® API 略有缓解)

  • + 无需包装系统调用

  • + 可以利用多个 CPU

1:N 线程

  • + 轻量级线程

  • + 用户可以轻松更改调度

  • - 必须包装系统调用

  • - 不能利用多个 CPU

2.3. 什么是 FreeBSD?

FreeBSD 项目是目前可用于日常使用的最古老的开源操作系统之一。它是真正的 UNIX® 的直接后代,因此可以说它是真正的 UNIX®,尽管许可问题不允许这样说。该项目的开始可以追溯到 20 世纪 90 年代初,当时一群 BSD 用户对 386BSD 操作系统进行了修补。基于此补丁包,出现了一个新的操作系统,名为 FreeBSD,因为它具有自由的许可证。另一组创建了 NetBSD 操作系统,其目标不同。我们将重点关注 FreeBSD。

FreeBSD 是一种现代的基于 UNIX® 的操作系统,具有 UNIX® 的所有功能。抢占式多任务处理、多用户功能、TCP/IP 网络、内存保护、对称多处理支持、具有合并的 VM 和缓冲区的虚拟内存,它们都存在。其中一个有趣且非常有用的功能是能够模拟其他 UNIX® 类操作系统。截至 2006 年 12 月和 7-CURRENT 开发,支持以下模拟功能

  • FreeBSD/i386 在 FreeBSD/amd64 上的模拟

  • FreeBSD/i386 在 FreeBSD/ia64 上的模拟

  • 在 FreeBSD 上模拟 Linux® 操作系统的 Linux® 模拟

  • Windows 网络驱动程序接口的 NDIS 模拟

  • NetBSD 操作系统的 NetBSD 模拟

  • PECoff FreeBSD 可执行文件的 PECoff 支持

  • System V 修订版 4 UNIX® 的 SVR4 模拟

积极开发的模拟是 Linux® 层和各种 FreeBSD-on-FreeBSD 层。其他模拟现在不应该正常工作或可用。

2.3.1. 技术细节

FreeBSD 是传统意义上的 UNIX® 系统,它将进程的运行分为两个部分:内核空间和用户空间运行。进程进入内核有两种方式:系统调用和陷阱。返回的方式只有一种。在接下来的章节中,我们将描述进出内核的三个门。整个描述适用于 i386 架构,因为 Linuxulator 仅存在于此架构中,但概念在其他架构上类似。信息来自 [1] 和源代码。

2.3.1.1. 系统入口

FreeBSD 有一个称为执行类加载器的抽象概念,它是 execve(2) 系统调用的一个切入点。它使用一个 sysentvec 结构体,该结构体描述了可执行文件的 ABI。它包含错误号转换表、信号转换表、以及各种用于服务系统调用需求的函数(栈修复、核心转储等)。FreeBSD 内核想要支持的每个 ABI 都必须定义此结构体,因为它在随后的系统调用处理代码和其他一些地方都会用到。系统入口由陷阱处理程序处理,在这里我们可以同时访问内核空间和用户空间。

2.3.1.2. 系统调用

FreeBSD 上的系统调用是通过执行中断 0x80 并将寄存器 %eax 设置为所需的系统调用编号,并将参数传递到栈上完成的。

当一个进程发出中断 0x80 时,就会发出 int0x80 系统调用陷阱处理程序(定义在 sys/i386/i386/exception.s 中),它为调用 C 函数 syscall(2)(定义在 sys/i386/i386/trap.c 中)准备参数(即将其复制到栈上),该函数处理传入的 trapframe。处理过程包括准备系统调用(取决于 sysvec 条目),确定系统调用是 32 位还是 64 位(更改参数的大小),然后复制参数,包括系统调用本身。接下来,执行实际的系统调用函数并处理返回值(针对 ERESTARTEJUSTRETURN 错误的特殊情况)。最后,调度一个 userret(),将进程切换回用户空间。传递给实际系统调用处理程序的参数采用 struct thread *tdstruct syscall args * 参数的形式,其中第二个参数是指向已复制的参数结构体的指针。

2.3.1.3. 陷阱

FreeBSD 中的陷阱处理与系统调用的处理类似。每当发生陷阱时,就会调用一个汇编处理程序。根据陷阱的类型,会在 alltraps、带有寄存器压栈的 alltraps 或 calltrap 之间进行选择。此处理程序为调用 C 函数 trap()(定义在 sys/i386/i386/trap.c 中)准备参数,然后该函数处理发生的陷阱。处理完成后,它可能会向进程发送信号和/或使用 userret() 退出到用户空间。

2.3.1.4. 退出

从内核到用户空间的退出使用汇编例程 doreti 完成,无论内核是通过陷阱还是通过系统调用进入的。这将从栈中恢复程序状态并返回到用户空间。

2.3.1.5. UNIX® 原语

FreeBSD 操作系统遵循传统的 UNIX® 方案,其中每个进程都有一个唯一的识别号,称为 PID(进程 ID)。PID 号是线性分配或随机分配的,范围从 0PID_MAX。PID 号的分配是使用 PID 空间的线性搜索完成的。进程中的每个线程在 getpid(2) 调用后都会收到与进程相同的 PID 号。

目前在 FreeBSD 中实现线程有两种方法。第一种是 M:N 线程,其次是 1:1 线程模型。默认使用的库是 M:N 线程(libpthread),可以在运行时切换到 1:1 线程(libthr)。计划很快将默认切换到 1:1 库。尽管这两个库使用相同的内核原语,但它们是通过不同的 API 访问的。M:N 库使用 kse_* 系列的系统调用,而 1:1 库使用 thr_* 系列的系统调用。因此,内核和用户空间之间没有共享的线程 ID 通用概念。当然,这两个线程库都实现了 pthread 线程 ID API。每个内核线程(由 struct thread 描述)都有 td tid 标识符,但这在用户空间无法直接访问,仅用于内核的需要。它也用于 1:1 线程库作为 pthread 的线程 ID,但其处理方式是库内部的,不可依赖。

如前所述,FreeBSD 中有两种线程实现方式。M:N 库将工作划分为内核空间和用户空间。线程是在内核中被调度的实体,但它可以代表多个用户空间线程。M 个用户空间线程映射到 N 个内核线程,从而节省资源,同时保持利用多处理器并行性的能力。有关实现的更多信息,可以从手册页或 [1] 中获取。1:1 库将用户空间线程直接映射到内核线程,从而大大简化了方案。这些设计都没有实现公平性机制(曾经实现过这种机制,但最近被移除,因为它导致严重的减速并使代码更难处理)。

2.4. 什么是 Linux®

Linux® 是一个类 UNIX® 内核,最初由 Linus Torvalds 开发,现在由全球众多程序员共同贡献。从最初的萌芽到今天,在 IBM 或 Google 等公司的广泛支持下,Linux® 以其快速的发展步伐、完整的硬件支持和仁慈的独裁者组织模式而闻名。

Linux® 的开发始于 1991 年,作为芬兰赫尔辛基大学的一个业余爱好者项目。从那时起,它获得了现代类 UNIX® 操作系统的所有功能:多处理、多用户支持、虚拟内存、网络,基本上所有功能都具备。它还具备虚拟化等高级功能。

截至 2006 年,Linux® 似乎是最广泛使用的开源操作系统,并得到了 Oracle、RealNetworks、Adobe 等独立软件供应商的支持。大多数为 Linux® 分发的商业软件只能以二进制形式获得,因此无法重新编译到其他操作系统。

大多数 Linux® 的开发都发生在 Git 版本控制系统中。Git 是一个分布式系统,因此没有 Linux® 代码的中央源,但某些分支被认为是突出的和官方的。Linux® 实现的版本号方案由四个数字 A.B.C.D 组成。当前的开发发生在 2.6.C.D 中,其中 C 表示主要版本,添加或更改新功能,而 D 是仅用于错误修复的次要版本。

更多信息可以从 [3] 中获取。

2.4.1. 技术细节

Linux® 遵循传统的 UNIX® 方案,将进程的运行分为两个部分:内核和用户空间。内核可以通过两种方式进入:通过陷阱或通过系统调用。返回只有一种处理方式。以下描述适用于 i386™ 架构上的 Linux® 2.6。此信息来自 [2]。

2.4.1.1. 系统调用

Linux® 中的系统调用(在用户空间)使用 syscallX 宏完成,其中 X 替换为表示给定系统调用参数数量的数字。此宏转换为加载 %eax 寄存器为系统调用编号并执行中断 0x80 的代码。在此系统调用返回后,会调用该函数,将负返回值转换为正 errno 值,并在发生错误时将 res 设置为 -1。每当调用中断 0x80 时,进程就会进入系统调用陷阱处理程序的内核。此例程将所有寄存器保存在栈上,并调用选定的系统调用入口。请注意,Linux® 调用约定期望通过寄存器传递系统调用的参数,如下所示

  1. 参数 → %ebx

  2. 参数 → %ecx

  3. 参数 → %edx

  4. 参数 → %esi

  5. 参数 → %edi

  6. 参数 → %ebp

有一些例外情况,Linux® 使用不同的调用约定(最值得注意的是 clone 系统调用)。

2.4.1.2. 陷阱

陷阱处理程序在 arch/i386/kernel/traps.c 中引入,其中大多数处理程序位于 arch/i386/kernel/entry.S 中,陷阱的处理就发生在这里。

2.4.1.3. 退出

系统调用的返回由系统调用 exit(3) 管理,它检查进程是否有未完成的工作,然后检查是否使用了用户提供的选择器。如果发生这种情况,则应用堆栈修复,最后从堆栈恢复寄存器,进程返回到用户空间。

2.4.1.4. UNIX® 原语

在 2.6 版本中,Linux® 操作系统重新定义了一些传统的 UNIX® 原语,特别是 PID、TID 和线程。PID 被定义为并非对每个进程都唯一,因此对于某些进程(线程),getppid(2) 返回相同的值。进程的唯一标识由 TID 提供。这是因为 NPTL(新的 POSIX® 线程库)将线程定义为普通进程(所谓的 1:1 线程)。在 Linux® 2.6 中生成新进程是使用 clone 系统调用完成的(fork 变体使用它重新实现)。此 clone 系统调用定义了一组标志,这些标志会影响克隆进程关于线程实现的行为。语义有点模糊,因为没有单个标志告诉系统调用创建线程。

已实现的 clone 标志有

  • CLONE_VM - 进程共享其内存空间

  • CLONE_FS - 共享 umask、cwd 和命名空间

  • CLONE_FILES - 共享打开的文件

  • CLONE_SIGHAND - 共享信号处理程序和阻塞信号

  • CLONE_PARENT - 共享父进程

  • CLONE_THREAD - 成为线程(以下进一步解释)

  • CLONE_NEWNS - 新命名空间

  • CLONE_SYSVSEM - 共享 SysV 撤销结构

  • CLONE_SETTLS - 在提供的地址处设置 TLS

  • CLONE_PARENT_SETTID - 在父进程中设置 TID

  • CLONE_CHILD_CLEARTID - 在子进程中清除 TID

  • CLONE_CHILD_SETTID - 在子进程中设置 TID

CLONE_PARENT 将真正的父进程设置为调用者的父进程。这对线程很有用,因为如果线程 A 创建了线程 B,我们希望线程 B 的父进程是整个线程组的父进程。CLONE_THREAD 执行与 CLONE_PARENTCLONE_VMCLONE_SIGHAND 完全相同的事情,将 PID 重写为与调用者的 PID 相同,将退出信号设置为无,并进入线程组。CLONE_SETTLS 为 TLS 处理设置 GDT 条目。CLONE_*_*TID 标志集设置/清除用户提供的地址到 TID 或 0。

如您所见,CLONE_THREAD 完成了大部分工作,并且似乎不太适合该方案。最初的意图尚不清楚(即使对于代码中的注释作者而言),但我认为最初有一个线程标志,后来被分成许多其他标志,但这种分离从未完全完成。目前也不清楚这种划分有什么好处,因为 glibc 没有使用它,因此只有手动使用 clone 才能让程序员访问这些功能。

对于非线程程序,PID 和 TID 相同。对于线程程序,第一个线程的 PID 和 TID 相同,并且每个创建的线程共享相同的 PID 并被分配一个唯一的 TID(因为传递了CLONE_THREAD),所有形成此线程程序的进程也共享父进程。

在 NPTL 中实现pthread_create(3) 的代码这样定义克隆标志

int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGNAL

 | CLONE_SETTLS | CLONE_PARENT_SETTID

| CLONE_CHILD_CLEARTID | CLONE_SYSVSEM
#if __ASSUME_NO_CLONE_DETACHED == 0

| CLONE_DETACHED
#endif

| 0);

CLONE_SIGNAL 的定义如下:

#define CLONE_SIGNAL (CLONE_SIGHAND | CLONE_THREAD)

最后的 0 表示当任何线程退出时不发送信号。

2.5. 什么是模拟

根据字典定义,模拟是指程序或设备模仿另一个程序或设备的能力。这是通过对给定刺激做出与被模拟对象相同的反应来实现的。在实践中,软件世界主要看到三种类型的模拟——用于模拟机器的程序(QEMU、各种游戏机模拟器等)、硬件设施的软件模拟(OpenGL 模拟器、浮点单元模拟等)和操作系统模拟(在操作系统内核中或作为用户空间程序)。

模拟通常用于无法或根本无法使用原始组件的地方。例如,有人可能希望使用为不同操作系统开发的程序。然后模拟就派上用场了。有时别无选择,只能使用模拟——例如,当您尝试使用的硬件设备不存在(尚未/不再存在)时,别无选择,只能使用模拟。这在将操作系统移植到新的(不存在的)平台时经常发生。有时模拟更便宜。

从实现的角度来看,模拟的实现主要有两种方法。您可以模拟整个过程——接受原始对象的可能输入,维护内部状态并根据状态和/或输入发出正确的输出。这种模拟不需要任何特殊条件,基本上可以在任何地方为任何设备/程序实现。缺点是实现这种模拟非常困难、耗时且容易出错。在某些情况下,我们可以使用更简单的方法。假设您想模拟一台从左到右打印的打印机,而您有一台从右到左打印的打印机。很明显,不需要复杂的模拟层,只需反转打印文本就足够了。有时模拟环境与被模拟环境非常相似,因此只需一层薄薄的转换即可提供完全有效的模拟!正如您所看到的,这在实现上要求低得多,因此比之前的方法更省时且不易出错。但前提条件是这两个环境必须足够相似。第三种方法结合了前两种方法。大多数情况下,对象提供的功能不相同,因此在将更强大的对象模拟到较弱的对象上时,我们必须使用上面描述的完整模拟来模拟缺失的功能。

本硕士论文探讨了 UNIX® 在 UNIX® 上的模拟,这正是只用一层薄薄的转换即可提供完整模拟的情况。UNIX® API 由一组系统调用组成,这些系统调用通常是自包含的,不会影响某些全局内核状态。

有一些系统调用会影响内部状态,但这可以通过提供一些维护额外状态的结构来解决。

没有完美的模拟,并且模拟往往缺少某些部分,但这通常不会造成任何严重的缺点。想象一下,一个游戏机模拟器模拟了所有内容,但音乐输出除外。毫无疑问,游戏是可以玩的,并且可以使用模拟器。它可能不如原始游戏机那么舒适,但它是价格和舒适度之间可以接受的折衷方案。

UNIX® API 也是如此。大多数程序都可以使用非常有限的系统调用集。这些系统调用往往是最古老的(read(2)/write(2)fork(2) 系列、signal(3) 处理、exit(3)socket(2) API),因此易于模拟,因为它们的语义在今天存在的所有 UNIX® 中都是共享的。

3. 模拟

3.1. FreeBSD 中的模拟工作原理

如前所述,FreeBSD 支持运行来自其他几个 UNIX® 的二进制文件。这是因为 FreeBSD 有一个称为执行类加载器的抽象。它插入到execve(2) 系统调用中,因此当execve(2) 即将执行二进制文件时,它会检查其类型。

FreeBSD 中基本上有两种类型的二进制文件。Shell 类型的文本脚本,其前两个字符为#! 标识,以及正常的(通常为ELF)二进制文件,它们是已编译的可执行对象的表示形式。FreeBSD 中绝大多数(可以说全部)二进制文件都属于 ELF 类型。ELF 文件包含一个标题,该标题指定此 ELF 文件的操作系统 ABI。通过读取这些信息,操作系统可以准确确定给定文件是什么类型的二进制文件。

每个 OS ABI 必须在 FreeBSD 内核中注册。这也适用于 FreeBSD 本机 OS ABI。因此,当execve(2) 执行二进制文件时,它会遍历已注册的 API 列表,并在找到正确的 API 时开始使用 OS ABI 描述中包含的信息(其系统调用表、errno 转换表等)。因此,每次进程调用系统调用时,它都会使用自己的系统调用集,而不是某些全局系统调用集。这有效地提供了一种非常优雅且简单的方法来支持各种二进制格式的执行。

不同操作系统(以及一些其他子系统)的模拟性质导致开发人员引入了处理程序事件机制。内核中有多个位置会调用事件处理程序列表。每个子系统都可以注册一个事件处理程序,并相应地调用它们。例如,当进程退出时,会调用一个处理程序,该处理程序可能会清理子系统需要清理的任何内容。

这些简单的功能基本上提供了模拟基础设施所需的一切,事实上,这些基本上是实现 Linux® 模拟层所必需的唯一内容。

3.2. FreeBSD 内核中的通用原语

模拟层需要来自操作系统的某些支持。我将描述 FreeBSD 操作系统中支持的一些原语。

3.2.1. 锁定原语

贡献者:Attilio Rao <attilio@FreeBSD.org>

FreeBSD 同步原语集基于这样的理念:以一种能够为每个特定、适当的情况使用最佳原语的方式提供相当大量的不同原语。

从高级角度来看,您可以在 FreeBSD 内核中考虑三种同步原语

  • 原子操作和内存屏障

  • 调度屏障

下面是这三个系列的描述。对于每个锁,您确实应该检查链接的手册页(如果可能),以获取更详细的解释。

3.2.1.1. 原子操作和内存屏障

原子操作是通过一组函数实现的,这些函数以相对于外部事件(中断、抢占等)的原子方式对内存操作数执行简单的算术运算。原子操作只能保证对小型数据类型(在.long. 架构 C 数据类型的数量级上)的原子性,因此在最终级代码中很少直接使用,除非仅用于非常简单的操作(例如位图中的标志设置)。事实上,仅基于原子操作(通常称为无锁)编写错误的语义非常简单且常见。FreeBSD 内核提供了一种结合内存屏障执行原子操作的方法。内存屏障将保证原子操作将按照相对于其他内存访问的某些指定顺序发生。例如,如果我们需要原子操作仅在所有其他挂起的写入(就指令重新排序缓冲区活动而言)完成后发生,则需要显式地将内存屏障与该原子操作结合使用。因此,很容易理解为什么内存屏障在构建更高级别的锁(如引用计数、互斥锁等)中起着关键作用。有关原子操作的详细说明,请参阅atomic(9)。然而,需要注意的是,原子操作(以及内存屏障)理想情况下应该仅用于构建前端锁(如互斥锁)。

3.2.1.2. 引用计数

引用计数是用于处理引用计数的接口。它们是通过原子操作实现的,并且仅用于引用计数是唯一需要保护的事物的情况,因此即使是自旋互斥锁也被弃用。在已经使用互斥锁的结构中使用引用计数接口通常是错误的,因为我们可能应该在某些已经受保护的路径中关闭引用计数。目前尚不存在讨论引用计数的手册页,只需检查sys/refcount.h 以了解现有 API 的概述。

3.2.1.3. 锁

FreeBSD 内核有大量的锁类。每个锁都由一些特殊的属性定义,但可能最重要的属性是与竞争持有者(或换句话说,无法获取锁的线程的行为)相关的事件。FreeBSD 的锁定方案为竞争者提供了三种不同的行为

  1. 自旋

  2. 阻塞

  3. 睡眠

数字不是随意的

3.2.1.4. 自旋锁

自旋锁允许等待线程持续自旋,直到它们能够获取锁。一个需要处理的重要问题是,当一个线程争用自旋锁时,如果它没有被调度出去。由于 FreeBSD 内核是抢占式的,这使得自旋锁存在死锁的风险,可以通过在获取锁期间禁用中断来解决。出于这个和其他原因(例如缺乏优先级传播支持,CPU 之间负载均衡方案的不足等),自旋锁旨在保护非常小的代码路径,或者理想情况下根本不使用,除非明确要求(稍后解释)。

3.2.1.5. 阻塞

阻塞锁允许等待线程被调度出去并阻塞,直到锁的所有者释放锁并唤醒一个或多个竞争者。为了避免饥饿问题,阻塞锁会将优先级从等待者传播到所有者。阻塞锁必须通过 turnstile 接口实现,并且旨在成为内核中最常用的锁类型,除非满足特定条件。

3.2.1.6. 休眠

休眠锁允许等待线程被调度出去并进入休眠状态,直到锁持有者释放锁并唤醒一个或多个等待者。由于休眠锁旨在保护较大的代码路径并处理异步事件,因此它们不执行任何形式的优先级传播。它们必须通过 sleepqueue(9) 接口实现。

获取锁的顺序非常重要,不仅因为可能会由于锁顺序反转而导致死锁,而且还因为锁获取应该遵循与锁性质相关的特定规则。如果你查看上面的表格,实际规则是,如果一个线程持有级别为 n 的锁(其中级别是锁类型旁边列出的数字),则不允许它获取级别更高的锁,因为这会破坏指定路径的语义。例如,如果一个线程持有阻塞锁(级别 2),则允许它获取自旋锁(级别 1),但不允许获取休眠锁(级别 3),因为阻塞锁旨在保护比休眠锁更小的路径(但是,这些规则与原子操作或调度屏障无关)。

这是一个锁及其相应行为的列表

在这些锁中,只有互斥锁、sx锁、读写锁和锁管理器旨在处理递归,但目前只有互斥锁和锁管理器支持递归。

3.2.1.7. 调度屏障

调度屏障旨在用于驱动线程的调度。它们主要由三个不同的存根组成

  • 临界区(和抢占)

  • sched_bind

  • sched_pin

通常,这些应该只在特定的上下文中使用,即使它们经常可以替代锁,也应该避免使用它们,因为它们不允许使用简单的锁调试工具(如 witness(4))来诊断可能的锁问题。

3.2.1.8. 临界区

FreeBSD 内核基本上是为了处理中断线程而被设计成抢占式的。事实上,为了避免高中断延迟,时间片优先级线程可以被中断线程抢占(这样,它们不需要像正常路径预览那样等待被调度)。但是,抢占引入了需要处理的新竞争点。通常,为了处理抢占,最简单的方法是完全禁用它。临界区定义了一段代码(由函数对 critical_enter(9)critical_exit(9) 划定边界),在其中保证不会发生抢占(直到受保护的代码完全执行)。这通常可以有效地替代锁,但应谨慎使用,以免失去抢占带来的所有优势。

3.2.1.9. sched_pin/sched_unpin

处理抢占的另一种方法是 sched_pin() 接口。如果一段代码包含在 sched_pin()sched_unpin() 函数对中,则保证相应的线程即使可以被抢占,也始终在同一 CPU 上执行。在必须访问每个 CPU 数据并且假设其他线程不会更改这些数据的情况下,固定非常有效。后一个条件将确定临界区作为我们代码的过于严格的条件。

3.2.1.10. sched_bind/sched_unbind

sched_bind 是一个 API,用于将线程绑定到特定的 CPU,在其执行代码的整个过程中,直到 sched_unbind 函数调用将其解除绑定。此功能在无法信任 CPU 当前状态的情况下(例如,在引导的早期阶段)发挥关键作用,因为您希望避免线程迁移到非活动 CPU 上。由于 sched_bindsched_unbind 操作内部调度程序结构,因此在使用时需要将其包含在 sched_lock 获取/释放中。

3.2.2. proc 结构

各种仿真层有时需要一些额外的每个进程数据。它可以管理包含这些数据的单独结构(列表、树等),用于每个进程,但这往往很慢且消耗内存。为了解决这个问题,FreeBSD proc 结构包含 p_emuldata,它是一个指向一些仿真层特定数据的 void 指针。此 proc 条目受 proc 互斥锁保护。

FreeBSD proc 结构包含一个 p_sysent 条目,用于识别此进程正在运行的 ABI。实际上,它是一个指向上面描述的 sysentvec 的指针。因此,通过将此指针与存储给定 ABI 的 sysentvec 结构的地址进行比较,我们可以有效地确定该进程是否属于我们的仿真层。代码通常如下所示

if (__predict_true(p->p_sysent != &elf_Linux(R)_sysvec))
	  return;

如您所见,我们有效地使用 __predict_true 修饰符将最常见的情况(FreeBSD 进程)折叠为一个简单的返回操作,从而保持高性能。此代码应转换为宏,因为当前它不够灵活,即我们不支持 Linux®64 仿真,也不支持 i386 上的 A.OUT Linux® 进程。

3.2.3. VFS

FreeBSD VFS 子系统非常复杂,但 Linux® 仿真层仅通过定义良好的 API 使用其中的一小部分。它可以对 vnodes 或文件句柄进行操作。Vnode 代表虚拟 vnode,即 VFS 中节点的表示。另一种表示形式是文件句柄,它代表从进程的角度打开的文件。文件句柄可以表示套接字或普通文件。文件句柄包含指向其 vnode 的指针。多个文件句柄可以指向同一个 vnode。

3.2.3.1. namei

namei(9) 例程是路径名查找和转换的中心入口点。它使用查找函数(VFS 内部函数)逐点遍历路径,从起点到终点。namei(9) 系统调用可以处理符号链接、绝对路径和相对路径。当使用 namei(9) 查找路径时,它会被输入到名称缓存中。此行为可以被抑制。此例程在整个内核中使用,其性能至关重要。

3.2.3.2. vn_fullpath

vn_fullpath(9) 函数尽最大努力遍历 VFS 名称缓存,并为给定的(已锁定)vnode 返回路径。此过程不可靠,但在最常见的情况下运行良好。不可靠性是因为它依赖于 VFS 缓存(它不遍历介质上的结构),它不适用于硬链接等。此例程在 Linuxulator 的多个地方使用。

3.2.3.3. Vnode 操作
  • fgetvp - 给定一个线程和一个文件描述符编号,它返回关联的 vnode

  • vn_lock(9) - 锁定 vnode

  • vn_unlock - 解锁 vnode

  • VOP_READDIR(9) - 读取 vnode 引用的目录

  • VOP_GETATTR(9) - 获取 vnode 引用的文件或目录的属性

  • VOP_LOOKUP(9) - 查找给定目录的路径

  • VOP_OPEN(9) - 打开 vnode 引用的文件

  • VOP_CLOSE(9) - 关闭 vnode 引用的文件

  • vput(9) - 减小 vnode 的使用计数并解锁它

  • vrele(9) - 减小 vnode 的使用计数

  • vref(9) - 增加 vnode 的使用计数

3.2.3.4. 文件句柄操作
  • fget - 给定一个线程和一个文件描述符编号,它返回关联的文件句柄并引用它

  • fdrop - 减少对文件句柄的引用

  • fhold - 引用文件句柄

4. Linux® 模拟层 -MD 部分

本节介绍 FreeBSD 操作系统中 Linux® 模拟层的实现。首先描述机器相关的部分,讨论用户空间和内核之间如何以及在哪里实现交互。它讨论了系统调用、信号、ptrace、陷阱、栈修复。这部分讨论了 i386,但它是通用的,因此其他架构应该不会有太大差异。下一部分是 Linuxulator 的机器无关部分。本节仅涵盖 i386 和 ELF 处理。A.OUT 已过时且未经测试。

4.1. 系统调用处理

系统调用处理主要在 linux_sysvec.c 中编写,其中涵盖了 sysentvec 结构中指出的大多数例程。当在 FreeBSD 上运行的 Linux® 进程发出系统调用时,通用系统调用例程会为 Linux® ABI 调用 linux prepsyscall 例程。

4.1.1. Linux® prepsyscall

Linux® 通过寄存器将参数传递给系统调用(这就是它在 i386 上仅限于 6 个参数的原因),而 FreeBSD 使用栈。Linux® prepsyscall 例程必须将参数从寄存器复制到栈。寄存器的顺序为:%ebx%ecx%edx%esi%edi%ebp。需要注意的是,这仅适用于大多数系统调用。有些(最显著的是 clone)使用不同的顺序,但幸运的是,可以通过在 linux_clone 原型中插入一个虚拟参数来轻松修复。

4.1.2. 系统调用编写

在 Linuxulator 中实现的每个系统调用都必须在 syscalls.master 中具有其带有各种标志的原型。文件格式如下:

...
	AUE_FORK STD		{ int linux_fork(void); }
...
	AUE_CLOSE NOPROTO	{ int close(int fd); }
...

第一列表示系统调用编号。第二列用于审计支持。第三列表示系统调用类型。它可以是 STDOBSOLNOPROTOUNIMPLSTD 是具有完整原型和实现的标准系统调用。OBSOL 已过时,仅定义原型。NOPROTO 意味着系统调用在其他地方实现,因此不要添加 ABI 前缀等。UNIMPL 意味着系统调用将被 nosys 系统调用替换(一个系统调用只是打印一条关于系统调用未实现的消息并返回 ENOSYS)。

syscalls.master 中,一个脚本生成三个文件:linux_syscall.hlinux_proto.hlinux_sysent.clinux_syscall.h 包含系统调用名称及其数值定义,例如:

...
#define LINUX_SYS_linux_fork 2
...
#define LINUX_SYS_close 6
...

linux_proto.h 包含每个系统调用参数的结构定义,例如:

struct linux_fork_args {
  register_t dummy;
};

最后,linux_sysent.c 包含描述系统入口表的结构,用于实际调度系统调用,例如:

{ 0, (sy_call_t *)linux_fork, AUE_FORK, NULL, 0, 0 }, /* 2 = linux_fork */
{ AS(close_args), (sy_call_t *)close, AUE_CLOSE, NULL, 0, 0 }, /* 6 = close */

如您所见,linux_fork 在 Linuxulator 本身中实现,因此定义为 STD 类型且没有参数,这由虚拟参数结构体现。另一方面,close 只是对真实 FreeBSD close(2) 的别名,因此它没有关联的 linux 参数结构,并且在系统入口表中它没有以 linux 为前缀,因为它调用了内核中的真实 close(2)

4.1.3. 虚拟系统调用

Linux® 模拟层并不完整,因为某些系统调用没有正确实现,而另一些则根本没有实现。模拟层采用了一种机制,使用 DUMMY 宏来标记未实现的系统调用。这些虚拟定义位于 linux_dummy.c 中,采用 DUMMY(syscall); 的形式,然后转换为各种系统调用辅助文件,并且实现包括打印一条消息,说明此系统调用未实现。未使用 UNIMPL 原型,因为我们希望能够识别调用的系统调用的名称,以了解哪些系统调用更重要。

4.2. 信号处理

信号处理通常在 FreeBSD 内核中为所有二进制兼容性完成,并调用一个依赖于兼容性的层。Linux® 兼容性层为此目的定义了 linux_sendsig 例程。

4.2.1. Linux® sendsig

此例程首先检查信号是否已使用 SA_SIGINFO 安装,如果是,则改为调用 linux_rt_sendsig 例程。此外,它分配(或重用已存在的)信号处理上下文,然后构建信号处理程序的参数列表。它根据信号转换表转换信号编号,分配处理程序,转换 sigset。然后它保存 sigreturn 例程的上下文(各种寄存器、转换后的陷阱编号和信号掩码)。最后,它将信号上下文复制到用户空间,并为实际的信号处理程序运行准备上下文。

4.2.2. linux_rt_sendsig

此例程类似于 linux_sendsig,只是信号上下文准备不同。它添加了 siginfoucontext 和一些 POSIX® 部分。值得考虑是否可以合并这两个函数,从而减少代码重复,并可能加快执行速度。

4.2.3. linux_sigreturn

此系统调用用于从信号处理程序返回。它执行一些安全检查并恢复原始进程上下文。它还在进程信号掩码中取消屏蔽信号。

4.3. Ptrace

许多 UNIX® 派生产品实现了 ptrace(2) 系统调用以允许各种跟踪和调试功能。此功能使跟踪进程能够获取有关被跟踪进程的各种信息,例如寄存器转储、进程地址空间中的任何内存等,还可以跟踪进程,例如单步执行指令或在系统入口(系统调用和陷阱)之间。 ptrace(2) 还允许您在被跟踪进程中设置各种信息(寄存器等)。ptrace(2) 是一个 UNIX® 范围内的标准,在世界各地的多数 UNIX® 中都实现了它。

FreeBSD 中的 Linux® 模拟在 linux_ptrace.c 中实现了 ptrace(2) 功能。用于在 Linux® 和 FreeBSD 之间转换寄存器的例程以及实际的 ptrace(2) 系统调用模拟系统调用。系统调用是一个长的 switch 块,它为每个 ptrace(2) 命令实现了其在 FreeBSD 中的对应部分。ptrace(2) 命令在 Linux® 和 FreeBSD 之间大多相同,因此通常只需要进行少量修改。例如,Linux® 中的 PT_GETREGS 操作直接数据,而 FreeBSD 使用指向数据的指针,因此在执行 (本机) ptrace(2) 系统调用后,必须执行 copyout 以保留 Linux® 语义。

Linuxulator 中的 ptrace(2) 实现存在一些已知的弱点。在 Linuxulator 环境中使用 strace(它是 ptrace(2) 的使用者)时,曾出现过 panic。此外,PT_SYSCALL 未实现。

4.4. 陷阱

每当在模拟层中运行的 Linux® 进程发生陷阱时,陷阱本身都会透明地处理,唯一的例外是陷阱转换。Linux® 和 FreeBSD 在陷阱的定义上存在差异,因此这里处理了这个问题。代码实际上非常短。

static int
translate_traps(int signal, int trap_code)
{

  if (signal != SIGBUS)
    return signal;

  switch (trap_code) {

    case T_PROTFLT:
    case T_TSSFLT:
    case T_DOUBLEFLT:
    case T_PAGEFLT:
      return SIGSEGV;

    default:
      return signal;
  }
}

4.5. 栈修复

RTLD 运行时链接编辑器在 execve 期间期望栈上存在所谓的 AUX 标签,因此必须进行修复以确保这一点。当然,每个 RTLD 系统都不同,因此模拟层必须提供自己的栈修复例程来执行此操作。Linuxulator 也是如此。elf_linux_fixup 只是将 AUX 标签复制到栈,并调整用户空间进程的栈以指向这些标签之后的位置。因此,RTLD 以一种智能的方式工作。

4.6. A.OUT 支持

i386 上的 Linux® 模拟层也支持 Linux® A.OUT 二进制文件。前面各节中描述的大多数内容都必须为 A.OUT 支持而实现(除了陷阱转换和信号发送)。对 A.OUT 二进制文件的支持不再维护,特别是 2.6 模拟不适用于它,但这不会造成任何问题,因为端口中的 linux-base 可能根本不支持 A.OUT 二进制文件。此支持可能会在将来删除。加载 Linux® A.OUT 二进制文件所需的大多数内容都位于 imgact_linux.c 文件中。

5. Linux® 模拟层 -MI 部分

本节介绍 Linuxulator 的机器无关部分。它涵盖了 Linux® 2.6 模拟所需的模拟基础设施、线程局部存储 (TLS) 实现(在 i386 上)和 futex。然后我们简要讨论一些系统调用。

5.1. NPTL 描述

Linux® 2.6 开发中的一个主要进展领域是线程。在 2.6 之前,Linux® 线程支持是在 linuxthreads 库中实现的。该库是 POSIX® 线程的部分实现。线程是使用每个线程的单独进程来实现的,使用 clone 系统调用使它们共享地址空间(以及其他内容)。这种方法的主要缺点是每个线程都有不同的 PID,信号处理被破坏(从 pthreads 的角度来看)等。此外,性能不是很好(使用 SIGUSR 信号进行线程同步、内核资源消耗等),因此为了克服这些问题,开发了一个新的线程系统并将其命名为 NPTL。

NPTL 库专注于两件事,但又出现第三件事,因此它通常被认为是 NPTL 的一部分。这两件事是将线程嵌入到进程结构中和 futex。额外的第三件事是 TLS,它不是 NPTL 直接需要的,但整个 NPTL 用户空间库都依赖于它。这些改进带来了性能和标准一致性的显著提升。NPTL 如今是 Linux® 系统中的标准线程库。

FreeBSD Linuxulator 实现从三个主要方面处理 NPTL。TLS、futex 和 PID 转换,旨在模拟 Linux® 线程。后面的部分将描述每个区域。

5.2. Linux® 2.6 模拟基础设施

这些部分处理 Linux® 线程的管理方式以及我们如何在 FreeBSD 中模拟它。

5.2.1. 运行时确定 2.6 模拟

FreeBSD 中的 Linux® 模拟层支持在运行时设置模拟的版本。这是通过 sysctl(8) 实现的,具体来说是 compat.linux.osrelease。设置此 sysctl(8) 会影响模拟层的运行时行为。当设置为 2.6.x 时,它会设置 linux_use_linux26 的值,而设置为其他值则保持其未设置状态。此变量(加上相同类型的每个监狱变量)决定代码中是否使用 2.6 基础设施(主要是 PID 转换)。版本设置是在系统范围内进行的,这会影响所有 Linux® 进程。在运行任何 Linux® 二进制文件时,不应更改 sysctl(8),因为它可能会造成损害。

5.2.2. Linux® 进程和线程标识符

Linux® 线程的语义有点令人困惑,并且使用与 FreeBSD 完全不同的命名法。Linux® 中的进程由一个 struct task 组成,该结构嵌入两个标识符字段 - PID 和 TGID。PID 不是进程 ID,而是线程 ID。TGID 标识线程组,换句话说就是进程。对于单线程进程,PID 等于 TGID。

NPTL 中的线程只是一个普通的进程,恰好具有不等于 PID 的 TGID,并且具有不等于自身(当然还有共享 VM 等)的组领导者。其他所有事情都与普通进程一样发生。与 FreeBSD 中一样,没有将共享状态分离到某个外部结构中。这会造成一些信息重复和可能的数据不一致。Linux® 内核似乎在某些地方使用 task → group 信息,而在其他地方使用 task 信息,它实际上并不一致,并且看起来容易出错。

每个 NPTL 线程都是通过调用带有特定标志集的 clone 系统调用创建的(下一小节中将详细介绍)。NPTL 实现严格的 1:1 线程。

在 FreeBSD 中,我们使用共享 VM 空间等的普通 FreeBSD 进程来模拟 NPTL 线程,并且 PID 体操只是在附加到进程的模拟特定结构中被模仿。附加到进程的结构如下所示

struct linux_emuldata {
  pid_t pid;

  int *child_set_tid; /* in clone(): Child.s TID to set on clone */
  int *child_clear_tid;/* in clone(): Child.s TID to clear on exit */

  struct linux_emuldata_shared *shared;

  int pdeath_signal; /* parent death signal */

  LIST_ENTRY(linux_emuldata) threads; /* list of linux threads */
};

PID 用于标识附加此结构的 FreeBSD 进程。child_se_tidchild_clear_tid 用于在进程退出和创建时进行 TID 地址复制。shared 指针指向线程之间共享的结构。pdeath_signal 变量标识父进程死亡信号,threads 指针用于将此结构链接到线程列表。linux_emuldata_shared 结构如下所示

struct linux_emuldata_shared {

  int refs;

  pid_t group_pid;

  LIST_HEAD(, linux_emuldata) threads; /* head of list of linux threads */
};

refs 是一个引用计数器,用于确定何时可以释放结构以避免内存泄漏。group_pid 用于标识整个进程(= 线程组)的 PID(= TGID)。threads 指针是进程中线程列表的头部。

可以使用 em_find 从进程中获取 linux_emuldata 结构。函数的原型如下

struct linux_emuldata *em_find(struct proc *, int locked);

这里,proc 是我们想要从中获取 emuldata 结构的进程,而 locked 参数确定我们是否要加锁。可接受的值为 EMUL_DOLOCKEMUL_DOUNLOCK。稍后将详细介绍锁定。

5.2.3. PID 转换

由于 FreeBSD 和 Linux® 对进程 ID 和线程 ID 的概念有不同的看法,因此我们必须以某种方式转换这种看法。我们通过 PID 转换来做到这一点。这意味着我们在内核和用户空间之间伪造 PID(=TGID)和 TID(=PID)是什么。经验法则是,在内核(在 Linuxulator 中)PID = PID 且 TGID = shared → group pid,而在用户空间中,我们呈现 PID = shared → group_pidTID = proc → p_pidlinux_emuldata 结构的 PID 成员是 FreeBSD PID。

上述内容主要影响 getpid、getppid、gettid 系统调用。我们在其中分别使用 PID/TGID。在 child_clear_tidchild_set_tid 中复制 TID 时,我们复制出 FreeBSD PID。

5.2.4. Clone 系统调用

clone 系统调用是 Linux® 中创建线程的方式。系统调用的原型如下所示

int linux_clone(l_int flags, void *stack, void *parent_tidptr, int dummy,
void * child_tidptr);

flags 参数告诉系统调用如何精确地克隆进程。如上所述,Linux® 可以创建共享各种事物的进程,例如两个进程可以共享文件描述符,但不能共享 VM 等。flags 参数的最后一个字节是新创建进程的退出信号。如果 stack 参数不为 NULL,则说明线程堆栈在哪里,如果为 NULL,则表示我们应该复制调用进程堆栈(即执行正常的 fork(2) 例程)。parent_tidptr 参数用作复制进程 PID(即线程 ID)的地址,一旦进程充分实例化但尚未可运行。dummy 参数在这里是因为此系统调用在 i386 上非常奇怪的调用约定。它直接使用寄存器,不允许编译器执行此操作,导致需要一个虚拟系统调用。child_tidptr 参数用作在进程完成分叉以及进程退出时复制 PID 的地址。

系统调用本身通过根据传递的标志设置相应的标志来进行。例如,CLONE_VM 映射到 RFMEM(共享 VM)等。这里唯一的细节是 CLONE_FSCLONE_FILES,因为 FreeBSD 不允许单独设置这些标志,因此如果定义了其中任何一个,我们通过不设置 RFFDG(复制 fd 表和其他 fs 信息)来伪造它。这不会导致任何问题,因为这些标志总是同时设置。设置标志后,使用内部 fork1 例程分叉进程,该进程被设置为不放入运行队列中,即不设置为可运行状态。分叉完成后,我们可能会将新创建的进程重新设置为父进程,以模拟 CLONE_PARENT 语义。下一部分是创建模拟数据。Linux® 中的线程不会向其父进程发送信号,因此我们将退出信号设置为 0 以禁用此功能。之后执行 child_set_tidchild_clear_tid 的设置,以便在代码中的后面启用该功能。此时,我们将 PID 复制到 parent_tidptr 指定的地址。进程堆栈的设置是通过简单地重写线程帧 %esp 寄存器(amd64 上的 %rsp)来完成的。下一部分是为新创建的进程设置 TLS。在此之后,可能会模拟 vfork(2) 语义,最后将新创建的进程放入运行队列中,并通过 clone 返回值将 PID 复制到父进程。

clone 系统调用能够并且实际上用于模拟经典的 fork(2)vfork(2) 系统调用。在 2.6 内核的情况下,较新的 glibc 使用 clone 来实现 fork(2)vfork(2) 系统调用。

5.2.5. 锁定

锁定实现为每个子系统,因为我们预计这些子系统上的争用不多。有两个锁:emul_lock 用于保护 linux_emuldata 的操作,emul_shared_lock 用于操作 linux_emuldata_sharedemul_lock 是一个不可睡眠的阻塞互斥锁,而 emul_shared_lock 是一个可睡眠的阻塞 sx_lock。由于每个子系统的锁定,我们可以合并一些锁,这就是 em find 提供非锁定访问的原因。

5.3. TLS

本节介绍 TLS,也称为线程本地存储。

5.3.1. 线程简介

计算机科学中的线程是进程内的实体,可以彼此独立地调度。进程中的线程共享进程范围的数据(文件描述符等),但也拥有自己的堆栈来存储自己的数据。有时需要特定于给定线程的进程范围的数据。想象一下正在执行的线程的名称或类似的东西。传统的 UNIX® 线程 API,pthreads 提供了一种通过 pthread_key_create(3)pthread_setspecific(3)pthread_getspecific(3) 来实现此目的,其中线程可以创建指向线程本地数据的键,并使用 pthread_getspecific(3)pthread_getspecific(3) 来操作这些数据。您可以很容易地看出,这不是实现此目的最便捷的方式。因此,各种 C/C++ 编译器的生产商引入了一种更好的方法。他们定义了一个新的修饰符关键字 thread,用于指定变量是线程特定的。还开发了一种访问此类变量的新方法(至少在 i386 上)。pthreads 方法倾向于在用户空间中实现为一个简单的查找表。这种解决方案的性能不是很好。因此,新方法使用(在 i386 上)段寄存器来寻址存储 TLS 区域的段,因此实际访问线程变量只是将段寄存器附加到地址,从而通过它进行寻址。段寄存器通常是 %gs%fs,充当段选择器。每个线程都有自己的区域来存储线程本地数据,并且必须在每次上下文切换时加载段。此方法非常快,并且几乎在整个 i386 UNIX® 世界中都使用。FreeBSD 和 Linux® 都实现了这种方法,并且它产生了非常好的结果。唯一的缺点是在每次上下文切换时都需要重新加载段,这可能会减慢上下文切换的速度。FreeBSD 试图通过为此仅使用 1 个段描述符来避免此开销,而 Linux® 使用 3 个。有趣的是,几乎没有任何东西使用超过 1 个描述符(只有 Wine 似乎使用 2 个),因此 Linux® 为上下文切换支付了不必要的代价。

5.3.2. i386 上的段

i386 架构实现了所谓的段。段是对内存区域的描述。内存区域的基地址(底部)、末尾(顶部)、类型、保护等。可以使用段选择器寄存器(%cs%ds%ss%es%fs%gs)访问段描述的内存。例如,假设我们有一个基地址为 0x1234 且长度的段,以及以下代码

mov %edx,%gs:0x10

这会将 %edx 寄存器的内容加载到内存位置 0x1244 中。某些段寄存器具有特殊用途,例如 %cs 用于代码段,%ss 用于堆栈段,但 %fs%gs 通常未用。段存储在全局 GDT 表或本地 LDT 表中。LDT 通过 GDT 中的条目进行访问。LDT 可以存储更多类型的段。LDT 可以是每个进程的。这两个表最多定义 8191 个条目。

5.3.3. Linux® i386 上的实现

Linux® 中设置 TLS 的主要方法有两种。它可以在使用 clone 系统调用克隆进程时设置,也可以调用 set_thread_area。当进程将 CLONE_SETTLS 标志传递给 clone 时,内核期望 %esi 寄存器指向的内存为段的 Linux® 用户空间表示,该表示将转换为段的机器表示并加载到 GDT 插槽中。GDT 插槽可以使用数字指定,也可以使用 -1 表示系统本身应该选择第一个空闲插槽。在实践中,绝大多数程序只使用一个 TLS 条目,并且不关心条目的数量。我们在模拟中利用了这一点,并且实际上依赖于它。

5.3.4. Linux® TLS 的模拟

5.3.4.1. i386

当前线程的TLS加载通过调用set_thread_area来完成,而在clone中加载第二个进程的TLS则在clone中的单独代码块中完成。这两个函数非常相似,唯一的区别在于实际的GDT段加载:新创建进程的GDT段加载会在下一次上下文切换时发生,而set_thread_area必须直接加载它。代码基本上是这样做的:它从用户空间复制Linux®格式的段描述符。代码检查描述符的编号,但由于FreeBSD和Linux®之间存在差异,因此我们对其进行了一些模拟。我们只支持索引6、3和-1。6是真正的Linux®编号,3是真正的FreeBSD编号,-1表示自动选择。然后我们将描述符编号设置为常量3,并将此复制到用户空间。我们依赖用户空间进程使用描述符中的编号,但这在大多数情况下都能正常工作(从未见过不工作的情况),因为用户空间进程通常传入1。然后我们将描述符从Linux®格式转换为机器相关的格式(即操作系统无关格式),并将此复制到FreeBSD定义的段描述符中。最后,我们可以加载它。我们将描述符分配给线程的PCB(进程控制块),并使用load_gs加载%gs段。此加载必须在临界区中完成,以防止任何中断。CLONE_SETTLS情况的工作方式与此完全相同,只是不执行使用load_gs的加载。用于此的段(段号3)在FreeBSD进程和Linux®进程之间共享,因此Linux®仿真层不会在普通FreeBSD之上增加任何开销。

5.3.4.2. amd64

amd64的实现类似于i386,但最初没有为此目的使用32位段描述符(因此甚至本机的32位TLS用户也不起作用),因此我们必须添加这样的段并在每次上下文切换时实现其加载(当设置了指示使用32位的标志时)。除此之外,TLS加载完全相同,只是段号不同,描述符格式和加载略有不同。

5.4. Futex

5.4.1. 同步简介

线程需要某种同步机制,POSIX®提供了一些同步机制:互斥锁用于互斥,读写锁用于互斥,并带有读写操作的偏向比例,以及条件变量用于通知状态更改。有趣的是,POSIX®线程API缺乏对信号量的支持。这些同步例程的实现很大程度上取决于我们拥有的线程支持类型。在纯1:M(用户空间)模型中,实现可以在用户空间中完成,因此非常快(条件变量可能会最终使用信号实现,即不快)且简单。在1:1模型中,情况也很清楚——线程必须使用内核机制进行同步(这非常慢,因为必须执行系统调用)。混合M:N场景只是结合了第一种和第二种方法,或完全依赖于内核。线程同步是支持线程的编程的重要组成部分,其性能会极大地影响最终程序。FreeBSD操作系统上的最新基准测试表明,改进的sx_lock实现使ZFS(一个重量级的sx用户)的运行速度提高了40%,这是内核级的东西,但它清楚地表明了同步原语的性能有多么重要。

线程程序应尽可能减少锁的竞争。否则,线程只会等待锁,而不是执行有用的工作。因此,编写良好的线程程序很少出现锁竞争。

5.4.2. Futex简介

Linux®实现了1:1线程,即它必须使用内核同步原语。如前所述,编写良好的线程程序很少出现锁竞争。因此,一个典型的序列可以执行为两个原子增加/减少互斥锁引用计数器,这非常快,如下例所示。

pthread_mutex_lock(&mutex);
...
pthread_mutex_unlock(&mutex);

1:1线程迫使我们对这些互斥锁调用执行两次系统调用,这非常慢。

Linux® 2.6实现的解决方案称为futex。Futex在用户空间实现对竞争的检查,仅在发生竞争时才调用内核原语。因此,典型情况无需任何内核干预即可发生。这使得同步原语的实现相当快速且灵活。

5.4.3. Futex API

futex系统调用如下所示

int futex(void *uaddr, int op, int val, struct timespec *timeout, void *uaddr2, int val3);

在此示例中,uaddr是用户空间中互斥锁的地址,op是我们即将执行的操作,其他参数具有针对每个操作的含义。

Futex实现以下操作

  • FUTEX_WAIT

  • FUTEX_WAKE

  • FUTEX_FD

  • FUTEX_REQUEUE

  • FUTEX_CMP_REQUEUE

  • FUTEX_WAKE_OP

5.4.3.1. FUTEX_WAIT

此操作验证地址uaddr上是否写入了值val。如果不是,则返回EWOULDBLOCK,否则线程将排队到futex上并被挂起。如果参数timeout非零,则指定睡眠的最大时间,否则睡眠将无限期进行。

5.4.3.2. FUTEX_WAKE

此操作获取地址uaddr处的futex,并唤醒此futex上排队的第一个val个futex。

5.4.3.3. FUTEX_FD

此操作将文件描述符与给定的futex关联。

5.4.3.4. FUTEX_REQUEUE

此操作获取地址uaddr处futex上排队的val个线程,唤醒它们,并获取接下来的val2个线程并将它们重新排队到地址uaddr2处的futex上。

5.4.3.5. FUTEX_CMP_REQUEUE

此操作与FUTEX_REQUEUE相同,但它首先检查val3是否等于val

5.4.3.6. FUTEX_WAKE_OP

此操作对val3(其中包含一些其他值的编码)和uaddr执行原子操作。然后,它唤醒地址uaddr处futex上的val个线程,如果原子操作返回正数,则它唤醒地址uaddr2处futex上的val2个线程。

FUTEX_WAKE_OP中实现的操作

  • FUTEX_OP_SET

  • FUTEX_OP_ADD

  • FUTEX_OP_OR

  • FUTEX_OP_AND

  • FUTEX_OP_XOR

futex原型中没有val2参数。对于操作FUTEX_REQUEUEFUTEX_CMP_REQUEUEFUTEX_WAKE_OPval2取自struct timespec *timeout参数。

5.4.4. FreeBSD中的Futex仿真

FreeBSD中的futex仿真取自NetBSD,并由我们进一步扩展。它位于linux_futex.clinux_futex.h文件中。futex结构如下所示

struct futex {
  void *f_uaddr;
  int f_refcount;

  LIST_ENTRY(futex) f_list;

  TAILQ_HEAD(lf_waiting_paroc, waiting_proc) f_waiting_proc;
};

而结构waiting_proc

struct waiting_proc {

  struct thread *wp_t;

  struct futex *wp_new_futex;

  TAILQ_ENTRY(waiting_proc) wp_list;
};
5.4.4.1. futex_get / futex_put

使用futex_get函数获取futex,该函数搜索futex的线性列表并返回找到的futex或创建一个新的futex。在释放futex的使用时,我们调用futex_put函数,该函数减少futex的引用计数器,如果引用计数达到零,则释放futex。

5.4.4.2. futex_sleep

当futex将线程排队以进行睡眠时,它会创建一个working_proc结构并将此结构放入futex结构中的列表中,然后它只需执行tsleep(9)来挂起线程。睡眠可以超时。在tsleep(9)返回(线程被唤醒或超时)后,working_proc结构将从列表中删除并被销毁。所有这些都在futex_sleep函数中完成。如果我们从futex_wake中被唤醒,则我们的wp_new_futex已设置,因此我们将在其上睡眠。这样,实际的重新排队将在此函数中完成。

5.4.4.3. futex_wake

唤醒在futex上睡眠的线程在futex_wake函数中执行。首先,在此函数中,我们模拟了奇怪的Linux®行为,它会为所有操作唤醒N个线程,唯一的例外是REQUEUE操作是在N+1个线程上执行的。但这通常不会有任何区别,因为我们正在唤醒所有线程。接下来,在函数中的循环中,我们唤醒n个线程,之后我们检查是否有新的futex用于重新排队。如果是,我们最多将n2个线程重新排队到新的futex上。这与futex_sleep配合使用。

5.4.4.4. futex_wake_op

FUTEX_WAKE_OP操作非常复杂。首先,我们获取地址uaddruaddr2处的两个futex,然后我们使用val3uaddr2执行原子操作。然后唤醒第一个futex上的val个等待者,如果原子操作条件成立,则唤醒第二个futex上的val2(即timeout)个等待者。

5.4.4.5. futex原子操作

原子操作采用两个参数encoded_opuaddr。编码操作对操作本身、比较值、操作参数和比较参数进行编码。操作的伪代码如下所示

oldval = *uaddr2
*uaddr2 = oldval OP oparg

这是以原子方式完成的。首先执行地址uaddr处数字的复制,然后执行操作。代码处理页面错误,如果未发生页面错误,则使用cmp比较器将oldvalcmparg参数进行比较。

5.4.4.6. Futex锁定

Futex实现使用两个锁列表来保护sx_lock和全局锁(Giant或另一个sx_lock)。每个操作从开始到结束都被锁定。

5.5. 各种系统调用的实现

在本节中,我将描述一些值得一提的较小的系统调用,因为它们的实现并不明显,或者这些系统调用从其他角度来看很有趣。

5.5.1. *at系列系统调用

在Linux® 2.6.16内核的开发过程中,添加了*at系统调用。这些系统调用(例如openat)的工作方式与其非at对应版本完全相同,除了dirfd参数略有不同。此参数更改了要执行系统调用的给定文件的位置。当filename参数为绝对路径时,将忽略dirfd,但当文件路径为相对路径时,它将发挥作用。dirfd参数是相对于其检查相对路径名的目录。dirfd参数是某个目录的文件描述符或AT_FDCWD。因此,例如,openat系统调用可以是这样的

file descriptor 123 = /tmp/foo/, current working directory = /tmp/

openat(123, /tmp/bah\, flags, mode)	/* opens /tmp/bah */
openat(123, bah\, flags, mode)		/* opens /tmp/foo/bah */
openat(AT_FDWCWD, bah\, flags, mode)	/* opens /tmp/bah */
openat(stdio, bah\, flags, mode)	/* returns error because stdio is not a directory */

此基础设施对于在工作目录外部打开文件时避免竞争条件是必要的。假设一个进程由两个线程组成,线程A和线程B。线程A发出open(./tmp/foo/bah., flags, mode),并在返回之前被抢占,线程B运行。线程B不关心线程A的需求,并重命名或删除/tmp/foo/。我们遇到了竞争条件。为了避免这种情况,我们可以打开/tmp/foo并将其用作openat系统调用的dirfd。这还使用户能够实现每个线程的工作目录。

Linux® 系列的 *at 系统调用包含:linux_openatlinux_mkdiratlinux_mknodatlinux_fchownatlinux_futimesatlinux_fstatat64linux_unlinkatlinux_renameatlinux_linkatlinux_symlinkatlinux_readlinkatlinux_fchmodatlinux_faccessat。所有这些都是使用修改后的 namei(9) 例程和简单的包装层实现的。

5.5.1.1. 实现

实现是通过修改 namei(9) 例程(如上所述)来完成的,使其在 nameidata 结构中接受额外的参数 dirfd,该参数指定路径名查找的起点,而不是每次都使用当前工作目录。从文件描述符编号到 vnode 的 dirfd 解析是在本机 *at 系统调用中完成的。当 dirfdAT_FDCWD 时,nameidata 结构中的 dvp 条目为 NULL,但当 dirfd 为不同的数字时,我们获取此文件描述符的文件,检查该文件是否有效,以及是否有 vnode 附加到它,然后我们获取一个 vnode。然后,我们检查此 vnode 是否为目录。在实际的 namei(9) 例程中,我们只需将 dvp vnode 替换为 namei(9) 函数中的 dp 变量,从而确定起点。namei(9) 不是直接使用,而是通过不同级别上的不同函数的跟踪来使用。例如,openat 的过程如下所示

openat() --> kern_openat() --> vn_open() -> namei()

因此,必须修改 kern_openvn_open 以合并额外的 dirfd 参数。没有为这些创建兼容层,因为这些用户的数量不多,并且用户可以轻松转换。这种通用实现使 FreeBSD 能够实现自己的 *at 系统调用。目前正在讨论此事。

5.5.2. Ioctl

ioctl 接口由于其通用性而非常脆弱。我们必须记住,Linux® 和 FreeBSD 之间的设备有所不同,因此必须谨慎地进行 ioctl 模拟工作。ioctl 处理在 linux_ioctl.c 中实现,其中定义了 linux_ioctl 函数。此函数简单地遍历 ioctl 处理程序集以查找实现给定命令的处理程序。ioctl 系统调用有三个参数:文件描述符、命令和参数。命令是一个 16 位数字,理论上它被划分为高 8 位确定 ioctl 命令的类别,低 8 位是给定集中实际的命令。模拟利用了这种划分。我们为每个集合实现处理程序,例如 sound_handlerdisk_handler。每个处理程序都定义了最大命令和最小命令,用于确定使用哪个处理程序。这种方法存在一些小问题,因为 Linux® 并不始终如一地使用集合划分,因此有时不同集合的 ioctl 会位于它们不应属于的集合中(例如,SCSI 通用 ioctl 位于 cdrom 集合中)。FreeBSD 目前没有实现许多 Linux® ioctl(例如,与 NetBSD 相比),但计划是从 NetBSD 移植这些 ioctl。趋势是在本机 FreeBSD 驱动程序中也使用 Linux® ioctl,因为应用程序的移植很容易。

5.5.3. 调试

每个系统调用都应该可以调试。为此,我们引入了一个小型基础设施。我们有 ldebug 功能,它指示是否应该调试给定的系统调用(可以通过 sysctl 设置)。对于打印,我们有 LMSG 和 ARGS 宏。这些用于更改可打印字符串以获得统一的调试消息。

6. 结论

6.1. 结果

截至 2007 年 4 月,Linux® 模拟层能够很好地模拟 Linux® 2.6.16 内核。剩余的问题涉及 futex、未完成的 *at 系统调用系列、有问题的信号传递、缺少 epollinotify,以及可能的一些我们尚未发现的错误。尽管如此,我们基本上能够运行 FreeBSD Ports Collection 中包含的所有 Linux® 程序,Fedora Core 4 版本为 2.6.16,并且有一些关于 Fedora Core 6 版本为 2.6.16 成功运行的基本报告。Fedora Core 6 linux_base 最近已提交,从而可以进一步测试模拟层,并为我们在实现缺少的内容方面提供更多提示。

我们能够运行最常用的应用程序,例如 www/linux-firefoxnet-im/skype 和 Ports Collection 中的一些游戏。一些程序在 2.6 模拟下表现出不良行为,但这目前正在调查中,希望很快就能解决。唯一已知无法运行的大型应用程序是 Linux® Java™ 开发工具包,这是因为需要 epoll 功能,而该功能与 Linux® 内核 2.6 没有直接关系。

我们希望在 FreeBSD 7.0 发布后的一段时间内默认启用 2.6.16 模拟,至少是为了公开 2.6 模拟部分以便进行更广泛的测试。完成后,我们可以切换到 Fedora Core 6 linux_base,这是最终计划。

6.2. 未来工作

未来的工作应侧重于修复 futex 的剩余问题,实现 *at 系统调用系列的其余部分,修复信号传递,并可能实现 epollinotify 功能。

我们希望能够很快完美运行最重要的程序,以便能够默认切换到 2.6 模拟,并将 Fedora Core 6 设为默认的 linux_base,因为我们当前使用的 Fedora Core 4 已经不再受支持。

另一个可能的目标是与 NetBSD 和 DragonflyBSD 共享我们的代码。NetBSD 对 2.6 模拟有一些支持,但它远未完成,也没有真正经过测试。DragonflyBSD 表达了对移植 2.6 改进的兴趣。

一般来说,随着 Linux® 的发展,我们希望跟上其发展步伐,实现新添加的系统调用。首先想到的是 Splice。一些已经实现的系统调用也并非最佳,例如 mremap 等。还可以进行一些性能改进,例如更细粒度的锁定等。

6.3. 团队

我与以下人员合作开展了该项目(按字母顺序排列)

我要感谢所有这些人在建议、代码审查和一般支持方面提供的帮助。

7. 文献

  1. Marshall Kirk McKusick - George V. Nevile-Neil。FreeBSD 操作系统的设计与实现。Addison-Wesley,2005 年。

  2. https://tldp.cn

  3. https://linuxkernel.org.cn


最后修改于:2023 年 12 月 29 日,作者 Benedict Reuschling