BSD 层
Mach只是一个微内核。尽管Mach的部分应用程序接口(API)也暴露给了用户态,但是开发者主要使用的还是更为流行的POSIX API,而这一套API 是通过Mach之上的BSD层实现的。
BSD 简介
在BSD出现在XNU 中之前,Mach 就和 BSD紧密结合在一起了。Mach 的陷阱和服务不足以支撑起完整的操作系统,而且Mach 缺少一些像文件系统这样最基本的服务。在Mach 提供的这些原语之上还需要建立一个层次提供像文件、设备、用户和组等重要抽象。Mach 最早选择的这个层次就是BSD,而且延续着XNU中了。
进程和线程
Mach调度中提到了任务和线程这两个原语。BSD也采用了这两个原语,并且组织成看UNIX世界中著名的进程和线程的概念。
BSD 进程结构
BSD 的进程可以唯一地映射到Mach任务,但是包含的信息比Mach任务提供的基本调度和统计信息要丰富。BSD 进程包含了文件描述符和信号处理程序的数据。进程还支持复杂的谱系,将进程和其父进程、兄弟进程和子进程连接起来。BSD 在struct proc 中维护了进程的很多特性,struct porc是一个庞然大物,拥有许多字段,因此需要多个锁来保护不同的字段,以及字段参与的列表。进程锁(PL)保护整个数据结构,还有一个线程自旋锁(PSL)、一个文件描述符锁(PFDL)以及其他保护进程组合兄弟进程的锁。
进程列表和进程组
XNU在struct proclist 变量中维护多个进程,这个变量就是struct proc 的链表。还一个遍历这些列表的特殊迭代器函数。
线程
进程就是容器,二进制代码的执行单元是线程。 BSD 的线程对象定义在struct uthread 结构体中。同样也是一个非常庞大的数据结构。用户态的线程始于对pthread_create的调用。主要工作是由bsdthread_create 系统调用完成的。bsdthread_create( ) 只不过是对Mach线程创建的复杂包装。真正的线程创建是由底层的Mach层完成的。bsdthread_create( ) 负责的工作是设置线程栈,设置线程状态以及设置自定义调度参数等。
BSD 线程对应到 Mach 任务
底层的Mach 微内核才是真正主要实现进程和线程结构原语的实体。每一个Mach 任务都包含一个bsd_info 指针指向其对应的BSD proc 数据结构;类似地,Mach 线程也包含一个uthread 字段指向对应的uthread 结构体。这些指针都是void类型的,因此Mach函数不需要具体的BSD数据结构。类似地,BSD进程也通过task字段指针指回其对应的任务(同样也是void*);BSD线程(uthread)也是通过vc_thread字段指回对应的Mach 线程,这个vc_thread字段是uthread的uu_context字段中的子字段。在Mach这边,Mach 调用task_for_pid( ) 用于将BSD的PID转换为底层Mach任务的端口。在XNU中,所有的内核线程都是Mach线程,没有对应的BSD进程。也就是说,这些线程的uthread指针都为NULL,并且都包含在kernel_task中。类似地,kernel_task 也没有BSD 进程标识符(处理PID 0之外)。
进程创建
用户态角度
在UNIX中,进程不能被创建出来,只能通过fork( ) 系统调用复制出来。fork( ) 只是一个特殊的系统调用,调用一次返回两次:
- 在子进程中,fork( ) 返回0
- 在父进程中,fork( ) 返回子进程的PID
如果fork( ) 操作失败,fork( ) 只会在调用的进程中返回-1,并且设置对应的errno值,通常是EAGAIN或ENOMEN。
子进程是父进程的完整复制,除了以下几个重要的例外: - 文件描述符,尽管数值和指向的文件都是一样的,但只是原始描述符的副本。这意味着后续对这些描述符修改的调用(例如lseek( ) 和 close( ) )只会影响创建这些描述符的进程
- 资源限制,即getrlimit( ) 和 ulimit( ) 调用设置的值,子进程会继承资源限制,但是资源利用率为0
- 子进程的内存镜像(从虚拟内存的角度)看上去是子进程私有的,但是事实上(从物理内存的角度看)子进程和父进程使用的是内存中相同的物理页面。虚拟内存的私有性是通过设置页面的写时复制标志位保证的,因此不论是哪个进程(子进程或者父进程)视图写入页面是都会引发页错误。在处理页错误时,内核创建页面的副本,创建同一页的另外一个物理页面副本,并且重新建立映射
内核态的角度
不管用户使用了哪一个系统调用:fork( )、vfork( )或posix_spawn( ),内核中的所有路径都会归一到同一个底层的实现,那就是fork( )。这个调用的行为取决于第三个参数:kind,每一个系统调用传入不同的值,如下表
int fork1(proct parent_proc, thread_t *child_threadp, int kind);
kind值 | 创建的进程 | 地址空间 |
---|---|---|
PROC_CREATE_FORK | 完整 | 复制(写时复制) |
PROC_CREATE_VFORK | 部分 | 新创建的 |
PROC_CREATE_SPAWN | 完整 | 延迟创建(不可用) |
fork1( ) 最终复制创建新的进程,这个调用会创建这个进程对应的新Mach 任务。尽管三个不同的系统调用最终都会汇聚到这个调用。如果是vforl调用,fork1( )则调用forkpric( ),其他情况下,则调用cloneproc( )。后者是对forkproc( ) 的封装,但是完成更多任务,关于fork1( ) 分支多种创建的调用如下图:
forkproc( ) 函数
不论调用来自fork( )、vfork( )还是posix_spwan( ),forkproc( ) 函数的职责都是初始化新进程的proc_t 数据结构。处理的流程如下所示:
- 从 M_PROC Zone 中分配 proc_t 类型的 child_proc,并且调用bzero 用0填充
- 分配子进程的统计信息数据结构(p_stats)和信号处理动作数据结构(p_sigacts)
- 分配间隔定时器callout(p_rcall)
- 获得子进程的PID,要处理PID越过PID_MAX(99999)的情况。将PID插入PID散列表
- 初始化进程的其他字段。大部分字段都是通过bcopy( ) 直接从父进程复制过来,包括从父进程的p_startcopy指针(被设置为p_argslen)到p_endcopy(被设置为p_aio_totalcount)的范围。有一些字段除外
- 通过fdcopy( ) 复制父进程的所有文件描述符
- 通过shmfork( ) 复制父进程的System V 共享内存(#if SUSV_SHM)
- 通过proc_limitfork( ) 复制父进程的资源限制
- 将p_stats 中从pstat_startzero(设置为p_ru)到pstat_endzero(设置为p_stat)的范围通过bzero( ) 设置为0.将p_start(进程的启动时间)设置为当前时间
- 如果父进程定义了信号动作(p_sigacts),则复制过来,否则将子进程的信号动作全部设置为NULL
- 如果有的话,设置子进程的控制终端
- 调用proc_signalstart(child_proc,0) 阻止所有信号,并调用 proc_transstart(child_proc, 0)将子进程标记为处于转换的状态
- 初始化子进程的线程列表(p_uthlist)和异步I/O队列
- 继承父进程的代码签名相关的标志
- 复制父进程的工作队列信息
- 如果父进程在登录上下文中(而且 #if CONFIG_LCTX),则通过enterctx( ) 将子进程也加入登录上下文
注意这个函数还漏了一个很重要的工作:在Mach 层次创建实际的进程和线程。在vfork( ) 中是不会完成这项任务的。只要fork( ) 和 posix_spawn( ) 才能完成。这也是为什么只有 vfork( ) 会直接调用forkproc( ),而其他情况下则是由cloneproc( )调用forkproc( ) 的原因 ,cloneproc( ) 会创建必要的 Mach 数据结构。vfork( ) 的进程没有对应的Mach 任务和线程。只要在接下来调用; execve( ) 之后才会创建任务和进程。事实上,除了有一个execve( ) 跟在后面之外,vfork( ) 没有存在的意义,因为这系统通过嘴馋的设计就是为了这个目的。子进程的task_t和thread_t完全就是父进程的task_t和thread_t,vm_map 也是如此。只有以后调用execve( ) 载入一个Mach-O 镜像才能最终真正创建一个Mach 任务和进程。
cloneproc( ) 函数
当传入的kind为PROC_CREATE_SPAWN和PROC_CREATE_FORK时才会调用cloneproc( ) 函数。由于我们关注的是“真正的”fork,而不是vfork( ) 所以这个函数处理吊椅forkproc( ) 之外还会执行其他操作。这个函数的流程如下:
- 对parent_proc 调用forkproc( ),这个函数会返回proc_t 类型的child_proc,而child_proc 最终会成为子进程完整填充的控制块
- 调用fork_create_child( ) 创建子进程的uthread
- 根据父进程的P_LP64设置子进程的64为属性
- 对parent_proc 和新生的child_proc 调用 pinserchild( )。这个函数将子进程插入父进程的p_children列表中,然后将子进程插入allproc列表,这样系统就知道这个新子进程的存在了。此外,这个函数还有一个副作用,就是清除子进程的p_listflahg中的P_LIST_INCREATE标志位。这个标志位是在forkproc( ) 中设置的,目的是为了让那个proc_ref_locked( ) 忽略子进程
加载和执行二进制文件
如果将一个进程比作是一个人体,那么在进程中执行的二进制程序就是这个人体的大脑。只是通过fork( ) 新创建出来的进程也没多大作用,除非执行镜像通过exec( ) 替换为另一个可执行程序。因此,进程创建的核心在于二进制文件的加载和执行。
进程控制和跟踪
Mach 提供了丰富的跟踪机制,其中最重要的就是DTrace。还有一个机制是ptrance( )
ptrace( )
BSD 和 其他UNIX 系统提供了一个名为ptace( ) 的一站式系统调用,这个调用支持进程跟踪和调试。其函数原型如下
int ptrace(int request, pid_t pid, caddr_t addr, int data);
策略
OS X 和 iOS 支持I/O 和 执行策略(policy)的概念。苹果在策略方面提供了以下两个系统调用:
- **iopolicysys( ) **:用于本地设置I/O。策略的作用范围可以是当前线程也可以是这个线程。这个策略可以取值NORMAL(尽力而为)、THROTTLE(限制带宽)和PASSIVE(其他进程优先)
- process_policy( ):允许对进程实施执行策略
进程挂起和恢复
挂起一个进程相当于停止一个进程的执行无限长时间,知道这个进程被恢复。通过信号停止进程运行的概念并不是OS X 和 iOS 中的进程挂起,XNU提供系统调用(而不是信号)来支持这种功能。这些系统调用只不过是对Mach API task_suspend( ) 和 task_resume( ) 的简单包装。而从iOS 5开始,这些系统调用就和Mach 的 defaulr_freezer以及进程的睡眠机制结合在一起了。通过这种设计,进程可以通过系统调用的方式选择性地冷冻和解冻进程,冷冻和解冻的决定权通常在iOS的加载器SpringBoard手中。
信号
Mach 已经通过异常机制提供了底层的陷阱处理。而BSD则在异常机制之上构建了信号处理机制。硬件产生的信号被Mach 层捕捉,然后转换成对应的UNIX 信号。为了维护一个统一的机制,操作系统和用户产生的信号首先被转换为Mach 异常,然后在转换为信号。
UNIX 异常处理程序
当第一个BSD进程(也是用户态进程)被bsdinit_task( ) 函数启动时,这个函数还调用了un_handle_init( ) 函数,这个函数设置了一个名为 un_hanler 的 Mach 内核线程。通过调用host_set_exception_ports( ) 函数,bsdinit_taks( ) 将所有的Mach 异常消息都重定向到ux_exception_port,这个端口由un_handler( ) 线程持有。遵循Mach消息传递的范式,PID 为1的进程(即launchd)的异常就会在进程之外由un_handler( ) 线程处理。由于所有后建的用户进程都是PID 1的后代,所以这些进程都会自动继承这个异常端口,相当于un_handler( ) 线程要负责处理系统上UNIX进程产生的每一个Mach 异常。
硬件产生的信号
硬件产生的信号始于处理器陷阱。处理器陷阱是平台相关的。ux_exception负责将显著转化为信号。为了处理机器相关的情况,un_exception 会调用machine_exception首先尝试处理机器陷阱。如果这个函数无法转换信号,un_exception则处理一般的情况。
软件产生的信号
如果信号不是由硬件产生的,那么这个信号源于两个API调用:kill( ) 或pthread_kill( )。这两个函数分别向进程和线程发送信号。
受害者的信号处理
不论是硬件残生的信号还是其他方式产生的信号,执行流程都会结束语 act_set_bsdast( )。这回导致收到信号的进程唤醒(准确的说,是这个进程中的某一个线程被唤醒),唤醒时执行流程进入ast_taken( ),ast_taken( )转而调用bsd_asr( )。