二、APP的加载过程

Reference:
iOS 底层 - 从头梳理 dyld 加载流程
iOS进阶 – 程序启动那些事
Dyld系列之一:_dyld_start之前
DYLD动态链接器
dyld与ObjC
从 dyld 到 runtime

Executable

APP的加载过程就是APP中可执行文件的加载过程,了解这个加载过程之前需要先了解iOS中的可执行文件。MacOS中可以使用chmod+x命令来赋予一个文件可执行权限,但实际上可以执行的文件只有两类,第一类是脚本文件,第二类是可执行二进制文件,可执行二进制文件又分为Mach-O和Fat Binary,它们之间的区别如下:

  • 脚本文件:脚本文件是以#!开头的文本文件,执行时会由shell根据#!后面指明的地址,找到脚本解释器,然后由脚本解释器,对文件中的脚步代码,进行解释执行;
  • Mach-O:Mach-O是以魔数0xfeedface(32位) 或0xfeedfacf(64位)作为文件头的二进制文件,Mach-O有多种type,可执行文件的type是Executable(MH_EXECUTE),运行一个可执行的Mach-O时,系统会fork一个新进程,然后执行execve调用,解析Mach-O,执行其中的的Load Commands,启动动态链接器dyld,链接主程序与依赖库,最终得到一个新的进程镜像,在内存中运行;
  • Fat Binary:Fat Binary就是针对不同芯片架构的Mach-O(Executable)的集合,是以魔数0xcafebabe(小端)或0xbebafeca(大端)为文件头的二进制文件,执行时根据Fat Header找到当前架构的Mach-O,再解析并加载对应的Mach-O;

iOS中的App在不使用BitCode的情况下,其内部的二进制文件是一个Fat Binary,使用BitCode时,App中是与设备的芯片架构所对应的Mach-O,它们在App启动时,最终都是解析并加载一个Mach-O。

Fat Binary的大端模式和小端模式:大端模式,是指数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址中;小端模式,则是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中。

Mach-O

Mach-O的文件结构如图:

Mach-O主要由Header、Load Commands和Data所构成:

  • Header:包含魔数、芯片类型与子类型、文件类型、加载命令数量与大小、动态链接器标识、字节顺序等信息;
  • Load Commands:程序的加载命令,包括段与进程地址的映射、指定动态链接器、指明需要动态链接的库等等;
  • Data:程序的原始数据段,包括_TEXT、_DATA等,加载时被映射到进程的内存空间中。

Load Commands

Mach-O中Load Commands部分是程序的加载命令,这些加载命令在内核在执行execve调用时,完成对Mach-O的文件解析之后,有的由内核直接执行,有的则由动态链接器处理,这部分可以参考文章—Mach-O文件介绍之loadcommand,下面列出一些常见的加载命令及其含义:

命令 含义
LC_SEGMENT 将数据段映射到地址空间中
LC_DYLD_INFO_ONLY 动态链接相关信息
LC_SYMTAB 符号表地址
LC_DYSYMTAB 动态符号表地址
LC_LOAD_DYLINKER 动态链接器
LC_UUID 文件的唯一标识
LC_VERSION_MIN_IPHONEOS 支持的最低iOS版本
LC_SOURCE_VERSION 源码版本
LC_MAIN 设置进程的主线程入口地址和栈大小
LC_ENCRPYTION_INFO 加密信息
LC_LOAD_DYLIB 加载动态库
LC_LOAD_WEAK_DYLIB 弱加载动态库(不因库不存在而中止)
LC_FUNCTION_STARTS 函数起始地址表
LC_CODE_SIGNATURE 代码签名信息

Dyld

Executable类型的Mach-O的Load Commands中通过LC_LOAD_DYLINKER指明了程序的动态链接器,MacOS/iOS中默认的动态链接器是dyld(/usr/lib/dyld),dyld主要负责将主程序及其依赖库加载为镜像文件(images),并链接这些镜像文件,dyld的代码是开源的,本文中引用的源码是732.8版本。

_dyld_start

dyld本身也是一个Mach-O,类型为MH_DYLINKER,在execve的调用中,完成主程序Mach-O的解析后,根据其中的LC_LOAD_DYLINKER,由内核解析并加载dyld,根据dyld的LC_UNIXTHREAD得到dyld的entry_point,即_dyld_start函数,然后通过_dyld_start执行dyld程序,开始dyld的工作。

从execve到_dyld_start的函数调用栈大致如下:

1
2
3
4
5
6
7
8
9
10
▼ execve    // 启动app时, 用户态发送一个系统调用execve到内核
▼ __mac_execve // 创建线程
▼ exec_activate_image
▼ exec_mach_imgact
▼ load_machfile
▶︎ parse_machfile // 解析主程序的Mach-O
▼ load_dylinker // 解析完Mach-O后,根据LC_LOAD_DYLINKER所指定的地址(/usr/bin/dyld)启动动态链接器
▼ parse_machfile // 解析dyld的Mach-O,根据它的LC_UNIXTHREAD得到dyld的entry_point,即_dyld_start
▼ activate_exec_state
▶︎ thread_setentrypoint // 设置entry_point,开始执行_dyld_start

start

_dyld_start中调用了start函数,start中主要是完成一些dyld的引导工作,包括rebaseDyld、栈溢出保护、获取主程序的ASLR随机数等,在start的末尾调用并返回了_main函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
uintptr_t start(const dyld3::MachOLoaded* appsMachHeader, int argc, const char* argv[],
const dyld3::MachOLoaded* dyldsMachHeader, uintptr_t* startGlue)
{
dyld3::kdebug_trace_dyld_marker(DBG_DYLD_TIMING_BOOTSTRAP_START, 0, 0, 0, 0);

rebaseDyld(dyldsMachHeader);

const char** envp = &argv[argc+1];

const char** apple = envp;
while(*apple != NULL) { ++apple; }
++apple;

__guard_setup(apple);

#if DYLD_INITIALIZER_SUPPORT
// run all C++ initializers inside dyld
runDyldInitializers(argc, argv, envp, apple);
#endif

uintptr_t appsSlide = appsMachHeader->getSlide();
return dyld::_main((macho_header*)appsMachHeader, appsSlide, argc, argv, envp, apple, startGlue);
}

_main

内核通过_dyld_start开始执行dyld程序,再调用start函数完成引导工作,最后调用_main函数开始执行dyld程序,_mian函数是dyld实际的逻辑入口,源码中对于_main函数的注释如下:

1
2
3
4
5
6
//
// Entry point for dyld. The kernel loads dyld and jumps to __dyld_start which
// sets up some registers and call this function.
//
// Returns address of main() in target program which __dyld_start jumps to
//

_main中所做的工作较多,大体上可以分为以下几步:

1. 加载共享缓存库

在加载共享缓存库之前,_main函数中会先设置运行环境,设置运行环境的工作包括包括:

  • 配置环境变量,将传入的参数赋值给一些环境变量;
  • 设置上下文信息setContext,包括一些回调函数、参数、标志信息等;
  • 检测进程是否受限configureProcessRestrictions
  • 根据环境变量配置打印信息DYLD_PRINT_OPTSDYLD_PRINT_ENV
  • 获取程序的架构信息getHostInfo

在设置了运行环境之后,_mian函数中通过mapSharedCache再调用了loadDyldCache函数,已确保共享缓存库已被加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
bool loadDyldCache(const SharedCacheOptions& options, SharedCacheLoadInfo* results)
{
results->loadAddress = 0;
results->slide = 0;
results->errorMessage = nullptr;

#if TARGET_OS_SIMULATOR
// simulator only supports mmap()ing cache privately into process
return mapCachePrivate(options, results);
#else
if ( options.forcePrivate ) {
// mmap cache into this process only
return mapCachePrivate(options, results);
}
else {
// fast path: when cache is already mapped into shared region
bool hasError = false;
if ( reuseExistingCache(options, results) ) {
hasError = (results->errorMessage != nullptr);
} else {
// slow path: this is first process to load cache
hasError = mapCacheSystemWide(options, results);
}
return hasError;
}
#endif
}

共享缓存库:实际上就是一个iOS系统动态库的集合,iOS中的系统动态库可以被不同的App所共享,所以系统动态库只需要在第一个App使用它时被加载,后续的App使用时,使用它第一次加载后在内存中的缓存即可,但每个App使用到的系统动态库较多,如果使用到的系统动态库需要逐个的去判断并加载的话,对App的启动速度依然有较大的影响,于是iOS中便将常用的系统动态库都合并到了一个较大的缓存文件中,这样所有App对系统动态库的使用,都只需要确保这个缓存文件已经被加载到了内存中即可,这个缓存文件就是共享缓存库,关于共享缓存库可以参考链接

共享缓存区与共享缓存库:动态库又被称为共享库,它们会被加载到内存的共享缓存区,供不同的进程共享,共享缓存库是常用系统动态库的集合,它也会被加载到共享缓存区,供所有的进程所共享,但共享缓存区不止共享缓存库,它还可能存在App的Embedded Framework,Embedded Framework不能与外部的App共享,但可以与自己的Extension等共享。

2. 实例化主程序

App使用到的二进制可执行文件都需要被加载、解析并实例化为内存镜像,每一个Mach-O都对应一个镜像实例。实例化主程序就是生成主程序的镜像,主程序二进制文件的读取和解析是在execve中完成的,然后作为参数传递给dyld的,在dyld的_main中通过instantiateFromLoadedImage将主程序实例化为镜像,再添加到全局的镜像列表中。

1
2
3
4
5
6
7
8
9
10
11
static ImageLoaderMachO* instantiateFromLoadedImage(const macho_header* mh, uintptr_t slide, const char* path)
{
// try mach-o loader
if ( isCompatibleMachO((const uint8_t*)mh, path) ) {
ImageLoader* image = ImageLoaderMachO::instantiateMainExecutable(mh, slide, path, gLinkContext);
addImage(image);
return (ImageLoaderMachO*)image;
}

throw "main executable not a known format";
}

3. 加载插入库

这一步是将环境变量DYLD_INSERT_LIBRARIES中配置的动态库加载到内存中,先判断环境变量DYLD_INSERT_LIBRARIES中是否有需要插入的动态库,如果有存在则调用loadInsertedDylib来依次加载,利用DYLD_INSERT_LIBRARIES环境变量是MacOS和iOS上非常知名的一种注入技术,通常越狱插件就是基于此来注入到应用中。

1
2
3
4
5
// load any inserted libraries
if ( sEnv.DYLD_INSERT_LIBRARIES != NULL ) {
for (const char* const* lib = sEnv.DYLD_INSERT_LIBRARIES; *lib != NULL; ++lib)
loadInsertedDylib(*lib);
}

Inserted Libraries与Embedded Framework:iOS的App在启动时涉及三类动态库,首先是System Libraries(系统动态库),它们被合并到共享缓存库中,在加载到手机内存之后,由所有App所共享;然后便是Inserted Libraries,它们在启动过程中主程序实例化之后,根据DYLD_INSERT_LIBRARIES环境变量依次进行加载,越狱设备中安装的应用插件通常就是这类动态库;最后便是Embedded Framework,它们是由开发者创建的,属于App自己的动态库,iOS8之前的iOS App是不能有自己的动态库的,在iOS8之后为了满足App和Extension之间代码共享的需求,于是便有了Embedded Framework的概念,它们不能与外部App共享,在App启动过程中链接主程序时,根据LC_LOAD_DYLIB命令和依赖关系递归的加载到内存中。除了这些启动时加载的动态库,MacOS的App还可以通过dlopen()在运行时加载动态库插件,但这在iOS的App提交审核时是被禁止的。

4. 链接镜像

_main函数中调用link来链接程序的镜像,启动时先链接主程序再链接插入库,通常link函数链接一个镜像时有三项主要的工作:递归的加载依赖库(App的Embedded Framework的实例化)、递归的Rebase和递归的Binding,这里732.8版本的源码与更早版本源码的做法有些许差异,它将主程序和插入库链接时的Binding先暂时跳过了,放在了主程序和插入库都链接完成之后再进行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void ImageLoader::link(const LinkContext& context, bool forceLazysBound, bool preflightOnly, bool neverUnload, const RPathChain& loaderRPaths, const char* imagePath)
{
...
uint64_t t0 = mach_absolute_time();
uint64_t t0 = mach_absolute_time();
// 1. 递归的加载镜像的依赖库
this->recursiveLoadLibraries(context, preflightOnly, loaderRPaths, imagePath);
context.notifyBatch(dyld_image_state_dependents_mapped, preflightOnly);
...
uint64_t t1 = mach_absolute_time();
context.clearAllDepths();
// 递归刷新依赖库的层级
this->recursiveUpdateDepth(context.imageCount());

__block uint64_t t2, t3, t4, t5;
{
dyld3::ScopedTimer(DBG_DYLD_TIMING_APPLY_FIXUPS, 0, 0, 0);
// 递归的进行Rebase
t2 = mach_absolute_time();
this->recursiveRebaseWithAccounting(context);
context.notifyBatch(dyld_image_state_rebased, false);

// 递归的进行Binding(主程序和插入库link时会跳过)
t3 = mach_absolute_time();
if ( !context.linkingMainExecutable )
this->recursiveBindWithAccounting(context, forceLazysBound, neverUnload);

// 递归的进行弱绑定(主程序和插入库link时会跳过)
t4 = mach_absolute_time();
if ( !context.linkingMainExecutable )
this->weakBind(context);
t5 = mach_absolute_time();
}
...
}

Rebase:为了增加攻击者对程序内部地址预测的难度,程序在加载到内存中时会有一个随机的地址偏移值,这项安全技术被称为ASLR(iOS中的ASLR可参考链接),因为这个随机偏移的存在,在程序每次加载到内存后都需要对内部代码中的地址做修正,启动过程中这步操作被称为Rebase,它是修复指向当前镜像内部的地址。

5. 符号绑定

符号绑定包括binding和weak binding,先进行binding,再是weak binding,从源码中可以看出Binding的耗时统计并不包括weak binding:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Bind and notify for the main executable now that interposing has been registered
uint64_t bindMainExecutableStartTime = mach_absolute_time();
sMainExecutable->recursiveBindWithAccounting(gLinkContext, sEnv.DYLD_BIND_AT_LAUNCH, true);
uint64_t bindMainExecutableEndTime = mach_absolute_time();
ImageLoaderMachO::fgTotalBindTime += bindMainExecutableEndTime - bindMainExecutableStartTime;
gLinkContext.notifyBatch(dyld_image_state_bound, false);

// Bind and notify for the inserted images now interposing has been registered
if ( sInsertedDylibCount > 0 ) {
for(unsigned int i=0; i < sInsertedDylibCount; ++i) {
ImageLoader* image = sAllImages[i+1];
image->recursiveBind(gLinkContext, sEnv.DYLD_BIND_AT_LAUNCH, true);
}
}

// do weak binding only after all inserted images linked
sMainExecutable->weakBind(gLinkContext);

Binding: 通常一个程序中都会包含对外部地址的引用,比如对动态库的使用,动态库不会在编译时被合并到程序中,运行时有自己的地址空间,所以无法在编译时就确定对其相关引用的具体地址,于是就有了PIC(Position Independent Code)技术:程序中指向外部的地址,在编译时先被设置为符号地址,在加载时再根据外部镜像的符号表等信息,重新把符号地址绑定为实际的地址,启动过程中符号地址重新绑定这步操作被称为Binding,它是绑定指向当前镜像外部的地址。

6. 初始化主程序

_main函数调用initializeMainExecutable函数来初始化程序,初始化时先初始化插入库中的根镜像,再初始化主程序。镜像通过调用recursiveInitialization方法进行初始化,recursiveInitialization中会根据依赖关系递归的先执行依赖库的初始化工作。

Xcode中可以通过配置环境变量DYLD_PRINT_INITIALIZERS=1来打印各个依赖库的初始化方法及其调用顺序,可以发现libSystem的初始化方法始终是最先调用的。

Objc_init

程序中镜像在初始化时会递归的先进行依赖库的初始化工作,从dyld的源码中可以得知,在没有插入库的情况下,libsystem通常会是第一个进行初始化的库,而libsystem的初始化方法中则调用了runtime的初始化方法_objc_init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void _objc_init(void)
{
static bool initialized = false;
if (initialized) return;
initialized = true;

// fixme defer initialization until an objc-using image is found?
environ_init();
tls_init();
static_init();
lock_init();
exception_init();

_dyld_objc_notify_register(&map_images, load_images, unmap_image);
}

_objc_init中调用dyld的_dyld_objc_notify_register注册了三个回调方法,来接收dyld加载镜像相关事件的回调,以完成对每个镜像中的Objc相关部分的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void _dyld_objc_notify_register(_dyld_objc_notify_mapped    mapped,
_dyld_objc_notify_init init,
_dyld_objc_notify_unmapped unmapped)
{
dyld::registerObjCNotifiers(mapped, init, unmapped);
}

void registerObjCNotifiers(_dyld_objc_notify_mapped mapped, _dyld_objc_notify_init init, _dyld_objc_notify_unmapped unmapped)
{
// record functions to call
sNotifyObjCMapped = mapped;
sNotifyObjCInit = init;
sNotifyObjCUnmapped = unmapped;

// call 'mapped' function with all images mapped so far
try {
notifyBatchPartial(dyld_image_state_bound, true, NULL, false, true);
}
catch (const char* msg) {
// ignore request to abort during registration
}

// call 'init' function on all images already init'ed (below libSystem)
for (std::vector<ImageLoader*>::iterator it=sAllImages.begin(); it != sAllImages.end(); it++) {
ImageLoader* image = *it;
if ( (image->getState() == dyld_image_state_initialized) && image->notifyObjC() ) {
dyld3::ScopedTimer timer(DBG_DYLD_TIMING_OBJC_INIT, (uint64_t)image->machHeader(), 0, 0);
(*sNotifyObjCInit)(image->getRealPath(), image->machHeader());
}
}
}

关于处理镜像中Objc相关信息的三个回调方法:

  • map_images:在镜像完成加载(即加载进内存中)后被调用,通过_read_images函数完成对镜像中Objc的类、协议、类别等的初始化工作;
  • load_images:在镜像的初始化完成后被调用,主要是调用镜像中各种Objc类的+load方法,完成Objc类的加载;
  • unmap_image:在移除镜像时被调用,主要是做镜像的Objc相关信息的清理工作。

libsystem的初始化时调用_objc_init才注册Objc的处理回调方法,这时主程序和插入库,以及它们依赖库的镜像都已经加载到内存中,所以在**注册回调的方法时,就触发了一次map_images来批量的初始化已加载镜像中的Objc信息,然后也循环的触发了load_images,来加载那些在libsystem初始化之前就已经随镜像载入内存中的Objc类。

7. 返回主程序入口

最后dyld的_main函数会根据不同的情况,查找到对应的主程序入口(通常是主程序中的main函数地址)作为返回值返回,这个值最终会返回给一开始fork创建的进程,进程据此跳转到主程序入口继续运行,主程序中的代码便开始执行。

-------------This article is over, thank you for reading -------------