iOS-底层探索30:启动优化(Clang插桩)

iOS 底层探索 文章汇总

目录

  • 一、查看APP启动耗时
  • 二、虚拟内存和物理内存
  • 三、二进制重排原理
  • 四、实现二进制重排
  • 五、Clang插桩
  • 六、其他问题


一、查看APP启动耗时

main函数之前的处理为pre-mian阶段,这篇文章主要分析这个阶段。
添加DYLD_PRINT_STATISTICS参数打印出pre-mian阶段的耗时情况:

iOS-底层探索30:启动优化(Clang插桩)_第1张图片

iOS-底层探索30:启动优化(Clang插桩)_第2张图片
各时段处理耗时分析:
  1. Total pre-main time: 总耗时
  2. dylib loading time: 动态库载入耗时
  3. rebase/binding time: rebase表示地址偏移修正(ASLR),binding表示符号绑定
  4. ObjC setup time: OC类注册耗时
  5. initializer time: 执行load构造函数的耗时
    slowest intializers :
  6. libSystem.B.dylib : 系统的
  7. libMainThreadChecker.dylib :
  8. XXXXX : 项目主程序耗时
pre-main优化方向:
  1. 官方建议非系统动态库的加载个数不超过6个,多于6个就要考虑动态库的合并;
  2. 减少OC类,减少C++虚函数
  3. 减少load方法和构造函数
main方法之后优化方向:
  1. 延迟初始化、懒加载
  2. 删除不使用类、方法、图片资源
  3. 尽量不用XIBStoryboard,特别是首屏界面
    参考:
    iOS 脚本查看项目中未使用的类、iOS 脚本查看项目未使用到的方法、iOS 脚本查找项目中无用资源脚本原理

二、虚拟内存和物理内存

1、虚拟内存和物理内存的区别

当我们向系统申请内存时,系统并不会给你返回物理内存的地址,而是给你一个虚拟内存地址。CPU读取数据时也是通过内存管理单元MMU虚拟地址映射到物理内存地址。每个进程都拥有相同大小的虚拟地址空间,对于32位的进程,可以拥有4GB的虚拟内存,64位进程则更多,可达18EB。只有我们开始使用申请到的虚拟内存时,系统才会将虚拟地址映射到物理地址上,从而让程序使用真实的物理内存。

2、内存分页

系统会对虚拟内存和物理内存进行分页,虚拟内存到物理内存的映射都是以页为最小粒度的。在OSX和早期的iOS系统中,物理和虚拟内存都按照4KB的大小进行分页。iOS近期的系统中,基于A7A8处理器的系统,物理内存按照4KB分页,虚拟内存按照16KB分页。基于A9处理器的系统,物理和虚拟内存都是以16KB进行分页。(终端输入PAGESIZE可以查看到macOS的分页大小)。

系统将内存页分为三种状态。
  • 活跃内存页(active pages)- 这种内存页已经被映射到物理内存中,而且近期被访问过,处于活跃状态。
  • 非活跃内存页(inactive pages)- 这种内存页已经被映射到物理内存中,但是近期没有被访问过。
  • 可用的内存页(free pages)- 没有关联到虚拟内存页的物理内存页集合。

当可用的内存页降低到一定的阀值时,系统就会采取低内存应对措施,在OSX中,系统会将非活跃内存页交换到硬盘上,而在iOS中,则会触发Memory Warning,如果你的App没有处理低内存警告并且还在后台占用太多内存,则有可能被杀掉。

3、如何解决内存浪费的?

应用程序加载到内存中时,并不会全部加载到物理内存中,属于懒加载,用哪一部分就加载那一部分。当访问进程的内存地址时,首先看页表,查看所要访问的对应页表是否已经加载到内存中。如果这一页没有在物理内存中时,操作系统会阻塞当前进程,发出一个缺页异常/缺页中断(pagefault),让后将磁盘中对应页的数据加载到内存中,完成虚拟内存和物理内存的映射。
当前进程的页表数据加载到物理内存中时,不一定是连续的,也有可能会覆盖其他进程的不活跃页,这样的按需分配,极大提高内存的使用效率。

4、虚拟内存的安全问题

虚拟内存通过页表映射到物理内存上,因此直接访问物理地址并不能实际正确的拿到进程的数据,但是进程的虚拟内存地址相对于自己来说也是绝对的,不管程序运行多少次,如果访问同一个函数,它在虚拟内存中的地址都是一样的这样也存在安全问题(比如直接静态注入)。
这样也出现了新的技术--ASLR(Address Space Layout Randomization)
每次虚拟内存在加载之前,都加一个随机偏移值。

iOS-底层探索30:启动优化(Clang插桩)_第3张图片
iOS-底层探索30:启动优化(Clang插桩)_第4张图片

三、二进制重排原理

1、什么是二进制重排

缺页中断/缺页异常:内存分页管理,每一页加载的时候都会发生。
在iOS中,在加载缺页内存的时候,不仅发生缺页阻塞从磁盘中加载数据,还要对加载的这页做签名验证
App使用中不会发生大量的pagefault,我们一般感受不到这个过程。但是在启动时,程序有大量的代码需要加载、执行,那么这个缺页中断有可能就很明显了。

如何优化?

假如我的App只有10页数据,但是启动的时候需要加载的代码分散放在1、3、5页。因为代码在Mach-o文件中的位置是根据文件加载生成的顺序来决定。那么这时候App启动需要运行的代码放在3个虚拟内存页中就会出现3pagefault
如果我们将需要启动用的代码全部放在第1页中,那么App启动时便只会触发一次pagefaultApp启动加载的数据也会变少,这样极大减少进程的阻塞。这就是二进制重排的原理。

2、查看pagefault

Xcode提供相关的调试工具,打开Instruments-System Trace,选中手机中的App,点击System Trace左上角开始记录后会自动打开手机中的App,进入首屏后点击System Trace左上角停止。查看Main Thread中虚拟内存的File Backed Page In项目,它代表着启动时产生的pagefault次数。

iOS-底层探索30:启动优化(Clang插桩)_第5张图片

查看pagefault次数时受App冷启动热启动影响很大,可以先开启几个其他App然后等一段时间再点击System Trace左上角开启记录。

二进制重排的优化是发生在编译链接阶段,对即将生成的二进制可执行文件进行重排。
Xcode使用的连接器叫ld它可以指向一个order_file文件,在这个文件中指定排列符号,那么Xcode在编译时会按照指定的排列编译出可执行的文件,苹果objc源码项目中的libobjc.order文件就是实现二进制重排功能的。

四、实现二进制重排

1、查看方法排列顺序

新建测试项目Test_TracingPCs
在项目的build settings中搜索link map开启这个文件的输出

iOS-底层探索30:启动优化(Clang插桩)_第6张图片

重新编译后就可以在工程的build目录里面找到一份link map文件
路径如下:
Xcode -> DerivedData-> 项目名-> Build-> Intermediates.noindex-> 项目名.build-> Debug-iphoneos-> 项目名.build-> 项目名-LinkMap-normal-arm64.txt

iOS-底层探索30:启动优化(Clang插桩)_第7张图片

这个文件里面就记录一些链接.o的文件、Mach-o文件里的一些信息、符号信息symbols等等…

iOS-底层探索30:启动优化(Clang插桩)_第8张图片

注意,这个symbols就是关注的要点:默认情况下它是按照Build Phases-Compile Sources中编译文件从上至下排序以及类中方法从上至下排序。

iOS-底层探索30:启动优化(Clang插桩)_第9张图片

2、通过order文件重新排列加载顺序:

在项目根目录创建lcj.order文件,在工程配置中添加.order文件的路径./lcj.order后,让编译器按照指定的顺序重新排列二进制文件,把最需要加载的代码段放在内存页靠前的位置。

iOS-底层探索30:启动优化(Clang插桩)_第10张图片

这里只是演示了让viewcontroller中的几个自定义方法优先靠排列在内存分页中,实际中一个App启动时的page fault可能多达几千次,那么需要重排的函数远不止这一点。

五、Clang插桩

1、引入Clang插桩

由于项目中存在大量的函数方法调用,此外还有Block、Swift、C、C++函数,因此仅仅HOOK msgSend方法不可行。因为Clang会读取所有代码,分析AST中所有节点,所有通过Clang插桩可以实现100%的符号覆盖

抖音研发实践:基于二进制文件重排的解决方案

官方插桩工具-Tracing PCs
Clang Documentation
Tracing PCs

2、使用Tracing PCs

根据官方文档添加-fsanitize-coverage=trace-pc-guard标记

iOS-底层探索30:启动优化(Clang插桩)_第11张图片

ViewController.m中添加两个官方文档中的方法实现:

void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
                                                    uint32_t *stop) {
  static uint64_t N;  // 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 = ++N;  // Guards should start from 1.
}

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
  if (!*guard) return;
  void *PC = __builtin_return_address(0);
  char PcDescr[1024];
    
  //__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
  printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

-[ViewController viewDidLoad]前添加断点,运行项目,到断点后打开汇编断点(菜单栏Debug->Debug Workflow->Always Show Disassembly)

iOS-底层探索30:启动优化(Clang插桩)_第12张图片

iOS-底层探索30:启动优化(Clang插桩)_第13张图片
iOS-底层探索30:启动优化(Clang插桩)_第14张图片

结合汇编中插入的__sanitizer_cov_trace_pc_guard代码和控制台打印的信息分析可知:添加-fsanitize-coverage=trace-pc-guard标记后Clang会在中间代码IR中的每个方法、Block等调用边缘插入__sanitizer_cov_trace_pc_guard方法的调用。
所以Clang插桩插入的就是__sanitizer_cov_trace_pc_guard方法调用。

3、修改__sanitizer_cov_trace_pc_guard方法,获取函数的调用方法名
#import 
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    //排除load方法
    //if (!*guard) return;
    
    //当前函数返回到上一个方法继续执行的地址
    void *PC = __builtin_return_address(0);
    Dl_info info;
    dladdr(PC, &info);
    printf("fname:%s \nfbase:%p \nsname:%s \nsaddr:%p\n\n\n",info.dli_fname,info.dli_fbase,info.dli_sname,info.dli_saddr);
    
    char PcDescr[1024];
    //__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
    printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
}

点击屏幕输出:

fname:/private/var/containers/Bundle/Application/BAE470B2.../Test_TracingPCs.app/Test_TracingPCs 
fbase:0x10236c000 
sname:-[ViewController touchesBegan:withEvent:] 
saddr:0x102371ad4
4、将获取到的符号写入到. order文件中
#import //用于定义原子队列

//定义原子队列
static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;

//定义符号结构体
typedef struct{
    void *pc;
    void *next;
} SYNode;

+ (void)load {
    
}
- (void)viewDidLoad {
    [super viewDidLoad];
    
    testCFunc();
    [self testOCFunc];
}
- (void)testOCFunc {
    NSLog(@"OC函数");
}
void testCFunc() {
    CJBlock();
}
void(^CJBlock)(void) = ^(void) {
    NSLog(@"Block");
};

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    //排除load方法
    //if (!*guard) return;
    
    //当前函数返回到上一个方法继续执行的地址
    void *PC = __builtin_return_address(0);
    //创建结构体!
    SYNode * node = malloc(sizeof(SYNode));
    *node = (SYNode){PC,NULL};
    
    //该方法在子线程中调用,因此需要使用线程安全的Atomic原子队列
    //加入结构
    OSAtomicEnqueue(&symbolList, node, offsetof(SYNode, next));
}


- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    //定义数组
    NSMutableArray *symbolNames = [NSMutableArray array];
    
    while (YES) {//一次循环!也会被HOOK一次!!(Tracing PCs只要有跳转(汇编中b/bl指令)就会被HOOK)
        SYNode *node = OSAtomicDequeue(&symbolList, offsetof(SYNode, next));
        
        if (node == NULL) {
            break;
        }
        Dl_info info = {0};
        dladdr(node->pc, &info);
        printf("%s \n",info.dli_sname);
        NSString *name = @(info.dli_sname);
        free(node);
        
        //C函数前需加 _
        BOOL isObjc = [name hasPrefix:@"+["]||[name hasPrefix:@"-["];
        NSString *symbolName = isObjc ? name : [@"_" stringByAppendingString:name];
        
        if (![symbolNames containsObject:symbolName]) {
            [symbolNames addObject:symbolName];
        }
    }
    //反向数组
    symbolNames = (NSMutableArray*)[[symbolNames reverseObjectEnumerator] allObjects];
    //去掉当前方法
    [symbolNames removeObject:[NSString stringWithFormat:@"%s",__FUNCTION__]];
    
    //数组转成字符串
    NSString *funcStr = [symbolNames componentsJoinedByString:@"\n"];
    //字符串写入文件
    NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"lcj.order"];
    //文件内容
    NSData *fileContents = [funcStr dataUsingEncoding:NSUTF8StringEncoding];
    [[NSFileManager defaultManager] createFileAtPath:filePath contents:fileContents attributes:nil];
}

由于Tracing PCs只要有跳转(汇编中b/bl指令)就会被HOOK,因此while也会被HOOK,为了避免循环调用需要修改Other C Flags为:-fsanitize-coverage=func,trace-pc-guard

运行后点击屏幕拿到.order文件

iOS-底层探索30:启动优化(Clang插桩)_第15张图片
iOS-底层探索30:启动优化(Clang插桩)_第16张图片

六、其他问题

1、Swift 工程 / 混编工程问题

通过上面的方法可以拿到OC项目中的符号,想要拿到Swift中的符号还需要做以下配置:
-sanitize-coverage=func
-sanitize=undefined

iOS-底层探索30:启动优化(Clang插桩)_第17张图片

2、cocoapod 工程问题

对于cocoapod工程引入的库 , 由于针对不同的target。那么我们在主程序中的target添加的编译设置Write Link Map File , -fsanitize-coverage=func,trace-pc-guard以及order file等设置肯定是不会生效的。解决方法就是针对需要的target去做对应的设置即可(配置target自己的Order File)。

iOS-底层探索30:启动优化(Clang插桩)_第18张图片

iOS-底层探索30:启动优化(Clang插桩)_第19张图片

对于直接手动导入到工程里的SDK , 不管是静态库.a还是动态库, 默认在主工程设置就可以可以拿到符号的。

手动导入的三方库如果没有导入使用的话 , 是不会加载的,添加了load方法也是如此。


参考

iOS 优化篇 - 启动优化之Clang插桩实现二进制重排

你可能感兴趣的:(iOS-底层探索30:启动优化(Clang插桩))