「这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战」。
hook objc_msgsend
该方法直接使用汇编编写 hook_msgSend,然后利用 fishhook 进行 hook。原理也相对简单,fishhook 会遍历 image ,查找 image 中的 __la_symbol_ptr 和 __nl_symbol_ptr/__got 表,该过程一般发生在动态链接之后, main 函数运行之后。也可通过 +load 来进行 rebind,但是相对不可控。因此,直接 hook objc_msgsend 方法就可以知道 OC 方法的耗时,进而进行监控和针对性优化;
存在的问题:
- 分类 hook 不到?
- 只能全部 hook,不能部分 hook;
- hook 之后的计时方法本身也是一种消性能耗;
第二个问题会导致:
- 全部 hook 会有性能问题;
- 项目本身可能会 crash,bad_access,问题不一定好解决。一个 crash 无法解决,不能部分 hook,整个项目就不能用这个工具;
因此发展成第二种方式......
静态插桩
静态插桩不知道这个叫法是怎么来的,感觉不应该是插桩。桩意味着 stub 桩函数,插桩可能是指修改 __TEXT段的 stub 函数吧。
而这里的实现方式是指修改符号表中 objc_msgsend 的指向,或者是修改字符串表的 objc_msgsend 为 hook_msgSend 。
对比静态插桩,在静态时期进行 hook 的方式还有:
- llvm 语法树解析,即将 objc_msgSend 解析成我们自己的函数,如果 hook_msgSend;
- 编译完成之后,链接之前,修改目标文件的 text 段调用;
上述两种办法都相对较难,门槛也比较高,而静态插桩的方式更简单有效:
因为链接之前,所有的文件都会被编译成目标文件。而目标文件中对外部函数的调用就会存在一张 symbol table。链接时,所有目标文件的 symbol table 会被整合到最终的可执行文件中,以此完成链接的操作。
这个方案的基本原理就是编译完成之后,链接之前,替换所有目标文件(.o 文件)的中的字符串表。因为 symbol table 中不存储字符串,存的是 symbol 相关的信息和指针,静态外部符号在静态链接之前为 0,动态外部符号在动态链接之前为 0。另外,hook string table 本身会更简单,替换了这个 string 之后,该目标文件的所有 objc_msgSend 方法都被改成了 hook_msgSend 方法了。
总结:
- 无法直接修改 symbol table 中的 string,或者说修改成本较大(遍历symbol table且在strtab中新增一个hook_msgSend且要hook objc_msgSend);
- 直接修改 string table 中的 objc_msgSend 可以完成整个目标文件的替换;
- 因为实在链接之前,所以整个目标文件的替换是相对于 .o 文件的,这样就可以支持部分文件的 hook;
二进制重排
App 启动之前会加载 Mach-O 文件到内存中。虚拟内存和磁盘不一样的地方在于内存分页,Mach-O 中的 segment 被夹在进入内存时,会因为分页的原因导致其在内存中的大小和磁盘中的大小不一定一致。
App 在启动周期时会调用很多函数,因此会加载很多符号的实现,即 __TEXT 段的代码。也正是因为分页的问题,如果启动时加载的函数过于分散,那么在启动时就会进行很多次的 Page Fault。
加载 image 时是把所有的 Mach-O 文件加载进内存吗?感觉应该不是,dyld 源码上好像是会先加载一个 page,如果不够,再继续加载 page。估计是先分配固定虚拟内存,以保证地址的连续且符合 Mach-O ,此时未 Page In 的虚拟内存不指向具体的物理内存。调用的代码未加载时再触发 Page In;存疑~~
二进制重排的核心在于将启动时调用的符号尽量排序到一个 page 中,以此来最大程度减少 Page Fault 的次数,减少 I/O 次数,最终来优化启动时间;
二进制重排存在的问题:
- 难度较大;
- 效果待确认;
- iOS13 之后,dyld3 引入 closure,重排的性价比可能会很低;
dyld3 中,App 启动需要的信息被存入磁盘中,相当于原来需要在启动前做的工作提前做了这部分工作。具体原理未知,所以二进制重排的方案的效果有待验证。