文章目录
  1. 1. 文档更新说明
    1. 1.1. 前言
  2. 2. 程序的本质
  3. 3. 进程从产生到运行
    1. 3.1. 从打开电源说起
    2. 3.2. 用户态的第一个进程 launchd
    3. 3.3. launchd的创建
    4. 3.4. launchd之后
    5. 3.5. Finder & SpringBoard进程
    6. 3.6. launchd进程启动用户App
    7. 3.7. 进入dyld
    8. 3.8. main函数
  4. 4. 总结
  5. 5. 专业名词解释
  6. 6. 阅读推荐

文档更新说明

  • 最后更新 2020年11月22日
  • 首次更新 2020年11月22日

前言

如果问一个稍微有些经验的iOS开发者,App是如何运行的,他可能会说从main函数开始运行。被谁启动的?他可能知道iOS的App是由一个叫SpringBoard进程启动的。我们都知道,iPhone自带的那个桌面程序就叫SpringBoard,点击桌面上的一个图标,就可以打开一个App,由于iOS系统只支持同时运行一个用户App,而且不允许在App内部直接启动另一个进程,所以作为iOS开发者,大多数人对一个进程是如何启动另一个进程的细节毫无了解。

但是如果是一名Mac开发者,因为桌面程序是允许多进程的,平时就会接触到父子进程关系,特别的如果是从事Mac杀毒软件,安全管控程序开发的,还需要从内核层面去了解一个进程是如何启动另一个进程。从源头去探究进程启动的本质,探究进程是如何产生到运行,能丰富我们对OS X以及iOS系统的认知,反过来这些知识也能更好的服务应用层App的开发。

程序的本质

进程,线程即是抽象的概念,也是实际存在的东西。从编程的角度看,进程,线程在内核中都有自己对应的结构体(没错就是C语言的那个结构体),内核也是通过一个表来维护进程信息的,比如我们调用fork 函数时,内部有一段代码是查询当前进程的数量,没有超过最大值才允许fork 子进程。下面是内核fork函数的实现,可以看到进程确实是用一个结构体来表示。

进程的创建都是在内核中进行的,本文分析的所有源码均为xun内核的源码。

int
fork1(proc_t parent_proc, thread_t *child_threadp, int kind, coalition_t *coalitions)
{
// 省略其他代码
/*
* Increment the count of procs running with this uid. Don't allow
* a nonprivileged user to exceed their current limit, which is
* always less than what an rlim_t can hold.
* (locking protection is provided by list lock held in chgproccnt)
*/
count = chgproccnt(uid, 1);
// 省略其他代码
}

从CPU的角度看,所有这些概念都是不复存在的,CPU只知道逐行执行指令,指令可能是操作寄存器,也可能是去某个内存地址读取数据,或者把内存数据写入到磁盘(通过驱动设备实现)。

进程从产生到运行

一个进程从产生到运行,就像一个宇宙从创建到孕育生命,很神奇,也很难理解。幸运的是进程是人为制造出来的,所以我们还是可以查文档看源码来找到答案,当然这个也是很难,本文不能保证100%正确,只能当作学习总结,说个大概吧 :)

从打开电源说起

这部分我没怎么研究,简单说说,硬件通电之后,CPU就开始工作了,CPU会从烧录在硬件ROM上的固定位置上的指令开始逐行执行,这部分指令执行的结果就是把ROM里的EFI固件(二进制程序)加载并运行,接着再由EFI去引导OS X或者iOS的内核(二进制程序),这部分有很多内容,书里占了几百页,最终的结果就是内核把一个操作系统的各个基本功能都初始化完毕,比如文件系统,虚拟内存,网络协议栈等,此时也有了进程这个抽象概念了,内核也是一个进程。

用户态的第一个进程 launchd

上面我们省略了引导和内核加载,到了launchd这一步,就相当于宇宙从大爆炸直接跳到地球的形成了:)

内核加载完毕之后,会分配一个线程来执行bsdinit_task 函数,最终会去加载launchd二进制文件,并最终切换到用户态运行launchd进程,到这里,用户态终于有了第一个进程了。

下面就是内核函数bsdinit_task 的源码,用C语言写的,在内核态执行。可以看到launchd这个进程一开始是叫init的,后面才被改成launchd :)

void
bsdinit_task(void)
{
proc_t p = current_proc();

process_name("init", p);
// 省略其他代码
bsd_init_kprintf("bsd_do_post - done");

load_init_program(p);
lock_trace = 1;
}

launchd的创建

上面代码并没有说明内核进程如何创建出launchd进程的,这里有必要详细说明一下,因为理解这个过程,也就能理解后续用户App比如微信,淘宝这些App进程的创建原理。

内核加载完毕后,文件系统也已经初始化完毕了。launchd这个进程对应的镜像文件,也就是平时说的二进制可执行文件位于如下目录,这个路径被硬编码到内核的代码里。

➜  ~ ll /sbin/launchd
-rwxr-xr-x 1 root wheel 378K 9 22 08:30 /sbin/launchd

bsdinit_task 函数最末尾调用了load_init_program 函数,该函数的源码如下:

static const char * init_programs[] = {
#if DEBUG
"/usr/local/sbin/launchd.debug",
#endif
#if DEVELOPMENT || DEBUG
"/usr/local/sbin/launchd.development",
#endif
"/sbin/launchd",
};

void
load_init_program(proc_t p)
{
uint32_t i;
int error;
vm_map_t map = current_map();
// 省略其他代码

error = ENOENT;
for (i = 0; i < sizeof(init_programs) / sizeof(init_programs[0]); i++) {
printf("load_init_program: attempting to load %s\n", init_programs[i]);
error = load_init_program_at_path(p, (user_addr_t)scratch_addr, init_programs[i]);
if (!error) {
return;
} else {
printf("load_init_program: failed loading %s: errno %d\n", init_programs[i], error);
}
}
}

可以看到launchd的二进制文件直接被硬编码到全局常量里面了,接着调用了load_init_program_at_path 函数,该函数末尾会调用execve

int
execve(proc_t p, struct execve_args *uap, int32_t *retval)
{
struct __mac_execve_args muap;
int err;

memoryshot(VM_EXECVE, DBG_FUNC_NONE);

muap.fname = uap->fname; //二进制文件路径
muap.argp = uap->argp; // 参数
muap.envp = uap->envp; // 环境变量,非常重要
muap.mac_p = USER_ADDR_NULL;
err = __mac_execve(p, &muap, retval);

return err;
}

内核函数execve 有对应的系统调用,可以通过man工具看到这个函数的作用:

// execve() transforms the calling process into a new process.
//系统调用函数签名如下
int execve(const char *path, char *const argv[], char *const envp[]);

意思是说调用该函数的进程会被转换成指定的新进程,这个系统调用最后也会调用到同名的内核函数,我们可以做一个小实验,就可以看到这个函数如果调用成功的话是不会返回的,最终会直接进入到目标进程的main 函数,运行目标进程。

// 目标程序
#include <stdio.h>

int main(int argc, const char * argv[]) {
// insert code here...
printf("Hello, Jue jin!\n");
return 0;
}​
// 演示程序
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/wait.h>

int main() {
pid_t pid = fork();

if (pid == 0) {
printf("%s\n", "parent");
} else {
int ret = execve("./TestHelloWorld",0,0);
printf("execve ret: %d\n", ret);
}
}

编译一下目标程序, 放到演示程序同目录下,然后运行演示程序就可以看到打印出Hello, Jue jin 了,而且看不到演示程序打印的execve ret: 。execve函数内部细节会在下文展开。

关于环境变量这里有必要强调一下,正常子进程的环境变量会继承自父进程,而很多程序的实现都依赖环境变量,比如Xcode的调试,在Xcode里面运行一个程序,通过活动监视器可以看到这个程序的父进程就是Xcode的调试进程debugserver

这个程序本身是很简单的,但是却被注入了多个dylib,这就是环境变量的威力了。dyld加载器会根据环境变量中的一些特定标志,在加载程序之前先加载指定动态库,这样我们才能愉快地使用Xcode提供的调试功能。

sudo launchctl procinfo 39203

通过launchctl工具可以看到进程信息,其中有一段环境变量数组(篇幅有限只保留其中一项)

environment vector = {
DYLD_INSERT_LIBRARIES => /Applications/Xcode.app/Contents/Developer/usr/lib/libBacktraceRecording.dylib:/Applications/Xcode.app/Contents/Developer/usr/lib/libMainThreadChecker.dylib:/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/Library/Debugger/libViewDebuggerSupport.dylib
}

 可以看到里面有DYLD_INSERT_LIBRARIES ,这个就是实现Xcode调试功能的最重要的环境变量了,这部分细节应该是属于dyld。

launchd之后

launchd进程作为第一个用户态进程,PID=1,此时系统还没有任何用户界面进程,launchd接下来的工作就是要把Mac OS X & iOS 系统的必备守护进程和代理进程给拉起来。代理进程

launchd主要是通过查询几个预先指定的目录,来确定需要启动哪些守护进程或者代理进程。对于Mac来说,守护进程就是用户还没登陆的时候就启动了,用户登陆之后才启动的那些进程称为代理进程;但是iOS没有用户登陆的概念,所以iOS里的这些进程都是守护进程(包括桌面),这几个目录如下:

/System/Library/LaunchDaemons #存放系统守护进程plist文件
/System/Library/LaunchAgents #存放系统代理进程plist文件
/Library/LaunchDaemons #存放第三方守护进程plist文件
/Library/LaunchAgents #存放第三方代理进程plist文件
~/Library/LaunchAgents #存放用户自由的代理程序plist文件,用户登录时启动

上面这些目录里面全部放的plist文件,plist文件会说明如何启动进程,进程二进制文件路径等等。具体的plist内容格式省略了,里面有很多细节。下面列举例子。

Mac OS X 在用户登陆之后,会启动Dock进程,Finder进程等等,Finder进程的plist文件如下:

~ cat /System/Library/LaunchAgents/com.apple.Finder.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>POSIXSpawnType</key>
<string>App</string>
<key>RunAtLoad</key>
<false/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
<key>AfterInitialDemand</key>
<true/>
</dict>
<key>Label</key>
<string>com.apple.Finder</string>
<key>Program</key>
<string>/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder</string>
<key>CFBundleIdentifier</key>
<string>com.apple.finder</string>
<key>ThrottleInterval</key>
<integer>1</integer>
</dict>
</plist>

Mac OS X和 iOS系统有很多相同的deamons程序,也有很多不同的,其中iOS的桌面进程SpringBoard,对于的plist文件在如下目录:

/System/Library/LaunchDaemons

除此之外,iOS系统的luanchd进程启动之后还会拉起很多其他系统进程,这一点不比Mac OS X多少。

这里没有具体说launchd是怎么拉起其他进程的,所以下文会具体说这个问题,现在先跳过。

Finder & SpringBoard进程

在iOS中,App可以通过如下API打开另一个App:

// UIApplication
- (void)openURL:(NSURL *)url
options:(NSDictionary<UIApplicationOpenExternalURLOptionsKey, id> *)options
completionHandler:(void (^)(BOOL success))completion;

SpringBoard是不是也用这个AIP,这个得反编译SpringBoard才知道。

同样的在Mac OS X系统里,打开其他进程可以用NSWorkspace 类,我们平时在Finder中打开一个App,Finder使用的就是NSWorkspace 类中的API。

以iOS为例,现在有了SpringBoard进程了,用户已经可以看到桌面了,这个时候点击一个App图标,比如微信 ,SpringBoard会通过某个方式告知launchd进程来打开微信。

实际上,Mac程序使用NSWorkspace 类打开其他App时,可以看到App的父进程也是launchd进程。由于这部分API没有开源,这里就直接跳过了,到这里只需要知道,不管是SpringBoard还是Finder,当用户打开一个App时,他们都通过某种方式告知launchd进程去打开App。

launchd进程启动用户App

launchd在启动其他进程时,会通过fork() 系统调用,进入内核态克隆出另一个launchd进程,再通过execve 系统调用,传入目标App的二进制文件的路径。execve 函数内部会有一些列调用,参考下面这个调用图:

这部分全部是开源的,最终会调用到load_dylinker 函数,下面附上内核函数load_dylinker 的源码:

#define DEFAULT_DYLD_PATH "/usr/lib/dyld"

#if (DEVELOPMENT || DEBUG)
extern char dyld_alt_path[];
extern int use_alt_dyld;
#endif

static load_return_t
load_dylinker(
struct dylinker_command *lcp,
integer_t archbits,
vm_map_t map,
thread_t thread,
int depth,
int64_t slide,
load_result_t *result,
struct image_params *imgp
)
{
const char *name;
struct vnode *vp = NULLVP; /* set by get_macho_vnode() */
struct mach_header *header;
off_t file_offset = 0; /* set by get_macho_vnode() */
off_t macho_size = 0; /* set by get_macho_vnode() */
load_result_t *myresult;
kern_return_t ret;
struct macho_data *macho_data;
struct {
struct mach_header __header;
load_result_t __myresult;
struct macho_data __macho_data;
} *dyld_data;

if (lcp->cmdsize < sizeof(*lcp) || lcp->name.offset >= lcp->cmdsize) {
return LOAD_BADMACHO;
}
// 这里是重点,从目标macho文件中找到dyld动态连接器的路径。
name = (const char *)lcp + lcp->name.offset;

/* Check for a proper null terminated string. */
size_t maxsz = lcp->cmdsize - lcp->name.offset;
size_t namelen = strnlen(name, maxsz);
if (namelen >= maxsz) {
return LOAD_BADMACHO;
}

#if (DEVELOPMENT || DEBUG)

/*
* rdar://23680808
* If an alternate dyld has been specified via boot args, check
* to see if PROC_UUID_ALT_DYLD_POLICY has been set on this
* executable and redirect the kernel to load that linker.
*/

if (use_alt_dyld) {
int policy_error;
uint32_t policy_flags = 0;
int32_t policy_gencount = 0;

policy_error = proc_uuid_policy_lookup(result->uuid, &policy_flags, &policy_gencount);
if (policy_error == 0) {
if (policy_flags & PROC_UUID_ALT_DYLD_POLICY) {
name = dyld_alt_path;
}
}
}
#endif

#if !(DEVELOPMENT || DEBUG)
if (0 != strcmp(name, DEFAULT_DYLD_PATH)) {
return LOAD_BADMACHO;
}
#endif

/* Allocate wad-of-data from heap to reduce excessively deep stacks */

MALLOC(dyld_data, void *, sizeof(*dyld_data), M_TEMP, M_WAITOK);
header = &dyld_data->__header;
myresult = &dyld_data->__myresult;
macho_data = &dyld_data->__macho_data;

ret = get_macho_vnode(name, archbits, header,
&file_offset, &macho_size, macho_data, &vp);
if (ret) {
goto novp_out;
}

*myresult = load_result_null;
myresult->is_64bit_addr = result->is_64bit_addr;
myresult->is_64bit_data = result->is_64bit_data;
// 递归调用,解析macho文件
ret = parse_machfile(vp, map, thread, header, file_offset,
macho_size, depth, slide, 0, myresult, result, imgp);

if (ret == LOAD_SUCCESS) {
if (result->threadstate) {
/* don't use the app's threadstate if we have a dyld */
kfree(result->threadstate, result->threadstate_sz);
}
result->threadstate = myresult->threadstate;
result->threadstate_sz = myresult->threadstate_sz;

result->dynlinker = TRUE;
result->entry_point = myresult->entry_point;
result->validentry = myresult->validentry;
result->all_image_info_addr = myresult->all_image_info_addr;
result->all_image_info_size = myresult->all_image_info_size;
if (myresult->platform_binary) {
result->csflags |= CS_DYLD_PLATFORM;
}
}

struct vnode_attr va;
VATTR_INIT(&va);
VATTR_WANTED(&va, va_fsid64);
VATTR_WANTED(&va, va_fsid);
VATTR_WANTED(&va, va_fileid);
int error = vnode_getattr(vp, &va, imgp->ip_vfs_context);
if (error == 0) {
imgp->ip_dyld_fsid = vnode_get_va_fsid(&va);
imgp->ip_dyld_fsobjid = va.va_fileid;
}

vnode_put(vp);
novp_out:
FREE(dyld_data, M_TEMP);
return ret;
}

重点看上面中文注释,关于macho文件的格式网上大把文章可以自行查看,macho文件的cmd部分,会指明当前二进制文件需要采用的动态加载器的路径。

从代码也可以看到,调试模式下,内核会直接把/usr/lib/dyld 路径下的二进制文件当作实际dyld加载器。

取得dyld的二进制文件之后,由于这个文件又是一个macho文件,所以又递归调用了parse_machfile 函数,该函数又按照macho文件的格式解析加载dyld ,可以看到,我们打开一个二进制文件的时候,内部不但解析了macho格式的二进制文件,还顺便也解析了dyld 二进制文件。

解析完dyld文件之后,load_dylinker 函数返回了结构体load_return_t ,该结构体就包括了dyld文件的入口指令地址,也就是entry_point

从上面流程图可以看到,获取到entry_point 之后,最终调用了thread_setentrypoint() 函数,把entry_point 设置给了指令寄存器。

该函数有多个实现,分别对应多个CPU架构:

intel:

/*
* thread_setentrypoint:
*
* Sets the user PC into the machine
* dependent thread state info.
*/
void
thread_setentrypoint(thread_t thread, mach_vm_address_t entry)
{
pal_register_cache_state(thread, DIRTY);
if (thread_is_64bit_addr(thread)) {
x86_saved_state64_t *iss64;

iss64 = USER_REGS64(thread);

iss64->isf.rip = (uint64_t)entry;
} else {
x86_saved_state32_t *iss32;

iss32 = USER_REGS32(thread);

iss32->eip = CAST_DOWN_EXPLICIT(unsigned int, entry);
}
}

arm64:

/*
* Routine: thread_setentrypoint
*
*/
void
thread_setentrypoint(thread_t thread,
mach_vm_offset_t entry)
{
struct arm_saved_state *sv;

sv = get_user_regs(thread);

set_saved_state_pc(sv, entry);

return;
}

entry_point 被设置给指令寄存器之后,CPU执行的下一条指令就是entry_point位置上的指令了。这里涉及到汇编知识,CPU就是根据指令寄存器(pc,eip,rip分别是不同CPU架构中的指令寄存器)里面的地址来决定下一条从哪儿加载的。详细汇编知识请自行上网找资料。

进入dyld

上面指令寄存器被设置为dyld的入口地址之后,接着就进入dyld了。 dyld的工作原理网上已经有很多资料了这里就不展开了。大概原理就是dyld也会解析目标程序的macho文件,加载动态库,初始化各种环境之后,比如OC的runtime,接着找到目标macho文件(比如微信的可执行文件)的entry_point ,目标macho的entry_point其实就是大家熟知的main 函数。

main函数

这一步就不用多说了,大家都懂的。

总结

用户点击一个App,会通过luanchd进程运行App。luanchd进程显示调用fork ,再调用execevexecev 这个函数在程序执行完之前是不会返回的,该函数里面会先加载dyld 的二进制文件,然后把进程控制权交给dyld 加载器,dyld 最后调用App的main函数,程序这个时候才开始运行起来。

专业名词解释

本文多次用到几个名词,这里解释一下意思:

  1. 二进制文件:指macho文件,可能是可执行文件,也可能是加载器文件比如dyld
  2. entry_point:程序被装在到内存之后的入口指令地址,该地址的内容就是CPU指令。

阅读推荐

macOS 内核之一个 App 如何运行起来

OSX内核加载mach-o流程分析 | mrh的学习分享

XNU源码下载

文章目录
  1. 1. 文档更新说明
    1. 1.1. 前言
  2. 2. 程序的本质
  3. 3. 进程从产生到运行
    1. 3.1. 从打开电源说起
    2. 3.2. 用户态的第一个进程 launchd
    3. 3.3. launchd的创建
    4. 3.4. launchd之后
    5. 3.5. Finder & SpringBoard进程
    6. 3.6. launchd进程启动用户App
    7. 3.7. 进入dyld
    8. 3.8. main函数
  4. 4. 总结
  5. 5. 专业名词解释
  6. 6. 阅读推荐