如果问一个稍微有些经验的iOS开发者,App是如何运行的,他可能会说从
main
函数开始运行。被谁启动的?他可能知道iOS的App是由一个叫SpringBoard进程启动的。我们都知道,iPhone自带的那个桌面程序就叫SpringBoard,点击桌面上的一个图标,就可以打开一个App,由于iOS系统只支持同时运行一个用户App,而且不允许在App内部直接启动另一个进程,所以作为iOS开发者,大多数人对一个进程是如何启动另一个进程的细节毫无了解。
但是如果是一名Mac开发者,因为桌面程序是允许多进程的,平时就会接触到父子进程关系,特别的如果是从事Mac杀毒软件,安全管控程序开发的,还需要从内核层面去了解一个进程是如何启动另一个进程。从源头去探究进程启动的本质,探究进程是如何产生到运行,能丰富我们对OS X以及iOS系统的认知,反过来这些知识也能更好的服务应用层App的开发。
程序的本质
进程,线程即是抽象的概念,也是实际存在的东西。从编程的角度看,进程,线程在内核中都有自己对应的结构体(没错就是C语言的那个结构体),内核也是通过一个表来维护进程信息的,比如我们调用
fork
函数时,内部有一段代码是查询当前进程的数量,没有超过最大值才允许
fork
子进程。下面是内核fork函数的实现,可以看到进程确实是用一个结构体来表示。
进程的创建都是在内核中进行的,本文分析的所有源码均为xun内核的源码。
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 :)
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",
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
,
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工具看到这个函数的作用:
int execve(const char *path, char *const argv[], char *const envp[]);
意思是说调用该函数的进程会被转换成指定的新进程,这个系统调用最后也会调用到同名的内核函数,我们可以做一个小实验,就可以看到这个函数如果调用成功的话是不会返回的,最终会直接进入到目标进程的main
函数,运行目标进程。
#include <stdio.h>
int main(int argc, const char * argv[]) {
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">
<key>POSIXSpawnType</key>
<string>App</string>
<key>RunAtLoad</key>
<false/>
<key>KeepAlive</key>
<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:
- (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;
struct mach_header *header;
off_t file_offset = 0;
off_t macho_size = 0;
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;
name = (const char *)lcp + lcp->name.offset;
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
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;
ret = parse_machfile(vp, map, thread, header, file_offset,
macho_size, depth, slide, 0, myresult, result, imgp);
if (ret == LOAD_SUCCESS) {
if (result->threadstate) {
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.
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
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
,再调用execev
,execev
这个函数在程序执行完之前是不会返回的,该函数里面会先加载dyld
的二进制文件,然后把进程控制权交给dyld
加载器,dyld
最后调用App的main函数,程序这个时候才开始运行起来。
专业名词解释
本文多次用到几个名词,这里解释一下意思:
二进制文件:指macho文件,可能是可执行文件,也可能是加载器文件比如dyld
entry_point:程序被装在到内存之后的入口指令地址,该地址的内容就是CPU指令。
macOS 内核之一个 App 如何运行起来
OSX内核加载mach-o流程分析 | mrh的学习分享
XNU源码下载