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

iOS 底层探索 文章汇总

目录


一、查看APP启动耗时

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

各时段处理耗时分析:
  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)
每次虚拟内存在加载之前,都加一个随机偏移值。

三、二进制重排原理

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次数。

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

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

四、实现二进制重排

1、查看方法排列顺序

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

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

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

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

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

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

这里只是演示了让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标记

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)

结合汇编中插入的__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 <dlfcn.h>
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<UITouch *> *)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 <libkern/OSAtomic.h>//用于定义原子队列

//定义原子队列
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<UITouch *> *)touches withEvent:(UIEvent *)event {
    //定义数组
    NSMutableArray<NSString *> *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<NSString *>*)[[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文件

六、其他问题

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

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

2、cocoapod 工程问题

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

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

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


参考

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容