文档更新说明
最后更新 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) { 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工具看到这个函数的作用:
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" > <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:
- (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) 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:
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:
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
,再调用execev
,execev
这个函数在程序执行完之前是不会返回的,该函数里面会先加载dyld
的二进制文件,然后把进程控制权交给dyld
加载器,dyld
最后调用App的main函数,程序这个时候才开始运行起来。
专业名词解释 本文多次用到几个名词,这里解释一下意思:
二进制文件:指macho文件,可能是可执行文件,也可能是加载器文件比如dyld
entry_point:程序被装在到内存之后的入口指令地址,该地址的内容就是CPU指令。
阅读推荐 macOS 内核之一个 App 如何运行起来
OSX内核加载mach-o流程分析 | mrh的学习分享
XNU源码下载