iOS底层原理之启动优化(二):二进制重排 & Clang插桩

2,780 阅读13分钟

前言

前文iOS底层原理之启动优化(一):相关概念 & 优化方案简单介绍了启动相关的概念和一些优化的方案,本文将来介绍下pre-main阶段的优化方案,即二进制重排

探索二进制重排之前,先扩展点其他方面的概念。

一: Link Map File

1.1: 什么是Link Map File

Link Map File中文直译为链接映射文件,它是在Xcode生成可执行文件的同时生成的链接信息文件,用于描述可执行文件的构造部分,包括了代码段和数据段的分布情况。Xcode在生成可执行文件的时候默认情况下不生成该文件,需要开发者手动设置Target -> Build Setting -> Write Link Map FileYES

image.png

这里还可以设置Link Map File存放的位置:

// 默认的位置
$(TARGET_TEMP_DIR)/$(PRODUCT_NAME)-LinkMap-$(CURRENT_VARIANT)-$(CURRENT_ARCH).txt

// 例如:
/Users/baypac/Library/Developer/Xcode/DerivedData/LaunchTraceDemo-hbwolihehtukkzdwncleyihzvfrv/Build/Intermediates.noindex/LaunchTraceDemo.build/Debug-iphoneos/LaunchTraceDemo.build/LaunchTraceDemo-LinkMap-normal-arm64.txt

开发者也可以根据自己的需要自行设置该文件的位置。

1.2: Link Map File的组成

双击打开Link Map File,可以发现里面包含了如下几个部分:

1.2.1: Path

# Path: /Users/baypac/Library/Developer/Xcode/DerivedData/LaunchTraceDemo-hbwolihehtukkzdwncleyihzvfrv/Build/Products/Debug-iphoneos/LaunchTraceDemo.app/LaunchTraceDemo

生成的可执行文件的路径。

1.2.2: Arch

# Arch: arm64

生成的可执行文件的路径。

1.2.3: Object files

# Object files:
[  0] linker synthesized
[  1] /Users/baypac/Library/Developer/Xcode/DerivedData/LaunchTraceDemo-hbwolihehtukkzdwncleyihzvfrv/Build/Intermediates.noindex/LaunchTraceDemo.build/Debug-iphoneos/LaunchTraceDemo.build/Objects-normal/arm64/ViewController.o
[  2] /Users/baypac/Library/Developer/Xcode/DerivedData/LaunchTraceDemo-hbwolihehtukkzdwncleyihzvfrv/Build/Intermediates.noindex/LaunchTraceDemo.build/Debug-iphoneos/LaunchTraceDemo.build/Objects-normal/arm64/AppDelegate.o
[  3] /Users/baypac/Library/Developer/Xcode/DerivedData/LaunchTraceDemo-hbwolihehtukkzdwncleyihzvfrv/Build/Intermediates.noindex/LaunchTraceDemo.build/Debug-iphoneos/LaunchTraceDemo.build/Objects-normal/arm64/main.o
[  4] /Users/baypac/Library/Developer/Xcode/DerivedData/LaunchTraceDemo-hbwolihehtukkzdwncleyihzvfrv/Build/Intermediates.noindex/LaunchTraceDemo.build/Debug-iphoneos/LaunchTraceDemo.build/Objects-normal/arm64/SceneDelegate.o
[  5] /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.5.sdk/System/Library/Frameworks//Foundation.framework/Foundation.tbd
[  6] /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.5.sdk/usr/lib/libobjc.tbd
[  7] /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.5.sdk/System/Library/Frameworks//UIKit.framework/UIKit.tbd

列举出了可执行文件里所有的目标文件和动态库.tbd。每行前面为文件编号。

1.2.4: Sections

# Sections:
# Address	Size    	Segment	Section
0x100005F10	0x00000588	__TEXT	__text
0x100006498	0x00000090	__TEXT	__stubs
0x100006528	0x000000A8	__TEXT	__stub_helper
0x1000065D0	0x000000BC	__TEXT	__objc_methlist
0x10000668C	0x00000D8A	__TEXT	__objc_methname
0x100007416	0x00000070	__TEXT	__objc_classname
0x100007486	0x00000B0F	__TEXT	__objc_methtype
0x100007F95	0x00000016	__TEXT	__cstring
0x100007FAC	0x00000054	__TEXT	__unwind_info
0x100008000	0x00000008	__DATA_CONST	__got
0x100008008	0x00000020	__DATA_CONST	__cfstring
0x100008028	0x00000018	__DATA_CONST	__objc_classlist
0x100008040	0x00000020	__DATA_CONST	__objc_protolist
0x100008060	0x00000008	__DATA_CONST	__objc_imageinfo
0x10000C000	0x00000060	__DATA	__la_symbol_ptr
0x10000C060	0x000011D8	__DATA	__objc_const
0x10000D238	0x00000078	__DATA	__objc_selrefs
0x10000D2B0	0x00000010	__DATA	__objc_classrefs
0x10000D2C0	0x00000008	__DATA	__objc_superrefs
0x10000D2C8	0x00000004	__DATA	__objc_ivar
0x10000D2D0	0x000000F0	__DATA	__objc_data
0x10000D3C0	0x00000188	__DATA	__data

单从字面含义理解:每个Section包含了AddressSizeSegment以及Section。介绍之前,这里先简单介绍一下Mach-O文件。 上面第一部分的Path是可执行文件的路径,使用iTerm进去到该文件夹,然后使用file命令即可查看该文件的类型:

file LaunchTraceDemo

输出结果为:

LaunchTraceDemo: Mach-O 64-bit executable arm64

可以知道该文件是一个Mach-O格式的文件,它是iOS系统应用执行文件格式。Mach-O文件中的虚拟地址最终会被映射到物理地址上,这些地址会被分为不同的Segment(段)类型:__TEXT__DATA以及__LINKEDIT等。各个段的含义如下:

  1. __TEXT包含了被执行的代码。这些代码是只读、可执行。
  2. __DATA包含了将会被更改的数据,例如全局变量、静态变量等,可读写,但是不可执行。
  3. __LINKEDIT包含了加载程序的元数据,比如函数名称和地址,只读。

Segment又被划分成了不同的Section,不同的Section存储了不同的信息,例如__objc_methname为方法的名称,__objc_classlist为类列表。

1.2.5: Symbols

# Symbols:
# Address	Size    	File  Name
0x100005F10	0x00000048	[  1] -[ViewController viewDidLoad]
0x100005F58	0x0000007C	[  2] -[AppDelegate application:didFinishLaunchingWithOptions:]
0x100005FD4	0x00000100	[  2] -[AppDelegate application:configurationForConnectingSceneSession:options:]
0x1000060D4	0x00000074	[  2] -[AppDelegate application:didDiscardSceneSessions:]
0x100006148	0x0000009C	[  3] _main
0x1000061E4	0x0000009C	[  4] -[SceneDelegate scene:willConnectToSession:options:]
0x100006280	0x0000004C	[  4] -[SceneDelegate sceneDidDisconnect:]
0x1000062CC	0x0000004C	[  4] -[SceneDelegate sceneDidBecomeActive:]
0x100006318	0x0000004C	[  4] -[SceneDelegate sceneWillResignActive:]
0x100006364	0x0000004C	[  4] -[SceneDelegate sceneWillEnterForeground:]
0x1000063B0	0x0000004C	[  4] -[SceneDelegate sceneDidEnterBackground:]
0x1000063FC	0x00000024	[  4] -[SceneDelegate window]
0x100006420	0x0000003C	[  4] -[SceneDelegate setWindow:]
0x10000645C	0x0000003C	[  4] -[SceneDelegate .cxx_destruct]
0x100006498	0x0000000C	[  5] _NSStringFromClass
0x1000064A4	0x0000000C	[  7] _UIApplicationMain
0x1000064B0	0x0000000C	[  6] _objc_alloc
0x1000064BC	0x0000000C	[  6] _objc_autoreleasePoolPop
0x1000064C8	0x0000000C	[  6] _objc_autoreleasePoolPush
0x1000064D4	0x0000000C	[  6] _objc_autoreleaseReturnValue
0x1000064E0	0x0000000C	[  6] _objc_msgSend
0x1000064EC	0x0000000C	[  6] _objc_msgSendSuper2
0x1000064F8	0x0000000C	[  6] _objc_opt_class
0x100006504	0x0000000C	[  6] _objc_release
...

根据Sections的起始地址,可以将Symbols分为Sections个数的组,例如0x100005F100x100006498之间,就是__text代码区。 Symbols包含的信息有:

  1. Address:起始地址
  2. Size:所占内存大小,这里使用16进制表示。
  3. File:该Symbol所在的文件编号,也就是Object files部分的中括号的数字,例如-[ViewController viewDidLoad]对应的文件编号为1,根据Object files部分可以看到所属的文件为:ViewController.o
  4. Name就是该Sybmol的名称。

1.2.6: Dead Stripped Symbols

# Dead Stripped Symbols:
#        	Size    	File  Name
<<dead>> 	0x00000005	[  2] literal string: hash
<<dead>> 	0x0000000B	[  2] literal string: superclass
<<dead>> 	0x0000000C	[  2] literal string: description
<<dead>> 	0x00000011	[  2] literal string: debugDescription
<<dead>> 	0x00000007	[  2] literal string: window
<<dead>> 	0x00000009	[  4] literal string: NSObject
...

链接器认为无用的符号,链接的时候不会记入。

上面便是对Link Map File简单的介绍。

二: 二进制重排原理

经过前文介绍虚拟内存后,可以知道当访问一页虚拟内存,而此页虚拟内存与物理内存没有建立映射关系时,就会发生缺页异常Page Fault,或缺页中断),从而阻塞进程。此时就需要先将数据加载到物理内存,建立映射关系,然后继续访问。Page Fault导致的进程阻塞虽然本身为毫秒级别,可以忽略不计,但是在冷启动时,有大量类、分类、三方库等需要加载,此时就会产生大量缺页异常,势必会影响启动速度。下面重签名微信,查看下微信启动时的Page Fault次数(重签名此处不作赘述)。

  • 自建项目使用脚本重签名微信安装好之后,同时按住Command + i,打开Instruments,双击打开System Trace

image.png

  • 点击Record(冷启动需要重启手机,清除物理内存里的缓存数据),待第一个界面呈现之后,点击Stop,然后按图中流程搜索Main Thread查看虚拟内存的Page Fault次数。

image.png

从图中可知,此次启动,Page Fault次数高达4000+次(而且还是非冷启动,冷启动Page Fault次数会更高),耗时636.31ms,相当影响启动速度。

接下来我们再来看一下Link Map File(链接映射文件)中Symbols的信息。

  • 修改Build Setting -> Write Link Map FileYES

image.png

  • Command + B编译项目,然后找到项目对应的.app应用包,Show in Finder,然后如图所示找到link map文件。

image.png image.png

  • 打开link map文件,可以发现符号(OC方法,C/C++函数等)是按类文件Build Phases -> Compile Sources里的编译顺序和文件内部的书写顺序进行排列的。

image.png

  • AppDelegate.m文件的编译顺序移到第一位,并添加两个方法,来验证一下。

image.png

  • Command + B再次编译项目,查看link map文件,可以发现经过修改之后,符号顺序也随之改变了。

image.png

从上面Page Fault和符号排列顺序的案例,我们是否可以有一个猜想:微信启动时需要加载4000+ Page的内存数据,但可能并不是每页的所有数据都是启动时必须要用到的。

那么可以得出:启动时Page Fault次数过多的根本原因是启动时需要调用的符号(OC方法,C/C++函数等)处于不同Page导致的。因此,我们的优化思路是将启动时需要调用的符号集中排列到前面,组成启动所需的若干Page。这样就避免了启动时需要调用的符号过于分散导致大量Page Fault的问题,减少了大量Page Fault的次数,提升了启动速度。这就是二进制重排的核心原理。

三: 二进制重排实践

前面已经了解了二进制重排的相关原理,我们可以生成一个.order的文件,并将路径配置到Build Setting -> Order Files中,将所需要的符号按照顺序写入文件里面,编译时编译器会按照文件里的符号顺序对二进制进行重排,以此来达到优化的效果。所以,二进制重排的本质就是对启动加载的符号进行重新排列

现在优化方案有了,如果项目较小,可以按照想要的启动流程将符号顺序添加到.order文件中(小项目不建议进行二进制重排,可能达不到启动优化的效果),但是如果项目较大,启动时涉及的符号特别多,此时我们如何获取启动时调用的所有符号呢?有以下几种思路:

  1. hook objc_msgSendOC方法的本质是发送消息,在底层都会调用objc_msgSend,但是objc_msgSend参数是可变的,需要通过汇编获取,对开发人员要求较高,而且也只能获取到OCSwift@objc后的方法。

  2. 静态扫描:扫描Mach-O特定段和节里面所存储的符号以及符号数据。

  3. Clang插桩:可以实现100%符号覆盖,即完全获取C/C++、Swift、OC、block相关符号。

3.1: Clang插桩

llvm内置了一个简单的代码覆盖率检测(SanitizerCoverage)。它在函数级、基本块级和边缘级插入对用户定义函数的调用。我们这里的批量hook,就需要借助于SanitizerCoverage

关于clang的插桩覆盖的官方文档如下 : clang 自带代码覆盖工具 文档中有详细概述,以及简短Example演示。

3.1.1: 第一步:配置

配置参数,开启SanitizerCoverage

  • OC项目需要在Build Setting -> Other C Flags添加-fsanitize-coverage=func,trace-pc-guard

image.png

  • Swift项目还需要额外在Build Setting -> Other Swift Flags中加入-sanitize-coverage=func-sanitize=undefined

image.png

  • 如果想完全覆盖包括三方库的所有调用,则链接到APP中的二进制都需要开启SanitizerCoverage。也可以通过podfile来配置参数。
post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['OTHER_CFLAGS'] = '-fsanitize-coverage=func,trace-pc-guard'
      config.build_settings['OTHER_SWIFT_FLAGS'] = '-sanitize-coverage=func -sanitize=undefined'
    end
  end
end

3.1.2: 第二步:重写回调函数

新建一个OC文件XJOrderCallback,重写两个回调函数。

  • __sanitizer_cov_trace_pc_guard_init函数

    • startstop是指针,指向unsigned int类型,4个字节,对应的是可执行文件的beginendExamplefor循环将符号的数量存在里面),stop - 0x4stop本身为结束标记)获取最后一个数据,就是项目符号数量(不包括三方库等,此处三方库未开启SanitizerCoverage)。

    image.png

    • 添加两个符号(一个函数,一个block),再次查看stop

    image.png

  • __sanitizer_cov_trace_pc_guard函数。

    • 只要开启了SanitizerCoverage,编译器就会在所有符号的代码实现的“边缘”添加这个函数的调用。 image.png
    • 此函数中可以获取所有启动时的符号地址,定义节点,借助链表存储起来。
      • 通过OSQueueHead创建原子队列,其目的是保证读写安全。

      • 定义链表节点XJNode

      • 通过OSAtomicEnqueue方法将node入队,通过链表的next指针访问下一个node

//原子队列,其目的是保证写入安全,线程安全
static  OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;
// 定义符号结构体,以链表的形式
typedef struct {
    void *pc;
    void *next;
}XJNode;

/*
 `stop - 0x4`(`stop`本身为结束标记)获取最后一个数据,就是项目符号数量
 */
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
                                                    uint32_t *stop) {
    static uint64_t N;
    if (start == stop || *start) return;
    printf("INIT: %p - %p\n", start, stop);
    for (uint32_t *x = start; x < stop; x++) {
        *x = ++N;
    }
}

/*
 可以hook所有符号
 */
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
//    if (!*guard) return; // 将load方法过滤掉了,所以需要注释掉
    
    // 获取PC
    /*
     - PC 当前函数返回上一个调用的地址
     - 0 当前这个函数地址,即当前函数的返回地址
     - 1 当前函数调用者的地址,即上一个函数的返回地址
    */
    void *PC = __builtin_return_address(0);
    // 创建node,并赋值
    XJNode *node = malloc(sizeof(XJNode));
    *node = (XJNode){PC, NULL};
    
    // 入队
    // 符号的访问不是通过下标访问,是通过链表的next指针,所以需要借用offsetof(结构体类型,下一个的地址即next)
    OSAtomicEnqueue(&symbolList, node, offsetof(XJNode, next));
}

3.1.3: 第三步:获取启动时所有符号,并写入文件

  • while循环从队列中取出node,转换成符号,非OC方法的符号添加前缀,存入数组。

  • 数组取反,因为入队存储的顺序是反序的。

  • 数组去重,并移除当前函数的符号。

  • 将数组转换成字符串并写入到xj.order文件。

extern void getOrderFile(void(^completion)(NSString *orderFilePath)) {
    
    __sync_synchronize();
    // 当前函数的符号
    NSString *functionExclude = [NSString stringWithFormat:@"_%s", __FUNCTION__];
    // 创建符号数组
    NSMutableArray<NSString *> *symbolNames = [NSMutableArray array];

    // while循环取符号
    while (YES) {
        // 出队,出一个,少一个
        XJNode *node = OSAtomicDequeue(&symbolList, offsetof(XJNode, next));
        if (node == NULL) break;

        // 取出地址PC,转换成Dl_info
        Dl_info info;
        dladdr(node->pc, &info);
//            printf("%s \n", info.dli_sname);

        if (info.dli_sname) {
            // 判断是不是OC方法,如果不是,需要加下划线存储,反之,则直接存储
            NSString *name = @(info.dli_sname);
            BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
            NSString *symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
            [symbolNames addObject:symbolName];
        }

    }

    if (symbolNames.count == 0) {
        if (completion) {
            completion(nil);
        }
        return;
    }

    // 取反(队列的存储是反序的)
    NSEnumerator *emt = [symbolNames reverseObjectEnumerator];

    // 去重
    NSMutableArray<NSString *> *funcs = [NSMutableArray arrayWithCapacity:symbolNames.count];
    NSString *name;
    while (name = [emt nextObject]) {
        if (![funcs containsObject:name]) {
            [funcs addObject:name];
        }
    }

    // 去掉自己
    [funcs removeObject:functionExclude];

    // 将数组变成字符串
    NSString *funcStr = [funcs componentsJoinedByString:@"\n"];
    NSLog(@"Order:\n%@", funcStr);

    // 字符串写入文件
    NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"XJ.order"];
    NSData *fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    BOOL success = [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
    if (completion) {
        completion(success ? filePath : nil);
    }
}

3.1.4: 第四步:启动完成之后调用

需要注意的是,调用位置是由你决定的。一般来说为首界面渲染完成之时。

image.png

image.png

注意:如果Other C Flags配置的是-fsanitize-coverage=trace-pc-guard的话,会产生死循环,因为对while循环也进行了拦截,每次循环都会将while循环所在的符号入队一次,所以链表里面永远不为空,就造成了死循环。所以请务必使用-fsanitize-coverage=func,trace-pc-guard,表示只对func进行拦截。

此时xj.order中的符号列表为:

image.png

3.1.5: 第五步:拷贝文件,放入指定位置,并配置路径

  • 本文生成order文件指定的是真机的沙盒目录,所以Xcode -> Devices and Simulators选择运行的设备下载沙盒文件。

image.png

  • 然后在工程目录下找到沙盒文件,右键选择显示包内容,然后根据路径找到xj.order文件。

image.png

image.png

  • xj.order拷贝到工程根目录下。

image.png

  • Build Settings -> Order File中配置./xj.orderimage.png

  • 下面是配置前后的Link Map File对比(上边是配置前的符号顺序,下边是配置后的符号顺序)。

image.png

根据前后的Link Map File对比,可以发现可执行文件确实根据我们的配置进行了二进制重排。