《深入理解osx ios 操作系统》读书笔记

delims 于 2020-12-29 发布

第一章 达尔文主义:OS X 的进化史

协作式多任务系统 和 抢占式多任务系统

只要进程获得了CPU的使用权,除非它放弃使用CPU,不然就会一直霸占着CPU,所以需要各个进程协作使用一段时间CPU,然后放弃使用,系统才能正常运行,如果一个进程发生死锁,系统也就崩溃了。

  1. 总控制权在操作系统手中,操作系统轮流询问进程是否需要使用CPU,需要就让用,不过在用了一段时间后,系统会剥夺进程的CPU使用权,让其他进程使用。
  2. 系统根据进程的优先级排定优先顺序,具有高优先级的进程就是当前执行的线程。
  3. 当前线程什么时候结束?① 属于该线程的时间片结束。② 加入了另一个优先级更高的线程。

macOS 历史

第二章 合众为一:OS X 和 iOS 的架构。

系统调用

POSIX (Portable Operating System Interface) 是一套标准的API。具体定义了一下内容。

POSIX 兼容性是由 XNU 中 BSD 层提供的。这个系统调用原型在 <unistd.h> 头文件中。

Mach 系统调用

OS X 是在 Mach 内核的基础上构建的,而 Mach 是 NeXTSTEP 的遗产。BSD 层是对 Mach 内核的包装。但是 Mach系统调用仍然可以在用户态访问。

XNU 概述

内核 XNU 是 Darwin 的核心,也是整个OS X 的核心。XNU 本身由以下几个部分组成。

内核是模块化的,允许根据需要动态加载内核扩展 KExt.

Mach

XNU 的核心。Mach 微内核的职责。

BSD 层

BSD 层建立在 Mach 之上,也是 XNU 中一个不可分割的部分。这一层是一个很可靠且更现代化的 API。提供了POSIX 兼容性。BSD 层提供了更高层次的抽象。其中包括:

XNU 中的BSD 实现很大程度上和 FreeBSD 的实现兼容。

站在巨人的肩膀上:OS X 和 iOS 使用的技术

强制访问控制

FreeBSD 5.x 最早引入了一项强大的安全特性:Mandatory Access Control ,强制访问控制MAC,允许更为精细的安全模型,添加对象级别的安全性。通过这种方式,可以控制一个给定的应用程序不允许访问用户的私有数据或某些网站。

MAC 是 OS X的隔离机制,即沙盒和 iOS的 entitlement机制的基础。

庖丁解进程:Mach-O 格式、进程以及线程内幕

加载命令

加载命令详解

最主要的加载命令,这条命令指导内核如何设置新运行的进程的内存空间。这些段直接从 Mach-O 二进制文件加载到内存中。

每一条LC_SEGMENT(_64)命令都提供了段布局所有必要细节信息。

  1. vmaddr 所描述段的虚拟起始地址
  2. vmsize 所描述段的虚拟内存大小
  3. fileoff 表示这个段在文件中的偏移量
  4. filesize 表示这个段在文件中占用的字节数
  5. maxprot 段的页面表示的最高内存保护
  6. initprot 段的页面最初始的内存保护
  7. nsects 段中section的数量
  8. flags 杂项标志位

设置程序主线程入口点地址和栈大小,设置寄存器状态,除了指令指针寄存器,其他都设置为0

包含了 Mach-O 二进制文件的代码签名,如果这个签名和代码本身不匹配或者在 iOS 上这条命令不存在,内核会直接给进程发送一个 SIGKILL 信号将进程杀死。

动态连接器是内核执行 LC_DYLINKER 加载命令启动的,通常事情下使用的是 /usr/lib/dyld 作为动态加载器,不过这条命令可以指定任何程序作为加载器,内核将进程的入口点设置为连接器的入口点,由连接器接管刚创建的进程的控制权。

启动时库的加载

只有少量进程只需要内核加载器就可以完成,几乎所有OS X上程序都是动态链接的,Mach-O 镜像中有很多空洞,即对外部库和符号的引用,这些空洞要在程序启动时填补。这项动作就需要由动态连接器来完成。这个过程有时候也称为符号绑定 (binding)。

OSX 的 dyld 环境变量

在程序加载时强行插入一个或多个库——和 Unix 上的 LD_PRELOAD 思想相同。强制地将一个库注入新创建进程的地址空间。

64位地址空间

内存的几个段

  1. 在 32 位系统中,这是内存中单独是一个页面(4KB),而且这个页面所有的访问权限都被撤销了,即没有任何访问权限。
  2. 在 64 位系统上,这个段对应一个完整的 32 位地址空间——即前 4GB。
  3. 这个段用来捕捉空指针引用,因为空指针实际上就是0,或者捕捉将整数做指针引用(32 位平台下的 4095 以下的值,以及 64 位平台下的 4GB 以下的值都在这个范围)。
  4. 这个范围地址所有访问权限都没有,所以在这个范围内的任何解引用操作都会引发来自 MMU 的硬件页错误,进而产生一个内核可以捕捉的陷阱。内核将这个陷阱转换为 C++ 异常或表示总线错误的 POSIX 信号 SIGBUS。
  5. 可以通过 MachOView 看到 pagezero 的段起始地址为 0,段大小为 4GB,文件大小为 0
  1. 这个段存放的程序代码。和其他操作系统一样,文本段被设置为 r-x,即只读且可执行。
  2. 这不仅可以防止二进制代码在内存中被修改,还可以通过共享这个只读文本段优化内存使用。
  3. 通过这种方式,同一个程序的多个实例可以仅使用一份 __TEXT 副本。
  4. 文本段通常包含多个区,实际的代码在 __text 区中。
  5. 文本段还包含其他只读数据,例如常量和硬编码的字符串。

由 dyld 使用,这个区包含了字符串表、符号表以及其他数据

有一个段不会被 vmmap 显示出来,那就是 commpage。 这个段包含了一组内核导出给用户态的页面,类似于 Linux 中 vsyscall 和 vdso 的概念。这些页面在所有进程中是共享的,并且在固定的地址位置。在 i386 上 为 0xffff0000, 在 x86_64 上为 0x7fffffe00000 ,在 ARM 上为 0x40000000。这些页面包含了各种和 CPU 以及平台相关的函数。

alloca

使用 alloca() 可以用栈来动态分配内存。这个函数原型 和 malloc 一样,区别在于这个函数返回的指针是栈上的地址。

页面是生命周期

物理内存页面的生命周期包含几个状态

线程

线程是过去时代的产物。进程是系统执行的基本单元,而且是执行过程中所需要的各种资源,包括虚拟内存、文件描述符以及其他各种对象。开发者编写顺序程序,从入口点 —— 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 更像是运行时环境。

贯穿始终 —— launchd

内核是一个服务提供者,而不是具体的应用程序,用户态的应用程序才是系统中负责真正工作的实体,应用程序构建在内核提供的原语之上,向用户提供丰富的用户态环境,包括文件、多媒体和用户交互。

整个用户态环境必须从某个地方启动,在 OS X 和 iOS 中,用户环境起始于 launchd。

launchd

c 的核心职责是根据预定的安排或实际的需要加载其他应用程序或作业。launchd 区分两种类型的后台作业。

  1. 守护程序(daemon)和传统的 unix 概念一样,是后台服务,通常和用户没有交互。守护程序是由系统启动的,不考虑是否有用户登录系统。
  2. 代理程序(agent)是一类特殊的守护程序,只有在用户登录的时候才启动。和守护程序的不同之处在于,代理程序和用户交互,有的代理程序还有 GUI。

第八章 内核架构

所有现代的操作系统在设计上都包含一个称为 内核 的组件。内核(kernel)是整个操作系统的核心。内核就是操作系统。所有运行的应用程序都是内核的客户,内核向客户提供服务,即系统调用(system call)。

###巨内核

关于地址空间

在 64 位架构上指针虽然是 64 位,但是硬件支持的虚拟内存地址的有效位却达不到这么多。常规的 x86_64 处理器只支持 48 位寻址,也就说在地址转换的时候只用到了低 48 位。

处理器规范要求虚拟地址才使用“规范形式地址(canonical form address)”,也就是第 48 ~ 63 位要和第 47 位相同。第 47 位 只能为 1 和 0 。所以 64 位地址空间会均等的划分为两个部分: 0x000000000000 ~ 0x00007FFFFFFFFFFF 和 0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF。每个部分都是 128 TB。中间形成了巨大空洞,如果使用了空洞中的地址,硬件就会抛出异常。

微内核

混合内核

混合内核(hybrid kernel)结合两种内核的好处。内核的最核心部门支持底层服务,包括调度、进程间通信和虚拟内存,是自包含的,这一部门就像微内核一样。所有其他的服务实现都在这个核心之外,但是也在内核态中,而且和这个核心在同一个内存空间中。混合内核没有微内核的健壮性,牺牲健壮性带来的运行的高效。

ARM 架构 —— CPSR

ARM 处理器使用了一个特殊的寄存器——当前程序状态寄存器 current program status register 来定义处理器所在的模式。ARM 处理器有不少于 7 种不同的操作模式。

内核态 / 用户态转换机制。

区分内核态和用户态非常重要,因此这个功能是由硬件提供的。

应用程序经常需要使用内核服务,因此这两种状态之间的转换不仅要高效,还要保持高度安全性。

用户态和内核态的转换机制有两种类型:

控制权的转移还分为“异步”和“同步”。同步的控制权转移和程序流程一直,通常由于某些指令在运行时发生异常导致。而异步的控制权转移是由于外部的中断请求导致。也就说如果没有中断发生,程序会正常执行,而出现了中断,所以必须处理中断。

不管是那种方式是控制权转移都是安全,执行的都是内核预定义的代码,用户态的代码无法修改这些代码。用户态的程序完全意识不到内核“抢夺”了控制权,特别是在非自愿的情况下。

操作系统在中断分配表(interrupt dispatch table,IDT: Intel的术语)或 异常向量(exception vector:ARM 的术语)中设置了预定义的入口点。这两个术语意思都一样,就是个数组,每个元素都保存了一个预定义的函数指针。CPU 会跳到函数指针指向的位置执行函数,同时也要进入内核态。

Intel 上的陷阱处理程序

Intel 架构定义了带有 255 个项的中断向量,系统引导时内核负责填充这个向量。

  1. 异常 —— 陷阱/错误/终止

在 Intel 架构上,中断向量的前 20 个单元定义为异常:异常指的是处理器在执行代码时可能碰倒的所有非正常情况。

一共有三种类型的异常:

  1. 中断

第二种非自愿的用户态/内核态转换发生在中断的时候。中断由 CPU 中的一个特殊组件产生,这个组件称为可编程中断控制器(programable interrupt Controller,PIC),在更加现代的 CPU 中,这个组件被称为高级可编程中断控制器(advanced PIC,APIC)。

PIC 接受来自系统总线上的设备的消息,然后把消息分拣到某一条中断请求(Interrupt Request,IRQ)线上去。当产生中断时。PIC将相应的中断线标记为活跃。在这条中断被处理之前一直保持活跃,中断处理程序处理中断后重置这条线的状态。

现代的 APIC 允许有 255 条这样的中断线,IRQ 线可以被多个设备共享。

一般只要满足下面的条件,中断请求可以被发送出去:

  1. 在 Intel 架构上 XNU 对陷阱和中断的处理
  1. 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 程序计数器执行该线程的函数代码。这个过程一直持续只要遇到以下几种情况:

运行队列

线程是通过运行队列管理的,运行队列是一个多层列表,是一个列表的数组,针对 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 和其各种衍生的操作系统。

附录部分

调用者要完成的任务:

被调函数的职责比调用者的职责更多一些:

  1. 保存任何要使用的寄存器
  2. 如果使用了帧指针(RBP),则设置RBP
  3. 保存任何要使用的浮点寄存器
  4. 在栈上分配局部变量的空间。
  1. 在栈上解除分配局部变量使用的空间。
  2. 恢复使用的浮点寄存器
  3. 恢复使用的通用寄存器
  4. 如果使用了 RBP ,则恢复 RBP,然后返回到调用者指定的返回地址。

Intel 和 ARM 的不同