草庐IT

iOS 启动优化(二)二进制重排

kalpa_shock 2023-09-21 原文

App启动分析

App启动分析

App启动分为 冷启动热启动

  • 冷启动:点击 App 启动前,它的进程不在系统里,需要系统新创建一个进程分配给它的情况。这是一次完整的启动过程
  • 热启动:App 在冷启动后,用户将App 退到后台,即在App的进程还在系统里的情况下,用户重新启动进入 App 的过程,这个过程做的事情非常少,启动速度非常快。

因此,我们主要针对 App 冷启动进行优化

一般而言,App 启动时间,指的是从用户点击 App 开始,到用户看到第一个界面之间的时间,总结来说:App 的启动主要包括三个阶段:

  1. main() 函数执行前
  2. main() 函数执行后
  3. 首屏渲染完成后

1、pre-main耗时检测
通过设置环境变量来统计 pre-main 的耗时

选择 `Edit Scheme` - `Arguments` - `Environment Variables`

添加 name `DYLD_PRINT_STATISTICS` value : `${DEBUG_ACTIVITY_MODE}`
启动时间检测日志.png

可见,在 main() 函数执行前,系统主要会做下面几件事情:

  • dylib loading:加载可执行文件(App 的.o 文件的集合),加载动态链接库
  • rebase/binding:对动态链接库进行 rebase 指针调整和 bind 符号绑定;
  • Objc setup:Objc 运行时的初始化处理,包括 Objc 相关类的注册、category 注册、selector 唯一性检查等;
  • initializer:初始化,包括了执行 +load() 方法、attribute((constructor)) 修饰的函数的调用、创建 C++ 静态全局变量。

相应地,这个阶段对于启动速度优化来说,可以做的事情包括:

  • 减少动态库加载:每个库本身都有依赖关系,苹果公司建议使用更少的动态库,并且建议在使用动态库的数量较多时,尽量将多个动态库进行合并。数量上,苹果公司建议最多使用 6 个非系统动态库
  • 减少加载启动后不会去使用的类或者方法。
  • +load()方法里的内容可以放到首屏渲染完成后再执行,或使用 +initialize()方法替换掉。因为,在一个 +load() 方法里,进行运行时方法替换操作会带来 4 毫秒的消耗。不要小看这 4 毫秒,积少成多,执行 +load() 方法对启动速度的影响会越来越大。
  • 控制 C++ 全局变量的数量。
launch.png

当我们做了以上工作,对 pre-main 的时间有所优化之后,如果还想再进行优化,那就需要使用 LLVM 为我们提供的优化方式:二进制重排

Page Fault

进程如果能直接访问物理内存无疑是很不安全的,所以操作系统在物理内存的上又建立了一层虚拟内存。为了提高效率和方便管理,又对虚拟内存和物理内存又进行分页(Page)。当进程访问一个虚拟内存Page而对应的物理内存却不存在时,会触发一次缺页中断(Page Fault),分配物理内存,有需要的话会从磁盘mmap读人数据。

通过App Store渠道分发的App,Page Fault还会进行签名验证,所以一次Page Fault的耗时比想象的要多


PageFault.png

LinkMap

LinkMap 是iOS编译过程的中间产物,记录了二进制文件的布局,需要在Xcode的Build Settings里开启Write Link Map File:

MapLink.png

Path to Link Map File:
选中编译后的 app,Show In Finder -- 找到build目录 -- 具体路径如下:
Build/Intermediates.noindex/Spirit.build/Debug-iphonesimulator/Spirit.build/Spirit-LinkMap-normal-x86_64.txt

LinkMap 主要包括三大部分:

  • Object Files 生成二进制用到的link单元的路径和文件编号
  • Sections 记录Mach-O每个Segment/section的地址范围
  • Symbols 按顺序记录每个符号的地址范围
引入文件顺序.png
链接函数顺序.png

通过MapLink就可以看到链接的函数的顺序和引用的文件顺序是一致的

重排

编译器在生成二进制代码的时候,默认按照链接的Object File(.o)顺序写文件,按照Object File内部的函数顺序写函数。

静态库文件.a就是一组.o文件的ar包,可以用ar -t查看.a包含的所有.o。

问题分析:假设我们只有两个 page:page1/page2,其中绿色的method1 和 method3 启动时候需要调用,为了执行对应的代码,系统必须进行两个 Page Fault。

PageFaultFlowOne.png

但如果我们把 method1 和 method3 排布到一起,那么只需要一个Page Fault 即可,这就是二进制文件重排的核心原理

PageFaultFlowTwo.png

为了完成重排,有以下几个问题要解决:

  • 重排效果怎么样 - 获取启动阶段的page fault次数
  • 重排成功了没 - 拿到当前二进制的函数布局
  • 如何重排 - 让链接器按照指定顺序生成Mach-O
  • 重排的内容 - 获取启动时候用到的函数

获取启动阶段的page fault次数

System Trace

日常开发中性能分析是用最多的工具无疑是Time Profiler,但Time Profiler是基于采样的,并且只能统计线程实际在运行的时间,而发生Page Fault的时候线程是被blocked,所以我们需要用一个不常用但功能却很强大的工具:System Trace

SystemTraceIDE.png

选中主线程,在VM Activity中的File Backed Page In次数就是Page Fault次数,并且双击还能按时序看到引起Page Fault的堆栈:

File Backed Page In 即为 Page Fault 的个数

拿到当前二进制的函数布局

可以通过Map Link 获取到当前的二进制函数布局表

让链接器按照指定顺序生成Mach-O

ld

Xcode 使用的链接器件是ld,ld有一个不常用的参数 -order_file,通过man ld可以看到详细文档:

Alters the order in which functions and data are laid out.For each section in the output file, any symbol in that sec-tion that are specified in the order file file is moved to the start of its section and laid out in the same order as in the order file file. Order files are text files with one symbol name per line. Lines starting with a # are comments. A symbol name may be optionally preceded with its object file leaf name and a colon (e.g. foo.o:_foo). This is useful for static functions/data that occur in multiple files. A symbol name may also be optionally preceded with the architecture (e.g. ppc:_foo or ppc:foo.o:_foo). This enables you to have one order file that works for multiple architectures. Lit-eral c-strings may be ordered by by quoting the string (e.g."Hello, world\n") in the order file.

可以看到,order_file中的符号会按照顺序排列在对应section的开始,完美的满足了我们的需求。

Xcode的GUI也提供了order_file选项


orderFileXcode.png

Xcode 的连接器 ld 默认忽略 order file不存在的方法
如果在 Other Linker Flags: Debug 中添加-order_file_statistics,会以 warning 的形式把这些没找到的符号打印在日志里

获取启动调用的函数符号

Clang官方文档

  • LLVM支持我们在添加编译选项 -fsanitize-coverage=trace-pc-guard 的时候,编译时帮我们在函数中插入__sanitizer_cov_trace_pc_guard,当函数调用的时候,会callq__sanitizer_cov_trace_pc_guard
  • 利用 __builtin_return_address(0) 来获得当前函数返回地址,也就是调用方的地址。
  • 通过 dladdr 来将指针解析成 Dl_info 结构体信息,其中dli_sname 就是符号的名称

简单来说 SanitizerCoverage 是 Clang 内置的一个代码覆盖工具。它把一系列以 __sanitizer_cov_trace_pc_ 为前缀的函数调用插入到用户定义的函数里,借此实现了全局 AOP 的大杀器。其覆盖之广,包含 Swift/Objective-C/C/C++ 等语言,Method/Function/Block 全支持。

Build Settings:

在App 的 Target - Build Settings - Other C Flags Debug 添加 -fsanitize-coverage=func,trace-pc-guard

OC - Swift 混编,则在 Other Swift Flags Debug 添加 -sanitize-coverage=func-sanitize=undefined

Cocoapods 管理的第三方库 可以通过Pod提供的hook来修改所有的pod库的编译选项

代码如下: 在hock方法 post_install 里面做修改配置的操作

post_install do |installer| 
    # 二进制重排设置
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      if config.name == 'Debug'
        config.build_settings['OTHER_CFLAGS'] ||= '$(inherited)'
        config.build_settings['OTHER_CFLAGS'] << ' '
        config.build_settings['OTHER_CFLAGS'] << '-fsanitize-coverage=func,trace-pc-guard'
      end
    end
  end
end
  # 修改主工程的配置
  app_project = Xcodeproj::Project.open(Dir.glob('*.xcodeproj')[0])

  # 主工程二进制重排设置
  app_project.native_targets.each do |target|
    if target.name == '主工程的名称'
      target.build_configurations.each do |config|
        if config.name == 'Debug'
          config.build_settings['OTHER_CFLAGS'] ||= '$(inherited)'
          config.build_settings['OTHER_CFLAGS'] << ' '
          config.build_settings['OTHER_CFLAGS'] << '-fsanitize-coverage=func,trace-pc-guard'
        end
      end
    end
  end

我们就需要拿到clang的回调来进行函数转化

代码直接附上


#import <dlfcn.h>
#import <libkern/OSAtomicQueue.h>
#import <pthread.h>

// 队列头的数据结构。
static OSQueueHead queue = OS_ATOMIC_QUEUE_INIT;

static BOOL collectFinished = NO;

typedef struct {
    void *pc;
    void *next;
} PCNode;

// The guards are [start, stop).
// This function will be called at least once per DSO and may be called
// more than once with the same values of start/stop.
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
                                         uint32_t *stop) {
    static uint32_t Counter;  // Counter for the guards.
    if (start == stop || *start) return;  // Initialize only once.
    printf("INIT: %p %p\n", start, stop);
    for (uint32_t *x = start; x < stop; x++){
        *x = ++Counter;  // Guards should start from 1.
    }
}

// This callback is inserted by the compiler on every edge in the
// control flow (some optimizations apply).
// Typically, the compiler will emit the code like this:
//    if(*guard)
//      __sanitizer_cov_trace_pc_guard(guard);
// But for large functions it will emit a simple call:
//    __sanitizer_cov_trace_pc_guard(guard);
// 该回调由编译器插入到
// 控制流(适用一些优化)
// 通常,编译器将发出如下代码:
//    if(*guard)
//      __sanitizer_cov_trace_pc_guard(guard);
// But for large functions it will emit a simple call:
//    __sanitizer_cov_trace_pc_guard(guard);
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (collectFinished || !*guard) {
        return;
    }
    // If you set *guard to 0 this code will not be called again for this edge.
    // Now you can get the PC and do whatever you want:
    //   store it somewhere or symbolize it and print right away.
    // The values of `*guard` are as you set them in
    // __sanitizer_cov_trace_pc_guard_init and so you can make them consecutive
    // and use them to dereference an array or a bit vector.
    *guard = 0;
    // __builtin_return_address(0)的含义是,得到当前函数返回地址,即此函数被别的函数调用,然后此函数执行完毕后,返回,所谓返回地址就是那时候的地址
    void *PC = __builtin_return_address(0);
    PCNode *node = malloc(sizeof(PCNode));
    *node = (PCNode){PC, NULL};
    OSAtomicEnqueue(&queue, node, offsetof(PCNode, next));
}

extern void AppOrderFiles(void(^completion)(NSString *orderFilePath)) {
    collectFinished = YES;
    __sync_synchronize();
    NSString *functionExclude = [NSString stringWithFormat:@"_%s", __FUNCTION__];
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.01 * NSEC_PER_SEC)), dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        NSMutableArray <NSString *> *functions = [NSMutableArray array];
        while (YES) {
            PCNode *node = OSAtomicDequeue(&queue, offsetof(PCNode, next));
            if (node == NULL) {
                break;
            }
            Dl_info info = {0};
            dladdr(node->pc, &info);
            if (info.dli_sname) {
                NSString *name = @(info.dli_sname);
                BOOL isObjc = [name hasPrefix:@"+["] || [name hasPrefix:@"-["];
                NSString *symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
                [functions addObject:symbolName];
            }
        }
        if (functions.count == 0) {
            if (completion) {
                completion(nil);
            }
            return;
        }
        NSMutableArray<NSString *> *calls = [NSMutableArray arrayWithCapacity:functions.count];
        NSEnumerator *enumerator = [functions reverseObjectEnumerator];
        NSString *obj;
        while (obj = [enumerator nextObject]) {
            if (![calls containsObject:obj]) {
                [calls addObject:obj];
            }
        }
        [calls removeObject:functionExclude];
        NSString *result = [calls componentsJoinedByString:@"\n"];
        NSLog(@"Order:\n%@", result);
        
        NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"app.order"];
        NSData *fileContents = [result dataUsingEncoding:NSUTF8StringEncoding];
        BOOL success = [[NSFileManager defaultManager] createFileAtPath:filePath
                                                               contents:fileContents
                                                             attributes:nil];
    });
}

在想要结束的地方执行一下AppOrderFiles()代码,就可以得到这句代码执行之前的所有的函数执行栈的记录

使用Xcode Run一下后会在沙盒tmp文件夹下面生成app.order文件

设置 order file

在Xcode中设置OrderFile的路径

PS:配置好 order file 之后,记得清理前面 Build SettingsPodfile 中与 Clang 相关的配置

可以使用System Trace验证一下前后 File Backed Page In 的次数

学习和实践过程中的参考文献:

https://www.cnblogs.com/chengxyyh/archive/2020/06/12/13099407.html

https://blog.csdn.net/chouju2014/article/details/100657380

https://www.jianshu.com/p/f9b305e2823d

有关iOS 启动优化(二)二进制重排的更多相关文章

  1. ruby-on-rails - 启动 Rails 服务器时 ImageMagick 的警告 - 2

    最近,当我启动我的Rails服务器时,我收到了一长串警告。虽然它不影响我的应用程序,但我想知道如何解决这些警告。我的估计是imagemagick以某种方式被调用了两次?当我在警告前后检查我的git日志时。我想知道如何解决这个问题。-bcrypt-ruby(3.1.2)-better_errors(1.0.1)+bcrypt(3.1.7)+bcrypt-ruby(3.1.5)-bcrypt(>=3.1.3)+better_errors(1.1.0)bcrypt和imagemagick有关系吗?/Users/rbchris/.rbenv/versions/2.0.0-p247/lib/ru

  2. ruby - 如何验证 IO.copy_stream 是否成功 - 2

    这里有一个很好的答案解释了如何在Ruby中下载文件而不将其加载到内存中:https://stackoverflow.com/a/29743394/4852737require'open-uri'download=open('http://example.com/image.png')IO.copy_stream(download,'~/image.png')我如何验证下载文件的IO.copy_stream调用是否真的成功——这意味着下载的文件与我打算下载的文件完全相同,而不是下载一半的损坏文件?documentation说IO.copy_stream返回它复制的字节数,但是当我还没有下

  3. Ruby 文件 IO 定界符? - 2

    我正在尝试解析一个文本文件,该文件每行包含可变数量的单词和数字,如下所示:foo4.500bar3.001.33foobar如何读取由空格而不是换行符分隔的文件?有什么方法可以设置File("file.txt").foreach方法以使用空格而不是换行符作为分隔符? 最佳答案 接受的答案将slurp文件,这可能是大文本文件的问题。更好的解决方案是IO.foreach.它是惯用的,将按字符流式传输文件:File.foreach(filename,""){|string|putsstring}包含“thisisanexample”结果的

  4. ruby - 我如何添加二进制数据来遏制 POST - 2

    我正在尝试使用Curbgem执行以下POST以解析云curl-XPOST\-H"X-Parse-Application-Id:PARSE_APP_ID"\-H"X-Parse-REST-API-Key:PARSE_API_KEY"\-H"Content-Type:image/jpeg"\--data-binary'@myPicture.jpg'\https://api.parse.com/1/files/pic.jpg用这个:curl=Curl::Easy.new("https://api.parse.com/1/files/lion.jpg")curl.multipart_form_

  5. Ruby - 如何将消息长度表示为 2 个二进制字节 - 2

    我正在使用Ruby,我正在与一个网络端点通信,该端点在发送消息本身之前需要格式化“header”。header中的第一个字段必须是消息长度,它被定义为网络字节顺序中的2二进制字节消息长度。比如我的消息长度是1024。如何将1024表示为二进制双字节? 最佳答案 Ruby(以及Perl和Python等)中字节整理的标准工具是pack和unpack。ruby的packisinArray.您的长度应该是两个字节长,并且按网络字节顺序排列,这听起来像是n格式说明符的工作:n|Integer|16-bitunsigned,network(bi

  6. UE4 源码阅读:从引擎启动到Receive Begin Play - 2

    一、引擎主循环UE版本:4.27一、引擎主循环的位置:Launch.cpp:GuardedMain函数二、、GuardedMain函数执行逻辑:1、EnginePreInit:加载大多数模块int32ErrorLevel=EnginePreInit(CmdLine);PreInit模块加载顺序:模块加载过程:(1)注册模块中定义的UObject,同时为每个类构造一个类默认对象(CDO,记录类的默认状态,作为模板用于子类实例创建)(2)调用模块的StartUpModule方法2、FEngineLoop::Init()1、检查Engine的配置文件找出使用了哪一个GameEngine类(UGame

  7. Get https://registry-1.docker.io/v2/: net/http: request canceled while waiting - 2

    1.错误信息:Errorresponsefromdaemon:Gethttps://registry-1.docker.io/v2/:net/http:requestcanceledwhilewaitingforconnection(Client.Timeoutexceededwhileawaitingheaders)或者:Errorresponsefromdaemon:Gethttps://registry-1.docker.io/v2/:net/http:TLShandshaketimeout2.报错原因:docker使用的镜像网址默认为国外,下载容易超时,需要修改成国内镜像地址(首先阿里

  8. ruby - ruby 脚本可以预编译成二进制文件吗? - 2

    我正在开发一个Ruby脚本,需要在没有Ruby解释器的情况下部署到系统上。它将需要在使用ELF格式的FreeBSD系统上运行。我知道有一个ruby​​2exe项目可以编译在Windows上运行的ruby​​脚本,但是在其他操作系统上这样做容易吗?甚至可能吗? 最佳答案 您是否检查过Rubinius或JRuby是否允许您预编译您的代码? 关于ruby-ruby脚本可以预编译成二进制文件吗?,我们在StackOverflow上找到一个类似的问题: https://

  9. ruby - 为什么不能使用类IO的实例方法noecho? - 2

    print"Enteryourpassword:"pass=STDIN.noecho(&:gets)puts"Yourpasswordis#{pass}!"输出:Enteryourpassword:input.rb:2:in`':undefinedmethod`noecho'for#>(NoMethodError) 最佳答案 一开始require'io/console'后来的Ruby1.9.3 关于ruby-为什么不能使用类IO的实例方法noecho?,我们在StackOverflow上

  10. ruby - 如何在 Ruby 中将负整数转换为二进制 - 2

    问题1:我无法通过以下方式找到将负整数转换为二进制的方法。我应该像这样转换它。-3=>"11111111111111111111111111111101"我在下面试过:sprintf('%b',-3)=>"..101"#..appearsanddoesnotshow111111bit.-3.to_s(2)=>"-11"#Thisjustadds-tothebinaryofthepositiveinteger3.问题2:有趣的是,如果我使用在线转换器,它告诉我-3的二进制是“0010110100110011”。"11111111111111111111111111111101"和"001

随机推荐