从内核探究Mac OS X和iOS App 进程的创建原理

从内核探究Mac OS X和iOS App 进程的创建原理如果问一个稍微有些经验的iOS开发者,App是如何运行的,他可能会说从main函数开始运行。被谁启动的?他可能知道iOS的App是由一个叫SpringBoard进程启动的。我们都知道,iPhone自带的那个桌面程序就叫SpringBoard,点击桌面上的一个图标,就可以打开一个…

原文来自本人博客

文档更新说明

  • 最后更新 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

从内核探究Mac OS X和iOS App 进程的创建原理

这个程序本身是很简单的,但是却被注入了多个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 函数内部会有一些列调用,参考下面这个调用图: 从内核探究Mac OS X和iOS App 进程的创建原理

这部分全部是开源的,最终会调用到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部分,会指明当前二进制文件需要采用的动态加载器的路径。

从内核探究Mac OS X和iOS App 进程的创建原理

从代码也可以看到,调试模式下,内核会直接把/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源码下载

今天的文章从内核探究Mac OS X和iOS App 进程的创建原理分享到此就结束了,感谢您的阅读。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:http://bianchenghao.cn/15246.html

(0)
编程小号编程小号

相关推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注