第一章 达尔文主义:OS X 的进化史
协作式多任务系统 和 抢占式多任务系统
- 协作式多任务系统
只要进程获得了CPU的使用权,除非它放弃使用CPU,不然就会一直霸占着CPU,所以需要各个进程协作使用一段时间CPU,然后放弃使用,系统才能正常运行,如果一个进程发生死锁,系统也就崩溃了。
- 抢占式多任务系统
- 总控制权在操作系统手中,操作系统轮流询问进程是否需要使用CPU,需要就让用,不过在用了一段时间后,系统会剥夺进程的CPU使用权,让其他进程使用。
- 系统根据进程的优先级排定优先顺序,具有高优先级的进程就是当前执行的线程。
- 当前线程什么时候结束?① 属于该线程的时间片结束。② 加入了另一个优先级更高的线程。
macOS 历史
- Mac OS Classic 是 MacOS 在 OSX 之前的名称。
- 1997年,当时只有一个没有任何发展前途操作系统的苹果公司收购了NeXT公司。
- Mac OS Classic 和 NeXTSTEP 结合变成了 OSX
第二章 合众为一:OS X 和 iOS 的架构。
- 框架就是一种特殊形式的库,框架更倾向于OSX 和 iOS 特有的,而库则是所有UNIX系统共有的。
- OSX 和 iOS 将传统的库保存在 /usr/lib。库的后缀是 .dylib
- 而其他Unix系统的库是.so
- 核心C库libc被吸收到苹果自己的libSystem.B.dylib中。这个库还包含了数据库 libm 和 libpthread 以及其他一些库提供的功能。
系统调用
- 作为所有操作系统都遵守的准则,用户程序不予许直接访问系统资源。用户程序可以操作通用寄存器,执行一些简单的计算,但是如果需要执行重要的功能,例如:打开文件或者 socket,甚至是发送一条简单的消息,都必须使用系统调用。
- 系统调用指的是由内核导出的预定义函数的入口点。
- 在用户态需要连接 libSystem.B.dylib 才能访问这些系统调用。
- OS X的系统调用特殊之处在于导出了两套系统调用接口,一个是Mach调用,一个是POSIX调用。
POSIX (Portable Operating System Interface) 是一套标准的API。具体定义了一下内容。
- 系统调用原型 :所有POSIX系统调用,不论底层如何实现,都有相同的原型,也就是说具有相同的参数和返回值。所以只要是POSIX兼容的代码就可以在任何兼容POSIX的系统上移植。
- 系统调用编号:除了固定的原型之外,POSIX 还完整定义了系统调用的编号。这在一定程度上允许了二进制层次的可以执行。也就是说POSIX兼容的二进制代码可以在底层架构相同的的POSIX系统之间移植。前提是两个系统的目标文件格式一致。
POSIX 兼容性是由 XNU 中 BSD 层提供的。这个系统调用原型在 <unistd.h> 头文件中。
Mach 系统调用
OS X 是在 Mach 内核的基础上构建的,而 Mach 是 NeXTSTEP 的遗产。BSD 层是对 Mach 内核的包装。但是 Mach系统调用仍然可以在用户态访问。
- 在 32 位系统上, Mach 系统调用的编号为负数。
- 这样可以使得 POSIX 和 Mach 系统调用共存。由于 POSIX 只定义了非负数的系统调用。负数空间没有使用,因此 Mach 就使用了负数。
- 在 64 位系统上,Mach 系统调用是正数,但是以 0x200 0000 开头,而 POSIX 调用编号以 0x100 0000 开头,所以两者可以明显区分开。
- 系统调用不是直接调用的,而是通过 libSystem.B.dylib 中的浅层封装进行的。
XNU 概述
内核 XNU 是 Darwin 的核心,也是整个OS X 的核心。XNU 本身由以下几个部分组成。
- Mach 微内核
- BSD 层
- libKern
- I/O Kit
内核是模块化的,允许根据需要动态加载内核扩展 KExt.
Mach
XNU 的核心。Mach 微内核的职责。
- 进程和线程抽象
- 虚拟内存管理
- 任务调度
- 进程间通信和消息传递机制
BSD 层
BSD 层建立在 Mach 之上,也是 XNU 中一个不可分割的部分。这一层是一个很可靠且更现代化的 API。提供了POSIX 兼容性。BSD 层提供了更高层次的抽象。其中包括:
- UNIX 进程模型
- POSIX 线程模型以及其他相关同步原语
- UNIX 用户和租
- 网络协议栈(BSD Socket API)
- 文件系统访问
- 设备访问(通过/dev 目录访问)
XNU 中的BSD 实现很大程度上和 FreeBSD 的实现兼容。
站在巨人的肩膀上:OS X 和 iOS 使用的技术
强制访问控制
FreeBSD 5.x 最早引入了一项强大的安全特性:Mandatory Access Control ,强制访问控制MAC,允许更为精细的安全模型,添加对象级别的安全性。通过这种方式,可以控制一个给定的应用程序不允许访问用户的私有数据或某些网站。
MAC 是 OS X的隔离机制,即沙盒和 iOS的 entitlement机制的基础。
-
特洛伊木马,木马程序需要在无知的用户配合下才能工作。
-
代码签名在OSX 中是可选的,但是在 iOS 中是强制的。
庖丁解进程:Mach-O 格式、进程以及线程内幕
- 线程只不过是一组寄存器的状态。
- 信号是一种软件中断,表示进程发生了异常,或者发生了外部事件。
- 信号是指发给程序的异步通知,其中不包含数据或者只包含非常少的数据。
- 信号是由操作系统发送给进程的,用于表示发生了某种条件,而这种条件通畅是因为某类硬件错误或者程序异常引起的。
- 代码注入的常见方法就是使用栈变量(自动变量),因此默认情况下栈都标记为不可执行,MH_ALLOW_STACK_EXECUTION 可以让栈可执行。堆默认情况下是可执行,MH_NO_HEAP_EXECUTION 可以让标记堆不可执行。
加载命令
-
Mach-O 文件头中包含了非常详细的加载指令,这些指令在被调用时清晰地指导了如何设置并加载二进制数据。
-
加载过程在内核的部分负责新进程的基本设置——分配虚拟内存,创建主线程,以及处理任何可能的代码签名/加密工作。然而对于动态链接的可执行文件(大部分可执行文件都是动态链接的)来说,真正的库加载和符号解析的工作都是通过 LC_LOAD_DYLINKER 命令指定的动态连接器在用户态完成的。控制权会转交给连接器,连接器进而接着处理文件头中其他的加载命令。
加载命令详解
- LC_SEGMENT 或者 LC_SEGMENT_64
最主要的加载命令,这条命令指导内核如何设置新运行的进程的内存空间。这些段直接从 Mach-O 二进制文件加载到内存中。
每一条LC_SEGMENT(_64)命令都提供了段布局所有必要细节信息。
- vmaddr 所描述段的虚拟起始地址
- vmsize 所描述段的虚拟内存大小
- fileoff 表示这个段在文件中的偏移量
- filesize 表示这个段在文件中占用的字节数
- maxprot 段的页面表示的最高内存保护
- initprot 段的页面最初始的内存保护
- nsects 段中section的数量
- flags 杂项标志位
- LC_MAIN
设置程序主线程入口点地址和栈大小,设置寄存器状态,除了指令指针寄存器,其他都设置为0
- LC_CODE_SIGNATURE
包含了 Mach-O 二进制文件的代码签名,如果这个签名和代码本身不匹配或者在 iOS 上这条命令不存在,内核会直接给进程发送一个 SIGKILL 信号将进程杀死。
- LC_DYLINKER
动态连接器是内核执行 LC_DYLINKER 加载命令启动的,通常事情下使用的是 /usr/lib/dyld 作为动态加载器,不过这条命令可以指定任何程序作为加载器,内核将进程的入口点设置为连接器的入口点,由连接器接管刚创建的进程的控制权。
启动时库的加载
只有少量进程只需要内核加载器就可以完成,几乎所有OS X上程序都是动态链接的,Mach-O 镜像中有很多空洞,即对外部库和符号的引用,这些空洞要在程序启动时填补。这项动作就需要由动态连接器来完成。这个过程有时候也称为符号绑定 (binding)。
- 如果二进制文件中使用了外部定义的函数和符号,那么在他们的 TEXT 段中会有一个名为 __stubs (桩)的区,在这个区中存放的是这些本地未定义符号的占位符。
OSX 的 dyld 环境变量
- DYLD_INSERT_LIBRARIES
在程序加载时强行插入一个或多个库——和 Unix 上的 LD_PRELOAD 思想相同。强制地将一个库注入新创建进程的地址空间。
64位地址空间
- 64位并不是真正的64位。由于虚拟地址到物理地址转换的开销,Intel 架构只使用了 48 位的虚拟地址。这是硬件上的限制,Linux 和 Windows都会受到这个限制的约束。因此用户内存空间可以访问的区域最高到 0x7FFF-FFFF-FFFF
- 64位模式下,可以将内核地址空间映射到每一个进程的地址空间,而在32位模式下内核是单独的地址空间。
内存的几个段
- __PAGEZERO
- 在 32 位系统中,这是内存中单独是一个页面(4KB),而且这个页面所有的访问权限都被撤销了,即没有任何访问权限。
- 在 64 位系统上,这个段对应一个完整的 32 位地址空间——即前 4GB。
- 这个段用来捕捉空指针引用,因为空指针实际上就是0,或者捕捉将整数做指针引用(32 位平台下的 4095 以下的值,以及 64 位平台下的 4GB 以下的值都在这个范围)。
- 这个范围地址所有访问权限都没有,所以在这个范围内的任何解引用操作都会引发来自 MMU 的硬件页错误,进而产生一个内核可以捕捉的陷阱。内核将这个陷阱转换为 C++ 异常或表示总线错误的 POSIX 信号 SIGBUS。
- 可以通过 MachOView 看到 pagezero 的段起始地址为 0,段大小为 4GB,文件大小为 0
- __TEXT
- 这个段存放的程序代码。和其他操作系统一样,文本段被设置为 r-x,即只读且可执行。
- 这不仅可以防止二进制代码在内存中被修改,还可以通过共享这个只读文本段优化内存使用。
- 通过这种方式,同一个程序的多个实例可以仅使用一份 __TEXT 副本。
- 文本段通常包含多个区,实际的代码在 __text 区中。
- 文本段还包含其他只读数据,例如常量和硬编码的字符串。
- __LINKEDIT
由 dyld 使用,这个区包含了字符串表、符号表以及其他数据
- __IMPORT : 用于 i386 的二进制文件的导入表。
- __DATA:用于可读写的数据。
- __MALLOC_TNY:用于小于一个页面的内存分配。
- __MALLOC_SMALL:用于几个页面大小的内存分配。
- __MALLOC_LARGE:用于 1MB 以上大小的内存分配。
有一个段不会被 vmmap 显示出来,那就是 commpage。 这个段包含了一组内核导出给用户态的页面,类似于 Linux 中 vsyscall 和 vdso 的概念。这些页面在所有进程中是共享的,并且在固定的地址位置。在 i386 上 为 0xffff0000, 在 x86_64 上为 0x7fffffe00000 ,在 ARM 上为 0x40000000。这些页面包含了各种和 CPU 以及平台相关的函数。
alloca
使用 alloca() 可以用栈来动态分配内存。这个函数原型 和 malloc 一样,区别在于这个函数返回的指针是栈上的地址。
- 栈中分配空间通常情况下只不过是简单的修改栈指针寄存器。这比遍历堆空间并试图找到一个合适的区域或者空间列表并从中活的一个内存块要快得多。
- 栈内存的页面已经在内存中了,不用担心页面错误的问题。尽管在用户态感觉不到发生了缺页错误,但是在性能上却有重大影响。
- 当分配了空间的函数返回时,栈中分配的空间会自动释放。这是由函数调用约定的开端和收尾确保的。
- 虽然动态分配栈内存可以提高性能,但是栈空间十分有限。只适合对小空间的分配。
页面是生命周期
物理内存页面的生命周期包含几个状态
- free(空闲) :物理页面没有被任何虚拟内存页面使用。
- Active(活跃) :物理页面当前正在被一个虚拟内存页面使用,而且最近被引用过。
- Inactive(非活跃) :物理页面当前正在被一个虚拟内存页面使用,但是最近没有被引用过。
- Speculative(投机) :页面被投机映射。产生这个状态的原因是针对可能的内存需求做了一次猜测的分配。
- Wired down(联动):物理页面当前正在被一个虚拟内存页面使用,但是不能被交换出去。
线程
线程是过去时代的产物。进程是系统执行的基本单元,而且是执行过程中所需要的各种资源,包括虚拟内存、文件描述符以及其他各种对象。开发者编写顺序程序,从入口点 —— main 开始执行,直到 main 函数返回或者调用 exit() 。
然而这种方法很快被证明太刻板,对于需要并发执行的任务来说灵活性太低。需要并发执行的任务中很重要的一部分就是带有 I/O 的任务:像 read 和 write 这样的操作可能会被永久的阻塞。特别是针对 socket 的操作。阻塞的读操作意味着在 socket 代码在等待读入时候不能持续发送数据。
大部分进程早晚都会在 I/O 上阻塞。I/O 操作意味着进程中时间片大部分都被放弃了。这对性能有很大的影响,因为进程的上线文切换开销很大。
线程
线程,作为最大化利用进程时间片的方法,应运而生。通过使用多线程,任务的执行表面上可以看到并发的多任务,当一个任务阻塞了,可以把剩下的时间片分配给其他并发的子任务。
线程之间的切换比较小——只需要保存和恢复寄存器即可。
进程的切换还需要切换虚拟地址空间,其中包括很多地城的开销,例如清空 cache 和 TLB。
多核处理器更适合线程,因为多个处理器核心共享同样的 cache 和 RAM —— 这为进程之间共享虚拟内存提供了基础。相比之下多处理器架构可能因为非一致性的内存架构和 cache 一致性方面的原因损失一些性能。
POSIX 线程
POSIX 线程模型实际上是除了 Windows 之外所有系统使用的线程 API。Windows 坚持提供 win32 线程 API。
引导过程:EFI 和 iBoot
引导过程指的是从计算机通电那一瞬间到 CPU 开始执行操作系统代码时的整个过程,这个过程往往是系统启动过程中被忽略的一部分。在这个非常初期的阶段中,CPU 执行标准的启动代码。这部分代码需要对硬件设备进行探测,寻找最优可能启动的操作系统并且根据用户定义的参数启动这个操作系统。
其他操作系统用的都是默认的引导加载器 boot loader, 而 iOS 和 OS X 使用的则是自己的引导加载器。
BIOS 是一个固定的程序,而且通常的封闭的。EFI 是一套接口。EFI 更像是运行时环境。
- EFI 程序——不论是应用程序、引导加载器还是驱动程序——就是一个二进制程序。
- EFI 二进制程序采用的便携式可执行格式(Portable Executable, PE)。这个格式是微软采用的可执行格式。
贯穿始终 —— launchd
内核是一个服务提供者,而不是具体的应用程序,用户态的应用程序才是系统中负责真正工作的实体,应用程序构建在内核提供的原语之上,向用户提供丰富的用户态环境,包括文件、多媒体和用户交互。
整个用户态环境必须从某个地方启动,在 OS X 和 iOS 中,用户环境起始于 launchd。
launchd
- launchd 对应于其他 Unix 系统 中的 init 。
- 作为系统中第一个用户态进程,负责直接或间接的启动系统中的其他进程。
-
尽管 launchd 是属于苹果是财产,但是仍然属于 Darwin 的范畴,是开源的。
- launchd 是由内核直接启动的。负责加载 BSD 子系统的主内核线程。
c 的核心职责是根据预定的安排或实际的需要加载其他应用程序或作业。launchd 区分两种类型的后台作业。
- 守护程序(daemon)和传统的 unix 概念一样,是后台服务,通常和用户没有交互。守护程序是由系统启动的,不考虑是否有用户登录系统。
- 代理程序(agent)是一类特殊的守护程序,只有在用户登录的时候才启动。和守护程序的不同之处在于,代理程序和用户交互,有的代理程序还有 GUI。
第八章 内核架构
所有现代的操作系统在设计上都包含一个称为 内核 的组件。内核(kernel)是整个操作系统的核心。内核就是操作系统。所有运行的应用程序都是内核的客户,内核向客户提供服务,即系统调用(system call)。
###巨内核
- 巨内核(monolithic,也称为宏内核,单内核)架构是“经典”的啮合架构,而且仍然是 Unix 和 Linux 世界采用的主要内核架构。
- 巨内核采用的方式就是将所有的功能,不管是基础功能还是高级功能全部放在一个地址空间中。在这种架构的内核中,线程调度、内存管理、文件系统、安全管理甚至设备驱动全部在一起。
- 所有内核功能都实现在一个地址空间中,并且将这个地址空间映射到每一个进程虚拟地址空间中。
- 虽然牺牲了进程的地址空间,但是从用户态到内核态的切换虽然高效,基本上就是一次线程的切换的开销。
- 内核常驻物理页面,这样可以避免系统调用时产生大量缺页。
关于地址空间
在 64 位架构上指针虽然是 64 位,但是硬件支持的虚拟内存地址的有效位却达不到这么多。常规的 x86_64 处理器只支持 48 位寻址,也就说在地址转换的时候只用到了低 48 位。
处理器规范要求虚拟地址才使用“规范形式地址(canonical form address)”,也就是第 48 ~ 63 位要和第 47 位相同。第 47 位 只能为 1 和 0 。所以 64 位地址空间会均等的划分为两个部分: 0x000000000000 ~ 0x00007FFFFFFFFFFF 和 0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF。每个部分都是 128 TB。中间形成了巨大空洞,如果使用了空洞中的地址,硬件就会抛出异常。
微内核
-
微内核只包含最核心的功能,代码量也最精简。微内核只负责最关键的部分——通常包含任务调度和内存管理,其他的功能都交给外部服务程序(通常是用户态)完成。服务程序之间完全隔离开,服务程序之间的所有通信都由消息传递完成。
- 微内核发生故障只需要重启受影响的服务组件就可以恢复故障,相比之下巨内核发生故障会导致内核崩溃(kernel panic)。
- 微内核非常灵活,由于功能定义的好,移植到其他架构的工作比较简单。
- 微内核的消息传递在底层需要通过内存复制以及数次上下文切换来实现,对性能有影响。
混合内核
混合内核(hybrid kernel)结合两种内核的好处。内核的最核心部门支持底层服务,包括调度、进程间通信和虚拟内存,是自包含的,这一部门就像微内核一样。所有其他的服务实现都在这个核心之外,但是也在内核态中,而且和这个核心在同一个内存空间中。混合内核没有微内核的健壮性,牺牲健壮性带来的运行的高效。
ARM 架构 —— CPSR
ARM 处理器使用了一个特殊的寄存器——当前程序状态寄存器 current program status register 来定义处理器所在的模式。ARM 处理器有不少于 7 种不同的操作模式。
内核态 / 用户态转换机制。
区分内核态和用户态非常重要,因此这个功能是由硬件提供的。
应用程序经常需要使用内核服务,因此这两种状态之间的转换不仅要高效,还要保持高度安全性。
用户态和内核态的转换机制有两种类型:
- 自愿转换:当应用程序要求内核服务时,通过预定义的硬件指令进入内核态。这些内核服务称为系统调用。
- 非自愿转换:当发生异常、中断或处理器陷阱时,代码的执行被挂起,并保留发生错误时的完整状态。控制权被转交给预定义的内核态错误处理程序或中断服务程序(interrupt service routine,ISR)。
控制权的转移还分为“异步”和“同步”。同步的控制权转移和程序流程一直,通常由于某些指令在运行时发生异常导致。而异步的控制权转移是由于外部的中断请求导致。也就说如果没有中断发生,程序会正常执行,而出现了中断,所以必须处理中断。
不管是那种方式是控制权转移都是安全,执行的都是内核预定义的代码,用户态的代码无法修改这些代码。用户态的程序完全意识不到内核“抢夺”了控制权,特别是在非自愿的情况下。
操作系统在中断分配表(interrupt dispatch table,IDT: Intel的术语)或 异常向量(exception vector:ARM 的术语)中设置了预定义的入口点。这两个术语意思都一样,就是个数组,每个元素都保存了一个预定义的函数指针。CPU 会跳到函数指针指向的位置执行函数,同时也要进入内核态。
Intel 上的陷阱处理程序
Intel 架构定义了带有 255 个项的中断向量,系统引导时内核负责填充这个向量。
- 异常 —— 陷阱/错误/终止
在 Intel 架构上,中断向量的前 20 个单元定义为异常:异常指的是处理器在执行代码时可能碰倒的所有非正常情况。
一共有三种类型的异常:
- 错误(fault):指令遇到一个可以纠正的异常,并且处理器可以重新启动这条出现异常的指令,这种异常称为错误。例如:当出现页错误(page fault)时,执行错误处理程序,完成后重新执行产生这个错误的指令。
- 陷阱(trap):类似于错误,但是错误处理程序完成后返回发生陷阱之后的那条指令。
- 中止(abort):不可重启的指令,例如:双重错误,也就是一条指令产生了两次错误,也就没有重试的必要了。
- 中断
第二种非自愿的用户态/内核态转换发生在中断的时候。中断由 CPU 中的一个特殊组件产生,这个组件称为可编程中断控制器(programable interrupt Controller,PIC),在更加现代的 CPU 中,这个组件被称为高级可编程中断控制器(advanced PIC,APIC)。
PIC 接受来自系统总线上的设备的消息,然后把消息分拣到某一条中断请求(Interrupt Request,IRQ)线上去。当产生中断时。PIC将相应的中断线标记为活跃。在这条中断被处理之前一直保持活跃,中断处理程序处理中断后重置这条线的状态。
现代的 APIC 允许有 255 条这样的中断线,IRQ 线可以被多个设备共享。
一般只要满足下面的条件,中断请求可以被发送出去:
- 对应的中断线不忙,也没有被屏蔽。
- 没有编号更低的中短线为忙,编号越低优先级越高,有更低编号的线为忙说明CPU正在处理优先级高的中断。
- CPU 核心没有禁用所有的中断。
- 在 Intel 架构上 XNU 对陷阱和中断的处理
- XNU 将 Intel 的异常统称为“陷阱”,而不是 3 种异常之一,代码中用的是 trap。
- ARM 上的陷阱处理程序
- 任何非用户态都是通过一个异常或中断进入的。系统调用是利用 SVC 指令通过模拟的中断完成。
- SVC 这条指令执行的时候,CPU 自动将控制权转交给机器的陷阱向量。在陷阱向量中有一个预定义的内核指令正在等待,通常是分支跳转到具体的处理程序指令。
自愿的内核转换
当用户态的程序需要使用内核服务时,发出系统调用,系统调用有专用指令,在 ARM上 使用 SVC,在 Intel 上使用 syscall。
syscall 使用了一组“型号特有寄存器(model specific register, MSR)”,在进入内核态的时候不用保存关键寄存器,而是使用 MSR,回到用户态的时候也不用恢复寄存器。
第九章 内核引导和内核崩溃
第十章 Mach 原语: 一切以消息为媒介
Mach 中最基本的概念是消息,消息在两个端点或端口之间传递。
一条消息就像一个网络数据报一样,定义为透明的 BLOB (binary large object,二进制大对象),通过固定的包头进行封装。
Mach 消息原本是为真正的微内核设计的。也就是说 mach_msg() 函数必须在发送和接受者之间复制消息所在的内存。尽管这种实现模式忠实于微内核的范式,但是频繁的内存复制操作带来的性能损耗是不能忍受的。XNU 算是通过单一内核的方式“作弊”:所有的内核组件都共享同一个地址空间,因此消息传递只需要传递消息的指针就可以了。
为了实现消息的发送和接受,mach_msg() 函数调用了一个 Mach 的陷阱(trap),Mach 的陷阱就是 Mach 中的系统调用。在用户态调用 mach_msg_trap() 引发陷阱机制,切换到内核态,在内核态中,内核实现的 mach_msg() 会完成实际的工作。
第十一章 刹那之间 —— Mach 调度
调度原语
和所有现代操作系统一样,内核调度的对象是线程,而不是进程。
线程表示的是底层的机器寄存器状态以及各种调度统计信息。线程从设计上提供了调度所需要的大量信息,同时又尽可能地维持最小的开销。
上下文切换
上下文切换(context switch)就是暂停某个线程的执行,并且将其寄存器状态记录在某个预定义的内存位置中。当一个线程被抢占时,CPU 寄存器中加载另一个线程保存的线程状态,从而恢复那个线程的运行。
线程调度的基本概念都是一样的,和具体操作系统无关。
一个线程在 CPU 上执行指的是这样一个事实:CPU 寄存器中填满了线程的状态,因此 CPU 通过 RIP 指针或 PC 程序计数器执行该线程的函数代码。这个过程一直持续只要遇到以下几种情况:
- 线程终止:线程的函数返回了,或者调用了 pthread_exit()
- 线程自愿放弃CPU:虽然线程的任务还没完,但是需要等待一个资源变的可用或者等待其他阻塞操作。因此线程主动请求调度器把 CPU 分配给其他线程。
- 外部中断打断了线程的执行:外部中断要求CPU保存线程状态并立即执行中断处理代码。
运行队列
线程是通过运行队列管理的,运行队列是一个多层列表,是一个列表的数组,针对 128 个优先级中每个优先级都有一个队列。
等待队列
一个线程有可能是就绪状态或者运行状态,对线程来说这是最好的,也有可能线程在等待一个条件发生,这时候可以把线程放在一个等待队列中。
CPU 亲缘性
在使用多核的架构中,可以设置线程和若干个指定 CPU 的亲缘性,当线程使用过的 CPU 重新调度这个线程时,线程的缓存数据可能还留在CPU的缓存中,从而提升性能。
控制权转交
所有操作系统都支持 yield 的概念,即主动放弃 CPU, 将 CPU 转交给其他线程,传统的 yield 不支持将 CPU 转交给哪个线程,因为选择权在自己手里。 Mach 对 yield 做了改进,允许将 CPU 转交给谁。
中断驱动的调度
对于抢占式多任务系统,必须提供一共机制,使得调度器可以抢占 CPU 使用权,然后才能运行调度器来决定当前线程是否可以继续运行还是要运行其他线程。现在的操作系统都采用硬件中断的方式来中断当前线程的执行,在执行中断处理程序时执行调度器。
但是有一个问题,那就是中断是异步的,在一个繁忙的系统上,系统每秒钟都会处理数千个中断,而如果系统有一段时间没有中断源(列如:磁盘,网络、用户)那么也就不会有中断。所以需要一个可预测的中断源。
所以可以将实时时钟当做这种可预测的中断源。这个时钟是和硬件相关的,在 Intel 架构上使用本地 CPU 的 APIC 实现这个目的。内核可以配置时钟给定的周期内产生中断。但是这样会设计到一个问题,如果设置的频率太高可能会引发不必要的中断,但是设置频率太低会降低系统的响应性。
所以采用一种方式,每一次定时器中断时,中断处理程序快速扫描还没有超期的时间线列表,这些时间线主要是各个线程设置的睡眠超时时间。处理程序根据时间线安排下一次定时器中断。
第十二章 Mach 虚拟内存
硬件页表项 (page table entry , PTE)
翻译查找表 (translation lookaside buffer , TLB)
第十三章 BSD
Mach 只是一个微内核。尽管 Mach 的部分应用程序编程接口也暴露给了用户态,但是开发者主要使用的还是更为流行的 POSIX API,这一套 API 是通过 Mach 之上的 BSD 层实现的。
BSD 本身就是一个复杂的设计,而且有很多不同的版本,最著名的就是 FreeBSD 和其各种衍生的操作系统。
-
phtread_create 是通过 bsdthread_create 的系统调用完成,主要是对 Mach 线程创建的复杂包装。
- 主引导记录(Master Boot Record, MBR)分区方案依赖于 BIOS,局限性非常大,最多支持4个分区,而且是 32 位的,最多可寻址 2 的 32 次方个扇区,每个扇区是 512 字节,因此支持最大寻址 2TB。
- GUID 分区表是一个 64 位的方案,最大可寻址 2 ^ 64 个扇区。但是 32 位的系统不支持 GPT。
附录部分
调用者要完成的任务:
- 在分配为传递参数的寄存器中传递尽可能多的参数
- 如果实际参数比可用寄存器少,则不用这些寄存器
- 如果实际参数比可用寄存器多,将剩下的参数通过栈传递
- 保存返回地址,这样被调用的函数在结束时,就可以返回到调用者。
- 跳转到被调函数的地址,转交控制权。
被调函数的职责比调用者的职责更多一些:
- 在进入时(prolog),被调函数应该:
- 保存任何要使用的寄存器
- 如果使用了帧指针(RBP),则设置RBP
- 保存任何要使用的浮点寄存器
- 在栈上分配局部变量的空间。
- 在退出时,被调函数应该:
- 在栈上解除分配局部变量使用的空间。
- 恢复使用的浮点寄存器
- 恢复使用的通用寄存器
- 如果使用了 RBP ,则恢复 RBP,然后返回到调用者指定的返回地址。
Intel 和 ARM 的不同
- 在 Intel 架构中,只能通过 jmp、call、ret 指令改变指令指针。
- 在 ARM 平台中,PC 不仅可以通过分支修改,可以通过 POP 指令,LDR 和 MOV 指令修改。